@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,101 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { PurchaseEnvironment, PurchaseRole } from "../data/purchase";
6
+ import { PaymentsRail } from "../data/rail";
7
+ import { PurchaseStatus } from "../data/status";
8
+ import { PaymentsSubject } from "../data/subject";
9
+
10
+ /**
11
+ * A provider event, normalized. This is the writer's whole input, and the seam between "how a rail talks"
12
+ * and "what the projection stores": a rail module verifies a receipt or parses a notification and produces
13
+ * one of these, and nothing past this point knows which store it came from.
14
+ *
15
+ * The shape is deliberately a *state*, not a *delta*. A rail reports what the transaction is now, and the
16
+ * writer projects it — so a renewal, a refund, a grace period, and a revocation are all the same code path
17
+ * rather than four handlers that must each be kept correct.
18
+ *
19
+ * `providerEventAt` is the field the whole design leans on. It is the provider's own timestamp, not ours,
20
+ * because ours would record delivery order and delivery order is exactly what providers do not guarantee.
21
+ *
22
+ * **This is the owner-bound half of a two-part shape.** A rail produces an `UnboundProviderEvent` — this
23
+ * object with the subject pair omitted, declared in `rails/contract.ts` — and the route binds an owner onto
24
+ * it to make one of these. The split is the whole reason a store's bytes cannot decide who is entitled: a
25
+ * rail parses a payload and knows the transaction completely and the holder not at all, so the field it
26
+ * would have to fill in does not exist for it to fill in wrongly.
27
+ */
28
+ export const ProviderEvent = z
29
+ .object({
30
+ rail: PaymentsRail.describe("Which store the event came from."),
31
+ providerTransactionId: z
32
+ .string()
33
+ .min(1)
34
+ .describe(
35
+ "The rail's own transaction id. Together with `rail` this is the row's identity forever — one provider transaction is one purchase row.",
36
+ ),
37
+ providerProductId: z
38
+ .string()
39
+ .min(1)
40
+ .describe("The rail's own SKU or price id. The writer resolves the logical product from it."),
41
+ subjectType: PaymentsSubject.shape.subjectType.describe(
42
+ "Whether `subjectId` names a user or an organization. Half the owner, and never separable from the other half — the route binds both together or neither.",
43
+ ),
44
+ subjectId: PaymentsSubject.shape.subjectId.describe(
45
+ "The subject the transaction belongs to. A client submission takes it from the authenticated caller through the configured subject seam; a webhook resolves it through the provider-account map or a decoded account reference.",
46
+ ),
47
+ status: PurchaseStatus.describe("The transaction's current normalized state, as the rail reports it."),
48
+ role: PurchaseRole.default("charge").describe(
49
+ "Whether this event records money moving or a subscription's standing. Omitted by every rail but Lemon Squeezy, which reports the two separately because its store does.",
50
+ ),
51
+ environment: PurchaseEnvironment.describe(
52
+ "Which store environment the transaction happened in. Checked against the deployment's own before anything is written.",
53
+ ),
54
+ purchasedAt: z.date().describe("When the store recorded the purchase."),
55
+ expiresAt: z
56
+ .date()
57
+ .nullable()
58
+ .default(null)
59
+ .describe("When access lapses, or null for a purchase that never expires. Null for a one-time purchase."),
60
+ revokedAt: z
61
+ .date()
62
+ .nullable()
63
+ .default(null)
64
+ .describe("When the purchase was refunded or revoked, or null while it stands."),
65
+ resumesAt: z
66
+ .date()
67
+ .nullable()
68
+ .default(null)
69
+ .describe(
70
+ "When a paused subscription resumes, as the provider stated it — never computed, and never set on a status other than `paused`. Null on a paused event is the provider reporting an indefinite pause. Produced only by `pauseResumesAt` in `data/pause.ts`, which is where each rail's source for it is declared.",
71
+ ),
72
+ originalTransactionId: z
73
+ .string()
74
+ .nullable()
75
+ .default(null)
76
+ .describe("The transaction that started the subscription, chaining renewals back to it. Null for a one-off."),
77
+ amountMinor: z
78
+ .number()
79
+ .int()
80
+ .nullable()
81
+ .default(null)
82
+ .describe("The amount charged in the currency's minor unit, or null when the rail did not report one."),
83
+ currency: z
84
+ .string()
85
+ .nullable()
86
+ .default(null)
87
+ .describe("The ISO currency of `amountMinor`, or null when the rail did not report one."),
88
+ providerEventAt: z
89
+ .date()
90
+ .describe(
91
+ "The provider's own timestamp for this event. The monotonic write rule compares against it, so an event staler than the row it would update changes nothing.",
92
+ ),
93
+ payload: z
94
+ .record(z.string(), z.unknown())
95
+ .describe("The verified provider payload, stored as received. The reconciliation and audit record."),
96
+ })
97
+ .describe(
98
+ "One provider event, normalized and bound to its owner — the projection writer's input. A rail produces this shape less the subject pair; the route adds it.",
99
+ );
100
+ export type ProviderEvent = z.output<typeof ProviderEvent>;
101
+ export type ProviderEventInput = z.input<typeof ProviderEvent>;
@@ -0,0 +1,226 @@
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 type { PaymentsConfig } from "../config/config";
7
+ import type { PurchaseEnvironment } from "../data/purchase";
8
+ import type { PaymentsRail } from "../data/rail";
9
+ import { PAYMENTS_WEBHOOK_EVENTS_TABLE, paymentsDatabase } from "../data/tables";
10
+ import { PaymentsWebhookEvent, WEBHOOK_EVENT_ORPHANED, webhookEventAwaitsOwner } from "../data/webhookEvent";
11
+ import type { VerifiedNotification } from "../rails/contract";
12
+ import { resolveNotificationOwner } from "./owner";
13
+ import { type PurchaseProjection, projectPurchase } from "./writer";
14
+
15
+ /**
16
+ * The repair that runs when an account links: project the purchases that were waiting for exactly that.
17
+ *
18
+ * ## The gap this closes (#341)
19
+ *
20
+ * An orphan is an authentic notification carrying a real purchase and nothing that says whose it is. Both
21
+ * paths that meet one leave the row unfinished on purpose — the webhook handler records the reason and
22
+ * withholds `processedAt`, the sweep stamps `abandonedAt` so its stream can advance — and both then rely on
23
+ * **a later delivery of the same event** to repair it. That is the right bet when the store redelivers. It
24
+ * is no bet at all when the only future event is the account linking: nothing redelivers on that signal,
25
+ * every store's retry window closes within days, and the sweep's cursor is long past.
26
+ *
27
+ * Reproduced on real D1 before this existed: sweep an unstamped `subscription.activated` for an unlinked
28
+ * customer, link the account, sweep ten more times. The purchase is never projected. Not once, ever — the
29
+ * orphan gets exactly one owner resolution in its life, at the moment nobody could answer it.
30
+ *
31
+ * ## Why here and not in the sweep
32
+ *
33
+ * The two halves of the question live in different places. The link path knows *which* customer just became
34
+ * resolvable; the sweep knows *which events* are outstanding. One of them has to ask the other, and asking
35
+ * from the link side is the cheaper direction by a wide margin: it runs once per link rather than once per
36
+ * sweep, the set it looks at is bounded by {@link ORPHAN_REPAIR_LIMIT}, and — decisively — it works on the
37
+ * four rails that have no sweep at all. A rail without a repair pass is exactly where a lost orphan stays
38
+ * lost.
39
+ *
40
+ * ## Which rows, and why not simply "unfinished"
41
+ *
42
+ * {@link webhookEventAwaitsOwner}: unfinished, and carrying the {@link WEBHOOK_EVENT_ORPHANED} marker. An
43
+ * account linking repairs one thing — a missing owner. A quarantined event, a failed projection, an unmapped
44
+ * SKU are all unfinished too, and re-running them on this signal would be an unbounded retry loop triggered
45
+ * by unrelated traffic. The marker is what makes the set the *right* set rather than merely a small one.
46
+ *
47
+ * ## Why a payload replay rather than a stored event
48
+ *
49
+ * The row already holds the notification whole — that is what `payload` is for, and the reason it is stored
50
+ * rather than summarized. So the purchase is recoverable without a second column, a migration, or a shape
51
+ * that could disagree with the payload it was derived from. What it costs is a rail able to re-read its own
52
+ * recorded body, which is {@link PaymentsRailProvider.replay} and is optional for the two rails whose stored
53
+ * body is a signed blob.
54
+ *
55
+ * ## What it deliberately does not do
56
+ *
57
+ * It never *invents* an owner. Every row goes back through {@link resolveNotificationOwner}, against the same
58
+ * table in the same trust order, so a link that resolves nothing changes nothing — and a row for a different
59
+ * holder that happens to be in the same page is left exactly as it stood. Passing the freshly linked subject
60
+ * straight to the writer would be the shortcut, and it would project one customer's purchase onto another's
61
+ * account the first time two orphans shared a page.
62
+ *
63
+ * **Per row, and both halves per row.** The subject is resolved inside the loop, from the row's own hints, so
64
+ * a page of orphans is a page of independent questions rather than one answer applied to all of them. There
65
+ * is no subject computed before the loop for the same reason there is no `subjectType` taken from config: a
66
+ * kind that outlived the row it came from is how an organization's purchase lands on a user with the same id.
67
+ */
68
+
69
+ /**
70
+ * How many orphaned rows one link may repair.
71
+ *
72
+ * A bound rather than "all of them", because this runs inside a webhook handler that a store is timing. A
73
+ * link arriving after a long outage could face hundreds of orphans, and a handler that walks them all is a
74
+ * handler the store times out and redelivers — which produces the same walk again, from the top.
75
+ *
76
+ * Small enough to be invisible in a request, and the leftovers are not lost: they still carry the marker, and
77
+ * the next link on that rail takes the next page. The rows are taken oldest first so the queue drains in
78
+ * order rather than starving its own tail.
79
+ */
80
+ export const ORPHAN_REPAIR_LIMIT = 25;
81
+
82
+ /** What the repair needs to project what it finds: the catalog, the environment it is writing for, the clock. */
83
+ export interface RepairOrphansOptions {
84
+ /** The resolved catalog, handed to the writer unchanged. */
85
+ config: PaymentsConfig;
86
+ /** This deployment's store environment. The writer refuses a row that disagrees. */
87
+ environment: PurchaseEnvironment;
88
+ /** The clock. */
89
+ now: Date;
90
+ /**
91
+ * Re-read one recorded payload — {@link PaymentsRailProvider.replay}, bound to its rail.
92
+ *
93
+ * Passed in rather than resolved here because building a rail provider needs credentials, and this module
94
+ * has no business reading a secret. A caller whose rail cannot replay passes nothing and the repair is a
95
+ * no-op, which is the honest answer for it.
96
+ */
97
+ replay?: (payload: Record<string, unknown>) => Promise<VerifiedNotification | undefined>;
98
+ /** What to do with each repaired projection — the same fulfillment the delivery would have performed. */
99
+ fulfill?: (projection: PurchaseProjection) => Promise<void>;
100
+ /** How many rows to take. Defaults to {@link ORPHAN_REPAIR_LIMIT}. */
101
+ limit?: number;
102
+ }
103
+
104
+ /** What one repair pass did. Returned so a caller can log it and a test can assert on it. */
105
+ export interface RepairedOrphans {
106
+ /** Rows examined — orphan-marked and unfinished, on this rail. */
107
+ readonly examined: number;
108
+ /** Rows whose purchase is now projected and whose event is now finished. */
109
+ readonly projected: readonly string[];
110
+ /** Rows still waiting: no owner yet, no replay, or nothing to project. Left exactly as they stood. */
111
+ readonly waiting: number;
112
+ }
113
+
114
+ /**
115
+ * Project the orphaned events on one rail that an owner can now be resolved for.
116
+ *
117
+ * Called after a link is written, on every path that writes one. Never throws for a row it cannot repair: a
118
+ * replay that cannot read an old payload, an owner that still does not resolve, a projection the catalog
119
+ * refuses — each leaves that row untouched and the pass moves to the next. The caller is a webhook handler
120
+ * answering a store, and a repair failing must not turn a delivery that succeeded into a non-2xx.
121
+ *
122
+ * A row it *does* project is finished the same way the original delivery would have finished it, so the
123
+ * store's own redelivery of it is a duplicate from then on.
124
+ */
125
+ export async function repairOrphanedEvents(
126
+ d1: D1Database,
127
+ rail: PaymentsRail,
128
+ options: RepairOrphansOptions,
129
+ ): Promise<RepairedOrphans> {
130
+ const projected: string[] = [];
131
+ let examined = 0;
132
+ let waiting = 0;
133
+
134
+ if (options.replay === undefined) return { examined, projected, waiting };
135
+
136
+ const db = paymentsDatabase(d1);
137
+ const rows = await db
138
+ .selectFrom(PAYMENTS_WEBHOOK_EVENTS_TABLE)
139
+ .selectAll()
140
+ .where("rail", "=", rail)
141
+ // Unfinished. The marker check below is the narrow half, but this one is what a `processedAt` index can
142
+ // serve, and it keeps a store's whole finished history out of the scan.
143
+ .where("processedAt", "is", null)
144
+ .where("error", "like", `${WEBHOOK_EVENT_ORPHANED}%`)
145
+ // Oldest first: the queue drains in the order the purchases were made, not the order D1 happens to scan.
146
+ .orderBy("receivedAt")
147
+ .limit(options.limit ?? ORPHAN_REPAIR_LIMIT)
148
+ .execute();
149
+
150
+ for (const stored of rows) {
151
+ const row = PaymentsWebhookEvent.parse(stored);
152
+ // The predicate again, on the decoded row. The `where` above is the query this scan can afford; this is
153
+ // the question being asked. They agree today, and a reader must not have to prove that to trust the set.
154
+ if (!webhookEventAwaitsOwner(stored)) continue;
155
+ examined += 1;
156
+
157
+ const notification = await options.replay(row.payload);
158
+ if (notification?.event === undefined || notification.event === null) {
159
+ waiting += 1;
160
+ continue;
161
+ }
162
+
163
+ // This row's own subject, resolved from this row's own hints. Both halves come back together or neither
164
+ // does — `projection/owner.ts` reads them from one row for exactly this reason.
165
+ const subject = await resolveNotificationOwner(db, rail, {
166
+ providerAccountId: notification.providerAccountId,
167
+ providerTransactionId: notification.event.providerTransactionId,
168
+ originalTransactionId: notification.event.originalTransactionId,
169
+ });
170
+ if (subject === undefined) {
171
+ // Still nobody. The link that just landed was somebody else's, and this row waits for its own.
172
+ waiting += 1;
173
+ continue;
174
+ }
175
+
176
+ let projection: PurchaseProjection;
177
+ try {
178
+ projection = await projectPurchase(
179
+ d1,
180
+ { ...notification.event, ...subject },
181
+ { config: options.config, environment: options.environment, now: options.now },
182
+ );
183
+ if (notification.stateEvent) {
184
+ await projectPurchase(
185
+ d1,
186
+ { ...notification.stateEvent, ...subject },
187
+ { config: options.config, environment: options.environment, now: options.now },
188
+ );
189
+ }
190
+ await options.fulfill?.(projection);
191
+ } catch {
192
+ // An unmapped SKU, a sandbox row in a production database, a ledger that would not assemble. None of
193
+ // them is this link's fault and none is repaired by trying again here, so the row keeps its marker and
194
+ // its reason and this pass moves on. The delivery that called us still succeeds.
195
+ waiting += 1;
196
+ continue;
197
+ }
198
+
199
+ await finish(d1, row.id, options.now);
200
+ projected.push(row.providerEventId);
201
+ }
202
+
203
+ return { examined, projected, waiting };
204
+ }
205
+
206
+ /**
207
+ * Mark a repaired row finished, and clear the reason it carried.
208
+ *
209
+ * `processedAt` because it now genuinely is finished — the purchase is projected, and a redelivery has
210
+ * nothing left to add. The `error` goes with it: it said "no subject could be resolved", which stopped
211
+ * being true, and a stale reason beside a finished timestamp is the contradiction `data/webhookEvent.ts`
212
+ * refuses to store.
213
+ *
214
+ * `abandonedAt` is left exactly where it was. A sweep having given up on this row is a fact about that pass
215
+ * and a record of how close the purchase came to being lost; `finished` already wins over it by construction.
216
+ */
217
+ async function finish(d1: D1Database, id: string, now: Date): Promise<void> {
218
+ await withD1Retry(() =>
219
+ paymentsDatabase(d1)
220
+ .updateTable(PAYMENTS_WEBHOOK_EVENTS_TABLE)
221
+ // biome-ignore lint/suspicious/noExplicitAny: encoded column values, not the app shape.
222
+ .set({ processedAt: now.getTime(), error: null } as any)
223
+ .where("id", "=", id)
224
+ .execute(),
225
+ );
226
+ }
@@ -0,0 +1,247 @@
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 { PaymentsProviderAccount } from "../data/providerAccount";
7
+ import type { PaymentsRail } from "../data/rail";
8
+ import { PaymentsSubject } from "../data/subject";
9
+ import {
10
+ PAYMENTS_PROVIDER_ACCOUNTS_TABLE,
11
+ PAYMENTS_PURCHASES_TABLE,
12
+ type PaymentsDatabase,
13
+ paymentsDatabase,
14
+ } from "../data/tables";
15
+
16
+ /**
17
+ * Who a purchase belongs to — the question a webhook cannot answer for itself.
18
+ *
19
+ * A notification arrives carrying the store's own identifiers and no Pithy identity at all. The projection
20
+ * writer takes an owner as an input and refuses to infer one, deliberately: a writer that could derive a
21
+ * holder from a payload could be talked into deriving the wrong one. So resolution happens here, once,
22
+ * against rows the server itself wrote.
23
+ *
24
+ * **The answer is a subject, not a user id** — the pair, and always both halves. Under organization billing
25
+ * the person who clicked is not the holder, and `data/subject.ts` states why an id on its own identifies
26
+ * nobody: nothing keeps an organization id from equalling some user's, so a resolution that returned an id
27
+ * alone would let one collect the other's renewals. Every lookup below therefore selects both columns **from
28
+ * the same row**, and the pair is never assembled from two places — not a kind from config beside an id from
29
+ * a row, not a kind from one table beside an id from another.
30
+ *
31
+ * **Three sources, and the order they are consulted in is a trust order.**
32
+ *
33
+ * 1. **The same transaction, already projected.** A redelivery of a notification we handled is never
34
+ * orphaned, whatever the app set.
35
+ * 2. **The transaction that started the subscription.** This is what makes renewals work for an app that set
36
+ * no account token at all: the first purchase was submitted by its owner, and every renewal chains back to
37
+ * it through `originalTransactionId`.
38
+ * 3. **The account link** — `pithy_payments_provider_accounts`, written when the purchase was submitted,
39
+ * through the hook every store provides: Apple's `appAccountToken`, Google's `obfuscatedAccountId`,
40
+ * Stripe's `client_reference_id`.
41
+ *
42
+ * The first two are facts this server established; the third is a value a client chose. That is the whole
43
+ * ordering, and {@link resolveNotificationOwner} spells out the attack it prevents.
44
+ *
45
+ * When nothing answers, the answer is undefined — an orphan. The notification is still recorded, so it is
46
+ * visible and replayable once a link arrives. Guessing would be the alternative, and a guess here grants
47
+ * somebody else's subscription.
48
+ */
49
+
50
+ /** What a notification offers as identification. Every field is optional; a rail supplies what it carries. */
51
+ export interface OwnerHints {
52
+ /** The store's account identifier, if the purchase carried one. */
53
+ providerAccountId?: string | null;
54
+ /** This transaction's own id. Matches a purchase already projected. */
55
+ providerTransactionId?: string | null;
56
+ /** The transaction that started the subscription. Matches the purchase a renewal descends from. */
57
+ originalTransactionId?: string | null;
58
+ }
59
+
60
+ /** Options for the account link. The clock is injected so a link's `createdAt` is deterministic in tests. */
61
+ export interface LinkProviderAccountOptions {
62
+ /** The clock. */
63
+ now?: Date;
64
+ /** The id minter. */
65
+ newId?: () => string;
66
+ }
67
+
68
+ /**
69
+ * The pair, read off the one row it was found in.
70
+ *
71
+ * A function rather than an object literal at each lookup, because the literal is exactly where the two
72
+ * halves could come from two places: a `subjectType` taken from config beside a `subjectId` taken from a row
73
+ * typechecks perfectly and names a holder nobody meant. Taking a row and reading both columns off it leaves
74
+ * nowhere to pass a mismatched pair. The parse is the D1 boundary check every other decoded row in this
75
+ * package gets, over a value the table's own CHECK constraint already closes.
76
+ */
77
+ function subjectOf(row: { subjectType: string; subjectId: string }): PaymentsSubject {
78
+ return PaymentsSubject.parse(row);
79
+ }
80
+
81
+ /**
82
+ * Bind a store account identifier to a subject, and return the subject it is bound to.
83
+ *
84
+ * Idempotent, and **never rebinding**: `UNIQUE (rail, providerAccountId)` plus `ON CONFLICT DO NOTHING`
85
+ * means the first binding wins and a later one is a no-op. That matters because a client re-submits its
86
+ * receipt on every launch, and because rebinding would let a second holder capture the first one's renewal
87
+ * notifications simply by submitting a receipt from the same store account.
88
+ *
89
+ * **The key is deliberately not widened by the subject**, and the kind is no escape from it either: an
90
+ * organization whose id equals a user's does not get its own link, because `organization:acme` and
91
+ * `user:acme` would still be two rows competing for one `(rail, providerAccountId)`. One provider identity
92
+ * resolves to one holder, and which holder is decided once.
93
+ *
94
+ * The returned subject is the one actually bound, which is not always the one passed in. A caller that cares
95
+ * about the difference compares them with `sameSubject`; the purchase-level owner check in the writer is
96
+ * what refuses a transaction outright.
97
+ *
98
+ * **A link is also a repair signal, and this function does not act on it.** Purchases that arrived before
99
+ * their owner was knowable are sitting in `pithy_payments_webhook_events` waiting for exactly this row —
100
+ * see `projection/orphans.ts`, and #341 for what it cost while nothing looked. Acting on it here would mean
101
+ * this function taking a catalog, an environment and a rail able to replay its own payloads, which is three
102
+ * arguments a link has no business knowing about; so every call site calls `repairOrphanedEvents` beside
103
+ * this one instead. **A fourth call site owes the same call.**
104
+ */
105
+ export async function linkProviderAccount(
106
+ d1: D1Database,
107
+ rail: PaymentsRail,
108
+ providerAccountId: string,
109
+ subject: PaymentsSubject,
110
+ options: LinkProviderAccountOptions = {},
111
+ ): Promise<PaymentsSubject> {
112
+ const db = paymentsDatabase(d1);
113
+ const row = PaymentsProviderAccount.encode({
114
+ id: options.newId?.() ?? crypto.randomUUID(),
115
+ rail,
116
+ providerAccountId,
117
+ subjectType: subject.subjectType,
118
+ subjectId: subject.subjectId,
119
+ createdAt: options.now ?? new Date(),
120
+ });
121
+
122
+ await withD1Retry(() =>
123
+ db
124
+ .insertInto(PAYMENTS_PROVIDER_ACCOUNTS_TABLE)
125
+ // biome-ignore lint/suspicious/noExplicitAny: an encoded row; Kysely's insert type derives from z.input.
126
+ .values(row as any)
127
+ .onConflict((oc) => oc.columns(["rail", "providerAccountId"]).doNothing())
128
+ .execute(),
129
+ );
130
+
131
+ const bound = await db
132
+ .selectFrom(PAYMENTS_PROVIDER_ACCOUNTS_TABLE)
133
+ .select(["subjectType", "subjectId"])
134
+ .where("rail", "=", rail)
135
+ .where("providerAccountId", "=", providerAccountId)
136
+ .executeTakeFirst();
137
+ // Absent only if the row vanished between the insert and this read, which nothing in this package does.
138
+ return bound === undefined ? subject : subjectOf(bound);
139
+ }
140
+
141
+ /**
142
+ * The store account this subject already has on this rail, or undefined.
143
+ *
144
+ * The reverse of {@link resolveNotificationOwner}, and it exists for the two hosted Stripe flows. `/checkout`
145
+ * passes it so a returning buyer keeps one Stripe customer instead of minting a new one per purchase — without
146
+ * that, the billing portal their first purchase created would not show their second. `/portal` needs it because
147
+ * a portal session is *about* a customer, and taking that from a request would let any caller open somebody
148
+ * else's billing.
149
+ *
150
+ * **Both halves filter, and that is what the portal rests on.** An id-only filter would hand `organization:acme`
151
+ * the portal session belonging to `user:acme` — a live billing session over somebody else's card, invoices and
152
+ * cancellation, reachable by anybody who can act for a subject whose id collides.
153
+ *
154
+ * The oldest link wins. A subject with two accounts on one rail is already an anomaly — the link is never
155
+ * rebound, so it takes two purchases made under two store accounts — and the first is the one every earlier
156
+ * purchase is filed under.
157
+ */
158
+ export async function providerAccountForSubject(
159
+ db: PaymentsDatabase,
160
+ rail: PaymentsRail,
161
+ subject: PaymentsSubject,
162
+ ): Promise<string | undefined> {
163
+ const row = await db
164
+ .selectFrom(PAYMENTS_PROVIDER_ACCOUNTS_TABLE)
165
+ .select("providerAccountId")
166
+ .where("rail", "=", rail)
167
+ .where("subjectType", "=", subject.subjectType)
168
+ .where("subjectId", "=", subject.subjectId)
169
+ .orderBy("createdAt")
170
+ .executeTakeFirst();
171
+ return row?.providerAccountId;
172
+ }
173
+
174
+ /**
175
+ * The subject a notification belongs to, or undefined when nothing identifies one.
176
+ *
177
+ * **The order is a trust order, not a convenience order.** The two transaction lookups ask a question this
178
+ * server already answered: who owned the purchase we projected for an authenticated caller. The account link
179
+ * asks a question the *app* answered, because on Apple and Google `providerAccountId` is `appAccountToken` /
180
+ * `obfuscatedAccountId` — values a client puts in a purchase and may put anything in. `contract.ts` says as
181
+ * much about `accountReference`, and the same reasoning applies one hop further out: a value a client chose
182
+ * must never outrank a fact this server established.
183
+ *
184
+ * Consulting the link first is a cross-subject entitlement hijack. An attacker who can guess a victim's token
185
+ * value makes one purchase carrying it, submits it as themselves, and owns the link; the victim's own
186
+ * notifications then resolve to the attacker, and because a renewal carries a fresh transaction id the
187
+ * writer's owner check never fires. Last is where a client's claim belongs.
188
+ *
189
+ * It still belongs *somewhere*: on Stripe the link is written from `client_reference_id`, which `/checkout`
190
+ * set from the authenticated buyer, so it is server-established and it is the only thing that can attribute
191
+ * a first invoice. Hence last rather than gone.
192
+ *
193
+ * Each source answers with the whole pair or with nothing. A source that matched a row contributes that
194
+ * row's `subjectType` **and** its `subjectId`, so a purchase filed against an organization can never resolve
195
+ * to the user with the same id — see {@link subjectOf}.
196
+ */
197
+ export async function resolveNotificationOwner(
198
+ db: PaymentsDatabase,
199
+ rail: PaymentsRail,
200
+ hints: OwnerHints,
201
+ ): Promise<PaymentsSubject | undefined> {
202
+ // A falsy hint is skipped rather than passed to the query: `where(column, "=", undefined)` in Kysely
203
+ // compares against null, which on a nullable column matches every unrelated one-off purchase.
204
+ if (hints.providerTransactionId) {
205
+ const owner = await db
206
+ .selectFrom(PAYMENTS_PURCHASES_TABLE)
207
+ .select(["subjectType", "subjectId"])
208
+ .where("rail", "=", rail)
209
+ .where("providerTransactionId", "=", hints.providerTransactionId)
210
+ .executeTakeFirst();
211
+ if (owner) return subjectOf(owner);
212
+ }
213
+
214
+ if (hints.originalTransactionId) {
215
+ // The whole subscription family, matched on either column. A renewal's `originalTransactionId` names the
216
+ // *first transaction's own id*, so the purchase that started the subscription is found on
217
+ // `providerTransactionId` while its siblings are found on `originalTransactionId`. Matching only the
218
+ // latter would miss the one row that is guaranteed to exist — the one somebody actually bought.
219
+ const owner = await db
220
+ .selectFrom(PAYMENTS_PURCHASES_TABLE)
221
+ .select(["subjectType", "subjectId"])
222
+ .where("rail", "=", rail)
223
+ .where((eb) =>
224
+ eb.or([
225
+ eb("providerTransactionId", "=", hints.originalTransactionId as string),
226
+ eb("originalTransactionId", "=", hints.originalTransactionId as string),
227
+ ]),
228
+ )
229
+ // Oldest first: the transaction that started the subscription is the one whose owner is least disputable.
230
+ .orderBy("purchasedAt")
231
+ .executeTakeFirst();
232
+ if (owner) return subjectOf(owner);
233
+ }
234
+
235
+ // Last, and only when nothing this server established answers. See the trust order above.
236
+ if (hints.providerAccountId) {
237
+ const linked = await db
238
+ .selectFrom(PAYMENTS_PROVIDER_ACCOUNTS_TABLE)
239
+ .select(["subjectType", "subjectId"])
240
+ .where("rail", "=", rail)
241
+ .where("providerAccountId", "=", hints.providerAccountId)
242
+ .executeTakeFirst();
243
+ if (linked) return subjectOf(linked);
244
+ }
245
+
246
+ return undefined;
247
+ }
@@ -0,0 +1,57 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Entitlement } from "@pithy-sh/core/src/entitlement/entitlement";
5
+ import { PaymentsEntitlement } from "../data/entitlement";
6
+ import type { PaymentsSubject } from "../data/subject";
7
+ import { PAYMENTS_ENTITLEMENTS_TABLE, type PaymentsDatabase } from "../data/tables";
8
+
9
+ /**
10
+ * The read path: every entitlement a subject holds, as the core seam's shape.
11
+ *
12
+ * **A read never writes.** Repairing a stale row is the reconciliation Workflow's job, which is what keeps
13
+ * this the hot path it needs to be — one indexed lookup per request, no KV cache, no token claim, so a
14
+ * revocation is immediate and the truth has one home (issue #79 decision 5).
15
+ *
16
+ * The recheck is why the read exists as its own function rather than a `selectAll`. A subscription can lapse
17
+ * with **no notification arriving at all** — the store simply stops renewing, and nothing tells us. So the
18
+ * stored `active` flag is an optimization and `expiresAt` is the truth, and the read applies the timestamp
19
+ * itself rather than trusting the flag. A row that says `active = 1` with an expiry in the past does not
20
+ * grant, and it does not need a write to stop granting.
21
+ *
22
+ * Lapsed rows are returned rather than filtered out, with `active` false. A paywall wants to say "your Pro
23
+ * ended on the 4th", and it can only do that if the row and its date survive the read.
24
+ *
25
+ * **Both halves of the subject are in the `where`, and neither is decorative.** Nothing in the kit makes the
26
+ * user id space and the adopter's organization id space disjoint — they are minted by different things, and
27
+ * neither knows the other exists — so a read filtered on the id alone would hand `user:acme` whatever
28
+ * `organization:acme` bought. The pair arrives as one object for exactly that reason: it is read from and
29
+ * compared as a unit, and no call site here pairs a kind from config with an id from somewhere else.
30
+ *
31
+ * Both columns lead `UNIQUE (subjectType, subjectId, entitlement)`, so this stays the one indexed lookup it
32
+ * has to be — the index answers the whole predicate rather than filtering a kind out afterwards.
33
+ */
34
+ export async function resolveEntitlements(
35
+ db: PaymentsDatabase,
36
+ subject: PaymentsSubject,
37
+ now: Date,
38
+ ): Promise<readonly Entitlement[]> {
39
+ const rows = await db
40
+ .selectFrom(PAYMENTS_ENTITLEMENTS_TABLE)
41
+ .selectAll()
42
+ .where("subjectType", "=", subject.subjectType)
43
+ .where("subjectId", "=", subject.subjectId)
44
+ .orderBy("entitlement")
45
+ .execute();
46
+
47
+ return rows.map((row) => {
48
+ const entitlement = PaymentsEntitlement.parse(row);
49
+ const lapsed = entitlement.expiresAt !== null && entitlement.expiresAt.getTime() <= now.getTime();
50
+ return {
51
+ key: entitlement.entitlement,
52
+ active: entitlement.active && !lapsed,
53
+ expiresAt: entitlement.expiresAt,
54
+ source: entitlement.sourcePurchaseId,
55
+ };
56
+ });
57
+ }