Billing Stripe, link regia mobile e staff solo trasmissione.

Aggiunge fatturazione club, pagina regia condivisibile senza account, roster squadre e rimuove la modalità controller dall'app (v1.1.0).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 07:23:13 +02:00
parent 4083bc5dee
commit f4b7be0f80
156 changed files with 5033 additions and 2033 deletions

View File

@@ -0,0 +1,76 @@
module Billing
module Stripe
class ChangePlan
VALID_PLANS = CheckoutSession::VALID_PLANS.freeze
def self.call(club:, plan_slug:)
new(club: club, plan_slug: plan_slug).call
end
def initialize(club:, plan_slug:)
@club = club
@plan_slug = plan_slug.to_s
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 "Sei già su questo piano" if sub.plan.slug == @plan_slug
price_id = Plan[@plan_slug].stripe_price_id.presence || stripe_price_id_for(@plan_slug)
raise "Price ID Stripe mancante per #{@plan_slug}" if price_id.blank?
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 },
proration_behavior: "create_prorations"
)
apply_local_plan!(updated)
updated
end
private
def stripe_price_id_for(slug)
case slug
when "premium_light" then MatchLiveTv.stripe_premium_light_price_id
when "premium_full" then MatchLiveTv.stripe_premium_full_price_id
end
end
def apply_local_plan!(stripe_sub)
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
Billing::AssignPlan.call(
club: @club,
plan_slug: @plan_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
}
)
end
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

@@ -64,11 +64,15 @@ module Billing
end
def success_url
"#{dashboard_url}?checkout=success"
"#{billing_url}?checkout=success"
end
def cancel_url
"#{dashboard_url}?checkout=canceled"
"#{billing_url}?checkout=canceled"
end
def billing_url
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing"
end
def dashboard_url

View File

@@ -0,0 +1,90 @@
module Billing
module Stripe
class RecordPayment
def self.call(stripe_invoice:, club: nil)
new(stripe_invoice: stripe_invoice, club: club).call
end
def initialize(stripe_invoice:, club: nil)
@stripe_invoice = stripe_invoice
@club = club
end
def call
@club ||= resolve_club
return unless @club
payment = Billing::Payment.find_or_initialize_by(stripe_invoice_id: @stripe_invoice.id)
payment.assign_attributes(
club: @club,
stripe_payment_intent_id: payment_intent_id,
stripe_charge_id: charge_id,
amount_cents: @stripe_invoice.amount_paid,
currency: @stripe_invoice.currency,
status: map_status(@stripe_invoice.status),
plan_slug: plan_slug,
description: description,
paid_at: paid_at,
invoice_pdf_url: @stripe_invoice.invoice_pdf,
receipt_url: @stripe_invoice.hosted_invoice_url
)
payment.save!
payment
end
private
def resolve_club
club_id = metadata_club_id(@stripe_invoice)
return Club.find_by(id: club_id) if club_id.present?
sub_id = @stripe_invoice.subscription
return unless sub_id.present?
Club.joins(:subscription).find_by(subscriptions: { stripe_subscription_id: sub_id })
end
def metadata_club_id(obj)
meta = obj.metadata
return nil if meta.blank?
meta["club_id"] || meta[:club_id]
end
def plan_slug
meta = @stripe_invoice.subscription_details&.metadata || @stripe_invoice.metadata
meta["plan_slug"] || meta[:plan_slug]
end
def description
lines = @stripe_invoice.lines&.data
return @stripe_invoice.description if lines.blank?
lines.first.description.presence || @stripe_invoice.description
end
def payment_intent_id
pi = @stripe_invoice.payment_intent
pi.is_a?(String) ? pi : pi&.id
end
def charge_id
charge = @stripe_invoice.charge
charge.is_a?(String) ? charge : charge&.id
end
def paid_at
ts = @stripe_invoice.status_transitions&.paid_at
ts ? Time.zone.at(ts) : Time.current
end
def map_status(stripe_status)
case stripe_status
when "paid" then "paid"
when "uncollectible", "void" then "failed"
else "failed"
end
end
end
end
end

View File

@@ -0,0 +1,27 @@
module Billing
module Stripe
module SubscriptionPeriod
module_function
def times(stripe_sub)
item = stripe_sub.items&.data&.first
start_at = read_value(stripe_sub, :current_period_start) || read_value(item, :current_period_start)
end_at = read_value(stripe_sub, :current_period_end) || read_value(item, :current_period_end)
[unix_time(start_at), unix_time(end_at)]
end
def read_value(object, field)
return nil if object.blank?
return nil unless object.respond_to?(field)
object.public_send(field)
end
def unix_time(value)
return nil if value.blank?
Time.zone.at(value.to_i)
end
end
end
end

