@percayso-community/cayso-contracts 0.23.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 +201 -0
- package/README.md +114 -0
- package/dist/assertion.d.ts +867 -0
- package/dist/assertion.js +63 -0
- package/dist/assurance.d.ts +353 -0
- package/dist/assurance.js +551 -0
- package/dist/citizen-market.d.ts +468 -0
- package/dist/citizen-market.js +16 -0
- package/dist/citizenship.d.ts +215 -0
- package/dist/citizenship.js +26 -0
- package/dist/client.d.ts +288 -0
- package/dist/client.js +370 -0
- package/dist/commercial-payment.d.ts +243 -0
- package/dist/commercial-payment.js +16 -0
- package/dist/contract-schema.generated.d.ts +14 -0
- package/dist/contract-schema.generated.js +3107 -0
- package/dist/errors.d.ts +171 -0
- package/dist/errors.js +28 -0
- package/dist/index.d.ts +621 -0
- package/dist/index.js +624 -0
- package/dist/marketplace-receipt.d.ts +306 -0
- package/dist/marketplace-receipt.js +116 -0
- package/dist/marketplace-settlement.d.ts +298 -0
- package/dist/marketplace-settlement.js +16 -0
- package/dist/money.d.ts +84 -0
- package/dist/money.js +34 -0
- package/dist/organisation.d.ts +60 -0
- package/dist/organisation.js +16 -0
- package/dist/purchase.d.ts +531 -0
- package/dist/purchase.js +107 -0
- package/dist/restriction.d.ts +262 -0
- package/dist/restriction.js +316 -0
- package/dist/routes.d.ts +155 -0
- package/dist/routes.js +128 -0
- package/dist/schema-types.d.ts +101 -0
- package/dist/schema-types.js +16 -0
- package/dist/schema.d.ts +24 -0
- package/dist/schema.js +16 -0
- package/dist/wallet.d.ts +92 -0
- package/dist/wallet.js +59 -0
- package/package.json +41 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cayso.purchase` — the request and response shapes for a holder buying Cayso
|
|
3
|
+
* with sterling, the closed set of reasons a purchase can fail, and — since
|
|
4
|
+
* CAY-0032 — what a repeat of the same idempotency key does, which statuses are
|
|
5
|
+
* terminal, what cancelling does and what expiry releases.
|
|
6
|
+
*
|
|
7
|
+
* A `Purchase` and a `Movement` are different nouns (CAY-0023). A purchase has a
|
|
8
|
+
* lifecycle — pending, then succeeded, failed or cancelled — and lives entirely
|
|
9
|
+
* off the ledger; a movement is the single, final, append-only consequence of a
|
|
10
|
+
* *succeeded* purchase, and a consumer of this surface never sees one. So there
|
|
11
|
+
* is no movement type here, and nothing on these shapes can express one.
|
|
12
|
+
*
|
|
13
|
+
* ## Why the failure reason is a closed union, not a string
|
|
14
|
+
*
|
|
15
|
+
* Every interesting failure — verification refused, the card declined, over the
|
|
16
|
+
* backing ceiling — happens **before** any cents move, so the reason belongs to
|
|
17
|
+
* the purchase rather than to a movement that never occurred. It is a union of
|
|
18
|
+
* fixed string literals for a regulatory-shaped reason: an open string means a
|
|
19
|
+
* consumer (Piper) pattern-matches on our prose, and our prose changes. A new
|
|
20
|
+
* failure mode is therefore a **new union member and a minor version**, never a
|
|
21
|
+
* free-text field and never a widening to `string`. Nothing on `PurchaseResult`
|
|
22
|
+
* can carry prose — that is the property its tests pin.
|
|
23
|
+
*
|
|
24
|
+
* ## Why the published value is recorded on the purchase
|
|
25
|
+
*
|
|
26
|
+
* The published value ratchets: it rises and never falls (CAY-0013). A purchase
|
|
27
|
+
* is priced at the value **fixed at authorisation** and that figure is carried on
|
|
28
|
+
* `PurchaseAuthorised` and echoed on `PurchaseResult`, so a revaluation between
|
|
29
|
+
* authorising and settling cannot silently change what somebody gets. Reading the
|
|
30
|
+
* value again later would price the purchase at a different rate than the one the
|
|
31
|
+
* holder was quoted.
|
|
32
|
+
*
|
|
33
|
+
* ## A repeat `POST /api/purchases` with the same key REPLAYS (CAY-0032)
|
|
34
|
+
*
|
|
35
|
+
* A consumer on a money path must not have to guess what a retry does, and this
|
|
36
|
+
* is the statement of record.
|
|
37
|
+
*
|
|
38
|
+
* **Posting the same `idempotencyKey` twice returns the ORIGINAL authorisation.**
|
|
39
|
+
* Same `purchaseId`, same `cents`, same `publishedValuePence`, with a freshly
|
|
40
|
+
* minted `paymentClientSecret` for the browser to confirm with. It is answered
|
|
41
|
+
* `200` where the first authorisation was answered `201`, and it is the whole of
|
|
42
|
+
* "resume": there is no separate resume operation, because the retry a consumer
|
|
43
|
+
* already performs after a dropped connection is the resume.
|
|
44
|
+
*
|
|
45
|
+
* - **The purchase is never minted twice.** One key is one purchase and one
|
|
46
|
+
* issuance (CLAUDE.md). A repeat writes no second purchase, reserves no
|
|
47
|
+
* second slice of headroom and takes no second hold at the payment gateway —
|
|
48
|
+
* the gateway deduplicates on the same key, so re-deriving the client secret
|
|
49
|
+
* returns the same hold rather than a second one.
|
|
50
|
+
* - **The price does not move.** The cents were priced at authorisation, at the
|
|
51
|
+
* published value recorded on the purchase, and a replay echoes THAT figure.
|
|
52
|
+
* If the published value ratchets between the first `POST` and the retry, the
|
|
53
|
+
* retry still answers the original `publishedValuePence` and the original
|
|
54
|
+
* `cents`. The value fixed at authorisation governs, so a revaluation
|
|
55
|
+
* mid-refresh cannot change what somebody gets — which is why replay was
|
|
56
|
+
* chosen over refusing the repeat and superseding it with a fresh
|
|
57
|
+
* authorisation: superseding would re-price, and re-pricing a retry is how a
|
|
58
|
+
* holder pays for a dropped connection.
|
|
59
|
+
* - **A repeat of a purchase that has already reached a terminal status does
|
|
60
|
+
* not revive it.** It reports the terminal outcome (see below), and in
|
|
61
|
+
* particular an EXPIRED purchase is not resurrected: its reservation has been
|
|
62
|
+
* released and its headroom belongs to somebody else now. The consumer's
|
|
63
|
+
* remedy is to start a new purchase under a NEW key.
|
|
64
|
+
*
|
|
65
|
+
* ## One response union, discriminated by `outcome`
|
|
66
|
+
*
|
|
67
|
+
* `POST /api/purchases` answers exactly one of {@link PurchaseAuthorisationResponse}'s
|
|
68
|
+
* three branches, and a consumer tells them apart by reading `outcome` — never by
|
|
69
|
+
* sniffing which fields happen to be present. Shape-sniffing is how a consumer
|
|
70
|
+
* comes to depend on a field being absent, and an absent field is not a contract.
|
|
71
|
+
*
|
|
72
|
+
* - `'authorised'` — a live authorisation to confirm in the browser. Fresh
|
|
73
|
+
* (`201`) and replayed (`200`) are the SAME shape, on purpose: a replay is not
|
|
74
|
+
* a lesser answer, and a consumer that had to branch on which it got would be
|
|
75
|
+
* branching on something it cannot control.
|
|
76
|
+
* - `'terminal'` — the purchase has already reached a terminal status and will
|
|
77
|
+
* not change again. Carries the status and, when it failed, the closed reason.
|
|
78
|
+
* - `'unresolved'` — the purchase is `'issued_uncaptured'`: its cents landed and
|
|
79
|
+
* its capture did not, so there is nothing to confirm and nothing has ended.
|
|
80
|
+
* A consumer polls the result route and does not re-post (CAY-0033).
|
|
81
|
+
* - `'refused'` — the authorisation was refused, with a member of the closed
|
|
82
|
+
* {@link PurchaseFailureReason} union. Nothing was charged: refuse comes
|
|
83
|
+
* before charge (CAY-0023).
|
|
84
|
+
*
|
|
85
|
+
* ONE REFUSAL ARRIVES BEFORE THE HANDLER, AND IS STATED HERE RATHER THAN HIDDEN.
|
|
86
|
+
* A credential that is not money-grade is refused AT THE GATE — the shared
|
|
87
|
+
* assurance check that guards every value-moving route (CAY-0029) — before this
|
|
88
|
+
* route's handler runs. That refusal is the gate's own shape, `403
|
|
89
|
+
* {"error":"verification_refused"}`, carried with no `outcome` because it is not
|
|
90
|
+
* this route's answer: the same body is what `citizenshipProvision` and the
|
|
91
|
+
* operator surface give, and one route may not redefine a refusal it shares with
|
|
92
|
+
* every other. `'verification_refused'` is still a member of
|
|
93
|
+
* {@link PurchaseFailureReason} so a consumer renders it from the same closed
|
|
94
|
+
* set, and a consumer reading a `403` should read `error` without expecting
|
|
95
|
+
* `outcome`. Stated, rather than left for somebody to discover.
|
|
96
|
+
*
|
|
97
|
+
* ## Which statuses are terminal
|
|
98
|
+
*
|
|
99
|
+
* `'pending'` and `'issued_uncaptured'` are the non-terminal statuses.
|
|
100
|
+
* {@link TERMINAL_PURCHASE_STATUSES} and {@link isTerminalPurchaseStatus} are the
|
|
101
|
+
* statement of that, exported so a consumer can stop polling on the same rule the
|
|
102
|
+
* service settles on rather than on its own list. A purchase in a terminal status
|
|
103
|
+
* never transitions again: it cannot be cancelled, cannot be re-authorised, and a
|
|
104
|
+
* repeat of its key reports it rather than reviving it.
|
|
105
|
+
*
|
|
106
|
+
* ## `issued_uncaptured` — the cents landed and the sterling did not (CAY-0033)
|
|
107
|
+
*
|
|
108
|
+
* CAY-0023 orders authorise, issue, then capture, so nothing is charged unless the
|
|
109
|
+
* cents landed. This status is that order's other half: the cents landed and the
|
|
110
|
+
* capture that should have taken the sterling did not, so cents exist that nobody
|
|
111
|
+
* has paid for and the purchase is neither a success nor a failure yet.
|
|
112
|
+
*
|
|
113
|
+
* **It is neither `'succeeded'` nor `'failed'`, and it may still become either.** A
|
|
114
|
+
* consumer polling `GET /api/purchases/:purchaseId` reads it there plainly and
|
|
115
|
+
* keeps polling: {@link isTerminalPurchaseStatus} answers `false` for it and it is
|
|
116
|
+
* absent from {@link TERMINAL_PURCHASE_STATUSES}. It carries **no failure reason**
|
|
117
|
+
* — nothing has failed yet, and a reason would be a verdict nobody has reached.
|
|
118
|
+
*
|
|
119
|
+
* **The cents ARE in the holder's balance while it lasts.** `GET /api/wallet`
|
|
120
|
+
* counts them and they can be spent; there is no provisional figure netting them
|
|
121
|
+
* off and no second balance to consult. A balance is the sum of a wallet's holdings
|
|
122
|
+
* (CAY-0019) and the holding is real — a surface that hid it would report a figure
|
|
123
|
+
* the ledger does not hold, which is the one thing a derived balance must never do.
|
|
124
|
+
*
|
|
125
|
+
* **Capture is retried a bounded number of times.** At most
|
|
126
|
+
* {@link MAX_CAPTURE_ATTEMPTS} attempts are made in all, each under the purchase's
|
|
127
|
+
* own idempotency key, so a transient gateway failure resolves with nobody involved
|
|
128
|
+
* and a re-attempt can neither take a second hold nor charge twice. When the
|
|
129
|
+
* attempts are spent, the purchase waits in this status for a person.
|
|
130
|
+
*
|
|
131
|
+
* **Resolution is operator-only.** No route a consumer can call moves a purchase
|
|
132
|
+
* out of this status, and a holder cannot cancel it — cancel refuses with
|
|
133
|
+
* `'already_issued'`, because the cents have already landed and a cancellation must
|
|
134
|
+
* not un-land them. An operator either re-attempts the capture or records a
|
|
135
|
+
* compensating movement. Meanwhile the holder sees this status on the purchase and
|
|
136
|
+
* the cents in their balance, and nothing else about their wallet changes.
|
|
137
|
+
*
|
|
138
|
+
* **What the holder eventually sees.** A re-attempted capture that succeeds moves
|
|
139
|
+
* the purchase to `'succeeded'` and the cents stay. A compensation carves the cents
|
|
140
|
+
* back out of the wallet as a NEW, final, append-only movement — never an
|
|
141
|
+
* amendment, and never an undoing, of the movement that issued them — and the
|
|
142
|
+
* purchase becomes `'failed'` with the closed reason `'capture_failed'`. `failed`
|
|
143
|
+
* is the honest word for it: nobody paid, so the holder does not keep the cents,
|
|
144
|
+
* and no other status says that.
|
|
145
|
+
*
|
|
146
|
+
* ## Cancel: `POST /api/purchases/:purchaseId/cancel`
|
|
147
|
+
*
|
|
148
|
+
* Cancelling a pending purchase releases its reservation and moves it to
|
|
149
|
+
* `'cancelled'` — a terminal status of its own, deliberately NOT folded into
|
|
150
|
+
* `'failed'`. A failure is something that happened TO a holder; a cancellation is
|
|
151
|
+
* something the holder (or an operator acting on their wallet) CHOSE, and a
|
|
152
|
+
* surface that rendered the two identically would tell somebody their payment
|
|
153
|
+
* failed when they had simply changed their mind. A cancelled purchase therefore
|
|
154
|
+
* carries **no failure reason**: there is no member of the closed union that
|
|
155
|
+
* describes a choice, and inventing one would make "failed" mean both things
|
|
156
|
+
* again.
|
|
157
|
+
*
|
|
158
|
+
* Cancel is idempotent — cancelling an already-cancelled purchase answers the
|
|
159
|
+
* same `'cancelled'` outcome and is not an error — and it refuses a purchase that
|
|
160
|
+
* has already reached a terminal status, with the closed
|
|
161
|
+
* {@link PurchaseCancelRefusalReason} union. **A succeeded purchase cannot be
|
|
162
|
+
* cancelled**: its cents have been issued and its card captured, and undoing that
|
|
163
|
+
* is a refund, which is a different act with different rules and is not this.
|
|
164
|
+
*
|
|
165
|
+
* **A cancelled purchase is not a reversed movement.** There is no movement to
|
|
166
|
+
* reverse, because cancel only ever reaches a purchase that never captured — a
|
|
167
|
+
* movement is final and append-only, and one that did not happen is not a
|
|
168
|
+
* movement (CAY-0023, CLAUDE.md).
|
|
169
|
+
*
|
|
170
|
+
* ## Expiry: how long, what it releases, what a poller sees
|
|
171
|
+
*
|
|
172
|
+
* An authorisation holds its slice of the backing ceiling for
|
|
173
|
+
* {@link PURCHASE_RESERVATION_WINDOW_SECONDS} from the moment it is authorised;
|
|
174
|
+
* `expiresAt` on {@link PurchaseAuthorised} is that instant, and a consumer should
|
|
175
|
+
* read the field rather than recompute it.
|
|
176
|
+
*
|
|
177
|
+
* When the window lapses, the reservation is released — the reserved headroom
|
|
178
|
+
* returns to the economy and is available to the next authorisation — and the
|
|
179
|
+
* purchase becomes `'failed'` with the reason `'expired'`. Releasing is
|
|
180
|
+
* idempotent: a reservation released twice frees nothing the second time, so a
|
|
181
|
+
* cancel racing the expiry sweep cannot return the same headroom to the ceiling
|
|
182
|
+
* twice.
|
|
183
|
+
*
|
|
184
|
+
* What expiry does NOT release is as important: **nothing was charged and nothing
|
|
185
|
+
* was minted**, so there is no money to return. Expiry releases a *reservation*, a
|
|
186
|
+
* promise on headroom, and never a movement, a holding or a capture.
|
|
187
|
+
*
|
|
188
|
+
* A consumer polling `GET /api/purchases/:purchaseId` sees `pending` until the
|
|
189
|
+
* window lapses and the sweep runs, then `failed` with `'expired'` — a terminal
|
|
190
|
+
* status, so polling stops there. A repeat `POST` under the same key answers the
|
|
191
|
+
* `'terminal'` branch for the same purchase and does NOT re-authorise it; the
|
|
192
|
+
* remedy is a new purchase under a new key.
|
|
193
|
+
*
|
|
194
|
+
* ## What this file must not decide
|
|
195
|
+
*
|
|
196
|
+
* - **Sterling in, cents out.** A caller asks in `sterlingPence` and is answered
|
|
197
|
+
* in `cents`; there is no formatted money and no money in a string on this
|
|
198
|
+
* surface. Pence are the sterling side and stay a bare `number`; cent amounts
|
|
199
|
+
* are `Cents` and timestamps are `Millis`, matching `cayso.wallet`.
|
|
200
|
+
* - **No economic numbers.** No amount, rate, tier or ceiling lives here or in
|
|
201
|
+
* these comments. `publishedValuePence` is a field, not a value. They live in
|
|
202
|
+
* the canon PRD and are read from one place. The single figure stated here is
|
|
203
|
+
* the reservation window — a MECHANISM parameter of this contract, not an
|
|
204
|
+
* economic one, stated for the same reason and in the same way
|
|
205
|
+
* `assertion.ts` states the credential's lifetime ceiling and skew tolerance:
|
|
206
|
+
* a consumer is entitled to know the window its authorisation is measured
|
|
207
|
+
* against, and cannot read it from a type.
|
|
208
|
+
* - **No flow.** The refusal ladder, the reservation, the sterling gateway, the
|
|
209
|
+
* expiry sweep and the capture all live in `apps/api`. This file is the shapes
|
|
210
|
+
* those slices exchange with a consumer — no handler, no state machine, no
|
|
211
|
+
* reservation record, no provider client.
|
|
212
|
+
* - **No credential.** `paymentClientSecret` is a value the payment provider
|
|
213
|
+
* mints for the caller's browser to confirm with. No provider key, publishable
|
|
214
|
+
* key, webhook secret or bank credential belongs anywhere near this tree.
|
|
215
|
+
* - `onBehalfOfWalletId` lets a caller buy for an organisation it holds the
|
|
216
|
+
* mandate for. It moves nothing out of that organisation and sets no Signatory
|
|
217
|
+
* threshold (a canon figure, read from configuration). An organisation paying a
|
|
218
|
+
* Citizen is the commercial payment, its own operation in
|
|
219
|
+
* `commercial-payment.ts` (CAY-0079), and never a purchase.
|
|
220
|
+
*/
|
|
221
|
+
import type { Cents, Millis } from './wallet.js';
|
|
222
|
+
/** The capability this contract surfaces. Matches the glossary exactly. */
|
|
223
|
+
export declare const PURCHASE_CAPABILITY: "cayso.purchase";
|
|
224
|
+
/**
|
|
225
|
+
* How long, in **seconds**, an authorisation holds its slice of the backing
|
|
226
|
+
* ceiling before the reservation is released and the purchase expires.
|
|
227
|
+
*
|
|
228
|
+
* A MECHANISM parameter of this contract, not a canon economic figure — no
|
|
229
|
+
* amount, rate, tier or schedule is restated here (CLAUDE.md). It is the
|
|
230
|
+
* browser-confirmation window: long enough for a holder to complete the payment
|
|
231
|
+
* provider's confirmation step after the client secret is handed over, short
|
|
232
|
+
* enough that an abandoned authorisation frees its headroom promptly rather than
|
|
233
|
+
* walling it off. It is not a number anyone tunes for economics.
|
|
234
|
+
*
|
|
235
|
+
* It lives on the contract for the reason {@link
|
|
236
|
+
* import('./assertion.js').MAX_ASSERTION_LIFETIME_SECONDS} does — that is the
|
|
237
|
+
* precedent, and this follows it. Two parties read this window: the service that
|
|
238
|
+
* computes `expiresAt` from it, and the consumer that has to decide how long to
|
|
239
|
+
* keep a confirmation page alive. A consumer cannot read a window from a type, and
|
|
240
|
+
* a consumer guessing it is a consumer that abandons a live authorisation or waits
|
|
241
|
+
* on a dead one. Seconds, matching the credential parameters beside it; the
|
|
242
|
+
* `expiresAt` a consumer actually renders is `Millis`, and is a FIELD it should
|
|
243
|
+
* read rather than recompute from this.
|
|
244
|
+
*/
|
|
245
|
+
export declare const PURCHASE_RESERVATION_WINDOW_SECONDS: number;
|
|
246
|
+
/**
|
|
247
|
+
* How many times in all a purchase's capture is attempted before it stops being
|
|
248
|
+
* attempted without a person: the first attempt plus its bounded retries.
|
|
249
|
+
*
|
|
250
|
+
* A MECHANISM parameter of this contract, not a canon economic figure — no amount,
|
|
251
|
+
* rate, tier or schedule is restated here (CLAUDE.md). It is a bound on how long
|
|
252
|
+
* the service will keep calling a gateway that is not answering, chosen so a
|
|
253
|
+
* transient failure resolves by itself and a persistent one reaches a person
|
|
254
|
+
* promptly rather than being retried at forever. It is not a number anyone tunes
|
|
255
|
+
* for economics.
|
|
256
|
+
*
|
|
257
|
+
* It lives on the contract for the reason {@link PURCHASE_RESERVATION_WINDOW_SECONDS}
|
|
258
|
+
* does, which in turn follows {@link import('./assertion.js').MAX_ASSERTION_LIFETIME_SECONDS}
|
|
259
|
+
* — that is the precedent, and this follows it. Two parties read the bound: the
|
|
260
|
+
* service, which stops at it, and the consumer, which is entitled to know that a
|
|
261
|
+
* purchase sitting in `'issued_uncaptured'` is not being retried indefinitely and
|
|
262
|
+
* that after this many attempts only an operator will move it. A consumer cannot
|
|
263
|
+
* read a bound from a type. The schema carries it a second time, as a `CHECK` on
|
|
264
|
+
* the stored attempt count, so neither layer can drift past it alone.
|
|
265
|
+
*/
|
|
266
|
+
export declare const MAX_CAPTURE_ATTEMPTS = 3;
|
|
267
|
+
/** Asked for in sterling; answered in cents. */
|
|
268
|
+
export interface PurchaseRequest {
|
|
269
|
+
readonly sterlingPence: number;
|
|
270
|
+
/** Caller-generated. The same key must never mint twice; a repeat REPLAYS. */
|
|
271
|
+
readonly idempotencyKey: string;
|
|
272
|
+
/** Buying for an organisation the caller holds the mandate for. */
|
|
273
|
+
readonly onBehalfOfWalletId?: string;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* A live authorisation: the purchase is `pending` and the browser has what it
|
|
277
|
+
* needs to confirm. Answered `201` for a fresh authorisation and `200` for a
|
|
278
|
+
* REPLAY of the same idempotency key — identical shape, identical `purchaseId`,
|
|
279
|
+
* `cents` and `publishedValuePence`, and a freshly minted client secret. The
|
|
280
|
+
* `outcome` discriminant is what a consumer switches on.
|
|
281
|
+
*/
|
|
282
|
+
export interface PurchaseAuthorised {
|
|
283
|
+
readonly outcome: 'authorised';
|
|
284
|
+
readonly purchaseId: string;
|
|
285
|
+
readonly cents: Cents;
|
|
286
|
+
/** The rate this purchase was priced at, fixed at authorisation. A replay echoes it unchanged. */
|
|
287
|
+
readonly publishedValuePence: number;
|
|
288
|
+
/** For the caller's browser to confirm with the payment provider. */
|
|
289
|
+
readonly paymentClientSecret: string;
|
|
290
|
+
readonly expiresAt: Millis;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The purchase has already reached a terminal status and will not change again —
|
|
294
|
+
* the answer to a repeat `POST` of a key whose purchase succeeded, failed
|
|
295
|
+
* (including by expiry) or was cancelled. It is NOT an authorisation: there is no
|
|
296
|
+
* client secret and no `expiresAt`, because there is nothing live to confirm.
|
|
297
|
+
*
|
|
298
|
+
* A consumer that wants to buy after seeing this starts a NEW purchase under a NEW
|
|
299
|
+
* key. Re-posting this key will keep answering this branch forever.
|
|
300
|
+
*/
|
|
301
|
+
export interface PurchaseTerminal {
|
|
302
|
+
readonly outcome: 'terminal';
|
|
303
|
+
readonly purchaseId: string;
|
|
304
|
+
readonly status: TerminalPurchaseStatus;
|
|
305
|
+
readonly cents: Cents;
|
|
306
|
+
readonly publishedValuePence: number;
|
|
307
|
+
/** The closed reason, when the purchase failed. `null` for succeeded and for cancelled. */
|
|
308
|
+
readonly failureReason: PurchaseFailureReason | null;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The purchase's cents landed and its capture did not: it is `'issued_uncaptured'`
|
|
312
|
+
* (CAY-0033). A repeat `POST` of its key is answered with this rather than with
|
|
313
|
+
* `'authorised'` or `'terminal'`, because neither would be true — there is no live
|
|
314
|
+
* hold to confirm, and nothing has ended.
|
|
315
|
+
*
|
|
316
|
+
* It is its own branch rather than a `'terminal'` one with a live status, for the
|
|
317
|
+
* reason {@link TerminalPurchaseStatus} exists: a "terminal" answer that carried a
|
|
318
|
+
* status the purchase can still leave is a contradiction, and a consumer that
|
|
319
|
+
* stopped on it would stop one step before the answer. A consumer reading this
|
|
320
|
+
* polls `GET /api/purchases/:purchaseId` and does not re-post; re-posting this key
|
|
321
|
+
* will keep answering this branch until the purchase resolves.
|
|
322
|
+
*/
|
|
323
|
+
export interface PurchaseUnresolved {
|
|
324
|
+
readonly outcome: 'unresolved';
|
|
325
|
+
readonly purchaseId: string;
|
|
326
|
+
readonly status: 'issued_uncaptured';
|
|
327
|
+
readonly cents: Cents;
|
|
328
|
+
readonly publishedValuePence: number;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The authorisation was refused, with a member of the closed reason union.
|
|
332
|
+
* Nothing was charged and nothing was reserved that is not already released:
|
|
333
|
+
* refuse comes before charge (CAY-0023).
|
|
334
|
+
*/
|
|
335
|
+
export interface PurchaseRefused {
|
|
336
|
+
readonly outcome: 'refused';
|
|
337
|
+
readonly error: PurchaseFailureReason;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The ONE declared response of `POST /api/purchases`, discriminated by `outcome`.
|
|
341
|
+
*
|
|
342
|
+
* A consumer switches on `outcome` and the compiler makes the switch exhaustive.
|
|
343
|
+
* It does not test for the presence of `paymentClientSecret` to decide whether it
|
|
344
|
+
* was authorised, and it does not read a field that is absent on the branch it
|
|
345
|
+
* actually received — which is precisely the failure a discriminant exists to
|
|
346
|
+
* prevent on a money path.
|
|
347
|
+
*/
|
|
348
|
+
export type PurchaseAuthorisationResponse = PurchaseAuthorised | PurchaseTerminal | PurchaseUnresolved | PurchaseRefused;
|
|
349
|
+
export type PurchaseFailureReason = 'verification_refused' | 'card_declined' | 'ceiling_exceeded'
|
|
350
|
+
/**
|
|
351
|
+
* The reservation window lapsed before the purchase settled. The headroom has
|
|
352
|
+
* been released; the purchase is terminal, and a repeat of its key reports it
|
|
353
|
+
* rather than re-authorising it. The remedy is a new purchase under a new key.
|
|
354
|
+
*/
|
|
355
|
+
| 'expired' | 'not_authorised_for_wallet' | 'gateway_unavailable'
|
|
356
|
+
/**
|
|
357
|
+
* The caller has never been provisioned, so there is no wallet to mint into.
|
|
358
|
+
* Since CAY-0031 nothing provisions as a side effect — a self-purchase used to
|
|
359
|
+
* bring a wallet into being on first touch, and no longer does — so this is the
|
|
360
|
+
* honest refusal rather than a silent creation. The remedy is
|
|
361
|
+
* `ROUTES.citizenshipProvision`, and it is the caller's to take.
|
|
362
|
+
*/
|
|
363
|
+
| 'not_provisioned'
|
|
364
|
+
/**
|
|
365
|
+
* The idempotency key has already been used, by this same holder, for a
|
|
366
|
+
* purchase of a DIFFERENT amount of sterling.
|
|
367
|
+
*
|
|
368
|
+
* A Replay is "the same idempotency key again" answered with the authorisation
|
|
369
|
+
* that key already has — the same purchase, the same cents, the same published
|
|
370
|
+
* value (CONTEXT.md, `Replay`). That licenses answering a REPEATED request. A
|
|
371
|
+
* request carrying the same key and a different `sterlingPence` is not a
|
|
372
|
+
* repeat of anything: the two amounts cannot both be what the caller asked
|
|
373
|
+
* for, so there is no honest answer to give and the request is refused rather
|
|
374
|
+
* than silently answered at the first amount or re-priced at the second. The
|
|
375
|
+
* remedy is a new key; the existing purchase is untouched and still replayable
|
|
376
|
+
* at its own amount.
|
|
377
|
+
*/
|
|
378
|
+
| 'amount_mismatch'
|
|
379
|
+
/**
|
|
380
|
+
* The sterling asked for cannot be expressed in cents at all.
|
|
381
|
+
*
|
|
382
|
+
* ₡1 is 100 cents and ₡1 is `publishedValuePence` pence, so a purchase of
|
|
383
|
+
* `sterlingPence` is `sterlingPence × 100 ÷ publishedValuePence` cents — and
|
|
384
|
+
* above some sterling that figure is larger than the ledger's integer cents can
|
|
385
|
+
* hold. There is no honest answer to give for such a request: rounding it would
|
|
386
|
+
* hand back a number nobody computed, and the amount is not malformed, so
|
|
387
|
+
* refusing it as a bad request would be saying the wrong thing.
|
|
388
|
+
*
|
|
389
|
+
* WHERE THE BOUND SITS IS NOT A FIGURE ANYONE PICKS. It is arithmetic: the
|
|
390
|
+
* largest sterling that still prices inside the range, at the published value
|
|
391
|
+
* in force at the moment of the check. It therefore MOVES — the published value
|
|
392
|
+
* ratchets upward and never falls (CAY-0002, CAY-0013), so the bound only ever
|
|
393
|
+
* rises, and an amount refused with this reason before a quickening may be
|
|
394
|
+
* admitted after one. A consumer renders it as "ask for less", not as "never".
|
|
395
|
+
*
|
|
396
|
+
* Nothing is authorised, nothing is reserved and the payment provider is never
|
|
397
|
+
* spoken to: this refusal arrives before any of them.
|
|
398
|
+
*/
|
|
399
|
+
| 'amount_not_priceable'
|
|
400
|
+
/**
|
|
401
|
+
* The cents were issued and the sterling was never taken (CAY-0033). Capture was
|
|
402
|
+
* attempted up to {@link MAX_CAPTURE_ATTEMPTS} times and did not succeed, and an
|
|
403
|
+
* operator resolved the purchase by recording a compensating movement — a NEW
|
|
404
|
+
* movement carving the issued cents back out of the wallet, never an amendment of
|
|
405
|
+
* the movement that put them there. The purchase is terminal and did not succeed;
|
|
406
|
+
* the holder was not charged, and does not keep the cents.
|
|
407
|
+
*/
|
|
408
|
+
| 'capture_failed'
|
|
409
|
+
/**
|
|
410
|
+
* The holder's binding was erased while the purchase was still authorised and
|
|
411
|
+
* nothing had been issued (CAY-0085). An erasure resolves every purchase still in
|
|
412
|
+
* flight before the binding goes, so the purchase ends here: nothing was issued,
|
|
413
|
+
* the hold on the sterling is released, and nothing is taken. It is also the
|
|
414
|
+
* answer a delivery gets for a purchase whose wallet no longer has a binding —
|
|
415
|
+
* settlement issues nothing into a wallet bound to nobody. The purchase is
|
|
416
|
+
* terminal and there is no remedy to offer: the person it was for asked to be
|
|
417
|
+
* forgotten.
|
|
418
|
+
*/
|
|
419
|
+
| 'holder_erased';
|
|
420
|
+
/**
|
|
421
|
+
* A status a purchase never leaves. Stated as its own union so that `status` on
|
|
422
|
+
* {@link PurchaseTerminal} cannot be `'pending'` — a "terminal" answer that
|
|
423
|
+
* carried a live status would be a contradiction the type language can rule out,
|
|
424
|
+
* so it does.
|
|
425
|
+
*
|
|
426
|
+
* `'cancelled'` is a member and is deliberately NOT `'failed'`: a cancellation is
|
|
427
|
+
* a choice and a failure is not, and a consumer rendering them identically would
|
|
428
|
+
* tell somebody their payment failed when they had changed their mind. A cancelled
|
|
429
|
+
* purchase carries no failure reason.
|
|
430
|
+
*/
|
|
431
|
+
export type TerminalPurchaseStatus = 'succeeded' | 'failed' | 'cancelled';
|
|
432
|
+
/**
|
|
433
|
+
* A status a purchase is still moving through. `'pending'` is the authorisation
|
|
434
|
+
* before anything landed; `'issued_uncaptured'` is the cents having landed and the
|
|
435
|
+
* sterling not having been taken (CAY-0033). Neither is a verdict, so neither
|
|
436
|
+
* carries a failure reason, and a consumer polls on through both.
|
|
437
|
+
*/
|
|
438
|
+
export type LivePurchaseStatus = 'pending' | 'issued_uncaptured';
|
|
439
|
+
/**
|
|
440
|
+
* The lifecycle of a purchase: the statuses it can still leave
|
|
441
|
+
* ({@link LivePurchaseStatus}) and the ones it cannot
|
|
442
|
+
* ({@link TerminalPurchaseStatus}). Written as that composition so the three unions
|
|
443
|
+
* can never drift into disagreement about which statuses exist.
|
|
444
|
+
*/
|
|
445
|
+
export type PurchaseStatus = LivePurchaseStatus | TerminalPurchaseStatus;
|
|
446
|
+
/**
|
|
447
|
+
* Every status a purchase never leaves. Derived from the terminality map rather
|
|
448
|
+
* than listed a second time, so a status classified there appears here without
|
|
449
|
+
* anybody having to remember to add it.
|
|
450
|
+
*
|
|
451
|
+
* A consumer stops polling when it sees one of these. Exporting it means the
|
|
452
|
+
* consumer stops on OUR rule rather than on a list it maintained itself, which is
|
|
453
|
+
* the list that goes stale the day a status is added.
|
|
454
|
+
*/
|
|
455
|
+
export declare const TERMINAL_PURCHASE_STATUSES: readonly PurchaseStatus[];
|
|
456
|
+
/** Whether a purchase in this status is finished — it will never transition again. */
|
|
457
|
+
export declare function isTerminalPurchaseStatus(status: PurchaseStatus): boolean;
|
|
458
|
+
/**
|
|
459
|
+
* Why a cancellation was refused. A closed union, for the reason
|
|
460
|
+
* {@link PurchaseFailureReason} is one: a consumer renders it rather than
|
|
461
|
+
* pattern-matching on our prose.
|
|
462
|
+
*
|
|
463
|
+
* Two members are "it is already finished", and they are kept apart because they
|
|
464
|
+
* are different things to tell somebody: their purchase went through, or it did
|
|
465
|
+
* not. There is deliberately no `'already_cancelled'` member — cancelling a
|
|
466
|
+
* cancelled purchase is not a refusal, it is the same answer again.
|
|
467
|
+
*
|
|
468
|
+
* The third, `'already_issued'`, is not "finished" at all: the purchase is live, in
|
|
469
|
+
* `'issued_uncaptured'`, and cancelling is refused precisely because its cents have
|
|
470
|
+
* already landed. Cancel releases a reservation; it has never had the power to take
|
|
471
|
+
* back a movement, and giving it that power here would make it the reversal
|
|
472
|
+
* CAY-0023 forbids.
|
|
473
|
+
*/
|
|
474
|
+
export type PurchaseCancelRefusalReason =
|
|
475
|
+
/**
|
|
476
|
+
* The cents were issued and the card captured. Undoing that is a refund — a
|
|
477
|
+
* different act, with different rules — and it is not this operation.
|
|
478
|
+
*/
|
|
479
|
+
'already_succeeded'
|
|
480
|
+
/** It had already failed, with its own closed reason. There is nothing left to release. */
|
|
481
|
+
| 'already_failed'
|
|
482
|
+
/**
|
|
483
|
+
* The cents have landed and the capture has not settled (`'issued_uncaptured'`,
|
|
484
|
+
* CAY-0033). There is no reservation left to release and cancelling cannot un-land
|
|
485
|
+
* a movement, so it is refused. The purchase is still going: a bounded capture
|
|
486
|
+
* retry, or an operator, resolves it to succeeded or failed.
|
|
487
|
+
*/
|
|
488
|
+
| 'already_issued';
|
|
489
|
+
/**
|
|
490
|
+
* The purchase is cancelled: its reservation is released and it will not
|
|
491
|
+
* transition again. Answered for the cancellation that did the work AND for a
|
|
492
|
+
* repeat of it — cancel is idempotent, and a second cancel is not an error.
|
|
493
|
+
*
|
|
494
|
+
* The figures describe the authorisation that was released, not anything minted: a
|
|
495
|
+
* cancelled purchase mints nothing, and there is no movement here to reverse.
|
|
496
|
+
*/
|
|
497
|
+
export interface PurchaseCancelled {
|
|
498
|
+
readonly outcome: 'cancelled';
|
|
499
|
+
readonly purchaseId: string;
|
|
500
|
+
readonly status: 'cancelled';
|
|
501
|
+
readonly cents: Cents;
|
|
502
|
+
readonly publishedValuePence: number;
|
|
503
|
+
}
|
|
504
|
+
/** The cancellation was refused: the purchase had already reached a terminal status. */
|
|
505
|
+
export interface PurchaseCancelRefused {
|
|
506
|
+
readonly outcome: 'refused';
|
|
507
|
+
readonly error: PurchaseCancelRefusalReason;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* The ONE declared response of `POST /api/purchases/:purchaseId/cancel`,
|
|
511
|
+
* discriminated by `outcome` — the same discipline the authorisation response
|
|
512
|
+
* follows, and for the same reason.
|
|
513
|
+
*/
|
|
514
|
+
export type PurchaseCancelResponse = PurchaseCancelled | PurchaseCancelRefused;
|
|
515
|
+
/**
|
|
516
|
+
* What `GET /api/purchases/:purchaseId` answers: where the purchase has got to.
|
|
517
|
+
*
|
|
518
|
+
* `status` is reported as it stands, including the two live statuses. A consumer
|
|
519
|
+
* that reads `'issued_uncaptured'` has been told plainly that the purchase is
|
|
520
|
+
* neither a success nor a failure, that it may still become either, and — per the
|
|
521
|
+
* statement at the head of this file — that the cents are in the holder's balance
|
|
522
|
+
* meanwhile. It carries no failure reason, because nothing has failed.
|
|
523
|
+
*/
|
|
524
|
+
export interface PurchaseResult {
|
|
525
|
+
readonly purchaseId: string;
|
|
526
|
+
readonly status: PurchaseStatus;
|
|
527
|
+
readonly cents?: Cents;
|
|
528
|
+
readonly publishedValuePence?: number;
|
|
529
|
+
/** Present only on a FAILED purchase. No live or cancelled purchase carries a reason. */
|
|
530
|
+
readonly failureReason?: PurchaseFailureReason;
|
|
531
|
+
}
|
package/dist/purchase.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Percayso Community
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
/** The capability this contract surfaces. Matches the glossary exactly. */
|
|
17
|
+
export const PURCHASE_CAPABILITY = 'cayso.purchase';
|
|
18
|
+
/**
|
|
19
|
+
* How long, in **seconds**, an authorisation holds its slice of the backing
|
|
20
|
+
* ceiling before the reservation is released and the purchase expires.
|
|
21
|
+
*
|
|
22
|
+
* A MECHANISM parameter of this contract, not a canon economic figure — no
|
|
23
|
+
* amount, rate, tier or schedule is restated here (CLAUDE.md). It is the
|
|
24
|
+
* browser-confirmation window: long enough for a holder to complete the payment
|
|
25
|
+
* provider's confirmation step after the client secret is handed over, short
|
|
26
|
+
* enough that an abandoned authorisation frees its headroom promptly rather than
|
|
27
|
+
* walling it off. It is not a number anyone tunes for economics.
|
|
28
|
+
*
|
|
29
|
+
* It lives on the contract for the reason {@link
|
|
30
|
+
* import('./assertion.js').MAX_ASSERTION_LIFETIME_SECONDS} does — that is the
|
|
31
|
+
* precedent, and this follows it. Two parties read this window: the service that
|
|
32
|
+
* computes `expiresAt` from it, and the consumer that has to decide how long to
|
|
33
|
+
* keep a confirmation page alive. A consumer cannot read a window from a type, and
|
|
34
|
+
* a consumer guessing it is a consumer that abandons a live authorisation or waits
|
|
35
|
+
* on a dead one. Seconds, matching the credential parameters beside it; the
|
|
36
|
+
* `expiresAt` a consumer actually renders is `Millis`, and is a FIELD it should
|
|
37
|
+
* read rather than recompute from this.
|
|
38
|
+
*/
|
|
39
|
+
export const PURCHASE_RESERVATION_WINDOW_SECONDS = 15 * 60;
|
|
40
|
+
/**
|
|
41
|
+
* How many times in all a purchase's capture is attempted before it stops being
|
|
42
|
+
* attempted without a person: the first attempt plus its bounded retries.
|
|
43
|
+
*
|
|
44
|
+
* A MECHANISM parameter of this contract, not a canon economic figure — no amount,
|
|
45
|
+
* rate, tier or schedule is restated here (CLAUDE.md). It is a bound on how long
|
|
46
|
+
* the service will keep calling a gateway that is not answering, chosen so a
|
|
47
|
+
* transient failure resolves by itself and a persistent one reaches a person
|
|
48
|
+
* promptly rather than being retried at forever. It is not a number anyone tunes
|
|
49
|
+
* for economics.
|
|
50
|
+
*
|
|
51
|
+
* It lives on the contract for the reason {@link PURCHASE_RESERVATION_WINDOW_SECONDS}
|
|
52
|
+
* does, which in turn follows {@link import('./assertion.js').MAX_ASSERTION_LIFETIME_SECONDS}
|
|
53
|
+
* — that is the precedent, and this follows it. Two parties read the bound: the
|
|
54
|
+
* service, which stops at it, and the consumer, which is entitled to know that a
|
|
55
|
+
* purchase sitting in `'issued_uncaptured'` is not being retried indefinitely and
|
|
56
|
+
* that after this many attempts only an operator will move it. A consumer cannot
|
|
57
|
+
* read a bound from a type. The schema carries it a second time, as a `CHECK` on
|
|
58
|
+
* the stored attempt count, so neither layer can drift past it alone.
|
|
59
|
+
*/
|
|
60
|
+
export const MAX_CAPTURE_ATTEMPTS = 3;
|
|
61
|
+
/**
|
|
62
|
+
* Whether each status is terminal, written out as a total map over
|
|
63
|
+
* {@link PurchaseStatus}.
|
|
64
|
+
*
|
|
65
|
+
* The mapped type is the exhaustiveness check the whole terminal-status
|
|
66
|
+
* declaration rests on: adding a member to `PurchaseStatus` does not compile until
|
|
67
|
+
* somebody writes down whether a purchase in it is finished. That is a deliberate
|
|
68
|
+
* decision forced at the one place it matters, rather than a new status silently
|
|
69
|
+
* inheriting "not terminal" and a consumer polling it forever. It did its job in
|
|
70
|
+
* CAY-0033: `'issued_uncaptured'` — the "issued but not captured" status CAY-0032
|
|
71
|
+
* anticipated — was classified here, as NOT terminal, before anything else compiled.
|
|
72
|
+
*
|
|
73
|
+
* It is module-private on purpose. The exported statements are
|
|
74
|
+
* {@link TERMINAL_PURCHASE_STATUSES} and {@link isTerminalPurchaseStatus}: a
|
|
75
|
+
* consumer needs the answer, not the table, and a published mapped type is a shape
|
|
76
|
+
* the contract-schema generator cannot describe.
|
|
77
|
+
*/
|
|
78
|
+
const PURCHASE_STATUS_TERMINALITY = {
|
|
79
|
+
// The authorisation is held, the browser may still confirm, and the reservation is
|
|
80
|
+
// still counted against the ceiling. Nothing has landed and nothing was charged.
|
|
81
|
+
pending: false,
|
|
82
|
+
// Issued and NOT captured: the cents landed, the sterling did not. Not terminal —
|
|
83
|
+
// a bounded capture retry or an operator still moves it to succeeded or failed, so
|
|
84
|
+
// a consumer that stopped polling here would stop one step before the answer.
|
|
85
|
+
issued_uncaptured: false,
|
|
86
|
+
// Issued and captured. Terminal, and the one status cancel refuses outright.
|
|
87
|
+
succeeded: true,
|
|
88
|
+
// Refused, declined or expired, with a closed reason. Terminal.
|
|
89
|
+
failed: true,
|
|
90
|
+
// Chosen, by the holder or by an operator acting on their wallet. Terminal, and
|
|
91
|
+
// carries no failure reason — see TerminalPurchaseStatus.
|
|
92
|
+
cancelled: true,
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Every status a purchase never leaves. Derived from the terminality map rather
|
|
96
|
+
* than listed a second time, so a status classified there appears here without
|
|
97
|
+
* anybody having to remember to add it.
|
|
98
|
+
*
|
|
99
|
+
* A consumer stops polling when it sees one of these. Exporting it means the
|
|
100
|
+
* consumer stops on OUR rule rather than on a list it maintained itself, which is
|
|
101
|
+
* the list that goes stale the day a status is added.
|
|
102
|
+
*/
|
|
103
|
+
export const TERMINAL_PURCHASE_STATUSES = Object.keys(PURCHASE_STATUS_TERMINALITY).filter((status) => PURCHASE_STATUS_TERMINALITY[status]);
|
|
104
|
+
/** Whether a purchase in this status is finished — it will never transition again. */
|
|
105
|
+
export function isTerminalPurchaseStatus(status) {
|
|
106
|
+
return PURCHASE_STATUS_TERMINALITY[status];
|
|
107
|
+
}
|