diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb index 47e1fbd..b79f2ee 100644 --- a/backend/app/controllers/public/clubs_controller.rb +++ b/backend/app/controllers/public/clubs_controller.rb @@ -83,17 +83,15 @@ module Public end plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light" - target_plan = Plan[plan_slug] sub = @club.subscription - - if sub&.stripe_subscription_id.present? && sub.active? && sub.plan.slug != plan_slug - Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug) - redirect_to public_club_billing_path(@club), - notice: "Piano aggiornato a #{target_plan.name}. L'eventuale differenza verrà addebitata o accreditata da Stripe." - else - url = Billing::Stripe::CheckoutSession.new(club: @club, user: current_user, plan_slug: plan_slug).url - redirect_to url, allow_other_host: true + if sub&.stripe_subscription_id.present? && sub.active? && sub.plan.slug == plan_slug + redirect_to public_club_billing_path(@club), notice: "Sei già su questo piano." + return end + + # Sempre Stripe Checkout (hosted): i dati carta non passano mai dal nostro server. + url = Billing::Stripe::CheckoutSession.new(club: @club, user: current_user, plan_slug: plan_slug).url + redirect_to url, allow_other_host: true rescue ::Stripe::StripeError => e Rails.logger.warn("[Stripe checkout] #{e.message}") redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}" diff --git a/backend/app/services/billing/stripe/change_plan.rb b/backend/app/services/billing/stripe/change_plan.rb index c4c99b5..0cff7ff 100644 --- a/backend/app/services/billing/stripe/change_plan.rb +++ b/backend/app/services/billing/stripe/change_plan.rb @@ -1,5 +1,8 @@ module Billing module Stripe + # Solo per sincronizzazione interna/test mockati. + # I cambi piano utente devono passare da CheckoutSession (Stripe Checkout hosted). + # Non creare mai PaymentMethod con card[number] sul server (violazione PCI). class ChangePlan VALID_PLANS = CheckoutSession::VALID_PLANS.freeze diff --git a/backend/app/services/billing/stripe/checkout_session.rb b/backend/app/services/billing/stripe/checkout_session.rb index 1ee4fb3..24ba201 100644 --- a/backend/app/services/billing/stripe/checkout_session.rb +++ b/backend/app/services/billing/stripe/checkout_session.rb @@ -18,15 +18,29 @@ module Billing raise "Price ID Stripe mancante per #{@plan_slug}" if price_id.blank? customer_id = ensure_customer_id! - session = ::Stripe::Checkout::Session.create( + CustomerSync.call(club: @club, customer_id: customer_id) + + session_params = { mode: "subscription", customer: customer_id, line_items: [{ price: price_id, quantity: 1 }], success_url: success_url, cancel_url: cancel_url, metadata: { club_id: @club.id, user_id: @user.id, plan_slug: @plan_slug }, - subscription_data: { metadata: { club_id: @club.id, plan_slug: @plan_slug } } - ) + subscription_data: { metadata: { club_id: @club.id, plan_slug: @plan_slug } }, + billing_address_collection: "required", + customer_update: { address: "auto", name: "auto" }, + allow_promotion_codes: true + } + + existing_subscription_id = active_stripe_subscription_id + if existing_subscription_id.present? + # Cambio piano: conferma su pagina Stripe (mai dati carta sul nostro server). + session_params[:subscription] = existing_subscription_id + session_params.delete(:subscription_data) + end + + session = ::Stripe::Checkout::Session.create(session_params) session.url end @@ -34,9 +48,10 @@ module Billing raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled? customer_id = ensure_customer_id! + CustomerSync.call(club: @club, customer_id: customer_id) session = ::Stripe::BillingPortal::Session.create( customer: customer_id, - return_url: dashboard_url + return_url: billing_url ) session.url end @@ -50,13 +65,20 @@ module Billing end end + def active_stripe_subscription_id + sub = @club.subscription + return nil unless sub&.stripe_subscription_id.present? && sub.active? + + sub.stripe_subscription_id + end + def ensure_customer_id! sub = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active") return sub.stripe_customer_id if sub.stripe_customer_id.present? customer = ::Stripe::Customer.create( email: @user.email, - name: @club.name, + name: @club.billing_legal_name.presence || @club.name, metadata: { club_id: @club.id } ) sub.update!(stripe_customer_id: customer.id) @@ -74,10 +96,6 @@ module Billing def billing_url "#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing" end - - def dashboard_url - "#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}" - end end end end diff --git a/backend/app/services/billing/stripe/customer_sync.rb b/backend/app/services/billing/stripe/customer_sync.rb new file mode 100644 index 0000000..ab3a1e8 --- /dev/null +++ b/backend/app/services/billing/stripe/customer_sync.rb @@ -0,0 +1,41 @@ +module Billing + module Stripe + # Sincronizza solo dati di fatturazione (mai dati carta) sul Customer Stripe. + class CustomerSync + def self.call(club:, customer_id:) + new(club: club, customer_id: customer_id).call + end + + def initialize(club:, customer_id:) + @club = club + @customer_id = customer_id + end + + def call + return @customer_id if @customer_id.blank? + + ::Stripe::Customer.update(@customer_id, customer_attributes) + @customer_id + end + + private + + def customer_attributes + attrs = { + name: @club.billing_legal_name.presence || @club.name, + email: @club.billing_email.presence, + metadata: { club_id: @club.id }, + address: { + line1: @club.billing_address_line, + city: @club.billing_city, + state: @club.billing_province, + postal_code: @club.billing_postal_code, + country: @club.billing_country.presence || "IT" + } + } + attrs[:address] = nil if attrs[:address].values.all?(&:blank?) + attrs.compact + end + end + end +end diff --git a/backend/app/views/public/regia/show.html.erb b/backend/app/views/public/regia/show.html.erb index cad6e95..0a184d6 100644 --- a/backend/app/views/public/regia/show.html.erb +++ b/backend/app/views/public/regia/show.html.erb @@ -43,7 +43,7 @@ <% unless @stream_closed %> <% end %> -
> +
<%= @stream_closed ? "Diretta terminata" : "Anteprima in attesa del segnale…" %>
@@ -94,4 +94,4 @@ - + diff --git a/backend/app/views/public/teams/billing.html.erb b/backend/app/views/public/teams/billing.html.erb index 7420704..ed0737e 100644 --- a/backend/app/views/public/teams/billing.html.erb +++ b/backend/app/views/public/teams/billing.html.erb @@ -29,7 +29,7 @@ <% elsif plan.slug == "free" %>

