@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,252 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { PAYMENTS_NO_BROWSER, type PaddleCheckoutHandoff, type PaymentsFailure } from "./api";
5
+ import {
6
+ loadPaddle,
7
+ type PaddleCheckoutSettings,
8
+ type PaddleCheckoutTheme,
9
+ type PaddleCheckoutVariant,
10
+ type PaddleOptions,
11
+ } from "./paddle";
12
+
13
+ /**
14
+ * Opening a Paddle checkout over the adopter's own page, or inside it.
15
+ *
16
+ * This is the half of the rail a redirect cannot do. `startCheckout` on a redirect rail sends the browser
17
+ * to somebody else's domain and the sale finishes there; Paddle's overlay draws the card form over the
18
+ * page the buyer was already on, and its inline mode draws it *in* the page, in a container the screen
19
+ * put there. Nothing about the money moves — Paddle is still the merchant of record, still owns SCA, tax
20
+ * and the card fields — but the buyer never leaves.
21
+ *
22
+ * ## `items[]` or `transactionId`, settled
23
+ *
24
+ * Paddle offers two ways to open a checkout. `items: [{ priceId, quantity }]` needs no server at all: a
25
+ * page with the publishable client token can open a checkout in one click, which is the single most
26
+ * pleasant thing about this provider. `transactionId` needs a round trip first, because a transaction is
27
+ * something the server creates.
28
+ *
29
+ * **This kit only ever opens a transaction, and the reason is the price and the buyer — not the stamp.**
30
+ * `items[]` lets the page name what is being sold and to whom, and every other route in this capability
31
+ * takes the price from the catalog entry a product id resolves to precisely so a client cannot buy Pro
32
+ * for the price of a coin pack. There is no reading under which that rule holds at `POST /checkout` and
33
+ * stops holding here. So the cost is one round trip before the overlay appears, paid once per purchase.
34
+ *
35
+ * **What `transactionId` does not buy is the ownership stamp, and that was measured rather than assumed.**
36
+ * #309 argued the stamp needed no proof because `Paddle.Checkout.open` refuses `customData` beside a
37
+ * transaction id. It does not refuse it. Against the live sandbox on 2026-08-13: a server-created
38
+ * transaction stamped `pithy_user: "server-owner"` was opened with
39
+ * `Paddle.Checkout.open({ transactionId, customData: { pithy_user: "attacker", … } })` from a page holding
40
+ * only the publishable client token, paid with a test card, and came back from `GET /transactions/{id}`
41
+ * carrying the attacker's values — same id, `origin` still `"api"`, stamp replaced. The recording is
42
+ * `../rails/paddle/fixtures/browserForged.ts` and the refusal is gated on it.
43
+ *
44
+ * So the two halves are independent, and both are needed. The transaction fixes what is sold; the MAC over
45
+ * `(environment, user)` keyed on the destination secret is the *only* thing that fixes who it belongs to,
46
+ * on either form of the call. See `../rails/paddle/objects.ts` for that half in full, and
47
+ * {@link PaddleCheckoutOpen} in `./paddle` for the type that keeps this one unrepresentable rather than
48
+ * merely conventional.
49
+ *
50
+ * ## How it looks is the caller's to say, and only what Paddle lets code say
51
+ *
52
+ * `theme`, `locale` and `variant` are passed through from the caller and are **never inferred**. No
53
+ * `prefers-color-scheme`, no `matchMedia`, no reading a computed style off the page. The OS preference is
54
+ * not the app's theme: an app with its own light/dark toggle, or one that is dark whatever the machine
55
+ * says, would get a card form contradicting the page it opened over — and a wrong guess is far harder to
56
+ * find than an option nobody passed. The adopter knows. So this takes it, and defaults to Paddle's default
57
+ * by saying nothing.
58
+ *
59
+ * **Saying nothing is a real message.** An omitted setting must reach `Checkout.open` as an absent key,
60
+ * not as a key holding `undefined`, because these overlay account-level settings a seller configured in
61
+ * the Paddle dashboard. {@link settingsFor} builds them one at a time for that reason, and the tests assert
62
+ * on `Object.keys` rather than on values.
63
+ *
64
+ * **Colors and fonts are not here because they are not anywhere in code.** Paddle configures them in its
65
+ * own dashboard — *Checkout → Branded inline checkout* for the 50-odd inline options, logo and brand
66
+ * color for the overlay. That is Paddle's product decision rather than a missing endpoint, and the one
67
+ * API that writes `primary_checkout_color` is a Partners route for a platform configuring another
68
+ * seller's account. Nothing to add here, and nothing to go looking for.
69
+ *
70
+ * ## Nothing here throws
71
+ *
72
+ * `Paddle.Checkout.open` is synchronous and returns nothing, so its only way to report a problem is to
73
+ * throw — inside a click handler, where an unhandled rejection is what a buyer gets instead of a card
74
+ * form. Every path through this module answers with a {@link PaymentsFailure} or with null.
75
+ */
76
+
77
+ /** A screen said `inline`, and there is no element with that class on the page to render into. */
78
+ export const PADDLE_NO_CONTAINER: PaymentsFailure = {
79
+ code: "client/paddle_no_container",
80
+ message: "We couldn't show the payment form.",
81
+ action: "Reload the page. If it keeps happening, this screen is missing its checkout container.",
82
+ };
83
+
84
+ /** Paddle.js refused to open the checkout. A transaction it will not sell, or one already completed. */
85
+ export const PADDLE_CHECKOUT_REFUSED: PaymentsFailure = {
86
+ code: "client/paddle_checkout_refused",
87
+ message: "We couldn't open the payment form.",
88
+ action: "Try again in a moment.",
89
+ };
90
+
91
+ /**
92
+ * The default styling of an inline checkout's container.
93
+ *
94
+ * Paddle's own recommendation, and `min-width` in it is a compliance requirement rather than taste: the
95
+ * inline frame carries the footer naming Paddle as merchant of record, and below 312px that footer is cut
96
+ * off. A default here rather than in the scaffolded screen keeps the number out of a file Pithy may never
97
+ * rewrite — and a screen that wants its own passes one.
98
+ */
99
+ export const PADDLE_FRAME_STYLE = "width: 100%; min-width: 312px; background-color: transparent; border: none;";
100
+
101
+ /** The container's height in pixels before the frame resizes itself. Paddle's recommended 450. */
102
+ export const PADDLE_FRAME_HEIGHT = 450;
103
+
104
+ /**
105
+ * The slice of `document` this module reads, as an injectable seam.
106
+ *
107
+ * Structural for the reason `PaymentsGlobal` is: the package compiles in a program with no DOM lib
108
+ * alongside the Worker's code, and a test proves the no-browser path without a DOM.
109
+ */
110
+ export interface PaddleDocument {
111
+ /** Elements carrying a class. Paddle's `frameTarget` is a class name, so this is the lookup it does. */
112
+ getElementsByClassName(names: string): { length: number };
113
+ }
114
+
115
+ /** What {@link openPaddleCheckout} lets a caller replace or decide. */
116
+ export interface PaddleCheckoutOptions extends PaddleOptions {
117
+ /**
118
+ * Light or dark. Omit to take Paddle's default, which is light.
119
+ *
120
+ * **Pass the theme your app is in. This kit will not work it out.** Reading `prefers-color-scheme` here
121
+ * would answer a different question — the machine's preference, not the app's — and an app with its own
122
+ * toggle would open a light card form over a dark page for a reader who chose dark. The screen already
123
+ * knows which theme it rendered; that is the only source that is right every time.
124
+ *
125
+ * Theme is the whole of what code may set. Colors and fonts live in the Paddle dashboard, deliberately.
126
+ */
127
+ theme?: PaddleCheckoutTheme;
128
+ /**
129
+ * The buyer's language — `"fr"`, `"pt-BR"`. Omit to take the browser's.
130
+ *
131
+ * Worth passing exactly when the app has a language choice of its own, for the reason `theme` is worth
132
+ * passing: a reader who set the app to French should not meet a checkout in the language their browser
133
+ * was installed with.
134
+ */
135
+ locale?: string;
136
+ /** One page or several. Omit to take Paddle's default, which is `multi-page`. */
137
+ variant?: PaddleCheckoutVariant;
138
+ /**
139
+ * The **class name** of the element an inline checkout renders into.
140
+ *
141
+ * The screen's to name, not the server's: the container is a thing in a layout, and a project switching
142
+ * `paddle.checkout` between `overlay` and `inline` in its config must not have to edit a scaffolded
143
+ * screen to match. So a screen passes one always and it is used only when the handoff says inline.
144
+ */
145
+ frameTarget?: string;
146
+ /** Styles for that element. Defaults to {@link PADDLE_FRAME_STYLE}. */
147
+ frameStyle?: string;
148
+ /** Its height in pixels on load. Defaults to {@link PADDLE_FRAME_HEIGHT}. */
149
+ frameInitialHeight?: number;
150
+ /** Where the container is looked for. Defaults to the browser's own `document`. */
151
+ document?: PaddleDocument;
152
+ }
153
+
154
+ /** The document to consult — the injected one, else the browser's own, else none. */
155
+ function documentOf(options: PaddleCheckoutOptions | undefined): PaddleDocument | undefined {
156
+ if (options && "document" in options) return options.document;
157
+ return (globalThis as { document?: PaddleDocument }).document;
158
+ }
159
+
160
+ /**
161
+ * The presentation settings the caller actually named, and no key for the ones they did not.
162
+ *
163
+ * **Assigned one at a time rather than spread, and that is the whole point of the function.**
164
+ * `{ theme: options?.theme }` writes the key whatever the value is, and a key holding `undefined` is not
165
+ * the same object as no key: `"theme" in settings` is true for one and false for the other, and Paddle's
166
+ * settings sit over defaults a seller configured in its dashboard. A caller who passed nothing must not
167
+ * silently overrule an account that did.
168
+ *
169
+ * Nothing is inferred here. There is no branch that reads the environment, because the option not being
170
+ * set is an answer — take Paddle's default — rather than a question to go and resolve.
171
+ */
172
+ function presentationOf(options: PaddleCheckoutOptions | undefined): PaddleCheckoutSettings {
173
+ const presentation: PaddleCheckoutSettings = {};
174
+ if (options?.theme !== undefined) presentation.theme = options.theme;
175
+ if (options?.locale !== undefined) presentation.locale = options.locale;
176
+ if (options?.variant !== undefined) presentation.variant = options.variant;
177
+ return presentation;
178
+ }
179
+
180
+ /**
181
+ * How this checkout is presented, or the refusal that stops it being opened at all.
182
+ *
183
+ * Inline is checked here rather than left to Paddle because of what Paddle does instead. Measured against
184
+ * the live sandbox: `Checkout.open` with a `frameTarget` matching no element throws synchronously, out of
185
+ * the call, with `TypeError: Cannot read properties of undefined (reading 'appendChild')`. That is a real
186
+ * refusal and it is caught below — but it is somebody else's minified stack trace, and the honest thing to
187
+ * tell a screen is that it is missing its container.
188
+ */
189
+ function settingsFor(
190
+ handoff: PaddleCheckoutHandoff,
191
+ options: PaddleCheckoutOptions | undefined,
192
+ ): { settings: PaddleCheckoutSettings } | { failure: PaymentsFailure } {
193
+ const base: PaddleCheckoutSettings = {
194
+ displayMode: handoff.displayMode,
195
+ successUrl: handoff.successUrl,
196
+ ...presentationOf(options),
197
+ };
198
+ if (handoff.displayMode !== "inline") return { settings: base };
199
+
200
+ const document = documentOf(options);
201
+ // A server render, not a screen with a missing container. Two different problems for two different
202
+ // people, and answering the wrong one sends an adopter looking in the wrong file.
203
+ if (document === undefined) return { failure: PAYMENTS_NO_BROWSER };
204
+
205
+ const frameTarget = options?.frameTarget;
206
+ if (frameTarget === undefined || frameTarget === "") return { failure: PADDLE_NO_CONTAINER };
207
+ if (document.getElementsByClassName(frameTarget).length === 0) return { failure: PADDLE_NO_CONTAINER };
208
+
209
+ return {
210
+ settings: {
211
+ ...base,
212
+ frameTarget,
213
+ frameStyle: options?.frameStyle ?? PADDLE_FRAME_STYLE,
214
+ frameInitialHeight: options?.frameInitialHeight ?? PADDLE_FRAME_HEIGHT,
215
+ },
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Open the checkout the server handed back, over this page or inside it.
221
+ *
222
+ * Loads Paddle.js if it is not loaded, from the token and environment on the handoff itself — so a screen
223
+ * needs no second source of truth for which Paddle account it is selling through, and a handoff for one
224
+ * account cannot open against another page's Paddle. Returns null when the checkout opened, and a failure
225
+ * to render otherwise.
226
+ *
227
+ * **The container must be on the page before this is called.** In React that means an effect rather than
228
+ * a click handler: the render that reveals the container has to commit first. `usePaddleCheckout` in
229
+ * `./hooks` is that ordering, written once.
230
+ */
231
+ export async function openPaddleCheckout(
232
+ handoff: PaddleCheckoutHandoff,
233
+ options?: PaddleCheckoutOptions,
234
+ ): Promise<PaymentsFailure | null> {
235
+ const prepared = settingsFor(handoff, options);
236
+ // Before the load, deliberately. A checkout that cannot be rendered should not also fetch a script from
237
+ // a CDN, and the refusal is the same either way.
238
+ if ("failure" in prepared) return prepared.failure;
239
+
240
+ const loaded = await loadPaddle({ clientToken: handoff.clientToken, environment: handoff.environment }, options);
241
+ if (!loaded.ok) return loaded.failure;
242
+
243
+ try {
244
+ loaded.value.Checkout.open({ transactionId: handoff.transactionId, settings: prepared.settings });
245
+ } catch {
246
+ // Synchronous and void: a throw is Paddle.js's only channel for "I will not open this", and it does
247
+ // use it — a `frameTarget` matching nothing throws a bare `TypeError` about `appendChild`. Left alone
248
+ // that escapes into a buy button as an unhandled rejection.
249
+ return PADDLE_CHECKOUT_REFUSED;
250
+ }
251
+ return null;
252
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "customerId": null,
3
+ "addressId": null,
4
+ "businessId": null,
5
+ "currencyCode": "USD",
6
+ "address": { "postalCode": "10115", "countryCode": "DE" },
7
+ "customerIpAddress": null,
8
+ "discountId": null,
9
+ "details": {
10
+ "lineItems": [
11
+ {
12
+ "price": {
13
+ "id": "pri_01kzvyz9e21z9vbhd7xqq3csyh",
14
+ "description": "Solo, monthly. $5/month.",
15
+ "type": "standard",
16
+ "name": "Monthly",
17
+ "productId": "pro_01kzvyz95263x6amf3w4cgbqh4",
18
+ "billingCycle": { "interval": "month", "frequency": 1 },
19
+ "trialPeriod": null,
20
+ "taxMode": "location",
21
+ "unitPrice": { "amount": "500", "currencyCode": "USD" },
22
+ "unitPriceOverrides": [],
23
+ "customData": { "tier": "solo" },
24
+ "quantity": { "minimum": 1, "maximum": 1 },
25
+ "status": "active",
26
+ "createdAt": "2026-08-12T21:45:05.730177Z",
27
+ "updatedAt": "2026-08-12T21:45:05.730178Z",
28
+ "importMeta": null
29
+ },
30
+ "quantity": 1,
31
+ "taxRate": "0.19",
32
+ "unitTotals": { "subtotal": "420", "discount": "0", "tax": "80", "total": "500" },
33
+ "formattedUnitTotals": { "subtotal": "$4.20", "discount": "$0.00", "tax": "$0.80", "total": "$5.00" },
34
+ "totals": { "subtotal": "420", "discount": "0", "tax": "80", "total": "500" },
35
+ "formattedTotals": { "subtotal": "$4.20", "discount": "$0.00", "tax": "$0.80", "total": "$5.00" },
36
+ "product": {
37
+ "id": "pro_01kzvyz95263x6amf3w4cgbqh4",
38
+ "name": "Solo",
39
+ "description": "One person, working from a browser rather than a terminal.",
40
+ "type": "standard",
41
+ "taxCategory": "saas",
42
+ "imageUrl": null,
43
+ "customData": { "tier": "solo" },
44
+ "status": "active",
45
+ "importMeta": null,
46
+ "createdAt": "2026-08-12T21:45:05.442Z",
47
+ "updatedAt": "2026-08-12T21:45:05.442Z"
48
+ },
49
+ "discounts": []
50
+ }
51
+ ]
52
+ },
53
+ "availablePaymentMethods": ["card", "paypal", "apple_pay"]
54
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "customerId": null,
3
+ "addressId": null,
4
+ "businessId": null,
5
+ "currencyCode": "USD",
6
+ "address": { "postalCode": "SW1A 1AA", "countryCode": "GB" },
7
+ "customerIpAddress": null,
8
+ "discountId": null,
9
+ "details": {
10
+ "lineItems": [
11
+ {
12
+ "price": {
13
+ "id": "pri_01kzvyz9e21z9vbhd7xqq3csyh",
14
+ "description": "Solo, monthly. $5/month.",
15
+ "type": "standard",
16
+ "name": "Monthly",
17
+ "productId": "pro_01kzvyz95263x6amf3w4cgbqh4",
18
+ "billingCycle": { "interval": "month", "frequency": 1 },
19
+ "trialPeriod": null,
20
+ "taxMode": "location",
21
+ "unitPrice": { "amount": "500", "currencyCode": "USD" },
22
+ "unitPriceOverrides": [],
23
+ "customData": { "tier": "solo" },
24
+ "quantity": { "minimum": 1, "maximum": 1 },
25
+ "status": "active",
26
+ "createdAt": "2026-08-12T21:45:05.730177Z",
27
+ "updatedAt": "2026-08-12T21:45:05.730178Z",
28
+ "importMeta": null
29
+ },
30
+ "quantity": 1,
31
+ "taxRate": "0.2",
32
+ "unitTotals": { "subtotal": "417", "discount": "0", "tax": "83", "total": "500" },
33
+ "formattedUnitTotals": { "subtotal": "$4.17", "discount": "$0.00", "tax": "$0.83", "total": "$5.00" },
34
+ "totals": { "subtotal": "417", "discount": "0", "tax": "83", "total": "500" },
35
+ "formattedTotals": { "subtotal": "$4.17", "discount": "$0.00", "tax": "$0.83", "total": "$5.00" },
36
+ "product": {
37
+ "id": "pro_01kzvyz95263x6amf3w4cgbqh4",
38
+ "name": "Solo",
39
+ "description": "One person, working from a browser rather than a terminal.",
40
+ "type": "standard",
41
+ "taxCategory": "saas",
42
+ "imageUrl": null,
43
+ "customData": { "tier": "solo" },
44
+ "status": "active",
45
+ "importMeta": null,
46
+ "createdAt": "2026-08-12T21:45:05.442Z",
47
+ "updatedAt": "2026-08-12T21:45:05.442Z"
48
+ },
49
+ "discounts": []
50
+ }
51
+ ]
52
+ },
53
+ "availablePaymentMethods": ["card", "paypal", "apple_pay"]
54
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "customerId": null,
3
+ "addressId": null,
4
+ "businessId": null,
5
+ "currencyCode": "JPY",
6
+ "address": { "postalCode": "1000001", "countryCode": "JP" },
7
+ "customerIpAddress": null,
8
+ "discountId": null,
9
+ "details": {
10
+ "lineItems": [
11
+ {
12
+ "price": {
13
+ "id": "pri_01kzvyz9e21z9vbhd7xqq3csyh",
14
+ "description": "Solo, monthly. $5/month.",
15
+ "type": "standard",
16
+ "name": "Monthly",
17
+ "productId": "pro_01kzvyz95263x6amf3w4cgbqh4",
18
+ "billingCycle": { "interval": "month", "frequency": 1 },
19
+ "trialPeriod": null,
20
+ "taxMode": "location",
21
+ "unitPrice": { "amount": "500", "currencyCode": "USD" },
22
+ "unitPriceOverrides": [],
23
+ "customData": { "tier": "solo" },
24
+ "quantity": { "minimum": 1, "maximum": 1 },
25
+ "status": "active",
26
+ "createdAt": "2026-08-12T21:45:05.730177Z",
27
+ "updatedAt": "2026-08-12T21:45:05.730178Z",
28
+ "importMeta": null
29
+ },
30
+ "quantity": 1,
31
+ "taxRate": "0.1",
32
+ "unitTotals": { "subtotal": "725", "discount": "0", "tax": "73", "total": "798" },
33
+ "formattedUnitTotals": { "subtotal": "¥725", "discount": "¥0", "tax": "¥73", "total": "¥798" },
34
+ "totals": { "subtotal": "725", "discount": "0", "tax": "73", "total": "798" },
35
+ "formattedTotals": { "subtotal": "¥725", "discount": "¥0", "tax": "¥73", "total": "¥798" },
36
+ "product": {
37
+ "id": "pro_01kzvyz95263x6amf3w4cgbqh4",
38
+ "name": "Solo",
39
+ "description": "One person, working from a browser rather than a terminal.",
40
+ "type": "standard",
41
+ "taxCategory": "saas",
42
+ "imageUrl": null,
43
+ "customData": { "tier": "solo" },
44
+ "status": "active",
45
+ "importMeta": null,
46
+ "createdAt": "2026-08-12T21:45:05.442Z",
47
+ "updatedAt": "2026-08-12T21:45:05.442Z"
48
+ },
49
+ "discounts": []
50
+ }
51
+ ]
52
+ },
53
+ "availablePaymentMethods": ["card", "paypal", "apple_pay"]
54
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "customerId": null,
3
+ "addressId": null,
4
+ "businessId": null,
5
+ "currencyCode": "USD",
6
+ "address": { "postalCode": "", "countryCode": "US" },
7
+ "customerIpAddress": null,
8
+ "discountId": null,
9
+ "details": {
10
+ "lineItems": [
11
+ {
12
+ "price": {
13
+ "id": "pri_01kzvyz9e21z9vbhd7xqq3csyh",
14
+ "description": "Solo, monthly. $5/month.",
15
+ "type": "standard",
16
+ "name": "Monthly",
17
+ "productId": "pro_01kzvyz95263x6amf3w4cgbqh4",
18
+ "billingCycle": { "interval": "month", "frequency": 1 },
19
+ "trialPeriod": null,
20
+ "taxMode": "location",
21
+ "unitPrice": { "amount": "500", "currencyCode": "USD" },
22
+ "unitPriceOverrides": [],
23
+ "customData": { "tier": "solo" },
24
+ "quantity": { "minimum": 1, "maximum": 1 },
25
+ "status": "active",
26
+ "createdAt": "2026-08-12T21:45:05.730177Z",
27
+ "updatedAt": "2026-08-12T21:45:05.730178Z",
28
+ "importMeta": null
29
+ },
30
+ "quantity": 1,
31
+ "taxRate": "0",
32
+ "unitTotals": { "subtotal": "500", "discount": "0", "tax": "0", "total": "500" },
33
+ "formattedUnitTotals": { "subtotal": "$5.00", "discount": "$0.00", "tax": "$0.00", "total": "$5.00" },
34
+ "totals": { "subtotal": "500", "discount": "0", "tax": "0", "total": "500" },
35
+ "formattedTotals": { "subtotal": "$5.00", "discount": "$0.00", "tax": "$0.00", "total": "$5.00" },
36
+ "product": {
37
+ "id": "pro_01kzvyz95263x6amf3w4cgbqh4",
38
+ "name": "Solo",
39
+ "description": "One person, working from a browser rather than a terminal.",
40
+ "type": "standard",
41
+ "taxCategory": "saas",
42
+ "imageUrl": null,
43
+ "customData": { "tier": "solo" },
44
+ "status": "active",
45
+ "importMeta": null,
46
+ "createdAt": "2026-08-12T21:45:05.442Z",
47
+ "updatedAt": "2026-08-12T21:45:05.442Z"
48
+ },
49
+ "discounts": []
50
+ }
51
+ ]
52
+ },
53
+ "availablePaymentMethods": ["card", "paypal", "apple_pay"]
54
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "customerId": null,
3
+ "addressId": null,
4
+ "businessId": null,
5
+ "currencyCode": "USD",
6
+ "address": { "postalCode": "10001", "countryCode": "US" },
7
+ "customerIpAddress": null,
8
+ "discountId": null,
9
+ "details": {
10
+ "lineItems": [
11
+ {
12
+ "price": {
13
+ "id": "pri_01kzvyz9e21z9vbhd7xqq3csyh",
14
+ "description": "Solo, monthly. $5/month.",
15
+ "type": "standard",
16
+ "name": "Monthly",
17
+ "productId": "pro_01kzvyz95263x6amf3w4cgbqh4",
18
+ "billingCycle": { "interval": "month", "frequency": 1 },
19
+ "trialPeriod": null,
20
+ "taxMode": "location",
21
+ "unitPrice": { "amount": "500", "currencyCode": "USD" },
22
+ "unitPriceOverrides": [],
23
+ "customData": { "tier": "solo" },
24
+ "quantity": { "minimum": 1, "maximum": 1 },
25
+ "status": "active",
26
+ "createdAt": "2026-08-12T21:45:05.730177Z",
27
+ "updatedAt": "2026-08-12T21:45:05.730178Z",
28
+ "importMeta": null
29
+ },
30
+ "quantity": 1,
31
+ "taxRate": "0.08875",
32
+ "unitTotals": { "subtotal": "500", "discount": "0", "tax": "44", "total": "544" },
33
+ "formattedUnitTotals": { "subtotal": "$5.00", "discount": "$0.00", "tax": "$0.44", "total": "$5.44" },
34
+ "totals": { "subtotal": "500", "discount": "0", "tax": "44", "total": "544" },
35
+ "formattedTotals": { "subtotal": "$5.00", "discount": "$0.00", "tax": "$0.44", "total": "$5.44" },
36
+ "product": {
37
+ "id": "pro_01kzvyz95263x6amf3w4cgbqh4",
38
+ "name": "Solo",
39
+ "description": "One person, working from a browser rather than a terminal.",
40
+ "type": "standard",
41
+ "taxCategory": "saas",
42
+ "imageUrl": null,
43
+ "customData": { "tier": "solo" },
44
+ "status": "active",
45
+ "importMeta": null,
46
+ "createdAt": "2026-08-12T21:45:05.442Z",
47
+ "updatedAt": "2026-08-12T21:45:05.442Z"
48
+ },
49
+ "discounts": []
50
+ }
51
+ ]
52
+ },
53
+ "availablePaymentMethods": ["card", "paypal", "apple_pay"]
54
+ }
@@ -0,0 +1,67 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import de from "./price-preview-de.json" with { type: "json" };
5
+ import gb from "./price-preview-gb.json" with { type: "json" };
6
+ import jpYen from "./price-preview-jp-yen.json" with { type: "json" };
7
+ import usCountryOnly from "./price-preview-us-country-only.json" with { type: "json" };
8
+ import usNewYork from "./price-preview-us-new-york.json" with { type: "json" };
9
+
10
+ /**
11
+ * Five real `PricePreview` answers, recorded from the Paddle **sandbox** on 2026-08-13 against one
12
+ * $5.00/month price with no `unit_price_overrides`.
13
+ *
14
+ * They exist because the claim this rail makes — "prices render localized and tax-correct" — is a claim
15
+ * about numbers Paddle chooses, and a test written against numbers *we* chose proves only that our
16
+ * arithmetic agrees with itself. Each of these was fetched, not composed, and each pins a different
17
+ * behavior of Paddle's that the reader has to get right:
18
+ *
19
+ * | Fixture | Subtotal | Tax | Total | What it pins |
20
+ * |---|---|---|---|---|
21
+ * | {@link US_NEW_YORK} | $5.00 | $0.44 | $5.44 | Tax **added** on top of the listed price. |
22
+ * | {@link US_COUNTRY_ONLY} | $5.00 | $0.00 | $5.00 | The same country with no postal code resolves **0% tax**. |
23
+ * | {@link GB} | $4.17 | $0.83 | $5.00 | VAT taken **out of** the listed price. |
24
+ * | {@link DE} | $4.20 | $0.80 | $5.00 | The same convention, a different rate. |
25
+ * | {@link JP_YEN} | ¥725 | ¥73 | ¥798 | A **zero-decimal** currency, and a converted amount. |
26
+ *
27
+ * Two facts these recordings carry that are easy to state wrongly from memory:
28
+ *
29
+ * **Currency is not localized.** Every response but the last is in USD, from a UK, German and Japanese
30
+ * address alike. Currency comes from `unit_price_overrides` in the catalog, and this price has none.
31
+ * What localizes without them is tax and formatting.
32
+ *
33
+ * **A converted amount is not a stable number.** {@link JP_YEN} was fetched twice minutes apart and
34
+ * returned ¥797 then ¥798 — Paddle's FX rate moved between the calls. Nothing may assert an exact
35
+ * converted figure against a live account; that is what a recording is for.
36
+ *
37
+ * **They carry the envelope, because that is what `PricePreview()` resolves.** Each JSON file below
38
+ * holds a recorded `data` payload; the exported value wraps it as `{ data }`, which is the shape the
39
+ * reader is actually handed. That wrapping is not cosmetic and it is the whole of `#416`: these five
40
+ * were originally exported unwrapped, `readPricePreview` was written to match them, and the reader
41
+ * therefore refused every real answer while this suite stayed green. A recording saved at the wrong
42
+ * depth is a gate that cannot fail. The envelope was verified against a live sandbox call on
43
+ * 2026-08-18 — top-level keys `data` and `meta`.
44
+ *
45
+ * `meta` is not recorded here. The live envelope carries `{ requestId: "715c7a82-…" }` — verified, not
46
+ * assumed — and nothing in this package reads it. It is left out because a fixture carrying a field no
47
+ * reader consults is an invitation to start consulting it, and because a per-call id pinned in a
48
+ * checked-in file is a value that was never true twice.
49
+ *
50
+ * Typed `unknown` on purpose. These are the input to {@link readPricePreview}, which is a trust
51
+ * boundary, and handing it a value TypeScript already believes would test the guard against itself.
52
+ */
53
+
54
+ /** New York, 10001. 8.875% added on top of a $5.00 listed price. */
55
+ export const US_NEW_YORK: unknown = { data: usNewYork };
56
+
57
+ /** The United States with no postal code. Tax resolves to zero, and the buyer will still pay $5.44. */
58
+ export const US_COUNTRY_ONLY: unknown = { data: usCountryOnly };
59
+
60
+ /** London, SW1A 1AA. 20% VAT taken out of an inclusive $5.00. */
61
+ export const GB: unknown = { data: gb };
62
+
63
+ /** Berlin, 10115. 19% VAT, same inclusive convention, different rate. */
64
+ export const DE: unknown = { data: de };
65
+
66
+ /** Tokyo, with `currencyCode: "JPY"` forced. Whole yen — ¥725, not ¥72500. */
67
+ export const JP_YEN: unknown = { data: jpYen };