From a5e781729ce2d75797e4d3c1d9950a17ce78a8be Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Tue, 2 Jun 2026 17:10:12 +0200 Subject: [PATCH] Billing: fatture manuali, profilo obbligatorio e piani Stripe mensile/annuale. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../admin/billing_invoices_controller.rb | 67 +++++- .../public/club_billing_controller.rb | 31 ++- .../controllers/public/clubs_controller.rb | 76 +++++-- .../public/site_base_controller.rb | 7 + backend/app/helpers/application_helper.rb | 9 + backend/app/helpers/public/billing_helper.rb | 70 ++++++- .../jobs/expire_ended_subscriptions_job.rb | 17 ++ backend/app/mailers/billing/invoice_mailer.rb | 18 ++ backend/app/models/billing/invoice.rb | 30 ++- backend/app/models/billing/payment.rb | 12 ++ .../models/concerns/club_billing_profile.rb | 20 +- backend/app/services/billing/issue_invoice.rb | 47 +++++ .../services/billing/payment_description.rb | 129 ++++++++++++ .../billing/stripe/cancel_subscription.rb | 29 +++ .../services/billing/stripe/change_plan.rb | 37 ++-- .../billing/stripe/checkout_session.rb | 51 ++--- .../services/billing/stripe/checkout_sync.rb | 196 ++++++++++++++++++ .../billing/stripe/finalize_subscription.rb | 65 ++++++ .../billing/stripe/invoice_metadata.rb | 41 ++++ .../billing/stripe/invoice_payment_refs.rb | 56 +++++ .../services/billing/stripe/price_catalog.rb | 74 +++++++ .../services/billing/stripe/record_payment.rb | 33 +-- .../services/billing/stripe/sync_payments.rb | 4 + .../billing/stripe/webhook_handler.rb | 91 +------- .../admin/billing_invoices/edit.html.erb | 45 ++++ .../admin/billing_invoices/index.html.erb | 55 ++++- .../views/admin/billing_invoices/new.html.erb | 38 ++-- .../invoice_mailer/invoice_pdf.html.erb | 8 + .../invoice_mailer/invoice_pdf.text.erb | 9 + backend/app/views/layouts/marketing.html.erb | 4 +- .../public/club_billing/profile.html.erb | 13 ++ .../app/views/public/clubs/billing.html.erb | 7 +- backend/app/views/public/clubs/new.html.erb | 13 +- backend/app/views/public/live/show.html.erb | 2 +- .../app/views/public/matches/index.html.erb | 2 +- backend/app/views/public/replay/show.html.erb | 2 +- .../app/views/public/sessions/new.html.erb | 1 - .../app/views/public/teams/billing.html.erb | 6 +- .../views/shared/_billing_documents.html.erb | 74 ++----- .../shared/_billing_profile_fields.html.erb | 27 +-- .../shared/_club_subscription_status.html.erb | 18 +- .../app/views/shared/_input_toggle.html.erb | 52 +++-- backend/app/views/shared/_plan_cards.html.erb | 61 ++++-- .../shared/_subscription_cancel.html.erb | 36 ++++ backend/config/application.rb | 3 + backend/config/initializers/i18n.rb | 5 + backend/config/initializers/match_live_tv.rb | 25 ++- backend/config/locales/it.yml | 60 ++++++ backend/config/routes.rb | 4 +- ...0_add_billing_interval_to_subscriptions.rb | 5 + ...0531140000_billing_invoices_manual_flow.rb | 12 ++ backend/db/schema.rb | 9 +- backend/public/marketing.css | 39 ++++ .../spec/helpers/application_helper_spec.rb | 8 + .../helpers/public/billing_helper_spec.rb | 68 ++++-- .../models/billing/invoice_display_spec.rb | 19 ++ .../spec/models/club_billing_profile_spec.rb | 15 ++ .../spec/requests/public/club_billing_spec.rb | 105 +++++++++- .../services/billing/issue_invoice_spec.rb | 41 ++++ .../billing/payment_description_spec.rb | 28 +++ .../stripe/cancel_subscription_spec.rb | 53 +++++ .../billing/stripe/change_plan_spec.rb | 19 +- .../billing/stripe/checkout_session_spec.rb | 47 ++--- .../billing/stripe/checkout_sync_spec.rb | 72 +++++++ .../stripe/finalize_subscription_spec.rb | 54 +++++ .../stripe/invoice_payment_refs_spec.rb | 20 ++ .../billing/stripe/price_catalog_spec.rb | 29 +++ .../billing/stripe/record_payment_spec.rb | 20 +- docs/PRODUCT_PREMIUM.md | 8 +- docs/STRIPE_PRODUCTION.md | 87 ++++++++ infra/.env.example | 6 + infra/.env.production.example | 11 +- infra/docker-compose.prod.yml | 4 + infra/docker-compose.yml | 4 + scripts/merge_production_env.sh | 53 +++++ scripts/setup_stripe_production.sh | 121 +++++++++++ scripts/test_billing_intervals.sh | 83 ++++++++ scripts/test_stripe_e2e.sh | 61 ++++++ 78 files changed, 2457 insertions(+), 424 deletions(-) create mode 100644 backend/app/helpers/application_helper.rb create mode 100644 backend/app/jobs/expire_ended_subscriptions_job.rb create mode 100644 backend/app/mailers/billing/invoice_mailer.rb create mode 100644 backend/app/services/billing/issue_invoice.rb create mode 100644 backend/app/services/billing/payment_description.rb create mode 100644 backend/app/services/billing/stripe/cancel_subscription.rb create mode 100644 backend/app/services/billing/stripe/checkout_sync.rb create mode 100644 backend/app/services/billing/stripe/finalize_subscription.rb create mode 100644 backend/app/services/billing/stripe/invoice_metadata.rb create mode 100644 backend/app/services/billing/stripe/invoice_payment_refs.rb create mode 100644 backend/app/services/billing/stripe/price_catalog.rb create mode 100644 backend/app/views/admin/billing_invoices/edit.html.erb create mode 100644 backend/app/views/billing/invoice_mailer/invoice_pdf.html.erb create mode 100644 backend/app/views/billing/invoice_mailer/invoice_pdf.text.erb create mode 100644 backend/app/views/shared/_subscription_cancel.html.erb create mode 100644 backend/config/initializers/i18n.rb create mode 100644 backend/config/locales/it.yml create mode 100644 backend/db/migrate/20260530120000_add_billing_interval_to_subscriptions.rb create mode 100644 backend/db/migrate/20260531140000_billing_invoices_manual_flow.rb create mode 100644 backend/spec/helpers/application_helper_spec.rb create mode 100644 backend/spec/models/billing/invoice_display_spec.rb create mode 100644 backend/spec/services/billing/issue_invoice_spec.rb create mode 100644 backend/spec/services/billing/payment_description_spec.rb create mode 100644 backend/spec/services/billing/stripe/cancel_subscription_spec.rb create mode 100644 backend/spec/services/billing/stripe/checkout_sync_spec.rb create mode 100644 backend/spec/services/billing/stripe/finalize_subscription_spec.rb create mode 100644 backend/spec/services/billing/stripe/invoice_payment_refs_spec.rb create mode 100644 backend/spec/services/billing/stripe/price_catalog_spec.rb create mode 100644 docs/STRIPE_PRODUCTION.md create mode 100755 scripts/merge_production_env.sh create mode 100755 scripts/setup_stripe_production.sh create mode 100755 scripts/test_billing_intervals.sh create mode 100755 scripts/test_stripe_e2e.sh 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 @@ +

