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
class BillingInvoicesController < BaseController
before_action :set_club
before_action :set_invoice, only: %i[edit update]
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
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
def create
@invoice = @club.billing_invoices.build(invoice_params)
@invoice.source = "aruba"
@invoice.source = "manual"
@invoice.status = "draft"
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
@payment = @invoice.billing_payment
flash.now[:alert] = @invoice.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
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
def set_club
@club = Club.find(params[:club_id])
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
params.require(:billing_invoice).permit(
:number,
@@ -35,8 +92,6 @@ module Admin
:amount_cents,
:amount_euros,
:currency,
:status,
:aruba_document_id,
:billing_payment_id,
:notes,
:pdf

View File

@@ -10,21 +10,34 @@ module Public
def update_profile
@club.assign_attributes(billing_profile_params)
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
flash.now[:alert] = @club.errors.full_messages.join(", ")
render :profile, status: :unprocessable_entity
end
end
def sync_payments
def cancel_subscription
unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: "Stripe non configurato."
return
end
count = Billing::Stripe::SyncPayments.call(club: @club)
redirect_to public_club_billing_path(@club), notice: "Sincronizzati #{count} pagamenti da Stripe."
Billing::Stripe::CancelSubscription.call(club: @club)
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
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
end
@@ -49,6 +62,16 @@ module Public
require_club_owner!(@club)
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
params.require(:club).permit(
:billing_entity_type,

View File

@@ -32,7 +32,14 @@ module Public
Billing::AssignPlan.call(club: club, plan_slug: plan)
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
redirect_to public_club_path(club), notice: "Società e prima squadra create."
end
@@ -66,13 +73,14 @@ module Public
def billing
require_club_owner!(@club)
sync_checkout_return!
finalize_subscription_if_due!
@subscription = @club.subscription&.reload
apply_checkout_flash!
@team = @club.teams.first!
@entitlements = @team.entitlements
@subscription = @club.subscription
@plans = Plan.ordered
@payments = @club.billing_payments.recent.limit(50)
@invoices = @club.billing_invoices.recent.limit(50)
@payments = @club.billing_payments.recent.includes(:invoice).limit(50)
end
def checkout
@@ -83,15 +91,29 @@ module Public
end
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
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
end
# Sempre Stripe Checkout (hosted): i dati carta non passano mai dal nostro server.
url = Billing::Stripe::CheckoutSession.new(club: @club, user: current_user, plan_slug: plan_slug).url
redirect_to url, allow_other_host: true
if sub&.stripe_subscription_id.present? && sub.active?
Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug, interval: interval)
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
Rails.logger.warn("[Stripe checkout] #{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 @club.billing_profile_complete?
redirect_to public_club_billing_profile_path(@club),
alert: "Completa i dati di fatturazione prima di attivare un piano premium."
redirect_to public_club_billing_profile_path(
@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
def apply_checkout_flash!
case params[:checkout]
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"
flash.now[:alert] = "Pagamento annullato. Nessun addebito è stato effettuato."
end

View File

@@ -4,10 +4,17 @@ module Public
include ::SeoHelper
include ::LegalHelper
helper ApplicationHelper
helper_method :current_user, :logged_in?
before_action :set_site_locale
private
def set_site_locale
I18n.locale = :it
end
def 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 BillingHelper
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:)
return { kind: :current, label: "Piano attuale" } if current_slug == target_plan.slug
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil)
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
unless MatchLiveTv.stripe_enabled?
return { kind: :disabled, label: "Stripe non configurato" }
end
if current_slug == "free" || !stripe_subscription_active
return { kind: :checkout, label: "Attiva #{target_plan.name}" }
intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug)
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
if Plan.tier(target_plan.slug) > Plan.tier(current_slug)
{ kind: :change, label: "Passa a #{target_plan.name}" }
if current_slug == "free" || !stripe_subscription_active
{ kind: :checkout_options, plan: target_plan, intervals: intervals }
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
def stripe_subscription_active?(subscription)
subscription&.stripe_subscription_id.present? && subscription.active?
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

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"
STATUSES = %w[draft issued sent cancelled].freeze
SOURCES = %w[aruba manual].freeze
SOURCES = %w[manual aruba].freeze
belongs_to :club
belongs_to :billing_payment, class_name: "Billing::Payment", optional: true
@@ -15,7 +15,8 @@ module Billing
validates :currency, presence: true
validates :status, inclusion: { in: STATUSES }
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) }
@@ -28,16 +29,35 @@ module Billing
end
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
private
def pdf_present_when_issued
return unless status.in?(%w[issued sent])
return unless validation_context == :issue
return if pdf.attached?
errors.add(:pdf, "è obbligatorio per le fatture emesse")
errors.add(:pdf, "è obbligatorio per emettere la fattura")
end
end
end

