@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,131 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { SQLiteDate, sqliteJson } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { PaymentsProductType } from "../config/config";
|
|
7
|
+
import { PaymentsRail } from "./rail";
|
|
8
|
+
import { PurchaseStatus } from "./status";
|
|
9
|
+
import { PaymentsSubject } from "./subject";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which store environment a purchase happened in. Every purchase carries it, and it never widens: a
|
|
13
|
+
* sandbox StoreKit transaction reaching production must not grant a real entitlement, and granting one is
|
|
14
|
+
* the most common in-app-purchase security defect there is.
|
|
15
|
+
*/
|
|
16
|
+
export const PurchaseEnvironment = z
|
|
17
|
+
.enum(["production", "sandbox"])
|
|
18
|
+
.describe("The store environment a purchase happened in. A sandbox purchase never grants in production.");
|
|
19
|
+
export type PurchaseEnvironment = z.infer<typeof PurchaseEnvironment>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What a purchase row *is* — the thing that grants access, or the thing that took the money.
|
|
23
|
+
*
|
|
24
|
+
* On three of the four rails these are one object and `charge` is the only value ever written: an Apple
|
|
25
|
+
* renewal, a Play renewal and a Stripe invoice each carry both the money and the subscription's state, so
|
|
26
|
+
* one row does both jobs and nothing needs distinguishing.
|
|
27
|
+
*
|
|
28
|
+
* Lemon Squeezy splits them, and splits them at the source. Its `subscription_*` webhooks carry a
|
|
29
|
+
* subscription object with the status and the renewal date and no charge; its `subscription_payment_*`
|
|
30
|
+
* webhooks carry an invoice with the money and no subscription state. Neither names the other's key —
|
|
31
|
+
* an LS subscription has no latest-invoice pointer — so collapsing them onto one row would mean inventing
|
|
32
|
+
* the missing pointer and stamping two different clocks into one monotonic watermark, which is exactly
|
|
33
|
+
* the ordering defect `providerEventAt` exists to prevent.
|
|
34
|
+
*
|
|
35
|
+
* So that rail writes two rows, and this field says which is which. **`state` never credits a ledger.**
|
|
36
|
+
* A `grants` clause fires once per paid provider transaction, and a subscription's state is not a
|
|
37
|
+
* transaction — without this discriminator a live subscription's honest `active` status passes
|
|
38
|
+
* `purchaseIsPaid` and every subscriber is credited once more than they paid for.
|
|
39
|
+
*/
|
|
40
|
+
export const PurchaseRole = z
|
|
41
|
+
.enum(["charge", "state"])
|
|
42
|
+
.describe(
|
|
43
|
+
"Whether this row records money moving (`charge`) or a subscription's standing (`state`). Only `charge` rows fulfill a `grants` clause. Every rail but Lemon Squeezy writes `charge` for everything.",
|
|
44
|
+
);
|
|
45
|
+
export type PurchaseRole = z.infer<typeof PurchaseRole>;
|
|
46
|
+
|
|
47
|
+
/** The raw verified provider payload — an object, whatever the rail's own shape. Retained as received. */
|
|
48
|
+
const ProviderPayload = z
|
|
49
|
+
.record(z.string(), z.unknown())
|
|
50
|
+
.describe("The rail's verified response, as received. Shapes differ per rail, so only the envelope is fixed.");
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One provider transaction, as the projection of provider truth — the row in `pithy_payments_purchases`.
|
|
54
|
+
*
|
|
55
|
+
* `UNIQUE (rail, providerTransactionId)` is the idempotency guard all three write paths rely on: a client
|
|
56
|
+
* submission, a webhook, and a reconciliation pass produce the identical row, so a dropped client call
|
|
57
|
+
* costs nothing and a replayed webhook changes nothing.
|
|
58
|
+
*
|
|
59
|
+
* The owner is a **subject pair**, not a user id: the buyer is whoever the project bills, which under
|
|
60
|
+
* organization billing is the organization rather than the person who happened to click. Both columns are
|
|
61
|
+
* written together, and `data/subject.ts` states why either alone is ambiguous.
|
|
62
|
+
*
|
|
63
|
+
* `providerEventAt` is what makes the write monotonic. Providers do not guarantee delivery order, so an
|
|
64
|
+
* `expired` notification can arrive after the `renewed` that superseded it. Last-write-wins would
|
|
65
|
+
* silently revoke a paying subscriber, so the writer ignores any event staler than the row it is
|
|
66
|
+
* updating.
|
|
67
|
+
*/
|
|
68
|
+
export const PaymentsPurchase = z
|
|
69
|
+
.object({
|
|
70
|
+
id: z
|
|
71
|
+
.string()
|
|
72
|
+
.describe(
|
|
73
|
+
"The purchase's UUID. Text rather than an autoincrement integer because these surface in API responses, and sequential ids would leak order volume.",
|
|
74
|
+
),
|
|
75
|
+
subjectType: PaymentsSubject.shape.subjectType.describe(
|
|
76
|
+
"Whether `subjectId` names a user or an organization. Half the owner — the id alone is ambiguous, so the two columns travel together.",
|
|
77
|
+
),
|
|
78
|
+
subjectId: PaymentsSubject.shape.subjectId.describe(
|
|
79
|
+
"The subject that owns the purchase — the authenticated buyer, or the organization they bought for.",
|
|
80
|
+
),
|
|
81
|
+
rail: PaymentsRail.describe("Which store this transaction came from."),
|
|
82
|
+
providerTransactionId: z
|
|
83
|
+
.string()
|
|
84
|
+
.describe("The rail's own transaction id. `UNIQUE (rail, providerTransactionId)` — the idempotency anchor."),
|
|
85
|
+
productId: z.string().describe("The logical catalog product id, from `products` in pithy.config.ts."),
|
|
86
|
+
providerProductId: z.string().describe("The rail's own SKU or price id, exactly as the provider presented it."),
|
|
87
|
+
type: PaymentsProductType.describe(
|
|
88
|
+
"The product type, copied from the catalog at projection time so a later config edit cannot rewrite history.",
|
|
89
|
+
),
|
|
90
|
+
status: PurchaseStatus.describe("The normalized status. Nothing downstream ever sees a rail-specific state."),
|
|
91
|
+
role: PurchaseRole.default("charge").describe(
|
|
92
|
+
"Whether this row is the money or the subscription's standing. Defaults to `charge`, which is what every rail but Lemon Squeezy writes for every row.",
|
|
93
|
+
),
|
|
94
|
+
environment: PurchaseEnvironment.describe(
|
|
95
|
+
"Which store environment the purchase happened in. Every purchase carries it: a sandbox StoreKit transaction reaching production must never grant a real entitlement.",
|
|
96
|
+
),
|
|
97
|
+
purchasedAt: SQLiteDate.describe("When the store recorded the purchase."),
|
|
98
|
+
expiresAt: SQLiteDate.nullable().describe(
|
|
99
|
+
"When access lapses, or null for a purchase that never expires. Evaluated at read time as well as write time.",
|
|
100
|
+
),
|
|
101
|
+
revokedAt: SQLiteDate.nullable().describe("When the purchase was refunded or revoked, or null."),
|
|
102
|
+
resumesAt: SQLiteDate.nullable().describe(
|
|
103
|
+
"When a paused subscription comes back, as the provider stated it — never computed. Null on a paused row means the provider said none, which is an indefinite pause; null everywhere else means the purchase is not paused, and the table's check constraint is what keeps those two apart. See `data/pause.ts`.",
|
|
104
|
+
),
|
|
105
|
+
originalTransactionId: z
|
|
106
|
+
.string()
|
|
107
|
+
.nullable()
|
|
108
|
+
.describe("Chains renewals back to the transaction that started the subscription. Null for a one-time purchase."),
|
|
109
|
+
amountMinor: z
|
|
110
|
+
.number()
|
|
111
|
+
.int()
|
|
112
|
+
.nullable()
|
|
113
|
+
.describe(
|
|
114
|
+
"The amount charged, in the currency's minor unit — an integer, never a float. Nullable: not every rail reports it on every event.",
|
|
115
|
+
),
|
|
116
|
+
currency: z
|
|
117
|
+
.string()
|
|
118
|
+
.nullable()
|
|
119
|
+
.describe("The ISO currency the amount is in, or null when the rail did not report one."),
|
|
120
|
+
providerEventAt: SQLiteDate.describe(
|
|
121
|
+
"The provider's own timestamp for the event that produced this row. The monotonic write rule compares against it, so out-of-order delivery cannot revoke a live subscription.",
|
|
122
|
+
),
|
|
123
|
+
payload: sqliteJson(ProviderPayload).describe(
|
|
124
|
+
"The raw verified provider payload, Zod-validated on write and on read. Retained for reconciliation and audit.",
|
|
125
|
+
),
|
|
126
|
+
createdAt: SQLiteDate.describe("When this row was first projected."),
|
|
127
|
+
updatedAt: SQLiteDate.describe("When this row was last projected."),
|
|
128
|
+
})
|
|
129
|
+
.describe("One verified provider transaction — the row in `pithy_payments_purchases`.");
|
|
130
|
+
export type PaymentsPurchase = z.output<typeof PaymentsPurchase>;
|
|
131
|
+
export type PaymentsPurchaseRow = z.input<typeof PaymentsPurchase>;
|
package/src/data/rail.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The five payment rails, as one normalized name each. Nothing downstream of a rail's own module ever
|
|
8
|
+
* branches on a store's vocabulary — Apple's `originalTransactionId`, Google's `purchaseToken`, Stripe's
|
|
9
|
+
* `subscription` and Paddle's `sub_…` all arrive as a `(rail, providerTransactionId)` pair, which is what
|
|
10
|
+
* lets one projection serve five stores and one entitlement resolve across them.
|
|
11
|
+
*
|
|
12
|
+
* Adding a sixth rail (Amazon Appstore) is a member here plus a provider module. Nothing else in the
|
|
13
|
+
* package knows how many there are.
|
|
14
|
+
*
|
|
15
|
+
* `lemonSqueezy` and `paddle` are the two **merchants of record**: each owns the tax registration, the VAT
|
|
16
|
+
* thresholds, the invoices and the chargebacks, where the other three leave every one of those to the
|
|
17
|
+
* adopter. That is a commercial difference rather than a technical one, and the only place it shows in
|
|
18
|
+
* this package is that a refund can arrive with no local write preceding it — both issue them on their own.
|
|
19
|
+
*/
|
|
20
|
+
export const PaymentsRail = z
|
|
21
|
+
.enum(["apple", "google", "stripe", "lemonSqueezy", "paddle"])
|
|
22
|
+
.describe("Which store a transaction came from — Apple's App Store, Google Play, Stripe, Lemon Squeezy, or Paddle.");
|
|
23
|
+
export type PaymentsRail = z.infer<typeof PaymentsRail>;
|
|
24
|
+
|
|
25
|
+
/** Every rail, in a stable order — for iterating the catalog's per-rail blocks and cross-checking them. */
|
|
26
|
+
export const PAYMENTS_RAILS: readonly PaymentsRail[] = PaymentsRail.options;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The rails that sell in a browser — and mint a billing portal, which is the same statement.
|
|
30
|
+
*
|
|
31
|
+
* **One name, because it is one question.** {@link CheckoutRail} declares `createCheckoutSession` and
|
|
32
|
+
* `createPortalSession` together, so a rail cannot start a purchase on the web without also having a
|
|
33
|
+
* portal to send that buyer back to. "Sells in a browser" and "mints a portal we can link to" are not
|
|
34
|
+
* two lists that happen to match today; they are one list, held that way by an interface. Two names
|
|
35
|
+
* would suggest a divergence the type system does not permit, and the next person would have to read
|
|
36
|
+
* both to learn they are the same.
|
|
37
|
+
*
|
|
38
|
+
* The day a rail sells without a portal, `CheckoutRail` splits first — and `providers.test.ts`, which
|
|
39
|
+
* compares this list against the rails that actually satisfy `isCheckoutRail`, goes red. That is the
|
|
40
|
+
* moment a second name is earned, and it arrives with a failing test rather than a judgment call.
|
|
41
|
+
*
|
|
42
|
+
* **Enumerated rather than computed**, because nothing can compute it. A screen cannot construct a rail
|
|
43
|
+
* provider to discover what it implements, and `PaymentsRail.options` cannot answer it either — Apple and
|
|
44
|
+
* Google are rails and are not hosted. So it is a named subset with a gate over it, in both programs: this
|
|
45
|
+
* one, and its DOM-safe mirror in `src/client/api.ts`, which stays a hand-written union because pulling
|
|
46
|
+
* Zod into an adopter's browser program is what that file exists to avoid.
|
|
47
|
+
*
|
|
48
|
+
* Ordered, and the order is only used to make a refusal deterministic when a product sells on two rails
|
|
49
|
+
* and the caller named neither. See `checkoutRailFor`.
|
|
50
|
+
*
|
|
51
|
+
* **Extracted from {@link PaymentsRail} rather than spelled again.** A member misspelled here is a
|
|
52
|
+
* compile error rather than a rail that silently never matches, and the extraction is what lets the four
|
|
53
|
+
* schemas that narrow to this subset share one definition instead of four literals.
|
|
54
|
+
*
|
|
55
|
+
* **One subset, not two.** These are also exactly the rails that mint discount codes today, and both
|
|
56
|
+
* discount schemas narrow to this. A second constant would be a mirror with nothing to mirror. The day a
|
|
57
|
+
* rail mints and does not sell, or sells and does not mint, `providers.test.ts` disagrees with itself and
|
|
58
|
+
* the subset splits — with a failing test rather than a judgment call, which is the same bargain
|
|
59
|
+
* `CheckoutRail` makes for selling and portal-minting.
|
|
60
|
+
*/
|
|
61
|
+
export const PaymentsHostedRail = PaymentsRail.extract(["stripe", "lemonSqueezy", "paddle"]).describe(
|
|
62
|
+
"Which hosted rail — the three that sell in a browser, mint a portal, and mint a discount code.",
|
|
63
|
+
);
|
|
64
|
+
export type PaymentsHostedRail = z.infer<typeof PaymentsHostedRail>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The same three as a plain array, for the callers that iterate rather than parse.
|
|
68
|
+
*
|
|
69
|
+
* Derived from the schema rather than written beside it. Four request and response schemas wrote this
|
|
70
|
+
* subset out by hand and Paddle reached none of them (#465), so a rail that sold, minted portals and minted
|
|
71
|
+
* discount codes was a 400 at every point a caller had to name it. One list, one edit — and
|
|
72
|
+
* `providers.test.ts` holds it to the rails that satisfy the interfaces at runtime, in both directions.
|
|
73
|
+
*/
|
|
74
|
+
export const PAYMENTS_HOSTED_RAILS: readonly PaymentsRail[] = PaymentsHostedRail.options;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
5
|
+
import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
|
|
6
|
+
import { withD1Retry } from "@pithy-sh/core/src/data/withD1Retry";
|
|
7
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { PurchaseEnvironment } from "./purchase";
|
|
10
|
+
import { PaymentsRail } from "./rail";
|
|
11
|
+
import { PAYMENTS_RECONCILE_RUNS_TABLE, paymentsDatabase } from "./tables";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One reconciliation pass, kept — the row in `pithy_payments_reconcile_runs`.
|
|
15
|
+
*
|
|
16
|
+
* **Reconciliation is the compensating control for a delivery mechanism that is known to fail.** Webhooks are
|
|
17
|
+
* dropped, delayed and delivered out of order, which is the whole reason the pass exists. So the run that
|
|
18
|
+
* *repaired* something is the most operationally valuable event this capability produces: it is the proof that
|
|
19
|
+
* a delivery went missing and that a customer was briefly wrong.
|
|
20
|
+
*
|
|
21
|
+
* Before this table, that was unobservable after the fact. The pass wrote its tally to a `Logger` and its
|
|
22
|
+
* repairs to an `AuditEmit`, and *"has reconciliation been running"* had no answer but reading logs, while
|
|
23
|
+
* *"what did it fix last month"* had none at all. An adopter could not tell a healthy integration from one
|
|
24
|
+
* whose cron stopped firing, because a silent nightly job and a job that is not running look identical.
|
|
25
|
+
*
|
|
26
|
+
* ## A run is a summary, not a second audit trail
|
|
27
|
+
*
|
|
28
|
+
* An audit event is one thing that happened; a run is the statement over many — started, finished, how much
|
|
29
|
+
* was compared, how much was repaired, what failed. That tally cannot be reconstructed by grouping events,
|
|
30
|
+
* because nothing in the events says where one pass ended and the next began.
|
|
31
|
+
*
|
|
32
|
+
* So the repairs stay in the audit trail, once, and **the run points at them rather than copying them**: the
|
|
33
|
+
* run's `id` travels on every `payments/purchase_reconciled` event the pass emits, as `runId`. Copying the
|
|
34
|
+
* repairs here would build a second, weaker copy of the trail inside a table with different access rules —
|
|
35
|
+
* the mistake `admin/coverage.ts` refuses on the webhook log for the same reason.
|
|
36
|
+
*
|
|
37
|
+
* ## A clean run is stored
|
|
38
|
+
*
|
|
39
|
+
* *"It ran and found nothing"* is the answer to *"is it healthy"*. Storing only the passes that repaired
|
|
40
|
+
* something would make silence ambiguous — indistinguishable from a cron that stopped firing, which is
|
|
41
|
+
* precisely the failure this table exists to make visible.
|
|
42
|
+
*
|
|
43
|
+
* ## Retention
|
|
44
|
+
*
|
|
45
|
+
* {@link RECONCILE_RUN_RETENTION_DAYS} days, pruned by the writer on every run. Runs are small and frequent —
|
|
46
|
+
* a daily cron is one row a day — so the table is bounded by the schedule rather than by the catalog, and a
|
|
47
|
+
* ninety-day window covers "did it run last quarter" while never growing without limit. It is stated here and
|
|
48
|
+
* enforced in {@link recordReconcileRun}, rather than left to be discovered when a table is large.
|
|
49
|
+
*
|
|
50
|
+
* ## No provider payload, and structurally so
|
|
51
|
+
*
|
|
52
|
+
* Every field below is a count, a timestamp, an enum or this deployment's own id. There is no column a
|
|
53
|
+
* store's response could be written into, so a run record cannot carry one — the same control
|
|
54
|
+
* `admin/read.ts` applies to a purchase by never selecting `payload`, taken one step further by never
|
|
55
|
+
* having the column.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** How long a run record is kept. Days, pruned by the writer — see the module doc. */
|
|
59
|
+
export const RECONCILE_RUN_RETENTION_DAYS = 90;
|
|
60
|
+
|
|
61
|
+
/** One reconciliation pass, as stored. `z.output` is the app shape; `z.input` is the SQLite row. */
|
|
62
|
+
export const PaymentsReconcileRun = z
|
|
63
|
+
.object({
|
|
64
|
+
id: z.string().describe("The run's UUID. Travels on every repair the pass audits, as `runId`."),
|
|
65
|
+
startedAt: SQLiteDate.describe("When the pass began, from the run's own clock."),
|
|
66
|
+
finishedAt: SQLiteDate.describe("When the pass finished. Equal to `startedAt` for a run that read nothing."),
|
|
67
|
+
environment: PurchaseEnvironment.describe(
|
|
68
|
+
"The store environment the host was deployed to. A sandbox pass and a production pass are different facts about different money, and a table that mixed them would answer the health question wrong.",
|
|
69
|
+
),
|
|
70
|
+
rail: PaymentsRail.nullable().describe(
|
|
71
|
+
"The single store this pass was narrowed to, or null for every enabled rail. Null is the scheduled behavior; a value means somebody ran it by hand against one store.",
|
|
72
|
+
),
|
|
73
|
+
pages: z.number().int().describe("Durable steps read — one per page of purchases."),
|
|
74
|
+
scanned: z.number().int().describe("Purchases examined."),
|
|
75
|
+
unchanged: z.number().int().describe("Purchases whose stored state already matched the store's."),
|
|
76
|
+
drifted: z
|
|
77
|
+
.number()
|
|
78
|
+
.int()
|
|
79
|
+
.describe(
|
|
80
|
+
"Purchases whose stored state disagreed and was repaired. The number that matters: a rising one means webhooks are being lost.",
|
|
81
|
+
),
|
|
82
|
+
superseded: z
|
|
83
|
+
.number()
|
|
84
|
+
.int()
|
|
85
|
+
.describe(
|
|
86
|
+
"Old periods a later transaction has replaced, settled on this pass. Falls back to zero once a catalog's history has been walked once, which is why it is counted apart from `drifted`.",
|
|
87
|
+
),
|
|
88
|
+
skipped: z
|
|
89
|
+
.number()
|
|
90
|
+
.int()
|
|
91
|
+
.describe("Purchases no store could be asked about — a rail switched off, or unaddressable."),
|
|
92
|
+
failed: z
|
|
93
|
+
.number()
|
|
94
|
+
.int()
|
|
95
|
+
.describe(
|
|
96
|
+
"Purchases a store refused to answer for. Counted rather than thrown, so one bad row cannot end a pass.",
|
|
97
|
+
),
|
|
98
|
+
truncated: SQLiteBoolean.describe(
|
|
99
|
+
"Whether the pass stopped at its page cap with catalog left unread. True means the tally is a floor, not a total.",
|
|
100
|
+
),
|
|
101
|
+
dryRun: SQLiteBoolean.describe(
|
|
102
|
+
"Whether this pass only reported. A dry run repairs nothing, so its `drifted` is a finding rather than a fix.",
|
|
103
|
+
),
|
|
104
|
+
createdAt: SQLiteDate.describe("When this row was written."),
|
|
105
|
+
})
|
|
106
|
+
.describe(
|
|
107
|
+
"One reconciliation pass — when it ran, what it was narrowed to, and its tally. Never a provider payload: there is no column for one.",
|
|
108
|
+
);
|
|
109
|
+
export type PaymentsReconcileRun = z.output<typeof PaymentsReconcileRun>;
|
|
110
|
+
export type PaymentsReconcileRunRow = z.input<typeof PaymentsReconcileRun>;
|
|
111
|
+
|
|
112
|
+
/** What the pass hands the writer. The row, minus the two fields the writer owns. */
|
|
113
|
+
export interface ReconcileRunInput {
|
|
114
|
+
/** The run's id — minted before the pass so the repairs it audits can name it. */
|
|
115
|
+
id: string;
|
|
116
|
+
/** When the pass began. */
|
|
117
|
+
startedAt: Date;
|
|
118
|
+
/** When it finished. */
|
|
119
|
+
finishedAt: Date;
|
|
120
|
+
/** The deployment's store environment. */
|
|
121
|
+
environment: PurchaseEnvironment;
|
|
122
|
+
/** The rail the pass was narrowed to, or null for every enabled one. */
|
|
123
|
+
rail: PaymentsRail | null;
|
|
124
|
+
/** The tally, as `reconcilePayments` reports it. */
|
|
125
|
+
report: {
|
|
126
|
+
pages: number;
|
|
127
|
+
scanned: number;
|
|
128
|
+
unchanged: number;
|
|
129
|
+
drifted: number;
|
|
130
|
+
superseded: number;
|
|
131
|
+
skipped: number;
|
|
132
|
+
failed: number;
|
|
133
|
+
truncated: boolean;
|
|
134
|
+
dryRun: boolean;
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Write one run record, and prune the ones past retention.
|
|
140
|
+
*
|
|
141
|
+
* Idempotent on the run's id: a Workflow step that wrote the row and then failed to journal its result
|
|
142
|
+
* replays the whole body, and a second insert for one pass would make the health read count a pass twice.
|
|
143
|
+
* `ON CONFLICT (id) DO UPDATE` is what makes the replay land on the same row it already wrote.
|
|
144
|
+
*
|
|
145
|
+
* The prune shares the write rather than living in a second scheduled job, because a job that prunes is a job
|
|
146
|
+
* that can stop — and the only thing that ever writes here is the thing that would then also stop pruning.
|
|
147
|
+
* Retention is therefore a property of the writer, and a table nobody writes to does not grow.
|
|
148
|
+
*/
|
|
149
|
+
export async function recordReconcileRun(
|
|
150
|
+
d1: D1Database,
|
|
151
|
+
input: ReconcileRunInput,
|
|
152
|
+
options: { now?: Date; retentionDays?: number } = {},
|
|
153
|
+
): Promise<PaymentsReconcileRun> {
|
|
154
|
+
const now = options.now ?? new Date();
|
|
155
|
+
const retentionDays = options.retentionDays ?? RECONCILE_RUN_RETENTION_DAYS;
|
|
156
|
+
const db = paymentsDatabase(d1);
|
|
157
|
+
const row = PaymentsReconcileRun.encode({
|
|
158
|
+
id: input.id,
|
|
159
|
+
startedAt: input.startedAt,
|
|
160
|
+
finishedAt: input.finishedAt,
|
|
161
|
+
environment: input.environment,
|
|
162
|
+
rail: input.rail,
|
|
163
|
+
...input.report,
|
|
164
|
+
createdAt: now,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await withD1Retry(() =>
|
|
168
|
+
db
|
|
169
|
+
.insertInto(PAYMENTS_RECONCILE_RUNS_TABLE)
|
|
170
|
+
// biome-ignore lint/suspicious/noExplicitAny: the row is the schema's z.input side; Kysely's insert type derives from it.
|
|
171
|
+
.values(row as any)
|
|
172
|
+
.onConflict((oc) =>
|
|
173
|
+
oc.column("id").doUpdateSet({
|
|
174
|
+
finishedAt: row.finishedAt,
|
|
175
|
+
pages: row.pages,
|
|
176
|
+
scanned: row.scanned,
|
|
177
|
+
unchanged: row.unchanged,
|
|
178
|
+
drifted: row.drifted,
|
|
179
|
+
superseded: row.superseded,
|
|
180
|
+
skipped: row.skipped,
|
|
181
|
+
failed: row.failed,
|
|
182
|
+
truncated: row.truncated,
|
|
183
|
+
dryRun: row.dryRun,
|
|
184
|
+
// biome-ignore lint/suspicious/noExplicitAny: as above — an encoded row, not the app shape.
|
|
185
|
+
} as any),
|
|
186
|
+
)
|
|
187
|
+
.execute(),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000);
|
|
191
|
+
await withD1Retry(() =>
|
|
192
|
+
db.deleteFrom(PAYMENTS_RECONCILE_RUNS_TABLE).where("startedAt", "<", SQLiteDate.encode(cutoff)).execute(),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const written = await db
|
|
196
|
+
.selectFrom(PAYMENTS_RECONCILE_RUNS_TABLE)
|
|
197
|
+
.selectAll()
|
|
198
|
+
.where("id", "=", input.id)
|
|
199
|
+
.executeTakeFirst();
|
|
200
|
+
if (written === undefined) {
|
|
201
|
+
throw new InternalError({
|
|
202
|
+
message: "The reconciliation run could not be recorded.",
|
|
203
|
+
action: "Retry. If it persists, check the app database for the pithy_payments_* tables.",
|
|
204
|
+
detail: `Wrote reconcile run ${input.id} but could not read it back.`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return PaymentsReconcileRun.parse(written);
|
|
208
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_LOCALE } from "@pithy-sh/core/src/i18n/translator";
|
|
5
|
+
import { minorUnitDigits } from "./money";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An integer in a currency's minor unit, as a sentence a customer reads — `6582` to `$65.82`.
|
|
9
|
+
*
|
|
10
|
+
* ## Why this exists at all, when `client/paddle.ts` says never to format a price
|
|
11
|
+
*
|
|
12
|
+
* That rule stands, and this does not break it: **it never decides an amount.** Paddle's integer goes in
|
|
13
|
+
* and comes back out, digit for digit, with a separator placed and a symbol attached. Nothing here
|
|
14
|
+
* divides, rounds, sums or nets, and the minor amount stays on the shape beside the string, so a consumer
|
|
15
|
+
* that wants the integer still gets the integer (see `QuotedMoney`).
|
|
16
|
+
*
|
|
17
|
+
* The reason the kit gave for never doing this was wrong, and it was measured wrong on 2026-08-28 (#465).
|
|
18
|
+
* `client/paddle.ts` argued that `Intl.NumberFormat` "would have to carry a table of which currencies have
|
|
19
|
+
* two decimals, and would get one wrong". It carries exactly that table, from CLDR, and it is right about
|
|
20
|
+
* the zero-decimal currencies: `resolvedOptions().maximumFractionDigits` answers 2 for `USD` and `GBP` and
|
|
21
|
+
* 0 for `JPY`, `KRW` and `CLP`. Paddle's own documentation instructs adopters to do this — "use a currency
|
|
22
|
+
* library to format monetary values to the correct number of decimals… symbols and decimal separators are
|
|
23
|
+
* placed correctly" (api-reference/about/data-types) — and offers `formatted_totals` only on the
|
|
24
|
+
* pricing-preview endpoint, "for convenience". `subscriptions.preview` and `transactions.preview` return no
|
|
25
|
+
* formatted field at any depth, verified against the recordings. So a plan-change quote has minor units or
|
|
26
|
+
* it has nothing, and a confirmation screen with no figure on it is a customer agreeing to an amount they
|
|
27
|
+
* were never shown.
|
|
28
|
+
*
|
|
29
|
+
* ## The exponent is the store's, and `Intl`'s own is not it
|
|
30
|
+
*
|
|
31
|
+
* This is the part the correction above would get wrong if it stopped one sentence early. **CLDR's
|
|
32
|
+
* fraction digits are a display convention, not the denomination the money arrived in**, and they are
|
|
33
|
+
* maintained by different people for a different purpose.
|
|
34
|
+
*
|
|
35
|
+
* The example this originally carried was wrong and is worth keeping as the correction. It claimed
|
|
36
|
+
* `Intl` renders 6582 forint as `66 Ft`, from CLDR giving `HUF` no fraction. CLDR carries **two**
|
|
37
|
+
* fraction tables — standard digits, and `cashDigits` for rounding physical currency — and `HUF` and
|
|
38
|
+
* `COP` are zero only in the second. `Intl.NumberFormat` reads the first, answers 2 for both, and
|
|
39
|
+
* renders `65,82 Ft`. Re-measured on ICU 75.1: across every code this package can be paid in, CLDR and
|
|
40
|
+
* ISO 4217 agree on all of them, so there is no live divergence to point at.
|
|
41
|
+
*
|
|
42
|
+
* The pinning stays anyway, and agreement today is the reason it is cheap rather than the reason to
|
|
43
|
+
* drop it: `Intl` is asked for a symbol and separators, and a renderer that let it choose the exponent
|
|
44
|
+
* would be taking the denomination from a table that can move under a runtime upgrade.
|
|
45
|
+
*
|
|
46
|
+
* So the digits come from {@link minorUnitDigits} — ISO 4217, the table this package already converts
|
|
47
|
+
* every rail's amounts through and the one `client/wholeUnits.ts` already trusts against Paddle's own
|
|
48
|
+
* strings — and `Intl` is **pinned** to it on both ends. `Intl` decides the symbol, the separators, the
|
|
49
|
+
* grouping and the placement; it does not decide how much money this is.
|
|
50
|
+
*
|
|
51
|
+
* ## The scaling is a string operation, deliberately
|
|
52
|
+
*
|
|
53
|
+
* `9007199254740991 / 100` is `90071992547409.9` in binary floating point — a cent, gone, on the largest
|
|
54
|
+
* figure this package can hold. `Intl.NumberFormat.prototype.format` accepts a decimal **string** (ES2023,
|
|
55
|
+
* and available in Workers and Node 22 alike), so the point is placed by moving characters and the digits
|
|
56
|
+
* that arrived are the digits that render. There is no arithmetic to be wrong.
|
|
57
|
+
*
|
|
58
|
+
* ## Where the locale comes from
|
|
59
|
+
*
|
|
60
|
+
* Not from here, and not from a request field. `Translator.formattingLocale` — `c.var.t` on every route —
|
|
61
|
+
* is what the kit already negotiates per reader, and `@pithy-sh/email` sets the precedent by building a
|
|
62
|
+
* per-recipient translator from a stored locale rather than inventing a second rule. This module takes
|
|
63
|
+
* that string as a value, which is the seam's stated contract for exactly this case: "an adopter who
|
|
64
|
+
* already owns their date and number rendering needs the locale as a *value* to hand to `Intl`".
|
|
65
|
+
*
|
|
66
|
+
* When nothing resolves it, the answer is {@link RENDER_FALLBACK_LOCALE} and it is *stated*: a quote
|
|
67
|
+
* rendered in the wrong language is recoverable, and a quote with no figure on it is not.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The language a figure is rendered in when the reader's own did not resolve.
|
|
72
|
+
*
|
|
73
|
+
* Core's `DEFAULT_LOCALE` rather than a second constant: it is already the locale a project falls back to
|
|
74
|
+
* and the one the kit writes in, and two answers to "what language when we do not know" is how a screen
|
|
75
|
+
* ends up in one and its money in another.
|
|
76
|
+
*/
|
|
77
|
+
export const RENDER_FALLBACK_LOCALE = DEFAULT_LOCALE;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A currency code `Intl` can be asked about at all: three ASCII letters, which is every code ISO 4217
|
|
81
|
+
* issues.
|
|
82
|
+
*
|
|
83
|
+
* **The shape, deliberately, and not a list of codes.** `Intl` throws a `RangeError` only on a code that is
|
|
84
|
+
* not well-formed; a well-formed one nobody has heard of renders with the code itself standing in for the
|
|
85
|
+
* symbol, which is the honest answer and is what CLDR does for `XTS`. A second allowlist of currencies here
|
|
86
|
+
* would be a table to keep current and a way for a real currency to become unrenderable.
|
|
87
|
+
*/
|
|
88
|
+
const CURRENCY_CODE = /^[A-Za-z]{3}$/;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `Intl.NumberFormat` with the `format` signature the runtime actually has.
|
|
92
|
+
*
|
|
93
|
+
* ES2023 — "NumberFormat V3" — widened `format` to accept a decimal **string**, and V8 has shipped it for
|
|
94
|
+
* years: it is what Workers and Node 22 both do, and `renderMoney.test.ts` proves it by asserting the
|
|
95
|
+
* cent that a `number` loses at the top of the safe-integer range. This repository compiles against
|
|
96
|
+
* `lib: ["ES2022"]`, whose declaration stops at `number | bigint` — and moving to TypeScript's own ES2023
|
|
97
|
+
* declaration would not help: it widens `format` to the template literal type `` `${number}` ``, which a
|
|
98
|
+
* string assembled at runtime is never assignable to.
|
|
99
|
+
*
|
|
100
|
+
* So the runtime's signature is named here, once, behind one assertion — rather than moving every
|
|
101
|
+
* package's `lib` for a single call, or passing a `number` and losing the digit the string exists to keep.
|
|
102
|
+
*/
|
|
103
|
+
interface DecimalFormatter {
|
|
104
|
+
/** The amount, as a decimal string in whole units — `"65.82"`, `"-6582"`, `"0.007"`. */
|
|
105
|
+
format(value: string): string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The amount as a decimal string, with the point moved `digits` places left.
|
|
110
|
+
*
|
|
111
|
+
* Lexical from end to end. The caller's integer is documented as a safe integer — `minorAmount` refuses
|
|
112
|
+
* anything else and `QuotedMoney` refuses a float — and when that is violated the split produces a string
|
|
113
|
+
* `Intl` renders as `NaN`. That is the intended direction of the failure: loud, and impossible to mistake
|
|
114
|
+
* for a figure, where a rounded or truncated one would be a wrong number nobody notices.
|
|
115
|
+
*/
|
|
116
|
+
function decimalOf(amountMinor: number, digits: number): string {
|
|
117
|
+
const sign = amountMinor < 0 ? "-" : "";
|
|
118
|
+
const magnitude = String(Math.abs(amountMinor));
|
|
119
|
+
if (digits === 0) return `${sign}${magnitude}`;
|
|
120
|
+
const padded = magnitude.padStart(digits + 1, "0");
|
|
121
|
+
const point = padded.length - digits;
|
|
122
|
+
return `${sign}${padded.slice(0, point)}.${padded.slice(point)}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `amountMinor` of `currency`, rendered for a reader of `locale` — or `null` when the currency cannot be
|
|
127
|
+
* named.
|
|
128
|
+
*
|
|
129
|
+
* Null rather than a guess, which is the idiom `minorAmount` and `minorUnitsFromScaled` already set in this
|
|
130
|
+
* package: a store that answered `"dollars"` where a currency goes has answered with something no amount can
|
|
131
|
+
* be denominated in, and the caller refuses the whole quote on it rather than letting a `RangeError` out of
|
|
132
|
+
* `Intl` reach a confirmation screen as a 500. A **locale** `Intl` refuses is the other case entirely, and
|
|
133
|
+
* costs the reader only their language — see {@link RENDER_FALLBACK_LOCALE}.
|
|
134
|
+
*
|
|
135
|
+
* `locale` is optional so that a caller with nothing to pass writes nothing, rather than writing `"en"` at
|
|
136
|
+
* a call site and putting the fallback in two places.
|
|
137
|
+
*/
|
|
138
|
+
export function renderMoney(amountMinor: number, currency: string, locale?: string): string | null {
|
|
139
|
+
if (!CURRENCY_CODE.test(currency)) return null;
|
|
140
|
+
const digits = minorUnitDigits(currency);
|
|
141
|
+
const options: Intl.NumberFormatOptions = {
|
|
142
|
+
style: "currency",
|
|
143
|
+
// Uppercased only here. Lowercase is how this package stores a currency, and `Intl` accepts either —
|
|
144
|
+
// but the ISO spelling is what a reader of this line should see being handed over.
|
|
145
|
+
currency: currency.toUpperCase(),
|
|
146
|
+
// Pinned to the store's denomination on both ends, so `Intl` cannot round a figure off a quote. See
|
|
147
|
+
// the module note: CLDR would render `HUF` and `COP` with no fraction at all.
|
|
148
|
+
minimumFractionDigits: digits,
|
|
149
|
+
maximumFractionDigits: digits,
|
|
150
|
+
};
|
|
151
|
+
return formatter(options, locale).format(decimalOf(amountMinor, digits));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A formatter for `locale`, or one for {@link RENDER_FALLBACK_LOCALE} when `locale` is not a tag `Intl`
|
|
156
|
+
* accepts.
|
|
157
|
+
*
|
|
158
|
+
* The chain negotiates and canonicalizes every reader-supplied tag before it reaches a route, so the only
|
|
159
|
+
* way an unconstructible one arrives here is an adopter's own configured default. That is a mistake worth
|
|
160
|
+
* surviving rather than raising through a confirmation screen: the fallback keeps the amount readable and
|
|
161
|
+
* the misconfiguration is visible in the language, not in a 500.
|
|
162
|
+
*
|
|
163
|
+
* The fallback formatter cannot throw in turn — the currency was checked before this was called, and
|
|
164
|
+
* `RENDER_FALLBACK_LOCALE` is a constant.
|
|
165
|
+
*/
|
|
166
|
+
function formatter(options: Intl.NumberFormatOptions, locale: string | undefined): DecimalFormatter {
|
|
167
|
+
if (locale !== undefined && locale !== "") {
|
|
168
|
+
try {
|
|
169
|
+
return decimalFormatter(locale, options);
|
|
170
|
+
} catch {
|
|
171
|
+
// A `RangeError` from a malformed tag. Nothing else here can throw, and the reader still gets a figure.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return decimalFormatter(RENDER_FALLBACK_LOCALE, options);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The one assertion in this module. See {@link DecimalFormatter} for what it asserts and what proves it. */
|
|
178
|
+
function decimalFormatter(locale: string, options: Intl.NumberFormatOptions): DecimalFormatter {
|
|
179
|
+
return new Intl.NumberFormat(locale, options) as unknown as DecimalFormatter;
|
|
180
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The normalized purchase status set. Each rail's events map into these nine, so nothing downstream of
|
|
8
|
+
* a rail module ever sees `DID_RENEW`, `SUBSCRIPTION_ON_HOLD`, or `invoice.payment_failed`.
|
|
9
|
+
*
|
|
10
|
+
* The set is deliberately about *state*, not about the event that produced it: a projection is keyed on a
|
|
11
|
+
* transaction, and a transaction has one current state however many notifications described it.
|
|
12
|
+
*
|
|
13
|
+
* ## Two endings, and the difference between them is the money
|
|
14
|
+
*
|
|
15
|
+
* **`expired` is a period we were paid for, ended.** A subscription that ran its course, a Stripe
|
|
16
|
+
* subscription Stripe closed, an Apple period past its `expiresDate`. It grants no access — the period is
|
|
17
|
+
* over — but it **credits**, because the invoice behind it cleared.
|
|
18
|
+
*
|
|
19
|
+
* **`never_paid` is a purchase that terminated before any money moved.** A delayed-payment checkout whose
|
|
20
|
+
* bank debit bounced, a Stripe subscription abandoned at `incomplete_expired`, a Play deferred purchase
|
|
21
|
+
* canceled before payment. It grants nothing and credits nothing, and no clawback ever follows one, because
|
|
22
|
+
* there is no payment to reverse. Leaning on `expired` for these is how a coin pack gets handed out for money
|
|
23
|
+
* that never arrived — see `UNPAID_STATUSES` in `grants/apply.ts`. Apple maps nothing here: StoreKit issues
|
|
24
|
+
* no transaction until the money moves.
|
|
25
|
+
*/
|
|
26
|
+
export const PurchaseStatus = z
|
|
27
|
+
.enum(["active", "in_grace", "on_hold", "canceled", "expired", "never_paid", "refunded", "revoked", "paused"])
|
|
28
|
+
.describe(
|
|
29
|
+
"The normalized purchase status. `active` is paid and current; `in_grace` is a failed renewal still inside the retry window; `on_hold` is a failed renewal past it; `canceled` is auto-renew turned off with the paid period still running; `expired` is a period we were paid for that has lapsed; `never_paid` terminated before any money cleared, so it credits nothing; `refunded` and `revoked` were taken back; `paused` is a subscription the user suspended.",
|
|
30
|
+
);
|
|
31
|
+
export type PurchaseStatus = z.infer<typeof PurchaseStatus>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The statuses a purchase can grant access from — the candidate set the projection's SQL filters on
|
|
35
|
+
* before the expiry check. `in_grace`'s membership is then narrowed per project by
|
|
36
|
+
* {@link statusGrantsAccess}, so this set and that predicate must stay in step; a test asserts both.
|
|
37
|
+
*
|
|
38
|
+
* **`canceled` still grants**, and it is the member most often got wrong. A user who turns off
|
|
39
|
+
* auto-renew has not lost access — they have declined the *next* period. The period they paid for runs
|
|
40
|
+
* to its end, and `expiresAt` is what ends it.
|
|
41
|
+
*/
|
|
42
|
+
export const ACCESS_GRANTING_STATUSES: ReadonlySet<PurchaseStatus> = new Set<PurchaseStatus>([
|
|
43
|
+
"active",
|
|
44
|
+
"in_grace",
|
|
45
|
+
"canceled",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a purchase in this status grants its entitlements, given the project's grace policy. The
|
|
50
|
+
* expiry check is separate and applied alongside it — a status says whether the purchase is still
|
|
51
|
+
* standing, `expiresAt` says whether the period it paid for is over.
|
|
52
|
+
*
|
|
53
|
+
* `in_grace` granting access is the point of grace: a failed card should not lock a paying subscriber
|
|
54
|
+
* out mid-period. It is a described config flag rather than hardcoded policy because an app that sells
|
|
55
|
+
* something expensive may reasonably decide otherwise. `on_hold` never grants — by then the retry window
|
|
56
|
+
* is exhausted and the renewal has genuinely failed. `never_paid` never grants either, and never can: it
|
|
57
|
+
* says no money ever moved.
|
|
58
|
+
*/
|
|
59
|
+
export function statusGrantsAccess(status: PurchaseStatus, graceGrantsAccess: boolean): boolean {
|
|
60
|
+
if (status === "in_grace") return graceGrantsAccess;
|
|
61
|
+
return ACCESS_GRANTING_STATUSES.has(status);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The statuses that grant access under this project's grace policy — what the projection's SQL filters on. */
|
|
65
|
+
export function grantingStatuses(graceGrantsAccess: boolean): PurchaseStatus[] {
|
|
66
|
+
return [...ACCESS_GRANTING_STATUSES].filter((status) => statusGrantsAccess(status, graceGrantsAccess));
|
|
67
|
+
}
|