@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.
- package/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/paddle-prices.iife.js +1 -0
- package/dist/pithy.manifest.json +68 -0
- package/docs/lemon-squeezy.md +167 -0
- package/docs/paddle.md +486 -0
- package/package.json +73 -0
- package/pithy.manifest.json +68 -0
- package/src/admin/coverage.ts +107 -0
- package/src/admin/read.ts +360 -0
- package/src/audit/actions.ts +214 -0
- package/src/capability.ts +377 -0
- package/src/client/api.ts +734 -0
- package/src/client/checkout.ts +252 -0
- package/src/client/fixtures/price-preview-de.json +54 -0
- package/src/client/fixtures/price-preview-gb.json +54 -0
- package/src/client/fixtures/price-preview-jp-yen.json +54 -0
- package/src/client/fixtures/price-preview-us-country-only.json +54 -0
- package/src/client/fixtures/price-preview-us-new-york.json +54 -0
- package/src/client/fixtures/pricePreview.ts +67 -0
- package/src/client/hooks.ts +590 -0
- package/src/client/paddle.ts +733 -0
- package/src/client/paddleCache.ts +265 -0
- package/src/client/paddlePrices.iife.ts +43 -0
- package/src/client/paddlePrices.ts +143 -0
- package/src/client/paddlePricesTag.ts +332 -0
- package/src/client/projection.ts +133 -0
- package/src/client/test-utils/cacheStore.ts +41 -0
- package/src/client/wholeUnits.ts +147 -0
- package/src/cloudflare-test.d.ts +16 -0
- package/src/config/config.ts +563 -0
- package/src/data/discount.ts +238 -0
- package/src/data/entitlement.ts +67 -0
- package/src/data/money.ts +75 -0
- package/src/data/pause.ts +148 -0
- package/src/data/providerAccount.ts +47 -0
- package/src/data/purchase.ts +131 -0
- package/src/data/rail.ts +74 -0
- package/src/data/reconcileRun.ts +208 -0
- package/src/data/renderMoney.ts +180 -0
- package/src/data/status.ts +67 -0
- package/src/data/subject.ts +148 -0
- package/src/data/subscription.ts +675 -0
- package/src/data/syncCursor.ts +44 -0
- package/src/data/tables.ts +61 -0
- package/src/data/webhookEvent.ts +196 -0
- package/src/entitlement/manual.ts +209 -0
- package/src/entitlement/resolver.ts +125 -0
- package/src/entitlement/subjectSeam.ts +171 -0
- package/src/error/errors.ts +421 -0
- package/src/grants/apply.ts +207 -0
- package/src/grants/clawback.ts +128 -0
- package/src/grants/ledgerSeam.ts +141 -0
- package/src/http/dispatch.ts +48 -0
- package/src/http/guards.ts +58 -0
- package/src/http/manifestConfig.ts +56 -0
- package/src/http/responses.ts +1147 -0
- package/src/http/routes.ts +2911 -0
- package/src/http/schemas.ts +577 -0
- package/src/http/scopes.ts +260 -0
- package/src/http/view.ts +188 -0
- package/src/http/webhookGuard.ts +341 -0
- package/src/index.ts +231 -0
- package/src/migrations/0001_purchases.ts +312 -0
- package/src/pricing/location.ts +175 -0
- package/src/pricing/visitor.ts +82 -0
- package/src/projection/event.ts +101 -0
- package/src/projection/orphans.ts +226 -0
- package/src/projection/owner.ts +247 -0
- package/src/projection/resolve.ts +57 -0
- package/src/projection/writer.ts +554 -0
- package/src/provision/resolvePaymentsConfig.ts +91 -0
- package/src/rails/apple/certs.ts +66 -0
- package/src/rails/apple/der.ts +249 -0
- package/src/rails/apple/fixtures/chain.ts +412 -0
- package/src/rails/apple/fixtures/did-fail-to-renew-grace.json +43 -0
- package/src/rails/apple/fixtures/did-renew.json +48 -0
- package/src/rails/apple/fixtures/one-time-charge.json +32 -0
- package/src/rails/apple/fixtures/refund.json +33 -0
- package/src/rails/apple/fixtures/subscribed-initial-buy-sandbox.json +38 -0
- package/src/rails/apple/fixtures/test.json +14 -0
- package/src/rails/apple/http.ts +128 -0
- package/src/rails/apple/jws.ts +123 -0
- package/src/rails/apple/notification.ts +357 -0
- package/src/rails/apple/rail.ts +114 -0
- package/src/rails/apple/serverApi.ts +412 -0
- package/src/rails/apple/verify.ts +93 -0
- package/src/rails/apple/x509.ts +424 -0
- package/src/rails/contract.ts +939 -0
- package/src/rails/google/fixtures/play-product-purchased.json +14 -0
- package/src/rails/google/fixtures/play-subscription-active.json +23 -0
- package/src/rails/google/fixtures/push.ts +152 -0
- package/src/rails/google/fixtures/rtdn-one-time-purchased.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-renewed.json +11 -0
- package/src/rails/google/fixtures/rtdn-subscription-revoked.json +11 -0
- package/src/rails/google/fixtures/rtdn-test.json +8 -0
- package/src/rails/google/fixtures/rtdn-voided-purchase.json +11 -0
- package/src/rails/google/http.ts +124 -0
- package/src/rails/google/jwt.ts +127 -0
- package/src/rails/google/oidc.ts +308 -0
- package/src/rails/google/playApi.ts +598 -0
- package/src/rails/google/rail.ts +174 -0
- package/src/rails/google/rtdn.ts +331 -0
- package/src/rails/google/verify.ts +127 -0
- package/src/rails/lemonSqueezy/api.ts +192 -0
- package/src/rails/lemonSqueezy/checkout.ts +186 -0
- package/src/rails/lemonSqueezy/discounts.ts +166 -0
- package/src/rails/lemonSqueezy/fixtures/events.ts +180 -0
- package/src/rails/lemonSqueezy/objects.ts +511 -0
- package/src/rails/lemonSqueezy/portal.ts +82 -0
- package/src/rails/lemonSqueezy/pricing.ts +124 -0
- package/src/rails/lemonSqueezy/rail.ts +137 -0
- package/src/rails/lemonSqueezy/read.ts +72 -0
- package/src/rails/lemonSqueezy/refresh.ts +86 -0
- package/src/rails/lemonSqueezy/signature.ts +119 -0
- package/src/rails/lemonSqueezy/verify.ts +45 -0
- package/src/rails/lemonSqueezy/webhook.ts +326 -0
- package/src/rails/paddle/adjustments.ts +94 -0
- package/src/rails/paddle/api.ts +266 -0
- package/src/rails/paddle/checkout.ts +240 -0
- package/src/rails/paddle/discounts.ts +176 -0
- package/src/rails/paddle/events.ts +285 -0
- package/src/rails/paddle/fixtures/browserForged.ts +54 -0
- package/src/rails/paddle/fixtures/pausedSubscription.ts +62 -0
- package/src/rails/paddle/objects.ts +883 -0
- package/src/rails/paddle/portal.ts +130 -0
- package/src/rails/paddle/rail.ts +219 -0
- package/src/rails/paddle/read.ts +108 -0
- package/src/rails/paddle/recorded.ts +83 -0
- package/src/rails/paddle/refresh.ts +153 -0
- package/src/rails/paddle/refund.ts +297 -0
- package/src/rails/paddle/signature.ts +225 -0
- package/src/rails/paddle/subscription.ts +793 -0
- package/src/rails/paddle/verify.ts +120 -0
- package/src/rails/paddle/webhook.ts +405 -0
- package/src/rails/providers.ts +168 -0
- package/src/rails/stripe/api.ts +253 -0
- package/src/rails/stripe/checkout.ts +175 -0
- package/src/rails/stripe/discounts.ts +169 -0
- package/src/rails/stripe/fixtures/event-charge-refunded.json +23 -0
- package/src/rails/stripe/fixtures/event-invoice-paid.json +19 -0
- package/src/rails/stripe/fixtures/event-session-completed-payment.json +27 -0
- package/src/rails/stripe/fixtures/event-session-completed-subscription.json +27 -0
- package/src/rails/stripe/fixtures/event-subscription-canceled.json +43 -0
- package/src/rails/stripe/fixtures/event-subscription-created.json +42 -0
- package/src/rails/stripe/fixtures/event-subscription-deleted.json +44 -0
- package/src/rails/stripe/fixtures/events.ts +111 -0
- package/src/rails/stripe/objects.ts +642 -0
- package/src/rails/stripe/portal.ts +45 -0
- package/src/rails/stripe/pricing.ts +123 -0
- package/src/rails/stripe/rail.ts +139 -0
- package/src/rails/stripe/refresh.ts +79 -0
- package/src/rails/stripe/signature.ts +127 -0
- package/src/rails/stripe/verify.ts +105 -0
- package/src/rails/stripe/webhook.ts +79 -0
- package/src/secret/registry.ts +275 -0
- package/src/seeds/example.ts +181 -0
- package/src/version.generated.ts +16 -0
- package/src/workflows/paddleSweep.ts +717 -0
- package/src/workflows/railAccess.ts +87 -0
- package/src/workflows/reconcile.ts +648 -0
- package/src/workflows/report.ts +64 -0
- package/src/workflows/retryPolicy.ts +40 -0
- package/src/workflows/specs.ts +143 -0
- package/src/workflows/worker.ts +166 -0
- package/src/workflows/workerConfig.ts +73 -0
- 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
|
+
}
|