@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.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +114 -0
  3. package/dist/assertion.d.ts +867 -0
  4. package/dist/assertion.js +63 -0
  5. package/dist/assurance.d.ts +353 -0
  6. package/dist/assurance.js +551 -0
  7. package/dist/citizen-market.d.ts +468 -0
  8. package/dist/citizen-market.js +16 -0
  9. package/dist/citizenship.d.ts +215 -0
  10. package/dist/citizenship.js +26 -0
  11. package/dist/client.d.ts +288 -0
  12. package/dist/client.js +370 -0
  13. package/dist/commercial-payment.d.ts +243 -0
  14. package/dist/commercial-payment.js +16 -0
  15. package/dist/contract-schema.generated.d.ts +14 -0
  16. package/dist/contract-schema.generated.js +3107 -0
  17. package/dist/errors.d.ts +171 -0
  18. package/dist/errors.js +28 -0
  19. package/dist/index.d.ts +621 -0
  20. package/dist/index.js +624 -0
  21. package/dist/marketplace-receipt.d.ts +306 -0
  22. package/dist/marketplace-receipt.js +116 -0
  23. package/dist/marketplace-settlement.d.ts +298 -0
  24. package/dist/marketplace-settlement.js +16 -0
  25. package/dist/money.d.ts +84 -0
  26. package/dist/money.js +34 -0
  27. package/dist/organisation.d.ts +60 -0
  28. package/dist/organisation.js +16 -0
  29. package/dist/purchase.d.ts +531 -0
  30. package/dist/purchase.js +107 -0
  31. package/dist/restriction.d.ts +262 -0
  32. package/dist/restriction.js +316 -0
  33. package/dist/routes.d.ts +155 -0
  34. package/dist/routes.js +128 -0
  35. package/dist/schema-types.d.ts +101 -0
  36. package/dist/schema-types.js +16 -0
  37. package/dist/schema.d.ts +24 -0
  38. package/dist/schema.js +16 -0
  39. package/dist/wallet.d.ts +92 -0
  40. package/dist/wallet.js +59 -0
  41. 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
+ }
@@ -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
+ }