Skip to content

Commit 30a3bba

Browse files
committed
fix(billing): return fail-open policy for unrecognized runtime rails
The generated BillingRail union is compile-time only, so an unknown backend value reached the default branch and returned the raw string. Keep the satisfies-never exhaustiveness check but return the fail-open policy at runtime, and correct the ADR matrix row: an omitted or failed refresh does not clear an already-cached rail.
1 parent dca1d81 commit 30a3bba

3 files changed

Lines changed: 35 additions & 18 deletions

File tree

docs/adr/0016-billing-rail-routing-authority.md

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,27 +46,32 @@ Two problems motivated this record (raised in
4646
The post-retirement routing matrix, confirmed as the intended final behavior in
4747
the #14615 review, is:
4848

49-
| Condition | Billing type | Cancellation UI |
50-
| ---------------------------------------------------- | ------------ | --------------- |
51-
| OSS distribution | `legacy` | fallback dialog |
52-
| Cloud, workspace not yet loaded (no `type`) | `legacy` | fallback dialog |
53-
| Cloud team workspace (any rail) | `workspace` | rail-dependent |
54-
| Cloud personal, rail `legacy_stripe` | `legacy` | fallback dialog |
55-
| Cloud personal, rail `stripe` | `workspace` | Churnkey |
56-
| Cloud personal, rail `metronome` | `workspace` | fallback dialog |
57-
| Cloud personal, rail omitted / fetch failed / `null` | `workspace` | fallback dialog |
49+
| Condition | Billing type | Cancellation UI |
50+
| ------------------------------------------- | ------------ | --------------- |
51+
| OSS distribution | `legacy` | fallback dialog |
52+
| Cloud, workspace not yet loaded (no `type`) | `legacy` | fallback dialog |
53+
| Cloud team workspace (any rail) | `workspace` | rail-dependent |
54+
| Cloud personal, rail `legacy_stripe` | `legacy` | fallback dialog |
55+
| Cloud personal, rail `stripe` | `workspace` | Churnkey |
56+
| Cloud personal, rail `metronome` | `workspace` | fallback dialog |
57+
| Cloud personal, no cached rail | `workspace` | fallback dialog |
5858

5959
Churnkey is available only when the rail is known to be `stripe`; every other
60-
rail (including unknown) uses the fallback cancellation dialog.
60+
rail (including unknown) uses the fallback cancellation dialog. "No cached
61+
rail" means the session never received one: an omitted rail or a failed
62+
refresh does not clear an already-cached value, so a session that once saw
63+
`legacy_stripe` keeps routing to legacy until a response delivers a different
64+
rail.
6165

6266
All consumers must obtain rail classification from a single shared decision
6367
site (`getBillingRailPolicy` in `src/composables/billing/billingRailPolicy.ts`)
6468
rather than comparing `billing_rail` inline. That site handles the
6569
`BillingRail` union exhaustively (`satisfies never` on the default branch), so
6670
a widened union from the backend fails `pnpm typecheck` instead of silently
67-
routing to workspace billing. Runtime absence (`null` — rail not yet fetched, omitted, or fetch
68-
failed) is handled outside the exhaustive switch, because it is an expected
69-
state, not a type error.
71+
routing to workspace billing. The generated union is compile-time only, so the
72+
same site also handles the runtime states explicitly: an absent rail (`null`
73+
not yet fetched, omitted, or fetch failed) and a rail value this build does
74+
not recognize both return the fail-open policy.
7075

7176
### 2. Unknown rail fails open to workspace billing, deliberately
7277

src/composables/billing/billingRailPolicy.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, it } from 'vitest'
22

3+
import type { BillingRail } from '@/platform/workspace/api/workspaceApi'
4+
35
import { getBillingRailPolicy } from './billingRailPolicy'
46

57
describe('getBillingRailPolicy', () => {
@@ -33,4 +35,11 @@ describe('getBillingRailPolicy', () => {
3335
})
3436
}
3537
)
38+
39+
it('fails open for a rail value this build does not recognize', () => {
40+
expect(getBillingRailPolicy('adyen' as BillingRail)).toEqual({
41+
usesLegacyAccountOperations: false,
42+
supportsChurnkeyCancellation: false
43+
})
44+
})
3645
})

src/composables/billing/billingRailPolicy.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ export interface BillingRailPolicy {
55
supportsChurnkeyCancellation: boolean
66
}
77

8+
const FAIL_OPEN_POLICY: BillingRailPolicy = {
9+
usesLegacyAccountOperations: false,
10+
supportsChurnkeyCancellation: false
11+
}
12+
813
/**
914
* Single decision site for `billing_rail` (ADR-0016). Every consumer must
1015
* classify the rail through this policy so one value cannot be read two
@@ -32,11 +37,9 @@ export function getBillingRailPolicy(
3237
}
3338
case null:
3439
case undefined:
35-
return {
36-
usesLegacyAccountOperations: false,
37-
supportsChurnkeyCancellation: false
38-
}
40+
return FAIL_OPEN_POLICY
3941
default:
40-
return rail satisfies never
42+
rail satisfies never
43+
return FAIL_OPEN_POLICY
4144
}
4245
}

0 commit comments

Comments
 (0)