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)