@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,98 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { checkRateLimit, type RateLimitOptions } from './rate-limit.js';
|
|
3
|
+
|
|
4
|
+
function createMockKV(): KVNamespace {
|
|
5
|
+
const store = new Map<string, { value: string; expiresAt?: number }>();
|
|
6
|
+
|
|
7
|
+
return {
|
|
8
|
+
async get(key: string): Promise<string | null> {
|
|
9
|
+
const entry = store.get(key);
|
|
10
|
+
if (!entry) return null;
|
|
11
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
12
|
+
store.delete(key);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return entry.value;
|
|
16
|
+
},
|
|
17
|
+
async put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void> {
|
|
18
|
+
const expiresAt = options?.expirationTtl
|
|
19
|
+
? Date.now() + options.expirationTtl * 1000
|
|
20
|
+
: undefined;
|
|
21
|
+
store.set(key, { value, expiresAt });
|
|
22
|
+
},
|
|
23
|
+
async delete(key: string): Promise<void> {
|
|
24
|
+
store.delete(key);
|
|
25
|
+
},
|
|
26
|
+
async list(): Promise<any> {
|
|
27
|
+
return { keys: [] };
|
|
28
|
+
},
|
|
29
|
+
async getWithMetadata(): Promise<any> {
|
|
30
|
+
return { value: null, metadata: null };
|
|
31
|
+
},
|
|
32
|
+
} as unknown as KVNamespace;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('checkRateLimit()', () => {
|
|
36
|
+
let kv: KVNamespace;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
kv = createMockKV();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('allows requests under the limit', async () => {
|
|
43
|
+
const opts: RateLimitOptions = { kv, key: 'user:1', maxRequests: 5, windowSeconds: 60 };
|
|
44
|
+
|
|
45
|
+
const result = await checkRateLimit(opts);
|
|
46
|
+
expect(result.allowed).toBe(true);
|
|
47
|
+
expect(result.remaining).toBe(4);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('blocks requests once the limit is reached', async () => {
|
|
51
|
+
const opts: RateLimitOptions = { kv, key: 'user:2', maxRequests: 3, windowSeconds: 60 };
|
|
52
|
+
|
|
53
|
+
await checkRateLimit(opts);
|
|
54
|
+
await checkRateLimit(opts);
|
|
55
|
+
await checkRateLimit(opts);
|
|
56
|
+
|
|
57
|
+
const blocked = await checkRateLimit(opts);
|
|
58
|
+
expect(blocked.allowed).toBe(false);
|
|
59
|
+
expect(blocked.remaining).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('counts remaining correctly as requests accumulate', async () => {
|
|
63
|
+
const opts: RateLimitOptions = { kv, key: 'user:3', maxRequests: 5, windowSeconds: 60 };
|
|
64
|
+
|
|
65
|
+
const r1 = await checkRateLimit(opts);
|
|
66
|
+
expect(r1.remaining).toBe(4);
|
|
67
|
+
|
|
68
|
+
const r2 = await checkRateLimit(opts);
|
|
69
|
+
expect(r2.remaining).toBe(3);
|
|
70
|
+
|
|
71
|
+
const r3 = await checkRateLimit(opts);
|
|
72
|
+
expect(r3.remaining).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('allows exactly maxRequests requests before blocking', async () => {
|
|
76
|
+
const opts: RateLimitOptions = { kv, key: 'user:4', maxRequests: 2, windowSeconds: 60 };
|
|
77
|
+
|
|
78
|
+
const r1 = await checkRateLimit(opts);
|
|
79
|
+
expect(r1.allowed).toBe(true);
|
|
80
|
+
|
|
81
|
+
const r2 = await checkRateLimit(opts);
|
|
82
|
+
expect(r2.allowed).toBe(true);
|
|
83
|
+
|
|
84
|
+
const r3 = await checkRateLimit(opts);
|
|
85
|
+
expect(r3.allowed).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('provides resetAt as epoch milliseconds in the future', async () => {
|
|
89
|
+
const opts: RateLimitOptions = { kv, key: 'user:5', maxRequests: 5, windowSeconds: 60 };
|
|
90
|
+
|
|
91
|
+
const before = Date.now();
|
|
92
|
+
const result = await checkRateLimit(opts);
|
|
93
|
+
|
|
94
|
+
expect(result.resetAt).toBeGreaterThan(before);
|
|
95
|
+
expect(result.resetAt).toBeGreaterThan(Date.now() - 1000);
|
|
96
|
+
expect(result.resetAt % 1000).toBe(0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface RateLimitOptions {
|
|
2
|
+
kv: KVNamespace;
|
|
3
|
+
key: string;
|
|
4
|
+
maxRequests: number;
|
|
5
|
+
windowSeconds: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RateLimitResult {
|
|
9
|
+
allowed: boolean;
|
|
10
|
+
remaining: number;
|
|
11
|
+
resetAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function checkRateLimit(opts: RateLimitOptions): Promise<RateLimitResult> {
|
|
15
|
+
const { kv, key, maxRequests, windowSeconds } = opts;
|
|
16
|
+
|
|
17
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
18
|
+
const windowStart = Math.floor(nowSeconds / windowSeconds) * windowSeconds;
|
|
19
|
+
const resetAt = (windowStart + windowSeconds) * 1000;
|
|
20
|
+
const kvKey = `rl:${key}:${windowStart}`;
|
|
21
|
+
|
|
22
|
+
const raw = await kv.get(kvKey);
|
|
23
|
+
let count = raw ? parseInt(raw, 10) : 0;
|
|
24
|
+
|
|
25
|
+
if (count >= maxRequests) {
|
|
26
|
+
return { allowed: false, remaining: 0, resetAt };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
count += 1;
|
|
30
|
+
await kv.put(kvKey, String(count), { expirationTtl: windowSeconds * 2 });
|
|
31
|
+
|
|
32
|
+
return { allowed: true, remaining: maxRequests - count, resetAt };
|
|
33
|
+
}
|
package/src/sms.test.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { TermiiProvider, createSmsProvider, sendTermiiSms } from './sms';
|
|
3
|
+
|
|
4
|
+
const mockFetch = vi.fn();
|
|
5
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockFetch.mockReset();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
function makeTermiiResponse(ok: boolean, body: Record<string, unknown>) {
|
|
12
|
+
return Promise.resolve({
|
|
13
|
+
ok,
|
|
14
|
+
json: () => Promise.resolve(body),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('TermiiProvider', () => {
|
|
19
|
+
const provider = new TermiiProvider('test-api-key', 'WebWaka');
|
|
20
|
+
|
|
21
|
+
it('sendOtp succeeds via whatsapp on first attempt', async () => {
|
|
22
|
+
mockFetch.mockResolvedValueOnce({
|
|
23
|
+
ok: true,
|
|
24
|
+
json: () => Promise.resolve({ message_id: 'msg-1' }),
|
|
25
|
+
});
|
|
26
|
+
const result = await provider.sendOtp('2348012345678', 'Your OTP is 123456');
|
|
27
|
+
expect(result.success).toBe(true);
|
|
28
|
+
expect(result.messageId).toBe('msg-1');
|
|
29
|
+
expect(result.channel).toBe('whatsapp');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('sendOtp falls back to sms when whatsapp fails', async () => {
|
|
33
|
+
// First call (whatsapp) fails
|
|
34
|
+
mockFetch.mockResolvedValueOnce({
|
|
35
|
+
ok: false,
|
|
36
|
+
json: () => Promise.resolve({ message: 'whatsapp unavailable' }),
|
|
37
|
+
});
|
|
38
|
+
// Second call (sms fallback) succeeds
|
|
39
|
+
mockFetch.mockResolvedValueOnce({
|
|
40
|
+
ok: true,
|
|
41
|
+
json: () => Promise.resolve({ message_id: 'msg-sms-1' }),
|
|
42
|
+
});
|
|
43
|
+
const result = await provider.sendOtp('2348012345678', 'Your OTP is 654321');
|
|
44
|
+
expect(result.success).toBe(true);
|
|
45
|
+
expect(result.channel).toBe('sms');
|
|
46
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('sendOtp does not retry when channel is sms and it fails', async () => {
|
|
50
|
+
mockFetch.mockResolvedValueOnce({
|
|
51
|
+
ok: false,
|
|
52
|
+
json: () => Promise.resolve({ message: 'sms failed' }),
|
|
53
|
+
});
|
|
54
|
+
const result = await provider.sendOtp('2348012345678', 'OTP', 'sms');
|
|
55
|
+
expect(result.success).toBe(false);
|
|
56
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('sendMessage calls sendOtp with whatsapp channel', async () => {
|
|
60
|
+
mockFetch.mockResolvedValueOnce({
|
|
61
|
+
ok: true,
|
|
62
|
+
json: () => Promise.resolve({ message_id: 'msg-2' }),
|
|
63
|
+
});
|
|
64
|
+
const result = await provider.sendMessage('2348012345678', 'Hello');
|
|
65
|
+
expect(result.success).toBe(true);
|
|
66
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
67
|
+
expect(body.channel).toBe('whatsapp');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns error when fetch throws', async () => {
|
|
71
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
72
|
+
// whatsapp fails with network error → falls back to sms
|
|
73
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
74
|
+
const result = await provider.sendOtp('2348012345678', 'OTP');
|
|
75
|
+
expect(result.success).toBe(false);
|
|
76
|
+
expect(result.error).toBe('Network error');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('uses whatsapp_business channel as whatsapp in Termii API', async () => {
|
|
80
|
+
mockFetch.mockResolvedValueOnce({
|
|
81
|
+
ok: true,
|
|
82
|
+
json: () => Promise.resolve({ message_id: 'msg-wb' }),
|
|
83
|
+
});
|
|
84
|
+
await provider.sendOtp('2348012345678', 'OTP', 'whatsapp_business');
|
|
85
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
86
|
+
expect(body.channel).toBe('whatsapp');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('createSmsProvider', () => {
|
|
91
|
+
it('returns a TermiiProvider instance', () => {
|
|
92
|
+
const p = createSmsProvider('key-123');
|
|
93
|
+
expect(p).toBeInstanceOf(TermiiProvider);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('accepts custom senderId', () => {
|
|
97
|
+
const p = createSmsProvider('key-123', 'MyApp') as TermiiProvider;
|
|
98
|
+
expect(p).toBeInstanceOf(TermiiProvider);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('sendTermiiSms', () => {
|
|
103
|
+
it('sends a message and returns result', async () => {
|
|
104
|
+
mockFetch.mockResolvedValueOnce({
|
|
105
|
+
ok: true,
|
|
106
|
+
json: () => Promise.resolve({ message_id: 'msg-3' }),
|
|
107
|
+
});
|
|
108
|
+
const result = await sendTermiiSms('2348012345678', 'Test message', 'api-key');
|
|
109
|
+
expect(result.success).toBe(true);
|
|
110
|
+
expect(result.messageId).toBe('msg-3');
|
|
111
|
+
});
|
|
112
|
+
});
|
package/src/sms.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export type OtpChannel = 'sms' | 'whatsapp' | 'whatsapp_business';
|
|
2
|
+
|
|
3
|
+
export interface OtpResult {
|
|
4
|
+
success: boolean;
|
|
5
|
+
messageId?: string;
|
|
6
|
+
channel: OtpChannel;
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ISmsProvider {
|
|
11
|
+
sendOtp(to: string, message: string, channel?: OtpChannel): Promise<OtpResult>;
|
|
12
|
+
sendMessage(to: string, message: string): Promise<OtpResult>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class TermiiProvider implements ISmsProvider {
|
|
16
|
+
private apiKey: string;
|
|
17
|
+
private senderId: string;
|
|
18
|
+
private apiUrl = 'https://api.ng.termii.com/api/sms/send';
|
|
19
|
+
|
|
20
|
+
constructor(apiKey: string, senderId = 'WebWaka') {
|
|
21
|
+
this.apiKey = apiKey;
|
|
22
|
+
this.senderId = senderId;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async sendOtp(to: string, message: string, channel: OtpChannel = 'whatsapp'): Promise<OtpResult> {
|
|
26
|
+
const result = await this.deliver(to, message, channel);
|
|
27
|
+
if (!result.success && channel !== 'sms') {
|
|
28
|
+
return this.deliver(to, message, 'sms');
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async sendMessage(to: string, message: string): Promise<OtpResult> {
|
|
34
|
+
return this.sendOtp(to, message, 'whatsapp');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async deliver(to: string, message: string, channel: OtpChannel): Promise<OtpResult> {
|
|
38
|
+
const termiiChannel =
|
|
39
|
+
channel === 'whatsapp' || channel === 'whatsapp_business' ? 'whatsapp' : 'generic';
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(this.apiUrl, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
to,
|
|
47
|
+
from: this.senderId,
|
|
48
|
+
sms: message,
|
|
49
|
+
type: 'plain',
|
|
50
|
+
channel: termiiChannel,
|
|
51
|
+
api_key: this.apiKey,
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
54
|
+
const data = (await res.json()) as any;
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
channel,
|
|
59
|
+
error: data?.message ?? 'Termii request failed',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
success: true,
|
|
64
|
+
messageId: data?.message_id ?? data?.pinId,
|
|
65
|
+
channel,
|
|
66
|
+
};
|
|
67
|
+
} catch (err: any) {
|
|
68
|
+
return { success: false, channel, error: err?.message };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createSmsProvider(apiKey: string, senderId?: string): ISmsProvider {
|
|
74
|
+
return new TermiiProvider(apiKey, senderId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function sendTermiiSms(
|
|
78
|
+
to: string,
|
|
79
|
+
message: string,
|
|
80
|
+
apiKey: string,
|
|
81
|
+
senderId?: string
|
|
82
|
+
): Promise<OtpResult> {
|
|
83
|
+
const provider = createSmsProvider(apiKey, senderId);
|
|
84
|
+
return provider.sendMessage(to, message);
|
|
85
|
+
}
|
package/src/tax.test.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { TaxEngine, createTaxEngine } from './tax.js';
|
|
3
|
+
|
|
4
|
+
describe('TaxEngine.compute()', () => {
|
|
5
|
+
it('applies VAT to all items when none are exempt', () => {
|
|
6
|
+
const engine = createTaxEngine({
|
|
7
|
+
vatRate: 0.075,
|
|
8
|
+
vatRegistered: true,
|
|
9
|
+
exemptCategories: [],
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const result = engine.compute([
|
|
13
|
+
{ category: 'food', amountKobo: 10_000 },
|
|
14
|
+
{ category: 'electronics', amountKobo: 50_000 },
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
expect(result.subtotalKobo).toBe(60_000);
|
|
18
|
+
expect(result.vatKobo).toBe(Math.round(10_000 * 0.075) + Math.round(50_000 * 0.075));
|
|
19
|
+
expect(result.totalKobo).toBe(result.subtotalKobo + result.vatKobo);
|
|
20
|
+
expect(result.vatBreakdown).toHaveLength(2);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('skips VAT for exempt categories', () => {
|
|
24
|
+
const engine = createTaxEngine({
|
|
25
|
+
vatRate: 0.075,
|
|
26
|
+
vatRegistered: true,
|
|
27
|
+
exemptCategories: ['food', 'medicine'],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const result = engine.compute([
|
|
31
|
+
{ category: 'food', amountKobo: 10_000 },
|
|
32
|
+
{ category: 'electronics', amountKobo: 20_000 },
|
|
33
|
+
{ category: 'medicine', amountKobo: 5_000 },
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const foodBreakdown = result.vatBreakdown.find((b) => b.category === 'food');
|
|
37
|
+
const medicineBreakdown = result.vatBreakdown.find((b) => b.category === 'medicine');
|
|
38
|
+
const electronicsBreakdown = result.vatBreakdown.find((b) => b.category === 'electronics');
|
|
39
|
+
|
|
40
|
+
expect(foodBreakdown?.vatKobo).toBe(0);
|
|
41
|
+
expect(medicineBreakdown?.vatKobo).toBe(0);
|
|
42
|
+
expect(electronicsBreakdown?.vatKobo).toBe(Math.round(20_000 * 0.075));
|
|
43
|
+
expect(result.subtotalKobo).toBe(35_000);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('skips all VAT when vatRegistered is false', () => {
|
|
47
|
+
const engine = createTaxEngine({
|
|
48
|
+
vatRate: 0.075,
|
|
49
|
+
vatRegistered: false,
|
|
50
|
+
exemptCategories: [],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const result = engine.compute([
|
|
54
|
+
{ category: 'electronics', amountKobo: 100_000 },
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
expect(result.vatKobo).toBe(0);
|
|
58
|
+
expect(result.totalKobo).toBe(100_000);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rounds kobo values correctly', () => {
|
|
62
|
+
const engine = new TaxEngine({
|
|
63
|
+
vatRate: 0.075,
|
|
64
|
+
vatRegistered: true,
|
|
65
|
+
exemptCategories: [],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = engine.compute([{ category: 'misc', amountKobo: 1 }]);
|
|
69
|
+
expect(result.vatKobo).toBe(Math.round(1 * 0.075));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns zero totals for empty items', () => {
|
|
73
|
+
const engine = createTaxEngine({
|
|
74
|
+
vatRate: 0.075,
|
|
75
|
+
vatRegistered: true,
|
|
76
|
+
exemptCategories: [],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const result = engine.compute([]);
|
|
80
|
+
expect(result.subtotalKobo).toBe(0);
|
|
81
|
+
expect(result.vatKobo).toBe(0);
|
|
82
|
+
expect(result.totalKobo).toBe(0);
|
|
83
|
+
expect(result.vatBreakdown).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/tax.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface TaxConfig {
|
|
2
|
+
vatRate: number;
|
|
3
|
+
vatRegistered: boolean;
|
|
4
|
+
exemptCategories: string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface TaxLineItem {
|
|
8
|
+
category: string;
|
|
9
|
+
amountKobo: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TaxResult {
|
|
13
|
+
subtotalKobo: number;
|
|
14
|
+
vatKobo: number;
|
|
15
|
+
totalKobo: number;
|
|
16
|
+
vatBreakdown: { category: string; vatKobo: number }[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class TaxEngine {
|
|
20
|
+
private config: TaxConfig;
|
|
21
|
+
|
|
22
|
+
constructor(config: TaxConfig) {
|
|
23
|
+
this.config = config;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
compute(items: TaxLineItem[]): TaxResult {
|
|
27
|
+
let subtotalKobo = 0;
|
|
28
|
+
let vatKobo = 0;
|
|
29
|
+
const vatBreakdown: { category: string; vatKobo: number }[] = [];
|
|
30
|
+
|
|
31
|
+
for (const item of items) {
|
|
32
|
+
subtotalKobo += item.amountKobo;
|
|
33
|
+
|
|
34
|
+
const isExempt =
|
|
35
|
+
!this.config.vatRegistered ||
|
|
36
|
+
this.config.exemptCategories.includes(item.category);
|
|
37
|
+
|
|
38
|
+
const itemVat = isExempt
|
|
39
|
+
? 0
|
|
40
|
+
: Math.round(item.amountKobo * this.config.vatRate);
|
|
41
|
+
|
|
42
|
+
vatKobo += itemVat;
|
|
43
|
+
vatBreakdown.push({ category: item.category, vatKobo: itemVat });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
subtotalKobo,
|
|
48
|
+
vatKobo,
|
|
49
|
+
totalKobo: subtotalKobo + vatKobo,
|
|
50
|
+
vatBreakdown,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createTaxEngine(config: TaxConfig): TaxEngine {
|
|
56
|
+
return new TaxEngine(config);
|
|
57
|
+
}
|