diff --git a/backend/app/controllers/admin/billing_controller.rb b/backend/app/controllers/admin/billing_controller.rb
index 0903165..b600c4e 100644
--- a/backend/app/controllers/admin/billing_controller.rb
+++ b/backend/app/controllers/admin/billing_controller.rb
@@ -2,6 +2,7 @@ module Admin
class BillingController < BaseController
def index
@pending_payments = pending_scope.recent
+ @pending_transfers = transfer_scope
@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?
@clubs = Club.order(:name)
@@ -18,6 +19,25 @@ module Admin
alert: e.message
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
def pending_scope
@@ -26,6 +46,12 @@ module Admin
scope
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)
{ club_id: payment.club_id, anchor: "payment-#{payment.id}" }.compact
end
diff --git a/backend/app/controllers/admin/clubs_controller.rb b/backend/app/controllers/admin/clubs_controller.rb
index 7bb4d8c..775c8da 100644
--- a/backend/app/controllers/admin/clubs_controller.rb
+++ b/backend/app/controllers/admin/clubs_controller.rb
@@ -1,9 +1,9 @@
module Admin
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
- @clubs = Club.includes(:teams, subscription: %i[plan admin_comped_by])
+ @clubs = Club.includes(:teams, :billing_quote, subscription: %i[plan admin_comped_by])
.order(:name)
end
@@ -11,6 +11,7 @@ module Admin
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
@plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.order(:name)
+ @quote = @club.active_billing_quote
end
def grant_comped
@@ -32,6 +33,32 @@ module Admin
redirect_back_or_club alert: e.message
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
def set_club
diff --git a/backend/app/controllers/public/club_billing_controller.rb b/backend/app/controllers/public/club_billing_controller.rb
index 4ea6f84..cbc73a2 100644
--- a/backend/app/controllers/public/club_billing_controller.rb
+++ b/backend/app/controllers/public/club_billing_controller.rb
@@ -11,11 +11,15 @@ module Public
@club.assign_attributes(billing_profile_params)
if @club.save(context: :billing_profile)
if premium_checkout_return_params.present? && @club.billing_profile_complete?
- redirect_to public_club_checkout_path(
- @club,
- plan: premium_checkout_return_params[:plan],
- interval: premium_checkout_return_params[:interval]
- ), notice: t("flash.club_billing.profile_saved_proceed_payment")
+ 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(
+ @club,
+ plan: premium_checkout_return_params[:plan],
+ interval: premium_checkout_return_params[:interval]
+ ), notice: t("flash.club_billing.profile_saved_proceed_payment")
+ end
else
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated")
end
@@ -42,6 +46,29 @@ module Public
redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.stripe_error", message: e.message)
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
invoice = @club.billing_invoices.find(params[:invoice_id])
unless invoice.pdf.attached?
diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb
index 08a2093..4da8036 100644
--- a/backend/app/controllers/public/clubs_controller.rb
+++ b/backend/app/controllers/public/clubs_controller.rb
@@ -82,6 +82,8 @@ module Public
@entitlements = @team.entitlements
@plans = Plan.ordered
@payments = @club.billing_payments.recent.includes(:invoice).limit(50)
+ @quote = @club.active_billing_quote
+ @pending_transfer = @club.billing_transfer_orders.awaiting_payment.first
end
def checkout
@@ -92,6 +94,12 @@ module Public
return
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?
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.stripe_not_configured")
return
diff --git a/backend/app/helpers/public/billing_helper.rb b/backend/app/helpers/public/billing_helper.rb
index e2231cd..63ec21e 100644
--- a/backend/app/helpers/public/billing_helper.rb
+++ b/backend/app/helpers/public/billing_helper.rb
@@ -1,6 +1,6 @@
module Public
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"
return { kind: :current, label: I18n.t("billing.actions.current_plan") } if current_slug == "free"
return { kind: :none } if stripe_subscription_active
@@ -8,17 +8,37 @@ module Public
return { kind: :contact, label: I18n.t("billing.actions.contact_for_free") }
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?
+ 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") }
end
- 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?
+ stripe_intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug)
+ 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
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?
label = "#{I18n.t('billing.actions.current_plan')} — #{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}"
return { kind: :current, label: label }
@@ -35,12 +55,12 @@ module Public
end
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
{
kind: :change_options,
plan: target_plan,
- intervals: intervals,
+ intervals: stripe_intervals,
current_slug: current_slug,
current_interval: active_interval,
subscription: subscription
@@ -96,5 +116,28 @@ module Public
I18n.t("billing.actions.profile_missing", fields: missing.join(", "))
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
diff --git a/backend/app/jobs/expire_ended_subscriptions_job.rb b/backend/app/jobs/expire_ended_subscriptions_job.rb
index 515ff6a..a2889ef 100644
--- a/backend/app/jobs/expire_ended_subscriptions_job.rb
+++ b/backend/app/jobs/expire_ended_subscriptions_job.rb
@@ -6,12 +6,37 @@ class ExpireEndedSubscriptionsJob
free_plan = Plan["free"]
Subscription.where(cancel_at_period_end: true)
- .where("current_period_end <= ?", Time.current)
- .where.not(plan_id: free_plan.id)
- .find_each do |sub|
- Billing::Stripe::FinalizeSubscription.call(club: sub.club)
+ .where("current_period_end <= ?", Time.current)
+ .where.not(plan_id: free_plan.id)
+ .where(admin_comped: false)
+ .find_each do |sub|
+ expire!(sub)
rescue StandardError => e
Rails.logger.warn("[ExpireEndedSubscriptions] club=#{sub.club_id} #{e.message}")
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
diff --git a/backend/app/mailers/billing/bank_transfer_mailer.rb b/backend/app/mailers/billing/bank_transfer_mailer.rb
new file mode 100644
index 0000000..97b50fc
--- /dev/null
+++ b/backend/app/mailers/billing/bank_transfer_mailer.rb
@@ -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
diff --git a/backend/app/mailers/billing/invoice_mailer.rb b/backend/app/mailers/billing/invoice_mailer.rb
index aabef33..7cbb07f 100644
--- a/backend/app/mailers/billing/invoice_mailer.rb
+++ b/backend/app/mailers/billing/invoice_mailer.rb
@@ -5,17 +5,37 @@ module Billing
@club = @invoice.club
I18n.with_locale(I18n.locale) do
- prefix = t("mailers.invoice.attachment_prefix")
- attachments["#{prefix}-#{@invoice.number.parameterize}.pdf"] = {
- mime_type: "application/pdf",
- content: @invoice.pdf.download
- }
-
+ attach_invoice_pdf!
mail(
to: @club.billing_email,
subject: t("mailers.invoice.subject", number: @invoice.display_number)
)
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
diff --git a/backend/app/models/billing/club_quote.rb b/backend/app/models/billing/club_quote.rb
new file mode 100644
index 0000000..33d6401
--- /dev/null
+++ b/backend/app/models/billing/club_quote.rb
@@ -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
diff --git a/backend/app/models/billing/payment.rb b/backend/app/models/billing/payment.rb
index 51f89e5..866d7dd 100644
--- a/backend/app/models/billing/payment.rb
+++ b/backend/app/models/billing/payment.rb
@@ -2,14 +2,17 @@ module Billing
class Payment < ApplicationRecord
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
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 :currency, presence: true
validates :status, inclusion: { in: STATUSES }
+ validates :provider, inclusion: { in: PROVIDERS }
validates :stripe_invoice_id, uniqueness: true, allow_nil: true
scope :recent, -> { order(paid_at: :desc, created_at: :desc) }
@@ -62,7 +65,12 @@ module Billing
end
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
def invoice_for_display
diff --git a/backend/app/models/billing/transfer_order.rb b/backend/app/models/billing/transfer_order.rb
new file mode 100644
index 0000000..32acd31
--- /dev/null
+++ b/backend/app/models/billing/transfer_order.rb
@@ -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
diff --git a/backend/app/models/club.rb b/backend/app/models/club.rb
index ce87000..f57aef7 100644
--- a/backend/app/models/club.rb
+++ b/backend/app/models/club.rb
@@ -7,8 +7,11 @@ class Club < ApplicationRecord
has_many :teams, dependent: :destroy
has_one :youtube_credential, 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_invoices, class_name: "Billing::Invoice", dependent: :destroy
+ has_many :billing_transfer_orders, class_name: "Billing::TransferOrder", dependent: :destroy
validates :name, presence: true
validates :sport, presence: true
@@ -23,6 +26,14 @@ class Club < ApplicationRecord
club_memberships.exists?(user: user, role: "owner")
end
+ def active_billing_quote
+ billing_quote
+ end
+
+ def pending_transfer_order
+ billing_transfer_orders.awaiting_payment.first
+ end
+
private
def branding_parent
diff --git a/backend/app/models/subscription.rb b/backend/app/models/subscription.rb
index 6305f00..041d04a 100644
--- a/backend/app/models/subscription.rb
+++ b/backend/app/models/subscription.rb
@@ -35,4 +35,8 @@ class Subscription < ApplicationRecord
def admin_comped?
admin_comped
end
+
+ def bank_transfer?
+ premium? && stripe_subscription_id.blank? && !admin_comped? && current_period_end.present?
+ end
end
diff --git a/backend/app/services/billing/admin_comped_subscription.rb b/backend/app/services/billing/admin_comped_subscription.rb
index 79cab3c..6381a9c 100644
--- a/backend/app/services/billing/admin_comped_subscription.rb
+++ b/backend/app/services/billing/admin_comped_subscription.rb
@@ -23,6 +23,7 @@ module Billing
raise Error, "Piano non valido" unless @plan_slug.in?(VALID_PLANS)
release_stripe_schedule!
+ cancel_awaiting_transfers!
AssignPlan.call(
club: @club,
@@ -97,5 +98,9 @@ module Billing
ensure
sub&.update!(stripe_schedule_id: nil, pending_plan_id: nil, pending_billing_interval: nil)
end
+
+ def cancel_awaiting_transfers!
+ @club.billing_transfer_orders.awaiting_payment.find_each(&:cancel!)
+ end
end
end
diff --git a/backend/app/services/billing/attach_payment_invoice.rb b/backend/app/services/billing/attach_payment_invoice.rb
index 6e5defe..012622a 100644
--- a/backend/app/services/billing/attach_payment_invoice.rb
+++ b/backend/app/services/billing/attach_payment_invoice.rb
@@ -2,13 +2,14 @@ module Billing
class AttachPaymentInvoice
class Error < StandardError; end
- def self.call(payment:, pdf:)
- new(payment: payment, pdf: pdf).call
+ def self.call(payment:, pdf:, mailer_action: :invoice_pdf)
+ new(payment: payment, pdf: pdf, mailer_action: mailer_action).call
end
- def initialize(payment:, pdf:)
+ def initialize(payment:, pdf:, mailer_action: :invoice_pdf)
@payment = payment
@pdf = pdf
+ @mailer_action = mailer_action
end
def call
@@ -17,7 +18,7 @@ module Billing
club = @payment.club
invoice = @payment.invoice || build_invoice!(club)
- IssueInvoice.call(invoice: invoice, pdf: @pdf)
+ IssueInvoice.call(invoice: invoice, pdf: @pdf, mailer_action: @mailer_action)
end
private
diff --git a/backend/app/services/billing/confirm_bank_transfer.rb b/backend/app/services/billing/confirm_bank_transfer.rb
new file mode 100644
index 0000000..2c28475
--- /dev/null
+++ b/backend/app/services/billing/confirm_bank_transfer.rb
@@ -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
diff --git a/backend/app/services/billing/euro_amount.rb b/backend/app/services/billing/euro_amount.rb
new file mode 100644
index 0000000..955af4e
--- /dev/null
+++ b/backend/app/services/billing/euro_amount.rb
@@ -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
diff --git a/backend/app/services/billing/issue_invoice.rb b/backend/app/services/billing/issue_invoice.rb
index f310725..bdabfe2 100644
--- a/backend/app/services/billing/issue_invoice.rb
+++ b/backend/app/services/billing/issue_invoice.rb
@@ -2,13 +2,17 @@ module Billing
class IssueInvoice
class Error < StandardError; end
- def self.call(invoice:, pdf: nil)
- new(invoice: invoice, pdf: pdf).call
+ MAILER_ACTIONS = %i[invoice_pdf plan_activated_with_invoice].freeze
+
+ def self.call(invoice:, pdf: nil, mailer_action: :invoice_pdf)
+ new(invoice: invoice, pdf: pdf, mailer_action: mailer_action).call
end
- def initialize(invoice:, pdf: nil)
+ def initialize(invoice:, pdf: nil, mailer_action: :invoice_pdf)
@invoice = invoice
@pdf = pdf
+ @mailer_action = mailer_action.to_sym
+ raise Error, "Azione email non valida" unless MAILER_ACTIONS.include?(@mailer_action)
end
def call
@@ -22,7 +26,7 @@ module Billing
@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
diff --git a/backend/app/services/billing/request_bank_transfer.rb b/backend/app/services/billing/request_bank_transfer.rb
new file mode 100644
index 0000000..92197ef
--- /dev/null
+++ b/backend/app/services/billing/request_bank_transfer.rb
@@ -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
diff --git a/backend/app/services/billing/set_club_quote.rb b/backend/app/services/billing/set_club_quote.rb
new file mode 100644
index 0000000..101ed9b
--- /dev/null
+++ b/backend/app/services/billing/set_club_quote.rb
@@ -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
diff --git a/backend/app/services/billing/stripe/price_catalog.rb b/backend/app/services/billing/stripe/price_catalog.rb
index 79dfff8..b0955e0 100644
--- a/backend/app/services/billing/stripe/price_catalog.rb
+++ b/backend/app/services/billing/stripe/price_catalog.rb
@@ -86,6 +86,24 @@ module Billing
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
def charge_cents(plan_slug, interval)
@@ -96,12 +114,6 @@ module Billing
AMOUNTS.dig(plan_slug.to_s, interval, :list)
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)
case [plan_slug, interval]
when %w[premium_light monthly]
diff --git a/backend/app/views/admin/billing/index.html.erb b/backend/app/views/admin/billing/index.html.erb
index 99bfbcc..824c2d1 100644
--- a/backend/app/views/admin/billing/index.html.erb
+++ b/backend/app/views/admin/billing/index.html.erb
@@ -18,6 +18,52 @@
club: @filter_club,
subscription: @filter_club.subscription,
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 %>
+
+
<%= t("admin.billing.index.transfers_title") %>
+<%= t("admin.billing.index.transfers_description") %>
+
+<% if @pending_transfers.any? %>
+
+ <% @pending_transfers.each do |order| %>
+
+
+
+
+ <%= t("admin.billing.index.transfers_table.causal") %>:
+ <%= order.payment_causal %>
+
+ <%= form_with url: admin_billing_transfer_confirm_path(order),
+ method: :post, multipart: true, local: true, class: "billing-upload-form" do %>
+
+ <%= 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") } } %>
+
+
+ <% end %>
+
+<% else %>
+ <%= t("admin.billing.index.transfers_none") %>
<% end %>
<% if @pending_payments.any? %>
diff --git a/backend/app/views/admin/clubs/_quote_form.html.erb b/backend/app/views/admin/clubs/_quote_form.html.erb
new file mode 100644
index 0000000..a743f58
--- /dev/null
+++ b/backend/app/views/admin/clubs/_quote_form.html.erb
@@ -0,0 +1,71 @@
+<%# locals: (club:, quote:, return_to: nil) %>
+
+ <%= t("admin.quote.title") %>
+
+ <%= t("admin.quote.description") %>
+
+
+ <% if quote %>
+
+ <%= 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? %>
+
<%= t("admin.quote.note_label") %> <%= quote.note %>
+ <% end %>
+
+ <% else %>
+ <%= t("admin.quote.none") %>
+ <% 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? %>
+
+
+
+
+
+ <%= submit_tag t("admin.quote.save_button"), class: "admin-btn admin-btn--primary" %>
+
+ <% end %>
+
+ <% if quote %>
+
+ <%= 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) } } %>
+
+ <% end %>
+
diff --git a/backend/app/views/admin/clubs/index.html.erb b/backend/app/views/admin/clubs/index.html.erb
index cf05d0a..61e2d95 100644
--- a/backend/app/views/admin/clubs/index.html.erb
+++ b/backend/app/views/admin/clubs/index.html.erb
@@ -11,6 +11,7 @@
<%= t("admin.clubs.index.table.teams") %> |
<%= t("admin.clubs.index.table.comped") %> |
<%= t("admin.clubs.index.table.stripe") %> |
+ <%= t("admin.clubs.index.table.quote") %> |
|
@@ -30,6 +31,14 @@
<% end %>
<%= sub&.stripe_subscription_id.present? ? t("admin.common.yes") : t("admin.common.dash") %> |
+
+ <% quote = club.billing_quote %>
+ <% if quote %>
+ <%= quote.plan.name %> · <%= quote.formatted_amount %>
+ <% else %>
+ <%= t("admin.common.dash") %>
+ <% end %>
+ |
<%= 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) %>
diff --git a/backend/app/views/admin/clubs/show.html.erb b/backend/app/views/admin/clubs/show.html.erb
index fb90dad..12eda8c 100644
--- a/backend/app/views/admin/clubs/show.html.erb
+++ b/backend/app/views/admin/clubs/show.html.erb
@@ -9,6 +9,7 @@
<%= 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 %>
diff --git a/backend/app/views/billing/bank_transfer_mailer/instructions.html.erb b/backend/app/views/billing/bank_transfer_mailer/instructions.html.erb
new file mode 100644
index 0000000..74efa01
--- /dev/null
+++ b/backend/app/views/billing/bank_transfer_mailer/instructions.html.erb
@@ -0,0 +1,25 @@
+ <%= t("mailers.bank_transfer.instructions.hello") %>
+
+<%= raw t("mailers.bank_transfer.instructions.intro_html",
+ 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 %>
+
+
+<%= raw t("mailers.bank_transfer.instructions.proof_html", email: @proof_email) %>
+
+<%= t("mailers.bank_transfer.thanks") %> <%= t("mailers.bank_transfer.signoff") %>
diff --git a/backend/app/views/billing/bank_transfer_mailer/instructions.text.erb b/backend/app/views/billing/bank_transfer_mailer/instructions.text.erb
new file mode 100644
index 0000000..d394950
--- /dev/null
+++ b/backend/app/views/billing/bank_transfer_mailer/instructions.text.erb
@@ -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") %>
diff --git a/backend/app/views/billing/bank_transfer_mailer/plan_activated.html.erb b/backend/app/views/billing/bank_transfer_mailer/plan_activated.html.erb
new file mode 100644
index 0000000..00bf6df
--- /dev/null
+++ b/backend/app/views/billing/bank_transfer_mailer/plan_activated.html.erb
@@ -0,0 +1,10 @@
+<%= t("mailers.bank_transfer.plan_activated.hello") %>
+
+<%= 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")) %>
+
+<%= t("mailers.bank_transfer.plan_activated.invoice_later") %>
+
+<%= t("mailers.bank_transfer.thanks") %> <%= t("mailers.bank_transfer.signoff") %>
diff --git a/backend/app/views/billing/bank_transfer_mailer/plan_activated.text.erb b/backend/app/views/billing/bank_transfer_mailer/plan_activated.text.erb
new file mode 100644
index 0000000..0ab350f
--- /dev/null
+++ b/backend/app/views/billing/bank_transfer_mailer/plan_activated.text.erb
@@ -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") %>
diff --git a/backend/app/views/billing/invoice_mailer/plan_activated_with_invoice.html.erb b/backend/app/views/billing/invoice_mailer/plan_activated_with_invoice.html.erb
new file mode 100644
index 0000000..3da366b
--- /dev/null
+++ b/backend/app/views/billing/invoice_mailer/plan_activated_with_invoice.html.erb
@@ -0,0 +1,12 @@
+<%= t("mailers.invoice_activated.hello") %>
+
+<%= 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) %>
+
+<%= raw t("mailers.invoice.also_html") %>
+
+<%= t("mailers.invoice.thanks") %> <%= t("mailers.invoice.signoff") %>
diff --git a/backend/app/views/billing/invoice_mailer/plan_activated_with_invoice.text.erb b/backend/app/views/billing/invoice_mailer/plan_activated_with_invoice.text.erb
new file mode 100644
index 0000000..6806e95
--- /dev/null
+++ b/backend/app/views/billing/invoice_mailer/plan_activated_with_invoice.text.erb
@@ -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") %>
diff --git a/backend/app/views/public/clubs/billing.html.erb b/backend/app/views/public/clubs/billing.html.erb
index 0246253..5733080 100644
--- a/backend/app/views/public/clubs/billing.html.erb
+++ b/backend/app/views/public/clubs/billing.html.erb
@@ -17,11 +17,19 @@
<% else %>
- <%= render "shared/stripe_secure_payment" %>
- <% if MatchLiveTv.stripe_enabled? %>
+ <% if @quote %>
+ <%= render "shared/quoted_price_banner", quote: @quote %>
+ <% elsif MatchLiveTv.stripe_enabled? %>
+ <%= render "shared/stripe_secure_payment" %>
+ <% end %>
+ <%= render "shared/pending_bank_transfer", order: @pending_transfer %>
+ <% if MatchLiveTv.stripe_enabled? && @quote.blank? %>
<%= render "shared/plan_change_info", subscription: @subscription %>
<% end %>
<%= render "shared/plan_cards", show_stripe_portal: false %>
+ <% if @subscription&.bank_transfer? %>
+ <%= raw t("billing.bank_transfer.renewal_html") %>
+ <% end %>
<%= render "shared/subscription_cancel", club: @club, subscription: @subscription %>
<% end %>
diff --git a/backend/app/views/shared/_club_subscription_status.html.erb b/backend/app/views/shared/_club_subscription_status.html.erb
index 1f1114a..49fdba0 100644
--- a/backend/app/views/shared/_club_subscription_status.html.erb
+++ b/backend/app/views/shared/_club_subscription_status.html.erb
@@ -33,4 +33,8 @@
<% end %>
<% end %>
+<% elsif subscription&.bank_transfer? %>
+
+ <%= raw t("billing.subscription_status.bank_transfer_until_html", date: l_local(subscription.current_period_end.to_date)) %>
+
<% end %>
diff --git a/backend/app/views/shared/_pending_bank_transfer.html.erb b/backend/app/views/shared/_pending_bank_transfer.html.erb
new file mode 100644
index 0000000..a1b7942
--- /dev/null
+++ b/backend/app/views/shared/_pending_bank_transfer.html.erb
@@ -0,0 +1,15 @@
+<%# locals: (order:) %>
+<% return if order.blank? %>
+
+
+ <%= t("billing.bank_transfer.pending_title") %>
+ <%= raw t("billing.bank_transfer.pending_body_html",
+ plan: order.plan.name,
+ price: order.price_label,
+ causal: order.payment_causal) %>
+
+
+ <%= raw t("billing.bank_transfer.pending_proof_html",
+ email_link: mail_to(MatchLiveTv.bank_transfer_proof_email, MatchLiveTv.bank_transfer_proof_email)) %>
+
+
diff --git a/backend/app/views/shared/_plan_cards.html.erb b/backend/app/views/shared/_plan_cards.html.erb
index af93fb9..fd84c5d 100644
--- a/backend/app/views/shared/_plan_cards.html.erb
+++ b/backend/app/views/shared/_plan_cards.html.erb
@@ -13,7 +13,8 @@
target_plan: plan,
stripe_subscription_active: stripe_subscription_active?(subscription),
current_interval: current_interval,
- subscription: subscription
+ subscription: subscription,
+ club: club
) : nil %>
<% featured = plan.slug == "premium_full" %>
">
@@ -23,6 +24,13 @@
<%= render "shared/plan_title", plan: plan %>
<% if plan.slug == "free" %>
€0
+ <% elsif billing_mode && club&.active_billing_quote&.plan_slug == plan.slug %>
+ <% quote = club.active_billing_quote %>
+
+ "><%= quote.formatted_amount %>
+ <%= t("billing.bank_transfer.quoted_badge") %>
+ <%= t("billing.bank_transfer.quoted_interval.#{quote.billing_interval}") %>
+
<% else %>
<% yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
<% monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
@@ -63,12 +71,14 @@
<% end %>
<% if billing_mode %>
<% action_kind = action[:kind] %>
+ <% quote = club&.active_billing_quote %>
+ <% pending_transfer = club&.pending_transfer_order %>
<% if action_kind == :current %>
<%= action[:label] %>
<% elsif action_kind == :none %>
- <% elsif action_kind.in?(%i[contact disabled]) %>
+ <% elsif action_kind.in?(%i[contact disabled quoted_other]) %>
<%= action[:label] %>
- <% 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) %>
<%= billing_profile_incomplete_message(club) %>
@@ -77,14 +87,33 @@
public_club_billing_profile_path(club, plan: plan.slug),
class: "btn btn-primary" %>
<% else %>
-
- <% plan_intervals_for_display(action[:intervals]).each do |interval| %>
- <% btn_kind = action_kind == :checkout_options ? :checkout_options : action_kind %>
- <%= link_to plan_interval_button_label(plan, interval, kind: btn_kind),
- plan_interval_checkout_path(club, plan, interval),
- class: plan_interval_button_class(interval) %>
+ <% unless action_kind.in?(%i[quoted bank_only]) %>
+
+ <% plan_intervals_for_display(action[:intervals]).each do |interval| %>
+ <% btn_kind = action_kind == :checkout_options ? :checkout_options : action_kind %>
+ <%= link_to plan_interval_button_label(plan, interval, kind: btn_kind),
+ plan_interval_checkout_path(club, plan, interval),
+ class: plan_interval_button_class(interval) %>
+ <% 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]) %>
+ <%= t("billing.bank_transfer.or_label") %>
<% end %>
-
+
+ <% 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 %>
+
+ <%= t("billing.bank_transfer.hint") %>
+ <% end %>
<% end %>
<% end %>
<% elsif plan.slug == "free" %>
diff --git a/backend/app/views/shared/_quoted_price_banner.html.erb b/backend/app/views/shared/_quoted_price_banner.html.erb
new file mode 100644
index 0000000..758ed5a
--- /dev/null
+++ b/backend/app/views/shared/_quoted_price_banner.html.erb
@@ -0,0 +1,11 @@
+<%# locals: (quote:) %>
+<% return if quote.blank? %>
+
+
+ <%= t("billing.bank_transfer.quote_banner_title") %>
+ <%= raw t("billing.bank_transfer.quote_banner_body_html", plan: quote.plan.name, price: quote.price_label) %>
+
+ <% if quote.note.present? %>
+ <%= t("billing.bank_transfer.quote_note", note: quote.note) %>
+ <% end %>
+
diff --git a/backend/app/views/shared/_subscription_cancel.html.erb b/backend/app/views/shared/_subscription_cancel.html.erb
index bb777a3..976f3c4 100644
--- a/backend/app/views/shared/_subscription_cancel.html.erb
+++ b/backend/app/views/shared/_subscription_cancel.html.erb
@@ -1,5 +1,5 @@
<%# 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? %>
<% if subscription.cancel_at_period_end? %>
diff --git a/backend/config/initializers/match_live_tv.rb b/backend/config/initializers/match_live_tv.rb
index 155e07c..b738a8b 100644
--- a/backend/config/initializers/match_live_tv.rb
+++ b/backend/config/initializers/match_live_tv.rb
@@ -73,6 +73,30 @@ module MatchLiveTv
stripe_secret_key.present?
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
ENV.fetch("PRIVACY_CONTROLLER_NAME", "Emiliano Frascaro")
end
diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml
index 7fd76c3..04b6d79 100644
--- a/backend/config/locales/admin.it.yml
+++ b/backend/config/locales/admin.it.yml
@@ -44,6 +44,10 @@ it:
invoice_issued: "Fattura %{number} emessa e inviata a %{email}."
invoice_updated: "Fattura %{number} aggiornata."
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
common:
free_plan: Free
@@ -193,6 +197,7 @@ it:
teams: Squadre
comped: Omaggio
stripe: Stripe
+ quote: Concordato
manage: Gestisci
invoices: Fatture
show:
@@ -228,6 +233,22 @@ it:
reason_label: "Motivo (es. sponsor 2026)"
reason_placeholder: "Sponsor, promozione…"
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: "%{plan} · %{interval} · %{amount}"
+ 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:
show:
back: "← %{club}"
@@ -281,6 +302,21 @@ it:
index:
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."
+ 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_all: Tutte
incomplete_profile_warning: Dati fatturazione incompleti
diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml
index 217263f..fd263f4 100644
--- a/backend/config/locales/app.it.yml
+++ b/backend/config/locales/app.it.yml
@@ -334,6 +334,7 @@ it:
contact_for_free: "Per passare al piano Free, contatta il supporto."
stripe_not_configured: Stripe non configurato
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.
profile_incomplete_default: Completa i dati di fatturazione prima di attivare un piano premium.
profile_missing: "Mancano: %{fields}."
@@ -396,6 +397,7 @@ it:
end_of_period: fine periodo
next_renewal: prossimo rinnovo
next_renewal_label: "Prossimo rinnovo: %{date}"
+ bank_transfer_until_html: "Pagato con bonifico, valido fino al %{date} (nessun rinnovo automatico)."
plan_change_info:
trigger_label: Info cambio piano e fatturazione
title: Cambio piano e fatturazione
@@ -425,6 +427,23 @@ it:
prices:
yearly: "%{amount}/anno"
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 %{plan} (%{price}). Il piano si attiverà dopo la conferma del pagamento. Causale: %{causal}."
+ pending_proof_html: "Invia la distinta a %{email_link}."
+ quote_banner_title: Prezzo concordato
+ quote_banner_body_html: "Per questa società vale %{plan} a %{price}. 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:
meta_title_fallback: "Regia — Match Live TV"
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."
stripe_error: "Errore Stripe: %{message}"
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:
not_authorized: Non autorizzato
teams:
diff --git a/backend/config/locales/mailers.en.yml b/backend/config/locales/mailers.en.yml
index de38023..bf7069d 100644
--- a/backend/config/locales/mailers.en.yml
+++ b/backend/config/locales/mailers.en.yml
@@ -54,3 +54,34 @@ en:
also_text: It is also available under Subscription on the Match Live TV site.
thanks: Thanks,
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 %{plan} plan is now active for %{club} until %{until_date}. Invoice %{number} for %{amount} 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 %{plan} plan (%{price}) for %{club} 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 %{email}."
+ 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 %{plan} plan is now active for %{club} until %{until_date}. 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.
+
diff --git a/backend/config/locales/mailers.it.yml b/backend/config/locales/mailers.it.yml
index 29075ba..a5e61b6 100644
--- a/backend/config/locales/mailers.it.yml
+++ b/backend/config/locales/mailers.it.yml
@@ -54,3 +54,34 @@ it:
also_text: È disponibile anche in Abbonamento sul sito Match Live TV.
thanks: Grazie,
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 %{plan} è attivo per %{club} fino al %{until_date}. In allegato la fattura %{number} di %{amount}."
+ 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 %{plan} (%{price}) per %{club} 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 %{email}."
+ 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 %{plan} è attivo per %{club} fino al %{until_date}. 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.
+
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 8f1b6f7..db539bb 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -93,11 +93,15 @@ Rails.application.routes.draw do
end
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/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 :clubs, only: %i[index show] do
member do
post :grant_comped
delete :revoke_comped
+ post :set_quote
+ delete :revoke_quote
end
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
@@ -198,6 +202,7 @@ Rails.application.routes.draw do
get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_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/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/checkout", to: "clubs#checkout", as: :club_checkout
post "clubs/:id/portal", to: "clubs#portal", as: :club_portal
diff --git a/backend/db/migrate/20260818220000_add_bank_transfer_billing.rb b/backend/db/migrate/20260818220000_add_bank_transfer_billing.rb
new file mode 100644
index 0000000..a6e0d12
--- /dev/null
+++ b/backend/db/migrate/20260818220000_add_bank_transfer_billing.rb
@@ -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
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index 77653d6..13c6f08 100644
--- a/backend/db/schema.rb
+++ b/backend/db/schema.rb
@@ -10,7 +10,7 @@
#
# 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
enable_extension "pgcrypto"
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
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|
t.uuid "club_id", null: false
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.datetime "created_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"], 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)"
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|
t.uuid "user_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_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", "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", "users"
add_foreign_key "device_states", "stream_sessions"
diff --git a/backend/spec/jobs/expire_ended_subscriptions_job_spec.rb b/backend/spec/jobs/expire_ended_subscriptions_job_spec.rb
new file mode 100644
index 0000000..d3f3be6
--- /dev/null
+++ b/backend/spec/jobs/expire_ended_subscriptions_job_spec.rb
@@ -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
diff --git a/backend/spec/requests/admin/bank_transfer_spec.rb b/backend/spec/requests/admin/bank_transfer_spec.rb
new file mode 100644
index 0000000..da6ad2c
--- /dev/null
+++ b/backend/spec/requests/admin/bank_transfer_spec.rb
@@ -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
diff --git a/backend/spec/requests/public/club_bank_transfer_spec.rb b/backend/spec/requests/public/club_bank_transfer_spec.rb
new file mode 100644
index 0000000..1e996fe
--- /dev/null
+++ b/backend/spec/requests/public/club_bank_transfer_spec.rb
@@ -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
diff --git a/backend/spec/services/billing/confirm_bank_transfer_spec.rb b/backend/spec/services/billing/confirm_bank_transfer_spec.rb
new file mode 100644
index 0000000..383c785
--- /dev/null
+++ b/backend/spec/services/billing/confirm_bank_transfer_spec.rb
@@ -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
diff --git a/backend/spec/services/billing/request_bank_transfer_spec.rb b/backend/spec/services/billing/request_bank_transfer_spec.rb
new file mode 100644
index 0000000..7068db0
--- /dev/null
+++ b/backend/spec/services/billing/request_bank_transfer_spec.rb
@@ -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
diff --git a/backend/spec/services/billing/set_club_quote_spec.rb b/backend/spec/services/billing/set_club_quote_spec.rb
new file mode 100644
index 0000000..5b43838
--- /dev/null
+++ b/backend/spec/services/billing/set_club_quote_spec.rb
@@ -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
diff --git a/infra/.env.collaudo.example b/infra/.env.collaudo.example
index f342d10..f3e19ff 100644
--- a/infra/.env.collaudo.example
+++ b/infra/.env.collaudo.example
@@ -31,6 +31,13 @@ STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
STRIPE_PREMIUM_LIGHT_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
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
diff --git a/infra/.env.example b/infra/.env.example
index a7b7fd5..8c139d4 100644
--- a/infra/.env.example
+++ b/infra/.env.example
@@ -37,6 +37,13 @@ STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
STRIPE_PREMIUM_LIGHT_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) ---
# 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.
diff --git a/infra/.env.production.example b/infra/.env.production.example
index e995fa0..c7a62af 100644
--- a/infra/.env.production.example
+++ b/infra/.env.production.example
@@ -33,6 +33,13 @@ STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
STRIPE_PREMIUM_LIGHT_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
# Titolare trattamento (GDPR) — obbligatorio in produzione
diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml
index 69d81dd..a6dabe4 100644
--- a/infra/docker-compose.prod.yml
+++ b/infra/docker-compose.prod.yml
@@ -124,6 +124,11 @@ services:
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_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
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-}
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index 4785bdd..bf5176c 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -131,6 +131,11 @@ services:
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_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
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900}
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
|