Skip to content

Commit 55bcd5d

Browse files
authored
fix(subscriptions): prevent duplicate external_ids per organization (#5558)
Fixes BIL-44 ## Context Multiple active subscriptions with the same `external_id` could coexist within the same organization for different customers. The model validation only ran on `:create` missing status changes (like `pending` => `active`). There was also no database-level unique index. ## Description In this PR: - Subscription model validation `validate_external_id` is now set to run on updates too - Added a service-level cross-customer check in `Subscriptions::CreateService` - Simplified the "with multuple subscriptions" tests for `SubscriptionsController`. They were by accident creating two active subscriptions and validations now block that - Did not add any unique index to the database. This should be discussed further and handled separately.
1 parent 30356f2 commit 55bcd5d

5 files changed

Lines changed: 69 additions & 13 deletions

File tree

app/models/subscription.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class Subscription < ApplicationRecord
4848
delegate :amount_currency, to: :plan, prefix: true
4949

5050
validates :external_id, :billing_time, presence: true
51-
validate :validate_external_id, on: :create
51+
validate :validate_external_id, on: [:create, :update], if: -> { status_changed? }
5252

5353
STATUSES = [
5454
:pending,

app/services/subscriptions/create_service.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ def call
6262
@current_subscription = editable_subscriptions
6363
.find_by("id = ? OR external_id = ?", params[:subscription_id], external_id)
6464

65+
if current_subscription.nil? &&
66+
customer.organization.subscriptions.active.exists?(external_id:)
67+
result.validation_failure!(errors: {external_id: ["value_already_exist"]})
68+
result.raise_if_error!
69+
end
70+
6571
subscription = handle_subscription
6672

6773
if params[:usage_thresholds].present?

spec/models/subscription_spec.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,46 @@
265265
expect(incomplete_sub).not_to be_valid
266266
end
267267
end
268+
269+
context "when a pending subscription transitions to active" do
270+
let(:external_id) { SecureRandom.uuid }
271+
let(:pending_subscription) do
272+
create(
273+
:subscription,
274+
plan:,
275+
status: :pending,
276+
external_id:,
277+
customer: create(:customer, organization:)
278+
)
279+
end
280+
281+
before { pending_subscription }
282+
283+
context "when another active subscription with the same external_id exists" do
284+
before do
285+
create(
286+
:subscription,
287+
plan:,
288+
status: :active,
289+
external_id:,
290+
customer: create(:customer, organization:)
291+
)
292+
end
293+
294+
it "rejects the activation" do
295+
pending_subscription.assign_attributes(status: :active)
296+
expect(pending_subscription).not_to be_valid
297+
expect(pending_subscription.errors[:external_id]).to include("value_already_exist")
298+
end
299+
end
300+
301+
context "when no other active subscription with the same external_id exists" do
302+
it "allows the activation" do
303+
pending_subscription.assign_attributes(status: :active)
304+
expect(pending_subscription).to be_valid
305+
end
306+
end
307+
end
268308
end
269309

270310
describe "started_at validation" do

spec/requests/api/v1/subscriptions_controller_spec.rb

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1723,39 +1723,33 @@ def test_termination(expected_on_termination_credit_note: nil, expected_on_termi
17231723
end
17241724

17251725
context "with multuple subscriptions" do
1726+
let(:update_params) do
1727+
{name: "subscription name new"}
1728+
end
17261729
let(:active_plan) { create(:plan, organization:, amount_cents: 5000, description: "desc") }
1727-
let(:active_subscription) do
1730+
let!(:active_subscription) do
17281731
create(:subscription, external_id: subscription.external_id, customer:, plan:)
17291732
end
17301733

1731-
before { active_subscription }
1732-
17331734
it "updates the active subscription" do
17341735
subject
17351736

17361737
expect(response).to have_http_status(:success)
17371738
expect(json[:subscription][:lago_id]).to eq(active_subscription.id)
17381739
expect(json[:subscription][:name]).to eq("subscription name new")
1739-
1740-
expect(json[:subscription][:plan]).to include(
1741-
name: "Override"
1742-
)
17431740
end
17441741

17451742
context "with pending params" do
17461743
let(:params) { {subscription: update_params, status: "pending"} }
17471744

17481745
it "updates the pending subscription" do
17491746
subject
1747+
subscription.reload
17501748

17511749
expect(response).to have_http_status(:success)
17521750
expect(json[:subscription][:lago_id]).to eq(subscription.id)
17531751
expect(json[:subscription][:name]).to eq("subscription name new")
1754-
expect(json[:subscription][:subscription_at].to_s).to eq("2022-09-05T12:23:12Z")
1755-
1756-
expect(json[:subscription][:plan]).to include(
1757-
name: "Override"
1758-
)
1752+
expect(subscription.status).to eq("pending")
17591753
end
17601754
end
17611755
end

spec/services/subscriptions/create_service_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1488,6 +1488,22 @@
14881488
end
14891489
end
14901490

1491+
context "when another customer in the same organization has an active subscription with the same external_id" do
1492+
let(:other_customer) { create(:customer, organization:) }
1493+
1494+
before do
1495+
create(:subscription, customer: other_customer, plan:, organization:, external_id:)
1496+
end
1497+
1498+
it "returns an external_id already exist error" do
1499+
result = create_service.call
1500+
1501+
expect(result).not_to be_success
1502+
expect(result.error).to be_a(BaseService::ValidationFailure)
1503+
expect(result.error.messages[:external_id]).to eq(["value_already_exist"])
1504+
end
1505+
end
1506+
14911507
context "with activation_rules" do
14921508
let(:customer) { create(:customer, organization:, payment_provider: "stripe") }
14931509

0 commit comments

Comments
 (0)