@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,939 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { CreatedDiscount, DiscountTerms, SubscriptionPricing } from "../data/discount";
|
|
5
|
+
import type { PaymentsPurchase } from "../data/purchase";
|
|
6
|
+
import type { PaymentsRail } from "../data/rail";
|
|
7
|
+
import type { PaymentsSubject } from "../data/subject";
|
|
8
|
+
import type {
|
|
9
|
+
RefundRequest,
|
|
10
|
+
SubscriptionCancelTiming,
|
|
11
|
+
SubscriptionChangeQuote,
|
|
12
|
+
SubscriptionStanding,
|
|
13
|
+
} from "../data/subscription";
|
|
14
|
+
import type { ProviderEventInput } from "../projection/event";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The one contract every rail implements, and the one it deliberately does not.
|
|
18
|
+
*
|
|
19
|
+
* A rail does three things: it verifies a receipt a client submitted, it parses a notification the store
|
|
20
|
+
* pushed, and it re-reads the current state of a purchase already projected. All three produce the same
|
|
21
|
+
* normalized event, so the projection writer never learns which store it came from — that is what lets one
|
|
22
|
+
* idempotent writer serve three stores and one entitlement resolve across them.
|
|
23
|
+
*
|
|
24
|
+
* ## Why `refresh` is on the shared contract and `createCheckoutSession` is not
|
|
25
|
+
*
|
|
26
|
+
* Every store can be asked what a purchase looks like now, and every store's answer is worth having: webhooks
|
|
27
|
+
* are dropped, push subscriptions are misconfigured, signing keys rotate, and a subscription can lapse with no
|
|
28
|
+
* notification arriving at all. So `refresh` is not a Stripe-shaped method the other two would stub — it is the
|
|
29
|
+
* question the reconciliation Workflow exists to ask, and all three rails answer it for real. A rail that
|
|
30
|
+
* cannot answer for one *particular* purchase returns `undefined`, which is a fact about that purchase rather
|
|
31
|
+
* than a hole in the rail.
|
|
32
|
+
*
|
|
33
|
+
* ## Why Stripe's session creation is a separate interface
|
|
34
|
+
*
|
|
35
|
+
* Stripe additionally creates the purchase. That asymmetry is real, and this contract does not paper over
|
|
36
|
+
* it. An Apple or Google purchase has **already happened inside the store SDK** by the time the server hears
|
|
37
|
+
* of it: the app presented the sheet, the user paid, and the server's job begins at a receipt. A Stripe
|
|
38
|
+
* purchase is one Pithy initiates, by creating a hosted Checkout Session and sending the browser to it.
|
|
39
|
+
*
|
|
40
|
+
* A single abstraction covering both would force Apple and Google to declare a `createCheckoutSession` with
|
|
41
|
+
* no meaning for them, and every such method is a lie a future maintainer has to read past — worse, one
|
|
42
|
+
* somebody eventually calls. So {@link CheckoutRail} is its own interface, and a rail that initiates
|
|
43
|
+
* purchases implements both. Nothing needs to widen when a fourth rail arrives on either side.
|
|
44
|
+
*
|
|
45
|
+
* ## Why managing a subscription is a third interface, and what changed to allow it
|
|
46
|
+
*
|
|
47
|
+
* {@link SubscriptionRail} is the same split for the same reason: a StoreKit or Play subscription is changed
|
|
48
|
+
* inside the store's own UI, on the customer's device, and a rail that had to declare `changePlan` to satisfy
|
|
49
|
+
* the shared contract would declare a method that cannot be written.
|
|
50
|
+
*
|
|
51
|
+
* It is also **the amendment of 2026-08-28 to #79's locked decision 2**, and the distinction is narrow enough
|
|
52
|
+
* to be worth stating precisely, because the docs it corrects said the wider thing. The kit may *invoke* a
|
|
53
|
+
* plan change and pass the provider's own figures through unmodified. It still never *computes* proration,
|
|
54
|
+
* tax, or SCA: no amount in {@link SubscriptionChangeQuote} is derived from another amount, nothing multiplies
|
|
55
|
+
* a price by a fraction of a period, and nothing checks the provider's arithmetic. The line is between asking
|
|
56
|
+
* a store what a change costs and answering that question ourselves — the second is a number a customer holds
|
|
57
|
+
* against their statement, and the statement is the one they will believe.
|
|
58
|
+
*
|
|
59
|
+
* ## Why a rail cannot name an owner
|
|
60
|
+
*
|
|
61
|
+
* Everything a rail returns is an {@link UnboundProviderEvent} — a provider event with **neither half** of the
|
|
62
|
+
* subject pair. That is a type-level fact, not a convention: a webhook arrives carrying the store's own
|
|
63
|
+
* identifier and nothing else, so a rail that could fill an owner in could be talked into filling in the wrong
|
|
64
|
+
* one. Binding happens in exactly one place, the route, from the authenticated caller through the configured
|
|
65
|
+
* subject seam or from the provider-account map.
|
|
66
|
+
*
|
|
67
|
+
* Omitting *both* halves is what makes the guarantee hold. A rail that could name a `subjectId` and leave the
|
|
68
|
+
* kind to be supplied from config would be pairing an id from a store with a type from somewhere else — and
|
|
69
|
+
* nothing keeps an organization id from equalling some user's, so the two together would grant one holder's
|
|
70
|
+
* subscription to the other. A subject is read and written whole. See `data/subject.ts`.
|
|
71
|
+
*
|
|
72
|
+
* The one thing a rail may report about ownership is an {@link VerifiedNotification.accountReference} — the
|
|
73
|
+
* encoded subject reference *this deployment's own server* attached when it created the purchase, echoed back
|
|
74
|
+
* by the store. That is a claim about the purchase rather than a rail's verdict on who holds it, and it is
|
|
75
|
+
* what a rail with no client-submission path needs: a Stripe purchase is only ever heard about through a
|
|
76
|
+
* webhook, so the pairing of `cus_…` with a subject has to arrive with the notification or never at all.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A normalized provider event with its owner left out — **both halves of it**. The rail knows everything about
|
|
81
|
+
* the transaction and nothing about who it belongs to, and the shape says so.
|
|
82
|
+
*
|
|
83
|
+
* Derived by omission rather than declared beside {@link ProviderEventInput}, so a field added to the event is
|
|
84
|
+
* a field every rail may report, and the only fields a rail may never report are the two named here.
|
|
85
|
+
*/
|
|
86
|
+
export type UnboundProviderEvent = Omit<ProviderEventInput, "subjectType" | "subjectId">;
|
|
87
|
+
|
|
88
|
+
/** What a rail needs from the request it is serving. The clock, and which deployment is asking. */
|
|
89
|
+
export interface RailRequestContext {
|
|
90
|
+
/** The clock, for certificate validity and freshness windows. Injected so verification is deterministic. */
|
|
91
|
+
now: Date;
|
|
92
|
+
/**
|
|
93
|
+
* This deployment's `ENVIRONMENT` var, verbatim — `dev`, `staging`, `prod` — or undefined.
|
|
94
|
+
*
|
|
95
|
+
* For a rail whose store is **one namespace across every environment**, which is the case for Lemon
|
|
96
|
+
* Squeezy: test mode is a flag on an object, not a separate store, so a `dev` deployment and a `staging`
|
|
97
|
+
* deployment pointed at one store both hear everything the other's buyers do. Such a rail stamps this
|
|
98
|
+
* value into the checkout it creates and reads it back off the webhook, and an event stamped for another
|
|
99
|
+
* deployment projects nothing.
|
|
100
|
+
*
|
|
101
|
+
* Deliberately the raw var and not `deploymentEnvironment`'s two-valued answer: that returns `sandbox`
|
|
102
|
+
* for `dev` and `staging` alike, so fencing on it would not separate the two environments this exists to
|
|
103
|
+
* separate. Optional because the other three stores partition by credential and have nothing to fence.
|
|
104
|
+
*/
|
|
105
|
+
deployment?: string;
|
|
106
|
+
/**
|
|
107
|
+
* The locale a figure in the answer is rendered for — `Translator.formattingLocale`, region and all.
|
|
108
|
+
*
|
|
109
|
+
* **One method reads it: {@link SubscriptionRail.previewChange}**, whose answer carries `rendered` on
|
|
110
|
+
* every amount, and it is here rather than on {@link SubscriptionChangeInput} because a locale is a fact
|
|
111
|
+
* about the reader of a response, not about the subscription being changed. Every other call on this
|
|
112
|
+
* context — a webhook, a verification, a refresh — answers a machine, and a machine has no locale.
|
|
113
|
+
*
|
|
114
|
+
* **Never a request field, and never `Accept-Language` read directly.** A locale a client can name in a
|
|
115
|
+
* body is a locale a client sets, and the header is only one of four inputs the kit's own negotiation
|
|
116
|
+
* weighs — reading it here would render the money in a language the rest of the same response is not
|
|
117
|
+
* written in. The route resolves it from the translator seam, which is what `@pithy-sh/email` does per
|
|
118
|
+
* recipient and what `@pithy-sh/i18n` fills in per request.
|
|
119
|
+
*
|
|
120
|
+
* Optional, and its absence is not an error: `data/renderMoney.ts` states the fallback rather than
|
|
121
|
+
* guessing one, because a quote in the wrong language is recoverable and a quote with no figure is not.
|
|
122
|
+
*/
|
|
123
|
+
locale?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One inbound notification, as bytes and headers rather than as a `Request`.
|
|
128
|
+
*
|
|
129
|
+
* Bytes because every rail's authenticity check covers the **exact received body** — Apple signs it, Stripe
|
|
130
|
+
* HMACs it — and re-serializing a parsed object would change what was signed. Headers because two of the
|
|
131
|
+
* three rails put their proof in one.
|
|
132
|
+
*
|
|
133
|
+
* Deliberately not a `Request`: a request body is a stream that can be read once, and a rail reading it
|
|
134
|
+
* would leave the route's own validator with nothing. The guard reads it once through Hono's body cache and
|
|
135
|
+
* hands the same string here, so the signature and the handler see the identical bytes.
|
|
136
|
+
*/
|
|
137
|
+
export interface WebhookDelivery {
|
|
138
|
+
/** The exact received body, decoded as UTF-8. */
|
|
139
|
+
body: string;
|
|
140
|
+
/** The delivery's headers — where Google's OIDC token and Stripe's signature live. */
|
|
141
|
+
headers: Headers;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A verified client submission: what was bought, and the store-account identifier to link it by. */
|
|
145
|
+
export interface VerifiedPurchase {
|
|
146
|
+
/** The transaction, normalized. The route binds the authenticated caller onto it. */
|
|
147
|
+
event: UnboundProviderEvent;
|
|
148
|
+
/**
|
|
149
|
+
* The store's own account identifier — Apple's `appAccountToken`, Google's `obfuscatedAccountId`, Stripe's
|
|
150
|
+
* customer id — or null when the purchase carried none. This is what a later webhook resolves a user
|
|
151
|
+
* through, so a submission that carries one is what stops that webhook arriving orphaned.
|
|
152
|
+
*/
|
|
153
|
+
providerAccountId: string | null;
|
|
154
|
+
/**
|
|
155
|
+
* The **encoded subject reference** this deployment's own server attached when it created the purchase,
|
|
156
|
+
* echoed back by the store — Stripe's `client_reference_id`, which the `/checkout` route sets from the
|
|
157
|
+
* caller's resolved subject. `user:ada`, `organization:acme`: the pair, as `encodeSubjectReference` in
|
|
158
|
+
* `data/subject.ts` writes it, and read back only by `decodeSubjectReference`. Never split by hand — one
|
|
159
|
+
* encoding with one decoder is what keeps a value stamped by one code path readable by another.
|
|
160
|
+
*
|
|
161
|
+
* Set only by a rail whose purchases Pithy initiates. Apple's `appAccountToken` and Google's
|
|
162
|
+
* `obfuscatedAccountId` deliberately do **not** go here: those are set by the *app*, which may put anything
|
|
163
|
+
* in them, so treating one as an owner would make a client's choice of value decide who a purchase belongs
|
|
164
|
+
* to. This is the value a server wrote and a store returned unchanged.
|
|
165
|
+
*
|
|
166
|
+
* A string all the same, and untrusted all the same: it comes back as bytes from a store. **It decodes or
|
|
167
|
+
* it names nobody** — a bare id, the shape every pre-subject client sent, is not a user, and a kind this
|
|
168
|
+
* build does not know is not a kind. The route uses it to refuse a submission whose own reference names
|
|
169
|
+
* somebody else, before projecting it.
|
|
170
|
+
*/
|
|
171
|
+
accountReference?: string | null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A verified notification: authentic, recorded, and sometimes carrying no state change at all. */
|
|
175
|
+
export interface VerifiedNotification {
|
|
176
|
+
/**
|
|
177
|
+
* The rail's own event id — Apple's `notificationUUID`, Stripe's event id. `UNIQUE (rail,
|
|
178
|
+
* providerEventId)` is what makes a redelivery recognized rather than reprocessed, and all three providers
|
|
179
|
+
* deliver at-least-once.
|
|
180
|
+
*/
|
|
181
|
+
providerEventId: string;
|
|
182
|
+
/** The notification as received, stored whole. What makes "why didn't this renew" answerable. */
|
|
183
|
+
payload: Record<string, unknown>;
|
|
184
|
+
/**
|
|
185
|
+
* The transaction the notification reports, or **null when it reports no transaction state**. Null is a
|
|
186
|
+
* successful outcome, not a failure: a test notification, a consumption request, a declined refund, and a
|
|
187
|
+
* type the store shipped after this package did are all authentic and all change nothing. The row is still
|
|
188
|
+
* recorded, so the reconciliation pass can see what arrived.
|
|
189
|
+
*/
|
|
190
|
+
event: UnboundProviderEvent | null;
|
|
191
|
+
/** The store account identifier the notification carried, or null. */
|
|
192
|
+
providerAccountId: string | null;
|
|
193
|
+
/**
|
|
194
|
+
* A refund the rail reported by **order id alone**, with no state attached — or null.
|
|
195
|
+
*
|
|
196
|
+
* Google's voided-purchase notification is the only one shaped this way. It names an order and says it was
|
|
197
|
+
* refunded, and Play offers no lookup that turns it back into a purchase: the one-time endpoint takes the
|
|
198
|
+
* product id as a path segment, and the notification does not carry one. So the rail cannot produce an
|
|
199
|
+
* `event`, and for a while this was recorded and dropped — leaving a refunded non-consumable granting its
|
|
200
|
+
* entitlement forever.
|
|
201
|
+
*
|
|
202
|
+
* The missing product was never in Play, though. It is in **our** row: an order id is exactly what a Google
|
|
203
|
+
* purchase's `providerTransactionId` is. So the rail reports the order and the route, which owns the
|
|
204
|
+
* database, resolves it — the same division as everywhere else, where a rail knows the store and the route
|
|
205
|
+
* knows the projection. An authentic void is itself the refund record, in the same way Apple's `REFUND`
|
|
206
|
+
* notification is; nothing needs to re-ask the store what it already said.
|
|
207
|
+
*/
|
|
208
|
+
voidedOrderId?: string | null;
|
|
209
|
+
/**
|
|
210
|
+
* The encoded subject reference this deployment attached when it created the purchase, echoed back — see
|
|
211
|
+
* {@link VerifiedPurchase.accountReference} for the encoding, and for why this is not the same as a rail
|
|
212
|
+
* naming an owner.
|
|
213
|
+
*
|
|
214
|
+
* On a rail with no client-submission path this is the **only** way the account map is ever populated: a
|
|
215
|
+
* Stripe webhook arrives carrying `cus_…`, and the pairing with a subject exists nowhere else. The route
|
|
216
|
+
* decodes it, writes the link from it, and `linkProviderAccount` never rebinds, so the first pairing wins.
|
|
217
|
+
*
|
|
218
|
+
* **It is ranked last in the owner trust order, and it fails closed.** Last because everything above it in
|
|
219
|
+
* `projection/owner.ts` is a fact this server established about a purchase it already projected, and this is
|
|
220
|
+
* a string that made a round trip through somebody else's system — Paddle's is stamped with a MAC precisely
|
|
221
|
+
* because a browser can overwrite it. Closed because `decodeSubjectReference` returns `undefined` for
|
|
222
|
+
* anything that is not exactly the encoding, and `undefined` means the event is recorded as an **orphan**,
|
|
223
|
+
* replayable when an account links, granting nothing. A decode miss is never read as a bare user id: that
|
|
224
|
+
* guess would attribute one customer's renewal to whoever happens to hold the id they sent.
|
|
225
|
+
*/
|
|
226
|
+
accountReference?: string | null;
|
|
227
|
+
/**
|
|
228
|
+
* A second event the same notification implies, describing the **subscription's standing** where `event`
|
|
229
|
+
* describes a **charge** — or null, which is the case on every rail but one.
|
|
230
|
+
*
|
|
231
|
+
* Exists because one store reports the two separately and neither message names the other's key. A Lemon
|
|
232
|
+
* Squeezy `subscription_payment_*` webhook carries an invoice: the money, and nothing about whether the
|
|
233
|
+
* subscription is still live. Its `subscription_*` webhooks carry the standing and no money. So that rail
|
|
234
|
+
* writes two rows — see {@link PurchaseRole} — and a notification that changes both has to say both.
|
|
235
|
+
*
|
|
236
|
+
* The case that forces it is a refund. Lemon Squeezy is a merchant of record and issues refunds on its own,
|
|
237
|
+
* for a chargeback or a tax dispute, with no local write preceding it; the invoice row must go `refunded` so
|
|
238
|
+
* the ledger claws back, *and* the subscription must stop granting, because the buyer has their money back.
|
|
239
|
+
* One event could do one or the other, and doing only the first leaves a refunded subscriber with the
|
|
240
|
+
* feature.
|
|
241
|
+
*
|
|
242
|
+
* The route projects it with the same owner as `event` and after it. It is never fulfilled for credit — a
|
|
243
|
+
* `state` row is not a charge, so `purchaseIsPaid` and the clawback both refuse it by role.
|
|
244
|
+
*/
|
|
245
|
+
stateEvent?: UnboundProviderEvent | null;
|
|
246
|
+
/**
|
|
247
|
+
* Why an authentic notification produced no event, when that is worth recording — written to the webhook row's
|
|
248
|
+
* `error` column so it is queryable and the reconciliation pass can act on it.
|
|
249
|
+
*
|
|
250
|
+
* Null when there is nothing to say, which is the common case: a test notification is exactly what it looks
|
|
251
|
+
* like. It earns its place on the rails that *can* be authentically told about a purchase they cannot resolve
|
|
252
|
+
* — Play's voided-purchase notification names no product, so the delivery is real, the refund is real, and
|
|
253
|
+
* neither is projectable without a lookup only the Workflow can make. A row recording that with its reason is
|
|
254
|
+
* the difference between a repairable gap and a silent drop.
|
|
255
|
+
*
|
|
256
|
+
* **Which of the two it is decides whether the row is finished** — see {@link NotificationNote}.
|
|
257
|
+
*/
|
|
258
|
+
note?: NotificationNote | null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Why there is no event, and **where that answer came from**. Two cases, because there are two.
|
|
263
|
+
*
|
|
264
|
+
* `{ stated }` — the delivered bytes say it. A partial refund that takes nothing away, a notification type
|
|
265
|
+
* this build does not act on, an order Play reports voided. The same bytes get the same answer from the same
|
|
266
|
+
* build for ever, so the row is **finished** and a redelivery is answered `duplicate`.
|
|
267
|
+
*
|
|
268
|
+
* `{ read }` — a call to the provider came back empty and this note is that answer. Play has no purchase
|
|
269
|
+
* under the token, Lemon Squeezy no longer knows the subscription, Paddle will not show the transaction an
|
|
270
|
+
* adjustment names. **Repairable**, and this is #341: three rails derived a note this way and it finished the
|
|
271
|
+
* row. A read that answers "no such thing" can be a race — an RTDN outrunning Play's own read-after-write, a
|
|
272
|
+
* key rotated mid-flight, a shared sandbox — so the second answer differs from the first, and the delivery
|
|
273
|
+
* that would have carried it was already being told it was a duplicate.
|
|
274
|
+
*
|
|
275
|
+
* **A note is safe as terminal only when it comes from a source that cannot have failed.** The delivered
|
|
276
|
+
* bytes are such a source; a read is not. So the provenance travels with the text rather than being inferred
|
|
277
|
+
* at the far end, and the two are one field so no rail can claim both.
|
|
278
|
+
*
|
|
279
|
+
* The same rule applies wherever a note finishes a row: the webhook handler and the Paddle events sweep both
|
|
280
|
+
* read this, and both treat `read` as a repairable failure rather than a completion.
|
|
281
|
+
*/
|
|
282
|
+
export type NotificationNote =
|
|
283
|
+
| {
|
|
284
|
+
/** What the delivered bytes say. Terminal. */
|
|
285
|
+
readonly stated: string;
|
|
286
|
+
}
|
|
287
|
+
| {
|
|
288
|
+
/** What a provider read answered, and could answer differently next time. Repairable. */
|
|
289
|
+
readonly read: string;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/** The note's text, whichever kind it is. An operator reads the two the same way; the row does not. */
|
|
293
|
+
export function noteText(note: NotificationNote | null | undefined): string | undefined {
|
|
294
|
+
if (note === null || note === undefined) return undefined;
|
|
295
|
+
return "stated" in note ? note.stated : note.read;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Whether this note leaves the delivery repairable — true only for one a read produced. */
|
|
299
|
+
export function noteIsRepairable(note: NotificationNote | null | undefined): boolean {
|
|
300
|
+
return note !== null && note !== undefined && "read" in note;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Every rail. Verification of a client submission, parsing of a pushed notification, and a state re-read. */
|
|
304
|
+
export interface PaymentsRailProvider {
|
|
305
|
+
/** Which store this provider speaks for. */
|
|
306
|
+
readonly rail: PaymentsRail;
|
|
307
|
+
/**
|
|
308
|
+
* Verify a receipt or signed transaction a client submitted, exactly as the store SDK returned it. Throws
|
|
309
|
+
* `payments/invalid_receipt` when it cannot be read and `payments/verification_failed` when the store does
|
|
310
|
+
* not vouch for it.
|
|
311
|
+
*/
|
|
312
|
+
verify(receipt: string, context: RailRequestContext): Promise<VerifiedPurchase>;
|
|
313
|
+
/**
|
|
314
|
+
* Verify and parse a pushed notification. Takes the delivery's bytes and headers rather than a parsed
|
|
315
|
+
* object, because authenticity lives in different places per rail — Apple signs the body, Google puts an
|
|
316
|
+
* OIDC token in a header, Stripe puts an HMAC in one — and every one of those covers the exact received
|
|
317
|
+
* bytes. Throws when authenticity cannot be established; the guard maps that to
|
|
318
|
+
* `payments/webhook_unverified`.
|
|
319
|
+
*/
|
|
320
|
+
parseNotification(delivery: WebhookDelivery, context: RailRequestContext): Promise<VerifiedNotification>;
|
|
321
|
+
/**
|
|
322
|
+
* Re-read the current state of a purchase this deployment already projected — the reconciliation path.
|
|
323
|
+
*
|
|
324
|
+
* Takes the stored row rather than a handful of ids, because what identifies a purchase at its store differs
|
|
325
|
+
* per rail and some of it survives only in the payload: Apple keys a subscription family on
|
|
326
|
+
* `originalTransactionId`, Play keys one on the purchase token, and Stripe on `sub_…`. Handing the whole row
|
|
327
|
+
* over means adding a rail never widens this signature.
|
|
328
|
+
*
|
|
329
|
+
* **The returned event's `providerEventAt` is the clock, not a store timestamp**, and that is deliberate. A
|
|
330
|
+
* refresh is a read of the state *now*, so it is the freshest fact anyone has — dating it earlier would let
|
|
331
|
+
* the monotonic write rule discard the very repair the run exists to make.
|
|
332
|
+
*
|
|
333
|
+
* `undefined` means the store has nothing to say about this purchase: the rail cannot address it (a one-time
|
|
334
|
+
* purchase whose store offers no lookup), or the store no longer knows it. That is a normal answer and leaves
|
|
335
|
+
* the row exactly as it stood. A store that cannot be *reached* throws `payments/provider_unavailable`
|
|
336
|
+
* instead, which is what tells the Workflow to fail the step and retry rather than record a repair that
|
|
337
|
+
* never happened.
|
|
338
|
+
*/
|
|
339
|
+
refresh(purchase: PaymentsPurchase, context: RailRequestContext): Promise<UnboundProviderEvent | undefined>;
|
|
340
|
+
/**
|
|
341
|
+
* Re-read a notification body this package already recorded, **without re-establishing authenticity**.
|
|
342
|
+
*
|
|
343
|
+
* The row it comes from was written by a delivery that verified, or by a sweep reading the store's own
|
|
344
|
+
* event stream over an authenticated connection, so the bytes are already vouched for. Re-verifying is not
|
|
345
|
+
* merely redundant — it is impossible: a signature covers headers this table never stored, and a JWS has a
|
|
346
|
+
* validity window that expired long ago.
|
|
347
|
+
*
|
|
348
|
+
* It exists for one caller: the repair that runs when an account links. An orphan is a purchase with
|
|
349
|
+
* nobody to project it against, and the only thing that changes is a `provider_accounts` row appearing
|
|
350
|
+
* later. Nothing about the event changes, so the payload is where the purchase is — which is precisely
|
|
351
|
+
* what {@link VerifiedNotification.payload} is stored whole for.
|
|
352
|
+
*
|
|
353
|
+
* **Optional, and a rail that cannot do it says so by not implementing it.** Apple and Google store a
|
|
354
|
+
* signed blob whose claims this build reads only through a verifier, and Lemon Squeezy's parse needs a live
|
|
355
|
+
* subscription read; a stub returning a half-read event would be worse than the absence. Those rails'
|
|
356
|
+
* orphans are repaired by the store's own redelivery, as they were.
|
|
357
|
+
*
|
|
358
|
+
* `undefined` means the payload is not one this rail can replay — a shape from an older build, an event
|
|
359
|
+
* type since dropped. That is a fact about the row, not a failure, and the repair leaves it exactly as it
|
|
360
|
+
* stood.
|
|
361
|
+
*/
|
|
362
|
+
replay?(payload: Record<string, unknown>, context: RailRequestContext): Promise<VerifiedNotification | undefined>;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** What a hosted checkout session needs. The catalog product is resolved before this is called. */
|
|
366
|
+
export interface CheckoutSessionInput {
|
|
367
|
+
/** The rail's own price identifier for the product being bought. */
|
|
368
|
+
providerProductId: string;
|
|
369
|
+
/** Whether this buys a recurring subscription or a one-off. Decides the hosted flow the store presents. */
|
|
370
|
+
subscription: boolean;
|
|
371
|
+
/**
|
|
372
|
+
* The subject the purchase is for — the authenticated caller, or the organization the project bills on
|
|
373
|
+
* their behalf, as the configured subject seam resolved it.
|
|
374
|
+
*
|
|
375
|
+
* Passed as the pair rather than an id so the rail stamps `encodeSubjectReference(input.subject)` into the
|
|
376
|
+
* checkout it creates, and the webhook that follows arrives already naming a holder this server chose. A
|
|
377
|
+
* rail that received an id alone would have to pair it with a kind from config to build that reference,
|
|
378
|
+
* which is the one pairing `data/subject.ts` forbids.
|
|
379
|
+
*/
|
|
380
|
+
subject: PaymentsSubject;
|
|
381
|
+
/**
|
|
382
|
+
* The store account this caller already has, from the provider-account map, or null on a first purchase.
|
|
383
|
+
*
|
|
384
|
+
* Passing it is what keeps one buyer to one store account. Without it every checkout creates a fresh
|
|
385
|
+
* customer, and a user's second purchase would be invisible from the billing portal their first one made.
|
|
386
|
+
*/
|
|
387
|
+
providerAccountId?: string | null;
|
|
388
|
+
/** Where the store sends the browser after a completed purchase. */
|
|
389
|
+
successUrl: string;
|
|
390
|
+
/**
|
|
391
|
+
* Where the store sends the browser if the purchase is abandoned, or undefined for a store that offers
|
|
392
|
+
* nowhere to abandon *to*.
|
|
393
|
+
*
|
|
394
|
+
* Optional for the same reason {@link PortalSessionInput.returnUrl} is: Lemon Squeezy's hosted checkout
|
|
395
|
+
* has no cancel destination — a buyer who backs out closes the tab — and a required field there would
|
|
396
|
+
* mean asking an adopter for a URL the rail then silently discarded.
|
|
397
|
+
*/
|
|
398
|
+
cancelUrl?: string;
|
|
399
|
+
/**
|
|
400
|
+
* A discount code to apply, passed to the store **unchanged**, or undefined.
|
|
401
|
+
*
|
|
402
|
+
* The store calculates the price. Pithy never computes a discounted amount and never checks a code against
|
|
403
|
+
* anything of its own: the provider is the authority on what is owed, and a second calculation here would
|
|
404
|
+
* be a second answer to the one question a customer will check against their card statement.
|
|
405
|
+
*
|
|
406
|
+
* An invalid or expired code therefore fails at the store, and the rail turns that into
|
|
407
|
+
* `payments/discount_invalid` naming the code — distinctly from a payment failure, because a customer told
|
|
408
|
+
* "something went wrong" at checkout concludes their card was declined.
|
|
409
|
+
*/
|
|
410
|
+
discountCode?: string;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** What a billing-portal session needs: the store account whose subscription is being managed. */
|
|
414
|
+
export interface PortalSessionInput {
|
|
415
|
+
/** The store's own account identifier for the caller, resolved from the provider-account map. */
|
|
416
|
+
providerAccountId: string;
|
|
417
|
+
/**
|
|
418
|
+
* Where the store sends the browser when the user is done, or undefined for a store that offers nowhere
|
|
419
|
+
* to return to.
|
|
420
|
+
*
|
|
421
|
+
* Optional because one store genuinely is shaped that way, and saying so in the type is better than the
|
|
422
|
+
* alternative. Lemon Squeezy's customer portal is a signed, expiring link read off the customer object;
|
|
423
|
+
* it takes no return parameter, and the subscriber closes the tab. A required field there would leave
|
|
424
|
+
* the rail silently discarding a URL an adopter configured and believed in — the failure would be a
|
|
425
|
+
* button that goes nowhere rather than a type error. So the contract admits the gap, and a project
|
|
426
|
+
* running that rail alone is never asked for a URL nothing will use.
|
|
427
|
+
*/
|
|
428
|
+
returnUrl?: string;
|
|
429
|
+
/**
|
|
430
|
+
* The store's own subscription ids this caller owns, for a store that mints per-subscription deep links.
|
|
431
|
+
*
|
|
432
|
+
* **Resolved by the route from the caller's own purchase rows, never from a request body.** `/portal`
|
|
433
|
+
* takes no body at all, and that is the request contract that makes the whole surface safe: a field here
|
|
434
|
+
* naming a subscription would let any signed-in caller ask for authenticated cancel links to somebody
|
|
435
|
+
* else's — the exact vulnerability {@link providerAccountId} coming from the account map exists to close.
|
|
436
|
+
*
|
|
437
|
+
* Undefined for the rails whose portal is one page for the whole account.
|
|
438
|
+
*/
|
|
439
|
+
subscriptionIds?: readonly string[];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* How a browser is handed to a store's payment page. Pithy never owns payment UI, SCA, or tax, and never
|
|
444
|
+
* *computes* proration.
|
|
445
|
+
*
|
|
446
|
+
* **"Never computes" is narrower than what this line used to say, and the narrowing is deliberate** (#79's
|
|
447
|
+
* locked decision 2, amended 2026-08-28). A checkout still names no amount and no quantity: the price is the
|
|
448
|
+
* price, and a checkout that could carry a figure is a checkout a client could put a figure on. What the kit
|
|
449
|
+
* may now do is *invoke* a plan change on an existing subscription and pass the provider's own preview through
|
|
450
|
+
* unmodified — see {@link SubscriptionRail}, which is a separate interface reached by a separate route, and
|
|
451
|
+
* which produces no figure of its own either.
|
|
452
|
+
*
|
|
453
|
+
* **A union, because one store has no URL to give.** Stripe and Lemon Squeezy both mint a hosted page and
|
|
454
|
+
* answer with its address, and for eleven months that was the whole shape — `{ url }`. Paddle's overlay and
|
|
455
|
+
* inline modes never leave the adopter's page: the server creates a transaction, and the browser opens it
|
|
456
|
+
* with Paddle.js against a publishable client token. There is no address to send anyone to, so a `{ url }`
|
|
457
|
+
* return could only be filled with a lie or with an empty string.
|
|
458
|
+
*
|
|
459
|
+
* `redirect` is what the two older rails return, unchanged in every field. The widening costs them nothing,
|
|
460
|
+
* and a screen that only ever handled a redirect keeps working by narrowing on `kind` — which is a compile
|
|
461
|
+
* error where it is missing rather than a runtime surprise.
|
|
462
|
+
*/
|
|
463
|
+
export type CheckoutHandoff =
|
|
464
|
+
| {
|
|
465
|
+
/** A hosted page the browser is sent to. */
|
|
466
|
+
kind: "redirect";
|
|
467
|
+
/** The absolute URL to send the browser to. */
|
|
468
|
+
url: string;
|
|
469
|
+
}
|
|
470
|
+
| {
|
|
471
|
+
/** A transaction the browser opens with Paddle.js, over the adopter's own page. */
|
|
472
|
+
kind: "paddle";
|
|
473
|
+
/** The transaction the server created — `txn_…`. What `Paddle.Checkout.open` is given. */
|
|
474
|
+
transactionId: string;
|
|
475
|
+
/**
|
|
476
|
+
* The publishable client token Paddle.js initializes with — `live_…` or `test_…`.
|
|
477
|
+
*
|
|
478
|
+
* Publishable by design, exactly as a Stripe price id is: it is what a browser needs to open a
|
|
479
|
+
* checkout, and nothing about verification depends on its secrecy. The API key and the webhook
|
|
480
|
+
* signing secret are secrets and never appear here.
|
|
481
|
+
*/
|
|
482
|
+
clientToken: string;
|
|
483
|
+
/** Which Paddle environment the token belongs to. `Paddle.Environment.set` takes it verbatim. */
|
|
484
|
+
environment: "sandbox" | "production";
|
|
485
|
+
/** Whether the checkout opens over the page or inside a container the screen provides. */
|
|
486
|
+
displayMode: "overlay" | "inline";
|
|
487
|
+
/**
|
|
488
|
+
* Where a buyer who paid is sent, from `config.paddle.successUrl`.
|
|
489
|
+
*
|
|
490
|
+
* On the handoff because Paddle.js takes it as `settings.successUrl` at the moment the checkout is
|
|
491
|
+
* opened, and that moment is in the browser. It still comes from config and never from a request:
|
|
492
|
+
* a client that could name a return URL could send a paying customer to a page it controls.
|
|
493
|
+
*/
|
|
494
|
+
successUrl: string;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
/** One subscription's authenticated portal deep links, as a store hands them back. */
|
|
498
|
+
export interface PortalSubscriptionLinks {
|
|
499
|
+
/** The store's own subscription id — `sub_…`. */
|
|
500
|
+
subscriptionId: string;
|
|
501
|
+
/** Where this subscription is canceled. */
|
|
502
|
+
cancel: string;
|
|
503
|
+
/** Where this subscription's payment method is changed. */
|
|
504
|
+
updatePaymentMethod: string;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* A billing portal, as a store returns it.
|
|
509
|
+
*
|
|
510
|
+
* `url` is the overview page every rail has. `subscriptions` is the part only Paddle offers: authenticated
|
|
511
|
+
* deep links straight to one subscription's cancel and update-payment-method screens, so a subscription
|
|
512
|
+
* screen can render per-subscription actions rather than one "Manage billing" button.
|
|
513
|
+
*
|
|
514
|
+
* **Every URL here is a bearer credential for that customer's billing, and Paddle's is good for 24 hours.**
|
|
515
|
+
* Its overview link carries a `pga_` JWT whose `iat` and `exp` are 86400 seconds apart, with scopes
|
|
516
|
+
* covering `customer.subscription.update`, `customer.customer.update` and `customer.transaction.create` —
|
|
517
|
+
* verified against a live sandbox session, and materially different from the "single-use, short-lived" the
|
|
518
|
+
* issue described. So: never cached, never persisted, never logged, and never a redirect target something
|
|
519
|
+
* else could read out of a `Referer`.
|
|
520
|
+
*/
|
|
521
|
+
export interface PortalHandoff {
|
|
522
|
+
/** The portal's overview page for this customer. */
|
|
523
|
+
url: string;
|
|
524
|
+
/** Per-subscription deep links, when the store offers them. Absent on the rails that do not. */
|
|
525
|
+
subscriptions?: readonly PortalSubscriptionLinks[];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The rails that *initiate* a purchase rather than merely hearing about one — Stripe, Lemon Squeezy, Paddle.
|
|
530
|
+
*
|
|
531
|
+
* Deliberately separate from {@link PaymentsRailProvider}: see the module doc. A rail implements both
|
|
532
|
+
* interfaces or only the first, and the route that needs a checkout session narrows to this one.
|
|
533
|
+
*/
|
|
534
|
+
export interface CheckoutRail {
|
|
535
|
+
/** Create a checkout for one product, and say how the browser reaches it. */
|
|
536
|
+
createCheckoutSession(input: CheckoutSessionInput, context: RailRequestContext): Promise<CheckoutHandoff>;
|
|
537
|
+
/** Create a billing-portal session, where a subscriber manages or cancels. */
|
|
538
|
+
createPortalSession(input: PortalSessionInput, context: RailRequestContext): Promise<PortalHandoff>;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Whether a rail provider also initiates purchases. The one narrowing `/checkout` and `/portal` need. */
|
|
542
|
+
export function isCheckoutRail(provider: PaymentsRailProvider): provider is PaymentsRailProvider & CheckoutRail {
|
|
543
|
+
return (
|
|
544
|
+
typeof (provider as Partial<CheckoutRail>).createCheckoutSession === "function" &&
|
|
545
|
+
typeof (provider as Partial<CheckoutRail>).createPortalSession === "function"
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* A rail that can *mint* a discount, as distinct from one that can merely apply one.
|
|
551
|
+
*
|
|
552
|
+
* **Its own interface, and the separation is load-bearing rather than tidy.** Applying a code must work with
|
|
553
|
+
* creation unimplemented — an adopter whose codes are minted by hand in a provider dashboard is fully served
|
|
554
|
+
* by the apply half, and making that half depend on this one would hold it hostage to a surface they never
|
|
555
|
+
* asked for. So `discountCode` rides on {@link CheckoutSessionInput}, which every checkout rail already
|
|
556
|
+
* takes, and this is a separate question asked separately.
|
|
557
|
+
*
|
|
558
|
+
* It is also the more dangerous half. It writes money-affecting objects into a payment provider through one
|
|
559
|
+
* shape over two APIs that agree on the concept and disagree on nearly every field, and the failures are
|
|
560
|
+
* quiet ones that surface on a customer's statement — see `data/discount.ts` for the three that were
|
|
561
|
+
* designed against. Minting a discount is an administrative act with a cost attached, which is why the route
|
|
562
|
+
* behind it carries its own scope and its own audit event rather than riding on an existing one.
|
|
563
|
+
*/
|
|
564
|
+
export interface DiscountRail {
|
|
565
|
+
/**
|
|
566
|
+
* Create a discount at the store and return what it made.
|
|
567
|
+
*
|
|
568
|
+
* Throws `payments/discount_invalid` when the store refuses the terms, with the store's own reason in
|
|
569
|
+
* `detail`. Nothing here validates the terms a second time — `DiscountTerms` has already refused the
|
|
570
|
+
* combinations that are wrong on their face, and the store owns the rest.
|
|
571
|
+
*/
|
|
572
|
+
createDiscount(terms: DiscountTerms, context: RailRequestContext): Promise<CreatedDiscount>;
|
|
573
|
+
/**
|
|
574
|
+
* The discount codes this store holds, newest first.
|
|
575
|
+
*
|
|
576
|
+
* A management client that can mint a code must be able to see what it minted — otherwise a pane over
|
|
577
|
+
* them computes *absent* rather than blocked, which no grant repairs (#247). Read from the store rather
|
|
578
|
+
* than from a table of ours: the store is where a code actually exists, and a local mirror would be a
|
|
579
|
+
* second answer that drifts the first time somebody uses the dashboard.
|
|
580
|
+
*
|
|
581
|
+
* Never reaches a browser. The set of codes an adopter has issued is a commercial fact, and the client
|
|
582
|
+
* projection draws the same line here it draws for SKUs and the `grants` block.
|
|
583
|
+
*/
|
|
584
|
+
listDiscounts(context: RailRequestContext): Promise<readonly ListedDiscount[]>;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** One discount as a store lists it. Deliberately less than {@link CreatedDiscount} — a list is not a receipt. */
|
|
588
|
+
export interface ListedDiscount {
|
|
589
|
+
/** The code a customer enters. */
|
|
590
|
+
code: string;
|
|
591
|
+
/** The store's own id. */
|
|
592
|
+
providerDiscountId: string;
|
|
593
|
+
/** How much comes off, rendered for a person — `20%`, `500 usd`. The store's own figures. */
|
|
594
|
+
amount: string;
|
|
595
|
+
/** How many times it has been claimed, when the store reports it. */
|
|
596
|
+
redemptions: number | null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* A rail that can say what a subscription pays now, what it becomes, and when.
|
|
601
|
+
*
|
|
602
|
+
* Separate from {@link DiscountRail} because they are separate abilities: a rail that can apply a code an
|
|
603
|
+
* adopter minted by hand must be able to report the resulting rate, whether or not it can mint one. Separate
|
|
604
|
+
* from the shared contract because Apple and Google have no equivalent — a store SDK subscription's price
|
|
605
|
+
* changes are the store's business and it does not publish a "what this becomes" figure.
|
|
606
|
+
*
|
|
607
|
+
* **This is the half that stops a bill changing unannounced.** A capability that can apply a discount but
|
|
608
|
+
* cannot report its end date has shipped the half that creates the surprise: from a customer's seat, a rate
|
|
609
|
+
* that silently lapses is indistinguishable from a billing error.
|
|
610
|
+
*/
|
|
611
|
+
export interface PricingRail {
|
|
612
|
+
/**
|
|
613
|
+
* What this subscription pays now, what it will pay, and when that changes — or `undefined` when the store
|
|
614
|
+
* has nothing to say about it, which is the same answer `refresh` gives for the same reason.
|
|
615
|
+
*
|
|
616
|
+
* Every figure comes from the store. Nothing multiplies a price by a percentage here or anywhere else.
|
|
617
|
+
*/
|
|
618
|
+
readPricing(purchase: PaymentsPurchase, context: RailRequestContext): Promise<SubscriptionPricing | undefined>;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Whether a rail reports pricing. Structural, like the others. */
|
|
622
|
+
export function isPricingRail(provider: PaymentsRailProvider): provider is PaymentsRailProvider & PricingRail {
|
|
623
|
+
return typeof (provider as Partial<PricingRail>).readPricing === "function";
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Whether a rail can mint **and** list discounts. Structural, so a rail gains the ability without an edit here.
|
|
628
|
+
*
|
|
629
|
+
* **It ANDs both methods, and until #465 it did not.** {@link DiscountRail} declares two, and a guard probing
|
|
630
|
+
* `createDiscount` alone narrowed a rail that could mint and not list into a type promising both — so
|
|
631
|
+
* `GET {base}/admin/discounts` passed its own guard and then called a method that was not there. That is a
|
|
632
|
+
* `TypeError` inside a handler, which reaches a management pane as a 500, where a rail that plainly cannot
|
|
633
|
+
* mint gets `rail_not_configured` and a pane that can say so. No rail ever hit it: all three hosted rails
|
|
634
|
+
* declare both. It was a defect waiting for the fourth, and it is the one every other guard here is written
|
|
635
|
+
* against by name.
|
|
636
|
+
*/
|
|
637
|
+
export function isDiscountRail(provider: PaymentsRailProvider): provider is PaymentsRailProvider & DiscountRail {
|
|
638
|
+
const rail = provider as Partial<DiscountRail>;
|
|
639
|
+
return typeof rail.createDiscount === "function" && typeof rail.listDiscounts === "function";
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Which subscription is being changed, and what it becomes. One row, one price, and nothing else.
|
|
644
|
+
*
|
|
645
|
+
* **There is no field naming a subscription, and that absence is the security boundary.** The subscription
|
|
646
|
+
* arrives as a {@link PaymentsPurchase} — a decoded row only this deployment's own database produces, already
|
|
647
|
+
* carrying the subject that owns it — exactly as {@link PaymentsRailProvider.refresh} and
|
|
648
|
+
* {@link PricingRail.readPricing} take one. A `subscriptionId` here would be a value a caller supplies, and a
|
|
649
|
+
* value a caller supplies is one they can point at somebody else's subscription. This capability holds no
|
|
650
|
+
* members table and no ownership graph of its own, so there is nothing it could check that claim against: the
|
|
651
|
+
* refusal has to be structural. It is the same rule {@link PortalSessionInput.subscriptionIds} states for
|
|
652
|
+
* reads, applied where the verb writes.
|
|
653
|
+
*
|
|
654
|
+
* The route's own job follows from that: resolve the row from the authenticated caller's purchases, never
|
|
655
|
+
* from a request body.
|
|
656
|
+
*/
|
|
657
|
+
export interface SubscriptionChangeInput {
|
|
658
|
+
/**
|
|
659
|
+
* The stored purchase whose subscription is being changed — the projection's own row, already owned.
|
|
660
|
+
*
|
|
661
|
+
* The whole row rather than the ids off it, for {@link PaymentsRailProvider.refresh}'s reason: what
|
|
662
|
+
* identifies a subscription at its store differs per rail and some of it survives only in the payload. A
|
|
663
|
+
* rail added later never widens this signature.
|
|
664
|
+
*/
|
|
665
|
+
purchase: PaymentsPurchase;
|
|
666
|
+
/**
|
|
667
|
+
* The rail's own price identifier for the plan being moved to — **exactly one, never a list**.
|
|
668
|
+
*
|
|
669
|
+
* Paddle's subscription update *replaces* the items array: an item omitted from the request is removed from
|
|
670
|
+
* the subscription. So a `readonly string[]` here would be a delete verb wearing an update verb's name, and
|
|
671
|
+
* the shape of the mistake is a caller passing the one price they are moving to and silently dropping every
|
|
672
|
+
* add-on beside it. **Building the complete item list the provider is sent is the rail's obligation**, from
|
|
673
|
+
* the subscription it re-read, and it is the rail's because that is where the provider's replace semantics
|
|
674
|
+
* are known.
|
|
675
|
+
*
|
|
676
|
+
* Named as {@link CheckoutSessionInput.providerProductId} is, and meaning what it means there: the store's
|
|
677
|
+
* own SKU or price id, resolved from the catalog before this is called. One vocabulary, so a reader does not
|
|
678
|
+
* have to work out whether two spellings are two things.
|
|
679
|
+
*/
|
|
680
|
+
providerProductId: string;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Which subscription is being canceled, and when the customer stops. */
|
|
684
|
+
export interface SubscriptionCancelInput {
|
|
685
|
+
/** The stored purchase whose subscription is being canceled. See {@link SubscriptionChangeInput.purchase}. */
|
|
686
|
+
purchase: PaymentsPurchase;
|
|
687
|
+
/**
|
|
688
|
+
* When it takes effect, in the customer's terms — `at_period_end` under the settled policy.
|
|
689
|
+
*
|
|
690
|
+
* The rail translates into the store's spelling; Paddle's own `next_billing_period` does not parse here, so a
|
|
691
|
+
* rail that stopped translating fails loudly rather than sending a string Paddle happens to accept. See
|
|
692
|
+
* `data/subscription.ts` for why the customer's word is the one modeled.
|
|
693
|
+
*/
|
|
694
|
+
timing: SubscriptionCancelTiming;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* A rail whose subscriptions can be *changed* from the server: read, quoted, moved, ended, and un-ended.
|
|
699
|
+
*
|
|
700
|
+
* Separate from {@link CheckoutRail} because selling and managing are separate abilities — and separate from
|
|
701
|
+
* the shared contract for the reason the module doc gives: an Apple or Google subscription is changed inside
|
|
702
|
+
* the store's own UI, on the device, and no server call exists to do it.
|
|
703
|
+
*
|
|
704
|
+
* ## Five methods, and the guard demands all five
|
|
705
|
+
*
|
|
706
|
+
* {@link isSubscriptionRail} ANDs every one of them. That is the rule {@link isCheckoutRail} sets and
|
|
707
|
+
* {@link isDiscountRail} broke: it probed a single method of two and narrowed a half-implemented rail into a
|
|
708
|
+
* type that promised the other, which is a `TypeError` inside a handler rather than a refusal at its edge.
|
|
709
|
+
* That guard was fixed with this interface's arrival, and the shape is named here so nothing is written to
|
|
710
|
+
* match it again. The floor it sets is a rule about what may ship together:
|
|
711
|
+
*
|
|
712
|
+
* - **No destructive verb without the read that makes it legible.** A rail that could cancel and not report
|
|
713
|
+
* the cancellation ships the half that creates the support ticket: Paddle leaves `status` at `active` and
|
|
714
|
+
* blanks `next_billed_at` when a cancellation is scheduled (recorded 2026-08-28, #465), so a customer who
|
|
715
|
+
* canceled is told they will be billed again by every screen that reads the status.
|
|
716
|
+
* - **No change without the quote that makes it consented to.** A confirmation screen with no figure on it is
|
|
717
|
+
* a customer agreeing to an amount they were never shown, and the amount is real: the recorded upgrade
|
|
718
|
+
* charges 6582 immediately.
|
|
719
|
+
*
|
|
720
|
+
* ## What is deliberately absent from every method here
|
|
721
|
+
*
|
|
722
|
+
* **A proration mode, and any other billing enum.** The rail picks the mode from the *direction* of the
|
|
723
|
+
* change — an upgrade prorates immediately and charges now, a downgrade prorates into the next billing period
|
|
724
|
+
* — and `on_payment_failure` is always `prevent_change`. Neither is a parameter, because a parameter is a
|
|
725
|
+
* thing a client eventually sets, and the value a client would eventually set is Paddle's `do_not_bill`: a
|
|
726
|
+
* free upgrade. It is unreachable because there is nowhere to write it.
|
|
727
|
+
*
|
|
728
|
+
* ## The no-op is a success, not a refusal
|
|
729
|
+
*
|
|
730
|
+
* A change to the plan already held, and a cancellation of a subscription that is already scheduled to
|
|
731
|
+
* cancel, both answer the current standing **without calling the provider at all**. That is the retry answer:
|
|
732
|
+
* these verbs sit behind a network, callers retry, and a second delivery of the same intent must not become a
|
|
733
|
+
* second proration. A 409 for the state the caller asked for is also simply wrong — the subscription is how
|
|
734
|
+
* they wanted it.
|
|
735
|
+
*/
|
|
736
|
+
export interface SubscriptionRail {
|
|
737
|
+
/**
|
|
738
|
+
* Where this subscription stands now — status, dates, and whatever is scheduled to happen to it — or
|
|
739
|
+
* `undefined` when the store has nothing to say about this purchase.
|
|
740
|
+
*
|
|
741
|
+
* `undefined` for {@link PaymentsRailProvider.refresh}'s reason and with its meaning: the rail cannot
|
|
742
|
+
* address this purchase, or the store no longer knows it. A store that cannot be *reached* throws
|
|
743
|
+
* `payments/provider_unavailable` instead, so a screen distinguishes "there is nothing to show" from "we
|
|
744
|
+
* could not look".
|
|
745
|
+
*
|
|
746
|
+
* Read live, never from a projected row. The one fact this exists to report — a scheduled cancellation —
|
|
747
|
+
* is precisely the one a purchase row does not carry, and a webhook announcing it can be dropped.
|
|
748
|
+
*/
|
|
749
|
+
readStanding(purchase: PaymentsPurchase, context: RailRequestContext): Promise<SubscriptionStanding | undefined>;
|
|
750
|
+
/**
|
|
751
|
+
* What moving to this price would cost, as the provider previews it — the figures a customer confirms.
|
|
752
|
+
*
|
|
753
|
+
* Every amount comes from the store and none is derived here. See `data/subscription.ts` for what the
|
|
754
|
+
* recordings established about reading them, and in particular for why the answer has three parts.
|
|
755
|
+
*
|
|
756
|
+
* **A read, so the no-op rule does not apply to it.** A preview takes and gives nothing, a second one is
|
|
757
|
+
* free, and asking the provider what the plan already held would cost is a question with an honest answer.
|
|
758
|
+
* The no-op exists to stop a retried *write* from prorating twice; borrowing it here would mean inventing a
|
|
759
|
+
* recurring figure to fill the quote with, which is the one thing this package will not do.
|
|
760
|
+
*
|
|
761
|
+
* Throws rather than answering `undefined`. A quote that could be absent is a confirm button rendered
|
|
762
|
+
* beside nothing, and `payments/provider_unavailable` is what tells a screen to say so.
|
|
763
|
+
*/
|
|
764
|
+
previewChange(input: SubscriptionChangeInput, context: RailRequestContext): Promise<SubscriptionChangeQuote>;
|
|
765
|
+
/**
|
|
766
|
+
* Move the subscription to a different plan, and answer where it now stands.
|
|
767
|
+
*
|
|
768
|
+
* **The rail chooses the proration mode from the direction of the change** — up charges immediately, down
|
|
769
|
+
* defers the credit to the next billing period — and nothing the caller sends influences it.
|
|
770
|
+
*
|
|
771
|
+
* Answers the resulting {@link SubscriptionStanding} rather than nothing, so the screen that just wrote can
|
|
772
|
+
* render what it wrote from the provider's own answer instead of predicting it. A prediction is how a
|
|
773
|
+
* customer sees a plan they are not on.
|
|
774
|
+
*
|
|
775
|
+
* A change to the plan already held is the no-op: the current standing, and no provider call. Anything the
|
|
776
|
+
* store refuses — a subscription that is paused, canceled, or past due — throws
|
|
777
|
+
* `payments/subscription_change_refused` (409) with the store's own reason in `detail`.
|
|
778
|
+
*/
|
|
779
|
+
changePlan(input: SubscriptionChangeInput, context: RailRequestContext): Promise<SubscriptionStanding>;
|
|
780
|
+
/**
|
|
781
|
+
* Stop the subscription renewing, and answer where it now stands.
|
|
782
|
+
*
|
|
783
|
+
* `at_period_end` is the settled policy and the tier holds until the paid period runs out; `now` exists
|
|
784
|
+
* because support occasionally has to end one today, and because a policy with no legitimate exit gets
|
|
785
|
+
* departed from by a direct provider call nothing audits.
|
|
786
|
+
*
|
|
787
|
+
* A cancellation on a subscription already scheduled to cancel is the no-op: the current standing, and no
|
|
788
|
+
* provider call. A subscription the store will not cancel throws `payments/subscription_change_refused`.
|
|
789
|
+
*/
|
|
790
|
+
cancelSubscription(input: SubscriptionCancelInput, context: RailRequestContext): Promise<SubscriptionStanding>;
|
|
791
|
+
/**
|
|
792
|
+
* Withdraw a scheduled cancellation, so the subscription renews after all.
|
|
793
|
+
*
|
|
794
|
+
* **It withdraws a cancellation, and only a cancellation.** Paddle offers no verb for that: the update
|
|
795
|
+
* clears `scheduled_change` *wholesale*, by setting it to null, and the field also holds a scheduled pause
|
|
796
|
+
* and a scheduled resume. So a rail that simply sent the clear would silently un-pause a paused subscription
|
|
797
|
+
* — the customer's account restarts billing, on a request that said nothing about pausing. The rail must
|
|
798
|
+
* **re-read the subscription first and refuse unless the pending action is `cancel`**, with
|
|
799
|
+
* `payments/subscription_change_refused` (409) naming what was actually scheduled. The check cannot move to
|
|
800
|
+
* the route: the route holds a projected row, and the pending action lives only at the store.
|
|
801
|
+
*
|
|
802
|
+
* A subscription with nothing scheduled is the no-op rather than a refusal: it already renews, which is what
|
|
803
|
+
* the caller asked for, and a retry after a successful withdrawal is exactly that request arriving twice.
|
|
804
|
+
*
|
|
805
|
+
* Its own method rather than `changePlan` to the same price, because the two are separate acts by possibly
|
|
806
|
+
* separate actors and the audit trail records them separately — a withdrawal folded into a plan change
|
|
807
|
+
* leaves a trail that asserts a cancellation and holds nothing saying it was taken back.
|
|
808
|
+
*/
|
|
809
|
+
keepSubscription(purchase: PaymentsPurchase, context: RailRequestContext): Promise<SubscriptionStanding>;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Whether a rail can manage a subscription from the server. Structural, like the others — and **it ANDs all
|
|
814
|
+
* five methods**, which the others do not all do.
|
|
815
|
+
*
|
|
816
|
+
* Every guard in this file ANDs its whole interface now, which was not true when this one was written — see
|
|
817
|
+
* {@link isDiscountRail} for the shape and what it cost. A guard that probes one method narrows a
|
|
818
|
+
* partially-implemented rail into a type promising the rest, and the first thing anybody learns about the gap
|
|
819
|
+
* is a `TypeError` thrown mid-cancellation, on a route that has already written an audit row. Here a rail
|
|
820
|
+
* missing any one verb is simply not a subscription rail, and the route answers `rail_not_configured` — which
|
|
821
|
+
* is true, and which is a refusal rather than a half-applied change.
|
|
822
|
+
*/
|
|
823
|
+
export function isSubscriptionRail(
|
|
824
|
+
provider: PaymentsRailProvider,
|
|
825
|
+
): provider is PaymentsRailProvider & SubscriptionRail {
|
|
826
|
+
const rail = provider as Partial<SubscriptionRail>;
|
|
827
|
+
return (
|
|
828
|
+
typeof rail.readStanding === "function" &&
|
|
829
|
+
typeof rail.previewChange === "function" &&
|
|
830
|
+
typeof rail.changePlan === "function" &&
|
|
831
|
+
typeof rail.cancelSubscription === "function" &&
|
|
832
|
+
typeof rail.keepSubscription === "function"
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Which payments are being refunded, and why — one set, no amount, and no identifier a caller could have
|
|
838
|
+
* written.
|
|
839
|
+
*
|
|
840
|
+
* **There is no transaction id here, and that absence is the security boundary**, exactly as
|
|
841
|
+
* {@link SubscriptionChangeInput} states it for a subscription. The payments arrive as
|
|
842
|
+
* {@link PaymentsPurchase} rows — decoded rows only this deployment's own database produces, each already
|
|
843
|
+
* carrying the subject that owns it — because a `txn_…` a caller supplies is a `txn_…` they can point at a
|
|
844
|
+
* stranger's money. This capability holds no ownership graph to check such a claim against, so the refusal
|
|
845
|
+
* has to be structural: the route resolves the rows from the authenticated caller's own purchases and there
|
|
846
|
+
* is nowhere in this shape to write anything else.
|
|
847
|
+
*
|
|
848
|
+
* **And there is no amount.** Every refund raised through this seam is for a transaction's whole total. A
|
|
849
|
+
* partial one is a different feature — it needs the store's own line-item ids, which nothing here holds —
|
|
850
|
+
* and an amount on a bearer route is a self-service withdrawal.
|
|
851
|
+
*/
|
|
852
|
+
export interface RefundRequestInput {
|
|
853
|
+
/**
|
|
854
|
+
* The payments to refund — the caller's own purchase rows, one adjustment each.
|
|
855
|
+
*
|
|
856
|
+
* A set rather than one, because refunds attach to transactions and a subscription is a family of them:
|
|
857
|
+
* a customer who upgraded mid-period has paid twice and is owed both. The rail raises one refund per row
|
|
858
|
+
* and reports one outcome per row.
|
|
859
|
+
*
|
|
860
|
+
* **Order is the caller's and the report keeps it**, so a partial report is reproducible rather than
|
|
861
|
+
* whatever the store answered first.
|
|
862
|
+
*/
|
|
863
|
+
purchases: readonly PaymentsPurchase[];
|
|
864
|
+
/**
|
|
865
|
+
* Why, in the operator's words — composed by the route, and **never read from a request body.**
|
|
866
|
+
*
|
|
867
|
+
* Every store requires one and shows it to a human in the merchant's own console. That is the argument
|
|
868
|
+
* for not taking it from the caller: a bearer request writing free text into an adopter's back office is
|
|
869
|
+
* a stranger writing into somebody's operational record, and there is nothing a customer could say there
|
|
870
|
+
* that the audit row and the store's own history do not already hold. An adopter who wants the
|
|
871
|
+
* customer's own words collects them on their own screen, where they belong to the adopter.
|
|
872
|
+
*/
|
|
873
|
+
reason: string;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* A rail that can ask its store to give a customer's money back.
|
|
878
|
+
*
|
|
879
|
+
* ## Its own interface, and not a sixth method on {@link SubscriptionRail}
|
|
880
|
+
*
|
|
881
|
+
* **The two abilities are independent in both directions, which is the whole argument.** Google Play has a
|
|
882
|
+
* server-side refund call and no server-side plan change — a subscription's plan is changed inside the
|
|
883
|
+
* store, on the device — so that rail could implement this and never implement {@link SubscriptionRail}.
|
|
884
|
+
* Apple is the other corner: refunds are Apple's own decision and the only server endpoint is a *lookup*,
|
|
885
|
+
* so a rail may be able to manage a subscription and have nothing to put here. An interface that demanded
|
|
886
|
+
* both would be one neither of them can satisfy.
|
|
887
|
+
*
|
|
888
|
+
* **And widening a guard that ANDs its methods is retroactive.** {@link isSubscriptionRail} requires all
|
|
889
|
+
* five verbs; a sixth would silently de-narrow every rail that already satisfies the five, and the first
|
|
890
|
+
* anybody would hear of it is `rail_not_configured` on a cancellation, from a release that changed nothing
|
|
891
|
+
* about canceling. A new interface costs one guard and breaks nothing that exists.
|
|
892
|
+
*
|
|
893
|
+
* **The floors differ too.** `SubscriptionRail`'s floor is *no destructive verb without the read that makes
|
|
894
|
+
* it legible* — the read being a standing. A refund's legibility read is a different one: the transactions
|
|
895
|
+
* and whatever adjustments already stand on them. Folding refunds in would make the ability to change a
|
|
896
|
+
* plan carry the power to move money back, which is a much larger power granted for a much smaller job.
|
|
897
|
+
*
|
|
898
|
+
* ## What a rail implementing this must guarantee
|
|
899
|
+
*
|
|
900
|
+
* - **It never claims money moved.** The answer is {@link RefundRequest}, whose every outcome is a request
|
|
901
|
+
* and whose statuses say so. Most live refunds sit at the store awaiting a human.
|
|
902
|
+
* - **It revokes nothing.** No entitlement, no purchase row, no projection. An approved refund arrives as a
|
|
903
|
+
* webhook and the projection acts on it — revoking on the *request* takes access from a customer whose
|
|
904
|
+
* refund the store then rejects, leaving them with neither the money nor the product.
|
|
905
|
+
* - **It refuses before it writes, and reports after.** Everything knowable in advance refuses the whole
|
|
906
|
+
* request with `payments/subscription_change_refused` and sends nothing; once one adjustment exists,
|
|
907
|
+
* every remaining failure is an outcome in the report. {@link RefundRequest} holds the long form.
|
|
908
|
+
* - **It bounds the set.** A set it cannot issue inside one request is refused before the first write, not
|
|
909
|
+
* discovered half way through.
|
|
910
|
+
*
|
|
911
|
+
* ## What is deliberately absent
|
|
912
|
+
*
|
|
913
|
+
* **An amount, a currency, a transaction id, and a window.** The first three are {@link RefundRequestInput}'s
|
|
914
|
+
* argument. The window is the *adopter's*: how many days a customer has to ask for their money back is a
|
|
915
|
+
* commercial policy with a company behind it, and a kit that hard-coded fourteen days would be wrong for the
|
|
916
|
+
* second adopter. The kit makes the refund possible; the adopter's screen decides which button exists.
|
|
917
|
+
*/
|
|
918
|
+
export interface RefundRail {
|
|
919
|
+
/**
|
|
920
|
+
* Ask the store to refund these payments in full, and report what became of each.
|
|
921
|
+
*
|
|
922
|
+
* Throws `payments/subscription_change_refused` (409) when the request is refused before anything is
|
|
923
|
+
* sent, and `payments/provider_unavailable` (503) when the store could not be reached or answered in a
|
|
924
|
+
* shape this build cannot read — both only while nothing has been raised. After the first adjustment
|
|
925
|
+
* exists it answers, whatever else happened.
|
|
926
|
+
*/
|
|
927
|
+
requestRefunds(input: RefundRequestInput, context: RailRequestContext): Promise<RefundRequest>;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Whether a rail can refund at its store. Structural, like the others.
|
|
932
|
+
*
|
|
933
|
+
* One method, so probing it *is* ANDing all of them — {@link isSubscriptionRail}'s rule, satisfied by
|
|
934
|
+
* arithmetic rather than by care. Stated because that stops being true the moment a second method is added
|
|
935
|
+
* here, and a guard left probing one of two is what {@link isDiscountRail} did until #465.
|
|
936
|
+
*/
|
|
937
|
+
export function isRefundRail(provider: PaymentsRailProvider): provider is PaymentsRailProvider & RefundRail {
|
|
938
|
+
return typeof (provider as Partial<RefundRail>).requestRefunds === "function";
|
|
939
|
+
}
|