@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,153 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SubscriptionPricing } from "../../data/discount";
5
+ import type { PaymentsPurchase } from "../../data/purchase";
6
+ import type { PaymentsPaddleCredentials } from "../../secret/registry";
7
+ import type { UnboundProviderEvent } from "../contract";
8
+ import { type PaddleEnvironment, type PaddleHttpFetch, paddleHttpFetch, paddleJson } from "./api";
9
+ import { currencyOf, minorAmount, subscriptionEvent, transactionEvent } from "./objects";
10
+ import { readSubscription, readTransaction } from "./read";
11
+
12
+ /**
13
+ * Re-read a Paddle purchase — the reconciliation path — and read what it pays.
14
+ *
15
+ * Webhooks are dropped, destinations are misconfigured, and a subscription can lapse with no notification
16
+ * arriving at all. This is the question the reconciliation Workflow exists to ask, and this rail answers
17
+ * it for real.
18
+ *
19
+ * ## Which rows it can answer for
20
+ *
21
+ * Paddle's ids are globally prefixed, so the row's own key says what to ask: `sub_…` is a subscription,
22
+ * `txn_…` is a transaction. Both are addressable, so both are re-read — unlike Lemon Squeezy, where a
23
+ * money row records a closed period and has nothing to re-read.
24
+ *
25
+ * A **subscription** transaction's money row is born `expired`, which is in the Workflow's terminal set,
26
+ * so the pass never selects one. Should one reach here it is answered honestly rather than skipped: a
27
+ * refunded transaction is exactly the thing worth re-reading.
28
+ *
29
+ * ## The clock is ours
30
+ *
31
+ * `providerEventAt` is `context.now`, not the object's `updated_at`, as the contract requires. A refresh
32
+ * is a read of the state *now*, so it is the freshest fact there is — dating it earlier would let the
33
+ * monotonic write rule discard the very repair this pass exists to make.
34
+ */
35
+
36
+ /** What a refresh needs: the credentials, the account, the clock, and the transport. */
37
+ export interface RefreshPaddleOptions {
38
+ /** The rail's credentials. */
39
+ credentials: PaymentsPaddleCredentials;
40
+ /** Which Paddle account to read from. */
41
+ environment: PaddleEnvironment;
42
+ /** The clock. Stamped onto the returned event, per the contract. */
43
+ now: Date;
44
+ /** The HTTP seam. Defaults to the runtime's `fetch`. */
45
+ transport?: PaddleHttpFetch;
46
+ }
47
+
48
+ /** Re-read one purchase, or `undefined` when Paddle has nothing to say about it. */
49
+ export async function refreshPaddlePurchase(
50
+ purchase: PaymentsPurchase,
51
+ options: RefreshPaddleOptions,
52
+ ): Promise<UnboundProviderEvent | undefined> {
53
+ const read = {
54
+ credentials: options.credentials,
55
+ environment: options.environment,
56
+ transport: options.transport ?? paddleHttpFetch,
57
+ };
58
+ // **The row's own key, never the family's.** A money row's family key is `sub_…`, so falling back would
59
+ // re-read the subscription and answer with a state event keyed to a different row — which reconciliation
60
+ // would read as the row having been superseded.
61
+ const id = purchase.providerTransactionId;
62
+ const environment = options.environment === "production" ? "production" : "sandbox";
63
+
64
+ if (id.startsWith("sub_")) {
65
+ const subscription = await readSubscription(id, read);
66
+ if (subscription === undefined) return undefined;
67
+ return { ...subscriptionEvent(subscription, options.now, environment), providerEventAt: options.now };
68
+ }
69
+
70
+ if (id.startsWith("txn_")) {
71
+ const transaction = await readTransaction(id, read);
72
+ if (transaction === undefined) return undefined;
73
+ return { ...transactionEvent(transaction, options.now, environment), providerEventAt: options.now };
74
+ }
75
+
76
+ // A prefix a later build wrote. Nothing to re-read, which the contract defines as "the store has
77
+ // nothing to say about this purchase" and which leaves the row exactly as it stands.
78
+ return undefined;
79
+ }
80
+
81
+ /**
82
+ * What a Paddle subscription pays now, what it becomes, and when.
83
+ *
84
+ * Read from Paddle rather than computed. `include=next_transaction` carries what the next invoice comes
85
+ * to under any discount in force; `include=recurring_transaction_details` carries what it comes to at
86
+ * list price. Nothing here multiplies a price by a percentage — a second calculation would be a second
87
+ * answer to the one question a customer checks against their statement.
88
+ *
89
+ * Only a `sub_…` row can be asked. A transaction records one closed period and has no "next", so it
90
+ * answers `undefined` — the same "nothing to say about this purchase" the refresh path uses.
91
+ */
92
+ export async function readPaddlePricing(
93
+ purchase: PaymentsPurchase,
94
+ options: RefreshPaddleOptions,
95
+ ): Promise<SubscriptionPricing | undefined> {
96
+ const id = purchase.providerTransactionId;
97
+ if (!id.startsWith("sub_")) return undefined;
98
+
99
+ const answer = await paddleJson(options.transport ?? paddleHttpFetch, `/subscriptions/${encodeURIComponent(id)}`, {
100
+ what: `subscription ${id}'s pricing`,
101
+ apiKey: options.credentials.apiKey,
102
+ environment: options.environment,
103
+ query: [["include", "next_transaction,recurring_transaction_details"]],
104
+ absentOn404: true,
105
+ });
106
+ if (answer === undefined) return undefined;
107
+
108
+ const data = answer.data as Record<string, unknown> | null;
109
+ if (data === null || typeof data !== "object") return undefined;
110
+
111
+ const next = totalsOf(data.next_transaction);
112
+ const recurring = totalsOf(data.recurring_transaction_details);
113
+ const discount = data.discount as Record<string, unknown> | null | undefined;
114
+ const endsAt = typeof discount?.ends_at === "string" ? new Date(discount.ends_at) : null;
115
+
116
+ return {
117
+ currency: next.currency ?? recurring.currency,
118
+ // What the next invoice actually comes to, under whatever is in force.
119
+ currentAmountMinor: next.total ?? recurring.total,
120
+ // What it comes to at list price. Equal to the current amount when no discount is in force, which is
121
+ // how a screen tells "this is your rate" from "this is your rate until March".
122
+ listAmountMinor: recurring.total ?? next.total,
123
+ // **Always null, and that is a stated limitation rather than a gap.** Paddle's subscription object
124
+ // carries `discount.id` and no code, so naming the code would mean a second round trip on every
125
+ // read of a screen a customer refreshes. `discountEndsAt` is the field that stops a bill changing
126
+ // unannounced, and it is here: a screen says "your rate changes on the 3rd" without naming the code
127
+ // that set it.
128
+ discountCode: null,
129
+ discountEndsAt: endsAt !== null && !Number.isNaN(endsAt.getTime()) ? endsAt : null,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Pull the total and its currency out of whichever include shape carried it.
135
+ *
136
+ * **The two includes are not the same shape**, and a reader that knew only one would silently answer null
137
+ * for the other — leaving a screen reporting no list price and a customer told nothing about the date
138
+ * their rate changes. `next_transaction` nests its figures under `details.totals`; `recurring_transaction
139
+ * _details` carries `totals` at the top. Both are checked, in that order.
140
+ */
141
+ function totalsOf(value: unknown): { total: number | null; currency: string | null } {
142
+ if (value === null || typeof value !== "object") return { total: null, currency: null };
143
+ const bagged = value as Record<string, unknown>;
144
+ const details = bagged.details;
145
+ const nested = details !== null && typeof details === "object" ? (details as Record<string, unknown>).totals : null;
146
+ const totals = nested ?? bagged.totals ?? null;
147
+ if (totals === null || typeof totals !== "object") return { total: null, currency: null };
148
+ const bag = totals as Record<string, unknown>;
149
+ return {
150
+ total: minorAmount(typeof bag.total === "string" ? bag.total : (bag.grand_total as string | undefined)),
151
+ currency: currencyOf(typeof bag.currency_code === "string" ? bag.currency_code : null),
152
+ };
153
+ }
@@ -0,0 +1,297 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PaymentsPurchase } from "../../data/purchase";
5
+ import { RefundRequest, type RefundRequestOutcome, type RefundRequestStatus } from "../../data/subscription";
6
+ import { PaymentsProviderUnavailableError, PaymentsSubscriptionChangeRefusedError } from "../../error/errors";
7
+ import type { PaymentsPaddleCredentials } from "../../secret/registry";
8
+ import type { RefundRequestInput } from "../contract";
9
+ import { REVOKING_ACTIONS } from "./adjustments";
10
+ import { type PaddleEnvironment, type PaddleHttpFetch, paddleHttpFetch, paddleJson, redactPaddleSecrets } from "./api";
11
+ import { PaddleAdjustment, type PaddleTransaction } from "./objects";
12
+ import { PADDLE_ADJUSTMENTS_INCLUDE, readTransaction } from "./read";
13
+
14
+ /**
15
+ * Asking Paddle to give a subscriber's payments back — the fourth verb of #465.
16
+ *
17
+ * ## Three facts about Paddle shaped every line of this
18
+ *
19
+ * **1. It is a request, not a completion.** `POST /adjustments` creates an adjustment, and on a live
20
+ * account Paddle holds most refunds at `pending_approval` until a person there reviews them; the sandbox
21
+ * approves on its own, roughly ten minutes later. So nothing here says the money moved, nothing revokes an
22
+ * entitlement, and nothing writes a purchase row. An approved refund arrives as a webhook, and
23
+ * `adjustments.ts` and the projection writer already act on it — they are the only things that do.
24
+ * Revoking on the *request* would take access from a customer whose refund Paddle then rejects, leaving
25
+ * them with neither the money nor the product.
26
+ *
27
+ * **2. Refunds attach to a transaction, not to a subscription.** There is no endpoint that refunds a
28
+ * subscription, so this raises one adjustment per transaction and reports one outcome per transaction.
29
+ * The case is ordinary: a customer who joined on Solo at 6.00, upgraded to Team on day 10 for a 65.82
30
+ * proration and cancels on day 13 has paid twice, and a refund policy owes them both.
31
+ *
32
+ * **3. They do not stack.** Paddle: *"You can't create an adjustment for a transaction that has a refund
33
+ * that's pending approval."* Learning that from Paddle, mid-set, after other adjustments have already been
34
+ * raised, is the worst possible place to learn it — so it is decided here, from the transaction's own
35
+ * `include=adjustments`, before anything is sent.
36
+ *
37
+ * ## The guarantee, and the line it is split at
38
+ *
39
+ * All-or-nothing across the whole set **cannot be built**: Paddle offers no batch adjustment and no delete,
40
+ * so once the first one is at `pending_approval` nothing un-raises it. The guarantee is therefore split at
41
+ * the only line that is real — whether this call has written anything yet:
42
+ *
43
+ * - **Before the first write**, every transaction is read and checked, and anything that makes one
44
+ * unrefundable refuses the *whole request* with `payments/subscription_change_refused` having sent
45
+ * nothing. {@link MAX_PADDLE_REFUND_TRANSACTIONS} is enforced first, for the same reason.
46
+ * - **After the first write**, nothing throws. Every remaining failure becomes a `failed` outcome, because
47
+ * an error raised over a state where money is already moving is precisely the silent partial this module
48
+ * is designed against.
49
+ *
50
+ * A refund that is already standing is neither: it is reported as `already_requested` and no call is made.
51
+ * That is the per-payment form of the no-op rule the other four verbs follow — a retried write must not
52
+ * become a second refund, and a 409 for the state the caller asked for is simply wrong.
53
+ *
54
+ * ## The window is not here
55
+ *
56
+ * How long a customer has to ask for their money back is the **adopter's** policy, with a company's
57
+ * commercial decisions behind it. This module refunds what it is handed and hard-codes no window; the
58
+ * adopter's screen decides which button exists.
59
+ */
60
+
61
+ /** What every call here needs: the credentials, which Paddle account, and the transport. */
62
+ export interface PaddleRefundOptions {
63
+ /** The rail's credentials. The API key is read at the point of need and never cached. */
64
+ credentials: PaymentsPaddleCredentials;
65
+ /** Which Paddle account to reach. */
66
+ environment: PaddleEnvironment;
67
+ /** The HTTP seam. Defaults to the runtime's `fetch`. */
68
+ transport?: PaddleHttpFetch;
69
+ }
70
+
71
+ /**
72
+ * The most transactions one request will refund.
73
+ *
74
+ * **A bound on subrequests, not a policy.** Each transaction costs two calls — a read and a create — and a
75
+ * Worker's subrequest budget is finite. A call that runs out of it half way through is a partial by
76
+ * another name, and the one thing this module will not produce. So the size is checked before anything is
77
+ * read, and a set that is too large is refused whole.
78
+ *
79
+ * Twelve, and the two ends of the range are what pick it. Any refund window an adopter would set holds one
80
+ * or two transactions on a monthly plan — the recorded upgrade case is two — so nothing legitimate comes
81
+ * near it. And twenty-four calls sits well inside the budget with room for everything else a request does.
82
+ * An adopter reversing a whole subscription history is doing something else, and the Paddle dashboard is
83
+ * the tool for it.
84
+ */
85
+ export const MAX_PADDLE_REFUND_TRANSACTIONS = 12;
86
+
87
+ /** What Paddle sends and what this module states. Anything else is `unknown` — see the enum's own doc. */
88
+ const REFUND_STATUS: Readonly<Record<string, RefundRequestStatus>> = {
89
+ pending_approval: "awaiting_review",
90
+ approved: "approved",
91
+ rejected: "rejected",
92
+ reversed: "reversed",
93
+ };
94
+
95
+ /** The request cannot be honored, and nothing has been sent. 409, with the reason in `detail`. */
96
+ function refuse(detail: string): never {
97
+ throw new PaymentsSubscriptionChangeRefusedError({
98
+ message: "That refund cannot be requested.",
99
+ action: "Re-read the subscription and the payments on it, then ask for a refund the present state allows.",
100
+ detail,
101
+ });
102
+ }
103
+
104
+ /** The transaction this purchase row names, or null when it names none. */
105
+ function transactionIdOf(purchase: PaymentsPurchase): string | null {
106
+ const id = purchase.providerTransactionId;
107
+ return id.startsWith("txn_") ? id : null;
108
+ }
109
+
110
+ /**
111
+ * The adjustment already standing against this transaction, or undefined.
112
+ *
113
+ * **Two cases block, and one deliberately does not.** A `refund` awaiting review blocks because Paddle
114
+ * refuses a second one outright. An *approved* revoking adjustment — {@link REVOKING_ACTIONS}, so a
115
+ * chargeback counts — blocks because the money is already back and a second refund would send it twice.
116
+ *
117
+ * A **rejected** refund does not block. A reviewer at Paddle turning one down is not a permanent bar, and
118
+ * treating it as one would make somebody else's decision final over money the adopter has chosen to give
119
+ * back. Neither does a `credit` against a balance, which is not a revocation — the same line
120
+ * `adjustments.ts` draws, read from its set rather than restated here.
121
+ */
122
+ function standingRefund(transaction: PaddleTransaction): PaddleAdjustment | undefined {
123
+ return (transaction.adjustments ?? []).find((one) => {
124
+ if (one.status === "pending_approval") return one.action === "refund";
125
+ return one.status === "approved" && REVOKING_ACTIONS.has(one.action);
126
+ });
127
+ }
128
+
129
+ /** One payment, checked at the store and ready to be acted on. */
130
+ interface Checked {
131
+ /** The row the outcome is reported against. */
132
+ purchase: PaymentsPurchase;
133
+ /** The Paddle transaction to refund. */
134
+ transactionId: string;
135
+ /** The refund already standing on it, when one is. Present means nothing is sent for this payment. */
136
+ standing: PaddleAdjustment | undefined;
137
+ }
138
+
139
+ /**
140
+ * Read and check every payment, or refuse the whole request. **No write happens inside this.**
141
+ *
142
+ * A transaction Paddle does not know, or one it will not refund, refuses the request rather than becoming
143
+ * an outcome: it is knowable in advance, and everything knowable in advance is decided while refusing is
144
+ * still free. A store that cannot be *reached* raises `payments/provider_unavailable` from `paddleJson`
145
+ * and is left to propagate, because nothing about the subscription is wrong and telling a customer their
146
+ * refund was refused over a timeout is a true sentence about the wrong thing.
147
+ */
148
+ async function check(purchases: readonly PaymentsPurchase[], options: PaddleRefundOptions): Promise<Checked[]> {
149
+ const read = {
150
+ credentials: options.credentials,
151
+ environment: options.environment,
152
+ transport: options.transport ?? paddleHttpFetch,
153
+ };
154
+ const checked: Checked[] = [];
155
+
156
+ for (const purchase of purchases) {
157
+ const transactionId = transactionIdOf(purchase);
158
+ if (transactionId === null) {
159
+ refuse(
160
+ `Purchase ${purchase.id} names ${JSON.stringify(purchase.providerTransactionId)}, which is not a Paddle transaction. A refund attaches to a transaction, and a subscription is not one.`,
161
+ );
162
+ }
163
+
164
+ const transaction = await readTransaction(transactionId, read, PADDLE_ADJUSTMENTS_INCLUDE);
165
+ if (transaction === undefined) {
166
+ refuse(`Paddle has no transaction ${transactionId}, so there is nothing on it to refund.`);
167
+ }
168
+ if (transaction.status !== "completed") {
169
+ refuse(
170
+ `Paddle reports transaction ${transactionId} as ${JSON.stringify(transaction.status)}, and only a completed transaction can be refunded. Nothing in this set was raised.`,
171
+ );
172
+ }
173
+
174
+ checked.push({ purchase, transactionId, standing: standingRefund(transaction) });
175
+ }
176
+
177
+ return checked;
178
+ }
179
+
180
+ /** Paddle's status, as this package states it. An unmapped one is `unknown` — never a throw. */
181
+ function statusOf(stated: string | null | undefined): RefundRequestStatus {
182
+ return (typeof stated === "string" ? REFUND_STATUS[stated] : undefined) ?? "unknown";
183
+ }
184
+
185
+ /**
186
+ * Ask Paddle to refund these payments in full, and report what became of each.
187
+ *
188
+ * The report is **total** over the input — one outcome per purchase, in the order given — because a report
189
+ * shorter than the set is what a silent partial looks like. See {@link RefundRequest}.
190
+ */
191
+ export async function requestPaddleRefunds(
192
+ input: RefundRequestInput,
193
+ options: PaddleRefundOptions,
194
+ ): Promise<RefundRequest> {
195
+ const { purchases, reason } = input;
196
+
197
+ if (purchases.length === 0) {
198
+ // Not an empty report. "Nothing to refund, all done" is a sentence about somebody's money that
199
+ // nobody asked for; a caller with nothing to refund has a refusal to render.
200
+ refuse("No payment was named, so there is nothing to refund.");
201
+ }
202
+ if (purchases.length > MAX_PADDLE_REFUND_TRANSACTIONS) {
203
+ refuse(
204
+ `${purchases.length} payments were named and one request refunds at most ${MAX_PADDLE_REFUND_TRANSACTIONS}. A call that runs out of budget half way through is a partial refund nobody asked for, so the whole set is refused before anything is read.`,
205
+ );
206
+ }
207
+
208
+ const checked = await check(purchases, options);
209
+ const transport = options.transport ?? paddleHttpFetch;
210
+ const outcomes: RefundRequestOutcome[] = [];
211
+ let raised = 0;
212
+
213
+ for (const one of checked) {
214
+ if (one.standing !== undefined) {
215
+ outcomes.push({
216
+ outcome: "already_requested",
217
+ purchaseId: one.purchase.id,
218
+ adjustmentId: one.standing.id,
219
+ status: statusOf(one.standing.status),
220
+ });
221
+ continue;
222
+ }
223
+
224
+ let answer: unknown;
225
+ try {
226
+ const response = await paddleJson(transport, "/adjustments", {
227
+ what: `a refund on transaction ${one.transactionId} (this key needs the adjustment.write permission)`,
228
+ apiKey: options.credentials.apiKey,
229
+ environment: options.environment,
230
+ method: "POST",
231
+ body: {
232
+ // The only action this module ever sends. `credit`, `chargeback` and the rest are Paddle's to
233
+ // raise; a value here would be a second verb wearing this one's name and its own audit row.
234
+ action: "refund",
235
+ // The transaction's whole total. `partial` needs `txnitm_` ids nothing here holds, and an
236
+ // amount on a bearer route is a self-service withdrawal.
237
+ type: "full",
238
+ transaction_id: one.transactionId,
239
+ reason,
240
+ },
241
+ });
242
+ answer = response?.data;
243
+ } catch (error) {
244
+ // **The line the whole design sits on.** With nothing raised, an error is the honest answer and the
245
+ // more useful one. With an adjustment already in flight, throwing would tell the caller the refund
246
+ // failed while the customer's money is on its way back — the silent partial, told backwards.
247
+ if (raised === 0) throw error;
248
+ outcomes.push({ outcome: "failed", purchaseId: one.purchase.id, reason: said(error, one.transactionId) });
249
+ continue;
250
+ }
251
+
252
+ const parsed = PaddleAdjustment.safeParse(answer);
253
+ if (!parsed.success) {
254
+ // Paddle answered 2xx, so an adjustment may well exist and this build simply cannot read it. Before
255
+ // the first write that is a shape change worth failing on; after it, losing the adjustment that
256
+ // definitely does exist is the worse outcome.
257
+ if (raised === 0) {
258
+ throw new PaymentsProviderUnavailableError({
259
+ detail: `Paddle answered the refund of transaction ${one.transactionId} in a shape this build cannot read.`,
260
+ });
261
+ }
262
+ outcomes.push({
263
+ outcome: "failed",
264
+ purchaseId: one.purchase.id,
265
+ reason: `Paddle answered the refund of transaction ${one.transactionId} in a shape this build cannot read, so whether one was raised is unknown. Check the transaction's adjustments at Paddle.`,
266
+ });
267
+ continue;
268
+ }
269
+
270
+ raised += 1;
271
+ outcomes.push({
272
+ outcome: "requested",
273
+ purchaseId: one.purchase.id,
274
+ adjustmentId: parsed.data.id,
275
+ status: statusOf(parsed.data.status),
276
+ });
277
+ }
278
+
279
+ // Parsed rather than returned raw: this crosses into a route that hands it to an audit trail and to a
280
+ // browser, and every boundary in this package is validated. It also refuses an outcome list that lost
281
+ // an entry, which is the one defect the shape exists to prevent.
282
+ return RefundRequest.parse({ outcomes });
283
+ }
284
+
285
+ /**
286
+ * What went wrong, in an operator's words, with anything key-shaped blanked.
287
+ *
288
+ * A reported reason lands in an audit trail and in a log, and the API key is in every request this module
289
+ * makes. `paddleJson` has already put Paddle's own sentence in `detail` and redacted it once; this redacts
290
+ * again rather than trusting that, because the cost of the second pass is nothing and the cost of the
291
+ * assumption being wrong is a credential in a long-lived queryable table.
292
+ */
293
+ function said(error: unknown, transactionId: string): string {
294
+ const detail = (error as { payload?: { detail?: unknown } } | null)?.payload?.detail;
295
+ const stated = typeof detail === "string" && detail !== "" ? ` ${redactPaddleSecrets(detail)}` : "";
296
+ return `Paddle would not refund transaction ${transactionId}.${stated}`;
297
+ }
@@ -0,0 +1,225 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PaymentsVerificationFailedError } from "../../error/errors";
5
+
6
+ /**
7
+ * Paddle's authenticity proof: a timestamp and one or more HMACs, in one header.
8
+ *
9
+ * `Paddle-Signature: ts=1770000000;h1=5257a8…` — elements split on `;`, key and value split on `=`, and
10
+ * the signed payload is the timestamp, **a colon**, and the exact received body. HMAC-SHA256, hex,
11
+ * keyed with the notification destination's `pdl_ntfset_…` secret.
12
+ *
13
+ * ## Why this is a second verifier and not a widened core primitive
14
+ *
15
+ * The issue that scoped this work said Paddle "fits the existing timestamped shape of
16
+ * `requireSignedWebhook` with no new scheme." It does not, and the difference is two characters that
17
+ * decide whether anything is verified at all. `packages/core/src/http/signedWebhook.ts` splits the header
18
+ * on `,` and builds its signed payload as `` `${timestamp}.${body}` `` — Stripe's format. Paddle uses `;`
19
+ * and `:`. Neither is a parameter, and the module doc there argues *deliberately* that the signed payload
20
+ * must never become a caller-supplied template, because a payload a caller can shape is a payload a caller
21
+ * can shape into one that matches.
22
+ *
23
+ * So there were two options, and this is the one taken out loud: generalize the core primitive — editing a
24
+ * package this issue does not scope, against its own written reasoning — or write the scheme here, as the
25
+ * Lemon Squeezy rail already did for its own. This is the sibling precedent, and it keeps the change inside
26
+ * `@pithy-sh/payments`.
27
+ *
28
+ * The cost is real and worth naming: two modules now know how to check an HMAC. What they do not share is
29
+ * a *format*, which is the part that would have had to become configurable, and a configurable format is
30
+ * how #125 shipped a `requireSignedWebhook("lemonSqueezy", …)` that structurally could not verify the
31
+ * signatures it claimed to.
32
+ *
33
+ * ## The freshness window, and why it is 300 seconds and not 5
34
+ *
35
+ * Paddle's own SDKs default to five seconds. This defaults to three hundred, and the reason is what
36
+ * replay protection actually rests on here: `UNIQUE (rail, providerEventId)` over `evt_…`, in the webhook
37
+ * guard, which is absolute. A captured delivery replayed a year later is refused because its event id is
38
+ * already recorded — no window is doing that work. What a five-second window *does* do is convert ordinary
39
+ * clock skew between a Cloudflare edge and Paddle into a dropped renewal.
40
+ *
41
+ * The window still earns its place, in both directions. It bounds how long a captured-but-never-delivered
42
+ * signature stays usable, and it refuses a far-future timestamp — either a broken clock or somebody trying
43
+ * to mint a delivery that never goes stale. Configurable, so an adopter who wants Paddle's number can set
44
+ * it.
45
+ *
46
+ * Paddle re-signs each retry attempt with a fresh `ts`, which is the only way a three-day retry window and
47
+ * a five-second SDK tolerance could both be coherent — so the window does not silently reject retries.
48
+ *
49
+ * ## What a refusal says
50
+ *
51
+ * `payments/verification_failed`, with the reason in `detail` and nothing the delivery carried — not the
52
+ * body, not the candidate signature, never the secret. The webhook guard maps this to
53
+ * `payments/webhook_unverified` (401) before anything reaches the sender, so a forger learns only that it
54
+ * failed.
55
+ */
56
+
57
+ /** The header Paddle puts its proof in. Lower case, because that is how Hono presents a header name. */
58
+ export const PADDLE_SIGNATURE_HEADER = "paddle-signature";
59
+
60
+ /** How long a delivery stays acceptable, by default. See the module doc for why this is not Paddle's 5. */
61
+ export const PADDLE_DEFAULT_FRESHNESS_SECONDS = 300;
62
+
63
+ /** How many bytes an HMAC-SHA256 is. A candidate of any other length cannot be one, whatever it decodes to. */
64
+ const HMAC_SHA256_BYTES = 32;
65
+
66
+ /**
67
+ * How many `h1` values a header may list.
68
+ *
69
+ * Paddle lists more than one only while a destination's secret is being rotated, which pairs the old and
70
+ * the new — so two is the real number and this is generous. It is bounded at all because each listed
71
+ * candidate costs an HMAC: unbounded, the header would be a free way to make this Worker do arbitrary
72
+ * work. Core's primitive caps its own list for the same reason.
73
+ */
74
+ const MAX_SIGNATURES = 5;
75
+
76
+ /** Why a delivery was refused. Three members: this scheme carries a timestamp, so `stale` is real here. */
77
+ type PaddleRefusal = "unreadable" | "stale" | "unmatched";
78
+
79
+ /** What the verifier needs beyond the bytes: the secret, the clock, and the window. */
80
+ export interface VerifyPaddleSignatureOptions {
81
+ /** The notification destination's signing secret — `pdl_ntfset_…`. Never logged. */
82
+ secret: string;
83
+ /** The clock, for the freshness window. Injected so verification is deterministic. */
84
+ now: Date;
85
+ /** How many seconds either side of `now` a delivery may be dated. Undefined uses the default. */
86
+ toleranceSeconds?: number;
87
+ }
88
+
89
+ /** One parsed header: when Paddle says it signed, and every candidate MAC it listed. */
90
+ interface ParsedSignature {
91
+ /** The `ts` value, as seconds since the epoch. */
92
+ timestamp: number;
93
+ /** Every `h1` value, decoded. */
94
+ candidates: Uint8Array[];
95
+ }
96
+
97
+ /** Decode lower- or upper-case hex, or `undefined` when it is not hex at all. */
98
+ function hexBytes(value: string): Uint8Array | undefined {
99
+ if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(value)) return undefined;
100
+ const bytes = new Uint8Array(value.length / 2);
101
+ for (let index = 0; index < bytes.length; index += 1) {
102
+ bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
103
+ }
104
+ return bytes;
105
+ }
106
+
107
+ /** Import a signing secret for HMAC-SHA256. */
108
+ async function signingKey(secret: string, usage: "sign" | "verify"): Promise<CryptoKey> {
109
+ return await crypto.subtle.importKey(
110
+ "raw",
111
+ new TextEncoder().encode(secret),
112
+ { name: "HMAC", hash: "SHA-256" },
113
+ false,
114
+ [usage],
115
+ );
116
+ }
117
+
118
+ /**
119
+ * The signature Paddle would send for these bytes at this second — lower-case hex.
120
+ *
121
+ * Exported for the tests and the fixtures, which mint their own so verification is exercised for real
122
+ * rather than stubbed. Nothing in the request path signs anything: this rail only ever verifies.
123
+ */
124
+ export async function signPaddleBody(timestamp: number, body: string, secret: string): Promise<string> {
125
+ const mac = await crypto.subtle.sign(
126
+ "HMAC",
127
+ await signingKey(secret, "sign"),
128
+ // The colon, and it is the whole difference from Stripe's scheme. A period here would produce a MAC
129
+ // that never matches, and a verifier that never matches is a rail that never receives a webhook.
130
+ new TextEncoder().encode(`${timestamp}:${body}`),
131
+ );
132
+ return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
133
+ }
134
+
135
+ /**
136
+ * Parse the header into a timestamp and its candidate MACs, or `undefined` when it is not one.
137
+ *
138
+ * Strict about shape on purpose. Everything here is attacker-controlled text, and a lenient parse is how
139
+ * a verifier ends up comparing an empty candidate against an empty expectation and returning happily.
140
+ */
141
+ function parse(header: string): ParsedSignature | undefined {
142
+ let timestamp: number | undefined;
143
+ const candidates: Uint8Array[] = [];
144
+
145
+ for (const element of header.split(";")) {
146
+ const at = element.indexOf("=");
147
+ if (at <= 0) continue;
148
+ const key = element.slice(0, at).trim();
149
+ const value = element.slice(at + 1).trim();
150
+ if (value === "") continue;
151
+
152
+ if (key === "ts") {
153
+ // The first `ts` wins, and a second one is not merged: a header listing two timestamps is not a
154
+ // shape Paddle produces, and picking either would be guessing at what was signed.
155
+ if (timestamp !== undefined) return undefined;
156
+ if (!/^\d+$/.test(value)) return undefined;
157
+ timestamp = Number.parseInt(value, 10);
158
+ if (!Number.isSafeInteger(timestamp)) return undefined;
159
+ continue;
160
+ }
161
+
162
+ if (key === "h1") {
163
+ // Refused rather than truncated. Silently taking the first five of a hundred would leave a header
164
+ // that costs bounded work and reads as accepted, which hides the abuse it exists to stop.
165
+ if (candidates.length >= MAX_SIGNATURES) return undefined;
166
+ const bytes = hexBytes(value);
167
+ if (bytes === undefined || bytes.length !== HMAC_SHA256_BYTES) return undefined;
168
+ candidates.push(bytes);
169
+ }
170
+ }
171
+
172
+ if (timestamp === undefined || candidates.length === 0) return undefined;
173
+ return { timestamp, candidates };
174
+ }
175
+
176
+ /**
177
+ * Verify one delivery's `Paddle-Signature`. Resolves when Paddle signed these exact bytes at a moment
178
+ * inside the window, and throws `payments/verification_failed` otherwise.
179
+ *
180
+ * The comparison is `crypto.subtle.verify`, never `===` on the hex and never a hand-written loop: the
181
+ * runtime's own compare is constant-time, and a string equality on a MAC leaks how many leading bytes an
182
+ * attacker got right.
183
+ *
184
+ * The freshness check runs **before** the MACs. A stale delivery is refused whatever it carries, and
185
+ * refusing it first means an expired capture does not buy an attacker even one HMAC of work.
186
+ */
187
+ export async function verifyPaddleSignature(
188
+ body: string,
189
+ header: string | null,
190
+ options: VerifyPaddleSignatureOptions,
191
+ ): Promise<void> {
192
+ const parsed = header === null ? undefined : parse(header.trim());
193
+ if (parsed === undefined) throw new PaymentsVerificationFailedError({ detail: refusal("unreadable") });
194
+
195
+ const tolerance = options.toleranceSeconds ?? PADDLE_DEFAULT_FRESHNESS_SECONDS;
196
+ const drift = Math.abs(Math.floor(options.now.getTime() / 1000) - parsed.timestamp);
197
+ if (drift > tolerance) throw new PaymentsVerificationFailedError({ detail: refusal("stale") });
198
+
199
+ // The timestamp is inside the signed message, so a captured signature cannot be re-dated to slip
200
+ // through the window above: editing `ts` breaks the MAC checked here.
201
+ const key = await signingKey(options.secret, "verify");
202
+ const message = new TextEncoder().encode(`${parsed.timestamp}:${body}`);
203
+ for (const candidate of parsed.candidates) {
204
+ if (await crypto.subtle.verify("HMAC", key, candidate, message)) return;
205
+ }
206
+ throw new PaymentsVerificationFailedError({ detail: refusal("unmatched") });
207
+ }
208
+
209
+ /**
210
+ * This rail's wording for its three refusals.
211
+ *
212
+ * Prefixed `Paddle:` because a payments Worker composes several rails and `detail` is what an operator
213
+ * reads to find out which one refused. The strings say what failed and what to check, and nothing the
214
+ * delivery carried — `detail` reaches a log, and a forger must not be able to write into one.
215
+ */
216
+ function refusal(reason: PaddleRefusal): string {
217
+ switch (reason) {
218
+ case "unreadable":
219
+ return "Paddle: the delivery carries no Paddle-Signature header of the form ts=<seconds>;h1=<64 hex chars>.";
220
+ case "stale":
221
+ return "Paddle: the delivery's ts is outside the freshness window. Check this Worker's clock, or widen `paddle.webhookFreshnessSeconds`.";
222
+ case "unmatched":
223
+ return "Paddle: no h1 matches an HMAC-SHA256 of `ts:body` under the configured signing secret. Check that the secret belongs to this notification destination and this environment.";
224
+ }
225
+ }