@topolo/sdk 0.2.8 → 0.2.10

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/src/index.ts CHANGED
@@ -43,6 +43,23 @@ export {
43
43
  type ListContactsResult,
44
44
  } from "./modules/crm.js";
45
45
  export { IdentityModule } from "./modules/identity.js";
46
+ export {
47
+ P2PModule,
48
+ type P2PCapability,
49
+ type P2PPricingModel,
50
+ type PublishCapabilityInput,
51
+ type P2PActionRow,
52
+ type P2PActionRun,
53
+ type P2PActionRequestInput,
54
+ type P2PActionRequestResult,
55
+ type P2PActionDetail,
56
+ type P2PHold,
57
+ type P2PLedgerEntry,
58
+ type P2PPolicy,
59
+ type P2PConnection,
60
+ type P2PSettlementGroup,
61
+ type P2PSettlementBatch,
62
+ } from "./modules/p2p.js";
46
63
  export {
47
64
  TopoloOAuth,
48
65
  type OAuthHelperOptions,
@@ -0,0 +1,218 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { TopoloClient } from '../client.js';
3
+ import { P2PModule } from './p2p.js';
4
+
5
+ const AGENT = { clientName: 'topolo-sdk-test', clientVersion: '0.0.0' };
6
+
7
+ function stubFetch(responses: Array<{ status?: number; body?: unknown }>) {
8
+ const calls: Array<{ url: string; init: RequestInit }> = [];
9
+ const impl = vi.fn(async (url: string, init: RequestInit) => {
10
+ calls.push({ url, init });
11
+ const next = responses.shift() ?? { status: 200, body: { success: true, data: {} } };
12
+ return new Response(JSON.stringify(next.body ?? { success: true, data: {} }), {
13
+ status: next.status ?? 200,
14
+ headers: { 'Content-Type': 'application/json' },
15
+ });
16
+ });
17
+ return { impl: impl as unknown as typeof fetch, calls };
18
+ }
19
+
20
+ function clientWith(impl: typeof fetch) {
21
+ return new TopoloClient({
22
+ credential: { kind: 'access_token', accessToken: 'tok_test' },
23
+ agent: AGENT,
24
+ fetch: impl,
25
+ });
26
+ }
27
+
28
+ describe('P2PModule.listDirectory', () => {
29
+ it('GETs /api/directory/capabilities and unwraps the envelope', async () => {
30
+ const { impl, calls } = stubFetch([
31
+ {
32
+ body: {
33
+ success: true,
34
+ data: {
35
+ capabilities: [
36
+ {
37
+ capability_id: 'cap_1',
38
+ publisher_org_id: 'org_a',
39
+ app_id: 'app_x',
40
+ service_id: 'srv_x',
41
+ name: 'Demo',
42
+ description: 'A capability',
43
+ category: 'other',
44
+ pricing_model: 'free',
45
+ pricing_amount_minor: null,
46
+ currency: null,
47
+ requires_human_approval: false,
48
+ supported_contexts: ['agent'],
49
+ trust_level: 'standard',
50
+ source_revision: 'rev_1',
51
+ synced_at: 1,
52
+ },
53
+ ],
54
+ },
55
+ },
56
+ },
57
+ ]);
58
+ const p2p = new P2PModule(clientWith(impl));
59
+
60
+ const capabilities = await p2p.listDirectory({ limit: 10 });
61
+ expect(capabilities).toHaveLength(1);
62
+ expect(capabilities[0]!.capability_id).toBe('cap_1');
63
+ const url = new URL(calls[0]!.url);
64
+ expect(url.pathname).toBe('/api/directory/capabilities');
65
+ expect(url.searchParams.get('limit')).toBe('10');
66
+ expect((calls[0]!.init.method ?? 'GET').toUpperCase()).toBe('GET');
67
+ });
68
+ });
69
+
70
+ describe('P2PModule.publishCapability', () => {
71
+ it('POSTs to the internal sync route with the publisher_org_id and acting_for_org_id default', async () => {
72
+ const { impl, calls } = stubFetch([
73
+ {
74
+ body: {
75
+ success: true,
76
+ data: {
77
+ capability: {
78
+ capability_id: 'cap_z',
79
+ publisher_org_id: 'org_p',
80
+ app_id: 'app_x',
81
+ service_id: 'srv_x',
82
+ name: 'Demo',
83
+ description: 'A capability',
84
+ category: 'other',
85
+ pricing_model: 'fixed',
86
+ pricing_amount_minor: 500,
87
+ currency: 'USD',
88
+ requires_human_approval: false,
89
+ supported_contexts: ['agent'],
90
+ trust_level: 'standard',
91
+ source_revision: 'rev_2',
92
+ synced_at: 1,
93
+ },
94
+ },
95
+ },
96
+ },
97
+ ]);
98
+ const p2p = new P2PModule(clientWith(impl));
99
+
100
+ const capability = await p2p.publishCapability({
101
+ capability_id: 'cap_z',
102
+ publisher_org_id: 'org_p',
103
+ app_id: 'app_x',
104
+ service_id: 'srv_x',
105
+ name: 'Demo',
106
+ description: 'A capability',
107
+ category: 'other',
108
+ pricing_model: 'fixed',
109
+ pricing_amount_minor: 500,
110
+ currency: 'USD',
111
+ supported_contexts: ['agent'],
112
+ public_directory_visible: true,
113
+ source_revision: 'rev_2',
114
+ });
115
+ expect(capability.capability_id).toBe('cap_z');
116
+
117
+ const sent = JSON.parse(String(calls[0]!.init.body));
118
+ expect(sent.publisher_org_id).toBe('org_p');
119
+ expect(sent.acting_for_org_id).toBe('org_p');
120
+ expect(sent.public_directory_visible).toBe(true);
121
+ expect(sent.requires_human_approval).toBe(false);
122
+ expect(sent.trust_level).toBe('standard');
123
+ const url = new URL(calls[0]!.url);
124
+ expect(url.pathname).toBe('/api/p2p/internal/capabilities/sync');
125
+ expect((calls[0]!.init.method ?? '').toUpperCase()).toBe('POST');
126
+ });
127
+
128
+ it('honours an explicit acting_for_org_id when the platform key is publishing for another org', async () => {
129
+ const { impl, calls } = stubFetch([
130
+ { body: { success: true, data: { capability: null } } },
131
+ ]);
132
+ const p2p = new P2PModule(clientWith(impl));
133
+ await p2p.publishCapability({
134
+ capability_id: 'cap_other',
135
+ publisher_org_id: 'org_target',
136
+ app_id: 'app_x',
137
+ service_id: 'srv_x',
138
+ name: 'Demo',
139
+ description: '...',
140
+ category: 'other',
141
+ pricing_model: 'free',
142
+ supported_contexts: ['agent'],
143
+ public_directory_visible: true,
144
+ source_revision: 'rev_1',
145
+ acting_for_org_id: 'org_target',
146
+ });
147
+ const sent = JSON.parse(String(calls[0]!.init.body));
148
+ expect(sent.acting_for_org_id).toBe('org_target');
149
+ });
150
+ });
151
+
152
+ describe('P2PModule.requestAction', () => {
153
+ it('rejects without an idempotency_key', async () => {
154
+ const { impl } = stubFetch([]);
155
+ const p2p = new P2PModule(clientWith(impl));
156
+ await expect(
157
+ p2p.requestAction({ capability_id: 'cap_1', idempotency_key: '', inputs: {} }),
158
+ ).rejects.toThrow(/idempotency_key/);
159
+ });
160
+
161
+ it('returns the worker decision payload', async () => {
162
+ const { impl } = stubFetch([
163
+ {
164
+ body: {
165
+ success: true,
166
+ data: {
167
+ decision: 'auto_accepted',
168
+ idempotent: false,
169
+ request: { request_id: 'req_1', state: 'auto_accepted' },
170
+ run: null,
171
+ },
172
+ },
173
+ },
174
+ ]);
175
+ const p2p = new P2PModule(clientWith(impl));
176
+ const result = await p2p.requestAction({
177
+ capability_id: 'cap_1',
178
+ idempotency_key: 'key_1',
179
+ inputs: { foo: 'bar' },
180
+ });
181
+ expect(result.decision).toBe('auto_accepted');
182
+ expect(result.idempotent).toBe(false);
183
+ expect(result.request.request_id).toBe('req_1');
184
+ });
185
+ });
186
+
187
+ describe('P2PModule.getCapability', () => {
188
+ it('returns null on a 404 from the worker', async () => {
189
+ const { impl } = stubFetch([{ status: 404, body: { success: false, error: 'capability_not_found' } }]);
190
+ const p2p = new P2PModule(clientWith(impl));
191
+ const capability = await p2p.getCapability('cap_missing');
192
+ expect(capability).toBeNull();
193
+ });
194
+ });
195
+
196
+ describe('P2PModule.releaseHold', () => {
197
+ it('POSTs the resolution + reason and returns hold + cancel entries', async () => {
198
+ const { impl, calls } = stubFetch([
199
+ {
200
+ body: {
201
+ success: true,
202
+ data: {
203
+ hold: { hold_id: 'hold_1', status: 'released' },
204
+ resolution: 'seller',
205
+ cancel_entries: [{ entry_id: 'led_1', amount_minor: 500 }],
206
+ },
207
+ },
208
+ },
209
+ ]);
210
+ const p2p = new P2PModule(clientWith(impl));
211
+ const result = await p2p.releaseHold('hold_1', 'seller', { reason: 'arbitration' });
212
+ expect(result.resolution).toBe('seller');
213
+ expect(result.cancel_entries[0]!.entry_id).toBe('led_1');
214
+ const sent = JSON.parse(String(calls[0]!.init.body));
215
+ expect(sent.resolution).toBe('seller');
216
+ expect(sent.reason).toBe('arbitration');
217
+ });
218
+ });
@@ -0,0 +1,451 @@
1
+ import type { TopoloClient } from '../client.js';
2
+
3
+ export type P2PPricingModel = 'free' | 'fixed' | 'metered' | 'quote' | 'invoice';
4
+
5
+ export interface P2PCapability {
6
+ capability_id: string;
7
+ publisher_org_id: string;
8
+ app_id: string;
9
+ service_id: string;
10
+ name: string;
11
+ description: string;
12
+ category: string;
13
+ pricing_model: P2PPricingModel;
14
+ pricing_amount_minor: number | null;
15
+ currency: string | null;
16
+ requires_human_approval: boolean;
17
+ supported_contexts: string[];
18
+ trust_level: string;
19
+ source_revision: string;
20
+ synced_at: number;
21
+ input_schema?: Record<string, unknown>;
22
+ output_schema?: Record<string, unknown>;
23
+ }
24
+
25
+ export interface PublishCapabilityInput {
26
+ capability_id: string;
27
+ publisher_org_id: string;
28
+ app_id: string;
29
+ service_id: string;
30
+ name: string;
31
+ description: string;
32
+ category: string;
33
+ pricing_model: P2PPricingModel;
34
+ pricing_amount_minor?: number | null;
35
+ currency?: string | null;
36
+ requires_human_approval?: boolean;
37
+ supported_contexts: string[];
38
+ trust_level?: string;
39
+ public_directory_visible: boolean;
40
+ source_revision: string;
41
+ input_schema?: Record<string, unknown>;
42
+ output_schema?: Record<string, unknown>;
43
+ /** Set when the caller is the platform P2P service publishing on behalf of another org. */
44
+ acting_for_org_id?: string;
45
+ }
46
+
47
+ export interface P2PActionRequestInput {
48
+ capability_id: string;
49
+ idempotency_key: string;
50
+ inputs?: Record<string, unknown>;
51
+ }
52
+
53
+ export interface P2PActionRow {
54
+ request_id: string;
55
+ buyer_org_id: string;
56
+ seller_org_id: string;
57
+ capability_id: string;
58
+ initiating_principal_id: string;
59
+ inputs: Record<string, unknown>;
60
+ pricing_snapshot: Record<string, unknown>;
61
+ state: string;
62
+ policy_decision_id: string | null;
63
+ idempotency_key: string;
64
+ created_at: number;
65
+ updated_at: number;
66
+ capability: {
67
+ name: string | null;
68
+ category: string | null;
69
+ pricing_model: string | null;
70
+ pricing_amount_minor: number | null;
71
+ currency: string | null;
72
+ };
73
+ run: P2PActionRun | null;
74
+ }
75
+
76
+ export interface P2PActionRun {
77
+ run_id: string;
78
+ state: string;
79
+ result: Record<string, unknown> | null;
80
+ error: string | null;
81
+ started_at: number | null;
82
+ completed_at: number | null;
83
+ updated_at: number;
84
+ }
85
+
86
+ export interface P2PActionRequestResult {
87
+ decision: string;
88
+ idempotent: boolean;
89
+ request: P2PActionRow;
90
+ run: P2PActionRun | null;
91
+ }
92
+
93
+ export interface P2PActionDetail {
94
+ action: P2PActionRow;
95
+ policy_decision: {
96
+ decision_id: string;
97
+ decision: string;
98
+ rationale: string;
99
+ policy_snapshot: Record<string, unknown>;
100
+ created_at: number;
101
+ } | null;
102
+ ledger_entries: P2PLedgerEntry[];
103
+ holds: P2PHold[];
104
+ }
105
+
106
+ export interface P2PHold {
107
+ hold_id: string;
108
+ request_id: string;
109
+ buyer_org_id: string;
110
+ seller_org_id: string;
111
+ ledger_entry_id: string | null;
112
+ reason: string;
113
+ status: 'active' | 'released' | 'disputed' | 'expired';
114
+ release_after: number | null;
115
+ released_at: number | null;
116
+ created_at: number;
117
+ updated_at: number;
118
+ }
119
+
120
+ export interface P2PLedgerEntry {
121
+ entry_id: string;
122
+ request_id: string;
123
+ capability_id: string;
124
+ pricing_component: string;
125
+ buyer_org_id: string;
126
+ seller_org_id: string;
127
+ initiating_principal_id: string;
128
+ executing_principal_id: string;
129
+ policy_decision_id: string;
130
+ amount_minor: number;
131
+ currency: string;
132
+ audit_correlation_id: string;
133
+ settlement_batch_id: string | null;
134
+ created_at: number;
135
+ }
136
+
137
+ export interface P2PPolicy {
138
+ organization_id: string;
139
+ auto_approve_under_minor: number;
140
+ agent_spend_limit_minor_per_day: number;
141
+ human_approval_threshold_minor: number;
142
+ unknown_counterparty_requires_approval: boolean;
143
+ blocked_capability_ids: string[];
144
+ trusted_counterparty_org_ids: string[];
145
+ updated_at: number | null;
146
+ }
147
+
148
+ export interface P2PConnection {
149
+ connection_id: string;
150
+ organization_id?: string;
151
+ counterparty_org_id: string;
152
+ status: 'pending' | 'active' | 'suspended' | 'revoked';
153
+ initiated_by_principal_id: string;
154
+ created_at: number;
155
+ updated_at: number;
156
+ }
157
+
158
+ export interface P2PSettlementGroup {
159
+ buyer_org_id: string;
160
+ seller_org_id: string;
161
+ currency: string;
162
+ total_minor: number;
163
+ line_items: Array<{
164
+ entry_id: string;
165
+ request_id: string;
166
+ capability_id: string;
167
+ pricing_component: string;
168
+ amount_minor: number;
169
+ audit_correlation_id: string;
170
+ }>;
171
+ }
172
+
173
+ export interface P2PSettlementBatch {
174
+ batch_id: string;
175
+ buyer_org_id: string;
176
+ seller_org_id: string;
177
+ currency: string;
178
+ total_minor: number;
179
+ trigger: string;
180
+ pay_settlement_id: string | null;
181
+ status: string;
182
+ created_at: number;
183
+ updated_at: number;
184
+ }
185
+
186
+ type EnvelopeData<T> = { data: T };
187
+
188
+ /**
189
+ * P2P module — typed wrapper over the TopoloP2P HTTP API.
190
+ *
191
+ * Cross-org isolation: `requestAction`, `respondAction`, `completeAction`,
192
+ * etc. all derive the buyer/seller org from the auth credential. There is
193
+ * intentionally no `orgId` parameter on per-org reads.
194
+ *
195
+ * Mutating calls go through TopoloClient's `confirm: true` write guardrail.
196
+ */
197
+ export class P2PModule {
198
+ constructor(private readonly client: TopoloClient) {}
199
+
200
+ // Directory ----------------------------------------------------------------
201
+
202
+ async listDirectory(options: { limit?: number } = {}): Promise<P2PCapability[]> {
203
+ const res = await this.client.request<EnvelopeData<{ capabilities: P2PCapability[] }>>({
204
+ service: 'p2p',
205
+ path: '/api/directory/capabilities',
206
+ query: { limit: options.limit },
207
+ });
208
+ return res.data.capabilities;
209
+ }
210
+
211
+ async getCapability(capabilityId: string): Promise<P2PCapability | null> {
212
+ if (!capabilityId) throw new Error('capabilityId is required');
213
+ try {
214
+ const res = await this.client.request<EnvelopeData<{ capability: P2PCapability }>>({
215
+ service: 'p2p',
216
+ path: `/api/directory/capabilities/${encodeURIComponent(capabilityId)}`,
217
+ });
218
+ return res.data.capability;
219
+ } catch (err) {
220
+ if (isNotFound(err)) return null;
221
+ throw err;
222
+ }
223
+ }
224
+
225
+ async publishCapability(input: PublishCapabilityInput): Promise<P2PCapability> {
226
+ const res = await this.client.request<EnvelopeData<{ capability: P2PCapability }>>({
227
+ service: 'p2p',
228
+ method: 'POST',
229
+ path: '/api/p2p/internal/capabilities/sync',
230
+ body: {
231
+ capability_id: input.capability_id,
232
+ publisher_org_id: input.publisher_org_id,
233
+ app_id: input.app_id,
234
+ service_id: input.service_id,
235
+ name: input.name,
236
+ description: input.description,
237
+ category: input.category,
238
+ pricing_model: input.pricing_model,
239
+ pricing_amount_minor: input.pricing_amount_minor ?? null,
240
+ currency: input.currency ?? null,
241
+ requires_human_approval: input.requires_human_approval ?? false,
242
+ supported_contexts: input.supported_contexts,
243
+ trust_level: input.trust_level ?? 'standard',
244
+ public_directory_visible: input.public_directory_visible,
245
+ source_revision: input.source_revision,
246
+ input_schema: input.input_schema ?? {},
247
+ output_schema: input.output_schema ?? {},
248
+ acting_for_org_id: input.acting_for_org_id ?? input.publisher_org_id,
249
+ },
250
+ confirm: true,
251
+ });
252
+ return res.data.capability;
253
+ }
254
+
255
+ // Actions ------------------------------------------------------------------
256
+
257
+ async listActions(options: { state?: string; limit?: number } = {}): Promise<P2PActionRow[]> {
258
+ const res = await this.client.request<EnvelopeData<{ actions: P2PActionRow[] }>>({
259
+ service: 'p2p',
260
+ path: '/api/actions',
261
+ query: { state: options.state, limit: options.limit },
262
+ });
263
+ return res.data.actions;
264
+ }
265
+
266
+ async getAction(requestId: string): Promise<P2PActionDetail | null> {
267
+ if (!requestId) throw new Error('requestId is required');
268
+ try {
269
+ const res = await this.client.request<EnvelopeData<P2PActionDetail>>({
270
+ service: 'p2p',
271
+ path: `/api/actions/${encodeURIComponent(requestId)}`,
272
+ });
273
+ return res.data;
274
+ } catch (err) {
275
+ if (isNotFound(err)) return null;
276
+ throw err;
277
+ }
278
+ }
279
+
280
+ async requestAction(input: P2PActionRequestInput): Promise<P2PActionRequestResult> {
281
+ if (!input.capability_id) throw new Error('capability_id is required');
282
+ if (!input.idempotency_key) throw new Error('idempotency_key is required');
283
+ const res = await this.client.request<EnvelopeData<P2PActionRequestResult>>({
284
+ service: 'p2p',
285
+ method: 'POST',
286
+ path: '/api/actions/request',
287
+ body: {
288
+ capability_id: input.capability_id,
289
+ idempotency_key: input.idempotency_key,
290
+ inputs: input.inputs ?? {},
291
+ },
292
+ confirm: true,
293
+ });
294
+ return res.data;
295
+ }
296
+
297
+ async respondAction(
298
+ requestId: string,
299
+ response: 'accepted' | 'rejected' | 'approved' | 'quoted',
300
+ extras: Record<string, unknown> = {},
301
+ ): Promise<P2PActionRow> {
302
+ if (!requestId) throw new Error('requestId is required');
303
+ const res = await this.client.request<EnvelopeData<{ request: P2PActionRow }>>({
304
+ service: 'p2p',
305
+ method: 'POST',
306
+ path: `/api/actions/${encodeURIComponent(requestId)}/respond`,
307
+ body: { response, ...extras },
308
+ confirm: true,
309
+ });
310
+ return res.data.request;
311
+ }
312
+
313
+ async completeAction(requestId: string, result: Record<string, unknown> = {}): Promise<P2PActionRow> {
314
+ if (!requestId) throw new Error('requestId is required');
315
+ const res = await this.client.request<EnvelopeData<{ request: P2PActionRow }>>({
316
+ service: 'p2p',
317
+ method: 'POST',
318
+ path: `/api/actions/${encodeURIComponent(requestId)}/complete`,
319
+ body: { result },
320
+ confirm: true,
321
+ });
322
+ return res.data.request;
323
+ }
324
+
325
+ async disputeAction(requestId: string, reason: string): Promise<P2PActionRow> {
326
+ if (!requestId) throw new Error('requestId is required');
327
+ const res = await this.client.request<EnvelopeData<{ request: P2PActionRow }>>({
328
+ service: 'p2p',
329
+ method: 'POST',
330
+ path: `/api/actions/${encodeURIComponent(requestId)}/dispute`,
331
+ body: { reason },
332
+ confirm: true,
333
+ });
334
+ return res.data.request;
335
+ }
336
+
337
+ // Policy + connections + ledger + settlement -----------------------------
338
+
339
+ async getPolicy(): Promise<P2PPolicy> {
340
+ const res = await this.client.request<EnvelopeData<{ policy: P2PPolicy }>>({
341
+ service: 'p2p',
342
+ path: '/api/policies',
343
+ });
344
+ return res.data.policy;
345
+ }
346
+
347
+ async updatePolicy(policy: Partial<Omit<P2PPolicy, 'organization_id' | 'updated_at'>>): Promise<P2PPolicy> {
348
+ const res = await this.client.request<EnvelopeData<{ policy: P2PPolicy }>>({
349
+ service: 'p2p',
350
+ method: 'PUT',
351
+ path: '/api/policies',
352
+ body: policy,
353
+ confirm: true,
354
+ });
355
+ return res.data.policy;
356
+ }
357
+
358
+ async listConnections(): Promise<P2PConnection[]> {
359
+ const res = await this.client.request<EnvelopeData<{ connections: P2PConnection[] }>>({
360
+ service: 'p2p',
361
+ path: '/api/connections',
362
+ });
363
+ return res.data.connections;
364
+ }
365
+
366
+ async createConnection(
367
+ counterpartyOrgId: string,
368
+ options: { status?: 'pending' | 'active' | 'suspended' } = {},
369
+ ): Promise<P2PConnection> {
370
+ if (!counterpartyOrgId) throw new Error('counterpartyOrgId is required');
371
+ const res = await this.client.request<EnvelopeData<{ connection: P2PConnection }>>({
372
+ service: 'p2p',
373
+ method: 'POST',
374
+ path: '/api/connections',
375
+ body: { counterparty_org_id: counterpartyOrgId, status: options.status ?? 'active' },
376
+ confirm: true,
377
+ });
378
+ return res.data.connection;
379
+ }
380
+
381
+ async revokeConnection(connectionId: string): Promise<void> {
382
+ if (!connectionId) throw new Error('connectionId is required');
383
+ await this.client.request({
384
+ service: 'p2p',
385
+ method: 'DELETE',
386
+ path: `/api/connections/${encodeURIComponent(connectionId)}`,
387
+ confirm: true,
388
+ });
389
+ }
390
+
391
+ async listLedger(): Promise<P2PLedgerEntry[]> {
392
+ const res = await this.client.request<EnvelopeData<{ ledger_entries: P2PLedgerEntry[] }>>({
393
+ service: 'p2p',
394
+ path: '/api/ledger',
395
+ });
396
+ return res.data.ledger_entries;
397
+ }
398
+
399
+ async previewSettlements(
400
+ filters: Partial<{ buyer_org_id: string; seller_org_id: string; currency: string }> = {},
401
+ ): Promise<P2PSettlementGroup[]> {
402
+ const res = await this.client.request<EnvelopeData<{ groups: P2PSettlementGroup[] }>>({
403
+ service: 'p2p',
404
+ method: 'POST',
405
+ path: '/api/settlements/preview',
406
+ body: filters,
407
+ confirm: true,
408
+ });
409
+ return res.data.groups;
410
+ }
411
+
412
+ async createSettlement(
413
+ filters: Partial<{
414
+ buyer_org_id: string;
415
+ seller_org_id: string;
416
+ currency: string;
417
+ trigger: 'threshold' | 'schedule' | 'manual' | 'invoice' | 'delivery';
418
+ }> = {},
419
+ ): Promise<{ batch: P2PSettlementBatch; group: P2PSettlementGroup }> {
420
+ const res = await this.client.request<EnvelopeData<{ batch: P2PSettlementBatch; group: P2PSettlementGroup }>>({
421
+ service: 'p2p',
422
+ method: 'POST',
423
+ path: '/api/settlements/create',
424
+ body: { trigger: 'manual', ...filters },
425
+ confirm: true,
426
+ });
427
+ return res.data;
428
+ }
429
+
430
+ async releaseHold(
431
+ holdId: string,
432
+ resolution: 'buyer' | 'seller',
433
+ options: { reason?: string } = {},
434
+ ): Promise<{ hold: P2PHold; resolution: 'buyer' | 'seller'; cancel_entries: P2PLedgerEntry[] }> {
435
+ if (!holdId) throw new Error('holdId is required');
436
+ const res = await this.client.request<
437
+ EnvelopeData<{ hold: P2PHold; resolution: 'buyer' | 'seller'; cancel_entries: P2PLedgerEntry[] }>
438
+ >({
439
+ service: 'p2p',
440
+ method: 'POST',
441
+ path: `/api/holds/${encodeURIComponent(holdId)}/release`,
442
+ body: { resolution, reason: options.reason ?? null },
443
+ confirm: true,
444
+ });
445
+ return res.data;
446
+ }
447
+ }
448
+
449
+ function isNotFound(err: unknown): boolean {
450
+ return Boolean(err && typeof err === 'object' && 'status' in err && (err as { status?: number }).status === 404);
451
+ }