@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,642 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { minorUnitDigits, minorUnitsFromScaled } from "../../data/money";
6
+ import type { PurchaseEnvironment } from "../../data/purchase";
7
+ import { ACCESS_GRANTING_STATUSES, type PurchaseStatus } from "../../data/status";
8
+ import { PaymentsVerificationFailedError } from "../../error/errors";
9
+ import type { NotificationNote, UnboundProviderEvent } from "../contract";
10
+
11
+ /**
12
+ * Stripe's objects, and the mapping from what Stripe calls a thing to what the projection stores.
13
+ *
14
+ * ## The two identifiers, and why they are what they are
15
+ *
16
+ * A subscription row is keyed on the **invoice**, not on the subscription. Stripe keeps one subscription id
17
+ * across every renewal, so keying on it would make one row for the lifetime of a subscription — and a `grants`
18
+ * clause would then credit once ever rather than once per billing period, which is the behavior issue #79
19
+ * specifies and the behavior Apple and Google already have. The invoice changes each period, so each period is
20
+ * its own purchase row. The subscription id becomes `originalTransactionId`, the family key, exactly as Play's
21
+ * purchase token does: a renewal's owner resolves through the purchase that started the subscription.
22
+ *
23
+ * A one-time row is keyed on the **payment intent**, not on the Checkout Session. A later `charge.refunded`
24
+ * names the payment intent and knows nothing about the session, so keying on it is what lets a refund land on
25
+ * the purchase it refunds.
26
+ *
27
+ * ## Where a product comes from
28
+ *
29
+ * A subscription carries its price inline. A Checkout Session does **not** — `line_items` is expandable, and a
30
+ * webhook payload never expands anything. So the price id is stamped into the session's metadata when the
31
+ * session is created (`checkout.ts`), and read back here. On the retrieve path the line items *are* expanded,
32
+ * and Stripe's own answer is preferred over our copy.
33
+ *
34
+ * A session that Pithy did not create — a Payment Link built in the dashboard — therefore carries no price we
35
+ * can resolve. That is recorded as a note rather than dropped or guessed, the same bounded gap Play's voided
36
+ * purchases have: authentic, unresolvable here, repairable from the recorded id.
37
+ *
38
+ * ## A completed session is frozen, and that is a security property
39
+ *
40
+ * `payment_status` reads `paid` for ever. It does not change when the charge is refunded, and a retrieve
41
+ * expands nothing about the refund unless asked — so on this path a session alone can only ever say the
42
+ * purchase was paid for once. Two things follow, and both are load-bearing: the retrieve expands
43
+ * `payment_intent.latest_charge` so a refund is *visible* (`verify.ts`), and a snapshot that claims access is
44
+ * dated by Stripe's own clock rather than ours so it cannot outrank the refund that followed it
45
+ * ({@link retrievedAt}). A subscription-mode session is different in kind: Stripe expands the live
46
+ * subscription object, so its state is current and the clock is the honest date.
47
+ *
48
+ * ## Two fields Stripe has moved
49
+ *
50
+ * `current_period_start` and `current_period_end` moved from the subscription onto its items. A webhook endpoint
51
+ * pinned to an older API version still sends the old spelling, and an adopter's endpoint version is not
52
+ * something this package controls — so both are read, item first. This is the same accommodation the Google rail
53
+ * makes for Play Billing's two spellings of a product id.
54
+ */
55
+
56
+ /** The metadata key carrying the reference this deployment stamped on a purchase it initiated. */
57
+ export const STRIPE_METADATA_ACCOUNT_REFERENCE = "pithy_account_reference";
58
+
59
+ /** The metadata key carrying the price a Checkout Session was created for. */
60
+ export const STRIPE_METADATA_PRICE = "pithy_price_id";
61
+
62
+ /**
63
+ * Stripe's subscription statuses, mapped.
64
+ *
65
+ * Two are worth reading twice. **`trialing` grants** — a trial is access, and `expiresAt` is what ends it.
66
+ * **`past_due` is `in_grace`** — Stripe is still retrying the card and the subscriber normally keeps access
67
+ * through the retry schedule, which is what grace means; `unpaid` is the same subscription once the retries are
68
+ * exhausted, and never grants.
69
+ *
70
+ * `incomplete` is `on_hold` rather than `expired`: the first payment has not completed, so nothing was bought
71
+ * yet, but it still might be. `incomplete_expired` is where that ends when it never does, and it is
72
+ * **`never_paid`** rather than `expired` — no charge ever cleared, so a `grants` clause must not credit it.
73
+ */
74
+ const SUBSCRIPTION_STATUSES: Readonly<Record<string, PurchaseStatus>> = {
75
+ trialing: "active",
76
+ active: "active",
77
+ past_due: "in_grace",
78
+ unpaid: "on_hold",
79
+ canceled: "expired",
80
+ incomplete: "on_hold",
81
+ incomplete_expired: "never_paid",
82
+ paused: "paused",
83
+ };
84
+
85
+ /** A Stripe price, as much of it as a purchase row needs. */
86
+ const StripePrice = z
87
+ .object({
88
+ id: z.string().min(1).describe("The price id — `price_1Abc`. The SKU the catalog maps, and publishable."),
89
+ currency: z.string().min(1).optional().describe("The price's ISO currency, lower case as Stripe writes it."),
90
+ unit_amount: z
91
+ .number()
92
+ .int()
93
+ .nullish()
94
+ .describe("The amount in the currency's minor unit, or null for a metered or tiered price."),
95
+ })
96
+ .loose()
97
+ .describe("One Stripe price. What a subscription item or a line item is sold at.");
98
+
99
+ /** One item of a subscription — the price, and the period it currently covers. */
100
+ const StripeSubscriptionItem = z
101
+ .object({
102
+ id: z.string().min(1).describe("The subscription item id. Recorded only."),
103
+ price: StripePrice.describe("What this item is priced at. Its id is the SKU the catalog maps."),
104
+ quantity: z.number().int().optional().describe("How many. One unless the price allows more."),
105
+ current_period_start: z
106
+ .number()
107
+ .int()
108
+ .nullish()
109
+ .describe("When the item's paid period began, in seconds. Where Stripe moved the subscription's own field."),
110
+ current_period_end: z
111
+ .number()
112
+ .int()
113
+ .nullish()
114
+ .describe("When the item's paid period ends, in seconds. This is what becomes the purchase's `expiresAt`."),
115
+ })
116
+ .loose()
117
+ .describe("One line of a Stripe subscription.");
118
+
119
+ export const StripeSubscription = z
120
+ .object({
121
+ id: z.string().min(1).describe("The subscription id — `sub_…`. The family key every renewal shares."),
122
+ customer: z
123
+ .string()
124
+ .min(1)
125
+ .nullish()
126
+ .describe("The Stripe customer — `cus_…`. The identifier a later notification carries instead of a user."),
127
+ status: z.string().min(1).describe("Stripe's own status. Mapped to a normalized one; an unknown one is refused."),
128
+ cancel_at_period_end: z
129
+ .boolean()
130
+ .optional()
131
+ .describe("Whether auto-renew is off. True with a live status is the normalized `canceled`, which grants."),
132
+ latest_invoice: z
133
+ .string()
134
+ .min(1)
135
+ .nullish()
136
+ .describe("The current period's invoice — `in_…`. The transaction identity, so each period is its own row."),
137
+ created: z.number().int().describe("When the subscription was created, in seconds."),
138
+ start_date: z.number().int().nullish().describe("When it began, in seconds. The fallback for `purchasedAt`."),
139
+ canceled_at: z.number().int().nullish().describe("When cancellation was requested, in seconds. Recorded only."),
140
+ ended_at: z.number().int().nullish().describe("When it actually ended, in seconds. Recorded only."),
141
+ current_period_start: z
142
+ .number()
143
+ .int()
144
+ .nullish()
145
+ .describe("The period start, as API versions before Basil reported it on the subscription itself."),
146
+ current_period_end: z
147
+ .number()
148
+ .int()
149
+ .nullish()
150
+ .describe("The period end, as API versions before Basil reported it on the subscription itself."),
151
+ currency: z.string().min(1).nullish().describe("The subscription's currency. The fallback for the price's own."),
152
+ livemode: z
153
+ .boolean()
154
+ .optional()
155
+ .describe("Whether this came from a live key. False is a test-mode object, which is a sandbox purchase."),
156
+ metadata: z
157
+ .record(z.string(), z.string())
158
+ .nullish()
159
+ .describe("Whatever was attached at creation, including the account reference this deployment stamped."),
160
+ items: z
161
+ .object({
162
+ data: z
163
+ .array(StripeSubscriptionItem)
164
+ .min(1)
165
+ .describe("The subscription's items. The first is the product this purchase is projected as."),
166
+ })
167
+ .loose()
168
+ .describe("The subscription's line items."),
169
+ })
170
+ .loose()
171
+ .describe("A Stripe subscription, as much of it as the projection reads.");
172
+ export type StripeSubscription = z.infer<typeof StripeSubscription>;
173
+
174
+ /** One expanded Checkout line item. Present only on a retrieve that asked for it. */
175
+ const StripeLineItem = z
176
+ .object({
177
+ id: z.string().min(1).describe("The line item id. Recorded only."),
178
+ price: StripePrice.nullish().describe("What the line was sold at. Its id is the SKU the catalog maps."),
179
+ quantity: z.number().int().nullish().describe("How many were bought."),
180
+ })
181
+ .loose()
182
+ .describe("One line of a Checkout Session, when the caller expanded them.");
183
+
184
+ export const StripeCharge = z
185
+ .object({
186
+ id: z.string().min(1).describe("The charge id — `ch_…`. Recorded, and named in a note when nothing else is."),
187
+ payment_intent: z
188
+ .string()
189
+ .min(1)
190
+ .nullish()
191
+ .describe("The payment intent this charge settled — the purchase row's key, and the only link back to it."),
192
+ customer: z.string().min(1).nullish().describe("The Stripe customer charged — `cus_…`."),
193
+ amount: z.number().int().nullish().describe("What was charged, in the currency's minor unit."),
194
+ amount_refunded: z.number().int().nullish().describe("How much has been given back. Above zero is a refund."),
195
+ refunded: z.boolean().optional().describe("Whether the charge was refunded in full."),
196
+ currency: z.string().min(1).nullish().describe("The ISO currency, lower case."),
197
+ created: z.number().int().describe("When the charge was made, in seconds. The purchase's own date."),
198
+ livemode: z.boolean().optional().describe("Whether this came from a live key. False is a sandbox purchase."),
199
+ metadata: z
200
+ .record(z.string(), z.string())
201
+ .nullish()
202
+ .describe("What this deployment stamped on the payment intent, when Stripe carried it through."),
203
+ })
204
+ .loose()
205
+ .describe("A Stripe charge, as much of it as a refund needs.");
206
+ export type StripeCharge = z.infer<typeof StripeCharge>;
207
+
208
+ /**
209
+ * A payment intent, as a retrieve that expanded it reports it. Declared before the session because that is
210
+ * where it hangs, and it exists for one reason: the charge underneath it is the only object that says what
211
+ * became of the money.
212
+ */
213
+ const StripePaymentIntent = z
214
+ .object({
215
+ id: z.string().min(1).describe("The payment intent id — `pi_…`. A one-time purchase's transaction identity."),
216
+ status: z.string().min(1).nullish().describe("Stripe's own state for the intent. Recorded only."),
217
+ created: z.number().int().nullish().describe("When the intent was created, in seconds."),
218
+ latest_charge: z
219
+ .union([
220
+ z.string().min(1).describe("The charge id, when the caller did not expand it."),
221
+ StripeCharge.describe("The charge itself, expanded — which is what makes a refund visible on a retrieve."),
222
+ ])
223
+ .nullish()
224
+ .describe("The charge that settled this intent, by id or expanded. Expanded, it dates and refutes the sale."),
225
+ })
226
+ .loose()
227
+ .describe("A Stripe payment intent, as much of it as a one-time purchase's state needs.");
228
+
229
+ export const StripeCheckoutSession = z
230
+ .object({
231
+ id: z.string().min(1).describe("The session id — `cs_…`. What a returning browser carries back."),
232
+ mode: z
233
+ .string()
234
+ .min(1)
235
+ .describe("`payment`, `subscription`, or `setup`. Decides which shape of purchase, if any, this completed."),
236
+ status: z.string().min(1).nullish().describe("`open`, `complete`, or `expired`. Recorded only."),
237
+ payment_status: z
238
+ .string()
239
+ .min(1)
240
+ .nullish()
241
+ .describe("`paid`, `unpaid`, or `no_payment_required`. What decides whether a one-time purchase grants."),
242
+ amount_total: z.number().int().nullish().describe("What was charged, in the currency's minor unit."),
243
+ currency: z.string().min(1).nullish().describe("The ISO currency of `amount_total`, lower case."),
244
+ customer: z.string().min(1).nullish().describe("The Stripe customer the session created or reused — `cus_…`."),
245
+ client_reference_id: z
246
+ .string()
247
+ .min(1)
248
+ .nullish()
249
+ .describe(
250
+ "The reference this deployment set when it created the session — the encoded subject the buyer was acting for. The only hook from a Stripe purchase back to a holder, and it decodes strictly: anything that is not `user:<id>` or `organization:<id>` binds nobody.",
251
+ ),
252
+ payment_intent: z
253
+ .union([
254
+ z.string().min(1).describe("The payment intent id, as a webhook payload carries it."),
255
+ StripePaymentIntent.describe("The intent itself, when the caller expanded it on a retrieve."),
256
+ ])
257
+ .nullish()
258
+ .describe("The payment intent — `pi_…`. A one-time purchase's transaction identity, and what a refund names."),
259
+ subscription: z
260
+ .union([
261
+ z.string().min(1).describe("The subscription id, as a webhook payload carries it."),
262
+ StripeSubscription.describe("The subscription itself, when the caller expanded it on a retrieve."),
263
+ ])
264
+ .nullish()
265
+ .describe("The subscription this session started, by id or expanded."),
266
+ created: z.number().int().describe("When the session was created, in seconds. A one-time purchase's date."),
267
+ expires_at: z.number().int().nullish().describe("When an unpaid session lapses, in seconds. Recorded only."),
268
+ livemode: z.boolean().optional().describe("Whether this came from a live key. False is a sandbox purchase."),
269
+ metadata: z
270
+ .record(z.string(), z.string())
271
+ .nullish()
272
+ .describe("What this deployment stamped: the account reference, and the price the session was created for."),
273
+ line_items: z
274
+ .object({
275
+ data: z.array(StripeLineItem).describe("The expanded lines. Absent on every webhook payload."),
276
+ })
277
+ .loose()
278
+ .nullish()
279
+ .describe("The session's line items, present only when a retrieve expanded them."),
280
+ })
281
+ .loose()
282
+ .describe("A Stripe Checkout Session, as much of it as the projection reads.");
283
+ export type StripeCheckoutSession = z.infer<typeof StripeCheckoutSession>;
284
+
285
+ export const StripeEvent = z
286
+ .object({
287
+ id: z
288
+ .string()
289
+ .min(1)
290
+ .describe("The event id — `evt_…`. `UNIQUE (rail, providerEventId)` keys on it, so a redelivery is one row."),
291
+ type: z.string().min(1).describe("What happened — `customer.subscription.updated`. Decides which object this is."),
292
+ created: z
293
+ .number()
294
+ .int()
295
+ .describe(
296
+ "Stripe's own timestamp for the event, in seconds. This is the provider event time the monotonic write rule compares — never the object's own `created`, which would make every later event look staler than the first.",
297
+ ),
298
+ livemode: z.boolean().optional().describe("Whether the event came from a live key. Recorded; the object decides."),
299
+ api_version: z.string().min(1).nullish().describe("The API version the payload is shaped by. Recorded only."),
300
+ data: z
301
+ .object({
302
+ object: z
303
+ .record(z.string(), z.unknown())
304
+ .describe("The object the event is about. Parsed against its own schema once the type is known."),
305
+ })
306
+ .loose()
307
+ .describe("The event's payload."),
308
+ })
309
+ .loose()
310
+ .describe("A Stripe event, as delivered to a webhook endpoint.");
311
+ export type StripeEvent = z.infer<typeof StripeEvent>;
312
+
313
+ /**
314
+ * One Stripe object, mapped. The shape maps one-to-one onto a `VerifiedNotification`, so the rail is a
315
+ * signature check and this call.
316
+ */
317
+ export interface StripeMappedEvent {
318
+ /** The transaction, or null when the event reports no transaction state. Null is a success. */
319
+ event: UnboundProviderEvent | null;
320
+ /** The Stripe customer the object named, or null. What a later notification is resolved through. */
321
+ providerAccountId: string | null;
322
+ /** The reference this deployment stamped when it created the purchase, or null. */
323
+ accountReference: string | null;
324
+ /**
325
+ * Why there is no event, when that is worth recording on the webhook row. Null when nothing is missing.
326
+ *
327
+ * Always `stated` on this rail, and that is a property of where the mapping gets its facts: every note
328
+ * below is a sentence about the delivered event object, which Stripe expands into the webhook body. This
329
+ * rail makes no call to Stripe on the notification path, so it has no read that could have failed — see
330
+ * {@link NotificationNote}, and #341 for the three rails that do.
331
+ */
332
+ note: NotificationNote | null;
333
+ }
334
+
335
+ /** The event types this build acts on. Anything else is authentic and changes nothing. */
336
+ const HANDLED_TYPES: ReadonlySet<string> = new Set([
337
+ "checkout.session.completed",
338
+ "checkout.session.async_payment_succeeded",
339
+ "checkout.session.async_payment_failed",
340
+ "customer.subscription.created",
341
+ "customer.subscription.updated",
342
+ "customer.subscription.deleted",
343
+ "charge.refunded",
344
+ ]);
345
+
346
+ /**
347
+ * The normalized status for a Stripe subscription, given whether auto-renew is off.
348
+ *
349
+ * `cancel_at_period_end` is read **only** when the subscription is otherwise live. Stripe leaves the flag set on
350
+ * a subscription it has since ended, so reading it first would resurrect a finished subscription into a
351
+ * granting state.
352
+ */
353
+ export function stripeSubscriptionStatus(status: string, cancelAtPeriodEnd: boolean): PurchaseStatus {
354
+ const mapped = SUBSCRIPTION_STATUSES[status];
355
+ if (mapped === undefined) {
356
+ // Stripe could add a status, and the only safe answer to one we have never seen is to project nothing.
357
+ throw new PaymentsVerificationFailedError({
358
+ detail: `Stripe: subscription status "${status}" is not one this build maps. The purchase was left as it stood.`,
359
+ });
360
+ }
361
+ return cancelAtPeriodEnd && mapped === "active" ? "canceled" : mapped;
362
+ }
363
+
364
+ /** One Stripe subscription, normalized. */
365
+ export function mapStripeSubscription(subscription: StripeSubscription, eventAt: Date): StripeMappedEvent {
366
+ const item = subscription.items.data[0] as z.infer<typeof StripeSubscriptionItem>;
367
+ const status = stripeSubscriptionStatus(subscription.status, subscription.cancel_at_period_end === true);
368
+ const currency = item.price.currency ?? subscription.currency ?? null;
369
+ const periodEnd = item.current_period_end ?? subscription.current_period_end ?? null;
370
+ const periodStart = item.current_period_start ?? subscription.current_period_start ?? null;
371
+
372
+ return {
373
+ event: {
374
+ rail: "stripe",
375
+ // The invoice, not the subscription: each billing period is its own row. See the module doc.
376
+ providerTransactionId: subscription.latest_invoice ?? subscription.id,
377
+ providerProductId: item.price.id,
378
+ status,
379
+ environment: environment(subscription.livemode),
380
+ // The period this row is about, falling back to when the subscription began.
381
+ purchasedAt: seconds(periodStart ?? subscription.start_date ?? subscription.created),
382
+ expiresAt: periodEnd === null ? null : seconds(periodEnd),
383
+ // A Stripe subscription has no refunded state — a refund is an invoice's business, and access is ended by
384
+ // canceling. So nothing on this path is a revocation, and dating one would be inventing it.
385
+ revokedAt: null,
386
+ originalTransactionId: subscription.id,
387
+ amountMinor: minorUnits(item.price.unit_amount ?? null, currency),
388
+ currency,
389
+ providerEventAt: eventAt,
390
+ payload: subscription as Record<string, unknown>,
391
+ },
392
+ providerAccountId: subscription.customer ?? null,
393
+ accountReference: reference(subscription.metadata),
394
+ note: null,
395
+ };
396
+ }
397
+
398
+ /** What a session mapping needs beyond the object: whose clock timed it, and any status the type overrides. */
399
+ export interface StripeSessionOptions {
400
+ /**
401
+ * Stripe's own timestamp for the event that carried this session, or **null on a retrieve** — a retrieve is
402
+ * not an event and Stripe times it with nothing. Null is what makes the mapping derive a date instead of
403
+ * being handed one; see {@link retrievedAt}.
404
+ */
405
+ eventAt: Date | null;
406
+ /** The clock. It dates what a retrieve *found*, and never a snapshot's claim to access. */
407
+ now: Date;
408
+ /** A status the event type knows and the object does not — an async payment that failed. */
409
+ statusOverride?: PurchaseStatus | null;
410
+ }
411
+
412
+ /** One Checkout Session, normalized. Returns no event for the shapes that bought nothing here. */
413
+ export function mapStripeSession(session: StripeCheckoutSession, options: StripeSessionOptions): StripeMappedEvent {
414
+ const providerAccountId = session.customer ?? null;
415
+ const accountReference = session.client_reference_id ?? reference(session.metadata);
416
+ const link = { providerAccountId, accountReference, note: null };
417
+
418
+ if (session.mode === "subscription") {
419
+ // Expanded on the retrieve path, a bare id on the webhook path. Unexpanded is not a gap: the subscription's
420
+ // own events carry the state, and this session's job was to pair the customer with the purchaser.
421
+ if (typeof session.subscription !== "object" || session.subscription === null) return { event: null, ...link };
422
+ // The clock is honest here, and this is the one asymmetry with a one-time session: Stripe expands the
423
+ // subscription *object*, whose `status` is what it is right now, so a read is the freshest fact anyone holds.
424
+ const mapped = mapStripeSubscription(session.subscription, options.eventAt ?? options.now);
425
+ // The session's reference wins: it is the one this deployment set for this checkout.
426
+ return { ...mapped, providerAccountId: providerAccountId ?? mapped.providerAccountId, accountReference };
427
+ }
428
+
429
+ // A `setup` session stores a card and buys nothing. Nothing to project, and nothing missing.
430
+ if (session.mode !== "payment") return { event: null, ...link };
431
+
432
+ const priceId = session.line_items?.data[0]?.price?.id ?? session.metadata?.[STRIPE_METADATA_PRICE];
433
+ const intent = session.payment_intent ?? null;
434
+ const transactionId = (typeof intent === "string" ? intent : intent?.id) ?? session.id;
435
+ if (priceId === undefined) {
436
+ return {
437
+ event: null,
438
+ ...link,
439
+ note: {
440
+ stated: `stripe: Checkout Session ${session.id} (payment intent ${transactionId}) names no price — it carries no ${STRIPE_METADATA_PRICE} metadata and no expanded line items, so it was not created by this deployment.`,
441
+ },
442
+ };
443
+ }
444
+
445
+ // The charge, when a retrieve expanded it. `payment_status` says `paid` for ever whatever became of the money;
446
+ // the charge is the only object on this path that knows about a refund.
447
+ const charge = typeof intent === "object" && typeof intent?.latest_charge === "object" ? intent.latest_charge : null;
448
+ const refunded = charge !== null && chargeIsRefunded(charge);
449
+ const status = options.statusOverride ?? (refunded ? "refunded" : sessionStatus(session.payment_status ?? null));
450
+ const currency = session.currency ?? null;
451
+
452
+ return {
453
+ event: {
454
+ rail: "stripe",
455
+ // The payment intent, not the session: a refund names the payment intent and nothing else.
456
+ providerTransactionId: transactionId,
457
+ providerProductId: priceId,
458
+ status,
459
+ environment: environment(session.livemode),
460
+ purchasedAt: seconds(session.created),
461
+ // A one-time purchase never lapses. That is the whole difference between it and a subscription.
462
+ expiresAt: null,
463
+ // Stripe does not date a refund on the charge. When we found one on a read, now is when we learned of it.
464
+ revokedAt: refunded ? (options.eventAt ?? options.now) : null,
465
+ originalTransactionId: null,
466
+ amountMinor: minorUnits(session.amount_total ?? null, currency),
467
+ currency,
468
+ providerEventAt: options.eventAt ?? retrievedAt(session, charge, status, options.now),
469
+ payload: session as Record<string, unknown>,
470
+ },
471
+ ...link,
472
+ };
473
+ }
474
+
475
+ /**
476
+ * The provider event time for a one-time session **a retrieve found**, where Stripe timed nothing.
477
+ *
478
+ * A completed Checkout Session is a frozen snapshot: `payment_status` reads `paid` for ever, refund or no
479
+ * refund, and Stripe expands nothing about a refund unless asked. Dating that snapshot with our clock is what
480
+ * let a buyer re-post the `cs_…` id from her success URL after a refund had landed — the session names her, so
481
+ * every owner check passes — and outrank the refund under the monotonic write rule, re-granting for good.
482
+ *
483
+ * So the rule splits on what the snapshot claims. **A claim to access is dated by Stripe's own clock**: the
484
+ * charge that moved the money, or failing that the session's creation. Such a date cannot beat a refund that
485
+ * followed it, by construction. **Anything that claims nothing is dated now**, because a status that grants
486
+ * nothing can resurrect nothing, and a refund we found on the read must be able to land on a row the
487
+ * completion webhook already wrote at the same second the charge carries.
488
+ */
489
+ function retrievedAt(
490
+ session: StripeCheckoutSession,
491
+ charge: StripeCharge | null,
492
+ status: PurchaseStatus,
493
+ now: Date,
494
+ ): Date {
495
+ if (!ACCESS_GRANTING_STATUSES.has(status)) return now;
496
+ return seconds(charge?.created ?? session.created);
497
+ }
498
+
499
+ /** One refunded charge, normalized onto the purchase its payment intent bought. */
500
+ export function mapStripeCharge(charge: StripeCharge, options: { eventAt: Date }): StripeMappedEvent {
501
+ const link = { providerAccountId: charge.customer ?? null, accountReference: reference(charge.metadata) };
502
+
503
+ if (charge.payment_intent === null || charge.payment_intent === undefined) {
504
+ // Nothing identifies which purchase this refunds. A charge with no payment intent predates Payment Intents
505
+ // or was created directly, and neither is a purchase this package made.
506
+ return {
507
+ event: null,
508
+ ...link,
509
+ note: {
510
+ stated: `stripe: charge ${charge.id} names no payment intent, so there is no purchase row it can be matched to.`,
511
+ },
512
+ };
513
+ }
514
+
515
+ const priceId = charge.metadata?.[STRIPE_METADATA_PRICE];
516
+ if (priceId === undefined) {
517
+ // The bounded gap, recorded rather than guessed. The purchase row is already keyed on the payment intent, so
518
+ // the reconciliation pass finds it from this note with one query.
519
+ return {
520
+ event: null,
521
+ ...link,
522
+ note: {
523
+ stated: `stripe: charge ${charge.id} carries no ${STRIPE_METADATA_PRICE} metadata, so the refund of payment intent ${charge.payment_intent} could not be projected.`,
524
+ },
525
+ };
526
+ }
527
+
528
+ const refunded = chargeIsRefunded(charge);
529
+ const currency = charge.currency ?? null;
530
+
531
+ return {
532
+ event: {
533
+ rail: "stripe",
534
+ providerTransactionId: charge.payment_intent,
535
+ providerProductId: priceId,
536
+ status: refunded ? "refunded" : "active",
537
+ environment: environment(charge.livemode),
538
+ purchasedAt: seconds(charge.created),
539
+ expiresAt: null,
540
+ // Stripe dates a refund by the event that reported it; the charge's own `created` is when it was paid.
541
+ revokedAt: refunded ? options.eventAt : null,
542
+ originalTransactionId: null,
543
+ amountMinor: minorUnits(charge.amount ?? null, currency),
544
+ currency,
545
+ providerEventAt: options.eventAt,
546
+ payload: charge as Record<string, unknown>,
547
+ },
548
+ ...link,
549
+ note: null,
550
+ };
551
+ }
552
+
553
+ /**
554
+ * One Stripe event, mapped — the whole of "which event types this build acts on", in one place.
555
+ *
556
+ * An unhandled type produces nothing at all, with no note: `invoice.paid` and its neighbors are authentic and
557
+ * duplicate what the subscription events already report, so throwing would make Stripe retry forever and a note
558
+ * would fill the table with rows nobody acts on. The delivery is still recorded — that is the guard's job — so a
559
+ * reconciliation pass can see everything that arrived.
560
+ */
561
+ export function mapStripeEvent(event: StripeEvent): StripeMappedEvent {
562
+ const eventAt = seconds(event.created);
563
+ if (!HANDLED_TYPES.has(event.type)) {
564
+ return { event: null, providerAccountId: null, accountReference: null, note: null };
565
+ }
566
+
567
+ if (event.type === "charge.refunded") {
568
+ return mapStripeCharge(parseObject(StripeCharge, event, "a charge"), { eventAt });
569
+ }
570
+
571
+ if (event.type.startsWith("customer.subscription.")) {
572
+ return mapStripeSubscription(parseObject(StripeSubscription, event, "a subscription"), eventAt);
573
+ }
574
+
575
+ return mapStripeSession(parseObject(StripeCheckoutSession, event, "a Checkout Session"), {
576
+ eventAt,
577
+ now: eventAt,
578
+ // The one type whose meaning is not in the object: Stripe reports a failed asynchronous payment by event
579
+ // type, leaving `payment_status` exactly as it was. `never_paid`, not `expired` — the debit bounced, so no
580
+ // money ever cleared and a `grants` clause must not credit it.
581
+ statusOverride: event.type === "checkout.session.async_payment_failed" ? "never_paid" : null,
582
+ });
583
+ }
584
+
585
+ /**
586
+ * Whether money went back on this charge. A part-refunded purchase is one somebody got money back for: access
587
+ * is not divisible, and the catalog's clawback policy is what decides whether a balance follows.
588
+ *
589
+ * One predicate for both paths — the `charge.refunded` event and the charge a retrieve expanded — because a
590
+ * refund that one path recognized and the other did not is a purchase whose access depends on which delivery
591
+ * arrived first.
592
+ */
593
+ function chargeIsRefunded(charge: StripeCharge): boolean {
594
+ return charge.refunded === true || (charge.amount_refunded ?? 0) > 0;
595
+ }
596
+
597
+ /** A one-time session's status, from whether the money actually moved. */
598
+ function sessionStatus(paymentStatus: string | null): PurchaseStatus {
599
+ // `unpaid` is a bank debit or a voucher still settling — days, sometimes. It has not been bought yet, but it
600
+ // still might be, which is what `on_hold` says.
601
+ return paymentStatus === "paid" || paymentStatus === "no_payment_required" ? "active" : "on_hold";
602
+ }
603
+
604
+ /** Parse an event's object against the schema its type promises, or refuse the delivery. */
605
+ function parseObject<T extends z.ZodType>(schema: T, event: StripeEvent, what: string): z.output<T> {
606
+ const parsed = schema.safeParse(event.data.object);
607
+ if (!parsed.success) {
608
+ // Never echo the object: it is a customer's purchase, and it carries their Stripe identifiers.
609
+ throw new PaymentsVerificationFailedError({
610
+ detail: `Stripe: the object on ${event.type} is not ${what} — ${parsed.error.issues
611
+ .map((issue) => `${issue.path.join(".") || "<root>"}:${issue.code}`)
612
+ .join(", ")}.`,
613
+ });
614
+ }
615
+ return parsed.data as z.output<T>;
616
+ }
617
+
618
+ /** `livemode` is the whole environment decision. Anything but a live object is sandbox. */
619
+ function environment(livemode: boolean | undefined): PurchaseEnvironment {
620
+ return livemode === true ? "production" : "sandbox";
621
+ }
622
+
623
+ /** One Stripe timestamp — seconds since the epoch — as a `Date`. */
624
+ function seconds(value: number): Date {
625
+ const date = new Date(value * 1000);
626
+ if (Number.isNaN(date.getTime())) {
627
+ // An Invalid Date encodes as NaN and loses every comparison the monotonic write rule makes.
628
+ throw new PaymentsVerificationFailedError({ detail: `Stripe: ${value} is not a timestamp in seconds.` });
629
+ }
630
+ return date;
631
+ }
632
+
633
+ /** An amount Stripe reported, in minor units. Stripe already reports minor units; the exponent guards the rest. */
634
+ function minorUnits(amount: number | null, currency: string | null): number | null {
635
+ if (currency === null) return null;
636
+ return minorUnitsFromScaled(amount, 10 ** minorUnitDigits(currency), currency);
637
+ }
638
+
639
+ /** The account reference this deployment stamped, or null. */
640
+ function reference(metadata: Record<string, string> | null | undefined): string | null {
641
+ return metadata?.[STRIPE_METADATA_ACCOUNT_REFERENCE] ?? null;
642
+ }