Skip to content

Commit a6b1cfb

Browse files
tiagolupepicrsempe
authored andcommitted
test(billing): cover v2 termination flow
## Context V2 product-catalog subscription termination needs request coverage for the real service behavior, including arrears final billing cycles and advance credit notes. ## Description Parse the termination timestamp before calling the termination service and add request specs that exercise monthly arrears, phased arrears, and billed advance termination scenarios.
1 parent df1195b commit a6b1cfb

2 files changed

Lines changed: 322 additions & 2 deletions

File tree

app/controllers/api/v2/subscriptions_controller.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def terminate
6464

6565
result = ::V2::Subscriptions::TerminateService.call(
6666
subscription:,
67-
terminated_at: params[:terminated_at] || Time.current
67+
terminated_at: termination_time
6868
)
6969

7070
if result.success?
@@ -131,6 +131,14 @@ def cycles
131131

132132
private
133133

134+
def termination_time
135+
if params[:terminated_at].present?
136+
Time.zone.parse(params[:terminated_at].to_s)
137+
else
138+
Time.current
139+
end
140+
end
141+
134142
def subscription_external_ids
135143
@subscription_external_ids ||= Array.wrap(
136144
params[:external_ids].presence ||

spec/requests/api/v2/subscriptions_controller_spec.rb

Lines changed: 313 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
expect(response).to have_http_status(:success)
8181
expect(V2::Subscriptions::TerminateService).to have_received(:call).with(
8282
subscription:,
83-
terminated_at:
83+
terminated_at: Time.zone.parse(terminated_at)
8484
)
8585
expect(json[:applied_rate_cards].sole[:lago_id]).to eq(subscription_rate_card.id)
8686
expect(json[:credit_notes]).to eq([])
@@ -116,6 +116,318 @@
116116
expect(V2::Subscriptions::TerminateService).not_to have_received(:call)
117117
end
118118
end
119+
120+
context "with the real termination flow" do
121+
before do
122+
allow(V2::Subscriptions::TerminateService).to receive(:call).and_call_original
123+
end
124+
125+
context "with a monthly arrears rate card" do
126+
let(:terminated_at) { "2026-08-17T12:34:56Z" }
127+
let(:subscription) do
128+
create(
129+
:subscription,
130+
customer:,
131+
organization:,
132+
plan:,
133+
external_id: "sub_monthly_arrears",
134+
started_at: Time.zone.parse("2026-08-01"),
135+
activated_at: Time.zone.parse("2026-08-01"),
136+
subscription_at: Time.zone.parse("2026-08-01")
137+
)
138+
end
139+
let(:product) { create(:product, :fixed, organization:) }
140+
let(:rate_card) { create(:rate_card, organization:, product:, code: "monthly_arrears", currency: "EUR") }
141+
let!(:rate) do
142+
create(
143+
:rate_card_rate,
144+
organization:,
145+
rate_card:,
146+
code: "monthly_arrears_v1",
147+
effective_from: Time.zone.parse("2026-01-01"),
148+
billing_interval_count: 1,
149+
billing_interval_unit: "month",
150+
rate_properties: {"amount" => "31.00"}
151+
)
152+
end
153+
let!(:subscription_rate_card) do
154+
create(
155+
:subscription_rate_card,
156+
organization:,
157+
subscription:,
158+
customer:,
159+
rate_card:,
160+
billing_anchor_date: Date.parse("2026-08-01"),
161+
started_at: Time.zone.parse("2026-08-01"),
162+
next_billing_at: Time.zone.parse("2026-09-01")
163+
)
164+
end
165+
166+
it "creates the final pending billing cycle clamped to the termination time" do
167+
expect { subject }.to change(BillingCycle, :count).by(1)
168+
169+
expect(response).to have_http_status(:success)
170+
expect(json[:credit_notes]).to eq([])
171+
172+
billing_cycle = BillingCycle.sole
173+
expect(billing_cycle).to have_attributes(
174+
subscription:,
175+
subscription_rate_card:,
176+
rate_card_rate: rate,
177+
rate_override: nil,
178+
period_from: Time.zone.parse("2026-08-01"),
179+
period_to: Time.zone.parse(terminated_at),
180+
billing_at: Time.zone.parse(terminated_at),
181+
status: "pending"
182+
)
183+
expect(subscription_rate_card.reload).to have_attributes(
184+
ended_at: Time.zone.parse(terminated_at),
185+
next_billing_at: Time.zone.parse(terminated_at)
186+
)
187+
end
188+
end
189+
190+
context "with multiple rates and phased interval overrides" do
191+
let(:terminated_at) { "2026-09-25T08:00:00Z" }
192+
let(:subscription) do
193+
create(
194+
:subscription,
195+
customer:,
196+
organization:,
197+
plan:,
198+
external_id: "sub_phased_arrears",
199+
started_at: Time.zone.parse("2026-08-03"),
200+
activated_at: Time.zone.parse("2026-08-03"),
201+
subscription_at: Time.zone.parse("2026-08-03")
202+
)
203+
end
204+
let(:product) { create(:product, :fixed, organization:) }
205+
let(:rate_card) { create(:rate_card, organization:, product:, code: "phased_arrears", currency: "EUR") }
206+
let!(:initial_rate) do
207+
create(
208+
:rate_card_rate,
209+
organization:,
210+
rate_card:,
211+
code: "phased_v1",
212+
effective_from: Time.zone.parse("2026-01-01"),
213+
billing_interval_count: 1,
214+
billing_interval_unit: "month",
215+
rate_properties: {"amount" => "90.00"}
216+
)
217+
end
218+
let!(:active_rate) do
219+
create(
220+
:rate_card_rate,
221+
organization:,
222+
rate_card:,
223+
code: "phased_v2",
224+
effective_from: Time.zone.parse("2026-09-01"),
225+
billing_interval_count: 1,
226+
billing_interval_unit: "month",
227+
rate_properties: {"amount" => "120.00"}
228+
)
229+
end
230+
let!(:future_rate) do
231+
create(
232+
:rate_card_rate,
233+
organization:,
234+
rate_card:,
235+
code: "phased_v3",
236+
effective_from: Time.zone.parse("2026-12-01"),
237+
billing_interval_count: 2,
238+
billing_interval_unit: "month",
239+
rate_properties: {"amount" => "250.00"}
240+
)
241+
end
242+
let!(:plan_rate_card) { create(:plan_rate_card, organization:, plan:, rate_card:) }
243+
let(:intro_override) do
244+
create(
245+
:rate_override,
246+
organization:,
247+
billing_interval_count: 1,
248+
billing_interval_unit: "week",
249+
rate_properties: {"amount" => "19.00"}
250+
)
251+
end
252+
let!(:intro_phase) do
253+
create(
254+
:rate_phase,
255+
organization:,
256+
plan_rate_card:,
257+
code: "weekly_intro",
258+
position: 1,
259+
billing_interval_cycle_count: 6,
260+
rate_override: intro_override
261+
)
262+
end
263+
let!(:standard_phase) do
264+
create(
265+
:rate_phase,
266+
organization:,
267+
plan_rate_card:,
268+
code: "monthly_standard",
269+
position: 2,
270+
billing_interval_cycle_count: nil
271+
)
272+
end
273+
let!(:subscription_rate_card) do
274+
create(
275+
:subscription_rate_card,
276+
organization:,
277+
subscription:,
278+
customer:,
279+
rate_card:,
280+
billing_anchor_date: Date.parse("2026-08-03"),
281+
started_at: Time.zone.parse("2026-08-03"),
282+
next_billing_at: Time.zone.parse("2026-10-14")
283+
)
284+
end
285+
286+
it "uses the active rate and the phase-adjusted final period" do
287+
expect { subject }.to change(BillingCycle, :count).by(1)
288+
289+
expect(response).to have_http_status(:success)
290+
291+
billing_cycle = BillingCycle.sole
292+
expect(billing_cycle).to have_attributes(
293+
rate_card_rate: active_rate,
294+
rate_override: nil,
295+
rate_properties: active_rate.rate_properties,
296+
period_from: Time.zone.parse("2026-09-14"),
297+
period_to: Time.zone.parse(terminated_at),
298+
billing_at: Time.zone.parse(terminated_at)
299+
)
300+
expect(json[:applied_rate_cards].sole[:lago_id]).to eq(subscription_rate_card.id)
301+
expect([initial_rate, future_rate]).not_to include(billing_cycle.rate_card_rate)
302+
expect([intro_phase, standard_phase].map(&:code)).to eq(%w[weekly_intro monthly_standard])
303+
end
304+
end
305+
306+
context "with a billed monthly advance rate card" do
307+
let(:terminated_at) { "2026-08-17T12:34:56Z" }
308+
let(:subscription) do
309+
create(
310+
:subscription,
311+
customer:,
312+
organization:,
313+
plan:,
314+
external_id: "sub_monthly_advance",
315+
started_at: Time.zone.parse("2026-08-01"),
316+
activated_at: Time.zone.parse("2026-08-01"),
317+
subscription_at: Time.zone.parse("2026-08-01")
318+
)
319+
end
320+
let(:product) { create(:product, :fixed, organization:) }
321+
let(:rate_card) { create(:rate_card, :advance, organization:, product:, code: "monthly_advance", currency: "EUR") }
322+
let!(:rate) do
323+
create(
324+
:rate_card_rate,
325+
organization:,
326+
rate_card:,
327+
code: "monthly_advance_v1",
328+
effective_from: Time.zone.parse("2026-01-01"),
329+
billing_interval_count: 1,
330+
billing_interval_unit: "month",
331+
rate_properties: {"amount" => "31.00"}
332+
)
333+
end
334+
let!(:subscription_rate_card) do
335+
create(
336+
:subscription_rate_card,
337+
organization:,
338+
subscription:,
339+
customer:,
340+
rate_card:,
341+
billing_anchor_date: Date.parse("2026-08-01"),
342+
started_at: Time.zone.parse("2026-08-01"),
343+
next_billing_at: Time.zone.parse("2026-09-01")
344+
)
345+
end
346+
let(:invoice) do
347+
create(
348+
:invoice,
349+
:subscription,
350+
organization:,
351+
customer:,
352+
subscriptions: [subscription],
353+
status: :finalized,
354+
currency: "EUR",
355+
fees_amount_cents: 3_100,
356+
total_amount_cents: 3_100
357+
)
358+
end
359+
let!(:billing_cycle) do
360+
create(
361+
:billing_cycle,
362+
organization:,
363+
subscription:,
364+
customer:,
365+
subscription_rate_card:,
366+
rate_card_rate: rate,
367+
billing_at: Time.zone.parse("2026-08-01"),
368+
period_from: Time.zone.parse("2026-08-01"),
369+
period_to: Time.zone.parse("2026-08-31 23:59:59.999999"),
370+
invoice:,
371+
status: :done
372+
)
373+
end
374+
let!(:fee) do
375+
create(
376+
:fee,
377+
organization:,
378+
subscription:,
379+
invoice:,
380+
invoiceable: product,
381+
amount_cents: 3_100,
382+
precise_amount_cents: 3_100,
383+
amount_currency: "EUR",
384+
taxes_amount_cents: 0,
385+
taxes_precise_amount_cents: 0
386+
)
387+
end
388+
let(:taxes_result) do
389+
CreditNotes::ApplyTaxesService::Result.new.tap do |result|
390+
result.applied_taxes = []
391+
result.coupons_adjustment_amount_cents = 0
392+
result.taxes_amount_cents = 0
393+
result.precise_taxes_amount_cents = 0
394+
result.taxes_rate = 0
395+
end
396+
end
397+
398+
before do
399+
allow(CreditNotes::ApplyTaxesService).to receive(:call).and_return(taxes_result)
400+
end
401+
402+
it "does not create a final billing cycle and credits the unused period" do
403+
expect { subject }
404+
.to change(CreditNote, :count).by(1)
405+
.and change(CreditNoteItem, :count).by(1)
406+
.and not_change(BillingCycle, :count)
407+
408+
expect(response).to have_http_status(:success)
409+
410+
credit_note = CreditNote.sole
411+
credit_note_item = CreditNoteItem.sole
412+
expect(credit_note).to have_attributes(
413+
invoice:,
414+
reason: "order_cancellation",
415+
credit_amount_cents: credit_note_item.amount_cents,
416+
total_amount_cents: credit_note_item.amount_cents,
417+
status: "finalized"
418+
)
419+
expect(credit_note_item.fee).to eq(fee)
420+
expect(credit_note_item.amount_cents).to be_positive
421+
expect(credit_note_item.amount_cents).to be < fee.amount_cents
422+
expect(json[:credit_notes].sole[:lago_id]).to eq(credit_note.id)
423+
expect(subscription_rate_card.reload).to have_attributes(
424+
ended_at: Time.zone.parse(terminated_at),
425+
next_billing_at: Time.zone.parse("2026-09-01")
426+
)
427+
expect(billing_cycle.reload).to be_done
428+
end
429+
end
430+
end
119431
end
120432

121433
describe "POST /api/v2/subscriptions/:external_id/bill" do

0 commit comments

Comments
 (0)