diff --git a/backend/app/controllers/api/v1/invitations_controller.rb b/backend/app/controllers/api/v1/invitations_controller.rb new file mode 100644 index 0000000..a58d967 --- /dev/null +++ b/backend/app/controllers/api/v1/invitations_controller.rb @@ -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 diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index 22c445f..e6ec7f0 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -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| diff --git a/backend/app/controllers/api/v1/youtube_controller.rb b/backend/app/controllers/api/v1/youtube_controller.rb index 5b14107..4f3d4f0 100644 --- a/backend/app/controllers/api/v1/youtube_controller.rb +++ b/backend/app/controllers/api/v1/youtube_controller.rb @@ -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 diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb index f0f5508..7f29262 100644 --- a/backend/app/controllers/public/clubs_controller.rb +++ b/backend/app/controllers/public/clubs_controller.rb @@ -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 diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index 3fcaae1..7a2fdd0 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -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) diff --git a/backend/app/controllers/youtube_oauth_callback_controller.rb b/backend/app/controllers/youtube_oauth_callback_controller.rb index 69e8a33..83cc28d 100644 --- a/backend/app/controllers/youtube_oauth_callback_controller.rb +++ b/backend/app/controllers/youtube_oauth_callback_controller.rb @@ -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 diff --git a/backend/app/helpers/application_helper.rb b/backend/app/helpers/application_helper.rb index c587773..be90c6c 100644 --- a/backend/app/helpers/application_helper.rb +++ b/backend/app/helpers/application_helper.rb @@ -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? diff --git a/backend/app/helpers/public/billing_helper.rb b/backend/app/helpers/public/billing_helper.rb index 5a70dcb..96e1479 100644 --- a/backend/app/helpers/public/billing_helper.rb +++ b/backend/app/helpers/public/billing_helper.rb @@ -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 diff --git a/backend/app/models/subscription.rb b/backend/app/models/subscription.rb index f853356..22622aa 100644 --- a/backend/app/models/subscription.rb +++ b/backend/app/models/subscription.rb @@ -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 diff --git a/backend/app/services/billing/stripe/apply_pending_plan.rb b/backend/app/services/billing/stripe/apply_pending_plan.rb new file mode 100644 index 0000000..dd3cdff --- /dev/null +++ b/backend/app/services/billing/stripe/apply_pending_plan.rb @@ -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 diff --git a/backend/app/services/billing/stripe/change_plan.rb b/backend/app/services/billing/stripe/change_plan.rb index bdfc118..ff8ea30 100644 --- a/backend/app/services/billing/stripe/change_plan.rb +++ b/backend/app/services/billing/stripe/change_plan.rb @@ -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 diff --git a/backend/app/services/billing/stripe/change_plan_result.rb b/backend/app/services/billing/stripe/change_plan_result.rb new file mode 100644 index 0000000..b5e0d62 --- /dev/null +++ b/backend/app/services/billing/stripe/change_plan_result.rb @@ -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 diff --git a/backend/app/services/billing/stripe/checkout_sync.rb b/backend/app/services/billing/stripe/checkout_sync.rb index b444eae..684daa4 100644 --- a/backend/app/services/billing/stripe/checkout_sync.rb +++ b/backend/app/services/billing/stripe/checkout_sync.rb @@ -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 diff --git a/backend/app/services/billing/stripe/plan_change_kind.rb b/backend/app/services/billing/stripe/plan_change_kind.rb new file mode 100644 index 0000000..d1a0e48 --- /dev/null +++ b/backend/app/services/billing/stripe/plan_change_kind.rb @@ -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 diff --git a/backend/app/services/billing/stripe/plan_change_messages.rb b/backend/app/services/billing/stripe/plan_change_messages.rb new file mode 100644 index 0000000..ffb8c35 --- /dev/null +++ b/backend/app/services/billing/stripe/plan_change_messages.rb @@ -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 diff --git a/backend/app/services/billing/stripe/schedule_downgrade.rb b/backend/app/services/billing/stripe/schedule_downgrade.rb new file mode 100644 index 0000000..ee166cb --- /dev/null +++ b/backend/app/services/billing/stripe/schedule_downgrade.rb @@ -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 diff --git a/backend/app/services/billing/stripe/webhook_handler.rb b/backend/app/services/billing/stripe/webhook_handler.rb index c687950..5055bad 100644 --- a/backend/app/services/billing/stripe/webhook_handler.rb +++ b/backend/app/services/billing/stripe/webhook_handler.rb @@ -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" diff --git a/backend/app/services/youtube/oauth_state.rb b/backend/app/services/youtube/oauth_state.rb index d0af87f..e392d51 100644 --- a/backend/app/services/youtube/oauth_state.rb +++ b/backend/app/services/youtube/oauth_state.rb @@ -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 diff --git a/backend/app/views/public/clubs/billing.html.erb b/backend/app/views/public/clubs/billing.html.erb index aded247..b54662a 100644 --- a/backend/app/views/public/clubs/billing.html.erb +++ b/backend/app/views/public/clubs/billing.html.erb @@ -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 %> diff --git a/backend/app/views/public/invitations/show.html.erb b/backend/app/views/public/invitations/show.html.erb index ceb19f4..b12c339 100644 --- a/backend/app/views/public/invitations/show.html.erb +++ b/backend/app/views/public/invitations/show.html.erb @@ -5,10 +5,17 @@
Sei stato aggiunto come responsabile trasmissione (<%= @invitation.email %>).
++ Nell’app Match Live TV accedi con <%= @invitation.email %>, accetta l’invito e collega il tuo canale YouTube. +
<% if logged_in? %> <%= button_to "Accetta invito", public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %> <% else %><%= link_to "Accedi", public_login_path %> o <%= link_to "registrati", public_signup_path %> con <%= @invitation.email %>.
<%= button_to "Accetta (se già loggato)", public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %> <% end %> ++ App mobile: + Apri invito nell’app +
Contatta il supporto per downgrade.
<% elsif MatchLiveTv.stripe_enabled? %> <%= link_to "Gestisci abbonamento società", public_club_billing_path(@team.club), class: "btn btn-primary" %> <% else %> diff --git a/backend/app/views/shared/_club_subscription_status.html.erb b/backend/app/views/shared/_club_subscription_status.html.erb index e665221..68d6f36 100644 --- a/backend/app/views/shared/_club_subscription_status.html.erb +++ b/backend/app/views/shared/_club_subscription_status.html.erb @@ -16,8 +16,20 @@ <%= subscription.current_period_end.present? ? l_local(subscription.current_period_end.to_date) : "fine periodo" %>, poi piano Free. + <% elsif subscription.plan_change_pending? && subscription.pending_plan.present? %> + + Dal <%= subscription.current_period_end.present? ? l_local(subscription.current_period_end) : "prossimo rinnovo" %> + passerai a <%= subscription.pending_plan.name %> + <% 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 <%= current_plan.name %>. + <% elsif subscription.current_period_end.present? %> Prossimo rinnovo: <%= l_local(subscription.current_period_end) %> + <% if subscription.billing_interval.present? %> + · <%= Billing::Stripe::PriceCatalog.label(plan_slug: current_plan.slug, interval: subscription.billing_interval) %> + <% end %> <% end %> <% end %> diff --git a/backend/app/views/shared/_plan_cards.html.erb b/backend/app/views/shared/_plan_cards.html.erb index da61398..ef9cac6 100644 --- a/backend/app/views/shared/_plan_cards.html.erb +++ b/backend/app/views/shared/_plan_cards.html.erb @@ -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 %><%= action[:label] %>
<% elsif action_kind == :checkout_options || action_kind == :change_options || action_kind == :interval_switch %> <% if billing_profile_blocks_premium?(club) %> @@ -52,16 +54,13 @@ <% else %>Fatturazione attuale: <%= Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: action[:current_interval]) %>
- <% end %> <% end %> <% elsif plan.slug == "free" %> <%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %> diff --git a/backend/app/views/shared/_plan_change_info.html.erb b/backend/app/views/shared/_plan_change_info.html.erb new file mode 100644 index 0000000..1c4f8f0 --- /dev/null +++ b/backend/app/views/shared/_plan_change_info.html.erb @@ -0,0 +1,17 @@ +<%# locals: (subscription: nil) %> +<% lines = billing_plan_change_info_lines(subscription: subscription) %> +Cambio piano e fatturazione
+
Disdetta programmata.
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 2f481a9..850eb76 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -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
diff --git a/backend/db/migrate/20260602120000_add_pending_plan_to_subscriptions.rb b/backend/db/migrate/20260602120000_add_pending_plan_to_subscriptions.rb
new file mode 100644
index 0000000..1605bc4
--- /dev/null
+++ b/backend/db/migrate/20260602120000_add_pending_plan_to_subscriptions.rb
@@ -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
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index 0153bd6..9bed993 100644
--- a/backend/db/schema.rb
+++ b/backend/db/schema.rb
@@ -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"
diff --git a/backend/public/marketing.css b/backend/public/marketing.css
index 8f4d028..0e31423 100644
--- a/backend/public/marketing.css
+++ b/backend/public/marketing.css
@@ -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;
+}
diff --git a/backend/spec/helpers/public/billing_helper_spec.rb b/backend/spec/helpers/public/billing_helper_spec.rb
index 3ae67a2..731dbcd 100644
--- a/backend/spec/helpers/public/billing_helper_spec.rb
+++ b/backend/spec/helpers/public/billing_helper_spec.rb
@@ -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
diff --git a/backend/spec/requests/api/v1/invitations_spec.rb b/backend/spec/requests/api/v1/invitations_spec.rb
new file mode 100644
index 0000000..c06b93a
--- /dev/null
+++ b/backend/spec/requests/api/v1/invitations_spec.rb
@@ -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
diff --git a/backend/spec/services/billing/stripe/change_plan_spec.rb b/backend/spec/services/billing/stripe/change_plan_spec.rb
index e02b96e..2f27e32 100644
--- a/backend/spec/services/billing/stripe/change_plan_spec.rb
+++ b/backend/spec/services/billing/stripe/change_plan_spec.rb
@@ -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
diff --git a/backend/spec/services/billing/stripe/plan_change_kind_spec.rb b/backend/spec/services/billing/stripe/plan_change_kind_spec.rb
new file mode 100644
index 0000000..320c9fa
--- /dev/null
+++ b/backend/spec/services/billing/stripe/plan_change_kind_spec.rb
@@ -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
diff --git a/docs/APP_YOUTUBE_LIVE.md b/docs/APP_YOUTUBE_LIVE.md
new file mode 100644
index 0000000..21ba253
--- /dev/null
+++ b/docs/APP_YOUTUBE_LIVE.md
@@ -0,0 +1,59 @@
+# Diretta YouTube dall’app (invito staff)
+
+Percorso per il **responsabile trasmissione** invitato via email: accede in app, collega **il proprio** canale YouTube e avvia la live.
+
+## Prerequisiti (società)
+
+- Piano **Premium Full** attivo
+- `YOUTUBE_CLIENT_ID` / `YOUTUBE_CLIENT_SECRET` configurati sul server
+- Partita programmata per la squadra
+
+## 1. Invito
+
+1. Il titolare società → **Responsabili trasmissione** → invita `tua@email.it`
+2. L’invitato apre il link (web o **Apri invito nell’app**)
+3. Registrazione / login con **la stessa email** dell’invito
+4. L’app accetta l’invito automaticamente dopo il login
+
+API: `GET /api/v1/invitations/:token`, `POST /api/v1/invitations/:token/accept`
+
+## 2. Collega canale YouTube (tuo)
+
+1. Nell’app: nuova diretta → passo **Piattaforma**
+2. Tocca **YouTube Live** → si apre Google OAuth
+3. **Avanzate** → continua (se app OAuth in verifica)
+4. Scegli l’account del **tuo** canale → Consenti
+5. Torna all’app → messaggio «Canale collegato»
+
+Il token OAuth è salvato sulla **squadra**; chi collega deve essere `can_stream` (responsabile trasmissione o titolare).
+
+## 3. Scegli la partita
+
+Nella home app (**Partite**):
+
+- **Partita programmata** — scegli dal calendario una gara già inserita (sito o app)
+- **Nuova partita** — programma data/ora oppure **Avvia subito** senza orario
+
+Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmissione → …).
+
+## 4. Avvia diretta
+
+1. Nel wizard, seleziona **YouTube Live** (dopo il collegamento canale)
+2. Completa i passi → **Camera**
+3. Il telefono invia RTMP a MediaMTX; il server inolvia su YouTube con la stream key della live creata via API
+
+## Sviluppo locale
+
+- App: `flutter run --dart-define=API_BASE_URL=http://10.0.2.2:3000` (emulatore) o IP LAN del PC (telefono)
+- Invito deep link: `matchlivetv://join/TOKEN` oppure incolla token aprendo `/login?invite=TOKEN`
+- OAuth: utente in **Utenti di test** Google Cloud
+
+## Troubleshooting
+
+| Problema | Soluzione |
+|----------|-----------|
+| Nessuna squadra in app | Accetta invito con email corretta |
+| YouTube disabilitato | Premium Full + collegamento canale |
+| «Collega canale» | Tocca YouTube Live per OAuth da app |
+| OAuth «app non verificata» | Utente di test Google + Avanzate |
+| Live non su YouTube | Verifica relay ffmpeg / `stream_key` in sessione |
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index ea6108b..8c44282 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -34,6 +34,18 @@