Billing: fatture manuali, profilo obbligatorio e piani Stripe mensile/annuale.

Rimuove link al portale Stripe in area cliente, aggiunge flusso admin per PDF
fattura e email, blocca checkout senza dati di fatturazione, allinea prezzi
a €4,90/€39,90 e €9,90/€69,90, locale italiano e documentazione deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 17:10:12 +02:00
parent 148402a97c
commit a5e781729c
78 changed files with 2457 additions and 424 deletions

View File

@@ -0,0 +1,53 @@
require "rails_helper"
RSpec.describe Billing::Stripe::CancelSubscription do
let(:club) do
Club.create!(
name: "Cancel Club", sport: "volleyball",
primary_color: "#e53935", secondary_color: "#ffffff",
billing_legal_name: "ASD", billing_email: "b@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)
Billing::AssignPlan.call(
club: club,
plan_slug: "premium_light",
status: "active",
stripe_attrs: {
stripe_subscription_id: "sub_cancel",
stripe_customer_id: "cus_x",
current_period_end: 1.month.from_now,
billing_interval: "monthly"
}
)
end
it "programma la disdetta a fine periodo su Stripe" do
stripe_sub = double(
id: "sub_cancel",
customer: "cus_x",
status: "active",
cancel_at_period_end: true,
metadata: { "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "monthly" },
items: double(data: [double(price: double(id: "price_m", recurring: double(interval: "month")))])
)
allow(Stripe::Subscription).to receive(:update).with("sub_cancel", cancel_at_period_end: true).and_return(stripe_sub)
allow(Billing::Stripe::CheckoutSync).to receive(:from_subscription)
allow(Billing::Stripe::SubscriptionPeriod).to receive(:times).and_return([Time.current, 1.month.from_now])
described_class.call(club: club)
expect(Stripe::Subscription).to have_received(:update).with("sub_cancel", cancel_at_period_end: true)
expect(Billing::Stripe::CheckoutSync).to have_received(:from_subscription).with(stripe_sub, club: club)
end
it "rifiuta se la disdetta è già programmata" do
club.subscription.update!(cancel_at_period_end: true)
expect { described_class.call(club: club) }.to raise_error(/già programmata/)
end
end

View File

@@ -15,10 +15,10 @@ RSpec.describe Billing::Stripe::ChangePlan do
}
)
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")
Plan.find_by!(slug: "premium_light").update!(stripe_price_id: "price_light")
Plan.find_by!(slug: "premium_full").update!(stripe_price_id: "price_full")
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_light_m")
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_full_m")
end
it "aggiorna Stripe e il piano locale verso premium_full" do
@@ -37,21 +37,22 @@ RSpec.describe Billing::Stripe::ChangePlan do
allow(Stripe::Subscription).to receive(:retrieve).with("sub_test_123").and_return(stripe_sub)
allow(Stripe::Subscription).to receive(:update).and_return(updated_sub)
described_class.call(club: club, plan_slug: "premium_full")
described_class.call(club: club, plan_slug: "premium_full", interval: "yearly")
expect(Stripe::Subscription).to have_received(:update).with(
"sub_test_123",
hash_including(
items: [{ id: "si_test", price: "price_full" }],
metadata: { club_id: club.id, plan_slug: "premium_full" }
items: [{ id: "si_test", price: "price_full_y" }],
metadata: { club_id: club.id, plan_slug: "premium_full", billing_interval: "yearly" }
)
)
expect(club.reload.subscription.plan.slug).to eq("premium_full")
end
it "rifiuta il cambio sullo stesso piano" do
it "rifiuta il cambio sullo stesso piano e intervallo" do
club.subscription.update!(billing_interval: "yearly")
expect {
described_class.call(club: club, plan_slug: "premium_light")
described_class.call(club: club, plan_slug: "premium_light", interval: "yearly")
}.to raise_error(/già su questo piano/)
end
end

View File