View File

@@ -0,0 +1,36 @@
module Billing
module Stripe
class SyncPayments
def self.call(club:)
new(club: club).call
end
def initialize(club:)
@club = club
end
def call
customer_id = @club.subscription&.stripe_customer_id
return 0 if customer_id.blank?
count = 0
starting_after = nil
loop do
list = ::Stripe::Invoice.list(customer: customer_id, limit: 25, starting_after: starting_after)
list.data.each do |invoice|
next unless invoice.status == "paid"
RecordPayment.call(stripe_invoice: invoice, club: @club)
count += 1
end
break unless list.has_more
starting_after = list.data.last.id
end
count
end
end
end
end

View File

@@ -19,6 +19,8 @@ module Billing
downgrade_to_free(@event.data.object)
when "invoice.payment_failed"
mark_past_due(@event.data.object)
when "invoice.paid"
record_payment(@event.data.object)
end
end
@@ -49,8 +51,7 @@ module Billing
plan_slug = "premium_full" if plan_slug == "premium"
plan = Plan[plan_slug]
status = map_status(stripe_sub.status)
period_start = unix_time(stripe_sub.current_period_start)
period_end = unix_time(stripe_sub.current_period_end)
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
Billing::AssignPlan.call(
club: club,
@@ -84,6 +85,10 @@ module Billing
)
end
def record_payment(invoice)
Billing::Stripe::RecordPayment.call(stripe_invoice: invoice)
end
def mark_past_due(invoice)
return if invoice.subscription.blank?

View File

@@ -0,0 +1,110 @@
module Scoring
class ApplyAction
Result = Struct.new(:score, :set_won, :match_won, :winner, keyword_init: true)
def initialize(session:, action:)
@session = session
@match = session.match
@action = action.to_s
@rules = Rules.from_match(@match)
end
def call
score = @session.score_state || @session.create_score_state!(
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
)
case @action
when "home_point"
apply_point(score, :home)
when "away_point"
apply_point(score, :away)
when "home_undo"
apply_undo(score, :home)
when "away_undo"
apply_undo(score, :away)
when "close_set"
apply_close_set(score)
else
raise ArgumentError, "Azione non valida"
end
end
private
def apply_point(score, side)
if side == :home
score.home_points += 1
else
score.away_points += 1
end
score.save!
broadcast(score)
winner = @rules.set_winner_from_points(
home_points: score.home_points,
away_points: score.away_points,
current_set: score.current_set
)
Result.new(score: score, set_won: winner.present?, match_won: false, winner: winner)
end
def apply_undo(score, side)
if side == :home
score.home_points = [score.home_points - 1, 0].max
else
score.away_points = [score.away_points - 1, 0].max
end
score.save!
broadcast(score)
Result.new(score: score, set_won: false, match_won: false, winner: nil)
end
def apply_close_set(score)
winner = @rules.set_winner_from_points(
home_points: score.home_points,
away_points: score.away_points,
current_set: score.current_set
)
winner ||= score.home_points > score.away_points ? :home : :away if score.home_points != score.away_points
return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless winner
close_set_for(score, winner)
end
def close_set_for(score, winner)
partials = Array(score.set_partials)
partials << {
"set" => score.current_set,
"home" => score.home_points,
"away" => score.away_points
}
if winner == :home
score.home_sets += 1
else
score.away_sets += 1
end
match_winner = @rules.match_winner(home_sets: score.home_sets, away_sets: score.away_sets)
score.assign_attributes(
set_partials: partials,
home_points: 0,
away_points: 0,
current_set: score.current_set + 1
)
score.save!
broadcast(score)
Result.new(
score: score,
set_won: true,
match_won: match_winner.present?,
winner: match_winner || winner
)
end
def broadcast(score)
SessionChannel.broadcast_message(@session, score.as_cable_payload)
end
end
end

View File

