@pithy-sh/payments 0.1.0

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.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -0
  3. package/dist/paddle-prices.iife.js +1 -0
  4. package/dist/pithy.manifest.json +68 -0
  5. package/docs/lemon-squeezy.md +167 -0
  6. package/docs/paddle.md +486 -0
  7. package/package.json +73 -0
  8. package/pithy.manifest.json +68 -0
  9. package/src/admin/coverage.ts +107 -0
  10. package/src/admin/read.ts +360 -0
  11. package/src/audit/actions.ts +214 -0
  12. package/src/capability.ts +377 -0
  13. package/src/client/api.ts +734 -0
  14. package/src/client/checkout.ts +252 -0
  15. package/src/client/fixtures/price-preview-de.json +54 -0
  16. package/src/client/fixtures/price-preview-gb.json +54 -0
  17. package/src/client/fixtures/price-preview-jp-yen.json +54 -0
  18. package/src/client/fixtures/price-preview-us-country-only.json +54 -0
  19. package/src/client/fixtures/price-preview-us-new-york.json +54 -0
  20. package/src/client/fixtures/pricePreview.ts +67 -0
  21. package/src/client/hooks.ts +590 -0
  22. package/src/client/paddle.ts +733 -0
  23. package/src/client/paddleCache.ts +265 -0
  24. package/src/client/paddlePrices.iife.ts +43 -0
  25. package/src/client/paddlePrices.ts +143 -0
  26. package/src/client/paddlePricesTag.ts +332 -0
  27. package/src/client/projection.ts +133 -0
  28. package/src/client/test-utils/cacheStore.ts +41 -0
  29. package/src/client/wholeUnits.ts +147 -0
  30. package/src/cloudflare-test.d.ts +16 -0
  31. package/src/config/config.ts +563 -0
  32. package/src/data/discount.ts +238 -0
  33. package/src/data/entitlement.ts +67 -0
  34. package/src/data/money.ts +75 -0
  35. package/src/data/pause.ts +148 -0
  36. package/src/data/providerAccount.ts +47 -0
  37. package/src/data/purchase.ts +131 -0
  38. package/src/data/rail.ts +74 -0
  39. package/src/data/reconcileRun.ts +208 -0
  40. package/src/data/renderMoney.ts +180 -0
  41. package/src/data/status.ts +67 -0
  42. package/src/data/subject.ts +148 -0
  43. package/src/data/subscription.ts +675 -0
  44. package/src/data/syncCursor.ts +44 -0
  45. package/src/data/tables.ts +61 -0
  46. package/src/data/webhookEvent.ts +196 -0
  47. package/src/entitlement/manual.ts +209 -0
  48. package/src/entitlement/resolver.ts +125 -0
  49. package/src/entitlement/subjectSeam.ts +171 -0
  50. package/src/error/errors.ts +421 -0
  51. package/src/grants/apply.ts +207 -0
  52. package/src/grants/clawback.ts +128 -0
  53. package/src/grants/ledgerSeam.ts +141 -0
  54. package/src/http/dispatch.ts +48 -0
  55. package/src/http/guards.ts +58 -0
  56. package/src/http/manifestConfig.ts +56 -0
  57. package/src/http/responses.ts +1147 -0
  58. package/src/http/routes.ts +2911 -0
  59. package/src/http/schemas.ts +577 -0
  60. package/src/http/scopes.ts +260 -0
  61. package/src/http/view.ts +188 -0
  62. package/src/http/webhookGuard.ts +341 -0
  63. package/src/index.ts +231 -0
  64. package/src/migrations/0001_purchases.ts +312 -0
  65. package/src/pricing/location.ts +175 -0
  66. package/src/pricing/visitor.ts +82 -0
  67. package/src/projection/event.ts +101 -0
  68. package/src/projection/orphans.ts +226 -0
  69. package/src/projection/owner.ts +247 -0
  70. package/src/projection/resolve.ts +57 -0
  71. package/src/projection/writer.ts +554 -0
  72. package/src/provision/resolvePaymentsConfig.ts +91 -0
  73. package/src/rails/apple/certs.ts +66 -0
  74. package/src/rails/apple/der.ts +249 -0
  75. package/src/rails/apple/fixtures/chain.ts +412 -0
  76. package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
  77. package/src/rails/apple/fixtures/did-renew.json +48 -0
  78. package/src/rails/apple/fixtures/one-time-charge.json +32 -0
  79. package/src/rails/apple/fixtures/refund.json +33 -0
  80. package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
  81. package/src/rails/apple/fixtures/test.json +14 -0
  82. package/src/rails/apple/http.ts +128 -0
  83. package/src/rails/apple/jws.ts +123 -0
  84. package/src/rails/apple/notification.ts +357 -0
  85. package/src/rails/apple/rail.ts +114 -0
  86. package/src/rails/apple/serverApi.ts +412 -0
  87. package/src/rails/apple/verify.ts +93 -0
  88. package/src/rails/apple/x509.ts +424 -0
  89. package/src/rails/contract.ts +939 -0
  90. package/src/rails/google/fixtures/play-product-purchased.json +14 -0
  91. package/src/rails/google/fixtures/play-subscription-active.json +23 -0
  92. package/src/rails/google/fixtures/push.ts +152 -0
  93. package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
  94. package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
  95. package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
  96. package/src/rails/google/fixtures/rtdn-test.json +8 -0
  97. package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
  98. package/src/rails/google/http.ts +124 -0
  99. package/src/rails/google/jwt.ts +127 -0
  100. package/src/rails/google/oidc.ts +308 -0
  101. package/src/rails/google/playApi.ts +598 -0
  102. package/src/rails/google/rail.ts +174 -0
  103. package/src/rails/google/rtdn.ts +331 -0
  104. package/src/rails/google/verify.ts +127 -0
  105. package/src/rails/lemonSqueezy/api.ts +192 -0
  106. package/src/rails/lemonSqueezy/checkout.ts +186 -0
  107. package/src/rails/lemonSqueezy/discounts.ts +166 -0
  108. package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
  109. package/src/rails/lemonSqueezy/objects.ts +511 -0
  110. package/src/rails/lemonSqueezy/portal.ts +82 -0
  111. package/src/rails/lemonSqueezy/pricing.ts +124 -0
  112. package/src/rails/lemonSqueezy/rail.ts +137 -0
  113. package/src/rails/lemonSqueezy/read.ts +72 -0
  114. package/src/rails/lemonSqueezy/refresh.ts +86 -0
  115. package/src/rails/lemonSqueezy/signature.ts +119 -0
  116. package/src/rails/lemonSqueezy/verify.ts +45 -0
  117. package/src/rails/lemonSqueezy/webhook.ts +326 -0
  118. package/src/rails/paddle/adjustments.ts +94 -0
  119. package/src/rails/paddle/api.ts +266 -0
  120. package/src/rails/paddle/checkout.ts +240 -0
  121. package/src/rails/paddle/discounts.ts +176 -0
  122. package/src/rails/paddle/events.ts +285 -0
  123. package/src/rails/paddle/fixtures/browserForged.ts +54 -0
  124. package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
  125. package/src/rails/paddle/objects.ts +883 -0
  126. package/src/rails/paddle/portal.ts +130 -0
  127. package/src/rails/paddle/rail.ts +219 -0
  128. package/src/rails/paddle/read.ts +108 -0
  129. package/src/rails/paddle/recorded.ts +83 -0
  130. package/src/rails/paddle/refresh.ts +153 -0
  131. package/src/rails/paddle/refund.ts +297 -0
  132. package/src/rails/paddle/signature.ts +225 -0
  133. package/src/rails/paddle/subscription.ts +793 -0
  134. package/src/rails/paddle/verify.ts +120 -0
  135. package/src/rails/paddle/webhook.ts +405 -0
  136. package/src/rails/providers.ts +168 -0
  137. package/src/rails/stripe/api.ts +253 -0
  138. package/src/rails/stripe/checkout.ts +175 -0
  139. package/src/rails/stripe/discounts.ts +169 -0
  140. package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
  141. package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
  142. package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
  143. package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
  144. package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
  145. package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
  146. package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
  147. package/src/rails/stripe/fixtures/events.ts +111 -0
  148. package/src/rails/stripe/objects.ts +642 -0
  149. package/src/rails/stripe/portal.ts +45 -0
  150. package/src/rails/stripe/pricing.ts +123 -0
  151. package/src/rails/stripe/rail.ts +139 -0
  152. package/src/rails/stripe/refresh.ts +79 -0
  153. package/src/rails/stripe/signature.ts +127 -0
  154. package/src/rails/stripe/verify.ts +105 -0
  155. package/src/rails/stripe/webhook.ts +79 -0
  156. package/src/secret/registry.ts +275 -0
  157. package/src/seeds/example.ts +181 -0
  158. package/src/version.generated.ts +16 -0
  159. package/src/workflows/paddleSweep.ts +717 -0
  160. package/src/workflows/railAccess.ts +87 -0
  161. package/src/workflows/reconcile.ts +648 -0
  162. package/src/workflows/report.ts +64 -0
  163. package/src/workflows/retryPolicy.ts +40 -0
  164. package/src/workflows/specs.ts +143 -0
  165. package/src/workflows/worker.ts +166 -0
  166. package/src/workflows/workerConfig.ts +73 -0
  167. package/src/workflows/wrangler.jsonc +55 -0
