Skip to content

Commit 64d024c

Browse files
committed
feat(purchase_order_number): Subscriptions invoices splitting
1 parent 23c2192 commit 64d024c

6 files changed

Lines changed: 177 additions & 2 deletions

File tree

app/services/invoices/subscription_service.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ def call
2929
return result.validation_failure!(errors: {billing_entity: ["mixed_billing_entities"]})
3030
end
3131

32+
if mixed_purchase_order_numbers?
33+
return result.validation_failure!(errors: {purchase_order_number: ["mixed_purchase_order_numbers"]})
34+
end
35+
3236
create_generating_invoice unless invoice
3337
invoice.status = :open if subscription_gated?
3438
result.invoice = invoice
@@ -180,6 +184,12 @@ def mixed_billing_entities?
180184
subscriptions.map(&:applicable_billing_entity_id).uniq.many?
181185
end
182186

187+
# NOTE: Backstop for the PO grouping done upstream (periodic consolidation and
188+
# upgrade/downgrade rotation). A single invoice must never mix PO numbers.
189+
def mixed_purchase_order_numbers?
190+
subscriptions.map(&:purchase_order_number).uniq.many?
191+
end
192+
183193
def set_invoice_generated_status
184194
return invoice.status = :draft if grace_period?
185195

app/services/subscriptions/activate_service.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,14 @@ def activate_standalone
143143

144144
def bill_rotation_subscriptions(billable_subscriptions, billing_at:, non_invoiceable_subscriptions: [subscription.previous_subscription])
145145
after_commit do
146-
BillSubscriptionJob.perform_later(billable_subscriptions, billing_at.to_i, invoicing_reason: :upgrading)
147-
BillNonInvoiceableFeesJob.perform_later(non_invoiceable_subscriptions, billing_at)
146+
# NOTE: On upgrade/downgrade the previous and new subscriptions may carry different
147+
# purchase order numbers. Split them so each PO produces its own invoice.
148+
billable_subscriptions.group_by(&:purchase_order_number).each_value do |subscriptions|
149+
BillSubscriptionJob.perform_later(subscriptions, billing_at.to_i, invoicing_reason: :upgrading)
150+
end
151+
non_invoiceable_subscriptions.group_by(&:purchase_order_number).each_value do |subscriptions|
152+
BillNonInvoiceableFeesJob.perform_later(subscriptions, billing_at)
153+
end
148154
end
149155
end
150156

app/services/subscriptions/organization_billing_service.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def call
2929
subscription_groups = group_by_payment_method(billing_subscriptions)
3030
subscription_groups = group_by_currency(subscription_groups)
3131
subscription_groups = group_by_billing_entity(subscription_groups)
32+
subscription_groups = group_by_purchase_order_number(subscription_groups)
3233
subscription_groups = split_consolidation_opted_out(subscription_groups)
3334

3435
subscription_groups.each do |subscriptions|
@@ -552,6 +553,15 @@ def group_by_billing_entity(subscription_groups)
552553
end
553554
end
554555

556+
# NOTE: A purchase order number ties an invoice to a specific buyer authorization.
557+
# Subscriptions carrying different PO numbers must never be consolidated onto
558+
# the same invoice, so they are always split regardless of feature flags.
559+
def group_by_purchase_order_number(subscription_groups)
560+
subscription_groups.flat_map do |subscriptions|
561+
subscriptions.group_by(&:purchase_order_number).values
562+
end
563+
end
564+
555565
# NOTE: Returns array of subscription groups
556566
# - Groups subscriptions by their EFFECTIVE payment method (resolved, not raw)
557567
# - If payment_method_id is nil, resolves to customer's default payment method

spec/services/invoices/subscription_service_spec.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,40 @@
185185
end
186186
end
187187

188+
context "when batched subscriptions carry different purchase order numbers" do
189+
let(:other_subscription) do
190+
create(
191+
:subscription,
192+
plan:,
193+
customer:,
194+
subscription_at: started_at.to_date,
195+
started_at:,
196+
created_at: started_at,
197+
purchase_order_number: "PO-2"
198+
)
199+
end
200+
let(:subscription) do
201+
create(
202+
:subscription,
203+
plan:,
204+
customer:,
205+
subscription_at: started_at.to_date,
206+
started_at:,
207+
created_at: started_at,
208+
purchase_order_number: "PO-1"
209+
)
210+
end
211+
let(:subscriptions) { [subscription, other_subscription] }
212+
213+
it "returns a validation failure rather than producing a mixed-PO invoice" do
214+
result = invoice_service.call
215+
216+
expect(result).not_to be_success
217+
expect(result.error).to be_a(BaseService::ValidationFailure)
218+
expect(result.error.messages[:purchase_order_number]).to eq(["mixed_purchase_order_numbers"])
219+
end
220+
end
221+
188222
it_behaves_like "syncs invoice" do
189223
let(:service_call) { invoice_service.call }
190224
end

