← all branches

paused/subscription-gating

needs attention
c807731 · fullpre-PRreviewed 2026-08-11 18:59 UTC4H · 7M · 4L · 2I
The branch
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)
The changes (whole branch)
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+2200packages/api/src/middleware+1030apps/platform/src/api+750apps/platform/src/components+520apps/platform/src/app+151services/utility/bills/cfe+1402packages/api/src+80docs/development+2090
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.
billing public-api
CI / typecheck· No PR — CI status not available for pre-PR branchCodeRabbit· No .coderabbit.yaml in repo

Findings · 17

correctness3

high

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

medium

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.

medium

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

low

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.

low

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.

info

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

high

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.

medium

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.

medium

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.

medium

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

high

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.

high

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.

medium

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.

medium

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.

low

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

low

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.

info

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.