Fattura <%= @invoice.number %> — <%= @club.name %>

+

+ Stato: <%= @invoice.status %> + <% if @invoice.emailed_at.present? %> + · Inviata il <%= @invoice.emailed_at.in_time_zone.strftime("%d/%m/%Y %H:%M") %> + <% end %> +

+ +
+ <%= 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 %> +

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 %> + +
+ <% 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 %> +
+ <% end %> +
+ +

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

Fatture — <%= @club.name %>

-

<%= link_to "← Dashboard", admin_root_path %> · <%= link_to "Nuova fattura", new_admin_club_billing_invoice_path(@club), class: "btn btn-primary" %>

+

Fatturazione — <%= @club.name %>

+

+ <%= link_to "← Dashboard", admin_root_path %> + · <%= link_to "Nuova fattura (senza pagamento)", new_admin_club_billing_invoice_path(@club), class: "btn btn-secondary" %> +

+

Pagamenti

+<% if @payments.any? %> + + + + + + + + + + + + <% @payments.each do |payment| %> + <% inv = payment.invoice %> + + + + + + + + <% end %> + +
DataDescrizioneImportoFattura
<%= 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 %> +
+<% else %> +

Nessun pagamento registrato per questa società.

+<% end %> + +

Tutte le fatture

<% if @invoices.any? %> - + <% @invoices.each do |inv| %> @@ -13,7 +59,8 @@ - + + <% 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 @@ -

Nuova fattura Aruba — <%= @club.name %>

-

Carica il PDF emesso da Aruba Fatture e collega il pagamento se disponibile.

+

Nuova fattura — <%= @club.name %>

+<% if @payment %> +

Collegata al pagamento del <%= @payment.paid_at&.to_date || @payment.created_at.to_date %> (<%= @payment.formatted_amount %>).

+<% end %>
- <%= 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" %> +

+ Salva la bozza, poi carica il PDF e inviala al cliente dalla schermata successiva. +

+ + <%= f.submit "Crea bozza fattura", class: "btn btn-primary" %> <% end %>
-

<%= link_to "← Elenco fatture", admin_club_billing_invoices_path(@club) %>

+

<%= link_to "← Pagamenti e fatture", admin_club_billing_invoices_path(@club) %>

diff --git a/backend/app/views/billing/invoice_mailer/invoice_pdf.html.erb b/backend/app/views/billing/invoice_mailer/invoice_pdf.html.erb new file mode 100644 index 0000000..ad3bea1 --- /dev/null +++ b/backend/app/views/billing/invoice_mailer/invoice_pdf.html.erb @@ -0,0 +1,8 @@ +

Buongiorno,

+ +

in allegato trovi la fattura <%= @invoice.display_number %> del <%= l(@invoice.issued_on, locale: :it, format: :long) %> + per un importo di <%= @invoice.formatted_amount %>, relativa alla società <%= @club.billing_legal_name.presence || @club.name %>.

+ +

Puoi scaricarla anche dalla sezione Abbonamento del tuo account Match Live TV.

+ +

Grazie,
Match Live TV

diff --git a/backend/app/views/billing/invoice_mailer/invoice_pdf.text.erb b/backend/app/views/billing/invoice_mailer/invoice_pdf.text.erb new file mode 100644 index 0000000..a03f21b --- /dev/null +++ b/backend/app/views/billing/invoice_mailer/invoice_pdf.text.erb @@ -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 diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index 97ba051..ab7f636 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -6,7 +6,7 @@ <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> <%= render "shared/meta_tags" %> - + <%= render "shared/marketing_nav" %> @@ -18,6 +18,6 @@ <%= render "shared/marketing_footer" %> - + diff --git a/backend/app/views/public/club_billing/profile.html.erb b/backend/app/views/public/club_billing/profile.html.erb index 24c699b..e0861a6 100644 --- a/backend/app/views/public/club_billing/profile.html.erb +++ b/backend/app/views/public/club_billing/profile.html.erb @@ -5,8 +5,21 @@

Dati di fatturazione

Società: <%= @club.name %>. Inserisci i dati per l’intestazione delle fatture e l’invio tramite SDI o PEC.

+ <% plan_return = params[:plan].presence_in(%w[premium_light premium_full]) %> + <% unless @club.billing_profile_complete? %> +
+ Obbligatori per abbonarti a un piano premium. + <%= billing_profile_incomplete_message(@club) %> + <% if plan_return %> + Dopo il salvataggio potrai procedere al pagamento di <%= Plan[plan_return].name %>. + <% end %> +
+ <% end %> +
<%= 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 %> diff --git a/backend/app/views/public/clubs/billing.html.erb b/backend/app/views/public/clubs/billing.html.erb index 8b0bb2d..aded247 100644 --- a/backend/app/views/public/clubs/billing.html.erb +++ b/backend/app/views/public/clubs/billing.html.erb @@ -3,12 +3,13 @@

Abbonamento

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

<%= link_to "← Società", public_club_path(@club) %>

diff --git a/backend/app/views/public/clubs/new.html.erb b/backend/app/views/public/clubs/new.html.erb index d712e89..b8d0257 100644 --- a/backend/app/views/public/clubs/new.html.erb +++ b/backend/app/views/public/clubs/new.html.erb @@ -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" %> +
+ <%= 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") %> +
<%= render "shared/stripe_secure_payment", compact: true %> <%= submit_tag "Crea società", class: "btn btn-primary" %> <% end %> diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb index 501d001..a2cec46 100644 --- a/backend/app/views/public/live/show.html.erb +++ b/backend/app/views/public/live/show.html.erb @@ -37,7 +37,7 @@

Diretta terminata

Lo streaming di questa partita è stato chiuso.

<% if @session.ended_at %> -

Chiusa il <%= l(@session.ended_at.in_time_zone, format: :long) %>

+

Chiusa il <%= l_local(@session.ended_at) %>

<% end %>

Il punteggio finale resta visibile sopra.

