Billing upgrade/downgrade, inviti in app e diretta YouTube da mobile.
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>
This commit is contained in:
76
backend/app/services/billing/stripe/apply_pending_plan.rb
Normal file
76
backend/app/services/billing/stripe/apply_pending_plan.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
# Allinea piano locale quando scatta un downgrade programmato (o dopo sync Stripe).
|
||||
class ApplyPendingPlan
|
||||
def self.call(club:)
|
||||
new(club: club).call
|
||||
end
|
||||
|
||||
def initialize(club:)
|
||||
@club = club
|
||||
@sub = club.subscription
|
||||
end
|
||||
|
||||
def call
|
||||
return false unless @sub&.pending_plan_id.present?
|
||||
|
||||
pending_slug = @sub.pending_plan.slug
|
||||
interval = @sub.pending_billing_interval.presence || @sub.billing_interval
|
||||
|
||||
if @sub.stripe_subscription_id.present?
|
||||
stripe_sub = ::Stripe::Subscription.retrieve(@sub.stripe_subscription_id)
|
||||
current_price_id = stripe_sub.items.data.first.price.id
|
||||
expected_price_id = PriceCatalog.price_id(plan_slug: pending_slug, interval: interval)
|
||||
return false unless current_price_id == expected_price_id
|
||||
|
||||
inferred = PriceCatalog.infer_from_price_id(current_price_id)
|
||||
if inferred
|
||||
pending_slug, interval = inferred
|
||||
end
|
||||
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
|
||||
Billing::AssignPlan.call(
|
||||
club: @club,
|
||||
plan_slug: pending_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,
|
||||
billing_interval: interval,
|
||||
pending_plan_id: nil,
|
||||
pending_billing_interval: nil,
|
||||
stripe_schedule_id: nil
|
||||
}
|
||||
)
|
||||
else
|
||||
Billing::AssignPlan.call(
|
||||
club: @club,
|
||||
plan_slug: pending_slug,
|
||||
stripe_attrs: {
|
||||
billing_interval: interval,
|
||||
pending_plan_id: nil,
|
||||
pending_billing_interval: nil,
|
||||
stripe_schedule_id: nil
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
@@ -11,37 +11,88 @@ module Billing
|
||||
@club = club
|
||||
@plan_slug = plan_slug.to_s
|
||||
@interval = interval
|
||||
@sub = club.subscription
|
||||
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 "Nessun abbonamento Stripe attivo" if @sub.blank? || @sub.stripe_subscription_id.blank?
|
||||
|
||||
interval = resolve_interval(sub)
|
||||
if sub.plan.slug == @plan_slug && sub.billing_interval == interval
|
||||
interval = resolve_interval(@sub)
|
||||
if @sub.plan.slug == @plan_slug && @sub.billing_interval == interval && !@sub.pending_plan_id?
|
||||
raise "Sei già su questo piano e intervallo di fatturazione"
|
||||
end
|
||||
|
||||
price_id = PriceCatalog.price_id(plan_slug: @plan_slug, interval: interval)
|
||||
kind = PlanChangeKind.classify(
|
||||
from_plan_slug: @sub.plan.slug,
|
||||
from_interval: @sub.billing_interval,
|
||||
to_plan_slug: @plan_slug,
|
||||
to_interval: interval
|
||||
)
|
||||
|
||||
stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id)
|
||||
case kind
|
||||
when :upgrade
|
||||
perform_upgrade!(interval: interval)
|
||||
when :downgrade
|
||||
ScheduleDowngrade.call(club: @club, plan_slug: @plan_slug, interval: interval)
|
||||
else
|
||||
raise "Sei già su questo piano e intervallo di fatturazione"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def perform_upgrade!(interval:)
|
||||
release_schedule_if_any!
|
||||
|
||||
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
|
||||
|
||||
updated = ::Stripe::Subscription.update(
|
||||
stripe_sub.id,
|
||||
items: [{ id: item_id, price: price_id }],
|
||||
metadata: { club_id: @club.id, plan_slug: @plan_slug, billing_interval: interval },
|
||||
proration_behavior: "create_prorations"
|
||||
proration_behavior: "always_invoice",
|
||||
payment_behavior: "error_if_incomplete"
|
||||
)
|
||||
|
||||
invoice = pay_open_subscription_invoice!(updated.id)
|
||||
apply_local_plan!(updated, interval: interval)
|
||||
updated
|
||||
|
||||
ChangePlanResult.new(
|
||||
kind: :upgrade,
|
||||
plan_slug: @plan_slug,
|
||||
interval: interval,
|
||||
effective_at: Time.current,
|
||||
amount_cents: invoice&.amount_paid,
|
||||
currency: invoice&.currency,
|
||||
invoice_id: invoice&.id
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def release_schedule_if_any!
|
||||
return if @sub.stripe_schedule_id.blank?
|
||||
|
||||
::Stripe::SubscriptionSchedule.release(@sub.stripe_schedule_id)
|
||||
rescue ::Stripe::InvalidRequestError
|
||||
nil
|
||||
ensure
|
||||
@sub.update!(stripe_schedule_id: nil, pending_plan_id: nil, pending_billing_interval: nil)
|
||||
end
|
||||
|
||||
def pay_open_subscription_invoice!(stripe_sub_id)
|
||||
invoice = ::Stripe::Invoice.list(subscription: stripe_sub_id, status: "open", limit: 1).data.first
|
||||
return nil unless invoice
|
||||
|
||||
paid = invoice.status == "paid" ? invoice : ::Stripe::Invoice.pay(invoice.id)
|
||||
RecordPayment.call(stripe_invoice: paid, club: @club) if paid.status == "paid"
|
||||
paid
|
||||
rescue ::Stripe::CardError => e
|
||||
raise ::Stripe::CardError, "Pagamento non riuscito: #{e.message}"
|
||||
end
|
||||
|
||||
def resolve_interval(sub)
|
||||
return PriceCatalog.normalize_interval(@interval) if @interval.present?
|
||||
@@ -61,7 +112,10 @@ module Billing
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
cancel_at_period_end: stripe_sub.cancel_at_period_end,
|
||||
billing_interval: interval
|
||||
billing_interval: interval,
|
||||
pending_plan_id: nil,
|
||||
pending_billing_interval: nil,
|
||||
stripe_schedule_id: nil
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
22
backend/app/services/billing/stripe/change_plan_result.rb
Normal file
22
backend/app/services/billing/stripe/change_plan_result.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
ChangePlanResult = Struct.new(
|
||||
:kind,
|
||||
:plan_slug,
|
||||
:interval,
|
||||
:effective_at,
|
||||
:amount_cents,
|
||||
:currency,
|
||||
:invoice_id,
|
||||
keyword_init: true
|
||||
) do
|
||||
def upgrade?
|
||||
kind == :upgrade
|
||||
end
|
||||
|
||||
def downgrade?
|
||||
kind == :downgrade
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -69,6 +69,10 @@ module Billing
|
||||
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,
|
||||
@@ -79,7 +83,10 @@ module Billing
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
cancel_at_period_end: stripe_sub.cancel_at_period_end,
|
||||
billing_interval: billing_interval
|
||||
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
|
||||
|
||||
33
backend/app/services/billing/stripe/plan_change_kind.rb
Normal file
33
backend/app/services/billing/stripe/plan_change_kind.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
module PlanChangeKind
|
||||
module_function
|
||||
|
||||
def classify(from_plan_slug:, from_interval:, to_plan_slug:, to_interval:)
|
||||
from_tier = Plan.tier(from_plan_slug)
|
||||
to_tier = Plan.tier(to_plan_slug)
|
||||
from_interval = PriceCatalog.normalize_interval(from_interval)
|
||||
to_interval = PriceCatalog.normalize_interval(to_interval)
|
||||
|
||||
return :upgrade if to_tier > from_tier
|
||||
return :downgrade if to_tier < from_tier
|
||||
|
||||
if from_interval == to_interval
|
||||
:same
|
||||
elsif to_interval == "yearly"
|
||||
:upgrade
|
||||
else
|
||||
:downgrade
|
||||
end
|
||||
end
|
||||
|
||||
def upgrade?(**kwargs)
|
||||
classify(**kwargs) == :upgrade
|
||||
end
|
||||
|
||||
def downgrade?(**kwargs)
|
||||
classify(**kwargs) == :downgrade
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
60
backend/app/services/billing/stripe/plan_change_messages.rb
Normal file
60
backend/app/services/billing/stripe/plan_change_messages.rb
Normal file
@@ -0,0 +1,60 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
module PlanChangeMessages
|
||||
module_function
|
||||
|
||||
def flash_notice(result, current_plan: nil)
|
||||
plan = Plan[result.plan_slug]
|
||||
interval_label = PriceCatalog.label(plan_slug: result.plan_slug, interval: result.interval)
|
||||
|
||||
if result.upgrade?
|
||||
amount = format_amount(result.amount_cents, result.currency)
|
||||
if amount.present?
|
||||
"Upgrade a #{plan.name} (#{interval_label}) completato. Addebito immediato: #{amount}."
|
||||
else
|
||||
"Upgrade a #{plan.name} (#{interval_label}) completato. L'addebito della differenza è in elaborazione sulla carta salvata."
|
||||
end
|
||||
else
|
||||
when_label = result.effective_at.present? ? I18n.l(result.effective_at, format: :long) : "il prossimo rinnovo"
|
||||
current_name = current_plan&.name || "il piano attuale"
|
||||
"Passaggio a #{plan.name} (#{interval_label}) programmato: resti su #{current_name} fino al #{when_label}. " \
|
||||
"Nessun addebito aggiuntivo ora; dal prossimo ciclo pagherai il nuovo prezzo."
|
||||
end
|
||||
end
|
||||
|
||||
def button_label(plan:, interval:, kind:)
|
||||
price = PriceCatalog.label(plan_slug: plan.slug, interval: interval)
|
||||
if kind == :checkout
|
||||
"Attiva #{plan.name} — #{price}"
|
||||
else
|
||||
"Passa a #{plan.name} — #{price}"
|
||||
end
|
||||
end
|
||||
|
||||
def billing_info_lines(subscription: nil)
|
||||
lines = [
|
||||
"Upgrade (piano superiore o passaggio alla fatturazione annuale): la differenza viene addebitata subito sulla carta salvata in Stripe.",
|
||||
"Downgrade tra piani a pagamento (es. Full → Light): il nuovo piano è attivo dal prossimo rinnovo; fino ad allora restano le funzioni del piano attuale, senza addebito aggiuntivo ora."
|
||||
]
|
||||
|
||||
if subscription&.plan_change_pending? && subscription.pending_plan.present?
|
||||
when_at = subscription.current_period_end
|
||||
when_label = when_at.present? ? I18n.l(when_at, format: :long) : "il prossimo rinnovo"
|
||||
lines.unshift(
|
||||
"Hai già richiesto #{subscription.pending_plan.name} dal #{when_label}: fino ad allora resta attivo #{subscription.plan.name}."
|
||||
)
|
||||
end
|
||||
|
||||
lines
|
||||
end
|
||||
|
||||
def format_amount(cents, currency)
|
||||
return nil if cents.blank? || cents.to_i <= 0
|
||||
|
||||
unit = currency.to_s.upcase.presence || "EUR"
|
||||
amount = cents.to_i / 100.0
|
||||
format("%.2f %s", amount, unit == "EUR" ? "€" : unit)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
80
backend/app/services/billing/stripe/schedule_downgrade.rb
Normal file
80
backend/app/services/billing/stripe/schedule_downgrade.rb
Normal file
@@ -0,0 +1,80 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
class ScheduleDowngrade
|
||||
def self.call(club:, plan_slug:, interval:)
|
||||
new(club: club, plan_slug: plan_slug, interval: interval).call
|
||||
end
|
||||
|
||||
def initialize(club:, plan_slug:, interval:)
|
||||
@club = club
|
||||
@plan_slug = plan_slug.to_s
|
||||
@interval = PriceCatalog.normalize_interval(interval)
|
||||
@sub = club.subscription
|
||||
end
|
||||
|
||||
def call
|
||||
release_existing_schedule!
|
||||
|
||||
stripe_sub = ::Stripe::Subscription.retrieve(@sub.stripe_subscription_id)
|
||||
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
|
||||
raise "Periodo di fatturazione non disponibile" if period_end.blank?
|
||||
|
||||
current_item = stripe_sub.items.data.first
|
||||
new_price_id = PriceCatalog.price_id(plan_slug: @plan_slug, interval: @interval)
|
||||
|
||||
schedule = ::Stripe::SubscriptionSchedule.create(from_subscription: stripe_sub.id)
|
||||
::Stripe::SubscriptionSchedule.update(
|
||||
schedule.id,
|
||||
{
|
||||
end_behavior: "release",
|
||||
phases: [
|
||||
{
|
||||
items: [{ price: current_item.price.id, quantity: 1 }],
|
||||
start_date: schedule_phase_start(stripe_sub, period_start),
|
||||
end_date: period_end.to_i
|
||||
},
|
||||
{
|
||||
items: [{ price: new_price_id, quantity: 1 }],
|
||||
metadata: {
|
||||
club_id: @club.id,
|
||||
plan_slug: @plan_slug,
|
||||
billing_interval: @interval
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
pending_plan = Plan[@plan_slug]
|
||||
@sub.update!(
|
||||
pending_plan: pending_plan,
|
||||
pending_billing_interval: @interval,
|
||||
stripe_schedule_id: schedule.id
|
||||
)
|
||||
|
||||
ChangePlanResult.new(
|
||||
kind: :downgrade,
|
||||
plan_slug: @plan_slug,
|
||||
interval: @interval,
|
||||
effective_at: period_end
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def release_existing_schedule!
|
||||
return if @sub.stripe_schedule_id.blank?
|
||||
|
||||
::Stripe::SubscriptionSchedule.release(@sub.stripe_schedule_id)
|
||||
rescue ::Stripe::InvalidRequestError
|
||||
nil
|
||||
ensure
|
||||
@sub.update!(stripe_schedule_id: nil, pending_plan_id: nil, pending_billing_interval: nil)
|
||||
end
|
||||
|
||||
def schedule_phase_start(stripe_sub, period_start)
|
||||
period_start&.to_i || stripe_sub.items.data.first.current_period_start
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -14,7 +14,11 @@ module Billing
|
||||
when "checkout.session.completed"
|
||||
CheckoutSync.from_session(@event.data.object)
|
||||
when "customer.subscription.updated", "customer.subscription.created"
|
||||
CheckoutSync.from_subscription(@event.data.object)
|
||||
stripe_sub = @event.data.object
|
||||
CheckoutSync.from_subscription(stripe_sub)
|
||||
club = Club.joins(:subscription)
|
||||
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
|
||||
ApplyPendingPlan.call(club: club) if club
|
||||
when "customer.subscription.deleted"
|
||||
downgrade_to_free(@event.data.object)
|
||||
when "invoice.payment_failed"
|
||||
|
||||
@@ -3,8 +3,10 @@ module Youtube
|
||||
PURPOSE = :youtube_oauth_connect
|
||||
|
||||
class << self
|
||||
def for_team(team_id:, user_id:)
|
||||
verifier.generate([team_id.to_s, user_id.to_s], expires_in: 1.hour)
|
||||
def for_team(team_id:, user_id:, return_app: false)
|
||||
payload = [team_id.to_s, user_id.to_s]
|
||||
payload << "app" if return_app
|
||||
verifier.generate(payload, expires_in: 1.hour)
|
||||
end
|
||||
|
||||
def for_platform(admin_id:)
|
||||
@@ -17,8 +19,13 @@ module Youtube
|
||||
when Array
|
||||
if payload.first == "platform"
|
||||
{ kind: :platform, admin_id: payload[1] }
|
||||
elsif payload.size == 2
|
||||
{ kind: :team, team_id: payload[0], user_id: payload[1] }
|
||||
elsif payload.size >= 2
|
||||
{
|
||||
kind: :team,
|
||||
team_id: payload[0],
|
||||
user_id: payload[1],
|
||||
return_app: payload[2] == "app"
|
||||
}
|
||||
else
|
||||
raise ArgumentError, "stato OAuth non valido"
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user