@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,590 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+ import {
6
+ type EntitlementView,
7
+ getEntitlements,
8
+ openBillingPortal,
9
+ openStoreSubscriptions,
10
+ type PaddleCheckoutHandoff,
11
+ type PaymentsClientOptions,
12
+ type PaymentsClientRail,
13
+ type PaymentsFailure,
14
+ type PaymentsHostedRail,
15
+ type PurchaseView,
16
+ restorePurchases,
17
+ startCheckout,
18
+ submitPurchase,
19
+ } from "./api";
20
+ import { openPaddleCheckout, type PaddleCheckoutOptions } from "./checkout";
21
+ import {
22
+ loadPaddle,
23
+ type PaddleJs,
24
+ type PaddleOptions,
25
+ type PaddlePriceQuery,
26
+ type PaddleSetup,
27
+ type PricePreview,
28
+ previewPrices,
29
+ priceQueryKey,
30
+ } from "./paddle";
31
+
32
+ /**
33
+ * The headless client surface: six hooks a paywall, a pricing page, a subscription screen, and a route
34
+ * guard are built out of.
35
+ *
36
+ * **They live here rather than in a scaffolded `.tsx` on purpose.** `pithy ui add` writes a file once and
37
+ * may never rewrite it, which is the right ownership rule and is exactly why a frozen paywall ages badly:
38
+ * store rules move — price-change consent prompts, external purchase link entitlements, subscription
39
+ * management requirements — and a purchase flow living in the adopter's repo is one Pithy cannot fix for
40
+ * them. So the hook owns the calls, the redirect-and-return dance, the error mapping and the entitlement
41
+ * reads, and upgrades with a minor release; the stub renders and styles, calling these rather than
42
+ * reimplementing them. Principle 3, applied to the client.
43
+ *
44
+ * `react` is an **optional** peer dependency, and this module is reachable only by its own deep path
45
+ * (`@pithy-sh/payments/src/client/hooks`). It is deliberately absent from `src/index.ts` — that is what
46
+ * keeps React out of a Worker bundle that composes payments.
47
+ *
48
+ * Cookie/session throughout, because `./api` is: same-origin, `credentials: "include"`, no token in web
49
+ * storage, no bearer header. Bearer stays the mobile path.
50
+ *
51
+ * **Nothing here throws, and nothing here is a security boundary.** A refusal is a message to render, and
52
+ * the server's `requireEntitlement()` is the gate; these exist so a user is sent to the paywall rather than
53
+ * shown a 403.
54
+ *
55
+ * **A failed read is not an answer, and each hook says so in the way its caller needs.** `useEntitlement`
56
+ * is a lock, so it fails closed — `entitled` stays false — and reports `readFailure` beside it so a screen
57
+ * can tell "you don't have Pro" from "we couldn't check". `useSubscription` *names the plan*, where failing
58
+ * closed would be a lie: an empty entitlement list is a positive claim that the account is on the free
59
+ * floor, so it reports the failure instead of rendering one.
60
+ */
61
+
62
+ /**
63
+ * Hold the newest value without making it a dependency.
64
+ *
65
+ * Every hook below takes an options object, and a caller writing `useEntitlement("pro", { basePath })`
66
+ * inline creates a new object every render. Depending on it would re-run the effect forever. So the
67
+ * effects depend on the one thing that can actually change the request — `basePath`, a string — and read
68
+ * the rest through this.
69
+ */
70
+ function useLatest<T>(value: T): { current: T } {
71
+ const ref = useRef(value);
72
+ ref.current = value;
73
+ return ref;
74
+ }
75
+
76
+ /** Whether a component is still mounted. Every async completion checks it before setting state. */
77
+ function useLive(): { current: boolean } {
78
+ const live = useRef(true);
79
+ useEffect(() => {
80
+ live.current = true;
81
+ return () => {
82
+ live.current = false;
83
+ };
84
+ }, []);
85
+ return live;
86
+ }
87
+
88
+ /** What {@link useEntitlement} gives a screen. */
89
+ export interface UseEntitlement {
90
+ /** Whether the caller holds the key right now. `false` while loading — a paywall fails closed. */
91
+ entitled: boolean;
92
+ /** Whether the first read is still in flight. */
93
+ loading: boolean;
94
+ /**
95
+ * Why the last read could not be made, or null.
96
+ *
97
+ * Distinct from `entitled: false`, which is an answer. This is the absence of one, and a screen that
98
+ * says "you don't have Pro" and one that says "we couldn't check" are different screens. `entitled`
99
+ * still fails closed while this is set — the lock holds, it just admits it is guessing.
100
+ */
101
+ readFailure: PaymentsFailure | null;
102
+ /** Re-read. Call it after a purchase completes. */
103
+ refresh: () => void;
104
+ }
105
+
106
+ /**
107
+ * Whether the caller holds `key`.
108
+ *
109
+ * Starts `false`, always. Starting `true` would flash the paid screen to everyone for one frame, which is
110
+ * both a leak and worse to look at than a spinner. An unreachable Worker reads as not entitled for the
111
+ * same reason: the server check is the boundary, so failing closed here costs nothing.
112
+ *
113
+ * The failing-closed happens *here*, visibly, rather than inside {@link getEntitlements} — a lock is a
114
+ * refusal and can carry an escape route on it, which is what `readFailure` is. A caller that names the
115
+ * plan instead of locking it must not inherit this choice; see {@link useSubscription}.
116
+ */
117
+ export function useEntitlement(key: string, options?: PaymentsClientOptions): UseEntitlement {
118
+ const [entitled, setEntitled] = useState(false);
119
+ const [loading, setLoading] = useState(true);
120
+ const [readFailure, setReadFailure] = useState<PaymentsFailure | null>(null);
121
+ const latest = useLatest(options);
122
+ const live = useLive();
123
+ const basePath = options?.basePath;
124
+
125
+ const refresh = useCallback(() => {
126
+ setLoading(true);
127
+ // `basePath` is read here rather than off the ref, so it is a real dependency: it is the one option
128
+ // that changes the request, and a project that moved its routes must re-read. Everything else comes
129
+ // through the ref, which is why an inline options object does not restart this on every render.
130
+ void getEntitlements({ ...latest.current, basePath }).then((result) => {
131
+ if (!live.current) return;
132
+ // Fail closed on the answer, report the failure beside it. Both, not either.
133
+ setEntitled(result.ok && result.value.some((entitlement) => entitlement.key === key && entitlement.granted));
134
+ setReadFailure(result.ok ? null : result.failure);
135
+ setLoading(false);
136
+ });
137
+ }, [key, basePath, latest, live]);
138
+
139
+ useEffect(refresh, [refresh]);
140
+
141
+ return { entitled, loading, readFailure, refresh };
142
+ }
143
+
144
+ /** What {@link useSubscription} gives a subscription screen. */
145
+ export interface UseSubscription {
146
+ /** Every entitlement the caller holds, as the server resolved it. */
147
+ entitlements: readonly EntitlementView[];
148
+ /** Whether any entitlement grants right now — the "is this account paid" answer, without naming a key. */
149
+ subscribed: boolean;
150
+ /** Whether the read is in flight. */
151
+ loading: boolean;
152
+ /** Re-read. */
153
+ refresh: () => void;
154
+ /** Open Stripe's Billing Portal for this caller. Resolves once the browser has been sent, or refused. */
155
+ manage: () => Promise<void>;
156
+ /** Send the visitor to a store's own subscription page — the only management a web page can offer there. */
157
+ manageStore: (rail: "apple" | "google") => void;
158
+ /** Whether a portal session is being created. */
159
+ managing: boolean;
160
+ /** The last refusal of something the subscriber *asked for* — opening the portal — or null. */
161
+ failure: PaymentsFailure | null;
162
+ /**
163
+ * Why the entitlements could not be read, or null. Kept apart from `failure` deliberately.
164
+ *
165
+ * This screen names the plan, and free is the floor of every ladder: an empty `entitlements` is a
166
+ * positive claim that the account is on the cheapest tier. Rendering that from a read that never
167
+ * happened tells a paying customer they are on Free and offers to sell them what they have. So a
168
+ * failed read leaves `entitlements` empty *and* says so here, and a screen must consult this before
169
+ * it renders a tier name. Collapsing it into `failure` would let a stale portal refusal mask it.
170
+ */
171
+ readFailure: PaymentsFailure | null;
172
+ }
173
+
174
+ /**
175
+ * The caller's standing entitlements, and the one action a subscriber needs.
176
+ *
177
+ * Managing a subscription is the store's job, not Pithy's — Stripe's Billing Portal owns cancellation,
178
+ * card changes and plan moves, under Stripe's own rules and its own compliance. Apple and Google
179
+ * subscriptions are managed in their own apps, which a web screen can only link to.
180
+ */
181
+ export function useSubscription(options?: PaymentsClientOptions): UseSubscription {
182
+ const [entitlements, setEntitlements] = useState<readonly EntitlementView[]>([]);
183
+ const [loading, setLoading] = useState(true);
184
+ const [managing, setManaging] = useState(false);
185
+ const [failure, setFailure] = useState<PaymentsFailure | null>(null);
186
+ const [readFailure, setReadFailure] = useState<PaymentsFailure | null>(null);
187
+ const latest = useLatest(options);
188
+ const live = useLive();
189
+ const basePath = options?.basePath;
190
+
191
+ const refresh = useCallback(() => {
192
+ setLoading(true);
193
+ // `basePath` explicitly, for the reason `useEntitlement` gives: it is the option that changes the
194
+ // request, so it has to be a dependency rather than something read off a ref.
195
+ void getEntitlements({ ...latest.current, basePath }).then((result) => {
196
+ if (!live.current) return;
197
+ // A refusal leaves the previous list alone rather than blanking it — the same rule `usePurchase`
198
+ // follows. What a screen must not do is read the untouched list as this account's current plan,
199
+ // which is what `readFailure` is there to stop.
200
+ if (result.ok) setEntitlements(result.value);
201
+ setReadFailure(result.ok ? null : result.failure);
202
+ setLoading(false);
203
+ });
204
+ }, [basePath, latest, live]);
205
+
206
+ useEffect(refresh, [refresh]);
207
+
208
+ const manage = useCallback(async () => {
209
+ setManaging(true);
210
+ setFailure(null);
211
+ const refused = await openBillingPortal(latest.current);
212
+ if (!live.current) return;
213
+ setManaging(false);
214
+ // On success the browser is already leaving; setting state is harmless and keeps the two paths one shape.
215
+ if (refused) setFailure(refused);
216
+ }, [latest, live]);
217
+
218
+ const manageStore = useCallback(
219
+ (rail: "apple" | "google") => {
220
+ // Synchronous, and it can only fail by there being no browser — nothing to await and nothing to
221
+ // report that a screen could act on.
222
+ openStoreSubscriptions(rail, latest.current);
223
+ },
224
+ [latest],
225
+ );
226
+
227
+ return {
228
+ entitlements,
229
+ subscribed: entitlements.some((entitlement) => entitlement.granted),
230
+ loading,
231
+ refresh,
232
+ manage,
233
+ manageStore,
234
+ managing,
235
+ failure,
236
+ readFailure,
237
+ };
238
+ }
239
+
240
+ /** What {@link useCheckout} gives a paywall's buy button. */
241
+ export interface UseCheckout {
242
+ /**
243
+ * Start checkout for a product.
244
+ *
245
+ * `rail` is only needed for a product sold on **more than one** hosted rail, where the server refuses to
246
+ * choose on the buyer's behalf. A product sold on one needs no argument.
247
+ */
248
+ start: (productId: string, options?: { rail?: PaymentsHostedRail; discountCode?: string }) => Promise<void>;
249
+ /** Whether a session is being created. Disable the button on it — a double click is a double session. */
250
+ starting: boolean;
251
+ /** The last refusal, or null. Cleared at the start of every attempt. */
252
+ failure: PaymentsFailure | null;
253
+ /**
254
+ * The handoff to open with Paddle.js, or null — set only on a rail with nowhere to navigate to.
255
+ *
256
+ * Null on a redirect rail, because the browser has already left and there is nothing for a screen to
257
+ * hold. Pass it to {@link usePaddleCheckout}, which opens it; a screen that never composes Paddle reads
258
+ * null forever and renders exactly as it did before this field existed.
259
+ */
260
+ handoff: PaddleCheckoutHandoff | null;
261
+ }
262
+
263
+ /**
264
+ * The web purchase path: start a checkout, and go wherever that rail goes.
265
+ *
266
+ * Apple and Google purchases happen inside a store SDK before any server hears of them, so there is no
267
+ * session for Pithy to create and no browser flow to start — a web paywall lists those products, it does
268
+ * not sell them. Stripe's and Lemon Squeezy's hosted checkouts need no SDK script and no publishable key
269
+ * in the page: the server mints a session, this navigates to it, and the store owns everything in between.
270
+ *
271
+ * Paddle is the one that does not leave. This hook does the same work for it — the server call, the
272
+ * refusal — and stops at the handoff, because opening it belongs where the container is. That is
273
+ * {@link usePaddleCheckout}.
274
+ */
275
+ export function useCheckout(options?: PaymentsClientOptions): UseCheckout {
276
+ const [starting, setStarting] = useState(false);
277
+ const [failure, setFailure] = useState<PaymentsFailure | null>(null);
278
+ const [handoff, setHandoff] = useState<PaddleCheckoutHandoff | null>(null);
279
+ const latest = useLatest(options);
280
+ const live = useLive();
281
+
282
+ const start = useCallback(
283
+ async (productId: string, choices?: { rail?: PaymentsHostedRail; discountCode?: string }) => {
284
+ setStarting(true);
285
+ setFailure(null);
286
+ setHandoff(null);
287
+ const outcome = await startCheckout(
288
+ { productId, rail: choices?.rail, discountCode: choices?.discountCode },
289
+ latest.current,
290
+ );
291
+ if (!live.current) return;
292
+ setStarting(false);
293
+ // Exhaustive on `kind` rather than truthiness. `left` is the browser already going; `paddle` is the
294
+ // one outcome a screen must act on; `refused` is the only one that is a failure. Reading any of the
295
+ // three as another is how a buyer ends up on a page whose button did nothing.
296
+ if (outcome.kind === "refused") setFailure(outcome.failure);
297
+ else if (outcome.kind === "paddle") setHandoff(outcome.handoff);
298
+ },
299
+ [latest, live],
300
+ );
301
+
302
+ return { start, starting, failure, handoff };
303
+ }
304
+
305
+ /** What {@link usePaddleCheckout} gives the screen that composes Paddle. */
306
+ export interface UsePaddleCheckout {
307
+ /**
308
+ * Whether this handoff needs a container on the page.
309
+ *
310
+ * Read it to render one, and render it unconditionally beside the button rather than deciding for
311
+ * yourself: the mode is `paddle.checkout` in the project's config, resolved by the server, so a project
312
+ * that switches from `overlay` to `inline` must not also have to edit a screen it was given a year ago.
313
+ */
314
+ inline: boolean;
315
+ /** Whether the checkout is being opened — the script may still be loading. */
316
+ opening: boolean;
317
+ /** Why it could not be opened, or null. */
318
+ failure: PaymentsFailure | null;
319
+ }
320
+
321
+ /**
322
+ * Open the handoff {@link useCheckout} produced, with Paddle.js, over this page or inside it.
323
+ *
324
+ * **In an effect, and that is the whole reason this is a hook rather than a line in a click handler.**
325
+ * Inline checkout renders into an element the screen provides, and Paddle finds it by class name at the
326
+ * moment `open` is called. Called from the handler that starts the checkout, the container React is about
327
+ * to render does not exist yet — and what Paddle does then is throw `TypeError: Cannot read properties of
328
+ * undefined (reading 'appendChild')` out of the click, which is not a sentence anyone can act on. An
329
+ * effect runs after the commit that revealed the container, so the element is there.
330
+ *
331
+ * One open per handoff. `start` mints a new transaction on every attempt, so the transaction id is what a
332
+ * fresh attempt looks like; a re-render with the same handoff must not open a second checkout over the
333
+ * first — and neither must a second *mount* of the same one. React's `StrictMode` runs every effect,
334
+ * cleans it up and runs it again, and that is the mode `pithy ui add` scaffolds: `client.tsx` wraps the
335
+ * router in it. So the transaction that has been opened is remembered, rather than the effect trusting
336
+ * that it runs once. The remembered id is the last one opened, not a flag — a buyer who closed the
337
+ * overlay and clicked Buy again arrives with a new transaction, and that one must open.
338
+ */
339
+ export function usePaddleCheckout(
340
+ handoff: PaddleCheckoutHandoff | null,
341
+ options?: PaddleCheckoutOptions,
342
+ ): UsePaddleCheckout {
343
+ const [opening, setOpening] = useState(false);
344
+ const [failure, setFailure] = useState<PaymentsFailure | null>(null);
345
+ const latest = useLatest(options);
346
+ const latestHandoff = useLatest(handoff);
347
+ const live = useLive();
348
+ const transactionId = handoff?.transactionId;
349
+ /** The transaction this hook has already opened, so a second effect pass over it opens nothing. */
350
+ const opened = useRef<string | null>(null);
351
+
352
+ // `transactionId` stands in for the handoff, the way `priceQueryKey` stands in for a query: the object
353
+ // is rebuilt by `useCheckout` on every attempt and identity would be a fine dependency today, but a
354
+ // screen that builds one inline would then reopen the checkout on every render.
355
+ useEffect(() => {
356
+ const current = latestHandoff.current;
357
+ if (transactionId === undefined || current === null) return;
358
+ // StrictMode's second pass, or any remount holding the same handoff. One click bought one checkout.
359
+ if (opened.current === transactionId) return;
360
+ opened.current = transactionId;
361
+ setOpening(true);
362
+ setFailure(null);
363
+ void openPaddleCheckout(current, latest.current).then((refused) => {
364
+ if (!live.current) return;
365
+ setOpening(false);
366
+ setFailure(refused);
367
+ });
368
+ }, [transactionId, latest, latestHandoff, live]);
369
+
370
+ return { inline: handoff?.displayMode === "inline", opening, failure };
371
+ }
372
+
373
+ /** What {@link usePurchase} gives the screen that submits receipts. */
374
+ export interface UsePurchase {
375
+ /** Submit one receipt for verification. */
376
+ submit: (rail: PaymentsClientRail, receipt: string) => Promise<void>;
377
+ /** Submit a store account's whole history on one rail — Restore Purchases. */
378
+ restore: (rail: PaymentsClientRail, receipts: readonly string[]) => Promise<void>;
379
+ /** The purchase the last successful submission projected, or null. */
380
+ purchase: PurchaseView | null;
381
+ /** The caller's entitlements as of the last successful write. */
382
+ entitlements: readonly EntitlementView[];
383
+ /** Whether a submission is in flight. */
384
+ busy: boolean;
385
+ /** The last refusal, or null. */
386
+ failure: PaymentsFailure | null;
387
+ }
388
+
389
+ /**
390
+ * Submitting receipts — the path that exists so a buyer sees their entitlement now rather than when the
391
+ * webhook lands.
392
+ *
393
+ * On the web that is the Stripe return: the success URL carries the Checkout Session id, and posting it
394
+ * here projects the purchase at once. On a native client it is the store SDK's transaction, and `restore`
395
+ * is Restore Purchases. Dropping either call costs nothing — the webhook produces the identical row, and
396
+ * the write is idempotent on `(rail, providerTransactionId)`.
397
+ *
398
+ * A refusal leaves `purchase` and `entitlements` exactly as they were. Clearing them would flicker a
399
+ * paywall back over a feature the user already owns, on nothing more than a failed second submission.
400
+ */
401
+ export function usePurchase(options?: PaymentsClientOptions): UsePurchase {
402
+ const [purchase, setPurchase] = useState<PurchaseView | null>(null);
403
+ const [entitlements, setEntitlements] = useState<readonly EntitlementView[]>([]);
404
+ const [busy, setBusy] = useState(false);
405
+ const [failure, setFailure] = useState<PaymentsFailure | null>(null);
406
+ const latest = useLatest(options);
407
+ const live = useLive();
408
+
409
+ const submit = useCallback(
410
+ async (rail: PaymentsClientRail, receipt: string) => {
411
+ setBusy(true);
412
+ setFailure(null);
413
+ const result = await submitPurchase({ rail, receipt }, latest.current);
414
+ if (!live.current) return;
415
+ setBusy(false);
416
+ if (!result.ok) {
417
+ setFailure(result.failure);
418
+ return;
419
+ }
420
+ setPurchase(result.value.purchase);
421
+ setEntitlements(result.value.entitlements);
422
+ },
423
+ [latest, live],
424
+ );
425
+
426
+ const restore = useCallback(
427
+ async (rail: PaymentsClientRail, receipts: readonly string[]) => {
428
+ setBusy(true);
429
+ setFailure(null);
430
+ const result = await restorePurchases({ rail, receipts }, latest.current);
431
+ if (!live.current) return;
432
+ setBusy(false);
433
+ if (!result.ok) {
434
+ setFailure(result.failure);
435
+ return;
436
+ }
437
+ setPurchase(result.value.purchases[0] ?? null);
438
+ setEntitlements(result.value.entitlements);
439
+ },
440
+ [latest, live],
441
+ );
442
+
443
+ return { submit, restore, purchase, entitlements, busy, failure };
444
+ }
445
+
446
+ /** What {@link usePaddle} gives a screen that has to talk to Paddle.js directly. */
447
+ export interface UsePaddle {
448
+ /** The initialized Paddle.js, or null while it loads and after it fails. */
449
+ paddle: PaddleJs | null;
450
+ /** Whether the load is in flight. False forever when there is no Paddle rail to load. */
451
+ loading: boolean;
452
+ /** Why it could not load, or null. */
453
+ failure: PaymentsFailure | null;
454
+ }
455
+
456
+ /**
457
+ * Paddle.js, loaded once per page.
458
+ *
459
+ * `setup` takes null so a screen never has to guard a hook: `paymentsConfig.paddle` is null when the
460
+ * rail is off, and passing that through reads as "nothing to load" rather than as a failure. A pricing
461
+ * page for a project that does not sell through Paddle renders its own empty state; it does not show an
462
+ * error about a provider it never asked for.
463
+ *
464
+ * Loading is the module's job, not this hook's — mounting two components that both call it produces one
465
+ * script and one `Initialize`, because {@link loadPaddle} remembers the page's one load. This is the
466
+ * React-shaped view of it: a state a screen can render.
467
+ */
468
+ export function usePaddle(setup: PaddleSetup | null, options?: PaddleOptions): UsePaddle {
469
+ const [paddle, setPaddle] = useState<PaddleJs | null>(null);
470
+ const [loading, setLoading] = useState(setup !== null);
471
+ const [failure, setFailure] = useState<PaymentsFailure | null>(null);
472
+ const latest = useLatest(options);
473
+ const live = useLive();
474
+ // The two fields, not the object: a screen writing `usePaddle(paymentsConfig.paddle)` passes a stable
475
+ // reference today, and one writing an object literal must not restart the load every render.
476
+ const clientToken = setup?.clientToken;
477
+ const environment = setup?.environment;
478
+
479
+ useEffect(() => {
480
+ if (clientToken === undefined || environment === undefined) {
481
+ setLoading(false);
482
+ return;
483
+ }
484
+ setLoading(true);
485
+ void loadPaddle({ clientToken, environment }, latest.current).then((result) => {
486
+ if (!live.current) return;
487
+ setPaddle(result.ok ? result.value : null);
488
+ setFailure(result.ok ? null : result.failure);
489
+ setLoading(false);
490
+ });
491
+ }, [clientToken, environment, latest, live]);
492
+
493
+ return { paddle, loading, failure };
494
+ }
495
+
496
+ /** What {@link usePricePreview} gives a pricing screen. */
497
+ export interface UsePricePreview {
498
+ /**
499
+ * What Paddle quoted this visitor, or null.
500
+ *
501
+ * Null while the first quote is in flight and null after a failure — **never a fallback figure.** A
502
+ * hardcoded price behind a failed lookup is the exact defect this hook exists to remove: it is wrong
503
+ * in every country whose tax convention differs from the one it was written in, and it is wrong
504
+ * silently. A page with no price and an honest sentence is worse than a right price and better than a
505
+ * wrong one.
506
+ */
507
+ preview: PricePreview | null;
508
+ /** Whether a quote is in flight. True on the first render, so a screen can hold the space. */
509
+ loading: boolean;
510
+ /** Why the last quote could not be made, or null. */
511
+ failure: PaymentsFailure | null;
512
+ /** Ask again. */
513
+ refresh: () => void;
514
+ }
515
+
516
+ /**
517
+ * What this visitor pays, read from Paddle for this visitor.
518
+ *
519
+ * **No price string appears in any screen this kit ships.** That is the whole contract. The figures come
520
+ * from Paddle, rendered by Paddle for the visitor's country — which is the only way one page can quote
521
+ * $5.44 in New York, $5.75 in Chicago, $5.00 in Berlin including €0.80 of VAT, and ¥725 in Tokyo without
522
+ * a table of tax rates aging in somebody's repository.
523
+ *
524
+ * `query` may be written inline. The effect depends on {@link priceQueryKey} rather than on the object,
525
+ * so an object literal re-created on every render re-quotes when the request changed and not before.
526
+ *
527
+ * A failure clears the previous quote rather than leaving it. The other hooks here keep their last good
528
+ * value on a refusal, and this one deliberately does not: a re-quote happens because the *request*
529
+ * changed, so the value it would be keeping is a price for something else.
530
+ *
531
+ * **Only the latest quote is rendered, whichever answers first.** Two previews can be in flight at once —
532
+ * an anonymous visitor's location resolves under the query, or a country picker moves — and the slower
533
+ * one landing last would otherwise win, putting a price for an address the visitor has left on the one
534
+ * screen whose entire job is showing a correct one. Superseded answers are *ignored*, not canceled:
535
+ * Paddle.js's `PricePreview` takes no `AbortSignal` and hands back a bare promise, so there is nothing to
536
+ * cancel and ignoring it is the whole of the fix. A superseded refusal is ignored the same way, which is
537
+ * what stops a dead request blanking a price the visitor is already reading.
538
+ */
539
+ export function usePricePreview(
540
+ setup: PaddleSetup | null,
541
+ query: PaddlePriceQuery,
542
+ options?: PaddleOptions,
543
+ ): UsePricePreview {
544
+ const [preview, setPreview] = useState<PricePreview | null>(null);
545
+ const [loading, setLoading] = useState(setup !== null);
546
+ const [failure, setFailure] = useState<PaymentsFailure | null>(null);
547
+ const latest = useLatest(options);
548
+ const latestQuery = useLatest(query);
549
+ const live = useLive();
550
+ const clientToken = setup?.clientToken;
551
+ const environment = setup?.environment;
552
+ const queryKey = priceQueryKey(query);
553
+ /**
554
+ * Which quote is the current one.
555
+ *
556
+ * A counter rather than a comparison against `queryKey`, because two quotes for the *same* query race
557
+ * too — a screen calling `refresh` twice is the ordinary way to get there — and a key would read those
558
+ * two as one and let either win. Every ask takes the next number; only the holder of the last one
559
+ * issued may write state.
560
+ */
561
+ const issued = useRef(0);
562
+
563
+ // `queryKey` is a dependency the body never reads, and that is the design. It is what makes "the request
564
+ // changed" a value React can compare — the query itself is an object literal rebuilt every render, and
565
+ // depending on that would quote forever. Reading the key back inside would mean parsing it, which is how
566
+ // a serialization becomes a second source of truth for the thing it serializes.
567
+ // biome-ignore lint/correctness/useExhaustiveDependencies: queryKey stands in for the query, deliberately.
568
+ const refresh = useCallback(() => {
569
+ if (clientToken === undefined || environment === undefined) {
570
+ setLoading(false);
571
+ return;
572
+ }
573
+ const ticket = ++issued.current;
574
+ setLoading(true);
575
+ void previewPrices({ clientToken, environment }, latestQuery.current, latest.current).then((result) => {
576
+ if (!live.current) return;
577
+ // A superseded answer is not a late answer to render, it is an answer to a question nobody is
578
+ // asking any more. It writes nothing at all — not the price, not the failure, and not `loading`,
579
+ // which still belongs to the quote that is out.
580
+ if (ticket !== issued.current) return;
581
+ setPreview(result.ok ? result.value : null);
582
+ setFailure(result.ok ? null : result.failure);
583
+ setLoading(false);
584
+ });
585
+ }, [clientToken, environment, queryKey, latest, latestQuery, live]);
586
+
587
+ useEffect(refresh, [refresh]);
588
+
589
+ return { preview, loading, failure, refresh };
590
+ }