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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
72
backend/spec/services/billing/stripe/checkout_sync_spec.rb
Normal file
72
backend/spec/services/billing/stripe/checkout_sync_spec.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
29
backend/spec/services/billing/stripe/price_catalog_spec.rb
Normal file
29
backend/spec/services/billing/stripe/price_catalog_spec.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user