<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %> diff --git a/backend/app/views/public/matches/index.html.erb b/backend/app/views/public/matches/index.html.erb index 311c87b..4516261 100644 --- a/backend/app/views/public/matches/index.html.erb +++ b/backend/app/views/public/matches/index.html.erb @@ -38,7 +38,7 @@
NumeroDataImportoStatoPDF
NumeroDataImportoStatoPagamento
<%= 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) %>
<%= match.opponent_name %> <% if match.scheduled_at %> - <%= l match.scheduled_at.in_time_zone, format: :long %> + <%= l_local(match.scheduled_at) %> <% else %> Senza orario <% end %> diff --git a/backend/app/views/public/replay/show.html.erb b/backend/app/views/public/replay/show.html.erb index c3efd03..b25bf04 100644 --- a/backend/app/views/public/replay/show.html.erb +++ b/backend/app/views/public/replay/show.html.erb @@ -32,7 +32,7 @@

Registrazione indicizzata. Il playback dipende dalla retention del piano Premium.

<% end %> <% if @recording.expires_at %> -

Disponibile fino al <%= l @recording.expires_at, format: :long %>

+

Disponibile fino al <%= l_local(@recording.expires_at) %>

<% end %> <% end %> diff --git a/backend/app/views/public/sessions/new.html.erb b/backend/app/views/public/sessions/new.html.erb index c16ed7e..aaf64ee 100644 --- a/backend/app/views/public/sessions/new.html.erb +++ b/backend/app/views/public/sessions/new.html.erb @@ -16,7 +16,6 @@ <%= render "shared/input_toggle", name: :password, label: "Password", - input_type: "text", required: true, autocomplete: "current-password" %>

<%= link_to "Password dimenticata?", public_password_forgot_path %>

diff --git a/backend/app/views/public/teams/billing.html.erb b/backend/app/views/public/teams/billing.html.erb index ed0737e..f359e33 100644 --- a/backend/app/views/public/teams/billing.html.erb +++ b/backend/app/views/public/teams/billing.html.erb @@ -29,7 +29,7 @@ <% elsif plan.slug == "free" %>

Contatta il supporto per downgrade.

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

Stripe non configurato

<% end %> @@ -41,9 +41,7 @@

Esigenze diverse? <%= mail_to "info@matchlive.it", "Contattaci" %> per un'offerta su misura.

- <% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %> -

<%= button_to "Gestisci su Stripe", public_club_portal_path(@team.club), class: "btn btn-secondary" %>

- <% end %> +

<%= link_to "Pagamenti e fatture della società", public_club_billing_path(@team.club) %>

<%= link_to "← Dettagli squadra", public_team_details_path(@team) %>

diff --git a/backend/app/views/shared/_billing_documents.html.erb b/backend/app/views/shared/_billing_documents.html.erb index fa39905..7926992 100644 --- a/backend/app/views/shared/_billing_documents.html.erb +++ b/backend/app/views/shared/_billing_documents.html.erb @@ -1,15 +1,14 @@ -<%# locals: (club:, payments:, invoices:) %> +<%# locals: (club:, payments:) %>

Pagamenti e fatture

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

<% unless club.billing_profile_complete? %>
Dati di fatturazione incompleti. - 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" %>
<% else %> @@ -19,14 +18,7 @@

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

Pagamenti effettuati

+

Pagamenti e fatture

<% if payments.any? %> @@ -34,54 +26,34 @@ - + + <% payments.each do |payment| %> + <% inv = payment.invoice_for_display %> - - + + - + - - <% end %> - -
Data Descrizione ImportoStatoStato pagamentoFattura
<%= payment.paid_at ? l(payment.paid_at, format: :short) : "—" %><%= payment.description.presence || payment.plan_slug&.humanize || "Abbonamento" %><%= payment.paid_at ? l_local(payment.paid_at, format: :short) : "—" %><%= payment.display_description %> <%= payment.formatted_amount %><%= payment.status %><%= payment.display_status %> - <% if payment.receipt_url.present? %> - <%= link_to "Ricevuta Stripe", payment.receipt_url, target: "_blank", rel: "noopener" %> - <% end %> -
- <% else %> -

Nessun pagamento registrato. Dopo un abbonamento premium comparirà qui (anche tramite «Sincronizza pagamenti»).

- <% end %> - -

Fatture

- <% if invoices.any? %> - - - - - - - - - - - - <% invoices.each do |invoice| %> - - - - - - + @@ -89,6 +61,6 @@
NumeroDataImportoStato
<%= invoice.number %><%= l(invoice.issued_on) %><%= invoice.formatted_amount %><%= invoice.status %> - <% 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 %> + + <% if inv&.downloadable? %> + <%= link_to "Scarica PDF", public_club_billing_invoice_path(club, inv) %> + <% elsif inv&.draft? %> PDF in preparazione + <% else %> + <% end %>
<% else %> -

Nessuna fattura disponibile. Compariranno qui dopo l’emissione.

+

Nessun pagamento registrato. Dopo un abbonamento premium comparirà qui.

<% end %>
diff --git a/backend/app/views/shared/_billing_profile_fields.html.erb b/backend/app/views/shared/_billing_profile_fields.html.erb index 61cb47e..d72a434 100644 --- a/backend/app/views/shared/_billing_profile_fields.html.erb +++ b/backend/app/views/shared/_billing_profile_fields.html.erb @@ -2,64 +2,65 @@ <% if show_legend %>

Dati di fatturazione

- 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 oppure PEC. I pagamenti restano gestiti in modo sicuro da Stripe.

<% 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" %>
- <%= 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" %>
- <%= 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" %>
-<%= 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" %>
- <%= label_tag "club[billing_city]", "Città" %> + <%= label_tag "club[billing_city]", "Città *" %> <%= text_field_tag "club[billing_city]", record.billing_city %>
- <%= 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" %>
- <%= 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 %>
-<%= 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 %>
- <%= 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" %>
- <%= 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" %>
diff --git a/backend/app/views/shared/_club_subscription_status.html.erb b/backend/app/views/shared/_club_subscription_status.html.erb index 9258c1f..e665221 100644 --- a/backend/app/views/shared/_club_subscription_status.html.erb +++ b/backend/app/views/shared/_club_subscription_status.html.erb @@ -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"] %>

Società: <%= club.name %> · Piano attuale: <%= current_plan.name %> (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 %>

<% if subscription&.stripe_subscription_id.present? %>

- Stato Stripe: <%= subscription.status %> - <% if subscription.current_period_end.present? %> - · Rinnovo: <%= l(subscription.current_period_end, format: :long) %> - <% end %> <% if subscription.cancel_at_period_end? %> - · Disdetta alla scadenza + + Abbonamento valido fino al + <%= subscription.current_period_end.present? ? l_local(subscription.current_period_end.to_date) : "fine periodo" %>, + poi piano Free. + + <% elsif subscription.current_period_end.present? %> + Prossimo rinnovo: <%= l_local(subscription.current_period_end) %> <% end %>

<% end %> diff --git a/backend/app/views/shared/_input_toggle.html.erb b/backend/app/views/shared/_input_toggle.html.erb index 972f97c..9cd28f9 100644 --- a/backend/app/views/shared/_input_toggle.html.erb +++ b/backend/app/views/shared/_input_toggle.html.erb @@ -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" %> -
+<% 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 %> + +
"<%= " data-input-toggle" if masked %>> <%= label_tag field_id, label %>
- <%= 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 } - ) %> - + <% 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" } + ) %> + + <% 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 %>
diff --git a/backend/app/views/shared/_plan_cards.html.erb b/backend/app/views/shared/_plan_cards.html.erb index 57be545..686259b 100644 --- a/backend/app/views/shared/_plan_cards.html.erb +++ b/backend/app/views/shared/_plan_cards.html.erb @@ -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? %>
@@ -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 %>
">

