@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,120 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PurchaseEnvironment } from "../../data/purchase";
5
+ import { PaymentsInvalidReceiptError, PaymentsVerificationFailedError } from "../../error/errors";
6
+ import type { PaymentsPaddleCredentials } from "../../secret/registry";
7
+ import type { VerifiedPurchase } from "../contract";
8
+ import type { PaddleEnvironment, PaddleHttpFetch } from "./api";
9
+ import { accountReferenceOf, PADDLE_CUSTOM_ACCOUNT, transactionEvent } from "./objects";
10
+ import { readTransaction } from "./read";
11
+
12
+ /**
13
+ * Verifying a transaction id a returning buyer handed back — and why this rail has one where Lemon
14
+ * Squeezy does not.
15
+ *
16
+ * ## What makes a submitted `txn_…` trustworthy here
17
+ *
18
+ * Not that it is unguessable. Lemon Squeezy's order UUID is unguessable too, and `verify.ts` there
19
+ * refuses it at length: an unguessable-but-unauthenticated identifier lets a client's choice of value
20
+ * decide who a purchase belongs to.
21
+ *
22
+ * What makes this one safe is the **check against the caller**, not the id. The transaction is read from
23
+ * Paddle, and its `custom_data.pithy_user` — a value this deployment's own server wrote, with a MAC only
24
+ * this deployment could produce — has to name the authenticated caller. A submitted id that names somebody
25
+ * else is refused, and one carrying no proven reference is refused too. So the id is a *pointer*; the
26
+ * authorization is the stamp.
27
+ *
28
+ * ## Why the rail needs it at all
29
+ *
30
+ * `dev` is not publicly routable, so a dev checkout's webhooks land at `staging` and the dev deployment
31
+ * never hears about its own purchase. The overlay's `checkout.completed` callback hands the browser a
32
+ * `txn_…`; the screen posts it here; this binds it. That is what makes local development work, and it is
33
+ * why `verify` is not optional on this rail the way it is on Lemon Squeezy.
34
+ *
35
+ * It is also the ordinary web nicety: a buyer sees their entitlement the moment the overlay closes rather
36
+ * than when the webhook lands. The webhook stays authoritative, produces the identical row, and the write
37
+ * is idempotent on `(rail, providerTransactionId)`.
38
+ */
39
+
40
+ /** What verifying a submitted transaction needs. */
41
+ export interface VerifyPaddleTransactionOptions {
42
+ /** The rail's credentials. `webhookSecret` keys the ownership proof; `apiKey` reads the transaction. */
43
+ credentials: PaymentsPaddleCredentials;
44
+ /** Which Paddle account to read from. */
45
+ environment: PaddleEnvironment;
46
+ /** This deployment's `ENVIRONMENT`. Without it no reference is trusted, so no submission is accepted. */
47
+ deployment?: string;
48
+ /** The clock — the returned event's `providerEventAt`, because a read is a fact about now. */
49
+ now: Date;
50
+ /** The HTTP seam. */
51
+ transport: PaddleHttpFetch;
52
+ }
53
+
54
+ /** Paddle's transaction ids. Checked before a round trip, so a malformed one costs nothing. */
55
+ const TRANSACTION_ID = /^txn_[a-z0-9]+$/;
56
+
57
+ /** Verify one submitted Paddle transaction id, and report who it says it belongs to. */
58
+ export async function verifyPaddleTransaction(
59
+ receipt: string,
60
+ options: VerifyPaddleTransactionOptions,
61
+ ): Promise<VerifiedPurchase> {
62
+ const id = receipt.trim();
63
+ if (!TRANSACTION_ID.test(id)) {
64
+ throw new PaymentsInvalidReceiptError({
65
+ message: "That isn't a Paddle transaction.",
66
+ action: "Submit the `txn_…` the checkout's completion callback handed back.",
67
+ detail: `A Paddle receipt is a transaction id of the form txn_…; this one is ${id.length} characters and does not match.`,
68
+ });
69
+ }
70
+
71
+ const transaction = await readTransaction(id, {
72
+ credentials: options.credentials,
73
+ environment: options.environment,
74
+ transport: options.transport,
75
+ });
76
+ if (transaction === undefined) {
77
+ throw new PaymentsVerificationFailedError({
78
+ message: "We couldn't confirm that purchase.",
79
+ detail: `Paddle does not know transaction ${id} on this account.`,
80
+ });
81
+ }
82
+
83
+ // **The reference, proven.** `accountReferenceOf` returns null unless the stamp carries a MAC this
84
+ // deployment's secret produces over (environment, user). A transaction created through
85
+ // `Paddle.Checkout.open` by a stranger can carry any `pithy_user` it likes and cannot carry this.
86
+ const reference = await accountReferenceOf(
87
+ transaction.custom_data,
88
+ options.deployment,
89
+ options.credentials.webhookSecret,
90
+ );
91
+
92
+ if (reference === null) {
93
+ // Either nothing this server wrote, or a stamp whose proof does not verify, or a deployment that does
94
+ // not know its own environment. All three mean the same thing: this submission carries no evidence of
95
+ // ownership, and the route has nothing to check the caller against.
96
+ //
97
+ // The refusal deliberately does not distinguish them, and does not echo whatever `pithy_user` the
98
+ // payload claimed. A caller learning "the transaction says it belongs to somebody else" learns that a
99
+ // transaction id exists and is owned; `detail` carries the distinction for the operator.
100
+ const claimed = transaction.custom_data?.[PADDLE_CUSTOM_ACCOUNT];
101
+ throw new PaymentsVerificationFailedError({
102
+ message: "We couldn't confirm that purchase.",
103
+ detail: `Paddle transaction ${id} carries no account reference this deployment can prove it wrote${
104
+ typeof claimed === "string" ? " (a claimed reference was present but unproven)" : ""
105
+ }.`,
106
+ });
107
+ }
108
+
109
+ const environment: PurchaseEnvironment = options.environment === "production" ? "production" : "sandbox";
110
+
111
+ return {
112
+ // The clock, not the transaction's own timestamp: a verify is a read of the state now, and dating it
113
+ // earlier would let the monotonic write rule discard it behind a webhook that arrived first.
114
+ event: { ...transactionEvent(transaction, options.now, environment), providerEventAt: options.now },
115
+ providerAccountId: transaction.customer_id ?? null,
116
+ // The route refuses a submission whose reference names somebody other than the caller. That check
117
+ // lives there because the route is the only place that knows who is authenticated.
118
+ accountReference: reference,
119
+ };
120
+ }
@@ -0,0 +1,405 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PurchaseEnvironment } from "../../data/purchase";
5
+ import { PaymentsRailNotConfiguredError, PaymentsVerificationFailedError } from "../../error/errors";
6
+ import type { PaymentsPaddleCredentials } from "../../secret/registry";
7
+ import type { NotificationNote, UnboundProviderEvent, VerifiedNotification, WebhookDelivery } from "../contract";
8
+ import { RESTORING_ACTIONS, REVOKING_ACTIONS, tallyAdjustments } from "./adjustments";
9
+ import type { PaddleEnvironment } from "./api";
10
+ import {
11
+ accountReferenceOf,
12
+ at,
13
+ fencedOut,
14
+ PaddleAdjustment,
15
+ PaddleEvent,
16
+ PaddleSubscription,
17
+ PaddleTransaction,
18
+ subscriptionEvent,
19
+ transactionEvent,
20
+ } from "./objects";
21
+ import { recordedPayload } from "./recorded";
22
+ import { PADDLE_SIGNATURE_HEADER, verifyPaddleSignature } from "./signature";
23
+
24
+ /**
25
+ * Verify and read one Paddle delivery, and the event map that decides what it projects.
26
+ *
27
+ * ## The event id is Paddle's own, and it is the key both paths share
28
+ *
29
+ * `evt_…`, straight off the envelope and inside the signed bytes. `UNIQUE (rail, providerEventId)` in the
30
+ * webhook guard is what makes a redelivery recognized rather than reprocessed, and Paddle retries — three
31
+ * times over fifteen minutes in sandbox, sixty times over three days live, confirmed in the docs.
32
+ *
33
+ * It is deliberately the same id the **events sweep** reads. Paddle's `/events` stream carries `event_id`
34
+ * and no `notification_id`, so the id a swept event reports and the id its webhook reported are the same
35
+ * string — which is what makes the sweep a repair rather than a second source of duplicate rows.
36
+ *
37
+ * A **replay** through Paddle's own replay endpoint creates a new *notification* against the same
38
+ * `event_id`, so it collides here too, which is correct: it is the same event.
39
+ *
40
+ * ## Recording is not projecting
41
+ *
42
+ * Every authentic delivery is recorded, whatever it is. What differs is what it projects, and the answer
43
+ * for most event types is nothing. An event type Paddle ships after this package did is authentic,
44
+ * recorded, projects nothing, and returns 200 — never a throw, because Paddle would redeliver it for
45
+ * three days.
46
+ *
47
+ * ## Adjustments, and why this rail is different
48
+ *
49
+ * As merchant of record Paddle issues refunds on its own account — for a chargeback, a tax dispute, or
50
+ * its own support decision. A refund therefore arrives **unsolicited**, with no local write preceding it.
51
+ * Every other rail in this package but Lemon Squeezy only ever sees refunds it initiated.
52
+ *
53
+ * A full refund revokes; a **partial** one records a note and revokes nothing, because a customer who got
54
+ * half their money back has not lost what they bought. That comparison is against the transaction's own
55
+ * grand total, which the adjustment does not carry — so an adjustment costs one read of the transaction,
56
+ * and a read that fails is `payments/provider_unavailable`, which the guard passes through so Paddle
57
+ * redelivers rather than the operator hunting for a rotated key.
58
+ *
59
+ * **Full means every approved adjustment summed, not this one's own total.** Paddle raises an adjustment
60
+ * per refund, so 99.00 refunded in two goes is two approved adjustments of 49.50 and neither reaches the
61
+ * grand total on its own. See `adjustments.ts`.
62
+ *
63
+ * The read is not optional, and a caller that omits it is refused rather than answered. It was optional
64
+ * once, and the events sweep duly forgot it: every swept adjustment then reported "this deployment cannot
65
+ * read the transaction", recorded itself as handled, and made the webhook redelivery of the same event a
66
+ * duplicate the guard skipped. A seam that silently degrades is a seam that hides a defect twice over.
67
+ *
68
+ * ## Recording is not storing whatever arrives
69
+ *
70
+ * Every payload written here goes through {@link recordedPayload}. A destination subscribed to `*` — or a
71
+ * sweep whose query filter was not honored — carries `client_token.created`, whose `token` Paddle does
72
+ * not redact. See `recorded.ts` for why the control is an allowlist here rather than a filter upstream.
73
+ */
74
+
75
+ /** What the parser needs: the credentials, the deployment asking, the account, and the freshness window. */
76
+ export interface ParsePaddleNotificationOptions {
77
+ /** The rail's credentials. The signing secret is read from here and never logged. */
78
+ credentials: PaymentsPaddleCredentials;
79
+ /** Which Paddle account this deployment sells through. Decides every purchase's `environment`. */
80
+ environment: PaddleEnvironment;
81
+ /** The clock, for the signature's freshness window. */
82
+ now: Date;
83
+ /** This deployment's `ENVIRONMENT`, for the shared-sandbox fence. */
84
+ deployment?: string;
85
+ /** How many seconds either side of `now` a delivery may be dated. Undefined uses the rail's default. */
86
+ freshnessSeconds?: number;
87
+ /**
88
+ * The transaction this adjustment adjusts, or `undefined` when Paddle no longer knows it.
89
+ *
90
+ * Supplied by the rail, which owns the transport. It exists because an adjustment says how much came
91
+ * off and never what the original was, and "full refund" is a comparison against the original.
92
+ */
93
+ readTransaction?: (transactionId: string) => Promise<PaddleTransaction | undefined>;
94
+ }
95
+
96
+ /**
97
+ * Which environment a purchase belongs to.
98
+ *
99
+ * The **account**, not a field on the payload. Paddle Billing partitions sandbox from live by account —
100
+ * separate host, separate key, separate destinations — so there is nothing in a delivery that could
101
+ * contradict this, and the issue's `data.mode` does not exist (on a discount, `mode` means
102
+ * `standard`/`custom`). A sandbox-configured deployment therefore never writes a production row, and the
103
+ * projection writer's own environment check does the rest.
104
+ */
105
+ function environmentOf(account: PaddleEnvironment): PurchaseEnvironment {
106
+ return account === "production" ? "production" : "sandbox";
107
+ }
108
+
109
+ /** Every transaction-domain event this build maps, and whether it says anything about money. */
110
+ export const PADDLE_TRANSACTION_EVENTS: ReadonlySet<string> = new Set([
111
+ "transaction.paid",
112
+ "transaction.completed",
113
+ "transaction.payment_failed",
114
+ "transaction.past_due",
115
+ "transaction.canceled",
116
+ "transaction.updated",
117
+ "transaction.revised",
118
+ "transaction.billed",
119
+ ]);
120
+
121
+ /**
122
+ * Transaction events that project nothing at all, however well-formed.
123
+ *
124
+ * `transaction.created` and `transaction.ready` describe a transaction that exists and has taken no
125
+ * money. Projecting one would write an `on_hold` row for every abandoned checkout — noise the reconcile
126
+ * pass then re-reads forever.
127
+ */
128
+ export const PADDLE_TRANSACTION_EVENTS_WITHOUT_STATE: ReadonlySet<string> = new Set([
129
+ "transaction.created",
130
+ "transaction.ready",
131
+ ]);
132
+
133
+ /** Every subscription-domain event: the standing changed, and no money moved. */
134
+ export const PADDLE_SUBSCRIPTION_EVENTS: ReadonlySet<string> = new Set([
135
+ "subscription.created",
136
+ "subscription.activated",
137
+ "subscription.resumed",
138
+ "subscription.trialing",
139
+ "subscription.past_due",
140
+ "subscription.paused",
141
+ "subscription.canceled",
142
+ "subscription.updated",
143
+ "subscription.imported",
144
+ ]);
145
+
146
+ /** Every adjustment-domain event: money going back, on Paddle's own initiative. */
147
+ export const PADDLE_ADJUSTMENT_EVENTS: ReadonlySet<string> = new Set(["adjustment.created", "adjustment.updated"]);
148
+
149
+ /** Nothing at all was learned, but the delivery was authentic. */
150
+ function nothing(event: PaddleEvent, note?: NotificationNote): VerifiedNotification {
151
+ return {
152
+ providerEventId: event.event_id,
153
+ payload: recordedPayload(event),
154
+ event: null,
155
+ providerAccountId: null,
156
+ accountReference: null,
157
+ // Null unless a caller supplied one: a note routes the route into an audit warning, and a delivery
158
+ // that is simply not ours — or an event type shipped after this package — is not worth warning about.
159
+ note: note ?? null,
160
+ };
161
+ }
162
+
163
+ /** Verify a delivery's signature and read what it says. */
164
+ export async function parsePaddleNotification(
165
+ delivery: WebhookDelivery,
166
+ options: ParsePaddleNotificationOptions,
167
+ ): Promise<VerifiedNotification> {
168
+ await verifyPaddleSignature(delivery.body, delivery.headers.get(PADDLE_SIGNATURE_HEADER), {
169
+ secret: options.credentials.webhookSecret,
170
+ now: options.now,
171
+ toleranceSeconds: options.freshnessSeconds,
172
+ });
173
+
174
+ let parsed: unknown;
175
+ try {
176
+ parsed = JSON.parse(delivery.body) as unknown;
177
+ } catch (cause) {
178
+ throw new PaymentsVerificationFailedError({ detail: "Paddle: the delivery body is not JSON." }, { cause });
179
+ }
180
+
181
+ const envelope = PaddleEvent.safeParse(parsed);
182
+ if (!envelope.success) {
183
+ throw new PaymentsVerificationFailedError({
184
+ detail: "Paddle: the delivery is not an event envelope — no event_id, event_type, occurred_at, or data.",
185
+ });
186
+ }
187
+ return await readPaddleEvent(envelope.data, options);
188
+ }
189
+
190
+ /**
191
+ * Read one authentic event — the whole map, shared by the webhook path and the events sweep.
192
+ *
193
+ * Shared deliberately. A sweep that projected through a second code path would be a second answer to
194
+ * "what does this event mean", and the two would drift the first time a status was added.
195
+ */
196
+ export async function readPaddleEvent(
197
+ event: PaddleEvent,
198
+ options: ParsePaddleNotificationOptions,
199
+ ): Promise<VerifiedNotification> {
200
+ const occurredAt = at(event.occurred_at, "an event's occurred_at");
201
+ const environment = environmentOf(options.environment);
202
+ const type = event.event_type;
203
+
204
+ if (PADDLE_TRANSACTION_EVENTS_WITHOUT_STATE.has(type)) {
205
+ // Authentic, recorded, and no money has moved. Deliberately checked before the fence: it projects
206
+ // nothing either way, and both answers are the same row.
207
+ return nothing(event);
208
+ }
209
+
210
+ if (PADDLE_TRANSACTION_EVENTS.has(type))
211
+ return await transactionNotification(event, occurredAt, environment, options);
212
+ if (PADDLE_SUBSCRIPTION_EVENTS.has(type))
213
+ return await subscriptionNotification(event, occurredAt, environment, options);
214
+ if (PADDLE_ADJUSTMENT_EVENTS.has(type)) return await adjustmentNotification(event, occurredAt, environment, options);
215
+
216
+ // `customer.*`, `address.*`, `business.*`, `payment_method.*`, `price.*`, `product.*`, `discount.*`, and
217
+ // a type Paddle ships after this package did. Authentic, recorded, projecting nothing. Never a throw:
218
+ // Paddle would redeliver it for three days.
219
+ return nothing(event);
220
+ }
221
+
222
+ /** A transaction-domain delivery: one money row. */
223
+ async function transactionNotification(
224
+ event: PaddleEvent,
225
+ occurredAt: Date,
226
+ environment: PurchaseEnvironment,
227
+ options: ParsePaddleNotificationOptions,
228
+ ): Promise<VerifiedNotification> {
229
+ const transaction = PaddleTransaction.parse(event.data);
230
+
231
+ // Another deployment's buyer, on the sandbox we share with it. Authentic, recorded, none of our
232
+ // business — no row, no entitlement, no audit warning, and a 200.
233
+ if (fencedOut(transaction.custom_data, options.deployment)) return nothing(event);
234
+
235
+ return {
236
+ providerEventId: event.event_id,
237
+ payload: recordedPayload(event),
238
+ event: transactionEvent(transaction, occurredAt, environment),
239
+ providerAccountId: transaction.customer_id ?? null,
240
+ accountReference: await accountReferenceOf(
241
+ transaction.custom_data,
242
+ options.deployment,
243
+ options.credentials.webhookSecret,
244
+ ),
245
+ };
246
+ }
247
+
248
+ /** A subscription-domain delivery: one state row. */
249
+ async function subscriptionNotification(
250
+ event: PaddleEvent,
251
+ occurredAt: Date,
252
+ environment: PurchaseEnvironment,
253
+ options: ParsePaddleNotificationOptions,
254
+ ): Promise<VerifiedNotification> {
255
+ const subscription = PaddleSubscription.parse(event.data);
256
+ if (fencedOut(subscription.custom_data, options.deployment)) return nothing(event);
257
+
258
+ return {
259
+ providerEventId: event.event_id,
260
+ payload: recordedPayload(event),
261
+ event: subscriptionEvent(subscription, occurredAt, environment),
262
+ providerAccountId: subscription.customer_id ?? null,
263
+ // Paddle copies `custom_data` from the transaction that created the subscription onto the
264
+ // subscription, so every later event in this domain carries the stamp too.
265
+ accountReference: await accountReferenceOf(
266
+ subscription.custom_data,
267
+ options.deployment,
268
+ options.credentials.webhookSecret,
269
+ ),
270
+ };
271
+ }
272
+
273
+ /**
274
+ * An adjustment-domain delivery — a refund, a credit, or a chargeback.
275
+ *
276
+ * **The fence here is ownership of the row, not a stamp.** An adjustment carries no `custom_data` of
277
+ * ours: Paddle raises it against a transaction, and the transaction is what this deployment either holds
278
+ * or does not. So the transaction is read back, and one it cannot read is one this deployment has no
279
+ * business acting on — recorded with a note, projecting nothing, on exactly the same path.
280
+ */
281
+ async function adjustmentNotification(
282
+ event: PaddleEvent,
283
+ occurredAt: Date,
284
+ environment: PurchaseEnvironment,
285
+ options: ParsePaddleNotificationOptions,
286
+ ): Promise<VerifiedNotification> {
287
+ const adjustment = PaddleAdjustment.parse(event.data);
288
+
289
+ // Only an approved adjustment acts. `pending_approval`, `rejected` and `reversed` are all authentic
290
+ // statements that nothing has happened to the money yet, or that it will not.
291
+ if (adjustment.status !== "approved") return nothing(event);
292
+
293
+ const revoking = REVOKING_ACTIONS.has(adjustment.action);
294
+ const restoring = RESTORING_ACTIONS.has(adjustment.action);
295
+ // `credit`, `credit_reverse`, `chargeback_warning`, `chargeback_warning_reverse` — and any action
296
+ // Paddle adds later. A credit against a balance is not a revocation, and a warning is not a decision.
297
+ if (!revoking && !restoring) return nothing(event);
298
+
299
+ if (options.readTransaction === undefined) {
300
+ // Not "cannot read this transaction" — *no reader at all*, which is a wiring defect in this package
301
+ // and not a fact about Paddle. Answering `nothing()` here is what let the events sweep record every
302
+ // adjustment as handled without ever asking, so a caller that forgot is refused and the throw stops
303
+ // the sweep's cursor in front of the event rather than past it.
304
+ throw new PaymentsRailNotConfiguredError({
305
+ detail: `Paddle adjustment ${adjustment.id} needs transaction ${adjustment.transaction_id} to tell a full refund from a partial one, and this caller supplied no transaction reader.`,
306
+ });
307
+ }
308
+
309
+ const transaction = await options.readTransaction(adjustment.transaction_id);
310
+ if (transaction === undefined) {
311
+ // Either Paddle no longer knows the transaction, or it belongs to another deployment on this shared
312
+ // sandbox. Authentic, unprojectable, and worth an operator's attention — so this is one of the two
313
+ // places on this rail that sets a note.
314
+ //
315
+ // **`read` (#341).** This is the answer a call to Paddle gave, and the second reason above is the giveaway
316
+ // that it is not a fact about the event: "cannot read" depends on which key asked. A rotated key, a
317
+ // shared sandbox, an adjustment arriving ahead of its transaction — each answers `undefined` now and
318
+ // something else later. Finishing the row on it made the redelivery that would have projected the
319
+ // clawback a `duplicate`.
320
+ return nothing(event, {
321
+ read: `Paddle adjustment ${adjustment.id} names transaction ${adjustment.transaction_id}, which this deployment cannot read.`,
322
+ });
323
+ }
324
+ if (fencedOut(transaction.custom_data, options.deployment)) return nothing(event);
325
+
326
+ const base = transactionEvent(transaction, occurredAt, environment);
327
+
328
+ if (restoring) {
329
+ // A later `occurred_at` than the revocation it undoes, so the monotonic write rule lets it win.
330
+ return projected(event, { ...base, revokedAt: null }, transaction.customer_id ?? null);
331
+ }
332
+
333
+ // Every approved refund and chargeback against this transaction, summed — not this adjustment's own
334
+ // total. Two approved refunds of half a transaction each are the customer's whole money back, and
335
+ // neither one on its own reaches the grand total.
336
+ const tally = tallyAdjustments(adjustment, transaction);
337
+
338
+ if (!tally.full) {
339
+ // A partial refund does not revoke. The entitlement stands, and the row says why.
340
+ //
341
+ // An unreadable figure lands here too, and deliberately: an amount this build could not read is not
342
+ // evidence of a full refund, and revoking on it would take an entitlement away on a guess. The note
343
+ // names both figures and how many adjustments were counted, which is what makes it repairable.
344
+ //
345
+ // **`stated`, and the difference from the branch above is the whole rule (#341).** That one is what a
346
+ // failed read answered; this one is a sum over a transaction the read *returned*. The figures are in
347
+ // hand, the arithmetic is this build's, and a redelivery re-runs it on the same numbers — so there is
348
+ // nothing here a second attempt could improve, and the row is finished.
349
+ return nothing(event, {
350
+ stated: `Paddle adjustment ${adjustment.id} (${adjustment.action}) brings transaction ${adjustment.transaction_id} to ${tally.revokedMinor ?? "an unreadable amount"} adjusted across ${tally.counted} adjustment(s), of a ${tally.totalMinor ?? "unreadable"} total. Recorded; the entitlement stands.`,
351
+ });
352
+ }
353
+
354
+ return projected(
355
+ event,
356
+ { ...base, status: "refunded", revokedAt: occurredAt, expiresAt: occurredAt },
357
+ transaction.customer_id ?? null,
358
+ // A refunded subscription period must also stop granting, and the money row cannot do it: the two are
359
+ // different rows keyed on different ids, and `refunded` on a `txn_…` says nothing about `sub_…`. One
360
+ // event without the other leaves a refunded subscriber holding the feature — the exact case the Lemon
361
+ // Squeezy rail documents. `revoked` rather than `refunded`, because the *transaction* was refunded and
362
+ // the subscription's standing was revoked as a consequence; a `state` row never claws back either way.
363
+ revocationOf(base, occurredAt),
364
+ );
365
+ }
366
+
367
+ /** The state event a full refund of a subscription's transaction implies, or null for a one-off. */
368
+ function revocationOf(charge: UnboundProviderEvent, occurredAt: Date): UnboundProviderEvent | null {
369
+ const subscriptionId = charge.originalTransactionId;
370
+ if (subscriptionId === null || subscriptionId === undefined) return null;
371
+ return {
372
+ ...charge,
373
+ providerTransactionId: subscriptionId,
374
+ originalTransactionId: subscriptionId,
375
+ role: "state",
376
+ status: "revoked",
377
+ revokedAt: occurredAt,
378
+ expiresAt: occurredAt,
379
+ amountMinor: null,
380
+ currency: null,
381
+ // The refund's own clock, not the subscription's. The subscription object was last touched before the
382
+ // refund happened, so its timestamp would be older than the row it is trying to move and the monotonic
383
+ // rule would discard the revocation entirely.
384
+ providerEventAt: occurredAt,
385
+ };
386
+ }
387
+
388
+ /** One projected notification, with the reference read off the transaction that carried the stamp. */
389
+ function projected(
390
+ event: PaddleEvent,
391
+ projection: UnboundProviderEvent,
392
+ customerId: string | null,
393
+ stateEvent: UnboundProviderEvent | null = null,
394
+ ): VerifiedNotification {
395
+ return {
396
+ providerEventId: event.event_id,
397
+ payload: recordedPayload(event),
398
+ event: projection,
399
+ providerAccountId: customerId,
400
+ // Deliberately not resolved here. An adjustment's owner is the row it adjusts, which this deployment
401
+ // already holds and already bound; re-deriving it would be a second answer to a settled question.
402
+ accountReference: null,
403
+ stateEvent,
404
+ };
405
+ }