Billing: fatture manuali, profilo obbligatorio e piani Stripe mensile/annuale.
Rimuove link al portale Stripe in area cliente, aggiunge flusso admin per PDF fattura e email, blocca checkout senza dati di fatturazione, allinea prezzi a €4,90/€39,90 e €9,90/€69,90, locale italiano e documentazione deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,33 +1,90 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class BillingInvoicesController < BaseController
|
class BillingInvoicesController < BaseController
|
||||||
before_action :set_club
|
before_action :set_club
|
||||||
|
before_action :set_invoice, only: %i[edit update]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@invoices = @club.billing_invoices.recent
|
@payments = @club.billing_payments.recent.includes(:invoice)
|
||||||
|
@invoices = @club.billing_invoices.recent.includes(:billing_payment, pdf_attachment: :blob)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@invoice = @club.billing_invoices.build(issued_on: Date.current, currency: "eur", source: "aruba")
|
@payment = @club.billing_payments.find_by(id: params[:billing_payment_id]) if params[:billing_payment_id].present?
|
||||||
|
if @payment&.invoice.present?
|
||||||
|
redirect_to edit_admin_club_billing_invoice_path(@club, @payment.invoice), alert: "Esiste già una fattura per questo pagamento."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@invoice = @club.billing_invoices.build(
|
||||||
|
issued_on: Date.current,
|
||||||
|
currency: "eur",
|
||||||
|
source: "manual",
|
||||||
|
status: "draft",
|
||||||
|
billing_payment: @payment,
|
||||||
|
amount_cents: @payment&.amount_cents,
|
||||||
|
number: suggested_invoice_number
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@invoice = @club.billing_invoices.build(invoice_params)
|
@invoice = @club.billing_invoices.build(invoice_params)
|
||||||
@invoice.source = "aruba"
|
@invoice.source = "manual"
|
||||||
|
@invoice.status = "draft"
|
||||||
|
|
||||||
if @invoice.save
|
if @invoice.save
|
||||||
redirect_to admin_club_billing_invoices_path(@club), notice: "Fattura #{@invoice.number} registrata."
|
redirect_to edit_admin_club_billing_invoice_path(@club, @invoice),
|
||||||
|
notice: "Bozza fattura #{@invoice.number} creata. Carica il PDF e invia al cliente."
|
||||||
else
|
else
|
||||||
|
@payment = @invoice.billing_payment
|
||||||
flash.now[:alert] = @invoice.errors.full_messages.join(", ")
|
flash.now[:alert] = @invoice.errors.full_messages.join(", ")
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def edit
|
||||||
|
@payment = @invoice.billing_payment
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@invoice.assign_attributes(invoice_params.except(:pdf))
|
||||||
|
|
||||||
|
if issuing?
|
||||||
|
Billing::IssueInvoice.call(invoice: @invoice, pdf: params.dig(:billing_invoice, :pdf))
|
||||||
|
redirect_to admin_club_billing_invoices_path(@club),
|
||||||
|
notice: "Fattura #{@invoice.number} emessa e inviata a #{@club.billing_email}."
|
||||||
|
elsif @invoice.save
|
||||||
|
redirect_to admin_club_billing_invoices_path(@club), notice: "Fattura #{@invoice.number} aggiornata."
|
||||||
|
else
|
||||||
|
@payment = @invoice.billing_payment
|
||||||
|
flash.now[:alert] = @invoice.errors.full_messages.join(", ")
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
rescue Billing::IssueInvoice::Error => e
|
||||||
|
@payment = @invoice.billing_payment
|
||||||
|
flash.now[:alert] = e.message
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_club
|
def set_club
|
||||||
@club = Club.find(params[:club_id])
|
@club = Club.find(params[:club_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_invoice
|
||||||
|
@invoice = @club.billing_invoices.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def issuing?
|
||||||
|
params[:commit].to_s == "Emetti e invia via email"
|
||||||
|
end
|
||||||
|
|
||||||
|
def suggested_invoice_number
|
||||||
|
year = Date.current.year
|
||||||
|
count = @club.billing_invoices.where("number LIKE ?", "%#{year}%").count + 1
|
||||||
|
"#{year}/#{count}"
|
||||||
|
end
|
||||||
|
|
||||||
def invoice_params
|
def invoice_params
|
||||||
params.require(:billing_invoice).permit(
|
params.require(:billing_invoice).permit(
|
||||||
:number,
|
:number,
|
||||||
@@ -35,8 +92,6 @@ module Admin
|
|||||||
:amount_cents,
|
:amount_cents,
|
||||||
:amount_euros,
|
:amount_euros,
|
||||||
:currency,
|
:currency,
|
||||||
:status,
|
|
||||||
:aruba_document_id,
|
|
||||||
:billing_payment_id,
|
:billing_payment_id,
|
||||||
:notes,
|
:notes,
|
||||||
:pdf
|
:pdf
|
||||||
|
|||||||
@@ -10,21 +10,34 @@ module Public
|
|||||||
def update_profile
|
def update_profile
|
||||||
@club.assign_attributes(billing_profile_params)
|
@club.assign_attributes(billing_profile_params)
|
||||||
if @club.save(context: :billing_profile)
|
if @club.save(context: :billing_profile)
|
||||||
redirect_to public_club_billing_path(@club), notice: "Dati di fatturazione aggiornati."
|
if premium_checkout_return_params.present? && @club.billing_profile_complete?
|
||||||
|
redirect_to public_club_checkout_path(
|
||||||
|
@club,
|
||||||
|
plan: premium_checkout_return_params[:plan],
|
||||||
|
interval: premium_checkout_return_params[:interval]
|
||||||
|
), notice: "Dati salvati. Procedi con il pagamento."
|
||||||
|
else
|
||||||
|
redirect_to public_club_billing_path(@club), notice: "Dati di fatturazione aggiornati."
|
||||||
|
end
|
||||||
else
|
else
|
||||||
flash.now[:alert] = @club.errors.full_messages.join(", ")
|
flash.now[:alert] = @club.errors.full_messages.join(", ")
|
||||||
render :profile, status: :unprocessable_entity
|
render :profile, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def sync_payments
|
def cancel_subscription
|
||||||
unless MatchLiveTv.stripe_enabled?
|
unless MatchLiveTv.stripe_enabled?
|
||||||
redirect_to public_club_billing_path(@club), alert: "Stripe non configurato."
|
redirect_to public_club_billing_path(@club), alert: "Stripe non configurato."
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
count = Billing::Stripe::SyncPayments.call(club: @club)
|
Billing::Stripe::CancelSubscription.call(club: @club)
|
||||||
redirect_to public_club_billing_path(@club), notice: "Sincronizzati #{count} pagamenti da Stripe."
|
sub = @club.subscription.reload
|
||||||
|
date = sub.current_period_end ? helpers.l_local(sub.current_period_end.to_date) : "fine periodo"
|
||||||
|
redirect_to public_club_billing_path(@club),
|
||||||
|
notice: "Abbonamento disdetto. Resta attivo fino al #{date}; da quel giorno passerai al piano Free."
|
||||||
|
rescue ArgumentError, RuntimeError => e
|
||||||
|
redirect_to public_club_billing_path(@club), alert: e.message
|
||||||
rescue ::Stripe::StripeError => e
|
rescue ::Stripe::StripeError => e
|
||||||
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
|
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
|
||||||
end
|
end
|
||||||
@@ -49,6 +62,16 @@ module Public
|
|||||||
require_club_owner!(@club)
|
require_club_owner!(@club)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def premium_checkout_return_params
|
||||||
|
plan = params[:plan].presence_in(%w[premium_light premium_full])
|
||||||
|
return nil if plan.blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
plan: plan,
|
||||||
|
interval: params[:interval].presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def billing_profile_params
|
def billing_profile_params
|
||||||
params.require(:club).permit(
|
params.require(:club).permit(
|
||||||
:billing_entity_type,
|
:billing_entity_type,
|
||||||
|
|||||||
@@ -32,7 +32,14 @@ module Public
|
|||||||
Billing::AssignPlan.call(club: club, plan_slug: plan)
|
Billing::AssignPlan.call(club: club, plan_slug: plan)
|
||||||
|
|
||||||
if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled?
|
if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled?
|
||||||
redirect_to public_club_checkout_path(club, plan: plan)
|
unless club.billing_profile_complete?
|
||||||
|
redirect_to public_club_billing_profile_path(club, plan: plan, interval: checkout_interval_param),
|
||||||
|
alert: "Completa i dati di fatturazione prima di attivare un piano premium."
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
interval = checkout_interval_param
|
||||||
|
redirect_to public_club_checkout_path(club, plan: plan, interval: interval)
|
||||||
else
|
else
|
||||||
redirect_to public_club_path(club), notice: "Società e prima squadra create."
|
redirect_to public_club_path(club), notice: "Società e prima squadra create."
|
||||||
end
|
end
|
||||||
@@ -66,13 +73,14 @@ module Public
|
|||||||
|
|
||||||
def billing
|
def billing
|
||||||
require_club_owner!(@club)
|
require_club_owner!(@club)
|
||||||
|
sync_checkout_return!
|
||||||
|
finalize_subscription_if_due!
|
||||||
|
@subscription = @club.subscription&.reload
|
||||||
apply_checkout_flash!
|
apply_checkout_flash!
|
||||||
@team = @club.teams.first!
|
@team = @club.teams.first!
|
||||||
@entitlements = @team.entitlements
|
@entitlements = @team.entitlements
|
||||||
@subscription = @club.subscription
|
|
||||||
@plans = Plan.ordered
|
@plans = Plan.ordered
|
||||||
@payments = @club.billing_payments.recent.limit(50)
|
@payments = @club.billing_payments.recent.includes(:invoice).limit(50)
|
||||||
@invoices = @club.billing_invoices.recent.limit(50)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def checkout
|
def checkout
|
||||||
@@ -83,15 +91,29 @@ module Public
|
|||||||
end
|
end
|
||||||
|
|
||||||
plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light"
|
plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light"
|
||||||
|
interval = checkout_interval_param
|
||||||
|
target_plan = Plan[plan_slug]
|
||||||
sub = @club.subscription
|
sub = @club.subscription
|
||||||
if sub&.stripe_subscription_id.present? && sub.active? && sub.plan.slug == plan_slug
|
|
||||||
redirect_to public_club_billing_path(@club), notice: "Sei già su questo piano."
|
if sub&.stripe_subscription_id.present? && sub.active? &&
|
||||||
|
sub.plan.slug == plan_slug && sub.billing_interval == interval
|
||||||
|
redirect_to public_club_billing_path(@club),
|
||||||
|
notice: "Sei già su #{target_plan.name} (#{Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)})."
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Sempre Stripe Checkout (hosted): i dati carta non passano mai dal nostro server.
|
if sub&.stripe_subscription_id.present? && sub.active?
|
||||||
url = Billing::Stripe::CheckoutSession.new(club: @club, user: current_user, plan_slug: plan_slug).url
|
Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug, interval: interval)
|
||||||
redirect_to url, allow_other_host: true
|
redirect_to public_club_billing_path(@club),
|
||||||
|
notice: "Piano aggiornato a #{target_plan.name} (#{Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)}). Eventuale differenza gestita da Stripe."
|
||||||
|
else
|
||||||
|
url = Billing::Stripe::CheckoutSession.new(
|
||||||
|
club: @club, user: current_user, plan_slug: plan_slug, interval: interval
|
||||||
|
).url
|
||||||
|
redirect_to url, allow_other_host: true
|
||||||
|
end
|
||||||
|
rescue ArgumentError => e
|
||||||
|
redirect_to public_club_billing_path(@club), alert: e.message
|
||||||
rescue ::Stripe::StripeError => e
|
rescue ::Stripe::StripeError => e
|
||||||
Rails.logger.warn("[Stripe checkout] #{e.message}")
|
Rails.logger.warn("[Stripe checkout] #{e.message}")
|
||||||
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
|
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
|
||||||
@@ -132,14 +154,44 @@ module Public
|
|||||||
return if plan_slug.blank?
|
return if plan_slug.blank?
|
||||||
return if @club.billing_profile_complete?
|
return if @club.billing_profile_complete?
|
||||||
|
|
||||||
redirect_to public_club_billing_profile_path(@club),
|
redirect_to public_club_billing_profile_path(
|
||||||
alert: "Completa i dati di fatturazione prima di attivare un piano premium."
|
@club,
|
||||||
|
plan: plan_slug,
|
||||||
|
interval: params[:interval].presence
|
||||||
|
), alert: "Completa i dati di fatturazione prima di attivare un piano premium."
|
||||||
|
end
|
||||||
|
|
||||||
|
def checkout_interval_param
|
||||||
|
Billing::Stripe::PriceCatalog.normalize_interval(params[:interval])
|
||||||
|
rescue ArgumentError
|
||||||
|
Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
|
||||||
|
end
|
||||||
|
|
||||||
|
def finalize_subscription_if_due!
|
||||||
|
return unless MatchLiveTv.stripe_enabled?
|
||||||
|
|
||||||
|
Billing::Stripe::FinalizeSubscription.call(club: @club)
|
||||||
|
rescue ::Stripe::StripeError => e
|
||||||
|
Rails.logger.warn("[Stripe finalize subscription] #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_checkout_return!
|
||||||
|
return unless params[:checkout] == "success"
|
||||||
|
return unless MatchLiveTv.stripe_enabled?
|
||||||
|
|
||||||
|
Billing::Stripe::CheckoutSync.call(
|
||||||
|
club: @club,
|
||||||
|
session_id: params[:session_id].presence
|
||||||
|
)
|
||||||
|
rescue ::Stripe::StripeError => e
|
||||||
|
Rails.logger.warn("[Stripe checkout sync] #{e.message}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply_checkout_flash!
|
def apply_checkout_flash!
|
||||||
case params[:checkout]
|
case params[:checkout]
|
||||||
when "success"
|
when "success"
|
||||||
flash.now[:notice] = "Pagamento completato! Il piano premium è attivo per la società."
|
plan_name = @subscription&.plan&.name || "premium"
|
||||||
|
flash.now[:notice] = "Pagamento completato! Piano attivo: #{plan_name}."
|
||||||
when "canceled"
|
when "canceled"
|
||||||
flash.now[:alert] = "Pagamento annullato. Nessun addebito è stato effettuato."
|
flash.now[:alert] = "Pagamento annullato. Nessun addebito è stato effettuato."
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ module Public
|
|||||||
|
|
||||||
include ::SeoHelper
|
include ::SeoHelper
|
||||||
include ::LegalHelper
|
include ::LegalHelper
|
||||||
|
helper ApplicationHelper
|
||||||
helper_method :current_user, :logged_in?
|
helper_method :current_user, :logged_in?
|
||||||
|
|
||||||
|
before_action :set_site_locale
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def set_site_locale
|
||||||
|
I18n.locale = :it
|
||||||
|
end
|
||||||
|
|
||||||
def current_user
|
def current_user
|
||||||
return @current_user if defined?(@current_user)
|
return @current_user if defined?(@current_user)
|
||||||
|
|
||||||
|
|||||||
9
backend/app/helpers/application_helper.rb
Normal file
9
backend/app/helpers/application_helper.rb
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
module ApplicationHelper
|
||||||
|
# Data/ora nel fuso dell'app (Europe/Rome) e nella lingua del sito.
|
||||||
|
def l_local(date_or_time, format: :long)
|
||||||
|
return nil if date_or_time.blank?
|
||||||
|
|
||||||
|
value = date_or_time.respond_to?(:in_time_zone) ? date_or_time.in_time_zone : date_or_time
|
||||||
|
I18n.with_locale(:it) { I18n.l(value, format: format) }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,29 +1,81 @@
|
|||||||
module Public
|
module Public
|
||||||
module BillingHelper
|
module BillingHelper
|
||||||
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:)
|
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil)
|
||||||
return { kind: :current, label: "Piano attuale" } if current_slug == target_plan.slug
|
|
||||||
|
|
||||||
if target_plan.slug == "free"
|
if target_plan.slug == "free"
|
||||||
return { kind: :contact, label: "Contatta il supporto per downgrade." }
|
return { kind: :contact, label: "Contatta il supporto per downgrade." } unless current_slug == "free"
|
||||||
|
|
||||||
|
return { kind: :current, label: "Piano attuale" }
|
||||||
end
|
end
|
||||||
|
|
||||||
unless MatchLiveTv.stripe_enabled?
|
unless MatchLiveTv.stripe_enabled?
|
||||||
return { kind: :disabled, label: "Stripe non configurato" }
|
return { kind: :disabled, label: "Stripe non configurato" }
|
||||||
end
|
end
|
||||||
|
|
||||||
if current_slug == "free" || !stripe_subscription_active
|
intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug)
|
||||||
return { kind: :checkout, label: "Attiva #{target_plan.name}" }
|
return { kind: :disabled, label: "Prezzi Stripe non configurati" } if intervals.empty?
|
||||||
|
|
||||||
|
active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
|
||||||
|
|
||||||
|
if current_slug == target_plan.slug && stripe_subscription_active
|
||||||
|
other_intervals = intervals - [active_interval]
|
||||||
|
if other_intervals.empty?
|
||||||
|
label = "Piano attuale — #{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}"
|
||||||
|
return { kind: :current, label: label }
|
||||||
|
end
|
||||||
|
|
||||||
|
return { kind: :interval_switch, plan: target_plan, intervals: other_intervals, current_interval: active_interval }
|
||||||
end
|
end
|
||||||
|
|
||||||
if Plan.tier(target_plan.slug) > Plan.tier(current_slug)
|
if current_slug == "free" || !stripe_subscription_active
|
||||||
{ kind: :change, label: "Passa a #{target_plan.name}" }
|
{ kind: :checkout_options, plan: target_plan, intervals: intervals }
|
||||||
else
|
else
|
||||||
{ kind: :change, label: "Passa a #{target_plan.name}" }
|
{ kind: :change_options, plan: target_plan, intervals: intervals }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def plan_interval_checkout_path(club, plan, interval)
|
||||||
|
public_club_checkout_path(club, plan: plan.slug, interval: interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
def plan_interval_button_label(plan, interval, kind: :checkout)
|
||||||
|
price = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)
|
||||||
|
case kind
|
||||||
|
when :change, :interval_switch
|
||||||
|
"Passa a #{plan.name} — #{price}"
|
||||||
|
else
|
||||||
|
"Attiva #{plan.name} — #{price}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def stripe_subscription_active?(subscription)
|
def stripe_subscription_active?(subscription)
|
||||||
subscription&.stripe_subscription_id.present? && subscription.active?
|
subscription&.stripe_subscription_id.present? && subscription.active?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def stripe_interval_available?(plan_slug, interval)
|
||||||
|
Billing::Stripe::PriceCatalog.configured?(plan_slug: plan_slug, interval: interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
def plan_interval_button_class(interval)
|
||||||
|
if interval.to_s == "yearly"
|
||||||
|
"btn btn-primary plan-interval-btn plan-interval-btn--yearly"
|
||||||
|
else
|
||||||
|
"btn btn-outline plan-interval-btn plan-interval-btn--monthly"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def plan_intervals_for_display(intervals)
|
||||||
|
Array(intervals).sort_by { |i| i.to_s == "yearly" ? 0 : 1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def billing_profile_blocks_premium?(club)
|
||||||
|
club.present? && !club.billing_profile_complete?
|
||||||
|
end
|
||||||
|
|
||||||
|
def billing_profile_incomplete_message(club)
|
||||||
|
missing = club.billing_profile_errors
|
||||||
|
return "Completa i dati di fatturazione prima di attivare un piano premium." if missing.empty?
|
||||||
|
|
||||||
|
"Mancano: #{missing.join(", ")}."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
17
backend/app/jobs/expire_ended_subscriptions_job.rb
Normal file
17
backend/app/jobs/expire_ended_subscriptions_job.rb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Passa a Free le società con disdetta programmata e periodo scaduto (anche senza visita pagina billing).
|
||||||
|
class ExpireEndedSubscriptionsJob
|
||||||
|
include Sidekiq::Job
|
||||||
|
|
||||||
|
def perform
|
||||||
|
free_plan = Plan["free"]
|
||||||
|
|
||||||
|
Subscription.where(cancel_at_period_end: true)
|
||||||
|
.where("current_period_end <= ?", Time.current)
|
||||||
|
.where.not(plan_id: free_plan.id)
|
||||||
|
.find_each do |sub|
|
||||||
|
Billing::Stripe::FinalizeSubscription.call(club: sub.club)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn("[ExpireEndedSubscriptions] club=#{sub.club_id} #{e.message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
18
backend/app/mailers/billing/invoice_mailer.rb
Normal file
18
backend/app/mailers/billing/invoice_mailer.rb
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module Billing
|
||||||
|
class InvoiceMailer < ApplicationMailer
|
||||||
|
def invoice_pdf
|
||||||
|
@invoice = params[:invoice]
|
||||||
|
@club = @invoice.club
|
||||||
|
|
||||||
|
attachments["fattura-#{@invoice.number.parameterize}.pdf"] = {
|
||||||
|
mime_type: "application/pdf",
|
||||||
|
content: @invoice.pdf.download
|
||||||
|
}
|
||||||
|
|
||||||
|
mail(
|
||||||
|
to: @club.billing_email,
|
||||||
|
subject: "Fattura #{@invoice.display_number} — Match Live TV"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,7 +3,7 @@ module Billing
|
|||||||
self.table_name = "billing_invoices"
|
self.table_name = "billing_invoices"
|
||||||
|
|
||||||
STATUSES = %w[draft issued sent cancelled].freeze
|
STATUSES = %w[draft issued sent cancelled].freeze
|
||||||
SOURCES = %w[aruba manual].freeze
|
SOURCES = %w[manual aruba].freeze
|
||||||
|
|
||||||
belongs_to :club
|
belongs_to :club
|
||||||
belongs_to :billing_payment, class_name: "Billing::Payment", optional: true
|
belongs_to :billing_payment, class_name: "Billing::Payment", optional: true
|
||||||
@@ -15,7 +15,8 @@ module Billing
|
|||||||
validates :currency, presence: true
|
validates :currency, presence: true
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
validates :source, inclusion: { in: SOURCES }
|
validates :source, inclusion: { in: SOURCES }
|
||||||
validate :pdf_present_when_issued
|
validates :billing_payment_id, uniqueness: true, allow_nil: true
|
||||||
|
validate :pdf_present_when_issued, on: :issue
|
||||||
|
|
||||||
scope :recent, -> { order(issued_on: :desc, created_at: :desc) }
|
scope :recent, -> { order(issued_on: :desc, created_at: :desc) }
|
||||||
|
|
||||||
@@ -28,16 +29,35 @@ module Billing
|
|||||||
end
|
end
|
||||||
|
|
||||||
def downloadable?
|
def downloadable?
|
||||||
pdf.attached?
|
pdf.attached? && status.in?(%w[issued sent])
|
||||||
|
end
|
||||||
|
|
||||||
|
def draft?
|
||||||
|
status == "draft"
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_number
|
||||||
|
n = number.to_s.strip
|
||||||
|
cleaned = n.gsub(/\baruba[-_\s]*/i, "").strip
|
||||||
|
cleaned.presence || n
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_status
|
||||||
|
{
|
||||||
|
"draft" => "In preparazione",
|
||||||
|
"issued" => "Emessa",
|
||||||
|
"sent" => "Inviata",
|
||||||
|
"cancelled" => "Annullata"
|
||||||
|
}[status] || status
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def pdf_present_when_issued
|
def pdf_present_when_issued
|
||||||
return unless status.in?(%w[issued sent])
|
return unless validation_context == :issue
|
||||||
return if pdf.attached?
|
return if pdf.attached?
|
||||||
|
|
||||||
errors.add(:pdf, "è obbligatorio per le fatture emesse")
|
errors.add(:pdf, "è obbligatorio per emettere la fattura")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -21,5 +21,17 @@ module Billing
|
|||||||
def formatted_amount
|
def formatted_amount
|
||||||
format("%.2f €", amount_euros)
|
format("%.2f €", amount_euros)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def display_description
|
||||||
|
PaymentDescription.for_payment(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
def display_status
|
||||||
|
{ "paid" => "Pagato", "failed" => "Non riuscito", "refunded" => "Rimborsato" }[status] || status
|
||||||
|
end
|
||||||
|
|
||||||
|
def invoice_for_display
|
||||||
|
invoice
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,18 +20,34 @@ module ClubBillingProfile
|
|||||||
billing_profile_errors.empty?
|
billing_profile_errors.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Campi minimi per intestazione fattura e invio (email PDF + SDI/PEC).
|
||||||
def billing_profile_errors
|
def billing_profile_errors
|
||||||
errors = []
|
errors = []
|
||||||
|
errors << "Tipo intestatario" if billing_entity_type.blank?
|
||||||
errors << "Ragione sociale o nome intestatario" if billing_legal_name.blank?
|
errors << "Ragione sociale o nome intestatario" if billing_legal_name.blank?
|
||||||
errors << "Email di fatturazione" if billing_email.blank?
|
errors << "Email di fatturazione" if billing_email.blank?
|
||||||
errors << "Indirizzo" if billing_address_line.blank?
|
errors << "Indirizzo" if billing_address_line.blank?
|
||||||
errors << "Città" if billing_city.blank?
|
errors << "Città" if billing_city.blank?
|
||||||
|
errors << "Provincia (sigla 2 lettere)" if billing_province.blank? || billing_province.to_s.length != 2
|
||||||
errors << "CAP" if billing_postal_code.blank?
|
errors << "CAP" if billing_postal_code.blank?
|
||||||
errors << "P.IVA o Codice Fiscale" if billing_vat_number.blank? && billing_fiscal_code.blank?
|
errors << "Paese (ISO)" if billing_country.blank? || billing_country.to_s.length != 2
|
||||||
errors << "Codice destinatario SDI o PEC" if billing_recipient_code.blank? && billing_pec.blank?
|
errors.concat(billing_tax_id_errors)
|
||||||
|
errors << "Codice destinatario SDI (7 caratteri) o PEC" if billing_recipient_code.blank? && billing_pec.blank?
|
||||||
|
errors << "Codice destinatario SDI (7 caratteri)" if billing_recipient_code.present? && billing_recipient_code.length != 7
|
||||||
errors
|
errors
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def billing_tax_id_errors
|
||||||
|
case billing_entity_type
|
||||||
|
when "company"
|
||||||
|
billing_vat_number.blank? ? ["Partita IVA"] : []
|
||||||
|
when "individual"
|
||||||
|
billing_fiscal_code.blank? ? ["Codice Fiscale"] : []
|
||||||
|
else
|
||||||
|
billing_vat_number.blank? && billing_fiscal_code.blank? ? ["P.IVA o Codice Fiscale"] : []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def billing_profile_summary
|
def billing_profile_summary
|
||||||
parts = [billing_legal_name.presence, billing_vat_number.presence && "P.IVA #{billing_vat_number}"]
|
parts = [billing_legal_name.presence, billing_vat_number.presence && "P.IVA #{billing_vat_number}"]
|
||||||
parts.compact.join(" · ")
|
parts.compact.join(" · ")
|
||||||
|
|||||||
47
backend/app/services/billing/issue_invoice.rb
Normal file
47
backend/app/services/billing/issue_invoice.rb
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
module Billing
|
||||||
|
class IssueInvoice
|
||||||
|
class Error < StandardError; end
|
||||||
|
|
||||||
|
def self.call(invoice:, pdf: nil)
|
||||||
|
new(invoice: invoice, pdf: pdf).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(invoice:, pdf: nil)
|
||||||
|
@invoice = invoice
|
||||||
|
@pdf = pdf
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
raise Error, "Carica il file PDF della fattura" if @pdf.blank? && !@invoice.pdf.attached?
|
||||||
|
|
||||||
|
attach_pdf!(@pdf) if @pdf.present?
|
||||||
|
raise Error, "PDF mancante" unless @invoice.pdf.attached?
|
||||||
|
|
||||||
|
recipient = @invoice.club.billing_email.presence
|
||||||
|
raise Error, "Email di fatturazione non configurata per la società" if recipient.blank?
|
||||||
|
|
||||||
|
@invoice.update!(status: "issued")
|
||||||
|
|
||||||
|
Billing::InvoiceMailer.with(invoice: @invoice).invoice_pdf.deliver_now
|
||||||
|
@invoice.update!(status: "sent", emailed_at: Time.current)
|
||||||
|
|
||||||
|
@invoice
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_pdf!(upload)
|
||||||
|
if upload.respond_to?(:tempfile)
|
||||||
|
@invoice.pdf.attach(
|
||||||
|
io: upload.tempfile,
|
||||||
|
filename: upload.original_filename,
|
||||||
|
content_type: upload.content_type.presence || "application/pdf"
|
||||||
|
)
|
||||||
|
elsif upload.is_a?(Hash)
|
||||||
|
@invoice.pdf.attach(**upload)
|
||||||
|
elsif upload.is_a?(StringIO)
|
||||||
|
@invoice.pdf.attach(io: upload, filename: "fattura.pdf", content_type: "application/pdf")
|
||||||
|
else
|
||||||
|
@invoice.pdf.attach(upload)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
129
backend/app/services/billing/payment_description.rb
Normal file
129
backend/app/services/billing/payment_description.rb
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
module Billing
|
||||||
|
# Etichette leggibili per lo storico pagamenti (mai testo grezzo Stripe / webhook).
|
||||||
|
class PaymentDescription
|
||||||
|
UGLY = /\A(webhook\s*test|test|x|—|-)\z/i
|
||||||
|
|
||||||
|
AMOUNT_HINTS = {
|
||||||
|
500 => %w[premium_light monthly],
|
||||||
|
4000 => %w[premium_light yearly],
|
||||||
|
2000 => %w[premium_full monthly],
|
||||||
|
20_000 => %w[premium_full yearly]
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def for_stripe_invoice(invoice)
|
||||||
|
plan_slug, interval = extract_plan_and_interval(invoice)
|
||||||
|
build(plan_slug: plan_slug, interval: interval, amount_cents: invoice.amount_paid)
|
||||||
|
end
|
||||||
|
|
||||||
|
def for_payment(payment)
|
||||||
|
stored = payment.description.to_s.strip
|
||||||
|
return stored if stored.present? && !ugly?(stored)
|
||||||
|
|
||||||
|
build(
|
||||||
|
plan_slug: payment.plan_slug,
|
||||||
|
interval: nil,
|
||||||
|
amount_cents: payment.amount_cents
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build(plan_slug:, interval:, amount_cents:)
|
||||||
|
plan_slug, interval = resolve_plan_interval(plan_slug, interval, amount_cents)
|
||||||
|
return plan_label(plan_slug, interval) if plan_slug.present?
|
||||||
|
|
||||||
|
if amount_cents.present? && amount_cents.to_i.positive?
|
||||||
|
euros = format("%.2f", amount_cents.to_i / 100.0)
|
||||||
|
return "Pagamento Match Live TV — #{euros} €"
|
||||||
|
end
|
||||||
|
|
||||||
|
"Abbonamento Match Live TV"
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_plan_interval(plan_slug, interval, amount_cents)
|
||||||
|
plan_slug = plan_slug.presence
|
||||||
|
interval = interval.presence
|
||||||
|
|
||||||
|
if plan_slug.blank? && amount_cents.present?
|
||||||
|
hint = AMOUNT_HINTS[amount_cents.to_i]
|
||||||
|
plan_slug, interval = hint if hint
|
||||||
|
end
|
||||||
|
|
||||||
|
if plan_slug.present? && interval.blank? && amount_cents.present?
|
||||||
|
hint = AMOUNT_HINTS[amount_cents.to_i]
|
||||||
|
interval = hint&.last if hint&.first == plan_slug
|
||||||
|
end
|
||||||
|
|
||||||
|
interval ||= Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL if plan_slug.present?
|
||||||
|
[plan_slug, interval]
|
||||||
|
end
|
||||||
|
|
||||||
|
def plan_label(plan_slug, interval)
|
||||||
|
plan = Plan[plan_slug]
|
||||||
|
price = Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)
|
||||||
|
"#{plan.name} — #{price}"
|
||||||
|
rescue KeyError
|
||||||
|
plan_slug.to_s.humanize
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_plan_and_interval(invoice)
|
||||||
|
meta = merged_metadata(invoice)
|
||||||
|
plan_slug = meta["plan_slug"].presence
|
||||||
|
interval = normalize_interval(meta["billing_interval"])
|
||||||
|
|
||||||
|
line = invoice.lines&.data&.first
|
||||||
|
if line
|
||||||
|
line_meta = line_metadata(line)
|
||||||
|
plan_slug ||= line_meta["plan_slug"]
|
||||||
|
interval ||= normalize_interval(line_meta["billing_interval"])
|
||||||
|
|
||||||
|
price_id = line_price_id(line)
|
||||||
|
from_price = Billing::Stripe::PriceCatalog.infer_from_price_id(price_id)
|
||||||
|
plan_slug ||= from_price&.first
|
||||||
|
interval ||= from_price&.last
|
||||||
|
end
|
||||||
|
|
||||||
|
plan_slug = normalize_plan_slug(plan_slug)
|
||||||
|
[plan_slug, interval]
|
||||||
|
end
|
||||||
|
|
||||||
|
def merged_metadata(invoice)
|
||||||
|
Billing::Stripe::InvoiceMetadata.merged(invoice)
|
||||||
|
end
|
||||||
|
|
||||||
|
def line_metadata(line)
|
||||||
|
meta = Billing::Stripe::InvoicePaymentRefs.safe_get(line, :metadata)
|
||||||
|
meta.to_h.stringify_keys
|
||||||
|
rescue NoMethodError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def line_price_id(line)
|
||||||
|
pricing = Billing::Stripe::InvoicePaymentRefs.safe_get(line, :pricing)
|
||||||
|
details = Billing::Stripe::InvoicePaymentRefs.safe_get(pricing, :price_details)
|
||||||
|
Billing::Stripe::InvoicePaymentRefs.safe_get(details, :price)
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_interval(value)
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
Billing::Stripe::PriceCatalog.normalize_interval(value)
|
||||||
|
rescue ArgumentError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_plan_slug(slug)
|
||||||
|
return nil if slug.blank?
|
||||||
|
|
||||||
|
slug = slug.to_s
|
||||||
|
slug = "premium_full" if slug == "premium"
|
||||||
|
slug.presence_in(%w[premium_light premium_full])
|
||||||
|
end
|
||||||
|
|
||||||
|
def ugly?(text)
|
||||||
|
UGLY.match?(text.to_s.strip)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
29
backend/app/services/billing/stripe/cancel_subscription.rb
Normal file
29
backend/app/services/billing/stripe/cancel_subscription.rb
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
module Billing
|
||||||
|
module Stripe
|
||||||
|
class CancelSubscription
|
||||||
|
def self.call(club:)
|
||||||
|
new(club: club).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(club:)
|
||||||
|
@club = club
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
|
||||||
|
|
||||||
|
sub = @club.subscription
|
||||||
|
raise "Nessun abbonamento premium attivo" unless sub&.premium? && sub.stripe_subscription_id.present?
|
||||||
|
raise "Disdetta già programmata" if sub.cancel_at_period_end?
|
||||||
|
|
||||||
|
stripe_sub = ::Stripe::Subscription.update(
|
||||||
|
sub.stripe_subscription_id,
|
||||||
|
cancel_at_period_end: true
|
||||||
|
)
|
||||||
|
|
||||||
|
CheckoutSync.from_subscription(stripe_sub, club: @club)
|
||||||
|
@club.subscription.reload
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
module Billing
|
module Billing
|
||||||
module Stripe
|
module Stripe
|
||||||
# Solo per sincronizzazione interna/test mockati.
|
|
||||||
# I cambi piano utente devono passare da CheckoutSession (Stripe Checkout hosted).
|
|
||||||
# Non creare mai PaymentMethod con card[number] sul server (violazione PCI).
|
|
||||||
class ChangePlan
|
class ChangePlan
|
||||||
VALID_PLANS = CheckoutSession::VALID_PLANS.freeze
|
VALID_PLANS = CheckoutSession::VALID_PLANS.freeze
|
||||||
|
|
||||||
def self.call(club:, plan_slug:)
|
def self.call(club:, plan_slug:, interval: nil)
|
||||||
new(club: club, plan_slug: plan_slug).call
|
new(club: club, plan_slug: plan_slug, interval: interval).call
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(club:, plan_slug:)
|
def initialize(club:, plan_slug:, interval: nil)
|
||||||
@club = club
|
@club = club
|
||||||
@plan_slug = plan_slug.to_s
|
@plan_slug = plan_slug.to_s
|
||||||
|
@interval = interval
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
@@ -21,10 +19,13 @@ module Billing
|
|||||||
|
|
||||||
sub = @club.subscription
|
sub = @club.subscription
|
||||||
raise "Nessun abbonamento Stripe attivo" if sub.blank? || sub.stripe_subscription_id.blank?
|
raise "Nessun abbonamento Stripe attivo" if sub.blank? || sub.stripe_subscription_id.blank?
|
||||||
raise "Sei già su questo piano" if sub.plan.slug == @plan_slug
|
|
||||||
|
|
||||||
price_id = Plan[@plan_slug].stripe_price_id.presence || stripe_price_id_for(@plan_slug)
|
interval = resolve_interval(sub)
|
||||||
raise "Price ID Stripe mancante per #{@plan_slug}" if price_id.blank?
|
if sub.plan.slug == @plan_slug && sub.billing_interval == interval
|
||||||
|
raise "Sei già su questo piano e intervallo di fatturazione"
|
||||||
|
end
|
||||||
|
|
||||||
|
price_id = PriceCatalog.price_id(plan_slug: @plan_slug, interval: interval)
|
||||||
|
|
||||||
stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id)
|
stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id)
|
||||||
item_id = stripe_sub.items.data.first.id
|
item_id = stripe_sub.items.data.first.id
|
||||||
@@ -32,24 +33,23 @@ module Billing
|
|||||||
updated = ::Stripe::Subscription.update(
|
updated = ::Stripe::Subscription.update(
|
||||||
stripe_sub.id,
|
stripe_sub.id,
|
||||||
items: [{ id: item_id, price: price_id }],
|
items: [{ id: item_id, price: price_id }],
|
||||||
metadata: { club_id: @club.id, plan_slug: @plan_slug },
|
metadata: { club_id: @club.id, plan_slug: @plan_slug, billing_interval: interval },
|
||||||
proration_behavior: "create_prorations"
|
proration_behavior: "create_prorations"
|
||||||
)
|
)
|
||||||
|
|
||||||
apply_local_plan!(updated)
|
apply_local_plan!(updated, interval: interval)
|
||||||
updated
|
updated
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def stripe_price_id_for(slug)
|
def resolve_interval(sub)
|
||||||
case slug
|
return PriceCatalog.normalize_interval(@interval) if @interval.present?
|
||||||
when "premium_light" then MatchLiveTv.stripe_premium_light_price_id
|
|
||||||
when "premium_full" then MatchLiveTv.stripe_premium_full_price_id
|
sub.billing_interval.presence || PriceCatalog::DEFAULT_INTERVAL
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply_local_plan!(stripe_sub)
|
def apply_local_plan!(stripe_sub, interval:)
|
||||||
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
|
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
|
||||||
Billing::AssignPlan.call(
|
Billing::AssignPlan.call(
|
||||||
club: @club,
|
club: @club,
|
||||||
@@ -60,7 +60,8 @@ module Billing
|
|||||||
stripe_subscription_id: stripe_sub.id,
|
stripe_subscription_id: stripe_sub.id,
|
||||||
current_period_start: period_start,
|
current_period_start: period_start,
|
||||||
current_period_end: period_end,
|
current_period_end: period_end,
|
||||||
cancel_at_period_end: stripe_sub.cancel_at_period_end
|
cancel_at_period_end: stripe_sub.cancel_at_period_end,
|
||||||
|
billing_interval: interval
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,44 +3,45 @@ module Billing
|
|||||||
class CheckoutSession
|
class CheckoutSession
|
||||||
VALID_PLANS = %w[premium_light premium_full].freeze
|
VALID_PLANS = %w[premium_light premium_full].freeze
|
||||||
|
|
||||||
def initialize(club:, user:, plan_slug: "premium_light")
|
def initialize(club:, user:, plan_slug: "premium_light", interval: PriceCatalog::DEFAULT_INTERVAL)
|
||||||
@club = club
|
@club = club
|
||||||
@user = user
|
@user = user
|
||||||
@plan_slug = plan_slug.to_s
|
@plan_slug = plan_slug.to_s
|
||||||
|
@interval = PriceCatalog.normalize_interval(interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
def url
|
def url
|
||||||
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
|
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
|
||||||
raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug)
|
raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug)
|
||||||
|
|
||||||
plan = Plan[@plan_slug]
|
price_id = PriceCatalog.price_id(plan_slug: @plan_slug, interval: @interval)
|
||||||
price_id = plan.stripe_price_id.presence || stripe_price_id_for(@plan_slug)
|
|
||||||
raise "Price ID Stripe mancante per #{@plan_slug}" if price_id.blank?
|
|
||||||
|
|
||||||
customer_id = ensure_customer_id!
|
customer_id = ensure_customer_id!
|
||||||
CustomerSync.call(club: @club, customer_id: customer_id)
|
CustomerSync.call(club: @club, customer_id: customer_id)
|
||||||
|
|
||||||
session_params = {
|
session = ::Stripe::Checkout::Session.create(
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
customer: customer_id,
|
customer: customer_id,
|
||||||
line_items: [{ price: price_id, quantity: 1 }],
|
line_items: [{ price: price_id, quantity: 1 }],
|
||||||
success_url: success_url,
|
success_url: success_url,
|
||||||
cancel_url: cancel_url,
|
cancel_url: cancel_url,
|
||||||
metadata: { club_id: @club.id, user_id: @user.id, plan_slug: @plan_slug },
|
metadata: {
|
||||||
subscription_data: { metadata: { club_id: @club.id, plan_slug: @plan_slug } },
|
club_id: @club.id,
|
||||||
|
user_id: @user.id,
|
||||||
|
plan_slug: @plan_slug,
|
||||||
|
billing_interval: @interval
|
||||||
|
},
|
||||||
|
subscription_data: {
|
||||||
|
metadata: {
|
||||||
|
club_id: @club.id,
|
||||||
|
plan_slug: @plan_slug,
|
||||||
|
billing_interval: @interval
|
||||||
|
}
|
||||||
|
},
|
||||||
billing_address_collection: "required",
|
billing_address_collection: "required",
|
||||||
customer_update: { address: "auto", name: "auto" },
|
customer_update: { address: "auto", name: "auto" },
|
||||||
allow_promotion_codes: true
|
allow_promotion_codes: true
|
||||||
}
|
)
|
||||||
|
|
||||||
existing_subscription_id = active_stripe_subscription_id
|
|
||||||
if existing_subscription_id.present?
|
|
||||||
# Cambio piano: conferma su pagina Stripe (mai dati carta sul nostro server).
|
|
||||||
session_params[:subscription] = existing_subscription_id
|
|
||||||
session_params.delete(:subscription_data)
|
|
||||||
end
|
|
||||||
|
|
||||||
session = ::Stripe::Checkout::Session.create(session_params)
|
|
||||||
session.url
|
session.url
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -58,20 +59,6 @@ module Billing
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def stripe_price_id_for(slug)
|
|
||||||
case slug
|
|
||||||
when "premium_light" then MatchLiveTv.stripe_premium_light_price_id
|
|
||||||
when "premium_full" then MatchLiveTv.stripe_premium_full_price_id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def active_stripe_subscription_id
|
|
||||||
sub = @club.subscription
|
|
||||||
return nil unless sub&.stripe_subscription_id.present? && sub.active?
|
|
||||||
|
|
||||||
sub.stripe_subscription_id
|
|
||||||
end
|
|
||||||
|
|
||||||
def ensure_customer_id!
|
def ensure_customer_id!
|
||||||
sub = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
|
sub = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
|
||||||
return sub.stripe_customer_id if sub.stripe_customer_id.present?
|
return sub.stripe_customer_id if sub.stripe_customer_id.present?
|
||||||
@@ -86,7 +73,7 @@ module Billing
|
|||||||
end
|
end
|
||||||
|
|
||||||
def success_url
|
def success_url
|
||||||
"#{billing_url}?checkout=success"
|
"#{billing_url}?checkout=success&session_id={CHECKOUT_SESSION_ID}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def cancel_url
|
def cancel_url
|
||||||
|
|||||||
196
backend/app/services/billing/stripe/checkout_sync.rb
Normal file
196
backend/app/services/billing/stripe/checkout_sync.rb
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
module Billing
|
||||||
|
module Stripe
|
||||||
|
# Allinea DB locale con Stripe dopo Checkout o webhook (idempotente).
|
||||||
|
class CheckoutSync
|
||||||
|
def self.call(club:, session_id: nil)
|
||||||
|
new(club: club, session_id: session_id).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_session(session)
|
||||||
|
new.apply_from_session(session)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_subscription(stripe_sub, club: nil, plan_slug: nil, billing_interval: nil)
|
||||||
|
new.apply_from_subscription(stripe_sub, club: club, plan_slug: plan_slug, billing_interval: billing_interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(club: nil, session_id: nil)
|
||||||
|
@club = club
|
||||||
|
@session_id = session_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return false unless MatchLiveTv.stripe_enabled?
|
||||||
|
|
||||||
|
if @session_id.present?
|
||||||
|
session = ::Stripe::Checkout::Session.retrieve(@session_id)
|
||||||
|
return false unless session_paid?(session)
|
||||||
|
|
||||||
|
apply_from_session(session)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
sync_from_customer
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_from_session(session)
|
||||||
|
club = resolve_club(session) || @club
|
||||||
|
return unless club
|
||||||
|
|
||||||
|
sub = club.subscription || club.build_subscription
|
||||||
|
sub.update!(stripe_customer_id: session.customer) if session.customer.present?
|
||||||
|
|
||||||
|
return unless session.subscription.present?
|
||||||
|
|
||||||
|
stripe_sub = ::Stripe::Subscription.retrieve(session.subscription)
|
||||||
|
plan_slug = metadata_plan_slug(session) || "premium_light"
|
||||||
|
plan_slug = "premium_full" if plan_slug == "premium"
|
||||||
|
apply_from_subscription(
|
||||||
|
stripe_sub,
|
||||||
|
club: club,
|
||||||
|
plan_slug: plan_slug,
|
||||||
|
billing_interval: metadata_billing_interval(session)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_from_subscription(stripe_sub, club: nil, plan_slug: nil, billing_interval: nil)
|
||||||
|
club ||= Club.joins(:subscription)
|
||||||
|
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
|
||||||
|
club ||= resolve_club(stripe_sub)
|
||||||
|
return unless club
|
||||||
|
|
||||||
|
plan_slug ||= metadata_plan_slug(stripe_sub) || infer_plan_slug_from_price(stripe_sub) || "premium_light"
|
||||||
|
plan_slug = "premium_full" if plan_slug == "premium"
|
||||||
|
plan = Plan[plan_slug]
|
||||||
|
status = map_status(stripe_sub.status)
|
||||||
|
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
|
||||||
|
|
||||||
|
billing_interval = billing_interval.presence || metadata_billing_interval(stripe_sub) ||
|
||||||
|
infer_interval_from_price(stripe_sub) ||
|
||||||
|
PriceCatalog::DEFAULT_INTERVAL
|
||||||
|
|
||||||
|
Billing::AssignPlan.call(
|
||||||
|
club: club,
|
||||||
|
plan_slug: plan.slug,
|
||||||
|
status: status,
|
||||||
|
stripe_attrs: {
|
||||||
|
stripe_customer_id: stripe_sub.customer,
|
||||||
|
stripe_subscription_id: stripe_sub.id,
|
||||||
|
current_period_start: period_start,
|
||||||
|
current_period_end: period_end,
|
||||||
|
cancel_at_period_end: stripe_sub.cancel_at_period_end,
|
||||||
|
billing_interval: billing_interval
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def sync_from_customer
|
||||||
|
return false unless @club
|
||||||
|
|
||||||
|
customer_id = @club.subscription&.stripe_customer_id
|
||||||
|
return false if customer_id.blank?
|
||||||
|
|
||||||
|
stripe_sub = latest_active_subscription_for_club(customer_id, @club.id)
|
||||||
|
return false unless stripe_sub
|
||||||
|
|
||||||
|
apply_from_subscription(stripe_sub, club: @club)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def latest_active_subscription_for_club(customer_id, club_id)
|
||||||
|
list = ::Stripe::Subscription.list(customer: customer_id, status: "all", limit: 20)
|
||||||
|
list.data
|
||||||
|
.select { |s| %w[active trialing].include?(s.status) }
|
||||||
|
.select { |s| subscription_belongs_to_club?(s, club_id) }
|
||||||
|
.max_by(&:created)
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscription_belongs_to_club?(stripe_sub, club_id)
|
||||||
|
meta_club = metadata_club_id(stripe_sub)
|
||||||
|
return meta_club == club_id if meta_club.present?
|
||||||
|
|
||||||
|
team_id = metadata_team_id(stripe_sub)
|
||||||
|
return Team.find_by(id: team_id)&.club_id == club_id if team_id.present?
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def session_paid?(session)
|
||||||
|
session.payment_status == "paid" || session.status == "complete"
|
||||||
|
end
|
||||||
|
|
||||||
|
def infer_plan_slug_from_price(stripe_sub)
|
||||||
|
price_id = stripe_sub.items&.data&.first&.price&.id
|
||||||
|
return "premium_light" if price_id == MatchLiveTv.stripe_premium_light_monthly_price_id
|
||||||
|
return "premium_light" if price_id == MatchLiveTv.stripe_premium_light_yearly_price_id
|
||||||
|
return "premium_full" if price_id == MatchLiveTv.stripe_premium_full_monthly_price_id
|
||||||
|
return "premium_full" if price_id == MatchLiveTv.stripe_premium_full_yearly_price_id
|
||||||
|
|
||||||
|
"premium_light"
|
||||||
|
end
|
||||||
|
|
||||||
|
def infer_interval_from_price(stripe_sub)
|
||||||
|
interval = stripe_sub.items&.data&.first&.price&.recurring&.interval
|
||||||
|
return "monthly" if interval == "month"
|
||||||
|
return "yearly" if interval == "year"
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_club(obj)
|
||||||
|
club_id = metadata_club_id(obj)
|
||||||
|
return Club.find_by(id: club_id) if club_id.present?
|
||||||
|
|
||||||
|
team_id = metadata_team_id(obj)
|
||||||
|
return Team.find_by(id: team_id)&.club if team_id.present?
|
||||||
|
|
||||||
|
@club
|
||||||
|
end
|
||||||
|
|
||||||
|
def metadata_billing_interval(obj)
|
||||||
|
meta = obj.metadata
|
||||||
|
return nil if meta.blank?
|
||||||
|
|
||||||
|
val = meta["billing_interval"] || meta[:billing_interval]
|
||||||
|
return nil if val.blank?
|
||||||
|
|
||||||
|
PriceCatalog.normalize_interval(val)
|
||||||
|
rescue ArgumentError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def metadata_plan_slug(obj)
|
||||||
|
meta = obj.metadata
|
||||||
|
return nil if meta.blank?
|
||||||
|
|
||||||
|
meta["plan_slug"] || meta[:plan_slug]
|
||||||
|
end
|
||||||
|
|
||||||
|
def metadata_club_id(obj)
|
||||||
|
meta = obj.metadata
|
||||||
|
return nil if meta.blank?
|
||||||
|
|
||||||
|
meta["club_id"] || meta[:club_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def metadata_team_id(obj)
|
||||||
|
meta = obj.metadata
|
||||||
|
return nil if meta.blank?
|
||||||
|
|
||||||
|
meta["team_id"] || meta[:team_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def map_status(stripe_status)
|
||||||
|
case stripe_status
|
||||||
|
when "trialing" then "trialing"
|
||||||
|
when "active" then "active"
|
||||||
|
when "past_due", "unpaid" then "past_due"
|
||||||
|
when "canceled", "incomplete_expired" then "canceled"
|
||||||
|
else "incomplete"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
65
backend/app/services/billing/stripe/finalize_subscription.rb
Normal file
65
backend/app/services/billing/stripe/finalize_subscription.rb
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
module Billing
|
||||||
|
module Stripe
|
||||||
|
# Passa a Free se il periodo pagato è scaduto (es. disdetta o webhook mancato in locale).
|
||||||
|
class FinalizeSubscription
|
||||||
|
def self.call(club:)
|
||||||
|
new(club: club).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(club:)
|
||||||
|
@club = club
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
sub = @club.subscription
|
||||||
|
return false unless sub&.stripe_subscription_id.present?
|
||||||
|
return false if sub.free?
|
||||||
|
|
||||||
|
stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id)
|
||||||
|
|
||||||
|
if stripe_sub.status.in?(%w[canceled incomplete_expired])
|
||||||
|
downgrade!(sub)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
period_end = period_end_time(sub, stripe_sub)
|
||||||
|
return false if period_end.blank? || period_end > Time.current
|
||||||
|
|
||||||
|
if sub.cancel_at_period_end? || stripe_sub.cancel_at_period_end
|
||||||
|
downgrade!(sub)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
false
|
||||||
|
rescue ::Stripe::InvalidRequestError => e
|
||||||
|
downgrade!(sub) if e.message.include?("No such subscription")
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def period_end_time(sub, stripe_sub)
|
||||||
|
local = sub.current_period_end
|
||||||
|
return local if local.present?
|
||||||
|
|
||||||
|
start_at, end_at = SubscriptionPeriod.times(stripe_sub)
|
||||||
|
end_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def downgrade!(sub)
|
||||||
|
Billing::AssignPlan.call(
|
||||||
|
club: @club,
|
||||||
|
plan_slug: "free",
|
||||||
|
status: "active",
|
||||||
|
stripe_attrs: {
|
||||||
|
stripe_subscription_id: nil,
|
||||||
|
current_period_start: nil,
|
||||||
|
current_period_end: nil,
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
billing_interval: nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
41
backend/app/services/billing/stripe/invoice_metadata.rb
Normal file
41
backend/app/services/billing/stripe/invoice_metadata.rb
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
module Billing
|
||||||
|
module Stripe
|
||||||
|
# Metadati e subscription id su Invoice (API Basil+: parent.subscription_details).
|
||||||
|
module InvoiceMetadata
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def merged(invoice)
|
||||||
|
base = metadata_hash(invoice)
|
||||||
|
base.merge(subscription_metadata(invoice))
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscription_metadata(invoice)
|
||||||
|
details = subscription_details(invoice)
|
||||||
|
return {} if details.blank?
|
||||||
|
|
||||||
|
metadata_hash(details)
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscription_id(invoice)
|
||||||
|
details = subscription_details(invoice)
|
||||||
|
return nil if details.blank?
|
||||||
|
|
||||||
|
InvoicePaymentRefs.safe_get(details, :subscription)
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscription_details(invoice)
|
||||||
|
parent = InvoicePaymentRefs.safe_get(invoice, :parent)
|
||||||
|
return nil if parent.blank?
|
||||||
|
|
||||||
|
InvoicePaymentRefs.safe_get(parent, :subscription_details)
|
||||||
|
end
|
||||||
|
|
||||||
|
def metadata_hash(obj)
|
||||||
|
meta = InvoicePaymentRefs.safe_get(obj, :metadata)
|
||||||
|
meta.to_h.stringify_keys
|
||||||
|
rescue NoMethodError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
56
backend/app/services/billing/stripe/invoice_payment_refs.rb
Normal file
56
backend/app/services/billing/stripe/invoice_payment_refs.rb
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
module Billing
|
||||||
|
module Stripe
|
||||||
|
# Estrae riferimenti pagamento da Invoice Stripe (API Basil+: niente payment_intent top-level).
|
||||||
|
module InvoicePaymentRefs
|
||||||
|
EXPAND = ["payments.data.payment.payment_intent"].freeze
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def payment_intent_id(invoice)
|
||||||
|
each_payment_intent(invoice) { |pi| stripe_id(pi) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def charge_id(invoice)
|
||||||
|
each_payment_intent(invoice) do |pi|
|
||||||
|
stripe_id(safe_get(pi, :latest_charge))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def each_payment_intent(invoice)
|
||||||
|
payment_entries(invoice).each do |entry|
|
||||||
|
payment = safe_get(entry, :payment)
|
||||||
|
next if payment.blank?
|
||||||
|
|
||||||
|
pi = safe_get(payment, :payment_intent)
|
||||||
|
result = yield(pi) if block_given?
|
||||||
|
return result if result.present?
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def payment_entries(invoice)
|
||||||
|
payments = safe_get(invoice, :payments)
|
||||||
|
return [] if payments.blank?
|
||||||
|
|
||||||
|
safe_get(payments, :data) || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def stripe_id(value)
|
||||||
|
return nil if value.blank?
|
||||||
|
return value if value.is_a?(String)
|
||||||
|
|
||||||
|
safe_get(value, :id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_get(obj, key)
|
||||||
|
return nil if obj.blank?
|
||||||
|
return obj[key] if obj.is_a?(Hash)
|
||||||
|
return obj.public_send(key) if obj.respond_to?(key)
|
||||||
|
|
||||||
|
nil
|
||||||
|
rescue NoMethodError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
74
backend/app/services/billing/stripe/price_catalog.rb
Normal file
74
backend/app/services/billing/stripe/price_catalog.rb
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
module Billing
|
||||||
|
module Stripe
|
||||||
|
class PriceCatalog
|
||||||
|
INTERVALS = %w[monthly yearly].freeze
|
||||||
|
DEFAULT_INTERVAL = "yearly"
|
||||||
|
|
||||||
|
PRICING = {
|
||||||
|
"premium_light" => { "yearly" => "€39,90/anno", "monthly" => "€4,90/mese" },
|
||||||
|
"premium_full" => { "yearly" => "€69,90/anno", "monthly" => "€9,90/mese" }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def normalize_interval(value)
|
||||||
|
interval = value.to_s.presence || DEFAULT_INTERVAL
|
||||||
|
raise ArgumentError, "Intervallo non valido" unless INTERVALS.include?(interval)
|
||||||
|
|
||||||
|
interval
|
||||||
|
end
|
||||||
|
|
||||||
|
def price_id(plan_slug:, interval:)
|
||||||
|
interval = normalize_interval(interval)
|
||||||
|
id = price_id_for(plan_slug.to_s, interval)
|
||||||
|
raise "Price ID Stripe mancante per #{plan_slug} (#{interval})" if id.blank?
|
||||||
|
|
||||||
|
id
|
||||||
|
end
|
||||||
|
|
||||||
|
def configured?(plan_slug:, interval:)
|
||||||
|
price_id_for(plan_slug.to_s, normalize_interval(interval)).present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def label(plan_slug:, interval:)
|
||||||
|
PRICING.dig(plan_slug.to_s, normalize_interval(interval)) || normalize_interval(interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
def available_intervals(plan_slug:)
|
||||||
|
INTERVALS.select { |interval| configured?(plan_slug: plan_slug, interval: interval) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def infer_from_price_id(price_id)
|
||||||
|
return nil if price_id.blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
MatchLiveTv.stripe_premium_light_monthly_price_id => %w[premium_light monthly],
|
||||||
|
MatchLiveTv.stripe_premium_light_yearly_price_id => %w[premium_light yearly],
|
||||||
|
MatchLiveTv.stripe_premium_full_monthly_price_id => %w[premium_full monthly],
|
||||||
|
MatchLiveTv.stripe_premium_full_yearly_price_id => %w[premium_full yearly]
|
||||||
|
}.each do |configured_id, pair|
|
||||||
|
return pair if configured_id.present? && configured_id == price_id
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def price_id_for(plan_slug, interval)
|
||||||
|
case [plan_slug, interval]
|
||||||
|
when %w[premium_light monthly]
|
||||||
|
MatchLiveTv.stripe_premium_light_monthly_price_id
|
||||||
|
when %w[premium_light yearly]
|
||||||
|
MatchLiveTv.stripe_premium_light_yearly_price_id
|
||||||
|
when %w[premium_full monthly]
|
||||||
|
MatchLiveTv.stripe_premium_full_monthly_price_id
|
||||||
|
when %w[premium_full yearly]
|
||||||
|
MatchLiveTv.stripe_premium_full_yearly_price_id
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -14,6 +14,8 @@ module Billing
|
|||||||
@club ||= resolve_club
|
@club ||= resolve_club
|
||||||
return unless @club
|
return unless @club
|
||||||
|
|
||||||
|
@stripe_invoice = ensure_payments_expanded(@stripe_invoice)
|
||||||
|
|
||||||
payment = Billing::Payment.find_or_initialize_by(stripe_invoice_id: @stripe_invoice.id)
|
payment = Billing::Payment.find_or_initialize_by(stripe_invoice_id: @stripe_invoice.id)
|
||||||
payment.assign_attributes(
|
payment.assign_attributes(
|
||||||
club: @club,
|
club: @club,
|
||||||
@@ -23,7 +25,7 @@ module Billing
|
|||||||
currency: @stripe_invoice.currency,
|
currency: @stripe_invoice.currency,
|
||||||
status: map_status(@stripe_invoice.status),
|
status: map_status(@stripe_invoice.status),
|
||||||
plan_slug: plan_slug,
|
plan_slug: plan_slug,
|
||||||
description: description,
|
description: PaymentDescription.for_stripe_invoice(@stripe_invoice),
|
||||||
paid_at: paid_at,
|
paid_at: paid_at,
|
||||||
invoice_pdf_url: @stripe_invoice.invoice_pdf,
|
invoice_pdf_url: @stripe_invoice.invoice_pdf,
|
||||||
receipt_url: @stripe_invoice.hosted_invoice_url
|
receipt_url: @stripe_invoice.hosted_invoice_url
|
||||||
@@ -38,7 +40,7 @@ module Billing
|
|||||||
club_id = metadata_club_id(@stripe_invoice)
|
club_id = metadata_club_id(@stripe_invoice)
|
||||||
return Club.find_by(id: club_id) if club_id.present?
|
return Club.find_by(id: club_id) if club_id.present?
|
||||||
|
|
||||||
sub_id = @stripe_invoice.subscription
|
sub_id = InvoiceMetadata.subscription_id(@stripe_invoice)
|
||||||
return unless sub_id.present?
|
return unless sub_id.present?
|
||||||
|
|
||||||
Club.joins(:subscription).find_by(subscriptions: { stripe_subscription_id: sub_id })
|
Club.joins(:subscription).find_by(subscriptions: { stripe_subscription_id: sub_id })
|
||||||
@@ -52,25 +54,26 @@ module Billing
|
|||||||
end
|
end
|
||||||
|
|
||||||
def plan_slug
|
def plan_slug
|
||||||
meta = @stripe_invoice.subscription_details&.metadata || @stripe_invoice.metadata
|
meta = InvoiceMetadata.merged(@stripe_invoice)
|
||||||
meta["plan_slug"] || meta[:plan_slug]
|
slug = meta["plan_slug"]
|
||||||
end
|
slug = "premium_full" if slug == "premium"
|
||||||
|
slug.presence_in(%w[premium_light premium_full])
|
||||||
def description
|
|
||||||
lines = @stripe_invoice.lines&.data
|
|
||||||
return @stripe_invoice.description if lines.blank?
|
|
||||||
|
|
||||||
lines.first.description.presence || @stripe_invoice.description
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def payment_intent_id
|
def payment_intent_id
|
||||||
pi = @stripe_invoice.payment_intent
|
InvoicePaymentRefs.payment_intent_id(@stripe_invoice)
|
||||||
pi.is_a?(String) ? pi : pi&.id
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def charge_id
|
def charge_id
|
||||||
charge = @stripe_invoice.charge
|
InvoicePaymentRefs.charge_id(@stripe_invoice)
|
||||||
charge.is_a?(String) ? charge : charge&.id
|
end
|
||||||
|
|
||||||
|
def ensure_payments_expanded(invoice)
|
||||||
|
return invoice if InvoicePaymentRefs.payment_entries(invoice).any?
|
||||||
|
|
||||||
|
::Stripe::Invoice.retrieve(id: invoice.id, expand: InvoicePaymentRefs::EXPAND)
|
||||||
|
rescue ::Stripe::StripeError
|
||||||
|
invoice
|
||||||
end
|
end
|
||||||
|
|
||||||
def paid_at
|
def paid_at
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ module Billing
|
|||||||
starting_after = nil
|
starting_after = nil
|
||||||
|
|
||||||
loop do
|
loop do
|
||||||
|
# Lista senza expand profondo (limite Stripe 4 livelli); RecordPayment arricchisce ogni fattura.
|
||||||
list = ::Stripe::Invoice.list(customer: customer_id, limit: 25, starting_after: starting_after)
|
list = ::Stripe::Invoice.list(customer: customer_id, limit: 25, starting_after: starting_after)
|
||||||
list.data.each do |invoice|
|
list.data.each do |invoice|
|
||||||
next unless invoice.status == "paid"
|
next unless invoice.status == "paid"
|
||||||
@@ -29,6 +30,9 @@ module Billing
|
|||||||
starting_after = list.data.last.id
|
starting_after = list.data.last.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Allinea anche piano/intervallo da abbonamento Stripe attivo (non solo lo storico pagamenti).
|
||||||
|
CheckoutSync.call(club: @club)
|
||||||
|
|
||||||
count
|
count
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ module Billing
|
|||||||
def call
|
def call
|
||||||
case @event.type
|
case @event.type
|
||||||
when "checkout.session.completed"
|
when "checkout.session.completed"
|
||||||
handle_checkout_completed(@event.data.object)
|
CheckoutSync.from_session(@event.data.object)
|
||||||
when "customer.subscription.updated", "customer.subscription.created"
|
when "customer.subscription.updated", "customer.subscription.created"
|
||||||
sync_subscription(@event.data.object)
|
CheckoutSync.from_subscription(@event.data.object)
|
||||||
when "customer.subscription.deleted"
|
when "customer.subscription.deleted"
|
||||||
downgrade_to_free(@event.data.object)
|
downgrade_to_free(@event.data.object)
|
||||||
when "invoice.payment_failed"
|
when "invoice.payment_failed"
|
||||||
@@ -26,47 +26,6 @@ module Billing
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def handle_checkout_completed(session)
|
|
||||||
club = resolve_club(session)
|
|
||||||
return unless club
|
|
||||||
|
|
||||||
sub = club.subscription || club.build_subscription
|
|
||||||
sub.update!(stripe_customer_id: session.customer) if session.customer.present?
|
|
||||||
|
|
||||||
if session.subscription.present?
|
|
||||||
stripe_sub = ::Stripe::Subscription.retrieve(session.subscription)
|
|
||||||
plan_slug = metadata_plan_slug(session) || "premium_light"
|
|
||||||
plan_slug = "premium_full" if plan_slug == "premium"
|
|
||||||
sync_subscription(stripe_sub, club: club, plan_slug: plan_slug)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sync_subscription(stripe_sub, club: nil, plan_slug: nil)
|
|
||||||
club ||= Club.joins(:subscription)
|
|
||||||
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
|
|
||||||
club ||= resolve_club(stripe_sub)
|
|
||||||
return unless club
|
|
||||||
|
|
||||||
plan_slug ||= metadata_plan_slug(stripe_sub) || "premium_light"
|
|
||||||
plan_slug = "premium_full" if plan_slug == "premium"
|
|
||||||
plan = Plan[plan_slug]
|
|
||||||
status = map_status(stripe_sub.status)
|
|
||||||
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
|
|
||||||
|
|
||||||
Billing::AssignPlan.call(
|
|
||||||
club: club,
|
|
||||||
plan_slug: plan.slug,
|
|
||||||
status: status,
|
|
||||||
stripe_attrs: {
|
|
||||||
stripe_customer_id: stripe_sub.customer,
|
|
||||||
stripe_subscription_id: stripe_sub.id,
|
|
||||||
current_period_start: period_start,
|
|
||||||
current_period_end: period_end,
|
|
||||||
cancel_at_period_end: stripe_sub.cancel_at_period_end
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def downgrade_to_free(stripe_sub)
|
def downgrade_to_free(stripe_sub)
|
||||||
club = Club.joins(:subscription)
|
club = Club.joins(:subscription)
|
||||||
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
|
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
|
||||||
@@ -99,52 +58,6 @@ module Billing
|
|||||||
club.subscription.update!(status: "past_due")
|
club.subscription.update!(status: "past_due")
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_club(obj)
|
|
||||||
club_id = metadata_club_id(obj)
|
|
||||||
return Club.find_by(id: club_id) if club_id.present?
|
|
||||||
|
|
||||||
team_id = metadata_team_id(obj)
|
|
||||||
return Team.find_by(id: team_id)&.club if team_id.present?
|
|
||||||
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def unix_time(value)
|
|
||||||
return nil if value.blank?
|
|
||||||
|
|
||||||
Time.zone.at(value.to_i)
|
|
||||||
end
|
|
||||||
|
|
||||||
def metadata_plan_slug(obj)
|
|
||||||
meta = obj.metadata
|
|
||||||
return nil if meta.blank?
|
|
||||||
|
|
||||||
meta["plan_slug"] || meta[:plan_slug] || (meta.respond_to?(:plan_slug) ? meta.plan_slug : nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
def metadata_club_id(obj)
|
|
||||||
meta = obj.metadata
|
|
||||||
return nil if meta.blank?
|
|
||||||
|
|
||||||
meta["club_id"] || meta[:club_id] || (meta.respond_to?(:club_id) ? meta.club_id : nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
def metadata_team_id(obj)
|
|
||||||
meta = obj.metadata
|
|
||||||
return nil if meta.blank?
|
|
||||||
|
|
||||||
meta["team_id"] || meta[:team_id] || (meta.respond_to?(:team_id) ? meta.team_id : nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
def map_status(stripe_status)
|
|
||||||
case stripe_status
|
|
||||||
when "trialing" then "trialing"
|
|
||||||
when "active" then "active"
|
|
||||||
when "past_due", "unpaid" then "past_due"
|
|
||||||
when "canceled", "incomplete_expired" then "canceled"
|
|
||||||
else "incomplete"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
45
backend/app/views/admin/billing_invoices/edit.html.erb
Normal file
45
backend/app/views/admin/billing_invoices/edit.html.erb
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<h1>Fattura <%= @invoice.number %> — <%= @club.name %></h1>
|
||||||
|
<p style="color:#666">
|
||||||
|
Stato: <strong><%= @invoice.status %></strong>
|
||||||
|
<% if @invoice.emailed_at.present? %>
|
||||||
|
· Inviata il <%= @invoice.emailed_at.in_time_zone.strftime("%d/%m/%Y %H:%M") %>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<%= form_with model: @invoice, url: admin_club_billing_invoice_path(@club, @invoice), multipart: true, method: :patch do |f| %>
|
||||||
|
<%= f.label :number, "Numero fattura" %>
|
||||||
|
<%= f.text_field :number, required: true, disabled: @invoice.status.in?(%w[sent]) %>
|
||||||
|
|
||||||
|
<%= f.label :issued_on, "Data emissione" %>
|
||||||
|
<%= f.date_field :issued_on, required: true, disabled: @invoice.status.in?(%w[sent]) %>
|
||||||
|
|
||||||
|
<%= label_tag :amount_euros, "Importo (€)" %>
|
||||||
|
<%= number_field_tag "billing_invoice[amount_euros]",
|
||||||
|
@invoice.amount_cents / 100.0,
|
||||||
|
step: 0.01, min: 0.01, required: true, disabled: @invoice.status.in?(%w[sent]) %>
|
||||||
|
|
||||||
|
<% if @payment %>
|
||||||
|
<p style="color:#888;font-size:0.9rem">Pagamento: <%= @payment.display_description %> — <%= @payment.formatted_amount %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= f.label :pdf, "PDF fattura" %>
|
||||||
|
<% if @invoice.pdf.attached? %>
|
||||||
|
<p style="color:#888">PDF già caricato: <%= @invoice.pdf.filename %></p>
|
||||||
|
<% end %>
|
||||||
|
<%= f.file_field :pdf, accept: "application/pdf" %>
|
||||||
|
|
||||||
|
<%= f.label :notes, "Note interne" %>
|
||||||
|
<%= f.text_area :notes, rows: 2 %>
|
||||||
|
|
||||||
|
<div style="margin-top:16px;display:flex;gap:10px;flex-wrap:wrap">
|
||||||
|
<% unless @invoice.status.in?(%w[sent]) %>
|
||||||
|
<%= f.submit "Salva bozza", class: "btn btn-secondary" %>
|
||||||
|
<%= f.submit "Emetti e invia via email", class: "btn btn-primary",
|
||||||
|
data: { turbo_confirm: "Inviare la fattura a #{@club.billing_email}?" } %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><%= link_to "← Pagamenti e fatture", admin_club_billing_invoices_path(@club) %></p>
|
||||||
@@ -1,10 +1,56 @@
|
|||||||
<h1>Fatture — <%= @club.name %></h1>
|
<h1>Fatturazione — <%= @club.name %></h1>
|
||||||
<p><%= link_to "← Dashboard", admin_root_path %> · <%= link_to "Nuova fattura", new_admin_club_billing_invoice_path(@club), class: "btn btn-primary" %></p>
|
<p>
|
||||||
|
<%= link_to "← Dashboard", admin_root_path %>
|
||||||
|
· <%= link_to "Nuova fattura (senza pagamento)", new_admin_club_billing_invoice_path(@club), class: "btn btn-secondary" %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 style="margin-top:24px;font-size:1.1rem">Pagamenti</h2>
|
||||||
|
<% if @payments.any? %>
|
||||||
|
<table class="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Data</th>
|
||||||
|
<th>Descrizione</th>
|
||||||
|
<th>Importo</th>
|
||||||
|
<th>Fattura</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @payments.each do |payment| %>
|
||||||
|
<% inv = payment.invoice %>
|
||||||
|
<tr>
|
||||||
|
<td><%= payment.paid_at&.to_date || payment.created_at.to_date %></td>
|
||||||
|
<td><%= payment.display_description %></td>
|
||||||
|
<td><%= payment.formatted_amount %></td>
|
||||||
|
<td>
|
||||||
|
<% if inv %>
|
||||||
|
<%= inv.number %> — <%= inv.status %>
|
||||||
|
<% if inv.pdf.attached? %> (PDF)<% end %>
|
||||||
|
<% else %>
|
||||||
|
—
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if inv %>
|
||||||
|
<%= link_to "Modifica / invia PDF", edit_admin_club_billing_invoice_path(@club, inv) %>
|
||||||
|
<% else %>
|
||||||
|
<%= link_to "Crea fattura", new_admin_club_billing_invoice_path(@club, billing_payment_id: payment.id), class: "btn btn-primary", style: "padding:6px 10px;font-size:0.85rem" %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% else %>
|
||||||
|
<p>Nessun pagamento registrato per questa società.</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h2 style="margin-top:32px;font-size:1.1rem">Tutte le fatture</h2>
|
||||||
<% if @invoices.any? %>
|
<% if @invoices.any? %>
|
||||||
<table class="data">
|
<table class="data">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Numero</th><th>Data</th><th>Importo</th><th>Stato</th><th>PDF</th></tr>
|
<tr><th>Numero</th><th>Data</th><th>Importo</th><th>Stato</th><th>Pagamento</th><th></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% @invoices.each do |inv| %>
|
<% @invoices.each do |inv| %>
|
||||||
@@ -13,7 +59,8 @@
|
|||||||
<td><%= inv.issued_on %></td>
|
<td><%= inv.issued_on %></td>
|
||||||
<td><%= inv.formatted_amount %></td>
|
<td><%= inv.formatted_amount %></td>
|
||||||
<td><%= inv.status %></td>
|
<td><%= inv.status %></td>
|
||||||
<td><%= inv.pdf.attached? ? "Sì" : "No" %></td>
|
<td><%= inv.billing_payment_id.present? ? "Sì" : "No" %></td>
|
||||||
|
<td><%= link_to "Modifica", edit_admin_club_billing_invoice_path(@club, inv) %></td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<h1>Nuova fattura Aruba — <%= @club.name %></h1>
|
<h1>Nuova fattura — <%= @club.name %></h1>
|
||||||
<p style="color:#666">Carica il PDF emesso da Aruba Fatture e collega il pagamento se disponibile.</p>
|
<% if @payment %>
|
||||||
|
<p style="color:#666">Collegata al pagamento del <%= @payment.paid_at&.to_date || @payment.created_at.to_date %> (<%= @payment.formatted_amount %>).</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<%= form_with model: @invoice, url: admin_club_billing_invoices_path(@club), multipart: true do |f| %>
|
<%= form_with model: @invoice, url: admin_club_billing_invoices_path(@club) do |f| %>
|
||||||
|
<%= f.hidden_field :billing_payment_id if @payment %>
|
||||||
|
|
||||||
<%= f.label :number, "Numero fattura" %>
|
<%= f.label :number, "Numero fattura" %>
|
||||||
<%= f.text_field :number, required: true %>
|
<%= f.text_field :number, required: true %>
|
||||||
|
|
||||||
@@ -10,27 +14,19 @@
|
|||||||
<%= f.date_field :issued_on, required: true %>
|
<%= f.date_field :issued_on, required: true %>
|
||||||
|
|
||||||
<%= label_tag :amount_euros, "Importo (€)" %>
|
<%= label_tag :amount_euros, "Importo (€)" %>
|
||||||
<%= number_field_tag "billing_invoice[amount_euros]", nil, step: 0.01, min: 0.01, required: true %>
|
<%= number_field_tag "billing_invoice[amount_euros]",
|
||||||
|
(@invoice.amount_cents ? @invoice.amount_cents / 100.0 : nil),
|
||||||
|
step: 0.01, min: 0.01, required: true %>
|
||||||
|
|
||||||
<%= f.label :status, "Stato" %>
|
<%= f.label :notes, "Note interne (opzionale)" %>
|
||||||
<%= f.select :status, Billing::Invoice::STATUSES %>
|
|
||||||
|
|
||||||
<%= f.label :billing_payment_id, "Pagamento collegato (opzionale)" %>
|
|
||||||
<%= f.collection_select :billing_payment_id, @club.billing_payments.recent, :id,
|
|
||||||
->(p) { "#{p.paid_at&.to_date || p.created_at.to_date} — #{p.formatted_amount}" },
|
|
||||||
{ include_blank: "Nessuno" } %>
|
|
||||||
|
|
||||||
<%= f.label :aruba_document_id, "ID documento Aruba (opzionale)" %>
|
|
||||||
<%= f.text_field :aruba_document_id %>
|
|
||||||
|
|
||||||
<%= f.label :pdf, "PDF fattura" %>
|
|
||||||
<%= f.file_field :pdf, accept: "application/pdf", required: true %>
|
|
||||||
|
|
||||||
<%= f.label :notes, "Note interne" %>
|
|
||||||
<%= f.text_area :notes, rows: 2 %>
|
<%= f.text_area :notes, rows: 2 %>
|
||||||
|
|
||||||
<%= f.submit "Registra fattura", class: "btn btn-primary" %>
|
<p style="color:#666;font-size:0.9rem;margin-top:12px">
|
||||||
|
Salva la bozza, poi carica il PDF e inviala al cliente dalla schermata successiva.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= f.submit "Crea bozza fattura", class: "btn btn-primary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p><%= link_to "← Elenco fatture", admin_club_billing_invoices_path(@club) %></p>
|
<p><%= link_to "← Pagamenti e fatture", admin_club_billing_invoices_path(@club) %></p>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<p>Buongiorno,</p>
|
||||||
|
|
||||||
|
<p>in allegato trovi la fattura <strong><%= @invoice.display_number %></strong> del <%= l(@invoice.issued_on, locale: :it, format: :long) %>
|
||||||
|
per un importo di <strong><%= @invoice.formatted_amount %></strong>, relativa alla società <strong><%= @club.billing_legal_name.presence || @club.name %></strong>.</p>
|
||||||
|
|
||||||
|
<p>Puoi scaricarla anche dalla sezione <strong>Abbonamento</strong> del tuo account Match Live TV.</p>
|
||||||
|
|
||||||
|
<p>Grazie,<br>Match Live TV</p>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Buongiorno,
|
||||||
|
|
||||||
|
in allegato la fattura <%= @invoice.display_number %> del <%= l(@invoice.issued_on, locale: :it, format: :long) %>
|
||||||
|
(<%= @invoice.formatted_amount %>) per <%= @club.billing_legal_name.presence || @club.name %>.
|
||||||
|
|
||||||
|
È disponibile anche in Abbonamento sul sito Match Live TV.
|
||||||
|
|
||||||
|
Grazie,
|
||||||
|
Match Live TV
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<link rel="stylesheet" href="/marketing.css?v=31">
|
<link rel="stylesheet" href="/marketing.css?v=32">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<%= render "shared/marketing_nav" %>
|
<%= render "shared/marketing_nav" %>
|
||||||
@@ -18,6 +18,6 @@
|
|||||||
<%= render "shared/marketing_footer" %>
|
<%= render "shared/marketing_footer" %>
|
||||||
<script src="/branding-form.js?v=1" defer></script>
|
<script src="/branding-form.js?v=1" defer></script>
|
||||||
<script src="/roster-form.js?v=1" defer></script>
|
<script src="/roster-form.js?v=1" defer></script>
|
||||||
<script src="/password-toggle.js?v=1" defer></script>
|
<script src="/password-toggle.js?v=2" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,8 +5,21 @@
|
|||||||
<h1>Dati di fatturazione</h1>
|
<h1>Dati di fatturazione</h1>
|
||||||
<p style="color:#aaa">Società: <strong><%= @club.name %></strong>. Inserisci i dati per l’intestazione delle fatture e l’invio tramite SDI o PEC.</p>
|
<p style="color:#aaa">Società: <strong><%= @club.name %></strong>. Inserisci i dati per l’intestazione delle fatture e l’invio tramite SDI o PEC.</p>
|
||||||
|
|
||||||
|
<% plan_return = params[:plan].presence_in(%w[premium_light premium_full]) %>
|
||||||
|
<% unless @club.billing_profile_complete? %>
|
||||||
|
<div class="flash alert" style="margin:16px 0">
|
||||||
|
<strong>Obbligatori per abbonarti a un piano premium.</strong>
|
||||||
|
<%= billing_profile_incomplete_message(@club) %>
|
||||||
|
<% if plan_return %>
|
||||||
|
Dopo il salvataggio potrai procedere al pagamento di <strong><%= Plan[plan_return].name %></strong>.
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<%= form_with url: public_club_billing_profile_path(@club), method: :patch do %>
|
<%= form_with url: public_club_billing_profile_path(@club), method: :patch do %>
|
||||||
|
<%= hidden_field_tag :plan, plan_return if plan_return %>
|
||||||
|
<%= hidden_field_tag :interval, params[:interval] if params[:interval].present? %>
|
||||||
<%= render "shared/billing_profile_fields", record: @club %>
|
<%= render "shared/billing_profile_fields", record: @club %>
|
||||||
<%= submit_tag "Salva dati di fatturazione", class: "btn btn-primary" %>
|
<%= submit_tag "Salva dati di fatturazione", class: "btn btn-primary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
|
|
||||||
<div class="wrap" style="padding-top:20px">
|
<div class="wrap" style="padding-top:20px">
|
||||||
<h1>Abbonamento</h1>
|
<h1>Abbonamento</h1>
|
||||||
<%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription %>
|
<%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription, on_billing_page: true %>
|
||||||
|
|
||||||
<%= render "shared/stripe_secure_payment" %>
|
<%= render "shared/stripe_secure_payment" %>
|
||||||
<%= render "shared/plan_cards", show_stripe_portal: true %>
|
<%= render "shared/plan_cards", show_stripe_portal: false %>
|
||||||
|
<%= render "shared/subscription_cancel", club: @club, subscription: @subscription %>
|
||||||
|
|
||||||
<%= render "shared/billing_documents", club: @club, payments: @payments, invoices: @invoices %>
|
<%= render "shared/billing_documents", club: @club, payments: @payments %>
|
||||||
|
|
||||||
<p style="margin-top:24px"><%= link_to "← Società", public_club_path(@club) %></p>
|
<p style="margin-top:24px"><%= link_to "← Società", public_club_path(@club) %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,9 +22,16 @@
|
|||||||
<%= label_tag :plan, "Piano iniziale (per tutta la società)" %>
|
<%= label_tag :plan, "Piano iniziale (per tutta la società)" %>
|
||||||
<%= select_tag :plan, options_for_select([
|
<%= select_tag :plan, options_for_select([
|
||||||
["Free — 1 responsabile trasmissione per squadra, 1 live", "free"],
|
["Free — 1 responsabile trasmissione per squadra, 1 live", "free"],
|
||||||
["Premium Light — €40/anno o €5/mese", "premium_light"],
|
["Premium Light", "premium_light"],
|
||||||
["Premium Full — €200/anno o €20/mese", "premium_full"]
|
["Premium Full", "premium_full"]
|
||||||
], params[:plan] || "free") %>
|
], params[:plan] || "free"), id: "club_plan_select" %>
|
||||||
|
<div id="club_plan_interval" style="margin-top:10px">
|
||||||
|
<%= label_tag :interval, "Fatturazione premium" %>
|
||||||
|
<%= select_tag :interval, options_for_select([
|
||||||
|
["Annuale — €40/anno (Light) o €200/anno (Full)", "yearly"],
|
||||||
|
["Mensile — €5/mese (Light) o €20/mese (Full)", "monthly"]
|
||||||
|
], params[:interval] || "yearly") %>
|
||||||
|
</div>
|
||||||
<%= render "shared/stripe_secure_payment", compact: true %>
|
<%= render "shared/stripe_secure_payment", compact: true %>
|
||||||
<%= submit_tag "Crea società", class: "btn btn-primary" %>
|
<%= submit_tag "Crea società", class: "btn btn-primary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<h2>Diretta terminata</h2>
|
<h2>Diretta terminata</h2>
|
||||||
<p>Lo streaming di questa partita è stato chiuso.</p>
|
<p>Lo streaming di questa partita è stato chiuso.</p>
|
||||||
<% if @session.ended_at %>
|
<% if @session.ended_at %>
|
||||||
<p class="stream-ended-meta">Chiusa il <%= l(@session.ended_at.in_time_zone, format: :long) %></p>
|
<p class="stream-ended-meta">Chiusa il <%= l_local(@session.ended_at) %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
|
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
|
||||||
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
|
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<td><strong><%= match.opponent_name %></strong></td>
|
<td><strong><%= match.opponent_name %></strong></td>
|
||||||
<td>
|
<td>
|
||||||
<% if match.scheduled_at %>
|
<% if match.scheduled_at %>
|
||||||
<%= l match.scheduled_at.in_time_zone, format: :long %>
|
<%= l_local(match.scheduled_at) %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span style="color:#888">Senza orario</span>
|
<span style="color:#888">Senza orario</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<p>Registrazione indicizzata. Il playback dipende dalla retention del piano Premium.</p>
|
<p>Registrazione indicizzata. Il playback dipende dalla retention del piano Premium.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if @recording.expires_at %>
|
<% if @recording.expires_at %>
|
||||||
<p class="stream-ended-meta" style="margin-top:12px">Disponibile fino al <%= l @recording.expires_at, format: :long %></p>
|
<p class="stream-ended-meta" style="margin-top:12px">Disponibile fino al <%= l_local(@recording.expires_at) %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
<%= render "shared/input_toggle",
|
<%= render "shared/input_toggle",
|
||||||
name: :password,
|
name: :password,
|
||||||
label: "Password",
|
label: "Password",
|
||||||
input_type: "text",
|
|
||||||
required: true,
|
required: true,
|
||||||
autocomplete: "current-password" %>
|
autocomplete: "current-password" %>
|
||||||
<p class="auth-forgot"><%= link_to "Password dimenticata?", public_password_forgot_path %></p>
|
<p class="auth-forgot"><%= link_to "Password dimenticata?", public_password_forgot_path %></p>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<% elsif plan.slug == "free" %>
|
<% elsif plan.slug == "free" %>
|
||||||
<p style="color:#888;font-size:0.85rem">Contatta il supporto per downgrade.</p>
|
<p style="color:#888;font-size:0.85rem">Contatta il supporto per downgrade.</p>
|
||||||
<% elsif MatchLiveTv.stripe_enabled? %>
|
<% elsif MatchLiveTv.stripe_enabled? %>
|
||||||
<%= link_to "Attiva #{plan.name}", public_club_checkout_path(@team.club, plan: plan.slug), class: "btn btn-primary" %>
|
<%= link_to "Gestisci abbonamento società", public_club_billing_path(@team.club), class: "btn btn-primary" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p style="color:#888">Stripe non configurato</p>
|
<p style="color:#888">Stripe non configurato</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -41,9 +41,7 @@
|
|||||||
<p style="color:#aaa">Esigenze diverse? <%= mail_to "info@matchlive.it", "Contattaci" %> per un'offerta su misura.</p>
|
<p style="color:#aaa">Esigenze diverse? <%= mail_to "info@matchlive.it", "Contattaci" %> per un'offerta su misura.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %>
|
<p style="margin-top:20px"><%= link_to "Pagamenti e fatture della società", public_club_billing_path(@team.club) %></p>
|
||||||
<p style="margin-top:20px"><%= button_to "Gestisci su Stripe", public_club_portal_path(@team.club), class: "btn btn-secondary" %></p>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<p><%= link_to "← Dettagli squadra", public_team_details_path(@team) %></p>
|
<p><%= link_to "← Dettagli squadra", public_team_details_path(@team) %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<%# locals: (club:, payments:, invoices:) %>
|
<%# locals: (club:, payments:) %>
|
||||||
<section class="billing-documents" style="margin-top:40px">
|
<section class="billing-documents" style="margin-top:40px">
|
||||||
<h2>Pagamenti e fatture</h2>
|
<h2>Pagamenti e fatture</h2>
|
||||||
<p style="color:#888;font-size:0.9rem">
|
<p style="color:#888;font-size:0.9rem">
|
||||||
Qui trovi lo storico dei pagamenti e le fatture emesse alla società.
|
Storico dei pagamenti della società e relative fatture emesse da Match Live TV.
|
||||||
I pagamenti premium sono elaborati in modo sicuro tramite Stripe.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<% unless club.billing_profile_complete? %>
|
<% unless club.billing_profile_complete? %>
|
||||||
<div class="flash alert" style="margin:16px 0">
|
<div class="flash alert" style="margin:16px 0">
|
||||||
<strong>Dati di fatturazione incompleti.</strong>
|
<strong>Dati di fatturazione incompleti.</strong>
|
||||||
Compila ragione sociale, indirizzo, P.IVA/CF e SDI o PEC per ricevere le fatture.
|
Compila ragione sociale, indirizzo, P.IVA/CF e SDI o PEC per ricevere le fatture via email.
|
||||||
<%= link_to "Completa ora", public_club_billing_profile_path(club), class: "btn btn-secondary", style: "margin-top:10px;display:inline-block" %>
|
<%= link_to "Completa ora", public_club_billing_profile_path(club), class: "btn btn-secondary", style: "margin-top:10px;display:inline-block" %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -19,14 +18,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if MatchLiveTv.stripe_enabled? && club.subscription&.stripe_customer_id.present? %>
|
<h3 style="margin-top:28px;font-size:1.1rem">Pagamenti e fatture</h3>
|
||||||
<%= button_to "Sincronizza pagamenti da Stripe",
|
|
||||||
public_club_billing_sync_payments_path(club),
|
|
||||||
class: "btn btn-secondary",
|
|
||||||
style: "margin:12px 0" %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<h3 style="margin-top:28px;font-size:1.1rem">Pagamenti effettuati</h3>
|
|
||||||
<% if payments.any? %>
|
<% if payments.any? %>
|
||||||
<table class="data billing-table">
|
<table class="data billing-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -34,54 +26,34 @@
|
|||||||
<th>Data</th>
|
<th>Data</th>
|
||||||
<th>Descrizione</th>
|
<th>Descrizione</th>
|
||||||
<th>Importo</th>
|
<th>Importo</th>
|
||||||
<th>Stato</th>
|
<th>Stato pagamento</th>
|
||||||
|
<th>Fattura</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% payments.each do |payment| %>
|
<% payments.each do |payment| %>
|
||||||
|
<% inv = payment.invoice_for_display %>
|
||||||
<tr>
|
<tr>
|
||||||
<td><%= payment.paid_at ? l(payment.paid_at, format: :short) : "—" %></td>
|
<td><%= payment.paid_at ? l_local(payment.paid_at, format: :short) : "—" %></td>
|
||||||
<td><%= payment.description.presence || payment.plan_slug&.humanize || "Abbonamento" %></td>
|
<td><%= payment.display_description %></td>
|
||||||
<td><%= payment.formatted_amount %></td>
|
<td><%= payment.formatted_amount %></td>
|
||||||
<td><%= payment.status %></td>
|
<td><%= payment.display_status %></td>
|
||||||
<td>
|
<td>
|
||||||
<% if payment.receipt_url.present? %>
|
<% if inv.present? %>
|
||||||
<%= link_to "Ricevuta Stripe", payment.receipt_url, target: "_blank", rel: "noopener" %>
|
<%= inv.display_number %>
|
||||||
<% end %>
|
· <%= inv.display_status %>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<% else %>
|
|
||||||
<p style="color:#888">Nessun pagamento registrato. Dopo un abbonamento premium comparirà qui (anche tramite «Sincronizza pagamenti»).</p>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<h3 style="margin-top:28px;font-size:1.1rem">Fatture</h3>
|
|
||||||
<% if invoices.any? %>
|
|
||||||
<table class="data billing-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Numero</th>
|
|
||||||
<th>Data</th>
|
|
||||||
<th>Importo</th>
|
|
||||||
<th>Stato</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<% invoices.each do |invoice| %>
|
|
||||||
<tr>
|
|
||||||
<td><%= invoice.number %></td>
|
|
||||||
<td><%= l(invoice.issued_on) %></td>
|
|
||||||
<td><%= invoice.formatted_amount %></td>
|
|
||||||
<td><%= invoice.status %></td>
|
|
||||||
<td>
|
|
||||||
<% if invoice.downloadable? %>
|
|
||||||
<%= link_to "Scarica PDF", public_club_billing_invoice_path(club, invoice) %>
|
|
||||||
<% else %>
|
<% else %>
|
||||||
|
—
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if inv&.downloadable? %>
|
||||||
|
<%= link_to "Scarica PDF", public_club_billing_invoice_path(club, inv) %>
|
||||||
|
<% elsif inv&.draft? %>
|
||||||
<span style="color:#888">PDF in preparazione</span>
|
<span style="color:#888">PDF in preparazione</span>
|
||||||
|
<% else %>
|
||||||
|
<span style="color:#888">—</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -89,6 +61,6 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p style="color:#888">Nessuna fattura disponibile. Compariranno qui dopo l’emissione.</p>
|
<p style="color:#888">Nessun pagamento registrato. Dopo un abbonamento premium comparirà qui.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,64 +2,65 @@
|
|||||||
<% if show_legend %>
|
<% if show_legend %>
|
||||||
<h2 class="form-section-title" style="margin-top:24px">Dati di fatturazione</h2>
|
<h2 class="form-section-title" style="margin-top:24px">Dati di fatturazione</h2>
|
||||||
<p style="color:#888;font-size:0.88rem;margin:-8px 0 16px">
|
<p style="color:#888;font-size:0.88rem;margin:-8px 0 16px">
|
||||||
Obbligatori per ricevere le fatture elettroniche (P.IVA/CF, indirizzo, SDI o PEC).
|
Tutti i campi contrassegnati sono obbligatori per abbonarti a un piano premium e per emettere le fatture.
|
||||||
|
Per le società serve la P.IVA; per le persone fisiche il Codice Fiscale. Serve SDI <em>oppure</em> PEC.
|
||||||
I pagamenti restano gestiti in modo sicuro da Stripe.
|
I pagamenti restano gestiti in modo sicuro da Stripe.
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= label_tag "club[billing_entity_type]", "Tipo intestatario" %>
|
<%= label_tag "club[billing_entity_type]", "Tipo intestatario *" %>
|
||||||
<%= select_tag "club[billing_entity_type]",
|
<%= select_tag "club[billing_entity_type]",
|
||||||
options_for_select(Club::BILLING_ENTITY_TYPES.map { |k, v| [v, k] }, record.billing_entity_type),
|
options_for_select(Club::BILLING_ENTITY_TYPES.map { |k, v| [v, k] }, record.billing_entity_type),
|
||||||
include_blank: false %>
|
include_blank: false %>
|
||||||
|
|
||||||
<%= label_tag "club[billing_legal_name]", "Ragione sociale / nome e cognome" %>
|
<%= label_tag "club[billing_legal_name]", "Ragione sociale / nome e cognome *" %>
|
||||||
<%= text_field_tag "club[billing_legal_name]", record.billing_legal_name, placeholder: "es. ASD Tigers Volley" %>
|
<%= text_field_tag "club[billing_legal_name]", record.billing_legal_name, placeholder: "es. ASD Tigers Volley" %>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag "club[billing_vat_number]", "Partita IVA" %>
|
<%= label_tag "club[billing_vat_number]", "Partita IVA * (società)" %>
|
||||||
<%= text_field_tag "club[billing_vat_number]", record.billing_vat_number, placeholder: "12345678901" %>
|
<%= text_field_tag "club[billing_vat_number]", record.billing_vat_number, placeholder: "12345678901" %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag "club[billing_fiscal_code]", "Codice Fiscale" %>
|
<%= label_tag "club[billing_fiscal_code]", "Codice Fiscale * (persona fisica)" %>
|
||||||
<%= text_field_tag "club[billing_fiscal_code]", record.billing_fiscal_code, placeholder: "RSSMRA80A01H501U" %>
|
<%= text_field_tag "club[billing_fiscal_code]", record.billing_fiscal_code, placeholder: "RSSMRA80A01H501U" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= label_tag "club[billing_email]", "Email fatturazione" %>
|
<%= label_tag "club[billing_email]", "Email fatturazione *" %>
|
||||||
<%= email_field_tag "club[billing_email]", record.billing_email, placeholder: "amministrazione@societa.it" %>
|
<%= email_field_tag "club[billing_email]", record.billing_email, placeholder: "amministrazione@societa.it" %>
|
||||||
|
|
||||||
<%= label_tag "club[billing_phone]", "Telefono (opzionale)" %>
|
<%= label_tag "club[billing_phone]", "Telefono (opzionale)" %>
|
||||||
<%= text_field_tag "club[billing_phone]", record.billing_phone %>
|
<%= text_field_tag "club[billing_phone]", record.billing_phone %>
|
||||||
|
|
||||||
<%= label_tag "club[billing_address_line]", "Indirizzo" %>
|
<%= label_tag "club[billing_address_line]", "Indirizzo *" %>
|
||||||
<%= text_field_tag "club[billing_address_line]", record.billing_address_line, placeholder: "Via Roma 1" %>
|
<%= text_field_tag "club[billing_address_line]", record.billing_address_line, placeholder: "Via Roma 1" %>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:12px">
|
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:12px">
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag "club[billing_city]", "Città" %>
|
<%= label_tag "club[billing_city]", "Città *" %>
|
||||||
<%= text_field_tag "club[billing_city]", record.billing_city %>
|
<%= text_field_tag "club[billing_city]", record.billing_city %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag "club[billing_province]", "Prov." %>
|
<%= label_tag "club[billing_province]", "Prov. *" %>
|
||||||
<%= text_field_tag "club[billing_province]", record.billing_province, maxlength: 2, placeholder: "MI" %>
|
<%= text_field_tag "club[billing_province]", record.billing_province, maxlength: 2, placeholder: "MI" %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag "club[billing_postal_code]", "CAP" %>
|
<%= label_tag "club[billing_postal_code]", "CAP *" %>
|
||||||
<%= text_field_tag "club[billing_postal_code]", record.billing_postal_code %>
|
<%= text_field_tag "club[billing_postal_code]", record.billing_postal_code %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= label_tag "club[billing_country]", "Paese (ISO)" %>
|
<%= label_tag "club[billing_country]", "Paese (ISO) *" %>
|
||||||
<%= text_field_tag "club[billing_country]", record.billing_country.presence || "IT", maxlength: 2 %>
|
<%= text_field_tag "club[billing_country]", record.billing_country.presence || "IT", maxlength: 2 %>
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag "club[billing_recipient_code]", "Codice destinatario SDI (7 caratteri)" %>
|
<%= label_tag "club[billing_recipient_code]", "Codice destinatario SDI (7 car.) *" %>
|
||||||
<%= text_field_tag "club[billing_recipient_code]", record.billing_recipient_code, maxlength: 7, placeholder: "XXXXXXX" %>
|
<%= text_field_tag "club[billing_recipient_code]", record.billing_recipient_code, maxlength: 7, placeholder: "XXXXXXX" %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<%= label_tag "club[billing_pec]", "PEC (alternativa a SDI)" %>
|
<%= label_tag "club[billing_pec]", "PEC (alternativa a SDI) *" %>
|
||||||
<%= email_field_tag "club[billing_pec]", record.billing_pec, placeholder: "pec@societa.it" %>
|
<%= email_field_tag "club[billing_pec]", record.billing_pec, placeholder: "pec@societa.it" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
<%# locals: (club:, entitlements: nil, subscription: nil) %>
|
<%# locals: (club:, entitlements: nil, subscription: nil, on_billing_page: false) %>
|
||||||
<% current_plan = entitlements&.plan || subscription&.plan || Plan["free"] %>
|
<% current_plan = entitlements&.plan || subscription&.plan || Plan["free"] %>
|
||||||
<p style="color:#aaa;margin-bottom:8px">
|
<p style="color:#aaa;margin-bottom:8px">
|
||||||
Società: <strong><%= club.name %></strong>
|
Società: <strong><%= club.name %></strong>
|
||||||
· Piano attuale: <strong><%= current_plan.name %></strong>
|
· Piano attuale: <strong><%= current_plan.name %></strong>
|
||||||
(valido per tutte le squadre)
|
(valido per tutte le squadre)
|
||||||
· <%= link_to "Gestisci abbonamento", public_club_billing_path(club) %>
|
<% unless local_assigns[:on_billing_page] %>
|
||||||
|
· <%= link_to "Gestisci abbonamento", public_club_billing_path(club) %>
|
||||||
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
<% if subscription&.stripe_subscription_id.present? %>
|
<% if subscription&.stripe_subscription_id.present? %>
|
||||||
<p style="color:#888;font-size:0.9rem;margin-top:0">
|
<p style="color:#888;font-size:0.9rem;margin-top:0">
|
||||||
Stato Stripe: <strong><%= subscription.status %></strong>
|
|
||||||
<% if subscription.current_period_end.present? %>
|
|
||||||
· Rinnovo: <strong><%= l(subscription.current_period_end, format: :long) %></strong>
|
|
||||||
<% end %>
|
|
||||||
<% if subscription.cancel_at_period_end? %>
|
<% if subscription.cancel_at_period_end? %>
|
||||||
· <span style="color:#e53935">Disdetta alla scadenza</span>
|
<span style="color:#e53935">
|
||||||
|
Abbonamento valido fino al
|
||||||
|
<strong><%= subscription.current_period_end.present? ? l_local(subscription.current_period_end.to_date) : "fine periodo" %></strong>,
|
||||||
|
poi piano Free.
|
||||||
|
</span>
|
||||||
|
<% elsif subscription.current_period_end.present? %>
|
||||||
|
Prossimo rinnovo: <strong><%= l_local(subscription.current_period_end) %></strong>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,22 +1,40 @@
|
|||||||
<%# locals: (name:, label:, value: nil, input_type: "text", required: false, autocomplete: nil, minlength: nil, id: nil) %>
|
<%# locals: (name:, label:, value: nil, input_type: "text", required: false, autocomplete: nil, minlength: nil, id: nil, masked: nil) %>
|
||||||
<% field_id = local_assigns.fetch(:id, nil).presence || name.to_s %>
|
<% field_id = local_assigns.fetch(:id, nil).presence || name.to_s %>
|
||||||
<% visible_type = local_assigns.fetch(:input_type, "text").to_s == "email" ? "email" : "text" %>
|
<% visible_type = local_assigns.fetch(:input_type, "text").to_s %>
|
||||||
<div class="input-toggle" data-input-toggle>
|
<% is_email = visible_type == "email" %>
|
||||||
|
<% masked = local_assigns.key?(:masked) ? local_assigns[:masked] : !is_email %>
|
||||||
|
|
||||||
|
<div class="input-toggle<%= " input-toggle--plain" unless masked %>"<%= " data-input-toggle" if masked %>>
|
||||||
<%= label_tag field_id, label %>
|
<%= label_tag field_id, label %>
|
||||||
<div class="input-toggle__field">
|
<div class="input-toggle__field">
|
||||||
<%= tag.input(
|
<% if masked %>
|
||||||
type: "password",
|
<%= tag.input(
|
||||||
name: name,
|
type: "password",
|
||||||
id: field_id,
|
name: name,
|
||||||
value: local_assigns.fetch(:value, nil),
|
id: field_id,
|
||||||
required: local_assigns.fetch(:required, false),
|
value: local_assigns.fetch(:value, nil),
|
||||||
autocomplete: local_assigns.fetch(:autocomplete, nil),
|
required: local_assigns.fetch(:required, false),
|
||||||
minlength: local_assigns.fetch(:minlength, nil),
|
autocomplete: local_assigns.fetch(:autocomplete, nil),
|
||||||
class: "input-toggle__input",
|
minlength: local_assigns.fetch(:minlength, nil),
|
||||||
data: { visible_type: visible_type }
|
class: "input-toggle__input",
|
||||||
) %>
|
data: { visible_type: "text" }
|
||||||
<button type="button" class="input-toggle__btn" aria-label="Mostra <%= label.downcase %>" data-label-hide="Nascondi <%= label.downcase %>">
|
) %>
|
||||||
<i class="fa-regular fa-eye" aria-hidden="true"></i>
|
<button type="button" class="input-toggle__btn" aria-label="Mostra <%= label.downcase %>" data-label-hide="Nascondi <%= label.downcase %>">
|
||||||
</button>
|
<i class="fa-regular fa-eye" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<% elsif is_email %>
|
||||||
|
<%= email_field_tag name, local_assigns.fetch(:value, nil),
|
||||||
|
id: field_id,
|
||||||
|
required: local_assigns.fetch(:required, false),
|
||||||
|
autocomplete: local_assigns.fetch(:autocomplete, nil),
|
||||||
|
class: "input-toggle__input input-toggle__input--plain" %>
|
||||||
|
<% else %>
|
||||||
|
<%= text_field_tag name, local_assigns.fetch(:value, nil),
|
||||||
|
id: field_id,
|
||||||
|
required: local_assigns.fetch(:required, false),
|
||||||
|
autocomplete: local_assigns.fetch(:autocomplete, nil),
|
||||||
|
minlength: local_assigns.fetch(:minlength, nil),
|
||||||
|
class: "input-toggle__input input-toggle__input--plain" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<% entitlements ||= @entitlements %>
|
<% entitlements ||= @entitlements %>
|
||||||
<% subscription ||= @subscription %>
|
<% subscription ||= @subscription %>
|
||||||
<% current_slug = local_assigns.fetch(:current_plan_slug, nil).presence || entitlements&.plan&.slug || subscription&.plan&.slug || "free" %>
|
<% current_slug = local_assigns.fetch(:current_plan_slug, nil).presence || entitlements&.plan&.slug || subscription&.plan&.slug || "free" %>
|
||||||
|
<% current_interval = subscription&.billing_interval %>
|
||||||
<% billing_mode = club.present? %>
|
<% billing_mode = club.present? %>
|
||||||
|
|
||||||
<div class="plans">
|
<div class="plans">
|
||||||
@@ -10,18 +11,23 @@
|
|||||||
<% action = billing_mode ? plan_billing_action(
|
<% action = billing_mode ? plan_billing_action(
|
||||||
current_slug: current_slug,
|
current_slug: current_slug,
|
||||||
target_plan: plan,
|
target_plan: plan,
|
||||||
stripe_subscription_active: stripe_subscription_active?(subscription)
|
stripe_subscription_active: stripe_subscription_active?(subscription),
|
||||||
|
current_interval: current_interval
|
||||||
) : nil %>
|
) : nil %>
|
||||||
<div class="plan-card <%= "featured" if plan.slug == current_slug || (!billing_mode && plan.slug == "premium_light") %>">
|
<div class="plan-card <%= "featured" if plan.slug == current_slug || (!billing_mode && plan.slug == "premium_light") %>">
|
||||||
<h3><%= plan.name %></h3>
|
<h3><%= plan.name %></h3>
|
||||||
<% if plan.slug == "free" %>
|
<% if plan.slug == "free" %>
|
||||||
<div class="plan-price">€0</div>
|
<div class="plan-price">€0</div>
|
||||||
<% elsif plan.slug == "premium_light" %>
|
<% elsif plan.slug == "premium_light" %>
|
||||||
<div class="plan-price" style="color:#e53935">€40<span style="font-size:1rem;font-weight:400">/anno</span></div>
|
<% light_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
||||||
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€5</strong>/mese</p>
|
<% light_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
||||||
|
<div class="plan-price" style="color:#e53935"><%= light_yearly %></div>
|
||||||
|
<p class="plan-price-alt">oppure <strong><%= light_monthly %></strong></p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="plan-price">€200<span style="font-size:1rem;font-weight:400">/anno</span></div>
|
<% full_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
||||||
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€20</strong>/mese</p>
|
<% full_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
||||||
|
<div class="plan-price"><%= full_yearly %></div>
|
||||||
|
<p class="plan-price-alt">oppure <strong><%= full_monthly %></strong></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Responsabili trasmissione: <strong><%= plan.max_staff_transmission || "illimitato" %></strong> / anno per squadra</li>
|
<li>Responsabili trasmissione: <strong><%= plan.max_staff_transmission || "illimitato" %></strong> / anno per squadra</li>
|
||||||
@@ -30,26 +36,45 @@
|
|||||||
<li>YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %></li>
|
<li>YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %></li>
|
||||||
</ul>
|
</ul>
|
||||||
<% if billing_mode %>
|
<% if billing_mode %>
|
||||||
<% case action[:kind] %>
|
<% action_kind = action[:kind] %>
|
||||||
<% when :current %>
|
<% if action_kind == :current %>
|
||||||
<span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span>
|
<span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span>
|
||||||
<% when :contact, :disabled %>
|
<% elsif action_kind == :contact || action_kind == :disabled %>
|
||||||
<p style="color:#888;font-size:0.85rem"><%= action[:label] %></p>
|
<p class="plan-action-hint"><%= action[:label] %></p>
|
||||||
<% when :checkout, :change %>
|
<% elsif action_kind == :checkout_options || action_kind == :change_options || action_kind == :interval_switch %>
|
||||||
<%= link_to action[:label], public_club_checkout_path(club, plan: plan.slug), class: "btn btn-primary" %>
|
<% if billing_profile_blocks_premium?(club) %>
|
||||||
|
<p class="plan-action-hint" style="color:#e53935;margin-bottom:10px">
|
||||||
|
<%= billing_profile_incomplete_message(club) %>
|
||||||
|
</p>
|
||||||
|
<%= link_to "Completa dati di fatturazione",
|
||||||
|
public_club_billing_profile_path(club, plan: plan.slug),
|
||||||
|
class: "btn btn-primary" %>
|
||||||
|
<% else %>
|
||||||
|
<div class="plan-interval-actions">
|
||||||
|
<% plan_intervals_for_display(action[:intervals]).each do |interval| %>
|
||||||
|
<% btn_kind = action_kind == :checkout_options ? :checkout : :change %>
|
||||||
|
<%= link_to plan_interval_button_label(plan, interval, kind: btn_kind),
|
||||||
|
plan_interval_checkout_path(club, plan, interval),
|
||||||
|
class: plan_interval_button_class(interval) %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if action_kind == :interval_switch && action[:current_interval].present? %>
|
||||||
|
<p class="plan-action-hint">Fatturazione attuale: <%= Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: action[:current_interval]) %></p>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% elsif plan.slug == "free" %>
|
<% elsif plan.slug == "free" %>
|
||||||
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %>
|
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to "Registrati", public_signup_path(plan: plan.slug), class: "btn btn-primary" %>
|
<div class="plan-interval-actions">
|
||||||
|
<% plan_intervals_for_display(Billing::Stripe::PriceCatalog.available_intervals(plan_slug: plan.slug)).each do |interval| %>
|
||||||
|
<%= link_to "Registrati — #{Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)}",
|
||||||
|
public_signup_path(plan: plan.slug, interval: interval),
|
||||||
|
class: plan_interval_button_class(interval) %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if show_stripe_portal && billing_mode && subscription&.premium? && MatchLiveTv.stripe_enabled? && subscription&.stripe_customer_id.present? %>
|
|
||||||
<p style="margin-top:20px">
|
|
||||||
<%= button_to "Gestisci fatturazione su Stripe", public_club_portal_path(club), class: "btn btn-secondary" %>
|
|
||||||
<span style="color:#888;font-size:0.85rem;display:block;margin-top:8px">Cambia metodo di pagamento, scarica fatture o disdici l'abbonamento.</span>
|
|
||||||
</p>
|
|
||||||
<% end %>
|
|
||||||
|
|||||||
36
backend/app/views/shared/_subscription_cancel.html.erb
Normal file
36
backend/app/views/shared/_subscription_cancel.html.erb
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<%# locals: (club:, subscription:) %>
|
||||||
|
<% return unless subscription&.premium? && subscription.stripe_subscription_id.present? && MatchLiveTv.stripe_enabled? %>
|
||||||
|
|
||||||
|
<section class="subscription-cancel" style="margin-top:24px;padding:16px;border:1px solid #333;border-radius:8px">
|
||||||
|
<% if subscription.cancel_at_period_end? %>
|
||||||
|
<p style="color:#e53935;margin:0 0 8px">
|
||||||
|
<strong>Disdetta programmata.</strong>
|
||||||
|
Il piano <strong><%= subscription.plan.name %></strong> resta attivo fino al
|
||||||
|
<% if subscription.current_period_end.present? %>
|
||||||
|
<strong><%= l_local(subscription.current_period_end.to_date) %></strong>.
|
||||||
|
<% else %>
|
||||||
|
<strong>fine del periodo già pagato</strong>.
|
||||||
|
<% end %>
|
||||||
|
Da quel giorno la società passerà automaticamente al piano <strong>Free</strong>.
|
||||||
|
</p>
|
||||||
|
<p style="color:#888;font-size:0.88rem;margin:0">
|
||||||
|
Per ripristinare l'abbonamento prima della scadenza, contatta il supporto Match Live TV.
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<p style="color:#aaa;margin:0 0 12px;font-size:0.9rem">
|
||||||
|
Puoi disdire in qualsiasi momento: l'abbonamento resta valido fino alla fine del periodo già pagato,
|
||||||
|
poi passi al piano Free senza ulteriori addebiti.
|
||||||
|
</p>
|
||||||
|
<%= button_to "Annulla abbonamento",
|
||||||
|
public_club_billing_cancel_subscription_path(club),
|
||||||
|
method: :post,
|
||||||
|
class: "btn btn-outline",
|
||||||
|
form: {
|
||||||
|
data: {
|
||||||
|
turbo_confirm: subscription.current_period_end.present? ?
|
||||||
|
"Confermi la disdetta? Il piano #{subscription.plan.name} resterà attivo fino al #{l_local(subscription.current_period_end)}, poi passerai al Free." :
|
||||||
|
"Confermi la disdetta dell'abbonamento?"
|
||||||
|
}
|
||||||
|
} %>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
@@ -25,6 +25,9 @@ module App
|
|||||||
config.api_only = false
|
config.api_only = false
|
||||||
config.active_job.queue_adapter = :sidekiq
|
config.active_job.queue_adapter = :sidekiq
|
||||||
config.time_zone = "Europe/Rome"
|
config.time_zone = "Europe/Rome"
|
||||||
|
config.i18n.default_locale = :it
|
||||||
|
config.i18n.available_locales = %i[it en]
|
||||||
|
config.i18n.fallbacks = { it: %i[it en], en: %i[en it] }
|
||||||
config.generators { |g| g.orm :active_record, primary_key_type: :uuid }
|
config.generators { |g| g.orm :active_record, primary_key_type: :uuid }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
5
backend/config/initializers/i18n.rb
Normal file
5
backend/config/initializers/i18n.rb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Sito in italiano: locale fissa per ogni richiesta web (evita date tipo "July 02, 2026").
|
||||||
|
Rails.application.config.after_initialize do
|
||||||
|
I18n.available_locales = %i[it en]
|
||||||
|
I18n.default_locale = :it
|
||||||
|
end
|
||||||
@@ -40,12 +40,33 @@ module MatchLiveTv
|
|||||||
ENV["STRIPE_WEBHOOK_SECRET"].presence
|
ENV["STRIPE_WEBHOOK_SECRET"].presence
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def stripe_premium_light_yearly_price_id
|
||||||
|
ENV["STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID"].presence ||
|
||||||
|
ENV["STRIPE_PREMIUM_LIGHT_PRICE_ID"].presence ||
|
||||||
|
ENV["STRIPE_PREMIUM_PRICE_ID"].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def stripe_premium_light_monthly_price_id
|
||||||
|
ENV["STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID"].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def stripe_premium_full_yearly_price_id
|
||||||
|
ENV["STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID"].presence ||
|
||||||
|
ENV["STRIPE_PREMIUM_FULL_PRICE_ID"].presence ||
|
||||||
|
ENV["STRIPE_PREMIUM_PRICE_ID"].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def stripe_premium_full_monthly_price_id
|
||||||
|
ENV["STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID"].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
# Retrocompatibilità
|
||||||
def stripe_premium_light_price_id
|
def stripe_premium_light_price_id
|
||||||
ENV.fetch("STRIPE_PREMIUM_LIGHT_PRICE_ID", ENV.fetch("STRIPE_PREMIUM_PRICE_ID", ""))
|
stripe_premium_light_yearly_price_id.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def stripe_premium_full_price_id
|
def stripe_premium_full_price_id
|
||||||
ENV.fetch("STRIPE_PREMIUM_FULL_PRICE_ID", ENV.fetch("STRIPE_PREMIUM_PRICE_ID", ""))
|
stripe_premium_full_yearly_price_id.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def stripe_enabled?
|
def stripe_enabled?
|
||||||
|
|||||||
60
backend/config/locales/it.yml
Normal file
60
backend/config/locales/it.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
it:
|
||||||
|
date:
|
||||||
|
formats:
|
||||||
|
default: "%d/%m/%Y"
|
||||||
|
short: "%d %b %Y"
|
||||||
|
long: "%d %B %Y"
|
||||||
|
day_names: [domenica, lunedì, martedì, mercoledì, giovedì, venerdì, sabato]
|
||||||
|
abbr_day_names: [dom, lun, mar, mer, gio, ven, sab]
|
||||||
|
month_names: [~, gennaio, febbraio, marzo, aprile, maggio, giugno, luglio, agosto, settembre, ottobre, novembre, dicembre]
|
||||||
|
abbr_month_names: [~, gen, feb, mar, apr, mag, giu, lug, ago, set, ott, nov, dic]
|
||||||
|
order:
|
||||||
|
- :day
|
||||||
|
- :month
|
||||||
|
- :year
|
||||||
|
time:
|
||||||
|
formats:
|
||||||
|
default: "%d/%m/%Y %H:%M"
|
||||||
|
short: "%d %b %Y %H:%M"
|
||||||
|
long: "%d %B %Y alle %H:%M"
|
||||||
|
am: ""
|
||||||
|
pm: ""
|
||||||
|
datetime:
|
||||||
|
distance_in_words:
|
||||||
|
about_x_hours:
|
||||||
|
one: circa un'ora
|
||||||
|
other: circa %{count} ore
|
||||||
|
about_x_months:
|
||||||
|
one: circa un mese
|
||||||
|
other: circa %{count} mesi
|
||||||
|
about_x_years:
|
||||||
|
one: circa un anno
|
||||||
|
other: circa %{count} anni
|
||||||
|
almost_x_years:
|
||||||
|
one: quasi un anno
|
||||||
|
other: quasi %{count} anni
|
||||||
|
half_a_minute: mezzo minuto
|
||||||
|
less_than_x_minutes:
|
||||||
|
one: meno di un minuto
|
||||||
|
other: meno di %{count} minuti
|
||||||
|
less_than_x_seconds:
|
||||||
|
one: meno di un secondo
|
||||||
|
other: meno di %{count} secondi
|
||||||
|
over_x_years:
|
||||||
|
one: oltre un anno
|
||||||
|
other: oltre %{count} anni
|
||||||
|
x_days:
|
||||||
|
one: 1 giorno
|
||||||
|
other: "%{count} giorni"
|
||||||
|
x_minutes:
|
||||||
|
one: 1 minuto
|
||||||
|
other: "%{count} minuti"
|
||||||
|
x_months:
|
||||||
|
one: 1 mese
|
||||||
|
other: "%{count} mesi"
|
||||||
|
x_seconds:
|
||||||
|
one: 1 secondo
|
||||||
|
other: "%{count} secondi"
|
||||||
|
x_years:
|
||||||
|
one: 1 anno
|
||||||
|
other: "%{count} anni"
|
||||||
@@ -64,7 +64,7 @@ Rails.application.routes.draw do
|
|||||||
get "metrics", to: "dashboard#metrics"
|
get "metrics", to: "dashboard#metrics"
|
||||||
resources :teams, only: %i[index show]
|
resources :teams, only: %i[index show]
|
||||||
resources :clubs, only: [] do
|
resources :clubs, only: [] do
|
||||||
resources :billing_invoices, only: %i[index new create], controller: "billing_invoices"
|
resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices"
|
||||||
end
|
end
|
||||||
resources :sessions, only: %i[index show] do
|
resources :sessions, only: %i[index show] do
|
||||||
member { post :stop }
|
member { post :stop }
|
||||||
@@ -112,7 +112,7 @@ Rails.application.routes.draw do
|
|||||||
get "clubs/:id/billing", to: "clubs#billing", as: :club_billing
|
get "clubs/:id/billing", to: "clubs#billing", as: :club_billing
|
||||||
get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile
|
get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile
|
||||||
patch "clubs/:id/billing/profile", to: "club_billing#update_profile"
|
patch "clubs/:id/billing/profile", to: "club_billing#update_profile"
|
||||||
post "clubs/:id/billing/sync_payments", to: "club_billing#sync_payments", as: :club_billing_sync_payments
|
post "clubs/:id/billing/cancel_subscription", to: "club_billing#cancel_subscription", as: :club_billing_cancel_subscription
|
||||||
get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice
|
get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice
|
||||||
get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout
|
get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout
|
||||||
post "clubs/:id/portal", to: "clubs#portal", as: :club_portal
|
post "clubs/:id/portal", to: "clubs#portal", as: :club_portal
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddBillingIntervalToSubscriptions < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :subscriptions, :billing_interval, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
class BillingInvoicesManualFlow < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
change_column_default :billing_invoices, :source, from: "aruba", to: "manual"
|
||||||
|
change_column_default :billing_invoices, :status, from: "issued", to: "draft"
|
||||||
|
|
||||||
|
add_column :billing_invoices, :emailed_at, :datetime
|
||||||
|
add_index :billing_invoices, :billing_payment_id,
|
||||||
|
unique: true,
|
||||||
|
where: "billing_payment_id IS NOT NULL",
|
||||||
|
name: "index_billing_invoices_unique_payment"
|
||||||
|
end
|
||||||
|
end
|
||||||
9
backend/db/schema.rb
generated
9
backend/db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_05_29_120000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_05_31_140000) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -58,13 +58,15 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120000) do
|
|||||||
t.date "issued_on", null: false
|
t.date "issued_on", null: false
|
||||||
t.integer "amount_cents", null: false
|
t.integer "amount_cents", null: false
|
||||||
t.string "currency", default: "eur", null: false
|
t.string "currency", default: "eur", null: false
|
||||||
t.string "status", default: "issued", null: false
|
t.string "status", default: "draft", null: false
|
||||||
t.string "source", default: "aruba", null: false
|
t.string "source", default: "manual", null: false
|
||||||
t.string "aruba_document_id"
|
t.string "aruba_document_id"
|
||||||
t.text "notes"
|
t.text "notes"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.datetime "emailed_at"
|
||||||
t.index ["billing_payment_id"], name: "index_billing_invoices_on_billing_payment_id"
|
t.index ["billing_payment_id"], name: "index_billing_invoices_on_billing_payment_id"
|
||||||
|
t.index ["billing_payment_id"], name: "index_billing_invoices_unique_payment", unique: true, where: "(billing_payment_id IS NOT NULL)"
|
||||||
t.index ["club_id", "issued_on"], name: "index_billing_invoices_on_club_id_and_issued_on"
|
t.index ["club_id", "issued_on"], name: "index_billing_invoices_on_club_id_and_issued_on"
|
||||||
t.index ["club_id", "number"], name: "index_billing_invoices_on_club_id_and_number", unique: true
|
t.index ["club_id", "number"], name: "index_billing_invoices_on_club_id_and_number", unique: true
|
||||||
t.index ["club_id"], name: "index_billing_invoices_on_club_id"
|
t.index ["club_id"], name: "index_billing_invoices_on_club_id"
|
||||||
@@ -247,6 +249,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_29_120000) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.uuid "club_id", null: false
|
t.uuid "club_id", null: false
|
||||||
|
t.string "billing_interval"
|
||||||
t.index ["club_id"], name: "index_subscriptions_on_club_id", unique: true
|
t.index ["club_id"], name: "index_subscriptions_on_club_id", unique: true
|
||||||
t.index ["plan_id"], name: "index_subscriptions_on_plan_id"
|
t.index ["plan_id"], name: "index_subscriptions_on_plan_id"
|
||||||
t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true, where: "(stripe_subscription_id IS NOT NULL)"
|
t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true, where: "(stripe_subscription_id IS NOT NULL)"
|
||||||
|
|||||||
@@ -254,6 +254,17 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; }
|
.btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; }
|
||||||
.btn-primary { background: #e53935; color: #fff; }
|
.btn-primary { background: #e53935; color: #fff; }
|
||||||
.btn-primary:hover { background: #c62828; text-decoration: none; color: #fff; }
|
.btn-primary:hover { background: #c62828; text-decoration: none; color: #fff; }
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: #e53935;
|
||||||
|
border: 1px solid #e53935;
|
||||||
|
}
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: rgba(229, 57, 53, 0.12);
|
||||||
|
text-decoration: none;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #c62828;
|
||||||
|
}
|
||||||
.btn-secondary { background: #252530; color: #fff; }
|
.btn-secondary { background: #252530; color: #fff; }
|
||||||
.btn-secondary:hover { background: #333; text-decoration: none; color: #fff; }
|
.btn-secondary:hover { background: #333; text-decoration: none; color: #fff; }
|
||||||
.form-section-title { font-size: 1.05rem; margin: 0 0 12px; color: #e8e8ee; }
|
.form-section-title { font-size: 1.05rem; margin: 0 0 12px; color: #e8e8ee; }
|
||||||
@@ -1095,6 +1106,10 @@ label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: #c8c8d4; f
|
|||||||
border-color: #e53935;
|
border-color: #e53935;
|
||||||
box-shadow: 0 0 0 2px rgba(229, 57, 53, 0.25);
|
box-shadow: 0 0 0 2px rgba(229, 57, 53, 0.25);
|
||||||
}
|
}
|
||||||
|
.input-toggle--plain .input-toggle__input--plain {
|
||||||
|
padding-right: 12px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
.input-toggle__btn {
|
.input-toggle__btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
@@ -1119,3 +1134,27 @@ label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: #c8c8d4; f
|
|||||||
}
|
}
|
||||||
.input-toggle__btn i { font-size: 1.05rem; pointer-events: none; }
|
.input-toggle__btn i { font-size: 1.05rem; pointer-events: none; }
|
||||||
.auth-page .auth-forgot { margin: -4px 0 16px; font-size: 0.88rem; }
|
.auth-page .auth-forgot { margin: -4px 0 16px; font-size: 0.88rem; }
|
||||||
|
|
||||||
|
.plan-price-alt {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: -8px 0 12px;
|
||||||
|
}
|
||||||
|
.plan-interval-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.plan-interval-btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
padding: 12px 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.plan-action-hint {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|||||||
8
backend/spec/helpers/application_helper_spec.rb
Normal file
8
backend/spec/helpers/application_helper_spec.rb
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe ApplicationHelper, type: :helper do
|
||||||
|
it "formatta data e ora in italiano (fuso Rome)" do
|
||||||
|
time = Time.zone.parse("2026-07-02 13:25:00")
|
||||||
|
expect(helper.l_local(time)).to eq("02 luglio 2026 alle 13:25")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,38 +1,70 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe Public::BillingHelper, type: :helper do
|
RSpec.describe Public::BillingHelper, type: :helper do
|
||||||
before { load Rails.root.join("db/seeds/plans.rb") }
|
before do
|
||||||
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("py")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("pm")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("fy")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("fm")
|
||||||
|
end
|
||||||
|
|
||||||
let(:premium_light) { Plan["premium_light"] }
|
let(:premium_light) { Plan["premium_light"] }
|
||||||
let(:premium_full) { Plan["premium_full"] }
|
let(:premium_full) { Plan["premium_full"] }
|
||||||
let(:free_plan) { Plan["free"] }
|
|
||||||
|
|
||||||
it "segna il piano corrente" do
|
it "propone due opzioni checkout da free" do
|
||||||
action = helper.plan_billing_action(
|
|
||||||
current_slug: "premium_light",
|
|
||||||
target_plan: premium_light,
|
|
||||||
stripe_subscription_active: true
|
|
||||||
)
|
|
||||||
expect(action[:kind]).to eq(:current)
|
|
||||||
end
|
|
||||||
|
|
||||||
it "propone attivazione da free" do
|
|
||||||
action = helper.plan_billing_action(
|
action = helper.plan_billing_action(
|
||||||
current_slug: "free",
|
current_slug: "free",
|
||||||
target_plan: premium_light,
|
target_plan: premium_light,
|
||||||
stripe_subscription_active: false
|
stripe_subscription_active: false
|
||||||
)
|
)
|
||||||
expect(action[:kind]).to eq(:checkout)
|
expect(action[:kind]).to eq(:checkout_options)
|
||||||
expect(action[:label]).to include("Attiva")
|
expect(action[:intervals]).to eq(%w[monthly yearly])
|
||||||
end
|
end
|
||||||
|
|
||||||
it "propone upgrade con abbonamento attivo" do
|
it "segna piano corrente quando non ci sono altri intervalli" do
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return(nil)
|
||||||
|
action = helper.plan_billing_action(
|
||||||
|
current_slug: "premium_light",
|
||||||
|
target_plan: premium_light,
|
||||||
|
stripe_subscription_active: true,
|
||||||
|
current_interval: "yearly"
|
||||||
|
)
|
||||||
|
expect(action[:kind]).to eq(:current)
|
||||||
|
expect(action[:label]).to include("€39,90/anno")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "propone cambio intervallo sullo stesso piano" do
|
||||||
|
action = helper.plan_billing_action(
|
||||||
|
current_slug: "premium_light",
|
||||||
|
target_plan: premium_light,
|
||||||
|
stripe_subscription_active: true,
|
||||||
|
current_interval: "yearly"
|
||||||
|
)
|
||||||
|
expect(action[:kind]).to eq(:interval_switch)
|
||||||
|
expect(action[:intervals]).to eq(["monthly"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "propone upgrade con opzioni intervallo" do
|
||||||
action = helper.plan_billing_action(
|
action = helper.plan_billing_action(
|
||||||
current_slug: "premium_light",
|
current_slug: "premium_light",
|
||||||
target_plan: premium_full,
|
target_plan: premium_full,
|
||||||
stripe_subscription_active: true
|
stripe_subscription_active: true,
|
||||||
|
current_interval: "yearly"
|
||||||
)
|
)
|
||||||
expect(action[:kind]).to eq(:change)
|
expect(action[:kind]).to eq(:change_options)
|
||||||
expect(action[:label]).to include("Passa a")
|
expect(action[:intervals].size).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "stile annuale pieno e mensile outline" do
|
||||||
|
expect(helper.plan_interval_button_class("yearly")).to include("btn-primary")
|
||||||
|
expect(helper.plan_interval_button_class("yearly")).not_to include("btn-outline")
|
||||||
|
expect(helper.plan_interval_button_class("monthly")).to include("btn-outline")
|
||||||
|
expect(helper.plan_interval_button_class("monthly")).not_to include("btn-primary")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra prima il pulsante annuale" do
|
||||||
|
expect(helper.plan_intervals_for_display(%w[monthly yearly])).to eq(%w[yearly monthly])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
19
backend/spec/models/billing/invoice_display_spec.rb
Normal file
19
backend/spec/models/billing/invoice_display_spec.rb
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Billing::Invoice do
|
||||||
|
let(:club) { Club.create!(name: "Inv Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
|
||||||
|
it "non mostra Aruba nel numero in UI" do
|
||||||
|
invoice = club.billing_invoices.build(
|
||||||
|
number: "ARUBA-RUNNER/2026",
|
||||||
|
issued_on: Date.current,
|
||||||
|
amount_cents: 5000,
|
||||||
|
currency: "eur",
|
||||||
|
status: "issued",
|
||||||
|
source: "aruba"
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(invoice.display_number).to eq("RUNNER/2026")
|
||||||
|
expect(invoice.display_number).not_to match(/aruba/i)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,11 +7,14 @@ RSpec.describe ClubBillingProfile do
|
|||||||
sport: "volleyball",
|
sport: "volleyball",
|
||||||
primary_color: "#e53935",
|
primary_color: "#e53935",
|
||||||
secondary_color: "#ffffff",
|
secondary_color: "#ffffff",
|
||||||
|
billing_entity_type: "company",
|
||||||
billing_legal_name: "ASD Test",
|
billing_legal_name: "ASD Test",
|
||||||
billing_email: "billing@test.it",
|
billing_email: "billing@test.it",
|
||||||
billing_address_line: "Via Roma 1",
|
billing_address_line: "Via Roma 1",
|
||||||
billing_city: "Milano",
|
billing_city: "Milano",
|
||||||
|
billing_province: "MI",
|
||||||
billing_postal_code: "20100",
|
billing_postal_code: "20100",
|
||||||
|
billing_country: "IT",
|
||||||
billing_vat_number: "12345678901",
|
billing_vat_number: "12345678901",
|
||||||
billing_recipient_code: "ABCDEFG"
|
billing_recipient_code: "ABCDEFG"
|
||||||
)
|
)
|
||||||
@@ -26,4 +29,16 @@ RSpec.describe ClubBillingProfile do
|
|||||||
club.billing_pec = nil
|
club.billing_pec = nil
|
||||||
expect(club.billing_profile_complete?).to be false
|
expect(club.billing_profile_complete?).to be false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "requires P.IVA for companies" do
|
||||||
|
club.billing_vat_number = nil
|
||||||
|
expect(club.billing_profile_complete?).to be false
|
||||||
|
expect(club.billing_profile_errors).to include("Partita IVA")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires Codice Fiscale for individuals" do
|
||||||
|
club.update!(billing_entity_type: "individual", billing_vat_number: nil, billing_fiscal_code: nil)
|
||||||
|
club.billing_fiscal_code = "RSSMRA80A01H501U"
|
||||||
|
expect(club.billing_profile_complete?).to be true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ RSpec.describe "Public club billing", type: :request do
|
|||||||
end
|
end
|
||||||
let!(:club) do
|
let!(:club) do
|
||||||
c = Club.create!(name: "Billing HTTP Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff",
|
c = Club.create!(name: "Billing HTTP Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff",
|
||||||
billing_legal_name: "ASD Billing", billing_email: "bill@test.it", billing_address_line: "Via 1",
|
billing_entity_type: "company", billing_legal_name: "ASD Billing", billing_email: "bill@test.it",
|
||||||
billing_city: "Milano", billing_postal_code: "20100", billing_vat_number: "12345678901",
|
billing_address_line: "Via 1", billing_city: "Milano", billing_province: "MI", billing_postal_code: "20100",
|
||||||
billing_recipient_code: "ABCDEFG")
|
billing_country: "IT", billing_vat_number: "12345678901", billing_recipient_code: "ABCDEFG")
|
||||||
ClubMembership.create!(user: coach, club: c, role: "owner")
|
ClubMembership.create!(user: coach, club: c, role: "owner")
|
||||||
c.teams.create!(name: "U15", sport: "volleyball")
|
c.teams.create!(name: "U15", sport: "volleyball")
|
||||||
load Rails.root.join("db/seeds/plans.rb")
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
@@ -23,21 +23,25 @@ RSpec.describe "Public club billing", type: :request do
|
|||||||
it "mostra pagamenti e fatture in billing" do
|
it "mostra pagamenti e fatture in billing" do
|
||||||
club.billing_payments.create!(
|
club.billing_payments.create!(
|
||||||
amount_cents: 4000, currency: "eur", status: "paid", paid_at: Time.current,
|
amount_cents: 4000, currency: "eur", status: "paid", paid_at: Time.current,
|
||||||
description: "Premium Light", stripe_invoice_id: "in_spec_1"
|
description: "Premium Light", stripe_invoice_id: "in_spec_#{SecureRandom.hex(6)}"
|
||||||
)
|
)
|
||||||
|
payment = club.billing_payments.reload.first
|
||||||
club.billing_invoices.create!(
|
club.billing_invoices.create!(
|
||||||
number: "2026/100", issued_on: Date.current, amount_cents: 4000, status: "draft"
|
number: "2026/100", issued_on: Date.current, amount_cents: 4000, status: "draft",
|
||||||
|
source: "manual", billing_payment: payment
|
||||||
)
|
)
|
||||||
|
|
||||||
login!
|
login!
|
||||||
get public_club_billing_path(club)
|
get public_club_billing_path(club)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include("Pagamenti effettuati")
|
expect(response.body).to include("Pagamenti e fatture")
|
||||||
expect(response.body).to include("Fatture")
|
expect(response.body).not_to include("Ricevuta Stripe")
|
||||||
expect(response.body).not_to include("Aruba")
|
expect(response.body).not_to include("Sincronizza pagamenti")
|
||||||
|
expect(response.body).not_to include("Gestisci fatturazione su Stripe")
|
||||||
expect(response.body).to include("Premium Light")
|
expect(response.body).to include("Premium Light")
|
||||||
expect(response.body).to include("2026/100")
|
expect(response.body).to include("2026/100")
|
||||||
|
expect(response.body).to include("In preparazione")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "salva profilo fatturazione" do
|
it "salva profilo fatturazione" do
|
||||||
@@ -57,6 +61,89 @@ RSpec.describe "Public club billing", type: :request do
|
|||||||
login!
|
login!
|
||||||
get public_club_checkout_path(club, plan: "premium_light")
|
get public_club_checkout_path(club, plan: "premium_light")
|
||||||
|
|
||||||
expect(response).to redirect_to(public_club_billing_profile_path(club))
|
expect(response).to redirect_to(public_club_billing_profile_path(club, plan: "premium_light"))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "non mostra pulsanti checkout se profilo incompleto" do
|
||||||
|
club.update!(billing_vat_number: nil)
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
||||||
|
|
||||||
|
login!
|
||||||
|
get public_club_billing_path(club)
|
||||||
|
|
||||||
|
expect(response.body).to include("Completa dati di fatturazione")
|
||||||
|
expect(response.body).not_to include("Attiva Premium Light — €39,90/anno")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "dopo profilo completo reindirizza al checkout" do
|
||||||
|
club.update!(
|
||||||
|
billing_entity_type: "company",
|
||||||
|
billing_vat_number: nil,
|
||||||
|
billing_fiscal_code: nil,
|
||||||
|
billing_recipient_code: nil,
|
||||||
|
billing_pec: nil
|
||||||
|
)
|
||||||
|
login!
|
||||||
|
patch public_club_billing_profile_path(club, plan: "premium_light", interval: "yearly"), params: {
|
||||||
|
club: {
|
||||||
|
billing_entity_type: "company",
|
||||||
|
billing_legal_name: "ASD Billing",
|
||||||
|
billing_vat_number: "12345678901",
|
||||||
|
billing_email: "bill@test.it",
|
||||||
|
billing_address_line: "Via 1",
|
||||||
|
billing_city: "Milano",
|
||||||
|
billing_province: "MI",
|
||||||
|
billing_postal_code: "20100",
|
||||||
|
billing_country: "IT",
|
||||||
|
billing_recipient_code: "ABCDEFG"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to redirect_to(public_club_checkout_path(club, plan: "premium_light", interval: "yearly"))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra pulsanti per intervallo e avvia checkout annuale" do
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_y")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_m")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_fy")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_fm")
|
||||||
|
|
||||||
|
login!
|
||||||
|
get public_club_billing_path(club)
|
||||||
|
expect(response.body).to include("Attiva Premium Light — €39,90/anno")
|
||||||
|
expect(response.body).to include("Attiva Premium Light — €4,90/mese")
|
||||||
|
|
||||||
|
allow(Billing::Stripe::CheckoutSession).to receive(:new).and_return(
|
||||||
|
instance_double(Billing::Stripe::CheckoutSession, url: "https://checkout.stripe.com/test")
|
||||||
|
)
|
||||||
|
get public_club_checkout_path(club, plan: "premium_light", interval: "yearly")
|
||||||
|
expect(response).to redirect_to("https://checkout.stripe.com/test")
|
||||||
|
expect(Billing::Stripe::CheckoutSession).to have_received(:new).with(
|
||||||
|
hash_including(club: club, plan_slug: "premium_light", interval: "yearly")
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "sincronizza il piano al ritorno da checkout success" do
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
||||||
|
allow(Billing::Stripe::FinalizeSubscription).to receive(:call).and_return(false)
|
||||||
|
allow(Billing::Stripe::CheckoutSync).to receive(:call) do |**kwargs|
|
||||||
|
Billing::AssignPlan.call(
|
||||||
|
club: kwargs[:club].reload,
|
||||||
|
plan_slug: "premium_light",
|
||||||
|
status: "active",
|
||||||
|
stripe_attrs: { stripe_subscription_id: "sub_ui", billing_interval: "monthly" }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
login!
|
||||||
|
get public_club_billing_path(club, checkout: "success", session_id: "cs_test")
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(club.reload.subscription.plan.slug).to eq("premium_light")
|
||||||
|
expect(response.body).to include("Piano attuale: <strong>Premium Light</strong>")
|
||||||
|
expect(Billing::Stripe::CheckoutSync).to have_received(:call).with(
|
||||||
|
club: club, session_id: "cs_test"
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
41
backend/spec/services/billing/issue_invoice_spec.rb
Normal file
41
backend/spec/services/billing/issue_invoice_spec.rb
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Billing::IssueInvoice do
|
||||||
|
let(:club) do
|
||||||
|
Club.create!(
|
||||||
|
name: "Mail Club", sport: "volleyball",
|
||||||
|
primary_color: "#e53935", secondary_color: "#ffffff",
|
||||||
|
billing_legal_name: "ASD Mail", billing_email: "fatture@test.it",
|
||||||
|
billing_address_line: "Via 1", billing_city: "Milano", billing_postal_code: "20100",
|
||||||
|
billing_vat_number: "12345678901", billing_recipient_code: "ABCDEFG"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let(:payment) do
|
||||||
|
club.billing_payments.create!(
|
||||||
|
amount_cents: 500, currency: "eur", status: "paid", paid_at: Time.current,
|
||||||
|
description: "Premium Light — €5/mese"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let(:invoice) do
|
||||||
|
club.billing_invoices.create!(
|
||||||
|
number: "2026/99", issued_on: Date.current, amount_cents: 500,
|
||||||
|
currency: "eur", status: "draft", source: "manual", billing_payment: payment
|
||||||
|
)
|
||||||
|
end
|
||||||
|
it "emette la fattura, allega il pdf e invia email" do
|
||||||
|
pdf_io = StringIO.new("%PDF-1.4 test\n")
|
||||||
|
|
||||||
|
expect do
|
||||||
|
described_class.call(
|
||||||
|
invoice: invoice,
|
||||||
|
pdf: { io: pdf_io, filename: "fattura-test.pdf", content_type: "application/pdf" }
|
||||||
|
)
|
||||||
|
end.to change { ActionMailer::Base.deliveries.size }.by(1)
|
||||||
|
|
||||||
|
invoice.reload
|
||||||
|
expect(invoice.status).to eq("sent")
|
||||||
|
expect(invoice.pdf).to be_attached
|
||||||
|
expect(invoice.emailed_at).to be_present
|
||||||
|
expect(ActionMailer::Base.deliveries.last.to).to include("fatture@test.it")
|
||||||
|
end
|
||||||
|
end
|
||||||
28
backend/spec/services/billing/payment_description_spec.rb
Normal file
28
backend/spec/services/billing/payment_description_spec.rb
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Billing::PaymentDescription do
|
||||||
|
before { load Rails.root.join("db/seeds/plans.rb") }
|
||||||
|
|
||||||
|
it "etichetta da piano e intervallo Stripe" do
|
||||||
|
invoice = double(
|
||||||
|
amount_paid: 500,
|
||||||
|
metadata: { "plan_slug" => "premium_light", "billing_interval" => "monthly" },
|
||||||
|
lines: double(data: [])
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(described_class.for_stripe_invoice(invoice)).to eq("Premium Light — €5/mese")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "ignora descrizioni di test nel pagamento salvato" do
|
||||||
|
payment = Billing::Payment.new(
|
||||||
|
description: "Webhook test",
|
||||||
|
plan_slug: "premium_light",
|
||||||
|
amount_cents: 4000,
|
||||||
|
currency: "eur",
|
||||||
|
status: "paid",
|
||||||
|
club: Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(payment.display_description).to eq("Premium Light — €40/anno")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Billing::Stripe::CancelSubscription do
|
||||||
|
let(:club) do
|
||||||
|
Club.create!(
|
||||||
|
name: "Cancel Club", sport: "volleyball",
|
||||||
|
primary_color: "#e53935", secondary_color: "#ffffff",
|
||||||
|
billing_legal_name: "ASD", billing_email: "b@test.it",
|
||||||
|
billing_address_line: "Via 1", billing_city: "Milano", billing_postal_code: "20100",
|
||||||
|
billing_vat_number: "12345678901", billing_recipient_code: "ABCDEFG"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
||||||
|
Billing::AssignPlan.call(
|
||||||
|
club: club,
|
||||||
|
plan_slug: "premium_light",
|
||||||
|
status: "active",
|
||||||
|
stripe_attrs: {
|
||||||
|
stripe_subscription_id: "sub_cancel",
|
||||||
|
stripe_customer_id: "cus_x",
|
||||||
|
current_period_end: 1.month.from_now,
|
||||||
|
billing_interval: "monthly"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "programma la disdetta a fine periodo su Stripe" do
|
||||||
|
stripe_sub = double(
|
||||||
|
id: "sub_cancel",
|
||||||
|
customer: "cus_x",
|
||||||
|
status: "active",
|
||||||
|
cancel_at_period_end: true,
|
||||||
|
metadata: { "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "monthly" },
|
||||||
|
items: double(data: [double(price: double(id: "price_m", recurring: double(interval: "month")))])
|
||||||
|
)
|
||||||
|
allow(Stripe::Subscription).to receive(:update).with("sub_cancel", cancel_at_period_end: true).and_return(stripe_sub)
|
||||||
|
allow(Billing::Stripe::CheckoutSync).to receive(:from_subscription)
|
||||||
|
allow(Billing::Stripe::SubscriptionPeriod).to receive(:times).and_return([Time.current, 1.month.from_now])
|
||||||
|
|
||||||
|
described_class.call(club: club)
|
||||||
|
|
||||||
|
expect(Stripe::Subscription).to have_received(:update).with("sub_cancel", cancel_at_period_end: true)
|
||||||
|
expect(Billing::Stripe::CheckoutSync).to have_received(:from_subscription).with(stripe_sub, club: club)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rifiuta se la disdetta è già programmata" do
|
||||||
|
club.subscription.update!(cancel_at_period_end: true)
|
||||||
|
expect { described_class.call(club: club) }.to raise_error(/già programmata/)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -15,10 +15,10 @@ RSpec.describe Billing::Stripe::ChangePlan do
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
||||||
allow(MatchLiveTv).to receive(:stripe_premium_light_price_id).and_return("price_light")
|
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
|
||||||
allow(MatchLiveTv).to receive(:stripe_premium_full_price_id).and_return("price_full")
|
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_light_m")
|
||||||
Plan.find_by!(slug: "premium_light").update!(stripe_price_id: "price_light")
|
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
|
||||||
Plan.find_by!(slug: "premium_full").update!(stripe_price_id: "price_full")
|
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_full_m")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "aggiorna Stripe e il piano locale verso premium_full" do
|
it "aggiorna Stripe e il piano locale verso premium_full" do
|
||||||
@@ -37,21 +37,22 @@ RSpec.describe Billing::Stripe::ChangePlan do
|
|||||||
allow(Stripe::Subscription).to receive(:retrieve).with("sub_test_123").and_return(stripe_sub)
|
allow(Stripe::Subscription).to receive(:retrieve).with("sub_test_123").and_return(stripe_sub)
|
||||||
allow(Stripe::Subscription).to receive(:update).and_return(updated_sub)
|
allow(Stripe::Subscription).to receive(:update).and_return(updated_sub)
|
||||||
|
|
||||||
described_class.call(club: club, plan_slug: "premium_full")
|
described_class.call(club: club, plan_slug: "premium_full", interval: "yearly")
|
||||||
|
|
||||||
expect(Stripe::Subscription).to have_received(:update).with(
|
expect(Stripe::Subscription).to have_received(:update).with(
|
||||||
"sub_test_123",
|
"sub_test_123",
|
||||||
hash_including(
|
hash_including(
|
||||||
items: [{ id: "si_test", price: "price_full" }],
|
items: [{ id: "si_test", price: "price_full_y" }],
|
||||||
metadata: { club_id: club.id, plan_slug: "premium_full" }
|
metadata: { club_id: club.id, plan_slug: "premium_full", billing_interval: "yearly" }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect(club.reload.subscription.plan.slug).to eq("premium_full")
|
expect(club.reload.subscription.plan.slug).to eq("premium_full")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "rifiuta il cambio sullo stesso piano" do
|
it "rifiuta il cambio sullo stesso piano e intervallo" do
|
||||||
|
club.subscription.update!(billing_interval: "yearly")
|
||||||
expect {
|
expect {
|
||||||
described_class.call(club: club, plan_slug: "premium_light")
|
described_class.call(club: club, plan_slug: "premium_light", interval: "yearly")
|
||||||
}.to raise_error(/già su questo piano/)
|
}.to raise_error(/già su questo piano/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,49 +15,38 @@ RSpec.describe Billing::Stripe::CheckoutSession do
|
|||||||
before do
|
before do
|
||||||
load Rails.root.join("db/seeds/plans.rb")
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
||||||
allow(MatchLiveTv).to receive(:stripe_premium_light_price_id).and_return("price_light")
|
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
|
||||||
allow(MatchLiveTv).to receive(:stripe_premium_full_price_id).and_return("price_full")
|
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_light_m")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_full_m")
|
||||||
allow(MatchLiveTv).to receive(:app_public_url).and_return("http://localhost:3000")
|
allow(MatchLiveTv).to receive(:app_public_url).and_return("http://localhost:3000")
|
||||||
Plan.find_by!(slug: "premium_light").update!(stripe_price_id: "price_light")
|
|
||||||
Plan.find_by!(slug: "premium_full").update!(stripe_price_id: "price_full")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "crea checkout senza subscription esistente" do
|
it "crea checkout annuale" do
|
||||||
session = double(url: "https://checkout.stripe.com/test")
|
session = double(url: "https://checkout.stripe.com/yearly")
|
||||||
expect(Stripe::Customer).to receive(:create).and_return(double(id: "cus_new"))
|
expect(Stripe::Customer).to receive(:create).and_return(double(id: "cus_new"))
|
||||||
expect(Billing::Stripe::CustomerSync).to receive(:call).with(club: club, customer_id: "cus_new")
|
expect(Billing::Stripe::CustomerSync).to receive(:call).with(club: club, customer_id: "cus_new")
|
||||||
expect(Stripe::Checkout::Session).to receive(:create).with(
|
expect(Stripe::Checkout::Session).to receive(:create).with(
|
||||||
hash_including(
|
hash_including(
|
||||||
mode: "subscription",
|
line_items: [{ price: "price_light_y", quantity: 1 }],
|
||||||
customer: "cus_new",
|
metadata: hash_including(plan_slug: "premium_light", billing_interval: "yearly"),
|
||||||
line_items: [{ price: "price_light", quantity: 1 }],
|
subscription_data: { metadata: hash_including(billing_interval: "yearly") }
|
||||||
subscription_data: { metadata: { club_id: club.id, plan_slug: "premium_light" } }
|
|
||||||
)
|
)
|
||||||
).and_return(session)
|
).and_return(session)
|
||||||
|
|
||||||
url = described_class.new(club: club, user: user, plan_slug: "premium_light").url
|
url = described_class.new(club: club, user: user, plan_slug: "premium_light", interval: "yearly").url
|
||||||
expect(url).to eq("https://checkout.stripe.com/test")
|
expect(url).to eq("https://checkout.stripe.com/yearly")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "crea checkout per cambio piano con subscription esistente" do
|
it "crea checkout mensile" do
|
||||||
Billing::AssignPlan.call(
|
session = double(url: "https://checkout.stripe.com/monthly")
|
||||||
club: club,
|
allow(Stripe::Customer).to receive(:create).and_return(double(id: "cus_m"))
|
||||||
plan_slug: "premium_light",
|
allow(Billing::Stripe::CustomerSync).to receive(:call)
|
||||||
status: "active",
|
|
||||||
stripe_attrs: { stripe_customer_id: "cus_x", stripe_subscription_id: "sub_x" }
|
|
||||||
)
|
|
||||||
|
|
||||||
session = double(url: "https://checkout.stripe.com/change")
|
|
||||||
expect(Billing::Stripe::CustomerSync).to receive(:call).with(club: club, customer_id: "cus_x")
|
|
||||||
expect(Stripe::Checkout::Session).to receive(:create).with(
|
expect(Stripe::Checkout::Session).to receive(:create).with(
|
||||||
hash_including(
|
hash_including(line_items: [{ price: "price_light_m", quantity: 1 }])
|
||||||
customer: "cus_x",
|
|
||||||
subscription: "sub_x",
|
|
||||||
line_items: [{ price: "price_full", quantity: 1 }]
|
|
||||||
)
|
|
||||||
).and_return(session)
|
).and_return(session)
|
||||||
|
|
||||||
url = described_class.new(club: club, user: user, plan_slug: "premium_full").url
|
url = described_class.new(club: club, user: user, plan_slug: "premium_light", interval: "monthly").url
|
||||||
expect(url).to eq("https://checkout.stripe.com/change")
|
expect(url).to eq("https://checkout.stripe.com/monthly")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
72
backend/spec/services/billing/stripe/checkout_sync_spec.rb
Normal file
72
backend/spec/services/billing/stripe/checkout_sync_spec.rb
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Billing::Stripe::CheckoutSync do
|
||||||
|
let(:club) do
|
||||||
|
Club.create!(
|
||||||
|
name: "Sync Club", sport: "volleyball",
|
||||||
|
primary_color: "#e53935", secondary_color: "#ffffff",
|
||||||
|
billing_legal_name: "ASD", billing_email: "b@test.it",
|
||||||
|
billing_address_line: "Via 1", billing_city: "Milano", billing_postal_code: "20100",
|
||||||
|
billing_vat_number: "12345678901", billing_recipient_code: "ABCDEFG"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
|
Billing::AssignPlan.call(club: club, plan_slug: "free")
|
||||||
|
club.subscription.update!(stripe_customer_id: "cus_sync")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_light_m")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_full_m")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "allinea il piano da checkout session pagata" do
|
||||||
|
session = double(
|
||||||
|
customer: "cus_sync",
|
||||||
|
subscription: "sub_new",
|
||||||
|
payment_status: "paid",
|
||||||
|
status: "complete",
|
||||||
|
metadata: { "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "monthly" }
|
||||||
|
)
|
||||||
|
stripe_sub = double(
|
||||||
|
id: "sub_new",
|
||||||
|
customer: "cus_sync",
|
||||||
|
status: "active",
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
metadata: { "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "monthly" },
|
||||||
|
items: double(data: [double(price: double(id: "price_light_m", recurring: double(interval: "month")))])
|
||||||
|
)
|
||||||
|
allow(Stripe::Checkout::Session).to receive(:retrieve).with("cs_test").and_return(session)
|
||||||
|
allow(Stripe::Subscription).to receive(:retrieve).with("sub_new").and_return(stripe_sub)
|
||||||
|
allow(Billing::Stripe::SubscriptionPeriod).to receive(:times).and_return([Time.current, 1.month.from_now])
|
||||||
|
|
||||||
|
described_class.call(club: club, session_id: "cs_test")
|
||||||
|
|
||||||
|
sub = club.reload.subscription
|
||||||
|
expect(sub.plan.slug).to eq("premium_light")
|
||||||
|
expect(sub.billing_interval).to eq("monthly")
|
||||||
|
expect(sub.stripe_subscription_id).to eq("sub_new")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "usa l'abbonamento Stripe attivo del cliente se manca session_id" do
|
||||||
|
stripe_sub = double(
|
||||||
|
id: "sub_fallback",
|
||||||
|
customer: "cus_sync",
|
||||||
|
status: "active",
|
||||||
|
created: 2,
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
metadata: { "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "yearly" },
|
||||||
|
items: double(data: [double(price: double(id: "price_light_y", recurring: double(interval: "year")))])
|
||||||
|
)
|
||||||
|
list = double(data: [stripe_sub])
|
||||||
|
allow(Stripe::Subscription).to receive(:list).and_return(list)
|
||||||
|
allow(Billing::Stripe::SubscriptionPeriod).to receive(:times).and_return([Time.current, 1.year.from_now])
|
||||||
|
|
||||||
|
described_class.call(club: club)
|
||||||
|
|
||||||
|
expect(club.reload.subscription.plan.slug).to eq("premium_light")
|
||||||
|
expect(club.subscription.billing_interval).to eq("yearly")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Billing::Stripe::FinalizeSubscription do
|
||||||
|
let(:club) do
|
||||||
|
Club.create!(
|
||||||
|
name: "Fin Club", sport: "volleyball",
|
||||||
|
primary_color: "#e53935", secondary_color: "#ffffff"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
before { load Rails.root.join("db/seeds/plans.rb") }
|
||||||
|
|
||||||
|
it "passa a free quando il periodo è scaduto e la disdetta era programmata" do
|
||||||
|
Billing::AssignPlan.call(
|
||||||
|
club: club,
|
||||||
|
plan_slug: "premium_light",
|
||||||
|
status: "active",
|
||||||
|
stripe_attrs: {
|
||||||
|
stripe_subscription_id: "sub_fin",
|
||||||
|
cancel_at_period_end: true,
|
||||||
|
current_period_end: 1.day.ago
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stripe_sub = double(
|
||||||
|
id: "sub_fin",
|
||||||
|
status: "active",
|
||||||
|
cancel_at_period_end: true
|
||||||
|
)
|
||||||
|
allow(Stripe::Subscription).to receive(:retrieve).with("sub_fin").and_return(stripe_sub)
|
||||||
|
allow(Billing::Stripe::SubscriptionPeriod).to receive(:times).and_return([2.months.ago, 1.day.ago])
|
||||||
|
|
||||||
|
expect(described_class.call(club: club)).to be true
|
||||||
|
expect(club.reload.subscription.plan.slug).to eq("free")
|
||||||
|
expect(club.subscription.stripe_subscription_id).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "non cambia piano se il periodo non è ancora scaduto" do
|
||||||
|
Billing::AssignPlan.call(
|
||||||
|
club: club,
|
||||||
|
plan_slug: "premium_light",
|
||||||
|
status: "active",
|
||||||
|
stripe_attrs: {
|
||||||
|
stripe_subscription_id: "sub_ok",
|
||||||
|
cancel_at_period_end: true,
|
||||||
|
current_period_end: 1.month.from_now
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stripe_sub = double(id: "sub_ok", status: "active", cancel_at_period_end: true)
|
||||||
|
allow(Stripe::Subscription).to receive(:retrieve).and_return(stripe_sub)
|
||||||
|
|
||||||
|
expect(described_class.call(club: club)).to be false
|
||||||
|
expect(club.reload.subscription.plan.slug).to eq("premium_light")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Billing::Stripe::InvoicePaymentRefs do
|
||||||
|
it "legge payment_intent dalla raccolta payments (API Basil+)" do
|
||||||
|
pi = double(id: "pi_new")
|
||||||
|
payment = double(payment_intent: pi)
|
||||||
|
invoice = double(
|
||||||
|
payments: double(data: [double(payment: payment)])
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(described_class.payment_intent_id(invoice)).to eq("pi_new")
|
||||||
|
expect(described_class.charge_id(invoice)).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "non accede a payment_intent top-level sull'invoice" do
|
||||||
|
invoice = double(payments: double(data: []))
|
||||||
|
expect(invoice).not_to receive(:payment_intent)
|
||||||
|
expect(described_class.payment_intent_id(invoice)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
29
backend/spec/services/billing/stripe/price_catalog_spec.rb
Normal file
29
backend/spec/services/billing/stripe/price_catalog_spec.rb
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Billing::Stripe::PriceCatalog do
|
||||||
|
before do
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_light_m")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_full_m")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "risolve price id per piano e intervallo" do
|
||||||
|
expect(described_class.price_id(plan_slug: "premium_light", interval: "yearly")).to eq("price_light_y")
|
||||||
|
expect(described_class.price_id(plan_slug: "premium_light", interval: "monthly")).to eq("price_light_m")
|
||||||
|
expect(described_class.price_id(plan_slug: "premium_full", interval: "monthly")).to eq("price_full_m")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "usa yearly come default" do
|
||||||
|
expect(described_class.normalize_interval(nil)).to eq("yearly")
|
||||||
|
expect(described_class.price_id(plan_slug: "premium_light", interval: nil)).to eq("price_light_y")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rifiuta intervalli non validi" do
|
||||||
|
expect { described_class.normalize_interval("weekly") }.to raise_error(ArgumentError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "elenca intervalli configurati" do
|
||||||
|
expect(described_class.available_intervals(plan_slug: "premium_light")).to eq(%w[monthly yearly])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,27 +5,32 @@ RSpec.describe Billing::Stripe::RecordPayment do
|
|||||||
|
|
||||||
before do
|
before do
|
||||||
load Rails.root.join("db/seeds/plans.rb")
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
|
||||||
Billing::AssignPlan.call(club: club, plan_slug: "premium_light", stripe_attrs: { stripe_subscription_id: "sub_x" })
|
Billing::AssignPlan.call(club: club, plan_slug: "premium_light", stripe_attrs: { stripe_subscription_id: "sub_x" })
|
||||||
end
|
end
|
||||||
|
|
||||||
it "crea un pagamento da fattura Stripe" do
|
it "crea un pagamento da fattura Stripe" do
|
||||||
invoice = double(
|
invoice = double(
|
||||||
id: "in_test_1",
|
id: "in_test_1",
|
||||||
subscription: "sub_x",
|
metadata: { "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "yearly" },
|
||||||
metadata: { "club_id" => club.id },
|
parent: nil,
|
||||||
amount_paid: 4000,
|
amount_paid: 4000,
|
||||||
currency: "eur",
|
currency: "eur",
|
||||||
status: "paid",
|
status: "paid",
|
||||||
description: "Premium Light",
|
description: "Premium Light",
|
||||||
payment_intent: "pi_x",
|
|
||||||
charge: "ch_x",
|
|
||||||
invoice_pdf: "https://stripe.com/invoice.pdf",
|
invoice_pdf: "https://stripe.com/invoice.pdf",
|
||||||
hosted_invoice_url: "https://stripe.com/hosted",
|
hosted_invoice_url: "https://stripe.com/hosted",
|
||||||
status_transitions: double(paid_at: Time.current.to_i),
|
status_transitions: double(paid_at: Time.current.to_i),
|
||||||
lines: double(data: [double(description: "Premium Light annuale")]),
|
lines: double(data: [
|
||||||
subscription_details: nil
|
double(
|
||||||
|
description: "Premium Light annuale",
|
||||||
|
metadata: { "plan_slug" => "premium_light", "billing_interval" => "yearly" },
|
||||||
|
pricing: double(price_details: double(price: "price_light_y"))
|
||||||
|
)
|
||||||
|
])
|
||||||
)
|
)
|
||||||
allow(invoice).to receive(:metadata).and_return({ "club_id" => club.id })
|
allow(invoice).to receive(:metadata).and_return({ "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "yearly" })
|
||||||
|
allow(Stripe::Invoice).to receive(:retrieve).and_return(invoice)
|
||||||
|
|
||||||
payment = described_class.call(stripe_invoice: invoice)
|
payment = described_class.call(stripe_invoice: invoice)
|
||||||
|
|
||||||
@@ -33,5 +38,6 @@ RSpec.describe Billing::Stripe::RecordPayment do
|
|||||||
expect(payment.club).to eq(club)
|
expect(payment.club).to eq(club)
|
||||||
expect(payment.amount_cents).to eq(4000)
|
expect(payment.amount_cents).to eq(4000)
|
||||||
expect(payment.stripe_invoice_id).to eq("in_test_1")
|
expect(payment.stripe_invoice_id).to eq("in_test_1")
|
||||||
|
expect(payment.description).to eq("Premium Light — €40/anno")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -29,12 +29,14 @@ Esigenze custom: contatto `info@matchlive.it`.
|
|||||||
## Stripe
|
## Stripe
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
STRIPE_PREMIUM_LIGHT_PRICE_ID=price_...
|
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID=price_...
|
||||||
STRIPE_PREMIUM_FULL_PRICE_ID=price_...
|
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID=price_...
|
||||||
|
STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID=price_...
|
||||||
|
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=price_...
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
```
|
```
|
||||||
|
|
||||||
Checkout (PCI-safe, redirect Stripe Hosted): `GET /clubs/:id/checkout?plan=premium_light|premium_full`
|
Checkout: `GET /clubs/:id/checkout?plan=premium_light|premium_full&interval=monthly|yearly`
|
||||||
|
|
||||||
**Sicurezza pagamenti:** non inviare mai `card[number]`, CVC o scadenza all'API Stripe dal backend.
|
**Sicurezza pagamenti:** non inviare mai `card[number]`, CVC o scadenza all'API Stripe dal backend.
|
||||||
Usare solo Checkout/Customer Portal. I test locali vanno fatti con carta `4242…` **sulla pagina Stripe**, non con `PaymentMethod.create` in `rails runner`.
|
Usare solo Checkout/Customer Portal. I test locali vanno fatti con carta `4242…` **sulla pagina Stripe**, non con `PaymentMethod.create` in `rails runner`.
|
||||||
|
|||||||
87
docs/STRIPE_PRODUCTION.md
Normal file
87
docs/STRIPE_PRODUCTION.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Stripe in produzione — guida rapida
|
||||||
|
|
||||||
|
## 1. Stripe Dashboard (modalità Test per prove, poi Live)
|
||||||
|
|
||||||
|
### Prodotti e prezzi
|
||||||
|
Crea **2 prodotti** in Stripe, ciascuno con **2 prezzi ricorrenti**:
|
||||||
|
|
||||||
|
| Piano app | Prodotto Stripe (Dashboard) | Mensile | Annuale | Variabile `.env` |
|
||||||
|
|-----------|------------------------------|---------|---------|------------------|
|
||||||
|
| Premium Light | **Match Live TV** | €4,90 | €39,90 | `STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
|
||||||
|
| Premium Full | **Match Live TV PRO** | €9,90 | €69,90 | `STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
|
||||||
|
|
||||||
|
In Stripe apri ogni prezzo e copia l’**ID prezzo** (`price_...`), non l’ID prodotto.
|
||||||
|
|
||||||
|
In Dashboard: Prodotto → **Aggiungi prezzo** (non serve un prodotto per ogni intervallo).
|
||||||
|
|
||||||
|
### Webhook (obbligatorio)
|
||||||
|
**Developers → Webhooks → Aggiungi endpoint**
|
||||||
|
|
||||||
|
| Campo | Valore |
|
||||||
|
|--------|--------|
|
||||||
|
| URL | `https://www.matchlivetv.it/webhooks/stripe` |
|
||||||
|
| Eventi | `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed` |
|
||||||
|
|
||||||
|
Copia il **Signing secret** (`whsec_...`) — **non** usare il secret di `stripe listen` (solo per locale).
|
||||||
|
|
||||||
|
### Chiavi API
|
||||||
|
- Test: `sk_test_...` + price test
|
||||||
|
- Live: `sk_live_...` + price live (prodotti creati in modalità Live)
|
||||||
|
|
||||||
|
## 2. Server (`/opt/matchlivetv/infra/.env`)
|
||||||
|
|
||||||
|
> **Non usare** `~/matchlivetv/infra/.env` — è una copia vecchia (bootstrap).
|
||||||
|
> Docker in produzione legge solo **`/opt/matchlivetv/infra/.env`**.
|
||||||
|
> Per aggiungere chiavi mancanti dal template: `./scripts/merge_production_env.sh` con `REMOTE=1`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
STRIPE_SECRET_KEY=sk_test_... # o sk_live_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_... # dal webhook Dashboard (URL produzione)
|
||||||
|
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID=price_...
|
||||||
|
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID=price_...
|
||||||
|
STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID=price_...
|
||||||
|
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=price_...
|
||||||
|
```
|
||||||
|
|
||||||
|
Da PC (senza incollare chiavi in chat):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/setup_stripe_production.sh
|
||||||
|
# oppure, se il webhook è nuovo:
|
||||||
|
./scripts/setup_stripe_production.sh --webhook-secret whsec_...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Test end-to-end
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test scelta mensile/annuale (Docker avviato)
|
||||||
|
chmod +x scripts/test_billing_intervals.sh
|
||||||
|
./scripts/test_billing_intervals.sh
|
||||||
|
|
||||||
|
# Smoke HTTP su produzione
|
||||||
|
BASE_URL=https://www.matchlivetv.it ./scripts/test_stripe_e2e.sh
|
||||||
|
|
||||||
|
# Test locale completo (Docker avviato)
|
||||||
|
./scripts/test_billing_flow.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Checkout: `/clubs/:id/checkout?plan=premium_light&interval=yearly` oppure `interval=monthly`
|
||||||
|
|
||||||
|
### Test manuale browser
|
||||||
|
1. Login titolare società → **Abbonamento**
|
||||||
|
2. **Completa dati di fatturazione** (P.IVA/CF, SDI o PEC, indirizzo)
|
||||||
|
3. **Attiva Premium Light** → pagina `checkout.stripe.com`
|
||||||
|
4. Carta test `4242 4242 4242 4242` (solo con `sk_test_`)
|
||||||
|
5. Ritorno su `/clubs/.../billing?checkout=success`
|
||||||
|
6. Verifica in Stripe → Webhooks: risposta **200** su `/webhooks/stripe`
|
||||||
|
|
||||||
|
## 4. PCI
|
||||||
|
- Mai `PaymentMethod.create` con `card[number]` sul server
|
||||||
|
- Prima attivazione: solo **Stripe Checkout**
|
||||||
|
- Cambio piano (già abbonato): **Subscription.update** Stripe (carta già salvata da Checkout)
|
||||||
|
|
||||||
|
## 5. Passaggio a Live
|
||||||
|
1. Crea prodotti/prezzi in modalità **Live**
|
||||||
|
2. Nuovo webhook endpoint (stesso URL, modalità Live) → nuovo `whsec_`
|
||||||
|
3. Sostituisci `sk_live_` e price live in `.env` produzione
|
||||||
|
4. `./scripts/setup_stripe_production.sh` e ri-test con carta reale
|
||||||
@@ -15,5 +15,11 @@ PASSWORD_RESET_EXPIRY_HOURS=2
|
|||||||
# Stripe (test: sk_test_... / price_... da Dashboard modalità Test)
|
# Stripe (test: sk_test_... / price_... da Dashboard modalità Test)
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
# Un price per prodotto e intervallo (Dashboard → Prodotto → Prezzi)
|
||||||
|
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID=
|
||||||
|
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID=
|
||||||
|
STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID=
|
||||||
|
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
|
||||||
|
# Legacy (equivale a YEARLY se le righe sopra sono vuote)
|
||||||
STRIPE_PREMIUM_LIGHT_PRICE_ID=
|
STRIPE_PREMIUM_LIGHT_PRICE_ID=
|
||||||
STRIPE_PREMIUM_FULL_PRICE_ID=
|
STRIPE_PREMIUM_FULL_PRICE_ID=
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ YOUTUBE_REDIRECT_URI=https://www.matchlivetv.it/api/v1/youtube/callback
|
|||||||
YOUTUBE_CLIENT_ID=
|
YOUTUBE_CLIENT_ID=
|
||||||
YOUTUBE_CLIENT_SECRET=
|
YOUTUBE_CLIENT_SECRET=
|
||||||
|
|
||||||
# Stripe (abbonamento Premium squadra)
|
# Stripe LIVE (Dashboard in modalità Live — non riusare price_ di Test)
|
||||||
STRIPE_SECRET_KEY=
|
# Match Live TV → Premium Light | Match Live TV PRO → Premium Full
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_SECRET_KEY=sk_live_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_... # webhook https://www.matchlivetv.it/webhooks/stripe (modalità Live)
|
||||||
|
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID=
|
||||||
|
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID=
|
||||||
|
STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID=
|
||||||
|
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
|
||||||
STRIPE_PREMIUM_LIGHT_PRICE_ID=
|
STRIPE_PREMIUM_LIGHT_PRICE_ID=
|
||||||
STRIPE_PREMIUM_FULL_PRICE_ID=
|
STRIPE_PREMIUM_FULL_PRICE_ID=
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ services:
|
|||||||
RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info}
|
RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info}
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||||
|
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID:-}
|
||||||
|
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}}
|
||||||
|
STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID:-}
|
||||||
|
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}}
|
||||||
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
|
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
|
||||||
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
|
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ services:
|
|||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||||
|
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID:-}
|
||||||
|
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}}
|
||||||
|
STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID:-}
|
||||||
|
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}}
|
||||||
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
|
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
|
||||||
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
|
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
53
scripts/merge_production_env.sh
Executable file
53
scripts/merge_production_env.sh
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Unisce infra/.env.production.example in infra/.env senza sovrascrivere valori già impostati.
|
||||||
|
# Uso locale: ./scripts/merge_production_env.sh
|
||||||
|
# Sul server: REMOTE=1 ./scripts/merge_production_env.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
EXAMPLE="$ROOT/infra/.env.production.example"
|
||||||
|
REMOTE="${REMOTE:-0}"
|
||||||
|
SERVER="${DEPLOY_SERVER:-eminux@192.168.1.146}"
|
||||||
|
ENV_PATH="${ENV_PATH:-/opt/matchlivetv/infra/.env}"
|
||||||
|
|
||||||
|
if [[ "$REMOTE" == "1" ]]; then
|
||||||
|
scp "$EXAMPLE" "$SERVER:/tmp/matchlivetv-env.example"
|
||||||
|
ssh "$SERVER" "REMOTE_ENV='$ENV_PATH' bash -s" <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
EXAMPLE=/tmp/matchlivetv-env.example
|
||||||
|
ENV="${REMOTE_ENV}"
|
||||||
|
touch "$ENV"
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||||
|
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||||
|
key="${line%%=*}"
|
||||||
|
[[ "$key" == "$line" ]] && continue
|
||||||
|
if ! grep -q "^${key}=" "$ENV" 2>/dev/null; then
|
||||||
|
echo "$line" >> "$ENV"
|
||||||
|
echo " + aggiunto: $key"
|
||||||
|
fi
|
||||||
|
done < "$EXAMPLE"
|
||||||
|
echo ""
|
||||||
|
echo "File: $ENV ($(wc -l < "$ENV") righe)"
|
||||||
|
echo "Chiavi presenti:"
|
||||||
|
grep -E '^[A-Z_]+=' "$ENV" | cut -d= -f1 | sort
|
||||||
|
echo ""
|
||||||
|
echo "Chiavi VUOTE (da compilare):"
|
||||||
|
grep -E '^[A-Z_]+=$' "$ENV" | cut -d= -f1 || echo " (nessuna)"
|
||||||
|
REMOTE
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ENV="${ENV_FILE:-$ROOT/infra/.env}"
|
||||||
|
touch "$ENV"
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||||
|
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
|
||||||
|
key="${line%%=*}"
|
||||||
|
[[ "$key" == "$line" ]] && continue
|
||||||
|
if ! grep -q "^${key}=" "$ENV" 2>/dev/null; then
|
||||||
|
echo "$line" >> "$ENV"
|
||||||
|
echo " + aggiunto: $key"
|
||||||
|
fi
|
||||||
|
done < "$EXAMPLE"
|
||||||
|
echo "Fatto: $ENV"
|
||||||
121
scripts/setup_stripe_production.sh
Executable file
121
scripts/setup_stripe_production.sh
Executable file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Configura Stripe sul server di produzione e verifica l'integrazione.
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# ./scripts/setup_stripe_production.sh # legge infra/.env locale
|
||||||
|
# ./scripts/setup_stripe_production.sh --webhook-secret whsec_xxx
|
||||||
|
# STRIPE_MODE=live ./scripts/setup_stripe_production.sh # usa chiavi live nel .env
|
||||||
|
#
|
||||||
|
# Prima di eseguire in LIVE:
|
||||||
|
# 1. Stripe Dashboard → modalità Live → crea prodotti/prezzi → copia price_...
|
||||||
|
# 2. Webhook endpoint: https://www.matchlivetv.it/webhooks/stripe
|
||||||
|
# Eventi: checkout.session.completed, customer.subscription.*, invoice.paid, invoice.payment_failed
|
||||||
|
# 3. Incolla whsec_... live in infra/.env come STRIPE_WEBHOOK_SECRET
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ENV_FILE="${ENV_FILE:-$ROOT/infra/.env}"
|
||||||
|
SERVER="${DEPLOY_SERVER:-eminux@192.168.1.146}"
|
||||||
|
REMOTE_DIR="${DEPLOY_PATH:-/opt/matchlivetv}"
|
||||||
|
WEBHOOK_SECRET=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--env-file) ENV_FILE="$2"; shift 2 ;;
|
||||||
|
--webhook-secret) WEBHOOK_SECRET="$2"; shift 2 ;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,14p' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "Argomento sconosciuto: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
echo "File non trovato: $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read_env() {
|
||||||
|
local key="$1"
|
||||||
|
grep -E "^${key}=" "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '\r'
|
||||||
|
}
|
||||||
|
|
||||||
|
STRIPE_SECRET_KEY="$(read_env STRIPE_SECRET_KEY)"
|
||||||
|
STRIPE_PREMIUM_LIGHT_PRICE_ID="$(read_env STRIPE_PREMIUM_LIGHT_PRICE_ID)"
|
||||||
|
STRIPE_PREMIUM_FULL_PRICE_ID="$(read_env STRIPE_PREMIUM_FULL_PRICE_ID)"
|
||||||
|
[[ -z "$WEBHOOK_SECRET" ]] && WEBHOOK_SECRET="$(read_env STRIPE_WEBHOOK_SECRET)"
|
||||||
|
|
||||||
|
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID="${STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID:-$(read_env STRIPE_PREMIUM_LIGHT_PRICE_ID)}"
|
||||||
|
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID="${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-$(read_env STRIPE_PREMIUM_FULL_PRICE_ID)}"
|
||||||
|
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID="${STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID:-}"
|
||||||
|
STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID="${STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID:-}"
|
||||||
|
|
||||||
|
for var in STRIPE_SECRET_KEY STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID; do
|
||||||
|
if [[ -z "${!var:-}" ]]; then
|
||||||
|
echo "Manca $var in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$WEBHOOK_SECRET" ]]; then
|
||||||
|
echo "Manca STRIPE_WEBHOOK_SECRET." >&2
|
||||||
|
echo "Crea un endpoint in Stripe → https://www.matchlivetv.it/webhooks/stripe" >&2
|
||||||
|
echo "Poi: $0 --webhook-secret whsec_..." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${STRIPE_SECRET_KEY}" == sk_test_* ]]; then
|
||||||
|
echo "Modalità: TEST (sk_test_) — pagamenti solo con carte di prova Stripe."
|
||||||
|
elif [[ "${STRIPE_SECRET_KEY}" == sk_live_* ]]; then
|
||||||
|
echo "Modalità: LIVE (sk_live_) — pagamenti reali."
|
||||||
|
else
|
||||||
|
echo "ATTENZIONE: STRIPE_SECRET_KEY non sembra sk_test_ né sk_live_" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Aggiorno $SERVER:$REMOTE_DIR/infra/.env ..."
|
||||||
|
|
||||||
|
ssh "$SERVER" "bash -s" <<REMOTE
|
||||||
|
set -euo pipefail
|
||||||
|
ENV="$REMOTE_DIR/infra/.env"
|
||||||
|
touch "\$ENV"
|
||||||
|
upsert() {
|
||||||
|
local key="\$1" val="\$2"
|
||||||
|
if grep -q "^\${key}=" "\$ENV" 2>/dev/null; then
|
||||||
|
sed -i "s|^\${key}=.*|\${key}=\${val}|" "\$ENV"
|
||||||
|
else
|
||||||
|
echo "\${key}=\${val}" >> "\$ENV"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
upsert STRIPE_SECRET_KEY '${STRIPE_SECRET_KEY}'
|
||||||
|
upsert STRIPE_WEBHOOK_SECRET '${WEBHOOK_SECRET}'
|
||||||
|
upsert STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID '${STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID}'
|
||||||
|
upsert STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID '${STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID}'
|
||||||
|
upsert STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID '${STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID}'
|
||||||
|
upsert STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID '${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID}'
|
||||||
|
upsert STRIPE_PREMIUM_LIGHT_PRICE_ID '${STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID}'
|
||||||
|
upsert STRIPE_PREMIUM_FULL_PRICE_ID '${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID}'
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo "Sync codice..."
|
||||||
|
bash "$ROOT/scripts/deploy/sync_to_server.sh"
|
||||||
|
|
||||||
|
echo "Rebuild rails + seed piani..."
|
||||||
|
ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose -f docker-compose.prod.yml --env-file .env up -d --build rails"
|
||||||
|
sleep 20
|
||||||
|
ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose -f docker-compose.prod.yml exec -T rails bundle exec rails db:seed" || true
|
||||||
|
|
||||||
|
echo "Verifica..."
|
||||||
|
ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose -f docker-compose.prod.yml exec -T rails bundle exec rails runner \"
|
||||||
|
puts 'stripe_enabled=' + MatchLiveTv.stripe_enabled?.to_s
|
||||||
|
puts 'light_price=' + (Plan['premium_light'].stripe_price_id || 'MISSING').to_s
|
||||||
|
puts 'full_price=' + (Plan['premium_full'].stripe_price_id || 'MISSING').to_s
|
||||||
|
\""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Prossimo passo: test browser"
|
||||||
|
echo " 1. Login su https://www.matchlivetv.it/login"
|
||||||
|
echo " 2. Società → Abbonamento → Attiva Premium Light"
|
||||||
|
echo " 3. Carta test 4242 4242 4242 4242 (solo se sk_test_)"
|
||||||
|
echo " 4. Stripe Dashboard → Webhooks → verifica eventi 200 su /webhooks/stripe"
|
||||||
83
scripts/test_billing_intervals.sh
Executable file
83
scripts/test_billing_intervals.sh
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test scelta mensile/annuale + redirect Checkout con price id corretto
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
COMPOSE="docker compose -f $ROOT/infra/docker-compose.yml"
|
||||||
|
BASE="${BASE_URL:-http://localhost:3000}"
|
||||||
|
COOKIES=$(mktemp)
|
||||||
|
trap 'rm -f "$COOKIES"' EXIT
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
fail=0
|
||||||
|
ok() { echo "OK $1"; pass=$((pass+1)); }
|
||||||
|
ko() { echo "FAIL $1"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
read_env() {
|
||||||
|
grep -E "^$1=" "$ROOT/infra/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
YEARLY_LIGHT=$(read_env STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID)
|
||||||
|
[[ -z "$YEARLY_LIGHT" ]] && YEARLY_LIGHT=$(read_env STRIPE_PREMIUM_LIGHT_PRICE_ID)
|
||||||
|
MONTHLY_LIGHT=$(read_env STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID)
|
||||||
|
|
||||||
|
login() {
|
||||||
|
curl -sf -c "$COOKIES" "$BASE/login" -o /tmp/mltv_login.html
|
||||||
|
T=$(grep -oP 'name="authenticity_token" value="\K[^"]+' /tmp/mltv_login.html | head -1)
|
||||||
|
curl -sf -c "$COOKIES" -b "$COOKIES" -X POST "$BASE/login" \
|
||||||
|
-d "authenticity_token=$T" -d "email=coach@matchlivetv.test" -d "password=password123" -o /dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "== Test intervalli billing =="
|
||||||
|
|
||||||
|
$COMPOSE exec -T rails bundle exec rails db:migrate >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
$COMPOSE exec -T rails bundle exec rails runner "
|
||||||
|
club = Club.first
|
||||||
|
club.update!(
|
||||||
|
billing_legal_name: 'ASD Test', billing_email: 'bill@test.it',
|
||||||
|
billing_address_line: 'Via 1', billing_city: 'Milano', billing_postal_code: '20100',
|
||||||
|
billing_vat_number: '12345678901', billing_recipient_code: 'ABCDEFG'
|
||||||
|
)
|
||||||
|
sub = club.subscription || club.create_subscription!(plan: Plan['free'], status: 'active')
|
||||||
|
sub.update!(plan: Plan['free'], stripe_subscription_id: nil, billing_interval: nil)
|
||||||
|
puts club.id
|
||||||
|
" >/dev/null 2>&1 && ok "setup club billing" || ko "setup club billing"
|
||||||
|
|
||||||
|
CLUB_ID=$($COMPOSE exec -T rails bundle exec rails runner "puts Club.first.id" 2>/dev/null | tail -1)
|
||||||
|
|
||||||
|
login && ok "login" || ko "login"
|
||||||
|
|
||||||
|
BODY=$(curl -sf -b "$COOKIES" "$BASE/clubs/$CLUB_ID/billing")
|
||||||
|
echo "$BODY" | grep -q "Attiva Premium Light — €40/anno" && ok "UI bottone annuale Light" || ko "UI bottone annuale Light"
|
||||||
|
if [[ -n "$MONTHLY_LIGHT" ]]; then
|
||||||
|
echo "$BODY" | grep -q "Attiva Premium Light — €5/mese" && ok "UI bottone mensile Light" || ko "UI bottone mensile Light"
|
||||||
|
else
|
||||||
|
echo "$BODY" | grep -q "€5/mese" && ko "UI mensile assente se non configurato" || ok "UI mensile nascosto (price id mancante)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$YEARLY_LIGHT" ]]; then
|
||||||
|
HEADERS=$(curl -s -D - -o /dev/null -b "$COOKIES" "$BASE/clubs/$CLUB_ID/checkout?plan=premium_light&interval=yearly" | tr -d '\r')
|
||||||
|
if echo "$HEADERS" | grep -qi "location:.*checkout\.stripe\.com"; then
|
||||||
|
ok "redirect checkout annuale"
|
||||||
|
else
|
||||||
|
ko "redirect checkout annuale"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PRICE_USED=$($COMPOSE exec -T rails bundle exec rails runner "
|
||||||
|
puts Billing::Stripe::PriceCatalog.price_id(plan_slug: 'premium_light', interval: 'yearly')
|
||||||
|
" 2>/dev/null | tail -1)
|
||||||
|
[[ "$PRICE_USED" == "$YEARLY_LIGHT" ]] && ok "price id annuale corretto ($PRICE_USED)" || ko "price id annuale ($PRICE_USED vs $YEARLY_LIGHT)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
$COMPOSE exec -T rails bundle exec rspec \
|
||||||
|
spec/services/billing/stripe/price_catalog_spec.rb \
|
||||||
|
spec/services/billing/stripe/checkout_session_spec.rb \
|
||||||
|
spec/services/billing/stripe/change_plan_spec.rb \
|
||||||
|
spec/helpers/public/billing_helper_spec.rb \
|
||||||
|
--format progress 2>&1 | tail -3
|
||||||
|
RS=$?
|
||||||
|
[[ $RS -eq 0 ]] && ok "RSpec billing intervals" || ko "RSpec billing intervals"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Risultato: $pass OK, $fail FAIL"
|
||||||
|
[[ "$fail" -eq 0 ]]
|
||||||
61
scripts/test_stripe_e2e.sh
Executable file
61
scripts/test_stripe_e2e.sh
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smoke test pagamenti: pagina billing + redirect Checkout (+ opzionale profilo completo).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${BASE_URL:-https://www.matchlivetv.it}"
|
||||||
|
EMAIL="${TEST_EMAIL:-coach@matchlivetv.test}"
|
||||||
|
PASSWORD="${TEST_PASSWORD:-password123}"
|
||||||
|
PLAN="${TEST_PLAN:-premium_light}"
|
||||||
|
COOKIES=$(mktemp)
|
||||||
|
trap 'rm -f "$COOKIES"' EXIT
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
fail=0
|
||||||
|
ok() { echo "OK $1"; pass=$((pass+1)); }
|
||||||
|
ko() { echo "FAIL $1"; fail=$((fail+1)); }
|
||||||
|
|
||||||
|
echo "== Stripe E2E smoke @ $BASE =="
|
||||||
|
|
||||||
|
curl -sf "$BASE/up" >/dev/null && ok "health /up" || ko "health /up"
|
||||||
|
|
||||||
|
curl -sf -c "$COOKIES" "$BASE/login" -o /tmp/mltv_login.html
|
||||||
|
T=$(grep -oP 'name="authenticity_token" value="\K[^"]+' /tmp/mltv_login.html | head -1 || true)
|
||||||
|
if [[ -z "$T" ]]; then
|
||||||
|
ko "login page (token CSRF)"
|
||||||
|
else
|
||||||
|
CODE=$(curl -s -o /dev/null -w "%{http_code}" -c "$COOKIES" -b "$COOKIES" -X POST "$BASE/login" \
|
||||||
|
-d "authenticity_token=$T" -d "email=$EMAIL" -d "password=$PASSWORD")
|
||||||
|
[[ "$CODE" == "302" || "$CODE" == "303" ]] && ok "login $EMAIL" || ko "login ($CODE)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CLUB_LINK=$(curl -sf -b "$COOKIES" "$BASE/" | grep -oP 'href="/clubs/[^"]+/billing"' | head -1 || true)
|
||||||
|
if [[ -z "$CLUB_LINK" ]]; then
|
||||||
|
# prova da pagina clubs
|
||||||
|
CLUB_ID=$(curl -sf -b "$COOKIES" "$BASE/" | grep -oP '/clubs/[0-9a-f-]{36}' | head -1 | sed 's|/clubs/||' || true)
|
||||||
|
CLUB_LINK="/clubs/${CLUB_ID}/billing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${CLUB_LINK:-}" || "$CLUB_LINK" == "/clubs//billing" ]]; then
|
||||||
|
ko "trova link billing club"
|
||||||
|
else
|
||||||
|
ok "billing path $CLUB_LINK"
|
||||||
|
BODY=$(curl -sf -b "$COOKIES" "$BASE$CLUB_LINK")
|
||||||
|
echo "$BODY" | grep -qi "stripe non configurato" && ko "Stripe abilitato in UI" || ok "Stripe abilitato in UI"
|
||||||
|
echo "$BODY" | grep -q "Attiva Premium" && ok "pulsante Attiva Premium" || ko "pulsante Attiva Premium"
|
||||||
|
echo "$BODY" | grep -q "Dati di fatturazione" && ok "link profilo fatturazione" || ko "link profilo fatturazione"
|
||||||
|
|
||||||
|
CHECKOUT_URL="${CLUB_LINK%/billing}/checkout?plan=${PLAN}"
|
||||||
|
HEADERS=$(curl -s -D - -o /dev/null -b "$COOKIES" "$BASE$CHECKOUT_URL" | tr -d '\r')
|
||||||
|
if echo "$HEADERS" | grep -qi "location:.*checkout\.stripe\.com"; then
|
||||||
|
ok "redirect Stripe Checkout"
|
||||||
|
elif echo "$HEADERS" | grep -qi "location:.*billing/profile"; then
|
||||||
|
ok "redirect profilo fatturazione (dati incompleti — completa prima del pagamento)"
|
||||||
|
else
|
||||||
|
ko "redirect checkout (atteso checkout.stripe.com o billing/profile)"
|
||||||
|
echo "$HEADERS" | grep -i "^location:" | head -3 || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Risultato: $pass OK, $fail FAIL"
|
||||||
|
[[ "$fail" -eq 0 ]]
|
||||||
Reference in New Issue
Block a user