Skip to content

Commit 1cfcad1

Browse files
committed
feat: recurring escrow series for crypto and card payments
1 parent 568a48a commit 1cfcad1

File tree

12 files changed

+1229
-2
lines changed

12 files changed

+1229
-2
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "coinpayportal",
3-
"version": "0.6.7",
3+
"version": "0.6.6",
44
"private": true,
55
"type": "module",
66
"bin": {

packages/sdk/bin/coinpay.js

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1598,9 +1598,119 @@ async function handleEscrow(subcommand, args, flags) {
15981598
break;
15991599
}
16001600

1601+
case 'series': {
1602+
const seriesCmd = args[0];
1603+
const seriesId = args[1];
1604+
1605+
switch (seriesCmd) {
1606+
case 'create': {
1607+
const rl = createInterface({ input: process.stdin, output: process.stdout });
1608+
const ask = (q) => new Promise((r) => rl.question(q, r));
1609+
1610+
try {
1611+
const businessId = flags['business-id'] || await ask('Business ID: ');
1612+
const paymentMethod = flags['payment-method'] || await ask('Payment method (crypto/card): ');
1613+
const amount = flags.amount || await ask('Amount (cents for card, satoshis-equiv for crypto): ');
1614+
const currency = flags.currency || await ask('Currency [USD]: ') || 'USD';
1615+
const interval = flags.interval || await ask('Interval (weekly/biweekly/monthly): ');
1616+
const maxPeriods = flags['max-periods'] || await ask('Max periods (blank=infinite): ') || undefined;
1617+
const customerEmail = flags.email || await ask('Customer email (optional): ') || undefined;
1618+
const description = flags.description || await ask('Description (optional): ') || undefined;
1619+
1620+
const params = {
1621+
business_id: businessId,
1622+
payment_method: paymentMethod,
1623+
amount: parseInt(amount),
1624+
currency,
1625+
interval,
1626+
max_periods: maxPeriods ? parseInt(maxPeriods) : undefined,
1627+
customer_email: customerEmail,
1628+
description,
1629+
};
1630+
1631+
if (paymentMethod === 'crypto') {
1632+
params.coin = flags.coin || await ask('Coin (BTC/ETH/SOL/etc): ');
1633+
params.beneficiary_address = flags.beneficiary || await ask('Beneficiary address: ');
1634+
params.depositor_address = flags.depositor || await ask('Depositor address: ');
1635+
} else {
1636+
params.stripe_account_id = flags['stripe-account'] || await ask('Stripe account ID: ');
1637+
}
1638+
1639+
rl.close();
1640+
1641+
const series = await client.createEscrowSeries(params);
1642+
print.success(`Escrow series created: ${series.id}`);
1643+
print.info(` Status: ${series.status}`);
1644+
print.info(` Interval: ${series.interval}`);
1645+
print.info(` Amount: ${series.amount} ${series.currency}`);
1646+
if (flags.json) print.json(series);
1647+
} catch (e) {
1648+
rl.close();
1649+
throw e;
1650+
}
1651+
break;
1652+
}
1653+
1654+
case 'list': {
1655+
const businessId = flags['business-id'];
1656+
if (!businessId) { print.error('--business-id required'); process.exit(1); }
1657+
const result = await client.listEscrowSeries(businessId, flags.status);
1658+
print.info(`Series (${result.series.length}):`);
1659+
for (const s of result.series) {
1660+
console.log(` ${s.id} | ${s.status} | ${s.payment_method} | ${s.interval} | ${s.amount} ${s.currency}`);
1661+
}
1662+
if (flags.json) print.json(result);
1663+
break;
1664+
}
1665+
1666+
case 'get': {
1667+
if (!seriesId) { print.error('Series ID required'); process.exit(1); }
1668+
const result = await client.getEscrowSeries(seriesId);
1669+
print.success(`Series ${result.series.id}`);
1670+
print.info(` Status: ${result.series.status}`);
1671+
print.info(` Method: ${result.series.payment_method}`);
1672+
print.info(` Amount: ${result.series.amount} ${result.series.currency}`);
1673+
print.info(` Interval: ${result.series.interval}`);
1674+
print.info(` Periods: ${result.series.periods_completed}${result.series.max_periods ? '/' + result.series.max_periods : ''}`);
1675+
print.info(` Next charge: ${result.series.next_charge_at}`);
1676+
print.info(` Crypto escrows: ${result.escrows.crypto.length}`);
1677+
print.info(` Stripe escrows: ${result.escrows.stripe.length}`);
1678+
if (flags.json) print.json(result);
1679+
break;
1680+
}
1681+
1682+
case 'pause': {
1683+
if (!seriesId) { print.error('Series ID required'); process.exit(1); }
1684+
await client.updateEscrowSeries(seriesId, { status: 'paused' });
1685+
print.success(`Series ${seriesId} paused`);
1686+
break;
1687+
}
1688+
1689+
case 'resume': {
1690+
if (!seriesId) { print.error('Series ID required'); process.exit(1); }
1691+
await client.updateEscrowSeries(seriesId, { status: 'active' });
1692+
print.success(`Series ${seriesId} resumed`);
1693+
break;
1694+
}
1695+
1696+
case 'cancel': {
1697+
if (!seriesId) { print.error('Series ID required'); process.exit(1); }
1698+
await client.cancelEscrowSeries(seriesId);
1699+
print.success(`Series ${seriesId} cancelled`);
1700+
break;
1701+
}
1702+
1703+
default:
1704+
print.error(`Unknown series command: ${seriesCmd}`);
1705+
print.info('Available: create, list, get, pause, resume, cancel');
1706+
process.exit(1);
1707+
}
1708+
break;
1709+
}
1710+
16011711
default:
16021712
print.error(`Unknown escrow command: ${subcommand}`);
1603-
print.info('Available: create, get, list, release, refund, dispute, events, auth');
1713+
print.info('Available: create, get, list, release, refund, dispute, events, auth, series');
16041714
process.exit(1);
16051715
}
16061716
}

