paused/subscription-gating
needs attentionc807731 · fullpre-PRreviewed 2026-08-11 18:59 UTC4H · 7M · 4L · 2I- Purpose
- Gate cost-incurring CFE bill downloads for organizations with inactive/unpaid subscriptions, using the existing subscription_status field synced daily from Stripe.
- Goal
- Implement subscription-based access control for CFE bill downloads: canceled orgs get 402, past_due orgs get a warning banner but can still download (grace period), active/trialing/billingExempt orgs are unaffected.
- Sub-goals
- Pure domain policy: decideFeatureAccess function in domains/core/src/billing/
- Request guard: requireActiveSubscription middleware (fail-open) in packages/api/
- Internal chokepoints: cfe-jobs handler create/batch/retry gates
- Public API chokepoint: POST /v1/jobs gate for bill_collection pipeline
- Backend subscriber gate: filterBySubscriptionAccess in subscriber Lambda
- Frontend: SubscriptionBanner component with i18n (es/en)
- What
- 26 files changed (+880/-4). New pure billing decision/error types, new request middleware, 3 handler gates wired, Lambda subscriber filter, UI banner component, API contracts updated with 402 responses, public error catalog extended.
- Why
- Stripe state is now trustworthy and synced daily — the platform can reliably stop delivering paid features to non-paying orgs without locking out paying customers or blocking self-cure (billing page always accessible).
- Areas
- domains/core/src/billing+220−0packages/api/src/middleware+103−0apps/platform/src/api+75−0apps/platform/src/components+52−0apps/platform/src/app+15−1services/utility/bills/cfe+140−2packages/api/src+8−0docs/development+209−0
- Blast
- 26 files, +880/-4 across billing decisions, request middleware, 3 handler files, Lambda subscriber, UI, i18n, contracts, error catalog, and public schemas. No DB migration. Pure-additive — rollback by removing gate calls.
Findings · 17
correctness3
decideFeatureAccess switch has no default — unrecognized subscription_status causes runtime crash
domains/core/src/billing/feature-access.decisions.ts:47
The switch is exhaustive over 4 known values with no default. If the DB contains a value outside the enum (e.g. 'incomplete' from a failed mrr-sync normalization), the switch returns undefined and callers crash dereferencing .allowed. Add a default: { level: 'blocked', allowed: false, status, reason: 'unpaid' }.
partitionGroupsByAccess may silently pass blocked orgs if RpuGroup.orgId is not internal UUID
services/utility/bills/cfe/src/domain/subscription-access.ts:41
The Set lookup uses internal UUIDs (o.id). If RpuGroup.orgId is the public ID instead, no orgs are ever blocked. Both are string — add a runtime assertion or test verifying the invariant.
SubscriptionBanner renders warning for any unrecognized status value
apps/platform/src/components/SubscriptionBanner.tsx:28
Early-return only suppresses for 'active', 'trialing', null/undefined, billingExempt. A future status value falls through showing a misleading warning. Low risk while the enum stays at 4 values.
security3
Fail-open on soft-deleted org: findById filters deletedAt IS NULL
packages/api/src/middleware/require-active-subscription.ts:33
A soft-deleted canceled org returns null → isOrgAllowed(null) = true → allowed. Edge case worth documenting.
402 response body carries raw subscriptionStatus — visible to all org members
domains/core/src/billing/feature-access.errors.ts
Not a cross-org disclosure, but all members (not just billing admins) can learn precise subscription status from a 402 response.
Bill-file retrieval endpoints are intentionally ungated — design is correct per spec
apps/platform/src/api/handlers/bill-files.handler.ts
Per the spec 'kept' column: export of already-downloaded data is explicitly open. The gate targets job creation (cost-incurring), not S3 retrieval. Correct design.
conventions4
Error code casing inconsistency: SUBSCRIPTION_INACTIVE (internal cfe-jobs) vs subscription_inactive (public-v1)
apps/platform/src/api/handlers/cfe-jobs.handler.ts:113
Internal handler emits SCREAMING_SNAKE; public mapper emits snake_case. Dashboard UI and API clients see different codes for the same error. Unifying via the mapper pattern would fix both this and the inline-helper duplication.
subscriptionInactiveResponse inline helper duplicates mapper pattern
apps/platform/src/api/handlers/cfe-jobs.handler.ts:107
All other errors in this handler route through mapCreateJobError / mapRetryJobError. Adding SubscriptionInactive to the mappers would eliminate this helper and fix the casing inconsistency.
isOrgAllowed conflates fail-open policy (shell concern) with pure decision logic
domains/core/src/billing/feature-access.decisions.ts:85
The null/fail-open check is an infrastructure concern mixed into a pure decision file. Cleaner: null check lives in the middleware, decideFeatureAccess only handles known statuses.
require-active-subscription calls organizationQueries.findById directly in middleware
packages/api/src/middleware/require-active-subscription.ts:33
Per domain-patterns.md, handlers must not call queries directly. This is a defensible escape hatch for a read-only single-row lookup, but should be explicitly documented with the escape-hatch rationale in the file.
tests5
mapPublicJobError({ _tag: 'SubscriptionInactive' }) is not tested
apps/platform/src/api/mappers/public-v1/__tests__/jobs.mapper.test.ts
The exhaustiveness sweep in jobs.mapper.test.ts is missing the SubscriptionInactive case. The 402 status code and 'subscription_inactive' error code are never asserted.
cfe-jobs internal handler gate call sites are completely untested (create/batch/retry)
apps/platform/src/api/handlers/cfe-jobs.handler.ts
No unit or integration tests verify that a canceled org hitting POST /cfe-jobs, POST /cfe-jobs/batch, or POST /cfe-jobs/:id/retry returns 402. The payment_check bypass (create and retry allow payment_status pipeline for canceled orgs) is also untested.
requireActiveSubscription middleware does not test 'trialing' status
packages/api/src/middleware/__tests__/require-active-subscription.test.ts
Tests cover active, past_due, canceled, null — but not 'trialing'. All four allowed statuses should be verified at the middleware layer.
Subscriber Lambda integration with partitionGroupsByAccess is untested at Lambda level
services/utility/bills/cfe/src/handlers/subscriber.lambda.ts:123
Pure partitionGroupsByAccess is well-tested, but no test verifies the end-to-end Lambda path — that a canceled org's subscriptions are not dispatched to SFN.
partitionGroupsByAccess does not test duplicate blocked orgs are deduplicated in blockedPublicIds
services/utility/bills/cfe/__tests__/unit/domain/subscription-access.test.ts
A canceled org with multiple RPU groups should appear only once in blockedPublicIds. Not tested.
improvement2
Consider isPipelineGated(type) helper to prevent create vs retry condition drift
apps/platform/src/api/handlers/cfe-jobs.handler.ts:217
create uses config.type !== 'payment_status', retry uses job.pipeline !== 'payment_status'. A shared helper prevents drift when adding future ungated pipeline types.
filterBySubscriptionAccess shell / partitionGroupsByAccess pure split is well-designed
services/utility/bills/cfe/src/handlers/subscriber.lambda.ts
The async shell / pure core separation is the correct FCIS pattern. No improvement warranted.