@@ -0,0 +1,44 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { PaymentsRail } from "./rail";
7
+
8
+ /**
9
+ * Where a resumable sweep of a provider's stream left off — the row in `pithy_payments_sync_cursors`,
10
+ * keyed `UNIQUE (rail, name)`.
11
+ *
12
+ * **This is not a copy of a provider's data**, and the distinction matters enough to state: the row holds
13
+ * one opaque resume token and two timestamps. No amount, no customer, no event body. The alternative to
14
+ * storing it is re-reading ninety days of somebody else's event stream on every run, which is worse for
15
+ * them as well as for us.
16
+ *
17
+ * One rail may sweep more than one stream, so the key is `(rail, name)` rather than the rail alone. Paddle
18
+ * has exactly one today — {@link PADDLE_EVENTS_CURSOR} — and the second column is what stops a second one
19
+ * needing a migration.
20
+ */
21
+ export const PaymentsSyncCursor = z
22
+ .object({
23
+ id: z.string().describe("The row's UUID."),
24
+ rail: PaymentsRail.describe("Which store's stream this cursor points into."),
25
+ name: z
26
+ .string()
27
+ .describe("Which of that store's streams. One rail may have several, so the name is part of the key."),
28
+ cursor: z
29
+ .string()
30
+ .nullable()
31
+ .describe(
32
+ "The provider's own resume token — Paddle's `evt_…` — or null for a stream never swept. Null is a real state and means 'start at the oldest event the provider still retains', not 'unknown'.",
33
+ ),
34
+ updatedAt: SQLiteDate.describe(
35
+ "When the cursor last advanced. Read against the provider's retention window: a cursor older than it can never be caught up, and the sweep reports that gap rather than restarting from the beginning.",
36
+ ),
37
+ createdAt: SQLiteDate.describe("When this row was written."),
38
+ })
39
+ .describe("Where a resumable sweep of a provider's event stream left off. One opaque token, and nothing else.");
40
+ export type PaymentsSyncCursor = z.output<typeof PaymentsSyncCursor>;
41
+ export type PaymentsSyncCursorRow = z.input<typeof PaymentsSyncCursor>;
42
+
43
+ /** The name of Paddle's events-stream cursor. One per rail per stream; this rail has one. */
44
+ export const PADDLE_EVENTS_CURSOR = "events";
@@ -0,0 +1,61 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
6
+ import type { Kysely } from "kysely";
7
+ import type { z } from "zod";
8
+ import { PaymentsEntitlement } from "./entitlement";
9
+ import { PaymentsProviderAccount } from "./providerAccount";
10
+ import { PaymentsPurchase } from "./purchase";
11
+ import { PaymentsReconcileRun } from "./reconcileRun";
12
+ import { PaymentsSyncCursor } from "./syncCursor";
13
+ import { PaymentsWebhookEvent } from "./webhookEvent";
14
+
15
+ /** The projection of provider truth. `CamelCasePlugin` snake-cases it to `pithy_payments_purchases`. */
16
+ export const PAYMENTS_PURCHASES_TABLE = "pithyPaymentsPurchases";
17
+ /** The materialized read model `requireEntitlement()` hits. → `pithy_payments_entitlements`. */
18
+ export const PAYMENTS_ENTITLEMENTS_TABLE = "pithyPaymentsEntitlements";
19
+ /** Provider identity → the subject that holds it. → `pithy_payments_provider_accounts`. */
20
+ export const PAYMENTS_PROVIDER_ACCOUNTS_TABLE = "pithyPaymentsProviderAccounts";
21
+ /** Raw received notifications, the replay source. → `pithy_payments_webhook_events`. */
22
+ export const PAYMENTS_WEBHOOK_EVENTS_TABLE = "pithyPaymentsWebhookEvents";
23
+ /** One row per reconciliation pass — the tally, kept. → `pithy_payments_reconcile_runs`. */
24
+ export const PAYMENTS_RECONCILE_RUNS_TABLE = "pithyPaymentsReconcileRuns";
25
+ /** Where a resumable sweep of a provider's stream left off. → `pithy_payments_sync_cursors`. */
26
+ export const PAYMENTS_SYNC_CURSORS_TABLE = "pithyPaymentsSyncCursors";
27
+
28
+ /** The payments tables map. All six are always present — none is behind a config flag. */
29
+ export function paymentsTables(): Record<string, z.ZodObject> {
30
+ return {
31
+ [PAYMENTS_PURCHASES_TABLE]: PaymentsPurchase,
32
+ [PAYMENTS_ENTITLEMENTS_TABLE]: PaymentsEntitlement,
33
+ [PAYMENTS_PROVIDER_ACCOUNTS_TABLE]: PaymentsProviderAccount,
34
+ [PAYMENTS_WEBHOOK_EVENTS_TABLE]: PaymentsWebhookEvent,
35
+ [PAYMENTS_RECONCILE_RUNS_TABLE]: PaymentsReconcileRun,
36
+ [PAYMENTS_SYNC_CURSORS_TABLE]: PaymentsSyncCursor,
37
+ };
38
+ }
39
+
40
+ /** The typed Kysely database over the payments tables. */
41
+ export type PaymentsTables = {
42
+ [PAYMENTS_PURCHASES_TABLE]: typeof PaymentsPurchase;
43
+ [PAYMENTS_ENTITLEMENTS_TABLE]: typeof PaymentsEntitlement;
44
+ [PAYMENTS_PROVIDER_ACCOUNTS_TABLE]: typeof PaymentsProviderAccount;
45
+ [PAYMENTS_WEBHOOK_EVENTS_TABLE]: typeof PaymentsWebhookEvent;
46
+ [PAYMENTS_RECONCILE_RUNS_TABLE]: typeof PaymentsReconcileRun;
47
+ [PAYMENTS_SYNC_CURSORS_TABLE]: typeof PaymentsSyncCursor;
48
+ };
49
+ export type PaymentsDatabase = Kysely<DatabaseSchema<PaymentsTables>>;
50
+
51
+ /** Build the payments database from the `DB` binding (CamelCasePlugin installed). */
52
+ export function paymentsDatabase(d1: D1Database): PaymentsDatabase {
53
+ return createDatabase(d1, {
54
+ [PAYMENTS_PURCHASES_TABLE]: PaymentsPurchase,
55
+ [PAYMENTS_ENTITLEMENTS_TABLE]: PaymentsEntitlement,
56
+ [PAYMENTS_PROVIDER_ACCOUNTS_TABLE]: PaymentsProviderAccount,
57
+ [PAYMENTS_WEBHOOK_EVENTS_TABLE]: PaymentsWebhookEvent,
58
+ [PAYMENTS_RECONCILE_RUNS_TABLE]: PaymentsReconcileRun,
59
+ [PAYMENTS_SYNC_CURSORS_TABLE]: PaymentsSyncCursor,
60
+ }) as unknown as PaymentsDatabase;
61
+ }
@@ -0,0 +1,196 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate, sqliteJson } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { PaymentsRail } from "./rail";
7
+
8
+ /** A received notification body, as the rail delivered it. Only the envelope is fixed; shapes differ. */
9
+ const NotificationPayload = z
10
+ .record(z.string(), z.unknown())
11
+ .describe("The notification body as received, before any rail-specific interpretation.");
12
+
13
+ /**
14
+ * One received provider notification — the row in `pithy_payments_webhook_events`, keyed
15
+ * `UNIQUE (rail, providerEventId)`.
16
+ *
17
+ * Every provider delivers at-least-once and retries, so a redelivery is expected and must be recognized
18
+ * rather than reprocessed. This is the replay source, and it is what makes "why didn't this renew"
19
+ * answerable: `receivedAt` says whether the notification arrived at all, `processedAt` whether it was
20
+ * finished with, `abandonedAt` whether a repair pass gave up on it, and `error` why.
21
+ *
22
+ * ## Why two timestamps and no status column (#337)
23
+ *
24
+ * `processedAt` used to be the whole answer, and three writers meant three different things by it —
25
+ * projected, tried and failed, and given up on. Because the webhook guard short-circuits on it, two of
26
+ * those three silently stopped a purchase from ever being projected.
27
+ *
28
+ * **The defect was not too few states. It was every reader spelling out its own predicate over a column
29
+ * whose meaning the writers disagreed about.** A stored `status` enum makes the writers exhaustive and
30
+ * leaves the readers exactly as free to write `status <> 'pending'` — which is the short-circuit that was
31
+ * wrong. So the fix has to be a named question each reader asks by name, and once there is one, the
32
+ * storage shape is the smaller decision.
33
+ *
34
+ * Given that, two nullable timestamps beat an enum twice over. They answer *when*, which is what this
35
+ * table is read for, and they cannot contradict themselves the way `status = 'projected'` beside a null
36
+ * `processedAt` can. An enum would add an invariant nothing enforces; a timestamp is its own evidence.
37
+ *
38
+ * So the state is **derived, never stored** — see {@link webhookEventState} — and the two readers that
39
+ * matter ask different questions of it: {@link isWebhookEventFinished} for the guard's short-circuit, and
40
+ * {@link isWebhookEventOutstanding} for a repair pass. Both switch over every state, so a fifth one cannot
41
+ * be added without each reader saying what it means by it.
42
+ *
43
+ * A third reader asks a question the state cannot answer — see {@link webhookEventAwaitsOwner}. It exists
44
+ * because a link repairs one kind of unfinished row and not the others, and *which* kind is in `error`.
45
+ */
46
+ export const PaymentsWebhookEvent = z
47
+ .object({
48
+ id: z.string().describe("The row's UUID."),
49
+ rail: PaymentsRail.describe("Which store delivered the notification."),
50
+ providerEventId: z
51
+ .string()
52
+ .describe(
53
+ "The rail's own event id. `UNIQUE (rail, providerEventId)` — a redelivery is recognized, not reprocessed.",
54
+ ),
55
+ payload: sqliteJson(NotificationPayload).describe(
56
+ "The notification body, Zod-validated on write and on read. The replay source, so it is stored whole.",
57
+ ),
58
+ receivedAt: SQLiteDate.describe("When the notification arrived. Present even for one that never processed."),
59
+ processedAt: SQLiteDate.nullable().describe(
60
+ "When this delivery was finished with — projected, or deliberately nothing to project — or null while it has not been. The only column the webhook guard short-circuits on, so it means 'we have finished this' and never 'we have seen this'. Null with an old `receivedAt` is the drift signal.",
61
+ ),
62
+ abandonedAt: SQLiteDate.nullable()
63
+ .optional()
64
+ .describe(
65
+ "When a repair pass gave up on this event so its stream could advance, or null. Separate from `processedAt` because abandoning is that pass's decision about its own progress and must never answer for a webhook: a later delivery of the same event has to be reprocessed, and fixing the cause has to be able to repair it. Optional because the webhook path never abandons anything and the column defaults to null for it.",
66
+ ),
67
+ error: z
68
+ .string()
69
+ .nullable()
70
+ .describe(
71
+ "Why this delivery did not project — or, beside a set `processedAt`, why there was never anything to project. Internal text: it is read by an operator and never rendered to a client. Its presence is not the state; the two timestamps are, because an explanation must not be able to change one.",
72
+ ),
73
+ attempts: z
74
+ .number()
75
+ .int()
76
+ .nonnegative()
77
+ .optional()
78
+ .describe(
79
+ "How many times a repair pass has tried this event and failed. Optional because the webhook path neither retries nor counts, and the column defaults to 0 for it; the sweep uses it to bound how long one unprojectable event may hold the stream up before it is quarantined.",
80
+ ),
81
+ createdAt: SQLiteDate.describe("When this row was written."),
82
+ })
83
+ .describe("One received provider notification — the row in `pithy_payments_webhook_events`, the replay source.");
84
+ export type PaymentsWebhookEvent = z.output<typeof PaymentsWebhookEvent>;
85
+ export type PaymentsWebhookEventRow = z.input<typeof PaymentsWebhookEvent>;
86
+
87
+ /**
88
+ * What has become of a recorded delivery. Four cases, and each one behaves differently.
89
+ *
90
+ * - `pending` — it arrived and nothing has tried it yet.
91
+ * - `failed` — it was tried, it did not project, and it is still outstanding.
92
+ * - `abandoned` — a repair pass gave up so its stream could advance. Outstanding, but not to that pass.
93
+ * - `finished` — projected, or deliberately nothing to project. Nothing is left to do.
94
+ */
95
+ export type PaymentsWebhookEventState = "pending" | "failed" | "abandoned" | "finished";
96
+
97
+ /**
98
+ * The two timestamps and the reason, as a reader gets them off a narrow `select`.
99
+ *
100
+ * Deliberately the **encoded** shape rather than {@link PaymentsWebhookEvent}: both readers want two or
101
+ * three columns, and making them decode a whole row — payload included — to answer "may this be
102
+ * short-circuited" would be a JSON parse per redelivery. `undefined` is accepted beside `null` for the
103
+ * same reason a defaulted column can read back absent.
104
+ */
105
+ export interface PaymentsWebhookEventProgress {
106
+ /** `processed_at`, as `SQLiteDate` accepts it, or absent. Only its presence is read here. */
107
+ readonly processedAt?: z.input<typeof SQLiteDate> | null;
108
+ /** `abandoned_at`, likewise. */
109
+ readonly abandonedAt?: z.input<typeof SQLiteDate> | null;
110
+ /** `error`, or absent. */
111
+ readonly error?: string | null;
112
+ }
113
+
114
+ /**
115
+ * Classify one recorded delivery. **Derived here, once, rather than stored** — see the module doc.
116
+ *
117
+ * Finished wins over abandoned, and that ordering is the point rather than an accident: an event the sweep
118
+ * gave up on and a later delivery then projected is *finished*, and `abandonedAt` stays on the row as the
119
+ * record of how close that purchase came to being lost.
120
+ */
121
+ export function webhookEventState(row: PaymentsWebhookEventProgress): PaymentsWebhookEventState {
122
+ if (row.processedAt !== null && row.processedAt !== undefined) return "finished";
123
+ if (row.abandonedAt !== null && row.abandonedAt !== undefined) return "abandoned";
124
+ if (row.error !== null && row.error !== undefined) return "failed";
125
+ return "pending";
126
+ }
127
+
128
+ /**
129
+ * May a redelivery be answered as a duplicate without running anything?
130
+ *
131
+ * **Only for a delivery that is finished with.** This is the webhook guard's short-circuit, and it is the
132
+ * whole of #337: while it meant "we have seen this", a delivery that arrived and failed was answered 200
133
+ * for ever, and no path in this package repaired it.
134
+ */
135
+ export function isWebhookEventFinished(state: PaymentsWebhookEventState): boolean {
136
+ switch (state) {
137
+ case "finished":
138
+ return true;
139
+ case "pending":
140
+ case "failed":
141
+ case "abandoned":
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * The prefix an event's `error` carries when the one thing missing was **an owner**.
148
+ *
149
+ * `error` is free text an operator reads, and two writers put two different sentences in it for the same
150
+ * condition — the webhook handler's "no subject could be resolved", and the sweep's own `orphaned:`. That
151
+ * was fine while nothing queried it. It stopped being fine with #341: linking an account has to find exactly
152
+ * the rows that were waiting on that link, and "waiting on a link" is not derivable from the two timestamps —
153
+ * a quarantine after three failures wears the same pair.
154
+ *
155
+ * So the reason is written on the row, once, by a constant both writers share. A prefix rather than a column
156
+ * because it costs no migration and stays legible to the operator reading `error`, and it is a prefix rather
157
+ * than a substring so the match is anchored and a note quoting the word cannot be mistaken for one.
158
+ *
159
+ * See {@link webhookEventAwaitsOwner} for the reader, and `projection/orphans.ts` for what it is read by.
160
+ */
161
+ export const WEBHOOK_EVENT_ORPHANED = "orphaned:";
162
+
163
+ /**
164
+ * Is this event outstanding for want of an owner, rather than for want of anything else?
165
+ *
166
+ * The question the relink repair asks, and it is deliberately narrower than "not finished". A quarantined
167
+ * event, a failed projection, an unmapped SKU — none of those is repaired by an account linking, and running
168
+ * them again on that signal would be a second unbounded retry loop wearing the first one's clothes.
169
+ *
170
+ * `abandoned` and `failed` both qualify, because both paths produce an orphan and they produce different
171
+ * states: the sweep abandons one so its stream can advance, and the webhook handler leaves one failed so a
172
+ * redelivery repairs it. Neither is finished, and both are waiting on the same thing.
173
+ */
174
+ export function webhookEventAwaitsOwner(row: PaymentsWebhookEventProgress): boolean {
175
+ if (webhookEventState(row) === "finished") return false;
176
+ return (row.error ?? "").startsWith(WEBHOOK_EVENT_ORPHANED);
177
+ }
178
+
179
+ /**
180
+ * Has a repair pass anything left to do with this event?
181
+ *
182
+ * `abandoned` is false here, and false is right: the bound exists so one unprojectable event cannot hold a
183
+ * stream up for ever, and a pass that picked its own quarantines back up would restart the count and stall
184
+ * again. That is the asymmetry the two readers exist for — the pass must move on, and a webhook delivery
185
+ * must still be able to repair what it walked past.
186
+ */
187
+ export function isWebhookEventOutstanding(state: PaymentsWebhookEventState): boolean {
188
+ switch (state) {
189
+ case "pending":
190
+ case "failed":
191
+ return true;
192
+ case "abandoned":
193
+ case "finished":
194
+ return false;
195
+ }
196
+ }
@@ -0,0 +1,209 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { withD1Retry } from "@pithy-sh/core/src/data/withD1Retry";
6
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { grantableEntitlements, type PaymentsConfig } from "../config/config";
8
+ import { PaymentsEntitlement } from "../data/entitlement";
9
+ import { encodeSubjectReference, type PaymentsSubject } from "../data/subject";
10
+ import { PAYMENTS_ENTITLEMENTS_TABLE, paymentsDatabase } from "../data/tables";
11
+ import { PaymentsEntitlementNotInCatalogError } from "../error/errors";
12
+
13
+ /**
14
+ * Manual entitlement writes — the support surface behind `POST /payments/entitlements/grant` and `/revoke`.
15
+ *
16
+ * These are the only writes to the read model that no purchase produced, which is exactly why the routes over
17
+ * them are `control-plane`, default-denied, and audited on both paths. Everything else in this package can be
18
+ * traced back to a store's own artifact; this cannot, so the trail is the only record of who decided it.
19
+ *
20
+ * ## The hold, and why `grant` and `revoke` are not symmetric
21
+ *
22
+ * Every other row in this table is derived: the projection recomputes it from the purchases table on every
23
+ * write that touches its key, which is what keeps the read model from silently disagreeing with the money.
24
+ * A comp has no purchase behind it, so a derivation would find nothing supporting it and clear it — and a
25
+ * support comp erased by the user's next renewal is worse than no comp at all, because nobody would notice.
26
+ *
27
+ * So a grant sets `manual`, and the projection skips a row carrying it. Both shapes work and both last:
28
+ * comping a key the catalog does not sell (`founder`) and comping one it does (`pro`).
29
+ *
30
+ * ## The catalog check is here, at the write
31
+ *
32
+ * {@link grantEntitlement} refuses a key this project does not define, with
33
+ * `payments/entitlement_not_in_catalog`, a 400 naming the key (#300). An operator who meant `pro` and
34
+ * typed `pr` used to get a 200, a row, and a customer who stayed locked out, invisible on both sides.
35
+ *
36
+ * That check first landed in `POST /payments/entitlements/grant`, and lived there alone (#305). A rule at a
37
+ * call site has a second call site eventually: an adopter's own handler, a later route in this package, the
38
+ * reconciliation workflow — each one a path to the row write with the rule removed. So the check moved to
39
+ * the thing being called. The route no longer decides; it reports.
40
+ *
41
+ * The shape that makes that unbypassable is a **split**: `writeEntitlement` below asks nothing and is not
42
+ * exported, and the exported grant takes a {@link PaymentsConfig} it cannot be called without. A caller
43
+ * cannot reach the unchecked half from outside this module, and the type says why.
44
+ *
45
+ * Comping a key nothing sells is therefore **declared** rather than achieved by nobody checking:
46
+ * `manualEntitlements` in `pithy.config.ts` is where an adopter names the keys they comp but do not sell,
47
+ * and those keys are grantable and are published on the catalog read beside the products. The check reads
48
+ * `grantableEntitlements`, so that escape travels with it. Only grants are constrained. A revoke is not, or
49
+ * dropping a product from the catalog would be irreversible for every account still holding its key — which
50
+ * is why {@link revokeEntitlement} takes no config, and must never grow one.
51
+ *
52
+ * A **revoke clears the hold** rather than setting it, which is the deliberate asymmetry. It means a revoke
53
+ * is the exact inverse of a grant — it takes the row back out of a human's hands — and it means a revoke
54
+ * never becomes a permanent block on a user who later pays. That matters: an entitlement the purchases still
55
+ * support is re-derived on the next event, so revoking a *paid* subscription here holds only until the store
56
+ * next says something about it. To end a paid entitlement, refund it through the store; that is the record
57
+ * the projection reads, and the only one that keeps the read model and the money agreeing.
58
+ *
59
+ * A revoke writes an inactive row even when the subject held nothing, because support tooling should not have
60
+ * to know whether a row exists, and because the row is itself the record that somebody decided this account is
61
+ * not entitled.
62
+ *
63
+ * ## The holder is a subject, and both halves are named
64
+ *
65
+ * A comp is a contract somebody signed, and under organization billing the party to it is the company rather
66
+ * than whoever at the company asked. So these take a {@link PaymentsSubject} — the pair, never an id on its
67
+ * own. The pair is the upsert's conflict target, the read-back's predicate and the row's identity, and it is
68
+ * the same pair the read path filters on, so a grant and the gate that honors it cannot disagree about who
69
+ * was meant. `user:acme` and `organization:acme` are two holders: nothing in the kit makes those id spaces
70
+ * disjoint, and support revoking the right id under the wrong kind must take nothing away from the other.
71
+ */
72
+
73
+ /**
74
+ * Who, and to what. The caller has already been through `requireControlPlane`.
75
+ *
76
+ * It **extends** {@link PaymentsSubject} rather than restating two strings, so a caller cannot supply half a
77
+ * holder and the type is the same one every other subject-bearing surface takes. Never the caller's own
78
+ * subject — this is support acting on somebody else's account.
79
+ */
80
+ export interface ManualEntitlementInput extends PaymentsSubject {
81
+ /** The entitlement key, as gating code names it. */
82
+ entitlement: string;
83
+ /** When a grant lapses. Absent or null never lapses; ignored on a revoke. */
84
+ expiresAt?: Date | null;
85
+ }
86
+
87
+ /** The clock and the id minter, injected so a test is deterministic. */
88
+ export interface ManualEntitlementOptions {
89
+ /** The write timestamp. Defaults to now. */
90
+ now?: Date;
91
+ /** The id minter for a row that does not exist yet. Defaults to `crypto.randomUUID`. */
92
+ newId?: () => string;
93
+ }
94
+
95
+ /**
96
+ * Grant an entitlement by hand, and hold it against the projection. Idempotent; overwrites whatever the
97
+ * projection last derived for this key, and survives every later purchase event for it.
98
+ *
99
+ * Refuses a key this project does not define — no product grants it and `manualEntitlements` does not
100
+ * declare it — before any row is written. The config is a parameter rather than a closure because a grant
101
+ * without one is the defect: see the module doc.
102
+ */
103
+ export async function grantEntitlement(
104
+ d1: D1Database,
105
+ config: PaymentsConfig,
106
+ input: ManualEntitlementInput,
107
+ options: ManualEntitlementOptions = {},
108
+ ): Promise<PaymentsEntitlement> {
109
+ const grantable = grantableEntitlements(config);
110
+ if (!grantable.has(input.entitlement)) {
111
+ throw new PaymentsEntitlementNotInCatalogError({
112
+ message: `No entitlement "${input.entitlement}" is defined here.`,
113
+ // The set goes in `detail`, which the HTTP codec strips. An operator reading the customer's logs gets
114
+ // the near miss spelled out; the caller learns only which key it sent, because what this project sells
115
+ // is a separate disclosure behind `payments:catalog:read`.
116
+ detail: `No product grants "${input.entitlement}" and \`manualEntitlements\` does not declare it. Defined: ${[...grantable].sort().join(", ") || "nothing"}.`,
117
+ });
118
+ }
119
+ return writeEntitlement(d1, input, true, options);
120
+ }
121
+
122
+ /**
123
+ * Revoke an entitlement by hand and release the hold. Idempotent, and records the decision even where there
124
+ * was nothing to clear. A key the purchases still support is re-derived on the next event — see the module doc.
125
+ *
126
+ * **Takes no config, deliberately.** A revoke of a key the catalog has since dropped must stay legal, or a
127
+ * catalog edit becomes irreversible for every account still holding it. The missing parameter is the rule.
128
+ */
129
+ export function revokeEntitlement(
130
+ d1: D1Database,
131
+ input: ManualEntitlementInput,
132
+ options: ManualEntitlementOptions = {},
133
+ ): Promise<PaymentsEntitlement> {
134
+ return writeEntitlement(d1, input, false, options);
135
+ }
136
+
137
+ /**
138
+ * The upsert both directions share. One statement, on the `UNIQUE (subjectType, subjectId, entitlement)`
139
+ * conflict target, so a concurrent write cannot produce a second row for one key.
140
+ *
141
+ * The target is all three columns because that is the index the table really has. A two-column target names
142
+ * no unique constraint and SQLite refuses the statement outright — which is the loud failure worth having
143
+ * here, rather than an upsert that quietly inserts a second row per holder and leaves two answers to one
144
+ * gate. The read-back's predicate is the same three columns for the same reason.
145
+ *
146
+ * `sourcePurchaseId` is set to null on both paths, and that is the honest answer: no purchase supports this
147
+ * row. `manual` is the half that differs — set by a grant, cleared by a revoke — and it is what the
148
+ * projection's derivation reads to decide whether the row is its to touch.
149
+ */
150
+ async function writeEntitlement(
151
+ d1: D1Database,
152
+ input: ManualEntitlementInput,
153
+ active: boolean,
154
+ options: ManualEntitlementOptions,
155
+ ): Promise<PaymentsEntitlement> {
156
+ const now = options.now ?? new Date();
157
+ const newId = options.newId ?? (() => crypto.randomUUID());
158
+ const db = paymentsDatabase(d1);
159
+ const row = PaymentsEntitlement.encode({
160
+ id: newId(),
161
+ // Both halves off the one input, together. Nothing here pairs a kind from config with an id from a row.
162
+ subjectType: input.subjectType,
163
+ subjectId: input.subjectId,
164
+ entitlement: input.entitlement,
165
+ active,
166
+ // An expiry only means anything on a grant. A revoke that carried one would describe a row that stops
167
+ // being revoked, which is not a thing.
168
+ expiresAt: active ? (input.expiresAt ?? null) : null,
169
+ sourcePurchaseId: null,
170
+ // A grant is held against the projection; a revoke hands the key back to it.
171
+ manual: active,
172
+ createdAt: now,
173
+ updatedAt: now,
174
+ });
175
+
176
+ await withD1Retry(() =>
177
+ db
178
+ .insertInto(PAYMENTS_ENTITLEMENTS_TABLE)
179
+ // biome-ignore lint/suspicious/noExplicitAny: the row is the schema's z.input side; Kysely's insert type derives from it.
180
+ .values(row as any)
181
+ .onConflict((oc) =>
182
+ oc.columns(["subjectType", "subjectId", "entitlement"]).doUpdateSet({
183
+ active: row.active,
184
+ expiresAt: row.expiresAt,
185
+ sourcePurchaseId: null,
186
+ manual: row.manual,
187
+ updatedAt: row.updatedAt,
188
+ // biome-ignore lint/suspicious/noExplicitAny: as above — an encoded row, not the app shape.
189
+ } as any),
190
+ )
191
+ .execute(),
192
+ );
193
+
194
+ const written = await db
195
+ .selectFrom(PAYMENTS_ENTITLEMENTS_TABLE)
196
+ .selectAll()
197
+ .where("subjectType", "=", input.subjectType)
198
+ .where("subjectId", "=", input.subjectId)
199
+ .where("entitlement", "=", input.entitlement)
200
+ .executeTakeFirst();
201
+ if (written === undefined) {
202
+ throw new InternalError({
203
+ message: "The entitlement could not be recorded.",
204
+ action: "Retry. If it persists, check the app database for the pithy_payments_* tables.",
205
+ detail: `Wrote entitlement "${input.entitlement}" for ${encodeSubjectReference(input)} but could not read it back.`,
206
+ });
207
+ }
208
+ return PaymentsEntitlement.parse(written);
209
+ }
@@ -0,0 +1,125 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PithyMiddleware } from "@pithy-sh/core/src/capability/capability";
5
+ import type { Entitlement, EntitlementHolder, EntitlementResolver } from "@pithy-sh/core/src/entitlement/entitlement";
6
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
7
+ import { encodeSubjectReference, type PaymentsSubject } from "../data/subject";
8
+ import type { PaymentsDatabase } from "../data/tables";
9
+ import { resolveEntitlements } from "../projection/resolve";
10
+ import { type PaymentsSubjectSeam, resolvePaymentsSubject } from "./subjectSeam";
11
+
12
+ /**
13
+ * Payments filling the entitlement seam. Core's default (`noEntitlementProvider`) holds nothing so that an
14
+ * uncomposed gate denies; this replaces it with a resolver over the materialized read model in D1.
15
+ *
16
+ * The name payments answers under. `requireEntitlement()` puts it in `detail` on a denial, which is what
17
+ * lets an operator tell "genuinely unentitled" from "nothing wired" without telling a client either.
18
+ */
19
+ export const PAYMENTS_ENTITLEMENT_PROVIDER = "payments";
20
+
21
+ /**
22
+ * How a subject appears in a denial's `detail` and on a denial's audit row — core's {@link EntitlementHolder},
23
+ * which is display text and an audit dimension and **nothing a gate compares**.
24
+ *
25
+ * The label is `encodeSubjectReference`'s form, so the string in a log line is the same string the rails
26
+ * stamp into a store and the same one `pithy payments reconcile --subject` takes. One spelling of a holder
27
+ * across the whole capability means an operator reading a denial can paste it into the support command.
28
+ *
29
+ * **`tenant` is the organization id, or null under per-person billing** — and the null is the honest answer
30
+ * rather than a gap. `AuditEvent.tenant` is the dimension a trail is read by, and a consumer app has no such
31
+ * dimension: the holder is the actor, so a tenant here would echo `actorId` and invent a tenancy the app
32
+ * does not have. Under organization billing it is exactly the value that answers "which of our customers is
33
+ * hitting the paywall", which `actorId` cannot, because one person acts in two organizations.
34
+ */
35
+ export function paymentsEntitlementHolder(subject: PaymentsSubject): EntitlementHolder {
36
+ return {
37
+ label: encodeSubjectReference(subject),
38
+ tenant: subject.subjectType === "organization" ? subject.subjectId : null,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * A resolver for one subject, over one database. The whole seam: who is asking is already decided.
44
+ *
45
+ * The subject is the **pair**, never an id — `data/subject.ts` for why both halves travel together. Taking
46
+ * one object rather than two strings is what keeps a caller from transposing them, and what keeps a
47
+ * `subjectType` from config being paired with a `subjectId` from somewhere else.
48
+ */
49
+ export function createPaymentsEntitlementResolver(
50
+ db: PaymentsDatabase,
51
+ subject: PaymentsSubject,
52
+ now: () => Date = () => new Date(),
53
+ ): EntitlementResolver {
54
+ return {
55
+ provider: PAYMENTS_ENTITLEMENT_PROVIDER,
56
+ list: () => resolveEntitlements(db, subject, now()),
57
+ // Unconditional here: this resolver was handed its subject, so there is no case where it cannot say.
58
+ holder: async () => paymentsEntitlementHolder(subject),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * The middleware that installs the resolver, per request, over `c.var.db.<database>`.
64
+ *
65
+ * **Everything is resolved inside `list()`, not around it, and that is the point.** Middleware order is the
66
+ * adopter's — it follows the order of the `capabilities` array in their `pithy.config.ts` — so this
67
+ * middleware may well run before `@pithy-sh/auth` has set `c.var.auth`. Reading the caller at gate time
68
+ * rather than at install time makes the resolver correct whatever that order is, and it means a request that
69
+ * never gates on an entitlement never builds a Kysely instance or touches D1. The subject seam is resolved
70
+ * on the same schedule and for the same reason: an adopter's resolver reads the session, and the session is
71
+ * only on the request once whichever middleware sets it has run.
72
+ *
73
+ * **The subject is resolved first, before the database is even looked for.** With nobody resolved there is
74
+ * nothing to ask D1, so the binding check below has no business running — an organization-billed Worker
75
+ * whose seam answers nothing must read as unentitled, which is a denial from the gate, and not as broken,
76
+ * which is what a 500 from a wiring check would have said. Unanswered is unentitled: see `subjectSeam.ts`
77
+ * for why that is the only safe direction, and why nothing here falls back to `c.var.auth.userId`.
78
+ *
79
+ * With nobody to resolve for, the resolver holds nothing. It still names payments as the provider, because
80
+ * payments *is* composed — an anonymous caller is a 401 from the gate and an unresolved subject is a 403,
81
+ * neither is a composition problem, and reporting `null` here would send an operator looking for a missing
82
+ * capability.
83
+ */
84
+ export function installEntitlementResolver(database: string, seam: PaymentsSubjectSeam): PithyMiddleware {
85
+ return (app) => {
86
+ app.use("*", async (c, next) => {
87
+ // **One resolution per request, shared by `list()` and `holder()`.** The seam is the adopter's and
88
+ // usually reads their session — a KV get, a D1 row — and a denial calls both halves, so resolving
89
+ // twice would double that cost on precisely the path that is already failing. Memoizing the
90
+ // *promise* rather than the value also makes two concurrent gates on one request share one read.
91
+ let resolving: Promise<PaymentsSubject | undefined> | undefined;
92
+ const subjectOnce = (): Promise<PaymentsSubject | undefined> => {
93
+ resolving ??= resolvePaymentsSubject(c, seam);
94
+ return resolving;
95
+ };
96
+ c.set("entitlements", {
97
+ provider: PAYMENTS_ENTITLEMENT_PROVIDER,
98
+ // Who the read answered for, for the log and the trail alone. `undefined` is a real answer and a
99
+ // different one from "holds nothing": it is a caller acting for no organization, which is what
100
+ // lets core's denial say so instead of reporting an empty list twice over.
101
+ holder: async (): Promise<EntitlementHolder | undefined> => {
102
+ const subject = await subjectOnce();
103
+ return subject ? paymentsEntitlementHolder(subject) : undefined;
104
+ },
105
+ list: async (): Promise<readonly Entitlement[]> => {
106
+ const subject = await subjectOnce();
107
+ if (!subject) return [];
108
+ const db = (c.var.db as Record<string, PaymentsDatabase>)[database];
109
+ if (!db) {
110
+ // Unreachable through `createBackend`, which validates the `DB` binding at boot. Thrown rather
111
+ // than returned as an empty list so the gate's own catch logs the wiring fault: a silent empty
112
+ // list denies correctly but looks identical to a subject that simply has not bought anything.
113
+ throw new InternalError({
114
+ message: "Payments is not configured.",
115
+ action: "Bind a D1 database named DB in wrangler.jsonc.",
116
+ detail: `No \`${database}\` database on c.var.db; payments cannot resolve entitlements.`,
117
+ });
118
+ }
119
+ return resolveEntitlements(db, subject, new Date());
120
+ },
121
+ });
122
+ await next();
123
+ });
124
+ };
125
+ }