@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,265 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * A price nobody has to fetch twice — opt-in, and only ever where the caller said to put it.
6
+ *
7
+ * One built artifact quotes the marketing site and the dashboard both, and each of them wants the same
8
+ * thing for a different reason: a marketing page a visitor moves around inside should not spend a Paddle
9
+ * round trip per page, and a dashboard that re-mounts a pricing pane should not re-ask a question it
10
+ * asked ten seconds ago. So the quote path can cache. What it may not do is decide any part of how.
11
+ *
12
+ * **Nothing is cached unless the caller names a store, a namespace and a lifetime.** All three, together.
13
+ * A quote resolved from `customerId` is one customer's price, resolved from the address on their account
14
+ * — so where it rests is not a detail this module may pick a default for. `sessionStorage` and
15
+ * `localStorage` are the same interface and very different promises about a shared machine, and only the
16
+ * program that knows who is signed in can choose between them. There is no default. There is no
17
+ * "sensible" fallback. A caller that wants a cache says where.
18
+ *
19
+ * **A lifetime is stated too, for the same reason.** Every figure here is Paddle's, and Paddle's figures
20
+ * move: two recordings of one Japanese price taken minutes apart came back ¥797 and ¥798, because the
21
+ * FX rate moved between the calls. Tax rules change, and a customer changes their billing address. A
22
+ * cached figure is a figure nobody re-checked, so how long that may last is the caller's call and nobody
23
+ * inherits a number they never looked at.
24
+ *
25
+ * **A partial cache is a mistake, and it says so.** Two of the three is what a caller reaches for first,
26
+ * and silently ignoring it would look exactly like caching that works — until the day somebody wonders
27
+ * why the network tab shows a request per page. It warns, names the parts that are missing, and quotes
28
+ * from the network. It never fails the quote: a broken cache must not be able to take a price off a page.
29
+ *
30
+ * **The question is inside the entry's key**, which is what makes caching a signed-in customer's price
31
+ * safe at all. Two visitors are two questions, so one can never be handed the other's answer; and the
32
+ * account is in there beside them, so a sandbox answer cannot survive into production.
33
+ *
34
+ * `console` rather than a logger, and this is the one file in the package that may. The kit's logger is
35
+ * a Worker's — resolved from a request context that does not exist here — and this module compiles into
36
+ * a browser program where `console` *is* the sink an adopter reads. `biome.jsonc` carries the exemption
37
+ * and this sentence is its reason.
38
+ */
39
+
40
+ /**
41
+ * The slice of a storage this needs.
42
+ *
43
+ * `localStorage` and `sessionStorage` both satisfy it as they are, and so does a `Map` wrapper, an
44
+ * in-memory object, or a caller's own store with a quota policy of its own. Structural for the reason
45
+ * every seam in this directory is: an adopter must be able to satisfy it without importing anything.
46
+ */
47
+ export interface PaddleCacheStore {
48
+ /** The stored text under a key, or null when there is none. */
49
+ getItem(key: string): string | null;
50
+ /** Store text under a key, replacing whatever was there. */
51
+ setItem(key: string, value: string): void;
52
+ /** Forget a key. Called on every entry this module decides it may no longer trust. */
53
+ removeItem(key: string): void;
54
+ /**
55
+ * How many entries the store holds, if it can say.
56
+ *
57
+ * `Storage` has this and `key` both, so `localStorage` and `sessionStorage` satisfy them as they are.
58
+ * They are optional because a caller's own three-method store is still a store — it simply does not
59
+ * get swept, which is the difference between a cache that tidies up after itself and one that does not.
60
+ */
61
+ readonly length?: number;
62
+ /** The nth key, if the store can enumerate. */
63
+ key?(index: number): string | null;
64
+ }
65
+
66
+ /** A cache, complete: where entries rest, what namespaces them, and how long one may stand. */
67
+ export interface PaddleQuoteCache {
68
+ /** The namespace entries are stored under. Two surfaces sharing a store share nothing else. */
69
+ readonly key: string;
70
+ /** Where entries rest. Named by the caller, always. */
71
+ readonly store: PaddleCacheStore;
72
+ /** How long an entry may stand, in milliseconds. Stated by the caller, always. */
73
+ readonly ttlMs: number;
74
+ }
75
+
76
+ /**
77
+ * The three parts as they arrive from outside TypeScript — a script tag's attributes, a JavaScript
78
+ * caller, a config file. Each may be absent, and each may be nonsense.
79
+ */
80
+ export interface PaddleQuoteCacheParts {
81
+ /** The intended namespace. */
82
+ readonly key?: string | null;
83
+ /**
84
+ * The intended store, or the **name** of one that did not resolve.
85
+ *
86
+ * A string is how a caller says "somebody named a store and nothing came back" — a script tag whose
87
+ * `data-paddle-cache-store` is a typo, an environment with no storage. It is not a store, so it is
88
+ * reported missing; but it *was* asked for, so the request is a partial cache and gets a line rather
89
+ * than the silence a caller who asked for nothing gets.
90
+ */
91
+ readonly store?: PaddleCacheStore | string | null;
92
+ /** The intended lifetime, in milliseconds. */
93
+ readonly ttlMs?: number | null;
94
+ }
95
+
96
+ /** What every entry this module writes is stored under, so a store shared with a page stays legible. */
97
+ const PREFIX = "pithy.paddle.price";
98
+
99
+ /** Whether a value can actually store: all three methods, not merely an object that arrived. */
100
+ function isStore(value: unknown): value is PaddleCacheStore {
101
+ if (typeof value !== "object" || value === null) return false;
102
+ const candidate = value as Partial<Record<keyof PaddleCacheStore, unknown>>;
103
+ return (
104
+ typeof candidate.getItem === "function" &&
105
+ typeof candidate.setItem === "function" &&
106
+ typeof candidate.removeItem === "function"
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Read a cache from parts that may be anything, or refuse it out loud.
112
+ *
113
+ * All three or none. Nothing given is a caller who wants no cache, and it says nothing at all. Anything
114
+ * given that does not add up to a cache is a caller who wanted one and will not get one, and that is
115
+ * worth a line in their console rather than a silence they would read as success.
116
+ */
117
+ export function readQuoteCache(parts: PaddleQuoteCacheParts): PaddleQuoteCache | null {
118
+ const key = typeof parts.key === "string" ? parts.key.trim() : "";
119
+ const store = isStore(parts.store) ? parts.store : null;
120
+ const ttlMs = typeof parts.ttlMs === "number" && Number.isFinite(parts.ttlMs) && parts.ttlMs > 0 ? parts.ttlMs : null;
121
+ if (key !== "" && store !== null && ttlMs !== null) return { key, store, ttlMs };
122
+
123
+ const asked = parts.key ?? parts.store ?? parts.ttlMs;
124
+ if (asked === undefined || asked === null) return null;
125
+
126
+ const missing = [key === "" ? "key" : null, store === null ? "store" : null, ttlMs === null ? "ttlMs" : null]
127
+ .filter((part): part is string => part !== null)
128
+ .join(", ");
129
+ console.warn(
130
+ `Paddle price cache ignored. Missing: ${missing}. A cache needs key, store and ttlMs together, so that where a customer's price rests and how long it stands are both stated. Pass all three, or none.`,
131
+ );
132
+ return null;
133
+ }
134
+
135
+ /** Everything one cache stores sits under this. */
136
+ function namespace(cache: PaddleQuoteCache): string {
137
+ return `${PREFIX}.${cache.key}.`;
138
+ }
139
+
140
+ /** Where one answer to one question rests. */
141
+ function entryKey(cache: PaddleQuoteCache, of: string): string {
142
+ return `${namespace(cache)}${of}`;
143
+ }
144
+
145
+ /** What one entry holds, or null for text this module will not trust. */
146
+ function readEntry(stored: string | null): { at: number; answer: unknown } | null {
147
+ if (stored === null) return null;
148
+ let parsed: unknown;
149
+ try {
150
+ parsed = JSON.parse(stored);
151
+ } catch {
152
+ return null;
153
+ }
154
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
155
+ const entry = parsed as { at?: unknown; answer?: unknown };
156
+ if (typeof entry.at !== "number" || !("answer" in entry)) return null;
157
+ return { at: entry.at, answer: entry.answer };
158
+ }
159
+
160
+ /**
161
+ * Whether an entry has stopped standing.
162
+ *
163
+ * Both directions. A negative age is a clock that moved backwards — a laptop waking up, a device
164
+ * correcting itself — and an entry stamped in the future would otherwise stand until the clock caught up
165
+ * with it, which is the one expiry nobody can predict the length of.
166
+ */
167
+ function stale(at: number, ttlMs: number): boolean {
168
+ const age = Date.now() - at;
169
+ return age < 0 || age > ttlMs;
170
+ }
171
+
172
+ /** Forget an entry, and never mind if the store will not let us. */
173
+ function forget(cache: PaddleQuoteCache, of: string): void {
174
+ try {
175
+ cache.store.removeItem(entryKey(cache, of));
176
+ } catch {
177
+ // A store that refuses a delete is a store this cannot repair. The entry expires on its own clock.
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The answer this cache holds for a question, while it is still fresh.
183
+ *
184
+ * Null for a miss, and *every* way of being wrong is a miss: no entry, unreadable text, an entry from a
185
+ * version that stored something else, one past its lifetime, one stamped in a future the clock has since
186
+ * moved back from, a store that throws on read. Each of those is a reason to ask Paddle again, and none
187
+ * of them is a reason to show a visitor no price.
188
+ *
189
+ * An entry it will not trust is thrown away rather than left to expire, so a store that has collected
190
+ * something unreadable stops being asked about it.
191
+ */
192
+ export function cachedAnswer(cache: PaddleQuoteCache, of: string): unknown | null {
193
+ let stored: string | null;
194
+ try {
195
+ stored = cache.store.getItem(entryKey(cache, of));
196
+ } catch {
197
+ return null;
198
+ }
199
+ if (stored === null) return null;
200
+
201
+ const entry = readEntry(stored);
202
+ if (entry === null || stale(entry.at, cache.ttlMs)) {
203
+ forget(cache, of);
204
+ return null;
205
+ }
206
+ return entry.answer;
207
+ }
208
+
209
+ /**
210
+ * Throw away this cache's own expired entries.
211
+ *
212
+ * **Its own, and judged by its own lifetime.** Two surfaces can share one store under different names
213
+ * and different ttls, and sweeping a neighbor's entries against *this* cache's number would throw away
214
+ * answers that are perfectly fresh by the rule they were kept under. Anything outside the namespace —
215
+ * another cache's, or the page's own — is not this module's to read or to delete.
216
+ *
217
+ * Silent, total, and skipped entirely where the store cannot enumerate. A sweep that fails costs the
218
+ * page nothing; the entry it did not reach expires on its own clock the next time anything asks for it.
219
+ */
220
+ function sweep(cache: PaddleQuoteCache): void {
221
+ const store = cache.store;
222
+ if (typeof store.key !== "function" || typeof store.length !== "number") return;
223
+ try {
224
+ const expired: string[] = [];
225
+ const mine = namespace(cache);
226
+ for (let index = 0; index < store.length; index += 1) {
227
+ const key = store.key(index);
228
+ if (key === null || key === undefined || !key.startsWith(mine)) continue;
229
+ const entry = readEntry(store.getItem(key));
230
+ if (entry === null || stale(entry.at, cache.ttlMs)) expired.push(key);
231
+ }
232
+ for (const key of expired) store.removeItem(key);
233
+ } catch {
234
+ // A store that will not be walked is a store that does not get tidied. Nothing else changes.
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Remember an answer to a question, if the store will have it.
240
+ *
241
+ * Silent about a refusal, deliberately. A full quota and Safari's private browsing both throw on write,
242
+ * and neither is a thing the visitor looking at the price can do anything about — the page already has
243
+ * its figure, and the only cost is asking again next time. The warning this module does emit is for a
244
+ * cache the *caller* got wrong, which is a thing they can fix.
245
+ *
246
+ * **It sweeps first.** Nothing reads a departed customer's key again, so nothing would ever expire it:
247
+ * a dashboard caching per customer on a shared machine writes one entry per person who ever signs in,
248
+ * the quota fills, and every write from then on throws into the silence above — caching stops working
249
+ * for everybody, and by design nobody is told. Expiring on the way past is what bounds it.
250
+ */
251
+ export function rememberAnswer(cache: PaddleQuoteCache, of: string, answer: unknown): void {
252
+ sweep(cache);
253
+ let text: string;
254
+ try {
255
+ text = JSON.stringify({ at: Date.now(), answer });
256
+ } catch {
257
+ // An answer that will not serialize is one this cannot store. Paddle's never is; a stub's might be.
258
+ return;
259
+ }
260
+ try {
261
+ cache.store.setItem(entryKey(cache, of), text);
262
+ } catch {
263
+ // Quota, or a store that only pretends to be one. The quote already happened.
264
+ }
265
+ }
@@ -0,0 +1,43 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { mountPrices } from "./paddlePricesTag";
5
+
6
+ /**
7
+ * The browser build's entry: one classic script, configured by its own tag.
8
+ *
9
+ * ```html
10
+ * <script src="/js/paddle-prices.js"
11
+ * data-paddle-env="sandbox"
12
+ * data-paddle-token="test_…"
13
+ * data-paddle-price-solo="pri_…"
14
+ * data-paddle-price-team="pri_…"></script>
15
+ * ```
16
+ *
17
+ * It quotes every plan the tag names and writes each formatted total into the `[data-price-plan]` slot
18
+ * that asked for it. A site with no build step gets the same quote path the dashboard imports, which is
19
+ * the whole point: `#416` was one surface's copy of this being wrong while the other surface's copy was
20
+ * right, and nothing ran both.
21
+ *
22
+ * **The answer is also handed back, on `window.pithyPaddlePrices`.** A site with its own formatting rule
23
+ * — `$6.00` rendered as `$6`, say — or its own markup to write sets `data-paddle-paint="off"` and awaits
24
+ * `window.pithyPaddlePrices.quotes` instead. Painting stays with whoever is rendering; only the quote is
25
+ * shared.
26
+ *
27
+ * **Caching is the tag's now**, and off unless it asks: `data-paddle-cache` names it,
28
+ * `data-paddle-cache-store` says `local` or `session`, and `data-paddle-cache-ttl` says for how many
29
+ * seconds. All three or none — a tag naming some of them warns and quotes from the network.
30
+ *
31
+ * `document.currentScript` is read here and nowhere deeper, because it is only itself while the script is
32
+ * running. Everything past that point takes the tag as an argument.
33
+ */
34
+
35
+ /** What the page can reach once this has run. */
36
+ export interface PithyPaddlePrices {
37
+ /** The quote, or the refusal. Resolves once; never rejects. */
38
+ readonly quotes: ReturnType<typeof mountPrices>;
39
+ }
40
+
41
+ (globalThis as unknown as { pithyPaddlePrices: PithyPaddlePrices }).pithyPaddlePrices = {
42
+ quotes: mountPrices(document, document.currentScript),
43
+ };
@@ -0,0 +1,143 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { PaymentsResult } from "./api";
5
+ import {
6
+ type PaddleOptions,
7
+ type PaddlePriceQuery,
8
+ type PaddleSetup,
9
+ type PriceSummaryOptions,
10
+ previewPrices,
11
+ priceSummary,
12
+ } from "./paddle";
13
+
14
+ /**
15
+ * One quote per named plan — the whole of what two Pithy surfaces share.
16
+ *
17
+ * The marketing site at `pithy.sh/pricing` and the dashboard both quote the same plans from the same
18
+ * Paddle account, and each once carried its own copy of the twelve lines that do it. One of those copies
19
+ * was wrong for months: `#416` read `currencyCode` and `details` off the top of `PricePreview()`'s answer
20
+ * when they live under `data`, so the reader refused every real response — and because a refusal is
21
+ * deliberately not an error, the screen rendered an empty price slot that looked deliberate. It was the
22
+ * *reviewed* copy that was wrong. The hand-written one on the marketing site had it right the whole time.
23
+ * Nothing could tell, because there was nothing that both of them ran.
24
+ *
25
+ * This is that thing. It goes through {@link previewPrices}, so it goes through `readPricePreview`, so
26
+ * the `#416` class of defect has exactly one place left it can exist.
27
+ *
28
+ * **What it deliberately does not do** is the reason it can be shared at all. It does not choose the
29
+ * account — the marketing site decides from the hostname at request time and the dashboard decides from
30
+ * `CLOUDFLARE_ENV` at build time, and both are right for their surface. It does not name a price id —
31
+ * the marketing site holds literals per account and the dashboard has a gate saying only one file in the
32
+ * Worker may name one. It does not cache — the dashboard forbids `localStorage` in a client module
33
+ * outright. And it does not paint: marketing writes text nodes, the dashboard renders React from the
34
+ * same answer. Given an account and a set of price ids, hand back a formatted total per plan. Everything
35
+ * above that stays with whoever is rendering.
36
+ */
37
+
38
+ /** Plan name to the Paddle price — `pri_…` — that quotes it. */
39
+ export type PaddlePlanPrices = Readonly<Record<string, string>>;
40
+
41
+ /**
42
+ * Who the quote is for, and where — every part of a `PricePreview` query but the prices themselves.
43
+ *
44
+ * **A quote must resolve from the same Paddle row the charge will.** Omitted, Paddle resolves location
45
+ * from the browser's IP, which is right for a stranger reading a marketing page and wrong for everybody
46
+ * who has signed in: a customer with an address on file is quoted from whatever network they happen to
47
+ * be on, and `priceSummary` marks the figure `estimated` because no postal code resolved. Pass
48
+ * `customerId` and Paddle prices them from the address `POST /payments/checkout` will bill.
49
+ *
50
+ * Derived from {@link PaddlePriceQuery} rather than restated, so a field Paddle adds arrives here with
51
+ * it. `items` is the one part {@link quotePlans} owns — it is the plans, and there is one source of it.
52
+ */
53
+ export type PaddleQuoteQuery = Omit<PaddlePriceQuery, "items">;
54
+
55
+ /** What {@link quotePlans} lets a caller replace, plus who the quote is for. */
56
+ export interface PaddleQuoteOptions extends PaddleOptions, PriceSummaryOptions {
57
+ /** Who to quote for, and where they are. Omitted, Paddle resolves it from the visitor's IP. */
58
+ readonly query?: PaddleQuoteQuery;
59
+ }
60
+
61
+ /** What one plan costs this visitor, ready to render. */
62
+ export interface PaddlePlanQuote {
63
+ /** The plan this quotes, as the caller named it. */
64
+ readonly plan: string;
65
+ /** The Paddle price it resolved to. */
66
+ readonly priceId: string;
67
+ /**
68
+ * The figure to show, already formatted by Paddle for this visitor.
69
+ *
70
+ * Per unit, and not the same field in every country: where tax is added on top the listed price is the
71
+ * subtotal, and where it is taken out of an inclusive figure the listed price is the total. See
72
+ * {@link priceSummary}, which is where that decision is made and tested.
73
+ *
74
+ * Paddle's own string, unless the caller passed `wholeUnits` — the one thing that ever changes it, and
75
+ * only by removing a fraction that is entirely zero.
76
+ */
77
+ readonly headline: string;
78
+ /** One sentence about tax, or null where there is nothing true to say. */
79
+ readonly note: string | null;
80
+ /** Whether the tax in this quote may be short of what the buyer is charged. */
81
+ readonly estimated: boolean;
82
+ /**
83
+ * The ISO-4217 currency Paddle answered in — `"USD"`, `"JPY"`.
84
+ *
85
+ * {@link headline} is already formatted, so nothing here needs this to render. A caller that formats
86
+ * the figure itself does, and this is the only place it exists: `PriceSummary` carries no currency, so
87
+ * a caller reading only the quote had `preview.currencyCode` held one layer down and dropped.
88
+ */
89
+ readonly currency: string;
90
+ }
91
+
92
+ /**
93
+ * Quote every named plan, or refuse.
94
+ *
95
+ * One `PricePreview` call for the whole set, because it is one round trip and Paddle answers a line per
96
+ * item. A plan Paddle returned no line for is left out rather than quoted from another plan's line — the
97
+ * caller sees a plan missing, which its own markup already has a sentence for, instead of two plans
98
+ * showing one price.
99
+ *
100
+ * **No plans means no network.** An unconfigured page must cost a visitor nothing, and `PricePreview`
101
+ * with an empty `items` is a request Paddle refuses anyway.
102
+ *
103
+ * **One price is asked for once, however many plans name it.** A pricing table with a highlighted row
104
+ * points two plans at one price often enough; asking for it twice is redundant at best, and whether
105
+ * Paddle tolerates a repeated `priceId` is not a thing to find out in production — a refusal there takes
106
+ * the whole table down to its placeholders, not just the duplicated row. Both plans still resolve, off
107
+ * the one line, because a plan is matched to its line by id.
108
+ */
109
+ export async function quotePlans(
110
+ setup: PaddleSetup,
111
+ plans: PaddlePlanPrices,
112
+ options?: PaddleQuoteOptions,
113
+ ): Promise<PaymentsResult<readonly PaddlePlanQuote[]>> {
114
+ const named = Object.entries(plans);
115
+ if (named.length === 0) return { ok: true, value: [] };
116
+
117
+ const asked = [...new Set(named.map(([, priceId]) => priceId))];
118
+ // The caller's half of the query first and `items` last, so a query naming items — which TypeScript
119
+ // forbids and a caller compiled from JavaScript is not asking TypeScript about — cannot quietly replace
120
+ // the prices the plans named.
121
+ const preview = await previewPrices(
122
+ setup,
123
+ { ...options?.query, items: asked.map((priceId) => ({ priceId, quantity: 1 })) },
124
+ options,
125
+ );
126
+ if (!preview.ok) return preview;
127
+
128
+ const quotes: PaddlePlanQuote[] = [];
129
+ for (const [plan, priceId] of named) {
130
+ const line = preview.value.lines.find((candidate) => candidate.priceId === priceId);
131
+ if (line === undefined) continue;
132
+ const summary = priceSummary(preview.value, line, { wholeUnits: options?.wholeUnits });
133
+ quotes.push({
134
+ plan,
135
+ priceId,
136
+ headline: summary.headline,
137
+ note: summary.note,
138
+ estimated: summary.estimated,
139
+ currency: preview.value.currencyCode,
140
+ });
141
+ }
142
+ return { ok: true, value: quotes };
143
+ }