← all branches

feat/subscription-gating

needs attentionviewing older commit
1ae74bd · fullPR #234reviewed 2026-07-04 02:36 UTC6H · 9M · 8L · 1I
The branch
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
The changes (whole branch)
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+1490domains/core/src/organization+171packages/api/src+480packages/api/package.json+40apps/platform/src/api+752apps/platform/src/app/(dashboard)+172apps/platform/src/components+550apps/platform/src/messages+140services/utility/bills/cfe/src/handlers+472docs/development+2090
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.
Supersedes #233 (branch rename detached head; same code) CDK CFE-layer preview build broken independently of this PR (onnxruntime-node issue) Payment-subscriber Lambda intentionally ungated (PS jobs aid collection for inactive orgs)
CI checks· GraphQL access insufficient for statusCheckRollup; CI status unknownCodeRabbit· No .coderabbit.yaml found

Findings · 25

correctness4

high

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.

medium

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.

low

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.

low

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

medium

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.

medium

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.

low

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.

low

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

high

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.

high

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.

medium

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.

low

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

high

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.

high

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.

high

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.

high

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.

medium

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.

medium

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'.

low

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.

low

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

medium

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.

medium

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.

medium

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.

low

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.

info

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

  1. c807731needs attentionincremental0H · 6M · 8L2026-07-30 17:24
  2. 372882bneeds attentionincremental0H · 1M · 2L2026-07-21 17:31
  3. 1fac5adneeds attentionincremental0H · 5M · 4L2026-07-07 02:50
  4. 99bf169safeincremental0H · 0M · 1L2026-07-04 04:01
  5. 6305b3cneeds attentionincremental0H · 1M · 4L2026-07-04 03:53
  6. f00e3caneeds attentionincremental0H · 7M · 16L2026-07-04 03:15
  7. 1ae74bdneeds attentionfull6H · 9M · 8L2026-07-04 02:36current