@carts1024/velo-sdk 0.1.0-alpha.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/CHANGELOG.md +14 -0
- package/README.md +213 -0
- package/package.json +18 -0
- package/src/client.test.ts +383 -0
- package/src/client.ts +93 -0
- package/src/errors.ts +95 -0
- package/src/http.ts +123 -0
- package/src/index.ts +4 -0
- package/src/testnet-flow.ts +152 -0
- package/src/types.ts +123 -0
- package/src/webhooks.test.ts +185 -0
- package/src/webhooks.ts +97 -0
- package/tsconfig.json +11 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the Velo SDK will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0-alpha.1] - 2026-07-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Velo Client**: Class-based SDK client (`Velo`) with simple initialization: `new Velo({ apiKey })`.
|
|
10
|
+
- **Checkout Sessions**: Creation of checkout links via `velo.checkout.sessions.create()`.
|
|
11
|
+
- **Payment Intents**: Retrieve and list payment intents with cursor pagination support and project scoping.
|
|
12
|
+
- **Webhook Verification**: Secure HMAC-SHA256 signature validation with clock skew/tolerance checking using `Velo.webhooks.verify()`.
|
|
13
|
+
- **Typed Errors**: Custom error classes (`VeloAPIError`, `VeloAuthError`, `VeloRateLimitError`, `VeloValidationError`) matching API status codes.
|
|
14
|
+
- **Idempotency**: Support for client-supplied `Idempotency-Key` headers on payment session creation.
|
package/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Velo SDK for Node.js (Alpha)
|
|
2
|
+
|
|
3
|
+
The official Velo SDK for Node.js and modern JavaScript environments.
|
|
4
|
+
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> This package is currently in **Alpha** (`0.1.0-alpha.1`) and is meant for server-side environments only.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @velo/sdk
|
|
12
|
+
# or
|
|
13
|
+
pnpm add @velo/sdk
|
|
14
|
+
# or
|
|
15
|
+
yarn add @velo/sdk
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Getting Started
|
|
19
|
+
|
|
20
|
+
Initialize the client with your Velo API key:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Velo } from "@velo/sdk";
|
|
24
|
+
|
|
25
|
+
const velo = new Velo({
|
|
26
|
+
apiKey: process.env.VELO_API_KEY!,
|
|
27
|
+
environment: "testnet", // 'production', 'testnet', or 'development'
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Creating a Checkout Session
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
const { checkoutUrl, paymentIntentId } = await velo.checkout.sessions.create({
|
|
35
|
+
amount: "10.00",
|
|
36
|
+
asset: "USDC",
|
|
37
|
+
description: "Order #1001",
|
|
38
|
+
successUrl: "https://yourdomain.com/success",
|
|
39
|
+
cancelUrl: "https://yourdomain.com/cancel",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Redirect customer to the checkout URL
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Retrieving a Payment Intent
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const paymentIntent = await velo.paymentIntents.retrieve("pi_12345");
|
|
49
|
+
console.log(`Payment status: ${paymentIntent.status}`);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Webhook Verification
|
|
55
|
+
|
|
56
|
+
Velo signs webhook events sent to your endpoints using HMAC-SHA256. Webhook verification is required to verify that incoming payloads are authentic and untampered.
|
|
57
|
+
|
|
58
|
+
> [!IMPORTANT]
|
|
59
|
+
> Webhook signature verification requires the **raw, unparsed request body**. Do not parse the request body as JSON prior to calling verify.
|
|
60
|
+
>
|
|
61
|
+
> Your webhook signing secret (`VELO_WEBHOOK_SECRET`) must remain **server-side only**. Never expose it to the browser.
|
|
62
|
+
|
|
63
|
+
### Verification API
|
|
64
|
+
|
|
65
|
+
You can verify signatures using the static `Velo.webhooks.verify` method or an instance-level `velo.webhooks.verify` method:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
const event = await Velo.webhooks.verify({
|
|
69
|
+
payload: rawBody, // Raw string payload
|
|
70
|
+
signature: signatureHeader, // 'x-velo-signature' header value
|
|
71
|
+
secret: process.env.VELO_WEBHOOK_SECRET!, // Webhook signing secret
|
|
72
|
+
toleranceSeconds: 300, // Optional clock drift tolerance (default 5 minutes)
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`verify` will throw a `VeloWebhookSignatureVerificationError` (which extends `VeloValidationError`) if:
|
|
77
|
+
|
|
78
|
+
- The signature is missing or malformed.
|
|
79
|
+
- The timestamp is expired (older than `toleranceSeconds` or from the future).
|
|
80
|
+
- The computed signature does not match the header.
|
|
81
|
+
|
|
82
|
+
### Next.js App Router Example
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { NextResponse } from "next/server";
|
|
86
|
+
import { Velo } from "@velo/sdk";
|
|
87
|
+
|
|
88
|
+
export async function POST(request: Request) {
|
|
89
|
+
// 1. Get the raw text payload (DO NOT call request.json())
|
|
90
|
+
const payload = await request.text();
|
|
91
|
+
|
|
92
|
+
// 2. Get the signature header
|
|
93
|
+
const signature = request.headers.get("x-velo-signature");
|
|
94
|
+
const secret = process.env.VELO_WEBHOOK_SECRET!;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// 3. Verify the signature
|
|
98
|
+
const event = await Velo.webhooks.verify({
|
|
99
|
+
payload,
|
|
100
|
+
signature,
|
|
101
|
+
secret,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 4. Handle typed events
|
|
105
|
+
switch (event.type) {
|
|
106
|
+
case "payment.succeeded": {
|
|
107
|
+
const paymentIntent = event.paymentIntent;
|
|
108
|
+
console.log(`Payment succeeded for amount: ${paymentIntent.amount}`);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "payment.failed": {
|
|
112
|
+
console.log(`Payment failed: ${event.paymentIntent.id}`);
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
case "payment_access.activated": {
|
|
116
|
+
console.log(`Project payment access activated!`);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
default:
|
|
120
|
+
console.log(`Unhandled event type: ${event.type}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return NextResponse.json({ received: true });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error("Signature verification failed:", error);
|
|
126
|
+
return new NextResponse("Webhook signature verification failed", { status: 400 });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Express.js Example
|
|
132
|
+
|
|
133
|
+
Ensure you capture the raw body as a string. You can use `express.raw` middleware for this specific route.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import express from "express";
|
|
137
|
+
import { Velo } from "@velo/sdk";
|
|
138
|
+
|
|
139
|
+
const app = express();
|
|
140
|
+
|
|
141
|
+
app.post("/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
|
|
142
|
+
// 1. Get raw string payload
|
|
143
|
+
const payload = req.body.toString("utf8");
|
|
144
|
+
|
|
145
|
+
// 2. Get the signature header
|
|
146
|
+
const signature = req.headers["x-velo-signature"];
|
|
147
|
+
const secret = process.env.VELO_WEBHOOK_SECRET!;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
// 3. Verify signature
|
|
151
|
+
const event = await Velo.webhooks.verify({
|
|
152
|
+
payload,
|
|
153
|
+
signature: Array.isArray(signature) ? signature[0] : signature || null,
|
|
154
|
+
secret,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// 4. Handle events
|
|
158
|
+
if (event.type === "payment.succeeded") {
|
|
159
|
+
console.log(`Payment succeeded: ${event.paymentIntent.id}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
res.status(200).send("OK");
|
|
163
|
+
} catch (error) {
|
|
164
|
+
console.error("Signature verification failed:", error);
|
|
165
|
+
res.status(400).send("Webhook signature verification failed");
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Environment Variables
|
|
173
|
+
|
|
174
|
+
Configure the following environment variables in your server environments:
|
|
175
|
+
|
|
176
|
+
| Variable | Required | Description |
|
|
177
|
+
| --------------------- | ----------------- | ----------------------------------------------------------------------------------------------- |
|
|
178
|
+
| `VELO_API_KEY` | **Yes** | Your Velo project API key (e.g. `tk_live_...` or `tk_test_...`). |
|
|
179
|
+
| `VELO_WEBHOOK_SECRET` | Only for Webhooks | Used to verify signature of incoming webhook events. |
|
|
180
|
+
| `VELO_BASE_URL` | No | Overrides the default Velo API endpoint (defaults to `https://api.velo.xyz` or local dev base). |
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Idempotency
|
|
185
|
+
|
|
186
|
+
To prevent double-charging or duplicate session creation due to network retries, pass an `idempotencyKey` in the `RequestOptions` object as the second parameter:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
const session = await velo.checkout.sessions.create(
|
|
190
|
+
{
|
|
191
|
+
amount: "10.00",
|
|
192
|
+
asset: "USDC",
|
|
193
|
+
description: "Order #1001",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
idempotencyKey: "unique-order-id-1001", // Prevents duplicates
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Idempotency keys are scoped to your project. Repeating a request with the same payload and same key will return the cached original response. Repeating with a different payload will throw a `VeloAPIError` with status code `409` (conflict).
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Testnet vs Mainnet & Alpha Limitations
|
|
206
|
+
|
|
207
|
+
> [!WARNING]
|
|
208
|
+
> This SDK is currently in **Alpha** (`0.1.0-alpha.1`) and subject to changes.
|
|
209
|
+
>
|
|
210
|
+
> - **Stellar Testnet Only**: During the alpha phase, all transactions and checkout sessions are routed through the Stellar Testnet. Mainnet is currently unsupported.
|
|
211
|
+
> - **ESM-Only**: The package uses ESM exports and requires `"type": "module"` or an ESM-compatible bundler/environment. CommonJS `require()` is not supported directly.
|
|
212
|
+
> - **Server-Side Only**: The SDK initializes and communicates using highly sensitive API keys and secrets. Do **NOT** use this SDK in browser environments or client-side code as it will leak your API credentials.
|
|
213
|
+
> - **Browser Limitations**: Direct wallet connection, browser-based payment tracking, and front-end React components are excluded from the current alpha release.
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@carts1024/velo-sdk",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"lint:fix": "oxlint --fix . && oxfmt --write . && tsc --noEmit",
|
|
11
|
+
"test": "node --experimental-strip-types --test src/*.test.ts"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@repo/typescript-config": "workspace:*",
|
|
15
|
+
"@types/node": "^22.15.3",
|
|
16
|
+
"typescript": "5.9.2"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { Velo } from "./client.ts";
|
|
5
|
+
import { VeloAPIError, VeloAuthError, VeloRateLimitError, VeloValidationError } from "./errors.ts";
|
|
6
|
+
import { HttpClient, resolveBaseUrl } from "./http.ts";
|
|
7
|
+
|
|
8
|
+
test("Velo constructor validates apiKey", () => {
|
|
9
|
+
assert.throws(() => new Velo({ apiKey: "" }), /API key is required/);
|
|
10
|
+
assert.throws(() => new Velo({ apiKey: " " }), /API key is required/);
|
|
11
|
+
assert.throws(() => new Velo({ apiKey: null as unknown as string }), /API key is required/);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("resolveBaseUrl works as expected", () => {
|
|
15
|
+
assert.equal(
|
|
16
|
+
resolveBaseUrl({ apiKey: "key", baseUrl: "https://custom.velo.pay" }),
|
|
17
|
+
"https://custom.velo.pay",
|
|
18
|
+
);
|
|
19
|
+
assert.equal(
|
|
20
|
+
resolveBaseUrl({ apiKey: "key", environment: "production" }),
|
|
21
|
+
"https://api.velo.pay",
|
|
22
|
+
);
|
|
23
|
+
assert.equal(
|
|
24
|
+
resolveBaseUrl({ apiKey: "key", environment: "testnet" }),
|
|
25
|
+
"https://api.testnet.velo.pay",
|
|
26
|
+
);
|
|
27
|
+
assert.equal(
|
|
28
|
+
resolveBaseUrl({ apiKey: "key", environment: "development" }),
|
|
29
|
+
"http://localhost:3000",
|
|
30
|
+
);
|
|
31
|
+
assert.equal(resolveBaseUrl({ apiKey: "key" }), "http://localhost:3000");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("HttpClient sets correct authorization and custom headers", async () => {
|
|
35
|
+
const originalFetch = globalThis.fetch;
|
|
36
|
+
let calledUrl = "";
|
|
37
|
+
let calledOptions: RequestInit | undefined;
|
|
38
|
+
|
|
39
|
+
globalThis.fetch = async (url, options) => {
|
|
40
|
+
calledUrl = url.toString();
|
|
41
|
+
calledOptions = options;
|
|
42
|
+
return new Response(JSON.stringify({ success: true }), {
|
|
43
|
+
status: 200,
|
|
44
|
+
headers: { "Content-Type": "application/json" },
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const client = new HttpClient({ apiKey: "test-key", baseUrl: "https://api.example.com" });
|
|
50
|
+
const res = await client.request(
|
|
51
|
+
"POST",
|
|
52
|
+
"/test",
|
|
53
|
+
{ foo: "bar" },
|
|
54
|
+
{ idempotencyKey: "idem-123" },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
assert.deepEqual(res, { success: true });
|
|
58
|
+
assert.equal(calledUrl, "https://api.example.com/test");
|
|
59
|
+
assert.equal(calledOptions?.method, "POST");
|
|
60
|
+
|
|
61
|
+
const headers = calledOptions?.headers as Record<string, string>;
|
|
62
|
+
assert.equal(headers["Authorization"], "Bearer test-key");
|
|
63
|
+
assert.equal(headers["Content-Type"], "application/json");
|
|
64
|
+
assert.equal(headers["Idempotency-Key"], "idem-123");
|
|
65
|
+
assert.equal(calledOptions?.body, JSON.stringify({ foo: "bar" }));
|
|
66
|
+
} finally {
|
|
67
|
+
globalThis.fetch = originalFetch;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("HttpClient timeout behavior throws error", async () => {
|
|
72
|
+
const originalFetch = globalThis.fetch;
|
|
73
|
+
|
|
74
|
+
globalThis.fetch = async (url, options) => {
|
|
75
|
+
const signal = options?.signal;
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
resolve(new Response(JSON.stringify({}), { status: 200 }));
|
|
79
|
+
}, 100);
|
|
80
|
+
|
|
81
|
+
if (signal) {
|
|
82
|
+
if (signal.aborted) {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
signal.addEventListener("abort", () => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const client = new HttpClient({ apiKey: "test-key", timeoutMs: 10 });
|
|
97
|
+
await assert.rejects(
|
|
98
|
+
() => client.request("GET", "/test"),
|
|
99
|
+
(err: unknown) => {
|
|
100
|
+
assert.equal(err instanceof VeloAPIError, true);
|
|
101
|
+
const error = err as VeloAPIError;
|
|
102
|
+
assert.equal(error.status, 408);
|
|
103
|
+
assert.match(error.message, /timed out/);
|
|
104
|
+
return true;
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
} finally {
|
|
108
|
+
globalThis.fetch = originalFetch;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("HttpClient maps REST error responses correctly", async () => {
|
|
113
|
+
const originalFetch = globalThis.fetch;
|
|
114
|
+
|
|
115
|
+
const mockErrorResponse = (
|
|
116
|
+
status: number,
|
|
117
|
+
type: string,
|
|
118
|
+
message: string,
|
|
119
|
+
code: string,
|
|
120
|
+
param?: string,
|
|
121
|
+
) => {
|
|
122
|
+
return async () => {
|
|
123
|
+
const responseBody = JSON.stringify({
|
|
124
|
+
error: { type, message, code, param },
|
|
125
|
+
});
|
|
126
|
+
return new Response(responseBody, {
|
|
127
|
+
status,
|
|
128
|
+
headers: {
|
|
129
|
+
"Content-Type": "application/json",
|
|
130
|
+
"x-request-id": "req-id-123",
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const client = new HttpClient({ apiKey: "test-key" });
|
|
138
|
+
|
|
139
|
+
// 401 Unauthorized -> VeloAuthError
|
|
140
|
+
globalThis.fetch = mockErrorResponse(401, "auth_error", "Invalid API key", "invalid_api_key");
|
|
141
|
+
await assert.rejects(
|
|
142
|
+
() => client.request("GET", "/test"),
|
|
143
|
+
(err: unknown) => {
|
|
144
|
+
assert.equal(err instanceof VeloAuthError, true);
|
|
145
|
+
const error = err as VeloAuthError;
|
|
146
|
+
assert.equal(error.status, 401);
|
|
147
|
+
assert.equal(error.code, "invalid_api_key");
|
|
148
|
+
assert.equal(error.requestId, "req-id-123");
|
|
149
|
+
return true;
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// 400 Bad Request -> VeloValidationError
|
|
154
|
+
globalThis.fetch = mockErrorResponse(
|
|
155
|
+
400,
|
|
156
|
+
"validation_error",
|
|
157
|
+
"Invalid amount",
|
|
158
|
+
"invalid_request",
|
|
159
|
+
"amount",
|
|
160
|
+
);
|
|
161
|
+
await assert.rejects(
|
|
162
|
+
() => client.request("GET", "/test"),
|
|
163
|
+
(err: unknown) => {
|
|
164
|
+
assert.equal(err instanceof VeloValidationError, true);
|
|
165
|
+
const error = err as VeloValidationError;
|
|
166
|
+
assert.equal(error.status, 400);
|
|
167
|
+
assert.equal(error.code, "invalid_request");
|
|
168
|
+
assert.equal(error.param, "amount");
|
|
169
|
+
assert.equal(error.requestId, "req-id-123");
|
|
170
|
+
return true;
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// 429 Too Many Requests -> VeloRateLimitError
|
|
175
|
+
globalThis.fetch = mockErrorResponse(
|
|
176
|
+
429,
|
|
177
|
+
"rate_limit_error",
|
|
178
|
+
"Rate limit exceeded",
|
|
179
|
+
"rate_limit_exceeded",
|
|
180
|
+
);
|
|
181
|
+
await assert.rejects(
|
|
182
|
+
() => client.request("GET", "/test"),
|
|
183
|
+
(err: unknown) => {
|
|
184
|
+
assert.equal(err instanceof VeloRateLimitError, true);
|
|
185
|
+
const error = err as VeloRateLimitError;
|
|
186
|
+
assert.equal(error.status, 429);
|
|
187
|
+
assert.equal(error.code, "rate_limit_exceeded");
|
|
188
|
+
assert.equal(error.requestId, "req-id-123");
|
|
189
|
+
return true;
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// 500 Internal Error -> VeloAPIError
|
|
194
|
+
globalThis.fetch = mockErrorResponse(500, "api_error", "Internal error", "internal_error");
|
|
195
|
+
await assert.rejects(
|
|
196
|
+
() => client.request("GET", "/test"),
|
|
197
|
+
(err: unknown) => {
|
|
198
|
+
assert.equal(err instanceof VeloAPIError, true);
|
|
199
|
+
const error = err as VeloAPIError;
|
|
200
|
+
assert.equal(error.status, 500);
|
|
201
|
+
assert.equal(error.code, "internal_error");
|
|
202
|
+
assert.equal(error.requestId, "req-id-123");
|
|
203
|
+
return true;
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
} finally {
|
|
207
|
+
globalThis.fetch = originalFetch;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("Velo checkout.sessions.create and paymentIntents.create construct correct requests", async () => {
|
|
212
|
+
const originalFetch = globalThis.fetch;
|
|
213
|
+
let calledUrl = "";
|
|
214
|
+
let calledOptions: RequestInit | undefined;
|
|
215
|
+
|
|
216
|
+
globalThis.fetch = async (url, options) => {
|
|
217
|
+
calledUrl = url.toString();
|
|
218
|
+
calledOptions = options;
|
|
219
|
+
return new Response(JSON.stringify({ id: "pi_123", object: "payment_intent" }), {
|
|
220
|
+
status: 201,
|
|
221
|
+
headers: { "Content-Type": "application/json" },
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const velo = new Velo({ apiKey: "test-key", baseUrl: "https://api.example.com" });
|
|
227
|
+
const res1 = await velo.checkout.sessions.create({ amount: "10.00", asset: "USDC" });
|
|
228
|
+
assert.deepEqual(res1, { id: "pi_123", object: "payment_intent" });
|
|
229
|
+
assert.equal(calledUrl, "https://api.example.com/api/v1/payment-intents");
|
|
230
|
+
assert.equal(calledOptions?.method, "POST");
|
|
231
|
+
assert.equal(calledOptions?.body, JSON.stringify({ amount: "10.00", asset: "USDC" }));
|
|
232
|
+
|
|
233
|
+
const res2 = await velo.paymentIntents.create({ amount: "20.00", asset: "USDC" });
|
|
234
|
+
assert.deepEqual(res2, { id: "pi_123", object: "payment_intent" });
|
|
235
|
+
assert.equal(calledUrl, "https://api.example.com/api/v1/payment-intents");
|
|
236
|
+
assert.equal(calledOptions?.method, "POST");
|
|
237
|
+
assert.equal(calledOptions?.body, JSON.stringify({ amount: "20.00", asset: "USDC" }));
|
|
238
|
+
} finally {
|
|
239
|
+
globalThis.fetch = originalFetch;
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("Velo paymentIntents.retrieve retrieves payment intent by id with encoding", async () => {
|
|
244
|
+
const originalFetch = globalThis.fetch;
|
|
245
|
+
let calledUrl = "";
|
|
246
|
+
|
|
247
|
+
globalThis.fetch = async (url) => {
|
|
248
|
+
calledUrl = url.toString();
|
|
249
|
+
return new Response(JSON.stringify({ id: "pi_123", object: "payment_intent" }), {
|
|
250
|
+
status: 200,
|
|
251
|
+
headers: { "Content-Type": "application/json" },
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const velo = new Velo({ apiKey: "test-key", baseUrl: "https://api.example.com" });
|
|
257
|
+
const res = await velo.paymentIntents.retrieve("pi/123");
|
|
258
|
+
assert.deepEqual(res, { id: "pi_123", object: "payment_intent" });
|
|
259
|
+
assert.equal(calledUrl, "https://api.example.com/api/v1/payment-intents/pi%2F123");
|
|
260
|
+
} finally {
|
|
261
|
+
globalThis.fetch = originalFetch;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("Velo paymentIntents.retrieve validates that ID is present", async () => {
|
|
266
|
+
const velo = new Velo({ apiKey: "test-key" });
|
|
267
|
+
await assert.rejects(() => velo.paymentIntents.retrieve(""), /Payment intent ID is required/);
|
|
268
|
+
await assert.rejects(() => velo.paymentIntents.retrieve(" "), /Payment intent ID is required/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("Velo paymentIntents.list parses query parameters correctly", async () => {
|
|
272
|
+
const originalFetch = globalThis.fetch;
|
|
273
|
+
let calledUrl = "";
|
|
274
|
+
|
|
275
|
+
globalThis.fetch = async (url) => {
|
|
276
|
+
calledUrl = url.toString();
|
|
277
|
+
return new Response(
|
|
278
|
+
JSON.stringify({ object: "list", data: [], hasMore: false, nextCursor: null }),
|
|
279
|
+
{
|
|
280
|
+
status: 200,
|
|
281
|
+
headers: { "Content-Type": "application/json" },
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const velo = new Velo({ apiKey: "test-key", baseUrl: "https://api.example.com" });
|
|
288
|
+
const res = await velo.paymentIntents.list({ status: "paid", limit: 50, cursor: "abc" });
|
|
289
|
+
assert.deepEqual(res, { object: "list", data: [], hasMore: false, nextCursor: null });
|
|
290
|
+
assert.equal(
|
|
291
|
+
calledUrl,
|
|
292
|
+
"https://api.example.com/api/v1/payment-intents?status=paid&limit=50&cursor=abc",
|
|
293
|
+
);
|
|
294
|
+
} finally {
|
|
295
|
+
globalThis.fetch = originalFetch;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("GET request retries on 500 then succeeds", async () => {
|
|
300
|
+
const originalFetch = globalThis.fetch;
|
|
301
|
+
let calls = 0;
|
|
302
|
+
|
|
303
|
+
globalThis.fetch = async (_url, _options) => {
|
|
304
|
+
calls++;
|
|
305
|
+
if (calls === 1) {
|
|
306
|
+
return new Response(
|
|
307
|
+
JSON.stringify({ error: { type: "api_error", message: "Server error", code: "internal" } }),
|
|
308
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return new Response(JSON.stringify({ id: "pi_123", object: "payment_intent" }), {
|
|
312
|
+
status: 200,
|
|
313
|
+
headers: { "Content-Type": "application/json" },
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
const velo = new Velo({ apiKey: "test-key", baseUrl: "https://api.example.com" });
|
|
319
|
+
const res = await velo.paymentIntents.retrieve("pi_123");
|
|
320
|
+
assert.deepEqual(res, { id: "pi_123", object: "payment_intent" });
|
|
321
|
+
assert.equal(calls, 2);
|
|
322
|
+
} finally {
|
|
323
|
+
globalThis.fetch = originalFetch;
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("POST without idempotency key does not retry on 500", async () => {
|
|
328
|
+
const originalFetch = globalThis.fetch;
|
|
329
|
+
let calls = 0;
|
|
330
|
+
|
|
331
|
+
globalThis.fetch = async (_url, _options) => {
|
|
332
|
+
calls++;
|
|
333
|
+
return new Response(
|
|
334
|
+
JSON.stringify({ error: { type: "api_error", message: "Server error", code: "internal" } }),
|
|
335
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
336
|
+
);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const velo = new Velo({ apiKey: "test-key", baseUrl: "https://api.example.com" });
|
|
341
|
+
await assert.rejects(
|
|
342
|
+
() => velo.checkout.sessions.create({ amount: "10.00" }),
|
|
343
|
+
(err: unknown) => {
|
|
344
|
+
assert.equal(err instanceof VeloAPIError, true);
|
|
345
|
+
return true;
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
assert.equal(calls, 1);
|
|
349
|
+
} finally {
|
|
350
|
+
globalThis.fetch = originalFetch;
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("POST with idempotency key retries on 500", async () => {
|
|
355
|
+
const originalFetch = globalThis.fetch;
|
|
356
|
+
let calls = 0;
|
|
357
|
+
|
|
358
|
+
globalThis.fetch = async (_url, _options) => {
|
|
359
|
+
calls++;
|
|
360
|
+
if (calls === 1) {
|
|
361
|
+
return new Response(
|
|
362
|
+
JSON.stringify({ error: { type: "api_error", message: "Server error", code: "internal" } }),
|
|
363
|
+
{ status: 500, headers: { "Content-Type": "application/json" } },
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return new Response(JSON.stringify({ id: "pi_123", object: "payment_intent" }), {
|
|
367
|
+
status: 200,
|
|
368
|
+
headers: { "Content-Type": "application/json" },
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
const velo = new Velo({ apiKey: "test-key", baseUrl: "https://api.example.com" });
|
|
374
|
+
const res = await velo.checkout.sessions.create(
|
|
375
|
+
{ amount: "10.00" },
|
|
376
|
+
{ idempotencyKey: "idem-1" },
|
|
377
|
+
);
|
|
378
|
+
assert.deepEqual(res, { id: "pi_123", object: "payment_intent" });
|
|
379
|
+
assert.equal(calls, 2);
|
|
380
|
+
} finally {
|
|
381
|
+
globalThis.fetch = originalFetch;
|
|
382
|
+
}
|
|
383
|
+
});
|