@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,171 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import type { Context } from "hono";
|
|
6
|
+
import { PaymentsSubject, type PaymentsSubjectType } from "../data/subject";
|
|
7
|
+
import { PaymentsSubjectUnresolvedError } from "../error/errors";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Which subject is this caller acting for — the one question this capability cannot answer itself.
|
|
11
|
+
*
|
|
12
|
+
* Under `billingSubject: "user"` there is nothing to ask: the caller is the holder, and the answer is the
|
|
13
|
+
* authenticated user id the auth capability already put on the request. Under `"organization"` the answer
|
|
14
|
+
* is a fact about the adopter's own membership model — who this person works for, which of their two
|
|
15
|
+
* companies they are currently looking at, whether the seat they were invited to is still theirs. Payments
|
|
16
|
+
* has no members table, no roles, and no business acquiring either. A payments capability that could
|
|
17
|
+
* enumerate a company's staff is a second product grown inside a billing one, and it would be wrong about
|
|
18
|
+
* every adopter whose org model is not the one it guessed.
|
|
19
|
+
*
|
|
20
|
+
* So the answer comes from **outside**: {@link PaymentsSubjectResolver} is a function the adopter supplies,
|
|
21
|
+
* and it receives the Hono `Context` — the same request `installEntitlementResolver` already reads the
|
|
22
|
+
* caller from, at gate time rather than install time, which is what keeps middleware order the adopter's.
|
|
23
|
+
* For Better Auth's `organization()` plugin that function is one line reading `activeOrganizationId` off
|
|
24
|
+
* the session; for anything else it is whatever that adopter's session already knows.
|
|
25
|
+
*
|
|
26
|
+
* **Unanswered is unentitled, in both directions.** A read holds nothing — the gate denies, exactly as it
|
|
27
|
+
* does for a caller who never bought anything, and for the same reason: a gate that resolved *something*
|
|
28
|
+
* when it could not tell who was asking would be granting one holder's plan to whoever asked next. A write
|
|
29
|
+
* refuses outright with {@link PaymentsSubjectUnresolvedError}, because writing needs a row key and a
|
|
30
|
+
* guessed key attributes real money to the wrong holder. **Nothing here ever falls back to
|
|
31
|
+
* `c.var.auth.userId` under organization billing** — that fallback is the half-migrated state this whole
|
|
32
|
+
* design exists to prevent, where a company's subscription silently becomes one employee's.
|
|
33
|
+
*
|
|
34
|
+
* There is deliberately **no default for `"organization"`**. An adopter who supplies no resolver resolves
|
|
35
|
+
* nothing and holds nothing, which fails loudly on the first paid route rather than quietly keying the
|
|
36
|
+
* company's plan to whoever logged in first.
|
|
37
|
+
*
|
|
38
|
+
* One implementation, one entry point: {@link resolvePaymentsSubject} answers the question for the gate,
|
|
39
|
+
* the routes, and anything that comes next. A second place asking it is a second policy, and the two
|
|
40
|
+
* disagree the day one of them is edited.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The seam: given the request, who is this caller acting for.
|
|
45
|
+
*
|
|
46
|
+
* Async because an adopter's answer is usually a lookup — a session row, a KV read, an organization the
|
|
47
|
+
* request names and their own model must confirm. Returning `undefined` is a legitimate answer, not an
|
|
48
|
+
* error: a signed-in person with no organization selected is exactly that, and the gate treats it as
|
|
49
|
+
* unentitled rather than as a fault.
|
|
50
|
+
*/
|
|
51
|
+
export type PaymentsSubjectResolver = (c: Context<PithyHonoEnv>) => Promise<PaymentsSubject | undefined>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What the question needs to be answered: the project's billing mode, and the adopter's resolver if they
|
|
55
|
+
* supplied one.
|
|
56
|
+
*
|
|
57
|
+
* A structural type rather than a parameter list, so a call site cannot transpose them and so
|
|
58
|
+
* `PaymentsConfig` — which carries `billingSubject` — satisfies it as it stands. The mode is read from
|
|
59
|
+
* config on every call rather than captured once, because it is the mode that decides both which default
|
|
60
|
+
* applies and which answers are legal.
|
|
61
|
+
*/
|
|
62
|
+
export interface PaymentsSubjectSeam {
|
|
63
|
+
/** The project's billing mode, from `PaymentsConfig.billingSubject`. Decided once, never per call. */
|
|
64
|
+
readonly billingSubject: PaymentsSubjectType;
|
|
65
|
+
/**
|
|
66
|
+
* The adopter's answer. Required in practice under `"organization"` — without it nothing resolves —
|
|
67
|
+
* and optional under `"user"`, where {@link authenticatedUserSubject} is the whole answer.
|
|
68
|
+
*/
|
|
69
|
+
readonly resolveSubject?: PaymentsSubjectResolver;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The default under `billingSubject: "user"`: the authenticated caller, as a user subject.
|
|
74
|
+
*
|
|
75
|
+
* It reads the core `AuthContext` seam and nothing else, so it works with whatever strategy verified the
|
|
76
|
+
* request — bearer or cookie session — and holds nothing when none did. `c.var.auth` is `null` on an
|
|
77
|
+
* unauthenticated request and `undefined` when no auth capability is composed at all; both mean the same
|
|
78
|
+
* thing here, and both resolve to nobody.
|
|
79
|
+
*/
|
|
80
|
+
export const authenticatedUserSubject: PaymentsSubjectResolver = async (c) => {
|
|
81
|
+
const auth = c.var.auth;
|
|
82
|
+
if (!auth) return undefined;
|
|
83
|
+
return { subjectType: "user", subjectId: auth.userId };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Who this caller is acting for, or `undefined`. **The one implementation of that question.**
|
|
88
|
+
*
|
|
89
|
+
* The adopter's resolver wins whenever they supplied one, in either mode: an answer of `undefined` from it
|
|
90
|
+
* is their answer, never a prompt to fall back to the caller. Only when they supplied none does the mode
|
|
91
|
+
* decide — the default under `"user"`, and nobody at all under `"organization"`.
|
|
92
|
+
*
|
|
93
|
+
* **The answer is validated before it is believed**, because it crosses a boundary: it is a value from the
|
|
94
|
+
* adopter's code, and it becomes a row key and — through `encodeSubjectReference` — a string handed to a
|
|
95
|
+
* payment provider. An id longer than a store's own field would come back truncated and decode to a
|
|
96
|
+
* *different* holder, so the schema's cap is enforced here rather than discovered at a refund.
|
|
97
|
+
*
|
|
98
|
+
* **An answer of the wrong kind for the configured mode is refused**, not half-trusted. One mode per
|
|
99
|
+
* project is the decision this capability is built on; a user subject resolved under organization billing
|
|
100
|
+
* would read rows the projection never writes and write rows no gate ever reads, and both halves of that
|
|
101
|
+
* are invisible until somebody is refused what they paid for. Refusing leaves them unentitled, which is
|
|
102
|
+
* the direction every gate in the kit already fails, and logs the mismatch where an operator finds it.
|
|
103
|
+
*/
|
|
104
|
+
export async function resolvePaymentsSubject(
|
|
105
|
+
c: Context<PithyHonoEnv>,
|
|
106
|
+
seam: PaymentsSubjectSeam,
|
|
107
|
+
): Promise<PaymentsSubject | undefined> {
|
|
108
|
+
const answered = await (seam.resolveSubject ?? modeDefault(seam.billingSubject))(c);
|
|
109
|
+
if (answered === undefined) return undefined;
|
|
110
|
+
|
|
111
|
+
const parsed = PaymentsSubject.safeParse(answered);
|
|
112
|
+
if (!parsed.success) {
|
|
113
|
+
// Not a throw: an adopter's malformed answer must deny like any other unanswered request, or a bug in
|
|
114
|
+
// their resolver becomes a 500 on a route that should simply have held nothing.
|
|
115
|
+
c.var.log?.warn("payments: the subject resolver answered something the subject schema refuses.", {
|
|
116
|
+
issues: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
|
|
117
|
+
});
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (parsed.data.subjectType !== seam.billingSubject) {
|
|
122
|
+
c.var.log?.warn("payments: the subject resolver answered the wrong kind of subject for this project.", {
|
|
123
|
+
billingSubject: seam.billingSubject,
|
|
124
|
+
answered: parsed.data.subjectType,
|
|
125
|
+
});
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return parsed.data;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The same question on a write path, where `undefined` is not an answer a caller can be handed a row for.
|
|
134
|
+
*
|
|
135
|
+
* A 403 rather than a 401: the caller may be perfectly well authenticated and simply not acting for any
|
|
136
|
+
* subject this project bills — no organization selected, or none they still belong to. The `action` is
|
|
137
|
+
* written for whichever mode is in force, because an operator reading this under user billing is looking
|
|
138
|
+
* at a missing auth strategy and one reading it under organization billing is looking at an unwired
|
|
139
|
+
* resolver, and sending either to the other's file wastes the afternoon.
|
|
140
|
+
*/
|
|
141
|
+
export async function requirePaymentsSubject(
|
|
142
|
+
c: Context<PithyHonoEnv>,
|
|
143
|
+
seam: PaymentsSubjectSeam,
|
|
144
|
+
): Promise<PaymentsSubject> {
|
|
145
|
+
const subject = await resolvePaymentsSubject(c, seam);
|
|
146
|
+
if (subject) return subject;
|
|
147
|
+
|
|
148
|
+
if (seam.billingSubject === "user") {
|
|
149
|
+
throw new PaymentsSubjectUnresolvedError({
|
|
150
|
+
message: "Sign in to continue.",
|
|
151
|
+
action: "requireAuth() must run before a payments write, and an auth capability must be composed.",
|
|
152
|
+
detail: "No authenticated caller, so this write has no holder to key its row to.",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
throw new PaymentsSubjectUnresolvedError({
|
|
156
|
+
detail: seam.resolveSubject
|
|
157
|
+
? "The subject resolver answered nothing for this request, so this write has no holder to key its row to."
|
|
158
|
+
: "This project bills organizations and no subject resolver is wired, so nothing can ever be resolved.",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The resolver a mode falls back to when the adopter supplied none.
|
|
164
|
+
*
|
|
165
|
+
* `"organization"` falls back to nobody, and that is the design rather than an omission: the capability
|
|
166
|
+
* has no way to know what an organization is, so the only honest answer it can produce on its own is
|
|
167
|
+
* none. See this file's header.
|
|
168
|
+
*/
|
|
169
|
+
function modeDefault(billingSubject: PaymentsSubjectType): PaymentsSubjectResolver {
|
|
170
|
+
return billingSubject === "user" ? authenticatedUserSubject : async () => undefined;
|
|
171
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { PithyError } from "@pithy-sh/core/src/error/pithyError";
|
|
5
|
+
import type { MessageParams } from "@pithy-sh/core/src/i18n/catalog";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `@pithy-sh/payments` throw sugar. The `payments/*` codes live in core's closed `KitErrorPayload` union
|
|
9
|
+
* (CLAUDE.md §Errors); these subclasses are the package-local vehicles that set one of those members.
|
|
10
|
+
* Runtime code in this package throws one of these, never a plain `new Error`.
|
|
11
|
+
*
|
|
12
|
+
* **A store's raw error text, a receipt payload, a webhook signature, and a purchase token belong in
|
|
13
|
+
* `detail`, never `message`.** The HTTP codec strips `detail`, and that is the single security boundary
|
|
14
|
+
* between what an operator reads in a log and what a caller reads in a response. Every default `message`
|
|
15
|
+
* below is written to be safe to hand a stranger.
|
|
16
|
+
*
|
|
17
|
+
* One code is absent from what this package raises, and deliberately: `payments/entitlement_required` is
|
|
18
|
+
* constructed by core's `requireEntitlement()`. The gate lives in core because a gate that arrives with a
|
|
19
|
+
* package fails **open** when that package is absent — so core owns both the middleware and its payload,
|
|
20
|
+
* and the subclass here exists so the domain's throw sugar is complete and every member of the namespace
|
|
21
|
+
* has exactly one vehicle.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
interface PaymentsErrorArgs {
|
|
25
|
+
message?: string;
|
|
26
|
+
action?: string;
|
|
27
|
+
detail?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Values a translating client interpolates into its own wording for this code. Client-facing, so —
|
|
30
|
+
* unlike `action` and `detail` — these cross the boundary with `message`.
|
|
31
|
+
*/
|
|
32
|
+
params?: MessageParams;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The receipt could not be read at all. Nothing was asked of the provider. */
|
|
36
|
+
export class PaymentsInvalidReceiptError extends PithyError {
|
|
37
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
38
|
+
super(
|
|
39
|
+
{
|
|
40
|
+
code: "payments/invalid_receipt",
|
|
41
|
+
status: 400,
|
|
42
|
+
message: args.message ?? "That receipt could not be read.",
|
|
43
|
+
action: args.action ?? "Submit the transaction exactly as the store SDK returned it.",
|
|
44
|
+
detail: args.detail,
|
|
45
|
+
params: args.params,
|
|
46
|
+
},
|
|
47
|
+
options,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The rail was asked and said no. Distinct from the rail failing to answer at all. */
|
|
53
|
+
export class PaymentsVerificationFailedError extends PithyError {
|
|
54
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
55
|
+
super(
|
|
56
|
+
{
|
|
57
|
+
code: "payments/verification_failed",
|
|
58
|
+
status: 400,
|
|
59
|
+
message: args.message ?? "The store did not recognize that purchase.",
|
|
60
|
+
action: args.action ?? "Restore purchases and retry. If it persists, contact support.",
|
|
61
|
+
detail: args.detail,
|
|
62
|
+
params: args.params,
|
|
63
|
+
},
|
|
64
|
+
options,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* An inbound notification failed its authenticity check. 401, not 403: a failed signature means the caller
|
|
71
|
+
* did not prove who it is, which is a different statement from a known caller being refused.
|
|
72
|
+
*/
|
|
73
|
+
export class PaymentsWebhookUnverifiedError extends PithyError {
|
|
74
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
75
|
+
super(
|
|
76
|
+
{
|
|
77
|
+
code: "payments/webhook_unverified",
|
|
78
|
+
status: 401,
|
|
79
|
+
message: args.message ?? "That notification could not be verified.",
|
|
80
|
+
action: args.action ?? "Check the webhook signing secret registered for this environment.",
|
|
81
|
+
detail: args.detail,
|
|
82
|
+
params: args.params,
|
|
83
|
+
},
|
|
84
|
+
options,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The request names a rail this project has not enabled. Rails are config, so a missing one is a 404. */
|
|
90
|
+
export class PaymentsRailNotConfiguredError extends PithyError {
|
|
91
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
92
|
+
super(
|
|
93
|
+
{
|
|
94
|
+
code: "payments/rail_not_configured",
|
|
95
|
+
status: 404,
|
|
96
|
+
message: args.message ?? "That payment method is not available.",
|
|
97
|
+
action: args.action ?? "Enable the rail in the `rails` block of pithy.config.ts and redeploy.",
|
|
98
|
+
detail: args.detail,
|
|
99
|
+
params: args.params,
|
|
100
|
+
},
|
|
101
|
+
options,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reconciliation cannot be started here: the pass's Workflow host is not deployed or not bound.
|
|
108
|
+
*
|
|
109
|
+
* **501 rather than 404 or 500.** The route exists and the caller's scope was right, so 404 would send them
|
|
110
|
+
* looking for a typo; nothing failed, so 500 would send them looking at logs. What is true is that this
|
|
111
|
+
* deployment does not implement the operation yet, and the fix is a deploy the adopter performs. `action` says
|
|
112
|
+
* which one.
|
|
113
|
+
*
|
|
114
|
+
* The read is unaffected and stays useful: a project with no Workflow host has an empty run log, which is the
|
|
115
|
+
* loudest thing that read says.
|
|
116
|
+
*/
|
|
117
|
+
export class PaymentsReconcileNotProvisionedError extends PithyError {
|
|
118
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
119
|
+
super(
|
|
120
|
+
{
|
|
121
|
+
code: "payments/reconcile_not_provisioned",
|
|
122
|
+
status: 501,
|
|
123
|
+
message: args.message ?? "Reconciliation is not set up for this deployment.",
|
|
124
|
+
action: args.action ?? "Deploy the payments workflow host, then bind PAYMENTS_RECONCILE in wrangler.jsonc.",
|
|
125
|
+
detail: args.detail,
|
|
126
|
+
params: args.params,
|
|
127
|
+
},
|
|
128
|
+
options,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** No catalog product maps the rail's SKU. The catalog is config, so a new SKU needs a deploy. */
|
|
134
|
+
export class PaymentsProductNotFoundError extends PithyError {
|
|
135
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
136
|
+
super(
|
|
137
|
+
{
|
|
138
|
+
code: "payments/product_not_found",
|
|
139
|
+
status: 404,
|
|
140
|
+
message: args.message ?? "That product is not for sale here.",
|
|
141
|
+
action: args.action ?? "Add the SKU to the `products` catalog in pithy.config.ts and redeploy.",
|
|
142
|
+
detail: args.detail,
|
|
143
|
+
params: args.params,
|
|
144
|
+
},
|
|
145
|
+
options,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* A sandbox purchase reached a production deployment, or the reverse. Rejected outright — granting a real
|
|
152
|
+
* entitlement from a sandbox transaction is the most common in-app-purchase security defect there is.
|
|
153
|
+
*/
|
|
154
|
+
export class PaymentsEnvironmentMismatchError extends PithyError {
|
|
155
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
156
|
+
super(
|
|
157
|
+
{
|
|
158
|
+
code: "payments/environment_mismatch",
|
|
159
|
+
status: 400,
|
|
160
|
+
message: args.message ?? "That purchase belongs to a different store environment.",
|
|
161
|
+
action: args.action ?? "Use a production purchase against production, and a sandbox one against sandbox.",
|
|
162
|
+
detail: args.detail,
|
|
163
|
+
params: args.params,
|
|
164
|
+
},
|
|
165
|
+
options,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The transaction is already projected against a different user. A replay by its own owner is a 200 — the
|
|
172
|
+
* write path is idempotent — but a receipt lifted from another account is refused rather than silently
|
|
173
|
+
* rebound, which is what makes a stolen receipt worthless.
|
|
174
|
+
*/
|
|
175
|
+
export class PaymentsReceiptAlreadyOwnedError extends PithyError {
|
|
176
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
177
|
+
super(
|
|
178
|
+
{
|
|
179
|
+
code: "payments/receipt_already_owned",
|
|
180
|
+
status: 409,
|
|
181
|
+
message: args.message ?? "That purchase belongs to another account.",
|
|
182
|
+
action: args.action ?? "Sign in as the account that made the purchase, then restore it.",
|
|
183
|
+
detail: args.detail,
|
|
184
|
+
params: args.params,
|
|
185
|
+
},
|
|
186
|
+
options,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The rail could not be reached, or answered with a server error. The purchase is neither granted nor
|
|
193
|
+
* refused: the reconciliation Workflow repairs it, so the caller may retry.
|
|
194
|
+
*/
|
|
195
|
+
export class PaymentsProviderUnavailableError extends PithyError {
|
|
196
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
197
|
+
super(
|
|
198
|
+
{
|
|
199
|
+
code: "payments/provider_unavailable",
|
|
200
|
+
status: 503,
|
|
201
|
+
message: args.message ?? "The store did not answer.",
|
|
202
|
+
action: args.action ?? "Retry shortly. Your purchase is safe and will be reconciled either way.",
|
|
203
|
+
detail: args.detail,
|
|
204
|
+
params: args.params,
|
|
205
|
+
},
|
|
206
|
+
options,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A refund's clawback debit was refused by the ledger, because the balance no longer covers it.
|
|
213
|
+
*
|
|
214
|
+
* Constructed far more often than it is thrown. A clawback runs after the refund is already recorded, so
|
|
215
|
+
* raising this at the caller would undo nothing and would turn a store's own webhook into a 5xx it retries
|
|
216
|
+
* forever. Instead the payload is *recorded* — the audit trail carries the code, the amount, and the account —
|
|
217
|
+
* which is what makes a failed clawback queryable and alertable rather than a line in a log. The class exists
|
|
218
|
+
* so that record is the same shape as every other failure in the system, and so an adopter's own tooling can
|
|
219
|
+
* raise the code when it has a caller to answer.
|
|
220
|
+
*/
|
|
221
|
+
export class PaymentsClawbackFailedError extends PithyError {
|
|
222
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
223
|
+
super(
|
|
224
|
+
{
|
|
225
|
+
code: "payments/clawback_failed",
|
|
226
|
+
status: 409,
|
|
227
|
+
message: args.message ?? "That refund could not be reversed against the balance.",
|
|
228
|
+
action: args.action ?? "Review the account's ledger. The balance was spent before the refund arrived.",
|
|
229
|
+
detail: args.detail,
|
|
230
|
+
params: args.params,
|
|
231
|
+
},
|
|
232
|
+
options,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A manual grant named an entitlement key this project does not define.
|
|
239
|
+
*
|
|
240
|
+
* 400 rather than the 404 the catalog's other refusals use, and the difference is what the caller named. A
|
|
241
|
+
* SKU or a product id names a *resource* the catalog either holds or does not, so a miss is Not Found. An
|
|
242
|
+
* entitlement key names the *vocabulary* gating code is written in, and a key outside it is a malformed
|
|
243
|
+
* request — `pr` for `pro`, `pro ` with a trailing space, a key renamed a release ago. Unchecked, each of
|
|
244
|
+
* those was a 200, a row, and a customer who stays locked out with nothing anywhere to read.
|
|
245
|
+
*
|
|
246
|
+
* **The `message` echoes the key and never the set.** A caller learns which key it got wrong, because it
|
|
247
|
+
* sent it. What this project defines is a separate disclosure behind `payments:catalog:read`, and a refusal
|
|
248
|
+
* that listed it would be that read, ungated.
|
|
249
|
+
*/
|
|
250
|
+
export class PaymentsEntitlementNotInCatalogError extends PithyError {
|
|
251
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
252
|
+
super(
|
|
253
|
+
{
|
|
254
|
+
code: "payments/entitlement_not_in_catalog",
|
|
255
|
+
status: 400,
|
|
256
|
+
message: args.message ?? "That entitlement is not one this project defines.",
|
|
257
|
+
action:
|
|
258
|
+
args.action ??
|
|
259
|
+
"Grant a key one of the catalog's products lists, or declare it in `manualEntitlements` in pithy.config.ts.",
|
|
260
|
+
detail: args.detail,
|
|
261
|
+
params: args.params,
|
|
262
|
+
},
|
|
263
|
+
options,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The caller does not hold an entitlement the route requires. Core's gate is what normally raises it. */
|
|
269
|
+
export class PaymentsEntitlementRequiredError extends PithyError {
|
|
270
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
271
|
+
super(
|
|
272
|
+
{
|
|
273
|
+
code: "payments/entitlement_required",
|
|
274
|
+
status: 403,
|
|
275
|
+
message: args.message ?? "This feature requires an active subscription or purchase.",
|
|
276
|
+
action: args.action ?? "Purchase or restore the product that grants access, then retry.",
|
|
277
|
+
detail: args.detail,
|
|
278
|
+
params: args.params,
|
|
279
|
+
},
|
|
280
|
+
options,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A discount code the store would not accept — unknown, expired, exhausted, or not valid for what is being
|
|
287
|
+
* bought.
|
|
288
|
+
*
|
|
289
|
+
* **400 and its own code, never a generic checkout failure.** A customer told "something went wrong" at
|
|
290
|
+
* checkout concludes their card was declined and stops trying; one told their code was not accepted removes
|
|
291
|
+
* the code and buys. The two are a different sentence on the screen and a different outcome for the sale.
|
|
292
|
+
*
|
|
293
|
+
* The code is echoed in `message` because the caller sent it and it is what they need to correct. The
|
|
294
|
+
* store's own reason rides in `detail`, which the HTTP codec strips.
|
|
295
|
+
*/
|
|
296
|
+
export class PaymentsDiscountInvalidError extends PithyError {
|
|
297
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
298
|
+
super(
|
|
299
|
+
{
|
|
300
|
+
code: "payments/discount_invalid",
|
|
301
|
+
status: 400,
|
|
302
|
+
message: args.message ?? "That discount code was not accepted.",
|
|
303
|
+
action: args.action ?? "Check the code, or continue without one.",
|
|
304
|
+
detail: args.detail,
|
|
305
|
+
params: args.params,
|
|
306
|
+
},
|
|
307
|
+
options,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* A write path could not learn which subject the caller is acting for.
|
|
314
|
+
*
|
|
315
|
+
* Under organization billing the capability asks the adopter *who is this caller acting for* and the
|
|
316
|
+
* adopter answers from its own session — it has the memberships, and this package never learns what an
|
|
317
|
+
* organization is. An unanswered seam on a **read** needs no code at all: the query holds nothing, the
|
|
318
|
+
* gate denies, and unentitled is the direction every gate in the kit already fails. A **write** has
|
|
319
|
+
* nowhere to fail to. Submitting a purchase, restoring, opening checkout, opening the billing portal —
|
|
320
|
+
* each of them has a row or a session to create and no holder to create it against. Writing nothing and
|
|
321
|
+
* answering 200 is the worse outcome by a distance: the customer is charged by the store, the webhook
|
|
322
|
+
* arrives for a subject nobody stamped, and the money is real while the entitlement is not.
|
|
323
|
+
*
|
|
324
|
+
* **403, not 400 and not 500.** The caller is authenticated and the request is well-formed, so it is not
|
|
325
|
+
* a bad request. And an operator reading *our* logs finds nothing to fix, because what is missing is the
|
|
326
|
+
* adopter's resolver returning a value — so it is not a fault of ours either.
|
|
327
|
+
*
|
|
328
|
+
* The three fields split hard here. `message` tells a caller to pick an account, which is the one thing
|
|
329
|
+
* they can do about it. `action` names `billingSubject` and the resolver, which are an operator's words
|
|
330
|
+
* and a small map of the deployment a stranger has no need of. `detail` carries what the resolver was
|
|
331
|
+
* asked and what it gave back. The codec strips the last two.
|
|
332
|
+
*/
|
|
333
|
+
export class PaymentsSubjectUnresolvedError extends PithyError {
|
|
334
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
335
|
+
super(
|
|
336
|
+
{
|
|
337
|
+
code: "payments/subject_unresolved",
|
|
338
|
+
status: 403,
|
|
339
|
+
message: args.message ?? "No billing account is selected. Choose one, then retry.",
|
|
340
|
+
action:
|
|
341
|
+
args.action ??
|
|
342
|
+
'This project sets `billingSubject: "organization"`. Have the subject resolver in pithy.config.ts return the organization the caller is acting for.',
|
|
343
|
+
detail: args.detail,
|
|
344
|
+
params: args.params,
|
|
345
|
+
},
|
|
346
|
+
options,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A subscription cannot be changed, or refunded, the way it was asked. The item shape is one this rail
|
|
353
|
+
* will not reproduce, the subscription is already canceled, there is no payment on it to refund, or the
|
|
354
|
+
* outcome asked for contradicts the state it is in — a plan move on a subscription whose cancellation is
|
|
355
|
+
* already scheduled, where honoring one instruction means discarding the other (#465).
|
|
356
|
+
*
|
|
357
|
+
* **A no-op is not a contradiction, and must not throw.** A change to the plan already held, and a cancel
|
|
358
|
+
* when that cancellation is already scheduled, name a state the subscription is already in. Both return
|
|
359
|
+
* success without calling the provider. That is the idempotency answer for a retried write: a client that
|
|
360
|
+
* loses a response and sends the same instruction again is not in conflict with anything, and answering 409
|
|
361
|
+
* would turn every duplicate submission into an incident. Only a request that cannot be reconciled with the
|
|
362
|
+
* present state reaches this code.
|
|
363
|
+
*
|
|
364
|
+
* **One code for all of it, and that is the argument, not an economy.** The obvious alternative was four:
|
|
365
|
+
* `subscription_multi_item`, `subscription_quantity_ambiguous`, `subscription_already_canceled`,
|
|
366
|
+
* `subscription_state_conflict`. Every one of them is the same 409, hands a caller the same sentence, and
|
|
367
|
+
* asks them to do the same thing — re-read the subscription and ask for something its present state
|
|
368
|
+
* allows. A code exists so a client can *branch*, and four codes nothing branches on is four members of
|
|
369
|
+
* core's closed union, four translation catalog keys, and a `switch` whose arms all render one string.
|
|
370
|
+
* What actually differs between the four is a fact about somebody's billing — how many items, which
|
|
371
|
+
* price, what quantity, what date — and that is throw-site context, which is what `detail` is and which
|
|
372
|
+
* the codec strips. So the distinction is kept where it is useful and off the wire where it is a leak.
|
|
373
|
+
*
|
|
374
|
+
* **The refund verb refuses through this code too, and the name is the only reason that needs saying.**
|
|
375
|
+
* `subscription_change_refused` reads as being about a plan move, so a reader looking for the code a
|
|
376
|
+
* refund refusal carries would conclude there is none. There is: a set with no payment in it, a payment
|
|
377
|
+
* the store will not refund because it is not completed, a transaction the store does not know, and a set
|
|
378
|
+
* too large to issue inside one request are all 409s, all tell the caller the same thing — re-read the
|
|
379
|
+
* subscription and its payments, then ask for something the present state allows — and nothing branches on
|
|
380
|
+
* telling them apart. Which of the four it was is a fact about somebody's billing, which is `detail`.
|
|
381
|
+
* **A refund that is already standing is not here at all**: it is a per-payment `already_requested`
|
|
382
|
+
* outcome and a 200, because it is the state the caller asked for. See `data/subscription.ts`.
|
|
383
|
+
*
|
|
384
|
+
* The one case that could earn its own code is "already canceled", because a screen genuinely wants to
|
|
385
|
+
* say *ends on the 15th* rather than *cannot be changed*. It does not earn it here: the screen learns
|
|
386
|
+
* that from the subscription it just read, which carries `scheduled_change.effective_at`. An error code
|
|
387
|
+
* is for a caller holding nothing else, and this caller is holding the subscription.
|
|
388
|
+
*
|
|
389
|
+
* **409, and neither 400 nor 500.** The request is well-formed and names a price this project sells, so
|
|
390
|
+
* it is not a bad request. Nothing is broken and there is nothing in a log for an operator to fix, so it
|
|
391
|
+
* is not ours. It is a conflict with the current state of the resource, which is the one thing 409 says,
|
|
392
|
+
* and it is what tells a client to refetch rather than to re-word.
|
|
393
|
+
*
|
|
394
|
+
* **Why refusing beats guessing**, since the refusal is the expensive-looking choice. A plan change
|
|
395
|
+
* replaces the whole items array: sending one item to a two-item subscription silently deletes the other,
|
|
396
|
+
* and inventing a quantity over- or under-charges a real card. Both failures are writes to somebody's
|
|
397
|
+
* billing that no read afterwards can distinguish from an intended change. A 409 is recoverable by a
|
|
398
|
+
* human in a minute; a dropped add-on is discovered on an invoice.
|
|
399
|
+
*
|
|
400
|
+
* `message` is safe to hand a stranger and names nothing about the subscription. `action` is the
|
|
401
|
+
* operator's half. `detail` carries the subscription id, the item shape, the status, the dates — none of
|
|
402
|
+
* which cross the boundary. `params` is the exception, and deliberately: it is client-facing, so a date a
|
|
403
|
+
* screen means to render in the reader's own language goes there and nowhere else.
|
|
404
|
+
*/
|
|
405
|
+
export class PaymentsSubscriptionChangeRefusedError extends PithyError {
|
|
406
|
+
constructor(args: PaymentsErrorArgs = {}, options?: { cause?: unknown }) {
|
|
407
|
+
super(
|
|
408
|
+
{
|
|
409
|
+
code: "payments/subscription_change_refused",
|
|
410
|
+
status: 409,
|
|
411
|
+
message: args.message ?? "That subscription cannot be changed that way.",
|
|
412
|
+
action:
|
|
413
|
+
args.action ??
|
|
414
|
+
"Re-read the subscription's current state — its status, what it holds, and any scheduled change — then ask for a move that state allows.",
|
|
415
|
+
detail: args.detail,
|
|
416
|
+
params: args.params,
|
|
417
|
+
},
|
|
418
|
+
options,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|