@@ -0,0 +1,46 @@
module Scoring
class Rules
Side = Struct.new(:home, :away, keyword_init: true)
def self.from_match(match)
overrides = match.scoring_rules.is_a?(Hash) ? match.scoring_rules : {}
new(
sets_to_win: match.sets_to_win,
points_per_set: overrides["points_per_set"] || overrides[:points_per_set] || 25,
points_deciding_set: overrides["points_deciding_set"] || overrides[:points_deciding_set] || 15,
min_point_lead: overrides["min_point_lead"] || overrides[:min_point_lead] || 2
)
end
def initialize(sets_to_win:, points_per_set: 25, points_deciding_set: 15, min_point_lead: 2)
@sets_to_win = sets_to_win
@points_per_set = points_per_set
@points_deciding_set = points_deciding_set
@min_point_lead = min_point_lead
end
def max_sets
(@sets_to_win * 2) - 1
end
def points_target(current_set)
current_set >= max_sets ? @points_deciding_set : @points_per_set
end
def set_winner_from_points(home_points:, away_points:, current_set:)
target = points_target(current_set)
if home_points >= target && (home_points - away_points) >= @min_point_lead
:home
elsif away_points >= target && (away_points - home_points) >= @min_point_lead
:away
end
end
def match_winner(home_sets:, away_sets:)
return :home if home_sets >= @sets_to_win
return :away if away_sets >= @sets_to_win
nil
end
end
end

View File

@@ -0,0 +1,65 @@
module Sessions
class RegiaAccess
DEFAULT_TTL = 12.hours
def initialize(session)
@session = session
end
# Genera un nuovo link (invalida quello precedente).
def issue_token!
rotate!
end
def rotate!
token = SecureRandom.urlsafe_base64(24)
expires = [DEFAULT_TTL.from_now, session_end_cap].compact.min
@session.update!(
regia_token_digest: digest(token),
regia_token_expires_at: expires
)
token
end
def url(token)
"#{MatchLiveTv.app_public_url.chomp('/')}/regia/#{token}"
end
def self.find_session_by_token(token)
return if token.blank?
session = StreamSession.find_by(regia_token_digest: digest(token))
return unless session
return if session.regia_token_expires_at.blank? || session.regia_token_expires_at.past?
return if session.terminal?
session
end
def self.revoke!(session)
session.update!(regia_token_digest: nil, regia_token_expires_at: nil)
end
def self.digest(token)
Digest::SHA256.hexdigest(token.to_s)
end
private
def token_valid?
@session.regia_token_digest.present? &&
@session.regia_token_expires_at&.future? &&
!@session.terminal?
end
def session_end_cap
return nil unless @session.started_at
@session.started_at + 8.hours
end
def digest(token)
self.class.digest(token)
end
end
end

View File

@@ -6,6 +6,7 @@ module Sessions
def call
cancel_timeout_job
Sessions::RegiaAccess.revoke!(@session)
@session.end_stream!
Youtube::BroadcastService.new(@session.match.team).complete_broadcast!(@session.youtube_broadcast_id)
Recordings::IndexSession.new(@session).call

View File

@@ -48,11 +48,15 @@ module Teams
end
def staff_count
staff_count_for("transmission") + staff_count_for("regia")
staff_count_for("transmission")
end
def staff_count_for(kind)
members_count_for(kind) + pending_invitations_count_for(kind)
def staff_count_for(_kind = "transmission")
staff_coverage.covered_count
end
def staff_assigned_for(_kind = "transmission")
staff_coverage.assigned_count
end
def members_count
@@ -149,29 +153,19 @@ module Teams
def assert_can_invite!(staff_kind: "transmission")
return unless active?
kind = staff_kind.to_s
unless TeamInvitation::STAFF_KINDS.include?(kind)
raise EntitlementError.new("Ruolo staff non valido", code: "invalid_staff_kind")
end
limit = staff_limit_for(kind)
limit = max_staff_transmission
return if limit.nil?
used = staff_count_for(kind)
used = staff_assigned_for
return if used < limit
label = kind == "regia" ? "regia" : "trasmissione"
raise EntitlementError.new(
"Limite staff #{label} raggiunto (#{limit} all'anno). Passa a un piano superiore.",
"Limite responsabili trasmissione raggiunto (#{limit} all'anno). Passa a un piano superiore.",
code: "staff_limit_reached",
billing_url: billing_url
)
end
def staff_limit_for(kind)
kind == "regia" ? max_staff_regia : max_staff_transmission
end
def assert_can_connect_youtube!
unless premium_full?
raise EntitlementError.new(
@@ -216,10 +210,8 @@ module Teams
staff_manage_url: staff_manage_url,
max_staff: max_staff,
max_staff_transmission: max_staff_transmission,
max_staff_regia: max_staff_regia,
staff_used: staff_count,
staff_transmission_used: staff_count_for("transmission"),
staff_regia_used: staff_count_for("regia"),
concurrent_streams_used: concurrent_streams_used,
concurrent_streams_limit: concurrent_streams_limit,
recordings_enabled: can_access_recordings?,
@@ -230,6 +222,10 @@ module Teams
}
end
def staff_coverage
@staff_coverage ||= StaffCoverage.new(@team)
end
private
def ensure_free_subscription!