View File

@@ -21,5 +21,17 @@ module Billing
def formatted_amount
format("%.2f €", amount_euros)
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

View File

@@ -20,18 +20,34 @@ module ClubBillingProfile
billing_profile_errors.empty?
end
# Campi minimi per intestazione fattura e invio (email PDF + SDI/PEC).
def billing_profile_errors
errors = []
errors << "Tipo intestatario" if billing_entity_type.blank?
errors << "Ragione sociale o nome intestatario" if billing_legal_name.blank?
errors << "Email di fatturazione" if billing_email.blank?
errors << "Indirizzo" if billing_address_line.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 << "P.IVA o Codice Fiscale" if billing_vat_number.blank? && billing_fiscal_code.blank?
errors << "Codice destinatario SDI o PEC" if billing_recipient_code.blank? && billing_pec.blank?
errors << "Paese (ISO)" if billing_country.blank? || billing_country.to_s.length != 2
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
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
parts = [billing_legal_name.presence, billing_vat_number.presence && "P.IVA #{billing_vat_number}"]
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 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
VALID_PLANS = CheckoutSession::VALID_PLANS.freeze
def self.call(club:, plan_slug:)
new(club: club, plan_slug: plan_slug).call
def self.call(club:, plan_slug:, interval: nil)
new(club: club, plan_slug: plan_slug, interval: interval).call
end
def initialize(club:, plan_slug:)
def initialize(club:, plan_slug:, interval: nil)
@club = club
@plan_slug = plan_slug.to_s
@interval = interval
end
def call
@@ -21,10 +19,13 @@ module Billing
sub = @club.subscription
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)
raise "Price ID Stripe mancante per #{@plan_slug}" if price_id.blank?
interval = resolve_interval(sub)
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)
item_id = stripe_sub.items.data.first.id
@@ -32,24 +33,23 @@ module Billing
updated = ::Stripe::Subscription.update(
stripe_sub.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"
)
apply_local_plan!(updated)
apply_local_plan!(updated, interval: interval)
updated
end
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
def resolve_interval(sub)
return PriceCatalog.normalize_interval(@interval) if @interval.present?
sub.billing_interval.presence || PriceCatalog::DEFAULT_INTERVAL
end
def apply_local_plan!(stripe_sub)
def apply_local_plan!(stripe_sub, interval:)
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
Billing::AssignPlan.call(
club: @club,
@@ -60,7 +60,8 @@ module Billing
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
cancel_at_period_end: stripe_sub.cancel_at_period_end,
billing_interval: interval
}
)
end

View File