<%= plan.name %>

<% if plan.slug == "free" %>
€0
<% elsif plan.slug == "premium_light" %> -
€40/anno
-

oppure €5/mese

+ <% light_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %> + <% light_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %> +
<%= light_yearly %>
+

oppure <%= light_monthly %>

<% else %> -
€200/anno
-

oppure €20/mese

+ <% full_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %> + <% full_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %> +
<%= full_yearly %>
+

oppure <%= full_monthly %>

<% end %>
  • Responsabili trasmissione: <%= plan.max_staff_transmission || "illimitato" %> / anno per squadra
  • @@ -30,26 +36,45 @@
  • YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %>
<% if billing_mode %> - <% case action[:kind] %> - <% when :current %> + <% action_kind = action[:kind] %> + <% if action_kind == :current %> <%= action[:label] %> - <% when :contact, :disabled %> -

<%= action[:label] %>

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

<%= action[:label] %>

+ <% elsif action_kind == :checkout_options || action_kind == :change_options || action_kind == :interval_switch %> + <% if billing_profile_blocks_premium?(club) %> +

+ <%= billing_profile_incomplete_message(club) %> +

+ <%= link_to "Completa dati di fatturazione", + public_club_billing_profile_path(club, plan: plan.slug), + class: "btn btn-primary" %> + <% else %> +
+ <% 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 %> +
+ <% end %> + <% if action_kind == :interval_switch && action[:current_interval].present? %> +

Fatturazione attuale: <%= Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: action[:current_interval]) %>

+ <% 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" %> +
+ <% 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 %> +
<% end %>
<% end %>
-<% if show_stripe_portal && billing_mode && subscription&.premium? && MatchLiveTv.stripe_enabled? && subscription&.stripe_customer_id.present? %> -

- <%= button_to "Gestisci fatturazione su Stripe", public_club_portal_path(club), class: "btn btn-secondary" %> - Cambia metodo di pagamento, scarica fatture o disdici l'abbonamento. -

-<% end %> diff --git a/backend/app/views/shared/_subscription_cancel.html.erb b/backend/app/views/shared/_subscription_cancel.html.erb new file mode 100644 index 0000000..a848283 --- /dev/null +++ b/backend/app/views/shared/_subscription_cancel.html.erb @@ -0,0 +1,36 @@ +<%# locals: (club:, subscription:) %> +<% return unless subscription&.premium? && subscription.stripe_subscription_id.present? && MatchLiveTv.stripe_enabled? %> + +
+ <% if subscription.cancel_at_period_end? %> +

+ Disdetta programmata. + Il piano <%= subscription.plan.name %> resta attivo fino al + <% if subscription.current_period_end.present? %> + <%= l_local(subscription.current_period_end.to_date) %>. + <% else %> + fine del periodo già pagato. + <% end %> + Da quel giorno la società passerà automaticamente al piano Free. +

+

+ Per ripristinare l'abbonamento prima della scadenza, contatta il supporto Match Live TV. +

+ <% else %> +

+ Puoi disdire in qualsiasi momento: l'abbonamento resta valido fino alla fine del periodo già pagato, + poi passi al piano Free senza ulteriori addebiti. +

