@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
package/dist/client.js ADDED
@@ -0,0 +1,370 @@
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
+ import { CONTRACT_VERSION, NOT_PROVISIONED } from './index.js';
17
+ import { ROUTES } from './routes.js';
18
+ // --- The retry mechanism -----------------------------------------------------
19
+ // These are this slice's own mechanism, not economics: an attempt count, a
20
+ // backoff base and the set of retryable statuses. No amount, rate, tier,
21
+ // schedule or threshold lives here (CLAUDE.md, "No numbers").
22
+ /** Total attempts, the first included. Three means three — never four. */
23
+ const MAX_ATTEMPTS = 3;
24
+ /** Base backoff in milliseconds; the wait doubles with each spent attempt. */
25
+ const BACKOFF_BASE_MS = 100;
26
+ /** The statuses a *safe* request retries on. Everything else fails fast. */
27
+ const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
28
+ /**
29
+ * Raised for any non-2xx response and any network failure. `code` is parsed from
30
+ * the body's `{ error, message }`, falling back to `'request_failed'` when the
31
+ * body is absent, empty, unparseable or the wrong shape — the client never throws
32
+ * a `SyntaxError` out at a consumer. Exported as a value, extending `Error` with
33
+ * a fixed prototype and name, so `instanceof CaysoApiError` holds for a consumer.
34
+ */
35
+ export class CaysoApiError extends Error {
36
+ code;
37
+ status;
38
+ detail;
39
+ constructor(code, status, detail) {
40
+ super(detail ?? code);
41
+ // A class extending a built-in loses `instanceof` once compiled down unless
42
+ // the prototype is restored and the name set explicitly.
43
+ this.name = 'CaysoApiError';
44
+ this.code = code;
45
+ this.status = status;
46
+ this.detail = detail;
47
+ Object.setPrototypeOf(this, CaysoApiError.prototype);
48
+ }
49
+ }
50
+ const realSleep = (ms) => new Promise((resolve) => {
51
+ setTimeout(resolve, ms);
52
+ });
53
+ export function createCaysoClient(options) {
54
+ return buildClient(options, { sleep: realSleep });
55
+ }
56
+ /**
57
+ * The construction seam the test suite uses to inject `sleep`. Not part of the
58
+ * supported client surface — a consumer calls {@link createCaysoClient}. It lives
59
+ * here rather than on the public options type so the timing stays untestable-by-
60
+ * accident for a consumer and fully testable for the suite.
61
+ *
62
+ * @internal
63
+ */
64
+ export function createCaysoClientWithInternals(options, internals) {
65
+ return buildClient(options, internals);
66
+ }
67
+ function buildClient(options, internals) {
68
+ const fetchImpl = options.fetchImpl ?? fetch;
69
+ const { sleep } = internals;
70
+ const base = options.baseUrl.replace(/\/+$/, '');
71
+ const warn = options.warn ?? ((message) => console.warn(message));
72
+ // "Once" is scoped to THIS client instance: the flag is closed over here, not held
73
+ // in a module-level variable, so two clients warn independently and a test can prove
74
+ // the scope by constructing two. It never resets — a warning is a one-time nudge to
75
+ // upgrade, not a per-request alarm that would bury the money path in noise.
76
+ let versionSkewWarned = false;
77
+ /**
78
+ * Warn at most once, and only when the server's contract MAJOR differs from this
79
+ * client's. A missing or unparseable version on either side is not a skew we can
80
+ * assert, so it stays silent rather than crying wolf. Only version strings reach
81
+ * the log line — never a credential.
82
+ */
83
+ function warnOnVersionSkew(serverContractVersion) {
84
+ if (versionSkewWarned)
85
+ return;
86
+ const localMajor = majorOf(CONTRACT_VERSION);
87
+ const serverMajor = majorOf(serverContractVersion);
88
+ if (localMajor === null || serverMajor === null || localMajor === serverMajor)
89
+ return;
90
+ versionSkewWarned = true;
91
+ warn(`@percayso-community/cayso-contracts: the server publishes contract version ${serverContractVersion} ` +
92
+ `(major ${serverMajor}) but this client was built against ${CONTRACT_VERSION} ` +
93
+ `(major ${localMajor}); upgrade @percayso-community/cayso-contracts to match the server's major.`);
94
+ }
95
+ async function send(spec) {
96
+ let attempt = 0;
97
+ for (;;) {
98
+ attempt += 1;
99
+ // Per attempt, never cached: a short-lived credential must not outlive the
100
+ // backoff it was minted before (CAY-0021).
101
+ const assertion = await options.getAssertion();
102
+ const headers = {
103
+ Authorization: `Bearer ${assertion}`,
104
+ Accept: 'application/json',
105
+ };
106
+ if (spec.body !== undefined)
107
+ headers['Content-Type'] = 'application/json';
108
+ // The key travels to the server so a redelivered request is deduplicated
109
+ // there rather than minting twice (CAY-0023).
110
+ if (spec.idempotencyKey !== undefined && spec.idempotencyKey.length > 0) {
111
+ headers['Idempotency-Key'] = spec.idempotencyKey;
112
+ }
113
+ const init = { method: spec.method, headers };
114
+ if (spec.body !== undefined)
115
+ init.body = JSON.stringify(spec.body);
116
+ let response;
117
+ try {
118
+ response = await fetchImpl(`${base}${spec.path}`, init);
119
+ }
120
+ catch (cause) {
121
+ if (spec.idempotent && attempt < MAX_ATTEMPTS) {
122
+ await sleep(backoffFor(attempt));
123
+ continue;
124
+ }
125
+ throw new CaysoApiError('request_failed', 0, networkDetail(cause));
126
+ }
127
+ if (response.ok)
128
+ return response;
129
+ if (spec.idempotent && RETRYABLE_STATUS.has(response.status) && attempt < MAX_ATTEMPTS) {
130
+ const retryAfter = parseRetryAfter(response.headers.get('retry-after'));
131
+ await sleep(retryAfter ?? backoffFor(attempt));
132
+ continue;
133
+ }
134
+ throw await errorFrom(response);
135
+ }
136
+ }
137
+ async function requestJson(spec) {
138
+ const response = await send(spec);
139
+ return (await readJson(response));
140
+ }
141
+ return {
142
+ async getMyWallet() {
143
+ try {
144
+ const view = await requestJson({
145
+ method: ROUTES.wallet.method,
146
+ path: ROUTES.wallet.path,
147
+ idempotent: true,
148
+ });
149
+ // A wallet that does not exist yet is `null`, not an error.
150
+ return view ?? null;
151
+ }
152
+ catch (error) {
153
+ // An UNPROVISIONED citizen has no wallet, and since CAY-0031 reading does
154
+ // not create one — the server answers 404 with the closed `not_provisioned`
155
+ // code. `null` is the honest translation: there is no wallet to hand back.
156
+ // A consumer that needs to know WHY there is none calls
157
+ // {@link CaysoClient.getMyCitizenship}, which distinguishes the states; this
158
+ // method's contract is `WalletView | null` and it keeps it.
159
+ if (error instanceof CaysoApiError && error.status === 404)
160
+ return null;
161
+ if (error instanceof CaysoApiError && error.code === NOT_PROVISIONED)
162
+ return null;
163
+ throw error;
164
+ }
165
+ },
166
+ async getMyCitizenship() {
167
+ return requestJson({
168
+ method: ROUTES.citizenship.method,
169
+ path: ROUTES.citizenship.path,
170
+ idempotent: true,
171
+ });
172
+ },
173
+ async provisionCitizenship() {
174
+ return requestJson({
175
+ method: ROUTES.citizenshipProvision.method,
176
+ path: ROUTES.citizenshipProvision.path,
177
+ // Safe to retry with no key, unlike a purchase: the operation is
178
+ // idempotent on the caller's own identity, so a repeat is the same act
179
+ // rather than a second one (see the interface docblock).
180
+ idempotent: true,
181
+ });
182
+ },
183
+ async claimFounderWallet() {
184
+ return requestJson({
185
+ method: ROUTES.citizenshipFounderClaim.method,
186
+ path: ROUTES.citizenshipFounderClaim.path,
187
+ // Safe to retry with no key, for provisioning's reason: the claim is
188
+ // idempotent on the caller's own identity, and each attempt mints a fresh
189
+ // assertion, so the single-use `jti` is never presented twice.
190
+ idempotent: true,
191
+ });
192
+ },
193
+ async provisionOrganisation() {
194
+ return requestJson({
195
+ method: ROUTES.organisationProvision.method,
196
+ path: ROUTES.organisationProvision.path,
197
+ // Safe to retry with no key, for the same reason provisioning a citizen
198
+ // is: the operation is idempotent on the attested Organisation ID, so a
199
+ // repeat is the same act rather than a second one.
200
+ idempotent: true,
201
+ });
202
+ },
203
+ async authorisePurchase(req) {
204
+ return requestJson({
205
+ method: ROUTES.purchaseAuthorise.method,
206
+ path: ROUTES.purchaseAuthorise.path,
207
+ // A POST is safe to repeat only because of the key it carries.
208
+ idempotent: typeof req.idempotencyKey === 'string' && req.idempotencyKey.length > 0,
209
+ body: req,
210
+ idempotencyKey: req.idempotencyKey,
211
+ });
212
+ },
213
+ async makeCommercialPayment(req) {
214
+ return requestJson({
215
+ method: ROUTES.commercialPayment.method,
216
+ path: ROUTES.commercialPayment.path,
217
+ // A POST is safe to repeat only because of the key it carries: a repeat
218
+ // under the same key replays the payment rather than making a second one.
219
+ idempotent: typeof req.idempotencyKey === 'string' && req.idempotencyKey.length > 0,
220
+ body: req,
221
+ idempotencyKey: req.idempotencyKey,
222
+ });
223
+ },
224
+ async getCitizenMarket() {
225
+ return requestJson({
226
+ method: ROUTES.citizenMarket.method,
227
+ path: ROUTES.citizenMarket.path,
228
+ idempotent: true,
229
+ });
230
+ },
231
+ async placeQuote(req) {
232
+ return requestJson({
233
+ method: ROUTES.placeQuote.method,
234
+ path: ROUTES.placeQuote.path,
235
+ // A POST is safe to repeat only because of the key it carries: a repeat
236
+ // under the same key replays the quote rather than placing a second one.
237
+ idempotent: typeof req.idempotencyKey === 'string' && req.idempotencyKey.length > 0,
238
+ body: req,
239
+ idempotencyKey: req.idempotencyKey,
240
+ });
241
+ },
242
+ async withdrawQuote(req) {
243
+ return requestJson({
244
+ method: ROUTES.withdrawQuote.method,
245
+ path: ROUTES.withdrawQuote.path,
246
+ // Safe to repeat with no key of its own: the quote id names the one thing
247
+ // withdrawn, and withdrawing it again is the same answer, not a second act.
248
+ idempotent: true,
249
+ body: req,
250
+ });
251
+ },
252
+ async requestBuyBack(req) {
253
+ return requestJson({
254
+ method: ROUTES.requestBuyBack.method,
255
+ path: ROUTES.requestBuyBack.path,
256
+ // A POST is safe to repeat only because of the key it carries: a repeat
257
+ // under the same key replays the buy-back rather than selling twice.
258
+ idempotent: typeof req.idempotencyKey === 'string' && req.idempotencyKey.length > 0,
259
+ body: req,
260
+ idempotencyKey: req.idempotencyKey,
261
+ });
262
+ },
263
+ async getPurchase(purchaseId) {
264
+ return requestJson({
265
+ method: ROUTES.purchaseResult.method,
266
+ path: fillPath(ROUTES.purchaseResult.path, { purchaseId }),
267
+ idempotent: true,
268
+ });
269
+ },
270
+ async cancelPurchase(purchaseId) {
271
+ return requestJson({
272
+ method: ROUTES.purchaseCancel.method,
273
+ path: fillPath(ROUTES.purchaseCancel.path, { purchaseId }),
274
+ // A POST, and safe to repeat with NO idempotency key — unlike authorising.
275
+ // The purchase id names the one thing being cancelled, and cancelling an
276
+ // already-cancelled purchase is the same answer rather than a second act,
277
+ // so a retried cancel cannot produce two of anything.
278
+ idempotent: true,
279
+ });
280
+ },
281
+ async getVersion() {
282
+ const version = await requestJson({
283
+ method: ROUTES.version.method,
284
+ path: ROUTES.version.path,
285
+ idempotent: true,
286
+ });
287
+ // The one line this client logs — at most once per instance, version strings
288
+ // only. A different major means the server's surface is not this package's.
289
+ warnOnVersionSkew(version.contractVersion);
290
+ return version;
291
+ },
292
+ };
293
+ }
294
+ // --- Helpers -----------------------------------------------------------------
295
+ /** Substitute `:param` segments in a route path from `ROUTES`. */
296
+ function fillPath(path, params) {
297
+ let out = path;
298
+ for (const [name, value] of Object.entries(params)) {
299
+ out = out.replace(`:${name}`, encodeURIComponent(value));
300
+ }
301
+ return out;
302
+ }
303
+ /** Exponential backoff for a spent attempt (1-based): base, then base·2, … */
304
+ function backoffFor(attempt) {
305
+ return BACKOFF_BASE_MS * 2 ** (attempt - 1);
306
+ }
307
+ /**
308
+ * The major component of a `MAJOR.minor.patch` version, or `null` when the string
309
+ * is absent or does not begin with a run of digits. `null` means "cannot tell", and
310
+ * the skew check stays silent on it rather than guessing — an unparseable version is
311
+ * not evidence of a mismatch.
312
+ */
313
+ function majorOf(version) {
314
+ if (version === undefined)
315
+ return null;
316
+ const first = version.split('.')[0];
317
+ if (first === undefined || !/^\d+$/.test(first))
318
+ return null;
319
+ return Number(first);
320
+ }
321
+ /**
322
+ * `Retry-After` in milliseconds, or `null` when absent or unrecognised. Both
323
+ * documented forms are handled: an integer count of seconds, and an HTTP-date.
324
+ */
325
+ function parseRetryAfter(value) {
326
+ if (value === null)
327
+ return null;
328
+ const trimmed = value.trim();
329
+ if (trimmed.length === 0)
330
+ return null;
331
+ if (/^\d+$/.test(trimmed))
332
+ return Number(trimmed) * 1000;
333
+ const when = Date.parse(trimmed);
334
+ if (Number.isNaN(when))
335
+ return null;
336
+ return Math.max(0, when - Date.now());
337
+ }
338
+ /**
339
+ * The response body as JSON, or `undefined` when it is empty or unparseable. A
340
+ * malformed body never throws a `SyntaxError` out of the client.
341
+ */
342
+ async function readJson(response) {
343
+ const text = await response.text();
344
+ if (text.trim().length === 0)
345
+ return undefined;
346
+ try {
347
+ return JSON.parse(text);
348
+ }
349
+ catch {
350
+ return undefined;
351
+ }
352
+ }
353
+ /** A `CaysoApiError` built from a non-2xx response's `{ error, message }` body. */
354
+ async function errorFrom(response) {
355
+ const body = await readJson(response);
356
+ let code = 'request_failed';
357
+ let detail;
358
+ if (typeof body === 'object' && body !== null) {
359
+ const record = body;
360
+ if (typeof record.error === 'string' && record.error.length > 0)
361
+ code = record.error;
362
+ if (typeof record.message === 'string')
363
+ detail = record.message;
364
+ }
365
+ return new CaysoApiError(code, response.status, detail);
366
+ }
367
+ /** A human-readable note for a network failure — never a credential. */
368
+ function networkDetail(cause) {
369
+ return cause instanceof Error ? cause.message : undefined;
370
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * `commercial-payment` — the request, the answer and the closed refusal union for
3
+ * an Organisation paying a Citizen (CAY-0079).
4
+ *
5
+ * A commercial payment is cents moving directly from an Organisation Wallet to a
6
+ * Citizen Wallet, for work done or a service supplied. It is not "P2P" and it is
7
+ * not the citizen market. The Treasury takes no part in it (CONTEXT.md, Commercial
8
+ * payment). This file is the Piper-facing half of CAY-0079's slices 2 and 3: the
9
+ * shapes a consumer sends and decodes. It is served at `ROUTES.commercialPayment`
10
+ * (CAY-0079 slice 6) on both routes: the mandate route first, and the Authorised-User
11
+ * route second (Open decision H, #284).
12
+ *
13
+ * ## One movement or nothing, so there is no status
14
+ *
15
+ * A Purchase has a lifecycle because sterling crosses the boundary and a provider
16
+ * can fail after authorising (CAY-0023). A commercial payment has no sterling leg
17
+ * and no provider, so there is nothing to authorise first and capture later
18
+ * (CAY-0079 §5). The answer is one of two things: a {@link CommercialPaymentMade},
19
+ * carrying the one movement that was written, final and append-only, or a
20
+ * {@link CommercialPaymentRefused}, carrying one reason and meaning nothing was
21
+ * written. **"Failed" means nothing moved.** There is no pending, failed or
22
+ * reversed payment on this surface, and nothing on these shapes can express one.
23
+ *
24
+ * ## Replay under one key
25
+ *
26
+ * The idempotency key is unique together with the paying wallet (CAY-0079 §5). A
27
+ * repeat with the same key, the same payee and the same cents answers the movement
28
+ * already written, in the same shape as the first answer, so a caller that lost
29
+ * the answer can ask again safely. A repeat with the same key and anything
30
+ * different is refused with `idempotency_key_reused_for_a_different_payment`. The
31
+ * two amounts cannot both be what the caller asked for, so the request is refused
32
+ * rather than answered at either.
33
+ *
34
+ * A refund is a NEW payment in the other direction, under the other party's
35
+ * authority and its own key. It is never a reversal of this one.
36
+ *
37
+ * ## The authority is declared by the caller and checked, never inferred
38
+ *
39
+ * A commercial payment is made by one of two parties and nobody else (CAY-0079
40
+ * §1): a mandate holder acting as the organisation, or an Authorised User under a
41
+ * payment allowance. The request says which with {@link
42
+ * CommercialPaymentRequest.authority}, and the service checks that route and only
43
+ * that route.
44
+ *
45
+ * It is declared rather than worked out from what the credential happens to carry
46
+ * for two reasons. The refusal set names each route's own failures, and
47
+ * `mandate_claim_absent` only means something when the mandate route was asked
48
+ * for. And falling back from one route to the other would let a failed mandate
49
+ * check become a draw against somebody's allowance limit. The movement would then
50
+ * be recorded under an authority the caller never invoked.
51
+ *
52
+ * Declaring the route is not a claim about who the caller is. Who they are comes
53
+ * from the verified assertion's subject (CAY-0021), and whether they may act for
54
+ * the paying wallet comes from the mandate claim or the allowance record. The
55
+ * field only chooses which of those two is checked.
56
+ *
57
+ * The Authorised-User route shipped after the mandate route (CAY-0079, Open decision
58
+ * H). Both were on the contract from the first version, so the refusal union did not
59
+ * grow for the second. A refusal on either route is answered as that route's, and is
60
+ * never tried on the other instead.
61
+ *
62
+ * ## The payee is a wallet id, never a Percayso identity
63
+ *
64
+ * A Citizen gives the organisation their wallet id, as they would give a delivery
65
+ * address (CAY-0079 §3). A request that named the payee by identity would turn the
66
+ * operator-facing handle into a directory of identities, and give anyone holding a
67
+ * mandate an identity oracle. So the request carries two wallet ids, and the
68
+ * acting party is resolved from the assertion's subject and then discarded. The
69
+ * answer echoes nothing about the person who acted.
70
+ *
71
+ * ## What this file must not decide
72
+ *
73
+ * - **This direction only.** Organisation to Citizen. A Citizen paying an
74
+ * Organisation shares the classification (CAY-0079 §4.3) and is not this
75
+ * operation. Organisation to organisation is not proposed at all.
76
+ * - **One payee.** A split between several payees is not designed (CAY-0079 §6).
77
+ * Rounding, refunds of a split, tax and who sets a split are all open, so
78
+ * nothing here has a list of payees.
79
+ * - **No figures.** The Signatory / Authorised-User threshold lives in the canon
80
+ * PRD and reaches the service as configuration. An unset threshold closes the
81
+ * Authorised-User route and is a refusal, never "no limit". No amount, rate,
82
+ * tier or threshold is restated here.
83
+ * - **No excluded-payee reason yet.** Exclusion is decided outside this economy
84
+ * and no signal of it reaches Cayso. A named case nothing can raise would be a
85
+ * dormant code path, so `payee_excluded` joins the union in the slice that
86
+ * delivers the signal (CAY-0079, Open decision F) and not before.
87
+ * - **No unconfigured adapter.** A verifier, holders mapping or database that is
88
+ * not configured refuses the REQUEST with the refusal envelope's own seam code
89
+ * (`errors.ts`), not the payment. It is not a member of this union.
90
+ */
91
+ import type { Cents, Millis } from './wallet.js';
92
+ /**
93
+ * Which of the two authorities the caller invokes to pay from the Organisation
94
+ * Wallet (CAY-0079 §1).
95
+ *
96
+ * - `'mandate'` — the caller holds the mandate for the paying wallet and acts as
97
+ * the organisation. Checked against the verified assertion's mandate claim
98
+ * (`AssertionClaims.mandateFor`), which Cayso accepts as Percayso ID's
99
+ * attestation and never checks against a register of its own (CAY-0068).
100
+ * - `'payment-allowance'` — the caller is the Authorised User named on a live
101
+ * allowance over the paying wallet, and that allowance was granted for payment.
102
+ * An allowance granted for spending does not convey payment and is never read
103
+ * as if it did.
104
+ */
105
+ export type CommercialPaymentAuthority = 'mandate' | 'payment-allowance';
106
+ /** An Organisation paying one Citizen, in cents, under one key. */
107
+ export interface CommercialPaymentRequest {
108
+ /** The route the caller invokes. Checked, and never swapped for the other on a refusal. */
109
+ readonly authority: CommercialPaymentAuthority;
110
+ /** The Organisation Wallet the cents leave. */
111
+ readonly payingWalletId: string;
112
+ /** The Citizen Wallet the cents arrive in. A wallet id, never a Percayso identity. */
113
+ readonly payeeWalletId: string;
114
+ /** Whole cents, above zero. Anything else is refused `amount_not_positive_whole_cents`. */
115
+ readonly cents: Cents;
116
+ /** Caller-generated, unique per paying wallet. The same key never moves cents twice; a repeat REPLAYS. */
117
+ readonly idempotencyKey: string;
118
+ }
119
+ /**
120
+ * The one movement a commercial payment wrote. Final and append-only: there is no
121
+ * second one, and nothing amends it.
122
+ *
123
+ * It carries no acting party. The caller is the acting party and already knows
124
+ * who they are, and the answer is not a place to repeat an identity or the wallet
125
+ * it resolved to.
126
+ */
127
+ export interface CommercialPayment {
128
+ /** The movement that carried the cents. A handle to quote back; the movement itself is not on this surface. */
129
+ readonly movementId: string;
130
+ readonly payingWalletId: string;
131
+ readonly payeeWalletId: string;
132
+ readonly cents: Cents;
133
+ readonly paidAt: Millis;
134
+ }
135
+ /**
136
+ * The payment was made. Answered for the request that made it and for every
137
+ * replay of its key, in the same shape: a replay is not a lesser answer.
138
+ */
139
+ export interface CommercialPaymentMade {
140
+ readonly outcome: 'paid';
141
+ readonly payment: CommercialPayment;
142
+ }
143
+ /** The payment was refused, with one member of the closed union. Nothing was written. */
144
+ export interface CommercialPaymentRefused {
145
+ readonly outcome: 'refused';
146
+ readonly error: CommercialPaymentRefusal;
147
+ }
148
+ /**
149
+ * The ONE declared answer to a commercial payment, discriminated by `outcome`. A
150
+ * consumer switches on `outcome` and never sniffs which fields are present, the
151
+ * discipline every response union on this surface follows.
152
+ */
153
+ export type CommercialPaymentResponse = CommercialPaymentMade | CommercialPaymentRefused;
154
+ /**
155
+ * Why a commercial payment was refused: CAY-0079 §3's set, closed, spelled here.
156
+ *
157
+ * A consumer renders a member by its name and never pattern-matches on prose. The
158
+ * SET was fixed by the record when it was accepted, and this contract fixes the
159
+ * spelling. A new reason is a new member and a contract version, never a free-text
160
+ * field. Every member means the same thing about money: nothing moved.
161
+ *
162
+ * Reasons for the Authorised-User route were here before that route was served
163
+ * (CAY-0079, Open decision H), for the reason `founder_wallet_lapsed` was published
164
+ * before anything produced it: a consumer exhausting this union rendered them from
165
+ * the first version, and the union did not grow to admit them when the route shipped.
166
+ */
167
+ export type CommercialPaymentRefusal =
168
+ /** Mandate route: the assertion carries no mandate claim. */
169
+ 'mandate_claim_absent'
170
+ /** Mandate route: the mandate claim does not name the paying wallet. */
171
+ | 'mandate_claim_does_not_cover_the_paying_wallet'
172
+ /**
173
+ * Either route: the verification method is outside the money-grade assurance
174
+ * profile (`ASSURANCE_POLICY['make-commercial-payment']`). `dev-bypass` is never
175
+ * inside it.
176
+ */
177
+ | 'assurance_not_money_grade'
178
+ /**
179
+ * Either route: the verified subject resolves to no wallet. The acting party on
180
+ * a commercial payment is always a wallet id (CAY-0079, Open decision B), and the
181
+ * payment never provisions one. The caller provisions their own wallet first.
182
+ */
183
+ | 'acting_party_has_no_citizen_wallet'
184
+ /** Allowance route: no allowance was ever granted to this caller over the paying wallet. */
185
+ | 'no_live_allowance'
186
+ /**
187
+ * Allowance route: an allowance existed and was revoked. Kept apart from
188
+ * `no_live_allowance` because a revoked allowance is kept, and "it was taken
189
+ * away" and "you never had one" are different things to tell somebody.
190
+ */
191
+ | 'allowance_revoked'
192
+ /** Allowance route: the live allowance was granted for spending, not for payment. */
193
+ | 'allowance_does_not_convey_payment'
194
+ /** Allowance route: what was already spent plus this payment would pass the allowance's limit. */
195
+ | 'allowance_limit_insufficient'
196
+ /**
197
+ * Allowance route: the Signatory / Authorised-User threshold, converted to cents
198
+ * at the published value at the moment of payment, refuses it (CAY-0079, Open
199
+ * decision A). The allowance is left untouched.
200
+ */
201
+ | 'payment_above_authorised_user_threshold'
202
+ /**
203
+ * Allowance route: the threshold is not configured. A closed door, the same one
204
+ * an allowance grant meets, and never "no limit". It does not close the mandate
205
+ * route.
206
+ */
207
+ | 'authorised_user_threshold_not_configured'
208
+ /** Allowance route: no published value is available to convert the threshold at. The same closed door. */
209
+ | 'authorised_user_threshold_published_value_unavailable'
210
+ /**
211
+ * The paying wallet is a Citizen Wallet or the Treasury. This operation is never
212
+ * citizen to citizen, and a citizen who wants to pay a citizen uses the citizen
213
+ * market's own controls (CAY-0079 §7).
214
+ */
215
+ | 'paying_wallet_not_an_organisations'
216
+ /** No wallet has the payee's id. */
217
+ | 'payee_wallet_not_found'
218
+ /** The payee's wallet is an Organisation Wallet or the Treasury. */
219
+ | 'payee_wallet_not_a_citizens'
220
+ /** No citizen is bound to the payee's wallet now, for example because the binding was erased. */
221
+ | 'payee_wallet_has_no_live_binding'
222
+ /**
223
+ * The caller is paying themselves. Refused on both routes, so the record of a
224
+ * director's remuneration always shows two people (CAY-0079, Open decision D).
225
+ */
226
+ | 'payee_is_the_acting_party'
227
+ /** The cents are zero, negative or not a whole number. */
228
+ | 'amount_not_positive_whole_cents'
229
+ /**
230
+ * The paying wallet's unrestricted cents are short. Restricted cents are never
231
+ * counted and never drawn, so a wallet holding enough only once restricted cents
232
+ * are included is still short (CAY-0079 §4).
233
+ */
234
+ | 'unrestricted_cents_insufficient'
235
+ /** A holding the payment would draw is frozen. A freeze is not restriction. */
236
+ | 'cents_frozen'
237
+ /** The key was already used by this paying wallet for a different payee or a different number of cents. */
238
+ | 'idempotency_key_reused_for_a_different_payment'
239
+ /**
240
+ * Too many concurrent draws on the paying wallet, and the retries ran out.
241
+ * Nothing moved, and the caller may try again under the same key.
242
+ */
243
+ | 'carve_contended';
@@ -0,0 +1,16 @@
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
+ export {};
@@ -0,0 +1,14 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Produced by `scripts/generate-contract-schema.ts` from the published types
5
+ * (`wallet.ts`, `purchase.ts`, `assertion.ts`, `citizenship.ts`, `errors.ts`)
6
+ * and the
7
+ * package's own value exports. Regenerate with `pnpm --filter @percayso-community/cayso-contracts generate-schema`.
8
+ *
9
+ * The CI contract test (`contract-schema.test.ts`) regenerates this in memory and
10
+ * compares: a type changed without regenerating this file turns it red, which is the
11
+ * whole point — the schema cannot silently drift from the types it describes.
12
+ */
13
+ import type { ContractSchema } from './schema-types.js';
14
+ export declare const CONTRACT_SCHEMA: ContractSchema;