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:
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
|
||||
|
||||
Reference in New Issue
Block a user