Billing: fatture manuali, profilo obbligatorio e piani Stripe mensile/annuale.
Rimuove link al portale Stripe in area cliente, aggiunge flusso admin per PDF fattura e email, blocca checkout senza dati di fatturazione, allinea prezzi a €4,90/€39,90 e €9,90/€69,90, locale italiano e documentazione deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
9
backend/app/helpers/application_helper.rb
Normal file
9
backend/app/helpers/application_helper.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module ApplicationHelper
|
||||
# Data/ora nel fuso dell'app (Europe/Rome) e nella lingua del sito.
|
||||
def l_local(date_or_time, format: :long)
|
||||
return nil if date_or_time.blank?
|
||||
|
||||
value = date_or_time.respond_to?(:in_time_zone) ? date_or_time.in_time_zone : date_or_time
|
||||
I18n.with_locale(:it) { I18n.l(value, format: format) }
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
17
backend/app/jobs/expire_ended_subscriptions_job.rb
Normal file
17
backend/app/jobs/expire_ended_subscriptions_job.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
# Passa a Free le società con disdetta programmata e periodo scaduto (anche senza visita pagina billing).
|
||||
class ExpireEndedSubscriptionsJob
|
||||
include Sidekiq::Job
|
||||
|
||||
def perform
|
||||
free_plan = Plan["free"]
|
||||
|
||||
Subscription.where(cancel_at_period_end: true)
|
||||
.where("current_period_end <= ?", Time.current)
|
||||
.where.not(plan_id: free_plan.id)
|
||||
.find_each do |sub|
|
||||
Billing::Stripe::FinalizeSubscription.call(club: sub.club)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[ExpireEndedSubscriptions] club=#{sub.club_id} #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
18
backend/app/mailers/billing/invoice_mailer.rb
Normal file
18
backend/app/mailers/billing/invoice_mailer.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
module Billing
|
||||
class InvoiceMailer < ApplicationMailer
|
||||
def invoice_pdf
|
||||
@invoice = params[:invoice]
|
||||
@club = @invoice.club
|
||||
|
||||
attachments["fattura-#{@invoice.number.parameterize}.pdf"] = {
|
||||
mime_type: "application/pdf",
|
||||
content: @invoice.pdf.download
|
||||
}
|
||||
|
||||
mail(
|
||||
to: @club.billing_email,
|
||||
subject: "Fattura #{@invoice.display_number} — Match Live TV"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(" · ")
|
||||
|
||||
47
backend/app/services/billing/issue_invoice.rb
Normal file
47
backend/app/services/billing/issue_invoice.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
module Billing
|
||||
class IssueInvoice
|
||||
class Error < StandardError; end
|
||||
|
||||
def self.call(invoice:, pdf: nil)
|
||||
new(invoice: invoice, pdf: pdf).call
|
||||
end
|
||||
|
||||
def initialize(invoice:, pdf: nil)
|
||||
@invoice = invoice
|
||||
@pdf = pdf
|
||||
end
|
||||
|
||||
def call
|
||||
raise Error, "Carica il file PDF della fattura" if @pdf.blank? && !@invoice.pdf.attached?
|
||||
|
||||
attach_pdf!(@pdf) if @pdf.present?
|
||||
raise Error, "PDF mancante" unless @invoice.pdf.attached?
|
||||
|
||||
recipient = @invoice.club.billing_email.presence
|
||||
raise Error, "Email di fatturazione non configurata per la società" if recipient.blank?
|
||||
|
||||
@invoice.update!(status: "issued")
|
||||
|
||||
Billing::InvoiceMailer.with(invoice: @invoice).invoice_pdf.deliver_now
|
||||
@invoice.update!(status: "sent", emailed_at: Time.current)
|
||||
|
||||
@invoice
|
||||
end
|
||||
|
||||
def attach_pdf!(upload)
|
||||
if upload.respond_to?(:tempfile)
|
||||
@invoice.pdf.attach(
|
||||
io: upload.tempfile,
|
||||
filename: upload.original_filename,
|
||||
content_type: upload.content_type.presence || "application/pdf"
|
||||
)
|
||||
elsif upload.is_a?(Hash)
|
||||
@invoice.pdf.attach(**upload)
|
||||
elsif upload.is_a?(StringIO)
|
||||
@invoice.pdf.attach(io: upload, filename: "fattura.pdf", content_type: "application/pdf")
|
||||
else
|
||||
@invoice.pdf.attach(upload)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
129
backend/app/services/billing/payment_description.rb
Normal file
129
backend/app/services/billing/payment_description.rb
Normal file
@@ -0,0 +1,129 @@
|
||||
module Billing
|
||||
# Etichette leggibili per lo storico pagamenti (mai testo grezzo Stripe / webhook).
|
||||
class PaymentDescription
|
||||
UGLY = /\A(webhook\s*test|test|x|—|-)\z/i
|
||||
|
||||
AMOUNT_HINTS = {
|
||||
500 => %w[premium_light monthly],
|
||||
4000 => %w[premium_light yearly],
|
||||
2000 => %w[premium_full monthly],
|
||||
20_000 => %w[premium_full yearly]
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def for_stripe_invoice(invoice)
|
||||
plan_slug, interval = extract_plan_and_interval(invoice)
|
||||
build(plan_slug: plan_slug, interval: interval, amount_cents: invoice.amount_paid)
|
||||
end
|
||||
|
||||
def for_payment(payment)
|
||||
stored = payment.description.to_s.strip
|
||||
return stored if stored.present? && !ugly?(stored)
|
||||
|
||||
build(
|
||||
plan_slug: payment.plan_slug,
|
||||
interval: nil,
|
||||
amount_cents: payment.amount_cents
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build(plan_slug:, interval:, amount_cents:)
|
||||
plan_slug, interval = resolve_plan_interval(plan_slug, interval, amount_cents)
|
||||
return plan_label(plan_slug, interval) if plan_slug.present?
|
||||
|
||||
if amount_cents.present? && amount_cents.to_i.positive?
|
||||
euros = format("%.2f", amount_cents.to_i / 100.0)
|
||||
return "Pagamento Match Live TV — #{euros} €"
|
||||
end
|
||||
|
||||
"Abbonamento Match Live TV"
|
||||
end
|
||||
|
||||
def resolve_plan_interval(plan_slug, interval, amount_cents)
|
||||
plan_slug = plan_slug.presence
|
||||
interval = interval.presence
|
||||
|
||||
if plan_slug.blank? && amount_cents.present?
|
||||
hint = AMOUNT_HINTS[amount_cents.to_i]
|
||||
plan_slug, interval = hint if hint
|
||||
end
|
||||
|
||||
if plan_slug.present? && interval.blank? && amount_cents.present?
|
||||
hint = AMOUNT_HINTS[amount_cents.to_i]
|
||||
interval = hint&.last if hint&.first == plan_slug
|
||||
end
|
||||
|
||||
interval ||= Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL if plan_slug.present?
|
||||
[plan_slug, interval]
|
||||
end
|
||||
|
||||
def plan_label(plan_slug, interval)
|
||||
plan = Plan[plan_slug]
|
||||
price = Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)
|
||||
"#{plan.name} — #{price}"
|
||||
rescue KeyError
|
||||
plan_slug.to_s.humanize
|
||||
end
|
||||
|
||||
def extract_plan_and_interval(invoice)
|
||||
meta = merged_metadata(invoice)
|
||||
plan_slug = meta["plan_slug"].presence
|
||||
interval = normalize_interval(meta["billing_interval"])
|
||||
|
||||
line = invoice.lines&.data&.first
|
||||
if line
|
||||
line_meta = line_metadata(line)
|
||||
plan_slug ||= line_meta["plan_slug"]
|
||||
interval ||= normalize_interval(line_meta["billing_interval"])
|
||||
|
||||
price_id = line_price_id(line)
|
||||
from_price = Billing::Stripe::PriceCatalog.infer_from_price_id(price_id)
|
||||
plan_slug ||= from_price&.first
|
||||
interval ||= from_price&.last
|
||||
end
|
||||
|
||||
plan_slug = normalize_plan_slug(plan_slug)
|
||||
[plan_slug, interval]
|
||||
end
|
||||
|
||||
def merged_metadata(invoice)
|
||||
Billing::Stripe::InvoiceMetadata.merged(invoice)
|
||||
end
|
||||
|
||||
def line_metadata(line)
|
||||
meta = Billing::Stripe::InvoicePaymentRefs.safe_get(line, :metadata)
|
||||
meta.to_h.stringify_keys
|
||||
rescue NoMethodError
|
||||
{}
|
||||
end
|
||||
|
||||
def line_price_id(line)
|
||||
pricing = Billing::Stripe::InvoicePaymentRefs.safe_get(line, :pricing)
|
||||
details = Billing::Stripe::InvoicePaymentRefs.safe_get(pricing, :price_details)
|
||||
Billing::Stripe::InvoicePaymentRefs.safe_get(details, :price)
|
||||
end
|
||||
|
||||
def normalize_interval(value)
|
||||
return nil if value.blank?
|
||||
|
||||
Billing::Stripe::PriceCatalog.normalize_interval(value)
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def normalize_plan_slug(slug)
|
||||
return nil if slug.blank?
|
||||
|
||||
slug = slug.to_s
|
||||
slug = "premium_full" if slug == "premium"
|
||||
slug.presence_in(%w[premium_light premium_full])
|
||||
end
|
||||
|
||||
def ugly?(text)
|
||||
UGLY.match?(text.to_s.strip)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
29
backend/app/services/billing/stripe/cancel_subscription.rb
Normal file
29
backend/app/services/billing/stripe/cancel_subscription.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
class CancelSubscription
|
||||
def self.call(club:)
|
||||
new(club: club).call
|
||||
end
|
||||
|
||||
def initialize(club:)
|
||||
@club = club
|
||||
end
|
||||
|
||||
def call
|
||||
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
|
||||
|
||||
sub = @club.subscription
|
||||
raise "Nessun abbonamento premium attivo" unless sub&.premium? && sub.stripe_subscription_id.present?
|
||||
raise "Disdetta già programmata" if sub.cancel_at_period_end?
|
||||
|
||||
stripe_sub = ::Stripe::Subscription.update(
|
||||
sub.stripe_subscription_id,
|
||||
cancel_at_period_end: true
|
||||
)
|
||||
|
||||
CheckoutSync.from_subscription(stripe_sub, club: @club)
|
||||
@club.subscription.reload
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
196
backend/app/services/billing/stripe/checkout_sync.rb
Normal file
196
backend/app/services/billing/stripe/checkout_sync.rb
Normal file
@@ -0,0 +1,196 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
# Allinea DB locale con Stripe dopo Checkout o webhook (idempotente).
|
||||
class CheckoutSync
|
||||
def self.call(club:, session_id: nil)
|
||||
new(club: club, session_id: session_id).call
|
||||
end
|
||||
|
||||
def self.from_session(session)
|
||||
new.apply_from_session(session)
|
||||
end
|
||||
|
||||
def self.from_subscription(stripe_sub, club: nil, plan_slug: nil, billing_interval: nil)
|
||||
new.apply_from_subscription(stripe_sub, club: club, plan_slug: plan_slug, billing_interval: billing_interval)
|
||||
end
|
||||
|
||||
def initialize(club: nil, session_id: nil)
|
||||
@club = club
|
||||
@session_id = session_id
|
||||
end
|
||||
|
||||
def call
|
||||
return false unless MatchLiveTv.stripe_enabled?
|
||||
|
||||
if @session_id.present?
|
||||
session = ::Stripe::Checkout::Session.retrieve(@session_id)
|
||||
return false unless session_paid?(session)
|
||||
|
||||
apply_from_session(session)
|
||||
return true
|
||||
end
|
||||
|
||||
sync_from_customer
|
||||
end
|
||||
|
||||
def apply_from_session(session)
|
||||
club = resolve_club(session) || @club
|
||||
return unless club
|
||||
|
||||
sub = club.subscription || club.build_subscription
|
||||
sub.update!(stripe_customer_id: session.customer) if session.customer.present?
|
||||
|
||||
return unless session.subscription.present?
|
||||
|
||||
stripe_sub = ::Stripe::Subscription.retrieve(session.subscription)
|
||||
plan_slug = metadata_plan_slug(session) || "premium_light"
|
||||
plan_slug = "premium_full" if plan_slug == "premium"
|
||||
apply_from_subscription(
|
||||
stripe_sub,
|
||||
club: club,
|
||||
plan_slug: plan_slug,
|
||||
billing_interval: metadata_billing_interval(session)
|
||||
)
|
||||
end
|
||||
|
||||
def apply_from_subscription(stripe_sub, club: nil, plan_slug: nil, billing_interval: nil)
|
||||
club ||= Club.joins(:subscription)
|
||||
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
|
||||
club ||= resolve_club(stripe_sub)
|
||||
return unless club
|
||||
|
||||
plan_slug ||= metadata_plan_slug(stripe_sub) || infer_plan_slug_from_price(stripe_sub) || "premium_light"
|
||||
plan_slug = "premium_full" if plan_slug == "premium"
|
||||
plan = Plan[plan_slug]
|
||||
status = map_status(stripe_sub.status)
|
||||
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
|
||||
|
||||
billing_interval = billing_interval.presence || metadata_billing_interval(stripe_sub) ||
|
||||
infer_interval_from_price(stripe_sub) ||
|
||||
PriceCatalog::DEFAULT_INTERVAL
|
||||
|
||||
Billing::AssignPlan.call(
|
||||
club: club,
|
||||
plan_slug: plan.slug,
|
||||
status: status,
|
||||
stripe_attrs: {
|
||||
stripe_customer_id: stripe_sub.customer,
|
||||
stripe_subscription_id: stripe_sub.id,
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
cancel_at_period_end: stripe_sub.cancel_at_period_end,
|
||||
billing_interval: billing_interval
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sync_from_customer
|
||||
return false unless @club
|
||||
|
||||
customer_id = @club.subscription&.stripe_customer_id
|
||||
return false if customer_id.blank?
|
||||
|
||||
stripe_sub = latest_active_subscription_for_club(customer_id, @club.id)
|
||||
return false unless stripe_sub
|
||||
|
||||
apply_from_subscription(stripe_sub, club: @club)
|
||||
true
|
||||
end
|
||||
|
||||
def latest_active_subscription_for_club(customer_id, club_id)
|
||||
list = ::Stripe::Subscription.list(customer: customer_id, status: "all", limit: 20)
|
||||
list.data
|
||||
.select { |s| %w[active trialing].include?(s.status) }
|
||||
.select { |s| subscription_belongs_to_club?(s, club_id) }
|
||||
.max_by(&:created)
|
||||
end
|
||||
|
||||
def subscription_belongs_to_club?(stripe_sub, club_id)
|
||||
meta_club = metadata_club_id(stripe_sub)
|
||||
return meta_club == club_id if meta_club.present?
|
||||
|
||||
team_id = metadata_team_id(stripe_sub)
|
||||
return Team.find_by(id: team_id)&.club_id == club_id if team_id.present?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def session_paid?(session)
|
||||
session.payment_status == "paid" || session.status == "complete"
|
||||
end
|
||||
|
||||
def infer_plan_slug_from_price(stripe_sub)
|
||||
price_id = stripe_sub.items&.data&.first&.price&.id
|
||||
return "premium_light" if price_id == MatchLiveTv.stripe_premium_light_monthly_price_id
|
||||
return "premium_light" if price_id == MatchLiveTv.stripe_premium_light_yearly_price_id
|
||||
return "premium_full" if price_id == MatchLiveTv.stripe_premium_full_monthly_price_id
|
||||
return "premium_full" if price_id == MatchLiveTv.stripe_premium_full_yearly_price_id
|
||||
|
||||
"premium_light"
|
||||
end
|
||||
|
||||
def infer_interval_from_price(stripe_sub)
|
||||
interval = stripe_sub.items&.data&.first&.price&.recurring&.interval
|
||||
return "monthly" if interval == "month"
|
||||
return "yearly" if interval == "year"
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def resolve_club(obj)
|
||||
club_id = metadata_club_id(obj)
|
||||
return Club.find_by(id: club_id) if club_id.present?
|
||||
|
||||
team_id = metadata_team_id(obj)
|
||||
return Team.find_by(id: team_id)&.club if team_id.present?
|
||||
|
||||
@club
|
||||
end
|
||||
|
||||
def metadata_billing_interval(obj)
|
||||
meta = obj.metadata
|
||||
return nil if meta.blank?
|
||||
|
||||
val = meta["billing_interval"] || meta[:billing_interval]
|
||||
return nil if val.blank?
|
||||
|
||||
PriceCatalog.normalize_interval(val)
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def metadata_plan_slug(obj)
|
||||
meta = obj.metadata
|
||||
return nil if meta.blank?
|
||||
|
||||
meta["plan_slug"] || meta[:plan_slug]
|
||||
end
|
||||
|
||||
def metadata_club_id(obj)
|
||||
meta = obj.metadata
|
||||
return nil if meta.blank?
|
||||
|
||||
meta["club_id"] || meta[:club_id]
|
||||
end
|
||||
|
||||
def metadata_team_id(obj)
|
||||
meta = obj.metadata
|
||||
return nil if meta.blank?
|
||||
|
||||
meta["team_id"] || meta[:team_id]
|
||||
end
|
||||
|
||||
def map_status(stripe_status)
|
||||
case stripe_status
|
||||
when "trialing" then "trialing"
|
||||
when "active" then "active"
|
||||
when "past_due", "unpaid" then "past_due"
|
||||
when "canceled", "incomplete_expired" then "canceled"
|
||||
else "incomplete"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
65
backend/app/services/billing/stripe/finalize_subscription.rb
Normal file
65
backend/app/services/billing/stripe/finalize_subscription.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
# Passa a Free se il periodo pagato è scaduto (es. disdetta o webhook mancato in locale).
|
||||
class FinalizeSubscription
|
||||
def self.call(club:)
|
||||
new(club: club).call
|
||||
end
|
||||
|
||||
def initialize(club:)
|
||||
@club = club
|
||||
end
|
||||
|
||||
def call
|
||||
sub = @club.subscription
|
||||
return false unless sub&.stripe_subscription_id.present?
|
||||
return false if sub.free?
|
||||
|
||||
stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id)
|
||||
|
||||
if stripe_sub.status.in?(%w[canceled incomplete_expired])
|
||||
downgrade!(sub)
|
||||
return true
|
||||
end
|
||||
|
||||
period_end = period_end_time(sub, stripe_sub)
|
||||
return false if period_end.blank? || period_end > Time.current
|
||||
|
||||
if sub.cancel_at_period_end? || stripe_sub.cancel_at_period_end
|
||||
downgrade!(sub)
|
||||
return true
|
||||
end
|
||||
|
||||
false
|
||||
rescue ::Stripe::InvalidRequestError => e
|
||||
downgrade!(sub) if e.message.include?("No such subscription")
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def period_end_time(sub, stripe_sub)
|
||||
local = sub.current_period_end
|
||||
return local if local.present?
|
||||
|
||||
start_at, end_at = SubscriptionPeriod.times(stripe_sub)
|
||||
end_at
|
||||
end
|
||||
|
||||
def downgrade!(sub)
|
||||
Billing::AssignPlan.call(
|
||||
club: @club,
|
||||
plan_slug: "free",
|
||||
status: "active",
|
||||
stripe_attrs: {
|
||||
stripe_subscription_id: nil,
|
||||
current_period_start: nil,
|
||||
current_period_end: nil,
|
||||
cancel_at_period_end: false,
|
||||
billing_interval: nil
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
41
backend/app/services/billing/stripe/invoice_metadata.rb
Normal file
41
backend/app/services/billing/stripe/invoice_metadata.rb
Normal file
@@ -0,0 +1,41 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
# Metadati e subscription id su Invoice (API Basil+: parent.subscription_details).
|
||||
module InvoiceMetadata
|
||||
module_function
|
||||
|
||||
def merged(invoice)
|
||||
base = metadata_hash(invoice)
|
||||
base.merge(subscription_metadata(invoice))
|
||||
end
|
||||
|
||||
def subscription_metadata(invoice)
|
||||
details = subscription_details(invoice)
|
||||
return {} if details.blank?
|
||||
|
||||
metadata_hash(details)
|
||||
end
|
||||
|
||||
def subscription_id(invoice)
|
||||
details = subscription_details(invoice)
|
||||
return nil if details.blank?
|
||||
|
||||
InvoicePaymentRefs.safe_get(details, :subscription)
|
||||
end
|
||||
|
||||
def subscription_details(invoice)
|
||||
parent = InvoicePaymentRefs.safe_get(invoice, :parent)
|
||||
return nil if parent.blank?
|
||||
|
||||
InvoicePaymentRefs.safe_get(parent, :subscription_details)
|
||||
end
|
||||
|
||||
def metadata_hash(obj)
|
||||
meta = InvoicePaymentRefs.safe_get(obj, :metadata)
|
||||
meta.to_h.stringify_keys
|
||||
rescue NoMethodError
|
||||
{}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
56
backend/app/services/billing/stripe/invoice_payment_refs.rb
Normal file
56
backend/app/services/billing/stripe/invoice_payment_refs.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
# Estrae riferimenti pagamento da Invoice Stripe (API Basil+: niente payment_intent top-level).
|
||||
module InvoicePaymentRefs
|
||||
EXPAND = ["payments.data.payment.payment_intent"].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def payment_intent_id(invoice)
|
||||
each_payment_intent(invoice) { |pi| stripe_id(pi) }
|
||||
end
|
||||
|
||||
def charge_id(invoice)
|
||||
each_payment_intent(invoice) do |pi|
|
||||
stripe_id(safe_get(pi, :latest_charge))
|
||||
end
|
||||
end
|
||||
|
||||
def each_payment_intent(invoice)
|
||||
payment_entries(invoice).each do |entry|
|
||||
payment = safe_get(entry, :payment)
|
||||
next if payment.blank?
|
||||
|
||||
pi = safe_get(payment, :payment_intent)
|
||||
result = yield(pi) if block_given?
|
||||
return result if result.present?
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def payment_entries(invoice)
|
||||
payments = safe_get(invoice, :payments)
|
||||
return [] if payments.blank?
|
||||
|
||||
safe_get(payments, :data) || []
|
||||
end
|
||||
|
||||
def stripe_id(value)
|
||||
return nil if value.blank?
|
||||
return value if value.is_a?(String)
|
||||
|
||||
safe_get(value, :id)
|
||||
end
|
||||
|
||||
def safe_get(obj, key)
|
||||
return nil if obj.blank?
|
||||
return obj[key] if obj.is_a?(Hash)
|
||||
return obj.public_send(key) if obj.respond_to?(key)
|
||||
|
||||
nil
|
||||
rescue NoMethodError
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
74
backend/app/services/billing/stripe/price_catalog.rb
Normal file
74
backend/app/services/billing/stripe/price_catalog.rb
Normal file
@@ -0,0 +1,74 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
class PriceCatalog
|
||||
INTERVALS = %w[monthly yearly].freeze
|
||||
DEFAULT_INTERVAL = "yearly"
|
||||
|
||||
PRICING = {
|
||||
"premium_light" => { "yearly" => "€39,90/anno", "monthly" => "€4,90/mese" },
|
||||
"premium_full" => { "yearly" => "€69,90/anno", "monthly" => "€9,90/mese" }
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def normalize_interval(value)
|
||||
interval = value.to_s.presence || DEFAULT_INTERVAL
|
||||
raise ArgumentError, "Intervallo non valido" unless INTERVALS.include?(interval)
|
||||
|
||||
interval
|
||||
end
|
||||
|
||||
def price_id(plan_slug:, interval:)
|
||||
interval = normalize_interval(interval)
|
||||
id = price_id_for(plan_slug.to_s, interval)
|
||||
raise "Price ID Stripe mancante per #{plan_slug} (#{interval})" if id.blank?
|
||||
|
||||
id
|
||||
end
|
||||
|
||||
def configured?(plan_slug:, interval:)
|
||||
price_id_for(plan_slug.to_s, normalize_interval(interval)).present?
|
||||
end
|
||||
|
||||
def label(plan_slug:, interval:)
|
||||
PRICING.dig(plan_slug.to_s, normalize_interval(interval)) || normalize_interval(interval)
|
||||
end
|
||||
|
||||
def available_intervals(plan_slug:)
|
||||
INTERVALS.select { |interval| configured?(plan_slug: plan_slug, interval: interval) }
|
||||
end
|
||||
|
||||
def infer_from_price_id(price_id)
|
||||
return nil if price_id.blank?
|
||||
|
||||
{
|
||||
MatchLiveTv.stripe_premium_light_monthly_price_id => %w[premium_light monthly],
|
||||
MatchLiveTv.stripe_premium_light_yearly_price_id => %w[premium_light yearly],
|
||||
MatchLiveTv.stripe_premium_full_monthly_price_id => %w[premium_full monthly],
|
||||
MatchLiveTv.stripe_premium_full_yearly_price_id => %w[premium_full yearly]
|
||||
}.each do |configured_id, pair|
|
||||
return pair if configured_id.present? && configured_id == price_id
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def price_id_for(plan_slug, interval)
|
||||
case [plan_slug, interval]
|
||||
when %w[premium_light monthly]
|
||||
MatchLiveTv.stripe_premium_light_monthly_price_id
|
||||
when %w[premium_light yearly]
|
||||
MatchLiveTv.stripe_premium_light_yearly_price_id
|
||||
when %w[premium_full monthly]
|
||||
MatchLiveTv.stripe_premium_full_monthly_price_id
|
||||
when %w[premium_full yearly]
|
||||
MatchLiveTv.stripe_premium_full_yearly_price_id
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
45
backend/app/views/admin/billing_invoices/edit.html.erb
Normal file
45
backend/app/views/admin/billing_invoices/edit.html.erb
Normal file
@@ -0,0 +1,45 @@
|
||||
<h1>Fattura <%= @invoice.number %> — <%= @club.name %></h1>
|
||||
<p style="color:#666">
|
||||
Stato: <strong><%= @invoice.status %></strong>
|
||||
<% if @invoice.emailed_at.present? %>
|
||||
· Inviata il <%= @invoice.emailed_at.in_time_zone.strftime("%d/%m/%Y %H:%M") %>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<%= form_with model: @invoice, url: admin_club_billing_invoice_path(@club, @invoice), multipart: true, method: :patch do |f| %>
|
||||
<%= f.label :number, "Numero fattura" %>
|
||||
<%= f.text_field :number, required: true, disabled: @invoice.status.in?(%w[sent]) %>
|
||||
|
||||
<%= f.label :issued_on, "Data emissione" %>
|
||||
<%= f.date_field :issued_on, required: true, disabled: @invoice.status.in?(%w[sent]) %>
|
||||
|
||||
<%= label_tag :amount_euros, "Importo (€)" %>
|
||||
<%= number_field_tag "billing_invoice[amount_euros]",
|
||||
@invoice.amount_cents / 100.0,
|
||||
step: 0.01, min: 0.01, required: true, disabled: @invoice.status.in?(%w[sent]) %>
|
||||
|
||||
<% if @payment %>
|
||||
<p style="color:#888;font-size:0.9rem">Pagamento: <%= @payment.display_description %> — <%= @payment.formatted_amount %></p>
|
||||
<% end %>
|
||||
|
||||
<%= f.label :pdf, "PDF fattura" %>
|
||||
<% if @invoice.pdf.attached? %>
|
||||
<p style="color:#888">PDF già caricato: <%= @invoice.pdf.filename %></p>
|
||||
<% end %>
|
||||
<%= f.file_field :pdf, accept: "application/pdf" %>
|
||||
|
||||
<%= f.label :notes, "Note interne" %>
|
||||
<%= f.text_area :notes, rows: 2 %>
|
||||
|
||||
<div style="margin-top:16px;display:flex;gap:10px;flex-wrap:wrap">
|
||||
<% unless @invoice.status.in?(%w[sent]) %>
|
||||
<%= f.submit "Salva bozza", class: "btn btn-secondary" %>
|
||||
<%= f.submit "Emetti e invia via email", class: "btn btn-primary",
|
||||
data: { turbo_confirm: "Inviare la fattura a #{@club.billing_email}?" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<p><%= link_to "← Pagamenti e fatture", admin_club_billing_invoices_path(@club) %></p>
|
||||
@@ -1,10 +1,56 @@
|
||||
<h1>Fatture — <%= @club.name %></h1>
|
||||
<p><%= link_to "← Dashboard", admin_root_path %> · <%= link_to "Nuova fattura", new_admin_club_billing_invoice_path(@club), class: "btn btn-primary" %></p>
|
||||
<h1>Fatturazione — <%= @club.name %></h1>
|
||||
<p>
|
||||
<%= link_to "← Dashboard", admin_root_path %>
|
||||
· <%= link_to "Nuova fattura (senza pagamento)", new_admin_club_billing_invoice_path(@club), class: "btn btn-secondary" %>
|
||||
</p>
|
||||
|
||||
<h2 style="margin-top:24px;font-size:1.1rem">Pagamenti</h2>
|
||||
<% if @payments.any? %>
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Descrizione</th>
|
||||
<th>Importo</th>
|
||||
<th>Fattura</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @payments.each do |payment| %>
|
||||
<% inv = payment.invoice %>
|
||||
<tr>
|
||||
<td><%= payment.paid_at&.to_date || payment.created_at.to_date %></td>
|
||||
<td><%= payment.display_description %></td>
|
||||
<td><%= payment.formatted_amount %></td>
|
||||
<td>
|
||||
<% if inv %>
|
||||
<%= inv.number %> — <%= inv.status %>
|
||||
<% if inv.pdf.attached? %> (PDF)<% end %>
|
||||
<% else %>
|
||||
—
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if inv %>
|
||||
<%= link_to "Modifica / invia PDF", edit_admin_club_billing_invoice_path(@club, inv) %>
|
||||
<% else %>
|
||||
<%= link_to "Crea fattura", new_admin_club_billing_invoice_path(@club, billing_payment_id: payment.id), class: "btn btn-primary", style: "padding:6px 10px;font-size:0.85rem" %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p>Nessun pagamento registrato per questa società.</p>
|
||||
<% end %>
|
||||
|
||||
<h2 style="margin-top:32px;font-size:1.1rem">Tutte le fatture</h2>
|
||||
<% if @invoices.any? %>
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr><th>Numero</th><th>Data</th><th>Importo</th><th>Stato</th><th>PDF</th></tr>
|
||||
<tr><th>Numero</th><th>Data</th><th>Importo</th><th>Stato</th><th>Pagamento</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @invoices.each do |inv| %>
|
||||
@@ -13,7 +59,8 @@
|
||||
<td><%= inv.issued_on %></td>
|
||||
<td><%= inv.formatted_amount %></td>
|
||||
<td><%= inv.status %></td>
|
||||
<td><%= inv.pdf.attached? ? "Sì" : "No" %></td>
|
||||
<td><%= inv.billing_payment_id.present? ? "Sì" : "No" %></td>
|
||||
<td><%= link_to "Modifica", edit_admin_club_billing_invoice_path(@club, inv) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<h1>Nuova fattura Aruba — <%= @club.name %></h1>
|
||||
<p style="color:#666">Carica il PDF emesso da Aruba Fatture e collega il pagamento se disponibile.</p>
|
||||
<h1>Nuova fattura — <%= @club.name %></h1>
|
||||
<% if @payment %>
|
||||
<p style="color:#666">Collegata al pagamento del <%= @payment.paid_at&.to_date || @payment.created_at.to_date %> (<%= @payment.formatted_amount %>).</p>
|
||||
<% end %>
|
||||
|
||||
<div class="card">
|
||||
<%= form_with model: @invoice, url: admin_club_billing_invoices_path(@club), multipart: true do |f| %>
|
||||
<%= form_with model: @invoice, url: admin_club_billing_invoices_path(@club) do |f| %>
|
||||
<%= f.hidden_field :billing_payment_id if @payment %>
|
||||
|
||||
<%= f.label :number, "Numero fattura" %>
|
||||
<%= f.text_field :number, required: true %>
|
||||
|
||||
@@ -10,27 +14,19 @@
|
||||
<%= f.date_field :issued_on, required: true %>
|
||||
|
||||
<%= label_tag :amount_euros, "Importo (€)" %>
|
||||
<%= number_field_tag "billing_invoice[amount_euros]", nil, step: 0.01, min: 0.01, required: true %>
|
||||
<%= number_field_tag "billing_invoice[amount_euros]",
|
||||
(@invoice.amount_cents ? @invoice.amount_cents / 100.0 : nil),
|
||||
step: 0.01, min: 0.01, required: true %>
|
||||
|
||||
<%= f.label :status, "Stato" %>
|
||||
<%= f.select :status, Billing::Invoice::STATUSES %>
|
||||
|
||||
<%= f.label :billing_payment_id, "Pagamento collegato (opzionale)" %>
|
||||
<%= f.collection_select :billing_payment_id, @club.billing_payments.recent, :id,
|
||||
->(p) { "#{p.paid_at&.to_date || p.created_at.to_date} — #{p.formatted_amount}" },
|
||||
{ include_blank: "Nessuno" } %>
|
||||
|
||||
<%= f.label :aruba_document_id, "ID documento Aruba (opzionale)" %>
|
||||
<%= f.text_field :aruba_document_id %>
|
||||
|
||||
<%= f.label :pdf, "PDF fattura" %>
|
||||
<%= f.file_field :pdf, accept: "application/pdf", required: true %>
|
||||
|
||||
<%= f.label :notes, "Note interne" %>
|
||||
<%= f.label :notes, "Note interne (opzionale)" %>
|
||||
<%= f.text_area :notes, rows: 2 %>
|
||||
|
||||
<%= f.submit "Registra fattura", class: "btn btn-primary" %>
|
||||
<p style="color:#666;font-size:0.9rem;margin-top:12px">
|
||||
Salva la bozza, poi carica il PDF e inviala al cliente dalla schermata successiva.
|
||||
</p>
|
||||
|
||||
<%= f.submit "Crea bozza fattura", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<p><%= link_to "← Elenco fatture", admin_club_billing_invoices_path(@club) %></p>
|
||||
<p><%= link_to "← Pagamenti e fatture", admin_club_billing_invoices_path(@club) %></p>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<p>Buongiorno,</p>
|
||||
|
||||
<p>in allegato trovi la fattura <strong><%= @invoice.display_number %></strong> del <%= l(@invoice.issued_on, locale: :it, format: :long) %>
|
||||
per un importo di <strong><%= @invoice.formatted_amount %></strong>, relativa alla società <strong><%= @club.billing_legal_name.presence || @club.name %></strong>.</p>
|
||||
|
||||
<p>Puoi scaricarla anche dalla sezione <strong>Abbonamento</strong> del tuo account Match Live TV.</p>
|
||||
|
||||
<p>Grazie,<br>Match Live TV</p>
|
||||
@@ -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
|
||||
@@ -6,7 +6,7 @@
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=31">
|
||||
<link rel="stylesheet" href="/marketing.css?v=32">
|
||||
</head>
|
||||
<body>
|
||||
<%= render "shared/marketing_nav" %>
|
||||
@@ -18,6 +18,6 @@
|
||||
<%= render "shared/marketing_footer" %>
|
||||
<script src="/branding-form.js?v=1" defer></script>
|
||||
<script src="/roster-form.js?v=1" defer></script>
|
||||
<script src="/password-toggle.js?v=1" defer></script>
|
||||
<script src="/password-toggle.js?v=2" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,8 +5,21 @@
|
||||
<h1>Dati di fatturazione</h1>
|
||||
<p style="color:#aaa">Società: <strong><%= @club.name %></strong>. Inserisci i dati per l’intestazione delle fatture e l’invio tramite SDI o PEC.</p>
|
||||
|
||||
<% plan_return = params[:plan].presence_in(%w[premium_light premium_full]) %>
|
||||
<% unless @club.billing_profile_complete? %>
|
||||
<div class="flash alert" style="margin:16px 0">
|
||||
<strong>Obbligatori per abbonarti a un piano premium.</strong>
|
||||
<%= billing_profile_incomplete_message(@club) %>
|
||||
<% if plan_return %>
|
||||
Dopo il salvataggio potrai procedere al pagamento di <strong><%= Plan[plan_return].name %></strong>.
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="card">
|
||||
<%= form_with url: public_club_billing_profile_path(@club), method: :patch do %>
|
||||
<%= hidden_field_tag :plan, plan_return if plan_return %>
|
||||
<%= hidden_field_tag :interval, params[:interval] if params[:interval].present? %>
|
||||
<%= render "shared/billing_profile_fields", record: @club %>
|
||||
<%= submit_tag "Salva dati di fatturazione", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
|
||||
<div class="wrap" style="padding-top:20px">
|
||||
<h1>Abbonamento</h1>
|
||||
<%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription %>
|
||||
<%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription, on_billing_page: true %>
|
||||
|
||||
<%= render "shared/stripe_secure_payment" %>
|
||||
<%= render "shared/plan_cards", show_stripe_portal: true %>
|
||||
<%= render "shared/plan_cards", show_stripe_portal: false %>
|
||||
<%= render "shared/subscription_cancel", club: @club, subscription: @subscription %>
|
||||
|
||||
<%= render "shared/billing_documents", club: @club, payments: @payments, invoices: @invoices %>
|
||||
<%= render "shared/billing_documents", club: @club, payments: @payments %>
|
||||
|
||||
<p style="margin-top:24px"><%= link_to "← Società", public_club_path(@club) %></p>
|
||||
</div>
|
||||
|
||||
@@ -22,9 +22,16 @@
|
||||
<%= label_tag :plan, "Piano iniziale (per tutta la società)" %>
|
||||
<%= select_tag :plan, options_for_select([
|
||||
["Free — 1 responsabile trasmissione per squadra, 1 live", "free"],
|
||||
["Premium Light — €40/anno o €5/mese", "premium_light"],
|
||||
["Premium Full — €200/anno o €20/mese", "premium_full"]
|
||||
], params[:plan] || "free") %>
|
||||
["Premium Light", "premium_light"],
|
||||
["Premium Full", "premium_full"]
|
||||
], params[:plan] || "free"), id: "club_plan_select" %>
|
||||
<div id="club_plan_interval" style="margin-top:10px">
|
||||
<%= label_tag :interval, "Fatturazione premium" %>
|
||||
<%= select_tag :interval, options_for_select([
|
||||
["Annuale — €40/anno (Light) o €200/anno (Full)", "yearly"],
|
||||
["Mensile — €5/mese (Light) o €20/mese (Full)", "monthly"]
|
||||
], params[:interval] || "yearly") %>
|
||||
</div>
|
||||
<%= render "shared/stripe_secure_payment", compact: true %>
|
||||
<%= submit_tag "Crea società", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<h2>Diretta terminata</h2>
|
||||
<p>Lo streaming di questa partita è stato chiuso.</p>
|
||||
<% if @session.ended_at %>
|
||||
<p class="stream-ended-meta">Chiusa il <%= l(@session.ended_at.in_time_zone, format: :long) %></p>
|
||||
<p class="stream-ended-meta">Chiusa il <%= l_local(@session.ended_at) %></p>
|
||||
<% end %>
|
||||
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
|
||||
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<td><strong><%= match.opponent_name %></strong></td>
|
||||
<td>
|
||||
<% if match.scheduled_at %>
|
||||
<%= l match.scheduled_at.in_time_zone, format: :long %>
|
||||
<%= l_local(match.scheduled_at) %>
|
||||
<% else %>
|
||||
<span style="color:#888">Senza orario</span>
|
||||
<% end %>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<p>Registrazione indicizzata. Il playback dipende dalla retention del piano Premium.</p>
|
||||
<% end %>
|
||||
<% if @recording.expires_at %>
|
||||
<p class="stream-ended-meta" style="margin-top:12px">Disponibile fino al <%= l @recording.expires_at, format: :long %></p>
|
||||
<p class="stream-ended-meta" style="margin-top:12px">Disponibile fino al <%= l_local(@recording.expires_at) %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
<%= render "shared/input_toggle",
|
||||
name: :password,
|
||||
label: "Password",
|
||||
input_type: "text",
|
||||
required: true,
|
||||
autocomplete: "current-password" %>
|
||||
<p class="auth-forgot"><%= link_to "Password dimenticata?", public_password_forgot_path %></p>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<% elsif plan.slug == "free" %>
|
||||
<p style="color:#888;font-size:0.85rem">Contatta il supporto per downgrade.</p>
|
||||
<% elsif MatchLiveTv.stripe_enabled? %>
|
||||
<%= link_to "Attiva #{plan.name}", public_club_checkout_path(@team.club, plan: plan.slug), class: "btn btn-primary" %>
|
||||
<%= link_to "Gestisci abbonamento società", public_club_billing_path(@team.club), class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<p style="color:#888">Stripe non configurato</p>
|
||||
<% end %>
|
||||
@@ -41,9 +41,7 @@
|
||||
<p style="color:#aaa">Esigenze diverse? <%= mail_to "info@matchlive.it", "Contattaci" %> per un'offerta su misura.</p>
|
||||
</div>
|
||||
|
||||
<% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %>
|
||||
<p style="margin-top:20px"><%= button_to "Gestisci su Stripe", public_club_portal_path(@team.club), class: "btn btn-secondary" %></p>
|
||||
<% end %>
|
||||
<p style="margin-top:20px"><%= link_to "Pagamenti e fatture della società", public_club_billing_path(@team.club) %></p>
|
||||
|
||||
<p><%= link_to "← Dettagli squadra", public_team_details_path(@team) %></p>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<%# locals: (club:, payments:, invoices:) %>
|
||||
<%# locals: (club:, payments:) %>
|
||||
<section class="billing-documents" style="margin-top:40px">
|
||||
<h2>Pagamenti e fatture</h2>
|
||||
<p style="color:#888;font-size:0.9rem">
|
||||
Qui trovi lo storico dei pagamenti e le fatture emesse alla società.
|
||||
I pagamenti premium sono elaborati in modo sicuro tramite Stripe.
|
||||
Storico dei pagamenti della società e relative fatture emesse da Match Live TV.
|
||||
</p>
|
||||
|
||||
<% unless club.billing_profile_complete? %>
|
||||
<div class="flash alert" style="margin:16px 0">
|
||||
<strong>Dati di fatturazione incompleti.</strong>
|
||||
Compila ragione sociale, indirizzo, P.IVA/CF e SDI o PEC per ricevere le fatture.
|
||||
Compila ragione sociale, indirizzo, P.IVA/CF e SDI o PEC per ricevere le fatture via email.
|
||||
<%= link_to "Completa ora", public_club_billing_profile_path(club), class: "btn btn-secondary", style: "margin-top:10px;display:inline-block" %>
|
||||
</div>
|
||||
<% else %>
|
||||
@@ -19,14 +18,7 @@
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<% if MatchLiveTv.stripe_enabled? && club.subscription&.stripe_customer_id.present? %>
|
||||
<%= button_to "Sincronizza pagamenti da Stripe",
|
||||
public_club_billing_sync_payments_path(club),
|
||||
class: "btn btn-secondary",
|
||||
style: "margin:12px 0" %>
|
||||
<% end %>
|
||||
|
||||
<h3 style="margin-top:28px;font-size:1.1rem">Pagamenti effettuati</h3>
|
||||
<h3 style="margin-top:28px;font-size:1.1rem">Pagamenti e fatture</h3>
|
||||
<% if payments.any? %>
|
||||
<table class="data billing-table">
|
||||
<thead>
|
||||
@@ -34,54 +26,34 @@
|
||||
<th>Data</th>
|
||||
<th>Descrizione</th>
|
||||
<th>Importo</th>
|
||||
<th>Stato</th>
|
||||
<th>Stato pagamento</th>
|
||||
<th>Fattura</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% payments.each do |payment| %>
|
||||
<% inv = payment.invoice_for_display %>
|
||||
<tr>
|
||||
<td><%= payment.paid_at ? l(payment.paid_at, format: :short) : "—" %></td>
|
||||
<td><%= payment.description.presence || payment.plan_slug&.humanize || "Abbonamento" %></td>
|
||||
<td><%= payment.paid_at ? l_local(payment.paid_at, format: :short) : "—" %></td>
|
||||
<td><%= payment.display_description %></td>
|
||||
<td><%= payment.formatted_amount %></td>
|
||||
<td><%= payment.status %></td>
|
||||
<td><%= payment.display_status %></td>
|
||||
<td>
|
||||
<% if payment.receipt_url.present? %>
|
||||
<%= link_to "Ricevuta Stripe", payment.receipt_url, target: "_blank", rel: "noopener" %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p style="color:#888">Nessun pagamento registrato. Dopo un abbonamento premium comparirà qui (anche tramite «Sincronizza pagamenti»).</p>
|
||||
<% end %>
|
||||
|
||||
<h3 style="margin-top:28px;font-size:1.1rem">Fatture</h3>
|
||||
<% if invoices.any? %>
|
||||
<table class="data billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Numero</th>
|
||||
<th>Data</th>
|
||||
<th>Importo</th>
|
||||
<th>Stato</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% invoices.each do |invoice| %>
|
||||
<tr>
|
||||
<td><%= invoice.number %></td>
|
||||
<td><%= l(invoice.issued_on) %></td>
|
||||
<td><%= invoice.formatted_amount %></td>
|
||||
<td><%= invoice.status %></td>
|
||||
<td>
|
||||
<% if invoice.downloadable? %>
|
||||
<%= link_to "Scarica PDF", public_club_billing_invoice_path(club, invoice) %>
|
||||
<% if inv.present? %>
|
||||
<%= inv.display_number %>
|
||||
· <%= inv.display_status %>
|
||||
<% else %>
|
||||
—
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if inv&.downloadable? %>
|
||||
<%= link_to "Scarica PDF", public_club_billing_invoice_path(club, inv) %>
|
||||
<% elsif inv&.draft? %>
|
||||
<span style="color:#888">PDF in preparazione</span>
|
||||
<% else %>
|
||||
<span style="color:#888">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -89,6 +61,6 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p style="color:#888">Nessuna fattura disponibile. Compariranno qui dopo l’emissione.</p>
|
||||
<p style="color:#888">Nessun pagamento registrato. Dopo un abbonamento premium comparirà qui.</p>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -2,64 +2,65 @@
|
||||
<% if show_legend %>
|
||||
<h2 class="form-section-title" style="margin-top:24px">Dati di fatturazione</h2>
|
||||
<p style="color:#888;font-size:0.88rem;margin:-8px 0 16px">
|
||||
Obbligatori per ricevere le fatture elettroniche (P.IVA/CF, indirizzo, SDI o PEC).
|
||||
Tutti i campi contrassegnati sono obbligatori per abbonarti a un piano premium e per emettere le fatture.
|
||||
Per le società serve la P.IVA; per le persone fisiche il Codice Fiscale. Serve SDI <em>oppure</em> PEC.
|
||||
I pagamenti restano gestiti in modo sicuro da Stripe.
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= label_tag "club[billing_entity_type]", "Tipo intestatario" %>
|
||||
<%= label_tag "club[billing_entity_type]", "Tipo intestatario *" %>
|
||||
<%= select_tag "club[billing_entity_type]",
|
||||
options_for_select(Club::BILLING_ENTITY_TYPES.map { |k, v| [v, k] }, record.billing_entity_type),
|
||||
include_blank: false %>
|
||||
|
||||
<%= label_tag "club[billing_legal_name]", "Ragione sociale / nome e cognome" %>
|
||||
<%= label_tag "club[billing_legal_name]", "Ragione sociale / nome e cognome *" %>
|
||||
<%= text_field_tag "club[billing_legal_name]", record.billing_legal_name, placeholder: "es. ASD Tigers Volley" %>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<%= label_tag "club[billing_vat_number]", "Partita IVA" %>
|
||||
<%= label_tag "club[billing_vat_number]", "Partita IVA * (società)" %>
|
||||
<%= text_field_tag "club[billing_vat_number]", record.billing_vat_number, placeholder: "12345678901" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= label_tag "club[billing_fiscal_code]", "Codice Fiscale" %>
|
||||
<%= label_tag "club[billing_fiscal_code]", "Codice Fiscale * (persona fisica)" %>
|
||||
<%= text_field_tag "club[billing_fiscal_code]", record.billing_fiscal_code, placeholder: "RSSMRA80A01H501U" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= label_tag "club[billing_email]", "Email fatturazione" %>
|
||||
<%= label_tag "club[billing_email]", "Email fatturazione *" %>
|
||||
<%= email_field_tag "club[billing_email]", record.billing_email, placeholder: "amministrazione@societa.it" %>
|
||||
|
||||
<%= label_tag "club[billing_phone]", "Telefono (opzionale)" %>
|
||||
<%= text_field_tag "club[billing_phone]", record.billing_phone %>
|
||||
|
||||
<%= label_tag "club[billing_address_line]", "Indirizzo" %>
|
||||
<%= label_tag "club[billing_address_line]", "Indirizzo *" %>
|
||||
<%= text_field_tag "club[billing_address_line]", record.billing_address_line, placeholder: "Via Roma 1" %>
|
||||
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<%= label_tag "club[billing_city]", "Città" %>
|
||||
<%= label_tag "club[billing_city]", "Città *" %>
|
||||
<%= text_field_tag "club[billing_city]", record.billing_city %>
|
||||
</div>
|
||||
<div>
|
||||
<%= label_tag "club[billing_province]", "Prov." %>
|
||||
<%= label_tag "club[billing_province]", "Prov. *" %>
|
||||
<%= text_field_tag "club[billing_province]", record.billing_province, maxlength: 2, placeholder: "MI" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= label_tag "club[billing_postal_code]", "CAP" %>
|
||||
<%= label_tag "club[billing_postal_code]", "CAP *" %>
|
||||
<%= text_field_tag "club[billing_postal_code]", record.billing_postal_code %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= label_tag "club[billing_country]", "Paese (ISO)" %>
|
||||
<%= label_tag "club[billing_country]", "Paese (ISO) *" %>
|
||||
<%= text_field_tag "club[billing_country]", record.billing_country.presence || "IT", maxlength: 2 %>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<%= label_tag "club[billing_recipient_code]", "Codice destinatario SDI (7 caratteri)" %>
|
||||
<%= label_tag "club[billing_recipient_code]", "Codice destinatario SDI (7 car.) *" %>
|
||||
<%= text_field_tag "club[billing_recipient_code]", record.billing_recipient_code, maxlength: 7, placeholder: "XXXXXXX" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= label_tag "club[billing_pec]", "PEC (alternativa a SDI)" %>
|
||||
<%= label_tag "club[billing_pec]", "PEC (alternativa a SDI) *" %>
|
||||
<%= email_field_tag "club[billing_pec]", record.billing_pec, placeholder: "pec@societa.it" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<%# locals: (club:, entitlements: nil, subscription: nil) %>
|
||||
<%# locals: (club:, entitlements: nil, subscription: nil, on_billing_page: false) %>
|
||||
<% current_plan = entitlements&.plan || subscription&.plan || Plan["free"] %>
|
||||
<p style="color:#aaa;margin-bottom:8px">
|
||||
Società: <strong><%= club.name %></strong>
|
||||
· Piano attuale: <strong><%= current_plan.name %></strong>
|
||||
(valido per tutte le squadre)
|
||||
· <%= link_to "Gestisci abbonamento", public_club_billing_path(club) %>
|
||||
<% unless local_assigns[:on_billing_page] %>
|
||||
· <%= link_to "Gestisci abbonamento", public_club_billing_path(club) %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% if subscription&.stripe_subscription_id.present? %>
|
||||
<p style="color:#888;font-size:0.9rem;margin-top:0">
|
||||
Stato Stripe: <strong><%= subscription.status %></strong>
|
||||
<% if subscription.current_period_end.present? %>
|
||||
· Rinnovo: <strong><%= l(subscription.current_period_end, format: :long) %></strong>
|
||||
<% end %>
|
||||
<% if subscription.cancel_at_period_end? %>
|
||||
· <span style="color:#e53935">Disdetta alla scadenza</span>
|
||||
<span style="color:#e53935">
|
||||
Abbonamento valido fino al
|
||||
<strong><%= subscription.current_period_end.present? ? l_local(subscription.current_period_end.to_date) : "fine periodo" %></strong>,
|
||||
poi piano Free.
|
||||
</span>
|
||||
<% elsif subscription.current_period_end.present? %>
|
||||
Prossimo rinnovo: <strong><%= l_local(subscription.current_period_end) %></strong>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
<%# locals: (name:, label:, value: nil, input_type: "text", required: false, autocomplete: nil, minlength: nil, id: nil) %>
|
||||
<%# locals: (name:, label:, value: nil, input_type: "text", required: false, autocomplete: nil, minlength: nil, id: nil, masked: nil) %>
|
||||
<% field_id = local_assigns.fetch(:id, nil).presence || name.to_s %>
|
||||
<% visible_type = local_assigns.fetch(:input_type, "text").to_s == "email" ? "email" : "text" %>
|
||||
<div class="input-toggle" data-input-toggle>
|
||||
<% visible_type = local_assigns.fetch(:input_type, "text").to_s %>
|
||||
<% is_email = visible_type == "email" %>
|
||||
<% masked = local_assigns.key?(:masked) ? local_assigns[:masked] : !is_email %>
|
||||
|
||||
<div class="input-toggle<%= " input-toggle--plain" unless masked %>"<%= " data-input-toggle" if masked %>>
|
||||
<%= label_tag field_id, label %>
|
||||
<div class="input-toggle__field">
|
||||
<%= tag.input(
|
||||
type: "password",
|
||||
name: name,
|
||||
id: field_id,
|
||||
value: local_assigns.fetch(:value, nil),
|
||||
required: local_assigns.fetch(:required, false),
|
||||
autocomplete: local_assigns.fetch(:autocomplete, nil),
|
||||
minlength: local_assigns.fetch(:minlength, nil),
|
||||
class: "input-toggle__input",
|
||||
data: { visible_type: visible_type }
|
||||
) %>
|
||||
<button type="button" class="input-toggle__btn" aria-label="Mostra <%= label.downcase %>" data-label-hide="Nascondi <%= label.downcase %>">
|
||||
<i class="fa-regular fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<% if masked %>
|
||||
<%= tag.input(
|
||||
type: "password",
|
||||
name: name,
|
||||
id: field_id,
|
||||
value: local_assigns.fetch(:value, nil),
|
||||
required: local_assigns.fetch(:required, false),
|
||||
autocomplete: local_assigns.fetch(:autocomplete, nil),
|
||||
minlength: local_assigns.fetch(:minlength, nil),
|
||||
class: "input-toggle__input",
|
||||
data: { visible_type: "text" }
|
||||
) %>
|
||||
<button type="button" class="input-toggle__btn" aria-label="Mostra <%= label.downcase %>" data-label-hide="Nascondi <%= label.downcase %>">
|
||||
<i class="fa-regular fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<% elsif is_email %>
|
||||
<%= email_field_tag name, local_assigns.fetch(:value, nil),
|
||||
id: field_id,
|
||||
required: local_assigns.fetch(:required, false),
|
||||
autocomplete: local_assigns.fetch(:autocomplete, nil),
|
||||
class: "input-toggle__input input-toggle__input--plain" %>
|
||||
<% else %>
|
||||
<%= text_field_tag name, local_assigns.fetch(:value, nil),
|
||||
id: field_id,
|
||||
required: local_assigns.fetch(:required, false),
|
||||
autocomplete: local_assigns.fetch(:autocomplete, nil),
|
||||
minlength: local_assigns.fetch(:minlength, nil),
|
||||
class: "input-toggle__input input-toggle__input--plain" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<% entitlements ||= @entitlements %>
|
||||
<% subscription ||= @subscription %>
|
||||
<% current_slug = local_assigns.fetch(:current_plan_slug, nil).presence || entitlements&.plan&.slug || subscription&.plan&.slug || "free" %>
|
||||
<% current_interval = subscription&.billing_interval %>
|
||||
<% billing_mode = club.present? %>
|
||||
|
||||
<div class="plans">
|
||||
@@ -10,18 +11,23 @@
|
||||
<% action = billing_mode ? plan_billing_action(
|
||||
current_slug: current_slug,
|
||||
target_plan: plan,
|
||||
stripe_subscription_active: stripe_subscription_active?(subscription)
|
||||
stripe_subscription_active: stripe_subscription_active?(subscription),
|
||||
current_interval: current_interval
|
||||
) : nil %>
|
||||
<div class="plan-card <%= "featured" if plan.slug == current_slug || (!billing_mode && plan.slug == "premium_light") %>">
|
||||
<h3><%= plan.name %></h3>
|
||||
<% if plan.slug == "free" %>
|
||||
<div class="plan-price">€0</div>
|
||||
<% elsif plan.slug == "premium_light" %>
|
||||
<div class="plan-price" style="color:#e53935">€40<span style="font-size:1rem;font-weight:400">/anno</span></div>
|
||||
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€5</strong>/mese</p>
|
||||
<% light_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
||||
<% light_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
||||
<div class="plan-price" style="color:#e53935"><%= light_yearly %></div>
|
||||
<p class="plan-price-alt">oppure <strong><%= light_monthly %></strong></p>
|
||||
<% else %>
|
||||
<div class="plan-price">€200<span style="font-size:1rem;font-weight:400">/anno</span></div>
|
||||
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€20</strong>/mese</p>
|
||||
<% full_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
||||
<% full_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
||||
<div class="plan-price"><%= full_yearly %></div>
|
||||
<p class="plan-price-alt">oppure <strong><%= full_monthly %></strong></p>
|
||||
<% end %>
|
||||
<ul>
|
||||
<li>Responsabili trasmissione: <strong><%= plan.max_staff_transmission || "illimitato" %></strong> / anno per squadra</li>
|
||||
@@ -30,26 +36,45 @@
|
||||
<li>YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %></li>
|
||||
</ul>
|
||||
<% if billing_mode %>
|
||||
<% case action[:kind] %>
|
||||
<% when :current %>
|
||||
<% action_kind = action[:kind] %>
|
||||
<% if action_kind == :current %>
|
||||
<span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span>
|
||||
<% when :contact, :disabled %>
|
||||
<p style="color:#888;font-size:0.85rem"><%= action[:label] %></p>
|
||||
<% when :checkout, :change %>
|
||||
<%= link_to action[:label], public_club_checkout_path(club, plan: plan.slug), class: "btn btn-primary" %>
|
||||
<% elsif action_kind == :contact || action_kind == :disabled %>
|
||||
<p class="plan-action-hint"><%= action[:label] %></p>
|
||||
<% elsif action_kind == :checkout_options || action_kind == :change_options || action_kind == :interval_switch %>
|
||||
<% if billing_profile_blocks_premium?(club) %>
|
||||
<p class="plan-action-hint" style="color:#e53935;margin-bottom:10px">
|
||||
<%= billing_profile_incomplete_message(club) %>
|
||||
</p>
|
||||
<%= link_to "Completa dati di fatturazione",
|
||||
public_club_billing_profile_path(club, plan: plan.slug),
|
||||
class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<div class="plan-interval-actions">
|
||||
<% plan_intervals_for_display(action[:intervals]).each do |interval| %>
|
||||
<% btn_kind = action_kind == :checkout_options ? :checkout : :change %>
|
||||
<%= link_to plan_interval_button_label(plan, interval, kind: btn_kind),
|
||||
plan_interval_checkout_path(club, plan, interval),
|
||||
class: plan_interval_button_class(interval) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if action_kind == :interval_switch && action[:current_interval].present? %>
|
||||
<p class="plan-action-hint">Fatturazione attuale: <%= Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: action[:current_interval]) %></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% elsif plan.slug == "free" %>
|
||||
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %>
|
||||
<% else %>
|
||||
<%= link_to "Registrati", public_signup_path(plan: plan.slug), class: "btn btn-primary" %>
|
||||
<div class="plan-interval-actions">
|
||||
<% plan_intervals_for_display(Billing::Stripe::PriceCatalog.available_intervals(plan_slug: plan.slug)).each do |interval| %>
|
||||
<%= link_to "Registrati — #{Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)}",
|
||||
public_signup_path(plan: plan.slug, interval: interval),
|
||||
class: plan_interval_button_class(interval) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if show_stripe_portal && billing_mode && subscription&.premium? && MatchLiveTv.stripe_enabled? && subscription&.stripe_customer_id.present? %>
|
||||
<p style="margin-top:20px">
|
||||
<%= button_to "Gestisci fatturazione su Stripe", public_club_portal_path(club), class: "btn btn-secondary" %>
|
||||
<span style="color:#888;font-size:0.85rem;display:block;margin-top:8px">Cambia metodo di pagamento, scarica fatture o disdici l'abbonamento.</span>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
36
backend/app/views/shared/_subscription_cancel.html.erb
Normal file
36
backend/app/views/shared/_subscription_cancel.html.erb
Normal file
@@ -0,0 +1,36 @@
|
||||
<%# locals: (club:, subscription:) %>
|
||||
<% return unless subscription&.premium? && subscription.stripe_subscription_id.present? && MatchLiveTv.stripe_enabled? %>
|
||||
|
||||
<section class="subscription-cancel" style="margin-top:24px;padding:16px;border:1px solid #333;border-radius:8px">
|
||||
<% if subscription.cancel_at_period_end? %>
|
||||
<p style="color:#e53935;margin:0 0 8px">
|
||||
<strong>Disdetta programmata.</strong>
|
||||
Il piano <strong><%= subscription.plan.name %></strong> resta attivo fino al
|
||||
<% if subscription.current_period_end.present? %>
|
||||
<strong><%= l_local(subscription.current_period_end.to_date) %></strong>.
|
||||
<% else %>
|
||||
<strong>fine del periodo già pagato</strong>.
|
||||
<% end %>
|
||||
Da quel giorno la società passerà automaticamente al piano <strong>Free</strong>.
|
||||
</p>
|
||||
<p style="color:#888;font-size:0.88rem;margin:0">
|
||||
Per ripristinare l'abbonamento prima della scadenza, contatta il supporto Match Live TV.
|
||||
</p>
|
||||
<% else %>
|
||||
<p style="color:#aaa;margin:0 0 12px;font-size:0.9rem">
|
||||
Puoi disdire in qualsiasi momento: l'abbonamento resta valido fino alla fine del periodo già pagato,
|
||||
poi passi al piano Free senza ulteriori addebiti.
|
||||
</p>
|
||||
<%= button_to "Annulla abbonamento",
|
||||
public_club_billing_cancel_subscription_path(club),
|
||||
method: :post,
|
||||
class: "btn btn-outline",
|
||||
form: {
|
||||
data: {
|
||||
turbo_confirm: subscription.current_period_end.present? ?
|
||||
"Confermi la disdetta? Il piano #{subscription.plan.name} resterà attivo fino al #{l_local(subscription.current_period_end)}, poi passerai al Free." :
|
||||
"Confermi la disdetta dell'abbonamento?"
|
||||
}
|
||||
} %>
|
||||
<% end %>
|
||||
</section>
|
||||
Reference in New Issue
Block a user