@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,675 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { JsonDate } from "@pithy-sh/core/src/data/codecs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { PurchaseStatus } from "./status";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A subscription's standing, what changing it costs, and what asking for the money back produces — the
|
|
10
|
+
* shapes a screen renders on either side of the button the customer presses.
|
|
11
|
+
*
|
|
12
|
+
* **Every shape here was rewritten against real Paddle sandbox responses on 2026-08-28 (#465), and the
|
|
13
|
+
* specification it replaced was written from the documentation.** That distinction is the whole value of
|
|
14
|
+
* this module, so the four things the recordings refuted are stated at the fields they shaped rather
|
|
15
|
+
* than left as a changelog nobody reads:
|
|
16
|
+
*
|
|
17
|
+
* - **Money is signed.** `update_summary.credit.amount` came back `"-380"` on an upgrade and `"-6961"`
|
|
18
|
+
* on a downgrade, and a downgrade's `subtotal`, `tax` and `total` are all negative. The first design
|
|
19
|
+
* put `.nonnegative()` on quote money, which throws on every real change a customer makes — a
|
|
20
|
+
* validation error in front of somebody trying to give us more money. See {@link QuotedMoney}.
|
|
21
|
+
* - **`grand_total` lies.** On the recorded downgrade it is `"0"` while the customer is owed 6581,
|
|
22
|
+
* which sits in `credit_to_balance`. A quote built from the totals renders "nothing happens today"
|
|
23
|
+
* and the customer's next statement disagrees. So the renderable figure is `update_summary.result` —
|
|
24
|
+
* a direction and a magnitude — and it is the only amount this module lets a screen reach.
|
|
25
|
+
* See {@link SubscriptionSettlement}.
|
|
26
|
+
* - **`update_summary.result` says *what*, and never *when*.** This is the correction of 2026-08-28
|
|
27
|
+
* (second pass), and it cost the quote a whole third part. A downgrade ships under
|
|
28
|
+
* `prorated_next_billing_period`, and the recording of that mode answers `immediate_transaction:
|
|
29
|
+
* null` *and* `result: { action: "credit", amount: "6558" }` at the same time. Read `result` as the
|
|
30
|
+
* headline and the screen says the customer was credited today, which is money they will look for
|
|
31
|
+
* and not find; read the absent immediate transaction as the whole answer and the 6558 disappears
|
|
32
|
+
* from the quote entirely. Both are wrong, and neither is a rounding matter. **What lands is
|
|
33
|
+
* `result`; when it lands is whether there is an immediate transaction at all.** See
|
|
34
|
+
* {@link SubscriptionChangeQuote}.
|
|
35
|
+
* - **`next_billed_at` goes null while the status stays `active`.** Scheduling a cancellation blanks it
|
|
36
|
+
* and leaves `canceled_at: null`, so a subscription that ends in eighteen days is indistinguishable
|
|
37
|
+
* from one that renews in eighteen days if you read the status. See {@link SubscriptionStanding} and
|
|
38
|
+
* {@link nextSubscriptionEvent}.
|
|
39
|
+
* - **Nothing settling today is a real outcome.** Under `prorated_next_billing_period` — the mode the
|
|
40
|
+
* downgrade policy uses — `immediate_transaction` comes back null (recorded, not just documented). A
|
|
41
|
+
* quote that could not say so would have to invent a zero charge, and "You will be charged $0.00 now"
|
|
42
|
+
* describes something that is not happening.
|
|
43
|
+
*
|
|
44
|
+
* ## This module computes nothing
|
|
45
|
+
*
|
|
46
|
+
* Exactly as `data/discount.ts` states for discounts: the provider is the authority on what is owed. No
|
|
47
|
+
* amount here is derived from another amount, no date from a duration, and nothing sums the totals to
|
|
48
|
+
* check the provider's arithmetic. A second answer to "what will this cost" is a second number for a
|
|
49
|
+
* customer to hold against their statement, and the one they will believe is the statement.
|
|
50
|
+
*
|
|
51
|
+
* ## What is deliberately absent
|
|
52
|
+
*
|
|
53
|
+
* **A proration mode, and any other billing enum.** The settled policy (2026-08-28) is that the rail
|
|
54
|
+
* picks the mode from the *direction* of the change — an upgrade prorates immediately and charges now, a
|
|
55
|
+
* downgrade prorates into the next billing period — and `on_payment_failure` is always `prevent_change`.
|
|
56
|
+
* Nothing a caller sends chooses either. Modeling the mode here would make it a field, a field is a
|
|
57
|
+
* thing a client can set, and the value a client would eventually set is Paddle's `do_not_bill`: a free
|
|
58
|
+
* upgrade. It is unreachable because there is nowhere to write it.
|
|
59
|
+
*
|
|
60
|
+
* **A price id, and the items of a change.** Paddle's `scheduled_change` carries an `items` array and
|
|
61
|
+
* this module drops it. These shapes are rail-neutral by construction — they sit in `data/`, below the
|
|
62
|
+
* rails, and are what a screen renders whichever store is behind them — and a `pri_…` is Paddle's
|
|
63
|
+
* vocabulary. What the subscription is *for* is the catalog's answer, read beside a standing rather than
|
|
64
|
+
* carried inside one.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A currency, as this package stores it: lowercase ISO 4217.
|
|
69
|
+
*
|
|
70
|
+
* **Lowercase is not cosmetic and Paddle does not send it.** Paddle states `"USD"`; `currencyOf` in
|
|
71
|
+
* `rails/paddle/objects.ts` is what lowers it, and `pithy_payments_purchases` holds the lowered form. A
|
|
72
|
+
* quote that carried `"USD"` would compare unequal to the purchase rows for the same money and would
|
|
73
|
+
* sort into a second bucket in any report that groups by currency — a drift that is invisible until a
|
|
74
|
+
* total is wrong. Refused here rather than lowered here, because silently accepting both spellings is
|
|
75
|
+
* how the rail stops translating.
|
|
76
|
+
*/
|
|
77
|
+
const Currency = z
|
|
78
|
+
.string()
|
|
79
|
+
.regex(/^[a-z]{3}$/, "An ISO 4217 currency code is three lowercase letters.")
|
|
80
|
+
.describe(
|
|
81
|
+
"The ISO 4217 currency, lowercase — the spelling this package stores. A rail lowers the provider's own casing before it gets here.",
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* An amount of money a provider quoted: an integer in the currency's minor unit, and the currency.
|
|
86
|
+
*
|
|
87
|
+
* **The amount is signed, and that is the correction.** Paddle sends amounts as strings in the lowest
|
|
88
|
+
* denomination and it sends negative ones freely — `"-380"` for the credit half of an upgrade, `"-6045"`
|
|
89
|
+
* for a downgrade's subtotal. A `.nonnegative()` here refuses the recorded response of every plan change
|
|
90
|
+
* this package exists to make. Parsing the string is `minorAmount`'s job in the Paddle rail, which
|
|
91
|
+
* already answers `null` rather than a guess for anything that is not a plain integer string; this
|
|
92
|
+
* schema is the other end of that, and refuses the `null`.
|
|
93
|
+
*
|
|
94
|
+
* **A fraction is refused.** `65.82` is what somebody reaches for having read "$65.82" on the screen the
|
|
95
|
+
* quote produced. The wire says `"6582"` and means 6582, and a float here would be a rounding error
|
|
96
|
+
* arriving later, in a currency whose minor unit is not a hundredth. `data/money.ts` holds which
|
|
97
|
+
* currencies those are; nothing in this module needs to know, because nothing here scales.
|
|
98
|
+
*
|
|
99
|
+
* **`rendered` is the sentence, and it never replaces the number** (#465, 2026-08-28). The shape shipped
|
|
100
|
+
* with minor units alone, and nothing downstream could turn 6582 into `$65.82`: five of six planned screens
|
|
101
|
+
* could not state the figure the customer was being asked to confirm, and the adopter dashboard refuses a
|
|
102
|
+
* bare digit string outright rather than render minor units as a price. So the string sits *beside* the
|
|
103
|
+
* integer — a consumer comparing amounts still reads `amountMinor`, and a consumer showing one reads
|
|
104
|
+
* `rendered`. `data/renderMoney.ts` holds how, and holds the measurement that corrected the rule against it.
|
|
105
|
+
*/
|
|
106
|
+
export const QuotedMoney = z
|
|
107
|
+
.object({
|
|
108
|
+
amountMinor: z
|
|
109
|
+
.number()
|
|
110
|
+
.int()
|
|
111
|
+
.describe(
|
|
112
|
+
"How much, as an integer in the currency's minor unit. **Signed** — a credit and a refund are negative on the wire, and refusing that refuses every downgrade. Never a float: 6582 is $65.82, and 65.82 is a bug.",
|
|
113
|
+
),
|
|
114
|
+
currency: Currency.describe("The currency the amount is in, lowercase."),
|
|
115
|
+
rendered: z
|
|
116
|
+
.string()
|
|
117
|
+
.min(1)
|
|
118
|
+
.describe(
|
|
119
|
+
"The same amount as a reader sees it — `$65.82` for an English reader, `65,82 US$` for a Spanish one, `¥6,582` where the currency has no subunit. Presentation of `amountMinor` and never a second answer to it: the provider's integer decides how much, and `Intl` decides only how it is spelled.",
|
|
120
|
+
),
|
|
121
|
+
})
|
|
122
|
+
.describe(
|
|
123
|
+
"An amount a provider quoted: minor units for comparing, one currency, and the figure rendered for the reader it is being shown to. Signed, because the provider's own numbers are.",
|
|
124
|
+
);
|
|
125
|
+
export type QuotedMoney = z.output<typeof QuotedMoney>;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The three members, declared once because two unions below are built from them.
|
|
129
|
+
*
|
|
130
|
+
* **Sharing them is not tidiness.** {@link SubscriptionSettlement} and
|
|
131
|
+
* {@link DeferredSubscriptionSettlement} differ by exactly one member, and two hand-written lists that
|
|
132
|
+
* differ by one member are two lists that will differ by two the next time somebody adds an outcome —
|
|
133
|
+
* with the newer one reachable today and unreachable on a customer's next invoice.
|
|
134
|
+
*/
|
|
135
|
+
/**
|
|
136
|
+
* The two halves a settled amount is the reconciliation of — what the new plan costs for the rest of the
|
|
137
|
+
* period, and what the old one gives back for that same unused time.
|
|
138
|
+
*
|
|
139
|
+
* **Added 2026-08-31 (#96) against a recording, because the net alone is a figure nobody can check.**
|
|
140
|
+
* Previewing Solo → Team mid-period answered `charge: "5116"`, `credit: "-233"`, `result: 4883`. A screen
|
|
141
|
+
* showing only 4883 states a number a customer cannot arrive at from the two prices on the same page, and
|
|
142
|
+
* the reasonable conclusion is that the store is wrong. The halves are what make the net legible.
|
|
143
|
+
*
|
|
144
|
+
* **`credit` is negative and stays negative.** `update_summary.credit.amount` came back `"-380"` on the
|
|
145
|
+
* recorded upgrade and `"-6961"` on the downgrade; flipping it here would put the sign in one place and the
|
|
146
|
+
* word "credit" in another, and a reader who trusted either would be right half the time. It reads as money
|
|
147
|
+
* coming off, which is what it is.
|
|
148
|
+
*
|
|
149
|
+
* **Nothing here is derived, and nothing is checked.** `charge + credit` is Paddle's arithmetic, not this
|
|
150
|
+
* package's: the two figures and the net are three things Paddle stated, reported as stated. Summing them to
|
|
151
|
+
* verify the net would produce a second answer to what a change costs, and the module header says why there
|
|
152
|
+
* is only ever one.
|
|
153
|
+
*/
|
|
154
|
+
const SettlementParts = z
|
|
155
|
+
.object({
|
|
156
|
+
charge: QuotedMoney.describe(
|
|
157
|
+
"What the plan being moved to costs for the remainder of the period already paid for, before the credit comes off. `update_summary.charge` — recorded 5116 on the upgrade of 2026-08-31.",
|
|
158
|
+
),
|
|
159
|
+
credit: QuotedMoney.describe(
|
|
160
|
+
"What the plan being left is worth back over that same unused time. `update_summary.credit`, **negative on the wire** and left that way — recorded -233 beside that 5116.",
|
|
161
|
+
),
|
|
162
|
+
})
|
|
163
|
+
.describe(
|
|
164
|
+
"The charge and the credit a settlement reconciles. Both as Paddle stated them; the net is stated separately and is not computed from these.",
|
|
165
|
+
);
|
|
166
|
+
export type SubscriptionSettlementParts = z.output<typeof SettlementParts>;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Why the breakdown is nullable, in one sentence a later reader can check: **a half Paddle did not state in
|
|
170
|
+
* full is not a half this package invents.** `update_summary.credit` arrived as `{ amount: "-6936" }` with no
|
|
171
|
+
* `currency_code` on one recording, and the only two ways to render that are to borrow the net's currency —
|
|
172
|
+
* a guess, printed as a price — or to say nothing. This says nothing, and the net still renders.
|
|
173
|
+
*/
|
|
174
|
+
const MADE_UP_OF =
|
|
175
|
+
"The charge and the credit this amount reconciles, or null when Paddle did not state both in full. Present, it is what lets a reader arrive at the amount; absent, the amount still stands on its own — a screen shows the breakdown when there is one and never waits for it.";
|
|
176
|
+
|
|
177
|
+
const SettlesByCharge = z
|
|
178
|
+
.object({
|
|
179
|
+
outcome: z.literal("charge").describe("The customer is billed."),
|
|
180
|
+
amount: QuotedMoney.describe("How much is taken, as a positive magnitude. The direction is `outcome`."),
|
|
181
|
+
madeUpOf: SettlementParts.nullable().describe(MADE_UP_OF),
|
|
182
|
+
})
|
|
183
|
+
.describe("Money leaves the customer — the upgrade case, prorated immediately.");
|
|
184
|
+
|
|
185
|
+
const SettlesByCredit = z
|
|
186
|
+
.object({
|
|
187
|
+
outcome: z.literal("credit").describe("The customer is owed, and it lands as credit rather than as cash."),
|
|
188
|
+
amount: QuotedMoney.describe(
|
|
189
|
+
"How much the customer is owed, as a positive magnitude. Read with `outcome` — the same number rendered without it is a charge.",
|
|
190
|
+
),
|
|
191
|
+
madeUpOf: SettlementParts.nullable().describe(MADE_UP_OF),
|
|
192
|
+
})
|
|
193
|
+
.describe(
|
|
194
|
+
'The customer is owed. Recorded as `result.action: "credit"` with the amount in `credit_to_balance`, while `grand_total` says 0.',
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const SettlesNothing = z
|
|
198
|
+
.object({
|
|
199
|
+
outcome: z.literal("nothing").describe("Nothing is billed or credited. There is no amount to state."),
|
|
200
|
+
})
|
|
201
|
+
.describe(
|
|
202
|
+
"Nothing settles. What `prorated_next_billing_period` produces today: no immediate transaction, the difference carried to the next invoice.",
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* What settles at one moment — the thing a confirmation screen states, with the day it happens supplied
|
|
207
|
+
* by whichever part of a quote holds it.
|
|
208
|
+
*
|
|
209
|
+
* **A discriminated union rather than an amount with a sign**, because the direction has to be
|
|
210
|
+
* unreadable without being read. `update_summary.result` states `{ action: "credit", amount: "6581" }`:
|
|
211
|
+
* the magnitude is positive and the direction lives in the action. A shape carrying only the number
|
|
212
|
+
* renders a 6581 credit as a 6581 charge, which is the same characters and the opposite meaning, and
|
|
213
|
+
* nothing in a type system would object. Here `amount` cannot be reached without matching `outcome`
|
|
214
|
+
* first.
|
|
215
|
+
*
|
|
216
|
+
* **`nothing` is a member, not a zero.** With `prorated_next_billing_period` there is no immediate
|
|
217
|
+
* transaction at all — recorded null, 2026-08-28 — and the credit lands on the next invoice instead.
|
|
218
|
+
* "Nothing to pay today" and "a charge of zero" are different sentences, and only one of them is true.
|
|
219
|
+
* It carries no amount because there is no amount, and one smuggled in does not survive the parse.
|
|
220
|
+
*
|
|
221
|
+
* **`nothing` says nothing about whether the change is free.** That is the trap this union alone cannot
|
|
222
|
+
* close, and why {@link SubscriptionChangeQuote} has three parts: the recorded deferred downgrade settles
|
|
223
|
+
* `nothing` today *and* owes the customer 6558. Which moment a settlement describes is the field it is
|
|
224
|
+
* read from, never the settlement itself.
|
|
225
|
+
*
|
|
226
|
+
* The alternative that was tried and refuted: reading the immediate transaction's totals. Recorded
|
|
227
|
+
* downgrade, 2026-08-28 — `grand_total: "0"`, `credit_to_balance: "6581"`. The totals are how a screen
|
|
228
|
+
* says nothing happened while 65.81 dollars moved.
|
|
229
|
+
*/
|
|
230
|
+
export const SubscriptionSettlement = z
|
|
231
|
+
.discriminatedUnion("outcome", [SettlesByCharge, SettlesByCredit, SettlesNothing])
|
|
232
|
+
.describe(
|
|
233
|
+
"What settles at one moment — a charge, a credit, or nothing. The direction is the discriminant so an amount can never be rendered without it.",
|
|
234
|
+
);
|
|
235
|
+
export type SubscriptionSettlement = z.output<typeof SubscriptionSettlement>;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The same settlement, minus `nothing` — what lands on an invoice that is not today's.
|
|
239
|
+
*
|
|
240
|
+
* **The two members are shared declarations, not a second copy**, so a fourth outcome cannot exist in
|
|
241
|
+
* one union and not the other. What differs is the absence, and the absence is the point.
|
|
242
|
+
*
|
|
243
|
+
* `nothing` is unrepresentable here because the block that holds this is nullable, and null already says
|
|
244
|
+
* it. Two spellings of one fact is how a screen ends up checking the block for presence, finding it, and
|
|
245
|
+
* rendering "$— credit on 15 Sep" — a row about no money, dated. A deferred settlement exists precisely
|
|
246
|
+
* when there is something to defer.
|
|
247
|
+
*/
|
|
248
|
+
export const DeferredSubscriptionSettlement = z
|
|
249
|
+
.discriminatedUnion("outcome", [SettlesByCharge, SettlesByCredit])
|
|
250
|
+
.describe(
|
|
251
|
+
"What lands on a later invoice: a charge or a credit, never nothing — a null block is how a quote says nothing lands later.",
|
|
252
|
+
);
|
|
253
|
+
export type DeferredSubscriptionSettlement = z.output<typeof DeferredSubscriptionSettlement>;
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* What a change costs, as a screen shows it before the customer confirms — the provider's own preview,
|
|
257
|
+
* normalized and nothing more.
|
|
258
|
+
*
|
|
259
|
+
* **Three facts, because a downgrade has three.** They are what happens **today**, what happens on the
|
|
260
|
+
* **next invoice**, and what the subscription pays **from then on**. Collapsing any two is how a customer
|
|
261
|
+
* reads a proration as their new monthly price, or is told about money on a day it does not move.
|
|
262
|
+
*
|
|
263
|
+
* The third part is the correction of 2026-08-28, and the recording that forced it is the downgrade under
|
|
264
|
+
* `prorated_next_billing_period` — **the mode a downgrade ships with**:
|
|
265
|
+
*
|
|
266
|
+
* | Recorded | Value | Part |
|
|
267
|
+
* | --- | --- | --- |
|
|
268
|
+
* | `immediate_transaction` | `null` | `settlesToday: { outcome: "nothing" }` |
|
|
269
|
+
* | `update_summary.result` | `{ action: "credit", amount: "6558" }` | `nextInvoice.settlement` |
|
|
270
|
+
* | `next_billed_at` | `2026-09-15T11:42:21.789736Z` | `nextInvoice.at` |
|
|
271
|
+
* | `recurring_transaction_details.totals.grand_total` | `"653"` | `recurring.amount` |
|
|
272
|
+
*
|
|
273
|
+
* With two parts that response is unwritable. `settlesToday` taken from `result` says "credited today" of
|
|
274
|
+
* a change that takes and gives nothing today; `settlesToday` taken from the missing immediate
|
|
275
|
+
* transaction is honest and drops 65.58 dollars out of the quote. The sentence the customer is owed —
|
|
276
|
+
* *"Nothing today. $65.58 credit on your next invoice, 15 Sep. Then $6.53/month."* — needs all three, and
|
|
277
|
+
* a shape that can hold only two guarantees one of them is a lie or a silence.
|
|
278
|
+
*
|
|
279
|
+
* The upgrade is the other side of the same rule and needs no third part: `immediate_transaction` is
|
|
280
|
+
* present, `result` is a charge of 6582, it settles today, and `nextInvoice` is null.
|
|
281
|
+
*
|
|
282
|
+
* A quote is never stored. It is a provider's answer to "if I did this now", it goes stale the moment
|
|
283
|
+
* the billing period moves, and a persisted one is a price nobody is bound by.
|
|
284
|
+
*/
|
|
285
|
+
/**
|
|
286
|
+
* What a recurring price is made of: the plan's own rate, and the tax on it.
|
|
287
|
+
*
|
|
288
|
+
* **Added 2026-08-31 (#96) against the same recording as {@link SettlementParts}, and for a defect the
|
|
289
|
+
* screen was already showing.** The plans table states Team at $110 a month; the quote beside it states
|
|
290
|
+
* $119.76. Both are that plan's price — one before tax and one after — and nothing on the page said so.
|
|
291
|
+
* The recording carries the split whole: `subtotal: "11000"`, `tax: "976"`, `grand_total: "11976"`, at a
|
|
292
|
+
* `tax_rate` of `0.08875`.
|
|
293
|
+
*
|
|
294
|
+
* **`subtotal`, not `total` minus `tax`.** Paddle states the base directly, and it is the figure after
|
|
295
|
+
* any discount rather than before it — so a screen deriving it from the other two would be right today
|
|
296
|
+
* and wrong the first time a coupon exists. Nothing here is computed, for the reason the module header
|
|
297
|
+
* gives: the provider is the authority on every amount, and a second answer is a second number for a
|
|
298
|
+
* customer to hold against their statement.
|
|
299
|
+
*
|
|
300
|
+
* **The rate itself is deliberately absent.** `0.08875` renders as *8.875%*, which is one rounding
|
|
301
|
+
* decision away from a figure that does not reproduce the tax beside it, and a customer reading a
|
|
302
|
+
* percentage against two amounts is being invited to check arithmetic this package does not do.
|
|
303
|
+
*/
|
|
304
|
+
const RecurringParts = z
|
|
305
|
+
.object({
|
|
306
|
+
beforeTax: QuotedMoney.describe(
|
|
307
|
+
"What the plan itself costs each period, before tax and after any discount. `recurring_transaction_details.totals.subtotal` — recorded 11000 beside a grand total of 11976.",
|
|
308
|
+
),
|
|
309
|
+
tax: QuotedMoney.describe(
|
|
310
|
+
"The tax on that base, as the provider assessed it for this customer's address. `…totals.tax` — recorded 976.",
|
|
311
|
+
),
|
|
312
|
+
})
|
|
313
|
+
.describe("The two figures a recurring price is the sum of, both as the provider stated them.");
|
|
314
|
+
export type SubscriptionRecurringParts = z.output<typeof RecurringParts>;
|
|
315
|
+
|
|
316
|
+
export const SubscriptionChangeQuote = z
|
|
317
|
+
.object({
|
|
318
|
+
settlesToday: SubscriptionSettlement.describe(
|
|
319
|
+
"What is taken or given **today, and only today** — charged, credited, or nothing. `update_summary.result` supplies the direction and the amount; whether there is an `immediate_transaction` at all decides whether it belongs here or on `nextInvoice`. Never from the immediate transaction's own totals, which report a `grand_total` of 0 on a downgrade that owes the customer 6581.",
|
|
320
|
+
),
|
|
321
|
+
nextInvoice: z
|
|
322
|
+
.object({
|
|
323
|
+
settlement: DeferredSubscriptionSettlement.describe(
|
|
324
|
+
"What lands on that invoice, direction first. The recorded deferred downgrade: a credit of 6558.",
|
|
325
|
+
),
|
|
326
|
+
at: JsonDate.describe(
|
|
327
|
+
"The day that invoice falls — `next_billed_at`, which is also `next_transaction.billing_period.starts_at`. Required, and deliberately not borrowed from `recurring.startsAt`: that block is nullable, so a screen reaching into it for this date prints an undated credit the first time a change ends the subscription.",
|
|
328
|
+
),
|
|
329
|
+
})
|
|
330
|
+
.nullable()
|
|
331
|
+
.describe(
|
|
332
|
+
'The part of the change that settles on the **next** invoice rather than now, and the day it does. **Null means nothing from this change lands later** — every immediate proration, which is every upgrade. The amount is `update_summary.result`: what *this change* is worth, which is the only figure the customer is being asked to confirm. It is not `next_transaction.details.totals`, which on the same recording states `total: "-5905"` and `credit_to_balance: "5905"` — the same credit with that invoice\'s own 653 already netted off it. A screen shows the credit and the new monthly rate side by side, so netting 653 into the credit subtracts it twice; and 5905 is a figure only the invoice can explain, arriving on a screen about a plan change. 6558 and 653 are stated separately because they are separate, and nothing here derives either from the other.',
|
|
333
|
+
),
|
|
334
|
+
recurring: z
|
|
335
|
+
.object({
|
|
336
|
+
amount: QuotedMoney.describe("What each period costs once the change has taken effect, tax included."),
|
|
337
|
+
startsAt: JsonDate.describe(
|
|
338
|
+
"When that amount first bills — the end of the period being prorated. Required alongside the amount: a new price with no date is a bill arriving on a day nobody was told about.",
|
|
339
|
+
),
|
|
340
|
+
madeUpOf: RecurringParts.nullable().describe(
|
|
341
|
+
"The base and the tax `amount` is the sum of, or null when the provider did not state both in full. The same name {@link SubscriptionSettlement} uses for the same idea — what a figure is made of — because one word for one idea is how a screen stops growing a second vocabulary for the second one.",
|
|
342
|
+
),
|
|
343
|
+
})
|
|
344
|
+
.nullable()
|
|
345
|
+
.describe(
|
|
346
|
+
"What the subscription pays each period afterwards, and from when. **Null means nothing renews after this change** — the subscription is ending — which is a sentence a screen writes. It is not the place to put a figure the provider declined to state; a rail with no recurring answer has a shape change to report, not a null to invent.",
|
|
347
|
+
),
|
|
348
|
+
})
|
|
349
|
+
.describe(
|
|
350
|
+
"A provider's preview of a subscription change: what settles today, what settles on the next invoice, and what the subscription pays afterwards. Three separate facts, because a deferred downgrade has three. Rendered, confirmed, discarded — never stored.",
|
|
351
|
+
);
|
|
352
|
+
export type SubscriptionChangeQuote = z.output<typeof SubscriptionChangeQuote>;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* What a scheduled change will do. Paddle's three, and the set is closed for the reason every other
|
|
356
|
+
* enum in this package is: an action this build has never seen is a shape change worth failing on, not
|
|
357
|
+
* a value to render as itself.
|
|
358
|
+
*/
|
|
359
|
+
export const ScheduledSubscriptionChangeAction = z
|
|
360
|
+
.enum(["cancel", "pause", "resume"])
|
|
361
|
+
.describe(
|
|
362
|
+
"What the pending change does when it lands: end the subscription, suspend it, or bring it back. Nothing else is schedulable.",
|
|
363
|
+
);
|
|
364
|
+
export type ScheduledSubscriptionChangeAction = z.output<typeof ScheduledSubscriptionChangeAction>;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A change the provider will apply later — the thing that makes an `active` subscription's future
|
|
368
|
+
* different from its present.
|
|
369
|
+
*
|
|
370
|
+
* **This is the object that distinguishes "renews on the 15th" from "ends on the 15th",** and after the
|
|
371
|
+
* recordings it is clear nothing else does: with a cancellation scheduled, Paddle reports
|
|
372
|
+
* `status: "active"`, `canceled_at: null` and `next_billed_at: null`. Two of those three say the
|
|
373
|
+
* subscription is fine and the third says nothing at all. Only this object says what is coming.
|
|
374
|
+
*
|
|
375
|
+
* `resumesAt` sits beside `effectiveAt` rather than replacing it because they are different instants on
|
|
376
|
+
* a pause: the pause begins at `effective_at` and the subscription comes back at `resume_at`, and Paddle
|
|
377
|
+
* leaves the second null for an open-ended one. `data/pause.ts` holds the longer argument about that
|
|
378
|
+
* null — on a pause it means *indefinitely*, never *we did not look*.
|
|
379
|
+
*/
|
|
380
|
+
export const ScheduledSubscriptionChange = z
|
|
381
|
+
.object({
|
|
382
|
+
action: ScheduledSubscriptionChangeAction.describe("What will happen."),
|
|
383
|
+
effectiveAt: JsonDate.describe(
|
|
384
|
+
"When it happens. Required: a pending change with no date is an announcement a screen cannot make, and on a scheduled cancel this date is the one the customer is owed — it is where 'renews on' has to come from once `nextBilledAt` has gone null.",
|
|
385
|
+
),
|
|
386
|
+
resumesAt: JsonDate.nullable().describe(
|
|
387
|
+
"When a paused subscription comes back, when the provider named a date. Null on a pause means indefinitely; null on a cancel or a resume means the field does not apply. Never computed from a duration — see `data/pause.ts`.",
|
|
388
|
+
),
|
|
389
|
+
})
|
|
390
|
+
.describe("A change the provider will apply at a stated future moment, on a subscription that is fine until then.");
|
|
391
|
+
export type ScheduledSubscriptionChange = z.output<typeof ScheduledSubscriptionChange>;
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Where a subscription stands — the read that ships beside the writes.
|
|
395
|
+
*
|
|
396
|
+
* A capability that can cancel a subscription and not report the cancellation has shipped the half that
|
|
397
|
+
* creates the support ticket. The two sentences this exists to make writable are "Team, renews 15 Sep"
|
|
398
|
+
* and "Team until 15 Sep, then ends", and until #465 they were the same shape: `status` is `active` in
|
|
399
|
+
* both, `canceled_at` is null in both, and the only difference is the scheduled change. Read the status
|
|
400
|
+
* alone and a customer who canceled is told they will be billed again.
|
|
401
|
+
*
|
|
402
|
+
* **Every field is stated, none is optional.** Null means the provider answered that there is none;
|
|
403
|
+
* absent would mean nobody looked, and the two are not the same claim about somebody's money. So a
|
|
404
|
+
* partial object does not parse into a standing.
|
|
405
|
+
*
|
|
406
|
+
* The money is not here. What the subscription costs is `SubscriptionPricing` in `data/discount.ts`,
|
|
407
|
+
* which already answers it together with any discount and when that discount lapses — and a second
|
|
408
|
+
* price on a second object is two numbers to keep in step. `currency` is here so a screen can format
|
|
409
|
+
* that price without a second lookup, and it is nullable for the same reason it is there: some stores do
|
|
410
|
+
* not state one on the subscription itself, and a whole standing must not fail to load over it.
|
|
411
|
+
*/
|
|
412
|
+
export const SubscriptionStanding = z
|
|
413
|
+
.object({
|
|
414
|
+
status: PurchaseStatus.describe(
|
|
415
|
+
"The normalized status — this package's vocabulary, never a store's. Paddle's `trialing` and `past_due` are already mapped by the rail. **It does not tell you whether the subscription is ending:** a scheduled cancel leaves it `active`, so read `scheduledChange` too.",
|
|
416
|
+
),
|
|
417
|
+
currency: Currency.nullable().describe(
|
|
418
|
+
"The currency this subscription bills in, lowercase, or null when the store did not state one. Here to format the price that `SubscriptionPricing` carries, not to carry a price itself.",
|
|
419
|
+
),
|
|
420
|
+
currentPeriodEndsAt: JsonDate.nullable().describe(
|
|
421
|
+
"When the period already paid for runs out — the day access lapses if nothing renews it. Null while a subscription is trialing or paused, which are the states with no billing period.",
|
|
422
|
+
),
|
|
423
|
+
nextBilledAt: JsonDate.nullable().describe(
|
|
424
|
+
"When the next charge falls due, or null when none is going to. **Null does not mean canceled and it does not mean broken:** Paddle blanks it the moment a cancellation is scheduled and leaves the status `active`. Recorded 2026-08-28. A screen reads it through `nextSubscriptionEvent` rather than printing it beside the word 'renews'.",
|
|
425
|
+
),
|
|
426
|
+
scheduledChange: ScheduledSubscriptionChange.nullable().describe(
|
|
427
|
+
"The change waiting to land, or null when nothing is. The only field that distinguishes a subscription that ends this period from one that renews.",
|
|
428
|
+
),
|
|
429
|
+
})
|
|
430
|
+
.describe(
|
|
431
|
+
"Where a subscription stands, for the person who holds it: its status, its dates, and whatever is scheduled to happen to it.",
|
|
432
|
+
);
|
|
433
|
+
export type SubscriptionStanding = z.output<typeof SubscriptionStanding>;
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* The next thing that happens to a subscription, and when — the answer a screen prints beside a date.
|
|
437
|
+
*
|
|
438
|
+
* `at` is null on exactly one kind, so a caller that has narrowed to any other has a date without
|
|
439
|
+
* checking for one.
|
|
440
|
+
*/
|
|
441
|
+
export type SubscriptionNextEvent =
|
|
442
|
+
| {
|
|
443
|
+
/** What happens next. */
|
|
444
|
+
readonly kind: "renews" | "ends" | "pauses" | "resumes";
|
|
445
|
+
/** When it happens. */
|
|
446
|
+
readonly at: Date;
|
|
447
|
+
}
|
|
448
|
+
| {
|
|
449
|
+
/** Nothing is scheduled and nothing is due. The provider has not said what comes next. */
|
|
450
|
+
readonly kind: "unknown";
|
|
451
|
+
/** There is no date, because there is no event. */
|
|
452
|
+
readonly at: null;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Which sentence a scheduled change becomes. `satisfies Record<…>` rather than a `switch`, so a fourth
|
|
457
|
+
* schedulable action does not compile until somebody decides what a screen says about it — a `switch`
|
|
458
|
+
* with no case for it would fall through to "renews", which is the wrong sentence written confidently.
|
|
459
|
+
*/
|
|
460
|
+
const SCHEDULED_CHANGE_EVENT = {
|
|
461
|
+
cancel: "ends",
|
|
462
|
+
pause: "pauses",
|
|
463
|
+
resume: "resumes",
|
|
464
|
+
} as const satisfies Record<ScheduledSubscriptionChangeAction, "ends" | "pauses" | "resumes">;
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* What happens to this subscription next.
|
|
468
|
+
*
|
|
469
|
+
* **The scheduled change wins, and that ordering is the point of the function.** Paddle blanks
|
|
470
|
+
* `next_billed_at` when a cancellation is scheduled, so a screen reading it first would say nothing at
|
|
471
|
+
* all about a subscription that ends in eighteen days; a screen reading the status first would say it
|
|
472
|
+
* renews. The date the customer is owed is `scheduled_change.effective_at`, and it is the only place it
|
|
473
|
+
* exists (recorded 2026-08-28).
|
|
474
|
+
*
|
|
475
|
+
* `unknown` rather than a fallback, for a subscription with nothing scheduled and nothing due. An
|
|
476
|
+
* expired subscription and one whose provider went quiet both land here, and neither of them renews. A
|
|
477
|
+
* screen that prints a date it was not given is how a customer learns a wrong one.
|
|
478
|
+
*/
|
|
479
|
+
export function nextSubscriptionEvent(standing: SubscriptionStanding): SubscriptionNextEvent {
|
|
480
|
+
const change = standing.scheduledChange;
|
|
481
|
+
if (change !== null) return { kind: SCHEDULED_CHANGE_EVENT[change.action], at: change.effectiveAt };
|
|
482
|
+
if (standing.nextBilledAt !== null) return { kind: "renews", at: standing.nextBilledAt };
|
|
483
|
+
return { kind: "unknown", at: null };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* When a cancellation takes effect, in the customer's terms.
|
|
488
|
+
*
|
|
489
|
+
* **Named for what the customer gets, not for what Paddle calls it.** Paddle's values are `immediately`
|
|
490
|
+
* and `next_billing_period`, and the second is the one an adopter reads too quickly: it does not mean
|
|
491
|
+
* "cancel next month", it means "stop renewing, and keep what has been paid for until it runs out".
|
|
492
|
+
* `at_period_end` says that. The rail translates; a value in Paddle's spelling does not parse here, so a
|
|
493
|
+
* rail that stopped translating fails loudly rather than sending a string Paddle happens to accept.
|
|
494
|
+
*
|
|
495
|
+
* **The settled policy is `at_period_end` for a cancellation and for a downgrade to free** (2026-08-28):
|
|
496
|
+
* the tier holds to the end of the period the customer paid for. `now` exists because support sometimes
|
|
497
|
+
* genuinely has to end a subscription today, and because a policy that cannot be departed from in the
|
|
498
|
+
* one case that needs it gets departed from by a direct provider call nothing audits.
|
|
499
|
+
*/
|
|
500
|
+
export const SubscriptionCancelTiming = z
|
|
501
|
+
.enum(["now", "at_period_end"])
|
|
502
|
+
.describe(
|
|
503
|
+
"When a cancellation takes effect: `now` ends access immediately, `at_period_end` stops the renewal and lets the paid period run out. The customer's terms — the rail translates them into the store's.",
|
|
504
|
+
);
|
|
505
|
+
export type SubscriptionCancelTiming = z.output<typeof SubscriptionCancelTiming>;
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* ## The refund half, and why it is in this module
|
|
509
|
+
*
|
|
510
|
+
* A refund is the money side of a cancellation. It is resolved from the same subscription the four verbs
|
|
511
|
+
* above act on, rendered on the same screen beside a standing, and — the reason it is not a module of its
|
|
512
|
+
* own — it is written *against* the settlement vocabulary declared here. {@link SubscriptionSettlement}
|
|
513
|
+
* says money moved. {@link RefundRequest} says it has not, and may never. Putting the two shapes a screen
|
|
514
|
+
* must never confuse in two files is how one of them gets rendered with the other's sentence.
|
|
515
|
+
*
|
|
516
|
+
* **Nothing here says money moved, and nothing here can be made to.** There is no amount in any of these
|
|
517
|
+
* shapes. That is deliberate: a figure would be read as what the customer is getting back, and at the
|
|
518
|
+
* moment these are produced nobody has decided that yet.
|
|
519
|
+
*/
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Where a refund request stands at the store — and **none of these five values means the customer has
|
|
523
|
+
* their money.**
|
|
524
|
+
*
|
|
525
|
+
* A refund is a request. Paddle holds most live ones at `pending_approval` until a person at Paddle
|
|
526
|
+
* reviews them (sandbox approves on its own, roughly ten minutes later), so the request returning is not
|
|
527
|
+
* the refund happening. Even `approved` is only what the store said at the instant it was asked: money
|
|
528
|
+
* reaching a card is the store's later business, it arrives as an `adjustment.updated` webhook, and the
|
|
529
|
+
* projection is what acts on it. Nothing on this side revokes anything.
|
|
530
|
+
*
|
|
531
|
+
* **`unknown` is here, and it is the one place this package reports a value it does not understand.**
|
|
532
|
+
* Everywhere else — `subscriptionStatus`, `ScheduledSubscriptionChangeAction`, `settlementOf` — a value
|
|
533
|
+
* this build has never seen is a shape change worth failing on. That rule assumes failing costs nothing
|
|
534
|
+
* but the read. Here the read follows a write that cannot be taken back: the adjustment exists, and
|
|
535
|
+
* throwing would discard the only handle anybody has on money already in flight. So an unmapped status is
|
|
536
|
+
* reported as `unknown` rather than as itself — a screen cannot render it as a decision, an operator has
|
|
537
|
+
* the adjustment id, and the fact that the store said something new is visible instead of swallowed.
|
|
538
|
+
*/
|
|
539
|
+
export const RefundRequestStatus = z
|
|
540
|
+
.enum(["awaiting_review", "approved", "rejected", "reversed", "unknown"])
|
|
541
|
+
.describe(
|
|
542
|
+
"Where a refund request stands at the store. **None of these means the money has moved** — `approved` is the store's decision, not its settlement, and the webhook that reports the settlement is what revokes anything.",
|
|
543
|
+
);
|
|
544
|
+
export type RefundRequestStatus = z.output<typeof RefundRequestStatus>;
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* A refund this call raised. The adjustment exists at the store and is awaiting whatever the store does
|
|
548
|
+
* next.
|
|
549
|
+
*/
|
|
550
|
+
const RefundRaised = z
|
|
551
|
+
.object({
|
|
552
|
+
outcome: z.literal("requested").describe("This call raised the adjustment."),
|
|
553
|
+
purchaseId: z
|
|
554
|
+
.string()
|
|
555
|
+
.min(1)
|
|
556
|
+
.describe("Which payment it is against — the purchase row's own id, never the store's transaction id."),
|
|
557
|
+
adjustmentId: z
|
|
558
|
+
.string()
|
|
559
|
+
.min(1)
|
|
560
|
+
.describe("The store's own id for the request. The only handle an operator has on money in flight."),
|
|
561
|
+
status: RefundRequestStatus.describe("What the store said about it at the moment it was raised."),
|
|
562
|
+
})
|
|
563
|
+
.describe("A refund request this call raised. It is a request: nothing here says the customer has been paid.");
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* A refund that was already standing before this call, so nothing was sent for it.
|
|
567
|
+
*
|
|
568
|
+
* **The no-op, per payment**, and it is the same rule the other four verbs follow: a client that lost a
|
|
569
|
+
* response and sent the same instruction again is not in conflict with anything, and a second delivery of
|
|
570
|
+
* one intent must not become a second refund. Paddle refuses a stacked adjustment anyway — *"You can't
|
|
571
|
+
* create an adjustment for a transaction that has a refund that's pending approval"* — and being told so
|
|
572
|
+
* by the store, mid-set, after other adjustments have already been raised, is the worst place to learn it.
|
|
573
|
+
* So it is decided here, before anything is sent, and reported rather than thrown.
|
|
574
|
+
*
|
|
575
|
+
* Its own member rather than a flag on {@link RefundRaised}, because "this call did it" and "it was
|
|
576
|
+
* already done" are different answers to *who acted*, and a boolean beside an outcome is exactly what a
|
|
577
|
+
* discriminated union exists to stop being ignored.
|
|
578
|
+
*/
|
|
579
|
+
const RefundAlreadyStanding = z
|
|
580
|
+
.object({
|
|
581
|
+
outcome: z.literal("already_requested").describe("A refund was already standing at the store. Nothing was sent."),
|
|
582
|
+
purchaseId: z.string().min(1).describe("Which payment it is against — the purchase row's own id."),
|
|
583
|
+
adjustmentId: z.string().min(1).describe("The store's id for the refund that was already there."),
|
|
584
|
+
status: RefundRequestStatus.describe("Where that standing refund is — awaiting review, or already approved."),
|
|
585
|
+
})
|
|
586
|
+
.describe(
|
|
587
|
+
"A payment that already had a refund against it. Reported, not refused: it is the state the caller asked for.",
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* A refund the store refused, **after at least one other in the same set had already been raised.**
|
|
592
|
+
*
|
|
593
|
+
* Unreachable before the first write, and that is the whole point of the member. Everything knowable in
|
|
594
|
+
* advance — a transaction the store will not refund, a set too large to send in one request — refuses the
|
|
595
|
+
* request outright and sends nothing. Once an adjustment exists, a throw would tell the caller the refund
|
|
596
|
+
* failed while money is on its way back to them, which is the silent partial this shape exists to make
|
|
597
|
+
* impossible. So after the first write, everything is reported.
|
|
598
|
+
*
|
|
599
|
+
* It carries no store sentence. The reason is written for an operator and rides in the audit trail and in
|
|
600
|
+
* a refusal's `detail`; a customer's screen is told which payment, and that it did not go through.
|
|
601
|
+
*/
|
|
602
|
+
const RefundNotRaised = z
|
|
603
|
+
.object({
|
|
604
|
+
outcome: z.literal("failed").describe("The store refused this one, after another in the same set was raised."),
|
|
605
|
+
purchaseId: z.string().min(1).describe("Which payment it is against — the purchase row's own id."),
|
|
606
|
+
reason: z
|
|
607
|
+
.string()
|
|
608
|
+
.min(1)
|
|
609
|
+
.describe(
|
|
610
|
+
"Why, in an operator's words. Throw-site context: it belongs in a trail and in a log, never on a wire.",
|
|
611
|
+
),
|
|
612
|
+
})
|
|
613
|
+
.describe(
|
|
614
|
+
"One payment the store would not refund, in a set where others were. Reported so a partial cannot be silent.",
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
/** What became of one payment. Three outcomes, and every payment asked about has exactly one. */
|
|
618
|
+
export const RefundRequestOutcome = z
|
|
619
|
+
.discriminatedUnion("outcome", [RefundRaised, RefundAlreadyStanding, RefundNotRaised])
|
|
620
|
+
.describe(
|
|
621
|
+
"What became of one payment: a refund was raised, one was already standing, or the store refused it. The discriminant is what stops a report being read as a success.",
|
|
622
|
+
);
|
|
623
|
+
export type RefundRequestOutcome = z.output<typeof RefundRequestOutcome>;
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* What came of asking for a subscription's payments back — **one outcome per payment asked about, always.**
|
|
627
|
+
*
|
|
628
|
+
* ## Refunds attach to transactions, so the seam takes a set
|
|
629
|
+
*
|
|
630
|
+
* There is no such thing as refunding a subscription. Every store raises a refund against a *transaction*,
|
|
631
|
+
* and a subscription is a family of them. The case is ordinary rather than exotic: a customer who joined on
|
|
632
|
+
* Solo at 6.00, upgraded to Team on day 10 for a 65.82 proration, and cancels on day 13 has paid twice, and
|
|
633
|
+
* an adopter's refund policy owes them both. One adjustment per transaction, one report covering all of them.
|
|
634
|
+
*
|
|
635
|
+
* ## All-or-nothing before the first write; a complete report after it
|
|
636
|
+
*
|
|
637
|
+
* The obvious ask is all-or-nothing over the whole set, and **it cannot be built**: no store offers a batch
|
|
638
|
+
* adjustment and none offers a delete, so once the first adjustment is at `pending_approval` there is
|
|
639
|
+
* nothing that un-raises it. Pretending otherwise would mean choosing which lie to tell when the third of
|
|
640
|
+
* four fails.
|
|
641
|
+
*
|
|
642
|
+
* So the guarantee is split at the one line that is real — has this call written anything yet:
|
|
643
|
+
*
|
|
644
|
+
* - **Before the first write, all-or-nothing.** Every payment is checked at the store first. Anything that
|
|
645
|
+
* makes one unrefundable refuses the *whole request*, sends nothing, and throws
|
|
646
|
+
* `payments/subscription_change_refused`. A set too large to issue inside one request refuses the same
|
|
647
|
+
* way, for the same reason: a call that runs out of budget half way through is a partial by another name.
|
|
648
|
+
* - **After the first write, nothing throws.** The remaining failures become {@link RefundNotRaised}
|
|
649
|
+
* entries. The caller is told exactly which payments came back and which did not, because the alternative
|
|
650
|
+
* — an error over a state where money is already moving — is the silent partial success this whole shape
|
|
651
|
+
* is designed against.
|
|
652
|
+
*
|
|
653
|
+
* **What makes a partial impossible to miss is that this report is total.** One entry per payment asked
|
|
654
|
+
* about, in a deterministic order, so a caller counting entries and a caller counting payments get the same
|
|
655
|
+
* number. There is no shape of this answer that omits a payment.
|
|
656
|
+
*
|
|
657
|
+
* ## Nothing here revokes anything
|
|
658
|
+
*
|
|
659
|
+
* Not the entitlement, not the purchase row, not a projection. A refund that is approved arrives as a
|
|
660
|
+
* webhook, and `rails/paddle/adjustments.ts` and the projection writer already act on it — they are the
|
|
661
|
+
* only things that do. Revoking on the *request* would take a paying customer's access away over a refund
|
|
662
|
+
* Paddle then rejects, and the customer would have neither the money nor the product.
|
|
663
|
+
*/
|
|
664
|
+
export const RefundRequest = z
|
|
665
|
+
.object({
|
|
666
|
+
outcomes: z
|
|
667
|
+
.array(RefundRequestOutcome)
|
|
668
|
+
.describe(
|
|
669
|
+
"One entry per payment asked about, in the order asked — never a subset. A report shorter than the set is what a silent partial looks like, so the shape does not permit one.",
|
|
670
|
+
),
|
|
671
|
+
})
|
|
672
|
+
.describe(
|
|
673
|
+
"What came of asking for a subscription's payments back: one outcome per payment, none of which says the money has moved.",
|
|
674
|
+
);
|
|
675
|
+
export type RefundRequest = z.output<typeof RefundRequest>;
|