diff --git a/backend/app/controllers/admin/billing_invoices_controller.rb b/backend/app/controllers/admin/billing_invoices_controller.rb index 1eadf23..ebefc07 100644 --- a/backend/app/controllers/admin/billing_invoices_controller.rb +++ b/backend/app/controllers/admin/billing_invoices_controller.rb @@ -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 diff --git a/backend/app/controllers/public/club_billing_controller.rb b/backend/app/controllers/public/club_billing_controller.rb index bbe9481..802e35d 100644 --- a/backend/app/controllers/public/club_billing_controller.rb +++ b/backend/app/controllers/public/club_billing_controller.rb @@ -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, diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb index b79f2ee..f0f5508 100644 --- a/backend/app/controllers/public/clubs_controller.rb +++ b/backend/app/controllers/public/clubs_controller.rb @@ -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 diff --git a/backend/app/controllers/public/site_base_controller.rb b/backend/app/controllers/public/site_base_controller.rb index 1bce238..e6f5b26 100644 --- a/backend/app/controllers/public/site_base_controller.rb +++ b/backend/app/controllers/public/site_base_controller.rb @@ -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) diff --git a/backend/app/helpers/application_helper.rb b/backend/app/helpers/application_helper.rb new file mode 100644 index 0000000..c587773 --- /dev/null +++ b/backend/app/helpers/application_helper.rb @@ -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 diff --git a/backend/app/helpers/public/billing_helper.rb b/backend/app/helpers/public/billing_helper.rb index 09200ed..5a70dcb 100644 --- a/backend/app/helpers/public/billing_helper.rb +++ b/backend/app/helpers/public/billing_helper.rb @@ -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 diff --git a/backend/app/jobs/expire_ended_subscriptions_job.rb b/backend/app/jobs/expire_ended_subscriptions_job.rb new file mode 100644 index 0000000..515ff6a --- /dev/null +++ b/backend/app/jobs/expire_ended_subscriptions_job.rb @@ -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 diff --git a/backend/app/mailers/billing/invoice_mailer.rb b/backend/app/mailers/billing/invoice_mailer.rb new file mode 100644 index 0000000..ca3f2e9 --- /dev/null +++ b/backend/app/mailers/billing/invoice_mailer.rb @@ -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 diff --git a/backend/app/models/billing/invoice.rb b/backend/app/models/billing/invoice.rb index 2d53468..db0d815 100644 --- a/backend/app/models/billing/invoice.rb +++ b/backend/app/models/billing/invoice.rb @@ -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 diff --git a/backend/app/models/billing/payment.rb b/backend/app/models/billing/payment.rb index 68fe8e1..d6abb05 100644 --- a/backend/app/models/billing/payment.rb +++ b/backend/app/models/billing/payment.rb @@ -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 diff --git a/backend/app/models/concerns/club_billing_profile.rb b/backend/app/models/concerns/club_billing_profile.rb index 4cb09f5..a6fa7c7 100644 --- a/backend/app/models/concerns/club_billing_profile.rb +++ b/backend/app/models/concerns/club_billing_profile.rb @@ -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(" · ") diff --git a/backend/app/services/billing/issue_invoice.rb b/backend/app/services/billing/issue_invoice.rb new file mode 100644 index 0000000..f310725 --- /dev/null +++ b/backend/app/services/billing/issue_invoice.rb @@ -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 diff --git a/backend/app/services/billing/payment_description.rb b/backend/app/services/billing/payment_description.rb new file mode 100644 index 0000000..289e840 --- /dev/null +++ b/backend/app/services/billing/payment_description.rb @@ -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 diff --git a/backend/app/services/billing/stripe/cancel_subscription.rb b/backend/app/services/billing/stripe/cancel_subscription.rb new file mode 100644 index 0000000..6035219 --- /dev/null +++ b/backend/app/services/billing/stripe/cancel_subscription.rb @@ -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 diff --git a/backend/app/services/billing/stripe/change_plan.rb b/backend/app/services/billing/stripe/change_plan.rb index 0cff7ff..bdfc118 100644 --- a/backend/app/services/billing/stripe/change_plan.rb +++ b/backend/app/services/billing/stripe/change_plan.rb @@ -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 diff --git a/backend/app/services/billing/stripe/checkout_session.rb b/backend/app/services/billing/stripe/checkout_session.rb index 24ba201..c36223c 100644 --- a/backend/app/services/billing/stripe/checkout_session.rb +++ b/backend/app/services/billing/stripe/checkout_session.rb @@ -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 diff --git a/backend/app/services/billing/stripe/checkout_sync.rb b/backend/app/services/billing/stripe/checkout_sync.rb new file mode 100644 index 0000000..b444eae --- /dev/null +++ b/backend/app/services/billing/stripe/checkout_sync.rb @@ -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 diff --git a/backend/app/services/billing/stripe/finalize_subscription.rb b/backend/app/services/billing/stripe/finalize_subscription.rb new file mode 100644 index 0000000..20a1891 --- /dev/null +++ b/backend/app/services/billing/stripe/finalize_subscription.rb @@ -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 diff --git a/backend/app/services/billing/stripe/invoice_metadata.rb b/backend/app/services/billing/stripe/invoice_metadata.rb new file mode 100644 index 0000000..5df4f01 --- /dev/null +++ b/backend/app/services/billing/stripe/invoice_metadata.rb @@ -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 diff --git a/backend/app/services/billing/stripe/invoice_payment_refs.rb b/backend/app/services/billing/stripe/invoice_payment_refs.rb new file mode 100644 index 0000000..1179eeb --- /dev/null +++ b/backend/app/services/billing/stripe/invoice_payment_refs.rb @@ -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 diff --git a/backend/app/services/billing/stripe/price_catalog.rb b/backend/app/services/billing/stripe/price_catalog.rb new file mode 100644 index 0000000..8ab48a2 --- /dev/null +++ b/backend/app/services/billing/stripe/price_catalog.rb @@ -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 diff --git a/backend/app/services/billing/stripe/record_payment.rb b/backend/app/services/billing/stripe/record_payment.rb index 0c2e4ac..a194314 100644 --- a/backend/app/services/billing/stripe/record_payment.rb +++ b/backend/app/services/billing/stripe/record_payment.rb @@ -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 diff --git a/backend/app/services/billing/stripe/sync_payments.rb b/backend/app/services/billing/stripe/sync_payments.rb index be8ede4..b28d805 100644 --- a/backend/app/services/billing/stripe/sync_payments.rb +++ b/backend/app/services/billing/stripe/sync_payments.rb @@ -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 diff --git a/backend/app/services/billing/stripe/webhook_handler.rb b/backend/app/services/billing/stripe/webhook_handler.rb index 1ffe48f..c687950 100644 --- a/backend/app/services/billing/stripe/webhook_handler.rb +++ b/backend/app/services/billing/stripe/webhook_handler.rb @@ -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 diff --git a/backend/app/views/admin/billing_invoices/edit.html.erb b/backend/app/views/admin/billing_invoices/edit.html.erb new file mode 100644 index 0000000..9f0c476 --- /dev/null +++ b/backend/app/views/admin/billing_invoices/edit.html.erb @@ -0,0 +1,45 @@ +
+ Stato: <%= @invoice.status %> + <% if @invoice.emailed_at.present? %> + · Inviata il <%= @invoice.emailed_at.in_time_zone.strftime("%d/%m/%Y %H:%M") %> + <% end %> +
+ +Pagamento: <%= @payment.display_description %> — <%= @payment.formatted_amount %>
+ <% end %> + + <%= f.label :pdf, "PDF fattura" %> + <% if @invoice.pdf.attached? %> +PDF già caricato: <%= @invoice.pdf.filename %>
+ <% end %> + <%= f.file_field :pdf, accept: "application/pdf" %> + + <%= f.label :notes, "Note interne" %> + <%= f.text_area :notes, rows: 2 %> + +<%= link_to "← Pagamenti e fatture", admin_club_billing_invoices_path(@club) %>
diff --git a/backend/app/views/admin/billing_invoices/index.html.erb b/backend/app/views/admin/billing_invoices/index.html.erb index ab67758..4d6f669 100644 --- a/backend/app/views/admin/billing_invoices/index.html.erb +++ b/backend/app/views/admin/billing_invoices/index.html.erb @@ -1,10 +1,56 @@ -<%= link_to "← Dashboard", admin_root_path %> · <%= link_to "Nuova fattura", new_admin_club_billing_invoice_path(@club), class: "btn btn-primary" %>
++ <%= link_to "← Dashboard", admin_root_path %> + · <%= link_to "Nuova fattura (senza pagamento)", new_admin_club_billing_invoice_path(@club), class: "btn btn-secondary" %> +
+| Data | +Descrizione | +Importo | +Fattura | ++ |
|---|---|---|---|---|
| <%= payment.paid_at&.to_date || payment.created_at.to_date %> | +<%= payment.display_description %> | +<%= payment.formatted_amount %> | ++ <% if inv %> + <%= inv.number %> — <%= inv.status %> + <% if inv.pdf.attached? %> (PDF)<% end %> + <% else %> + — + <% end %> + | ++ <% 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 %> + | +
Nessun pagamento registrato per questa società.
+<% end %> + +| Numero | Data | Importo | Stato | ||
|---|---|---|---|---|---|
| Numero | Data | Importo | Stato | Pagamento | <%= inv.issued_on %> | <%= inv.formatted_amount %> | <%= inv.status %> | -<%= inv.pdf.attached? ? "Sì" : "No" %> | +<%= inv.billing_payment_id.present? ? "Sì" : "No" %> | +<%= link_to "Modifica", edit_admin_club_billing_invoice_path(@club, inv) %> | <% end %> diff --git a/backend/app/views/admin/billing_invoices/new.html.erb b/backend/app/views/admin/billing_invoices/new.html.erb index e30a097..e00a854 100644 --- a/backend/app/views/admin/billing_invoices/new.html.erb +++ b/backend/app/views/admin/billing_invoices/new.html.erb @@ -1,8 +1,12 @@ -