@@ -15,49 +15,38 @@ RSpec.describe Billing::Stripe::CheckoutSession do
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(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_light_m")
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_full_m")
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")
it "crea checkout annuale" do
session = double(url: "https://checkout.stripe.com/yearly")
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" } }
line_items: [{ price: "price_light_y", quantity: 1 }],
metadata: hash_including(plan_slug: "premium_light", billing_interval: "yearly"),
subscription_data: { metadata: hash_including(billing_interval: "yearly") }
)
).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")
url = described_class.new(club: club, user: user, plan_slug: "premium_light", interval: "yearly").url
expect(url).to eq("https://checkout.stripe.com/yearly")
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")
it "crea checkout mensile" do
session = double(url: "https://checkout.stripe.com/monthly")
allow(Stripe::Customer).to receive(:create).and_return(double(id: "cus_m"))
allow(Billing::Stripe::CustomerSync).to receive(:call)
expect(Stripe::Checkout::Session).to receive(:create).with(
hash_including(
customer: "cus_x",
subscription: "sub_x",
line_items: [{ price: "price_full", quantity: 1 }]
)
hash_including(line_items: [{ price: "price_light_m", 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")
url = described_class.new(club: club, user: user, plan_slug: "premium_light", interval: "monthly").url
expect(url).to eq("https://checkout.stripe.com/monthly")
end
end

View File

@@ -0,0 +1,72 @@
require "rails_helper"
RSpec.describe Billing::Stripe::CheckoutSync do
let(:club) do
Club.create!(
name: "Sync Club", sport: "volleyball",
primary_color: "#e53935", secondary_color: "#ffffff",
billing_legal_name: "ASD", billing_email: "b@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")
Billing::AssignPlan.call(club: club, plan_slug: "free")
club.subscription.update!(stripe_customer_id: "cus_sync")
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_light_m")
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_full_m")
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
end
it "allinea il piano da checkout session pagata" do
session = double(
customer: "cus_sync",
subscription: "sub_new",
payment_status: "paid",
status: "complete",
metadata: { "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "monthly" }
)
stripe_sub = double(
id: "sub_new",
customer: "cus_sync",
status: "active",
cancel_at_period_end: false,
metadata: { "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "monthly" },
items: double(data: [double(price: double(id: "price_light_m", recurring: double(interval: "month")))])
)
allow(Stripe::Checkout::Session).to receive(:retrieve).with("cs_test").and_return(session)
allow(Stripe::Subscription).to receive(:retrieve).with("sub_new").and_return(stripe_sub)
allow(Billing::Stripe::SubscriptionPeriod).to receive(:times).and_return([Time.current, 1.month.from_now])
described_class.call(club: club, session_id: "cs_test")
sub = club.reload.subscription
expect(sub.plan.slug).to eq("premium_light")
expect(sub.billing_interval).to eq("monthly")
expect(sub.stripe_subscription_id).to eq("sub_new")
end
it "usa l'abbonamento Stripe attivo del cliente se manca session_id" do
stripe_sub = double(
id: "sub_fallback",
customer: "cus_sync",
status: "active",
created: 2,
cancel_at_period_end: false,
metadata: { "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "yearly" },
items: double(data: [double(price: double(id: "price_light_y", recurring: double(interval: "year")))])
)
list = double(data: [stripe_sub])
allow(Stripe::Subscription).to receive(:list).and_return(list)
allow(Billing::Stripe::SubscriptionPeriod).to receive(:times).and_return([Time.current, 1.year.from_now])
described_class.call(club: club)
expect(club.reload.subscription.plan.slug).to eq("premium_light")
expect(club.subscription.billing_interval).to eq("yearly")
end
end

View File

@@ -0,0 +1,54 @@
require "rails_helper"
RSpec.describe Billing::Stripe::FinalizeSubscription do
let(:club) do
Club.create!(
name: "Fin Club", sport: "volleyball",
primary_color: "#e53935", secondary_color: "#ffffff"
)
end
before { load Rails.root.join("db/seeds/plans.rb") }
it "passa a free quando il periodo è scaduto e la disdetta era programmata" do
Billing::AssignPlan.call(
club: club,
plan_slug: "premium_light",
status: "active",
stripe_attrs: {
stripe_subscription_id: "sub_fin",
cancel_at_period_end: true,
current_period_end: 1.day.ago
}
)
stripe_sub = double(
id: "sub_fin",
status: "active",
cancel_at_period_end: true
)
allow(Stripe::Subscription).to receive(:retrieve).with("sub_fin").and_return(stripe_sub)
allow(Billing::Stripe::SubscriptionPeriod).to receive(:times).and_return([2.months.ago, 1.day.ago])
expect(described_class.call(club: club)).to be true
expect(club.reload.subscription.plan.slug).to eq("free")
expect(club.subscription.stripe_subscription_id).to be_nil
end
it "non cambia piano se il periodo non è ancora scaduto" do
Billing::AssignPlan.call(
club: club,
plan_slug: "premium_light",
status: "active",
stripe_attrs: {
stripe_subscription_id: "sub_ok",
cancel_at_period_end: true,
current_period_end: 1.month.from_now
}
)
stripe_sub = double(id: "sub_ok", status: "active", cancel_at_period_end: true)
allow(Stripe::Subscription).to receive(:retrieve).and_return(stripe_sub)
expect(described_class.call(club: club)).to be false
expect(club.reload.subscription.plan.slug).to eq("premium_light")
end
end

View File

@@ -0,0 +1,20 @@
require "rails_helper"
RSpec.describe Billing::Stripe::InvoicePaymentRefs do
it "legge payment_intent dalla raccolta payments (API Basil+)" do
pi = double(id: "pi_new")
payment = double(payment_intent: pi)
invoice = double(
payments: double(data: [double(payment: payment)])
)
expect(described_class.payment_intent_id(invoice)).to eq("pi_new")
expect(described_class.charge_id(invoice)).to be_nil
end
it "non accede a payment_intent top-level sull'invoice" do
invoice = double(payments: double(data: []))
expect(invoice).not_to receive(:payment_intent)
expect(described_class.payment_intent_id(invoice)).to be_nil
end
end

View File

@@ -0,0 +1,29 @@
require "rails_helper"
RSpec.describe Billing::Stripe::PriceCatalog do
before do
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_light_m")
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_full_m")
end
it "risolve price id per piano e intervallo" do
expect(described_class.price_id(plan_slug: "premium_light", interval: "yearly")).to eq("price_light_y")
expect(described_class.price_id(plan_slug: "premium_light", interval: "monthly")).to eq("price_light_m")
expect(described_class.price_id(plan_slug: "premium_full", interval: "monthly")).to eq("price_full_m")
end
it "usa yearly come default" do
expect(described_class.normalize_interval(nil)).to eq("yearly")
expect(described_class.price_id(plan_slug: "premium_light", interval: nil)).to eq("price_light_y")
end
it "rifiuta intervalli non validi" do
expect { described_class.normalize_interval("weekly") }.to raise_error(ArgumentError)
end
it "elenca intervalli configurati" do
expect(described_class.available_intervals(plan_slug: "premium_light")).to eq(%w[monthly yearly])
end
end

View File

@@ -5,27 +5,32 @@ RSpec.describe Billing::Stripe::RecordPayment do
before do
load Rails.root.join("db/seeds/plans.rb")
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
Billing::AssignPlan.call(club: club, plan_slug: "premium_light", stripe_attrs: { stripe_subscription_id: "sub_x" })
end
it "crea un pagamento da fattura Stripe" do
invoice = double(
id: "in_test_1",
subscription: "sub_x",
metadata: { "club_id" => club.id },
metadata: { "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "yearly" },
parent: nil,
amount_paid: 4000,
currency: "eur",
status: "paid",
description: "Premium Light",
payment_intent: "pi_x",
charge: "ch_x",
invoice_pdf: "https://stripe.com/invoice.pdf",
hosted_invoice_url: "https://stripe.com/hosted",
status_transitions: double(paid_at: Time.current.to_i),
lines: double(data: [double(description: "Premium Light annuale")]),
subscription_details: nil
lines: double(data: [
double(
description: "Premium Light annuale",
metadata: { "plan_slug" => "premium_light", "billing_interval" => "yearly" },
pricing: double(price_details: double(price: "price_light_y"))
)
])
)
allow(invoice).to receive(:metadata).and_return({ "club_id" => club.id })
allow(invoice).to receive(:metadata).and_return({ "club_id" => club.id, "plan_slug" => "premium_light", "billing_interval" => "yearly" })
allow(Stripe::Invoice).to receive(:retrieve).and_return(invoice)
payment = described_class.call(stripe_invoice: invoice)
@@ -33,5 +38,6 @@ RSpec.describe Billing::Stripe::RecordPayment do
expect(payment.club).to eq(club)
expect(payment.amount_cents).to eq(4000)
expect(payment.stripe_invoice_id).to eq("in_test_1")
expect(payment.description).to eq("Premium Light — €40/anno")
end
end