@codelitdev/billing 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +339 -0
- package/dist/adapters/drizzle/index.d.ts +28 -0
- package/dist/adapters/drizzle/index.d.ts.map +1 -0
- package/dist/adapters/drizzle/index.js +75 -0
- package/dist/adapters/drizzle/index.js.map +1 -0
- package/dist/adapters/drizzle/store.d.ts +37 -0
- package/dist/adapters/drizzle/store.d.ts.map +1 -0
- package/dist/adapters/drizzle/store.js +889 -0
- package/dist/adapters/drizzle/store.js.map +1 -0
- package/dist/catalog/index.d.ts +5 -0
- package/dist/catalog/index.d.ts.map +1 -0
- package/dist/catalog/index.js +4 -0
- package/dist/catalog/index.js.map +1 -0
- package/dist/catalog/public-view.d.ts +7 -0
- package/dist/catalog/public-view.d.ts.map +1 -0
- package/dist/catalog/public-view.js +22 -0
- package/dist/catalog/public-view.js.map +1 -0
- package/dist/catalog/revisions.d.ts +8 -0
- package/dist/catalog/revisions.d.ts.map +1 -0
- package/dist/catalog/revisions.js +12 -0
- package/dist/catalog/revisions.js.map +1 -0
- package/dist/catalog/types.d.ts +28 -0
- package/dist/catalog/types.d.ts.map +1 -0
- package/dist/catalog/types.js +2 -0
- package/dist/catalog/types.js.map +1 -0
- package/dist/catalog/validate.d.ts +16 -0
- package/dist/catalog/validate.d.ts.map +1 -0
- package/dist/catalog/validate.js +66 -0
- package/dist/catalog/validate.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +35 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/generate.d.ts +15 -0
- package/dist/cli/generate.d.ts.map +1 -0
- package/dist/cli/generate.js +68 -0
- package/dist/cli/generate.js.map +1 -0
- package/dist/config/define.d.ts +3 -0
- package/dist/config/define.d.ts.map +1 -0
- package/dist/config/define.js +5 -0
- package/dist/config/define.js.map +1 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +3 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/validate.d.ts +32 -0
- package/dist/config/validate.d.ts.map +1 -0
- package/dist/config/validate.js +227 -0
- package/dist/config/validate.js.map +1 -0
- package/dist/core/checkout-attempt.d.ts +29 -0
- package/dist/core/checkout-attempt.d.ts.map +1 -0
- package/dist/core/checkout-attempt.js +10 -0
- package/dist/core/checkout-attempt.js.map +1 -0
- package/dist/core/clock.d.ts +10 -0
- package/dist/core/clock.d.ts.map +1 -0
- package/dist/core/clock.js +26 -0
- package/dist/core/clock.js.map +1 -0
- package/dist/core/customer.d.ts +13 -0
- package/dist/core/customer.d.ts.map +1 -0
- package/dist/core/customer.js +2 -0
- package/dist/core/customer.js.map +1 -0
- package/dist/core/errors.d.ts +28 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +43 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/ids.d.ts +15 -0
- package/dist/core/ids.d.ts.map +1 -0
- package/dist/core/ids.js +29 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/index.d.ts +14 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +14 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/money.d.ts +8 -0
- package/dist/core/money.d.ts.map +1 -0
- package/dist/core/money.js +23 -0
- package/dist/core/money.js.map +1 -0
- package/dist/core/plan-change-attempt.d.ts +30 -0
- package/dist/core/plan-change-attempt.d.ts.map +1 -0
- package/dist/core/plan-change-attempt.js +9 -0
- package/dist/core/plan-change-attempt.js.map +1 -0
- package/dist/core/projection-diff.d.ts +32 -0
- package/dist/core/projection-diff.d.ts.map +1 -0
- package/dist/core/projection-diff.js +32 -0
- package/dist/core/projection-diff.js.map +1 -0
- package/dist/core/reconciliation-job.d.ts +21 -0
- package/dist/core/reconciliation-job.d.ts.map +1 -0
- package/dist/core/reconciliation-job.js +26 -0
- package/dist/core/reconciliation-job.js.map +1 -0
- package/dist/core/subscription.d.ts +62 -0
- package/dist/core/subscription.d.ts.map +1 -0
- package/dist/core/subscription.js +14 -0
- package/dist/core/subscription.js.map +1 -0
- package/dist/core/transitions.d.ts +25 -0
- package/dist/core/transitions.d.ts.map +1 -0
- package/dist/core/transitions.js +161 -0
- package/dist/core/transitions.js.map +1 -0
- package/dist/core/webhook-inbox.d.ts +23 -0
- package/dist/core/webhook-inbox.d.ts.map +1 -0
- package/dist/core/webhook-inbox.js +9 -0
- package/dist/core/webhook-inbox.js.map +1 -0
- package/dist/maintenance/batches.d.ts +3 -0
- package/dist/maintenance/batches.d.ts.map +1 -0
- package/dist/maintenance/batches.js +3 -0
- package/dist/maintenance/batches.js.map +1 -0
- package/dist/maintenance/claims.d.ts +2 -0
- package/dist/maintenance/claims.d.ts.map +1 -0
- package/dist/maintenance/claims.js +2 -0
- package/dist/maintenance/claims.js.map +1 -0
- package/dist/maintenance/health.d.ts +13 -0
- package/dist/maintenance/health.d.ts.map +1 -0
- package/dist/maintenance/health.js +19 -0
- package/dist/maintenance/health.js.map +1 -0
- package/dist/maintenance/retry.d.ts +9 -0
- package/dist/maintenance/retry.d.ts.map +1 -0
- package/dist/maintenance/retry.js +19 -0
- package/dist/maintenance/retry.js.map +1 -0
- package/dist/operations/index.d.ts +28 -0
- package/dist/operations/index.d.ts.map +1 -0
- package/dist/operations/index.js +106 -0
- package/dist/operations/index.js.map +1 -0
- package/dist/persistence/memory.d.ts +104 -0
- package/dist/persistence/memory.d.ts.map +1 -0
- package/dist/persistence/memory.js +657 -0
- package/dist/persistence/memory.js.map +1 -0
- package/dist/persistence/store.d.ts +126 -0
- package/dist/persistence/store.d.ts.map +1 -0
- package/dist/persistence/store.js +2 -0
- package/dist/persistence/store.js.map +1 -0
- package/dist/ports/audit.d.ts +20 -0
- package/dist/ports/audit.d.ts.map +1 -0
- package/dist/ports/audit.js +9 -0
- package/dist/ports/audit.js.map +1 -0
- package/dist/ports/authorization.d.ts +28 -0
- package/dist/ports/authorization.d.ts.map +1 -0
- package/dist/ports/authorization.js +33 -0
- package/dist/ports/authorization.js.map +1 -0
- package/dist/ports/lifecycle.d.ts +23 -0
- package/dist/ports/lifecycle.d.ts.map +1 -0
- package/dist/ports/lifecycle.js +2 -0
- package/dist/ports/lifecycle.js.map +1 -0
- package/dist/ports/sensitive-values.d.ts +23 -0
- package/dist/ports/sensitive-values.d.ts.map +1 -0
- package/dist/ports/sensitive-values.js +19 -0
- package/dist/ports/sensitive-values.js.map +1 -0
- package/dist/ports/telemetry.d.ts +16 -0
- package/dist/ports/telemetry.d.ts.map +1 -0
- package/dist/ports/telemetry.js +48 -0
- package/dist/ports/telemetry.js.map +1 -0
- package/dist/providers/contract.d.ts +98 -0
- package/dist/providers/contract.d.ts.map +1 -0
- package/dist/providers/contract.js +2 -0
- package/dist/providers/contract.js.map +1 -0
- package/dist/providers/dodo/index.d.ts +25 -0
- package/dist/providers/dodo/index.d.ts.map +1 -0
- package/dist/providers/dodo/index.js +296 -0
- package/dist/providers/dodo/index.js.map +1 -0
- package/dist/providers/dodo/normalize.d.ts +7 -0
- package/dist/providers/dodo/normalize.d.ts.map +1 -0
- package/dist/providers/dodo/normalize.js +77 -0
- package/dist/providers/dodo/normalize.js.map +1 -0
- package/dist/providers/fake/index.d.ts +59 -0
- package/dist/providers/fake/index.d.ts.map +1 -0
- package/dist/providers/fake/index.js +312 -0
- package/dist/providers/fake/index.js.map +1 -0
- package/dist/providers/index.d.ts +4 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +3 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/registry.d.ts +10 -0
- package/dist/providers/registry.d.ts.map +1 -0
- package/dist/providers/registry.js +32 -0
- package/dist/providers/registry.js.map +1 -0
- package/dist/schema/canonical.d.ts +7 -0
- package/dist/schema/canonical.d.ts.map +1 -0
- package/dist/schema/canonical.js +35 -0
- package/dist/schema/canonical.js.map +1 -0
- package/dist/schema/generator.d.ts +3 -0
- package/dist/schema/generator.d.ts.map +1 -0
- package/dist/schema/generator.js +496 -0
- package/dist/schema/generator.js.map +1 -0
- package/dist/schema/pglite-sql.d.ts +3 -0
- package/dist/schema/pglite-sql.d.ts.map +1 -0
- package/dist/schema/pglite-sql.js +226 -0
- package/dist/schema/pglite-sql.js.map +1 -0
- package/dist/testing/clocks.d.ts +2 -0
- package/dist/testing/clocks.d.ts.map +1 -0
- package/dist/testing/clocks.js +2 -0
- package/dist/testing/clocks.js.map +1 -0
- package/dist/testing/consumer-conformance.d.ts +12 -0
- package/dist/testing/consumer-conformance.d.ts.map +1 -0
- package/dist/testing/consumer-conformance.js +17 -0
- package/dist/testing/consumer-conformance.js.map +1 -0
- package/dist/testing/fixtures.d.ts +9 -0
- package/dist/testing/fixtures.d.ts.map +1 -0
- package/dist/testing/fixtures.js +69 -0
- package/dist/testing/fixtures.js.map +1 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +7 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/provider-contract.d.ts +14 -0
- package/dist/testing/provider-contract.d.ts.map +1 -0
- package/dist/testing/provider-contract.js +238 -0
- package/dist/testing/provider-contract.js.map +1 -0
- package/dist/testing/state-machine.d.ts +2 -0
- package/dist/testing/state-machine.d.ts.map +1 -0
- package/dist/testing/state-machine.js +2 -0
- package/dist/testing/state-machine.js.map +1 -0
- package/dist/testing/workflow-harness.d.ts +224 -0
- package/dist/testing/workflow-harness.d.ts.map +1 -0
- package/dist/testing/workflow-harness.js +65 -0
- package/dist/testing/workflow-harness.js.map +1 -0
- package/dist/workflows/engine.d.ts +157 -0
- package/dist/workflows/engine.d.ts.map +1 -0
- package/dist/workflows/engine.js +2015 -0
- package/dist/workflows/engine.js.map +1 -0
- package/dist/workflows/index.d.ts +10 -0
- package/dist/workflows/index.d.ts.map +1 -0
- package/dist/workflows/index.js +6 -0
- package/dist/workflows/index.js.map +1 -0
- package/dist/workflows/lifecycle.d.ts +5 -0
- package/dist/workflows/lifecycle.d.ts.map +1 -0
- package/dist/workflows/lifecycle.js +7 -0
- package/dist/workflows/lifecycle.js.map +1 -0
- package/docs/architecture.md +1188 -0
- package/docs/decisions/0001-extraction-discrepancies.md +29 -0
- package/docs/runbooks.md +140 -0
- package/package.json +110 -0
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
# CodeLit Billing architecture
|
|
2
|
+
|
|
3
|
+
Status: proposed
|
|
4
|
+
Audience: SendLit, CourseLit, FrontLit, MediaLit, and billing maintainers
|
|
5
|
+
Package: @codelitdev/billing
|
|
6
|
+
Package manager: pnpm
|
|
7
|
+
|
|
8
|
+
## 1. Purpose
|
|
9
|
+
|
|
10
|
+
@codelitdev/billing is a provider-neutral engine for billing a CodeLit product's own SaaS customers. It extracts stable mechanics already proven in SendLit so CourseLit, FrontLit, MediaLit, and future products do not create divergent billing engines.
|
|
11
|
+
|
|
12
|
+
It standardizes difficult, security-sensitive mechanics:
|
|
13
|
+
|
|
14
|
+
- Provider adapters and canonical provider events.
|
|
15
|
+
- Immutable, versioned price catalogs.
|
|
16
|
+
- Checkout, portal, cancellation, and plan-change workflows.
|
|
17
|
+
- Subscription transitions and provider reconciliation.
|
|
18
|
+
- Webhook verification, deduplication, replay, and recovery.
|
|
19
|
+
- Deterministic clock and idempotency behavior.
|
|
20
|
+
- Provider-safe public read models and stable workflow outcomes.
|
|
21
|
+
- Multi-instance maintenance claims, lifecycle blockers, and operator recovery.
|
|
22
|
+
- Fake providers and reusable contract suites.
|
|
23
|
+
|
|
24
|
+
Each product still defines whom it bills, which plans it sells, what those plans unlock, and what expiry does.
|
|
25
|
+
|
|
26
|
+
### Source baseline
|
|
27
|
+
|
|
28
|
+
This architecture is grounded in two concrete SendLit sources:
|
|
29
|
+
|
|
30
|
+
- apps/api/docs/pricing-plans-and-payments-integration.md, the implementation PRD dated 2026-08-28.
|
|
31
|
+
- The current SendLit branch implementing that PRD under apps/api/src/billing and apps/api/src/db/schema.ts.
|
|
32
|
+
|
|
33
|
+
The extraction unit is a behavior with tests, not a SendLit directory or file. Several current files deliberately mix generic payment mechanics with organisation lifecycle, Free-plan policy, mail usage, reputation, notifications, Express security, and Drizzle transactions. Those files must be split at their dependency seams rather than moved wholesale.
|
|
34
|
+
|
|
35
|
+
## 2. Consumer policy matrix
|
|
36
|
+
|
|
37
|
+
The first two consumers intentionally differ:
|
|
38
|
+
|
|
39
|
+
| Concern | SendLit | CourseLit |
|
|
40
|
+
| ------------------ | -------------------------------------------------- | ----------------------------------------------------------- |
|
|
41
|
+
| Billable entity | Organisation | School |
|
|
42
|
+
| Cloud entry state | Permanent Free plan | Cardless 14-day trial; no Free plan |
|
|
43
|
+
| Paid plans | Pro and Business | Pro and Business |
|
|
44
|
+
| Intervals | Monthly and yearly | Monthly and yearly |
|
|
45
|
+
| OSS | All plan-controlled features unlocked; no provider | All plan-controlled features unlocked; no provider |
|
|
46
|
+
| Trial | SendLit-defined; may be provider-assisted | CourseLit-managed per school, no fake provider subscription |
|
|
47
|
+
| Expiry consequence | SendLit entitlement policy | Public school unavailable; admin recovery remains |
|
|
48
|
+
| Usage | Contacts, sends, teams, reputation | CourseLit-defined capabilities/limits |
|
|
49
|
+
| Payer relationship | Product-defined | One payer can pay for several independently billed schools |
|
|
50
|
+
|
|
51
|
+
The shared package cannot contain a fixed plan union such as oss/free/pro/business. In particular, free must never leak into CourseLit's plan types, constraints, API, or UI.
|
|
52
|
+
|
|
53
|
+
FrontLit and MediaLit adopt only after SendLit and CourseLit prove the boundary.
|
|
54
|
+
|
|
55
|
+
## 3. Goals
|
|
56
|
+
|
|
57
|
+
1. Preserve SendLit's behavior during extraction.
|
|
58
|
+
2. Give CourseLit per-school billing without copying SendLit.
|
|
59
|
+
3. Test all providers against one public contract.
|
|
60
|
+
4. Make duplicate, delayed, missing, and out-of-order webhooks safe.
|
|
61
|
+
5. Keep plan and entitlement policy application-owned.
|
|
62
|
+
6. Release independently using SemVer and migration guides.
|
|
63
|
+
7. Work with or without the CodeLit Platform bootstrap.
|
|
64
|
+
|
|
65
|
+
## 4. Non-goals
|
|
66
|
+
|
|
67
|
+
The package does not provide:
|
|
68
|
+
|
|
69
|
+
- Product routes, ts-rest contracts, MCP tools, or UI.
|
|
70
|
+
- Authentication, membership, owner-role policy, CSRF, or recent-auth token issuance.
|
|
71
|
+
- Plan names, feature policy, quota dimensions, trial/grace duration, or availability.
|
|
72
|
+
- Product-specific schema extensions, entitlement-adjustment tables, migrations, backfills, retention, or deletion policy.
|
|
73
|
+
- Automatic DDL during package installation, import, API startup, or worker startup.
|
|
74
|
+
- A shared billing database or hosted billing service.
|
|
75
|
+
- Accounting, revenue recognition, or tax behavior beyond a provider contract.
|
|
76
|
+
- A universal usage-metering system in version 1.
|
|
77
|
+
- Automatic migration of a subscription between providers.
|
|
78
|
+
- SendLit's one-owned-Free-organisation rule or verified-email trial-abuse policy.
|
|
79
|
+
- SendLit's sending reputation, domain verification, marketing ramp, and notification policy.
|
|
80
|
+
|
|
81
|
+
Although actor eligibility is application-owned, package workflows still enforce billing identities already persisted in canonical state. For example, a portal or plan-change request cannot substitute a different payer merely because the application passed an authorized actor.
|
|
82
|
+
|
|
83
|
+
## 5. Architecture
|
|
84
|
+
|
|
85
|
+
Consuming application
|
|
86
|
+
API/UI -> authentication and authorization
|
|
87
|
+
-> product plans/trials/entitlements
|
|
88
|
+
-> @codelitdev/billing workflows
|
|
89
|
+
|-> catalog and transition core
|
|
90
|
+
|-> provider adapter -> Dodo / future provider
|
|
91
|
+
|-> generated canonical schema + Drizzle adapter
|
|
92
|
+
| -> product PostgreSQL
|
|
93
|
+
|-> application audit/outbox
|
|
94
|
+
+-> injected no-op or product telemetry
|
|
95
|
+
|
|
96
|
+
The package uses hexagonal boundaries:
|
|
97
|
+
|
|
98
|
+
- Core code imports no provider SDK, Express, Drizzle, Pino, PostHog, or product module.
|
|
99
|
+
- Provider SDKs remain behind adapters.
|
|
100
|
+
- The package owns the canonical billing schema specification, generator, and optional Drizzle adapter.
|
|
101
|
+
- Applications own the checked-in generated schema, migration generation/review/execution, foreign-key targets, backfills, and product extensions.
|
|
102
|
+
- Product effects are application-supplied transactional hooks.
|
|
103
|
+
- Workflows coordinate local/remote state but do not own product policy.
|
|
104
|
+
|
|
105
|
+
## 6. Package layout and exports
|
|
106
|
+
|
|
107
|
+
Start as one package with explicit subpath exports. Split provider packages only if SDK weight or release cadence demonstrates a need.
|
|
108
|
+
|
|
109
|
+
billing/
|
|
110
|
+
src/
|
|
111
|
+
core/
|
|
112
|
+
ids.ts
|
|
113
|
+
money.ts
|
|
114
|
+
subscription.ts
|
|
115
|
+
checkout-attempt.ts
|
|
116
|
+
plan-change-attempt.ts
|
|
117
|
+
webhook-inbox.ts
|
|
118
|
+
transitions.ts
|
|
119
|
+
errors.ts
|
|
120
|
+
clock.ts
|
|
121
|
+
projection-diff.ts
|
|
122
|
+
catalog/
|
|
123
|
+
types.ts
|
|
124
|
+
validate.ts
|
|
125
|
+
revisions.ts
|
|
126
|
+
public-view.ts
|
|
127
|
+
config/
|
|
128
|
+
define.ts
|
|
129
|
+
validate.ts
|
|
130
|
+
schema/
|
|
131
|
+
canonical.ts
|
|
132
|
+
generator.ts
|
|
133
|
+
drizzle.ts
|
|
134
|
+
adapters/
|
|
135
|
+
drizzle/
|
|
136
|
+
workflows/ experimental until two-consumer proof
|
|
137
|
+
customer.ts
|
|
138
|
+
checkout.ts
|
|
139
|
+
portal.ts
|
|
140
|
+
plan-change.ts
|
|
141
|
+
cancellation.ts
|
|
142
|
+
webhook.ts
|
|
143
|
+
reconciliation.ts
|
|
144
|
+
lifecycle.ts
|
|
145
|
+
maintenance/
|
|
146
|
+
claims.ts
|
|
147
|
+
batches.ts
|
|
148
|
+
health.ts
|
|
149
|
+
operations/
|
|
150
|
+
inspect.ts
|
|
151
|
+
reconcile.ts
|
|
152
|
+
retry.ts
|
|
153
|
+
catalog.ts
|
|
154
|
+
cancellation.ts
|
|
155
|
+
ports/
|
|
156
|
+
authorization.ts
|
|
157
|
+
audit.ts
|
|
158
|
+
sensitive-values.ts
|
|
159
|
+
lifecycle.ts
|
|
160
|
+
telemetry.ts
|
|
161
|
+
providers/
|
|
162
|
+
contract.ts
|
|
163
|
+
registry.ts
|
|
164
|
+
dodo/
|
|
165
|
+
fake/
|
|
166
|
+
testing/
|
|
167
|
+
provider-contract.ts
|
|
168
|
+
workflow-harness.ts
|
|
169
|
+
consumer-conformance.ts
|
|
170
|
+
state-machine.ts
|
|
171
|
+
clocks.ts
|
|
172
|
+
fixtures.ts
|
|
173
|
+
cli/
|
|
174
|
+
generate.ts
|
|
175
|
+
check.ts
|
|
176
|
+
docs/
|
|
177
|
+
architecture.md
|
|
178
|
+
adoption.md
|
|
179
|
+
provider-authoring.md
|
|
180
|
+
decisions/
|
|
181
|
+
|
|
182
|
+
Public paths:
|
|
183
|
+
|
|
184
|
+
@codelitdev/billing/core
|
|
185
|
+
@codelitdev/billing/catalog
|
|
186
|
+
@codelitdev/billing/config
|
|
187
|
+
@codelitdev/billing/drizzle
|
|
188
|
+
@codelitdev/billing/workflows
|
|
189
|
+
@codelitdev/billing/operations
|
|
190
|
+
@codelitdev/billing/providers
|
|
191
|
+
@codelitdev/billing/providers/dodo
|
|
192
|
+
@codelitdev/billing/testing
|
|
193
|
+
|
|
194
|
+
The package exposes schema-generation/check commands through its bin entry. Product operator CLIs and admin screens call the operations API; the package does not ship a product-authenticated universal operator CLI. Consumers cannot import src paths. CI tests a packed artifact using only exports.
|
|
195
|
+
|
|
196
|
+
## 7. Canonical domain
|
|
197
|
+
|
|
198
|
+
The model uses opaque application identifiers and generic string plan IDs:
|
|
199
|
+
|
|
200
|
+
BillingInterval = month | year
|
|
201
|
+
|
|
202
|
+
CanonicalSubscriptionStatus =
|
|
203
|
+
pending | trialing | active | past_due | cancelled | expired
|
|
204
|
+
|
|
205
|
+
BillableEntityRef = {
|
|
206
|
+
kind: application-defined string
|
|
207
|
+
id: opaque application ID
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
PayerRef = {
|
|
211
|
+
id: opaque application ID
|
|
212
|
+
email: provider input, never an analytics identity
|
|
213
|
+
name: optional
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
BillingOffer<PlanId, OfferKey> = {
|
|
217
|
+
key
|
|
218
|
+
revision
|
|
219
|
+
plan
|
|
220
|
+
interval
|
|
221
|
+
currency
|
|
222
|
+
amountMinor
|
|
223
|
+
provider
|
|
224
|
+
providerProductId
|
|
225
|
+
providerTrialDays
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
Rules:
|
|
229
|
+
|
|
230
|
+
- Money is an integer minor-unit amount plus uppercase ISO 4217 currency.
|
|
231
|
+
- Provider IDs are bounded opaque strings.
|
|
232
|
+
- Product IDs are never inferred from provider IDs.
|
|
233
|
+
- PlanId and OfferKey come from the application.
|
|
234
|
+
- Application trials are entitlement sources, not plans.
|
|
235
|
+
- A cardless application trial does not require a provider subscription.
|
|
236
|
+
- cancelAtPeriodEnd is independent from canonical status.
|
|
237
|
+
- Provider period, paid-through, provider-trial, occurrence, and observation instants are explicit.
|
|
238
|
+
- Product grace and cardless-trial deadlines are supplied by the application and never inferred from provider timestamps.
|
|
239
|
+
- Decision functions receive an injected Clock; core code never reads wall time directly.
|
|
240
|
+
|
|
241
|
+
A normalized provider snapshot contains provider/customer/subscription/product IDs, canonical status, period dates, paid-through/trial dates, scheduled cancellation, provider occurrence/version information when available, observation time, and allowlisted string metadata. Provider-native status and SDK objects never escape the adapter.
|
|
242
|
+
|
|
243
|
+
Webhook evidence and subscription truth are separate types:
|
|
244
|
+
|
|
245
|
+
VerifiedWebhookEnvelope = {
|
|
246
|
+
provider
|
|
247
|
+
providerEventId
|
|
248
|
+
eventType
|
|
249
|
+
occurredAt
|
|
250
|
+
subscriptionId | null
|
|
251
|
+
verifiedKeyVersion | null
|
|
252
|
+
correlationMetadata
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
SubscriptionSnapshot = {
|
|
256
|
+
provider
|
|
257
|
+
providerCustomerId
|
|
258
|
+
providerSubscriptionId
|
|
259
|
+
providerProductId
|
|
260
|
+
canonical status and periods
|
|
261
|
+
providerOccurredAt | null
|
|
262
|
+
providerVersion | null
|
|
263
|
+
observedAt
|
|
264
|
+
correlationMetadata
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
The verified webhook envelope is a durable wake-up signal, not entitlement truth. For a subscription event, the worker calls retrieveSubscription and projects the current snapshot. This is the implemented SendLit behavior and prevents a delayed but valid webhook from rolling state backward.
|
|
268
|
+
|
|
269
|
+
Adapters may return a snapshot while parsing when a provider has already supplied and verified one, but the workflow must still support a configured retrieve-current-before-project policy. The initial Dodo policy always retrieves. Webhook occurrence, provider-state change, and local observation are distinct clocks. When the provider exposes no state version/change time, ordering uses the retrieved current snapshot plus material-state comparison; it must not pretend that retrieval time is provider event time.
|
|
270
|
+
|
|
271
|
+
## 8. Billable entity, payer, customer, and subscription
|
|
272
|
+
|
|
273
|
+
These are distinct:
|
|
274
|
+
|
|
275
|
+
- Billable entity: resource receiving entitlement, such as an organisation or school.
|
|
276
|
+
- Payer: authenticated account authorized by the application to manage billing.
|
|
277
|
+
- Provider customer: provider-side record for a payer and provider.
|
|
278
|
+
- Subscription: commercial agreement attached to one billable entity.
|
|
279
|
+
|
|
280
|
+
One provider customer may be reused by the same payer/provider. Every billable entity still has an independent subscription. A failed or cancelled CourseLit school cannot affect sibling schools.
|
|
281
|
+
|
|
282
|
+
The application authorizes the actor before calling billing. The package receives resolved references; it never queries memberships or assumes owner roles. Once a payer starts checkout, however, the package persists that association on the customer, attempt, and resulting subscription and enforces it for payer-sensitive workflows. A second owner does not become the provider customer merely by being an owner.
|
|
283
|
+
|
|
284
|
+
The package exposes lock-aware billing responsibility queries rather than owning product deletion:
|
|
285
|
+
|
|
286
|
+
getBillableEntityBillingBlockers(entity, now) ->
|
|
287
|
+
nonterminal_subscription |
|
|
288
|
+
future_paid_entitlement |
|
|
289
|
+
live_checkout |
|
|
290
|
+
pending_plan_change
|
|
291
|
+
|
|
292
|
+
getPayerBillingResponsibilities(payer, now) ->
|
|
293
|
+
provider customers and subscriptions for which the payer remains responsible
|
|
294
|
+
|
|
295
|
+
The application calls these inside the same transaction and documented lock order as close, delete, owner demotion, or payer-account deletion. It decides the user-facing consequence. A normal entity DELETE never performs a surprise remote cancellation, and a payer/provider-customer transfer is never implemented by overwriting an identifier. Cancellation-and-recreate or an eventual explicit transfer workflow requires verified provider support and an audited product operation.
|
|
296
|
+
|
|
297
|
+
## 9. Provider contract
|
|
298
|
+
|
|
299
|
+
The initial interface follows SendLit's working adapter while removing SendLit metadata:
|
|
300
|
+
|
|
301
|
+
BillingProviderAdapter {
|
|
302
|
+
provider
|
|
303
|
+
capabilities {
|
|
304
|
+
planChanges
|
|
305
|
+
intervalChanges
|
|
306
|
+
portalPlanChanges
|
|
307
|
+
portalIntervalChanges
|
|
308
|
+
proratedPlanChanges
|
|
309
|
+
mutationRecovery {
|
|
310
|
+
createCustomer: idempotency_key | lookup
|
|
311
|
+
createCheckout: idempotency_key | lookup
|
|
312
|
+
planChange: idempotency_key | lookup | unsupported
|
|
313
|
+
cancellation: idempotency_key | lookup | unsupported
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
createCustomer(input)
|
|
318
|
+
createCheckout(input)
|
|
319
|
+
createPortalSession(input)
|
|
320
|
+
changeSubscriptionPlan(input)
|
|
321
|
+
cancelSubscription(subscriptionId, idempotencyKey)
|
|
322
|
+
retrieveProduct(productId)
|
|
323
|
+
retrieveSubscription(subscriptionId)
|
|
324
|
+
parseWebhook(rawBodyAndHeaders)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
Only adapters import provider SDKs or interpret provider payload/status names. Errors map to:
|
|
328
|
+
|
|
329
|
+
- invalid
|
|
330
|
+
- unauthorized
|
|
331
|
+
- conflict
|
|
332
|
+
- rate_limited
|
|
333
|
+
- unavailable
|
|
334
|
+
- misconfigured
|
|
335
|
+
|
|
336
|
+
Persisted attempts and logs store only stable categories. Raw SDK messages/bodies are never exposed or persisted as summaries.
|
|
337
|
+
|
|
338
|
+
Provider capabilities are authoritative. A workflow returns a typed unsupported-operation result when a provider cannot make a requested change. UI and API then choose portal, scheduled change, or rejection explicitly.
|
|
339
|
+
|
|
340
|
+
An idempotencyKey parameter alone is not proof that a provider honors it. Every mutation declares a tested recovery strategy. A provider whose create-subscription operation supports neither idempotency nor a deterministic lookup cannot be used by the checkout workflow. Contract tests inject an ambiguous timeout after the provider side effect and prove that retry returns the original remote object rather than creating another one.
|
|
341
|
+
|
|
342
|
+
Initial adapters:
|
|
343
|
+
|
|
344
|
+
- Dodo Payments, extracted from SendLit without behavior changes.
|
|
345
|
+
- Deterministic fake supporting success, delay, duplicate, out-of-order, bad-signature, and outage scenarios.
|
|
346
|
+
|
|
347
|
+
A new adapter must pass the exported provider contract suite.
|
|
348
|
+
|
|
349
|
+
Provider construction uses explicit typed options, not direct process.env reads:
|
|
350
|
+
|
|
351
|
+
createDodoBillingProvider({
|
|
352
|
+
apiKey,
|
|
353
|
+
environment,
|
|
354
|
+
webhookSecrets: [{ version, secret, expiresAt }],
|
|
355
|
+
requestTimeoutMs,
|
|
356
|
+
clock
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
The consuming app parses and validates environment variables, chooses which adapters to instantiate, and limits secrets to API/worker processes that need them.
|
|
360
|
+
|
|
361
|
+
Provider calls have bounded deadlines. Read-only retrieval may retry with bounded exponential backoff and jitter. Customer, checkout, plan-change, and cancellation mutations retry only with the original provider idempotency key or a proven provider lookup; an ambiguous timeout never creates a fresh mutation.
|
|
362
|
+
|
|
363
|
+
The provider contract includes cancellation even though the original PRD interface excerpt omitted it, because the implemented SendLit adapter, fake, and contract suite already require it.
|
|
364
|
+
|
|
365
|
+
## 10. Catalog
|
|
366
|
+
|
|
367
|
+
Prices are deployment data; capabilities are product policy.
|
|
368
|
+
|
|
369
|
+
The catalog module validates application-provided offers and provider snapshots. It does not directly read SENDLIT or COURSELIT environment variables.
|
|
370
|
+
|
|
371
|
+
Invariants:
|
|
372
|
+
|
|
373
|
+
- Offer keys are unique within a revision.
|
|
374
|
+
- A revision contains exactly the application-declared required offer-key set; neither partial activation nor an undeclared extra offer is allowed.
|
|
375
|
+
- Provider product IDs are unique per provider/revision unless explicitly supported.
|
|
376
|
+
- Amount, currency, interval, and provider identity match provider truth.
|
|
377
|
+
- Published revisions and entries are immutable.
|
|
378
|
+
- Historical subscriptions retain the checkout revision.
|
|
379
|
+
- Cloud startup synchronously validates configuration shape and records the requested revision, but ordinary product readiness does not wait on provider availability.
|
|
380
|
+
- A fully verified database revision is required for new checkout.
|
|
381
|
+
- OSS may use an empty catalog and no provider.
|
|
382
|
+
|
|
383
|
+
The catalog module exports a provider-safe public projection containing revision, offer key, plan, interval, currency, amountMinor, and application-supplied trial display data. It never contains provider IDs. The app may expose and cache this projection, but every checkout/change command must submit the revision it displayed. A stale revision fails before customer/attempt/provider mutation and may return the current safe catalog so the user can explicitly review the new price.
|
|
384
|
+
|
|
385
|
+
Canonical generated persistence, following SendLit's implemented model:
|
|
386
|
+
|
|
387
|
+
- billing_price_entries
|
|
388
|
+
- billing_catalog_revisions
|
|
389
|
+
- billing_catalog_revision_items
|
|
390
|
+
|
|
391
|
+
CourseLit supplies exactly pro_month, pro_year, business_month, and business_year for Cloud. SendLit may use the same paid offer shape while also having a Free plan. Neither rule is hardcoded here.
|
|
392
|
+
|
|
393
|
+
Catalog activation follows the implemented SendLit rolling-deployment model:
|
|
394
|
+
|
|
395
|
+
1. Synchronously validate local amounts, currencies, provider IDs, uniqueness, and monotonically positive revision.
|
|
396
|
+
2. Idempotently record a higher requested revision as pending_verification.
|
|
397
|
+
3. Verify provider products asynchronously.
|
|
398
|
+
4. Atomically insert/reuse immutable price entries, attach all revision items, retire the prior revision, and activate the new revision.
|
|
399
|
+
5. Prevent an older application instance from rolling the active revision backward.
|
|
400
|
+
6. Keep all historical product mappings needed by attempts, subscriptions, webhooks, and reconciliation.
|
|
401
|
+
|
|
402
|
+
A pending or invalid requested revision disables new checkout but never takes down ordinary API traffic, changes current entitlement, or guesses a price. An operator can abandon the requested revision with an audit reason after reverting configuration, restoring checkout on the prior verified revision.
|
|
403
|
+
|
|
404
|
+
Immediately before creating a provider customer/session, checkout retrieves and re-verifies the selected product. A mismatch freezes checkout before any charge while existing subscribers continue from the local verified projection.
|
|
405
|
+
|
|
406
|
+
### 10.1 Provider coexistence and migration
|
|
407
|
+
|
|
408
|
+
Configuration distinguishes:
|
|
409
|
+
|
|
410
|
+
- checkoutProvider: used only for new checkout.
|
|
411
|
+
- enabledProviders: adapters/webhook/reconciliation retained for every nonterminal or retained historical subscription.
|
|
412
|
+
|
|
413
|
+
Customer, price, attempt, subscription, webhook, and reconciliation records always carry provider identity. Switching provider is staged:
|
|
414
|
+
|
|
415
|
+
1. Enable old and new adapters and webhook endpoints.
|
|
416
|
+
2. Retain old catalog reverse mappings.
|
|
417
|
+
3. Activate a new catalog whose checkout provider is the new adapter.
|
|
418
|
+
4. Route only new checkout to the new provider.
|
|
419
|
+
5. Continue reconciling old subscriptions through their recorded provider.
|
|
420
|
+
6. Disable the old adapter only after its final relevant subscription is terminal and retention/runbook requirements permit it.
|
|
421
|
+
|
|
422
|
+
A late old-provider event may update its historical subscription but cannot replace the current entitlement source without winning the application's explicit activation state machine.
|
|
423
|
+
|
|
424
|
+
## 11. Generated persistence model
|
|
425
|
+
|
|
426
|
+
The package follows the [Better Auth database pattern](https://better-auth.com/docs/concepts/database): it owns a canonical model specification and a CLI that generates the schema required by the selected application adapter. With Drizzle, the consuming application checks in the generated schema and uses drizzle-kit to create and apply migrations. The package never mutates the database merely because it was installed or imported.
|
|
427
|
+
|
|
428
|
+
This splits ownership precisely:
|
|
429
|
+
|
|
430
|
+
| Artifact | Owner |
|
|
431
|
+
| --------------------------------------------------------- | -------------------------------- |
|
|
432
|
+
| Canonical billing model and constraints | @codelitdev/billing |
|
|
433
|
+
| Schema generator and Drizzle runtime adapter | @codelitdev/billing |
|
|
434
|
+
| Billing configuration and external model mapping | Consuming application |
|
|
435
|
+
| Checked-in generated Drizzle schema | Consuming application repository |
|
|
436
|
+
| Generated SQL migration, review, backfill, and deployment | Consuming application |
|
|
437
|
+
| Product-specific tables and columns | Consuming application |
|
|
438
|
+
| Canonical billing CRUD/state persistence | Billing Drizzle adapter |
|
|
439
|
+
| Product audit, notifications, and lifecycle effects | Application transactional hooks |
|
|
440
|
+
|
|
441
|
+
### 11.1 Canonical generated schema
|
|
442
|
+
|
|
443
|
+
Version 1 generates these package-owned models:
|
|
444
|
+
|
|
445
|
+
- billing_price_entries
|
|
446
|
+
- billing_catalog_revisions
|
|
447
|
+
- billing_catalog_revision_items
|
|
448
|
+
- billing_provider_customers
|
|
449
|
+
- billing_checkout_attempts
|
|
450
|
+
- billing_plan_change_attempts
|
|
451
|
+
- billing_subscriptions
|
|
452
|
+
- billing_plan_states
|
|
453
|
+
- billing_webhook_events
|
|
454
|
+
- billing_reconciliation_jobs
|
|
455
|
+
|
|
456
|
+
The generated models include canonical columns, indexes, unique constraints, partial unique constraints, status checks, timestamps, and provider identities described in this architecture. In particular:
|
|
457
|
+
|
|
458
|
+
- Provider customers are unique by provider and payer.
|
|
459
|
+
- Provider subscriptions are unique by provider and provider subscription ID.
|
|
460
|
+
- At most one subscription per billable entity is the entitlement source.
|
|
461
|
+
- Checkout and plan-change idempotency keys are unique.
|
|
462
|
+
- Only one nonterminal checkout/change attempt exists per billable entity.
|
|
463
|
+
- Webhook events are unique by provider and provider event ID.
|
|
464
|
+
- Catalog revisions and immutable provider price identities obey Section 10.
|
|
465
|
+
- Reconciliation jobs have exactly one canonical subject foreign key, one live job per subject, and reclaimable lease metadata.
|
|
466
|
+
|
|
467
|
+
The package uses generic billing_subscriptions and billing_plan_states names. It does not generate organisation- or school-named tables. The generated billableEntityId and payerId columns reference application-selected models.
|
|
468
|
+
|
|
469
|
+
The canonical meaning of billing_plan_states is deliberately narrow: billableEntityId, nullable activeSubscriptionId, and monotonically increasing projectionVersion. The active paid plan is read from the referenced subscription. A fallback Free plan, a cardless trial, overrides, ramp state, availability, and other effective-entitlement columns are application fields or separate tables. This avoids forcing a Free row value into CourseLit while allowing SendLit to map its existing plan column as an extension.
|
|
470
|
+
|
|
471
|
+
A subscription stores its exact commercial lineage, not merely the provider product currently attached to it:
|
|
472
|
+
|
|
473
|
+
- Provider customer and immutable payer/billing-manager reference.
|
|
474
|
+
- Origin checkout attempt when known.
|
|
475
|
+
- Current catalog revision, offer key, and immutable price entry.
|
|
476
|
+
- Provider subscription/product identity and canonical snapshot fields.
|
|
477
|
+
- Last provider version/change time when available, last observed time, and last reconciled time.
|
|
478
|
+
|
|
479
|
+
An unchanged price entry may appear in several revisions, so priceEntryId alone cannot recover the revision the customer accepted. Initial activation takes lineage from the checked attempt. A verified plan change moves the subscription to that attempt's target revision/offer/price in the same transaction that completes the attempt.
|
|
480
|
+
|
|
481
|
+
A provider-side product change with no correlated plan-change attempt cannot silently select the newest revision. It is quarantined unless the application explicitly supports provider-originated changes and supplies an audited deterministic mapping. Pre-extraction subscriptions receive lineage through an application backfill manifest rather than a max-revision guess.
|
|
482
|
+
|
|
483
|
+
billing_reconciliation_jobs provides durable claims for provider customers, checkout attempts, plan-change attempts, and subscriptions. It uses nullable foreign keys plus a check that exactly one subject is set, and stores provider, status, attempt count, availableAt, lockedAt, leaseExpiresAt, workerId, lastError, and timestamps. This is separate from the webhook inbox: webhook delivery is evidence, while reconciliation is scheduled recovery of canonical records.
|
|
484
|
+
|
|
485
|
+
The canonical specification does not define:
|
|
486
|
+
|
|
487
|
+
- Trial-claim policy tables.
|
|
488
|
+
- Product entitlement-adjustment or overage tables.
|
|
489
|
+
- Product usage, quota, reputation, or safety-control tables.
|
|
490
|
+
- Product audit/outbox/notification tables.
|
|
491
|
+
- Product lifecycle semantics.
|
|
492
|
+
|
|
493
|
+
Those remain application extensions because their semantics differ between consumers. Applications normally use separate tables. When an existing schema or atomic access pattern requires extra columns on a canonical model, configuration may declare additionalFields. The generator emits and types those fields, while package workflows treat them as opaque and never assign product meaning to them.
|
|
494
|
+
|
|
495
|
+
State-changing billing and operator workflows require an application audit hook; telemetry is not a substitute. The hook receives a stable effect ID, actor kind/reference, reason where required, previous and next canonical values, and safe correlation IDs inside the same database transaction. The application's audit table and product nouns remain application-owned, but an absent required hook is a composition error in Cloud mode.
|
|
496
|
+
|
|
497
|
+
### 11.2 Application configuration
|
|
498
|
+
|
|
499
|
+
The reference app defines one workspace as the billable entity and one account as the payer:
|
|
500
|
+
|
|
501
|
+
// billing.config.ts
|
|
502
|
+
import { defineBillingConfig } from "@codelitdev/billing/config";
|
|
503
|
+
|
|
504
|
+
export default defineBillingConfig({
|
|
505
|
+
dialect: "postgresql",
|
|
506
|
+
adapter: "drizzle",
|
|
507
|
+
output: "./src/db/schema/billing.generated.ts",
|
|
508
|
+
|
|
509
|
+
billableEntity: {
|
|
510
|
+
modelName: "workspace",
|
|
511
|
+
tableImport: "./workspaces",
|
|
512
|
+
tableExport: "workspaces",
|
|
513
|
+
idColumn: "id",
|
|
514
|
+
idType: "uuid",
|
|
515
|
+
onDelete: "restrict"
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
payer: {
|
|
519
|
+
modelName: "account",
|
|
520
|
+
tableImport: "./auth",
|
|
521
|
+
tableExport: "accounts",
|
|
522
|
+
idColumn: "id",
|
|
523
|
+
idType: "text",
|
|
524
|
+
onDelete: "restrict"
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
planIds: ["pro", "business"]
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
CourseLit maps billableEntity to schools. SendLit maps it to organizations. The generated schema therefore retains real database foreign keys without teaching the package either product noun.
|
|
531
|
+
|
|
532
|
+
Configuration controls table prefix/names, per-model table names, SQL dialect, external model imports, ID column types, deletion behavior, allowed persisted paid plan identifiers, and optional additionalFields. It cannot remove package-required columns or weaken uniqueness/state constraints.
|
|
533
|
+
|
|
534
|
+
This extension mechanism lets SendLit preserve fields such as teams/contact overrides, ramp state, and pending team name while mapping its existing organisation-named tables. Those fields remain SendLit policy despite appearing in generated source.
|
|
535
|
+
|
|
536
|
+
### 11.3 Generation and migration lifecycle
|
|
537
|
+
|
|
538
|
+
Initial reference-app setup:
|
|
539
|
+
|
|
540
|
+
pnpm dlx @codelitdev/billing generate
|
|
541
|
+
pnpm drizzle-kit generate
|
|
542
|
+
pnpm drizzle-kit migrate
|
|
543
|
+
|
|
544
|
+
The first command loads billing.config.ts and writes billing.generated.ts. The file starts with a generated warning and package schema version. It is committed but never manually edited.
|
|
545
|
+
|
|
546
|
+
CI runs:
|
|
547
|
+
|
|
548
|
+
pnpm dlx @codelitdev/billing generate --check
|
|
549
|
+
|
|
550
|
+
Check mode renders into memory and fails when the committed output differs. Package CI also generates a clean reference app, type-checks the schema, runs drizzle-kit generation, applies the migration to an empty database, and runs adapter conformance.
|
|
551
|
+
|
|
552
|
+
For a package upgrade:
|
|
553
|
+
|
|
554
|
+
1. Upgrade @codelitdev/billing.
|
|
555
|
+
2. Regenerate the Drizzle schema.
|
|
556
|
+
3. Review the generated source diff.
|
|
557
|
+
4. Generate and review the SQL migration.
|
|
558
|
+
5. Add any application-specific backfill.
|
|
559
|
+
6. Run package and product conformance tests.
|
|
560
|
+
7. Apply the migration through the product's deployment migration job.
|
|
561
|
+
8. Deploy compatible API/worker code.
|
|
562
|
+
|
|
563
|
+
The package never runs DDL from postinstall, module import, API startup, or worker startup. It does not silently invoke drizzle-kit. A package release that changes storage ships a schema changelog, compatibility range, expand/backfill/enforce guidance, and test fixtures. Code generation cannot decide how production data should be backfilled or deleted.
|
|
564
|
+
|
|
565
|
+
Direct billing migrate and programmatic migration commands are out of scope for the Drizzle adapter in version 1. The application remains the migration authority.
|
|
566
|
+
|
|
567
|
+
### 11.4 Runtime Drizzle adapter
|
|
568
|
+
|
|
569
|
+
The application passes the generated schema and Drizzle client into the package:
|
|
570
|
+
|
|
571
|
+
import * as billingSchema from "./db/schema/billing.generated";
|
|
572
|
+
|
|
573
|
+
const billing = createBilling({
|
|
574
|
+
database: drizzleBillingAdapter(db, {
|
|
575
|
+
schema: billingSchema
|
|
576
|
+
}),
|
|
577
|
+
providers,
|
|
578
|
+
clock,
|
|
579
|
+
telemetry,
|
|
580
|
+
hooks,
|
|
581
|
+
// Application-owned absolute HTTPS origin/path allowlist.
|
|
582
|
+
returnUrlValidator: isAllowedBillingReturnUrl
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
The adapter implements canonical repositories and transaction/locking behavior. Core/workflow modules remain ORM-neutral; only the drizzle subpath imports Drizzle.
|
|
586
|
+
|
|
587
|
+
The adapter may open short product-database transactions around canonical billing state. It never holds one open across provider network calls. Durable attempts bridge the local/remote boundary. Database uniqueness remains the final concurrency defense.
|
|
588
|
+
|
|
589
|
+
Every workflow that crosses the network follows prepare/call/finalize:
|
|
590
|
+
|
|
591
|
+
1. In a short transaction, lock in canonical order, revalidate, persist the operation/claim and stable idempotency key, then commit.
|
|
592
|
+
2. Call the provider with no application transaction open.
|
|
593
|
+
3. In a new short transaction, lock and revalidate the same rows, then finalize only if the operation still owns the claim and expected state.
|
|
594
|
+
4. If step 2 is ambiguous or step 3 fails, leave a durable nonterminal record for reconciliation with the original key.
|
|
595
|
+
|
|
596
|
+
The adapter computes a canonical snapshot diff before writing a projection. Provider status, product/price lineage, period boundaries, paid-through/trial values, cancellation flag, and active-subscription pointer are material fields. lastObservedAt and lastReconciledAt are operational freshness fields. Reprocessing an equivalent current snapshot may update freshness but must not increment projectionVersion, append another audit transition, or invoke a product effect twice. A material change increments projectionVersion exactly once in the same transaction as canonical state and the audit/outbox hooks.
|
|
597
|
+
|
|
598
|
+
Application hooks receive a constrained transaction-scoped context so product audit/outbox effects can commit atomically with a billing projection. Hook effects use the stable effect ID as a uniqueness key. Hooks cannot replace canonical rows or weaken billing invariants. Network notifications run from an application outbox after commit.
|
|
599
|
+
|
|
600
|
+
Sensitive checkout/portal URLs and replay payloads use configured application security services. Encryption keys, rotation, retention, purge, and audited operator decryption remain deployment policy even when the adapter stores the encrypted values.
|
|
601
|
+
|
|
602
|
+
### 11.5 Reference app and generated products
|
|
603
|
+
|
|
604
|
+
The platform reference app is the executable schema integration:
|
|
605
|
+
|
|
606
|
+
examples/reference-product/
|
|
607
|
+
apps/api/
|
|
608
|
+
billing.config.ts
|
|
609
|
+
src/
|
|
610
|
+
billing/
|
|
611
|
+
policy.ts
|
|
612
|
+
hooks.ts
|
|
613
|
+
routes.ts
|
|
614
|
+
webhook-routes.ts
|
|
615
|
+
worker.ts
|
|
616
|
+
db/schema/
|
|
617
|
+
auth.ts
|
|
618
|
+
workspaces.ts
|
|
619
|
+
billing.generated.ts
|
|
620
|
+
db/migrations/
|
|
621
|
+
|
|
622
|
+
The platform template includes the configuration, generated schema, initial migration, runtime composition, and conformance test. Bootstrap copies these once. A generated product then owns its configuration, generated artifact, migrations, and product hooks; later changes arrive through package upgrades and explicit regeneration, not template merges.
|
|
623
|
+
|
|
624
|
+
Product extensions live in separate files and reference canonical billing rows when needed. For example, a SendLit entitlement-adjustment table remains SendLit code rather than modifying billing.generated.ts.
|
|
625
|
+
|
|
626
|
+
### 11.6 Application-owned trial persistence
|
|
627
|
+
|
|
628
|
+
Trial claims remain application persistence in version 1. SendLit's user-plus-verified-email HMAC claim prevents repeat Pro-month trials, while CourseLit's cardless trial belongs durably to a school and has different abuse/lifecycle rules. Billing offers may carry providerTrialDays as application input, but the package does not decide eligibility.
|
|
629
|
+
|
|
630
|
+
### 11.7 Shared state machines
|
|
631
|
+
|
|
632
|
+
Pure transition guards are package-owned and used by the generated persistence adapter.
|
|
633
|
+
|
|
634
|
+
Checkout attempt:
|
|
635
|
+
|
|
636
|
+
| From | Allowed next |
|
|
637
|
+
| ----------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
638
|
+
| creating | open, expired, abandoned, conflicted |
|
|
639
|
+
| open | completed, expired, abandoned, conflicted |
|
|
640
|
+
| expired | completed only with verified pre-expiry subscription and unchanged entity/payer; otherwise conflicted |
|
|
641
|
+
| abandoned | conflicted on any late subscription |
|
|
642
|
+
| completed or conflicted | none without audited operator repair |
|
|
643
|
+
|
|
644
|
+
Plan-change attempt:
|
|
645
|
+
|
|
646
|
+
| From | Allowed next |
|
|
647
|
+
| -------------------------------- | -------------------------------------- |
|
|
648
|
+
| creating | pending, succeeded, failed, conflicted |
|
|
649
|
+
| pending | succeeded, failed, conflicted |
|
|
650
|
+
| succeeded, failed, or conflicted | none without audited operator repair |
|
|
651
|
+
|
|
652
|
+
Webhook inbox:
|
|
653
|
+
|
|
654
|
+
| State | Meaning |
|
|
655
|
+
| ----------- | --------------------------------------------------- |
|
|
656
|
+
| pending | durably verified and waiting |
|
|
657
|
+
| processing | leased by a worker |
|
|
658
|
+
| failed | retryable and waiting for availableAt |
|
|
659
|
+
| processed | applied or safely no-op |
|
|
660
|
+
| ignored | verified but irrelevant by explicit policy |
|
|
661
|
+
| quarantined | exhausted or inconsistent; operator action required |
|
|
662
|
+
|
|
663
|
+
Provider customer:
|
|
664
|
+
|
|
665
|
+
| State | Meaning |
|
|
666
|
+
| ---------- | ------------------------------------------------------------ |
|
|
667
|
+
| creating | durable placeholder exists; remote outcome may be ambiguous |
|
|
668
|
+
| active | provider customer identity attached |
|
|
669
|
+
| conflicted | identity mismatch or unrecoverable duplicate requires review |
|
|
670
|
+
|
|
671
|
+
Reconciliation job:
|
|
672
|
+
|
|
673
|
+
| State | Meaning |
|
|
674
|
+
| ----------- | ---------------------------------------------------------------- |
|
|
675
|
+
| pending | due or scheduled and claimable at availableAt |
|
|
676
|
+
| processing | owned until leaseExpiresAt |
|
|
677
|
+
| failed | retryable under bounded backoff |
|
|
678
|
+
| completed | no current work; may be rescheduled idempotently |
|
|
679
|
+
| quarantined | inconsistent/exhausted and requires an audited operator decision |
|
|
680
|
+
|
|
681
|
+
The package exports transition decisions and reasons. The Drizzle adapter performs canonical row locks and enforces generated constraints. Application hooks add product audit events, entity lifecycle changes, and outbox notifications.
|
|
682
|
+
|
|
683
|
+
At most one subscription per billable entity may be the entitlement source. This is both a pure projection invariant and a generated partial unique database constraint.
|
|
684
|
+
|
|
685
|
+
## 12. Workflows
|
|
686
|
+
|
|
687
|
+
### 12.1 Startup catalog verification
|
|
688
|
+
|
|
689
|
+
1. The application parses deployment configuration and declares offers.
|
|
690
|
+
2. Package validates local invariants.
|
|
691
|
+
3. Application records a higher requested revision without blocking ordinary readiness.
|
|
692
|
+
4. Verification worker retrieves configured products.
|
|
693
|
+
5. Package compares amount, currency, interval, and identity.
|
|
694
|
+
6. Application transaction inserts or verifies immutable entries/revision and switches active revision atomically.
|
|
695
|
+
7. Checkout is available only when the configured requested revision is the verified active revision.
|
|
696
|
+
|
|
697
|
+
OSS mode is application policy. It supplies no provider/offers and should reject provider configuration at startup.
|
|
698
|
+
|
|
699
|
+
### 12.2 Customer resolution
|
|
700
|
+
|
|
701
|
+
1. Find customer by payer/provider.
|
|
702
|
+
2. If absent, persist an attempt/idempotency key.
|
|
703
|
+
3. Call createCustomer outside the transaction.
|
|
704
|
+
4. Upsert under a unique payer/provider constraint.
|
|
705
|
+
5. If remote success is followed by local failure, retry with the same key or reconcile; never create blindly.
|
|
706
|
+
|
|
707
|
+
### 12.3 Checkout
|
|
708
|
+
|
|
709
|
+
1. Application authenticates, requires recent auth where needed, and authorizes billing management.
|
|
710
|
+
2. Resolve an immutable active offer.
|
|
711
|
+
3. Reject incompatible active subscription/attempt state.
|
|
712
|
+
4. Persist checkout attempt and idempotency key.
|
|
713
|
+
5. Resolve provider customer.
|
|
714
|
+
6. Create provider checkout outside a DB transaction with attempt ID, entity reference, offer key/revision, safe URLs, and allowlisted metadata.
|
|
715
|
+
7. Persist provider session and redirect URL.
|
|
716
|
+
8. Recover local-finalization failure via durable attempt and provider idempotency.
|
|
717
|
+
|
|
718
|
+
A redirect is not proof of payment. Entitlement changes only from a verified provider snapshot/webhook or reconciliation.
|
|
719
|
+
|
|
720
|
+
### 12.4 Webhook
|
|
721
|
+
|
|
722
|
+
1. Product route preserves the exact raw body.
|
|
723
|
+
2. Adapter verifies signature/timestamp and returns a VerifiedWebhookEnvelope, including which key version verified it.
|
|
724
|
+
3. Persist provider event identity/receipt under a unique constraint before returning 2xx.
|
|
725
|
+
4. Duplicate processed events return success without mutation.
|
|
726
|
+
5. Worker retrieves the provider's current subscription snapshot; the event is only a wake-up signal.
|
|
727
|
+
6. Resolve entity from trusted stored association or checked high-entropy attempt metadata, never request host/input.
|
|
728
|
+
7. Validate provider, customer, subscription, product, price entry, payer, and attempt correlations.
|
|
729
|
+
8. Apply snapshot through transition guards in one product transaction.
|
|
730
|
+
9. Update the canonical subscription and authoritative pointer only for a material snapshot diff.
|
|
731
|
+
10. Write the required product audit/outbox effect transactionally under a stable effect ID.
|
|
732
|
+
11. Mark processed; retryable failures remain claimable.
|
|
733
|
+
|
|
734
|
+
An older event cannot regress a newer snapshot. Unknown products, unmatched subscriptions, abandoned-entity activation, and conflicting live subscription sources are quarantined rather than mapped to a default plan.
|
|
735
|
+
|
|
736
|
+
The durable inbox uses reclaimable leases and configurable bounded backoff. SendLit's current policy—five-minute leases, eight attempts, then quarantine—is the first fixture, not an immutable package constant.
|
|
737
|
+
|
|
738
|
+
### 12.5 Plan change
|
|
739
|
+
|
|
740
|
+
1. Product authorization and recent-auth checks.
|
|
741
|
+
2. Validate source/target offers and provider capabilities.
|
|
742
|
+
3. Persist idempotent attempt.
|
|
743
|
+
4. Request explicit immediate or next-date change and explicit proration.
|
|
744
|
+
5. Persist any payment/action URL without granting target entitlement.
|
|
745
|
+
6. Apply target only after verified snapshot.
|
|
746
|
+
7. Duplicate request returns prior attempt/result.
|
|
747
|
+
|
|
748
|
+
### 12.6 Portal and cancellation
|
|
749
|
+
|
|
750
|
+
Portal uses a persisted customer and allowlisted return URL. Cancellation is idempotent and records immediate versus scheduled behavior. The product entitlement resolver decides paid-through access.
|
|
751
|
+
|
|
752
|
+
### 12.7 Reconciliation
|
|
753
|
+
|
|
754
|
+
An app worker asks the package to claim due reconciliation jobs and applies the same transitions as webhooks. Claiming uses a committed lease; FOR UPDATE SKIP LOCKED may select work inside the claim transaction but no row lock is held across the provider call. It covers:
|
|
755
|
+
|
|
756
|
+
- Missing/delayed webhooks.
|
|
757
|
+
- Remote success before local failure.
|
|
758
|
+
- Stuck checkout/plan-change attempts.
|
|
759
|
+
- Periodic active/past-due verification.
|
|
760
|
+
- Operator-requested repair.
|
|
761
|
+
|
|
762
|
+
Work is idempotent, bounded, observable, and retryable.
|
|
763
|
+
|
|
764
|
+
Reconciliation selects the adapter recorded on each subscription/attempt, not the current checkout provider. Provider outage is isolated per record and never downgrades a verified active entitlement. Locally elapsed trial, paid-through, or grace boundaries are still enforced without waiting for the provider.
|
|
765
|
+
|
|
766
|
+
The package exports bounded work units—runWebhookInboxBatch, runReconciliationBatch, runDeadlineBatch, verifyRequestedCatalog, and purgeExpiredSensitiveValues—but does not start timers or choose cron infrastructure. The application scheduler may invoke them from an API process, worker, or external job runner. Multi-instance correctness comes from database claims, not a process-local running flag.
|
|
767
|
+
|
|
768
|
+
### 12.8 Safe read models and workflow outcomes
|
|
769
|
+
|
|
770
|
+
Applications should not rebuild payment semantics in every route. The package exports provider-free base read models:
|
|
771
|
+
|
|
772
|
+
PublicBillingCatalog<PlanId, OfferKey> = {
|
|
773
|
+
revision
|
|
774
|
+
currency
|
|
775
|
+
checkoutAvailable
|
|
776
|
+
offers: { key, plan, interval, amountMinor, currency, displayTrialDays }[]
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
CommercialBillingState<PlanId, OfferKey> = {
|
|
780
|
+
activePaidPlan: PlanId | null
|
|
781
|
+
billingInterval: BillingInterval | null
|
|
782
|
+
subscriptionStatus: CanonicalSubscriptionStatus | null
|
|
783
|
+
providerTrialEndsAt
|
|
784
|
+
currentPeriodEndsAt
|
|
785
|
+
paidThroughAt
|
|
786
|
+
cancelAtPeriodEnd
|
|
787
|
+
pendingCheckout
|
|
788
|
+
pendingPlanChange
|
|
789
|
+
projectionVersion
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
The product composes this with deployment mode, app trial/grace, entitlements, usage, availability, and canManageBilling. Those fields cannot be safely generalized from SendLit's organization summary. Ordinary reads use the verified local projection and never call the provider. The package does not impose a cache, but projectionVersion may key or invalidate an application cache; final entitlement checks bypass stale caches.
|
|
793
|
+
|
|
794
|
+
Workflows return typed results and a stable, provider-neutral error taxonomy rather than HTTP status codes or message matching. Initial codes include catalog_changed, catalog_unavailable, provider_unavailable, active_subscription_exists, checkout_pending, subscription_required, subscription_not_changeable, plan_change_pending, plan_change_not_supported, same_offer, payer_mismatch, operation_conflicted, and operation_quarantined. An error declares retryability and exposes only allowlisted safe details. catalog_changed may include the current public catalog; no error includes provider SDK text or identifiers. REST, MCP, CLI, and UI mappings remain application code.
|
|
795
|
+
|
|
796
|
+
### 12.9 Sensitive action authorization
|
|
797
|
+
|
|
798
|
+
SendLit's human-session, recent-auth, Origin, CSRF, Better Auth verification-table token, and HTTP-header implementation belongs in the Platform/application layer. The reusable billing contract is narrower:
|
|
799
|
+
|
|
800
|
+
BillingActionGrant = {
|
|
801
|
+
grantId
|
|
802
|
+
actorId
|
|
803
|
+
action
|
|
804
|
+
target
|
|
805
|
+
issuedAt
|
|
806
|
+
expiresAt
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
BillingAuthorizationPort.consume(grant, expectedAction, expectedTarget)
|
|
810
|
+
|
|
811
|
+
Externally initiated checkout, portal, plan change, cancellation, and payer transfer require a single-use application-issued grant before the package mutates state or calls a provider. The application uses the same port around product-owned entity close and billing-manager lifecycle mutations. It decides how it proves recent human authentication; the Platform reference adapter can implement the SendLit five-minute, user/session/action/target-bound token pattern. Webhook and scheduled reconciliation enter through separate trusted system workflows and cannot present a human grant. Product-authenticated operator surfaces use OperatorContext and their own stronger authorization policy rather than impersonating a customer action token.
|
|
812
|
+
|
|
813
|
+
The grant proves authorization freshness, not billing ownership. The workflow independently compares the actor/payer with the persisted checkout, customer, and subscription association. This defense remains even when an app has an authorization bug.
|
|
814
|
+
|
|
815
|
+
### 12.10 Operator and health surfaces
|
|
816
|
+
|
|
817
|
+
The package exports services, not an unauthenticated script, for generic recovery:
|
|
818
|
+
|
|
819
|
+
- Inspect, record, verify, and abandon catalog revisions.
|
|
820
|
+
- Inspect a billable entity's canonical commercial state and blockers.
|
|
821
|
+
- Reconcile one subscription or other canonical subject from current provider truth.
|
|
822
|
+
- Inspect normalized webhook metadata; decrypt replay material only through the injected sensitive-value port.
|
|
823
|
+
- Retry, release, or quarantine a webhook/reconciliation job under an explicit retry policy.
|
|
824
|
+
- Request idempotent provider cancellation without projecting entitlement ahead of provider truth.
|
|
825
|
+
|
|
826
|
+
Every mutating operator call requires OperatorContext with actorId, bounded reason, and optional external ticket reference. It writes the required audit hook transactionally. Sensitive-value access is separately audited even when it makes no state change. “Retry” must state whether it preserves the attempt counter or starts a new audited operator retry budget; directly setting status=pending is not a public API.
|
|
827
|
+
|
|
828
|
+
Product operations remain product code: negotiated entitlement adjustments, SendLit reputation controls, school trial changes, and any lifecycle consequence. An application CLI may compose generic and product commands behind one interface.
|
|
829
|
+
|
|
830
|
+
The maintenance module also returns neutral health facts—oldest inbox age, quarantined counts, stuck creating operations, unreconciled subscriptions, invalid requested catalog, lease expiry/reclaim counts, and last successful batch timestamps. Thresholds, cooldowns, PostHog/Pino/email integration, paging recipients, and messages are application operations policy.
|
|
831
|
+
|
|
832
|
+
## 13. Subscription versus entitlement
|
|
833
|
+
|
|
834
|
+
The package normalizes commercial state but does not decide access:
|
|
835
|
+
|
|
836
|
+
provider snapshot -> canonical subscription -> active-subscription pointer
|
|
837
|
+
|
|
|
838
|
+
app trial --------------------------------------+
|
|
839
|
+
deployment mode --------------------------------+-> app entitlement resolver
|
|
840
|
+
app grace/expiry policy ------------------------+
|
|
841
|
+
app plan policy/adjustments --------------------+-> capabilities/availability
|
|
842
|
+
|
|
843
|
+
The package projects verified commercial truth; it does not project a product fallback plan. The product combines deployment mode, app trial, canonical subscription, paid-through dates, app grace/expiry policy, plan policy, negotiated adjustments, and injected current time.
|
|
844
|
+
|
|
845
|
+
Routes do not compare plan strings. Products expose central resolveEntitlements, assertCapability, and atomic quota/reservation operations.
|
|
846
|
+
|
|
847
|
+
Consequences remain product-owned. CourseLit derives public school availability synchronously from time and state; it cannot rely only on a cron-updated column. SendLit independently decides sending and provisioning access.
|
|
848
|
+
|
|
849
|
+
## 14. Trials and OSS
|
|
850
|
+
|
|
851
|
+
The package offers time-window and durable-claim primitives but no universal trial.
|
|
852
|
+
|
|
853
|
+
CourseLit:
|
|
854
|
+
|
|
855
|
+
- Each new Cloud school gets a cardless 14-day app-managed trial.
|
|
856
|
+
- Trial identity survives rename/delete/restore/ownership changes per CourseLit policy.
|
|
857
|
+
- It creates no fake provider subscription.
|
|
858
|
+
- Expiry makes public school functionality unavailable while admin billing/recovery remains.
|
|
859
|
+
- Subscription recovery restores that school only.
|
|
860
|
+
|
|
861
|
+
SendLit:
|
|
862
|
+
|
|
863
|
+
- Cloud Free remains an application plan.
|
|
864
|
+
- Provider/application trial details remain SendLit policy.
|
|
865
|
+
|
|
866
|
+
OSS:
|
|
867
|
+
|
|
868
|
+
- The entire deployment explicitly selects OSS.
|
|
869
|
+
- All commercially gated capability is unlocked.
|
|
870
|
+
- Billing routes/UI are disabled and no provider/catalog is required.
|
|
871
|
+
- Operational abuse/safety limits can still apply.
|
|
872
|
+
|
|
873
|
+
Environment variable names and mode validation stay in each app.
|
|
874
|
+
|
|
875
|
+
## 15. Usage and quotas
|
|
876
|
+
|
|
877
|
+
SendLit's billing area contains a sound concurrency pattern: reserve capacity before an asynchronous side effect, include committed plus reserved usage in the decision, commit after provider acceptance, release after a definitive non-acceptance, and settle expired reservations by checking the durable outbound record. A stable operation identity makes retries reuse one reservation, and an elapsed bucket is released/re-reserved atomically before work proceeds.
|
|
878
|
+
|
|
879
|
+
That is a generic reservation primitive, but it is not automatically a billing primitive. Version 1 leaves SendLit's send buckets/reservations in SendLit and records characterization tests for reserve, commit, release, retry, bucket rollover, and crash settlement. Extract it only after CourseLit or another consumer demonstrates the same state machine, likely as a separate metering/quota module or package.
|
|
880
|
+
|
|
881
|
+
The boundaries are:
|
|
882
|
+
|
|
883
|
+
- Product capability and capacity adjustments—including SendLit's negotiated Business team/contact limits—belong to the application policy/adjustment table.
|
|
884
|
+
- Dimension names, aggregation windows, reset rules, counting queries, enforcement surfaces, and reputation/safety decisions belong to the application.
|
|
885
|
+
- A future generic metering component may own idempotent measurement and reservation mechanics.
|
|
886
|
+
- Monetary overage rating, provider usage reporting, invoices, and prepaid credits are a separate future billing capability; nothing in SendLit's current fixed-price implementation proves that abstraction yet.
|
|
887
|
+
|
|
888
|
+
The billing package can carry opaque entitlement/usage summaries through composition helpers, but it never interprets them or writes product overrides into billing_plan_states.
|
|
889
|
+
|
|
890
|
+
## 16. Security invariants
|
|
891
|
+
|
|
892
|
+
- Verify webhook signatures over the exact raw body.
|
|
893
|
+
- Uniquely constrain event IDs and operation idempotency keys.
|
|
894
|
+
- Build return/cancel URLs from product allowlists.
|
|
895
|
+
- Put only bounded opaque IDs in provider metadata.
|
|
896
|
+
- Inject provider credentials; never return them in results.
|
|
897
|
+
- Expose stable error categories, not SDK bodies.
|
|
898
|
+
- Require an application-issued, single-use action grant for sensitive human workflows and consume it before provider mutation.
|
|
899
|
+
- Keep session, recent-auth, Origin, CSRF, and transport enforcement in the app/Platform adapter.
|
|
900
|
+
- Scope customers by payer/provider and subscriptions by stored entity association.
|
|
901
|
+
- Prevent older webhooks from regressing state.
|
|
902
|
+
- Enforce at most one entitlement-producing subscription per billable entity.
|
|
903
|
+
- Let apps define retention while preserving required immutable history.
|
|
904
|
+
- Keep analytics separate from billing audit.
|
|
905
|
+
- Require actor/reason audit for operator mutation and separate audit for replay-payload decryption.
|
|
906
|
+
|
|
907
|
+
## 17. Telemetry
|
|
908
|
+
|
|
909
|
+
Billing accepts a tiny injected sink:
|
|
910
|
+
|
|
911
|
+
BillingTelemetry {
|
|
912
|
+
event(engineEvent, allowlistedProperties)
|
|
913
|
+
error(error, safeContext)
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
Default is no-op. Engine events cover attempts, provider failure, duplicate webhook, material/no-op projection, lease recovery, quarantine, and reconciliation recovery. The maintenance API exposes queryable health facts separately from emitted telemetry. Product lifecycle analytics, alert thresholds/delivery, and customer notifications remain in the app.
|
|
917
|
+
|
|
918
|
+
Only opaque entity/attempt/provider identifiers enter telemetry. Emails, money instrument data, raw webhook bodies, tokens, reasons that may contain support context, and SDK error bodies are excluded. Telemetry failure cannot affect a result. Audit failure does fail the transaction when an audit hook is required.
|
|
919
|
+
|
|
920
|
+
## 18. Tests
|
|
921
|
+
|
|
922
|
+
Core:
|
|
923
|
+
|
|
924
|
+
- Table-driven transition tests.
|
|
925
|
+
- Exact provider-trial, paid-through, period-end, observation, and provider-version boundaries.
|
|
926
|
+
- Money/currency/catalog invariants.
|
|
927
|
+
- Property-based duplicate and out-of-order sequences.
|
|
928
|
+
- Concurrent checkout/customer/change/webhook idempotency.
|
|
929
|
+
- Fault injection at every remote-call/local-write boundary.
|
|
930
|
+
- Stable public catalog/read-model redaction and workflow error details.
|
|
931
|
+
- Material snapshot diff: an equivalent refresh changes freshness only; a real change increments projectionVersion and emits one effect.
|
|
932
|
+
|
|
933
|
+
Schema generator and Drizzle adapter:
|
|
934
|
+
|
|
935
|
+
- Configuration validation for model imports, ID types, table names, plan IDs, and deletion rules.
|
|
936
|
+
- Deterministic generation and generate --check drift detection.
|
|
937
|
+
- Generated source compiles against the configured external tables.
|
|
938
|
+
- Every canonical index, check, and partial unique constraint is present.
|
|
939
|
+
- Exact catalog revision/attempt lineage survives price-entry reuse and plan changes.
|
|
940
|
+
- Existing compatible schemas can be mapped without destructive table duplication.
|
|
941
|
+
- An empty database can apply the generated application migration.
|
|
942
|
+
- Package import and runtime startup execute no DDL.
|
|
943
|
+
- Adapter transactions, locks, and idempotency pass on PGlite where supported and real PostgreSQL for production semantics.
|
|
944
|
+
- Reconciliation/webhook leases are exclusively claimed, expire, and are reclaimed after a simulated worker crash; no transaction spans the injected provider call.
|
|
945
|
+
|
|
946
|
+
Provider contract:
|
|
947
|
+
|
|
948
|
+
- Customer, checkout, plan-change, and cancellation idempotency/recovery.
|
|
949
|
+
- Product/subscription normalization.
|
|
950
|
+
- Capability accuracy.
|
|
951
|
+
- Portal, change, cancel, and retrieval behavior.
|
|
952
|
+
- Valid/invalid raw-body signatures.
|
|
953
|
+
- Webhook verification returns a durable envelope independently from subscription retrieval.
|
|
954
|
+
- Stable error mapping.
|
|
955
|
+
- Duplicate event identity and redaction.
|
|
956
|
+
- Mutation retries reuse the same idempotency key after an ambiguous timeout.
|
|
957
|
+
- A provider cannot declare an idempotency/lookup capability that the contract's side-effect-then-timeout scenario disproves.
|
|
958
|
+
|
|
959
|
+
Live provider sandbox tests run separately from deterministic CI.
|
|
960
|
+
|
|
961
|
+
Consumer conformance:
|
|
962
|
+
|
|
963
|
+
- SendLit OSS unlimited and Cloud Free/Pro/Business remain unchanged.
|
|
964
|
+
- CourseLit OSS unlimited and Cloud trial/Pro/Business contain no Free state.
|
|
965
|
+
- Two CourseLit schools under one payer transition independently.
|
|
966
|
+
- Advancing beyond 14 days disables only an unpaid school.
|
|
967
|
+
- Duplicate/out-of-order events neither duplicate nor regress entitlement.
|
|
968
|
+
- Delayed webhooks trigger current-snapshot retrieval before projection.
|
|
969
|
+
- Remote success plus local failure reconciles.
|
|
970
|
+
- New checkout can use a new provider while old-provider subscriptions continue reconciling.
|
|
971
|
+
- Entity close and payer demotion/deletion observe canonical blockers under concurrent billing activity.
|
|
972
|
+
- A fresh action grant cannot bypass a persisted payer mismatch, and a grant is consumed once.
|
|
973
|
+
- Generic operator mutation requires actor/reason audit; sensitive replay inspection creates a separate audit effect.
|
|
974
|
+
- Product grace/trial/adjustment policy composes with the commercial state without changing canonical billing rows.
|
|
975
|
+
|
|
976
|
+
Use PGlite for fast integration and real PostgreSQL for locking, uniqueness, isolation, and migrations.
|
|
977
|
+
|
|
978
|
+
## 19. SendLit extraction map
|
|
979
|
+
|
|
980
|
+
The current SendLit branch is the origin implementation. Its files divide as follows:
|
|
981
|
+
|
|
982
|
+
| SendLit area | Version 1 treatment | Reason |
|
|
983
|
+
| -------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
984
|
+
| billing/provider.ts | Extract now and strengthen | Canonical provider types, capabilities, errors, snapshots, and adapter interface are product-neutral after metadata/time semantics are generalized; mutation recovery capability must become explicit |
|
|
985
|
+
| billing/provider-registry.ts | Extract now | Registry can be instance-based and configured by the consumer |
|
|
986
|
+
| billing/providers/dodo | Extract now after constructor/metadata cleanup | SDK translation, signature verification, error normalization, and provider capability mapping are reusable |
|
|
987
|
+
| billing/providers/fake | Extract now after removing SendLit catalog defaults | Deterministic provider and scenario controls are essential consumer fixtures |
|
|
988
|
+
| billing/provider-contract.ts | Extract now and substantially expand | Becomes the acceptance contract for every provider; current tests cover the fake's happy path but not side-effect-then-timeout recovery for all mutations or capability truthfulness |
|
|
989
|
+
| billing/webhook-retry.ts | Extract as configurable pure policy | Attempt/backoff/quarantine decision has no product persistence dependency |
|
|
990
|
+
| billing/catalog.ts | Split | Extract money, offer, provider-set, and validation primitives; retain SendLit env names, exact keys, plan union, trial HMAC, and Free policy |
|
|
991
|
+
| billing/catalog-store.ts | Split | Extract revision/price invariants, exact required-offer set, public catalog projection, and canonical persistence; retain alert delivery and app authorization in SendLit |
|
|
992
|
+
| billing/checkout.ts | Split, do not move wholesale | Extract canonical attempt persistence/transitions/idempotency into package workflows; retain organisation creation, Free policy, payer lookup, SendLit trial claim, URLs, authorization, and product hooks |
|
|
993
|
+
| billing/plan-change.ts | Split, do not move wholesale | Extract canonical attempt persistence/transitions/provider invocation; retain plan ranking, effective-time policy, authorization, API errors, and product hooks |
|
|
994
|
+
| billing/portal.ts | Split | Extract stored-payer/customer invariant and provider invocation; retain owner/session authorization, return URL, and route response in SendLit |
|
|
995
|
+
| billing/webhooks/processor.ts | Split aggressively | Extract correlation, canonical subscription/attempt projection, material-diff/version behavior, exact catalog lineage, and event classification; retain first-team creation, Free fallback policy, product audit implementation, and notification hooks |
|
|
996
|
+
| billing/webhooks/routes.ts | Keep app transport | Express ordering, raw-body limits, encryption storage, HTTP response, and enabled-provider routing belong to the app/platform |
|
|
997
|
+
| billing/reconciliation.ts | Keep timer composition; extract billing work units and durable claims | Current file also runs send-reservation, reputation, and domain jobs. Product owns when/where jobs run; package owns canonical billing claims, bounded batches, and reconcile-one functions |
|
|
998
|
+
| billing/crypto.ts | Keep app/platform security | Secret sourcing, envelope keys, rotation, retention, and audited decryption are deployment responsibilities |
|
|
999
|
+
| billing/policies.ts | Keep SendLit | Contains Free/Pro/Business capabilities, grace results, and mail policy |
|
|
1000
|
+
| billing/entitlements.ts and errors.ts | Keep SendLit initially | Organisation/team/contact/send guards and plan-gate responses are product domain. Preserve reservation characterization as evidence for a possible later metering extraction |
|
|
1001
|
+
| billing/usage.ts | Keep SendLit | Organisation send/contact/team dimensions and counting queries are not generic billing |
|
|
1002
|
+
| billing/reputation*, domains.ts | Keep SendLit | Deliverability, sending ramp, and DNS ownership are SendLit product operations |
|
|
1003
|
+
| billing/security.ts | Split contract from implementation | Better Auth verification rows, human session, CSRF, Origin, and token issuance stay Platform/SendLit; package workflows consume an opaque single-use action grant and still enforce persisted payer identity |
|
|
1004
|
+
| billing/routes.ts and api-contract billing schemas | Split safe projections from transport | ts-rest routes, authorization, caching headers, entitlement/usage composition, and HTTP mapping stay in SendLit; provider-free catalog/commercial-state DTOs and workflow error codes are reusable |
|
|
1005
|
+
| billing/alerts.ts, metrics.ts, notifications.ts | Split facts from policy | Canonical health queries and engine events are reusable; thresholds, cooldowns, Pino/PostHog/email delivery, recipients, and customer messages stay in SendLit |
|
|
1006
|
+
| scripts/billing.ts | Replace direct canonical writes with composed operation services | Catalog inspection/verification/abandonment, reconcile, webhook retry/inspect, and cancellation use package operations with actor/reason audit; overrides and reputation remain SendLit commands |
|
|
1007
|
+
| organization/queries.ts billing lifecycle checks | Extract blocker queries and lock order | Close and payer-responsibility facts are canonical; role mutation, entity status, audit noun, and user-facing behavior stay SendLit |
|
|
1008
|
+
| db/schema.ts canonical billing models | Convert to package schema specification | Generator reproduces compatible tables and configured organisation/user foreign keys; SendLit owns the checked-in output and migration, which should be a no-op for already-compatible structures |
|
|
1009
|
+
| db/schema.ts product billing extensions | Keep SendLit | Trial claims, usage, reputation, domains, audit, and lifecycle columns/tables are not canonical generated models |
|
|
1010
|
+
|
|
1011
|
+
This map is deliberately asymmetric: extracting a provider class is safe early; extracting an organisation checkout coordinator before CourseLit supplies a second persistence implementation would merely hide SendLit behind generic type names.
|
|
1012
|
+
|
|
1013
|
+
### Stable, experimental, and application APIs
|
|
1014
|
+
|
|
1015
|
+
The first releases label exports:
|
|
1016
|
+
|
|
1017
|
+
- Stable: core money/status types, schema configuration/generator, Drizzle adapter, provider contract/registry, Dodo, fake, provider/workflow error normalization, pure state/diff guards, safe catalog projection, catalog validation, and testing.
|
|
1018
|
+
- Experimental: commercial-state read model, cross-boundary workflows, action-grant port, blocker queries, operator services, maintenance claims/health, and transactional lifecycle hooks until both SendLit and CourseLit consume them.
|
|
1019
|
+
- Application-only: authentication/token issuance, plan/entitlement policy, adjustments, usage, transport routes, schema extensions/migrations/backfills, UI, audit storage, alerts/notifications, scheduler composition, and product entity lifecycle.
|
|
1020
|
+
|
|
1021
|
+
Experimental exports can change within documented pre-1.0 rules. Version 1 requires the two-consumer proof described below.
|
|
1022
|
+
|
|
1023
|
+
## 20. Pre-extraction ADRs and implementation discrepancies
|
|
1024
|
+
|
|
1025
|
+
The PRD and current branch disagree or leave ambiguity in several places. Resolve these with characterization tests and ADRs before freezing public package behavior:
|
|
1026
|
+
|
|
1027
|
+
1. Cancellation recovery: the PRD transition table permits cancelled only to expired, while the implemented processor permits cancelled to trialing, active, or past_due to support provider recovery before paid-through expiry. Provider sandbox evidence and desired recovery semantics decide the canonical transition.
|
|
1028
|
+
2. Pending status: the implementation's SubscriptionSnapshot includes pending while the PRD adapter excerpt omits it. The shared canonical status should include pending because durable checkout can observe it.
|
|
1029
|
+
3. Webhook type shape: the implementation can persist a canonical snapshot in its encrypted envelope but intentionally retrieves current provider state before projection. Public types must separate verified event evidence from authoritative retrieved snapshot.
|
|
1030
|
+
4. Catalog availability: configuration validation is synchronous, provider verification is asynchronous, and a pending/invalid requested revision freezes only checkout—not ordinary API readiness or existing entitlement.
|
|
1031
|
+
5. Provider cancellation: implemented adapters and tests require cancelSubscription although the PRD interface excerpt omits it.
|
|
1032
|
+
6. Metadata: sendlitCheckoutAttemptId must become a namespaced generic attempt-correlation field configured by the consumer; no SendLit key may remain in Dodo/fake output.
|
|
1033
|
+
7. Clock usage: current code frequently calls new Date and Date.now. Extraction introduces an injected Clock before time-boundary behavior becomes public.
|
|
1034
|
+
8. Reconciliation claims: some SendLit reconciliation reads use row locks outside an explicit transaction, and a row lock cannot safely protect a later provider call without violating the no-transaction-across-network rule. Extraction replaces this with committed billing_reconciliation_jobs leases and real PostgreSQL crash/reclaim tests.
|
|
1035
|
+
9. Catalog failure transition: marking an active revision invalid freezes checkout. The package must define whether the last verified revision remains active-but-unavailable or becomes invalid, so rolling deployments and operator abandonment behave consistently.
|
|
1036
|
+
10. Existing-schema mapping: generator configuration must reproduce SendLit's current table/column/index names and constraints without creating parallel generic tables or destructive renames.
|
|
1037
|
+
11. Configuration loading: billing.config.ts must be a deterministic schema description that does not boot the application, connect to databases, or require provider secrets merely to generate code.
|
|
1038
|
+
12. Plan constraints: adding/removing a configured paid plan ID can change a generated database check constraint and therefore follows schema-change SemVer and migration guidance.
|
|
1039
|
+
13. Catalog provenance: SendLit subscriptions store a price entry and offer key but not the exact accepted catalog revision; one price entry can appear in several revisions, and current plan-change code can only infer a latest revision. The generated subscription must persist exact initial/last-confirmed revision lineage and update it only from the correlated checkout/change attempt.
|
|
1040
|
+
14. Projection idempotency: SendLit currently increments projectionVersion on every accepted current snapshot even when commercial state is unchanged, although the PRD requires replay to be a no-op without duplicate audit. Define the material-field diff and freshness-only update before exposing projectionVersion for cache invalidation.
|
|
1041
|
+
15. Snapshot time: Dodo retrieval currently assigns new Date as occurredAt. The public model must separate provider change/version time from observedAt; retrieval time cannot be used as proof that the provider state changed after another snapshot.
|
|
1042
|
+
16. Payer invariant: SendLit's routes authorize an owner/billing manager, but the reusable workflow must also compare the persisted payer/customer/subscription association. Define portal, plan-change, cancellation, account deletion, and future transfer behavior without importing membership roles.
|
|
1043
|
+
17. Sensitive action seam: SendLit's Better Auth verification-row action token is a sound Platform adapter, not billing persistence. Freeze the opaque consume-once grant contract and protected-action list while leaving session freshness, CSRF, Origin, headers, and token table to Platform/apps.
|
|
1044
|
+
18. Operator audit: current catalog abandonment and override commands record a metric with reason, and webhook retry directly rewrites state; this does not satisfy the PRD's immutable operator audit requirement. Generic operations require actor/reason/effect audit, explicit retry-budget semantics, and an audited sensitive-decryption port.
|
|
1045
|
+
19. Read-model boundary: SendLit's catalog DTO is reusable, while its billing summary mixes commercial state with owner authorization, entitlements, and usage. Export only the safe generic base projections and stable workflow errors; product composition and transport status remain outside.
|
|
1046
|
+
20. Provider recovery declaration: current interfaces accept idempotency keys but do not state or fully test whether each provider mutation honors them. Add per-operation recovery capabilities and side-effect-then-timeout contract cases before a provider is production-eligible.
|
|
1047
|
+
21. Plan-state boundary: SendLit's plan state stores Free and product adjustment/ramp columns. The canonical generated model owns only the active paid-subscription pointer and projection version; SendLit's plan/override/ramp fields map as application extensions rather than defining every consumer's no-subscription state.
|
|
1048
|
+
22. Required audit hook: product audit storage stays outside the package, but state-changing/operator workflows cannot make it optional in Cloud composition. Define stable effect IDs, safe canonical diffs, transactional failure behavior, and idempotent outbox use.
|
|
1049
|
+
|
|
1050
|
+
These are not reasons to redesign SendLit during extraction. The default is behavioral compatibility unless the ADR identifies a correctness defect and both the migration and tests are explicit.
|
|
1051
|
+
|
|
1052
|
+
## 21. Release policy
|
|
1053
|
+
|
|
1054
|
+
- Changesets and SemVer.
|
|
1055
|
+
- Provenance and generated API reports.
|
|
1056
|
+
- Patch: public-type and state-semantic preserving.
|
|
1057
|
+
- Minor: additive capability/provider/workflow.
|
|
1058
|
+
- Major: removed contract, changed normalized meaning, or required migration.
|
|
1059
|
+
- Every release has a migration guide and tested provider SDK versions.
|
|
1060
|
+
- Platform preset records compatible versions, but billing stays independently installable.
|
|
1061
|
+
- Package install/import/startup never runs database migrations or DDL.
|
|
1062
|
+
- Schema-changing releases include generator changelog, generated reference diff, compatibility range, and expand/backfill/enforce guidance.
|
|
1063
|
+
- Generated schema format/version is part of compatibility; generate --check detects stale application artifacts.
|
|
1064
|
+
- Provider normalization changes require transition fixtures and an origin-product canary even without a type change.
|
|
1065
|
+
|
|
1066
|
+
## 22. Extraction and rollout
|
|
1067
|
+
|
|
1068
|
+
### Phase 0 — Characterize SendLit
|
|
1069
|
+
|
|
1070
|
+
- Inventory catalog, provider, checkout, portal, change, webhook, reconciliation, entitlement, security, trial, usage, alert, and notification behavior.
|
|
1071
|
+
- Add characterization tests to every money/entitlement path.
|
|
1072
|
+
- Characterize safe catalog/commercial read projections, workflow error codes, payer blockers, operator behavior, and equivalent-snapshot replay.
|
|
1073
|
+
- Record constraints and recovery procedures.
|
|
1074
|
+
- Mark product-specific policy that stays in SendLit.
|
|
1075
|
+
|
|
1076
|
+
Exit: behavior is provable before code moves.
|
|
1077
|
+
|
|
1078
|
+
### Phase 1 — Core and providers
|
|
1079
|
+
|
|
1080
|
+
- Extract canonical types, stable workflow errors, clock, transition/diff guards, provider interface, Dodo, fake, and provider contracts.
|
|
1081
|
+
- Replace SendLit metadata with bounded generic metadata.
|
|
1082
|
+
- Separate provider occurrence/version from local observation and prove mutation recovery capabilities with ambiguous-timeout tests.
|
|
1083
|
+
- Keep orchestration in SendLit initially.
|
|
1084
|
+
|
|
1085
|
+
Exit: SendLit consumes public provider/core exports without changed behavior.
|
|
1086
|
+
|
|
1087
|
+
### Phase 2 — Catalog
|
|
1088
|
+
|
|
1089
|
+
- Extract offer validation, exact offer-set/provider verification, revision invariants, provider-safe public projection, and test helpers.
|
|
1090
|
+
- Keep environment parsing and exact offers in SendLit.
|
|
1091
|
+
- Migrate SendLit to public catalog exports.
|
|
1092
|
+
|
|
1093
|
+
Exit: old and new SendLit subscriptions resolve identical catalog history.
|
|
1094
|
+
|
|
1095
|
+
### Phase 3 — Schema generator, Drizzle adapter, and workflows
|
|
1096
|
+
|
|
1097
|
+
- Define the canonical schema model, configuration, deterministic generator, check mode, and Drizzle adapter.
|
|
1098
|
+
- Include exact subscription catalog lineage, the narrow active-subscription projection, and durable reconciliation jobs.
|
|
1099
|
+
- Configure SendLit table/column/model mappings so generation matches its existing compatible billing tables rather than creating parallel tables.
|
|
1100
|
+
- Treat any generated SendLit SQL as a reviewed application migration; extraction should be a no-op where existing constraints already match.
|
|
1101
|
+
- Move canonical persistence into the adapter and add product lifecycle hooks one workflow at a time.
|
|
1102
|
+
- Add material projection diff/versioning, blocker queries, action-grant consumption, required audit effects, generic operator services, and neutral health facts.
|
|
1103
|
+
- Keep cross-boundary workflow/hook contracts experimental.
|
|
1104
|
+
- Migrate SendLit first while its routes, auth, plan policy, extensions, and operational effects remain.
|
|
1105
|
+
- Fault-inject every provider/local boundary and worker-lease crash point.
|
|
1106
|
+
|
|
1107
|
+
Exit: SendLit imports no private billing code; generate --check passes; no parallel billing tables or unplanned destructive migration are produced; and characterization, adapter, conformance, and sandbox tests pass. Do not declare cross-boundary workflow hooks stable yet.
|
|
1108
|
+
|
|
1109
|
+
### Phase 4 — CourseLit
|
|
1110
|
+
|
|
1111
|
+
- Configure and generate CourseLit's canonical billing schema with real school and payer-account foreign keys.
|
|
1112
|
+
- Generate, review, and apply CourseLit's application migration through drizzle-kit.
|
|
1113
|
+
- Supply four paid offers and no-Free policy.
|
|
1114
|
+
- Implement durable per-school 14-day trials and synchronous availability.
|
|
1115
|
+
- Keep CourseLit trial/availability/audit extensions outside billing.generated.ts.
|
|
1116
|
+
- Reuse provider/core/generator/adapter/workflow suites.
|
|
1117
|
+
- Compose CourseLit's Platform action-grant adapter and product-authenticated operator CLI/admin surface.
|
|
1118
|
+
|
|
1119
|
+
Exit: one owner can independently trial, subscribe, change, cancel, expire, and recover two schools.
|
|
1120
|
+
|
|
1121
|
+
### Phase 5 — Stabilize from two consumers
|
|
1122
|
+
|
|
1123
|
+
- Compare generated mappings, product hooks, and workflows.
|
|
1124
|
+
- Compare payer/lifecycle blockers, safe read composition, action grants, operations, health, and maintenance claims.
|
|
1125
|
+
- Share only genuinely identical mechanics.
|
|
1126
|
+
- Keep trial, grace, usage, notification, and deletion differences in products.
|
|
1127
|
+
- Promote only lifecycle hooks and workflows proven by both consumers from experimental to stable.
|
|
1128
|
+
- Publish version 1 after both run production canaries.
|
|
1129
|
+
|
|
1130
|
+
### Phase 6 — FrontLit and MediaLit
|
|
1131
|
+
|
|
1132
|
+
- Adopt with their own aggregates and policy.
|
|
1133
|
+
- Add generic seams only for concrete consumer needs.
|
|
1134
|
+
- Return improvements through releases, conformance, and explicit migrations.
|
|
1135
|
+
|
|
1136
|
+
## 23. Operational runbooks required
|
|
1137
|
+
|
|
1138
|
+
- Webhook outage and replay.
|
|
1139
|
+
- Catalog mismatch at startup.
|
|
1140
|
+
- Stuck checkout/change.
|
|
1141
|
+
- Remote success with failed local persistence.
|
|
1142
|
+
- Duplicate provider customer.
|
|
1143
|
+
- Conflicting active subscriptions.
|
|
1144
|
+
- Manual reconciliation with immutable correction/audit.
|
|
1145
|
+
- Reconciliation lease exhaustion, reclaim, and quarantine.
|
|
1146
|
+
- Entity close or payer removal blocked by live billing responsibility.
|
|
1147
|
+
- Sensitive action-token outage/replay and persisted payer mismatch.
|
|
1148
|
+
- Operator retry-budget reset versus preservation.
|
|
1149
|
+
- Audited replay-payload inspection and retention purge.
|
|
1150
|
+
- Provider credential rotation.
|
|
1151
|
+
- Package rollback with app-owned migrations.
|
|
1152
|
+
- CourseLit trial/subscription expiry and public-availability verification.
|
|
1153
|
+
|
|
1154
|
+
Runbooks use product-owned authenticated commands/screens composed over package operation services so authorization, audit storage, and product nouns remain correct.
|
|
1155
|
+
|
|
1156
|
+
## 24. Version 1 definition of done
|
|
1157
|
+
|
|
1158
|
+
- SendLit is first production consumer and preserves Cloud Free/Pro/Business and OSS.
|
|
1159
|
+
- CourseLit is second and provides per-school Cloud trial/Pro/Business and OSS with no Cloud Free state.
|
|
1160
|
+
- Dodo and fake pass the same public provider suite, including declared mutation recovery after ambiguous side effects.
|
|
1161
|
+
- Catalog revisions are immutable and provider-verified.
|
|
1162
|
+
- Public catalog/commercial-state projections and stable workflow errors contain no provider IDs or SDK text.
|
|
1163
|
+
- The reference app generates its canonical Drizzle billing schema from billing.config.ts, commits the result, and creates/applies its migration through drizzle-kit.
|
|
1164
|
+
- generate --check detects schema drift, and package install/import/API/worker startup performs no DDL.
|
|
1165
|
+
- SendLit maps its existing compatible schema without parallel tables or an unplanned destructive migration.
|
|
1166
|
+
- CourseLit generates real foreign keys to schools and payer accounts.
|
|
1167
|
+
- Subscriptions retain exact checkout/plan-change catalog lineage even when price entries are reused.
|
|
1168
|
+
- Checkout, change, cancel, webhook, and reconciliation are idempotent and fault-tested.
|
|
1169
|
+
- Equivalent snapshots are freshness-only no-ops; material snapshots increment projectionVersion and emit one audited effect.
|
|
1170
|
+
- Verified webhook evidence is stored before 2xx and current provider state is retrieved before subscription projection.
|
|
1171
|
+
- Multi-instance reconciliation uses committed reclaimable leases and no database transaction spans a provider call.
|
|
1172
|
+
- Lifecycle blocker queries protect billable-entity and payer mutations, and persisted payer checks cannot be bypassed by route authorization.
|
|
1173
|
+
- Sensitive workflows consume app-issued action grants; generic operator mutations require actor/reason audit and sensitive reads are audited.
|
|
1174
|
+
- New checkout provider selection coexists safely with retained historical providers.
|
|
1175
|
+
- Product policy, entitlements/adjustments, metering, schema extensions, migrations/backfills, routes, auth/token issuance, UI, audit-table implementation, alert delivery, and notifications stay outside.
|
|
1176
|
+
- Packed public exports are documented and tested.
|
|
1177
|
+
- One upgrade rolls through SendLit and CourseLit with a guide and canary.
|
|
1178
|
+
|
|
1179
|
+
## 25. Deferred decisions
|
|
1180
|
+
|
|
1181
|
+
- Whether additional ORM adapters warrant first-party generators/runtime adapters.
|
|
1182
|
+
- Whether metering is part of billing or a separate package.
|
|
1183
|
+
- Whether new provider adapters split into separate packages.
|
|
1184
|
+
- Whether app-managed invoices need canonical types.
|
|
1185
|
+
- Multi-currency catalogs.
|
|
1186
|
+
- Tax and merchant-of-record abstractions.
|
|
1187
|
+
|
|
1188
|
+
Working consumers and provider requirements—not speculative generalization—decide these.
|