@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,266 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { PaymentsProviderUnavailableError, PaymentsRailNotConfiguredError } from "../../error/errors";
6
+
7
+ /**
8
+ * The one door out to Paddle, and the mapping from how it answered to what that means.
9
+ *
10
+ * Reached with `fetch` and JSON rather than through `@paddle/paddle-node-sdk`, for the reason
11
+ * `stripe/api.ts` and `lemonSqueezy/api.ts` both give: a handful of calls are needed and each is one
12
+ * request. Every other third-party API in this repo is spoken to the same way — `fetch` out, Zod in — and
13
+ * a Node SDK in a Workers bundle buys nothing but weight.
14
+ *
15
+ * **Paddle Billing, not Paddle Classic.** `api.paddle.com` in production and `sandbox-api.paddle.com` in
16
+ * sandbox, with `Paddle-Version: 1` pinned on every request so a future default at Paddle cannot silently
17
+ * reshape a response this package parses.
18
+ *
19
+ * ## How an answer is read
20
+ *
21
+ * **A 5xx or a 429 is `payments/provider_unavailable` (503).** Paddle is up but not answering. The caller
22
+ * retries, and on the webhook path that code passes through the guard unchanged so Paddle redelivers
23
+ * rather than being told its signature was bad.
24
+ *
25
+ * **Every other 4xx is `payments/rail_not_configured` (404).** A 4xx here is never the buyer's fault:
26
+ * these requests are built from config, from the credential bundle, and from rows we wrote. A rejected
27
+ * API key, a price that is not in this account, a customer that is not ours — one statement, that this
28
+ * project's Paddle rail is not set up to do what it was asked.
29
+ *
30
+ * **A 404 is an absent resource only for a caller that said it was probing.** `absentOn404` is how a
31
+ * refresh distinguishes "Paddle no longer knows this subscription" — a normal answer, which the contract
32
+ * says is `undefined` — from "our API key is wrong". Only a caller that knows the difference may claim it.
33
+ *
34
+ * ## Two of Paddle's refusals are the adopter's account rather than their credentials
35
+ *
36
+ * `transactions.create` refuses account-wide with *"Cannot create a transaction or open a checkout as no
37
+ * default payment link has been set for this account"* until somebody sets one in the Paddle dashboard.
38
+ * Verified live against the assigned sandbox, where it is still true. It arrives as an ordinary 4xx and
39
+ * would read as "the rail is misconfigured", which is correct but useless — so {@link paddleSaid} keeps
40
+ * Paddle's own sentence in `detail`, and `pithy doctor` asks the same question before a buyer does.
41
+ *
42
+ * **Nothing in a refusal carries the API key.** `detail` is written to an operator's logs, so anything
43
+ * key-shaped in a message is redacted before it gets there.
44
+ */
45
+
46
+ /** Paddle's production API. Public and stable, so it is pinned rather than configured. */
47
+ export const PADDLE_API_BASE = "https://api.paddle.com";
48
+
49
+ /** Paddle's sandbox API. A separate host, not a flag — sandbox and live are separate accounts entirely. */
50
+ export const PADDLE_SANDBOX_API_BASE = "https://sandbox-api.paddle.com";
51
+
52
+ /**
53
+ * The API version every request pins.
54
+ *
55
+ * Sent on every call so a future default at Paddle cannot reshape a response this package's schemas parse
56
+ * — the failure would be a webhook that stops projecting, discovered by a customer.
57
+ */
58
+ export const PADDLE_API_VERSION = "1";
59
+
60
+ /** Which Paddle account a deployment sells through. Two hosts, two sets of credentials, one code path. */
61
+ export type PaddleEnvironment = "sandbox" | "production";
62
+
63
+ /** The base URL for an environment. */
64
+ export function paddleApiBase(environment: PaddleEnvironment): string {
65
+ return environment === "production" ? PADDLE_API_BASE : PADDLE_SANDBOX_API_BASE;
66
+ }
67
+
68
+ /** One outbound request, narrowed to what this rail's endpoints need. */
69
+ export interface PaddleHttpRequest {
70
+ /** The HTTP method. */
71
+ method?: string;
72
+ /** Request headers — the bearer key, the pinned version, and any idempotency key. */
73
+ headers?: Record<string, string>;
74
+ /** The JSON body, on a POST. */
75
+ body?: string;
76
+ }
77
+
78
+ /** What this rail needs of a response. Structural, so a test can answer without building a `Response`. */
79
+ export interface PaddleHttpResponse {
80
+ /** Whether the status is 2xx. */
81
+ ok: boolean;
82
+ /** The HTTP status. */
83
+ status: number;
84
+ /** The body as text — read once, then parsed here, so a non-JSON body is reportable rather than a throw. */
85
+ text(): Promise<string>;
86
+ }
87
+
88
+ /** The HTTP seam. One explicit parameter defaulting to the runtime's `fetch`, as every rail has. */
89
+ export type PaddleHttpFetch = (url: string, init?: PaddleHttpRequest) => Promise<PaddleHttpResponse>;
90
+
91
+ /** The runtime's own `fetch`, adapted to the seam. */
92
+ export const paddleHttpFetch: PaddleHttpFetch = (url, init) =>
93
+ fetch(url, init as RequestInit) as unknown as Promise<PaddleHttpResponse>;
94
+
95
+ /** Paddle's error envelope, of which only the human parts are worth reporting. */
96
+ const PaddleApiError = z.object({
97
+ error: z
98
+ .object({
99
+ type: z.string().optional(),
100
+ code: z.string().optional(),
101
+ detail: z.string().optional(),
102
+ })
103
+ .loose(),
104
+ });
105
+
106
+ /** A Paddle success envelope: the entity under `data`, and pagination under `meta` for a list. */
107
+ const PaddleEnvelope = z
108
+ .object({
109
+ data: z.unknown(),
110
+ meta: z
111
+ .object({ pagination: z.object({ has_more: z.boolean().optional() }).loose().optional() })
112
+ .loose()
113
+ .optional(),
114
+ })
115
+ .loose();
116
+
117
+ /** What one call needs: what it is for, the key, and optionally a body, a query, or a probe flag. */
118
+ export interface PaddleJsonOptions {
119
+ /** What was being asked for, in an operator's words. Lands in every refusal's `detail`. */
120
+ what: string;
121
+ /** The Paddle API key — `pdl_live_apikey_…` or `pdl_sdbx_apikey_…`. */
122
+ apiKey: string;
123
+ /** Which account to reach. Decides the host, and nothing else. */
124
+ environment: PaddleEnvironment;
125
+ /** The HTTP method. Defaults to GET, or POST when a body is present. */
126
+ method?: string;
127
+ /** The JSON request body. */
128
+ body?: unknown;
129
+ /**
130
+ * The query string as pairs, appended in order.
131
+ *
132
+ * Pairs rather than a record so order is this caller's to decide and a key may legitimately repeat.
133
+ * **An array parameter is not one of those cases.** Paddle documents every `array[string]` parameter as
134
+ * one key with comma-separated values — `event_type=a,b,c`, `include=x,y` — and a repeated key is a form
135
+ * it documents nowhere. Build the comma-separated value at the call site.
136
+ */
137
+ query?: readonly (readonly [string, string])[];
138
+ /**
139
+ * An idempotency key, so a request Paddle already performed is not performed twice.
140
+ *
141
+ * The one place this matters is checkout: a double-submitted buy button must create one transaction,
142
+ * not two, and Paddle's own header is what guarantees that rather than a check of ours.
143
+ */
144
+ idempotencyKey?: string;
145
+ /** Whether a 404 means "no such object" rather than "this rail is misconfigured". */
146
+ absentOn404?: boolean;
147
+ }
148
+
149
+ /** One Paddle answer: the entity, and whether the list it came from has more. */
150
+ export interface PaddleAnswer {
151
+ /** The `data` member, unnarrowed — the caller parses it with its own Zod object. */
152
+ data: unknown;
153
+ /** Whether a paginated list has another page. False for a single entity. */
154
+ hasMore: boolean;
155
+ }
156
+
157
+ /**
158
+ * One call to Paddle. Returns the parsed envelope, or `undefined` for a probing caller's 404.
159
+ *
160
+ * `unknown` inside rather than a typed answer: what a subscription looks like is `objects.ts`'s business
161
+ * and not this module's.
162
+ */
163
+ export async function paddleJson(
164
+ transport: PaddleHttpFetch,
165
+ path: string,
166
+ options: PaddleJsonOptions,
167
+ ): Promise<PaddleAnswer | undefined> {
168
+ const search = new URLSearchParams();
169
+ for (const [key, value] of options.query ?? []) search.append(key, value);
170
+ const query = [...search].length === 0 ? "" : `?${search.toString()}`;
171
+
172
+ const headers: Record<string, string> = {
173
+ authorization: `Bearer ${options.apiKey}`,
174
+ accept: "application/json",
175
+ "paddle-version": PADDLE_API_VERSION,
176
+ };
177
+ if (options.body !== undefined) headers["content-type"] = "application/json";
178
+ if (options.idempotencyKey !== undefined) headers["paddle-idempotency-key"] = options.idempotencyKey;
179
+
180
+ let response: PaddleHttpResponse;
181
+ try {
182
+ response = await transport(`${paddleApiBase(options.environment)}${path}${query}`, {
183
+ method: options.method ?? (options.body === undefined ? "GET" : "POST"),
184
+ headers,
185
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
186
+ });
187
+ } catch (cause) {
188
+ throw new PaymentsProviderUnavailableError(
189
+ { detail: `Paddle did not answer when asked for ${options.what}.` },
190
+ { cause },
191
+ );
192
+ }
193
+
194
+ const text = await response.text();
195
+
196
+ if (!response.ok) {
197
+ if (response.status === 404 && options.absentOn404) return undefined;
198
+ throw refusal(response.status, text, options);
199
+ }
200
+
201
+ let parsed: unknown;
202
+ try {
203
+ parsed = JSON.parse(text) as unknown;
204
+ } catch (cause) {
205
+ throw new PaymentsProviderUnavailableError(
206
+ { detail: `Paddle answered with a non-JSON body when asked for ${options.what}.` },
207
+ { cause },
208
+ );
209
+ }
210
+
211
+ const envelope = PaddleEnvelope.safeParse(parsed);
212
+ if (!envelope.success) {
213
+ throw new PaymentsProviderUnavailableError({
214
+ detail: `Paddle answered for ${options.what} with a body carrying no \`data\`.`,
215
+ });
216
+ }
217
+ return { data: envelope.data.data, hasMore: envelope.data.meta?.pagination?.has_more === true };
218
+ }
219
+
220
+ /** The refusal for a non-2xx answer: retryable if Paddle is struggling, a configuration failure otherwise. */
221
+ function refusal(status: number, body: string, options: PaddleJsonOptions): PaymentsProviderUnavailableError {
222
+ if (status === 429 || status >= 500) {
223
+ return new PaymentsProviderUnavailableError({
224
+ detail: `Paddle answered ${status} when asked for ${options.what}. ${paddleSaid(body)}`,
225
+ });
226
+ }
227
+
228
+ const key = status === 401 || status === 403 ? " Check the API key stored for this environment." : "";
229
+ return new PaymentsRailNotConfiguredError({
230
+ detail: `Paddle refused the request for ${options.what} with ${status}.${key} ${paddleSaid(body)}`,
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Paddle's own account of the failure, redacted. Empty when the body was not its error envelope.
236
+ *
237
+ * Worth carrying rather than dropping, because Paddle's most consequential refusal is a sentence and not
238
+ * a code: *"Cannot create a transaction or open a checkout as no default payment link has been set for
239
+ * this account."* Without it an operator sees a 400 on `/transactions` and has nothing to act on.
240
+ */
241
+ export function paddleSaid(body: string): string {
242
+ let parsed: unknown;
243
+ try {
244
+ parsed = JSON.parse(body) as unknown;
245
+ } catch {
246
+ return "";
247
+ }
248
+ const envelope = PaddleApiError.safeParse(parsed);
249
+ if (!envelope.success) return "";
250
+ const parts = [envelope.data.error.code, envelope.data.error.detail].filter(
251
+ (part): part is string => part !== undefined && part !== "",
252
+ );
253
+ return parts.length === 0 ? "" : `Paddle said: ${redactPaddleSecrets(parts.join(" / "))}.`;
254
+ }
255
+
256
+ /**
257
+ * Blank out anything key-shaped in text bound for a log.
258
+ *
259
+ * Paddle's credentials carry fixed prefixes, which makes this sharper than the Lemon Squeezy equivalent:
260
+ * an API key is `pdl_live_apikey_…` or `pdl_sdbx_apikey_…`, a destination secret is `pdl_ntfset_…`, and a
261
+ * client token is `live_…` or `test_…`. The token is publishable, but a log is not where it belongs
262
+ * either, and a rule with an exception is a rule somebody eventually reads wrong.
263
+ */
264
+ export function redactPaddleSecrets(text: string): string {
265
+ return text.replace(/\bpdl_[A-Za-z0-9_]+/g, "…").replace(/\b(?:live|test)_[A-Za-z0-9]{16,}\b/g, "…");
266
+ }
@@ -0,0 +1,240 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PithyError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { z } from "zod";
6
+ import { encodeSubjectReference } from "../../data/subject";
7
+ import { PaymentsDiscountInvalidError, PaymentsRailNotConfiguredError } from "../../error/errors";
8
+ import type { PaymentsPaddleCredentials } from "../../secret/registry";
9
+ import type { CheckoutHandoff, CheckoutSessionInput } from "../contract";
10
+ import { type PaddleEnvironment, type PaddleHttpFetch, paddleHttpFetch, paddleJson } from "./api";
11
+ import {
12
+ accountReferenceProof,
13
+ PADDLE_CUSTOM_ACCOUNT,
14
+ PADDLE_CUSTOM_ENV,
15
+ PADDLE_CUSTOM_PROOF,
16
+ PaddleTransaction,
17
+ } from "./objects";
18
+
19
+ /**
20
+ * Checkout: one server path, three ways of handing it to a browser.
21
+ *
22
+ * The server does the same thing in every mode — `POST /transactions` with the price from the catalog,
23
+ * the customer when the buyer has one, the ownership stamp, and the resolved discount. What differs is
24
+ * only the handoff: `hosted` returns `transaction.checkout.url`, and `overlay` and `inline` return the
25
+ * transaction for Paddle.js to open in place.
26
+ *
27
+ * ## What this call stamps, and why each of them
28
+ *
29
+ * **The resolved subject, in `custom_data.pithy_user`.** A Paddle purchase is heard about through a webhook
30
+ * carrying `ctm_…` and no Pithy holder. This is the pairing, and it is why the `/checkout` route resolves the
31
+ * subject through the configured seam and never from a request body.
32
+ *
33
+ * The value is `encodeSubjectReference`'s output — `user:ada`, `organization:acme` — and **both halves travel
34
+ * or neither does**. Nothing keeps an organization id from equalling some user's, so a bare id read back at
35
+ * the far end would eventually hand one holder's subscription to the other. A bare id is also exactly what
36
+ * this rail stamped before subjects existed, and `decodeSubjectReference` refuses one on purpose: the
37
+ * purchase orphans, replayable, rather than being attributed to a stranger. The key name is unchanged
38
+ * through all of that — see `objects.ts`.
39
+ *
40
+ * **This deployment's environment, beside it.** `dev` is not publicly routable, so a dev checkout's
41
+ * webhooks land at `staging`, and both point at one Paddle sandbox with one set of destinations. The
42
+ * stamp is what lets each ignore the other's traffic.
43
+ *
44
+ * **A MAC over both, which is the only part that makes either mean anything.** `Paddle.Checkout.open`
45
+ * accepts `customData` beside an `items[]` array of price ids with nothing but the publishable client
46
+ * token — the token this rail ships to every browser that loads a paywall. So a stranger can write the
47
+ * first two values. They cannot write the third. See `objects.ts`.
48
+ *
49
+ * ## The idempotency key
50
+ *
51
+ * `Paddle-Idempotency-Key`, derived from the buyer, the price and the deployment. A double-submitted buy
52
+ * button then creates one transaction rather than two — enforced by Paddle rather than by a check here,
53
+ * which is the only place it can be enforced, since the two requests may land on different isolates.
54
+ *
55
+ * ## What it deliberately does not do
56
+ *
57
+ * No quantity and no amount. The price *is* the price — that is Paddle's model — and a checkout that could
58
+ * name an amount would be a checkout a client could name an amount on.
59
+ *
60
+ * **No plan change either, and that is now a fact about this module rather than about the kit.** #79's
61
+ * locked decision 2 was amended on 2026-08-28: a rail may invoke a plan change and pass the provider's own
62
+ * figures through unmodified, and this rail does — `rails/paddle/subscription.ts`, behind
63
+ * {@link SubscriptionRail}. It is a different verb on a subscription that already exists, so it does not
64
+ * belong on the call that creates one. Checkout still computes no proration and no tax; nothing here
65
+ * derives an amount from another amount, and `rails/contract.ts` states the line in full.
66
+ */
67
+
68
+ /** What creating a checkout needs beyond the input. */
69
+ export interface PaddleCheckoutOptions {
70
+ /** The rail's credentials. `apiKey` creates the transaction; `webhookSecret` keys the ownership proof. */
71
+ credentials: PaymentsPaddleCredentials;
72
+ /** Which Paddle account to sell through. */
73
+ environment: PaddleEnvironment;
74
+ /** The publishable client token a browser initializes Paddle.js with. */
75
+ clientToken: string;
76
+ /** Which of the three modes this project uses. */
77
+ checkout: "overlay" | "inline" | "hosted";
78
+ /** This deployment's `ENVIRONMENT`, stamped so its own webhooks are recognizable. */
79
+ deployment?: string;
80
+ /** The HTTP seam. Defaults to the runtime's `fetch`. */
81
+ transport?: PaddleHttpFetch;
82
+ }
83
+
84
+ /** Paddle's discount list, narrowed to the id a transaction needs. */
85
+ const PaddleDiscountMatches = z.array(z.object({ id: z.string().min(1) }).loose());
86
+
87
+ /** Create a checkout for one product, and say how the browser reaches it. */
88
+ export async function createPaddleCheckoutSession(
89
+ input: CheckoutSessionInput,
90
+ options: PaddleCheckoutOptions,
91
+ ): Promise<CheckoutHandoff> {
92
+ const transport = options.transport ?? paddleHttpFetch;
93
+
94
+ // Resolved before the transaction is created, so an unusable code is refused as a *code* rather than
95
+ // surfacing later as a checkout that failed with nothing to say.
96
+ const discountId =
97
+ input.discountCode === undefined ? undefined : await resolveDiscount(input.discountCode, options, transport);
98
+
99
+ // One encoding, from the one function. The webhook reads it back through `decodeSubjectReference`, and a
100
+ // second spelling anywhere is a purchase stamped by one code path and read by another.
101
+ const accountReference = encodeSubjectReference(input.subject);
102
+
103
+ const custom: Record<string, string> = { [PADDLE_CUSTOM_ACCOUNT]: accountReference };
104
+ if (options.deployment !== undefined) {
105
+ custom[PADDLE_CUSTOM_ENV] = options.deployment;
106
+ custom[PADDLE_CUSTOM_PROOF] = await accountReferenceProof(
107
+ accountReference,
108
+ options.deployment,
109
+ options.credentials.webhookSecret,
110
+ );
111
+ }
112
+
113
+ const answer = await paddleJson(transport, "/transactions", {
114
+ what: "a transaction",
115
+ apiKey: options.credentials.apiKey,
116
+ environment: options.environment,
117
+ // Derived, not random. A retry of the same buy click must carry the same key or the header buys
118
+ // nothing; two different buyers, prices or deployments must differ or one would suppress the other.
119
+ //
120
+ // **The discount code is in the key**, and its absence was a defect rather than a simplification: a
121
+ // buyer who starts a checkout, closes it, types a code and starts again would otherwise be handed back
122
+ // the transaction created *before* the code — charged full price by an idempotency key that was doing
123
+ // exactly what it was asked to.
124
+ //
125
+ // **The buyer is the encoded pair, not the id**, for the same reason and with a sharper edge: an
126
+ // organization that shares an id with a user is a different buyer entirely, and a key derived from the
127
+ // id alone would hand the second one back the first one's transaction. Deriving it from the reference
128
+ // this call already stamped is what keeps the two definitions of "the buyer" from drifting apart.
129
+ idempotencyKey: `pithy:${options.deployment ?? "unknown"}:${accountReference}:${input.providerProductId}:${input.discountCode ?? ""}`,
130
+ body: {
131
+ items: [{ price_id: input.providerProductId, quantity: 1 }],
132
+ // Reuse the buyer's existing Paddle customer, so one buyer keeps one account and their portal shows
133
+ // everything they have bought rather than only the last thing.
134
+ ...(input.providerAccountId ? { customer_id: input.providerAccountId } : {}),
135
+ ...(discountId === undefined ? {} : { discount_id: discountId }),
136
+ // Automatic, always. Manual collection raises an invoice and waits, which is a different product
137
+ // and one this rail does not sell through.
138
+ collection_mode: "automatic",
139
+ custom_data: custom,
140
+ },
141
+ });
142
+
143
+ const parsed = PaddleTransaction.safeParse(answer?.data);
144
+ if (!parsed.success) {
145
+ throw new PaymentsRailNotConfiguredError({
146
+ detail:
147
+ "Paddle created a transaction this build cannot read. Check `Paddle-Version: 1` against the account's default.",
148
+ });
149
+ }
150
+ const transaction = parsed.data;
151
+
152
+ if (options.checkout !== "hosted") {
153
+ return {
154
+ kind: "paddle",
155
+ transactionId: transaction.id,
156
+ clientToken: options.clientToken,
157
+ environment: options.environment,
158
+ displayMode: options.checkout,
159
+ // `config.paddle.successUrl` says of itself that it is "used as `settings.successUrl` for
160
+ // Paddle.js", and until this line nothing passed it — the overlay closed onto the page the buyer
161
+ // started from and the adopter's return screen was unreachable in two of the three modes. It
162
+ // travels on the handoff rather than being read in the browser for the reason every other return
163
+ // URL is config: a client that could name one could send a paying customer to a page it controls.
164
+ successUrl: input.successUrl,
165
+ };
166
+ }
167
+
168
+ const url = transaction.checkout?.url;
169
+ if (typeof url !== "string" || url === "") {
170
+ // Paddle returns a null `checkout.url` when the account has no default payment link. A checkout button
171
+ // that navigates nowhere is worse than a refusal naming the setting, and `pithy doctor` asks the same
172
+ // question before a buyer finds it.
173
+ throw new PaymentsRailNotConfiguredError({
174
+ detail: `Paddle created transaction ${transaction.id} with no checkout URL. Set a default payment link under Checkout → Checkout settings in the Paddle dashboard, or use \`paddle.checkout: "overlay"\`.`,
175
+ });
176
+ }
177
+ return { kind: "redirect", url };
178
+ }
179
+
180
+ /**
181
+ * Turn the code a customer typed into the discount id a transaction wants.
182
+ *
183
+ * Paddle's `discount_id` takes a `dsc_…`, not the customer-facing string, so this lookup is unavoidable.
184
+ * It is also where the code's validity is learned, and learning it *here* is what makes the refusal a good
185
+ * one: an unknown, expired or exhausted code is refused before a transaction exists, as
186
+ * `payments/discount_invalid` naming the code, rather than becoming a checkout that fails in front of the
187
+ * customer with nothing to say but that something went wrong.
188
+ *
189
+ * `status=active` is part of the query rather than a check afterwards, so an expired or exhausted code
190
+ * simply does not come back. Paddle decides what active means — nothing here reads `expires_at` or counts
191
+ * redemptions, for the same reason nothing here computes a discounted price.
192
+ *
193
+ * **One refusal for all three cases**, and that is a decision rather than an omission. Naming which of
194
+ * "no such code", "expired" and "limit reached" it was helps the buyer a little and tells an
195
+ * unauthenticated enumerator which codes exist, which is worth more to them.
196
+ */
197
+ async function resolveDiscount(
198
+ code: string,
199
+ options: PaddleCheckoutOptions,
200
+ transport: PaddleHttpFetch,
201
+ ): Promise<string> {
202
+ let answer: Awaited<ReturnType<typeof paddleJson>>;
203
+ try {
204
+ answer = await paddleJson(transport, "/discounts", {
205
+ what: `the discount code ${code}`,
206
+ apiKey: options.credentials.apiKey,
207
+ environment: options.environment,
208
+ query: [
209
+ ["code", code],
210
+ ["status", "active"],
211
+ ["per_page", "1"],
212
+ ],
213
+ });
214
+ } catch (cause) {
215
+ // Paddle refuses a malformed code outright rather than answering an empty list — its codes are
216
+ // `^[a-zA-Z0-9]{1,32}$`, so anything with a dash or an underscore is a 400 with a bare "Invalid
217
+ // request." Left alone that surfaces as `rail_not_configured`, which tells a customer this payment
218
+ // method is unavailable when what happened is that their code was not one Paddle accepts.
219
+ if (cause instanceof PithyError && cause.payload.code === "payments/rail_not_configured") {
220
+ throw new PaymentsDiscountInvalidError(
221
+ {
222
+ message: `"${code}" is not a discount code we can accept.`,
223
+ detail: `Paddle refused a lookup of discount code "${code}". ${cause.payload.detail ?? ""}`,
224
+ },
225
+ { cause },
226
+ );
227
+ }
228
+ throw cause;
229
+ }
230
+
231
+ const matches = PaddleDiscountMatches.safeParse(answer?.data);
232
+ const found = matches.success ? matches.data[0] : undefined;
233
+ if (found === undefined) {
234
+ throw new PaymentsDiscountInvalidError({
235
+ message: `"${code}" is not a discount code we can accept.`,
236
+ detail: `Paddle has no active discount matching "${code}" — unknown, expired, or fully redeemed.`,
237
+ });
238
+ }
239
+ return found.id;
240
+ }