@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,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
+ }