Billing Subscribe Cascading 409s
This retrospective covers the May 27-28, 2026 billing subscribe incident. The failure started as a demo blocker, but it exposed three lasting backend contract problems: the subscribe boundary lived on the frontend, database integrity failures all looked like the same 409, and a same-transaction billing-key upsert still violated the active-key unique index.
Context
Before the fix, the paid-plan subscribe flow was coordinated by the frontend success page after the Toss billing callback:
- register the billing key,
- change the tenant subscription,
- charge the first invoice.
That sequence assumed two invariants that were not always true:
- every tenant already had a FREE subscription row;
- each step could be retried independently without stranding the tenant in a half-finished billing state.
The current repo still shows the final boundary that replaced that design:
lumie-backend/modules/billing/src/main/java/com/lumie/billing/application/service/BillingSubscribeService.javalumie-frontend/src/widgets/billing-home/ui/BillingSetupSuccess.tsx
What Broke Or Changed
The visible symptom was a series of near-identical 409 Conflict responses, but the causes were different:
- legacy tenants without a subscription row kept returning
404untilV35__backfill_free_subscriptions.sqlrestored the invariant; - Toss billing-key persistence hit nullable-card metadata drift until
V36__billing_keys_nullable_card_metadata.sqlwidened the schema; - re-running the billing-key step after a partial failure collided with the tenant's existing active key;
- the frontend was coordinating a distributed transaction across three backend calls;
DataIntegrityViolationExceptionmapped to the same generic409even when the real cause was a server-sideNOT NULLor foreign-key bug.
Because the wire response hid the difference between user conflicts and server defects, each fix only revealed the next failure class.
Root Cause
Three confirmed defects survived the incident and explain the final design.
1. The business-operation boundary was on the wrong layer
The durable fix was to move "subscribe to a paid plan with a new card" into one backend orchestration step. The current source of truth is BillingSubscribeService, which owns billing-key issuance, active-key replacement, subscription attachment, and the first charge under POST /v1/billing/subscribe.
The frontend success page now reflects that split:
planId === CHANGE_METHOD_SENTINELkeeps the card-swap path onuseRegisterBillingKey();- every paid subscribe path calls
useSubscribe()once and waits for the backend to finish the operation.
2. The global exception mapper hid the real failure class
lumie-backend/libs/common/src/main/java/com/lumie/common/exception/GlobalExceptionHandler.java now branches on JDBC SQLSTATE instead of flattening every DataIntegrityViolationException into 409 RESOURCE_CONFLICT.
That change turned the debugging contract into something honest:
23505stays a real conflict;23514becomes422 BUSINESS_RULE_VIOLATION;23502,23503, and unknown integrity failures surface as500 INTERNAL_ERROR.
3. Billing-key replacement needed explicit flush ordering
lumie-backend/modules/billing/src/main/java/com/lumie/billing/application/service/BillingKeyPersistenceHelper.java now calls saveAndFlush(existing) before inserting the new key. That ordering matters because the partial unique index only allows one ACTIVE billing key per tenant, and JPA can otherwise defer the revoke update until commit while flushing the insert immediately.
Resolution
The lasting repository state is:
V35__backfill_free_subscriptions.sqlrestores the "every tenant has a subscription row" invariant for legacy tenants.V36__billing_keys_nullable_card_metadata.sqlallows nullable card metadata that Toss sandbox responses can omit.BillingSubscribeService.javais the single backend subscribe boundary.GlobalExceptionHandler.javapreserves SQLSTATE-level cause visibility at the HTTP layer.BillingKeyPersistenceHelper.javaflushes revoke-before-insert ordering explicitly.BillingSetupSuccess.tsxdelegates paid subscription setup to one backend mutation instead of coordinating three.
Regression coverage also survived in current source:
lumie-backend/libs/common/src/test/java/com/lumie/common/exception/GlobalExceptionHandlerTest.javalumie-backend/modules/billing/src/test/java/com/lumie/billing/application/service/BillingKeyPersistenceHelperTest.javalumie-backend/modules/billing/src/test/java/com/lumie/billing/application/service/BillingSubscribeServiceTest.java
Prevention
- Put the business operation on the backend when the user action spans billing-key registration, subscription state, and a charge.
- Treat exception-to-HTTP mapping as observability infrastructure, not just API polish.
- Force write order explicitly when same-transaction updates and inserts share a partial unique index.
- Keep legacy-invariant repair in migrations, not in frontend retries or ad hoc operator steps.