@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.
- package/README.md +14 -0
- package/dist/cjs/browser/mount.js +55 -8
- package/dist/cjs/browser/payerClient.js +58 -8
- package/dist/cjs/checkout.js +9 -1
- package/dist/cjs/core/client.js +25 -1
- package/dist/cjs/core/config.js +11 -0
- package/dist/cjs/core/errors.js +1 -1
- package/dist/cjs/core/serviceToken.js +11 -2
- package/dist/cjs/index.js +28 -1
- package/dist/cjs/resources/billing.js +25 -0
- package/dist/cjs/resources/checkoutSessions.js +2 -1
- package/dist/cjs/resources/connectedAccounts.js +63 -0
- package/dist/cjs/resources/merchants.js +51 -0
- package/dist/cjs/resources/paymentIntents.js +30 -2
- package/dist/cjs/resources/refunds.js +54 -0
- package/dist/cjs/resources/transfers.js +49 -0
- package/dist/esm/browser/mount.js +52 -7
- package/dist/esm/browser/payerClient.js +58 -8
- package/dist/esm/checkout.js +5 -0
- package/dist/esm/core/client.js +25 -1
- package/dist/esm/core/config.js +10 -0
- package/dist/esm/core/errors.js +1 -1
- package/dist/esm/core/serviceToken.js +11 -2
- package/dist/esm/index.js +28 -1
- package/dist/esm/resources/billing.js +21 -0
- package/dist/esm/resources/checkoutSessions.js +2 -1
- package/dist/esm/resources/connectedAccounts.js +59 -0
- package/dist/esm/resources/merchants.js +46 -0
- package/dist/esm/resources/paymentIntents.js +30 -2
- package/dist/esm/resources/refunds.js +50 -0
- package/dist/esm/resources/transfers.js +45 -0
- package/dist/types/browser/mount.d.ts +23 -1
- package/dist/types/browser/payerClient.d.ts +56 -2
- package/dist/types/checkout.d.ts +2 -1
- package/dist/types/core/client.d.ts +1 -0
- package/dist/types/core/config.d.ts +4 -0
- package/dist/types/core/errors.d.ts +2 -2
- package/dist/types/index.d.ts +40 -1
- package/dist/types/resources/billing.d.ts +15 -0
- package/dist/types/resources/checkoutSessions.d.ts +15 -1
- package/dist/types/resources/connectedAccounts.d.ts +84 -0
- package/dist/types/resources/merchants.d.ts +113 -0
- package/dist/types/resources/paymentIntents.d.ts +47 -1
- package/dist/types/resources/refunds.d.ts +77 -0
- package/dist/types/resources/transfers.d.ts +75 -0
- 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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
// `@
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
//
|
|
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
|
}
|
package/dist/esm/checkout.js
CHANGED
|
@@ -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';
|
package/dist/esm/core/client.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/dist/esm/core/config.js
CHANGED
|
@@ -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),
|
package/dist/esm/core/errors.js
CHANGED
|
@@ -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
|
-
* - `@
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
}
|