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,43 @@
module Billing
class Invoice < ApplicationRecord
self.table_name = "billing_invoices"
STATUSES = %w[draft issued sent cancelled].freeze
SOURCES = %w[aruba manual].freeze
belongs_to :club
belongs_to :billing_payment, class_name: "Billing::Payment", optional: true
has_one_attached :pdf
validates :number, presence: true, uniqueness: { scope: :club_id }
validates :issued_on, presence: true
validates :amount_cents, numericality: { greater_than: 0 }
validates :currency, presence: true
validates :status, inclusion: { in: STATUSES }
validates :source, inclusion: { in: SOURCES }
validate :pdf_present_when_issued
scope :recent, -> { order(issued_on: :desc, created_at: :desc) }
def amount_euros
amount_cents / 100.0
end
def formatted_amount
format("%.2f €", amount_euros)
end
def downloadable?
pdf.attached?
end
private
def pdf_present_when_issued
return unless status.in?(%w[issued sent])
return if pdf.attached?
errors.add(:pdf, "è obbligatorio per le fatture emesse")
end
end
end

View File

@@ -0,0 +1,25 @@
module Billing
class Payment < ApplicationRecord
self.table_name = "billing_payments"
STATUSES = %w[paid failed refunded].freeze
belongs_to :club
has_one :invoice, class_name: "Billing::Invoice", foreign_key: :billing_payment_id, dependent: :nullify
validates :amount_cents, numericality: { greater_than: 0 }
validates :currency, presence: true
validates :status, inclusion: { in: STATUSES }
validates :stripe_invoice_id, uniqueness: true, allow_nil: true
scope :recent, -> { order(paid_at: :desc, created_at: :desc) }
def amount_euros
amount_cents / 100.0
end
def formatted_amount
format("%.2f €", amount_euros)
end
end
end

View File

@@ -1,10 +1,13 @@
class Club < ApplicationRecord
include Brandable
include ClubBillingProfile
has_many :club_memberships, dependent: :destroy
has_many :users, through: :club_memberships
has_many :teams, dependent: :destroy
has_one :subscription, dependent: :destroy
has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy
has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy
validates :name, presence: true
validates :sport, presence: true

View File

@@ -0,0 +1,47 @@
module ClubBillingProfile
extend ActiveSupport::Concern
BILLING_ENTITY_TYPES = {
"company" => "Società / ASD con P.IVA",
"individual" => "Persona fisica",
"nonprofit" => "Associazione senza scopo di lucro"
}.freeze
included do
validates :billing_entity_type, inclusion: { in: BILLING_ENTITY_TYPES.keys }, allow_nil: true
validates :billing_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
validates :billing_recipient_code, length: { is: 7 }, allow_blank: true
validates :billing_province, length: { is: 2 }, allow_blank: true
validates :billing_country, length: { is: 2 }, allow_blank: true
validate :billing_profile_for_invoicing, on: :billing_profile
end
def billing_profile_complete?
billing_profile_errors.empty?
end
def billing_profile_errors
errors = []
errors << "Ragione sociale o nome intestatario" if billing_legal_name.blank?
errors << "Email di fatturazione" if billing_email.blank?
errors << "Indirizzo" if billing_address_line.blank?
errors << "Città" if billing_city.blank?
errors << "CAP" if billing_postal_code.blank?
errors << "P.IVA o Codice Fiscale" if billing_vat_number.blank? && billing_fiscal_code.blank?
errors << "Codice destinatario SDI o PEC" if billing_recipient_code.blank? && billing_pec.blank?
errors
end
def billing_profile_summary
parts = [billing_legal_name.presence, billing_vat_number.presence && "P.IVA #{billing_vat_number}"]
parts.compact.join(" · ")
end
private
def billing_profile_for_invoicing
billing_profile_errors.each do |message|
errors.add(:base, message)
end
end
end

View File

