@peable.to/sdk 0.1.1 → 0.2.1

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 (46) hide show
  1. package/README.md +14 -0
  2. package/dist/cjs/browser/mount.js +55 -8
  3. package/dist/cjs/browser/payerClient.js +58 -8
  4. package/dist/cjs/checkout.js +9 -1
  5. package/dist/cjs/core/client.js +25 -1
  6. package/dist/cjs/core/config.js +11 -0
  7. package/dist/cjs/core/errors.js +1 -1
  8. package/dist/cjs/core/serviceToken.js +11 -2
  9. package/dist/cjs/index.js +28 -1
  10. package/dist/cjs/resources/billing.js +25 -0
  11. package/dist/cjs/resources/checkoutSessions.js +2 -1
  12. package/dist/cjs/resources/connectedAccounts.js +63 -0
  13. package/dist/cjs/resources/merchants.js +51 -0
  14. package/dist/cjs/resources/paymentIntents.js +30 -2
  15. package/dist/cjs/resources/refunds.js +54 -0
  16. package/dist/cjs/resources/transfers.js +49 -0
  17. package/dist/esm/browser/mount.js +52 -7
  18. package/dist/esm/browser/payerClient.js +58 -8
  19. package/dist/esm/checkout.js +5 -0
  20. package/dist/esm/core/client.js +25 -1
  21. package/dist/esm/core/config.js +10 -0
  22. package/dist/esm/core/errors.js +1 -1
  23. package/dist/esm/core/serviceToken.js +11 -2
  24. package/dist/esm/index.js +28 -1
  25. package/dist/esm/resources/billing.js +21 -0
  26. package/dist/esm/resources/checkoutSessions.js +2 -1
  27. package/dist/esm/resources/connectedAccounts.js +59 -0
  28. package/dist/esm/resources/merchants.js +46 -0
  29. package/dist/esm/resources/paymentIntents.js +30 -2
  30. package/dist/esm/resources/refunds.js +50 -0
  31. package/dist/esm/resources/transfers.js +45 -0
  32. package/dist/types/browser/mount.d.ts +23 -1
  33. package/dist/types/browser/payerClient.d.ts +56 -2
  34. package/dist/types/checkout.d.ts +2 -1
  35. package/dist/types/core/client.d.ts +1 -0
  36. package/dist/types/core/config.d.ts +4 -0
  37. package/dist/types/core/errors.d.ts +2 -2
  38. package/dist/types/index.d.ts +40 -1
  39. package/dist/types/resources/billing.d.ts +15 -0
  40. package/dist/types/resources/checkoutSessions.d.ts +15 -1
  41. package/dist/types/resources/connectedAccounts.d.ts +84 -0
  42. package/dist/types/resources/merchants.d.ts +113 -0
  43. package/dist/types/resources/paymentIntents.d.ts +47 -1
  44. package/dist/types/resources/refunds.d.ts +77 -0
  45. package/dist/types/resources/transfers.d.ts +75 -0
  46. package/package.json +2 -2
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RefundsResource = void 0;
4
+ class RefundsResource {
5
+ constructor(client) {
6
+ this.client = client;
7
+ }
8
+ /**
9
+ * Refund part or all of a payment.
10
+ *
11
+ * **`status` is the refund's own lifecycle and it is not always
12
+ * `succeeded`.** A provider can report a refund `pending` — and a bank can
13
+ * reject one days later, which arrives as an event and moves the row to
14
+ * `failed`. Treating the creation as completion is how a merchant's books
15
+ * come to say money went back that is still with them.
16
+ *
17
+ * `paymentStatus` on the response is where the PAYMENT stands after it, so a
18
+ * caller does not need a second read whose answer can move on before it
19
+ * arrives.
20
+ */
21
+ create(params, options = {}) {
22
+ return this.client.request('POST', '/v1/refunds', { body: params, idempotencyKey: options.idempotencyKey });
23
+ }
24
+ /**
25
+ * What this payment actually came to — gross, fee, net.
26
+ *
27
+ * On this resource because it is the same question you ask in the same
28
+ * breath: how much of this money is really mine.
29
+ *
30
+ * **A missing figure is `null` with a `status`, never `0`.** `unknown` means
31
+ * there is nothing to read — an uncaptured payment, a FairCoin one, a
32
+ * provider that could not be reached — and treating it as zero is how a
33
+ * reconciliation silently gains money that was never there.
34
+ *
35
+ * It reports what the provider took. It does NOT say which entity bears that
36
+ * cost: a fee paid by the operator of a Peable deployment is not
37
+ * automatically your expense, and that is a commercial question this API does
38
+ * not answer.
39
+ */
40
+ settlement(paymentIntentId) {
41
+ return this.client.request('GET', `/v1/payment_intents/${encodeURIComponent(paymentIntentId)}/settlement`);
42
+ }
43
+ /**
44
+ * Every refund against one payment, including ones made OUTSIDE Peable.
45
+ *
46
+ * A refund issued from the acquirer's dashboard, or created by the network
47
+ * resolving a dispute, is imported with `origin: 'provider'` and no
48
+ * `externalRef` — the merchant did not make it and has no id for it.
49
+ */
50
+ listForPaymentIntent(paymentIntentId) {
51
+ return this.client.request('GET', `/v1/payment_intents/${encodeURIComponent(paymentIntentId)}/refunds`);
52
+ }
53
+ }
54
+ exports.RefundsResource = RefundsResource;
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TransfersResource = void 0;
4
+ class TransfersResource {
5
+ constructor(client) {
6
+ this.client = client;
7
+ }
8
+ /**
9
+ * Settle one seller.
10
+ *
11
+ * Answers 201 for a new settlement and 200 for an order already settled,
12
+ * which is the distinction to act on after a timeout: "did I just pay this
13
+ * seller a second time" is a question with a real answer.
14
+ *
15
+ * Refused when the payment cannot fund it — the total settled out of one
16
+ * payment may not exceed what that payment brought in, because the overflow
17
+ * would come from the platform's general balance, which is other merchants'
18
+ * money in flight.
19
+ */
20
+ create(params, options = {}) {
21
+ return this.client.request('POST', '/v1/transfers', { body: params, idempotencyKey: options.idempotencyKey });
22
+ }
23
+ /**
24
+ * Take some or all of a settlement back.
25
+ *
26
+ * The response carries the transfer AND the reversal leg, so a caller can
27
+ * tell which operation it is reading: `amountReversed` on the transfer is the
28
+ * provider's CUMULATIVE figure, while `reversal` is this one.
29
+ */
30
+ reverse(transferId, params, options = {}) {
31
+ if (params.externalRef === undefined && options.idempotencyKey === undefined) {
32
+ // Refused HERE rather than at the gateway, because the failure it
33
+ // prevents is silent: without an identity a retry is a second reversal,
34
+ // and a seller loses the amount twice with nothing saying so.
35
+ throw new TypeError('a reversal needs an identity: pass externalRef, or options.idempotencyKey');
36
+ }
37
+ return this.client.request('POST', `/v1/transfers/${encodeURIComponent(transferId)}/reversals`, {
38
+ body: params,
39
+ ...(options.idempotencyKey === undefined
40
+ ? {}
41
+ : { idempotencyKey: options.idempotencyKey }),
42
+ });
43
+ }
44
+ /** What one payment settled — the reconciliation read. */
45
+ listForPaymentIntent(paymentIntentId) {
46
+ return this.client.request('GET', `/v1/payment_intents/${encodeURIComponent(paymentIntentId)}/transfers`);
47
+ }
48
+ }
49
+ exports.TransfersResource = TransfersResource;
@@ -50,6 +50,46 @@ export function deriveIntentId(clientSecret, intentId) {
50
50
  /** The `peable://pay` deep-link scheme + host — mirrors `PAYMENT_REQUEST_PREFIX`
51
51
  * in the wallet's `packages/frontend/src/pay/payment-request.ts`. */
52
52
  const PAYMENT_REQUEST_PREFIX = 'peable://pay';
53
+ /**
54
+ * Thrown when a deep link is asked for on an intent that has no chain payment
55
+ * behind it.
56
+ *
57
+ * `address` and `network` became nullable when the card rail landed (gateway
58
+ * ADR 0001 D6), and the tempting fix was a `?? ''` at the call site. That would
59
+ * mint `peable://pay?...&address=&network=` — a link the wallet's parser
60
+ * accepts structurally and which sends a payer to an empty address. Failing
61
+ * here is the only safe direction.
62
+ */
63
+ export class PeableRailUnsupportedError extends PeableError {
64
+ constructor(rail) {
65
+ // Extends `PeableError`, not `Error`: the widget reports everything through
66
+ // its `error` event, whose payload type is `PeableError`, and a bare `Error`
67
+ // would arrive there only after being flattened into a `PeableApiError`
68
+ // that a caller cannot tell apart from a network failure.
69
+ // `invalid_request_error`, because that is what it is: the caller asked
70
+ // this widget for a surface the intent does not have. Nothing went wrong
71
+ // upstream and retrying cannot help.
72
+ super('invalid_request_error', `the '${rail}' rail has no wallet deep link; it is not a chain payment`);
73
+ this.name = 'PeableRailUnsupportedError';
74
+ Object.setPrototypeOf(this, PeableRailUnsupportedError.prototype);
75
+ }
76
+ }
77
+ /**
78
+ * Build the deep link for an intent, refusing any intent that is not a chain
79
+ * payment. The narrowing is what lets `buildPayDeepLink` keep non-null fields.
80
+ */
81
+ export function payDeepLinkFor(intent) {
82
+ if (intent.rail !== 'faircoin' || intent.address === null || intent.network === null) {
83
+ throw new PeableRailUnsupportedError(intent.rail);
84
+ }
85
+ return buildPayDeepLink({
86
+ intentId: intent.id,
87
+ clientSecret: intent.clientSecret,
88
+ address: intent.address,
89
+ amount: intent.amount,
90
+ network: intent.network,
91
+ });
92
+ }
53
93
  /**
54
94
  * Build an `peable://pay?...` deep link. Param names AND percent-encoding
55
95
  * must match the wallet's parser byte-for-byte
@@ -241,13 +281,18 @@ function mount(target, opts) {
241
281
  button.addEventListener('click', () => {
242
282
  if (!latestIntent)
243
283
  return; // initial snapshot hasn't loaded yet
244
- const deepLink = buildPayDeepLink({
245
- intentId,
246
- clientSecret,
247
- address: latestIntent.address,
248
- amount: latestIntent.amount,
249
- network: latestIntent.network,
250
- });
284
+ // A card intent has no wallet to deep-link into. `error` rather than a
285
+ // throw into an event handler nobody is listening to: this widget's whole
286
+ // contract is that a host page learns what happened through `on(...)`, and
287
+ // an uncaught exception here would leave the button silently dead.
288
+ let deepLink;
289
+ try {
290
+ deepLink = payDeepLinkFor(latestIntent);
291
+ }
292
+ catch (err) {
293
+ emitter.emit('error', toPeableError(err));
294
+ return;
295
+ }
251
296
  if (isMobileUserAgent(navigator.userAgent)) {
252
297
  window.location.href = deepLink;
253
298
  }
@@ -1,7 +1,7 @@
1
1
  // The payer-side REST+socket client core — the "./checkout" entry's payer
2
2
  // capability (Fase 2 SDK plan, Task 5). Mirrors the wallet's
3
3
  // `gateway-client.ts`/`gateway-socket.ts` but standalone: no
4
- // `@oxyhq/services`/`@oxyhq/core` — this is a third-party browser bundle, so
4
+ // `@oxy.so/services`/`@oxy.so/core` — this is a third-party browser bundle, so
5
5
  // it talks to the Gateway over bare `fetch` + `socket.io-client`.
6
6
  //
7
7
  // Holds NO service token and NO private key — only a public `client_secret`,
@@ -62,6 +62,25 @@ export function createPeableCheckout(opts = {}) {
62
62
  throw errorFromResponse(response.status, body);
63
63
  return body;
64
64
  }
65
+ async function getClientAction(id, clientSecret) {
66
+ let response;
67
+ try {
68
+ response = await fetch(`${baseUrl}/v1/payment_intents/${encodeURIComponent(id)}/client_action`, {
69
+ method: 'POST',
70
+ // The HEADER, never a query param, for the same reason the snapshot
71
+ // read uses one: a capability token in a URL lands in every access
72
+ // log between here and the gateway.
73
+ headers: { 'X-Peable-Client-Secret': clientSecret },
74
+ });
75
+ }
76
+ catch (cause) {
77
+ throw new PeableApiError(`Failed to reach the Peable Gateway at ${baseUrl}: ${cause instanceof Error ? cause.message : String(cause)}`);
78
+ }
79
+ const body = await readJsonBody(response);
80
+ if (!response.ok)
81
+ throw errorFromResponse(response.status, body);
82
+ return body;
83
+ }
65
84
  /**
66
85
  * Opens its OWN socket per call (never a shared/module-level connection —
67
86
  * this SDK has no session to key a singleton off) and connects with NO
@@ -71,7 +90,7 @@ export function createPeableCheckout(opts = {}) {
71
90
  * the real authorization check — it requires this intent's `client_secret`
72
91
  * — so nothing here needs an Oxy session.
73
92
  */
74
- async function subscribe(id, clientSecret, onUpdate) {
93
+ async function subscribe(id, clientSecret, onUpdate, onConnectionChange) {
75
94
  const socket = io(baseUrl, {
76
95
  transports: ['websocket'],
77
96
  });
@@ -102,6 +121,22 @@ export function createPeableCheckout(opts = {}) {
102
121
  }
103
122
  };
104
123
  socket.on('intent.updated', listener);
124
+ // A drop AFTER this point used to be invisible to the caller. `subscribe`
125
+ // had already resolved, so a `.catch` on it could not fire again, and the
126
+ // reconnect below restores DELIVERY but replays nothing — socket.io has no
127
+ // buffer for frames emitted while the socket was gone. A payment that
128
+ // settled during a thirty-second blip therefore never reached the page, and
129
+ // the payer watched a snapshot that would never update again.
130
+ //
131
+ // Reported rather than handled here: the SDK cannot know what a host wants
132
+ // to do about it. The hosted checkout turns REST polling on and re-reads
133
+ // the intent before turning it off again.
134
+ let closedByUs = false;
135
+ const onDisconnect = () => {
136
+ if (!closedByUs)
137
+ onConnectionChange?.('lost');
138
+ };
139
+ socket.on('disconnect', onDisconnect);
105
140
  // socket.io-client's default reconnection restores the TRANSPORT, but the
106
141
  // server spins up a fresh Socket with no room membership — so every
107
142
  // reconnect (mobile network blip, backgrounded tab, LB cycle) must re-run
@@ -112,21 +147,36 @@ export function createPeableCheckout(opts = {}) {
112
147
  // the user reopening the screen. `reconnect` is a Manager-level event
113
148
  // (`socket.io`, not `socket` itself) in socket.io-client.
114
149
  const onReconnect = () => {
115
- void socket.emitWithAck('subscribe', { intentId: id, clientSecret }).catch(() => {
116
- // Fire-and-forget: `onUpdate` has no error channel to surface a
117
- // failed resubscribe (e.g. the intent expired between drop and
118
- // reconnect) — socket.io keeps retrying the transport regardless, so
119
- // there is nothing actionable to do with this rejection here.
150
+ void socket
151
+ .emitWithAck('subscribe', { intentId: id, clientSecret })
152
+ .then((ack) => {
153
+ // `'live'` only once the ROOM is rejoined, never on the transport
154
+ // alone: a socket that is connected and in no room delivers nothing,
155
+ // and telling the host it is live there would have them stand their
156
+ // fallback down into permanent silence.
157
+ if (ack.ok)
158
+ onConnectionChange?.('live');
159
+ })
160
+ .catch(() => {
161
+ // The subscription is gone for good (the intent expired between drop
162
+ // and reconnect, say). Staying `lost` is the honest report: socket.io
163
+ // keeps retrying the transport, but nothing will arrive on it.
120
164
  });
121
165
  };
122
166
  socket.io.on('reconnect', onReconnect);
123
167
  return {
124
168
  unsubscribe() {
169
+ // Set BEFORE `disconnect()`: a deliberate close fires `disconnect` like
170
+ // any other, and reporting `'lost'` there would have a host spin up a
171
+ // fallback for a subscription it just tore down — on an unmounting
172
+ // component, at that.
173
+ closedByUs = true;
125
174
  socket.off('intent.updated', listener);
175
+ socket.off('disconnect', onDisconnect);
126
176
  socket.io.off('reconnect', onReconnect);
127
177
  socket.disconnect();
128
178
  },
129
179
  };
130
180
  }
131
- return { getPaymentIntent, subscribe, submitTx };
181
+ return { getPaymentIntent, subscribe, submitTx, getClientAction };
132
182
  }
@@ -10,3 +10,8 @@
10
10
  // checkout+links plan's `intentClient.ts`).
11
11
  export { createPeableCheckout, } from './browser/payerClient.js';
12
12
  export { PeableCheckout, } from './browser/mount.js';
13
+ // The rail-aware deep-link builder and its refusal. Exported because
14
+ // `packages/checkout` renders its own pay button over the payer client core
15
+ // and must make the same decision this widget makes — one definition of "which
16
+ // intents have a wallet deep link", not two.
17
+ export { payDeepLinkFor, buildPayDeepLink, PeableRailUnsupportedError, } from './browser/mount.js';
@@ -2,6 +2,7 @@
2
2
  // Prefixes `baseURL`, attaches the bearer service token, sets
3
3
  // `Idempotency-Key` when given, serializes query params, parses JSON, and
4
4
  // maps every non-2xx response via `errors.ts`.
5
+ import { resolveRequestTimeout } from './config.js';
5
6
  import { errorFromResponse, PeableApiError } from './errors.js';
6
7
  const HTTP_UNAUTHORIZED = 401;
7
8
  function buildUrl(baseURL, path, query) {
@@ -29,6 +30,7 @@ async function readJsonBody(response) {
29
30
  }
30
31
  export function createRestClient(config, tokenProvider, deps = {}) {
31
32
  const fetchImpl = deps.fetch ?? fetch;
33
+ const timeout = resolveRequestTimeout(config.requestTimeoutMs);
32
34
  async function performRequest(method, path, opts, allowRetryOn401) {
33
35
  const token = await tokenProvider.getToken();
34
36
  const headers = { Authorization: `Bearer ${token}` };
@@ -43,6 +45,7 @@ export function createRestClient(config, tokenProvider, deps = {}) {
43
45
  response = await fetchImpl(buildUrl(config.baseURL, path, opts.query), {
44
46
  method,
45
47
  headers,
48
+ ...(timeout === undefined ? {} : { signal: AbortSignal.timeout(timeout) }),
46
49
  body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
47
50
  });
48
51
  }
@@ -60,10 +63,31 @@ export function createRestClient(config, tokenProvider, deps = {}) {
60
63
  // The Gateway echoes a date-based `Peable-Version` response header
61
64
  // (Stripe parity). No SDK behavior depends on it yet, so it is
62
65
  // deliberately not read here — nothing to do with it today.
63
- const body = await readJsonBody(response);
66
+ let body;
67
+ try {
68
+ body = await readJsonBody(response);
69
+ }
70
+ catch {
71
+ // Headers can arrive before the connection fails during body consumption.
72
+ // Preserve the known status, but do not infer whether a mutation committed
73
+ // or retry it automatically. Recovery keeps the caller's original key.
74
+ throw new PeableApiError('The Peable Gateway response body could not be read; remote outcome is unknown', {
75
+ statusCode: response.status,
76
+ code: 'invalid_response',
77
+ });
78
+ }
64
79
  if (!response.ok) {
65
80
  throw errorFromResponse(response.status, body);
66
81
  }
82
+ // Only explicit No Content can satisfy a void response. A missing or
83
+ // malformed JSON success is indeterminate, not a typed resource. Do not
84
+ // retry a mutation automatically: callers retain the original intent key.
85
+ if (body === undefined && response.status !== 204) {
86
+ throw new PeableApiError('The Peable Gateway returned an invalid JSON response', {
87
+ statusCode: response.status,
88
+ code: 'invalid_response',
89
+ });
90
+ }
67
91
  return body;
68
92
  }
69
93
  return {
@@ -9,6 +9,15 @@
9
9
  export const DEFAULT_GATEWAY_BASE_URL = 'https://api.peable.to';
10
10
  /** Default oxy-api host used to mint the short-lived service token. */
11
11
  export const DEFAULT_OXY_API_URL = 'https://api.oxy.so';
12
+ export function resolveRequestTimeout(value) {
13
+ if (value === undefined)
14
+ return undefined;
15
+ const timeout = value;
16
+ if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2147483647) {
17
+ throw new Error('Peable: requestTimeoutMs must be a positive32-bit integer');
18
+ }
19
+ return timeout;
20
+ }
12
21
  function stripTrailingSlash(url) {
13
22
  return url.endsWith('/') ? url.slice(0, -1) : url;
14
23
  }
@@ -26,6 +35,7 @@ export function resolveConfig(config) {
26
35
  throw new Error('Peable: `secret` is required');
27
36
  }
28
37
  return {
38
+ requestTimeoutMs: resolveRequestTimeout(config.requestTimeoutMs),
29
39
  publicKey: config.publicKey,
30
40
  secret: config.secret,
31
41
  baseURL: stripTrailingSlash(config.baseURL ?? DEFAULT_GATEWAY_BASE_URL),
@@ -68,7 +68,7 @@ function isFlatGatewayErrorBody(body) {
68
68
  * request was rejected:
69
69
  * - The Gateway's own route handlers use the Stripe-style nested envelope
70
70
  * `{ error: { type, message } }` (`lib/http.ts`'s `sendError`).
71
- * - `@oxyhq/core`'s Express auth middleware (`oxyClient.serviceAuth()` /
71
+ * - `@oxy.so/core`'s Express auth middleware (`oxy.middleware.service()` /
72
72
  * `requireScope()` — rejecting a missing/expired/insufficiently-scoped
73
73
  * service token BEFORE a route handler ever runs) and oxy-api's own
74
74
  * `ApiError.toJSON()` use a FLAT envelope: `{ error: <CODE_STRING>,
@@ -1,6 +1,6 @@
1
1
  // Mints a short-lived Oxy service token from an `ApplicationCredential`
2
2
  // (`{publicKey, secret}`) by presenting it to oxy-api's `POST
3
- // /auth/service-token` — the exact mechanism `oxyClient.serviceAuth()`
3
+ // /auth/service-token` — the exact mechanism `oxy.middleware.service()`
4
4
  // validates at the Gateway. Caches the token until near expiry and re-mints
5
5
  // reactively when `RestClient` sees a 401 (see `client.ts`).
6
6
  import { resolveConfig } from './config.js';
@@ -40,6 +40,7 @@ export function createServiceTokenProvider(cfg, deps = {}) {
40
40
  try {
41
41
  response = await fetchImpl(`${resolved.oxyApiUrl}${SERVICE_TOKEN_PATH}`, {
42
42
  method: 'POST',
43
+ ...(resolved.requestTimeoutMs === undefined ? {} : { signal: AbortSignal.timeout(resolved.requestTimeoutMs) }),
43
44
  headers: { 'Content-Type': 'application/json' },
44
45
  body: JSON.stringify({ apiKey: resolved.publicKey, apiSecret: resolved.secret }),
45
46
  });
@@ -47,7 +48,15 @@ export function createServiceTokenProvider(cfg, deps = {}) {
47
48
  catch (cause) {
48
49
  throw new PeableApiError(`Failed to reach ${resolved.oxyApiUrl} to mint an Peable service token: ${cause instanceof Error ? cause.message : String(cause)}`);
49
50
  }
50
- const body = await readJsonBody(response);
51
+ let body;
52
+ try {
53
+ body = await readJsonBody(response);
54
+ }
55
+ catch {
56
+ throw new PeableApiError('The Oxy service-token response body could not be read', {
57
+ statusCode: response.status, code: 'invalid_response',
58
+ });
59
+ }
51
60
  if (!response.ok) {
52
61
  throw errorFromResponse(response.status, body);
53
62
  }
package/dist/esm/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { BillingResource } from './resources/billing.js';
1
2
  // Server entry (`@peable.to/sdk`) — the merchant-authed SDK. Configured with a
2
3
  // confidential `ApplicationCredential` (`{publicKey, secret}`); mints and
3
4
  // caches an Oxy service token, and exposes Stripe-ergonomics resource
@@ -12,15 +13,41 @@ import { PaymentIntentsResource } from './resources/paymentIntents.js';
12
13
  import { PaymentLinksResource } from './resources/paymentLinks.js';
13
14
  import { CheckoutResource } from './resources/checkoutSessions.js';
14
15
  import { WebhooksResource } from './resources/webhooks.js';
16
+ import { ConnectedAccountsResource } from './resources/connectedAccounts.js';
17
+ import { DisputesResource, MerchantsResource } from './resources/merchants.js';
18
+ import { RefundsResource } from './resources/refunds.js';
19
+ import { TransfersResource } from './resources/transfers.js';
20
+ /**
21
+ * The merchant-authed client.
22
+ *
23
+ * ## Why the namespaces below are not optional extras
24
+ *
25
+ * This class published four: intents, links, checkout and webhooks. The gateway
26
+ * has always served merchants, connected accounts, refunds, transfers and
27
+ * disputes as well — so every integrator reaching those wrote their own HTTP
28
+ * client and their own partial types for responses this package already knew.
29
+ * Mercaria did exactly that. Two descriptions of one wire format, in two
30
+ * repositories, with nothing comparing them: a field renamed in the gateway is
31
+ * a runtime failure over there, found by a settlement that does not happen.
32
+ *
33
+ * An SDK that covers less than the API it wraps does not reduce surface, it
34
+ * relocates it.
35
+ */
15
36
  export class Peable {
16
37
  constructor(config) {
17
38
  const resolved = resolveConfig(config);
18
39
  const tokenProvider = createServiceTokenProvider(config);
19
- const client = createRestClient({ baseURL: resolved.baseURL }, tokenProvider);
40
+ const client = createRestClient({ baseURL: resolved.baseURL, requestTimeoutMs: resolved.requestTimeoutMs }, tokenProvider);
41
+ this.billing = new BillingResource(client);
20
42
  this.paymentIntents = new PaymentIntentsResource(client);
21
43
  this.paymentLinks = new PaymentLinksResource(client);
22
44
  this.checkout = new CheckoutResource(client);
23
45
  this.webhooks = new WebhooksResource();
46
+ this.merchants = new MerchantsResource(client);
47
+ this.connectedAccounts = new ConnectedAccountsResource(client);
48
+ this.refunds = new RefundsResource(client);
49
+ this.transfers = new TransfersResource(client);
50
+ this.disputes = new DisputesResource(client);
24
51
  }
25
52
  }
26
53
  export { DEFAULT_GATEWAY_BASE_URL, DEFAULT_OXY_API_URL } from './core/config.js';
@@ -0,0 +1,21 @@
1
+ /** Recurring billing is separate from one-off checkout and marketplace transfers. */
2
+ export class BillingResource {
3
+ constructor(client) {
4
+ this.client = client;
5
+ }
6
+ ensureCustomer(params, options) {
7
+ return this.client.request('POST', '/v1/billing/customers', { body: params, idempotencyKey: options.idempotencyKey });
8
+ }
9
+ createCheckoutSession(params, options) {
10
+ return this.client.request('POST', '/v1/billing/checkout_sessions', { body: params, idempotencyKey: options.idempotencyKey });
11
+ }
12
+ createPortalSession(params, options) {
13
+ return this.client.request('POST', '/v1/billing/portal_sessions', { body: params, idempotencyKey: options.idempotencyKey });
14
+ }
15
+ retrieveSubscription(ref) {
16
+ return this.client.request('GET', `/v1/billing/subscriptions/${encodeURIComponent(ref)}`);
17
+ }
18
+ cancelAtPeriodEnd(ref, options) {
19
+ return this.client.request('POST', `/v1/billing/subscriptions/${encodeURIComponent(ref)}/cancel_at_period_end`, { body: {}, idempotencyKey: options.idempotencyKey });
20
+ }
21
+ }
@@ -2,9 +2,10 @@ export class CheckoutSessionsResource {
2
2
  constructor(client) {
3
3
  this.client = client;
4
4
  }
5
- create(params) {
5
+ create(params, options = {}) {
6
6
  return this.client.request('POST', '/v1/checkout_sessions', {
7
7
  body: params,
8
+ ...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }),
8
9
  });
9
10
  }
10
11
  retrieve(id) {
@@ -0,0 +1,59 @@
1
+ export class ConnectedAccountsResource {
2
+ constructor(client) {
3
+ this.client = client;
4
+ }
5
+ /**
6
+ * Open the account for a seller, or return the one they already have.
7
+ *
8
+ * Converges rather than erroring, because the underlying object CANNOT BE
9
+ * DELETED: opening two for one seller leaves them with two forever, one of
10
+ * which nobody uses and which keeps generating requirement emails.
11
+ */
12
+ create(params) {
13
+ return this.client.request('POST', '/v1/connected_accounts', {
14
+ body: params,
15
+ });
16
+ }
17
+ list(params = {}) {
18
+ return this.client.request('GET', '/v1/connected_accounts', {
19
+ query: { limit: params.limit, starting_after: params.starting_after },
20
+ });
21
+ }
22
+ retrieve(accountId) {
23
+ return this.client.request('GET', `/v1/connected_accounts/${encodeURIComponent(accountId)}`);
24
+ }
25
+ /**
26
+ * By the merchant's OWN reference.
27
+ *
28
+ * The recovery read: a merchant who lost the `ca_…` — a create whose response
29
+ * never arrived — still has the reference they chose, and this is what makes
30
+ * finding the account possible without a list scan.
31
+ */
32
+ retrieveByRef(externalRef) {
33
+ return this.client.request('GET', `/v1/connected_accounts/by_ref/${encodeURIComponent(externalRef)}`);
34
+ }
35
+ /**
36
+ * Re-read this account from the provider now.
37
+ *
38
+ * Rarely needed: readiness arrives on its own through the gateway's event
39
+ * drain and a backstop sweep. This is for a seller staring at a dashboard who
40
+ * has just finished onboarding.
41
+ */
42
+ refresh(accountId) {
43
+ return this.client.request('POST', `/v1/connected_accounts/${encodeURIComponent(accountId)}/refresh`);
44
+ }
45
+ /**
46
+ * A hosted onboarding link.
47
+ *
48
+ * SINGLE-USE and short-lived at the provider, so it is minted on demand and
49
+ * must never be stored, emailed or put in a chat message — a stored link is
50
+ * one that has already expired by the time anyone follows it, and the failure
51
+ * looks like the seller's fault.
52
+ *
53
+ * A visit to `returnUrl` proves NOTHING about verification. Read the account
54
+ * back, or wait for the readiness event.
55
+ */
56
+ createAccountLink(accountId, params) {
57
+ return this.client.request('POST', `/v1/connected_accounts/${encodeURIComponent(accountId)}/account_links`, { body: params });
58
+ }
59
+ }
@@ -0,0 +1,46 @@
1
+ export class MerchantsResource {
2
+ constructor(client) {
3
+ this.client = client;
4
+ }
5
+ register(params = {}) {
6
+ return this.client.request('POST', '/v1/merchants', { body: params });
7
+ }
8
+ retrieve() {
9
+ return this.client.request('GET', '/v1/merchants/me');
10
+ }
11
+ update(params) {
12
+ return this.client.request('PATCH', '/v1/merchants/me', { body: params });
13
+ }
14
+ }
15
+ /**
16
+ * Disputes: read them, and answer one.
17
+ *
18
+ * A dispute is initiated by the card NETWORK, not by you, so there is nothing
19
+ * to create — the only write is a response. This surface was read-only for a
20
+ * while, because the gateway had no way to deliver evidence and a route that
21
+ * accepted it and did nothing would have been worse than none: you would have
22
+ * believed you had responded.
23
+ */
24
+ export class DisputesResource {
25
+ constructor(client) {
26
+ this.client = client;
27
+ }
28
+ listForPaymentIntent(paymentIntentId) {
29
+ return this.client.request('GET', `/v1/payment_intents/${encodeURIComponent(paymentIntentId)}/disputes`);
30
+ }
31
+ /**
32
+ * Answer a dispute. **One shot.**
33
+ *
34
+ * Submitting is one-way at the card network, so this cannot be revised and a
35
+ * second call does not send a second response — it answers 200 with the
36
+ * dispute as it stands. Send everything you have the first time.
37
+ *
38
+ * Refused (409) when the dispute is already answered, already decided, or
39
+ * past `evidenceDueAt`. That last one is checked by the gateway rather than
40
+ * by the acquirer, so you learn it was the clock and not your request — a
41
+ * late response is the one failure here that no retry fixes.
42
+ */
43
+ submitEvidence(disputeId, evidence) {
44
+ return this.client.request('POST', `/v1/disputes/${encodeURIComponent(disputeId)}/evidence`, { body: evidence });
45
+ }
46
+ }
@@ -20,7 +20,35 @@ export class PaymentIntentsResource {
20
20
  },
21
21
  });
22
22
  }
23
- reject(id) {
24
- return this.client.request('POST', `/v1/payment_intents/${encodeURIComponent(id)}/reject`);
23
+ /**
24
+ * Reject a payment the payer has not completed.
25
+ *
26
+ * The gateway CANCELS it at the provider before it announces anything, so
27
+ * this can lose a race and say so: a 409 means the payer confirmed first and
28
+ * the payment is settled, and a 502 means the provider could not be reached
29
+ * and nothing was decided. Neither is a failure of the request — both are
30
+ * facts about a payment that is still live.
31
+ */
32
+ reject(id, options = {}) {
33
+ return this.client.request('POST', `/v1/payment_intents/${encodeURIComponent(id)}/reject`, { idempotencyKey: options.idempotencyKey });
34
+ }
35
+ /**
36
+ * What the PAYER's client has to do next — the resume operation.
37
+ *
38
+ * The payment-intent shape carries no confirmation credential, because a
39
+ * credential on that shape would be handed out by every list and every
40
+ * re-read. This is the bounded way to get one: a single payment, named
41
+ * explicitly, for a caller who has proven they may pay it.
42
+ *
43
+ * Call it to resume an unpaid checkout rather than creating a second payment
44
+ * — which is what a buyer returning the next day would otherwise produce, and
45
+ * an idempotency key that has aged out at the provider will not prevent.
46
+ *
47
+ * @throws {PeableInvalidRequestError} (409) when the payment can no longer be
48
+ * paid, so a surface handed this can never render a card form over a
49
+ * settled payment.
50
+ */
51
+ clientAction(id) {
52
+ return this.client.request('POST', `/v1/payment_intents/${encodeURIComponent(id)}/client_action`);
25
53
  }
26
54
  }