Contatta il supporto per downgrade.

<% elsif MatchLiveTv.stripe_enabled? %> - <%= link_to "Attiva #{plan.name}", public_team_checkout_path(@team, plan: plan.slug), class: "btn btn-primary" %> + <%= link_to "Attiva #{plan.name}", public_club_checkout_path(@team.club, plan: plan.slug), class: "btn btn-primary" %> <% else %>

Stripe non configurato

<% end %> @@ -42,7 +42,7 @@ <% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %> -

<%= button_to "Gestisci su Stripe", public_team_portal_path(@team), class: "btn btn-secondary" %>

+

<%= button_to "Gestisci su Stripe", public_club_portal_path(@team.club), class: "btn btn-secondary" %>

<% end %>

<%= link_to "← Dettagli squadra", public_team_details_path(@team) %>

diff --git a/backend/config/initializers/stripe_pci_guard.rb b/backend/config/initializers/stripe_pci_guard.rb new file mode 100644 index 0000000..6c64ba8 --- /dev/null +++ b/backend/config/initializers/stripe_pci_guard.rb @@ -0,0 +1,55 @@ +# Blocca l'invio accidentale di numeri di carta completi all'API Stripe dal backend. +# I dati carta devono essere raccolti solo da Stripe Checkout / Elements lato browser. +module StripePciGuard + RAW_CARD_KEYS = %w[number cvc exp_month exp_year].freeze + + def self.raw_card_hash?(value) + return false unless value.is_a?(Hash) + + key_set = value.keys.map(&:to_s) + (key_set & RAW_CARD_KEYS).any? + end + + def self.guard_params!(params) + return unless params.is_a?(Hash) + + type = params[:type] || params["type"] + card = params[:card] || params["card"] + payment_method_data = params[:payment_method_data] || params["payment_method_data"] + + if type.to_s == "card" && raw_card_hash?(card) + raise_security_error! + end + + pmd_card = payment_method_data.is_a?(Hash) ? (payment_method_data[:card] || payment_method_data["card"]) : nil + raise_security_error! if raw_card_hash?(pmd_card) + end + + def self.raise_security_error! + raise SecurityError, + "PCI: non inviare numeri di carta completi all'API Stripe dal server. " \ + "Usa Stripe Checkout (redirect) o Stripe Elements lato client." + end +end + +if defined?(Stripe::PaymentMethod) + Stripe::PaymentMethod.singleton_class.prepend( + Module.new do + def create(params = {}, opts = {}) + StripePciGuard.guard_params!(params) + super + end + end + ) +end + +if defined?(Stripe::Token) + Stripe::Token.singleton_class.prepend( + Module.new do + def create(params = {}, opts = {}) + StripePciGuard.guard_params!(params) + super + end + end + ) +end diff --git a/backend/public/regia.css b/backend/public/regia.css index ef23b11..03fa9a5 100644 --- a/backend/public/regia.css +++ b/backend/public/regia.css @@ -61,6 +61,8 @@ } .regia-preview video { + position: relative; + z-index: 1; width: 100%; height: 100%; object-fit: cover; @@ -70,6 +72,7 @@ .regia-preview__placeholder { position: absolute; inset: 0; + z-index: 2; display: flex; align-items: center; justify-content: center; @@ -77,6 +80,11 @@ font-size: 0.88rem; text-align: center; padding: 16px; + pointer-events: none; +} + +.regia-preview__placeholder[hidden] { + display: none !important; } .regia-scoreboard { diff --git a/backend/public/regia.js b/backend/public/regia.js index eb4616e..ca9b9b9 100644 --- a/backend/public/regia.js +++ b/backend/public/regia.js @@ -35,6 +35,7 @@ let pendingAction = null; let hls = null; + let previewStarted = false; function toast(msg) { els.toast.textContent = msg; @@ -156,6 +157,22 @@ }); }); + function syncPreviewFromStatus(data) { + if (data.stream_closed) { + cfg.streamClosed = true; + stopPreview(); + return; + } + + if (data.on_air) { + if (!previewStarted) startPreview(); + else if (els.preview && els.preview.readyState >= 2) hidePreviewPlaceholder(); + } else { + destroyPreview(); + showPreviewPlaceholder("Anteprima in attesa del segnale…"); + } + } + async function pollStatus() { try { const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } }); @@ -163,38 +180,52 @@ const data = await res.json(); applyScorePayload(data); setBadge(data); - if (data.stream_closed) { - cfg.streamClosed = true; - stopPreview(); - } + syncPreviewFromStatus(data); } catch (_) {} } - function initPreview() { - if (cfg.streamClosed || !els.preview || !cfg.hlsUrl) return; - - if (els.preview.canPlayType("application/vnd.apple.mpegurl")) { - els.preview.src = cfg.hlsUrl; - } else if (window.Hls && Hls.isSupported()) { - hls = new Hls({ maxBufferLength: 8 }); - hls.loadSource(cfg.hlsUrl); - hls.attachMedia(els.preview); - } - els.preview.addEventListener("playing", () => { - if (els.previewPlaceholder) els.previewPlaceholder.hidden = true; + function bindPreviewEvents() { + if (!els.preview) return; + ["playing", "loadeddata", "canplay"].forEach((ev) => { + els.preview.addEventListener(ev, hidePreviewPlaceholder); }); } + function startPreview() { + if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return; + + previewStarted = true; + const url = hlsUrlFresh(); + + if (window.Hls && Hls.isSupported()) { + hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); + hls.loadSource(url); + hls.attachMedia(els.preview); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + els.preview.play().catch(() => {}); + }); + hls.on(Hls.Events.ERROR, (_, err) => { + if (err.fatal) { + destroyPreview(); + setTimeout(() => { + if (!cfg.streamClosed) startPreview(); + }, 2500); + } + }); + } else if (els.preview.canPlayType("application/vnd.apple.mpegurl")) { + els.preview.src = url; + els.preview.addEventListener( + "loadedmetadata", + () => els.preview.play().catch(() => {}), + { once: true } + ); + } + } + function stopPreview() { - if (hls) { - hls.destroy(); - hls = null; - } - if (els.preview) els.preview.remove(); - if (els.previewPlaceholder) { - els.previewPlaceholder.hidden = false; - els.previewPlaceholder.textContent = "Diretta terminata"; - } + destroyPreview(); + if (els.preview) els.preview.style.display = "none"; + showPreviewPlaceholder("Diretta terminata"); } async function pauseStream() { @@ -246,7 +277,11 @@ document.getElementById("btn-share")?.addEventListener("click", () => shareLink()); document.getElementById("btn-copy")?.addEventListener("click", () => copyLink()); - initPreview(); + bindPreviewEvents(); + if (!cfg.streamClosed && cfg.hlsUrl) { + pollStatus(); + } else if (cfg.streamClosed) { + stopPreview(); + } setInterval(pollStatus, 4000); - pollStatus(); })(); diff --git a/backend/spec/config/stripe_pci_guard_spec.rb b/backend/spec/config/stripe_pci_guard_spec.rb new file mode 100644 index 0000000..f6f6571 --- /dev/null +++ b/backend/spec/config/stripe_pci_guard_spec.rb @@ -0,0 +1,20 @@ +require "rails_helper" + +RSpec.describe StripePciGuard do + it "rileva hash carta grezza" do + expect(described_class.raw_card_hash?({ number: "4242424242424242" })).to be true + expect(described_class.raw_card_hash?({ exp_month: 12 })).to be true + expect(described_class.raw_card_hash?({ token: "tok_visa" })).to be false + end + + it "blocca PaymentMethod.create con numero carta" do + skip "Stripe gem non caricata" unless defined?(Stripe::PaymentMethod) + + expect { + Stripe::PaymentMethod.create( + type: "card", + card: { number: "4242424242424242", exp_month: 12, exp_year: 2034, cvc: "123" } + ) + }.to raise_error(SecurityError, /PCI/) + end +end diff --git a/backend/spec/services/billing/stripe/checkout_session_spec.rb b/backend/spec/services/billing/stripe/checkout_session_spec.rb new file mode 100644 index 0000000..3d8161b --- /dev/null +++ b/backend/spec/services/billing/stripe/checkout_session_spec.rb @@ -0,0 +1,63 @@ +require "rails_helper" + +RSpec.describe Billing::Stripe::CheckoutSession do + let(:user) { User.create!(email: "checkout@test.it", name: "Owner", password: "password123", role: "coach") } + let(:club) do + Club.create!( + name: "Checkout Club", sport: "volleyball", + primary_color: "#e53935", secondary_color: "#ffffff", + billing_legal_name: "ASD Checkout", billing_email: "bill@test.it", + billing_address_line: "Via 1", billing_city: "Milano", billing_postal_code: "20100", + billing_vat_number: "12345678901", billing_recipient_code: "ABCDEFG" + ) + end + + before do + load Rails.root.join("db/seeds/plans.rb") + allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true) + allow(MatchLiveTv).to receive(:stripe_premium_light_price_id).and_return("price_light") + allow(MatchLiveTv).to receive(:stripe_premium_full_price_id).and_return("price_full") + allow(MatchLiveTv).to receive(:app_public_url).and_return("http://localhost:3000") + Plan.find_by!(slug: "premium_light").update!(stripe_price_id: "price_light") + Plan.find_by!(slug: "premium_full").update!(stripe_price_id: "price_full") + end + + it "crea checkout senza subscription esistente" do + session = double(url: "https://checkout.stripe.com/test") + expect(Stripe::Customer).to receive(:create).and_return(double(id: "cus_new")) + expect(Billing::Stripe::CustomerSync).to receive(:call).with(club: club, customer_id: "cus_new") + expect(Stripe::Checkout::Session).to receive(:create).with( + hash_including( + mode: "subscription", + customer: "cus_new", + line_items: [{ price: "price_light", quantity: 1 }], + subscription_data: { metadata: { club_id: club.id, plan_slug: "premium_light" } } + ) + ).and_return(session) + + url = described_class.new(club: club, user: user, plan_slug: "premium_light").url + expect(url).to eq("https://checkout.stripe.com/test") + end + + it "crea checkout per cambio piano con subscription esistente" do + Billing::AssignPlan.call( + club: club, + plan_slug: "premium_light", + status: "active", + stripe_attrs: { stripe_customer_id: "cus_x", stripe_subscription_id: "sub_x" } + ) + + session = double(url: "https://checkout.stripe.com/change") + expect(Billing::Stripe::CustomerSync).to receive(:call).with(club: club, customer_id: "cus_x") + expect(Stripe::Checkout::Session).to receive(:create).with( + hash_including( + customer: "cus_x", + subscription: "sub_x", + line_items: [{ price: "price_full", quantity: 1 }] + ) + ).and_return(session) + + url = described_class.new(club: club, user: user, plan_slug: "premium_full").url + expect(url).to eq("https://checkout.stripe.com/change") + end +end diff --git a/docs/PRODUCT_PREMIUM.md b/docs/PRODUCT_PREMIUM.md index 792d1ef..28ef038 100644 --- a/docs/PRODUCT_PREMIUM.md +++ b/docs/PRODUCT_PREMIUM.md @@ -34,4 +34,7 @@ STRIPE_PREMIUM_FULL_PRICE_ID=price_... STRIPE_WEBHOOK_SECRET=whsec_... ``` -Checkout: `GET /teams/:id/checkout?plan=premium_light|premium_full` +Checkout (PCI-safe, redirect Stripe Hosted): `GET /clubs/:id/checkout?plan=premium_light|premium_full` + +**Sicurezza pagamenti:** non inviare mai `card[number]`, CVC o scadenza all'API Stripe dal backend. +Usare solo Checkout/Customer Portal. I test locali vanno fatti con carta `4242…` **sulla pagina Stripe**, non con `PaymentMethod.create` in `rails runner`.