@webwaka/core 1.3.0
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 +70 -0
- package/dist/ai.d.ts +25 -0
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +53 -0
- package/dist/ai.js.map +1 -0
- package/dist/core/ai/AIEngine.d.ts +69 -0
- package/dist/core/ai/AIEngine.d.ts.map +1 -0
- package/dist/core/ai/AIEngine.js +185 -0
- package/dist/core/ai/AIEngine.js.map +1 -0
- package/dist/core/auth/index.d.ts +183 -0
- package/dist/core/auth/index.d.ts.map +1 -0
- package/dist/core/auth/index.js +369 -0
- package/dist/core/auth/index.js.map +1 -0
- package/dist/core/billing/index.d.ts +52 -0
- package/dist/core/billing/index.d.ts.map +1 -0
- package/dist/core/billing/index.js +91 -0
- package/dist/core/billing/index.js.map +1 -0
- package/dist/core/booking/index.d.ts +38 -0
- package/dist/core/booking/index.d.ts.map +1 -0
- package/dist/core/booking/index.js +60 -0
- package/dist/core/booking/index.js.map +1 -0
- package/dist/core/chat/index.d.ts +48 -0
- package/dist/core/chat/index.d.ts.map +1 -0
- package/dist/core/chat/index.js +83 -0
- package/dist/core/chat/index.js.map +1 -0
- package/dist/core/document/index.d.ts +41 -0
- package/dist/core/document/index.d.ts.map +1 -0
- package/dist/core/document/index.js +68 -0
- package/dist/core/document/index.js.map +1 -0
- package/dist/core/events/index.d.ts +64 -0
- package/dist/core/events/index.d.ts.map +1 -0
- package/dist/core/events/index.js +60 -0
- package/dist/core/events/index.js.map +1 -0
- package/dist/core/geolocation/index.d.ts +32 -0
- package/dist/core/geolocation/index.d.ts.map +1 -0
- package/dist/core/geolocation/index.js +50 -0
- package/dist/core/geolocation/index.js.map +1 -0
- package/dist/core/kyc/index.d.ts +37 -0
- package/dist/core/kyc/index.d.ts.map +1 -0
- package/dist/core/kyc/index.js +60 -0
- package/dist/core/kyc/index.js.map +1 -0
- package/dist/core/logger/index.d.ts +60 -0
- package/dist/core/logger/index.d.ts.map +1 -0
- package/dist/core/logger/index.js +91 -0
- package/dist/core/logger/index.js.map +1 -0
- package/dist/core/notifications/index.d.ts +41 -0
- package/dist/core/notifications/index.d.ts.map +1 -0
- package/dist/core/notifications/index.js +111 -0
- package/dist/core/notifications/index.js.map +1 -0
- package/dist/core/rbac/index.d.ts +43 -0
- package/dist/core/rbac/index.d.ts.map +1 -0
- package/dist/core/rbac/index.js +66 -0
- package/dist/core/rbac/index.js.map +1 -0
- package/dist/events.d.ts +23 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +22 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/kyc.d.ts +12 -0
- package/dist/kyc.d.ts.map +1 -0
- package/dist/kyc.js +2 -0
- package/dist/kyc.js.map +1 -0
- package/dist/nanoid.d.ts +8 -0
- package/dist/nanoid.d.ts.map +1 -0
- package/dist/nanoid.js +15 -0
- package/dist/nanoid.js.map +1 -0
- package/dist/ndpr.d.ts +13 -0
- package/dist/ndpr.d.ts.map +1 -0
- package/dist/ndpr.js +19 -0
- package/dist/ndpr.js.map +1 -0
- package/dist/optimistic-lock.d.ts +11 -0
- package/dist/optimistic-lock.d.ts.map +1 -0
- package/dist/optimistic-lock.js +24 -0
- package/dist/optimistic-lock.js.map +1 -0
- package/dist/payment.d.ts +41 -0
- package/dist/payment.d.ts.map +1 -0
- package/dist/payment.js +116 -0
- package/dist/payment.js.map +1 -0
- package/dist/pin.d.ts +6 -0
- package/dist/pin.d.ts.map +1 -0
- package/dist/pin.js +18 -0
- package/dist/pin.js.map +1 -0
- package/dist/query-helpers.d.ts +18 -0
- package/dist/query-helpers.d.ts.map +1 -0
- package/dist/query-helpers.js +22 -0
- package/dist/query-helpers.js.map +1 -0
- package/dist/rate-limit.d.ts +13 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +16 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/sms.d.ts +23 -0
- package/dist/sms.d.ts.map +1 -0
- package/dist/sms.js +60 -0
- package/dist/sms.js.map +1 -0
- package/dist/tax.d.ts +25 -0
- package/dist/tax.d.ts.map +1 -0
- package/dist/tax.js +31 -0
- package/dist/tax.js.map +1 -0
- package/package.json +99 -0
- package/src/ai.test.ts +146 -0
- package/src/ai.ts +75 -0
- package/src/core/ai/AIEngine.test.ts +386 -0
- package/src/core/ai/AIEngine.ts +281 -0
- package/src/core/auth/index.test.ts +268 -0
- package/src/core/auth/index.ts +570 -0
- package/src/core/billing/index.test.ts +154 -0
- package/src/core/billing/index.ts +132 -0
- package/src/core/booking/index.test.ts +153 -0
- package/src/core/booking/index.ts +91 -0
- package/src/core/chat/index.test.ts +159 -0
- package/src/core/chat/index.ts +130 -0
- package/src/core/document/index.test.ts +106 -0
- package/src/core/document/index.ts +99 -0
- package/src/core/events/index.test.ts +91 -0
- package/src/core/events/index.ts +91 -0
- package/src/core/geolocation/index.test.ts +70 -0
- package/src/core/geolocation/index.ts +69 -0
- package/src/core/kyc/index.test.ts +105 -0
- package/src/core/kyc/index.ts +86 -0
- package/src/core/logger/index.test.ts +110 -0
- package/src/core/logger/index.ts +127 -0
- package/src/core/notifications/index.test.ts +85 -0
- package/src/core/notifications/index.ts +136 -0
- package/src/core/rbac/index.test.ts +81 -0
- package/src/core/rbac/index.ts +85 -0
- package/src/events.test.ts +43 -0
- package/src/events.ts +23 -0
- package/src/index.test.ts +123 -0
- package/src/index.ts +97 -0
- package/src/kyc.ts +23 -0
- package/src/nanoid.test.ts +43 -0
- package/src/nanoid.ts +16 -0
- package/src/ndpr.test.ts +68 -0
- package/src/ndpr.ts +49 -0
- package/src/optimistic-lock.test.ts +75 -0
- package/src/optimistic-lock.ts +36 -0
- package/src/payment.test.ts +152 -0
- package/src/payment.ts +163 -0
- package/src/pin.test.ts +57 -0
- package/src/pin.ts +38 -0
- package/src/query-helpers.test.ts +98 -0
- package/src/query-helpers.ts +36 -0
- package/src/rate-limit.test.ts +98 -0
- package/src/rate-limit.ts +33 -0
- package/src/sms.test.ts +112 -0
- package/src/sms.ts +85 -0
- package/src/tax.test.ts +85 -0
- package/src/tax.ts +57 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { updateWithVersionLock } from './optimistic-lock';
|
|
3
|
+
|
|
4
|
+
function makeMockDb(changes: number) {
|
|
5
|
+
const mockRun = vi.fn().mockResolvedValue({ meta: { changes } });
|
|
6
|
+
const mockBind = vi.fn().mockReturnValue({ run: mockRun });
|
|
7
|
+
const mockPrepare = vi.fn().mockReturnValue({ bind: mockBind });
|
|
8
|
+
return { prepare: mockPrepare, mockRun, mockBind, mockPrepare };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('updateWithVersionLock', () => {
|
|
12
|
+
it('returns success: true when row is updated (changes = 1)', async () => {
|
|
13
|
+
const { prepare } = makeMockDb(1);
|
|
14
|
+
const result = await updateWithVersionLock(
|
|
15
|
+
{ prepare } as any,
|
|
16
|
+
'orders',
|
|
17
|
+
{ status: 'completed' },
|
|
18
|
+
{ id: 'order-1', tenantId: 'tenant-1', expectedVersion: 3 }
|
|
19
|
+
);
|
|
20
|
+
expect(result.success).toBe(true);
|
|
21
|
+
expect(result.conflict).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns conflict: true when no rows are updated (version mismatch)', async () => {
|
|
25
|
+
const { prepare } = makeMockDb(0);
|
|
26
|
+
const result = await updateWithVersionLock(
|
|
27
|
+
{ prepare } as any,
|
|
28
|
+
'orders',
|
|
29
|
+
{ status: 'completed' },
|
|
30
|
+
{ id: 'order-1', tenantId: 'tenant-1', expectedVersion: 3 }
|
|
31
|
+
);
|
|
32
|
+
expect(result.success).toBe(false);
|
|
33
|
+
expect(result.conflict).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns error when db throws', async () => {
|
|
37
|
+
const mockRun = vi.fn().mockRejectedValue(new Error('D1 error'));
|
|
38
|
+
const mockBind = vi.fn().mockReturnValue({ run: mockRun });
|
|
39
|
+
const mockPrepare = vi.fn().mockReturnValue({ bind: mockBind });
|
|
40
|
+
const mockDb = { prepare: mockPrepare } as any;
|
|
41
|
+
|
|
42
|
+
const result = await updateWithVersionLock(
|
|
43
|
+
mockDb,
|
|
44
|
+
'orders',
|
|
45
|
+
{ status: 'completed' },
|
|
46
|
+
{ id: 'order-1', tenantId: 'tenant-1', expectedVersion: 1 }
|
|
47
|
+
);
|
|
48
|
+
expect(result.success).toBe(false);
|
|
49
|
+
expect(result.conflict).toBe(false);
|
|
50
|
+
expect(result.error).toBe('D1 error');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('builds SET clause from multiple update fields', async () => {
|
|
54
|
+
const { prepare, mockBind } = makeMockDb(1);
|
|
55
|
+
await updateWithVersionLock(
|
|
56
|
+
{ prepare } as any,
|
|
57
|
+
'products',
|
|
58
|
+
{ name: 'Widget', priceKobo: 5000, stock: 10 },
|
|
59
|
+
{ id: 'prod-1', tenantId: 'tenant-2', expectedVersion: 5 }
|
|
60
|
+
);
|
|
61
|
+
const sql = (prepare as any).mock.calls[0][0] as string;
|
|
62
|
+
expect(sql).toContain('name = ?');
|
|
63
|
+
expect(sql).toContain('priceKobo = ?');
|
|
64
|
+
expect(sql).toContain('stock = ?');
|
|
65
|
+
expect(sql).toContain('version = version + 1');
|
|
66
|
+
// bind args: 3 update values + updatedAt + id + tenantId + expectedVersion
|
|
67
|
+
const bindArgs = mockBind.mock.calls[0];
|
|
68
|
+
expect(bindArgs).toContain('Widget');
|
|
69
|
+
expect(bindArgs).toContain(5000);
|
|
70
|
+
expect(bindArgs).toContain(10);
|
|
71
|
+
expect(bindArgs).toContain('prod-1');
|
|
72
|
+
expect(bindArgs).toContain('tenant-2');
|
|
73
|
+
expect(bindArgs).toContain(5);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface OptimisticLockResult {
|
|
2
|
+
success: boolean;
|
|
3
|
+
conflict: boolean;
|
|
4
|
+
error?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function updateWithVersionLock(
|
|
8
|
+
db: D1Database,
|
|
9
|
+
table: string,
|
|
10
|
+
updates: Record<string, any>,
|
|
11
|
+
where: { id: string; tenantId: string; expectedVersion: number }
|
|
12
|
+
): Promise<OptimisticLockResult> {
|
|
13
|
+
try {
|
|
14
|
+
const now = new Date().toISOString();
|
|
15
|
+
const setClauses = Object.keys(updates)
|
|
16
|
+
.map((col) => `${col} = ?`)
|
|
17
|
+
.join(', ');
|
|
18
|
+
const setValues = Object.values(updates);
|
|
19
|
+
|
|
20
|
+
const sql = `
|
|
21
|
+
UPDATE ${table}
|
|
22
|
+
SET ${setClauses}, version = version + 1, updatedAt = ?
|
|
23
|
+
WHERE id = ? AND tenantId = ? AND version = ? AND deletedAt IS NULL
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const stmt = db.prepare(sql).bind(...setValues, now, where.id, where.tenantId, where.expectedVersion);
|
|
27
|
+
const result = await stmt.run();
|
|
28
|
+
|
|
29
|
+
if ((result.meta as any).changes === 0) {
|
|
30
|
+
return { success: false, conflict: true };
|
|
31
|
+
}
|
|
32
|
+
return { success: true, conflict: false };
|
|
33
|
+
} catch (err: any) {
|
|
34
|
+
return { success: false, conflict: false, error: err?.message };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { PaystackProvider, createPaymentProvider } from './payment';
|
|
3
|
+
|
|
4
|
+
const mockFetch = vi.fn();
|
|
5
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockFetch.mockReset();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const provider = new PaystackProvider('sk_test_key');
|
|
12
|
+
|
|
13
|
+
describe('PaystackProvider.verifyCharge', () => {
|
|
14
|
+
it('returns success when Paystack responds with status: true', async () => {
|
|
15
|
+
mockFetch.mockResolvedValueOnce({
|
|
16
|
+
ok: true,
|
|
17
|
+
json: () => Promise.resolve({
|
|
18
|
+
status: true,
|
|
19
|
+
data: { reference: 'ref-001', amount: 50000, status: 'success' },
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
const result = await provider.verifyCharge('ref-001');
|
|
23
|
+
expect(result.success).toBe(true);
|
|
24
|
+
expect(result.reference).toBe('ref-001');
|
|
25
|
+
expect(result.amountKobo).toBe(50000);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns failure when Paystack responds with status: false', async () => {
|
|
29
|
+
mockFetch.mockResolvedValueOnce({
|
|
30
|
+
ok: true,
|
|
31
|
+
json: () => Promise.resolve({ status: false, message: 'Invalid key' }),
|
|
32
|
+
});
|
|
33
|
+
const result = await provider.verifyCharge('ref-bad');
|
|
34
|
+
expect(result.success).toBe(false);
|
|
35
|
+
expect(result.error).toBe('Invalid key');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns failure when fetch throws', async () => {
|
|
39
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
40
|
+
const result = await provider.verifyCharge('ref-err');
|
|
41
|
+
expect(result.success).toBe(false);
|
|
42
|
+
expect(result.error).toBe('Network error');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns failure when response is not ok', async () => {
|
|
46
|
+
mockFetch.mockResolvedValueOnce({
|
|
47
|
+
ok: false,
|
|
48
|
+
json: () => Promise.resolve({ message: 'Unauthorized' }),
|
|
49
|
+
});
|
|
50
|
+
const result = await provider.verifyCharge('ref-unauth');
|
|
51
|
+
expect(result.success).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('PaystackProvider.initiateRefund', () => {
|
|
56
|
+
it('returns success with refundId on successful refund', async () => {
|
|
57
|
+
mockFetch.mockResolvedValueOnce({
|
|
58
|
+
ok: true,
|
|
59
|
+
json: () => Promise.resolve({ status: true, data: { id: 'rfnd-123' } }),
|
|
60
|
+
});
|
|
61
|
+
const result = await provider.initiateRefund('ref-001', 50000);
|
|
62
|
+
expect(result.success).toBe(true);
|
|
63
|
+
expect(result.refundId).toBe('rfnd-123');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns failure when Paystack returns status: false', async () => {
|
|
67
|
+
mockFetch.mockResolvedValueOnce({
|
|
68
|
+
ok: true,
|
|
69
|
+
json: () => Promise.resolve({ status: false, message: 'Refund failed' }),
|
|
70
|
+
});
|
|
71
|
+
const result = await provider.initiateRefund('ref-001');
|
|
72
|
+
expect(result.success).toBe(false);
|
|
73
|
+
expect(result.error).toBe('Refund failed');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns failure when fetch throws', async () => {
|
|
77
|
+
mockFetch.mockRejectedValueOnce(new Error('Timeout'));
|
|
78
|
+
const result = await provider.initiateRefund('ref-err');
|
|
79
|
+
expect(result.success).toBe(false);
|
|
80
|
+
expect(result.error).toBe('Timeout');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('PaystackProvider.initiateSplit', () => {
|
|
85
|
+
it('returns success when split initiation succeeds', async () => {
|
|
86
|
+
mockFetch.mockResolvedValueOnce({
|
|
87
|
+
ok: true,
|
|
88
|
+
json: () => Promise.resolve({ status: true, data: {} }),
|
|
89
|
+
});
|
|
90
|
+
const result = await provider.initiateSplit(
|
|
91
|
+
100000,
|
|
92
|
+
[{ subaccountCode: 'ACCT_abc', amountKobo: 60000 }],
|
|
93
|
+
'split-ref-1'
|
|
94
|
+
);
|
|
95
|
+
expect(result.success).toBe(true);
|
|
96
|
+
expect(result.reference).toBe('split-ref-1');
|
|
97
|
+
expect(result.amountKobo).toBe(100000);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns failure when Paystack returns status: false', async () => {
|
|
101
|
+
mockFetch.mockResolvedValueOnce({
|
|
102
|
+
ok: false,
|
|
103
|
+
json: () => Promise.resolve({ status: false, message: 'Split failed' }),
|
|
104
|
+
});
|
|
105
|
+
const result = await provider.initiateSplit(100000, [], 'split-ref-2');
|
|
106
|
+
expect(result.success).toBe(false);
|
|
107
|
+
expect(result.error).toBe('Split failed');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns failure when fetch throws', async () => {
|
|
111
|
+
mockFetch.mockRejectedValueOnce(new Error('Network'));
|
|
112
|
+
const result = await provider.initiateSplit(100000, [], 'split-ref-3');
|
|
113
|
+
expect(result.success).toBe(false);
|
|
114
|
+
expect(result.error).toBe('Network');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('PaystackProvider.initiateTransfer', () => {
|
|
119
|
+
it('returns success with transferCode', async () => {
|
|
120
|
+
mockFetch.mockResolvedValueOnce({
|
|
121
|
+
ok: true,
|
|
122
|
+
json: () => Promise.resolve({ status: true, data: { transfer_code: 'TRF_abc' } }),
|
|
123
|
+
});
|
|
124
|
+
const result = await provider.initiateTransfer('RCP_xyz', 25000, 'trf-ref-1');
|
|
125
|
+
expect(result.success).toBe(true);
|
|
126
|
+
expect(result.transferCode).toBe('TRF_abc');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns failure when Paystack returns status: false', async () => {
|
|
130
|
+
mockFetch.mockResolvedValueOnce({
|
|
131
|
+
ok: false,
|
|
132
|
+
json: () => Promise.resolve({ status: false, message: 'Transfer failed' }),
|
|
133
|
+
});
|
|
134
|
+
const result = await provider.initiateTransfer('RCP_xyz', 25000, 'trf-ref-2');
|
|
135
|
+
expect(result.success).toBe(false);
|
|
136
|
+
expect(result.error).toBe('Transfer failed');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns failure when fetch throws', async () => {
|
|
140
|
+
mockFetch.mockRejectedValueOnce(new Error('Timeout'));
|
|
141
|
+
const result = await provider.initiateTransfer('RCP_xyz', 25000, 'trf-ref-3');
|
|
142
|
+
expect(result.success).toBe(false);
|
|
143
|
+
expect(result.error).toBe('Timeout');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('createPaymentProvider', () => {
|
|
148
|
+
it('returns a PaystackProvider instance', () => {
|
|
149
|
+
const p = createPaymentProvider('sk_live_key');
|
|
150
|
+
expect(p).toBeInstanceOf(PaystackProvider);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/src/payment.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
export interface ChargeResult {
|
|
2
|
+
success: boolean;
|
|
3
|
+
reference: string;
|
|
4
|
+
amountKobo: number;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RefundResult {
|
|
9
|
+
success: boolean;
|
|
10
|
+
refundId: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SplitRecipient {
|
|
15
|
+
subaccountCode: string;
|
|
16
|
+
amountKobo: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface IPaymentProvider {
|
|
20
|
+
verifyCharge(reference: string): Promise<ChargeResult>;
|
|
21
|
+
initiateRefund(reference: string, amountKobo?: number): Promise<RefundResult>;
|
|
22
|
+
initiateSplit(
|
|
23
|
+
totalKobo: number,
|
|
24
|
+
recipients: SplitRecipient[],
|
|
25
|
+
reference: string
|
|
26
|
+
): Promise<ChargeResult>;
|
|
27
|
+
initiateTransfer(
|
|
28
|
+
recipientCode: string,
|
|
29
|
+
amountKobo: number,
|
|
30
|
+
reference: string
|
|
31
|
+
): Promise<{ success: boolean; transferCode: string; error?: string }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class PaystackProvider implements IPaymentProvider {
|
|
35
|
+
private secretKey: string;
|
|
36
|
+
private baseUrl = 'https://api.paystack.co';
|
|
37
|
+
|
|
38
|
+
constructor(secretKey: string) {
|
|
39
|
+
this.secretKey = secretKey;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private get headers(): Record<string, string> {
|
|
43
|
+
return {
|
|
44
|
+
Authorization: `Bearer ${this.secretKey}`,
|
|
45
|
+
'Content-Type': 'application/json',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async verifyCharge(reference: string): Promise<ChargeResult> {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(
|
|
52
|
+
`${this.baseUrl}/transaction/verify/${encodeURIComponent(reference)}`,
|
|
53
|
+
{ method: 'GET', headers: this.headers }
|
|
54
|
+
);
|
|
55
|
+
const data = (await res.json()) as any;
|
|
56
|
+
if (!res.ok || !data.status) {
|
|
57
|
+
return {
|
|
58
|
+
success: false,
|
|
59
|
+
reference,
|
|
60
|
+
amountKobo: 0,
|
|
61
|
+
error: data.message ?? 'Verification failed',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
success: data.data?.status === 'success',
|
|
66
|
+
reference,
|
|
67
|
+
amountKobo: data.data?.amount ?? 0,
|
|
68
|
+
error: data.data?.status !== 'success' ? data.data?.gateway_response : undefined,
|
|
69
|
+
};
|
|
70
|
+
} catch (err: any) {
|
|
71
|
+
return { success: false, reference, amountKobo: 0, error: err?.message };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async initiateRefund(reference: string, amountKobo?: number): Promise<RefundResult> {
|
|
76
|
+
try {
|
|
77
|
+
const body: Record<string, unknown> = { transaction: reference };
|
|
78
|
+
if (amountKobo !== undefined) body.amount = amountKobo;
|
|
79
|
+
|
|
80
|
+
const res = await fetch(`${this.baseUrl}/refund`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: this.headers,
|
|
83
|
+
body: JSON.stringify(body),
|
|
84
|
+
});
|
|
85
|
+
const data = (await res.json()) as any;
|
|
86
|
+
if (!res.ok || !data.status) {
|
|
87
|
+
return { success: false, refundId: '', error: data.message ?? 'Refund failed' };
|
|
88
|
+
}
|
|
89
|
+
return { success: true, refundId: String(data.data?.id ?? '') };
|
|
90
|
+
} catch (err: any) {
|
|
91
|
+
return { success: false, refundId: '', error: err?.message };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async initiateSplit(
|
|
96
|
+
totalKobo: number,
|
|
97
|
+
recipients: SplitRecipient[],
|
|
98
|
+
reference: string
|
|
99
|
+
): Promise<ChargeResult> {
|
|
100
|
+
const subaccounts = recipients.map((r) => ({
|
|
101
|
+
subaccount: r.subaccountCode,
|
|
102
|
+
amount: r.amountKobo,
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const res = await fetch(`${this.baseUrl}/transaction/initialize`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: this.headers,
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
amount: totalKobo,
|
|
111
|
+
reference,
|
|
112
|
+
split: { type: 'flat', subaccounts },
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
const data = (await res.json()) as any;
|
|
116
|
+
if (!res.ok || !data.status) {
|
|
117
|
+
return {
|
|
118
|
+
success: false,
|
|
119
|
+
reference,
|
|
120
|
+
amountKobo: totalKobo,
|
|
121
|
+
error: data.message ?? 'Split initiation failed',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { success: true, reference, amountKobo: totalKobo };
|
|
125
|
+
} catch (err: any) {
|
|
126
|
+
return { success: false, reference, amountKobo: totalKobo, error: err?.message };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async initiateTransfer(
|
|
131
|
+
recipientCode: string,
|
|
132
|
+
amountKobo: number,
|
|
133
|
+
reference: string
|
|
134
|
+
): Promise<{ success: boolean; transferCode: string; error?: string }> {
|
|
135
|
+
try {
|
|
136
|
+
const res = await fetch(`${this.baseUrl}/transfer`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: this.headers,
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
source: 'balance',
|
|
141
|
+
amount: amountKobo,
|
|
142
|
+
recipient: recipientCode,
|
|
143
|
+
reference,
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
146
|
+
const data = (await res.json()) as any;
|
|
147
|
+
if (!res.ok || !data.status) {
|
|
148
|
+
return {
|
|
149
|
+
success: false,
|
|
150
|
+
transferCode: '',
|
|
151
|
+
error: data.message ?? 'Transfer failed',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { success: true, transferCode: data.data?.transfer_code ?? '' };
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
return { success: false, transferCode: '', error: err?.message };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createPaymentProvider(secretKey: string): IPaymentProvider {
|
|
162
|
+
return new PaystackProvider(secretKey);
|
|
163
|
+
}
|
package/src/pin.test.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { hashPin, verifyPin } from './pin';
|
|
3
|
+
|
|
4
|
+
describe('hashPin', () => {
|
|
5
|
+
it('returns a hash and a salt', async () => {
|
|
6
|
+
const result = await hashPin('1234');
|
|
7
|
+
expect(result).toHaveProperty('hash');
|
|
8
|
+
expect(result).toHaveProperty('salt');
|
|
9
|
+
expect(typeof result.hash).toBe('string');
|
|
10
|
+
expect(typeof result.salt).toBe('string');
|
|
11
|
+
expect(result.hash.length).toBeGreaterThan(0);
|
|
12
|
+
expect(result.salt.length).toBeGreaterThan(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('produces a deterministic hash when salt is provided', async () => {
|
|
16
|
+
const salt = 'fixed-salt-for-test';
|
|
17
|
+
const result1 = await hashPin('5678', salt);
|
|
18
|
+
const result2 = await hashPin('5678', salt);
|
|
19
|
+
expect(result1.hash).toBe(result2.hash);
|
|
20
|
+
expect(result1.salt).toBe(salt);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('produces different hashes for different PINs with the same salt', async () => {
|
|
24
|
+
const salt = 'same-salt';
|
|
25
|
+
const r1 = await hashPin('1111', salt);
|
|
26
|
+
const r2 = await hashPin('2222', salt);
|
|
27
|
+
expect(r1.hash).not.toBe(r2.hash);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('generates a random salt when none is provided', async () => {
|
|
31
|
+
const r1 = await hashPin('9999');
|
|
32
|
+
const r2 = await hashPin('9999');
|
|
33
|
+
// Different salts → different hashes
|
|
34
|
+
expect(r1.salt).not.toBe(r2.salt);
|
|
35
|
+
expect(r1.hash).not.toBe(r2.hash);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('verifyPin', () => {
|
|
40
|
+
it('returns true for a correct PIN', async () => {
|
|
41
|
+
const { hash, salt } = await hashPin('4321');
|
|
42
|
+
const valid = await verifyPin('4321', hash, salt);
|
|
43
|
+
expect(valid).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns false for an incorrect PIN', async () => {
|
|
47
|
+
const { hash, salt } = await hashPin('4321');
|
|
48
|
+
const valid = await verifyPin('9999', hash, salt);
|
|
49
|
+
expect(valid).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns false when salt is wrong', async () => {
|
|
53
|
+
const { hash } = await hashPin('1234', 'correct-salt');
|
|
54
|
+
const valid = await verifyPin('1234', hash, 'wrong-salt');
|
|
55
|
+
expect(valid).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
package/src/pin.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export async function hashPin(
|
|
2
|
+
pin: string,
|
|
3
|
+
salt?: string
|
|
4
|
+
): Promise<{ hash: string; salt: string }> {
|
|
5
|
+
const usedSalt = salt ?? crypto.randomUUID();
|
|
6
|
+
const enc = new TextEncoder();
|
|
7
|
+
|
|
8
|
+
const keyMaterial = await crypto.subtle.importKey(
|
|
9
|
+
'raw',
|
|
10
|
+
enc.encode(pin),
|
|
11
|
+
'PBKDF2',
|
|
12
|
+
false,
|
|
13
|
+
['deriveBits']
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const bits = await crypto.subtle.deriveBits(
|
|
17
|
+
{
|
|
18
|
+
name: 'PBKDF2',
|
|
19
|
+
salt: enc.encode(usedSalt),
|
|
20
|
+
iterations: 100_000,
|
|
21
|
+
hash: 'SHA-256',
|
|
22
|
+
},
|
|
23
|
+
keyMaterial,
|
|
24
|
+
256
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const hash = btoa(String.fromCharCode(...new Uint8Array(bits)));
|
|
28
|
+
return { hash, salt: usedSalt };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function verifyPin(
|
|
32
|
+
pin: string,
|
|
33
|
+
storedHash: string,
|
|
34
|
+
salt: string
|
|
35
|
+
): Promise<boolean> {
|
|
36
|
+
const { hash } = await hashPin(pin, salt);
|
|
37
|
+
return hash === storedHash;
|
|
38
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parsePagination, metaResponse, applyTenantScope } from './query-helpers';
|
|
3
|
+
|
|
4
|
+
describe('parsePagination', () => {
|
|
5
|
+
it('returns default limit and offset when no params provided', () => {
|
|
6
|
+
const result = parsePagination({});
|
|
7
|
+
expect(result.limit).toBe(20);
|
|
8
|
+
expect(result.offset).toBe(0);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('parses valid limit and offset', () => {
|
|
12
|
+
const result = parsePagination({ limit: '50', offset: '100' });
|
|
13
|
+
expect(result.limit).toBe(50);
|
|
14
|
+
expect(result.offset).toBe(100);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('clamps limit to maxLimit', () => {
|
|
18
|
+
const result = parsePagination({ limit: '500' }, 100);
|
|
19
|
+
expect(result.limit).toBe(100);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('clamps limit to minimum of 1 for non-zero invalid values', () => {
|
|
23
|
+
// parseInt('0') = 0, which is falsy, so || 20 kicks in → 20
|
|
24
|
+
// The minimum-1 clamp applies to values like parseInt('-5') = -5 → max(-5,1) = 1
|
|
25
|
+
const result = parsePagination({ limit: '-5' });
|
|
26
|
+
expect(result.limit).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('clamps offset to minimum of 0', () => {
|
|
30
|
+
const result = parsePagination({ offset: '-5' });
|
|
31
|
+
expect(result.offset).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('handles non-numeric values gracefully', () => {
|
|
35
|
+
const result = parsePagination({ limit: 'abc', offset: 'xyz' });
|
|
36
|
+
expect(result.limit).toBe(20);
|
|
37
|
+
expect(result.offset).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('metaResponse', () => {
|
|
42
|
+
it('returns data and correct meta fields', () => {
|
|
43
|
+
const data = [{ id: 1 }, { id: 2 }];
|
|
44
|
+
const result = metaResponse(data, 50, 20, 0);
|
|
45
|
+
expect(result.data).toEqual(data);
|
|
46
|
+
expect(result.meta.total).toBe(50);
|
|
47
|
+
expect(result.meta.limit).toBe(20);
|
|
48
|
+
expect(result.meta.offset).toBe(0);
|
|
49
|
+
expect(result.meta.has_more).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('sets has_more to false when on last page', () => {
|
|
53
|
+
const result = metaResponse([], 20, 20, 0);
|
|
54
|
+
expect(result.meta.has_more).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('sets has_more to false when offset + limit >= total', () => {
|
|
58
|
+
const result = metaResponse([], 25, 10, 20);
|
|
59
|
+
expect(result.meta.has_more).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('sets has_more to true when more pages exist', () => {
|
|
63
|
+
const result = metaResponse([], 100, 10, 10);
|
|
64
|
+
expect(result.meta.has_more).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('applyTenantScope', () => {
|
|
69
|
+
it('appends WHERE clause when no WHERE exists', () => {
|
|
70
|
+
const { query, params } = applyTenantScope(
|
|
71
|
+
'SELECT * FROM orders',
|
|
72
|
+
[],
|
|
73
|
+
'tenant-1'
|
|
74
|
+
);
|
|
75
|
+
expect(query).toContain('WHERE operator_id = ?');
|
|
76
|
+
expect(params).toContain('tenant-1');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('appends AND clause when WHERE already exists', () => {
|
|
80
|
+
const { query, params } = applyTenantScope(
|
|
81
|
+
'SELECT * FROM orders WHERE status = ?',
|
|
82
|
+
['active'],
|
|
83
|
+
'tenant-2'
|
|
84
|
+
);
|
|
85
|
+
expect(query).toContain('AND operator_id = ?');
|
|
86
|
+
expect(params).toEqual(['active', 'tenant-2']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('uses custom column name when provided', () => {
|
|
90
|
+
const { query } = applyTenantScope(
|
|
91
|
+
'SELECT * FROM items',
|
|
92
|
+
[],
|
|
93
|
+
'tenant-3',
|
|
94
|
+
'tenant_id'
|
|
95
|
+
);
|
|
96
|
+
expect(query).toContain('WHERE tenant_id = ?');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function parsePagination(
|
|
2
|
+
q: Record<string, string>,
|
|
3
|
+
maxLimit = 100
|
|
4
|
+
): { limit: number; offset: number } {
|
|
5
|
+
const limit = Math.min(Math.max(parseInt(q.limit ?? '20', 10) || 20, 1), maxLimit);
|
|
6
|
+
const offset = Math.max(parseInt(q.offset ?? '0', 10) || 0, 0);
|
|
7
|
+
return { limit, offset };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function metaResponse<T>(
|
|
11
|
+
data: T[],
|
|
12
|
+
total: number,
|
|
13
|
+
limit: number,
|
|
14
|
+
offset: number
|
|
15
|
+
) {
|
|
16
|
+
return {
|
|
17
|
+
data,
|
|
18
|
+
meta: {
|
|
19
|
+
total,
|
|
20
|
+
limit,
|
|
21
|
+
offset,
|
|
22
|
+
has_more: offset + limit < total,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function applyTenantScope(
|
|
28
|
+
baseQuery: string,
|
|
29
|
+
params: unknown[],
|
|
30
|
+
tenantId: string,
|
|
31
|
+
column = 'operator_id'
|
|
32
|
+
): { query: string; params: unknown[] } {
|
|
33
|
+
const hasWhere = /\bWHERE\b/i.test(baseQuery);
|
|
34
|
+
const clause = hasWhere ? ` AND ${column} = ?` : ` WHERE ${column} = ?`;
|
|
35
|
+
return { query: baseQuery + clause, params: [...params, tenantId] };
|
|
36
|
+
}
|