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:
43
backend/app/models/billing/invoice.rb
Normal file
43
backend/app/models/billing/invoice.rb
Normal 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
|
||||
25
backend/app/models/billing/payment.rb
Normal file
25
backend/app/models/billing/payment.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
47
backend/app/models/concerns/club_billing_profile.rb
Normal file
47
backend/app/models/concerns/club_billing_profile.rb
Normal 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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class TeamInvitation < ApplicationRecord
|
||||
STAFF_KINDS = %w[transmission regia].freeze
|
||||
STAFF_KINDS = %w[transmission].freeze
|
||||
|
||||
belongs_to :team
|
||||
|
||||
|
||||
87
backend/app/models/team_roster_member.rb
Normal file
87
backend/app/models/team_roster_member.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user