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