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:
2026-06-02 17:10:12 +02:00
parent 148402a97c
commit a5e781729c
78 changed files with 2457 additions and 424 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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(" · ")

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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 lintestazione delle fatture e linvio tramite SDI o PEC.</p> <p style="color:#aaa">Società: <strong><%= @club.name %></strong>. Inserisci i dati per lintestazione delle fatture e linvio 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 %>

View File

@@ -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>

View File

@@ -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 %>

View File

@@ -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" %>

View File

@@ -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 %>

View File

@@ -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 %>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 lemissione.</p> <p style="color:#888">Nessun pagamento registrato. Dopo un abbonamento premium comparirà qui.</p>
<% end %> <% end %>
</section> </section>

View File

@@ -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>

View File

@@ -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 %>

View File

@@ -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>

View File

@@ -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 %>

View 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>

View File

@@ -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

View 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

View File

@@ -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?

View 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"

View File

@@ -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

View File

@@ -0,0 +1,5 @@
class AddBillingIntervalToSubscriptions < ActiveRecord::Migration[7.2]
def change
add_column :subscriptions, :billing_interval, :string
end
end

View File

@@ -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
View File

@@ -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)"

View File

@@ -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;
}

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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
View 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 lID 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

View File

@@ -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=

View File

@@ -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=

View File

@@ -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:

View File

@@ -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
View 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"

View 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"

View 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
View 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 ]]