@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
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;
|