feat/subscription-gating
needs attentionviewing older commit1ae74bd · fullPR #234reviewed 2026-07-04 02:36 UTC6H · 9M · 8L · 1I- Purpose
- Gate cost-incurring CFE bill downloads for organizations whose Stripe subscription is unpaid or canceled, while keeping reads, billing pages, and payment-status checks open.
- Goal
- Add a subscription enforcement layer using the existing organizations.subscription_status field (synced daily from Stripe by mrr-sync), with no new table, migration, or sync required.
- Sub-goals
- SG-1: Pure decideFeatureAccess policy + unit tests (feature-access.decisions.ts / errors.ts)
- SG-2: requireActiveSubscription middleware — request-path guard returning 402 on block
- SG-3: Enforce at 3 cfe-jobs handler chokepoints (collect create, batch, collect retry) — payment_status exempt
- SG-4: Enforce at public POST /v1/jobs — bill_collection blocked, payment_check exempt
- SG-5: CFE subscriber Lambda — filter inactive orgs from daily auto-download work list
- SG-6: Frontend SubscriptionBanner (amber past_due / red canceled) in dashboard layout + es/en strings
- SG-7: organizationQueries.findManyByIds — batch status lookup for subscriber Lambda
- What
- New billing domain module (feature-access.decisions.ts, errors.ts, tests), new middleware (require-active-subscription.ts), subscription gates at 4 handler chokepoints, subscriber Lambda batch filter, new findManyByIds query, SubscriptionBanner React component, error code and type additions, i18n strings, docs/development/subscription-access-gating.md design doc.
- Why
- Stripe state is now reliably synced; blocking unpaid orgs from cost-incurring downloads aligns revenue with delivery. Fail-open policy ensures a sync hiccup never locks out a paying customer.
- Areas
- domains/core/src/billing+149−0domains/core/src/organization+17−1packages/api/src+48−0packages/api/package.json+4−0apps/platform/src/api+75−2apps/platform/src/app/(dashboard)+17−2apps/platform/src/components+55−0apps/platform/src/messages+14−0services/utility/bills/cfe/src/handlers+47−2docs/development+209−0
- Blast
- 21 files, +631/-7 net. Touches: billing domain (new module), org queries, API middleware + schemas, 4 handler files, Lambda subscriber, React dashboard layout, i18n. No DB migration. Read-only paths, billing page, and payment-status checks are intentionally ungated.
Findings · 25
correctness4
Payment-subscriber cron has no subscription gate for collect jobs
services/utility/bills/cfe/src/handlers/payment-subscriber.lambda.ts
filterBySubscriptionAccess was added to subscriber.lambda.ts (collect engine) but payment-subscriber.lambda.ts (the daily PS cron) has no equivalent guard. If PS jobs for canceled orgs are intentionally allowed (as the handler comments suggest), the omission should be explicitly documented in the payment-subscriber source to prevent accidental addition of a gate later.
UI call sites have no specific handling for 402 SUBSCRIPTION_INACTIVE
apps/platform/src/app/[locale]/(dashboard)/bills/_components/AddContractDrawer.tsx:265
Multiple UI call sites check err?.status === 409 but fall through to a generic error toast for 402. A canceled-org user will see a confusing generic message instead of a directed billing CTA. Affected: AddContractDrawer.tsx, StaleBillBadge.tsx, ContractServiceCell.tsx, contratos/page.tsx, BatchContractForm.tsx, BulkActionBar.tsx.
filterBySubscriptionAccess fail-open allows soft-deleted org groups
services/utility/bills/cfe/src/handlers/subscriber.lambda.ts:121
findManyByIds filters out soft-deleted orgs, so a soft-deleted org's RPU groups pass through as allowed (fail-open). In practice soft-deleted orgs should have cascaded subscription cleanup, but the timing window is undocumented.
SubscriptionBanner billingExempt check requires metadata in org API response
apps/platform/src/components/SubscriptionBanner.tsx:30
The banner reads metadata?.billingExempt from the org context. If the organization API response mapper does not expose the metadata field, exempt orgs with past_due/canceled status will see false-positive banners (though the server gate still correctly allows them through).
security4
Fail-open on missing org bypasses subscription gate entirely
packages/api/src/middleware/require-active-subscription.ts:34
When findById returns null the guard returns ok(undefined) — any request with a JWT referencing a deleted or non-existent orgId is unconditionally permitted. The orgId must still come from a valid JWT claim, limiting the surface, but a safer pattern would be to block on not-found and only fail-open on genuine DB exceptions.
Subscription status field carried in SubscriptionInactiveError struct
domains/core/src/billing/feature-access.errors.ts:22
The error struct includes the raw subscriptionStatus field. Currently not surfaced in public API responses, but it is one property access away from a future logging/serialization slip. Consider removing status from the public-facing error or keeping it internal-only.
Internal org UUIDs logged in Lambda for blocked orgs
services/utility/bills/cfe/src/handlers/subscriber.lambda.ts:136
filterBySubscriptionAccess logs o.id (internal UUID PK) for blocked orgs. Prefer the public org.publicId (org_XXX) in operational logs.
Canceled-org user can create payment_status jobs at volume (no rate limit visible)
apps/platform/src/api/handlers/cfe-jobs.handler.ts:214
The payment_status carve-out is intentional. If these jobs have per-execution cost (Lambda/SFN), a canceled-org user could create them at volume. Rate limiting on the endpoint (not visible in this diff) would mitigate this.
conventions4
SUBSCRIPTION_INACTIVE error code not registered in catalog
apps/platform/src/api/handlers/cfe-jobs.handler.ts:111
subscriptionInactiveResponse() emits error: "SUBSCRIPTION_INACTIVE" but this code has no entry in packages/api/src/responses/codes.ts. Per api-patterns.md every error code must be registered there; the internal JSendFailSchema in the contract is a local untyped copy that silently bypasses compile-time enforcement.
cfe-jobs contract uses local untyped JSendFailSchema shadow
apps/platform/src/api/contracts/cfe-jobs.contract.ts:33
The contract file re-declares a local JSendFailSchema that accepts any string as error, bypassing the catalog-typed factory in packages/api/src/schemas/jsend.schemas.ts. The missing SUBSCRIPTION_INACTIVE registration above is a direct consequence — the catalog-typed version would make it a compile error.
FeatureAccess and FeatureAccessLevel types in decisions file instead of type file
domains/core/src/billing/feature-access.decisions.ts:23
Canonical form requires domain types in {entity}.type.ts. FeatureAccess and FeatureAccessLevel are declared in feature-access.decisions.ts. No Drizzle mapping is needed, so a type file may be minimal, but placing return types in the decisions file deviates from the canonical pattern.
SubscriptionBanner.tsx duplicates SubscriptionStatus type instead of importing from domain
apps/platform/src/components/SubscriptionBanner.tsx:8
A local SubscriptionStatus = 'active' | 'past_due' | 'canceled' | 'trialing' is declared locally. The comment cites avoiding server-only deps (drizzle), but SubscriptionStatus is a pure string-literal union — import type from @batu/core-domain is always elided by the bundler and never pulls drizzle into the client bundle.
tests8
No integration tests for handler subscription gates
apps/platform/src/__tests__/integration/cfe-jobs-subscription-gate.test.ts (new)
All three chokepoints in cfe-jobs.handler.ts (createCfeJob, retryCfeJob, createBatch) and one in public-v1/jobs.handler.ts have no integration tests. Missing scenarios: canceled org → 402; active org → pass; billingExempt=true overrides canceled → pass; payment_status type bypasses gate for canceled org.
No tests for requireActiveSubscription middleware fail-open and exempt paths
packages/api/src/middleware/__tests__/require-active-subscription.test.ts (new)
The middleware has two non-obvious behaviors: fail-open when org is not found (returns ok — untested), and billingExempt cast (stringified 'true' must NOT pass — untested). These are unit tests with mocked findById.
No tests for filterBySubscriptionAccess in subscriber Lambda
services/utility/bills/cfe/src/__tests__/subscriber-subscription-filter.test.ts (new)
The batch subscription filter is untested. Critical missing cases: mixed active/canceled batch; all-blocked returns empty array; missing org row is fail-open (not added to blocked set); billingExempt=true with canceled status is kept. A wrong blocked.has(g.orgId) check using mismatched ID fields would silently block everyone or no one.
No tests for findManyByIds query function
domains/core/src/organization/__tests__/organization.queries.test.ts (extend)
findManyByIds has a non-trivial early-return for empty ids array (guards Drizzle inArray edge case) and a soft-delete filter. Both are untested. Integration tests following the existing organization.queries.test.ts pattern.
No tests for SubscriptionBanner conditional rendering
apps/platform/src/components/__tests__/SubscriptionBanner.test.tsx (new)
The banner re-implements the gating policy (noted in its own comment). Without tests the server/client policy drift risk is undetected. Missing: active/trialing → null; past_due → amber warning; canceled → red blocker; billingExempt=true+canceled → null; CTA link locale.
No tests for cfe-job-client.ts error augmentation
apps/platform/src/app/[locale]/(dashboard)/bills/_lib/__tests__/cfe-job-client.test.ts (new)
The .status and .code fields on thrown errors are the mechanism callers use to detect 402 subscription errors. No test verifies a 402 response throws with e.status===402 and e.code==='SUBSCRIPTION_INACTIVE'.
SubscriptionInactiveError shape not pinned by test
domains/core/src/billing/__tests__/feature-access.decisions.test.ts
FeatureAccessErrors.subscriptionInactive() message is forwarded directly to the HTTP response body. No test pins the exact _tag, statusCode: 402, and message fields. A change to the message or statusCode would silently change API behavior.
No test for retry handler payment_status pipeline bypass
apps/platform/src/__tests__/integration/cfe-jobs-subscription-gate.test.ts (new)
The retry handler gates on job.pipeline !== 'payment_status'. A canceled org retrying a payment_status job must still pass — this exemption is untested. A regression here would block users from checking payment status during the exact moment they need it most.
improvement5
Duplicated metadata billingExempt cast in two places
packages/api/src/middleware/require-active-subscription.ts
The pattern (org.metadata as Record<string,unknown> | null)?.billingExempt === true appears in both requireActiveSubscription and filterBySubscriptionAccess. Extract isBillingExempt(metadata: unknown): boolean into feature-access.decisions.ts.
Subscription gate block copy-pasted 3x in cfe-jobs.handler.ts
apps/platform/src/api/handlers/cfe-jobs.handler.ts
The guard check (type gate + requireActiveSubscription call + early return) is duplicated for create, retry, and batch with only the variable name differing. Extract into a helper to give the bypass rule a single location.
SubscriptionBanner.tsx uses pathname split instead of useLocale()
apps/platform/src/components/SubscriptionBanner.tsx
const locale = pathname.split('/')[1] || 'en' reimplements locale detection. Replace with useLocale() from next-intl — the pathname split breaks on root paths and routing changes.
SUBSCRIPTION_INACTIVE casing inconsistency between internal and public API
apps/platform/src/api/handlers/cfe-jobs.handler.ts
Internal surface emits SUBSCRIPTION_INACTIVE (uppercase SCREAMING_SNAKE) while public API uses subscription_inactive (lowercase). Even if intentional, derive from the same source to prevent future divergence.
findManyByIds fetches full org rows when only metadata + subscriptionStatus are needed
services/utility/bills/cfe/src/handlers/subscriber.lambda.ts
filterBySubscriptionAccess only reads metadata and subscriptionStatus but fetches full org rows. If the query layer supports projection, narrowing to those two fields reduces Lambda payload in cold paths.
History · 7 commits
- c807731needs attentionincremental0H · 6M · 8L2026-07-30 17:24
- 372882bneeds attentionincremental0H · 1M · 2L2026-07-21 17:31
- 1fac5adneeds attentionincremental0H · 5M · 4L2026-07-07 02:50
- 99bf169safeincremental0H · 0M · 1L2026-07-04 04:01
- 6305b3cneeds attentionincremental0H · 1M · 4L2026-07-04 03:53
- f00e3caneeds attentionincremental0H · 7M · 16L2026-07-04 03:15
- 1ae74bdneeds attentionfull6H · 9M · 8L2026-07-04 02:36current