View File

@@ -9,77 +9,55 @@ module Teams
end
class StaffAssignment
def self.call(team:, user:, staff_kind:, membership: nil)
def self.call(team:, user:, staff_kind: "transmission", membership: nil)
new(team: team, user: user, staff_kind: staff_kind, membership: membership).call
end
def initialize(team:, user:, staff_kind:, membership: nil)
def initialize(team:, user:, staff_kind: "transmission", membership: nil)
@team = team
@user = user
@staff_kind = staff_kind.to_s
@staff_kind = "transmission"
@membership = membership
end
def call
unless TeamInvitation::STAFF_KINDS.include?(@staff_kind)
raise StaffAssignmentError, "Ruolo staff non valido"
end
StaffEmailValidator.assert_available!(team: @team, email: @user.email, staff_kind: @staff_kind, except_user: @user)
StaffEmailValidator.assert_available!(
team: @team, email: @user.email, except_user: @user
)
ut = @membership || @user.user_teams.find_by!(team: @team)
return ut if ut.staff_kind == @staff_kind
return ut if ut.staff_kind == "transmission"
ent = Entitlements.new(@team)
ent.assert_can_invite!(staff_kind: @staff_kind)
ut.update!(staff_kind: @staff_kind)
Entitlements.new(@team).assert_can_invite!
ut.update!(staff_kind: "transmission")
ut
end
end
class StaffEmailValidator
def self.assert_available!(team:, email:, staff_kind:, except_user: nil)
new(team: team, email: email, staff_kind: staff_kind, except_user: except_user).assert_available!
def self.assert_available!(team:, email:, except_user: nil, staff_kind: nil)
new(team: team, email: email, except_user: except_user).assert_available!
end
def initialize(team:, email:, staff_kind:, except_user: nil)
def initialize(team:, email:, except_user: nil)
@team = team
@email = email.to_s.downcase.strip
@staff_kind = staff_kind.to_s
@except_user = except_user
end
def assert_available!
if @except_user
ut = @team.user_teams.find_by(user: @except_user)
if ut&.staff_kind.present? && ut.staff_kind != @staff_kind
other_label = ut.staff_kind == "regia" ? "regia" : "trasmissione"
raise StaffAssignmentError,
"Questo account è già staff #{other_label}. Trasmissione e regia devono essere email diverse."
end
user_scope = @team.user_teams.joins(:user).where(users: { email: @email }).where.not(staff_kind: nil)
user_scope = user_scope.where.not(user_id: @except_user.id) if @except_user
if user_scope.exists?
raise StaffAssignmentError,
"L'email #{@email} è già assegnata come responsabile trasmissione per questa squadra."
end
conflict = conflicting_assignment
return unless conflict
inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email)
return unless inv.exists?
other_label = conflict[:kind] == "regia" ? "regia" : "trasmissione"
raise StaffAssignmentError,
"L'email #{@email} è già assegnata come staff #{other_label}. Trasmissione e regia devono essere persone (email) diverse."
end
def conflicting_assignment
other_kind = @staff_kind == "transmission" ? "regia" : "transmission"
user_scope = @team.user_teams.joins(:user).where(users: { email: @email }).where(staff_kind: other_kind)
user_scope = user_scope.where.not(user_id: @except_user.id) if @except_user
return { kind: other_kind, source: :member } if user_scope.exists?
inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email).where(staff_kind: other_kind)
return { kind: other_kind, source: :invitation } if inv.exists?
nil
"Esiste già un invito in sospeso per #{@email}."
end
end
end

View File

@@ -0,0 +1,35 @@
module Teams
class StaffCoverage
def initialize(team)
@team = team
end
def assigned_count(_kind = "transmission")
members_count + pending_invitations_count
end
def covered_count(_kind = "transmission")
assigned_count.positive? ? 1 : 0
end
def role_label_for(membership)
return "Trasmissione" if membership.staff_kind.present?
nil
end
def open_slot_for_invite?(_kind = "transmission")
assigned_count < 1
end
private
def members_count
@team.user_teams.where.not(staff_kind: nil).count
end
def pending_invitations_count
@team.team_invitations.pending.count
end
end
end