@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,883 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { pauseResumesAt } from "../../data/pause";
|
|
6
|
+
import type { PurchaseEnvironment } from "../../data/purchase";
|
|
7
|
+
import type { PurchaseStatus } from "../../data/status";
|
|
8
|
+
import { decodeSubjectReference } from "../../data/subject";
|
|
9
|
+
import { PaymentsVerificationFailedError } from "../../error/errors";
|
|
10
|
+
import type { UnboundProviderEvent } from "../contract";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Paddle's objects, and the mapping from what its events say to what this package stores.
|
|
14
|
+
*
|
|
15
|
+
* ## Two rows per subscription, exactly as Lemon Squeezy has
|
|
16
|
+
*
|
|
17
|
+
* Paddle splits money from state at the source. A `transaction.*` event carries a **transaction**: one
|
|
18
|
+
* billing period's charge, its totals, its status. A `subscription.*` event carries a **subscription**: a
|
|
19
|
+
* status, a billing period, a scheduled change, and no money. So there is a **state row** keyed on
|
|
20
|
+
* `sub_…` and a **money row** per `txn_…`, distinguished by `PurchaseRole`, and each is monotonic on
|
|
21
|
+
* exactly one provider clock.
|
|
22
|
+
*
|
|
23
|
+
* Paddle's ids are globally prefixed and distinct — `txn_`, `sub_`, `adj_`, `ctm_` — so unlike Lemon
|
|
24
|
+
* Squeezy nothing needs namespacing before it can be a row's identity.
|
|
25
|
+
*
|
|
26
|
+
* **A subscription transaction is born `expired`, not `active`, and this is a departure from the issue's
|
|
27
|
+
* event table stated out loud.** The issue maps `transaction.completed` to `active`. On a one-off that is
|
|
28
|
+
* right and this module does it. On a subscription it would be a row with no expiry saying "paid and
|
|
29
|
+
* current" forever — and a never-expiring `active` row outranks the subscription's own state row, so a
|
|
30
|
+
* canceled subscriber would keep their entitlement for good. The paid period is real and it has closed:
|
|
31
|
+
* `expired` grants nothing and still credits a `grants` clause, which is what makes two renewals credit
|
|
32
|
+
* twice. This is the trap `lemonSqueezy/objects.ts` documents at length; Paddle has it identically.
|
|
33
|
+
*
|
|
34
|
+
* ## The environment is the account, not a field on the payload
|
|
35
|
+
*
|
|
36
|
+
* The issue says `data.mode` is `sandbox` or `live`. It is not. Verified against a live sandbox: `mode`
|
|
37
|
+
* appears on a **discount**, where its values are `standard` and `custom`, and it is absent from customers
|
|
38
|
+
* and transactions entirely. Paddle Billing partitions sandbox from live by *account* — a separate host,
|
|
39
|
+
* a separate API key, a separate set of notification destinations — so the environment a purchase belongs
|
|
40
|
+
* to is the environment this deployment's credentials point at, and nothing in a payload can contradict
|
|
41
|
+
* it. That is the same arrangement Stripe has, and the opposite of Lemon Squeezy's single namespace.
|
|
42
|
+
*
|
|
43
|
+
* ## The environment fence, and why `custom_data` alone cannot be one
|
|
44
|
+
*
|
|
45
|
+
* `dev` is not publicly routable, so a dev checkout's webhooks land at `staging` — both point at one
|
|
46
|
+
* Paddle sandbox, and a sandbox has one set of notification destinations. So a delivery stamped for
|
|
47
|
+
* another environment is the normal case here, not an anomaly, and it projects nothing and returns 200.
|
|
48
|
+
*
|
|
49
|
+
* The issue argues the stamp needs no proof, because "`Paddle.Checkout.open` refuses `customData`
|
|
50
|
+
* alongside `transactionId`, and the server always creates the transaction." **That constraint does not
|
|
51
|
+
* exist.** Two things were measured against the live sandbox on 2026-08-13, both from a page holding
|
|
52
|
+
* nothing but the publishable client token this rail ships to every browser that loads a paywall:
|
|
53
|
+
*
|
|
54
|
+
* - `Paddle.Checkout.open({ items: [{ priceId, quantity }], customData: {…} })` opens and completes with
|
|
55
|
+
* no server involved at all, and Paddle stores the page's `custom_data` verbatim.
|
|
56
|
+
* - `Paddle.Checkout.open({ transactionId, customData: {…} })` does not throw, is not refused, and
|
|
57
|
+
* **replaces** the `custom_data` the server wrote when the transaction was created. Same transaction id,
|
|
58
|
+
* `origin` still `"api"`, owner now whoever the page said.
|
|
59
|
+
*
|
|
60
|
+
* Both recordings are in `fixtures/browserForged.ts` and `objects.test.ts` gates on them. So
|
|
61
|
+
* `custom_data.pithy_user` is a string a browser can write on either form of the call, and binding
|
|
62
|
+
* ownership on it would let anyone attach a purchase to any account, permanently, because
|
|
63
|
+
* `linkProviderAccount` never rebinds and the first pairing wins.
|
|
64
|
+
*
|
|
65
|
+
* The Lemon Squeezy rail met the same problem and answered it with a MAC. So does this one: see
|
|
66
|
+
* {@link accountReferenceProof}. Without the proof the two stamped values are worth nothing, and with it
|
|
67
|
+
* they are worth exactly what a value only this deployment's server could have produced is worth.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The `custom_data` key carrying the subject reference. Literal snake_case, as the issue specifies.
|
|
72
|
+
*
|
|
73
|
+
* **The name says `user` and the value no longer has to be one, and that mismatch is deliberate.** What
|
|
74
|
+
* travels here is now `encodeSubjectReference`'s output — `user:ada`, `organization:acme` — because a
|
|
75
|
+
* purchase can be held by an organization. The obvious tidy-up is to rename the key to match. It is the one
|
|
76
|
+
* thing that must not happen: this string is a wire contract with Paddle, not an identifier of ours. A
|
|
77
|
+
* checkout stamped `pithy_user` today is a transaction sitting open in somebody's browser, a subscription
|
|
78
|
+
* renewing next month, and a `custom_data` object Paddle stores verbatim for the life of the customer. A
|
|
79
|
+
* renamed key reads nothing on any of them — every in-flight purchase comes back naming nobody, permanently,
|
|
80
|
+
* with a `linkProviderAccount` that never rebinds behind it. The name is frozen; the meaning moved.
|
|
81
|
+
*/
|
|
82
|
+
export const PADDLE_CUSTOM_ACCOUNT = "pithy_user";
|
|
83
|
+
|
|
84
|
+
/** The `custom_data` key carrying this deployment's environment — the shared-sandbox fence. */
|
|
85
|
+
export const PADDLE_CUSTOM_ENV = "pithy_env";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The `custom_data` key carrying the proof that **this deployment's server** wrote the two above.
|
|
89
|
+
*
|
|
90
|
+
* Without it the other two prove nothing: `Paddle.Checkout.open` accepts `customData` with only the
|
|
91
|
+
* publishable client token — beside an `items[]` array *and* beside a `transactionId`, where it overwrites
|
|
92
|
+
* what the server wrote — both key names are exported constants in an open-source package, and the
|
|
93
|
+
* environment value is one of three. Requiring them asks an attacker to guess nothing.
|
|
94
|
+
*
|
|
95
|
+
* A MAC is the only part a stranger cannot produce.
|
|
96
|
+
*/
|
|
97
|
+
export const PADDLE_CUSTOM_PROOF = "pithy_ref_proof";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Paddle stores `custom_data` verbatim — it does not normalize keys the way Lemon Squeezy snake_cases
|
|
101
|
+
* them. The `pithy_` prefix is still load-bearing: this object is shared with whatever an adopter puts in
|
|
102
|
+
* it, and a prefix is what stops a collision being silent rather than being a wrong owner.
|
|
103
|
+
*/
|
|
104
|
+
const CustomData = z
|
|
105
|
+
.record(z.string(), z.unknown())
|
|
106
|
+
.describe("Arbitrary key/value data stored verbatim by Paddle, shared with whatever the adopter puts there.");
|
|
107
|
+
|
|
108
|
+
/** The envelope every webhook delivery and every swept event arrives in. */
|
|
109
|
+
export const PaddleEvent = z
|
|
110
|
+
.object({
|
|
111
|
+
event_id: z
|
|
112
|
+
.string()
|
|
113
|
+
.min(1)
|
|
114
|
+
.describe(
|
|
115
|
+
"Paddle's own id for this event — `evt_…`. The dedup key, and the one the webhook path and the events sweep share, because the stream carries no notification_id.",
|
|
116
|
+
),
|
|
117
|
+
event_type: z.string().min(1).describe("Which event this is — `transaction.completed`, `subscription.canceled`."),
|
|
118
|
+
occurred_at: z
|
|
119
|
+
.string()
|
|
120
|
+
.min(1)
|
|
121
|
+
.describe(
|
|
122
|
+
"When the event happened, from the envelope. This is `providerEventAt`, never the entity's `updated_at`: a transaction's clock lives in the transaction's domain, and stamping it onto a subscription's watermark discards the `subscription.activated` that follows.",
|
|
123
|
+
),
|
|
124
|
+
data: z.record(z.string(), z.unknown()).describe("The entity the event is about."),
|
|
125
|
+
})
|
|
126
|
+
.loose()
|
|
127
|
+
.describe("One Paddle event, as a webhook delivers it and as the events stream returns it.");
|
|
128
|
+
export type PaddleEvent = z.infer<typeof PaddleEvent>;
|
|
129
|
+
|
|
130
|
+
/** One transaction line item, narrowed to the price it names. */
|
|
131
|
+
const PaddleTransactionItem = z
|
|
132
|
+
.object({
|
|
133
|
+
price: z
|
|
134
|
+
.object({ id: z.string().optional().describe("The price sold — this rail's SKU.") })
|
|
135
|
+
.loose()
|
|
136
|
+
.optional()
|
|
137
|
+
.describe("The price object, which is where a transaction carries its SKU."),
|
|
138
|
+
price_id: z.string().optional().describe("The price id, on the shapes that carry it flat."),
|
|
139
|
+
})
|
|
140
|
+
.loose()
|
|
141
|
+
.describe("One line of a transaction. Single-product checkouts have exactly one.");
|
|
142
|
+
|
|
143
|
+
/** An adjustment's fields — a refund, a credit, or a chargeback, which on this rail can arrive unsolicited. */
|
|
144
|
+
export const PaddleAdjustment = z
|
|
145
|
+
.object({
|
|
146
|
+
id: z.string().min(1).describe("The adjustment — `adj_…`."),
|
|
147
|
+
action: z
|
|
148
|
+
.string()
|
|
149
|
+
.min(1)
|
|
150
|
+
.describe(
|
|
151
|
+
"What it does — `refund`, `credit`, `chargeback`, `chargeback_reverse`, `chargeback_warning`, `chargeback_warning_reverse`, `credit_reverse`.",
|
|
152
|
+
),
|
|
153
|
+
status: z.string().nullish().describe("`pending_approval`, `approved`, `rejected`, or `reversed`."),
|
|
154
|
+
transaction_id: z.string().min(1).describe("The transaction adjusted. The only key that names a row of ours."),
|
|
155
|
+
subscription_id: z.string().nullish().describe("The subscription behind it, when there is one."),
|
|
156
|
+
customer_id: z.string().nullish().describe("The customer adjusted."),
|
|
157
|
+
currency_code: z.string().nullish().describe("The currency the totals are in."),
|
|
158
|
+
totals: z
|
|
159
|
+
.object({
|
|
160
|
+
total: z.string().nullish().describe("How much was adjusted, in the currency's lowest denomination."),
|
|
161
|
+
})
|
|
162
|
+
.loose()
|
|
163
|
+
.nullish()
|
|
164
|
+
.describe(
|
|
165
|
+
"What the adjustment came to. Summed with every other approved adjustment against the transaction, and that sum is what tells a full refund from a partial one.",
|
|
166
|
+
),
|
|
167
|
+
created_at: z.string().nullish().describe("When Paddle raised the adjustment."),
|
|
168
|
+
updated_at: z.string().nullish().describe("The adjustment's own clock. Deliberately not used as a watermark."),
|
|
169
|
+
})
|
|
170
|
+
.loose()
|
|
171
|
+
.describe("A Paddle adjustment. As merchant of record Paddle issues these on its own, with no local write first.");
|
|
172
|
+
export type PaddleAdjustment = z.infer<typeof PaddleAdjustment>;
|
|
173
|
+
|
|
174
|
+
/** A transaction's fields, as an event and the API both present them. */
|
|
175
|
+
export const PaddleTransaction = z
|
|
176
|
+
.object({
|
|
177
|
+
id: z.string().min(1).describe("The transaction — `txn_…`. This row's identity forever."),
|
|
178
|
+
status: z.string().min(1).describe("Paddle's own status vocabulary, normalized by `transactionStatus`."),
|
|
179
|
+
customer_id: z.string().nullish().describe("The Paddle customer charged — this rail's `providerAccountId`."),
|
|
180
|
+
subscription_id: z
|
|
181
|
+
.string()
|
|
182
|
+
.nullish()
|
|
183
|
+
.describe("The subscription this transaction bills, or null for a one-off. The family key."),
|
|
184
|
+
discount_id: z.string().nullish().describe("The discount applied, when one was."),
|
|
185
|
+
origin: z
|
|
186
|
+
.string()
|
|
187
|
+
.nullish()
|
|
188
|
+
.describe(
|
|
189
|
+
"How the transaction came to exist — `web`, `subscription_recurring`, `api`. Recorded rather than acted on: it is undocumented enough that a fence on it would be a guess.",
|
|
190
|
+
),
|
|
191
|
+
items: z.array(PaddleTransactionItem).optional().describe("The lines sold. The first carries this rail's SKU."),
|
|
192
|
+
details: z
|
|
193
|
+
.object({
|
|
194
|
+
totals: z
|
|
195
|
+
.object({
|
|
196
|
+
grand_total: z
|
|
197
|
+
.string()
|
|
198
|
+
.optional()
|
|
199
|
+
.describe("What was charged, in the currency's lowest denomination, as a string."),
|
|
200
|
+
currency_code: z.string().optional().describe("The currency, uppercase ISO 4217."),
|
|
201
|
+
})
|
|
202
|
+
.loose()
|
|
203
|
+
.optional()
|
|
204
|
+
.describe("The money, as Paddle computed it."),
|
|
205
|
+
})
|
|
206
|
+
.loose()
|
|
207
|
+
.optional()
|
|
208
|
+
.describe("The computed totals. Paddle is the authority on every figure here; nothing recomputes one."),
|
|
209
|
+
billing_period: z
|
|
210
|
+
.object({
|
|
211
|
+
starts_at: z.string().nullish().describe("When the period this transaction bills began."),
|
|
212
|
+
ends_at: z.string().nullish().describe("When it ends."),
|
|
213
|
+
})
|
|
214
|
+
.loose()
|
|
215
|
+
.nullish()
|
|
216
|
+
.describe("The billing period a subscription transaction covers. Absent on a one-off."),
|
|
217
|
+
currency_code: z.string().nullish().describe("The transaction's currency, when it carries one flat."),
|
|
218
|
+
custom_data: CustomData.nullish().describe("What this deployment's server stamped at checkout, echoed back."),
|
|
219
|
+
checkout: z
|
|
220
|
+
.object({ url: z.string().nullish().describe("The hosted checkout page, when the account has a payment link.") })
|
|
221
|
+
.loose()
|
|
222
|
+
.nullish()
|
|
223
|
+
.describe("Where a hosted checkout lives. Null unless the account has a default payment link set."),
|
|
224
|
+
adjustments: z
|
|
225
|
+
.array(PaddleAdjustment)
|
|
226
|
+
.optional()
|
|
227
|
+
.describe(
|
|
228
|
+
"Every adjustment raised against this transaction, returned when the read asks `include=adjustments`. The whole reason it is asked for: a full refund delivered as two partial adjustments is only full when they are summed.",
|
|
229
|
+
),
|
|
230
|
+
created_at: z.string().min(1).describe("When Paddle recorded the transaction."),
|
|
231
|
+
updated_at: z.string().nullish().describe("The transaction's own clock. Deliberately not used as a watermark."),
|
|
232
|
+
})
|
|
233
|
+
.loose()
|
|
234
|
+
.describe("A Paddle transaction — one billing period's charge, or a one-off purchase.");
|
|
235
|
+
export type PaddleTransaction = z.infer<typeof PaddleTransaction>;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A change Paddle will apply later, exactly as it sits on a subscription.
|
|
239
|
+
*
|
|
240
|
+
* Lifted out of {@link PaddleSubscription}, where it was inline, because two readers now ask it questions
|
|
241
|
+
* and a second inline copy is how the two field lists drift apart: {@link subscriptionResumesAt} asks when
|
|
242
|
+
* a paused subscription comes back, and {@link subscriptionPendingChange} asks what is pending at all. One
|
|
243
|
+
* declaration, one parse, both readers.
|
|
244
|
+
*
|
|
245
|
+
* **`items` is declared because Paddle sends it.** It arrived `null` on every change recorded so far —
|
|
246
|
+
* the pause and resume of 2026-08-15, the cancel of 2026-08-28 — and nothing reads it. Declaring a field
|
|
247
|
+
* the payload carries costs a line; discovering it later inside a `.loose()` passthrough that nobody wrote
|
|
248
|
+
* down costs an afternoon.
|
|
249
|
+
*/
|
|
250
|
+
export const PaddleScheduledChange = z
|
|
251
|
+
.object({
|
|
252
|
+
action: z.string().optional().describe("`cancel`, `pause`, or `resume`."),
|
|
253
|
+
effective_at: z
|
|
254
|
+
.string()
|
|
255
|
+
.nullish()
|
|
256
|
+
.describe(
|
|
257
|
+
"When it takes effect. On a `resume` action against a paused subscription this is the resume date itself — verified live, see `subscriptionResumesAt`. On a `cancel` it is when access ends, and it is the only place that date exists: `next_billed_at` is null on a subscription with a cancellation scheduled, recorded 2026-08-28.",
|
|
258
|
+
),
|
|
259
|
+
resume_at: z
|
|
260
|
+
.string()
|
|
261
|
+
.nullish()
|
|
262
|
+
.describe(
|
|
263
|
+
"When a paused subscription should resume. Paddle documents it as set only on a `pause` action, and a subscription carrying one is still `active` — so it is read for completeness rather than as the field a paused row is filled from.",
|
|
264
|
+
),
|
|
265
|
+
items: z
|
|
266
|
+
.array(z.unknown())
|
|
267
|
+
.nullish()
|
|
268
|
+
.describe("The items the change applies to. Null on every change recorded so far; carried, not read."),
|
|
269
|
+
})
|
|
270
|
+
.loose()
|
|
271
|
+
.describe(
|
|
272
|
+
"A change Paddle will apply later — the shape both readers of `scheduled_change` parse through, so neither can drift from the other.",
|
|
273
|
+
);
|
|
274
|
+
export type PaddleScheduledChange = z.infer<typeof PaddleScheduledChange>;
|
|
275
|
+
|
|
276
|
+
/** A subscription's fields, as an event and the API both present them. */
|
|
277
|
+
export const PaddleSubscription = z
|
|
278
|
+
.object({
|
|
279
|
+
id: z.string().min(1).describe("The subscription — `sub_…`. The state row's identity, and the family key."),
|
|
280
|
+
status: z.string().min(1).describe("Paddle's own status vocabulary, normalized by `subscriptionStatus`."),
|
|
281
|
+
customer_id: z.string().nullish().describe("The Paddle customer — this rail's `providerAccountId`."),
|
|
282
|
+
items: z
|
|
283
|
+
.array(
|
|
284
|
+
z
|
|
285
|
+
.object({
|
|
286
|
+
price: z
|
|
287
|
+
.object({ id: z.string().optional().describe("The price — `pri_…`. This rail's SKU.") })
|
|
288
|
+
.loose()
|
|
289
|
+
.optional()
|
|
290
|
+
.describe("The price this item is billed at."),
|
|
291
|
+
status: z.string().optional().describe("The item's own status, which can differ from the subscription's."),
|
|
292
|
+
})
|
|
293
|
+
.loose()
|
|
294
|
+
.describe("One priced item on the subscription."),
|
|
295
|
+
)
|
|
296
|
+
.optional()
|
|
297
|
+
.describe("The priced items. The first carries this rail's SKU."),
|
|
298
|
+
current_billing_period: z
|
|
299
|
+
.object({
|
|
300
|
+
starts_at: z.string().nullish().describe("When the current period began."),
|
|
301
|
+
ends_at: z.string().nullish().describe("When it ends — what a cancellation's `expiresAt` becomes."),
|
|
302
|
+
})
|
|
303
|
+
.loose()
|
|
304
|
+
.nullish()
|
|
305
|
+
.describe("The period currently paid for. Null while a subscription is trialing or paused."),
|
|
306
|
+
scheduled_change: PaddleScheduledChange.nullish().describe(
|
|
307
|
+
"A change Paddle will apply later. A subscription with a scheduled cancel is still `active` today — the distinction between `canceled` and a scheduled change is the whole difference between access now and access until the period ends.",
|
|
308
|
+
),
|
|
309
|
+
trial_dates: z
|
|
310
|
+
.object({
|
|
311
|
+
starts_at: z.string().nullish().describe("When the trial began."),
|
|
312
|
+
ends_at: z.string().nullish().describe("When the trial ends — a trialing subscription's `expiresAt`."),
|
|
313
|
+
})
|
|
314
|
+
.loose()
|
|
315
|
+
.nullish()
|
|
316
|
+
.describe("The trial window, when this subscription has one. Its end is a trialing subscription's expiry."),
|
|
317
|
+
next_billed_at: z.string().nullish().describe("When the next charge falls due, when one is going to."),
|
|
318
|
+
canceled_at: z.string().nullish().describe("When it was canceled, if it was."),
|
|
319
|
+
paused_at: z.string().nullish().describe("When it was paused, if it was."),
|
|
320
|
+
discount: z
|
|
321
|
+
.object({
|
|
322
|
+
id: z.string().optional().describe("The discount in force."),
|
|
323
|
+
starts_at: z.string().nullish().describe("When the discount started applying."),
|
|
324
|
+
ends_at: z.string().nullish().describe("When the discount stops applying, or null for one that runs forever."),
|
|
325
|
+
})
|
|
326
|
+
.loose()
|
|
327
|
+
.nullish()
|
|
328
|
+
.describe(
|
|
329
|
+
"The discount in force, when one is. Carries an id and no code, so a screen names the date rather than the code.",
|
|
330
|
+
),
|
|
331
|
+
custom_data: CustomData.nullish().describe(
|
|
332
|
+
"What Paddle copied from the transaction that created this subscription. The stamp, on every later event.",
|
|
333
|
+
),
|
|
334
|
+
created_at: z.string().min(1).describe("When the subscription began."),
|
|
335
|
+
updated_at: z.string().nullish().describe("The subscription's own clock. Deliberately not used as a watermark."),
|
|
336
|
+
})
|
|
337
|
+
.loose()
|
|
338
|
+
.describe("A Paddle subscription — the standing of a recurring purchase, carrying no charge.");
|
|
339
|
+
export type PaddleSubscription = z.infer<typeof PaddleSubscription>;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* A money figure Paddle quotes, as it sends one: a **string**, in the currency's lowest denomination, and
|
|
343
|
+
* **signed**.
|
|
344
|
+
*
|
|
345
|
+
* The sign is the part a schema gets wrong, and it gets it wrong in the direction that throws in front of a
|
|
346
|
+
* customer. Recorded against the sandbox on 2026-08-28: previewing an upgrade returns
|
|
347
|
+
* `credit: { amount: "-380" }`, and previewing a downgrade returns a whole totals block below zero. A
|
|
348
|
+
* `.nonnegative()` anywhere on this path would refuse every real plan change while reading, in the source,
|
|
349
|
+
* like ordinary care. {@link minorAmount} already accepts a leading `-`; nothing else here needs to know.
|
|
350
|
+
*
|
|
351
|
+
* `amount` is required where `PaddleTransaction`'s totals are optional, and the difference is deliberate: a
|
|
352
|
+
* transaction's totals are a block this package reads one figure out of, while a quoted money object with no
|
|
353
|
+
* amount is not a partial reading — it is a shape change, and it should be loud.
|
|
354
|
+
*/
|
|
355
|
+
const PaddleMoney = z
|
|
356
|
+
.object({
|
|
357
|
+
amount: z
|
|
358
|
+
.string()
|
|
359
|
+
.describe(
|
|
360
|
+
"How much, as an integer string in the currency's lowest denomination, signed. `-380` is a credit of $3.80.",
|
|
361
|
+
),
|
|
362
|
+
currency_code: z.string().nullish().describe("The currency, uppercase ISO 4217, as Paddle sends it."),
|
|
363
|
+
})
|
|
364
|
+
.loose()
|
|
365
|
+
.describe("One money figure Paddle quoted. Never recomputed here — Paddle is the authority on every amount.");
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* One block of Paddle's computed totals, on a preview.
|
|
369
|
+
*
|
|
370
|
+
* **`grand_total` is not the headline, and on a downgrade it is actively misleading.** Recorded 2026-08-28:
|
|
371
|
+
* previewing Team → Solo answers `grand_total: "0"` while the customer is owed 6581, which is sitting in
|
|
372
|
+
* `credit_to_balance`. A screen wired to the totals says "You will be charged $0.00" — true, and it never
|
|
373
|
+
* mentions the money. What renders is `update_summary.result`; see {@link PaddleUpdateSummary}.
|
|
374
|
+
*
|
|
375
|
+
* Every field is optional because Paddle's blocks differ by mode and by direction, and every one may be
|
|
376
|
+
* negative because a downgrade's are: `subtotal: "-6045"`, `tax: "-536"`, `total: "-6581"`. `fee` and
|
|
377
|
+
* `earnings` arrive **null** on a preview — the money has not moved, so there is nothing to have earned —
|
|
378
|
+
* and they are declared rather than left to `.loose()` so a later reader finds null in the type instead of
|
|
379
|
+
* inferring absence.
|
|
380
|
+
*
|
|
381
|
+
* **Deliberately not shared with {@link PaddleTransaction}'s own `details.totals`.** Unifying them would
|
|
382
|
+
* widen that shape's public type — `string | undefined` becomes `string | null | undefined` on fields four
|
|
383
|
+
* modules already read — in the same commit that adds a preview, which is precisely the regression
|
|
384
|
+
* `objects.test.ts` guards against. The *treatment* is identical and that is what matters here: strings,
|
|
385
|
+
* unscaled, `.loose()`, no constraint on sign. One type in both places is the job of the step that needs
|
|
386
|
+
* one type in both places.
|
|
387
|
+
*/
|
|
388
|
+
export const PaddleTotals = z
|
|
389
|
+
.object({
|
|
390
|
+
subtotal: z.string().nullish().describe("Before tax. Negative when the change is a credit."),
|
|
391
|
+
tax: z.string().nullish().describe("The tax on it, which is negative alongside a negative subtotal."),
|
|
392
|
+
discount: z.string().nullish().describe("What a discount took off, when one applies."),
|
|
393
|
+
total: z.string().nullish().describe("Subtotal plus tax, still signed."),
|
|
394
|
+
grand_total: z
|
|
395
|
+
.string()
|
|
396
|
+
.nullish()
|
|
397
|
+
.describe(
|
|
398
|
+
'What Paddle will actually take today — and `"0"` on a downgrade, where the money owed is in `credit_to_balance` instead. Not the figure a screen quotes.',
|
|
399
|
+
),
|
|
400
|
+
grand_total_tax: z.string().nullish().describe("The tax within the grand total."),
|
|
401
|
+
credit: z.string().nullish().describe("Credit consumed by this transaction."),
|
|
402
|
+
credit_to_balance: z
|
|
403
|
+
.string()
|
|
404
|
+
.nullish()
|
|
405
|
+
.describe(
|
|
406
|
+
"What lands on the customer's balance rather than on a card — where a downgrade's whole refund sits while `grand_total` reads zero.",
|
|
407
|
+
),
|
|
408
|
+
balance: z.string().nullish().describe("What is left outstanding after credit is applied."),
|
|
409
|
+
fee: z.string().nullish().describe("Paddle's fee. Null on a preview: nothing has been charged to take one from."),
|
|
410
|
+
earnings: z.string().nullish().describe("The seller's share. Null on a preview, for the same reason."),
|
|
411
|
+
currency_code: z.string().nullish().describe("The currency every figure in this block is in."),
|
|
412
|
+
exchange_rate: z
|
|
413
|
+
.string()
|
|
414
|
+
.nullish()
|
|
415
|
+
.describe('The rate Paddle used, `"1"` when the currency is the account\'s own. Recorded, never applied here.'),
|
|
416
|
+
})
|
|
417
|
+
.loose()
|
|
418
|
+
.describe("One block of totals as Paddle computed it. Strings, signed, in the currency's lowest denomination.");
|
|
419
|
+
export type PaddleTotals = z.infer<typeof PaddleTotals>;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* What the change comes to, and **the only part of a preview a screen should quote**.
|
|
423
|
+
*
|
|
424
|
+
* `credit` is the unused remainder of the old plan and arrives negative; `charge` is what the new plan costs
|
|
425
|
+
* for the rest of the period; `result` is Paddle's own reconciliation of the two, and it carries the word as
|
|
426
|
+
* well as the number — `{ action: "charge", amount: "6582" }` on the recorded upgrade,
|
|
427
|
+
* `{ action: "credit", amount: "6581" }` on the recorded downgrade.
|
|
428
|
+
*
|
|
429
|
+
* The alternative — reading `immediate_transaction.details.totals.grand_total` — is right on an upgrade and
|
|
430
|
+
* silently wrong on a downgrade, where it is `"0"`. That is why `result` is required here while the two
|
|
431
|
+
* halves it reconciles are not: a summary with no result is a summary nothing can be said from.
|
|
432
|
+
*
|
|
433
|
+
* `action` stays a plain string rather than an enum for the reason `status` does: an unknown value is
|
|
434
|
+
* refused by the reader that has to act on it, with a message naming what arrived, rather than by a parse
|
|
435
|
+
* failure that discards the rest of a valid response.
|
|
436
|
+
*/
|
|
437
|
+
export const PaddleUpdateSummary = z
|
|
438
|
+
.object({
|
|
439
|
+
credit: PaddleMoney.nullish().describe(
|
|
440
|
+
"What the unused part of the current plan is worth back. Negative — it is money coming off.",
|
|
441
|
+
),
|
|
442
|
+
charge: PaddleMoney.nullish().describe("What the new plan costs for the remainder of the period, before credit."),
|
|
443
|
+
result: PaddleMoney.extend({
|
|
444
|
+
action: z.string().describe("What happens on balance — `charge` or `credit`. The verb a screen uses."),
|
|
445
|
+
}).describe("The reconciliation of credit against charge: what happens, and how much. The renderable headline."),
|
|
446
|
+
})
|
|
447
|
+
.loose()
|
|
448
|
+
.describe("Paddle's own summary of what a plan change costs. The one place a downgrade's credit is stated plainly.");
|
|
449
|
+
export type PaddleUpdateSummary = z.infer<typeof PaddleUpdateSummary>;
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* A preview of a subscription change — what Paddle answers before anything is committed.
|
|
453
|
+
*
|
|
454
|
+
* **`immediate_transaction` is nullable, and the adopter's own downgrade policy is the case that makes it
|
|
455
|
+
* null.** Under `proration_billing_mode: "prorated_next_billing_period"` nothing settles today, so there is
|
|
456
|
+
* no transaction to describe and Paddle sends none; the whole answer is `update_summary` plus the recurring
|
|
457
|
+
* block. A shape requiring it would fail on every downgrade this package is designed to perform.
|
|
458
|
+
*
|
|
459
|
+
* **Only the preview's own keys are declared.** The response is a subscription entity *with* these three
|
|
460
|
+
* fields on it — the recordings are excerpts of exactly those fields, and this shape claims no more than was
|
|
461
|
+
* measured. `.loose()` carries the rest through, and a caller wanting the entity parses the same body a
|
|
462
|
+
* second time through {@link PaddleSubscription}, which is the shape that knows what an entity requires.
|
|
463
|
+
*/
|
|
464
|
+
export const PaddleSubscriptionPreview = z
|
|
465
|
+
.object({
|
|
466
|
+
update_summary: PaddleUpdateSummary.nullish().describe(
|
|
467
|
+
"What the change costs, reconciled. The headline, and the only figure a downgrade states honestly.",
|
|
468
|
+
),
|
|
469
|
+
immediate_transaction: z
|
|
470
|
+
.object({
|
|
471
|
+
details: z
|
|
472
|
+
.object({ totals: PaddleTotals.nullish().describe("What today's charge comes to, if there is one.") })
|
|
473
|
+
.loose()
|
|
474
|
+
.nullish()
|
|
475
|
+
.describe("The computed detail of the transaction that would settle now."),
|
|
476
|
+
billing_period: z
|
|
477
|
+
.object({
|
|
478
|
+
starts_at: z.string().nullish().describe("When the prorated period being charged for begins — now."),
|
|
479
|
+
ends_at: z.string().nullish().describe("When it ends, which is where the existing paid period ends."),
|
|
480
|
+
})
|
|
481
|
+
.loose()
|
|
482
|
+
.nullish()
|
|
483
|
+
.describe("The period today's charge covers. The remainder of the period already running."),
|
|
484
|
+
})
|
|
485
|
+
.loose()
|
|
486
|
+
.nullish()
|
|
487
|
+
.describe(
|
|
488
|
+
"The transaction that would be raised immediately — **null whenever nothing settles today**, which is every change made under `prorated_next_billing_period`.",
|
|
489
|
+
),
|
|
490
|
+
recurring_transaction_details: z
|
|
491
|
+
.object({ totals: PaddleTotals.nullish().describe("What each subsequent period will come to.") })
|
|
492
|
+
.loose()
|
|
493
|
+
.nullish()
|
|
494
|
+
.describe("What the subscription costs from the next renewal onward — the 'then' half of a quote."),
|
|
495
|
+
})
|
|
496
|
+
.loose()
|
|
497
|
+
.describe(
|
|
498
|
+
"Paddle's preview of a subscription change: what settles now, what recurs after, and what the change comes to on balance.",
|
|
499
|
+
);
|
|
500
|
+
export type PaddleSubscriptionPreview = z.infer<typeof PaddleSubscriptionPreview>;
|
|
501
|
+
|
|
502
|
+
/** A Paddle timestamp, or a refusal. Its API emits RFC-3339 throughout. */
|
|
503
|
+
export function at(value: string, what: string): Date {
|
|
504
|
+
const parsed = new Date(value);
|
|
505
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
506
|
+
throw new PaymentsVerificationFailedError({ detail: `Paddle: ${what} is not a readable timestamp.` });
|
|
507
|
+
}
|
|
508
|
+
return parsed;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** The same, tolerating an absent value. */
|
|
512
|
+
function maybeAt(value: string | null | undefined, what: string): Date | null {
|
|
513
|
+
return value === null || value === undefined || value === "" ? null : at(value, what);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* A subscription's status, normalized.
|
|
518
|
+
*
|
|
519
|
+
* `trialing` grants, with `expiresAt` at the trial's end. That hands an entitlement to somebody who has
|
|
520
|
+
* paid nothing, which is what a trial *is* — and it is bounded, because the trial's end date is on the
|
|
521
|
+
* row. The issue asked for this to be an explicit decision rather than a table row, so: it is deliberate,
|
|
522
|
+
* and the bound is what makes it safe.
|
|
523
|
+
*
|
|
524
|
+
* An unknown status refuses rather than guessing, which is every other rail's rule too: a status this
|
|
525
|
+
* package has never seen is a shape change worth failing loudly on.
|
|
526
|
+
*/
|
|
527
|
+
export function subscriptionStatus(status: string): PurchaseStatus {
|
|
528
|
+
switch (status) {
|
|
529
|
+
case "active":
|
|
530
|
+
case "trialing":
|
|
531
|
+
return "active";
|
|
532
|
+
case "past_due":
|
|
533
|
+
return "in_grace";
|
|
534
|
+
case "paused":
|
|
535
|
+
return "paused";
|
|
536
|
+
case "canceled":
|
|
537
|
+
return "canceled";
|
|
538
|
+
default:
|
|
539
|
+
throw new PaymentsVerificationFailedError({
|
|
540
|
+
detail: `Paddle: subscription status "${status}" is not one this build maps.`,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* A transaction's status, normalized — and it depends on whether the transaction bills a subscription.
|
|
547
|
+
*
|
|
548
|
+
* **A one-off is money and state at once**, so a completed one is `active` with no expiry: that is a
|
|
549
|
+
* non-consumable bought and owned.
|
|
550
|
+
*
|
|
551
|
+
* **A subscription transaction is one closed billing period**, so a completed one is `expired`. It
|
|
552
|
+
* credits a `grants` clause — which is what makes N renewals credit exactly N times — and grants no
|
|
553
|
+
* access, because access is the state row's job. See the module doc for why `active` here would outlive
|
|
554
|
+
* a cancellation.
|
|
555
|
+
*/
|
|
556
|
+
export function transactionStatus(status: string, subscription: boolean): PurchaseStatus {
|
|
557
|
+
switch (status) {
|
|
558
|
+
case "paid":
|
|
559
|
+
case "completed":
|
|
560
|
+
return subscription ? "expired" : "active";
|
|
561
|
+
case "past_due":
|
|
562
|
+
return "in_grace";
|
|
563
|
+
case "canceled":
|
|
564
|
+
// Terminated before anything cleared, so it credits nothing and no clawback follows one.
|
|
565
|
+
return "never_paid";
|
|
566
|
+
case "billed":
|
|
567
|
+
// An invoice raised under manual collection, which this rail does not sell through. The money is
|
|
568
|
+
// outstanding, so it neither grants nor credits.
|
|
569
|
+
return "on_hold";
|
|
570
|
+
case "draft":
|
|
571
|
+
case "ready":
|
|
572
|
+
return "on_hold";
|
|
573
|
+
default:
|
|
574
|
+
throw new PaymentsVerificationFailedError({
|
|
575
|
+
detail: `Paddle: transaction status "${status}" is not one this build maps.`,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The price id a transaction sold. Paddle carries it under `items[].price.id`; some shapes flatten it. */
|
|
581
|
+
export function transactionPriceId(transaction: PaddleTransaction): string {
|
|
582
|
+
const first = transaction.items?.[0];
|
|
583
|
+
return first?.price?.id ?? first?.price_id ?? "";
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The price id a subscription is for. */
|
|
587
|
+
export function subscriptionPriceId(subscription: PaddleSubscription): string {
|
|
588
|
+
return subscription.items?.[0]?.price?.id ?? "";
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* An amount Paddle reported, as an integer in the currency's lowest denomination.
|
|
593
|
+
*
|
|
594
|
+
* Paddle sends amounts as **strings** and always in the lowest denomination, including for the
|
|
595
|
+
* zero-decimal currencies — ¥725 arrives as `"725"`, not `"72500"`, confirmed live. So this parses and
|
|
596
|
+
* never scales: a multiplication here would be this package computing money, which it does not do.
|
|
597
|
+
*
|
|
598
|
+
* Anything that is not a plain integer string is `null` rather than a guess. `Number.parseInt` on
|
|
599
|
+
* `"0.105"` answers `0`, which is a wrong amount rather than a missing one.
|
|
600
|
+
*/
|
|
601
|
+
export function minorAmount(value: string | null | undefined): number | null {
|
|
602
|
+
if (typeof value !== "string" || !/^-?\d+$/.test(value)) return null;
|
|
603
|
+
const parsed = Number(value);
|
|
604
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** A currency as this package stores it. Paddle sends uppercase; every other rail here is lowercase. */
|
|
608
|
+
export function currencyOf(value: string | null | undefined): string | null {
|
|
609
|
+
return typeof value === "string" && value !== "" ? value.toLowerCase() : null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* The money row a transaction implies.
|
|
614
|
+
*
|
|
615
|
+
* `providerEventAt` comes from the caller — the **envelope's** `occurred_at`, never `updated_at` — for
|
|
616
|
+
* the reason the envelope schema states at the field.
|
|
617
|
+
*/
|
|
618
|
+
export function transactionEvent(
|
|
619
|
+
transaction: PaddleTransaction,
|
|
620
|
+
occurredAt: Date,
|
|
621
|
+
environment: PurchaseEnvironment,
|
|
622
|
+
): UnboundProviderEvent {
|
|
623
|
+
const subscriptionId = transaction.subscription_id ?? null;
|
|
624
|
+
const status = transactionStatus(transaction.status, subscriptionId !== null);
|
|
625
|
+
const purchased = at(transaction.created_at, "a transaction's created_at");
|
|
626
|
+
return {
|
|
627
|
+
rail: "paddle",
|
|
628
|
+
providerTransactionId: transaction.id,
|
|
629
|
+
// The subscription is the family. Two renewals are two transactions chaining back to one `sub_…`,
|
|
630
|
+
// which is what makes a `grants` clause credit twice.
|
|
631
|
+
originalTransactionId: subscriptionId,
|
|
632
|
+
providerProductId: transactionPriceId(transaction),
|
|
633
|
+
role: "charge",
|
|
634
|
+
status,
|
|
635
|
+
environment,
|
|
636
|
+
purchasedAt: purchased,
|
|
637
|
+
// A subscription's money row is an already-closed window rather than null: a money row must never be
|
|
638
|
+
// the reason access is granted — that is the state row's job — and a past expiry says exactly that.
|
|
639
|
+
// A one-off has no expiry, because owning it is the entitlement.
|
|
640
|
+
expiresAt: subscriptionId === null ? null : purchased,
|
|
641
|
+
revokedAt: null,
|
|
642
|
+
amountMinor: minorAmount(transaction.details?.totals?.grand_total),
|
|
643
|
+
currency: currencyOf(transaction.details?.totals?.currency_code ?? transaction.currency_code),
|
|
644
|
+
providerEventAt: occurredAt,
|
|
645
|
+
payload: { ...transaction },
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* What Paddle says a paused subscription resumes at, verbatim — or nothing, for an open-ended pause.
|
|
651
|
+
*
|
|
652
|
+
* **The date is not in the field named after it**, which is the trap this function exists to hold. Recorded
|
|
653
|
+
* against a live sandbox on 2026-08-15: pausing immediately with `resume_at: "2026-10-01T00:00:00Z"` leaves
|
|
654
|
+
* `scheduled_change: { action: "resume", effective_at: "2026-10-01T00:00:00Z", resume_at: null }`. Paddle
|
|
655
|
+
* turns the request's `resume_at` into a scheduled *resume* whose `effective_at` is the date, and blanks
|
|
656
|
+
* `resume_at` — that field is populated only while a `pause` is scheduled and the subscription is still
|
|
657
|
+
* `active`, which is not a paused subscription. Pausing with no resume date leaves `scheduled_change: null`.
|
|
658
|
+
*
|
|
659
|
+
* So both spellings are read and the one that means resumption wins. `effective_at` is taken only from a
|
|
660
|
+
* `resume` action: on a `cancel` it is when access ends, and on a `pause` it is when the pause begins —
|
|
661
|
+
* writing either into a resume date would be a wrong date in front of a paying customer.
|
|
662
|
+
*/
|
|
663
|
+
export function subscriptionResumesAt(subscription: PaddleSubscription): string | null {
|
|
664
|
+
const change = subscription.scheduled_change;
|
|
665
|
+
if (change === null || change === undefined) return null;
|
|
666
|
+
if (typeof change.resume_at === "string" && change.resume_at !== "") return change.resume_at;
|
|
667
|
+
return change.action === "resume" ? (change.effective_at ?? null) : null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** What Paddle is going to do to a subscription later, normalized — the three actions and their dates. */
|
|
671
|
+
export interface PaddlePendingChange {
|
|
672
|
+
/** Which of Paddle's three scheduled actions is pending. Narrowed here, so a reader switches exhaustively. */
|
|
673
|
+
action: "cancel" | "pause" | "resume";
|
|
674
|
+
/** When it happens, verbatim as Paddle sent it. A string, converted at the site that needs a `Date`. */
|
|
675
|
+
effectiveAt: string | null;
|
|
676
|
+
/** When a scheduled pause is due to end, on the one action that carries it. Null everywhere else. */
|
|
677
|
+
resumeAt: string | null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* What is pending on a subscription, or nothing.
|
|
682
|
+
*
|
|
683
|
+
* **The field a screen reaches for first is empty exactly when this one fills.** Recorded against the
|
|
684
|
+
* sandbox on 2026-08-28: `cancel({ effective_from: "next_billing_period" })` leaves `status: "active"`,
|
|
685
|
+
* `canceled_at: null` and — the part that surprises — **`next_billed_at: null`**, with the date living only
|
|
686
|
+
* on `scheduled_change.effective_at`. So "Renews on {next_billed_at}" goes blank on precisely the
|
|
687
|
+
* subscription whose end date the customer most needs to see, while the row still says `active`.
|
|
688
|
+
* `update(subscription, { scheduled_change: null })` withdraws the cancellation and puts the date back.
|
|
689
|
+
*
|
|
690
|
+
* An action this build does not map refuses rather than being read as one of the three. That is
|
|
691
|
+
* {@link subscriptionStatus}'s rule and it is the same argument: a new Paddle action quietly normalized into
|
|
692
|
+
* `cancel` is a wrong date, or a wrong sentence, in front of a paying customer — and a `scheduled_change`
|
|
693
|
+
* with no action at all is a shape change, not an absence, because Paddle sends the object or sends null.
|
|
694
|
+
*
|
|
695
|
+
* **This deliberately does not change what {@link subscriptionResumesAt} answers.** That function reads
|
|
696
|
+
* `effective_at` only on a `resume`, which is why a scheduled *cancel* is invisible to the projection today.
|
|
697
|
+
* The two now parse one declared shape — {@link PaddleScheduledChange} — so whichever step decides what the
|
|
698
|
+
* projection should carry about a pending cancellation reconciles them in one place, with both readings in
|
|
699
|
+
* front of it.
|
|
700
|
+
*/
|
|
701
|
+
export function subscriptionPendingChange(subscription: PaddleSubscription): PaddlePendingChange | null {
|
|
702
|
+
const change = subscription.scheduled_change;
|
|
703
|
+
if (change === null || change === undefined) return null;
|
|
704
|
+
|
|
705
|
+
const action = change.action;
|
|
706
|
+
if (action !== "cancel" && action !== "pause" && action !== "resume") {
|
|
707
|
+
throw new PaymentsVerificationFailedError({
|
|
708
|
+
detail: `Paddle: scheduled change action "${action ?? ""}" is not one this build maps.`,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
return { action, effectiveAt: change.effective_at ?? null, resumeAt: change.resume_at ?? null };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* The state row a subscription implies. Carries no amount — a subscription is not a charge.
|
|
716
|
+
*
|
|
717
|
+
* `expiresAt` is the end of what has been paid for, and which field that is depends on the standing: a
|
|
718
|
+
* trial ends at its own date, everything else at the current billing period's end. A cancellation keeps
|
|
719
|
+
* the period already bought rather than taking it away, which is what `canceled` means in this package's
|
|
720
|
+
* status set.
|
|
721
|
+
*/
|
|
722
|
+
export function subscriptionEvent(
|
|
723
|
+
subscription: PaddleSubscription,
|
|
724
|
+
occurredAt: Date,
|
|
725
|
+
environment: PurchaseEnvironment,
|
|
726
|
+
): UnboundProviderEvent {
|
|
727
|
+
const status = subscriptionStatus(subscription.status);
|
|
728
|
+
const ends =
|
|
729
|
+
subscription.status === "trialing"
|
|
730
|
+
? (subscription.trial_dates?.ends_at ?? subscription.current_billing_period?.ends_at ?? null)
|
|
731
|
+
: (subscription.current_billing_period?.ends_at ?? subscription.next_billed_at ?? null);
|
|
732
|
+
return {
|
|
733
|
+
rail: "paddle",
|
|
734
|
+
providerTransactionId: subscription.id,
|
|
735
|
+
// Uniform with the money rows, which name this same subscription as their family. A state row is its
|
|
736
|
+
// own family's head, so both columns carry the same value and the owner lookup matches on either.
|
|
737
|
+
originalTransactionId: subscription.id,
|
|
738
|
+
providerProductId: subscriptionPriceId(subscription),
|
|
739
|
+
role: "state",
|
|
740
|
+
status,
|
|
741
|
+
environment,
|
|
742
|
+
purchasedAt: at(subscription.created_at, "a subscription's created_at"),
|
|
743
|
+
expiresAt: maybeAt(ends, "a subscription's period end"),
|
|
744
|
+
revokedAt: null,
|
|
745
|
+
resumesAt: pauseResumesAt({ rail: "paddle", status, reported: subscriptionResumesAt(subscription) }),
|
|
746
|
+
amountMinor: null,
|
|
747
|
+
currency: null,
|
|
748
|
+
providerEventAt: occurredAt,
|
|
749
|
+
payload: { ...subscription },
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Whether this delivery belongs to another deployment sharing the same Paddle sandbox.
|
|
755
|
+
*
|
|
756
|
+
* True only when both sides named an environment and they differ. An unstamped delivery is not fenced —
|
|
757
|
+
* a transaction created in the Paddle dashboard carries no `custom_data`, and fencing on absence would
|
|
758
|
+
* silently drop real sales — and a deployment that does not know its own `ENVIRONMENT` fences nothing,
|
|
759
|
+
* which is precisely how the other rails behave.
|
|
760
|
+
*
|
|
761
|
+
* **This is a fence, not an authorization.** It is read off unauthenticated `custom_data` on purpose: its
|
|
762
|
+
* job is to stop this deployment acting on another deployment's traffic, and the direction it can be
|
|
763
|
+
* abused in is a forger declining to be projected — which they achieve by not sending anything. Deciding
|
|
764
|
+
* *who owns* a purchase is {@link accountReferenceOf}'s job, and that one demands a MAC.
|
|
765
|
+
*/
|
|
766
|
+
export function fencedOut(custom: Record<string, unknown> | null | undefined, deployment: string | undefined): boolean {
|
|
767
|
+
const stamped = custom?.[PADDLE_CUSTOM_ENV];
|
|
768
|
+
if (typeof stamped !== "string" || stamped === "" || deployment === undefined) return false;
|
|
769
|
+
return stamped !== deployment;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The account reference this deployment stamped into the checkout, or null.
|
|
774
|
+
*
|
|
775
|
+
* **Honored only when a MAC this deployment could have produced is beside it**, and that condition is the
|
|
776
|
+
* whole security of the field. `accountReference`'s contract says it is "a value this deployment's own
|
|
777
|
+
* server wrote and the store returned unchanged" — the route writes the provider-account link from it, and
|
|
778
|
+
* `linkProviderAccount` never rebinds, so the first pairing is permanent.
|
|
779
|
+
*
|
|
780
|
+
* `Paddle.Checkout.open` accepts `customData` with nothing but the publishable client token, and it does
|
|
781
|
+
* so on both forms of the call — beside an `items[]` array of price ids, and beside a `transactionId`,
|
|
782
|
+
* where the page's object replaces the one the server wrote. So `custom_data` on its own is **not**
|
|
783
|
+
* evidence our server wrote anything, and it is not evidence even when our server did: a stranger can put
|
|
784
|
+
* any string in it and permanently bind their Paddle customer to an account they chose. The env stamp does
|
|
785
|
+
* not save it either — the key names are exported constants in an open-source package and the value is one
|
|
786
|
+
* of three.
|
|
787
|
+
*
|
|
788
|
+
* A deployment that does not know its own `ENVIRONMENT` trusts no reference at all. That is the safe
|
|
789
|
+
* direction: the cost is a purchase that lands unbound and is repairable from the trail, where the other
|
|
790
|
+
* way round is an unauthenticated write into the account map that nothing ever undoes.
|
|
791
|
+
*
|
|
792
|
+
* **And the value must be the subject encoding, which the MAC alone does not make it.** A stamp can be
|
|
793
|
+
* authentic and still name nobody: a bare id is what this rail wrote before subjects existed, and reading
|
|
794
|
+
* one as a user would hand a purchase to whoever else holds that id. So the reference is put through
|
|
795
|
+
* `decodeSubjectReference` — the one decoder, never a split written here — and anything that is not exactly
|
|
796
|
+
* the encoding is refused in the same direction as everything else above. The string is returned rather than
|
|
797
|
+
* the pair because a rail reports `accountReference` as a string; the route decodes it again through the same
|
|
798
|
+
* function, and gets the same answer.
|
|
799
|
+
*/
|
|
800
|
+
export async function accountReferenceOf(
|
|
801
|
+
custom: Record<string, unknown> | null | undefined,
|
|
802
|
+
deployment: string | undefined,
|
|
803
|
+
secret: string,
|
|
804
|
+
): Promise<string | null> {
|
|
805
|
+
if (deployment === undefined) return null;
|
|
806
|
+
|
|
807
|
+
const reference = custom?.[PADDLE_CUSTOM_ACCOUNT];
|
|
808
|
+
const stampedEnv = custom?.[PADDLE_CUSTOM_ENV];
|
|
809
|
+
const proof = custom?.[PADDLE_CUSTOM_PROOF];
|
|
810
|
+
if (typeof reference !== "string" || reference === "") return null;
|
|
811
|
+
if (typeof stampedEnv !== "string" || typeof proof !== "string") return null;
|
|
812
|
+
|
|
813
|
+
// Fail closed on the shape before spending an HMAC on it. A reference that does not decode names nobody
|
|
814
|
+
// however well it is proven, so there is nothing a MAC could add.
|
|
815
|
+
if (decodeSubjectReference(reference) === undefined) return null;
|
|
816
|
+
|
|
817
|
+
// The environment is checked as part of the MAC's message rather than beside it, so a proof minted for
|
|
818
|
+
// staging cannot be replayed against production by editing one field.
|
|
819
|
+
if (stampedEnv !== deployment) return null;
|
|
820
|
+
return (await proofMatches(reference, deployment, secret, proof)) ? reference : null;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* The proof this deployment stamps beside an account reference, and the only reason the reference is
|
|
825
|
+
* worth anything.
|
|
826
|
+
*
|
|
827
|
+
* An HMAC over the environment and the user id, keyed with a secret a stranger does not have. The message
|
|
828
|
+
* is domain-separated by a fixed prefix so this MAC can never be confused with the webhook-body signature
|
|
829
|
+
* that shares its key: the two answer different questions, and a value satisfying both would be a
|
|
830
|
+
* cross-protocol attack waiting to be found.
|
|
831
|
+
*
|
|
832
|
+
* The key is the notification destination's signing secret because it is already per-environment, already
|
|
833
|
+
* rotated with the rail, and already known only to this deployment and to Paddle — and Paddle is not the
|
|
834
|
+
* attacker here. The attacker is anyone who can load the paywall, which is everyone.
|
|
835
|
+
*/
|
|
836
|
+
export async function accountReferenceProof(reference: string, deployment: string, secret: string): Promise<string> {
|
|
837
|
+
const key = await crypto.subtle.importKey(
|
|
838
|
+
"raw",
|
|
839
|
+
new TextEncoder().encode(secret),
|
|
840
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
841
|
+
false,
|
|
842
|
+
["sign"],
|
|
843
|
+
);
|
|
844
|
+
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message(reference, deployment)));
|
|
845
|
+
return [...new Uint8Array(mac)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** Domain separation, so this MAC and the webhook body signature can never be mistaken for one another. */
|
|
849
|
+
const ACCOUNT_REFERENCE_DOMAIN = "pithy:paddle:account-reference:v1:";
|
|
850
|
+
|
|
851
|
+
/** What the MAC covers: the domain, the environment, and the subject — in that order, and never one of them. */
|
|
852
|
+
function message(reference: string, deployment: string): string {
|
|
853
|
+
return `${ACCOUNT_REFERENCE_DOMAIN}${deployment}:${reference}`;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Whether a stamped proof matches, compared in constant time by the runtime rather than by `===`. */
|
|
857
|
+
async function proofMatches(
|
|
858
|
+
reference: string,
|
|
859
|
+
deployment: string,
|
|
860
|
+
secret: string,
|
|
861
|
+
candidate: string,
|
|
862
|
+
): Promise<boolean> {
|
|
863
|
+
const bytes = hexToBytes(candidate);
|
|
864
|
+
if (bytes === undefined) return false;
|
|
865
|
+
const key = await crypto.subtle.importKey(
|
|
866
|
+
"raw",
|
|
867
|
+
new TextEncoder().encode(secret),
|
|
868
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
869
|
+
false,
|
|
870
|
+
["verify"],
|
|
871
|
+
);
|
|
872
|
+
return await crypto.subtle.verify("HMAC", key, bytes, new TextEncoder().encode(message(reference, deployment)));
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Decode hex, or undefined when it is not hex. */
|
|
876
|
+
function hexToBytes(value: string): Uint8Array | undefined {
|
|
877
|
+
if (value.length === 0 || value.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(value)) return undefined;
|
|
878
|
+
const bytes = new Uint8Array(value.length / 2);
|
|
879
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
880
|
+
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
|
881
|
+
}
|
|
882
|
+
return bytes;
|
|
883
|
+
}
|