Billing Stripe, link regia mobile e staff solo trasmissione.

Aggiunge fatturazione club, pagina regia condivisibile senza account, roster squadre e rimuove la modalità controller dall'app (v1.1.0).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 07:23:13 +02:00
parent 4083bc5dee
commit f4b7be0f80
156 changed files with 5033 additions and 2033 deletions

View File

@@ -0,0 +1,76 @@
module Billing
module Stripe
class ChangePlan
VALID_PLANS = CheckoutSession::VALID_PLANS.freeze
def self.call(club:, plan_slug:)
new(club: club, plan_slug: plan_slug).call
end
def initialize(club:, plan_slug:)
@club = club
@plan_slug = plan_slug.to_s
end
def call
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug)
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?
stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id)
item_id = stripe_sub.items.data.first.id
updated = ::Stripe::Subscription.update(
stripe_sub.id,
items: [{ id: item_id, price: price_id }],
metadata: { club_id: @club.id, plan_slug: @plan_slug },
proration_behavior: "create_prorations"
)
apply_local_plan!(updated)
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
end
def apply_local_plan!(stripe_sub)
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
Billing::AssignPlan.call(
club: @club,
plan_slug: @plan_slug,
status: map_status(stripe_sub.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 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

View File

@@ -64,11 +64,15 @@ module Billing
end
def success_url
"#{dashboard_url}?checkout=success"
"#{billing_url}?checkout=success"
end
def cancel_url
"#{dashboard_url}?checkout=canceled"
"#{billing_url}?checkout=canceled"
end
def billing_url
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing"
end
def dashboard_url

View File

@@ -0,0 +1,90 @@
module Billing
module Stripe
class RecordPayment
def self.call(stripe_invoice:, club: nil)
new(stripe_invoice: stripe_invoice, club: club).call
end
def initialize(stripe_invoice:, club: nil)
@stripe_invoice = stripe_invoice
@club = club
end
def call
@club ||= resolve_club
return unless @club
payment = Billing::Payment.find_or_initialize_by(stripe_invoice_id: @stripe_invoice.id)
payment.assign_attributes(
club: @club,
stripe_payment_intent_id: payment_intent_id,
stripe_charge_id: charge_id,
amount_cents: @stripe_invoice.amount_paid,
currency: @stripe_invoice.currency,
status: map_status(@stripe_invoice.status),
plan_slug: plan_slug,
description: description,
paid_at: paid_at,
invoice_pdf_url: @stripe_invoice.invoice_pdf,
receipt_url: @stripe_invoice.hosted_invoice_url
)
payment.save!
payment
end
private
def resolve_club
club_id = metadata_club_id(@stripe_invoice)
return Club.find_by(id: club_id) if club_id.present?
sub_id = @stripe_invoice.subscription
return unless sub_id.present?
Club.joins(:subscription).find_by(subscriptions: { stripe_subscription_id: sub_id })
end
def metadata_club_id(obj)
meta = obj.metadata
return nil if meta.blank?
meta["club_id"] || meta[:club_id]
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
end
def payment_intent_id
pi = @stripe_invoice.payment_intent
pi.is_a?(String) ? pi : pi&.id
end
def charge_id
charge = @stripe_invoice.charge
charge.is_a?(String) ? charge : charge&.id
end
def paid_at
ts = @stripe_invoice.status_transitions&.paid_at
ts ? Time.zone.at(ts) : Time.current
end
def map_status(stripe_status)
case stripe_status
when "paid" then "paid"
when "uncollectible", "void" then "failed"
else "failed"
end
end
end
end
end

View File

@@ -0,0 +1,27 @@
module Billing
module Stripe
module SubscriptionPeriod
module_function
def times(stripe_sub)
item = stripe_sub.items&.data&.first
start_at = read_value(stripe_sub, :current_period_start) || read_value(item, :current_period_start)
end_at = read_value(stripe_sub, :current_period_end) || read_value(item, :current_period_end)
[unix_time(start_at), unix_time(end_at)]
end
def read_value(object, field)
return nil if object.blank?
return nil unless object.respond_to?(field)
object.public_send(field)
end
def unix_time(value)
return nil if value.blank?
Time.zone.at(value.to_i)
end
end
end
end

View File

@@ -0,0 +1,36 @@
module Billing
module Stripe
class SyncPayments
def self.call(club:)
new(club: club).call
end
def initialize(club:)
@club = club
end
def call
customer_id = @club.subscription&.stripe_customer_id
return 0 if customer_id.blank?
count = 0
starting_after = nil
loop do
list = ::Stripe::Invoice.list(customer: customer_id, limit: 25, starting_after: starting_after)
list.data.each do |invoice|
next unless invoice.status == "paid"
RecordPayment.call(stripe_invoice: invoice, club: @club)
count += 1
end
break unless list.has_more
starting_after = list.data.last.id
end
count
end
end
end
end

View File

@@ -19,6 +19,8 @@ module Billing
downgrade_to_free(@event.data.object)
when "invoice.payment_failed"
mark_past_due(@event.data.object)
when "invoice.paid"
record_payment(@event.data.object)
end
end
@@ -49,8 +51,7 @@ module Billing
plan_slug = "premium_full" if plan_slug == "premium"
plan = Plan[plan_slug]
status = map_status(stripe_sub.status)
period_start = unix_time(stripe_sub.current_period_start)
period_end = unix_time(stripe_sub.current_period_end)
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
Billing::AssignPlan.call(
club: club,
@@ -84,6 +85,10 @@ module Billing
)
end
def record_payment(invoice)
Billing::Stripe::RecordPayment.call(stripe_invoice: invoice)
end
def mark_past_due(invoice)
return if invoice.subscription.blank?