@@ -3,44 +3,45 @@ module Billing
class CheckoutSession
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
@user = user
@plan_slug = plan_slug.to_s
@interval = PriceCatalog.normalize_interval(interval)
end
def url
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug)
plan = Plan[@plan_slug]
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?
price_id = PriceCatalog.price_id(plan_slug: @plan_slug, interval: @interval)
customer_id = ensure_customer_id!
CustomerSync.call(club: @club, customer_id: customer_id)
session_params = {
session = ::Stripe::Checkout::Session.create(
mode: "subscription",
customer: customer_id,
line_items: [{ price: price_id, quantity: 1 }],
success_url: success_url,
cancel_url: cancel_url,
metadata: { club_id: @club.id, user_id: @user.id, plan_slug: @plan_slug },
subscription_data: { metadata: { club_id: @club.id, plan_slug: @plan_slug } },
metadata: {
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",
customer_update: { address: "auto", name: "auto" },
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
end
@@ -58,20 +59,6 @@ module Billing
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!
sub = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
return sub.stripe_customer_id if sub.stripe_customer_id.present?
@@ -86,7 +73,7 @@ module Billing
end
def success_url
"#{billing_url}?checkout=success"
"#{billing_url}?checkout=success&session_id={CHECKOUT_SESSION_ID}"
end
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
return unless @club
@stripe_invoice = ensure_payments_expanded(@stripe_invoice)
payment = Billing::Payment.find_or_initialize_by(stripe_invoice_id: @stripe_invoice.id)
payment.assign_attributes(
club: @club,
@@ -23,7 +25,7 @@ module Billing
currency: @stripe_invoice.currency,
status: map_status(@stripe_invoice.status),
plan_slug: plan_slug,
description: description,
description: PaymentDescription.for_stripe_invoice(@stripe_invoice),
paid_at: paid_at,
invoice_pdf_url: @stripe_invoice.invoice_pdf,
receipt_url: @stripe_invoice.hosted_invoice_url
@@ -38,7 +40,7 @@ module Billing
club_id = metadata_club_id(@stripe_invoice)
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?
Club.joins(:subscription).find_by(subscriptions: { stripe_subscription_id: sub_id })
@@ -52,25 +54,26 @@ module Billing
end
def plan_slug
meta = @stripe_invoice.subscription_details&.metadata || @stripe_invoice.metadata
meta["plan_slug"] || meta[:plan_slug]
end
def description
lines = @stripe_invoice.lines&.data
return @stripe_invoice.description if lines.blank?
lines.first.description.presence || @stripe_invoice.description
meta = InvoiceMetadata.merged(@stripe_invoice)
slug = meta["plan_slug"]
slug = "premium_full" if slug == "premium"
slug.presence_in(%w[premium_light premium_full])
end
def payment_intent_id
pi = @stripe_invoice.payment_intent
pi.is_a?(String) ? pi : pi&.id
InvoicePaymentRefs.payment_intent_id(@stripe_invoice)
end
def charge_id
charge = @stripe_invoice.charge
charge.is_a?(String) ? charge : charge&.id
InvoicePaymentRefs.charge_id(@stripe_invoice)
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
def paid_at

View File

@@ -17,6 +17,7 @@ module Billing
starting_after = nil
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.data.each do |invoice|
next unless invoice.status == "paid"
@@ -29,6 +30,9 @@ module Billing
starting_after = list.data.last.id
end
# Allinea anche piano/intervallo da abbonamento Stripe attivo (non solo lo storico pagamenti).
CheckoutSync.call(club: @club)
count
end
end

View File

@@ -12,9 +12,9 @@ module Billing
def call
case @event.type
when "checkout.session.completed"
handle_checkout_completed(@event.data.object)
CheckoutSync.from_session(@event.data.object)
when "customer.subscription.updated", "customer.subscription.created"
sync_subscription(@event.data.object)
CheckoutSync.from_subscription(@event.data.object)
when "customer.subscription.deleted"
downgrade_to_free(@event.data.object)
when "invoice.payment_failed"
@@ -26,47 +26,6 @@ module Billing
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)
club = Club.joins(:subscription)
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
@@ -99,52 +58,6 @@ module Billing
club.subscription.update!(status: "past_due")
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

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>
<p><%= link_to "← Dashboard", admin_root_path %> · <%= link_to "Nuova fattura", new_admin_club_billing_invoice_path(@club), class: "btn btn-primary" %></p>
<h1>Fatturazione — <%= @club.name %></h1>
<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? %>
<table class="data">
<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>
<tbody>
<% @invoices.each do |inv| %>
@@ -13,7 +59,8 @@
<td><%= inv.issued_on %></td>
<td><%= inv.formatted_amount %></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>
<% end %>
</tbody>

View File

@@ -1,8 +1,12 @@
<h1>Nuova fattura Aruba — <%= @club.name %></h1>
<p style="color:#666">Carica il PDF emesso da Aruba Fatture e collega il pagamento se disponibile.</p>
<h1>Nuova fattura — <%= @club.name %></h1>
<% 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">
<%= 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.text_field :number, required: true %>
@@ -10,27 +14,19 @@
<%= f.date_field :issued_on, required: true %>
<%= 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.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.label :notes, "Note interne (opzionale)" %>
<%= 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 %>
</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>
<%= 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="/marketing.css?v=31">
<link rel="stylesheet" href="/marketing.css?v=32">
</head>
<body>
<%= render "shared/marketing_nav" %>
@@ -18,6 +18,6 @@
<%= render "shared/marketing_footer" %>
<script src="/branding-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>
</html>

View File

@@ -5,8 +5,21 @@
<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>
<% 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">
<%= 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 %>
<%= submit_tag "Salva dati di fatturazione", class: "btn btn-primary" %>
<% end %>

View File

@@ -3,12 +3,13 @@
<div class="wrap" style="padding-top:20px">
<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/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>
</div>

View File

@@ -22,9 +22,16 @@
<%= label_tag :plan, "Piano iniziale (per tutta la società)" %>
<%= select_tag :plan, options_for_select([
["Free — 1 responsabile trasmissione per squadra, 1 live", "free"],
["Premium Light — €40/anno o €5/mese", "premium_light"],
["Premium Full — €200/anno o €20/mese", "premium_full"]
], params[:plan] || "free") %>
["Premium Light", "premium_light"],
["Premium Full", "premium_full"]
], 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 %>
<%= submit_tag "Crea società", class: "btn btn-primary" %>
<% end %>

View File

@@ -37,7 +37,7 @@
<h2>Diretta terminata</h2>
<p>Lo streaming di questa partita è stato chiuso.</p>
<% 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 %>
<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" %>

View File

@@ -38,7 +38,7 @@
<td><strong><%= match.opponent_name %></strong></td>
<td>
<% if match.scheduled_at %>
<%= l match.scheduled_at.in_time_zone, format: :long %>
<%= l_local(match.scheduled_at) %>
<% else %>
<span style="color:#888">Senza orario</span>
<% end %>

View File

@@ -32,7 +32,7 @@
<p>Registrazione indicizzata. Il playback dipende dalla retention del piano Premium.</p>
<% end %>
<% 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 %>
</div>
<% end %>

View File

@@ -16,7 +16,6 @@
<%= render "shared/input_toggle",
name: :password,
label: "Password",
input_type: "text",
required: true,
autocomplete: "current-password" %>
<p class="auth-forgot"><%= link_to "Password dimenticata?", public_password_forgot_path %></p>

View File

@@ -29,7 +29,7 @@
<% elsif plan.slug == "free" %>
<p style="color:#888;font-size:0.85rem">Contatta il supporto per downgrade.</p>
<% 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 %>
<p style="color:#888">Stripe non configurato</p>
<% end %>
@@ -41,9 +41,7 @@
<p style="color:#aaa">Esigenze diverse? <%= mail_to "info@matchlive.it", "Contattaci" %> per un'offerta su misura.</p>
</div>
<% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %>
<p style="margin-top:20px"><%= button_to "Gestisci su Stripe", public_club_portal_path(@team.club), class: "btn btn-secondary" %></p>
<% end %>
<p style="margin-top:20px"><%= link_to "Pagamenti e fatture della società", public_club_billing_path(@team.club) %></p>
<p><%= link_to "← Dettagli squadra", public_team_details_path(@team) %></p>
</div>

View File

@@ -1,15 +1,14 @@
<%# locals: (club:, payments:, invoices:) %>
<%# locals: (club:, payments:) %>
<section class="billing-documents" style="margin-top:40px">
<h2>Pagamenti e fatture</h2>
<p style="color:#888;font-size:0.9rem">
Qui trovi lo storico dei pagamenti e le fatture emesse alla società.
I pagamenti premium sono elaborati in modo sicuro tramite Stripe.
Storico dei pagamenti della società e relative fatture emesse da Match Live TV.
</p>
<% unless club.billing_profile_complete? %>
<div class="flash alert" style="margin:16px 0">
<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" %>
</div>
<% else %>
@@ -19,14 +18,7 @@
</p>
<% end %>
<% if MatchLiveTv.stripe_enabled? && club.subscription&.stripe_customer_id.present? %>
<%= 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>
<h3 style="margin-top:28px;font-size:1.1rem">Pagamenti e fatture</h3>
<% if payments.any? %>
<table class="data billing-table">
<thead>
@@ -34,54 +26,34 @@
<th>Data</th>
<th>Descrizione</th>
<th>Importo</th>
<th>Stato</th>
<th>Stato pagamento</th>
<th>Fattura</th>
<th></th>
</tr>
</thead>
<tbody>
<% payments.each do |payment| %>
<% inv = payment.invoice_for_display %>
<tr>
<td><%= payment.paid_at ? l(payment.paid_at, format: :short) : "—" %></td>
<td><%= payment.description.presence || payment.plan_slug&.humanize || "Abbonamento" %></td>
<td><%= payment.paid_at ? l_local(payment.paid_at, format: :short) : "—" %></td>
<td><%= payment.display_description %></td>
<td><%= payment.formatted_amount %></td>
<td><%= payment.status %></td>
<td><%= payment.display_status %></td>
<td>
<% if payment.receipt_url.present? %>
<%= link_to "Ricevuta Stripe", payment.receipt_url, target: "_blank", rel: "noopener" %>
<% end %>
</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) %>
<% if inv.present? %>
<%= inv.display_number %>
· <%= inv.display_status %>
<% 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>
<% else %>
<span style="color:#888">—</span>
<% end %>
</td>
</tr>
@@ -89,6 +61,6 @@
</tbody>
</table>
<% 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 %>
</section>

View File

@@ -2,64 +2,65 @@
<% if show_legend %>
<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">
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.
</p>
<% end %>
<%= label_tag "club[billing_entity_type]", "Tipo intestatario" %>
<%= label_tag "club[billing_entity_type]", "Tipo intestatario *" %>
<%= select_tag "club[billing_entity_type]",
options_for_select(Club::BILLING_ENTITY_TYPES.map { |k, v| [v, k] }, record.billing_entity_type),
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" %>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<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" %>
</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" %>
</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" %>
<%= label_tag "club[billing_phone]", "Telefono (opzionale)" %>
<%= 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" %>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:12px">
<div>
<%= label_tag "club[billing_city]", "Città" %>
<%= label_tag "club[billing_city]", "Città *" %>
<%= text_field_tag "club[billing_city]", record.billing_city %>
</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" %>
</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 %>
</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 %>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<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" %>
</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" %>
</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"] %>
<p style="color:#aaa;margin-bottom:8px">
Società: <strong><%= club.name %></strong>
· Piano attuale: <strong><%= current_plan.name %></strong>
(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>
<% if subscription&.stripe_subscription_id.present? %>
<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? %>
· <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 %>
</p>
<% 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 %>
<% visible_type = local_assigns.fetch(:input_type, "text").to_s == "email" ? "email" : "text" %>
<div class="input-toggle" data-input-toggle>
<% visible_type = local_assigns.fetch(:input_type, "text").to_s %>
<% 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 %>
<div class="input-toggle__field">
<%= tag.input(
type: "password",
name: name,
id: field_id,
value: local_assigns.fetch(:value, nil),
required: local_assigns.fetch(:required, false),
autocomplete: local_assigns.fetch(:autocomplete, nil),
minlength: local_assigns.fetch(:minlength, nil),
class: "input-toggle__input",
data: { visible_type: visible_type }
) %>
<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>
<% if masked %>
<%= tag.input(
type: "password",
name: name,
id: field_id,
value: local_assigns.fetch(:value, nil),
required: local_assigns.fetch(:required, false),
autocomplete: local_assigns.fetch(:autocomplete, nil),
minlength: local_assigns.fetch(:minlength, nil),
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>
<% 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>

View File

@@ -3,6 +3,7 @@
<% entitlements ||= @entitlements %>
<% subscription ||= @subscription %>
<% 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? %>
<div class="plans">
@@ -10,18 +11,23 @@
<% action = billing_mode ? plan_billing_action(
current_slug: current_slug,
target_plan: plan,
stripe_subscription_active: stripe_subscription_active?(subscription)
stripe_subscription_active: stripe_subscription_active?(subscription),
current_interval: current_interval
) : nil %>
<div class="plan-card <%= "featured" if plan.slug == current_slug || (!billing_mode && plan.slug == "premium_light") %>">
<h3><%= plan.name %></h3>
<% if plan.slug == "free" %>
<div class="plan-price">€0</div>
<% elsif plan.slug == "premium_light" %>
<div class="plan-price" style="color:#e53935">€40<span style="font-size:1rem;font-weight:400">/anno</span></div>
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€5</strong>/mese</p>
<% light_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
<% 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 %>
<div class="plan-price">€200<span style="font-size:1rem;font-weight:400">/anno</span></div>
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€20</strong>/mese</p>
<% full_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
<% 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 %>
<ul>
<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>
</ul>
<% if billing_mode %>
<% case action[:kind] %>
<% when :current %>
<% action_kind = action[:kind] %>
<% if action_kind == :current %>
<span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span>
<% when :contact, :disabled %>
<p style="color:#888;font-size:0.85rem"><%= action[:label] %></p>
<% when :checkout, :change %>
<%= link_to action[:label], public_club_checkout_path(club, plan: plan.slug), class: "btn btn-primary" %>
<% elsif action_kind == :contact || action_kind == :disabled %>
<p class="plan-action-hint"><%= action[:label] %></p>
<% elsif action_kind == :checkout_options || action_kind == :change_options || action_kind == :interval_switch %>
<% 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 %>
<% elsif plan.slug == "free" %>
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %>
<% 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 %>
</div>
<% end %>
</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.active_job.queue_adapter = :sidekiq
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 }
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
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
ENV.fetch("STRIPE_PREMIUM_LIGHT_PRICE_ID", ENV.fetch("STRIPE_PREMIUM_PRICE_ID", ""))
stripe_premium_light_yearly_price_id.to_s
end
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
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"
resources :teams, only: %i[index show]
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
resources :sessions, only: %i[index show] do
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/profile", to: "club_billing#profile", as: :club_billing_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/checkout", to: "clubs#checkout", as: :club_checkout
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.
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
enable_extension "pgcrypto"
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.integer "amount_cents", null: false
t.string "currency", default: "eur", null: false
t.string "status", default: "issued", null: false
t.string "source", default: "aruba", null: false
t.string "status", default: "draft", null: false
t.string "source", default: "manual", null: false
t.string "aruba_document_id"
t.text "notes"
t.datetime "created_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_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", "number"], name: "index_billing_invoices_on_club_id_and_number", unique: true
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 "updated_at", 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 ["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)"

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-primary { background: #e53935; 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:hover { background: #333; text-decoration: none; color: #fff; }
.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;
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 {
position: absolute;
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; }
.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"
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_full) { Plan["premium_full"] }
let(:free_plan) { Plan["free"] }
it "segna il piano corrente" 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
it "propone due opzioni checkout da free" do
action = helper.plan_billing_action(
current_slug: "free",
target_plan: premium_light,
stripe_subscription_active: false
)
expect(action[:kind]).to eq(:checkout)
expect(action[:label]).to include("Attiva")
expect(action[:kind]).to eq(:checkout_options)
expect(action[:intervals]).to eq(%w[monthly yearly])
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(
current_slug: "premium_light",
target_plan: premium_full,
stripe_subscription_active: true
stripe_subscription_active: true,
current_interval: "yearly"
)
expect(action[:kind]).to eq(:change)
expect(action[:label]).to include("Passa a")
expect(action[:kind]).to eq(:change_options)
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

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",
primary_color: "#e53935",
secondary_color: "#ffffff",
billing_entity_type: "company",
billing_legal_name: "ASD Test",
billing_email: "billing@test.it",
billing_address_line: "Via Roma 1",
billing_city: "Milano",
billing_province: "MI",
billing_postal_code: "20100",
billing_country: "IT",
billing_vat_number: "12345678901",
billing_recipient_code: "ABCDEFG"
)
@@ -26,4 +29,16 @@ RSpec.describe ClubBillingProfile do
club.billing_pec = nil
expect(club.billing_profile_complete?).to be false
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

View File

@@ -6,9 +6,9 @@ RSpec.describe "Public club billing", type: :request do
end
let!(:club) do
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_city: "Milano", billing_postal_code: "20100", billing_vat_number: "12345678901",
billing_recipient_code: "ABCDEFG")
billing_entity_type: "company", billing_legal_name: "ASD Billing", billing_email: "bill@test.it",
billing_address_line: "Via 1", billing_city: "Milano", billing_province: "MI", billing_postal_code: "20100",
billing_country: "IT", billing_vat_number: "12345678901", billing_recipient_code: "ABCDEFG")
ClubMembership.create!(user: coach, club: c, role: "owner")
c.teams.create!(name: "U15", sport: "volleyball")
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
club.billing_payments.create!(
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!(
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!
get public_club_billing_path(club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Pagamenti effettuati")
expect(response.body).to include("Fatture")
expect(response.body).not_to include("Aruba")
expect(response.body).to include("Pagamenti e fatture")
expect(response.body).not_to include("Ricevuta Stripe")
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("2026/100")
expect(response.body).to include("In preparazione")
end
it "salva profilo fatturazione" do
@@ -57,6 +61,89 @@ RSpec.describe "Public club billing", type: :request do
login!
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

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_premium_light_price_id).and_return("price_light")
allow(MatchLiveTv).to receive(:stripe_premium_full_price_id).and_return("price_full")
Plan.find_by!(slug: "premium_light").update!(stripe_price_id: "price_light")
Plan.find_by!(slug: "premium_full").update!(stripe_price_id: "price_full")
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 "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(: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(
"sub_test_123",
hash_including(
items: [{ id: "si_test", price: "price_full" }],
metadata: { club_id: club.id, plan_slug: "premium_full" }
items: [{ id: "si_test", price: "price_full_y" }],
metadata: { club_id: club.id, plan_slug: "premium_full", billing_interval: "yearly" }
)
)
expect(club.reload.subscription.plan.slug).to eq("premium_full")
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 {
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/)
end
end

View File

@@ -15,49 +15,38 @@ RSpec.describe Billing::Stripe::CheckoutSession do
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_price_id).and_return("price_light")
allow(MatchLiveTv).to receive(:stripe_premium_full_price_id).and_return("price_full")
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")
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
it "crea checkout senza subscription esistente" do
session = double(url: "https://checkout.stripe.com/test")
it "crea checkout annuale" do
session = double(url: "https://checkout.stripe.com/yearly")
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(Stripe::Checkout::Session).to receive(:create).with(
hash_including(
mode: "subscription",
customer: "cus_new",
line_items: [{ price: "price_light", quantity: 1 }],
subscription_data: { metadata: { club_id: club.id, plan_slug: "premium_light" } }
line_items: [{ price: "price_light_y", quantity: 1 }],
metadata: hash_including(plan_slug: "premium_light", billing_interval: "yearly"),
subscription_data: { metadata: hash_including(billing_interval: "yearly") }
)
).and_return(session)
url = described_class.new(club: club, user: user, plan_slug: "premium_light").url
expect(url).to eq("https://checkout.stripe.com/test")
url = described_class.new(club: club, user: user, plan_slug: "premium_light", interval: "yearly").url
expect(url).to eq("https://checkout.stripe.com/yearly")
end
it "crea checkout per cambio piano con subscription esistente" do
Billing::AssignPlan.call(
club: club,
plan_slug: "premium_light",
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")
it "crea checkout mensile" do
session = double(url: "https://checkout.stripe.com/monthly")
allow(Stripe::Customer).to receive(:create).and_return(double(id: "cus_m"))
allow(Billing::Stripe::CustomerSync).to receive(:call)
expect(Stripe::Checkout::Session).to receive(:create).with(
hash_including(
customer: "cus_x",
subscription: "sub_x",
line_items: [{ price: "price_full", quantity: 1 }]
)
hash_including(line_items: [{ price: "price_light_m", quantity: 1 }])
).and_return(session)
url = described_class.new(club: club, user: user, plan_slug: "premium_full").url
expect(url).to eq("https://checkout.stripe.com/change")
url = described_class.new(club: club, user: user, plan_slug: "premium_light", interval: "monthly").url
expect(url).to eq("https://checkout.stripe.com/monthly")
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
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" })
end
it "crea un pagamento da fattura Stripe" do
invoice = double(
id: "in_test_1",
subscription: "sub_x",
metadata: { "club_id" => club.id },
metadata: { "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "yearly" },
parent: nil,
amount_paid: 4000,
currency: "eur",
status: "paid",
description: "Premium Light",
payment_intent: "pi_x",
charge: "ch_x",
invoice_pdf: "https://stripe.com/invoice.pdf",
hosted_invoice_url: "https://stripe.com/hosted",
status_transitions: double(paid_at: Time.current.to_i),
lines: double(data: [double(description: "Premium Light annuale")]),
subscription_details: nil
lines: double(data: [
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)
@@ -33,5 +38,6 @@ RSpec.describe Billing::Stripe::RecordPayment do
expect(payment.club).to eq(club)
expect(payment.amount_cents).to eq(4000)
expect(payment.stripe_invoice_id).to eq("in_test_1")
expect(payment.description).to eq("Premium Light — €40/anno")
end
end