Compare commits

...
3 Commits
Author SHA1 Message Date
eminuxandCursor e436f5ece4 Aggiunge pagamento con bonifico e importo concordato per società.
Il piano si attiva solo dopo la conferma admin; Stripe resta a listino se non c'è un prezzo commerciale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:20:43 +02:00
eminuxandCursor 1e4e0560f1 Allinea le spec alla policy password e prepara i piani di test.
Senza questo la suite locale fallisce sulla complessità password e sui piani mancanti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:20:31 +02:00
eminuxandCursor 841a6a15be Avvia i relay YouTube overflow dal Sidekiq home e passa STREAM/HCLOUD in produzione.
Così un CPX nuovo non richiede worker Docker a mano, l'agent è nel cloud-init e gli E2E restano non in elenco.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 08:57:56 +02:00
93 changed files with 2064 additions and 117 deletions
@@ -2,6 +2,7 @@ module Admin
class BillingController < BaseController class BillingController < BaseController
def index def index
@pending_payments = pending_scope.recent @pending_payments = pending_scope.recent
@pending_transfers = transfer_scope
@completed_payments = Billing::Payment.with_invoice_pdf.includes(:club, :invoice).recent.limit(40) @completed_payments = Billing::Payment.with_invoice_pdf.includes(:club, :invoice).recent.limit(40)
@filter_club = Club.find_by(id: params[:club_id]) if params[:club_id].present? @filter_club = Club.find_by(id: params[:club_id]) if params[:club_id].present?
@clubs = Club.order(:name) @clubs = Club.order(:name)
@@ -18,6 +19,25 @@ module Admin
alert: e.message alert: e.message
end end
def confirm_transfer
order = Billing::TransferOrder.find(params[:id])
Billing::ConfirmBankTransfer.call(
order: order,
admin: current_admin_account,
pdf: params[:pdf]
)
redirect_to admin_billing_path(club_id: order.club_id),
notice: t("admin.flash.transfer_confirmed", plan: order.plan.name, club: order.club.name)
rescue Billing::ConfirmBankTransfer::Error, Billing::AttachPaymentInvoice::Error, Billing::IssueInvoice::Error => e
redirect_to admin_billing_path, alert: e.message
end
def cancel_transfer
order = Billing::TransferOrder.find(params[:id])
order.cancel!
redirect_to admin_billing_path(club_id: order.club_id), notice: t("admin.flash.transfer_cancelled")
end
private private
def pending_scope def pending_scope
@@ -26,6 +46,12 @@ module Admin
scope scope
end end
def transfer_scope
scope = Billing::TransferOrder.awaiting_payment.includes(:club, :requested_by_user)
scope = scope.where(club_id: params[:club_id]) if params[:club_id].present?
scope
end
def billing_redirect_params(payment) def billing_redirect_params(payment)
{ club_id: payment.club_id, anchor: "payment-#{payment.id}" }.compact { club_id: payment.club_id, anchor: "payment-#{payment.id}" }.compact
end end
@@ -1,9 +1,9 @@
module Admin module Admin
class ClubsController < BaseController class ClubsController < BaseController
before_action :set_club, only: %i[show grant_comped revoke_comped] before_action :set_club, only: %i[show grant_comped revoke_comped set_quote revoke_quote]
def index def index
@clubs = Club.includes(:teams, subscription: %i[plan admin_comped_by]) @clubs = Club.includes(:teams, :billing_quote, subscription: %i[plan admin_comped_by])
.order(:name) .order(:name)
end end
@@ -11,6 +11,7 @@ module Admin
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active") @subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
@plans = Plan.ordered.reject { |p| p.slug == "free" } @plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.order(:name) @teams = @club.teams.order(:name)
@quote = @club.active_billing_quote
end end
def grant_comped def grant_comped
@@ -32,6 +33,32 @@ module Admin
redirect_back_or_club alert: e.message redirect_back_or_club alert: e.message
end end
def set_quote
quote = Billing::SetClubQuote.upsert(
club: @club,
plan_slug: params.require(:plan_slug),
interval: params[:interval],
amount_euros: params[:amount_euros],
note: params[:note],
admin: current_admin_account
)
redirect_back_or_club notice: t(
"admin.flash.quote_saved",
club: @club.name,
plan: quote.plan.name,
amount: quote.formatted_amount
)
rescue Billing::SetClubQuote::Error, ActionController::ParameterMissing => e
redirect_back_or_club alert: e.message
end
def revoke_quote
Billing::SetClubQuote.revoke(club: @club, admin: current_admin_account)
redirect_back_or_club notice: t("admin.flash.quote_revoked", club: @club.name)
rescue Billing::SetClubQuote::Error => e
redirect_back_or_club alert: e.message
end
private private
def set_club def set_club
@@ -11,11 +11,15 @@ module Public
@club.assign_attributes(billing_profile_params) @club.assign_attributes(billing_profile_params)
if @club.save(context: :billing_profile) if @club.save(context: :billing_profile)
if premium_checkout_return_params.present? && @club.billing_profile_complete? if premium_checkout_return_params.present? && @club.billing_profile_complete?
if @club.active_billing_quote.present?
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated")
else
redirect_to public_club_checkout_path( redirect_to public_club_checkout_path(
@club, @club,
plan: premium_checkout_return_params[:plan], plan: premium_checkout_return_params[:plan],
interval: premium_checkout_return_params[:interval] interval: premium_checkout_return_params[:interval]
), notice: t("flash.club_billing.profile_saved_proceed_payment") ), notice: t("flash.club_billing.profile_saved_proceed_payment")
end
else else
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated") redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated")
end end
@@ -42,6 +46,29 @@ module Public
redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.stripe_error", message: e.message) redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.stripe_error", message: e.message)
end end
def request_bank_transfer
if @club.subscription&.admin_comped?
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.comped_change_denied")
return
end
unless @club.billing_profile_complete?
redirect_to public_club_billing_profile_path(@club, plan: params[:plan], interval: params[:interval]),
alert: t("flash.clubs.complete_billing_first")
return
end
Billing::RequestBankTransfer.call(
club: @club,
user: current_user,
plan_slug: params[:plan],
interval: params[:interval]
)
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.bank_transfer_requested")
rescue Billing::RequestBankTransfer::Error, ArgumentError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
def download_invoice def download_invoice
invoice = @club.billing_invoices.find(params[:invoice_id]) invoice = @club.billing_invoices.find(params[:invoice_id])
unless invoice.pdf.attached? unless invoice.pdf.attached?
@@ -82,6 +82,8 @@ module Public
@entitlements = @team.entitlements @entitlements = @team.entitlements
@plans = Plan.ordered @plans = Plan.ordered
@payments = @club.billing_payments.recent.includes(:invoice).limit(50) @payments = @club.billing_payments.recent.includes(:invoice).limit(50)
@quote = @club.active_billing_quote
@pending_transfer = @club.billing_transfer_orders.awaiting_payment.first
end end
def checkout def checkout
@@ -92,6 +94,12 @@ module Public
return return
end end
if @club.active_billing_quote.present?
redirect_to public_club_billing_path(@club),
alert: t("flash.club_billing.quote_checkout_denied")
return
end
unless MatchLiveTv.stripe_enabled? unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.stripe_not_configured") redirect_to public_club_billing_path(@club), alert: t("flash.clubs.stripe_not_configured")
return return
+49 -6
View File
@@ -1,6 +1,6 @@
module Public module Public
module BillingHelper module BillingHelper
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil) def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil, club: nil)
if target_plan.slug == "free" if target_plan.slug == "free"
return { kind: :current, label: I18n.t("billing.actions.current_plan") } if current_slug == "free" return { kind: :current, label: I18n.t("billing.actions.current_plan") } if current_slug == "free"
return { kind: :none } if stripe_subscription_active return { kind: :none } if stripe_subscription_active
@@ -8,17 +8,37 @@ module Public
return { kind: :contact, label: I18n.t("billing.actions.contact_for_free") } return { kind: :contact, label: I18n.t("billing.actions.contact_for_free") }
end end
quote = club&.active_billing_quote
if quote
if quote.plan_slug == target_plan.slug
return { kind: :quoted, plan: target_plan, quote: quote, intervals: [quote.billing_interval] }
end
return { kind: :quoted_other, label: I18n.t("billing.bank_transfer.quoted_other") }
end
intervals = bank_transfer_intervals_for(target_plan)
unless MatchLiveTv.stripe_enabled? unless MatchLiveTv.stripe_enabled?
if MatchLiveTv.bank_transfer_configured?
return { kind: :bank_only, plan: target_plan, intervals: intervals }
end
return { kind: :disabled, label: I18n.t("billing.actions.stripe_not_configured") } return { kind: :disabled, label: I18n.t("billing.actions.stripe_not_configured") }
end end
intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug) stripe_intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug)
return { kind: :disabled, label: I18n.t("billing.actions.stripe_prices_not_configured") } if intervals.empty? if stripe_intervals.empty?
if MatchLiveTv.bank_transfer_configured?
return { kind: :bank_only, plan: target_plan, intervals: intervals }
end
return { kind: :disabled, label: I18n.t("billing.actions.stripe_prices_not_configured") }
end
active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
if current_slug == target_plan.slug && stripe_subscription_active && !subscription&.plan_change_pending? if current_slug == target_plan.slug && stripe_subscription_active && !subscription&.plan_change_pending?
other_intervals = intervals - [active_interval] other_intervals = stripe_intervals - [active_interval]
if other_intervals.empty? if other_intervals.empty?
label = "#{I18n.t('billing.actions.current_plan')}#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}" label = "#{I18n.t('billing.actions.current_plan')}#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}"
return { kind: :current, label: label } return { kind: :current, label: label }
@@ -35,12 +55,12 @@ module Public
end end
if current_slug == "free" || !stripe_subscription_active if current_slug == "free" || !stripe_subscription_active
{ kind: :checkout_options, plan: target_plan, intervals: intervals, subscription: subscription } { kind: :checkout_options, plan: target_plan, intervals: stripe_intervals.presence || intervals, subscription: subscription }
else else
{ {
kind: :change_options, kind: :change_options,
plan: target_plan, plan: target_plan,
intervals: intervals, intervals: stripe_intervals,
current_slug: current_slug, current_slug: current_slug,
current_interval: active_interval, current_interval: active_interval,
subscription: subscription subscription: subscription
@@ -96,5 +116,28 @@ module Public
I18n.t("billing.actions.profile_missing", fields: missing.join(", ")) I18n.t("billing.actions.profile_missing", fields: missing.join(", "))
end end
def bank_transfer_intervals_for(plan)
Billing::Stripe::PriceCatalog.catalog_intervals(plan_slug: plan.slug)
end
def bank_transfer_price_label(plan, interval, quote: nil)
if quote&.matches?(plan.slug, interval)
quote.price_label
else
Billing::Stripe::PriceCatalog.format_interval_price(
Billing::Stripe::PriceCatalog.amount_cents(plan_slug: plan.slug, interval: interval),
interval
)
end
end
def show_bank_transfer_for?(club:, plan:, interval:, quote: nil, pending_transfer: nil)
return false unless club.present? && plan.slug != "free"
return false unless MatchLiveTv.bank_transfer_configured?
return false if quote && !quote.matches?(plan.slug, interval)
return false if pending_transfer&.awaiting_payment?
true
end
end end
end end
@@ -8,10 +8,35 @@ class ExpireEndedSubscriptionsJob
Subscription.where(cancel_at_period_end: true) Subscription.where(cancel_at_period_end: true)
.where("current_period_end <= ?", Time.current) .where("current_period_end <= ?", Time.current)
.where.not(plan_id: free_plan.id) .where.not(plan_id: free_plan.id)
.where(admin_comped: false)
.find_each do |sub| .find_each do |sub|
Billing::Stripe::FinalizeSubscription.call(club: sub.club) expire!(sub)
rescue StandardError => e rescue StandardError => e
Rails.logger.warn("[ExpireEndedSubscriptions] club=#{sub.club_id} #{e.message}") Rails.logger.warn("[ExpireEndedSubscriptions] club=#{sub.club_id} #{e.message}")
end end
end end
private
def expire!(sub)
if sub.stripe_subscription_id.present?
Billing::Stripe::FinalizeSubscription.call(club: sub.club)
else
Billing::AssignPlan.call(
club: sub.club,
plan_slug: "free",
status: "active",
stripe_attrs: {
stripe_subscription_id: nil,
stripe_schedule_id: nil,
current_period_start: nil,
current_period_end: nil,
cancel_at_period_end: false,
billing_interval: nil,
pending_plan_id: nil,
pending_billing_interval: nil
}
)
end
end
end end
@@ -0,0 +1,35 @@
module Billing
class BankTransferMailer < ApplicationMailer
def instructions
@order = params[:order]
@club = @order.club
@iban = MatchLiveTv.bank_transfer_iban
@holder = MatchLiveTv.bank_transfer_account_holder
@bank_name = MatchLiveTv.bank_transfer_bank_name
@bic = MatchLiveTv.bank_transfer_bic
@proof_email = MatchLiveTv.bank_transfer_proof_email
mail(
to: recipient_email,
subject: t("mailers.bank_transfer.instructions.subject", plan: @order.plan.name)
)
end
def plan_activated
@order = params[:order]
@club = @order.club
@subscription = @club.subscription
mail(
to: recipient_email,
subject: t("mailers.bank_transfer.plan_activated.subject", plan: @order.plan.name)
)
end
private
def recipient_email
@club.billing_email.presence || @club.owner&.email
end
end
end
+26 -6
View File
@@ -5,17 +5,37 @@ module Billing
@club = @invoice.club @club = @invoice.club
I18n.with_locale(I18n.locale) do I18n.with_locale(I18n.locale) do
prefix = t("mailers.invoice.attachment_prefix") attach_invoice_pdf!
attachments["#{prefix}-#{@invoice.number.parameterize}.pdf"] = {
mime_type: "application/pdf",
content: @invoice.pdf.download
}
mail( mail(
to: @club.billing_email, to: @club.billing_email,
subject: t("mailers.invoice.subject", number: @invoice.display_number) subject: t("mailers.invoice.subject", number: @invoice.display_number)
) )
end end
end end
def plan_activated_with_invoice
@invoice = params[:invoice]
@club = @invoice.club
I18n.with_locale(I18n.locale) do
attach_invoice_pdf!
mail(
to: @club.billing_email,
subject: t("mailers.invoice_activated.subject",
plan: @club.subscription&.plan&.name || @invoice.display_number,
number: @invoice.display_number)
)
end
end
private
def attach_invoice_pdf!
prefix = t("mailers.invoice.attachment_prefix")
attachments["#{prefix}-#{@invoice.number.parameterize}.pdf"] = {
mime_type: "application/pdf",
content: @invoice.pdf.download
}
end
end end
end end
+34
View File
@@ -0,0 +1,34 @@
module Billing
class ClubQuote < ApplicationRecord
self.table_name = "billing_club_quotes"
PLAN_SLUGS = %w[premium_light premium_full].freeze
INTERVALS = Billing::Stripe::PriceCatalog::INTERVALS
belongs_to :club
belongs_to :created_by_admin, class_name: "AdminAccount", optional: true
validates :plan_slug, inclusion: { in: PLAN_SLUGS }
validates :billing_interval, inclusion: { in: INTERVALS }
validates :amount_cents, numericality: { greater_than: 0 }
validates :currency, presence: true
scope :active, -> { where(active: true) }
def matches?(plan_slug, interval)
self.plan_slug == plan_slug.to_s && billing_interval == interval.to_s
end
def plan
Plan[plan_slug]
end
def formatted_amount
Billing::Stripe::PriceCatalog.format_eur(amount_cents)
end
def price_label
Billing::Stripe::PriceCatalog.format_interval_price(amount_cents, billing_interval)
end
end
end
+10 -2
View File
@@ -2,14 +2,17 @@ module Billing
class Payment < ApplicationRecord class Payment < ApplicationRecord
self.table_name = "billing_payments" self.table_name = "billing_payments"
STATUSES = %w[paid failed refunded].freeze STATUSES = %w[pending paid failed refunded].freeze
PROVIDERS = %w[stripe bank_transfer].freeze
belongs_to :club belongs_to :club
has_one :invoice, class_name: "Billing::Invoice", foreign_key: :billing_payment_id, dependent: :nullify has_one :invoice, class_name: "Billing::Invoice", foreign_key: :billing_payment_id, dependent: :nullify
has_one :transfer_order, class_name: "Billing::TransferOrder", foreign_key: :billing_payment_id, dependent: :nullify
validates :amount_cents, numericality: { greater_than: 0 } validates :amount_cents, numericality: { greater_than: 0 }
validates :currency, presence: true validates :currency, presence: true
validates :status, inclusion: { in: STATUSES } validates :status, inclusion: { in: STATUSES }
validates :provider, inclusion: { in: PROVIDERS }
validates :stripe_invoice_id, uniqueness: true, allow_nil: true validates :stripe_invoice_id, uniqueness: true, allow_nil: true
scope :recent, -> { order(paid_at: :desc, created_at: :desc) } scope :recent, -> { order(paid_at: :desc, created_at: :desc) }
@@ -62,7 +65,12 @@ module Billing
end end
def display_status def display_status
{ "paid" => "Pagato", "failed" => "Non riuscito", "refunded" => "Rimborsato" }[status] || status {
"pending" => "In attesa di bonifico",
"paid" => "Pagato",
"failed" => "Non riuscito",
"refunded" => "Rimborsato"
}[status] || status
end end
def invoice_for_display def invoice_for_display
@@ -0,0 +1,67 @@
module Billing
class TransferOrder < ApplicationRecord
self.table_name = "billing_transfer_orders"
KINDS = %w[list_price commercial_quote].freeze
STATUSES = %w[awaiting_payment paid cancelled].freeze
PLAN_SLUGS = ClubQuote::PLAN_SLUGS
INTERVALS = ClubQuote::INTERVALS
belongs_to :club
belongs_to :billing_club_quote, class_name: "Billing::ClubQuote", optional: true
belongs_to :billing_payment, class_name: "Billing::Payment", optional: true
belongs_to :requested_by_user, class_name: "User", optional: true
belongs_to :confirmed_by_admin, class_name: "AdminAccount", optional: true
validates :plan_slug, inclusion: { in: PLAN_SLUGS }
validates :billing_interval, inclusion: { in: INTERVALS }
validates :amount_cents, numericality: { greater_than: 0 }
validates :kind, inclusion: { in: KINDS }
validates :status, inclusion: { in: STATUSES }
validates :reference_code, presence: true, uniqueness: true
validates :currency, presence: true
scope :awaiting_payment, -> { where(status: "awaiting_payment").order(created_at: :desc) }
scope :recent, -> { order(created_at: :desc) }
def awaiting_payment?
status == "awaiting_payment"
end
def paid?
status == "paid"
end
def quoted?
kind == "commercial_quote"
end
def plan
Plan[plan_slug]
end
def formatted_amount
format("%.2f €", amount_cents / 100.0)
end
def price_label
Billing::Stripe::PriceCatalog.format_interval_price(amount_cents, billing_interval)
end
def payment_causal
"MLTV #{reference_code} #{club.name}".truncate(140, omission: "")
end
def cancel!
return self unless awaiting_payment?
transaction do
update!(status: "cancelled", cancelled_at: Time.current)
if billing_payment&.status == "pending"
billing_payment.update!(status: "failed")
end
end
self
end
end
end
+11
View File
@@ -7,8 +7,11 @@ class Club < ApplicationRecord
has_many :teams, dependent: :destroy has_many :teams, dependent: :destroy
has_one :youtube_credential, dependent: :destroy has_one :youtube_credential, dependent: :destroy
has_one :subscription, dependent: :destroy has_one :subscription, dependent: :destroy
has_one :billing_quote, -> { where(active: true) }, class_name: "Billing::ClubQuote", inverse_of: :club
has_many :billing_quotes, class_name: "Billing::ClubQuote", dependent: :destroy, inverse_of: :club
has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy
has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy
has_many :billing_transfer_orders, class_name: "Billing::TransferOrder", dependent: :destroy
validates :name, presence: true validates :name, presence: true
validates :sport, presence: true validates :sport, presence: true
@@ -23,6 +26,14 @@ class Club < ApplicationRecord
club_memberships.exists?(user: user, role: "owner") club_memberships.exists?(user: user, role: "owner")
end end
def active_billing_quote
billing_quote
end
def pending_transfer_order
billing_transfer_orders.awaiting_payment.first
end
private private
def branding_parent def branding_parent
+16
View File
@@ -35,4 +35,20 @@ class StreamNode < ApplicationRecord
def allocatable? def allocatable?
status == "ready" && free_slots.positive? status == "ready" && free_slots.positive?
end end
# Agent ffmpeg sul CPX (cloud-init :9100). Home Sidekiq lo chiama senza worker Docker per nodo.
def relay_agent_url
return unless role == "cloud"
ip = (metadata || {})["public_ip"].presence
if ip.blank?
host = URI.parse(api_base_url.to_s).host
ip = host if host.present? && host != "localhost"
end
return if ip.blank? || ip == "127.0.0.1"
"http://#{ip}:#{ENV.fetch("STREAM_NODE_AGENT_PORT", "9100")}"
rescue URI::InvalidURIError
nil
end
end end
+4
View File
@@ -35,4 +35,8 @@ class Subscription < ApplicationRecord
def admin_comped? def admin_comped?
admin_comped admin_comped
end end
def bank_transfer?
premium? && stripe_subscription_id.blank? && !admin_comped? && current_period_end.present?
end
end end
@@ -23,6 +23,7 @@ module Billing
raise Error, "Piano non valido" unless @plan_slug.in?(VALID_PLANS) raise Error, "Piano non valido" unless @plan_slug.in?(VALID_PLANS)
release_stripe_schedule! release_stripe_schedule!
cancel_awaiting_transfers!
AssignPlan.call( AssignPlan.call(
club: @club, club: @club,
@@ -97,5 +98,9 @@ module Billing
ensure ensure
sub&.update!(stripe_schedule_id: nil, pending_plan_id: nil, pending_billing_interval: nil) sub&.update!(stripe_schedule_id: nil, pending_plan_id: nil, pending_billing_interval: nil)
end end
def cancel_awaiting_transfers!
@club.billing_transfer_orders.awaiting_payment.find_each(&:cancel!)
end
end end
end end
@@ -2,13 +2,14 @@ module Billing
class AttachPaymentInvoice class AttachPaymentInvoice
class Error < StandardError; end class Error < StandardError; end
def self.call(payment:, pdf:) def self.call(payment:, pdf:, mailer_action: :invoice_pdf)
new(payment: payment, pdf: pdf).call new(payment: payment, pdf: pdf, mailer_action: mailer_action).call
end end
def initialize(payment:, pdf:) def initialize(payment:, pdf:, mailer_action: :invoice_pdf)
@payment = payment @payment = payment
@pdf = pdf @pdf = pdf
@mailer_action = mailer_action
end end
def call def call
@@ -17,7 +18,7 @@ module Billing
club = @payment.club club = @payment.club
invoice = @payment.invoice || build_invoice!(club) invoice = @payment.invoice || build_invoice!(club)
IssueInvoice.call(invoice: invoice, pdf: @pdf) IssueInvoice.call(invoice: invoice, pdf: @pdf, mailer_action: @mailer_action)
end end
private private
@@ -0,0 +1,102 @@
module Billing
class ConfirmBankTransfer
class Error < StandardError; end
def self.call(order:, admin:, pdf: nil)
new(order: order, admin: admin, pdf: pdf).call
end
def initialize(order:, admin:, pdf: nil)
@order = order
@admin = admin
@pdf = pdf
end
def call
raise Error, "Bonifico già gestito" unless @order.awaiting_payment?
club = @order.club
payment = @order.billing_payment
raise Error, "Pagamento collegato mancante" if payment.blank?
ApplicationRecord.transaction do
cancel_existing_stripe!(club.subscription)
period_start, period_end = period_bounds(club.subscription)
AssignPlan.call(
club: club,
plan_slug: @order.plan_slug,
status: "active",
stripe_attrs: {
stripe_subscription_id: nil,
stripe_schedule_id: nil,
pending_plan_id: nil,
pending_billing_interval: nil,
billing_interval: @order.billing_interval,
current_period_start: period_start,
current_period_end: period_end,
cancel_at_period_end: true,
admin_comped: false,
admin_comped_reason: nil,
admin_comped_at: nil,
admin_comped_by_id: nil
}
)
payment.update!(status: "paid", paid_at: Time.current, provider: "bank_transfer")
@order.update!(
status: "paid",
confirmed_by_admin: @admin,
confirmed_at: Time.current
)
end
deliver_activation!(payment)
@order.reload
end
private
def period_bounds(subscription)
start_at = Time.current
if subscription&.premium? &&
!subscription.admin_comped? &&
subscription.plan.slug == @order.plan_slug &&
subscription.current_period_end.present? &&
subscription.current_period_end > Time.current
start_at = subscription.current_period_end
end
end_at = @order.billing_interval == "yearly" ? start_at.advance(years: 1) : start_at.advance(months: 1)
[start_at, end_at]
end
def cancel_existing_stripe!(subscription)
return if subscription.blank? || subscription.stripe_subscription_id.blank?
return unless MatchLiveTv.stripe_enabled?
::Stripe::Subscription.cancel(subscription.stripe_subscription_id)
rescue ::Stripe::InvalidRequestError => e
Rails.logger.warn("[BankTransfer] stripe cancel club=#{subscription.club_id} #{e.message}")
end
def deliver_activation!(payment)
if pdf_present?
AttachPaymentInvoice.call(
payment: payment.reload,
pdf: @pdf,
mailer_action: :plan_activated_with_invoice
)
else
BankTransferMailer.with(order: @order.reload).plan_activated.deliver_now
end
end
def pdf_present?
return false if @pdf.blank?
return @pdf.present? unless @pdf.respond_to?(:tempfile)
@pdf.original_filename.present?
end
end
end
@@ -0,0 +1,26 @@
module Billing
class EuroAmount
class Error < StandardError; end
def self.to_cents(value)
raw = value.to_s.strip
raise Error, "Indica l'importo in euro" if raw.blank?
normalized = raw.gsub(/\s+/, "")
if normalized.match?(/\A\d{1,3}(\.\d{3})*,\d{1,2}\z/)
normalized = normalized.gsub(".", "").tr(",", ".")
elsif normalized.match?(/\A\d+,\d{1,2}\z/)
normalized = normalized.tr(",", ".")
elsif normalized.match?(/\A\d{1,3}(,\d{3})*\.\d{1,2}\z/)
normalized = normalized.gsub(",", "")
end
raise Error, "Importo non valido" unless normalized.match?(/\A\d+(\.\d{1,2})?\z/)
cents = (BigDecimal(normalized) * 100).round
raise Error, "L'importo deve essere maggiore di zero" unless cents.positive?
cents.to_i
end
end
end
@@ -2,13 +2,17 @@ module Billing
class IssueInvoice class IssueInvoice
class Error < StandardError; end class Error < StandardError; end
def self.call(invoice:, pdf: nil) MAILER_ACTIONS = %i[invoice_pdf plan_activated_with_invoice].freeze
new(invoice: invoice, pdf: pdf).call
def self.call(invoice:, pdf: nil, mailer_action: :invoice_pdf)
new(invoice: invoice, pdf: pdf, mailer_action: mailer_action).call
end end
def initialize(invoice:, pdf: nil) def initialize(invoice:, pdf: nil, mailer_action: :invoice_pdf)
@invoice = invoice @invoice = invoice
@pdf = pdf @pdf = pdf
@mailer_action = mailer_action.to_sym
raise Error, "Azione email non valida" unless MAILER_ACTIONS.include?(@mailer_action)
end end
def call def call
@@ -22,7 +26,7 @@ module Billing
@invoice.update!(status: "issued") @invoice.update!(status: "issued")
Billing::InvoiceMailer.with(invoice: @invoice).invoice_pdf.deliver_now Billing::InvoiceMailer.with(invoice: @invoice).public_send(@mailer_action).deliver_now
@invoice.update!(status: "sent", emailed_at: Time.current) @invoice.update!(status: "sent", emailed_at: Time.current)
@invoice @invoice
@@ -0,0 +1,95 @@
module Billing
class RequestBankTransfer
class Error < StandardError; end
PLAN_SLUGS = %w[premium_light premium_full].freeze
def self.call(club:, user:, plan_slug:, interval:)
new(club: club, user: user, plan_slug: plan_slug, interval: interval).call
end
def initialize(club:, user:, plan_slug:, interval:)
@club = club
@user = user
@plan_slug = plan_slug.to_s
@interval = interval
end
def call
raise Error, "Bonifico non configurato sul server" unless MatchLiveTv.bank_transfer_configured?
raise Error, "Piano non valido" unless @plan_slug.in?(PLAN_SLUGS)
raise Error, "Completa i dati di fatturazione prima di richiedere il bonifico." unless @club.billing_profile_complete?
raise Error, "Il piano è un abbonamento omaggio. Contatta il supporto per passarlo a pagamento." if @club.subscription&.admin_comped?
@interval = Billing::Stripe::PriceCatalog.normalize_interval(@interval)
quote = @club.active_billing_quote
amount_cents, kind = resolve_amount(quote)
existing = @club.billing_transfer_orders.awaiting_payment.first
if existing
if existing.plan_slug == @plan_slug && existing.billing_interval == @interval && existing.amount_cents == amount_cents
BankTransferMailer.with(order: existing).instructions.deliver_now
return existing
end
existing.cancel!
end
order = nil
ApplicationRecord.transaction do
payment = @club.billing_payments.create!(
provider: "bank_transfer",
amount_cents: amount_cents,
currency: "eur",
status: "pending",
plan_slug: @plan_slug,
description: payment_description(kind, amount_cents)
)
order = @club.billing_transfer_orders.create!(
billing_club_quote: kind == "commercial_quote" ? quote : nil,
billing_payment: payment,
plan_slug: @plan_slug,
billing_interval: @interval,
amount_cents: amount_cents,
currency: "eur",
kind: kind,
status: "awaiting_payment",
reference_code: generate_reference_code,
requested_by_user: @user
)
end
BankTransferMailer.with(order: order).instructions.deliver_now
order
end
private
def resolve_amount(quote)
if quote
unless quote.matches?(@plan_slug, @interval)
raise Error, "Per questa società è attivo un prezzo concordato su #{quote.plan.name} (#{quote.price_label})."
end
return [quote.amount_cents, "commercial_quote"]
end
[Billing::Stripe::PriceCatalog.amount_cents(plan_slug: @plan_slug, interval: @interval), "list_price"]
end
def payment_description(kind, amount_cents)
label = Billing::Stripe::PriceCatalog.format_interval_price(amount_cents, @interval)
suffix = kind == "commercial_quote" ? "prezzo concordato, bonifico" : "bonifico"
"#{Plan[@plan_slug].name}#{label} (#{suffix})"
end
def generate_reference_code
8.times do
code = "MLTV-#{SecureRandom.alphanumeric(6).upcase}"
return code unless TransferOrder.exists?(reference_code: code)
end
raise Error, "Impossibile generare il riferimento del bonifico"
end
end
end
@@ -0,0 +1,74 @@
module Billing
class SetClubQuote
class Error < StandardError; end
PLAN_SLUGS = %w[premium_light premium_full].freeze
def self.upsert(club:, plan_slug:, interval:, amount_euros:, note:, admin:)
new(club: club, plan_slug: plan_slug, interval: interval, amount_euros: amount_euros, note: note, admin: admin).upsert
end
def self.revoke(club:, admin:)
new(club: club, admin: admin).revoke
end
def initialize(club:, plan_slug: nil, interval: nil, amount_euros: nil, note: nil, admin: nil)
@club = club
@plan_slug = plan_slug.to_s.presence
@interval = interval
@amount_euros = amount_euros
@note = note.to_s.strip.presence
@admin = admin
end
def upsert
raise Error, "Piano non valido" unless @plan_slug.in?(PLAN_SLUGS)
interval = Billing::Stripe::PriceCatalog.normalize_interval(@interval)
amount_cents = EuroAmount.to_cents(@amount_euros)
quote = @club.billing_quotes.active.first || @club.billing_quotes.build
ApplicationRecord.transaction do
quote.assign_attributes(
plan_slug: @plan_slug,
billing_interval: interval,
amount_cents: amount_cents,
currency: "eur",
note: @note,
active: true,
created_by_admin: @admin || quote.created_by_admin
)
quote.save!
cancel_incompatible_orders!(quote)
end
quote
rescue EuroAmount::Error, ArgumentError => e
raise Error, e.message
end
def revoke
quote = @club.active_billing_quote
raise Error, "Nessun prezzo concordato attivo" if quote.blank?
ApplicationRecord.transaction do
quote.update!(active: false)
@club.billing_transfer_orders.awaiting_payment.where(kind: "commercial_quote").find_each(&:cancel!)
end
quote
end
private
def cancel_incompatible_orders!(quote)
@club.billing_transfer_orders.awaiting_payment.find_each do |order|
next if order.plan_slug == quote.plan_slug &&
order.billing_interval == quote.billing_interval &&
order.amount_cents == quote.amount_cents
order.cancel!
end
end
end
end
@@ -86,6 +86,24 @@ module Billing
end end
end end
def catalog_intervals(plan_slug:)
INTERVALS.select { |interval| AMOUNTS.dig(plan_slug.to_s, interval, :charge).present? }
end
def amount_cents(plan_slug:, interval:)
interval = normalize_interval(interval)
cents = charge_cents(plan_slug, interval)
raise ArgumentError, "Prezzo listino non disponibile per #{plan_slug} (#{interval})" if cents.blank?
cents
end
def format_interval_price(cents, interval)
return nil if cents.blank?
I18n.t("billing.prices.#{interval}", amount: format_eur(cents))
end
private private
def charge_cents(plan_slug, interval) def charge_cents(plan_slug, interval)
@@ -96,12 +114,6 @@ module Billing
AMOUNTS.dig(plan_slug.to_s, interval, :list) AMOUNTS.dig(plan_slug.to_s, interval, :list)
end end
def format_interval_price(cents, interval)
return nil if cents.blank?
I18n.t("billing.prices.#{interval}", amount: format_eur(cents))
end
def price_id_for(plan_slug, interval) def price_id_for(plan_slug, interval)
case [plan_slug, interval] case [plan_slug, interval]
when %w[premium_light monthly] when %w[premium_light monthly]
+58 -23
View File
@@ -35,7 +35,26 @@ module Streams
assigned_node_slug(session) == local_node_slug assigned_node_slug(session) == local_node_slug
end end
# Home può avviare ffmpeg sull'agent del CPX (niente worker Docker per nodo).
def can_process?(session)
return false unless worker?
return true if node_matches?(session)
cloud_home_dispatch?(session)
end
def cloud_home_dispatch?(session)
local_node_slug == NodeRegistry::HOME_SLUG &&
session.stream_node&.role == "cloud" &&
session.stream_node.relay_agent_url.present?
end
def queue_for(session) def queue_for(session)
node = session.stream_node
if node&.role == "cloud" && node.relay_agent_url.present?
return "#{QUEUE_PREFIX}_cloud"
end
"#{QUEUE_PREFIX}_#{assigned_node_slug(session)}" "#{QUEUE_PREFIX}_#{assigned_node_slug(session)}"
end end
@@ -50,14 +69,14 @@ module Streams
end end
def start(session) def start(session)
return enqueue_ensure!(session) unless worker? && node_matches?(session) return enqueue_ensure!(session) unless can_process?(session)
start_on_worker!(session) start_on_worker!(session)
end end
# Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg. # Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg.
def stop(session) def stop(session)
if worker? && owner_is_local?(session.id) && node_matches?(session) if worker? && owner_is_local?(session.id) && can_process?(session)
stop_on_worker!(session) stop_on_worker!(session)
else else
enqueue_stop!(session) enqueue_stop!(session)
@@ -83,7 +102,7 @@ module Streams
return if session.terminal? return if session.terminal?
return if session.stream_key.blank? return if session.stream_key.blank?
if worker? && node_matches?(session) if can_process?(session)
ensure_on_worker!(session) ensure_on_worker!(session)
else else
enqueue_ensure!(session) enqueue_ensure!(session)
@@ -92,7 +111,7 @@ module Streams
def ensure_on_worker!(session) def ensure_on_worker!(session)
return :not_worker unless worker? return :not_worker unless worker?
unless node_matches?(session) unless can_process?(session)
enqueue_ensure!(session, wait: 2.seconds) enqueue_ensure!(session, wait: 2.seconds)
Rails.logger.info( Rails.logger.info(
"[YoutubeRelay] wrong_node local=#{local_node_slug} assigned=#{assigned_node_slug(session)} session=#{session.id}" "[YoutubeRelay] wrong_node local=#{local_node_slug} assigned=#{assigned_node_slug(session)} session=#{session.id}"
@@ -115,7 +134,7 @@ module Streams
return :already_running return :already_running
end end
if at_capacity? if at_capacity_for?(session)
enqueue_ensure!(session, wait: 5.seconds) enqueue_ensure!(session, wait: 5.seconds)
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} node=#{local_node_slug} session=#{session.id} requeue") Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} node=#{local_node_slug} session=#{session.id} requeue")
return :at_capacity return :at_capacity
@@ -129,6 +148,7 @@ module Streams
:started :started
rescue Error => e rescue Error => e
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}") Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
enqueue_ensure!(session, wait: 5.seconds) unless session.terminal?
:error :error
end end
@@ -161,6 +181,13 @@ module Streams
local_owned_count >= max_concurrent local_owned_count >= max_concurrent
end end
# I relay sull'agent CPX non consumano ffmpeg locale: non applicare il cap del worker home.
def at_capacity_for?(session)
return false if cloud_home_dispatch?(session)
at_capacity?
end
def owner_is_local?(session_id) def owner_is_local?(session_id)
owner = redis.get(format(OWNER_KEY, session_id)) owner = redis.get(format(OWNER_KEY, session_id))
owner.blank? || owner == worker_id owner.blank? || owner == worker_id
@@ -188,7 +215,7 @@ module Streams
intake_source = mediamtx_intake_source(session) intake_source = mediamtx_intake_source(session)
output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}" output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
pid = if relay_agent_enabled? pid = if agent_dispatch?(session)
start_via_agent!(session) start_via_agent!(session)
else else
spawn_ffmpeg!(youtube_ffmpeg_args(intake_source, output), log_path) spawn_ffmpeg!(youtube_ffmpeg_args(intake_source, output), log_path)
@@ -197,7 +224,7 @@ module Streams
claim_ownership!(session.id) claim_ownership!(session.id)
Rails.logger.info( Rails.logger.info(
"[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(':')} " \ "[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(':')} " \
"worker=#{worker_id} agent=#{relay_agent_url || '-'}" "worker=#{worker_id} agent=#{agent_base_url(session) || '-'}"
) )
schedule_youtube_activate(session) schedule_youtube_activate(session)
pid pid
@@ -205,16 +232,18 @@ module Streams
raise Error, "ffmpeg non disponibile: #{e.message}" raise Error, "ffmpeg non disponibile: #{e.message}"
end end
def relay_agent_url def agent_base_url(session)
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence return if session.blank?
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence || session.stream_node&.relay_agent_url
end end
def relay_agent_enabled? def agent_dispatch?(session)
relay_agent_url.present? agent_base_url(session).present?
end end
def relay_agent_secret def relay_agent_secret
ENV["STREAM_NODE_AGENT_SECRET"].presence || ENV["MEDIAMTX_WEBHOOK_SECRET"].presence || "" ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
end end
def spawn_ffmpeg!(args, log_path) def spawn_ffmpeg!(args, log_path)
@@ -229,19 +258,23 @@ module Streams
"path" => session.mediamtx_path_name, "path" => session.mediamtx_path_name,
"rtmps" => "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}" "rtmps" => "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
} }
res = agent_request(Net::HTTP::Post, "/relays", payload) res = agent_request(Net::HTTP::Post, "/relays", payload, session: session)
pid = res["pid"].to_i pid = res["pid"].to_i
raise Error, "relay agent spawn failed: #{res.inspect}" if pid <= 0 raise Error, "relay agent spawn failed: #{res.inspect}" if pid <= 0
File.write( File.write(
log_file(session), log_file(session),
"agent=#{relay_agent_url} pid=#{pid} path=#{session.mediamtx_path_name}\n" "agent=#{agent_base_url(session)} pid=#{pid} path=#{session.mediamtx_path_name}\n"
) )
pid pid
end end
def agent_request(http_class, path, payload = nil) def agent_request(http_class, path, payload = nil, session: nil, session_id: nil)
uri = URI.parse("#{relay_agent_url.chomp('/')}#{path}") session ||= StreamSession.find_by(id: session_id) if session_id.present?
base = agent_base_url(session)
raise Error, "relay agent URL mancante" if base.blank?
uri = URI.parse("#{base.chomp('/')}#{path}")
http = Net::HTTP.new(uri.host, uri.port) http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5 http.open_timeout = 5
http.read_timeout = 10 http.read_timeout = 10
@@ -304,7 +337,7 @@ module Streams
# Sul nodo assegnato ffmpeg legge MediaMTX in loopback (o hostname Docker locale). # Sul nodo assegnato ffmpeg legge MediaMTX in loopback (o hostname Docker locale).
def intake_bases(session) def intake_bases(session)
if relay_agent_enabled? && node_matches?(session) if agent_dispatch?(session) && (node_matches?(session) || cloud_home_dispatch?(session))
return ["rtmp://127.0.0.1:1935", "http://127.0.0.1:8888"] return ["rtmp://127.0.0.1:1935", "http://127.0.0.1:8888"]
end end
@@ -376,7 +409,8 @@ module Streams
end end
def process_alive?(pid, session_id: nil) def process_alive?(pid, session_id: nil)
return agent_session_running?(session_id) if relay_agent_enabled? && session_id.present? session = session_id.present? ? StreamSession.find_by(id: session_id) : nil
return agent_session_running?(session) if session && agent_dispatch?(session)
stat = File.read("/proc/#{pid.to_i}/stat") stat = File.read("/proc/#{pid.to_i}/stat")
return false if stat.split[2] == "Z" return false if stat.split[2] == "Z"
@@ -387,16 +421,17 @@ module Streams
false false
end end
def agent_session_running?(session_id) def agent_session_running?(session)
res = agent_request(Net::HTTP::Get, "/relays/#{session_id}") res = agent_request(Net::HTTP::Get, "/relays/#{session.id}", session: session)
res["running"] == true res["running"] == true
rescue Error rescue Error
false false
end end
def terminate_pid(pid, session_id: nil) def terminate_pid(pid, session_id: nil)
if relay_agent_enabled? && session_id.present? session = session_id.present? ? StreamSession.find_by(id: session_id) : nil
agent_request(Net::HTTP::Delete, "/relays/#{session_id}") if session && agent_dispatch?(session)
agent_request(Net::HTTP::Delete, "/relays/#{session_id}", session: session)
return return
end end
@@ -405,7 +440,7 @@ module Streams
rescue Errno::ESRCH rescue Errno::ESRCH
nil nil
else else
return if relay_agent_enabled? return if session && agent_dispatch?(session)
begin begin
Process.kill("KILL", -pid.to_i) Process.kill("KILL", -pid.to_i)
@@ -18,6 +18,52 @@
club: @filter_club, club: @filter_club,
subscription: @filter_club.subscription, subscription: @filter_club.subscription,
return_to: admin_billing_path(club_id: @filter_club.id) %> return_to: admin_billing_path(club_id: @filter_club.id) %>
<%= render "admin/clubs/quote_form",
club: @filter_club,
quote: @filter_club.active_billing_quote,
return_to: admin_billing_path(club_id: @filter_club.id) %>
<% end %>
<h2 style="margin-top:8px;font-size:1.15rem"><%= t("admin.billing.index.transfers_title") %></h2>
<p style="color:#666;margin-bottom:16px"><%= t("admin.billing.index.transfers_description") %></p>
<% if @pending_transfers.any? %>
<div class="billing-pending-list" style="margin-bottom:32px">
<% @pending_transfers.each do |order| %>
<article id="transfer-<%= order.id %>" class="billing-pending-card">
<header class="billing-pending-card__head">
<div>
<strong><%= order.club.name %></strong>
· <%= order.plan.name %>
· <%= t("admin.billing.index.kind_#{order.quoted? ? 'quote' : 'list'}") %>
· <strong><%= order.formatted_amount %></strong>
· <%= order.created_at.to_date.strftime("%d/%m/%Y") %>
</div>
</header>
<div class="billing-pending-card__body">
<p style="margin:0 0 12px">
<%= t("admin.billing.index.transfers_table.causal") %>:
<strong><%= order.payment_causal %></strong>
</p>
<%= form_with url: admin_billing_transfer_confirm_path(order),
method: :post, multipart: true, local: true, class: "billing-upload-form" do %>
<label class="billing-upload-form__label">
<%= t("admin.billing.index.pdf_optional") %>
<%= file_field_tag :pdf, accept: "application/pdf" %>
</label>
<%= submit_tag t("admin.billing.index.confirm_button"), class: "admin-btn admin-btn--primary" %>
<% end %>
<%= button_to t("admin.billing.index.cancel_button"),
admin_billing_transfer_cancel_path(order),
method: :post,
class: "admin-btn admin-btn--secondary",
form: { style: "margin-top:8px", data: { turbo_confirm: t("admin.billing.index.cancel_confirm") } } %>
</div>
</article>
<% end %>
</div>
<% else %>
<p class="muted" style="margin-bottom:28px"><%= t("admin.billing.index.transfers_none") %></p>
<% end %> <% end %>
<% if @pending_payments.any? %> <% if @pending_payments.any? %>
@@ -0,0 +1,71 @@
<%# locals: (club:, quote:, return_to: nil) %>
<section class="admin-comped-card" style="margin-bottom:24px;padding:16px;border:1px solid #2e5a3c;border-radius:8px;background:#142018">
<h2 style="margin:0 0 8px;font-size:1.1rem"><%= t("admin.quote.title") %></h2>
<p style="color:#bbb;font-size:0.9rem;margin:0 0 14px">
<%= t("admin.quote.description") %>
</p>
<% if quote %>
<p style="margin:0 0 12px">
<%= raw t("admin.quote.active_html",
plan: quote.plan.name,
interval: t("admin.quote.interval_#{quote.billing_interval}"),
amount: quote.formatted_amount) %>
<% if quote.note.present? %>
<br><span style="color:#888;font-size:0.85rem"><%= t("admin.quote.note_label") %> <%= quote.note %></span>
<% end %>
</p>
<% else %>
<p style="margin:0 0 12px;color:#888"><%= t("admin.quote.none") %></p>
<% end %>
<%= form_with url: set_quote_admin_club_path(club), method: :post, local: true do %>
<%= hidden_field_tag :return_to, return_to if return_to.present? %>
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end">
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
<%= t("admin.quote.plan_label") %>
<%= select_tag :plan_slug,
options_for_select(
[["Premium Light", "premium_light"], ["Premium Full", "premium_full"]],
quote&.plan_slug
),
required: true,
class: "admin-input" %>
</label>
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
<%= t("admin.quote.interval_label") %>
<%= select_tag :interval,
options_for_select(
[[t("admin.quote.interval_yearly"), "yearly"], [t("admin.quote.interval_monthly"), "monthly"]],
quote&.billing_interval || "yearly"
),
required: true,
class: "admin-input" %>
</label>
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
<%= t("admin.quote.amount_label") %>
<%= text_field_tag :amount_euros,
quote ? format("%.2f", quote.amount_cents / 100.0) : nil,
placeholder: t("admin.quote.amount_placeholder"),
required: true,
class: "admin-input",
inputmode: "decimal" %>
</label>
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem;flex:1;min-width:200px">
<%= t("admin.quote.commercial_note_label") %>
<%= text_field_tag :note, quote&.note, class: "admin-input", style: "width:100%" %>
</label>
<%= submit_tag t("admin.quote.save_button"), class: "admin-btn admin-btn--primary" %>
</div>
<% end %>
<% if quote %>
<div style="margin-top:12px">
<%= button_to t("admin.quote.revoke_button"),
revoke_quote_admin_club_path(club, return_to: return_to),
method: :delete,
class: "admin-btn admin-btn--secondary",
form: { data: { turbo_confirm: t("admin.quote.revoke_confirm", club: club.name) } } %>
</div>
<% end %>
</section>
@@ -11,6 +11,7 @@
<th><%= t("admin.clubs.index.table.teams") %></th> <th><%= t("admin.clubs.index.table.teams") %></th>
<th><%= t("admin.clubs.index.table.comped") %></th> <th><%= t("admin.clubs.index.table.comped") %></th>
<th><%= t("admin.clubs.index.table.stripe") %></th> <th><%= t("admin.clubs.index.table.stripe") %></th>
<th><%= t("admin.clubs.index.table.quote") %></th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -30,6 +31,14 @@
<% end %> <% end %>
</td> </td>
<td><%= sub&.stripe_subscription_id.present? ? t("admin.common.yes") : t("admin.common.dash") %></td> <td><%= sub&.stripe_subscription_id.present? ? t("admin.common.yes") : t("admin.common.dash") %></td>
<td>
<% quote = club.billing_quote %>
<% if quote %>
<%= quote.plan.name %> · <%= quote.formatted_amount %>
<% else %>
<%= t("admin.common.dash") %>
<% end %>
</td>
<td> <td>
<%= link_to t("admin.clubs.index.manage"), admin_club_path(club) %> <%= link_to t("admin.clubs.index.manage"), admin_club_path(club) %>
· <%= link_to t("admin.clubs.index.invoices"), admin_billing_path(club_id: club.id) %> · <%= link_to t("admin.clubs.index.invoices"), admin_billing_path(club_id: club.id) %>
@@ -9,6 +9,7 @@
</p> </p>
<%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %> <%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %>
<%= render "admin/clubs/quote_form", club: @club, quote: @quote, return_to: admin_club_path(@club) %>
<% cred = @club.youtube_credential %> <% cred = @club.youtube_credential %>
<p style="margin-top:16px"> <p style="margin-top:16px">
@@ -0,0 +1,25 @@
<p><%= t("mailers.bank_transfer.instructions.hello") %></p>
<p><%= raw t("mailers.bank_transfer.instructions.intro_html",
club: @club.billing_legal_name.presence || @club.name,
plan: @order.plan.name,
price: @order.price_label) %></p>
<p><%= t("mailers.bank_transfer.instructions.not_active_yet") %></p>
<p>
<strong><%= t("mailers.bank_transfer.instructions.iban_label") %></strong> <%= @iban %><br>
<strong><%= t("mailers.bank_transfer.instructions.holder_label") %></strong> <%= @holder %><br>
<% if @bank_name.present? %>
<strong><%= t("mailers.bank_transfer.instructions.bank_label") %></strong> <%= @bank_name %><br>
<% end %>
<% if @bic.present? %>
<strong><%= t("mailers.bank_transfer.instructions.bic_label") %></strong> <%= @bic %><br>
<% end %>
<strong><%= t("mailers.bank_transfer.instructions.amount_label") %></strong> <%= @order.formatted_amount %><br>
<strong><%= t("mailers.bank_transfer.instructions.causal_label") %></strong> <%= @order.payment_causal %>
</p>
<p><%= raw t("mailers.bank_transfer.instructions.proof_html", email: @proof_email) %></p>
<p><%= t("mailers.bank_transfer.thanks") %><br><%= t("mailers.bank_transfer.signoff") %></p>
@@ -0,0 +1,20 @@
<%= t("mailers.bank_transfer.instructions.hello") %>
<%= t("mailers.bank_transfer.instructions.intro_text",
club: @club.billing_legal_name.presence || @club.name,
plan: @order.plan.name,
price: @order.price_label) %>
<%= t("mailers.bank_transfer.instructions.not_active_yet") %>
<%= t("mailers.bank_transfer.instructions.iban_label") %> <%= @iban %>
<%= t("mailers.bank_transfer.instructions.holder_label") %> <%= @holder %>
<% if @bank_name.present? %><%= t("mailers.bank_transfer.instructions.bank_label") %> <%= @bank_name %>
<% end %><% if @bic.present? %><%= t("mailers.bank_transfer.instructions.bic_label") %> <%= @bic %>
<% end %><%= t("mailers.bank_transfer.instructions.amount_label") %> <%= @order.formatted_amount %>
<%= t("mailers.bank_transfer.instructions.causal_label") %> <%= @order.payment_causal %>
<%= t("mailers.bank_transfer.instructions.proof_text", email: @proof_email) %>
<%= t("mailers.bank_transfer.thanks") %>
<%= t("mailers.bank_transfer.signoff") %>
@@ -0,0 +1,10 @@
<p><%= t("mailers.bank_transfer.plan_activated.hello") %></p>
<p><%= raw t("mailers.bank_transfer.plan_activated.body_html",
plan: @order.plan.name,
club: @club.billing_legal_name.presence || @club.name,
until_date: @subscription&.current_period_end ? l(@subscription.current_period_end.to_date, format: :long) : t("mailers.bank_transfer.plan_activated.period_fallback")) %></p>
<p><%= t("mailers.bank_transfer.plan_activated.invoice_later") %></p>
<p><%= t("mailers.bank_transfer.thanks") %><br><%= t("mailers.bank_transfer.signoff") %></p>
@@ -0,0 +1,11 @@
<%= t("mailers.bank_transfer.plan_activated.hello") %>
<%= t("mailers.bank_transfer.plan_activated.body_text",
plan: @order.plan.name,
club: @club.billing_legal_name.presence || @club.name,
until_date: @subscription&.current_period_end ? l(@subscription.current_period_end.to_date, format: :long) : t("mailers.bank_transfer.plan_activated.period_fallback")) %>
<%= t("mailers.bank_transfer.plan_activated.invoice_later") %>
<%= t("mailers.bank_transfer.thanks") %>
<%= t("mailers.bank_transfer.signoff") %>
@@ -0,0 +1,12 @@
<p><%= t("mailers.invoice_activated.hello") %></p>
<p><%= raw t("mailers.invoice_activated.body_html",
plan: @invoice.club.subscription&.plan&.name || @invoice.billing_payment&.plan_slug.to_s.humanize,
club: @club.billing_legal_name.presence || @club.name,
until_date: @invoice.club.subscription&.current_period_end ? l(@invoice.club.subscription.current_period_end.to_date, format: :long) : t("mailers.invoice_activated.period_fallback"),
number: @invoice.display_number,
amount: @invoice.formatted_amount) %></p>
<p><%= raw t("mailers.invoice.also_html") %></p>
<p><%= t("mailers.invoice.thanks") %><br><%= t("mailers.invoice.signoff") %></p>
@@ -0,0 +1,13 @@
<%= t("mailers.invoice_activated.hello") %>
<%= t("mailers.invoice_activated.body_text",
plan: @invoice.club.subscription&.plan&.name || @invoice.billing_payment&.plan_slug.to_s.humanize,
club: @club.billing_legal_name.presence || @club.name,
until_date: @invoice.club.subscription&.current_period_end ? l(@invoice.club.subscription.current_period_end.to_date, format: :long) : t("mailers.invoice_activated.period_fallback"),
number: @invoice.display_number,
amount: @invoice.formatted_amount) %>
<%= t("mailers.invoice.also_text") %>
<%= t("mailers.invoice.thanks") %>
<%= t("mailers.invoice.signoff") %>
@@ -17,11 +17,19 @@
</p> </p>
</div> </div>
<% else %> <% else %>
<% if @quote %>
<%= render "shared/quoted_price_banner", quote: @quote %>
<% elsif MatchLiveTv.stripe_enabled? %>
<%= render "shared/stripe_secure_payment" %> <%= render "shared/stripe_secure_payment" %>
<% if MatchLiveTv.stripe_enabled? %> <% end %>
<%= render "shared/pending_bank_transfer", order: @pending_transfer %>
<% if MatchLiveTv.stripe_enabled? && @quote.blank? %>
<%= render "shared/plan_change_info", subscription: @subscription %> <%= render "shared/plan_change_info", subscription: @subscription %>
<% end %> <% end %>
<%= render "shared/plan_cards", show_stripe_portal: false %> <%= render "shared/plan_cards", show_stripe_portal: false %>
<% if @subscription&.bank_transfer? %>
<p class="muted" style="margin-top:16px"><%= raw t("billing.bank_transfer.renewal_html") %></p>
<% end %>
<%= render "shared/subscription_cancel", club: @club, subscription: @subscription %> <%= render "shared/subscription_cancel", club: @club, subscription: @subscription %>
<% end %> <% end %>
@@ -33,4 +33,8 @@
<% end %> <% end %>
<% end %> <% end %>
</p> </p>
<% elsif subscription&.bank_transfer? %>
<p style="color:#888;font-size:0.9rem;margin-top:0">
<%= raw t("billing.subscription_status.bank_transfer_until_html", date: l_local(subscription.current_period_end.to_date)) %>
</p>
<% end %> <% end %>
@@ -0,0 +1,15 @@
<%# locals: (order:) %>
<% return if order.blank? %>
<div class="card" style="margin-top:16px;border-color:#3d3520;background:#1a1810">
<p style="margin:0 0 8px;color:#ddd">
<strong><%= t("billing.bank_transfer.pending_title") %></strong>
<%= raw t("billing.bank_transfer.pending_body_html",
plan: order.plan.name,
price: order.price_label,
causal: order.payment_causal) %>
</p>
<p style="margin:0;color:#aaa;font-size:0.9rem">
<%= raw t("billing.bank_transfer.pending_proof_html",
email_link: mail_to(MatchLiveTv.bank_transfer_proof_email, MatchLiveTv.bank_transfer_proof_email)) %>
</p>
</div>
+32 -3
View File
@@ -13,7 +13,8 @@
target_plan: plan, target_plan: plan,
stripe_subscription_active: stripe_subscription_active?(subscription), stripe_subscription_active: stripe_subscription_active?(subscription),
current_interval: current_interval, current_interval: current_interval,
subscription: subscription subscription: subscription,
club: club
) : nil %> ) : nil %>
<% featured = plan.slug == "premium_full" %> <% featured = plan.slug == "premium_full" %>
<div class="plan-card<%= " plan-card--featured" if featured %>"> <div class="plan-card<%= " plan-card--featured" if featured %>">
@@ -23,6 +24,13 @@
<%= render "shared/plan_title", plan: plan %> <%= render "shared/plan_title", plan: plan %>
<% if plan.slug == "free" %> <% if plan.slug == "free" %>
<div class="plan-price">€0</div> <div class="plan-price">€0</div>
<% elsif billing_mode && club&.active_billing_quote&.plan_slug == plan.slug %>
<% quote = club.active_billing_quote %>
<div class="plan-price-block">
<div class="plan-price<%= " plan-price--featured" if featured %>"><%= quote.formatted_amount %></div>
<span class="plan-price-badge"><%= t("billing.bank_transfer.quoted_badge") %></span>
<p class="plan-price-equiv"><%= t("billing.bank_transfer.quoted_interval.#{quote.billing_interval}") %></p>
</div>
<% else %> <% else %>
<% yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %> <% yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
<% monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %> <% monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
@@ -63,12 +71,14 @@
<% end %> <% end %>
<% if billing_mode %> <% if billing_mode %>
<% action_kind = action[:kind] %> <% action_kind = action[:kind] %>
<% quote = club&.active_billing_quote %>
<% pending_transfer = club&.pending_transfer_order %>
<% if action_kind == :current %> <% if action_kind == :current %>
<span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span> <span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span>
<% elsif action_kind == :none %> <% elsif action_kind == :none %>
<% elsif action_kind.in?(%i[contact disabled]) %> <% elsif action_kind.in?(%i[contact disabled quoted_other]) %>
<p class="plan-action-hint"><%= action[:label] %></p> <p class="plan-action-hint"><%= action[:label] %></p>
<% elsif action_kind == :checkout_options || action_kind == :change_options || action_kind == :interval_switch %> <% elsif action_kind.in?(%i[checkout_options change_options interval_switch quoted bank_only]) %>
<% if billing_profile_blocks_premium?(club) %> <% if billing_profile_blocks_premium?(club) %>
<p class="plan-action-hint" style="color:#e53935;margin-bottom:10px"> <p class="plan-action-hint" style="color:#e53935;margin-bottom:10px">
<%= billing_profile_incomplete_message(club) %> <%= billing_profile_incomplete_message(club) %>
@@ -77,6 +87,7 @@
public_club_billing_profile_path(club, plan: plan.slug), public_club_billing_profile_path(club, plan: plan.slug),
class: "btn btn-primary" %> class: "btn btn-primary" %>
<% else %> <% else %>
<% unless action_kind.in?(%i[quoted bank_only]) %>
<div class="plan-interval-actions"> <div class="plan-interval-actions">
<% plan_intervals_for_display(action[:intervals]).each do |interval| %> <% plan_intervals_for_display(action[:intervals]).each do |interval| %>
<% btn_kind = action_kind == :checkout_options ? :checkout_options : action_kind %> <% btn_kind = action_kind == :checkout_options ? :checkout_options : action_kind %>
@@ -86,6 +97,24 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% transfer_intervals = action_kind == :quoted ? action[:intervals] : (action[:intervals].presence || bank_transfer_intervals_for(plan)) %>
<% if MatchLiveTv.bank_transfer_configured? && transfer_intervals.any? %>
<% unless action_kind.in?(%i[quoted bank_only]) %>
<p class="plan-action-hint" style="margin:10px 0 6px"><%= t("billing.bank_transfer.or_label") %></p>
<% end %>
<div class="plan-interval-actions">
<% plan_intervals_for_display(transfer_intervals).each do |interval| %>
<% next unless show_bank_transfer_for?(club: club, plan: plan, interval: interval, quote: quote, pending_transfer: pending_transfer) %>
<%= button_to t("billing.bank_transfer.pay_button_with_price", price: bank_transfer_price_label(plan, interval, quote: quote)),
public_club_billing_bank_transfer_path(club, plan: plan.slug, interval: interval),
method: :post,
class: "btn btn-outline plan-interval-btn",
form: { data: { turbo_confirm: t("billing.bank_transfer.hint") } } %>
<% end %>
</div>
<p class="plan-action-hint" style="margin-top:8px"><%= t("billing.bank_transfer.hint") %></p>
<% end %>
<% end %>
<% end %> <% end %>
<% elsif plan.slug == "free" %> <% elsif plan.slug == "free" %>
<%= link_to t("pages.plans.start_free"), public_signup_path, class: "btn btn-secondary" %> <%= link_to t("pages.plans.start_free"), public_signup_path, class: "btn btn-secondary" %>
@@ -0,0 +1,11 @@
<%# locals: (quote:) %>
<% return if quote.blank? %>
<div class="card" style="margin-top:16px;border-color:#2e5a3c;background:#142018">
<p style="margin:0;color:#ddd">
<strong><%= t("billing.bank_transfer.quote_banner_title") %></strong>
<%= raw t("billing.bank_transfer.quote_banner_body_html", plan: quote.plan.name, price: quote.price_label) %>
</p>
<% if quote.note.present? %>
<p style="margin:8px 0 0;color:#aaa;font-size:0.9rem"><%= t("billing.bank_transfer.quote_note", note: quote.note) %></p>
<% end %>
</div>
@@ -1,5 +1,5 @@
<%# locals: (club:, subscription:) %> <%# locals: (club:, subscription:) %>
<% return unless subscription&.premium? && subscription.stripe_subscription_id.present? && MatchLiveTv.stripe_enabled? %> <% return unless subscription&.premium? && MatchLiveTv.stripe_enabled? && subscription.stripe_subscription_id.present? %>
<section id="subscription-cancel" class="subscription-cancel" style="margin-top:24px;padding:16px;border:1px solid #333;border-radius:8px"> <section id="subscription-cancel" class="subscription-cancel" style="margin-top:24px;padding:16px;border:1px solid #333;border-radius:8px">
<% if subscription.cancel_at_period_end? %> <% if subscription.cancel_at_period_end? %>
@@ -73,6 +73,30 @@ module MatchLiveTv
stripe_secret_key.present? stripe_secret_key.present?
end end
def bank_transfer_iban
ENV["BANK_TRANSFER_IBAN"].to_s.gsub(/\s+/, "").presence
end
def bank_transfer_account_holder
ENV["BANK_TRANSFER_ACCOUNT_HOLDER"].to_s.strip.presence
end
def bank_transfer_bank_name
ENV["BANK_TRANSFER_BANK_NAME"].to_s.strip.presence
end
def bank_transfer_bic
ENV["BANK_TRANSFER_BIC"].to_s.strip.presence
end
def bank_transfer_proof_email
ENV["BANK_TRANSFER_PROOF_EMAIL"].presence || support_email
end
def bank_transfer_configured?
bank_transfer_iban.present? && bank_transfer_account_holder.present?
end
def privacy_controller_name def privacy_controller_name
ENV.fetch("PRIVACY_CONTROLLER_NAME", "Emiliano Frascaro") ENV.fetch("PRIVACY_CONTROLLER_NAME", "Emiliano Frascaro")
end end
+36
View File
@@ -44,6 +44,10 @@ it:
invoice_issued: "Fattura %{number} emessa e inviata a %{email}." invoice_issued: "Fattura %{number} emessa e inviata a %{email}."
invoice_updated: "Fattura %{number} aggiornata." invoice_updated: "Fattura %{number} aggiornata."
invoice_uploaded: "Fattura caricata e inviata a %{email}." invoice_uploaded: "Fattura caricata e inviata a %{email}."
quote_saved: "Prezzo concordato salvato per %{club}: %{plan} a %{amount}."
quote_revoked: "Prezzo concordato revocato per %{club}."
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
transfer_cancelled: Bonifico in attesa annullato.
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env
common: common:
free_plan: Free free_plan: Free
@@ -193,6 +197,7 @@ it:
teams: Squadre teams: Squadre
comped: Omaggio comped: Omaggio
stripe: Stripe stripe: Stripe
quote: Concordato
manage: Gestisci manage: Gestisci
invoices: Fatture invoices: Fatture
show: show:
@@ -228,6 +233,22 @@ it:
reason_label: "Motivo (es. sponsor 2026)" reason_label: "Motivo (es. sponsor 2026)"
reason_placeholder: "Sponsor, promozione…" reason_placeholder: "Sponsor, promozione…"
grant_button: Concedi omaggio grant_button: Concedi omaggio
quote:
title: Prezzo concordato (bonifico)
description: "Importo commerciale per questa società: piano, cadenza e cifra in euro. In billing la società vede solo questo prezzo e può pagare solo con bonifico. L'omaggio (0 €) resta nella card sopra."
none: Nessun prezzo concordato. La società paga a listino (carta o bonifico).
active_html: "<strong>%{plan}</strong> · %{interval} · <strong>%{amount}</strong>"
note_label: "Nota:"
plan_label: Piano
interval_label: Cadenza
interval_yearly: Annuale
interval_monthly: Mensile
amount_label: "Importo (€)"
amount_placeholder: "es. 150 oppure 150,00"
commercial_note_label: Nota commerciale (opzionale)
save_button: Salva prezzo concordato
revoke_button: Revoca prezzo concordato
revoke_confirm: "Revocare il prezzo concordato per %{club}? Tornerà il listino (carta + bonifico)."
teams: teams:
show: show:
back: "← %{club}" back: "← %{club}"
@@ -281,6 +302,21 @@ it:
index: index:
title: Pagamenti da fatturare title: Pagamenti da fatturare
description: "Pagamenti Stripe pagati senza PDF fattura. Genera il PDF nei tuoi sistemi, poi caricalo qui: viene associato al pagamento e inviato via email al cliente." description: "Pagamenti Stripe pagati senza PDF fattura. Genera il PDF nei tuoi sistemi, poi caricalo qui: viene associato al pagamento e inviato via email al cliente."
transfers_title: Bonifici in attesa
transfers_description: "La società ha chiesto di pagare con bonifico: il piano non è ancora attivo. Conferma dopo l'accredito (puoi allegare subito il PDF fattura)."
transfers_none: Nessun bonifico in attesa.
transfers_table:
club: Società
plan: Piano
amount: Importo
causal: Causale
requested: Richiesto
confirm_button: Conferma pagamento e attiva
cancel_button: Annulla richiesta
cancel_confirm: Annullare questa richiesta di bonifico?
pdf_optional: PDF fattura (opzionale)
kind_quote: prezzo concordato
kind_list: listino
filter_label: "Filtra società:" filter_label: "Filtra società:"
filter_all: Tutte filter_all: Tutte
incomplete_profile_warning: Dati fatturazione incompleti incomplete_profile_warning: Dati fatturazione incompleti
+21
View File
@@ -334,6 +334,7 @@ it:
contact_for_free: "Per passare al piano Free, contatta il supporto." contact_for_free: "Per passare al piano Free, contatta il supporto."
stripe_not_configured: Stripe non configurato stripe_not_configured: Stripe non configurato
stripe_prices_not_configured: Prezzi Stripe non configurati stripe_prices_not_configured: Prezzi Stripe non configurati
bank_transfer_not_configured: Bonifico non ancora configurato
complete_billing_prefix: Completa i dati di fatturazione prima di attivare un piano premium. complete_billing_prefix: Completa i dati di fatturazione prima di attivare un piano premium.
profile_incomplete_default: Completa i dati di fatturazione prima di attivare un piano premium. profile_incomplete_default: Completa i dati di fatturazione prima di attivare un piano premium.
profile_missing: "Mancano: %{fields}." profile_missing: "Mancano: %{fields}."
@@ -396,6 +397,7 @@ it:
end_of_period: fine periodo end_of_period: fine periodo
next_renewal: prossimo rinnovo next_renewal: prossimo rinnovo
next_renewal_label: "Prossimo rinnovo: %{date}" next_renewal_label: "Prossimo rinnovo: %{date}"
bank_transfer_until_html: "Pagato con bonifico, valido fino al <strong>%{date}</strong> (nessun rinnovo automatico)."
plan_change_info: plan_change_info:
trigger_label: Info cambio piano e fatturazione trigger_label: Info cambio piano e fatturazione
title: Cambio piano e fatturazione title: Cambio piano e fatturazione
@@ -425,6 +427,23 @@ it:
prices: prices:
yearly: "%{amount}/anno" yearly: "%{amount}/anno"
monthly: "%{amount}/mese" monthly: "%{amount}/mese"
bank_transfer:
quoted_badge: Prezzo concordato
quoted_interval:
yearly: fatturazione annuale, solo bonifico
monthly: fatturazione mensile, solo bonifico
quoted_other: "Per questa società è attivo un prezzo concordato su un altro piano."
pay_button: Paga con bonifico
pay_button_with_price: "Paga con bonifico — %{price}"
or_label: oppure
pending_title: Bonifico in attesa di conferma
pending_body_html: "Hai richiesto <strong>%{plan}</strong> (%{price}). Il piano si attiverà dopo la conferma del pagamento. Causale: <strong>%{causal}</strong>."
pending_proof_html: "Invia la distinta a %{email_link}."
quote_banner_title: Prezzo concordato
quote_banner_body_html: "Per questa società vale <strong>%{plan}</strong> a <strong>%{price}</strong>. Il pagamento avviene solo con bonifico (non con carta)."
quote_note: "Nota commerciale: %{note}"
hint: "Il piano resta quello attuale finché Match Live TV non conferma l'accredito."
renewal_html: "Con il bonifico l'abbonamento non si rinnova da solo: alla scadenza torna Free, salvo nuovo bonifico o rinnovo da parte nostra."
regia: regia:
meta_title_fallback: "Regia — Match Live TV" meta_title_fallback: "Regia — Match Live TV"
title: "Regia — %{home} vs %{away}" title: "Regia — %{home} vs %{away}"
@@ -627,6 +646,8 @@ it:
subscription_canceled: "Abbonamento disdetto. Resta attivo fino al %{date}; da quel giorno passerai al piano Free." subscription_canceled: "Abbonamento disdetto. Resta attivo fino al %{date}; da quel giorno passerai al piano Free."
stripe_error: "Errore Stripe: %{message}" stripe_error: "Errore Stripe: %{message}"
invoice_pdf_unavailable: PDF fattura non disponibile. invoice_pdf_unavailable: PDF fattura non disponibile.
bank_transfer_requested: Istruzioni per il bonifico inviate all'email di fatturazione. Il piano si attiverà dopo la conferma del pagamento.
quote_checkout_denied: "Per questa società è attivo un prezzo concordato: usa il pagamento con bonifico."
club_recordings: club_recordings:
not_authorized: Non autorizzato not_authorized: Non autorizzato
teams: teams:
+31
View File
@@ -54,3 +54,34 @@ en:
also_text: It is also available under Subscription on the Match Live TV site. also_text: It is also available under Subscription on the Match Live TV site.
thanks: Thanks, thanks: Thanks,
signoff: Match Live TV signoff: Match Live TV
invoice_activated:
subject: "Plan %{plan} is active — invoice %{number}"
hello: Hello,
period_fallback: the end of the period
body_html: "we received your bank transfer: the <strong>%{plan}</strong> plan is now active for <strong>%{club}</strong> until <strong>%{until_date}</strong>. Invoice <strong>%{number}</strong> for <strong>%{amount}</strong> is attached."
body_text: "we received your bank transfer: the %{plan} plan is now active for %{club} until %{until_date}. Invoice %{number} for %{amount} is attached."
bank_transfer:
thanks: Thanks,
signoff: Match Live TV
instructions:
subject: "Bank transfer instructions — %{plan}"
hello: Hello,
intro_html: "you requested the <strong>%{plan}</strong> plan (%{price}) for <strong>%{club}</strong> with a bank transfer."
intro_text: "you requested the %{plan} plan (%{price}) for %{club} with a bank transfer."
not_active_yet: The plan will be activated only after Match Live TV confirms the payment. Until then your current plan stays active.
iban_label: "IBAN:"
holder_label: "Account holder:"
bank_label: "Bank:"
bic_label: "BIC/SWIFT:"
amount_label: "Amount:"
causal_label: "Payment reference:"
proof_html: "After paying, send the receipt to <strong>%{email}</strong>."
proof_text: "After paying, send the receipt to %{email}."
plan_activated:
subject: "Plan %{plan} is active — Match Live TV"
hello: Hello,
period_fallback: the end of the period
body_html: "we received your bank transfer: the <strong>%{plan}</strong> plan is now active for <strong>%{club}</strong> until <strong>%{until_date}</strong>. The invoice will follow by email shortly."
body_text: "we received your bank transfer: the %{plan} plan is now active for %{club} until %{until_date}. The invoice will follow by email shortly."
invoice_later: You can already use the plan from the Subscription section of your account.
+31
View File
@@ -54,3 +54,34 @@ it:
also_text: È disponibile anche in Abbonamento sul sito Match Live TV. also_text: È disponibile anche in Abbonamento sul sito Match Live TV.
thanks: Grazie, thanks: Grazie,
signoff: Match Live TV signoff: Match Live TV
invoice_activated:
subject: "Piano %{plan} attivo — fattura %{number}"
hello: Buongiorno,
period_fallback: fine periodo
body_html: "abbiamo ricevuto il bonifico: il piano <strong>%{plan}</strong> è attivo per <strong>%{club}</strong> fino al <strong>%{until_date}</strong>. In allegato la fattura <strong>%{number}</strong> di <strong>%{amount}</strong>."
body_text: "abbiamo ricevuto il bonifico: il piano %{plan} è attivo per %{club} fino al %{until_date}. In allegato la fattura %{number} di %{amount}."
bank_transfer:
thanks: Grazie,
signoff: Match Live TV
instructions:
subject: "Istruzioni per il bonifico — %{plan}"
hello: Buongiorno,
intro_html: "hai richiesto l'abbonamento <strong>%{plan}</strong> (%{price}) per <strong>%{club}</strong> con pagamento tramite bonifico."
intro_text: "hai richiesto l'abbonamento %{plan} (%{price}) per %{club} con pagamento tramite bonifico."
not_active_yet: Il piano verrà attivato solo dopo la conferma del pagamento da parte di Match Live TV. Fino ad allora resta attivo il piano attuale.
iban_label: "IBAN:"
holder_label: "Intestatario:"
bank_label: "Banca:"
bic_label: "BIC/SWIFT:"
amount_label: "Importo:"
causal_label: "Causale:"
proof_html: "Dopo il pagamento invia la distinta o la ricevuta a <strong>%{email}</strong>."
proof_text: "Dopo il pagamento invia la distinta o la ricevuta a %{email}."
plan_activated:
subject: "Piano %{plan} attivo — Match Live TV"
hello: Buongiorno,
period_fallback: fine periodo
body_html: "abbiamo ricevuto il bonifico: il piano <strong>%{plan}</strong> è attivo per <strong>%{club}</strong> fino al <strong>%{until_date}</strong>. La fattura ti arriverà a breve via email."
body_text: "abbiamo ricevuto il bonifico: il piano %{plan} è attivo per %{club} fino al %{until_date}. La fattura ti arriverà a breve via email."
invoice_later: Puoi già usare le funzioni del piano dalla sezione Abbonamento del tuo account.
+5
View File
@@ -93,11 +93,15 @@ Rails.application.routes.draw do
end end
get "billing", to: "billing#index", as: :billing get "billing", to: "billing#index", as: :billing
post "billing/payments/:payment_id/attach_pdf", to: "billing#attach_pdf", as: :billing_payment_attach_pdf post "billing/payments/:payment_id/attach_pdf", to: "billing#attach_pdf", as: :billing_payment_attach_pdf
post "billing/transfers/:id/confirm", to: "billing#confirm_transfer", as: :billing_transfer_confirm
post "billing/transfers/:id/cancel", to: "billing#cancel_transfer", as: :billing_transfer_cancel
resources :teams, only: %i[show] resources :teams, only: %i[show]
resources :clubs, only: %i[index show] do resources :clubs, only: %i[index show] do
member do member do
post :grant_comped post :grant_comped
delete :revoke_comped delete :revoke_comped
post :set_quote
delete :revoke_quote
end end
resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices" resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices"
resources :club_recordings, only: %i[index update destroy], path: "replays", as: :recordings do resources :club_recordings, only: %i[index update destroy], path: "replays", as: :recordings do
@@ -198,6 +202,7 @@ Rails.application.routes.draw do
get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile
patch "clubs/:id/billing/profile", to: "club_billing#update_profile" patch "clubs/:id/billing/profile", to: "club_billing#update_profile"
post "clubs/:id/billing/cancel_subscription", to: "club_billing#cancel_subscription", as: :club_billing_cancel_subscription post "clubs/:id/billing/cancel_subscription", to: "club_billing#cancel_subscription", as: :club_billing_cancel_subscription
post "clubs/:id/billing/bank_transfer", to: "club_billing#request_bank_transfer", as: :club_billing_bank_transfer
get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice
get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout
post "clubs/:id/portal", to: "clubs#portal", as: :club_portal post "clubs/:id/portal", to: "clubs#portal", as: :club_portal
+1
View File
@@ -2,5 +2,6 @@
:queues: :queues:
- critical - critical
- youtube_relay_home - youtube_relay_home
- youtube_relay_cloud
- youtube_relay - youtube_relay
- default - default
@@ -0,0 +1,42 @@
class AddBankTransferBilling < ActiveRecord::Migration[7.2]
def change
create_table :billing_club_quotes, id: :uuid do |t|
t.references :club, null: false, foreign_key: true, type: :uuid, index: false
t.string :plan_slug, null: false
t.string :billing_interval, null: false
t.integer :amount_cents, null: false
t.string :currency, null: false, default: "eur"
t.string :note
t.boolean :active, null: false, default: true
t.references :created_by_admin, foreign_key: { to_table: :admin_accounts }, type: :uuid
t.timestamps
end
add_index :billing_club_quotes, :club_id, unique: true, where: "active = TRUE",
name: "index_billing_club_quotes_active_club"
create_table :billing_transfer_orders, id: :uuid do |t|
t.references :club, null: false, foreign_key: true, type: :uuid
t.references :billing_club_quote, foreign_key: true, type: :uuid
t.references :billing_payment, foreign_key: true, type: :uuid
t.string :plan_slug, null: false
t.string :billing_interval, null: false
t.integer :amount_cents, null: false
t.string :currency, null: false, default: "eur"
t.string :kind, null: false
t.string :status, null: false, default: "awaiting_payment"
t.string :reference_code, null: false
t.references :requested_by_user, foreign_key: { to_table: :users }, type: :uuid
t.references :confirmed_by_admin, foreign_key: { to_table: :admin_accounts }, type: :uuid
t.datetime :confirmed_at
t.datetime :cancelled_at
t.timestamps
end
add_index :billing_transfer_orders, :reference_code, unique: true
add_index :billing_transfer_orders, :status
add_index :billing_transfer_orders, :club_id, unique: true, where: "status = 'awaiting_payment'",
name: "index_billing_transfer_orders_awaiting_club"
add_column :billing_payments, :provider, :string, null: false, default: "stripe"
add_index :billing_payments, :provider
end
end
+52 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -51,6 +51,21 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
t.index ["username"], name: "index_admin_accounts_on_username", unique: true t.index ["username"], name: "index_admin_accounts_on_username", unique: true
end end
create_table "billing_club_quotes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "club_id", null: false
t.string "plan_slug", null: false
t.string "billing_interval", null: false
t.integer "amount_cents", null: false
t.string "currency", default: "eur", null: false
t.string "note"
t.boolean "active", default: true, null: false
t.uuid "created_by_admin_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["club_id"], name: "index_billing_club_quotes_active_club", unique: true, where: "(active = true)"
t.index ["created_by_admin_id"], name: "index_billing_club_quotes_on_created_by_admin_id"
end
create_table "billing_invoices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "billing_invoices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "club_id", null: false t.uuid "club_id", null: false
t.uuid "billing_payment_id" t.uuid "billing_payment_id"
@@ -87,11 +102,40 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
t.string "receipt_url" t.string "receipt_url"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "provider", default: "stripe", null: false
t.index ["club_id", "paid_at"], name: "index_billing_payments_on_club_id_and_paid_at" t.index ["club_id", "paid_at"], name: "index_billing_payments_on_club_id_and_paid_at"
t.index ["club_id"], name: "index_billing_payments_on_club_id" t.index ["club_id"], name: "index_billing_payments_on_club_id"
t.index ["provider"], name: "index_billing_payments_on_provider"
t.index ["stripe_invoice_id"], name: "index_billing_payments_on_stripe_invoice_id", unique: true, where: "(stripe_invoice_id IS NOT NULL)" t.index ["stripe_invoice_id"], name: "index_billing_payments_on_stripe_invoice_id", unique: true, where: "(stripe_invoice_id IS NOT NULL)"
end end
create_table "billing_transfer_orders", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "club_id", null: false
t.uuid "billing_club_quote_id"
t.uuid "billing_payment_id"
t.string "plan_slug", null: false
t.string "billing_interval", null: false
t.integer "amount_cents", null: false
t.string "currency", default: "eur", null: false
t.string "kind", null: false
t.string "status", default: "awaiting_payment", null: false
t.string "reference_code", null: false
t.uuid "requested_by_user_id"
t.uuid "confirmed_by_admin_id"
t.datetime "confirmed_at"
t.datetime "cancelled_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["billing_club_quote_id"], name: "index_billing_transfer_orders_on_billing_club_quote_id"
t.index ["billing_payment_id"], name: "index_billing_transfer_orders_on_billing_payment_id"
t.index ["club_id"], name: "index_billing_transfer_orders_awaiting_club", unique: true, where: "((status)::text = 'awaiting_payment'::text)"
t.index ["club_id"], name: "index_billing_transfer_orders_on_club_id"
t.index ["confirmed_by_admin_id"], name: "index_billing_transfer_orders_on_confirmed_by_admin_id"
t.index ["reference_code"], name: "index_billing_transfer_orders_on_reference_code", unique: true
t.index ["requested_by_user_id"], name: "index_billing_transfer_orders_on_requested_by_user_id"
t.index ["status"], name: "index_billing_transfer_orders_on_status"
end
create_table "club_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "club_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id", null: false t.uuid "user_id", null: false
t.uuid "club_id", null: false t.uuid "club_id", null: false
@@ -427,9 +471,16 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "billing_club_quotes", "admin_accounts", column: "created_by_admin_id"
add_foreign_key "billing_club_quotes", "clubs"
add_foreign_key "billing_invoices", "billing_payments" add_foreign_key "billing_invoices", "billing_payments"
add_foreign_key "billing_invoices", "clubs" add_foreign_key "billing_invoices", "clubs"
add_foreign_key "billing_payments", "clubs" add_foreign_key "billing_payments", "clubs"
add_foreign_key "billing_transfer_orders", "admin_accounts", column: "confirmed_by_admin_id"
add_foreign_key "billing_transfer_orders", "billing_club_quotes"
add_foreign_key "billing_transfer_orders", "billing_payments"
add_foreign_key "billing_transfer_orders", "clubs"
add_foreign_key "billing_transfer_orders", "users", column: "requested_by_user_id"
add_foreign_key "club_memberships", "clubs" add_foreign_key "club_memberships", "clubs"
add_foreign_key "club_memberships", "users" add_foreign_key "club_memberships", "users"
add_foreign_key "device_states", "stream_sessions" add_foreign_key "device_states", "stream_sessions"
@@ -0,0 +1,27 @@
require "rails_helper"
RSpec.describe ExpireEndedSubscriptionsJob do
let(:club) do
c = Club.create!(name: "Expire BT", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
load Rails.root.join("db/seeds/plans.rb")
c
end
it "passa a Free un piano pagato con bonifico scaduto" do
Billing::AssignPlan.call(
club: club,
plan_slug: "premium_light",
status: "active",
stripe_attrs: {
billing_interval: "monthly",
current_period_start: 2.months.ago,
current_period_end: 1.day.ago,
cancel_at_period_end: true
}
)
described_class.new.perform
expect(club.reload.subscription.plan.slug).to eq("free")
end
end
+1 -1
View File
@@ -5,7 +5,7 @@ RSpec.describe Match, "scheduling scopes" do
Club.create!(name: "Sched Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") Club.create!(name: "Sched Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
end end
let!(:team) { club.teams.create!(name: "U16", sport: "volleyball") } let!(:team) { club.teams.create!(name: "U16", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
it "scheduled_for_live esclude partite già passate oggi" do it "scheduled_for_live esclude partite già passate oggi" do
past = team.matches.create!(opponent_name: "Past", scheduled_at: 2.hours.ago, sets_to_win: 3) past = team.matches.create!(opponent_name: "Past", scheduled_at: 2.hours.ago, sets_to_win: 3)
+1 -1
View File
@@ -3,7 +3,7 @@ require "rails_helper"
RSpec.describe Recording do RSpec.describe Recording do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do let!(:session) do
StreamSession.create!( StreamSession.create!(
+4
View File
@@ -9,6 +9,10 @@ RSpec.configure do |config|
config.infer_spec_type_from_file_location! config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace! config.filter_rails_from_backtrace!
config.before(:suite) do
load Rails.root.join("db/seeds/plans.rb")
end
config.before(:each, type: :request) do config.before(:each, type: :request) do
host! "www.example.com" host! "www.example.com"
end end
@@ -0,0 +1,54 @@
require "rails_helper"
RSpec.describe "Admin bank transfer", type: :request do
let!(:admin) { AdminAccount.create!(username: "ops-bt-http", password: "Password123") }
let!(:club) do
c = Club.create!(
name: "Admin BT Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff",
billing_entity_type: "company", billing_legal_name: "ASD Admin BT", billing_email: "adminbt@test.it",
billing_address_line: "Via 1", billing_city: "Milano", billing_province: "MI", billing_postal_code: "20100",
billing_country: "IT", billing_vat_number: "12345678901", billing_recipient_code: "ABCDEFG"
)
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: c, plan_slug: "free")
c
end
before do
post admin_login_path, params: { username: "ops-bt-http", password: "Password123" }
end
it "salva il prezzo concordato sulla società" do
post set_quote_admin_club_path(club), params: {
plan_slug: "premium_full",
interval: "yearly",
amount_euros: "175,50",
note: "Accordo regionale"
}
expect(response).to redirect_to(admin_club_path(club))
quote = club.reload.active_billing_quote
expect(quote.amount_cents).to eq(17_550)
expect(quote.note).to eq("Accordo regionale")
end
it "conferma un bonifico in attesa e attiva il piano" do
allow(MatchLiveTv).to receive_messages(
bank_transfer_configured?: true,
bank_transfer_iban: "IT60X0542811101000000123456",
bank_transfer_account_holder: "Match Live TV",
bank_transfer_bank_name: "Banca",
bank_transfer_bic: "TESTITMM",
bank_transfer_proof_email: "info@matchlivetv.it"
)
user = User.create!(email: "req@test.it", name: "R", password: "Password123", role: "coach")
ClubMembership.create!(user: user, club: club, role: "owner")
order = Billing::RequestBankTransfer.call(club: club, user: user, plan_slug: "premium_light", interval: "monthly")
post admin_billing_transfer_confirm_path(order)
expect(response).to redirect_to(admin_billing_path(club_id: club.id))
expect(club.reload.subscription.plan.slug).to eq("premium_light")
expect(order.reload).to be_paid
end
end
@@ -1,10 +1,10 @@
require "rails_helper" require "rails_helper"
RSpec.describe "Admin club replays", type: :request do RSpec.describe "Admin club replays", type: :request do
let!(:admin) { AdminAccount.create!(username: "ops", password: "secret123") } let!(:admin) { AdminAccount.create!(username: "ops", password: "Password123") }
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Tigers", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Tigers", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.ago) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.ago) }
let!(:session) do let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago) StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago)
@@ -19,7 +19,7 @@ RSpec.describe "Admin club replays", type: :request do
end end
before do before do
post admin_login_path, params: { username: "ops", password: "secret123" } post admin_login_path, params: { username: "ops", password: "Password123" }
end end
it "mostra l'archivio replay della società" do it "mostra l'archivio replay della società" do
+2 -2
View File
@@ -3,10 +3,10 @@
require "rails_helper" require "rails_helper"
RSpec.describe "Api::V1::Sports", type: :request do RSpec.describe "Api::V1::Sports", type: :request do
let!(:user) { User.create!(email: "s@example.com", password: "secret123", name: "U", role: "coach") } let!(:user) { User.create!(email: "s@example.com", password: "Password123", name: "U", role: "coach") }
it "GET /api/v1/sports restituisce il catalogo" do it "GET /api/v1/sports restituisce il catalogo" do
post "/api/v1/auth/login", params: { email: user.email, password: "secret123" } post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
token = response.parsed_body["access_token"] token = response.parsed_body["access_token"]
get "/api/v1/sports", headers: { "Authorization" => "Bearer #{token}" } get "/api/v1/sports", headers: { "Authorization" => "Bearer #{token}" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -0,0 +1,62 @@
require "rails_helper"
RSpec.describe "Public bank transfer billing", type: :request do
let!(:coach) do
User.create!(email: "bt-coach@test.it", name: "Coach", password: "Password123", role: "coach")
end
let!(:club) do
c = Club.create!(
name: "BT HTTP Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff",
billing_entity_type: "company", billing_legal_name: "ASD BT", billing_email: "bill@test.it",
billing_address_line: "Via 1", billing_city: "Milano", billing_province: "MI", billing_postal_code: "20100",
billing_country: "IT", billing_vat_number: "12345678901", billing_recipient_code: "ABCDEFG"
)
ClubMembership.create!(user: coach, club: c, role: "owner")
c.teams.create!(name: "U15", sport: "volleyball")
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: c, plan_slug: "free")
c
end
before do
allow(MatchLiveTv).to receive_messages(
stripe_enabled?: true,
stripe_premium_light_yearly_price_id: "py",
stripe_premium_light_monthly_price_id: "pm",
stripe_premium_full_yearly_price_id: "fy",
stripe_premium_full_monthly_price_id: "fm",
bank_transfer_configured?: true,
bank_transfer_iban: "IT60X0542811101000000123456",
bank_transfer_account_holder: "Match Live TV",
bank_transfer_bank_name: "Banca Test",
bank_transfer_bic: "TESTITMM",
bank_transfer_proof_email: "info@matchlivetv.it"
)
post public_login_path, params: { email: coach.email, password: "Password123" }
end
it "mostra il pulsante bonifico a listino" do
get public_club_billing_path(club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Paga con bonifico")
end
it "accetta la richiesta di bonifico senza attivare il piano" do
post public_club_billing_bank_transfer_path(club, plan: "premium_light", interval: "yearly")
expect(response).to redirect_to(public_club_billing_path(club))
expect(club.reload.subscription.plan.slug).to eq("free")
expect(club.billing_transfer_orders.awaiting_payment).to exist
end
it "con prezzo concordato nasconde Stripe e mostra l'importo" do
Billing::SetClubQuote.upsert(
club: club, plan_slug: "premium_full", interval: "yearly",
amount_euros: "120", note: "Test", admin: nil
)
get public_club_billing_path(club)
expect(response.body).to include("Prezzo concordato")
expect(response.body).to include("€120")
expect(response.body).not_to include("Attiva Full — €199/anno")
end
end
@@ -2,7 +2,7 @@ require "rails_helper"
RSpec.describe "Public club billing", type: :request do RSpec.describe "Public club billing", type: :request do
let!(:coach) do let!(:coach) do
User.create!(email: "billing-coach@test.it", name: "Coach", password: "password123", role: "coach") User.create!(email: "billing-coach@test.it", name: "Coach", password: "Password123", role: "coach")
end end
let!(:club) do let!(:club) do
c = Club.create!(name: "Billing HTTP Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff", c = Club.create!(name: "Billing HTTP Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff",
@@ -17,7 +17,7 @@ RSpec.describe "Public club billing", type: :request do
end end
def login! def login!
post public_login_path, params: { email: coach.email, password: "password123" } post public_login_path, params: { email: coach.email, password: "Password123" }
end end
it "mostra pagamenti e fatture in billing" do it "mostra pagamenti e fatture in billing" do
@@ -42,7 +42,7 @@ end
RSpec.describe "Public team directory", type: :request do RSpec.describe "Public team directory", type: :request do
let(:club) { Club.create!(name: "ASD Eagles", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let(:club) { Club.create!(name: "ASD Eagles", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let(:user) { User.create!(email: "dir2@test.com", name: "Coach", password: "password123", role: "coach") } let(:user) { User.create!(email: "dir2@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:team) { club.teams.create!(name: "Tigers Volley U17", sport_key: "pallavolo") } let!(:team) { club.teams.create!(name: "Tigers Volley U17", sport_key: "pallavolo") }
before do before do
@@ -1,7 +1,7 @@
require "rails_helper" require "rails_helper"
RSpec.describe Billing::AdminCompedSubscription do RSpec.describe Billing::AdminCompedSubscription do
let(:admin) { AdminAccount.create!(username: "ops", password: "secret123") } let(:admin) { AdminAccount.create!(username: "ops", password: "Password123") }
let(:club) { Club.create!(name: "Sponsor FC", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let(:club) { Club.create!(name: "Sponsor FC", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
before do before do
@@ -0,0 +1,62 @@
require "rails_helper"
RSpec.describe Billing::ConfirmBankTransfer do
let(:admin) { AdminAccount.create!(username: "ops-bt", password: "Password123") }
let(:user) { User.create!(email: "owner-confirm@test.it", name: "Coach", password: "Password123", role: "coach") }
let(:club) do
c = Club.create!(
name: "Confirm Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff",
billing_entity_type: "company", billing_legal_name: "ASD Confirm", billing_email: "fatture@confirm.test",
billing_address_line: "Via 1", billing_city: "Milano", billing_province: "MI", billing_postal_code: "20100",
billing_country: "IT", billing_vat_number: "12345678901", billing_recipient_code: "ABCDEFG"
)
ClubMembership.create!(user: user, club: c, role: "owner")
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: c, plan_slug: "free")
c
end
before do
allow(MatchLiveTv).to receive_messages(
bank_transfer_configured?: true,
bank_transfer_iban: "IT60X0542811101000000123456",
bank_transfer_account_holder: "Match Live TV",
bank_transfer_bank_name: "Banca Test",
bank_transfer_bic: "TESTITMM",
bank_transfer_proof_email: "info@matchlivetv.it"
)
end
def request_order
Billing::RequestBankTransfer.call(club: club, user: user, plan_slug: "premium_full", interval: "yearly")
end
it "attiva il piano e invia la mail di attivazione senza PDF" do
order = request_order
ActionMailer::Base.deliveries.clear
described_class.call(order: order, admin: admin)
sub = club.reload.subscription
expect(sub.plan.slug).to eq("premium_full")
expect(sub).to be_bank_transfer
expect(sub.cancel_at_period_end).to be true
expect(sub.current_period_end).to be_within(2.seconds).of(1.year.from_now)
expect(order.reload).to be_paid
expect(order.billing_payment.status).to eq("paid")
expect(ActionMailer::Base.deliveries.last.subject).to include("Premium Full")
end
it "con PDF invia una sola email di attivazione con fattura" do
order = request_order
ActionMailer::Base.deliveries.clear
pdf = { io: StringIO.new("%PDF-1.4 test"), filename: "fattura.pdf", content_type: "application/pdf" }
expect {
described_class.call(order: order, admin: admin, pdf: pdf)
}.to change { ActionMailer::Base.deliveries.size }.by(1)
expect(order.billing_payment.reload.invoice.pdf).to be_attached
expect(ActionMailer::Base.deliveries.last.subject).to include("fattura")
end
end
@@ -0,0 +1,65 @@
require "rails_helper"
RSpec.describe Billing::RequestBankTransfer do
let(:user) { User.create!(email: "owner-bt@test.it", name: "Coach", password: "Password123", role: "coach") }
let(:club) do
c = Club.create!(
name: "Bonifico Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff",
billing_entity_type: "company", billing_legal_name: "ASD Bonifico", billing_email: "bill@test.it",
billing_address_line: "Via 1", billing_city: "Milano", billing_province: "MI", billing_postal_code: "20100",
billing_country: "IT", billing_vat_number: "12345678901", billing_recipient_code: "ABCDEFG"
)
ClubMembership.create!(user: user, club: c, role: "owner")
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: c, plan_slug: "free")
c
end
before do
allow(MatchLiveTv).to receive_messages(
bank_transfer_configured?: true,
bank_transfer_iban: "IT60X0542811101000000123456",
bank_transfer_account_holder: "Match Live TV",
bank_transfer_bank_name: "Banca Test",
bank_transfer_bic: "TESTITMM",
bank_transfer_proof_email: "info@matchlivetv.it"
)
end
it "crea un ordine a listino e invia le istruzioni" do
expect {
described_class.call(club: club, user: user, plan_slug: "premium_light", interval: "yearly")
}.to change { club.billing_transfer_orders.count }.by(1)
.and change { ActionMailer::Base.deliveries.size }.by(1)
order = club.billing_transfer_orders.last
expect(order).to be_awaiting_payment
expect(order.kind).to eq("list_price")
expect(order.amount_cents).to eq(5900)
expect(order.billing_payment.status).to eq("pending")
expect(order.billing_payment.provider).to eq("bank_transfer")
expect(club.reload.subscription.plan.slug).to eq("free")
end
it "usa l'importo concordato se presente" do
Billing::SetClubQuote.upsert(
club: club, plan_slug: "premium_full", interval: "yearly",
amount_euros: "150", note: "Accordo 2026", admin: nil
)
order = described_class.call(club: club, user: user, plan_slug: "premium_full", interval: "yearly")
expect(order.kind).to eq("commercial_quote")
expect(order.amount_cents).to eq(15_000)
end
it "rifiuta un piano diverso dal prezzo concordato" do
Billing::SetClubQuote.upsert(
club: club, plan_slug: "premium_full", interval: "yearly",
amount_euros: "150", note: nil, admin: nil
)
expect {
described_class.call(club: club, user: user, plan_slug: "premium_light", interval: "yearly")
}.to raise_error(Billing::RequestBankTransfer::Error, /prezzo concordato/)
end
end
@@ -0,0 +1,37 @@
require "rails_helper"
RSpec.describe Billing::SetClubQuote do
let(:admin) { AdminAccount.create!(username: "ops-quote", password: "Password123") }
let(:club) do
c = Club.create!(name: "Quote Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: c, plan_slug: "free")
c
end
it "salva l'importo in centesimi per la società" do
quote = described_class.upsert(
club: club,
plan_slug: "premium_full",
interval: "yearly",
amount_euros: "150,00",
note: "Accordo sponsor",
admin: admin
)
expect(quote).to be_active
expect(quote.amount_cents).to eq(15_000)
expect(quote.plan_slug).to eq("premium_full")
expect(club.reload.active_billing_quote).to eq(quote)
end
it "revoca il prezzo concordato" do
described_class.upsert(
club: club, plan_slug: "premium_light", interval: "monthly",
amount_euros: "5", note: nil, admin: admin
)
described_class.revoke(club: club, admin: admin)
expect(club.reload.active_billing_quote).to be_nil
end
end
@@ -1,7 +1,7 @@
require "rails_helper" require "rails_helper"
RSpec.describe Billing::Stripe::CheckoutSession do RSpec.describe Billing::Stripe::CheckoutSession do
let(:user) { User.create!(email: "checkout@test.it", name: "Owner", password: "password123", role: "coach") } let(:user) { User.create!(email: "checkout@test.it", name: "Owner", password: "Password123", role: "coach") }
let(:club) do let(:club) do
Club.create!( Club.create!(
name: "Checkout Club", sport: "volleyball", name: "Checkout Club", sport: "volleyball",
@@ -1,7 +1,7 @@
require "rails_helper" require "rails_helper"
RSpec.describe Billing::Stripe::EnsureCustomer do RSpec.describe Billing::Stripe::EnsureCustomer do
let(:user) { User.create!(email: "ensure@test.it", name: "U", password: "password123", role: "coach") } let(:user) { User.create!(email: "ensure@test.it", name: "U", password: "Password123", role: "coach") }
let(:club) do let(:club) do
Club.create!(name: "Ensure Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") Club.create!(name: "Ensure Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
end end
@@ -3,7 +3,7 @@ require "rails_helper"
RSpec.describe Mediamtx::CleanupOrphanPaths do RSpec.describe Mediamtx::CleanupOrphanPaths do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let(:client) { instance_double(Mediamtx::Client) } let(:client) { instance_double(Mediamtx::Client) }
@@ -3,7 +3,7 @@ require "rails_helper"
RSpec.describe Recordings::AccessPolicy do RSpec.describe Recordings::AccessPolicy do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended") StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended")
@@ -3,7 +3,7 @@ require "rails_helper"
RSpec.describe Recordings::CleanupLocal do RSpec.describe Recordings::CleanupLocal do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let(:recordings_root) { Rails.root.join("tmp/recordings_spec").to_s } let(:recordings_root) { Rails.root.join("tmp/recordings_spec").to_s }
@@ -4,7 +4,7 @@ RSpec.describe Recordings::ClubArchive do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team_a) { club.teams.create!(name: "A", sport: "volleyball") } let!(:team_a) { club.teams.create!(name: "A", sport: "volleyball") }
let!(:team_b) { club.teams.create!(name: "B", sport: "volleyball") } let!(:team_b) { club.teams.create!(name: "B", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match_a) { team_a.matches.create!(opponent_name: "Rival A", scheduled_at: 1.day.ago) } let!(:match_a) { team_a.matches.create!(opponent_name: "Rival A", scheduled_at: 1.day.ago) }
let!(:match_b) { team_b.matches.create!(opponent_name: "Rival B", scheduled_at: 1.day.ago) } let!(:match_b) { team_b.matches.create!(opponent_name: "Rival B", scheduled_at: 1.day.ago) }
let!(:session_a) do let!(:session_a) do
@@ -3,7 +3,7 @@ require "rails_helper"
RSpec.describe Recordings::ClubStats do RSpec.describe Recordings::ClubStats do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended") StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended")
@@ -3,7 +3,7 @@ require "rails_helper"
RSpec.describe Recordings::Delete do RSpec.describe Recordings::Delete do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do let!(:session) do
StreamSession.create!(match: match, user: user, platform: "youtube", status: "ended") StreamSession.create!(match: match, user: user, platform: "youtube", status: "ended")
@@ -3,7 +3,7 @@ require "rails_helper"
RSpec.describe Recordings::ExtendExpiry do RSpec.describe Recordings::ExtendExpiry do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Tigers", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Tigers", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.ago) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.ago) }
let!(:session) do let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago) StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago)
@@ -3,7 +3,7 @@ require "rails_helper"
RSpec.describe Recordings::FinalizeSession do RSpec.describe Recordings::FinalizeSession do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do let!(:session) do
StreamSession.create!( StreamSession.create!(
@@ -3,7 +3,7 @@ require "rails_helper"
RSpec.describe Recordings::IncrementViews do RSpec.describe Recordings::IncrementViews do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "c@test.com", name: "C", password: "password123", role: "coach") } let!(:user) { User.create!(email: "c@test.com", name: "C", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended") } let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended") }
let!(:recording) do let!(:recording) do
@@ -2,9 +2,9 @@ require "rails_helper"
RSpec.describe Recordings::NotifyReady do RSpec.describe Recordings::NotifyReady do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:owner) { User.create!(email: "owner@test.com", name: "Owner", password: "password123", role: "coach") } let!(:owner) { User.create!(email: "owner@test.com", name: "Owner", password: "Password123", role: "coach") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "c@test.com", name: "C", password: "password123", role: "coach") } let!(:user) { User.create!(email: "c@test.com", name: "C", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended") } let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended") }
@@ -1,7 +1,7 @@
require "rails_helper" require "rails_helper"
RSpec.describe Scoring::ApplyAction do RSpec.describe Scoring::ApplyAction do
let!(:user) { User.create!(email: "score@test.it", name: "U", password: "password123", role: "coach") } let!(:user) { User.create!(email: "score@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) } let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) }
@@ -6,7 +6,7 @@ RSpec.describe Scoring::Engines::GenericEngine do
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "T", sport: "altro") } let!(:team) { club.teams.create!(name: "T", sport: "altro") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "altro") } let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "altro") }
let!(:user) { User.create!(email: "g@example.com", password: "secret123", name: "Coach") } let!(:user) { User.create!(email: "g@example.com", password: "Password123", name: "Coach") }
let!(:session) { StreamSession.create!(match: match, user: user, status: "live", platform: "matchlivetv") } let!(:session) { StreamSession.create!(match: match, user: user, status: "live", platform: "matchlivetv") }
it "incrementa solo il contatore senza chiudere set" do it "incrementa solo il contatore senza chiudere set" do
@@ -13,7 +13,7 @@ RSpec.describe Scoring::PeriodData do
end end
RSpec.describe "Basket advance_period con periodo corrotto" do RSpec.describe "Basket advance_period con periodo corrotto" do
let!(:user) { User.create!(email: "period@test.it", name: "U", password: "password123", role: "coach") } let!(:user) { User.create!(email: "period@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "basket", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "C", sport: "basket", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "U13", sport_key: "basket") } let!(:team) { club.teams.create!(name: "U13", sport_key: "basket") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "basket") } let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "basket") }
@@ -1,7 +1,7 @@
require "rails_helper" require "rails_helper"
RSpec.describe Scoring::SyncState do RSpec.describe Scoring::SyncState do
let!(:user) { User.create!(email: "sync@test.it", name: "U", password: "password123", role: "coach") } let!(:user) { User.create!(email: "sync@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) } let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) }
@@ -1,7 +1,7 @@
require "rails_helper" require "rails_helper"
RSpec.describe "Volley close_set" do RSpec.describe "Volley close_set" do
let!(:user) { User.create!(email: "volley-close@test.it", name: "U", password: "password123", role: "coach") } let!(:user) { User.create!(email: "volley-close@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) } let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) }
@@ -33,7 +33,15 @@ RSpec.describe Streams::YoutubeRelay do
end end
let(:session_id) { SecureRandom.uuid } let(:session_id) { SecureRandom.uuid }
let(:lab_node) { instance_double(StreamNode, slug: "ingest-lab-01") } let(:lab_node) { instance_double(StreamNode, slug: "ingest-lab-01", role: "lab", relay_agent_url: nil) }
let(:cloud_node) do
instance_double(
StreamNode,
slug: "ingest-01",
role: "cloud",
relay_agent_url: "http://203.0.113.10:9100"
)
end
def session_double(stream_node: nil) def session_double(stream_node: nil)
instance_double( instance_double(
@@ -54,10 +62,14 @@ RSpec.describe Streams::YoutubeRelay do
expect(described_class.queue_for(session_double)).to eq("youtube_relay_home") expect(described_class.queue_for(session_double)).to eq("youtube_relay_home")
end end
it "routes overflow sessions to the node slug queue" do it "routes overflow lab sessions to the node slug queue" do
expect(described_class.queue_for(session_double(stream_node: lab_node))).to eq("youtube_relay_ingest-lab-01") expect(described_class.queue_for(session_double(stream_node: lab_node))).to eq("youtube_relay_ingest-lab-01")
end end
it "routes cloud overflow to youtube_relay_cloud for the home dispatcher" do
expect(described_class.queue_for(session_double(stream_node: cloud_node))).to eq("youtube_relay_cloud")
end
it "requeues ensure to the assigned node when the local worker does not match" do it "requeues ensure to the assigned node when the local worker does not match" do
with_env("STREAM_NODE_SLUG" => "home", "YOUTUBE_RELAY_WORKER" => "1") do with_env("STREAM_NODE_SLUG" => "home", "YOUTUBE_RELAY_WORKER" => "1") do
allow(described_class).to receive(:worker?).and_return(true) allow(described_class).to receive(:worker?).and_return(true)
@@ -67,6 +79,13 @@ RSpec.describe Streams::YoutubeRelay do
expect(described_class.ensure_on_worker!(session_double(stream_node: lab_node))).to eq(:wrong_host) expect(described_class.ensure_on_worker!(session_double(stream_node: lab_node))).to eq(:wrong_host)
end end
end end
it "lets the home worker process cloud overflow instead of requeueing to a per-node queue" do
with_env("STREAM_NODE_SLUG" => "home", "YOUTUBE_RELAY_WORKER" => "1") do
expect(described_class.can_process?(session_double(stream_node: cloud_node))).to be true
expect(described_class.cloud_home_dispatch?(session_double(stream_node: cloud_node))).to be true
end
end
end end
describe "multi-host sticky stop/capacity" do describe "multi-host sticky stop/capacity" do
@@ -125,7 +144,7 @@ RSpec.describe Streams::YoutubeRelay do
let(:session) do let(:session) do
instance_double( instance_double(
StreamSession, StreamSession,
stream_node: instance_double(StreamNode, slug: "ingest-lab-01"), stream_node: instance_double(StreamNode, slug: "ingest-lab-01", role: "lab", relay_agent_url: nil),
mediamtx_path_name: "live/match_abc", mediamtx_path_name: "live/match_abc",
mediamtx_internal_rtmp_url: "rtmp://10.0.0.9:1935", mediamtx_internal_rtmp_url: "rtmp://10.0.0.9:1935",
mediamtx_internal_hls_url: "http://10.0.0.9:8888" mediamtx_internal_hls_url: "http://10.0.0.9:8888"
@@ -179,7 +198,8 @@ RSpec.describe Streams::YoutubeRelay do
expect(described_class).to receive(:agent_request).with( expect(described_class).to receive(:agent_request).with(
Net::HTTP::Post, Net::HTTP::Post,
"/relays", "/relays",
hash_including("session_id" => "sess-1", "path" => "live/match_abc") hash_including("session_id" => "sess-1", "path" => "live/match_abc"),
session: session
).and_return("pid" => 4242) ).and_return("pid" => 4242)
pid = with_env("STREAM_NODE_RELAY_AGENT_URL" => "http://203.0.113.10:9100") do pid = with_env("STREAM_NODE_RELAY_AGENT_URL" => "http://203.0.113.10:9100") do
@@ -189,3 +209,27 @@ RSpec.describe Streams::YoutubeRelay do
end end
end end
end end
RSpec.describe StreamNode do
describe "#relay_agent_url" do
it "uses metadata public_ip on cloud nodes" do
node = described_class.new(
slug: "ingest-01",
hostname: "ingest-01.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://x:1935",
hls_base_url: "https://x/hls",
api_base_url: "http://10.0.0.9:9997",
metadata: { "public_ip" => "203.0.113.9" }
)
expect(node.relay_agent_url).to eq("http://203.0.113.9:9100")
end
it "is nil for lab nodes" do
node = described_class.new(role: "lab", api_base_url: "http://10.0.0.9:9997", metadata: { "public_ip" => "1.2.3.4" })
expect(node.relay_agent_url).to be_nil
end
end
end
@@ -3,7 +3,7 @@ require "rails_helper"
RSpec.describe Teams::Entitlements, "recordings" do RSpec.describe Teams::Entitlements, "recordings" do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "password123", role: "coach") } let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) } let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended") StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended")
@@ -10,7 +10,7 @@ RSpec.describe Teams::Entitlements do
end end
it "limits transmission invites on free plan" do it "limits transmission invites on free plan" do
user = User.create!(email: "t1@test.com", name: "T1", password: "password123", role: "coach") user = User.create!(email: "t1@test.com", name: "T1", password: "Password123", role: "coach")
UserTeam.create!(user: user, team: team, role: "member", staff_kind: "transmission") UserTeam.create!(user: user, team: team, role: "member", staff_kind: "transmission")
ent = described_class.new(team) ent = described_class.new(team)
expect { ent.assert_can_invite! }.to raise_error(Teams::EntitlementError, /trasmissione/) expect { ent.assert_can_invite! }.to raise_error(Teams::EntitlementError, /trasmissione/)
@@ -1,7 +1,7 @@
require "rails_helper" require "rails_helper"
RSpec.describe Teams::PublicDirectory do RSpec.describe Teams::PublicDirectory do
let(:user) { User.create!(email: "dir-#{SecureRandom.hex(4)}@test.com", name: "Dir", password: "password123", role: "coach") } let(:user) { User.create!(email: "dir-#{SecureRandom.hex(4)}@test.com", name: "Dir", password: "Password123", role: "coach") }
let(:club) { Club.create!(name: "ASD Directory #{SecureRandom.hex(3)}", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let(:club) { Club.create!(name: "ASD Directory #{SecureRandom.hex(3)}", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team_live) { club.teams.create!(name: "Live Team", sport_key: "pallavolo") } let!(:team_live) { club.teams.create!(name: "Live Team", sport_key: "pallavolo") }
let!(:team_replay) { club.teams.create!(name: "Replay Team", sport_key: "basket") } let!(:team_replay) { club.teams.create!(name: "Replay Team", sport_key: "basket") }
@@ -1,7 +1,7 @@
require "rails_helper" require "rails_helper"
RSpec.describe Webhooks::MediamtxHandler do RSpec.describe Webhooks::MediamtxHandler do
let(:user) { User.create!(email: "c@test.com", name: "C", password: "password123", role: "coach") } let(:user) { User.create!(email: "c@test.com", name: "C", password: "Password123", role: "coach") }
let(:club) { Club.create!(name: "MTX Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let(:club) { Club.create!(name: "MTX Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let(:team) { club.teams.create!(name: "T", sport: "volleyball") } let(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Away") } let!(:match) { team.matches.create!(opponent_name: "Away") }
@@ -12,6 +12,10 @@ RSpec.describe Webhooks::MediamtxHandler do
) )
end end
before do
allow_any_instance_of(Mediamtx::Client).to receive(:set_path_recording)
end
it "transitions to reconnecting on disconnect" do it "transitions to reconnecting on disconnect" do
described_class.new(event: "disconnect", session_id: session.id).call described_class.new(event: "disconnect", session_id: session.id).call
expect(session.reload.status).to eq("reconnecting") expect(session.reload.status).to eq("reconnecting")
+4 -4
View File
@@ -255,12 +255,12 @@ Loverflow deve spostare **ffmpeg**, non solo lingest RTMP. In lab `local_l
### Fase 2 — dettagli implementati ### Fase 2 — dettagli implementati
- Coda Sidekiq dedicata `youtube_relay_<slug>` (home: `youtube_relay_home`; overflow: `youtube_relay_ingest-01`) - Coda Sidekiq `youtube_relay_home` (ffmpeg locale) e `youtube_relay_cloud` (home chiama lagent `:9100` sul CPX). Lab overflow resta `youtube_relay_<slug>`
- `YoutubeRelayEnsureJob` / `YoutubeRelayStopJob` sulla coda del **nodo assegnato** - `YoutubeRelayEnsureJob` / `YoutubeRelayStopJob` sulla coda del **nodo assegnato** (cloud → `youtube_relay_cloud`)
- Stop sticky: non cancella owner da Rails; lo stop gira sullowner o requeue (`:wrong_host`) - Stop sticky: non cancella owner da Rails; lo stop gira sullowner o requeue (`:wrong_host`)
- Cap per worker: `RELAY_MAX_CONCURRENT` (default 4) + set Redis `youtube_relay:owned:HOSTNAME` - Cap per worker: `RELAY_MAX_CONCURRENT` (default 4) + set Redis `youtube_relay:owned:HOSTNAME` (non si applica al dispatch agent dal home)
- Ensure a capacità piena → requeue 5s sulla stessa coda nodo - Ensure a capacità piena → requeue 5s sulla stessa coda nodo
- Worker home ascolta `youtube_relay_home` (+ `youtube_relay` legacy). Worker overflow: solo la propria coda, **niente** `default`/`critical` - Worker home ascolta `youtube_relay_home`, `youtube_relay_cloud` (+ `youtube_relay` legacy). Un CPX nuovo non richiede `prod_relay_worker.sh`
- Intake locale: `STREAM_NODE_LOCAL_RTMP_URL` (loopback sul CPX, `mediamtx` in Docker lab) - Intake locale: `STREAM_NODE_LOCAL_RTMP_URL` (loopback sul CPX, `mediamtx` in Docker lab)
### Fase 3 — dettagli implementati ### Fase 3 — dettagli implementati
+7
View File
@@ -31,6 +31,13 @@ STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
STRIPE_PREMIUM_LIGHT_PRICE_ID= STRIPE_PREMIUM_LIGHT_PRICE_ID=
STRIPE_PREMIUM_FULL_PRICE_ID= STRIPE_PREMIUM_FULL_PRICE_ID=
# Bonifico (IBAN + intestatario obbligatori)
BANK_TRANSFER_IBAN=
BANK_TRANSFER_ACCOUNT_HOLDER=
BANK_TRANSFER_BANK_NAME=
BANK_TRANSFER_BIC=
BANK_TRANSFER_PROOF_EMAIL=info@matchlivetv.it
RAILS_LOG_LEVEL=info RAILS_LOG_LEVEL=info
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
+7
View File
@@ -37,6 +37,13 @@ STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
STRIPE_PREMIUM_LIGHT_PRICE_ID= STRIPE_PREMIUM_LIGHT_PRICE_ID=
STRIPE_PREMIUM_FULL_PRICE_ID= STRIPE_PREMIUM_FULL_PRICE_ID=
# Bonifico (IBAN + intestatario obbligatori per mostrare «Paga con bonifico»)
BANK_TRANSFER_IBAN=
BANK_TRANSFER_ACCOUNT_HOLDER=
BANK_TRANSFER_BANK_NAME=
BANK_TRANSFER_BIC=
BANK_TRANSFER_PROOF_EMAIL=info@matchlivetv.it
# --- Replay storage (dopo ogni diretta Premium) --- # --- Replay storage (dopo ogni diretta Premium) ---
# Dev locale con Docker: Garage parte con `docker compose up` (servizio garage). # Dev locale con Docker: Garage parte con `docker compose up` (servizio garage).
# Bootstrap una tantum: bash infra/scripts/setup_garage_replays.sh → aggiorna .env con le chiavi. # Bootstrap una tantum: bash infra/scripts/setup_garage_replays.sh → aggiorna .env con le chiavi.
+9
View File
@@ -33,6 +33,13 @@ STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
STRIPE_PREMIUM_LIGHT_PRICE_ID= STRIPE_PREMIUM_LIGHT_PRICE_ID=
STRIPE_PREMIUM_FULL_PRICE_ID= STRIPE_PREMIUM_FULL_PRICE_ID=
# Bonifico (IBAN + intestatario obbligatori per attivare il pagamento extra-Stripe)
BANK_TRANSFER_IBAN=
BANK_TRANSFER_ACCOUNT_HOLDER=
BANK_TRANSFER_BANK_NAME=
BANK_TRANSFER_BIC=
BANK_TRANSFER_PROOF_EMAIL=info@matchlivetv.it
RAILS_LOG_LEVEL=info RAILS_LOG_LEVEL=info
# Titolare trattamento (GDPR) — obbligatorio in produzione # Titolare trattamento (GDPR) — obbligatorio in produzione
@@ -125,6 +132,8 @@ STREAM_NODE_ENV=prod
STREAM_CLOUD_PROVIDER=local_lab STREAM_CLOUD_PROVIDER=local_lab
RELAY_MAX_CONCURRENT=4 RELAY_MAX_CONCURRENT=4
YOUTUBE_RELAY_WORKER=1 YOUTUBE_RELAY_WORKER=1
# Deve coincidere con cloud-init (default mediamtx_webhook_dev_secret) — home chiama l'agent :9100.
STREAM_NODE_AGENT_SECRET=mediamtx_webhook_dev_secret
# Autoscaler (kill-switch: 0 finché WireGuard/smoke Cloud non sono OK — NON abilitare in prod senza lab) # Autoscaler (kill-switch: 0 finché WireGuard/smoke Cloud non sono OK — NON abilitare in prod senza lab)
STREAM_AUTOSCALE_ENABLED=0 STREAM_AUTOSCALE_ENABLED=0
+63
View File
@@ -53,6 +53,8 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
command: bundle exec rails server -b 0.0.0.0 -p 3000 -e production command: bundle exec rails server -b 0.0.0.0 -p 3000 -e production
env_file:
- .env
environment: environment:
RAILS_ENV: production RAILS_ENV: production
RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5} RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5}
@@ -70,6 +72,33 @@ services:
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-} YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-} YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-}
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-} YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
STREAM_NODE_AGENT_SECRET: ${STREAM_NODE_AGENT_SECRET:-mediamtx_webhook_dev_secret}
STREAM_NODE_HOME_MAX_PUBLISHERS: ${STREAM_NODE_HOME_MAX_PUBLISHERS:-6}
STREAM_CLOUD_PROVIDER: ${STREAM_CLOUD_PROVIDER:-local_lab}
STREAM_DNS_PROVIDER: ${STREAM_DNS_PROVIDER:-hetzner}
STREAM_AUTOSCALE_ENABLED: ${STREAM_AUTOSCALE_ENABLED:-0}
STREAM_AUTOSCALE_KIND: ${STREAM_AUTOSCALE_KIND:-lab}
STREAM_AUTOSCALE_ALLOW_CLOUD: ${STREAM_AUTOSCALE_ALLOW_CLOUD:-0}
STREAM_AUTOSCALE_SOFT_FREE_SLOTS: ${STREAM_AUTOSCALE_SOFT_FREE_SLOTS:-2}
STREAM_AUTOSCALE_WARM_SPARE: ${STREAM_AUTOSCALE_WARM_SPARE:-1}
STREAM_AUTOSCALE_MAX_NODES: ${STREAM_AUTOSCALE_MAX_NODES:-3}
STREAM_AUTOSCALE_IDLE_MINUTES: ${STREAM_AUTOSCALE_IDLE_MINUTES:-30}
STREAM_AUTOSCALE_INTERVAL_SECS: ${STREAM_AUTOSCALE_INTERVAL_SECS:-60}
STREAM_AUTOSCALE_NODE_EUR_PER_HOUR: ${STREAM_AUTOSCALE_NODE_EUR_PER_HOUR:-0.015}
STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR: ${STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR:-40}
STREAM_CLOUD_PUBLIC_CONTROL: ${STREAM_CLOUD_PUBLIC_CONTROL:-1}
STREAM_NODE_ENV: ${STREAM_NODE_ENV:-prod}
STREAM_DNS_ZONE: ${STREAM_DNS_ZONE:-mltv-stream.net}
STREAM_DNS_TTL: ${STREAM_DNS_TTL:-60}
STREAM_CLOUD_DNS_SUFFIX: ${STREAM_CLOUD_DNS_SUFFIX:-mltv-stream.net}
STREAM_CLOUD_MAX_PUBLISHERS: ${STREAM_CLOUD_MAX_PUBLISHERS:-4}
HCLOUD_TOKEN: ${HCLOUD_TOKEN:-}
HCLOUD_LOCATION: ${HCLOUD_LOCATION:-nbg1}
HCLOUD_SERVER_TYPE: ${HCLOUD_SERVER_TYPE:-cpx12}
HCLOUD_IMAGE: ${HCLOUD_IMAGE:-debian-12}
HCLOUD_SSH_KEY: ${HCLOUD_SSH_KEY:-matchlivetv-stream-hetzner}
HCLOUD_NETWORK_ID: ${HCLOUD_NETWORK_ID:-}
HCLOUD_USER_DATA_FILE: /opt/matchlivetv/infra/stream-node/cloud-init.yaml
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888} HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it} PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
@@ -95,6 +124,11 @@ services:
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}} STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}}
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-} STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-} STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
BANK_TRANSFER_IBAN: ${BANK_TRANSFER_IBAN:-}
BANK_TRANSFER_ACCOUNT_HOLDER: ${BANK_TRANSFER_ACCOUNT_HOLDER:-}
BANK_TRANSFER_BANK_NAME: ${BANK_TRANSFER_BANK_NAME:-}
BANK_TRANSFER_BIC: ${BANK_TRANSFER_BIC:-}
BANK_TRANSFER_PROOF_EMAIL: ${BANK_TRANSFER_PROOF_EMAIL:-info@matchlivetv.it}
RECORDINGS_PATH: /recordings RECORDINGS_PATH: /recordings
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-} REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-}
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays} REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
@@ -166,6 +200,8 @@ services:
restart: unless-stopped restart: unless-stopped
hostname: home hostname: home
command: bundle exec sidekiq -C config/sidekiq.yml -e production command: bundle exec sidekiq -C config/sidekiq.yml -e production
env_file:
- .env
environment: environment:
RAILS_ENV: production RAILS_ENV: production
RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5} RAILS_MAX_THREADS: ${RAILS_MAX_THREADS:-5}
@@ -182,6 +218,33 @@ services:
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-} YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
YOUTUBE_RELAY_WORKER: "1" YOUTUBE_RELAY_WORKER: "1"
STREAM_NODE_SLUG: home STREAM_NODE_SLUG: home
STREAM_NODE_HOME_MAX_PUBLISHERS: ${STREAM_NODE_HOME_MAX_PUBLISHERS:-6}
STREAM_CLOUD_PROVIDER: ${STREAM_CLOUD_PROVIDER:-local_lab}
STREAM_DNS_PROVIDER: ${STREAM_DNS_PROVIDER:-hetzner}
STREAM_AUTOSCALE_ENABLED: ${STREAM_AUTOSCALE_ENABLED:-0}
STREAM_AUTOSCALE_KIND: ${STREAM_AUTOSCALE_KIND:-lab}
STREAM_AUTOSCALE_ALLOW_CLOUD: ${STREAM_AUTOSCALE_ALLOW_CLOUD:-0}
STREAM_AUTOSCALE_SOFT_FREE_SLOTS: ${STREAM_AUTOSCALE_SOFT_FREE_SLOTS:-2}
STREAM_AUTOSCALE_WARM_SPARE: ${STREAM_AUTOSCALE_WARM_SPARE:-1}
STREAM_AUTOSCALE_MAX_NODES: ${STREAM_AUTOSCALE_MAX_NODES:-3}
STREAM_AUTOSCALE_IDLE_MINUTES: ${STREAM_AUTOSCALE_IDLE_MINUTES:-30}
STREAM_AUTOSCALE_INTERVAL_SECS: ${STREAM_AUTOSCALE_INTERVAL_SECS:-60}
STREAM_AUTOSCALE_NODE_EUR_PER_HOUR: ${STREAM_AUTOSCALE_NODE_EUR_PER_HOUR:-0.015}
STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR: ${STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR:-40}
STREAM_CLOUD_PUBLIC_CONTROL: ${STREAM_CLOUD_PUBLIC_CONTROL:-1}
STREAM_NODE_ENV: ${STREAM_NODE_ENV:-prod}
STREAM_DNS_ZONE: ${STREAM_DNS_ZONE:-mltv-stream.net}
STREAM_DNS_TTL: ${STREAM_DNS_TTL:-60}
STREAM_CLOUD_DNS_SUFFIX: ${STREAM_CLOUD_DNS_SUFFIX:-mltv-stream.net}
STREAM_CLOUD_MAX_PUBLISHERS: ${STREAM_CLOUD_MAX_PUBLISHERS:-4}
HCLOUD_TOKEN: ${HCLOUD_TOKEN:-}
HCLOUD_LOCATION: ${HCLOUD_LOCATION:-nbg1}
HCLOUD_SERVER_TYPE: ${HCLOUD_SERVER_TYPE:-cpx12}
HCLOUD_IMAGE: ${HCLOUD_IMAGE:-debian-12}
HCLOUD_SSH_KEY: ${HCLOUD_SSH_KEY:-matchlivetv-stream-hetzner}
HCLOUD_NETWORK_ID: ${HCLOUD_NETWORK_ID:-}
HCLOUD_USER_DATA_FILE: /opt/matchlivetv/infra/stream-node/cloud-init.yaml
STREAM_NODE_AGENT_SECRET: ${STREAM_NODE_AGENT_SECRET:-mediamtx_webhook_dev_secret}
STREAM_NODE_LOCAL_RTMP_URL: rtmp://mediamtx:1935 STREAM_NODE_LOCAL_RTMP_URL: rtmp://mediamtx:1935
STREAM_NODE_LOCAL_HLS_URL: http://mediamtx:8888 STREAM_NODE_LOCAL_HLS_URL: http://mediamtx:8888
RAILS_INTERNAL_URL: http://rails:3000 RAILS_INTERNAL_URL: http://rails:3000
+5
View File
@@ -131,6 +131,11 @@ services:
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}} STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}}
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-} STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-} STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
BANK_TRANSFER_IBAN: ${BANK_TRANSFER_IBAN:-}
BANK_TRANSFER_ACCOUNT_HOLDER: ${BANK_TRANSFER_ACCOUNT_HOLDER:-}
BANK_TRANSFER_BANK_NAME: ${BANK_TRANSFER_BANK_NAME:-}
BANK_TRANSFER_BIC: ${BANK_TRANSFER_BIC:-}
BANK_TRANSFER_PROOF_EMAIL: ${BANK_TRANSFER_PROOF_EMAIL:-info@matchlivetv.it}
RECORDINGS_PATH: /recordings RECORDINGS_PATH: /recordings
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900} REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900}
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays} REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
+204
View File
@@ -56,6 +56,210 @@ write_files:
STREAM_NODE_AGENT_LISTEN=0.0.0.0:9100 STREAM_NODE_AGENT_LISTEN=0.0.0.0:9100
STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888 STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888
STREAM_NODE_RELAY_LOG_DIR=/var/log STREAM_NODE_RELAY_LOG_DIR=/var/log
- path: /opt/stream-node/relay-agent.py
permissions: "0755"
content: |
#!/usr/bin/env python3
"""Avvia/ferma ffmpeg sul nodo stream (loopback MediaMTX → YouTube).
Il control plane chiama questo agent all'avvio della diretta; ffmpeg resta sul CPX.
"""
from __future__ import annotations
import json
import os
import signal
import subprocess
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
SECRET = os.environ.get("STREAM_NODE_AGENT_SECRET", "")
LISTEN = os.environ.get("STREAM_NODE_AGENT_LISTEN", "0.0.0.0:9100")
HLS_BASE = os.environ.get("STREAM_NODE_LOCAL_HLS_URL", "http://127.0.0.1:8888").rstrip("/")
LOG_DIR = os.environ.get("STREAM_NODE_RELAY_LOG_DIR", "/var/log")
_lock = threading.Lock()
# session_id -> {"pid": int, "path": str, "proc": Popen}
_relays: dict[str, dict] = {}
def _authorized(handler: BaseHTTPRequestHandler) -> bool:
if not SECRET:
return True
hdr = handler.headers.get("Authorization", "")
return hdr == f"Bearer {SECRET}"
def _ffmpeg_cmd(path: str, rtmps: str) -> list[str]:
return [
"ffmpeg",
"-nostdin",
"-hide_banner",
"-loglevel",
"warning",
"-fflags",
"+genpts+discardcorrupt",
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_on_network_error",
"1",
"-reconnect_delay_max",
"2",
"-rw_timeout",
"15000000",
"-live_start_index",
"-1",
"-i",
f"{HLS_BASE}/{path}/index.m3u8",
"-c:v",
"copy",
"-c:a",
"copy",
"-bsf:a",
"aac_adtstoasc",
"-f",
"flv",
rtmps,
]
def _alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except OSError:
return False
def _stop_session(session_id: str) -> None:
info = _relays.pop(session_id, None)
if not info:
return
proc = info.get("proc")
pid = int(info.get("pid") or 0)
if proc and proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGTERM)
except OSError:
pass
try:
proc.wait(timeout=2)
except Exception:
try:
os.killpg(proc.pid, signal.SIGKILL)
except OSError:
pass
elif pid:
try:
os.kill(pid, signal.SIGTERM)
except OSError:
pass
def _start_session(session_id: str, path: str, rtmps: str) -> int:
existing = _relays.get(session_id)
if existing:
proc = existing.get("proc")
pid = int(existing.get("pid") or 0)
if proc is not None and proc.poll() is None and existing.get("path") == path:
return pid
_stop_session(session_id)
os.makedirs(LOG_DIR, exist_ok=True)
safe = path.replace("/", "_")
log_path = os.path.join(LOG_DIR, f"youtube-relay-{safe}.log")
log = open(log_path, "ab", buffering=0)
proc = subprocess.Popen(
_ffmpeg_cmd(path, rtmps),
stdout=log,
stderr=log,
start_new_session=True,
)
_relays[session_id] = {"pid": proc.pid, "path": path, "proc": proc}
return proc.pid
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
print(f"[relay-agent] {self.address_string()} {fmt % args}")
def _json(self, code: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path in ("/health", "/"):
self._json(200, {"ok": True, "relays": len(_relays)})
return
if not _authorized(self):
self._json(401, {"error": "unauthorized"})
return
if parsed.path.startswith("/relays/"):
session_id = parsed.path.split("/relays/", 1)[1].strip("/")
with _lock:
info = _relays.get(session_id)
running = bool(info and _alive(int(info["pid"])))
if info and not running:
_relays.pop(session_id, None)
if not running:
self._json(404, {"running": False, "session_id": session_id})
return
self._json(200, {"running": True, "session_id": session_id, "pid": info["pid"], "path": info["path"]})
return
self._json(404, {"error": "not found"})
def do_POST(self) -> None:
if not _authorized(self):
self._json(401, {"error": "unauthorized"})
return
if urlparse(self.path).path != "/relays":
self._json(404, {"error": "not found"})
return
length = int(self.headers.get("Content-Length") or 0)
try:
data = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
self._json(400, {"error": "invalid json"})
return
session_id = str(data.get("session_id") or "").strip()
path = str(data.get("path") or "").strip()
rtmps = str(data.get("rtmps") or "").strip()
if not session_id or not path or not rtmps.startswith("rtmps://"):
self._json(400, {"error": "session_id, path, rtmps required"})
return
with _lock:
pid = _start_session(session_id, path, rtmps)
self._json(201, {"pid": pid, "session_id": session_id, "path": path})
def do_DELETE(self) -> None:
if not _authorized(self):
self._json(401, {"error": "unauthorized"})
return
parsed = urlparse(self.path)
if not parsed.path.startswith("/relays/"):
self._json(404, {"error": "not found"})
return
session_id = parsed.path.split("/relays/", 1)[1].strip("/")
with _lock:
_stop_session(session_id)
self._json(200, {"stopped": True, "session_id": session_id})
def main() -> None:
host, port_s = LISTEN.rsplit(":", 1)
httpd = ThreadingHTTPServer((host, int(port_s)), Handler)
print(f"[relay-agent] listen {LISTEN} hls={HLS_BASE}")
httpd.serve_forever()
if __name__ == "__main__":
main()
- path: /opt/stream-node/bootstrap.sh - path: /opt/stream-node/bootstrap.sh
permissions: "0755" permissions: "0755"
content: | content: |
@@ -65,6 +65,7 @@ class E2EWizardFlowTest {
waitForText(s(R.string.wizard_step_title_transmission), timeoutMs = 45_000) waitForText(s(R.string.wizard_step_title_transmission), timeoutMs = 45_000)
waitForText(s(R.string.wizard_transmission_platform_title), timeoutMs = 30_000) waitForText(s(R.string.wizard_transmission_platform_title), timeoutMs = 30_000)
selectYoutubeIfRequested() selectYoutubeIfRequested()
selectUnlistedPrivacy()
val onNetworkStep = { val onNetworkStep = {
device.hasObject(By.text(s(R.string.wizard_network_test_start_label))) || device.hasObject(By.text(s(R.string.wizard_network_test_start_label))) ||
device.hasObject(By.text("AVVIA TEST RETE")) || device.hasObject(By.text("AVVIA TEST RETE")) ||
@@ -189,6 +190,12 @@ class E2EWizardFlowTest {
if (!wantsYoutube()) return if (!wantsYoutube()) return
waitForAnyText("YouTube Live", timeoutMs = 20_000) waitForAnyText("YouTube Live", timeoutMs = 20_000)
tapClickableText("YouTube Live") tapClickableText("YouTube Live")
device.waitForIdle()
SystemClock.sleep(400)
}
// Test su backend reali: mai pubblico (sito e YouTube). L'app espone solo pubblico / non in elenco.
private fun selectUnlistedPrivacy() {
waitForAnyText( waitForAnyText(
s(R.string.wizard_transmission_unlisted_label), s(R.string.wizard_transmission_unlisted_label),
"NON IN ELENCO", "NON IN ELENCO",
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Fallback manuale: worker dedicato youtube_relay_<slug> verso l'agent sul CPX.
# In produzione il Sidekiq home ascolta youtube_relay_cloud e chiama l'agent da solo;
# questo script serve solo se quel dispatch è spento o per debug.
# STREAM_NODE_SLUG=ingest-01 STREAM_NODE_RELAY_AGENT_URL=http://1.2.3.4:9100 \
# bash scripts/deploy/prod_relay_worker.sh up
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
INFRA="${MATCHLIVETV_INFRA:-$ROOT/infra}"
if [ -d /opt/matchlivetv/infra ]; then
INFRA=/opt/matchlivetv/infra
fi
SLUG="${STREAM_NODE_SLUG:?Imposta STREAM_NODE_SLUG (es. ingest-01)}"
ACTION="${1:-up}"
NAME="infra-sidekiq-relay-${SLUG}"
AGENT_URL="${STREAM_NODE_RELAY_AGENT_URL:-}"
AGENT_SECRET="${STREAM_NODE_AGENT_SECRET:-mediamtx_webhook_dev_secret}"
cd "$INFRA"
COMPOSE=(docker compose -f docker-compose.prod.yml --env-file .env)
case "$ACTION" in
up)
docker rm -f "$NAME" >/dev/null 2>&1 || true
extra=()
if [[ -n "$AGENT_URL" ]]; then
extra+=(
-e STREAM_NODE_RELAY_AGENT_URL="$AGENT_URL"
-e STREAM_NODE_AGENT_SECRET="$AGENT_SECRET"
-e STREAM_NODE_LOCAL_RTMP_URL=rtmp://127.0.0.1:1935
-e STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888
)
fi
"${COMPOSE[@]}" run -d --name "$NAME" --no-deps \
-e STREAM_NODE_SLUG="$SLUG" \
-e YOUTUBE_RELAY_WORKER=1 \
-e HOSTNAME="$SLUG" \
-e RELAY_MAX_CONCURRENT="${RELAY_MAX_CONCURRENT:-2}" \
"${extra[@]}" \
sidekiq \
bash -lc "bundle exec sidekiq -c \${RELAY_MAX_CONCURRENT:-2} -q youtube_relay_${SLUG} -e production"
echo "relay worker up name=${NAME} queue=youtube_relay_${SLUG} agent=${AGENT_URL:-local}"
;;
down)
docker rm -f "$NAME" >/dev/null 2>&1 || true
echo "relay worker down name=${NAME}"
;;
logs)
docker logs -f --tail=80 "$NAME"
;;
*)
echo "Uso: STREAM_NODE_SLUG=ingest-01 STREAM_NODE_RELAY_AGENT_URL=http://IP:9100 $0 up|down|logs" >&2
exit 1
;;
esac