@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,326 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PaymentsVerificationFailedError } from "../../error/errors";
5
+ import type { PaymentsLemonSqueezyCredentials } from "../../secret/registry";
6
+ import type { UnboundProviderEvent, VerifiedNotification, WebhookDelivery } from "../contract";
7
+ import { type LemonSqueezyHttpFetch, lemonSqueezyHttpFetch } from "./api";
8
+ import {
9
+ accountReferenceOf,
10
+ fencedOut,
11
+ invoiceEvent,
12
+ LemonSqueezyOrder,
13
+ LemonSqueezySubscription,
14
+ LemonSqueezySubscriptionInvoice,
15
+ LemonSqueezyWebhook,
16
+ namespacedId,
17
+ orderEvent,
18
+ subscriptionEvent,
19
+ } from "./objects";
20
+ import { readSubscription } from "./read";
21
+ import { verifyLemonSqueezySignature } from "./signature";
22
+
23
+ /**
24
+ * Verify and read one Lemon Squeezy delivery.
25
+ *
26
+ * ## The event id, and where replay protection lives
27
+ *
28
+ * Lemon Squeezy does not put an event id in the body, so the id this rail reports is composed from what the
29
+ * **signed bytes** always carry: `meta.event_name` and the object it is about. That is stable across
30
+ * redeliveries of the same event — which is the whole property `UNIQUE (rail, providerEventId)` needs — and
31
+ * different for two genuinely different events about one object.
32
+ *
33
+ * **Nothing outside the signed body may contribute to it.** The HMAC covers the body alone, so the
34
+ * `X-Event-Name` header Lemon Squeezy also sends is attacker-controlled on a replayed delivery; composing the
35
+ * id from it would let one captured body be resent under any name, defeating the only replay defense this
36
+ * rail has. Every other rail derives its id from signed content too — Stripe's `event.id`, Apple's
37
+ * `notificationUUID` from inside the JWS — and this one is no exception.
38
+ *
39
+ * It is deliberately *not* unique per delivery attempt. A redelivery must collide, because colliding is how
40
+ * the guard recognizes it and answers 200 without reprojecting.
41
+ *
42
+ * ## Why an invoice event costs a round-trip
43
+ *
44
+ * A Lemon Squeezy subscription invoice carries no `variant_id`, and `providerProductId` is mandatory — the
45
+ * writer resolves the catalog product from it and refuses without one. So an invoice-domain delivery reads
46
+ * its subscription to learn which variant it bills. That is the Google rail's shape exactly, and it is why
47
+ * `payments/provider_unavailable` passes through the webhook guard unchanged: an outage at Lemon Squeezy
48
+ * must make the store redeliver, not make the operator hunt for a rotated signing key.
49
+ *
50
+ * Subscription-domain and order-domain deliveries carry everything they need and cost nothing.
51
+ */
52
+
53
+ /** What the parser needs: the credentials, the deployment doing the asking, and the transport. */
54
+ export interface ParseLemonSqueezyNotificationOptions {
55
+ /** The rail's credentials — the signing secret is read from here and never logged. */
56
+ credentials: PaymentsLemonSqueezyCredentials;
57
+ /** This deployment's `ENVIRONMENT`, for the shared-store fence. */
58
+ deployment?: string;
59
+ /** The transport the subscription read goes through. Defaults to the runtime's `fetch`. */
60
+ transport?: LemonSqueezyHttpFetch;
61
+ /**
62
+ * Whether this variant is sold as a subscription, per the adopter's catalog.
63
+ *
64
+ * The rail cannot answer it — a Lemon Squeezy order carries no subscription marker — and it must not
65
+ * guess, because guessing wrong either double-credits a subscriber's first period or drops a one-off
66
+ * sale. `resolveRailProvider` has the config, so it supplies the answer. Absent means "assume one-off",
67
+ * which is the behavior for a project whose catalog the rail was built without.
68
+ */
69
+ sellsSubscription?: (variantId: string) => boolean;
70
+ }
71
+
72
+ /**
73
+ * This delivery's id: the event name, the object, and **the object's own clock**.
74
+ *
75
+ * The clock is the part that makes it an *event* id rather than an object id, and leaving it out was a
76
+ * defect rather than a simplification. `subscription_updated` fires on every renewal, plan change, pause and
77
+ * status move for one subscription, so `subscription_updated:90001` names all of them. The guard inserts
78
+ * `UNIQUE (rail, providerEventId)` and answers a row it has already processed with a 200 and no reprojection
79
+ * — so the first update would be projected and **every later one silently dropped as a replay**, including
80
+ * the cancellation.
81
+ *
82
+ * `updated_at` moves with each of those changes and is inside the signed bytes, so two genuinely different
83
+ * events differ and a redelivery of one event still collides, which is exactly what the guard needs. Where
84
+ * the object carries no clock the id falls back to the pair, which is no worse than before.
85
+ *
86
+ * Not `receivedAt` or a random value: a redelivery **must** collide, because colliding is how a store's
87
+ * at-least-once retry is recognized rather than reprocessed.
88
+ */
89
+ function composeEventId(name: string, webhook: LemonSqueezyWebhook): string {
90
+ const clock = webhook.data.attributes.updated_at;
91
+ return typeof clock === "string" && clock !== ""
92
+ ? `${name}:${webhook.data.id}:${clock}`
93
+ : `${name}:${webhook.data.id}`;
94
+ }
95
+
96
+ /** Every subscription-domain event: the standing changed, and no money moved. */
97
+ const SUBSCRIPTION_EVENTS: ReadonlySet<string> = new Set([
98
+ "subscription_created",
99
+ "subscription_updated",
100
+ "subscription_cancelled",
101
+ "subscription_expired",
102
+ "subscription_paused",
103
+ "subscription_unpaused",
104
+ ]);
105
+
106
+ /** Every invoice-domain event: money moved, and the standing is not in the payload. */
107
+ const INVOICE_EVENTS: ReadonlySet<string> = new Set([
108
+ "subscription_payment_success",
109
+ "subscription_payment_failed",
110
+ "subscription_payment_recovered",
111
+ "subscription_payment_refunded",
112
+ ]);
113
+
114
+ /** Every order-domain event: a one-off, where money and state are the same object. */
115
+ const ORDER_EVENTS: ReadonlySet<string> = new Set(["order_created", "order_refunded"]);
116
+
117
+ /** Nothing at all was learned, but the delivery was authentic. The four-field null tuple every rail returns. */
118
+ function nothing(webhook: LemonSqueezyWebhook, providerEventId: string): VerifiedNotification {
119
+ return {
120
+ providerEventId,
121
+ payload: { ...webhook },
122
+ event: null,
123
+ providerAccountId: null,
124
+ accountReference: null,
125
+ // Null, and it matters: a note routes the route into an audit warning, and a delivery that is simply
126
+ // not ours — or an event type shipped after this package — is not a thing to warn an operator about.
127
+ note: null,
128
+ };
129
+ }
130
+
131
+ /** Verify a delivery's signature and read what it says. */
132
+ export async function parseLemonSqueezyNotification(
133
+ delivery: WebhookDelivery,
134
+ options: ParseLemonSqueezyNotificationOptions,
135
+ ): Promise<VerifiedNotification> {
136
+ await verifyLemonSqueezySignature(
137
+ delivery.body,
138
+ delivery.headers.get("x-signature"),
139
+ options.credentials.webhookSecret,
140
+ );
141
+
142
+ let parsed: unknown;
143
+ try {
144
+ parsed = JSON.parse(delivery.body) as unknown;
145
+ } catch (cause) {
146
+ throw new PaymentsVerificationFailedError({ detail: "Lemon Squeezy: the delivery body is not JSON." }, { cause });
147
+ }
148
+
149
+ const envelope = LemonSqueezyWebhook.safeParse(parsed);
150
+ if (!envelope.success) {
151
+ throw new PaymentsVerificationFailedError({
152
+ detail: "Lemon Squeezy: the delivery is not a webhook envelope — no meta.event_name, or no data object.",
153
+ });
154
+ }
155
+ const webhook = envelope.data;
156
+
157
+ // **The signed body, and never the header.** The HMAC covers `delivery.body` and nothing else, so every
158
+ // header on the request is unauthenticated attacker input — including `X-Event-Name`, which Lemon Squeezy
159
+ // also sends. Preferring it would hand a replay adversary two things at once: `name` composes
160
+ // `providerEventId`, which is this rail's entire replay defense, and it selects the domain handler,
161
+ // including the refund branch that revokes a subscription. One captured authentic body could then be
162
+ // resent under any event name, evading the guard's UNIQUE insert and steering the handler.
163
+ //
164
+ // `meta.event_name` is inside the signed bytes and is required by the envelope schema, so it is always
165
+ // there to use. If the header is ever wanted, it may only be a cross-check that *refuses* on disagreement.
166
+ const name = webhook.meta.event_name;
167
+ const providerEventId = composeEventId(name, webhook);
168
+
169
+ // Another deployment's buyer, on the store we share with it. Authentic, recorded, and none of our business.
170
+ if (fencedOut(webhook, options.deployment)) return nothing(webhook, providerEventId);
171
+
172
+ if (SUBSCRIPTION_EVENTS.has(name)) return await subscriptionNotification(webhook, providerEventId, options);
173
+ if (INVOICE_EVENTS.has(name)) return await invoiceNotification(webhook, providerEventId, name, options);
174
+ if (ORDER_EVENTS.has(name)) return await orderNotification(webhook, providerEventId, options);
175
+
176
+ // A type Lemon Squeezy shipped after this package did — a license key, an affiliate payout. Authentic,
177
+ // recorded, and projecting nothing. Never a throw: the store would redeliver it forever.
178
+ return nothing(webhook, providerEventId);
179
+ }
180
+
181
+ /** A subscription-domain delivery: one state row, no round-trip. */
182
+ async function subscriptionNotification(
183
+ webhook: LemonSqueezyWebhook,
184
+ providerEventId: string,
185
+ options: ParseLemonSqueezyNotificationOptions,
186
+ ): Promise<VerifiedNotification> {
187
+ const subscription = LemonSqueezySubscription.parse(webhook.data.attributes);
188
+ return {
189
+ providerEventId,
190
+ payload: { ...webhook },
191
+ event: subscriptionEvent(webhook.data.id, subscription),
192
+ providerAccountId: subscription.customer_id === undefined ? null : String(subscription.customer_id),
193
+ accountReference: await accountReferenceOf(webhook, options.deployment, options.credentials.webhookSecret),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * An order-domain delivery: one row, money and state together — **unless the order started a subscription**.
199
+ *
200
+ * A payment received is what creates a charge row, and one payment must create exactly one. Lemon Squeezy
201
+ * raises an Order for a subscription purchase as well as the subscription itself, and it raises a
202
+ * subscription invoice for every billing period *including the first* — so for a subscription the order and
203
+ * the first invoice are the **same money**, reported twice. Projecting both credits one period twice, and
204
+ * the order's row would grant forever besides: an order is a one-off, so it carries no expiry, and a
205
+ * never-expiring `active` row outranks the subscription's state row even after a cancellation.
206
+ *
207
+ * A subscription's money is its invoices. So an order that started one records nothing here and says why,
208
+ * and the subscription's own events carry both halves.
209
+ *
210
+ * The discriminator is the **catalog**, not a round-trip: a Lemon Squeezy variant is a subscription variant
211
+ * or a one-off variant and never both, so the product an adopter declared for that variant already answers
212
+ * it. `resolveRailProvider` has the config, so the rail is handed the question rather than guessing at it or
213
+ * paying for an extra call.
214
+ */
215
+ async function orderNotification(
216
+ webhook: LemonSqueezyWebhook,
217
+ providerEventId: string,
218
+ options: ParseLemonSqueezyNotificationOptions,
219
+ ): Promise<VerifiedNotification> {
220
+ const order = LemonSqueezyOrder.parse(webhook.data.attributes);
221
+ const variantId = String(order.first_order_item?.variant_id ?? "");
222
+
223
+ if (options.sellsSubscription?.(variantId) === true) {
224
+ return {
225
+ providerEventId,
226
+ payload: { ...webhook },
227
+ event: null,
228
+ // The customer is still worth learning, and the reference still binds them: this is the delivery that
229
+ // carries the account this deployment stamped at checkout, and dropping it would orphan the
230
+ // subscription's own events that arrive without one.
231
+ providerAccountId: order.customer_id === undefined ? null : String(order.customer_id),
232
+ accountReference: await accountReferenceOf(webhook, options.deployment, options.credentials.webhookSecret),
233
+ // Null: this is ordinary, expected traffic for a subscription sale, not something to warn about.
234
+ note: null,
235
+ };
236
+ }
237
+
238
+ return {
239
+ providerEventId,
240
+ payload: { ...webhook },
241
+ event: orderEvent(webhook.data.id, order),
242
+ providerAccountId: order.customer_id === undefined ? null : String(order.customer_id),
243
+ accountReference: await accountReferenceOf(webhook, options.deployment, options.credentials.webhookSecret),
244
+ };
245
+ }
246
+
247
+ /**
248
+ * An invoice-domain delivery: one money row, and on a refund a second event revoking access.
249
+ *
250
+ * The subscription read is needed anyway — the invoice carries no variant — so the refund's state event
251
+ * costs no extra call beyond the one the variant already required.
252
+ */
253
+ async function invoiceNotification(
254
+ webhook: LemonSqueezyWebhook,
255
+ providerEventId: string,
256
+ name: string,
257
+ options: ParseLemonSqueezyNotificationOptions,
258
+ ): Promise<VerifiedNotification> {
259
+ const invoice = LemonSqueezySubscriptionInvoice.parse(webhook.data.attributes);
260
+ const subscriptionId = String(invoice.subscription_id);
261
+ const subscription = await readSubscription(subscriptionId, {
262
+ credentials: options.credentials,
263
+ transport: options.transport ?? lemonSqueezyHttpFetch,
264
+ });
265
+
266
+ // The store no longer knows the subscription this invoice bills. Authentic, unprojectable, and worth an
267
+ // operator's attention — so this is the one place on this rail that does set a note.
268
+ //
269
+ // **`read`, not `stated` (#341).** `readSubscription` answers `undefined` on a 404, and a 404 here is not
270
+ // only "deleted": an invoice webhook can outrun the subscription's own visibility, and a key scoped to the
271
+ // wrong store answers the same way. So this finishes nothing. The row stays repairable, and Lemon Squeezy's
272
+ // redelivery — which it makes for three days — runs the handler again against a store that may now answer.
273
+ if (subscription === undefined) {
274
+ return {
275
+ providerEventId,
276
+ payload: { ...webhook },
277
+ event: null,
278
+ providerAccountId: invoice.customer_id === undefined ? null : String(invoice.customer_id),
279
+ accountReference: await accountReferenceOf(webhook, options.deployment, options.credentials.webhookSecret),
280
+ note: {
281
+ read: `Lemon Squeezy no longer knows subscription ${subscriptionId}, which invoice ${webhook.data.id} bills.`,
282
+ },
283
+ };
284
+ }
285
+
286
+ const event = invoiceEvent(webhook.data.id, invoice, String(subscription.variant_id ?? ""));
287
+
288
+ return {
289
+ providerEventId,
290
+ payload: { ...webhook },
291
+ event,
292
+ providerAccountId: invoice.customer_id === undefined ? null : String(invoice.customer_id),
293
+ accountReference: await accountReferenceOf(webhook, options.deployment, options.credentials.webhookSecret),
294
+ stateEvent: name === "subscription_payment_refunded" ? revocation(subscriptionId, subscription, event) : null,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * The state event a refund implies: access stops, now.
300
+ *
301
+ * Deliberately **not** the subscription's current status. Lemon Squeezy is a merchant of record and refunds
302
+ * on its own — for a chargeback, a tax dispute, or its own support decision — and it does not necessarily
303
+ * cancel the subscription when it does, so reading its status back would frequently say `active` and leave a
304
+ * refunded buyer holding the feature. The money went back; the access goes with it.
305
+ *
306
+ * `revoked` rather than `refunded` because the two say different things about this row: the *invoice* was
307
+ * refunded, and the subscription's standing was revoked as a consequence. Neither status grants, and
308
+ * `revoked` on a `state` row never claws back — that is the money row's job, on the row that credited.
309
+ */
310
+ function revocation(
311
+ subscriptionId: string,
312
+ subscription: ReturnType<typeof LemonSqueezySubscription.parse>,
313
+ invoice: UnboundProviderEvent,
314
+ ): UnboundProviderEvent {
315
+ return {
316
+ ...subscriptionEvent(subscriptionId, subscription),
317
+ providerTransactionId: namespacedId("subscription", subscriptionId),
318
+ status: "revoked",
319
+ revokedAt: invoice.providerEventAt,
320
+ expiresAt: invoice.providerEventAt,
321
+ // The refund's own clock, not the subscription's. The subscription object was last touched before the
322
+ // refund happened, so its `updated_at` would be older than the row it is trying to move and the
323
+ // monotonic rule would discard the revocation entirely.
324
+ providerEventAt: invoice.providerEventAt,
325
+ };
326
+ }
@@ -0,0 +1,94 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { minorAmount, type PaddleAdjustment, type PaddleTransaction } from "./objects";
5
+
6
+ /**
7
+ * What a transaction has had taken back off it — every approved adjustment, not just the one in hand.
8
+ *
9
+ * ## Why one adjustment's own total is not the answer
10
+ *
11
+ * Paddle raises an adjustment per refund, not per transaction. A seller refunding 99.00 in two goes
12
+ * raises **two** approved `refund` adjustments of 49.50, and neither one on its own reaches the
13
+ * transaction's grand total. A comparison against a single adjustment therefore calls both of them
14
+ * partial, revokes nothing, and leaves a fully refunded customer holding what they bought.
15
+ *
16
+ * So the comparison is against the **sum**, and the sum comes from Paddle: `GET /transactions/{id}` with
17
+ * `include=adjustments` returns every adjustment raised against it. See `read.ts`.
18
+ *
19
+ * ## Why the adjustment in hand is merged in by id rather than trusted to be in the list
20
+ *
21
+ * The delivery that carries an `adjustment.created` can reach us before that adjustment is visible on the
22
+ * transaction's own include, and Paddle makes no ordering promise between the two. Merging by id is
23
+ * therefore both safe and necessary: present in the list, the in-hand copy replaces a staler one; absent,
24
+ * it is added, and the very refund being processed still counts toward its own total.
25
+ *
26
+ * ## Why an unreadable amount is null and never zero
27
+ *
28
+ * A figure this build cannot parse is not evidence of a small refund. Summing it as zero would call a full
29
+ * refund partial; summing it as the total would revoke an entitlement on a guess. Null, and the caller
30
+ * says so in a note an operator can act on.
31
+ */
32
+
33
+ /**
34
+ * The adjustment actions that revoke an entitlement when approved, and the ones that do not.
35
+ *
36
+ * `chargeback_warning` and its **reverse** are both here as stated rows rather than as an omission — the
37
+ * live `action` enum carries `chargeback_warning_reverse`, which the issue's table does not name. A
38
+ * warning is a notice that a dispute has been opened, not a decision, so neither moves anything. A
39
+ * `credit` against a balance is not a revocation either.
40
+ */
41
+ export const REVOKING_ACTIONS: ReadonlySet<string> = new Set(["refund", "chargeback"]);
42
+
43
+ /** The adjustment actions that restore what a revocation took. */
44
+ export const RESTORING_ACTIONS: ReadonlySet<string> = new Set(["chargeback_reverse"]);
45
+
46
+ /** What every approved revoking adjustment against one transaction comes to, and what that means. */
47
+ export interface AdjustmentTally {
48
+ /**
49
+ * Every approved revoking adjustment against the transaction, summed, in the currency's lowest
50
+ * denomination — or null when any one of them carries an amount this build could not read.
51
+ */
52
+ revokedMinor: number | null;
53
+ /** The transaction's own grand total, or null when this build could not read one. */
54
+ totalMinor: number | null;
55
+ /** How many adjustments the sum counted. Two is the case a single-adjustment comparison gets wrong. */
56
+ counted: number;
57
+ /**
58
+ * Whether what has been taken back covers the whole transaction.
59
+ *
60
+ * False whenever either figure is null, deliberately: a full refund is a claim, and an unreadable amount
61
+ * is not evidence for it.
62
+ */
63
+ full: boolean;
64
+ }
65
+
66
+ /** Sum every approved revoking adjustment against this transaction, the one in hand included. */
67
+ export function tallyAdjustments(adjustment: PaddleAdjustment, transaction: PaddleTransaction): AdjustmentTally {
68
+ // Merged by id: the in-hand copy is the fresher of the two, and it counts whether or not Paddle's
69
+ // include has caught up with it yet.
70
+ const byId = new Map<string, PaddleAdjustment>();
71
+ for (const other of transaction.adjustments ?? []) byId.set(other.id, other);
72
+ byId.set(adjustment.id, adjustment);
73
+
74
+ let revokedMinor: number | null = 0;
75
+ let counted = 0;
76
+ for (const one of byId.values()) {
77
+ if (one.status !== "approved" || !REVOKING_ACTIONS.has(one.action)) continue;
78
+ counted += 1;
79
+ const amount = minorAmount(one.totals?.total);
80
+ if (amount === null) {
81
+ revokedMinor = null;
82
+ continue;
83
+ }
84
+ if (revokedMinor !== null) revokedMinor += amount;
85
+ }
86
+
87
+ const totalMinor = minorAmount(transaction.details?.totals?.grand_total);
88
+ return {
89
+ revokedMinor,
90
+ totalMinor,
91
+ counted,
92
+ full: totalMinor !== null && revokedMinor !== null && revokedMinor >= totalMinor,
93
+ };
94
+ }