spec/services/subscriptions/activate_service_spec.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,29 @@
684684
expect(BillNonInvoiceableFeesJob).to have_been_enqueued
685685
.with([previous_subscription, subscription], anything)
686686
end
687+
688+
context "when the previous and new subscriptions carry different purchase order numbers" do
689+
before do
690+
previous_subscription.update!(purchase_order_number: "PO-1")
691+
subscription.update!(purchase_order_number: "PO-2")
692+
end
693+
694+
it "splits the upgrade bill into one job per purchase order number" do
695+
result
696+
697+
expect(BillSubscriptionJob).to have_been_enqueued
698+
.with([previous_subscription], anything, invoicing_reason: :upgrading)
699+
expect(BillSubscriptionJob).to have_been_enqueued
700+
.with([subscription], anything, invoicing_reason: :upgrading)
701+
end
702+
703+
it "splits BillNonInvoiceableFeesJob per purchase order number" do
704+
result
705+
706+
expect(BillNonInvoiceableFeesJob).to have_been_enqueued.with([previous_subscription], anything)
707+
expect(BillNonInvoiceableFeesJob).to have_been_enqueued.with([subscription], anything)
708+
end
709+
end
687710
end
688711

689712
context "when activation_rules gate the new subscription" do

spec/services/subscriptions/organization_billing_service_spec.rb

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,98 @@ def expect_to_bill_together(subscriptions, date)
442442
end
443443
end
444444

445+
context "when grouping subscriptions by purchase order number" do
446+
let(:interval) { :monthly }
447+
let(:billing_time) { :anniversary }
448+
let(:current_date) { subscription_at.next_month }
449+
450+
before { subscription.destroy }
451+
452+
context "when subscriptions have different purchase order numbers" do
453+
let(:po1_subscription) do
454+
create(
455+
:subscription,
456+
customer:,
457+
plan:,
458+
subscription_at:,
459+
started_at: current_date - 10.days,
460+
billing_time:,
461+
created_at:,
462+
purchase_order_number: "PO-1"
463+
)
464+
end
465+
let(:po2_subscription) do
466+
create(
467+
:subscription,
468+
customer:,
469+
plan:,
470+
subscription_at:,
471+
started_at: current_date - 10.days,
472+
billing_time:,
473+
created_at:,
474+
purchase_order_number: "PO-2"
475+
)
476+
end
477+
478+
before do
479+
po1_subscription
480+
po2_subscription
481+
end
482+
483+
it "produces separate billing jobs per purchase order number" do
484+
billing_service.call
485+
486+
expect(BillSubscriptionJob).to have_been_enqueued
487+
.with([po1_subscription], current_date.to_i, invoicing_reason: :subscription_periodic)
488+
expect(BillSubscriptionJob).to have_been_enqueued
489+
.with([po2_subscription], current_date.to_i, invoicing_reason: :subscription_periodic)
490+
end
491+
end
492+
493+
context "when subscriptions share the same purchase order number" do
494+
let(:subscription1) do
495+
create(
496+
:subscription,
497+
customer:,
498+
plan:,
499+
subscription_at:,
500+
started_at: current_date - 10.days,
501+
billing_time:,
502+
created_at:,
503+
purchase_order_number: "PO-1"
504+
)
505+
end
506+
let(:subscription2) do
507+
create(
508+
:subscription,
509+
customer:,
510+
plan:,
511+
subscription_at:,
512+
started_at: current_date - 10.days,
513+
billing_time:,
514+
created_at:,
515+
purchase_order_number: "PO-1"
516+
)
517+
end
518+
519+
before do
520+
subscription1
521+
subscription2
522+
end
523+
524+
it "groups them into a single billing job" do
525+
billing_service.call
526+
527+
expect(BillSubscriptionJob).to have_been_enqueued
528+
.with(
529+
contain_exactly(subscription1, subscription2),
530+
current_date.to_i,
531+
invoicing_reason: :subscription_periodic
532+
)
533+
end
534+
end
535+
end
536+
445537
context "when grouping subscriptions by currency" do
446538
let(:organization) { create(:organization, feature_flags: ["multi_currency"]) }
447539
let(:interval) { :monthly }

0 commit comments

Comments
 (0)