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:
52
backend/app/controllers/api/v1/invitations_controller.rb
Normal file
52
backend/app/controllers/api/v1/invitations_controller.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
module Api
|
||||
module V1
|
||||
class InvitationsController < BaseController
|
||||
skip_before_action :authenticate_request!, only: :show
|
||||
|
||||
def show
|
||||
invitation = find_pending_invitation
|
||||
unless invitation
|
||||
return render json: { valid: false, error: "Invito non valido o scaduto" }, status: :not_found
|
||||
end
|
||||
|
||||
render json: {
|
||||
valid: true,
|
||||
email: invitation.email,
|
||||
team_id: invitation.team_id,
|
||||
team_name: invitation.team.name,
|
||||
club_name: invitation.team.club.name,
|
||||
staff_kind: invitation.staff_kind,
|
||||
expires_at: invitation.expires_at
|
||||
}
|
||||
end
|
||||
|
||||
def accept
|
||||
invitation = find_pending_invitation
|
||||
return render json: { error: "Invito non valido o scaduto" }, status: :not_found unless invitation
|
||||
|
||||
if current_user.email.downcase != invitation.email.downcase
|
||||
return render json: {
|
||||
error: "Questo invito è per #{invitation.email}. Accedi con quell'indirizzo email.",
|
||||
invited_email: invitation.email
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
invitation.accept!(current_user)
|
||||
render json: {
|
||||
team_id: invitation.team_id,
|
||||
team_name: invitation.team.name,
|
||||
message: "Sei entrato in #{invitation.team.name} come responsabile trasmissione."
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_pending_invitation
|
||||
token = params[:token].to_s
|
||||
return nil if token.blank?
|
||||
|
||||
TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -103,7 +103,8 @@ module Api
|
||||
youtube_enabled: ent.youtube_enabled?,
|
||||
youtube_mode: ent.plan.youtube_mode,
|
||||
staff_role: current_user.staff_role_for(team),
|
||||
can_stream: current_user.can_stream_for?(team)
|
||||
can_stream: current_user.can_stream_for?(team),
|
||||
can_connect_youtube: current_user.can_stream_for?(team) && ent.premium_full? && ent.youtube_enabled?
|
||||
}
|
||||
if detail
|
||||
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
|
||||
|
||||
@@ -2,11 +2,18 @@ module Api
|
||||
module V1
|
||||
class YoutubeController < BaseController
|
||||
def authorize
|
||||
team = current_user.manageable_teams.find(params[:team_id])
|
||||
team = current_user.streamable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
|
||||
raise ActiveRecord::RecordNotFound unless team
|
||||
|
||||
team.entitlements.assert_can_connect_youtube!
|
||||
state = Youtube::OauthState.for_team(team_id: team.id, user_id: current_user.id)
|
||||
return_app = ActiveModel::Type::Boolean.new.cast(params[:return_app])
|
||||
state = Youtube::OauthState.for_team(
|
||||
team_id: team.id,
|
||||
user_id: current_user.id,
|
||||
return_app: return_app
|
||||
)
|
||||
url = Youtube::OauthUrl.build(state: state, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
|
||||
render json: { authorization_url: url }
|
||||
render json: { authorization_url: url, return_app: return_app }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -96,16 +96,27 @@ module Public
|
||||
sub = @club.subscription
|
||||
|
||||
if sub&.stripe_subscription_id.present? && sub.active? &&
|
||||
sub.plan.slug == plan_slug && sub.billing_interval == interval
|
||||
sub.plan.slug == plan_slug && sub.billing_interval == interval && !sub.plan_change_pending?
|
||||
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
|
||||
|
||||
if sub&.stripe_subscription_id.present? && sub.active?
|
||||
Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug, interval: interval)
|
||||
if sub&.plan_change_pending? && sub.pending_plan&.slug == plan_slug &&
|
||||
sub.pending_billing_interval == interval
|
||||
when_label = sub.current_period_end.present? ? I18n.l(sub.current_period_end, format: :long) : "il prossimo rinnovo"
|
||||
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."
|
||||
notice: "Il passaggio a #{target_plan.name} è già programmato dal #{when_label}."
|
||||
return
|
||||
end
|
||||
|
||||
if sub&.stripe_subscription_id.present? && sub.active?
|
||||
result = Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug, interval: interval)
|
||||
redirect_to public_club_billing_path(@club),
|
||||
notice: Billing::Stripe::PlanChangeMessages.flash_notice(
|
||||
result,
|
||||
current_plan: sub.plan
|
||||
)
|
||||
else
|
||||
url = Billing::Stripe::CheckoutSession.new(
|
||||
club: @club, user: current_user, plan_slug: plan_slug, interval: interval
|
||||
|
||||
@@ -116,7 +116,7 @@ module Public
|
||||
end
|
||||
|
||||
def youtube_connect
|
||||
require_club_owner_for_team!(@team)
|
||||
require_youtube_connect_access!(@team)
|
||||
@team.entitlements.assert_can_connect_youtube!
|
||||
|
||||
if ENV["YOUTUBE_CLIENT_ID"].blank?
|
||||
@@ -124,7 +124,12 @@ module Public
|
||||
return
|
||||
end
|
||||
|
||||
state = Youtube::OauthState.for_team(team_id: @team.id, user_id: current_user.id)
|
||||
return_app = params[:return_app] == "1"
|
||||
state = Youtube::OauthState.for_team(
|
||||
team_id: @team.id,
|
||||
user_id: current_user.id,
|
||||
return_app: return_app
|
||||
)
|
||||
redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true
|
||||
rescue Teams::EntitlementError => e
|
||||
redirect_to public_team_details_path(@team), alert: e.message
|
||||
@@ -138,6 +143,13 @@ module Public
|
||||
|
||||
private
|
||||
|
||||
def require_youtube_connect_access!(team)
|
||||
return if team.club&.owned_by?(current_user)
|
||||
return if current_user.can_stream_for?(team)
|
||||
|
||||
redirect_to public_team_details_path(team), alert: "Accesso non consentito"
|
||||
end
|
||||
|
||||
def load_team_details!
|
||||
@club = @team.club
|
||||
@can_manage = @club.owned_by?(current_user)
|
||||
|
||||
@@ -13,7 +13,11 @@ class YoutubeOauthCallbackController < ActionController::Base
|
||||
end
|
||||
|
||||
user = User.find(ctx[:user_id])
|
||||
team = user.manageable_teams.find(ctx[:team_id])
|
||||
team = Team.find(ctx[:team_id])
|
||||
unless user.can_stream_for?(team) || team.club&.owned_by?(user)
|
||||
return redirect_to_oauth_error("Non autorizzato a collegare YouTube per questa squadra")
|
||||
end
|
||||
|
||||
cred = team.youtube_credential || team.build_youtube_credential
|
||||
cred.update!(
|
||||
access_token: tokens[:access_token],
|
||||
@@ -22,7 +26,12 @@ class YoutubeOauthCallbackController < ActionController::Base
|
||||
channel_id: tokens[:channel_id],
|
||||
channel_title: tokens[:channel_title]
|
||||
)
|
||||
redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true
|
||||
|
||||
if ctx[:return_app]
|
||||
redirect_to "matchlivetv://youtube-connected?team_id=#{team.id}", allow_other_host: true
|
||||
else
|
||||
redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true
|
||||
end
|
||||
rescue ArgumentError, Youtube::BroadcastService::Error => e
|
||||
redirect_to_oauth_error(e.message)
|
||||
end
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
module ApplicationHelper
|
||||
PLAN_ICONS = {
|
||||
"free" => "fa-solid fa-seedling",
|
||||
"premium_light" => "fa-solid fa-rocket",
|
||||
"premium_full" => "fa-solid fa-crown"
|
||||
}.freeze
|
||||
|
||||
def plan_icon_class(plan)
|
||||
PLAN_ICONS[plan.slug] || "fa-solid fa-circle"
|
||||
end
|
||||
|
||||
# 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?
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
module Public
|
||||
module BillingHelper
|
||||
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil)
|
||||
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil)
|
||||
if target_plan.slug == "free"
|
||||
return { kind: :contact, label: "Contatta il supporto per downgrade." } unless current_slug == "free"
|
||||
return { kind: :current, label: "Piano attuale" } if current_slug == "free"
|
||||
return { kind: :none } if stripe_subscription_active
|
||||
|
||||
return { kind: :current, label: "Piano attuale" }
|
||||
return { kind: :contact, label: "Per passare al piano Free, contatta il supporto." }
|
||||
end
|
||||
|
||||
unless MatchLiveTv.stripe_enabled?
|
||||
@@ -16,20 +17,34 @@ module Public
|
||||
|
||||
active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
|
||||
|
||||
if current_slug == target_plan.slug && stripe_subscription_active
|
||||
if current_slug == target_plan.slug && stripe_subscription_active && !subscription&.plan_change_pending?
|
||||
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 }
|
||||
return {
|
||||
kind: :interval_switch,
|
||||
plan: target_plan,
|
||||
intervals: other_intervals,
|
||||
current_interval: active_interval,
|
||||
current_slug: current_slug,
|
||||
subscription: subscription
|
||||
}
|
||||
end
|
||||
|
||||
if current_slug == "free" || !stripe_subscription_active
|
||||
{ kind: :checkout_options, plan: target_plan, intervals: intervals }
|
||||
{ kind: :checkout_options, plan: target_plan, intervals: intervals, subscription: subscription }
|
||||
else
|
||||
{ kind: :change_options, plan: target_plan, intervals: intervals }
|
||||
{
|
||||
kind: :change_options,
|
||||
plan: target_plan,
|
||||
intervals: intervals,
|
||||
current_slug: current_slug,
|
||||
current_interval: active_interval,
|
||||
subscription: subscription
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,14 +52,13 @@ module Public
|
||||
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
|
||||
def plan_interval_button_label(plan, interval, kind: :checkout, **)
|
||||
btn_kind = kind == :checkout_options ? :checkout : :change
|
||||
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
|
||||
end
|
||||
|
||||
def billing_plan_change_info_lines(subscription: nil)
|
||||
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
|
||||
end
|
||||
|
||||
def stripe_subscription_active?(subscription)
|
||||
@@ -55,7 +69,7 @@ module Public
|
||||
Billing::Stripe::PriceCatalog.configured?(plan_slug: plan_slug, interval: interval)
|
||||
end
|
||||
|
||||
def plan_interval_button_class(interval)
|
||||
def plan_interval_button_class(interval, **)
|
||||
if interval.to_s == "yearly"
|
||||
"btn btn-primary plan-interval-btn plan-interval-btn--yearly"
|
||||
else
|
||||
@@ -77,5 +91,6 @@ module Public
|
||||
|
||||
"Mancano: #{missing.join(", ")}."
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ class Subscription < ApplicationRecord
|
||||
|
||||
belongs_to :club
|
||||
belongs_to :plan
|
||||
belongs_to :pending_plan, class_name: "Plan", optional: true
|
||||
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :club_id, uniqueness: true
|
||||
@@ -25,4 +26,8 @@ class Subscription < ApplicationRecord
|
||||
def free?
|
||||
plan.slug == "free"
|
||||
end
|
||||
|
||||
def plan_change_pending?
|
||||
pending_plan_id.present?
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription, on_billing_page: true %>
|
||||
|
||||
<%= render "shared/stripe_secure_payment" %>
|
||||
<% if MatchLiveTv.stripe_enabled? %>
|
||||
<%= render "shared/plan_change_info", subscription: @subscription %>
|
||||
<% end %>
|
||||
<%= render "shared/plan_cards", show_stripe_portal: false %>
|
||||
<%= render "shared/subscription_cancel", club: @club, subscription: @subscription %>
|
||||
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
<div class="card" style="max-width:480px">
|
||||
<h1>Accesso staff — <%= @invitation.team.name %></h1>
|
||||
<p>Sei stato aggiunto come <strong>responsabile trasmissione</strong> (<%= @invitation.email %>).</p>
|
||||
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
||||
Nell’app Match Live TV accedi con <strong><%= @invitation.email %></strong>, accetta l’invito e collega il tuo canale YouTube.
|
||||
</p>
|
||||
<% if logged_in? %>
|
||||
<%= button_to "Accetta invito", public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<p><%= link_to "Accedi", public_login_path %> o <%= link_to "registrati", public_signup_path %> con <%= @invitation.email %>.</p>
|
||||
<%= button_to "Accetta (se già loggato)", public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
<p style="margin-top:16px;font-size:0.88rem;color:#888">
|
||||
App mobile:
|
||||
<a href="matchlivetv://join/<%= @token %>">Apri invito nell’app</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="plans">
|
||||
<% @plans.each do |plan| %>
|
||||
<div class="plan-card">
|
||||
<h3><%= plan.name %></h3>
|
||||
<%= render "shared/plan_title", plan: plan %>
|
||||
<% if plan.slug == "free" %>
|
||||
<div class="plan-price">€0</div>
|
||||
<% elsif plan.slug == "premium_light" %>
|
||||
@@ -27,7 +27,6 @@
|
||||
<% if plan.slug == @entitlements.plan.slug %>
|
||||
<span class="btn btn-secondary" style="opacity:0.7">Piano attuale</span>
|
||||
<% elsif plan.slug == "free" %>
|
||||
<p style="color:#888;font-size:0.85rem">Contatta il supporto per downgrade.</p>
|
||||
<% elsif MatchLiveTv.stripe_enabled? %>
|
||||
<%= link_to "Gestisci abbonamento società", public_club_billing_path(@team.club), class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
|
||||
@@ -16,8 +16,20 @@
|
||||
<strong><%= subscription.current_period_end.present? ? l_local(subscription.current_period_end.to_date) : "fine periodo" %></strong>,
|
||||
poi piano Free.
|
||||
</span>
|
||||
<% elsif subscription.plan_change_pending? && subscription.pending_plan.present? %>
|
||||
<span style="color:#ffb74d">
|
||||
Dal <strong><%= subscription.current_period_end.present? ? l_local(subscription.current_period_end) : "prossimo rinnovo" %></strong>
|
||||
passerai a <strong><%= subscription.pending_plan.name %></strong>
|
||||
<% if subscription.pending_billing_interval.present? %>
|
||||
(<%= Billing::Stripe::PriceCatalog.label(plan_slug: subscription.pending_plan.slug, interval: subscription.pending_billing_interval) %>)
|
||||
<% end %>.
|
||||
Fino ad allora resta attivo <strong><%= current_plan.name %></strong>.
|
||||
</span>
|
||||
<% elsif subscription.current_period_end.present? %>
|
||||
Prossimo rinnovo: <strong><%= l_local(subscription.current_period_end) %></strong>
|
||||
<% if subscription.billing_interval.present? %>
|
||||
· <%= Billing::Stripe::PriceCatalog.label(plan_slug: current_plan.slug, interval: subscription.billing_interval) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
current_slug: current_slug,
|
||||
target_plan: plan,
|
||||
stripe_subscription_active: stripe_subscription_active?(subscription),
|
||||
current_interval: current_interval
|
||||
current_interval: current_interval,
|
||||
subscription: subscription
|
||||
) : nil %>
|
||||
<div class="plan-card">
|
||||
<h3><%= plan.name %></h3>
|
||||
<%= render "shared/plan_title", plan: plan %>
|
||||
<% if plan.slug == "free" %>
|
||||
<div class="plan-price">€0</div>
|
||||
<% elsif plan.slug == "premium_light" %>
|
||||
@@ -39,7 +40,8 @@
|
||||
<% action_kind = action[:kind] %>
|
||||
<% if action_kind == :current %>
|
||||
<span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span>
|
||||
<% elsif action_kind == :contact || action_kind == :disabled %>
|
||||
<% elsif action_kind == :none %>
|
||||
<% elsif action_kind.in?(%i[contact 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) %>
|
||||
@@ -52,16 +54,13 @@
|
||||
<% else %>
|
||||
<div class="plan-interval-actions">
|
||||
<% plan_intervals_for_display(action[:intervals]).each do |interval| %>
|
||||
<% btn_kind = action_kind == :checkout_options ? :checkout : :change %>
|
||||
<% btn_kind = action_kind == :checkout_options ? :checkout_options : action_kind %>
|
||||
<%= 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" %>
|
||||
|
||||
17
backend/app/views/shared/_plan_change_info.html.erb
Normal file
17
backend/app/views/shared/_plan_change_info.html.erb
Normal file
@@ -0,0 +1,17 @@
|
||||
<%# locals: (subscription: nil) %>
|
||||
<% lines = billing_plan_change_info_lines(subscription: subscription) %>
|
||||
<div class="plan-change-info-wrap">
|
||||
<details class="plan-change-info">
|
||||
<summary class="plan-change-info__trigger" aria-label="Info cambio piano e fatturazione" title="Info cambio piano e fatturazione">
|
||||
<i class="fa-solid fa-circle-info" aria-hidden="true"></i>
|
||||
</summary>
|
||||
<div class="plan-change-info__box" role="note">
|
||||
<p class="plan-change-info__title">Cambio piano e fatturazione</p>
|
||||
<ul>
|
||||
<% lines.each do |line| %>
|
||||
<li><%= line %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
5
backend/app/views/shared/_plan_title.html.erb
Normal file
5
backend/app/views/shared/_plan_title.html.erb
Normal file
@@ -0,0 +1,5 @@
|
||||
<%# locals: (plan:) %>
|
||||
<h3 class="plan-card__title">
|
||||
<i class="<%= plan_icon_class(plan) %>" aria-hidden="true"></i>
|
||||
<span><%= plan.name %></span>
|
||||
</h3>
|
||||
@@ -1,7 +1,7 @@
|
||||
<%# 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">
|
||||
<section id="subscription-cancel" 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>
|
||||
|
||||
@@ -11,6 +11,9 @@ Rails.application.routes.draw do
|
||||
post "auth/refresh", to: "auth#refresh"
|
||||
get "auth/me", to: "auth#me"
|
||||
|
||||
get "invitations/:token", to: "invitations#show"
|
||||
post "invitations/:token/accept", to: "invitations#accept"
|
||||
|
||||
resources :teams do
|
||||
member do
|
||||
post :members, action: :add_member
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddPendingPlanToSubscriptions < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
change_table :subscriptions, bulk: true do |t|
|
||||
t.references :pending_plan, type: :uuid, foreign_key: { to_table: :plans }
|
||||
t.string :pending_billing_interval
|
||||
t.string :stripe_schedule_id
|
||||
end
|
||||
end
|
||||
end
|
||||
7
backend/db/schema.rb
generated
7
backend/db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_05_31_140000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_06_02_120000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -250,7 +250,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_140000) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.uuid "club_id", null: false
|
||||
t.string "billing_interval"
|
||||
t.uuid "pending_plan_id"
|
||||
t.string "pending_billing_interval"
|
||||
t.string "stripe_schedule_id"
|
||||
t.index ["club_id"], name: "index_subscriptions_on_club_id", unique: true
|
||||
t.index ["pending_plan_id"], name: "index_subscriptions_on_pending_plan_id"
|
||||
t.index ["plan_id"], name: "index_subscriptions_on_plan_id"
|
||||
t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true, where: "(stripe_subscription_id IS NOT NULL)"
|
||||
end
|
||||
@@ -353,6 +357,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_140000) do
|
||||
add_foreign_key "stream_sessions", "users"
|
||||
add_foreign_key "subscriptions", "clubs"
|
||||
add_foreign_key "subscriptions", "plans"
|
||||
add_foreign_key "subscriptions", "plans", column: "pending_plan_id"
|
||||
add_foreign_key "team_invitations", "teams"
|
||||
add_foreign_key "team_roster_members", "teams"
|
||||
add_foreign_key "teams", "clubs"
|
||||
|
||||
@@ -943,7 +943,31 @@ body.nav-menu-open { overflow: hidden; }
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.plan-card h3 { margin: 0 0 8px; }
|
||||
.plan-card h3,
|
||||
.plan-card__title {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.plan-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.plan-card__title i {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
color: #e53935;
|
||||
}
|
||||
.plan-card__title i.fa-seedling {
|
||||
color: #81c784;
|
||||
}
|
||||
.plan-card__title i.fa-rocket {
|
||||
color: #64b5f6;
|
||||
}
|
||||
.plan-card__title i.fa-crown {
|
||||
color: #ffb74d;
|
||||
}
|
||||
.plan-price { font-size: 1.8rem; font-weight: 800; margin: 12px 0; }
|
||||
.plan-card ul {
|
||||
flex: 1 1 auto;
|
||||
@@ -1190,3 +1214,59 @@ label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: #c8c8d4; f
|
||||
font-size: 0.85rem;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.plan-change-info-wrap {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.plan-change-info {
|
||||
display: inline-block;
|
||||
}
|
||||
.plan-change-info__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.plan-change-info__trigger:hover {
|
||||
background: #1e1e28;
|
||||
}
|
||||
.plan-change-info__trigger::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.plan-change-info__trigger i {
|
||||
color: #635bff;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.plan-change-info[open] .plan-change-info__trigger {
|
||||
color: #ddd;
|
||||
background: #1e1e28;
|
||||
}
|
||||
.plan-change-info__box {
|
||||
margin-top: 12px;
|
||||
max-width: 640px;
|
||||
padding: 14px 16px;
|
||||
background: #14141c;
|
||||
border: 1px solid #2a2a36;
|
||||
border-radius: 10px;
|
||||
color: #aaa;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.plan-change-info__title {
|
||||
margin: 0 0 10px;
|
||||
color: #ddd;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.plan-change-info__box ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
.plan-change-info__box li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -67,4 +67,13 @@ RSpec.describe Public::BillingHelper, type: :helper do
|
||||
it "mostra prima il pulsante annuale" do
|
||||
expect(helper.plan_intervals_for_display(%w[monthly yearly])).to eq(%w[yearly monthly])
|
||||
end
|
||||
|
||||
it "sulla card Free non mostra testo se c'è abbonamento Stripe" do
|
||||
action = helper.plan_billing_action(
|
||||
current_slug: "premium_full",
|
||||
target_plan: Plan["free"],
|
||||
stripe_subscription_active: true
|
||||
)
|
||||
expect(action[:kind]).to eq(:none)
|
||||
end
|
||||
end
|
||||
|
||||
54
backend/spec/requests/api/v1/invitations_spec.rb
Normal file
54
backend/spec/requests/api/v1/invitations_spec.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Api::V1::Invitations", type: :request do
|
||||
let(:club) { Club.create!(name: "FC Test", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||
let(:team) { club.teams.create!(name: "Squadra A", sport: "volleyball") }
|
||||
let(:owner) do
|
||||
User.create!(email: "owner@test.it", name: "Owner", password: "password123", role: "coach").tap do |u|
|
||||
ClubMembership.create!(user: u, club: club, role: "owner")
|
||||
end
|
||||
end
|
||||
let(:invitee) { User.create!(email: "streamer@test.it", name: "Streamer", password: "password123", role: "coach") }
|
||||
let(:token) { TeamInvitation.generate_token }
|
||||
let!(:invitation) do
|
||||
team.team_invitations.create!(
|
||||
email: invitee.email,
|
||||
token_digest: Digest::SHA256.hexdigest(token),
|
||||
role: "member",
|
||||
staff_kind: "transmission",
|
||||
expires_at: 7.days.from_now
|
||||
)
|
||||
end
|
||||
|
||||
describe "GET /api/v1/invitations/:token" do
|
||||
it "mostra anteprima invito valido" do
|
||||
get "/api/v1/invitations/#{token}"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.parsed_body["email"]).to eq("streamer@test.it")
|
||||
expect(response.parsed_body["team_name"]).to eq("Squadra A")
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/v1/invitations/:token/accept" do
|
||||
it "accetta con email corretta" do
|
||||
post "/api/v1/auth/login", params: { email: invitee.email, password: "password123" }
|
||||
access = response.parsed_body["access_token"]
|
||||
|
||||
post "/api/v1/invitations/#{token}/accept",
|
||||
headers: { "Authorization" => "Bearer #{access}" }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(invitee.user_teams.find_by(team: team)&.staff_kind).to eq("transmission")
|
||||
end
|
||||
|
||||
it "rifiuta email diversa" do
|
||||
post "/api/v1/auth/login", params: { email: owner.email, password: "password123" }
|
||||
access = response.parsed_body["access_token"]
|
||||
|
||||
post "/api/v1/invitations/#{token}/accept",
|
||||
headers: { "Authorization" => "Bearer #{access}" }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,48 +11,69 @@ RSpec.describe Billing::Stripe::ChangePlan do
|
||||
status: "active",
|
||||
stripe_attrs: {
|
||||
stripe_customer_id: "cus_test",
|
||||
stripe_subscription_id: "sub_test_123"
|
||||
stripe_subscription_id: "sub_test_123",
|
||||
billing_interval: "monthly",
|
||||
current_period_end: 1.month.from_now
|
||||
}
|
||||
)
|
||||
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_light_m")
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_full_m")
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
|
||||
end
|
||||
|
||||
it "aggiorna Stripe e il piano locale verso premium_full" do
|
||||
stripe_item = double(id: "si_test")
|
||||
it "upgrade: always_invoice e addebito immediato" do
|
||||
stripe_item = double(id: "si_test", price: double(id: "price_light_m"))
|
||||
stripe_sub = double(
|
||||
id: "sub_test_123",
|
||||
customer: "cus_test",
|
||||
status: "active",
|
||||
current_period_start: Time.current.to_i,
|
||||
current_period_end: 1.year.from_now.to_i,
|
||||
cancel_at_period_end: false,
|
||||
items: double(data: [stripe_item])
|
||||
)
|
||||
updated_sub = stripe_sub
|
||||
allow(stripe_sub).to receive(:current_period_start).and_return(Time.current.to_i)
|
||||
allow(stripe_sub).to receive(:current_period_end).and_return(1.month.from_now.to_i)
|
||||
allow(stripe_item).to receive(:current_period_start).and_return(Time.current.to_i)
|
||||
allow(stripe_item).to receive(:current_period_end).and_return(1.month.from_now.to_i)
|
||||
|
||||
invoice = double(id: "in_up", status: "paid", amount_paid: 1500, currency: "eur")
|
||||
allow(Stripe::Subscription).to receive(:retrieve).with("sub_test_123").and_return(stripe_sub)
|
||||
allow(Stripe::Subscription).to receive(:update).and_return(updated_sub)
|
||||
allow(Stripe::Subscription).to receive(:update).and_return(stripe_sub)
|
||||
allow(Stripe::Invoice).to receive(:list).and_return(double(data: [invoice]))
|
||||
allow(Billing::Stripe::RecordPayment).to receive(:call)
|
||||
|
||||
described_class.call(club: club, plan_slug: "premium_full", interval: "yearly")
|
||||
result = described_class.call(club: club, plan_slug: "premium_full", interval: "monthly")
|
||||
|
||||
expect(Stripe::Subscription).to have_received(:update).with(
|
||||
"sub_test_123",
|
||||
hash_including(
|
||||
items: [{ id: "si_test", price: "price_full_y" }],
|
||||
metadata: { club_id: club.id, plan_slug: "premium_full", billing_interval: "yearly" }
|
||||
)
|
||||
hash_including(proration_behavior: "always_invoice", payment_behavior: "error_if_incomplete")
|
||||
)
|
||||
expect(result).to be_upgrade
|
||||
expect(club.reload.subscription.plan.slug).to eq("premium_full")
|
||||
end
|
||||
|
||||
it "rifiuta il cambio sullo stesso piano e intervallo" do
|
||||
club.subscription.update!(billing_interval: "yearly")
|
||||
expect {
|
||||
described_class.call(club: club, plan_slug: "premium_light", interval: "yearly")
|
||||
}.to raise_error(/già su questo piano/)
|
||||
it "downgrade: programma ScheduleDowngrade senza cambiare piano subito" do
|
||||
Billing::AssignPlan.call(
|
||||
club: club,
|
||||
plan_slug: "premium_full",
|
||||
status: "active",
|
||||
stripe_attrs: { stripe_subscription_id: "sub_test_123", billing_interval: "monthly" }
|
||||
)
|
||||
|
||||
allow(Billing::Stripe::ScheduleDowngrade).to receive(:call).and_return(
|
||||
Billing::Stripe::ChangePlanResult.new(
|
||||
kind: :downgrade,
|
||||
plan_slug: "premium_light",
|
||||
interval: "monthly",
|
||||
effective_at: 1.month.from_now
|
||||
)
|
||||
)
|
||||
|
||||
result = described_class.call(club: club, plan_slug: "premium_light", interval: "monthly")
|
||||
|
||||
expect(Billing::Stripe::ScheduleDowngrade).to have_received(:call)
|
||||
expect(result).to be_downgrade
|
||||
expect(club.reload.subscription.plan.slug).to eq("premium_full")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Billing::Stripe::PlanChangeKind do
|
||||
before { load Rails.root.join("db/seeds/plans.rb") }
|
||||
|
||||
it "classifica light → full come upgrade" do
|
||||
expect(described_class.classify(
|
||||
from_plan_slug: "premium_light",
|
||||
from_interval: "monthly",
|
||||
to_plan_slug: "premium_full",
|
||||
to_interval: "monthly"
|
||||
)).to eq(:upgrade)
|
||||
end
|
||||
|
||||
it "classifica full → light come downgrade" do
|
||||
expect(described_class.classify(
|
||||
from_plan_slug: "premium_full",
|
||||
from_interval: "monthly",
|
||||
to_plan_slug: "premium_light",
|
||||
to_interval: "monthly"
|
||||
)).to eq(:downgrade)
|
||||
end
|
||||
|
||||
it "classifica passaggio a annuale come upgrade sullo stesso piano" do
|
||||
expect(described_class.classify(
|
||||
from_plan_slug: "premium_light",
|
||||
from_interval: "monthly",
|
||||
to_plan_slug: "premium_light",
|
||||
to_interval: "yearly"
|
||||
)).to eq(:upgrade)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user