+ <%= 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 %> +
diff --git a/backend/config/application.rb b/backend/config/application.rb index 7f71b3e..8387b2a 100644 --- a/backend/config/application.rb +++ b/backend/config/application.rb @@ -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 diff --git a/backend/config/initializers/i18n.rb b/backend/config/initializers/i18n.rb new file mode 100644 index 0000000..98a841b --- /dev/null +++ b/backend/config/initializers/i18n.rb @@ -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 diff --git a/backend/config/initializers/match_live_tv.rb b/backend/config/initializers/match_live_tv.rb index fcc14c2..1784ea0 100644 --- a/backend/config/initializers/match_live_tv.rb +++ b/backend/config/initializers/match_live_tv.rb @@ -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? diff --git a/backend/config/locales/it.yml b/backend/config/locales/it.yml new file mode 100644 index 0000000..15fae0c --- /dev/null +++ b/backend/config/locales/it.yml @@ -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" diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 8820d37..2e1fcda 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -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 diff --git a/backend/db/migrate/20260530120000_add_billing_interval_to_subscriptions.rb b/backend/db/migrate/20260530120000_add_billing_interval_to_subscriptions.rb new file mode 100644 index 0000000..957ecaa --- /dev/null +++ b/backend/db/migrate/20260530120000_add_billing_interval_to_subscriptions.rb @@ -0,0 +1,5 @@ +class AddBillingIntervalToSubscriptions < ActiveRecord::Migration[7.2] + def change + add_column :subscriptions, :billing_interval, :string + end +end diff --git a/backend/db/migrate/20260531140000_billing_invoices_manual_flow.rb b/backend/db/migrate/20260531140000_billing_invoices_manual_flow.rb new file mode 100644 index 0000000..460d4e7 --- /dev/null +++ b/backend/db/migrate/20260531140000_billing_invoices_manual_flow.rb @@ -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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 4773087..0153bd6 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -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)" diff --git a/backend/public/marketing.css b/backend/public/marketing.css index 36e6348..c6eef7c 100644 --- a/backend/public/marketing.css +++ b/backend/public/marketing.css @@ -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; +} diff --git a/backend/spec/helpers/application_helper_spec.rb b/backend/spec/helpers/application_helper_spec.rb new file mode 100644 index 0000000..70e2a77 --- /dev/null +++ b/backend/spec/helpers/application_helper_spec.rb @@ -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 diff --git a/backend/spec/helpers/public/billing_helper_spec.rb b/backend/spec/helpers/public/billing_helper_spec.rb index 2348a71..33e8176 100644 --- a/backend/spec/helpers/public/billing_helper_spec.rb +++ b/backend/spec/helpers/public/billing_helper_spec.rb @@ -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 diff --git a/backend/spec/models/billing/invoice_display_spec.rb b/backend/spec/models/billing/invoice_display_spec.rb new file mode 100644 index 0000000..2459f74 --- /dev/null +++ b/backend/spec/models/billing/invoice_display_spec.rb @@ -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 diff --git a/backend/spec/models/club_billing_profile_spec.rb b/backend/spec/models/club_billing_profile_spec.rb index d14cb23..5caf949 100644 --- a/backend/spec/models/club_billing_profile_spec.rb +++ b/backend/spec/models/club_billing_profile_spec.rb @@ -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 diff --git a/backend/spec/requests/public/club_billing_spec.rb b/backend/spec/requests/public/club_billing_spec.rb index e6fb08f..c8cb30e 100644 --- a/backend/spec/requests/public/club_billing_spec.rb +++ b/backend/spec/requests/public/club_billing_spec.rb @@ -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: Premium Light") + expect(Billing::Stripe::CheckoutSync).to have_received(:call).with( + club: club, session_id: "cs_test" + ) end end diff --git a/backend/spec/services/billing/issue_invoice_spec.rb b/backend/spec/services/billing/issue_invoice_spec.rb new file mode 100644 index 0000000..83a6cfc --- /dev/null +++ b/backend/spec/services/billing/issue_invoice_spec.rb @@ -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 diff --git a/backend/spec/services/billing/payment_description_spec.rb b/backend/spec/services/billing/payment_description_spec.rb new file mode 100644 index 0000000..87275db --- /dev/null +++ b/backend/spec/services/billing/payment_description_spec.rb @@ -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 diff --git a/backend/spec/services/billing/stripe/cancel_subscription_spec.rb b/backend/spec/services/billing/stripe/cancel_subscription_spec.rb new file mode 100644 index 0000000..a2564f0 --- /dev/null +++ b/backend/spec/services/billing/stripe/cancel_subscription_spec.rb @@ -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 diff --git a/backend/spec/services/billing/stripe/change_plan_spec.rb b/backend/spec/services/billing/stripe/change_plan_spec.rb index 9b1c390..e02b96e 100644 --- a/backend/spec/services/billing/stripe/change_plan_spec.rb +++ b/backend/spec/services/billing/stripe/change_plan_spec.rb @@ -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 diff --git a/backend/spec/services/billing/stripe/checkout_session_spec.rb b/backend/spec/services/billing/stripe/checkout_session_spec.rb index 3d8161b..b611098 100644 --- a/backend/spec/services/billing/stripe/checkout_session_spec.rb +++ b/backend/spec/services/billing/stripe/checkout_session_spec.rb @@ -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 diff --git a/backend/spec/services/billing/stripe/checkout_sync_spec.rb b/backend/spec/services/billing/stripe/checkout_sync_spec.rb new file mode 100644 index 0000000..91c6515 --- /dev/null +++ b/backend/spec/services/billing/stripe/checkout_sync_spec.rb @@ -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 diff --git a/backend/spec/services/billing/stripe/finalize_subscription_spec.rb b/backend/spec/services/billing/stripe/finalize_subscription_spec.rb new file mode 100644 index 0000000..d3b8f67 --- /dev/null +++ b/backend/spec/services/billing/stripe/finalize_subscription_spec.rb @@ -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 diff --git a/backend/spec/services/billing/stripe/invoice_payment_refs_spec.rb b/backend/spec/services/billing/stripe/invoice_payment_refs_spec.rb new file mode 100644 index 0000000..b96b1bc --- /dev/null +++ b/backend/spec/services/billing/stripe/invoice_payment_refs_spec.rb @@ -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 diff --git a/backend/spec/services/billing/stripe/price_catalog_spec.rb b/backend/spec/services/billing/stripe/price_catalog_spec.rb new file mode 100644 index 0000000..044e749 --- /dev/null +++ b/backend/spec/services/billing/stripe/price_catalog_spec.rb @@ -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 diff --git a/backend/spec/services/billing/stripe/record_payment_spec.rb b/backend/spec/services/billing/stripe/record_payment_spec.rb index 18fd98b..d404155 100644 --- a/backend/spec/services/billing/stripe/record_payment_spec.rb +++ b/backend/spec/services/billing/stripe/record_payment_spec.rb @@ -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 diff --git a/docs/PRODUCT_PREMIUM.md b/docs/PRODUCT_PREMIUM.md index 28ef038..67f7d79 100644 --- a/docs/PRODUCT_PREMIUM.md +++ b/docs/PRODUCT_PREMIUM.md @@ -29,12 +29,14 @@ Esigenze custom: contatto `info@matchlive.it`. ## Stripe ```bash -STRIPE_PREMIUM_LIGHT_PRICE_ID=price_... -STRIPE_PREMIUM_FULL_PRICE_ID=price_... +STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID=price_... +STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID=price_... +STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID=price_... +STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=price_... STRIPE_WEBHOOK_SECRET=whsec_... ``` -Checkout (PCI-safe, redirect Stripe Hosted): `GET /clubs/:id/checkout?plan=premium_light|premium_full` +Checkout: `GET /clubs/:id/checkout?plan=premium_light|premium_full&interval=monthly|yearly` **Sicurezza pagamenti:** non inviare mai `card[number]`, CVC o scadenza all'API Stripe dal backend. Usare solo Checkout/Customer Portal. I test locali vanno fatti con carta `4242…` **sulla pagina Stripe**, non con `PaymentMethod.create` in `rails runner`. diff --git a/docs/STRIPE_PRODUCTION.md b/docs/STRIPE_PRODUCTION.md new file mode 100644 index 0000000..dd486d0 --- /dev/null +++ b/docs/STRIPE_PRODUCTION.md @@ -0,0 +1,87 @@ +# Stripe in produzione — guida rapida + +## 1. Stripe Dashboard (modalità Test per prove, poi Live) + +### Prodotti e prezzi +Crea **2 prodotti** in Stripe, ciascuno con **2 prezzi ricorrenti**: + +| Piano app | Prodotto Stripe (Dashboard) | Mensile | Annuale | Variabile `.env` | +|-----------|------------------------------|---------|---------|------------------| +| Premium Light | **Match Live TV** | €4,90 | €39,90 | `STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID` / `..._YEARLY_...` | +| Premium Full | **Match Live TV PRO** | €9,90 | €69,90 | `STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID` / `..._YEARLY_...` | + +In Stripe apri ogni prezzo e copia l’**ID prezzo** (`price_...`), non l’ID prodotto. + +In Dashboard: Prodotto → **Aggiungi prezzo** (non serve un prodotto per ogni intervallo). + +### Webhook (obbligatorio) +**Developers → Webhooks → Aggiungi endpoint** + +| Campo | Valore | +|--------|--------| +| URL | `https://www.matchlivetv.it/webhooks/stripe` | +| Eventi | `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed` | + +Copia il **Signing secret** (`whsec_...`) — **non** usare il secret di `stripe listen` (solo per locale). + +### Chiavi API +- Test: `sk_test_...` + price test +- Live: `sk_live_...` + price live (prodotti creati in modalità Live) + +## 2. Server (`/opt/matchlivetv/infra/.env`) + +> **Non usare** `~/matchlivetv/infra/.env` — è una copia vecchia (bootstrap). +> Docker in produzione legge solo **`/opt/matchlivetv/infra/.env`**. +> Per aggiungere chiavi mancanti dal template: `./scripts/merge_production_env.sh` con `REMOTE=1`. + +```bash +STRIPE_SECRET_KEY=sk_test_... # o sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... # dal webhook Dashboard (URL produzione) +STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID=price_... +STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID=price_... +STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID=price_... +STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=price_... +``` + +Da PC (senza incollare chiavi in chat): + +```bash +./scripts/setup_stripe_production.sh +# oppure, se il webhook è nuovo: +./scripts/setup_stripe_production.sh --webhook-secret whsec_... +``` + +## 3. Test end-to-end + +```bash +# Test scelta mensile/annuale (Docker avviato) +chmod +x scripts/test_billing_intervals.sh +./scripts/test_billing_intervals.sh + +# Smoke HTTP su produzione +BASE_URL=https://www.matchlivetv.it ./scripts/test_stripe_e2e.sh + +# Test locale completo (Docker avviato) +./scripts/test_billing_flow.sh +``` + +Checkout: `/clubs/:id/checkout?plan=premium_light&interval=yearly` oppure `interval=monthly` + +### Test manuale browser +1. Login titolare società → **Abbonamento** +2. **Completa dati di fatturazione** (P.IVA/CF, SDI o PEC, indirizzo) +3. **Attiva Premium Light** → pagina `checkout.stripe.com` +4. Carta test `4242 4242 4242 4242` (solo con `sk_test_`) +5. Ritorno su `/clubs/.../billing?checkout=success` +6. Verifica in Stripe → Webhooks: risposta **200** su `/webhooks/stripe` + +## 4. PCI +- Mai `PaymentMethod.create` con `card[number]` sul server +- Prima attivazione: solo **Stripe Checkout** +- Cambio piano (già abbonato): **Subscription.update** Stripe (carta già salvata da Checkout) + +## 5. Passaggio a Live +1. Crea prodotti/prezzi in modalità **Live** +2. Nuovo webhook endpoint (stesso URL, modalità Live) → nuovo `whsec_` +3. Sostituisci `sk_live_` e price live in `.env` produzione +4. `./scripts/setup_stripe_production.sh` e ri-test con carta reale diff --git a/infra/.env.example b/infra/.env.example index 8fd2a74..59152dd 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -15,5 +15,11 @@ PASSWORD_RESET_EXPIRY_HOURS=2 # Stripe (test: sk_test_... / price_... da Dashboard modalità Test) STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= +# Un price per prodotto e intervallo (Dashboard → Prodotto → Prezzi) +STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID= +STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID= +STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID= +STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID= +# Legacy (equivale a YEARLY se le righe sopra sono vuote) STRIPE_PREMIUM_LIGHT_PRICE_ID= STRIPE_PREMIUM_FULL_PRICE_ID= diff --git a/infra/.env.production.example b/infra/.env.production.example index 200f05c..fd673b4 100644 --- a/infra/.env.production.example +++ b/infra/.env.production.example @@ -21,9 +21,14 @@ YOUTUBE_REDIRECT_URI=https://www.matchlivetv.it/api/v1/youtube/callback YOUTUBE_CLIENT_ID= YOUTUBE_CLIENT_SECRET= -# Stripe (abbonamento Premium squadra) -STRIPE_SECRET_KEY= -STRIPE_WEBHOOK_SECRET= +# Stripe LIVE (Dashboard in modalità Live — non riusare price_ di Test) +# Match Live TV → Premium Light | Match Live TV PRO → Premium Full +STRIPE_SECRET_KEY=sk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... # webhook https://www.matchlivetv.it/webhooks/stripe (modalità Live) +STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID= +STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID= +STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID= +STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID= STRIPE_PREMIUM_LIGHT_PRICE_ID= STRIPE_PREMIUM_FULL_PRICE_ID= diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 0fad3ff..b2801e5 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -87,6 +87,10 @@ services: RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID:-} + STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}} + STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID:-} + STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}} STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-} STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-} ports: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index d9a4e56..6a90dc2 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -74,6 +74,10 @@ services: APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID:-} + STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}} + STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID:-} + STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}} STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-} STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-} ports: diff --git a/scripts/merge_production_env.sh b/scripts/merge_production_env.sh new file mode 100755 index 0000000..5b951b0 --- /dev/null +++ b/scripts/merge_production_env.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Unisce infra/.env.production.example in infra/.env senza sovrascrivere valori già impostati. +# Uso locale: ./scripts/merge_production_env.sh +# Sul server: REMOTE=1 ./scripts/merge_production_env.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +EXAMPLE="$ROOT/infra/.env.production.example" +REMOTE="${REMOTE:-0}" +SERVER="${DEPLOY_SERVER:-eminux@192.168.1.146}" +ENV_PATH="${ENV_PATH:-/opt/matchlivetv/infra/.env}" + +if [[ "$REMOTE" == "1" ]]; then + scp "$EXAMPLE" "$SERVER:/tmp/matchlivetv-env.example" + ssh "$SERVER" "REMOTE_ENV='$ENV_PATH' bash -s" <<'REMOTE' +set -euo pipefail +EXAMPLE=/tmp/matchlivetv-env.example +ENV="${REMOTE_ENV}" +touch "$ENV" +while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ "$line" =~ ^[[:space:]]*$ ]] && continue + key="${line%%=*}" + [[ "$key" == "$line" ]] && continue + if ! grep -q "^${key}=" "$ENV" 2>/dev/null; then + echo "$line" >> "$ENV" + echo " + aggiunto: $key" + fi +done < "$EXAMPLE" +echo "" +echo "File: $ENV ($(wc -l < "$ENV") righe)" +echo "Chiavi presenti:" +grep -E '^[A-Z_]+=' "$ENV" | cut -d= -f1 | sort +echo "" +echo "Chiavi VUOTE (da compilare):" +grep -E '^[A-Z_]+=$' "$ENV" | cut -d= -f1 || echo " (nessuna)" +REMOTE + exit 0 +fi + +ENV="${ENV_FILE:-$ROOT/infra/.env}" +touch "$ENV" +while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*# ]] && continue + [[ "$line" =~ ^[[:space:]]*$ ]] && continue + key="${line%%=*}" + [[ "$key" == "$line" ]] && continue + if ! grep -q "^${key}=" "$ENV" 2>/dev/null; then + echo "$line" >> "$ENV" + echo " + aggiunto: $key" + fi +done < "$EXAMPLE" +echo "Fatto: $ENV" diff --git a/scripts/setup_stripe_production.sh b/scripts/setup_stripe_production.sh new file mode 100755 index 0000000..be4eab1 --- /dev/null +++ b/scripts/setup_stripe_production.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Configura Stripe sul server di produzione e verifica l'integrazione. +# +# Uso: +# ./scripts/setup_stripe_production.sh # legge infra/.env locale +# ./scripts/setup_stripe_production.sh --webhook-secret whsec_xxx +# STRIPE_MODE=live ./scripts/setup_stripe_production.sh # usa chiavi live nel .env +# +# Prima di eseguire in LIVE: +# 1. Stripe Dashboard → modalità Live → crea prodotti/prezzi → copia price_... +# 2. Webhook endpoint: https://www.matchlivetv.it/webhooks/stripe +# Eventi: checkout.session.completed, customer.subscription.*, invoice.paid, invoice.payment_failed +# 3. Incolla whsec_... live in infra/.env come STRIPE_WEBHOOK_SECRET +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${ENV_FILE:-$ROOT/infra/.env}" +SERVER="${DEPLOY_SERVER:-eminux@192.168.1.146}" +REMOTE_DIR="${DEPLOY_PATH:-/opt/matchlivetv}" +WEBHOOK_SECRET="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --env-file) ENV_FILE="$2"; shift 2 ;; + --webhook-secret) WEBHOOK_SECRET="$2"; shift 2 ;; + -h|--help) + sed -n '2,14p' "$0" + exit 0 + ;; + *) echo "Argomento sconosciuto: $1" >&2; exit 1 ;; + esac +done + +if [[ ! -f "$ENV_FILE" ]]; then + echo "File non trovato: $ENV_FILE" >&2 + exit 1 +fi + +read_env() { + local key="$1" + grep -E "^${key}=" "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '\r' +} + +STRIPE_SECRET_KEY="$(read_env STRIPE_SECRET_KEY)" +STRIPE_PREMIUM_LIGHT_PRICE_ID="$(read_env STRIPE_PREMIUM_LIGHT_PRICE_ID)" +STRIPE_PREMIUM_FULL_PRICE_ID="$(read_env STRIPE_PREMIUM_FULL_PRICE_ID)" +[[ -z "$WEBHOOK_SECRET" ]] && WEBHOOK_SECRET="$(read_env STRIPE_WEBHOOK_SECRET)" + +STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID="${STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID:-$(read_env STRIPE_PREMIUM_LIGHT_PRICE_ID)}" +STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID="${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-$(read_env STRIPE_PREMIUM_FULL_PRICE_ID)}" +STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID="${STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID:-}" +STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID="${STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID:-}" + +for var in STRIPE_SECRET_KEY STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID; do + if [[ -z "${!var:-}" ]]; then + echo "Manca $var in $ENV_FILE" >&2 + exit 1 + fi +done + +if [[ -z "$WEBHOOK_SECRET" ]]; then + echo "Manca STRIPE_WEBHOOK_SECRET." >&2 + echo "Crea un endpoint in Stripe → https://www.matchlivetv.it/webhooks/stripe" >&2 + echo "Poi: $0 --webhook-secret whsec_..." >&2 + exit 1 +fi + +if [[ "${STRIPE_SECRET_KEY}" == sk_test_* ]]; then + echo "Modalità: TEST (sk_test_) — pagamenti solo con carte di prova Stripe." +elif [[ "${STRIPE_SECRET_KEY}" == sk_live_* ]]; then + echo "Modalità: LIVE (sk_live_) — pagamenti reali." +else + echo "ATTENZIONE: STRIPE_SECRET_KEY non sembra sk_test_ né sk_live_" >&2 +fi + +echo "Aggiorno $SERVER:$REMOTE_DIR/infra/.env ..." + +ssh "$SERVER" "bash -s" </dev/null; then + sed -i "s|^\${key}=.*|\${key}=\${val}|" "\$ENV" + else + echo "\${key}=\${val}" >> "\$ENV" + fi +} +upsert STRIPE_SECRET_KEY '${STRIPE_SECRET_KEY}' +upsert STRIPE_WEBHOOK_SECRET '${WEBHOOK_SECRET}' +upsert STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID '${STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID}' +upsert STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID '${STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID}' +upsert STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID '${STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID}' +upsert STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID '${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID}' +upsert STRIPE_PREMIUM_LIGHT_PRICE_ID '${STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID}' +upsert STRIPE_PREMIUM_FULL_PRICE_ID '${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID}' +REMOTE + +echo "Sync codice..." +bash "$ROOT/scripts/deploy/sync_to_server.sh" + +echo "Rebuild rails + seed piani..." +ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose -f docker-compose.prod.yml --env-file .env up -d --build rails" +sleep 20 +ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose -f docker-compose.prod.yml exec -T rails bundle exec rails db:seed" || true + +echo "Verifica..." +ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose -f docker-compose.prod.yml exec -T rails bundle exec rails runner \" + puts 'stripe_enabled=' + MatchLiveTv.stripe_enabled?.to_s + puts 'light_price=' + (Plan['premium_light'].stripe_price_id || 'MISSING').to_s + puts 'full_price=' + (Plan['premium_full'].stripe_price_id || 'MISSING').to_s +\"" + +echo "" +echo "Prossimo passo: test browser" +echo " 1. Login su https://www.matchlivetv.it/login" +echo " 2. Società → Abbonamento → Attiva Premium Light" +echo " 3. Carta test 4242 4242 4242 4242 (solo se sk_test_)" +echo " 4. Stripe Dashboard → Webhooks → verifica eventi 200 su /webhooks/stripe" diff --git a/scripts/test_billing_intervals.sh b/scripts/test_billing_intervals.sh new file mode 100755 index 0000000..2628087 --- /dev/null +++ b/scripts/test_billing_intervals.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Test scelta mensile/annuale + redirect Checkout con price id corretto +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE="docker compose -f $ROOT/infra/docker-compose.yml" +BASE="${BASE_URL:-http://localhost:3000}" +COOKIES=$(mktemp) +trap 'rm -f "$COOKIES"' EXIT + +pass=0 +fail=0 +ok() { echo "OK $1"; pass=$((pass+1)); } +ko() { echo "FAIL $1"; fail=$((fail+1)); } + +read_env() { + grep -E "^$1=" "$ROOT/infra/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r' || true +} + +YEARLY_LIGHT=$(read_env STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID) +[[ -z "$YEARLY_LIGHT" ]] && YEARLY_LIGHT=$(read_env STRIPE_PREMIUM_LIGHT_PRICE_ID) +MONTHLY_LIGHT=$(read_env STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID) + +login() { + curl -sf -c "$COOKIES" "$BASE/login" -o /tmp/mltv_login.html + T=$(grep -oP 'name="authenticity_token" value="\K[^"]+' /tmp/mltv_login.html | head -1) + curl -sf -c "$COOKIES" -b "$COOKIES" -X POST "$BASE/login" \ + -d "authenticity_token=$T" -d "email=coach@matchlivetv.test" -d "password=password123" -o /dev/null +} + +echo "== Test intervalli billing ==" + +$COMPOSE exec -T rails bundle exec rails db:migrate >/dev/null 2>&1 || true + +$COMPOSE exec -T rails bundle exec rails runner " +club = Club.first +club.update!( + billing_legal_name: 'ASD Test', billing_email: 'bill@test.it', + billing_address_line: 'Via 1', billing_city: 'Milano', billing_postal_code: '20100', + billing_vat_number: '12345678901', billing_recipient_code: 'ABCDEFG' +) +sub = club.subscription || club.create_subscription!(plan: Plan['free'], status: 'active') +sub.update!(plan: Plan['free'], stripe_subscription_id: nil, billing_interval: nil) +puts club.id +" >/dev/null 2>&1 && ok "setup club billing" || ko "setup club billing" + +CLUB_ID=$($COMPOSE exec -T rails bundle exec rails runner "puts Club.first.id" 2>/dev/null | tail -1) + +login && ok "login" || ko "login" + +BODY=$(curl -sf -b "$COOKIES" "$BASE/clubs/$CLUB_ID/billing") +echo "$BODY" | grep -q "Attiva Premium Light — €40/anno" && ok "UI bottone annuale Light" || ko "UI bottone annuale Light" +if [[ -n "$MONTHLY_LIGHT" ]]; then + echo "$BODY" | grep -q "Attiva Premium Light — €5/mese" && ok "UI bottone mensile Light" || ko "UI bottone mensile Light" +else + echo "$BODY" | grep -q "€5/mese" && ko "UI mensile assente se non configurato" || ok "UI mensile nascosto (price id mancante)" +fi + +if [[ -n "$YEARLY_LIGHT" ]]; then + HEADERS=$(curl -s -D - -o /dev/null -b "$COOKIES" "$BASE/clubs/$CLUB_ID/checkout?plan=premium_light&interval=yearly" | tr -d '\r') + if echo "$HEADERS" | grep -qi "location:.*checkout\.stripe\.com"; then + ok "redirect checkout annuale" + else + ko "redirect checkout annuale" + fi + + PRICE_USED=$($COMPOSE exec -T rails bundle exec rails runner " + puts Billing::Stripe::PriceCatalog.price_id(plan_slug: 'premium_light', interval: 'yearly') + " 2>/dev/null | tail -1) + [[ "$PRICE_USED" == "$YEARLY_LIGHT" ]] && ok "price id annuale corretto ($PRICE_USED)" || ko "price id annuale ($PRICE_USED vs $YEARLY_LIGHT)" +fi + +$COMPOSE exec -T rails bundle exec rspec \ + spec/services/billing/stripe/price_catalog_spec.rb \ + spec/services/billing/stripe/checkout_session_spec.rb \ + spec/services/billing/stripe/change_plan_spec.rb \ + spec/helpers/public/billing_helper_spec.rb \ + --format progress 2>&1 | tail -3 +RS=$? +[[ $RS -eq 0 ]] && ok "RSpec billing intervals" || ko "RSpec billing intervals" + +echo "" +echo "Risultato: $pass OK, $fail FAIL" +[[ "$fail" -eq 0 ]] diff --git a/scripts/test_stripe_e2e.sh b/scripts/test_stripe_e2e.sh new file mode 100755 index 0000000..d81546e --- /dev/null +++ b/scripts/test_stripe_e2e.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Smoke test pagamenti: pagina billing + redirect Checkout (+ opzionale profilo completo). +set -euo pipefail + +BASE="${BASE_URL:-https://www.matchlivetv.it}" +EMAIL="${TEST_EMAIL:-coach@matchlivetv.test}" +PASSWORD="${TEST_PASSWORD:-password123}" +PLAN="${TEST_PLAN:-premium_light}" +COOKIES=$(mktemp) +trap 'rm -f "$COOKIES"' EXIT + +pass=0 +fail=0 +ok() { echo "OK $1"; pass=$((pass+1)); } +ko() { echo "FAIL $1"; fail=$((fail+1)); } + +echo "== Stripe E2E smoke @ $BASE ==" + +curl -sf "$BASE/up" >/dev/null && ok "health /up" || ko "health /up" + +curl -sf -c "$COOKIES" "$BASE/login" -o /tmp/mltv_login.html +T=$(grep -oP 'name="authenticity_token" value="\K[^"]+' /tmp/mltv_login.html | head -1 || true) +if [[ -z "$T" ]]; then + ko "login page (token CSRF)" +else + CODE=$(curl -s -o /dev/null -w "%{http_code}" -c "$COOKIES" -b "$COOKIES" -X POST "$BASE/login" \ + -d "authenticity_token=$T" -d "email=$EMAIL" -d "password=$PASSWORD") + [[ "$CODE" == "302" || "$CODE" == "303" ]] && ok "login $EMAIL" || ko "login ($CODE)" +fi + +CLUB_LINK=$(curl -sf -b "$COOKIES" "$BASE/" | grep -oP 'href="/clubs/[^"]+/billing"' | head -1 || true) +if [[ -z "$CLUB_LINK" ]]; then + # prova da pagina clubs + CLUB_ID=$(curl -sf -b "$COOKIES" "$BASE/" | grep -oP '/clubs/[0-9a-f-]{36}' | head -1 | sed 's|/clubs/||' || true) + CLUB_LINK="/clubs/${CLUB_ID}/billing" +fi + +if [[ -z "${CLUB_LINK:-}" || "$CLUB_LINK" == "/clubs//billing" ]]; then + ko "trova link billing club" +else + ok "billing path $CLUB_LINK" + BODY=$(curl -sf -b "$COOKIES" "$BASE$CLUB_LINK") + echo "$BODY" | grep -qi "stripe non configurato" && ko "Stripe abilitato in UI" || ok "Stripe abilitato in UI" + echo "$BODY" | grep -q "Attiva Premium" && ok "pulsante Attiva Premium" || ko "pulsante Attiva Premium" + echo "$BODY" | grep -q "Dati di fatturazione" && ok "link profilo fatturazione" || ko "link profilo fatturazione" + + CHECKOUT_URL="${CLUB_LINK%/billing}/checkout?plan=${PLAN}" + HEADERS=$(curl -s -D - -o /dev/null -b "$COOKIES" "$BASE$CHECKOUT_URL" | tr -d '\r') + if echo "$HEADERS" | grep -qi "location:.*checkout\.stripe\.com"; then + ok "redirect Stripe Checkout" + elif echo "$HEADERS" | grep -qi "location:.*billing/profile"; then + ok "redirect profilo fatturazione (dati incompleti — completa prima del pagamento)" + else + ko "redirect checkout (atteso checkout.stripe.com o billing/profile)" + echo "$HEADERS" | grep -i "^location:" | head -3 || true + fi +fi + +echo "" +echo "Risultato: $pass OK, $fail FAIL" +[[ "$fail" -eq 0 ]]