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:
2026-06-02 22:15:27 +02:00
parent 2cb267f991
commit 566104aff4
49 changed files with 1615 additions and 173 deletions

View 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

View File

@@ -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|

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

@@ -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

View 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

View 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

View 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

View File

@@ -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"

View File

@@ -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

View File

@@ -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 %>

View File

@@ -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">
Nellapp Match Live TV accedi con <strong><%= @invitation.email %></strong>, accetta linvito 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 nellapp</a>
</p>
</div>

View File

@@ -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 %>

View File

@@ -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 %>

View File

@@ -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" %>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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
View File

@@ -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"

View File

@@ -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;
}

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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