Upgrade Stripe con addebito immediato; downgrade programmato a fine periodo. UX billing più sobria con box info; icone piani. API inviti e OAuth YouTube per staff trasmissione; deep link join; scelta partita programmata o nuova. Co-authored-by: Cursor <cursoragent@cursor.com>
204 lines
6.6 KiB
Ruby
204 lines
6.6 KiB
Ruby
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
|
|
|
|
sub = club.subscription
|
|
clear_pending = sub&.pending_plan_id.present? &&
|
|
(sub.pending_plan.slug == plan.slug || infer_plan_slug_from_price(stripe_sub) == sub.pending_plan.slug)
|
|
|
|
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,
|
|
pending_plan_id: clear_pending ? nil : sub&.pending_plan_id,
|
|
pending_billing_interval: clear_pending ? nil : sub&.pending_billing_interval,
|
|
stripe_schedule_id: clear_pending ? nil : sub&.stripe_schedule_id
|
|
}
|
|
)
|
|
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
|