packages/sdk/src/client.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,64 @@ export class CoinPayClient {
722722
}),
723723
});
724724
}
725+
// ── Escrow Series (Recurring) Methods ────────────────────
726+
727+
/**
728+
* Create a recurring escrow series
729+
* @param {Object} params
730+
* @returns {Promise<Object>} Created series
731+
*/
732+
async createEscrowSeries(params) {
733+
return this.request('/escrow/series', {
734+
method: 'POST',
735+
body: JSON.stringify(params),
736+
});
737+
}
738+
739+
/**
740+
* List escrow series for a business
741+
* @param {string} businessId
742+
* @param {string} [status]
743+
* @returns {Promise<Object>}
744+
*/
745+
async listEscrowSeries(businessId, status) {
746+
const params = new URLSearchParams({ business_id: businessId });
747+
if (status) params.set('status', status);
748+
return this.request(`/escrow/series?${params.toString()}`);
749+
}
750+
751+
/**
752+
* Get escrow series detail with child escrows
753+
* @param {string} id
754+
* @returns {Promise<Object>}
755+
*/
756+
async getEscrowSeries(id) {
757+
return this.request(`/escrow/series/${id}`);
758+
}
759+
760+
/**
761+
* Update escrow series (pause/resume/cancel, update amount/interval)
762+
* @param {string} id
763+
* @param {Object} params - { status?, amount?, interval? }
764+
* @returns {Promise<Object>}
765+
*/
766+
async updateEscrowSeries(id, params) {
767+
return this.request(`/escrow/series/${id}`, {
768+
method: 'PATCH',
769+
body: JSON.stringify(params),
770+
});
771+
}
772+
773+
/**
774+
* Cancel an escrow series
775+
* @param {string} id
776+
* @returns {Promise<Object>}
777+
*/
778+
async cancelEscrowSeries(id) {
779+
return this.request(`/escrow/series/${id}`, {
780+
method: 'DELETE',
781+
});
782+
}
725783
}
726784

