@wdft/micropayments-sdk 0.0.1 → 0.0.2

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/package.json CHANGED
@@ -1,13 +1,18 @@
1
1
  {
2
2
  "name": "@wdft/micropayments-sdk",
3
- "type": "module",
4
3
  "description": "wdft - we do fintech - micropayments sdk",
5
- "version": "0.0.1",
4
+ "version": "0.0.2",
5
+ "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "files": [
9
- "dist/**/*"
9
+ "dist/**/*",
10
+ "src/**/*"
10
11
  ],
12
+ "exports": {
13
+ ".": "./dist/index.js",
14
+ "./*": "./dist/*.js"
15
+ },
11
16
  "author": "Mateusz Worotyński <mateusz@wdft.ovh>",
12
17
  "license": "MIT",
13
18
  "devDependencies": {
package/src/browser.ts ADDED
@@ -0,0 +1,5 @@
1
+ import QRCode from "qrcode";
2
+
3
+ export async function generateQRCode(text: string) {
4
+ return await QRCode.toDataURL(text, { errorCorrectionLevel: "L" });
5
+ }
@@ -0,0 +1,164 @@
1
+ import { describe, it } from "vitest";
2
+ import { expect } from "vitest";
3
+ import { Client } from "./client.js";
4
+ import { mockFetch } from "./test_utils/mock_fetcher.js";
5
+
6
+ describe("client e2e", () => {
7
+ const mock = mockFetch();
8
+
9
+ const client = Client.new({
10
+ currency: "PLN",
11
+ tenant_id: "my-tenant",
12
+ endpoint: "http://localhost:8787",
13
+ fetcher: mock.fetcher.bind(mock),
14
+ retry_ms: 100,
15
+ });
16
+
17
+ it("initialized -> waiting x2 -> resolved", async () => {
18
+ await new Promise((resolve, reject) => {
19
+ mock.next(200, {
20
+ click_url: "mock_click_url",
21
+ incoming_message_device: "mock_num",
22
+ expires_at: Date.now() + 30_000,
23
+ commitment_id: "sample_commitment_id",
24
+ });
25
+
26
+ mock.next(200, {
27
+ message: "waiting for sms",
28
+ status: "waiting",
29
+ commitment_id: "sample_commitment_id",
30
+ });
31
+
32
+ mock.next(200, {
33
+ message: "waiting for sms",
34
+ status: "waiting",
35
+ commitment_id: "sample_commitment_id",
36
+ });
37
+
38
+ mock.next(200, {
39
+ message: "waiting for sms",
40
+ status: "resolved",
41
+ commitment_id: "sample_commitment_id",
42
+ });
43
+
44
+ // Sync with Events
45
+ client.create("my-ref", 10, (error, payload) => {
46
+ if (!payload || error) {
47
+ console.log(error);
48
+ reject(error);
49
+ return;
50
+ }
51
+
52
+ const { status } = payload;
53
+
54
+ switch (status) {
55
+ case "waiting":
56
+ case "created":
57
+ case "duplicated":
58
+ case "expired":
59
+ console.log(payload);
60
+ break;
61
+ case "resolved":
62
+ resolve(status);
63
+ }
64
+ });
65
+ });
66
+ });
67
+
68
+ it("initialized -> expired", async () => {
69
+ await expect(
70
+ new Promise((resolve, reject) => {
71
+ mock.next(200, {
72
+ click_url: "mock_click_url",
73
+ incoming_message_device: "mock_num",
74
+ expires_at: Date.now() + 30_000,
75
+ commitment_id: "sample_commitment_id",
76
+ });
77
+
78
+ mock.next(200, {
79
+ message: "waiting for sms",
80
+ status: "waiting",
81
+ commitment_id: "sample_commitment_id",
82
+ });
83
+
84
+ mock.next(200, {
85
+ message: "waiting for sms",
86
+ status: "waiting",
87
+ commitment_id: "sample_commitment_id",
88
+ });
89
+
90
+ mock.next(410, {
91
+ message: "commitment expired",
92
+ status: "expired",
93
+ commitment_id: "sample_commitment_id",
94
+ });
95
+
96
+ // Sync with Events
97
+ client.create("my-ref", 10, (error, payload) => {
98
+ if (!payload || error) {
99
+ reject(error);
100
+ return;
101
+ }
102
+
103
+ const { status } = payload;
104
+
105
+ switch (status) {
106
+ case "waiting":
107
+ case "created":
108
+ case "duplicated":
109
+ break;
110
+ case "expired":
111
+ reject(status);
112
+ break;
113
+ case "resolved":
114
+ resolve(status);
115
+ break;
116
+ }
117
+ });
118
+ }),
119
+ ).rejects.toBe("expired");
120
+ });
121
+
122
+ it("initialized -> duplicated", async () => {
123
+ await expect(
124
+ new Promise((resolve, reject) => {
125
+ mock.next(200, {
126
+ click_url: "mock_click_url",
127
+ incoming_message_device: "mock_num",
128
+ expires_at: Date.now() + 30_000,
129
+ commitment_id: "sample_commitment_id",
130
+ });
131
+
132
+ mock.next(200, {
133
+ message: "waiting for sms",
134
+ status: "duplicated",
135
+ commitment_id: "sample_commitment_id",
136
+ });
137
+
138
+ // Sync with Events
139
+ client.create("my-ref", 10, (error, payload) => {
140
+ if (!payload || error) {
141
+ reject(error);
142
+ return;
143
+ }
144
+
145
+ const { status } = payload;
146
+
147
+ switch (status) {
148
+ case "waiting":
149
+ case "created":
150
+ case "duplicated":
151
+ resolve(status);
152
+ break;
153
+ case "expired":
154
+ reject(status);
155
+ break;
156
+ case "resolved":
157
+ resolve(status);
158
+ break;
159
+ }
160
+ });
161
+ }),
162
+ ).resolves.toBe("duplicated");
163
+ });
164
+ });
package/src/client.ts ADDED
@@ -0,0 +1,475 @@
1
+ import { type IServiceOptions, Service } from "./service.js";
2
+
3
+ interface ICommitmentPayload {
4
+ commitment_id: string;
5
+ amount: number;
6
+ currency: string;
7
+ reference_id: string;
8
+ expires_at: number;
9
+ click_url: string;
10
+ retry_ms: number;
11
+ message: string;
12
+ status: CommitmentStatus;
13
+ }
14
+
15
+ export type CommitmentStatus =
16
+ | "initializing"
17
+ | "created"
18
+ | "waiting"
19
+ | "expired"
20
+ | "resolved"
21
+ | "duplicated";
22
+
23
+ export type ICommitmentPayloadSimple = Omit<
24
+ ICommitmentPayload,
25
+ "status" | "message"
26
+ >;
27
+
28
+ class Commitment {
29
+ private readonly payload: ICommitmentPayload;
30
+
31
+ constructor(
32
+ private readonly service: Service,
33
+ payload: ICommitmentPayloadSimple,
34
+ private readonly options: ICommitmentOptions,
35
+ ) {
36
+ this.payload = {
37
+ message: "Creating...",
38
+ status: "initializing",
39
+ ...payload,
40
+ };
41
+
42
+ if (this.options.onCreated) {
43
+ this.options.onCreated(this);
44
+ }
45
+ }
46
+
47
+ isExpired() {
48
+ return Date.now() > this.payload.expires_at;
49
+ }
50
+
51
+ isResolved() {
52
+ return this.getStatus() === "resolved";
53
+ }
54
+
55
+ getStatus(): CommitmentStatus {
56
+ return this.payload.status;
57
+ }
58
+
59
+ getClickUrl() {
60
+ return this.payload.click_url;
61
+ }
62
+
63
+ getAmount() {
64
+ return this.payload.amount;
65
+ }
66
+
67
+ getCurrency() {
68
+ return this.payload.currency;
69
+ }
70
+
71
+ getReferenceId() {
72
+ return this.payload.reference_id;
73
+ }
74
+
75
+ getCommitmentId() {
76
+ return this.payload.commitment_id;
77
+ }
78
+
79
+ markAsCreated() {
80
+ if (this.payload.status === "created") {
81
+ return;
82
+ }
83
+
84
+ this.payload.status = "created";
85
+
86
+ if (this.options.onCreated) {
87
+ this.options.onCreated(this);
88
+ }
89
+ }
90
+
91
+ markAsDuplicated() {
92
+ if (this.payload.status === "duplicated") {
93
+ this.stopChecking();
94
+ return;
95
+ }
96
+
97
+ this.payload.status = "duplicated";
98
+
99
+ this.stopChecking();
100
+ if (this.options.onDuplicated) {
101
+ this.options.onDuplicated(this);
102
+ }
103
+ }
104
+
105
+ markAsWaiting() {
106
+ if (this.payload.status === "waiting") {
107
+ return;
108
+ }
109
+
110
+ this.payload.status = "waiting";
111
+
112
+ if (this.options.onWaiting) {
113
+ this.options.onWaiting(this);
114
+ }
115
+ }
116
+
117
+ markAsExpired() {
118
+ if (this.payload.status === "expired") {
119
+ this.stopChecking();
120
+ return;
121
+ }
122
+
123
+ this.payload.status = "expired";
124
+
125
+ if (this.options.onExpired) {
126
+ this.options.onExpired(this);
127
+ }
128
+
129
+ this.stopChecking();
130
+ }
131
+
132
+ markAsResolved() {
133
+ if (this.payload.status === "resolved") {
134
+ this.stopChecking();
135
+ return;
136
+ }
137
+
138
+ this.payload.status = "resolved";
139
+
140
+ this.stopChecking();
141
+
142
+ if (this.options.onResolved) {
143
+ this.options.onResolved(this);
144
+ }
145
+ }
146
+
147
+ private retries = 0;
148
+ private currentTimer: number | null = null;
149
+
150
+ getNextCallTime() {
151
+ this.retries++;
152
+ return Math.min(
153
+ 5000,
154
+ this.payload.retry_ms + this.payload.retry_ms * (this.retries / 10),
155
+ );
156
+ }
157
+
158
+ startChecking() {
159
+ if (this.currentCheck) {
160
+ return;
161
+ }
162
+
163
+ this.currentCheck = this.check()
164
+ .then((status) => {
165
+ const nextTime = this.getNextCallTime();
166
+ if (status === "waiting") {
167
+ this.currentTimer = setTimeout(
168
+ () => this.startChecking(),
169
+ nextTime,
170
+ ) as unknown as number;
171
+ }
172
+ return status;
173
+ })
174
+ .finally(() => {
175
+ this.stopChecking(false);
176
+ });
177
+ }
178
+
179
+ stopChecking(withTimer = true) {
180
+ this.currentCheck = null;
181
+ if (this.currentTimer && withTimer) {
182
+ clearTimeout(this.currentTimer);
183
+ }
184
+ }
185
+
186
+ private currentCheck: Promise<CommitmentStatus> | null = null;
187
+
188
+ async check(): Promise<CommitmentStatus> {
189
+ if (this.isExpired()) {
190
+ this.markAsExpired();
191
+ return this.getStatus();
192
+ }
193
+
194
+ if (this.isResolved()) {
195
+ this.markAsResolved();
196
+ return this.getStatus();
197
+ }
198
+
199
+ try {
200
+ const status = await this.service.checkCommitment(this.getCommitmentId());
201
+
202
+ switch (status.status) {
203
+ case "duplicated": {
204
+ this.markAsDuplicated();
205
+ break;
206
+ }
207
+ case "expired": {
208
+ this.markAsExpired();
209
+ break;
210
+ }
211
+ case "resolved": {
212
+ this.markAsResolved();
213
+ break;
214
+ }
215
+ case "waiting": {
216
+ this.markAsWaiting();
217
+ break;
218
+ }
219
+ }
220
+
221
+ if (this.options.onCheck) {
222
+ this.options.onCheck(this);
223
+ }
224
+ } catch (error) {
225
+ if (this.options.onError) {
226
+ this.options.onError(error as Error, this);
227
+ this.stopChecking();
228
+ } else {
229
+ throw error;
230
+ }
231
+ }
232
+
233
+ return this.getStatus();
234
+ }
235
+ }
236
+
237
+ interface IClientOptions {
238
+ currency: string;
239
+ retry_ms?: number;
240
+ }
241
+
242
+ interface INewClientOptions extends IClientOptions, IServiceOptions {}
243
+
244
+ interface ICommitmentOptions {
245
+ currency?: string;
246
+ retry_ms?: number;
247
+ onCreated?: (commitment: Commitment) => void;
248
+ onResolved?: (commitment: Commitment) => void;
249
+ onError?: (error: Error, commitment: Commitment) => void;
250
+ onExpired?: (commitment: Commitment) => void;
251
+ onDuplicated?: (commitment: Commitment) => void;
252
+ onCheck?: (commitment: Commitment) => void;
253
+ onWaiting?: (commitment: Commitment) => void;
254
+ }
255
+
256
+ export class Client {
257
+ static new(opts: INewClientOptions) {
258
+ return new Client(new Service(opts), opts);
259
+ }
260
+
261
+ constructor(
262
+ private readonly svc: Service,
263
+ private readonly options: IClientOptions,
264
+ ) {}
265
+
266
+ async createDirect(
267
+ reference_id: string,
268
+ amount: number,
269
+ options: ICommitmentOptions = {},
270
+ ): Promise<Commitment> {
271
+ const curr = options.currency ?? this.options.currency;
272
+ const response = await this.svc.createCommitment(
273
+ reference_id,
274
+ amount,
275
+ curr,
276
+ );
277
+
278
+ return new Commitment(
279
+ this.svc,
280
+ {
281
+ commitment_id: response.commitment_id,
282
+ amount: amount,
283
+ currency: curr,
284
+ reference_id: reference_id,
285
+ expires_at: response.expires_at,
286
+ click_url: response.click_url,
287
+ retry_ms: this.options.retry_ms ?? 1000,
288
+ },
289
+ options,
290
+ );
291
+ }
292
+
293
+ create(reference_id: string, amount: number, on: ICallback) {
294
+ const builder = this.commitmentBuilder()
295
+ .setAmount(amount)
296
+ .setCurrency(this.options.currency)
297
+ .setReference(reference_id);
298
+
299
+ if (this.options.retry_ms) {
300
+ builder.setRetryTime(this.options.retry_ms);
301
+ }
302
+
303
+ builder
304
+ .onCreated((commitment) => {
305
+ commitment.startChecking();
306
+ return on(null, {
307
+ status: commitment.getStatus(),
308
+ reference_id,
309
+ commitment_id: commitment.getCommitmentId(),
310
+ click_url: commitment.getClickUrl(),
311
+ });
312
+ })
313
+ .onWaiting((commitment) =>
314
+ on(null, {
315
+ status: commitment.getStatus(),
316
+ reference_id,
317
+ commitment_id: commitment.getCommitmentId(),
318
+ click_url: commitment.getClickUrl(),
319
+ }),
320
+ )
321
+ .onDuplicated((commitment) =>
322
+ on(null, {
323
+ status: commitment.getStatus(),
324
+ reference_id,
325
+ commitment_id: commitment.getCommitmentId(),
326
+ click_url: commitment.getClickUrl(),
327
+ }),
328
+ )
329
+ .onExpired((commitment) =>
330
+ on(null, {
331
+ status: commitment.getStatus(),
332
+ reference_id,
333
+ commitment_id: commitment.getCommitmentId(),
334
+ click_url: commitment.getClickUrl(),
335
+ }),
336
+ )
337
+ .onResolved((commitment) =>
338
+ on(null, {
339
+ status: commitment.getStatus(),
340
+ reference_id,
341
+ commitment_id: commitment.getCommitmentId(),
342
+ click_url: commitment.getClickUrl(),
343
+ }),
344
+ )
345
+ .onError((error, commitment) => {
346
+ commitment?.stopChecking();
347
+ on(error, null);
348
+ })
349
+ .exec();
350
+ }
351
+
352
+ commitmentBuilder(): CommitmentObservabileBuilder {
353
+ return new CommitmentObservabileBuilder(this);
354
+ }
355
+ }
356
+
357
+ export interface CallbackPayload {
358
+ status: CommitmentStatus;
359
+ reference_id: string;
360
+ commitment_id: string;
361
+ click_url: string;
362
+ }
363
+
364
+ type OnStatusCallback = (error: null, payload: CallbackPayload) => void;
365
+ type OnStatusErrorCallback = (error: Error, payload: null) => void;
366
+ export type ICallback = OnStatusCallback & OnStatusErrorCallback;
367
+
368
+ class CommitmentObservabileBuilder {
369
+ private _reference_id: string | undefined;
370
+ private _amount: number | undefined;
371
+ private _retry_ms = 1000;
372
+ private _currency: string | undefined;
373
+
374
+ private _onCreatedFn: (commitment: Commitment) => void = () => {};
375
+ private _onResolvedFn: (commitment: Commitment) => void = () => {};
376
+ private _onErrorFn: (error: Error, commitment?: Commitment) => void =
377
+ () => {};
378
+ private _onExpiredFn: (commitment: Commitment) => void = () => {};
379
+ private _onDuplicatedFn: (commitment: Commitment) => void = () => {};
380
+ private _onCheckFn: (commitment: Commitment) => void = () => {};
381
+ private _onWaitingFn: (commitment: Commitment) => void = () => {};
382
+
383
+ constructor(private readonly client: Client) {}
384
+
385
+ setRetryTime(retry: number) {
386
+ this._retry_ms = retry;
387
+ return this;
388
+ }
389
+
390
+ setAmount(amount: number) {
391
+ this._amount = amount;
392
+ return this;
393
+ }
394
+
395
+ setReference(reference: string) {
396
+ this._reference_id = reference;
397
+ return this;
398
+ }
399
+
400
+ setCurrency(currency: string) {
401
+ this._currency = currency;
402
+ return this;
403
+ }
404
+
405
+ onCreated(cb: (commitment: Commitment) => void) {
406
+ this._onCreatedFn = cb;
407
+ return this;
408
+ }
409
+
410
+ onResolved(cb: (commitment: Commitment) => void) {
411
+ this._onResolvedFn = cb;
412
+ return this;
413
+ }
414
+
415
+ onExpired(cb: (commitment: Commitment) => void) {
416
+ this._onExpiredFn = cb;
417
+ return this;
418
+ }
419
+
420
+ onError(cb: (error: Error, commitment?: Commitment) => void) {
421
+ this._onErrorFn = cb;
422
+ return this;
423
+ }
424
+
425
+ onDuplicated(cb: (commitment: Commitment) => void) {
426
+ this._onDuplicatedFn = cb;
427
+ return this;
428
+ }
429
+
430
+ onCheck(cb: (commitment: Commitment) => void) {
431
+ this._onCheckFn = cb;
432
+ return this;
433
+ }
434
+
435
+ onWaiting(cb: (commitment: Commitment) => void) {
436
+ this._onWaitingFn = cb;
437
+ return this;
438
+ }
439
+
440
+ exec() {
441
+ if (!this._reference_id) {
442
+ throw new Error("reference_id must be defined");
443
+ }
444
+
445
+ if (!this._amount) {
446
+ throw new Error("amount must be defined");
447
+ }
448
+
449
+ if (!this._currency) {
450
+ throw new Error("currency must be defined");
451
+ }
452
+
453
+ this.client
454
+ .createDirect(this._reference_id, this._amount, {
455
+ retry_ms: this._retry_ms,
456
+ currency: this._currency,
457
+ onCheck: this._onCheckFn,
458
+ onCreated: this._onCreatedFn,
459
+ onDuplicated: this._onDuplicatedFn,
460
+ onError: this._onErrorFn,
461
+ onExpired: this._onExpiredFn,
462
+ onWaiting: this._onWaitingFn,
463
+ onResolved: this._onResolvedFn,
464
+ })
465
+ .catch((error) => {
466
+ if (this._onErrorFn) {
467
+ this._onErrorFn(error);
468
+ } else {
469
+ throw error;
470
+ }
471
+ });
472
+ }
473
+ }
474
+
475
+ export type { Commitment };