@@ -7,17 +7,47 @@ class Match < ApplicationRecord
validates :opponent_name, presence: true
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
scope :scheduled_upcoming, -> {
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.current)
# Partite con data/ora: da inizio giornata (Europe/Rome) in poi — allineato alla pagina Partite.
scope :scheduled_for_live, -> {
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now.beginning_of_day)
}
scope :scheduled_upcoming, -> { scheduled_for_live }
def scheduled_upcoming?
scheduled_at.present? && scheduled_at >= Time.zone.now
end
def scheduled_on_calendar?
scheduled_at.present? && scheduled_at >= Time.zone.now.beginning_of_day
end
def active_stream_session
stream_sessions.sort_by { |s| s.created_at }.reverse
.find { |s| !%w[ended error].include?(s.status) }
end
def deletable?
session = active_stream_session
session.nil? || !session.resumable?
end
def public_status_label
active = active_stream_session
return "In corso / da riprendere" if active
return "Programmata" if scheduled_upcoming?
return "Pronta" if scheduled_at.present?
"Senza orario"
end
scope :search_teams_or_opponents, lambda { |query|
q = query.to_s.strip
return all if q.blank?
term = "%#{sanitize_sql_like(q)}%"
joins(:team).where(
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term",
joins(team: :club).where(
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term",
term: term
)
}

View File

@@ -16,6 +16,14 @@ class Plan < ApplicationRecord
find_by!(slug: slug.to_s)
end
def self.tier(slug)
SLUGS.index(slug.to_s) || 0
end
def tier
self.class.tier(slug)
end
def allows_platform?(platform)
return false if platform.to_s == "youtube" && !youtube_enabled?

View File

@@ -21,8 +21,8 @@ class StreamSession < ApplicationRecord
return all if q.blank?
term = "%#{sanitize_sql_like(q)}%"
joins(match: :team).where(
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term",
joins(match: { team: :club }).where(
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term",
term: term
)
}

View File

@@ -8,8 +8,12 @@ class Team < ApplicationRecord
has_one :youtube_credential, dependent: :destroy
has_many :recordings, dependent: :destroy
has_many :team_invitations, dependent: :destroy
has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy
has_one_attached :photo_file
validates :name, presence: true
validate :photo_file_type, if: -> { photo_file.attached? }
def subscription
club.subscription
@@ -23,8 +27,26 @@ class Team < ApplicationRecord
club&.owner
end
def team_photo_url
if photo_file.attached?
Rails.application.routes.url_helpers.rails_blob_path(photo_file, only_path: true)
end
end
def roster_by_category
TeamRosterMember::DISPLAY_ORDER.index_with do |cat|
roster_members.by_category(cat).to_a
end
end
private
def photo_file_type
return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp])
errors.add(:photo_file, "deve essere PNG, JPEG o WebP")
end
def branding_parent
club
end

View File

@@ -1,5 +1,5 @@
class TeamInvitation < ApplicationRecord
STAFF_KINDS = %w[transmission regia].freeze
STAFF_KINDS = %w[transmission].freeze
belongs_to :team

View File

@@ -0,0 +1,87 @@
class TeamRosterMember < ApplicationRecord
CATEGORIES = %w[staff coach manager player].freeze
# Ordine di visualizzazione in pagina organico
DISPLAY_ORDER = %w[coach manager player staff].freeze
CATEGORY_LABELS = {
"staff" => "Staff",
"coach" => "Allenatori",
"manager" => "Dirigenti",
"player" => "Giocatori"
}.freeze
VOLLEYBALL_PLAYER_ROLES = %w[
Schiacciatore
Opposto
Centrale
Palleggiatore
Libero
Universale
].freeze
belongs_to :team
has_one_attached :photo_file
validates :full_name, presence: true
validates :category, inclusion: { in: CATEGORIES }
validates :jersey_number, numericality: { only_integer: true, greater_than: 0, less_than: 100 }, allow_nil: true
validate :photo_file_type, if: -> { photo_file.attached? }
validate :player_role_label_valid, if: -> { category == "player" && role_label.present? }
before_validation :assign_position, on: :create
scope :ordered, -> { order(:position, :created_at) }
scope :by_category, ->(cat) { where(category: cat).ordered }
def category_label
CATEGORY_LABELS[category] || category
end
def initials
parts = full_name.to_s.split(/\s+/).reject(&:blank?)
return "?" if parts.empty?
parts.first(2).map { |p| p[0] }.join.upcase
end
def photo_url
return unless photo_file.attached?
Rails.application.routes.url_helpers.rails_blob_path(photo_file, only_path: true)
end
def display_role
role_label.presence || default_role_label
end
private
def assign_position
return if position.positive? || team.blank?
max_pos = team.roster_members.where(category: category).maximum(:position) || -1
self.position = max_pos + 1
end
def default_role_label
case category
when "coach" then "Allenatore"
when "manager" then "Dirigente"
when "staff" then "Staff"
when "player" then jersey_number.present? ? "Giocatore ##{jersey_number}" : "Giocatore"
else category
end
end
def player_role_label_valid
return if role_label.in?(VOLLEYBALL_PLAYER_ROLES)
errors.add(:role_label, "non è un ruolo pallavolo valido")
end
def photo_file_type
return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp])
errors.add(:photo_file, "deve essere PNG, JPEG o WebP")
end
end

View File

@@ -16,6 +16,51 @@ class User < ApplicationRecord
Team.where(id: staff_ids).or(Team.where(id: owner_ids))
end
# Squadre da cui l'utente può programmare partite e avviare lo streaming (app).
def streamable_teams
manageable_teams.includes(:club).select { |team| can_stream_for?(team) }
end
def club_admin?(club)
club&.owned_by?(self)
end
def can_schedule_for?(team)
return true if team.club&.owned_by?(self)
membership = user_teams.find_by(team: team)
return false if membership&.staff_kind.blank?
membership.staff_kind == "transmission"
end
def schedulable_teams_for(club)
teams = club.teams.order(:name).to_a
return teams if club.owned_by?(self)
teams.select { |team| can_schedule_for?(team) }
end
def can_stream_for?(team)
return true if team.club&.owned_by?(self)
membership = user_teams.find_by(team: team)
return false if membership&.staff_kind.blank?
return true if membership.staff_kind == "transmission"
Teams::StaffCoverage.new(team).covers_both_roles?(membership)
end
def staff_role_for(team)
return "owner" if team.club&.owned_by?(self)
membership = user_teams.find_by(team: team)
return nil unless membership&.staff_kind.present?
"transmission"
end
def primary_club
owned_clubs.order(:created_at).first
end