727785
export default CoinPayClient;
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Escrow Series SDK Tests
3+
*/
4+
5+
import { describe, it, expect, vi, beforeEach } from 'vitest';
6+
import { CoinPayClient } from '../src/client.js';
7+
8+
const createMockClient = () => {
9+
const client = new CoinPayClient({ apiKey: 'test-key', baseUrl: 'http://localhost:3000/api' });
10+
client.request = vi.fn();
11+
return client;
12+
};
13+
14+
describe('Escrow Series SDK', () => {
15+
let client;
16+
17+
beforeEach(() => {
18+
client = createMockClient();
19+
});
20+
21+
describe('createEscrowSeries', () => {
22+
it('should create a crypto escrow series', async () => {
23+
const mockSeries = {
24+
id: 'ser_123',
25+
merchant_id: 'biz_123',
26+
payment_method: 'crypto',
27+
amount: 100000,
28+
currency: 'USD',
29+
coin: 'SOL',
30+
interval: 'monthly',
31+
status: 'active',
32+
};
33+
client.request.mockResolvedValue(mockSeries);
34+
35+
const result = await client.createEscrowSeries({
36+
business_id: 'biz_123',
37+
payment_method: 'crypto',
38+
amount: 100000,
39+
currency: 'USD',
40+
coin: 'SOL',
41+
interval: 'monthly',
42+
beneficiary_address: 'ben_addr',
43+
depositor_address: 'dep_addr',
44+
});
45+
46+
expect(client.request).toHaveBeenCalledWith('/escrow/series', {
47+
method: 'POST',
48+
body: expect.any(String),
49+
});
50+
expect(result.id).toBe('ser_123');
51+
});
52+
53+
it('should create a card escrow series', async () => {
54+
const mockSeries = {
55+
id: 'ser_456',
56+
payment_method: 'card',
57+
amount: 5000,
58+
interval: 'weekly',
59+
status: 'active',
60+
};
61+
client.request.mockResolvedValue(mockSeries);
62+
63+
const result = await client.createEscrowSeries({
64+
business_id: 'biz_123',
65+
payment_method: 'card',
66+
amount: 5000,
67+
interval: 'weekly',
68+
stripe_account_id: 'acct_123',
69+
});
70+
71+
expect(result.payment_method).toBe('card');
72+
});
73+
});
74+
75+
describe('listEscrowSeries', () => {
76+
it('should list series for a business', async () => {
77+
client.request.mockResolvedValue({ series: [{ id: 'ser_1' }, { id: 'ser_2' }] });
78+
79+
const result = await client.listEscrowSeries('biz_123');
80+
expect(client.request).toHaveBeenCalledWith('/escrow/series?business_id=biz_123');
81+
expect(result.series).toHaveLength(2);
82+
});
83+
84+
it('should filter by status', async () => {
85+
client.request.mockResolvedValue({ series: [] });
86+
87+
await client.listEscrowSeries('biz_123', 'active');
88+
expect(client.request).toHaveBeenCalledWith('/escrow/series?business_id=biz_123&status=active');
89+
});
90+
});
91+
92+
describe('getEscrowSeries', () => {
93+
it('should get series detail with child escrows', async () => {
94+
client.request.mockResolvedValue({
95+
series: { id: 'ser_123', status: 'active' },
96+
escrows: { crypto: [], stripe: [] },
97+
});
98+
99+
const result = await client.getEscrowSeries('ser_123');
100+
expect(client.request).toHaveBeenCalledWith('/escrow/series/ser_123');
101+
expect(result.series.id).toBe('ser_123');
102+
});
103+
});
104+
105+
describe('updateEscrowSeries', () => {
106+
it('should pause a series', async () => {
107+
client.request.mockResolvedValue({ id: 'ser_123', status: 'paused' });
108+
109+
const result = await client.updateEscrowSeries('ser_123', { status: 'paused' });
110+
expect(client.request).toHaveBeenCalledWith('/escrow/series/ser_123', {
111+
method: 'PATCH',
112+
body: JSON.stringify({ status: 'paused' }),
113+
});
114+
expect(result.status).toBe('paused');
115+
});
116+
117+
it('should update amount', async () => {
118+
client.request.mockResolvedValue({ id: 'ser_123', amount: 200000 });
119+
120+
await client.updateEscrowSeries('ser_123', { amount: 200000 });
121+
expect(client.request).toHaveBeenCalledWith('/escrow/series/ser_123', {
122+
method: 'PATCH',
123+
body: JSON.stringify({ amount: 200000 }),
124+
});
125+
});
126+
});
127+
128+
describe('cancelEscrowSeries', () => {
129+
it('should cancel a series', async () => {
130+
client.request.mockResolvedValue({ id: 'ser_123', status: 'cancelled' });
131+
132+
const result = await client.cancelEscrowSeries('ser_123');
133+
expect(client.request).toHaveBeenCalledWith('/escrow/series/ser_123', {
134+
method: 'DELETE',
135+
});
136+
expect(result.status).toBe('cancelled');
137+
});
138+
});
139+
});

0 commit comments

Comments
 (0)