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