diff --git a/backend/app/controllers/admin/clubs_controller.rb b/backend/app/controllers/admin/clubs_controller.rb new file mode 100644 index 0000000..08570eb --- /dev/null +++ b/backend/app/controllers/admin/clubs_controller.rb @@ -0,0 +1,45 @@ +module Admin + class ClubsController < BaseController + before_action :set_club, only: %i[show grant_comped revoke_comped] + + def index + @clubs = Club.includes(:subscription, subscription: :plan, subscription: :admin_comped_by) + .order(:name) + end + + def show + @subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active") + @plans = Plan.ordered.reject { |p| p.slug == "free" } + end + + def grant_comped + Billing::AdminCompedSubscription.grant( + club: @club, + plan_slug: params.require(:plan_slug), + reason: params[:reason], + admin: current_admin_account + ) + redirect_back_or_club notice: "Abbonamento omaggio #{Plan[params[:plan_slug]].name} attivato per #{@club.name}." + rescue Billing::AdminCompedSubscription::Error, ActiveRecord::RecordInvalid => e + redirect_back_or_club alert: e.message + end + + def revoke_comped + Billing::AdminCompedSubscription.revoke(club: @club, admin: current_admin_account) + redirect_back_or_club notice: "Abbonamento omaggio revocato per #{@club.name}." + rescue Billing::AdminCompedSubscription::Error => e + redirect_back_or_club alert: e.message + end + + private + + def set_club + @club = Club.find(params[:id]) + end + + def redirect_back_or_club(notice: nil, alert: nil) + target = params[:return_to].presence || admin_club_path(@club) + redirect_to target, notice: notice, alert: alert + end + end +end diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb index 7f29262..971933e 100644 --- a/backend/app/controllers/public/clubs_controller.rb +++ b/backend/app/controllers/public/clubs_controller.rb @@ -85,6 +85,12 @@ module Public def checkout require_club_owner!(@club) + if @club.subscription&.admin_comped? + redirect_to public_club_billing_path(@club), + alert: "Il piano è un abbonamento omaggio gestito da Match Live TV. Per passare a un abbonamento a pagamento contatta il supporto." + return + end + unless MatchLiveTv.stripe_enabled? redirect_to public_club_billing_path(@club), alert: "Pagamenti non ancora configurati sul server" return diff --git a/backend/app/models/subscription.rb b/backend/app/models/subscription.rb index 22622aa..6305f00 100644 --- a/backend/app/models/subscription.rb +++ b/backend/app/models/subscription.rb @@ -5,6 +5,7 @@ class Subscription < ApplicationRecord belongs_to :club belongs_to :plan belongs_to :pending_plan, class_name: "Plan", optional: true + belongs_to :admin_comped_by, class_name: "AdminAccount", optional: true validates :status, inclusion: { in: STATUSES } validates :club_id, uniqueness: true @@ -30,4 +31,8 @@ class Subscription < ApplicationRecord def plan_change_pending? pending_plan_id.present? end + + def admin_comped? + admin_comped + end end diff --git a/backend/app/services/billing/admin_comped_subscription.rb b/backend/app/services/billing/admin_comped_subscription.rb new file mode 100644 index 0000000..79cab3c --- /dev/null +++ b/backend/app/services/billing/admin_comped_subscription.rb @@ -0,0 +1,101 @@ +module Billing + class AdminCompedSubscription + class Error < StandardError; end + + VALID_PLANS = %w[premium_light premium_full].freeze + + def self.grant(club:, plan_slug:, reason:, admin:) + new(club: club, plan_slug: plan_slug, reason: reason, admin: admin).grant + end + + def self.revoke(club:, admin:) + new(club: club, admin: admin).revoke + end + + def initialize(club:, plan_slug: nil, reason: nil, admin: nil) + @club = club + @plan_slug = plan_slug.to_s.presence + @reason = reason.to_s.strip.presence + @admin = admin + end + + def grant + raise Error, "Piano non valido" unless @plan_slug.in?(VALID_PLANS) + + release_stripe_schedule! + + AssignPlan.call( + club: @club, + plan_slug: @plan_slug, + status: "active", + stripe_attrs: comped_attrs.merge( + pending_plan_id: nil, + pending_billing_interval: nil, + stripe_schedule_id: nil, + cancel_at_period_end: false + ) + ) + end + + def revoke + sub = @club.subscription + raise Error, "Nessun abbonamento omaggio attivo" unless sub&.admin_comped? + + if sub.stripe_subscription_id.present? && MatchLiveTv.stripe_enabled? + stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id) + if stripe_sub.status.in?(%w[active trialing past_due]) + Billing::Stripe::CheckoutSync.from_subscription(stripe_sub, club: @club) + clear_comped_flags!(sub.reload) + return sub + end + end + + AssignPlan.call( + club: @club, + plan_slug: "free", + status: "active", + stripe_attrs: { + admin_comped: false, + admin_comped_reason: nil, + admin_comped_at: nil, + admin_comped_by_id: nil, + pending_plan_id: nil, + pending_billing_interval: nil, + stripe_schedule_id: nil + } + ) + end + + private + + def comped_attrs + { + admin_comped: true, + admin_comped_reason: @reason, + admin_comped_at: Time.current, + admin_comped_by_id: @admin&.id + } + end + + def clear_comped_flags!(sub) + sub.update!( + admin_comped: false, + admin_comped_reason: nil, + admin_comped_at: nil, + admin_comped_by_id: nil + ) + end + + def release_stripe_schedule! + sub = @club.subscription + return if sub.blank? || sub.stripe_schedule_id.blank? + return unless MatchLiveTv.stripe_enabled? + + ::Stripe::SubscriptionSchedule.release(sub.stripe_schedule_id) + rescue ::Stripe::InvalidRequestError + nil + ensure + sub&.update!(stripe_schedule_id: nil, pending_plan_id: nil, pending_billing_interval: nil) + end + end +end diff --git a/backend/app/services/billing/stripe/change_plan.rb b/backend/app/services/billing/stripe/change_plan.rb index ff8ea30..71a498e 100644 --- a/backend/app/services/billing/stripe/change_plan.rb +++ b/backend/app/services/billing/stripe/change_plan.rb @@ -18,6 +18,7 @@ module Billing raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled? raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug) + raise "Abbonamento omaggio: contatta il supporto per cambiare piano a pagamento" if @sub&.admin_comped? raise "Nessun abbonamento Stripe attivo" if @sub.blank? || @sub.stripe_subscription_id.blank? interval = resolve_interval(@sub) diff --git a/backend/app/services/billing/stripe/checkout_sync.rb b/backend/app/services/billing/stripe/checkout_sync.rb index 684daa4..32011d7 100644 --- a/backend/app/services/billing/stripe/checkout_sync.rb +++ b/backend/app/services/billing/stripe/checkout_sync.rb @@ -70,6 +70,8 @@ module Billing PriceCatalog::DEFAULT_INTERVAL sub = club.subscription + return sub if sub&.admin_comped? + clear_pending = sub&.pending_plan_id.present? && (sub.pending_plan.slug == plan.slug || infer_plan_slug_from_price(stripe_sub) == sub.pending_plan.slug) diff --git a/backend/app/services/billing/stripe/finalize_subscription.rb b/backend/app/services/billing/stripe/finalize_subscription.rb index 20a1891..b781ae7 100644 --- a/backend/app/services/billing/stripe/finalize_subscription.rb +++ b/backend/app/services/billing/stripe/finalize_subscription.rb @@ -14,6 +14,7 @@ module Billing sub = @club.subscription return false unless sub&.stripe_subscription_id.present? return false if sub.free? + return false if sub.admin_comped? stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id) diff --git a/backend/app/views/admin/billing/index.html.erb b/backend/app/views/admin/billing/index.html.erb index e547685..7fa8644 100644 --- a/backend/app/views/admin/billing/index.html.erb +++ b/backend/app/views/admin/billing/index.html.erb @@ -13,6 +13,13 @@
<% end %> +<% if @filter_club %> + <%= render "admin/clubs/comped_form", + club: @filter_club, + subscription: @filter_club.subscription, + return_to: admin_billing_path(club_id: @filter_club.id) %> +<% end %> + <% if @pending_payments.any? %>+ Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento. +
+ + <% sub = subscription %> + <% if sub&.admin_comped? %> +
+ Attivo: <%= sub.plan.name %>
+ <% if sub.admin_comped_reason.present? %>
+ · <%= sub.admin_comped_reason %>
+ <% end %>
+ <% if sub.admin_comped_at.present? %>
+
Dal <%= l(sub.admin_comped_at, format: :long) %>
+ <% if sub.admin_comped_by.present? %> · admin <%= sub.admin_comped_by.username %><% end %>
+
+ <% end %>
+ <% if sub.stripe_subscription_id.present? %>
+
Nota: esiste anche un abbonamento Stripe collegato; l’omaggio ha priorità sul piano.
+ <% end %>
+
+ Piano attuale: <%= sub&.plan&.name || "Free" %> + <% if sub&.stripe_subscription_id.present? %> + · pagamento Stripe attivo + <% end %> +
+ <%= form_with url: grant_comped_admin_club_path(club), method: :post, local: true, class: "admin-comped-form" do %> + <%= hidden_field_tag :return_to, return_to if return_to.present? %> ++ Gestisci abbonamenti omaggio (sponsor / promozioni). Per pagamenti Stripe usa <%= link_to "Fatturazione", admin_billing_path %>. +
+ +| Società | +Piano | +Omaggio | +Stripe | ++ |
|---|---|---|---|---|
| <%= club.name %> | +<%= sub&.plan&.name || "Free" %> | ++ <% if sub&.admin_comped? %> + Sì + <% if sub.admin_comped_reason.present? %> · <%= sub.admin_comped_reason %><% end %> + <% else %> + — + <% end %> + | +<%= sub&.stripe_subscription_id.present? ? "Sì" : "—" %> | ++ <%= link_to "Gestisci", admin_club_path(club) %> + · <%= link_to "Fatture", admin_billing_path(club_id: club.id) %> + | +
<%= link_to "← Tutte le società", admin_clubs_path %>
+ ++ Sport: <%= @club.sport %> + · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @club.id) %> +
+ +<%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %> + ++ Abbonamento omaggio Match Live TV + — piano <%= @subscription.plan.name %> + <% if @subscription.admin_comped_reason.present? %> + (<%= @subscription.admin_comped_reason %>) + <% end %>. + Non è richiesto alcun pagamento. Per modifiche o passaggio a un piano a pagamento, contatta + <%= mail_to "info@matchlive.it", "info@matchlive.it" %>. +
+
Società: <%= club.name %>
· Piano attuale: <%= current_plan.name %>
+ <% if subscription&.admin_comped? %>
+ (omaggio Match Live TV)
+ <% end %>
(valido per tutte le squadre)
<% unless local_assigns[:on_billing_page] %>
· <%= link_to "Gestisci abbonamento", public_club_billing_path(club) %>
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 850eb76..1a72663 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -68,7 +68,11 @@ Rails.application.routes.draw do
get "billing", to: "billing#index", as: :billing
post "billing/payments/:payment_id/attach_pdf", to: "billing#attach_pdf", as: :billing_payment_attach_pdf
resources :teams, only: %i[index show]
- resources :clubs, only: [] do
+ resources :clubs, only: %i[index show] do
+ member do
+ post :grant_comped
+ delete :revoke_comped
+ end
resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices"
end
resources :sessions, only: %i[index show] do
diff --git a/backend/db/migrate/20260603120000_add_admin_comped_to_subscriptions.rb b/backend/db/migrate/20260603120000_add_admin_comped_to_subscriptions.rb
new file mode 100644
index 0000000..c6c3225
--- /dev/null
+++ b/backend/db/migrate/20260603120000_add_admin_comped_to_subscriptions.rb
@@ -0,0 +1,10 @@
+class AddAdminCompedToSubscriptions < ActiveRecord::Migration[7.2]
+ def change
+ change_table :subscriptions, bulk: true do |t|
+ t.boolean :admin_comped, null: false, default: false
+ t.string :admin_comped_reason
+ t.datetime :admin_comped_at
+ t.references :admin_comped_by, type: :uuid, foreign_key: { to_table: :admin_accounts }
+ end
+ end
+end
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index 9bed993..0cd4f4a 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_06_02_120000) do
+ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -253,6 +253,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_02_120000) do
t.uuid "pending_plan_id"
t.string "pending_billing_interval"
t.string "stripe_schedule_id"
+ t.boolean "admin_comped", default: false, null: false
+ t.string "admin_comped_reason"
+ t.datetime "admin_comped_at"
+ t.uuid "admin_comped_by_id"
+ t.index ["admin_comped_by_id"], name: "index_subscriptions_on_admin_comped_by_id"
t.index ["club_id"], name: "index_subscriptions_on_club_id", unique: true
t.index ["pending_plan_id"], name: "index_subscriptions_on_pending_plan_id"
t.index ["plan_id"], name: "index_subscriptions_on_plan_id"
@@ -355,6 +360,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_02_120000) do
add_foreign_key "stream_events", "stream_sessions"
add_foreign_key "stream_sessions", "matches"
add_foreign_key "stream_sessions", "users"
+ add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
add_foreign_key "subscriptions", "clubs"
add_foreign_key "subscriptions", "plans"
add_foreign_key "subscriptions", "plans", column: "pending_plan_id"
diff --git a/backend/public/admin.css b/backend/public/admin.css
index b0ea784..fc59c29 100644
--- a/backend/public/admin.css
+++ b/backend/public/admin.css
@@ -233,6 +233,24 @@ body.admin-body {
align-items: center;
}
+.admin-input {
+ padding: 0.45rem 0.6rem;
+ border: 1px solid #444;
+ border-radius: 6px;
+ background: #1a1a22;
+ color: #eee;
+ font-size: 0.9rem;
+}
+
+.admin-btn--secondary {
+ background: #333;
+ color: #eee;
+}
+
+.admin-btn--secondary:hover {
+ background: #444;
+}
+
.admin-btn {
display: inline-block;
padding: 0.45rem 0.9rem;
diff --git a/backend/spec/services/billing/admin_comped_subscription_spec.rb b/backend/spec/services/billing/admin_comped_subscription_spec.rb
new file mode 100644
index 0000000..938546a
--- /dev/null
+++ b/backend/spec/services/billing/admin_comped_subscription_spec.rb
@@ -0,0 +1,36 @@
+require "rails_helper"
+
+RSpec.describe Billing::AdminCompedSubscription do
+ let(:admin) { AdminAccount.create!(username: "ops", password: "secret123") }
+ let(:club) { Club.create!(name: "Sponsor FC", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
+
+ before do
+ load Rails.root.join("db/seeds/plans.rb")
+ Billing::AssignPlan.call(club: club, plan_slug: "free")
+ end
+
+ it "concede premium full senza stripe" do
+ described_class.grant(
+ club: club,
+ plan_slug: "premium_full",
+ reason: "Sponsor stagione 2026",
+ admin: admin
+ )
+
+ sub = club.reload.subscription
+ expect(sub.plan.slug).to eq("premium_full")
+ expect(sub.admin_comped?).to be true
+ expect(sub.admin_comped_reason).to eq("Sponsor stagione 2026")
+ expect(sub.admin_comped_by).to eq(admin)
+ end
+
+ it "revoca omaggio e torna a free" do
+ described_class.grant(club: club, plan_slug: "premium_light", reason: "Promo", admin: admin)
+
+ described_class.revoke(club: club, admin: admin)
+
+ sub = club.reload.subscription
+ expect(sub.plan.slug).to eq("free")
+ expect(sub.admin_comped?).to be false
+ end
+end
diff --git a/mobile/linux/flutter/generated_plugin_registrant.cc b/mobile/linux/flutter/generated_plugin_registrant.cc
index f6f23bf..3792af4 100644
--- a/mobile/linux/flutter/generated_plugin_registrant.cc
+++ b/mobile/linux/flutter/generated_plugin_registrant.cc
@@ -6,9 +6,13 @@
#include "generated_plugin_registrant.h"
+#include