@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,332 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PaymentsFailure, PaymentsResult } from "./api";
5
+ import type { PaddleSetup } from "./paddle";
6
+ import { type PaddleCacheStore, type PaddleQuoteCache, readQuoteCache } from "./paddleCache";
7
+ import {
8
+ type PaddlePlanPrices,
9
+ type PaddlePlanQuote,
10
+ type PaddleQuoteOptions,
11
+ type PaddleQuoteQuery,
12
+ quotePlans,
13
+ } from "./paddlePrices";
14
+
15
+ /**
16
+ * The script tag as configuration, for a site with no build step.
17
+ *
18
+ * A static site loads one classic script and gets prices. It cannot import a module, it has no bundler,
19
+ * and it must not carry a price id in its markup — an id belongs to one Paddle account, so it is
20
+ * environment-specific and a page that names one is a page that quotes the wrong account the day the
21
+ * account changes. So the ids arrive on the tag, where whoever deploys the site puts them, and the page
22
+ * itself names only plans.
23
+ */
24
+
25
+ /** The tag names no account, or is still carrying the placeholders it was scaffolded with. */
26
+ export const PADDLE_PRICES_NOT_CONFIGURED: PaymentsFailure = {
27
+ code: "client/paddle_prices_not_configured",
28
+ message: "Prices aren't available right now.",
29
+ action:
30
+ "Set data-paddle-env, data-paddle-token and every data-paddle-price-<name> id on the paddle-prices script tag.",
31
+ };
32
+
33
+ /** The slice of a script element this reads, declared structurally for the reason `./checkout` gives. */
34
+ export interface PaddlePricesTag {
35
+ /** Every attribute on the tag, so the `data-paddle-price-*` set can be enumerated rather than guessed. */
36
+ getAttributeNames(): readonly string[];
37
+ /** One attribute's value, or null. */
38
+ getAttribute(name: string): string | null;
39
+ }
40
+
41
+ /** What a configured tag says. */
42
+ export interface PaddlePricesTagConfig {
43
+ /** Which Paddle account answers, and with which publishable token. */
44
+ readonly setup: PaddleSetup;
45
+ /** Plan name to price id, one entry per `data-paddle-price-*` attribute. */
46
+ readonly plans: PaddlePlanPrices;
47
+ /** Whether to write the totals into the page, or only to hand them to whoever asked. */
48
+ readonly paint: boolean;
49
+ /**
50
+ * Whether the page asked for a zero fraction to be dropped from each figure — `$6.00` as `$6`.
51
+ *
52
+ * False unless the tag said `on`, and false for any other value. A seller who prices in whole numbers
53
+ * is the one who knows it, and a mistyped attribute leaves every figure exactly as Paddle rendered it,
54
+ * which is the safe half of the two. See `./wholeUnits`.
55
+ */
56
+ readonly wholeUnits: boolean;
57
+ /** Who to quote for. Empty unless the tag named a customer, which is a visitor Paddle knows. */
58
+ readonly query: PaddleQuoteQuery;
59
+ /** Where a quote may rest, or null — which is every tag that did not ask for a cache, and every tag
60
+ * that asked for half of one. */
61
+ readonly cache: PaddleQuoteCache | null;
62
+ }
63
+
64
+ /**
65
+ * The two stores a script tag can name, injectable so a suite can look inside one.
66
+ *
67
+ * A tag cannot hand over an object, so it names a store rather than passing one — `local` or `session`,
68
+ * the only two a browser has. Everywhere else in this kit the caller passes the store itself, and that
69
+ * stays true: this is the one surface where the caller is HTML.
70
+ */
71
+ export interface PricesCacheStores {
72
+ /** What `data-paddle-cache-store="local"` resolves to. Defaults to `globalThis.localStorage`. */
73
+ readonly local?: PaddleCacheStore | null;
74
+ /** What `data-paddle-cache-store="session"` resolves to. Defaults to `globalThis.sessionStorage`. */
75
+ readonly session?: PaddleCacheStore | null;
76
+ }
77
+
78
+ /** The prefix that marks an attribute as naming a plan. */
79
+ const PRICE_PREFIX = "data-paddle-price-";
80
+
81
+ /** The token prefix each Paddle account issues. Sandbox and live are separate accounts, not a flag. */
82
+ const CLIENT_TOKEN: Readonly<Record<PaddleSetup["environment"], string>> = {
83
+ sandbox: "test_",
84
+ production: "live_",
85
+ };
86
+
87
+ /** A real Paddle price. */
88
+ const PRICE_ID = /^pri_/;
89
+
90
+ /** A real Paddle customer. */
91
+ const CUSTOMER_ID = /^ctm_/;
92
+
93
+ /**
94
+ * One of the browser's own stores, or null where there is not one.
95
+ *
96
+ * Guarded rather than read, because reaching for `localStorage` **throws** where a browser has storage
97
+ * switched off or a sandboxed frame denies it — a page that quotes prices would take that exception on
98
+ * the way to asking Paddle a question it could have asked anyway.
99
+ */
100
+ function browserStore(name: "localStorage" | "sessionStorage"): PaddleCacheStore | null {
101
+ try {
102
+ const held = (globalThis as { localStorage?: PaddleCacheStore; sessionStorage?: PaddleCacheStore })[name];
103
+ return held ?? null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The store a tag named, the **name** it gave when nothing resolved, or null when it named nothing.
111
+ *
112
+ * The three are different and used to be two. `data-paddle-cache-store="localStorage"` is the likely
113
+ * typo — the accepted value is `local` — and collapsing it into the same null a bare tag produces made
114
+ * it the one misconfiguration that got no console line, because "did anybody ask for a cache?" had
115
+ * nothing left to see. Handing the name back is what makes an unresolvable store an answer.
116
+ *
117
+ * An injected `null` is honored as an answer too: `PricesCacheStores` says `PaddleCacheStore | null`,
118
+ * so null means *this environment has none* — a suite saying so, or an adopter's SSR-safe wrapper — and
119
+ * quietly resolving the browser's real store instead would exercise the opposite path from the one the
120
+ * caller named.
121
+ */
122
+ function storeNamed(named: string | null, stores?: PricesCacheStores): PaddleCacheStore | string | null {
123
+ if (named === "local") return stores?.local !== undefined ? stores.local : (browserStore("localStorage") ?? named);
124
+ if (named === "session") {
125
+ return stores?.session !== undefined ? stores.session : (browserStore("sessionStorage") ?? named);
126
+ }
127
+ return named;
128
+ }
129
+
130
+ /**
131
+ * A lifetime in seconds, as milliseconds.
132
+ *
133
+ * Seconds on the attribute because a tag is HTML and HTML counts a cache in seconds — `max-age`, and
134
+ * every header that copied it. `NaN` rather than null for text that is not a number, so a tag that
135
+ * tried to state a lifetime and failed is a warning rather than a silence.
136
+ */
137
+ function lifetime(value: string | null): number | null {
138
+ if (value === null) return null;
139
+ const seconds = Number(value);
140
+ return Number.isFinite(seconds) ? seconds * 1000 : Number.NaN;
141
+ }
142
+
143
+ /**
144
+ * Read a tag's configuration, or refuse it.
145
+ *
146
+ * **Refusal is the whole point of this function, and it is why the shapes are checked rather than
147
+ * trusted.** A site is deployed with the placeholders still in it far more often than anyone plans to:
148
+ * the live account is opened weeks after the sandbox one, and the tag ships carrying
149
+ * `REPLACE_WITH_LIVE_CLIENT_TOKEN` in the meantime. Handing that to Paddle produces a refused
150
+ * initialization, a console warning nobody is watching, and — because a page that has already blanked
151
+ * its own sentence has nothing left to fall back to — an empty price slot that looks deliberate. That is
152
+ * `#416` arriving by a second road.
153
+ *
154
+ * So an unconfigured tag is not an error and not a quote. It is null, and a null tag paints nothing, so
155
+ * the sentence the page shipped with is still standing. **All or nothing across the plans**: one plan
156
+ * still on a placeholder refuses the tag rather than quoting the others, because a pricing table with
157
+ * one real figure and one placeholder is the version a reader believes.
158
+ *
159
+ * **The token is checked against the environment beside it, not merely for a prefix.** The half-updated
160
+ * deploy above has a second shape: the environment is switched to `production` and the token below it is
161
+ * still the sandbox one. Paddle refuses that pairing itself, eventually — after the page has fetched
162
+ * Paddle.js and spent a round trip finding out. Refusing it here is what makes "no request" true of
163
+ * every misconfigured tag rather than of most of them.
164
+ */
165
+ export function readPaddlePricesTag(
166
+ tag: PaddlePricesTag | null,
167
+ stores?: PricesCacheStores,
168
+ ): PaddlePricesTagConfig | null {
169
+ if (tag === null) return null;
170
+ const environment = tag.getAttribute("data-paddle-env");
171
+ if (environment !== "sandbox" && environment !== "production") return null;
172
+ const clientToken = tag.getAttribute("data-paddle-token");
173
+ if (clientToken === null || !clientToken.startsWith(CLIENT_TOKEN[environment])) return null;
174
+
175
+ const plans: Record<string, string> = {};
176
+ for (const name of tag.getAttributeNames()) {
177
+ if (!name.startsWith(PRICE_PREFIX)) continue;
178
+ const priceId = tag.getAttribute(name);
179
+ if (priceId === null || !PRICE_ID.test(priceId)) return null;
180
+ plans[name.slice(PRICE_PREFIX.length)] = priceId;
181
+ }
182
+ if (Object.keys(plans).length === 0) return null;
183
+
184
+ // **A customer that is not one is dropped, not refused** — the opposite call to the one a placeholder
185
+ // price id gets one line above, and deliberately. A wrong price is unrecoverable, so a placeholder id
186
+ // takes the whole tag down. A missing customer costs the visitor a quote resolved from their IP and
187
+ // marked `estimated`, which is exactly what every anonymous visitor already sees, and is a great deal
188
+ // better than a pricing table with no figures in it.
189
+ const named = tag.getAttribute("data-paddle-customer")?.trim() ?? "";
190
+ const query: PaddleQuoteQuery = CUSTOMER_ID.test(named) ? { customerId: named } : {};
191
+
192
+ const cache = readQuoteCache({
193
+ key: tag.getAttribute("data-paddle-cache"),
194
+ store: storeNamed(tag.getAttribute("data-paddle-cache-store"), stores),
195
+ ttlMs: lifetime(tag.getAttribute("data-paddle-cache-ttl")),
196
+ });
197
+
198
+ return {
199
+ setup: { clientToken, environment },
200
+ plans,
201
+ paint: tag.getAttribute("data-paddle-paint") !== "off",
202
+ // The opposite polarity to `paint`, and deliberately: painting is what the artifact is for, so it is
203
+ // on until a page opts out, while a pricing decision is off until a page opts in.
204
+ wholeUnits: tag.getAttribute("data-paddle-whole-units") === "on",
205
+ query,
206
+ cache,
207
+ };
208
+ }
209
+
210
+ /** What {@link mountPrices} lets a caller replace, plus the stores a tag may name. */
211
+ export interface MountPricesOptions extends PaddleQuoteOptions {
212
+ /** What `data-paddle-cache-store` resolves to. Defaults to the browser's own two. */
213
+ readonly stores?: PricesCacheStores;
214
+ }
215
+
216
+ /** The slice of a plan slot this writes. */
217
+ export interface PricesPlanNode {
218
+ /** Which plan the slot is for. */
219
+ getAttribute(name: string): string | null;
220
+ /** The sentence in the slot — the page's own until a quote replaces it. */
221
+ textContent: string | null;
222
+ }
223
+
224
+ /** The slice of `document` this reaches, as an injectable seam. */
225
+ export interface PricesDocument {
226
+ /** Whether the page is still being parsed, so the slots to paint may not all exist yet. */
227
+ readyState: string;
228
+ /** How the paint waits for the rest of the page. */
229
+ addEventListener(type: string, listener: () => void, options?: { once?: boolean }): void;
230
+ /** Every plan slot on the page. */
231
+ querySelectorAll(selectors: string): ArrayLike<PricesPlanNode>;
232
+ }
233
+
234
+ /** The attribute a page marks a plan slot with. */
235
+ const PLAN_SLOT = "data-price-plan";
236
+
237
+ /** The attribute a page marks the sentence beside a figure with. */
238
+ const NOTE_SLOT = "data-price-note";
239
+
240
+ /**
241
+ * Match a plan named on the tag to a plan named in the markup.
242
+ *
243
+ * Case-insensitively, and that is forced rather than lenient. A plan arrives on the tag as part of an
244
+ * *attribute name* — `data-paddle-price-teamPlus` — and HTML lower-cases those, so the tag can only ever say
245
+ * `teamplus`. In the markup the same plan is an attribute *value*, `data-price-plan="teamPlus"`, which
246
+ * keeps its case. Matching exactly would leave that slot on its placeholder with nothing anywhere saying
247
+ * why.
248
+ */
249
+ function samePlan(named: string | null, quoted: string): boolean {
250
+ return named !== null && named.toLowerCase() === quoted.toLowerCase();
251
+ }
252
+
253
+ /**
254
+ * Write each quote into the slot that named its plan, and the sentence that makes it true beside it.
255
+ *
256
+ * **The headline alone is not the price, and a page that shows only the headline is the defect the quote
257
+ * path exists to avoid.** Where tax is added on top — the United States convention — the headline is the
258
+ * subtotal and the buyer is charged more; where no postal code resolved, Paddle returns 0% tax and the
259
+ * card is charged up to 15% above the figure. `priceSummary` returns a sentence for each of those cases,
260
+ * and this puts it in `[data-price-note="<plan>"]`. A page quoting a tax-added price wants that slot.
261
+ *
262
+ * **A slot no quote names is left exactly as it was**, and so is a note slot for a quote with nothing to
263
+ * say. A pricing page ships a true sentence in every slot — "Priced where you are billed" — precisely so
264
+ * that a plan which cannot be quoted still says something honest. Blanking it, or writing `undefined`
265
+ * into it, replaces a true sentence with a worse one; leaving it is the whole reason it is there.
266
+ */
267
+ export function paintPlanQuotes(document: PricesDocument, quotes: readonly PaddlePlanQuote[]): void {
268
+ // `Array.from` because the seam is an `ArrayLike` — a `NodeList` in a browser, and something else in a
269
+ // test — and indexing one is where an off-by-one would live.
270
+ for (const slot of Array.from(document.querySelectorAll(`[${PLAN_SLOT}]`))) {
271
+ const quote = quotes.find((candidate) => samePlan(slot.getAttribute(PLAN_SLOT), candidate.plan));
272
+ if (quote === undefined) continue;
273
+ slot.textContent = quote.headline;
274
+ }
275
+ for (const slot of Array.from(document.querySelectorAll(`[${NOTE_SLOT}]`))) {
276
+ const quote = quotes.find((candidate) => samePlan(slot.getAttribute(NOTE_SLOT), candidate.plan));
277
+ if (quote === undefined || quote.note === null) continue;
278
+ slot.textContent = quote.note;
279
+ }
280
+ }
281
+
282
+ /** Resolve once the page has the slots this paints into. */
283
+ function parsed(document: PricesDocument): Promise<void> {
284
+ if (document.readyState !== "loading") return Promise.resolve();
285
+ return new Promise((resolve) => {
286
+ document.addEventListener("DOMContentLoaded", () => resolve(), { once: true });
287
+ });
288
+ }
289
+
290
+ /**
291
+ * Read the tag, quote its plans, and — unless the page said it paints itself — put the figures on it.
292
+ *
293
+ * The one entry point the browser build calls, and the one place the three halves meet: configuration
294
+ * off the tag, the quote from `./prices`, and the page. It answers with the result rather than swallowing
295
+ * it, so a page that keeps its own cache and its own formatting can have the same answer this painted.
296
+ */
297
+ export async function mountPrices(
298
+ document: PricesDocument,
299
+ tag: PaddlePricesTag | null,
300
+ options?: MountPricesOptions,
301
+ ): Promise<PaymentsResult<readonly PaddlePlanQuote[]>> {
302
+ const { stores, ...given } = options ?? {};
303
+ const config = readPaddlePricesTag(tag, stores);
304
+ if (config === null) return { ok: false, failure: PADDLE_PRICES_NOT_CONFIGURED };
305
+
306
+ // The tag is the page's configuration; an explicit option is a caller who knows better than the
307
+ // markup — a screen mounting this itself, or a test. Neither is common and both are legitimate, so
308
+ // the more specific one wins and the tag fills in the rest.
309
+ //
310
+ // **The query merges, field by field; the cache does not.** A query is a bag of independent facts, and
311
+ // a dashboard that server-renders `data-paddle-customer` onto the tag and passes an address from the
312
+ // screen means both — replacing wholesale would drop the customer and quote from the network again,
313
+ // which is the defect this module exists to remove. A cache is one indivisible decision about where a
314
+ // price rests and for how long, so half of the tag's and half of the caller's is not a cache anybody
315
+ // chose.
316
+ const quoted = await quotePlans(config.setup, config.plans, {
317
+ ...given,
318
+ query: { ...config.query, ...given.query },
319
+ cache: given.cache ?? config.cache ?? undefined,
320
+ // One boolean, so the more specific answer wins outright — a caller passing `false` over a tag that
321
+ // said `on` is saying so, and `??` is what keeps that from reading as "nobody asked".
322
+ wholeUnits: given.wholeUnits ?? config.wholeUnits,
323
+ });
324
+ if (!quoted.ok || !config.paint) return quoted;
325
+ // **The quote starts immediately and the paint waits.** A third-party tag's usual home is `<head>`,
326
+ // where nothing it paints into has been parsed yet — so a paint that ran the moment Paddle answered
327
+ // would find some of the page's slots, or none, and leave the rest on their placeholders for good.
328
+ // Starting the round trip early is the reason to load early; only the writing has to wait.
329
+ await parsed(document);
330
+ paintPlanQuotes(document, quoted.value);
331
+ return quoted;
332
+ }
@@ -0,0 +1,133 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * One product, browser-safe — an element of {@link PaymentsClientProjection}'s catalog.
6
+ *
7
+ * Declared as its own type because the element type is the half a value-derived shape loses first: a
8
+ * catalog of one product infers one product's shape, and every branch the next product would have taken
9
+ * — an empty `entitlements`, a SKU on a rail the first product does not sell — is simply not in it.
10
+ *
11
+ * **It is annotated on the `.map` callback, not on the array it builds.** Measured, and the difference
12
+ * matters: `const products: PaymentsClientProduct[] = …map(…)` type-checks the *result*, by which point
13
+ * the element literals are no longer fresh, so a product that grew an `apple` SKU passed silently. On the
14
+ * callback's own return type the literal is checked where it is written, and the store-only field is a
15
+ * compile error at the line that added it.
16
+ */
17
+ export type PaymentsClientProduct = {
18
+ /** The logical product id — what `/payments/checkout` is asked for. */
19
+ id: string;
20
+ /** What kind of product it is. */
21
+ type: "consumable" | "non_consumable" | "subscription";
22
+ /** The entitlement keys it grants. Gating code names these, never the product. */
23
+ entitlements: string[];
24
+ /** The display name a paywall renders. */
25
+ name: string;
26
+ /**
27
+ * This product's SKU on each web rail, or null where it is not sold. Keyed by rail rather than one
28
+ * field per rail, so a screen asks `skus[rail]` and a new rail cannot leave a `purchasable()` check
29
+ * silently out of date. Every id here is publishable by design — each is what a checkout names.
30
+ *
31
+ * Apple's and Google's product ids are deliberately absent: a browser cannot open either store.
32
+ */
33
+ skus: {
34
+ /** The Stripe price id. */
35
+ stripe: string | null;
36
+ /** The Lemon Squeezy variant id. */
37
+ lemonSqueezy: string | null;
38
+ /** The Paddle price id. */
39
+ paddle: string | null;
40
+ };
41
+ };
42
+
43
+ /**
44
+ * What a browser may know about this project's payments — the shape of `virtual:pithy/payments`.
45
+ *
46
+ * **This declaration is the contract, and the projection is checked against it.** It is written here
47
+ * rather than inferred from the closure that builds it, and that is the whole point: an inferred type
48
+ * follows whatever the producer last happened to say, so a projection that dropped `basePath`, or that
49
+ * widened `type` because `PaymentsConfig` learned a fourth product kind, or that started passing a
50
+ * product's Apple id through, would take the type with it and nothing would go red. Declared, the
51
+ * function is the thing that has to change — every widening of what a browser sees is decided here.
52
+ *
53
+ * Nothing sensitive is in reach of the producer to begin with: Apple's issuer key, Google's service
54
+ * account, and Stripe's secret and signing keys live in the secrets store behind
55
+ * `paymentsSecretsRegistry`, not in the config the closure can see. What this type covers is the rest of
56
+ * the catalog — the `grants` block and the store-only SKUs, which are omissions of judgment rather than
57
+ * of reach, and so are the ones worth writing down.
58
+ *
59
+ * **`billingSubject` is a third omission of judgment, and it is deliberate.** The subject *id* obviously
60
+ * never crosses — it is a fact about one caller, and this projection is inlined into a bundle every caller
61
+ * receives, so there is no request here to be right about. But the mode is not a secret and is left out
62
+ * anyway, on the plainer ground that nothing in a browser can use it. A paywall renders products and buy
63
+ * buttons; who holds what it buys is decided on the server, on the request, by the subject seam, and a
64
+ * screen that branched on the mode would be a second copy of that decision drifting from the first. The
65
+ * shape is also not free: this type is what `@pithy-sh/vite` generates `templates/client-env.d.ts` from, so
66
+ * a key added here is a key added to every adopter's ambient declaration. It goes in the day something in
67
+ * a browser genuinely cannot be written without it, and not before.
68
+ *
69
+ * **This is the only statement of the shape.** `@pithy-sh/ui-react`'s `templates/client-env.d.ts` — the
70
+ * ambient declaration `pithy ui add react` copies into an adopter's Worker — is generated from this type
71
+ * by `@pithy-sh/vite`'s `clientEnvDeclaration.ts` (#398). The unions and the per-field doc comments below
72
+ * are emitted verbatim, so what is written here is what a screen author reads.
73
+ */
74
+ export type PaymentsClientProjection =
75
+ | {
76
+ /**
77
+ * Payments is not composed, or has no catalog this environment can render. Both read the same on
78
+ * purpose: "composed with nothing to sell" is a paywall with nothing on it, exactly like "not
79
+ * composed", and a screen branches on one value rather than guarding.
80
+ */
81
+ enabled: false;
82
+ }
83
+ | {
84
+ /** Payments is composed AND has a catalog this environment can render. */
85
+ enabled: true;
86
+ /** The environment this bundle was built for. */
87
+ environment: string;
88
+ /**
89
+ * Which rails this project sells through. Apple and Google are display-only on the web — a
90
+ * paywall shows such a product as owned-elsewhere rather than offering a buy button nothing on
91
+ * the web can honor.
92
+ */
93
+ rails: {
94
+ /** Whether the App Store rail is on. Display-only in a browser. */
95
+ apple: boolean;
96
+ /** Whether the Play Store rail is on. Display-only in a browser. */
97
+ google: boolean;
98
+ /** Whether the Stripe rail is on. */
99
+ stripe: boolean;
100
+ /** Whether the Lemon Squeezy rail is on. */
101
+ lemonSqueezy: boolean;
102
+ /** Whether the Paddle rail is on. */
103
+ paddle: boolean;
104
+ };
105
+ /**
106
+ * What Paddle.js needs to initialize, or null when the rail is off. The client token is
107
+ * publishable by design — it is what a browser opens a checkout with — and the API key and the
108
+ * webhook signing secret are neither here nor expressible here.
109
+ */
110
+ paddle: {
111
+ /** The publishable client token Paddle.js initializes with. */
112
+ clientToken: string;
113
+ /** Which Paddle account the token belongs to. */
114
+ environment: "sandbox" | "production";
115
+ /**
116
+ * How checkout is presented: `overlay` opens Paddle.js over your own page, `inline` renders it
117
+ * into a container the screen provides, `hosted` redirects to Paddle's own page.
118
+ *
119
+ * The union is stated, not `string`. A screen switches on this to decide whether to render a
120
+ * container at all, and the exhaustiveness is the point. It was the one field the hand-written
121
+ * `templates/client-env.d.ts` widened, and generating that file from here is what closed it.
122
+ */
123
+ checkout: "overlay" | "inline" | "hosted";
124
+ } | null;
125
+ /** Where the payments routes mount, e.g. `/payments`. */
126
+ basePath: string;
127
+ /**
128
+ * The catalog, browser-safe, in the order the adopter wrote it. A web rail's price id is
129
+ * publishable by design — a checkout names one. Apple's and Google's SKUs, and anything a
130
+ * purchase fulfills beyond its entitlements, stay server-side.
131
+ */
132
+ products: PaymentsClientProduct[];
133
+ };
@@ -0,0 +1,41 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PaddleCacheStore } from "../paddleCache";
5
+
6
+ /**
7
+ * A {@link PaddleCacheStore} a test can look inside.
8
+ *
9
+ * `localStorage` is the store an adopter passes and the wrong one to test against: a suite that reached
10
+ * for it would need a DOM, would leak entries between tests, and could not answer "what is in there now"
11
+ * without re-deriving the key the module chose. This is a `Map`, and the `Map` is exposed — so a test can
12
+ * assert an entry was written, corrupt one to see what happens next, or count what a run left behind.
13
+ *
14
+ * It enumerates, because `Storage` does: `length` and `key` are what a sweep walks, and a stand-in
15
+ * missing them would quietly exercise the un-swept path on every test that thought it was testing one.
16
+ */
17
+ export function memoryStore(): PaddleCacheStore & { entries: Map<string, string> } {
18
+ const entries = new Map<string, string>();
19
+ return {
20
+ entries,
21
+ get length() {
22
+ return entries.size;
23
+ },
24
+ key: (index: number) => [...entries.keys()][index] ?? null,
25
+ getItem: (key: string) => entries.get(key) ?? null,
26
+ setItem: (key: string, value: string) => {
27
+ entries.set(key, value);
28
+ },
29
+ removeItem: (key: string) => {
30
+ entries.delete(key);
31
+ },
32
+ };
33
+ }
34
+
35
+ /** A store that refuses everything — Safari in private browsing, and a quota that is full. */
36
+ export function refusingStore(): PaddleCacheStore {
37
+ const refuse = (): never => {
38
+ throw new Error("the store is unavailable");
39
+ };
40
+ return { getItem: refuse, setItem: refuse, removeItem: refuse };
41
+ }
@@ -0,0 +1,147 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { minorUnitDigits } from "../data/money";
5
+
6
+ /**
7
+ * Removing a fraction that is entirely zero from a price Paddle already rendered.
8
+ *
9
+ * A seller whose plans are `$6`, `€12` and `KD 6` renders `$6.00`, `12,00 €` and `<U+200F>٦٫٠٠٠ د.ك.<U+200F>`, and on a
10
+ * pricing table the decimal is noise on every row. Two Pithy surfaces reached the same conclusion and each
11
+ * wrote `total.replace(/([.,])00(?=\D*$)/, "")` — the same rule, byte for byte, in two repositories. That
12
+ * regex is wrong in the three-decimal dinars, silent in every Arabic-Indic script, and cannot be made
13
+ * right, because the only thing either surface holds is the finished string. This module is the reason
14
+ * neither of them has to.
15
+ *
16
+ * **The decision is arithmetic, and only this package holds the number.** Paddle sends every figure twice:
17
+ * in minor units for comparing, and rendered for showing. $6.00 is `600`, so whether the fraction is zero
18
+ * is `amount % 10 ** places === 0` — no parsing, no locale, no guess. That is what `PriceLine.totals` says
19
+ * it is for. It also removes the whole ambiguity class: *is the trailing `000` in `$1,000` a fraction or a
20
+ * thousands group?* is never asked, because the fraction is known before the string is touched.
21
+ *
22
+ * **The removal never asks which character the separator is.** It is a property of the locale, not of the
23
+ * currency, so the ISO code cannot give it — `de-DE` renders EUR as `6,00 €` and `en-IE` renders the same
24
+ * currency as `€6.00`. And the browser cannot give it either: `Intl.NumberFormat(undefined, …)` answers
25
+ * for the **visitor's** locale while the string was rendered by **Paddle**, so matching the browser's
26
+ * separator is a silent no-op in exactly the case where the two differ, which is the case that matters.
27
+ *
28
+ * So the fraction is located positionally: the trailing run of `places` decimal digits, in whatever
29
+ * script, with a single non-digit before it that must itself follow a digit. Trailing symbols, spaces and
30
+ * bidi marks sit after the run and are carried through untouched. A run preceded by another digit is a
31
+ * thousands group and is left alone — `$1,000` and `1.000 €` both come back exactly as they arrived.
32
+ *
33
+ * **A digit is treated as its script's zero when the code point below it is not a decimal digit.** Every
34
+ * `Nd` block is ten contiguous points beginning at zero, so that one comparison answers it for
35
+ * Arabic-Indic, Devanagari and most of the rest. The usual shortcuts do not: `Number("٠")` is `NaN`, and
36
+ * `"٠".normalize("NFKD")` is still `"٠"`.
37
+ *
38
+ * **It is not a universal, and the first draft of this paragraph said it was — Jim, 2026-08-21.** Five of
39
+ * Unicode's seventy-two `Nd` blocks sit immediately after another one, so their zero has a decimal digit
40
+ * below it and this test answers `false` for it.
41
+ *
42
+ * **The direction of that error is the reason it is still the right test.** A missed zero means the
43
+ * fraction is not recognized and the figure is returned exactly as Paddle rendered it — a silent refusal
44
+ * to trim, never a mangled price. A false *positive* is what would remove a digit that mattered, and
45
+ * cannot happen here: block lengths are all ten, so the start of a digit run is always a block zero.
46
+ *
47
+ * Brute-forced rather than reasoned about: 152,592 combinations over `Intl.supportedValuesOf("currency")`
48
+ * against 34 locales and 15 amounts produced zero value-changing outputs, holding
49
+ * `digits(out) * 10**places === digits(in)` with exactly one non-digit removed.
50
+ *
51
+ * **Only an all-zero fraction ever goes.** Nothing here divides, rounds or truncates, and a real fraction
52
+ * comes back whole. Both halves have to agree before a character is removed — the arithmetic says the
53
+ * fraction is zero *and* the digits in the string are zeros — because the two figures disagreeing means
54
+ * somebody handed this the wrong pair, and the string is the only thing a visitor sees.
55
+ *
56
+ * **`minorUnitDigits` is shared with the server half, deliberately.** It is `../data/money`'s ISO-4217
57
+ * exponent table, the one the purchase projection already uses, and it imports nothing — so the browser
58
+ * bundle gains a set of currency codes and no graph. A second copy of that table here would be this
59
+ * issue's own defect, one layer down.
60
+ *
61
+ * **#465 corrected `./paddle.ts` about `Intl` and did not touch this module's reasoning — checked, 2026-08-28.**
62
+ * That correction is that `Intl` does know the currency exponents, so a server with only minor units may
63
+ * render a figure. Neither half of the argument here rested on it. This module never needed the exponent
64
+ * from `Intl` — it has ISO 4217, which is what a *store* denominates in, and that turned out to be the
65
+ * stronger choice: CLDR renders `HUF` and `COP` with no fraction at all, while Paddle bills both with two,
66
+ * so a `places` taken from `Intl` would answer 0 for a currency whose minor unit is the hundredth. And the
67
+ * separator claim above is untouched, because it is not a claim about what `Intl` knows: it is that
68
+ * `Intl.NumberFormat(undefined, …)` answers for the **visitor's** locale while the string in hand was
69
+ * rendered in **Paddle's**, and the two differing is exactly the case that matters. Trimming a string
70
+ * Paddle rendered stays right here; rendering one from scratch stays wrong here, and is right only where
71
+ * Paddle rendered nothing — `../data/renderMoney.ts`, on the server, from the negotiated locale.
72
+ */
73
+
74
+ /** A decimal digit in any script. `Nd`, so Arabic-Indic and Devanagari count as much as ASCII. */
75
+ const DECIMAL_DIGIT = /\p{Nd}/u;
76
+
77
+ /** An amount in minor units, as Paddle writes one: an integer, optionally signed, and nothing else. */
78
+ const MINOR_UNITS = /^-?[0-9]+$/;
79
+
80
+ /** One figure, in both of the forms Paddle sends it in, and what it is denominated in. */
81
+ export interface WholeUnitPrice {
82
+ /** Paddle's own rendering of the figure for this visitor. The only thing ever returned. */
83
+ readonly formatted: string;
84
+ /** The same figure in minor units — `"600"` for $6.00, `"6000"` for KD 6.000. Never rendered. */
85
+ readonly minorAmount: string;
86
+ /** The ISO-4217 code the quote is in. It decides how many digits the fraction occupies. */
87
+ readonly currency: string;
88
+ }
89
+
90
+ /** Whether a single character is a decimal digit. */
91
+ function isDecimalDigit(character: string | undefined): boolean {
92
+ return character !== undefined && DECIMAL_DIGIT.test(character);
93
+ }
94
+
95
+ /** Whether a single character is the zero of its own script. */
96
+ function isScriptZero(character: string | undefined): boolean {
97
+ if (character === undefined || !isDecimalDigit(character)) return false;
98
+ const code = character.codePointAt(0);
99
+ if (code === undefined) return false;
100
+ return !isDecimalDigit(String.fromCodePoint(code - 1));
101
+ }
102
+
103
+ /**
104
+ * The string with its trailing `places`-digit zero fraction and that fraction's separator removed.
105
+ *
106
+ * By code point rather than by UTF-16 unit: a digit outside the BMP would be two units, and a scan that
107
+ * counted units would cut one of them in half. The string itself is returned wherever the shape is not
108
+ * exactly a fraction, which is every refusal in this file — there is no partial removal.
109
+ */
110
+ function withoutTrailingZeros(formatted: string, places: number): string {
111
+ const characters = [...formatted];
112
+ // Trailing symbols, spaces and bidi marks come after the fraction, so walk back to the last digit.
113
+ let end = characters.length;
114
+ while (end > 0 && !isDecimalDigit(characters[end - 1])) end -= 1;
115
+ const start = end - places;
116
+ // Two characters at least must precede the run: the separator, and the digit it separates from.
117
+ if (start < 2) return formatted;
118
+ for (let index = start; index < end; index += 1) {
119
+ if (!isScriptZero(characters[index])) return formatted;
120
+ }
121
+ // A digit before the run makes it a thousands group. A non-digit before *that* makes it something
122
+ // this cannot read as a price at all.
123
+ if (isDecimalDigit(characters[start - 1])) return formatted;
124
+ if (!isDecimalDigit(characters[start - 2])) return formatted;
125
+ return [...characters.slice(0, start - 1), ...characters.slice(end)].join("");
126
+ }
127
+
128
+ /**
129
+ * Paddle's rendering with an all-zero fraction removed, or that rendering exactly as it arrived.
130
+ *
131
+ * Opt-in everywhere it is reachable from, and never a default. Only an all-zero fraction ever goes, so an
132
+ * adopter selling at `$6.99` is unaffected either way — but which figures a seller advertises is a pricing
133
+ * decision, and this kit does not get to restyle somebody's prices on their behalf.
134
+ */
135
+ export function withoutZeroFraction(price: WholeUnitPrice): string {
136
+ const places = minorUnitDigits(price.currency);
137
+ // The yen has no subunit, so there is no fraction to consider and every digit in the string is part of
138
+ // the figure. This states that rather than enforcing it: with no places the scan below looks at a run
139
+ // of no digits, whose preceding character is the last digit of the figure, and refuses on its own. A
140
+ // test can only see the outcome, so the comment is where the intent lives.
141
+ if (places === 0) return price.formatted;
142
+ if (!MINOR_UNITS.test(price.minorAmount)) return price.formatted;
143
+ const amount = Number(price.minorAmount);
144
+ if (!Number.isSafeInteger(amount)) return price.formatted;
145
+ if (amount % 10 ** places !== 0) return price.formatted;
146
+ return withoutTrailingZeros(price.formatted, places);
147
+ }