@reevit/react 0.5.8 → 0.6.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/README.md +15 -1
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +148 -81
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +148 -81
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Unified Payment Widget for React Applications. Accept card and mobile money paym
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install @reevit/react
|
|
8
|
+
npm install @reevit/react
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Quick Start
|
|
@@ -23,6 +23,7 @@ function App() {
|
|
|
23
23
|
amount={10000} // Amount in smallest unit (e.g., pesewas for GHS)
|
|
24
24
|
currency="GHS"
|
|
25
25
|
email="customer@example.com"
|
|
26
|
+
idempotencyKey={`order_${Date.now()}`}
|
|
26
27
|
onSuccess={(result) => {
|
|
27
28
|
console.log('Payment success!', result);
|
|
28
29
|
alert(`Payment of ${result.currency} ${result.amount/100} successful!`);
|
|
@@ -37,6 +38,19 @@ function App() {
|
|
|
37
38
|
}
|
|
38
39
|
```
|
|
39
40
|
|
|
41
|
+
## Idempotency
|
|
42
|
+
|
|
43
|
+
Provide an `idempotencyKey` tied to your order/cart to avoid duplicate intent creation and enable safe retries.
|
|
44
|
+
|
|
45
|
+
```tsx
|
|
46
|
+
<ReevitCheckout
|
|
47
|
+
publicKey="pk_test_your_key"
|
|
48
|
+
amount={10000}
|
|
49
|
+
currency="GHS"
|
|
50
|
+
idempotencyKey="order_12345"
|
|
51
|
+
/>
|
|
52
|
+
```
|
|
53
|
+
|
|
40
54
|
## Payment Links
|
|
41
55
|
|
|
42
56
|
If you have a hosted payment link, pass the link code. The widget will create the payment intent from the public link endpoint.
|
package/dist/index.d.mts
CHANGED
|
@@ -22,6 +22,8 @@ interface ReevitCheckoutConfig {
|
|
|
22
22
|
customerName?: string;
|
|
23
23
|
/** Unique reference for this transaction */
|
|
24
24
|
reference?: string;
|
|
25
|
+
/** Optional idempotency key to safely retry or dedupe intent creation */
|
|
26
|
+
idempotencyKey?: string;
|
|
25
27
|
/** Additional metadata to attach to the payment */
|
|
26
28
|
metadata?: Record<string, unknown>;
|
|
27
29
|
/** Custom fields for payment links (if applicable) */
|
package/dist/index.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ interface ReevitCheckoutConfig {
|
|
|
22
22
|
customerName?: string;
|
|
23
23
|
/** Unique reference for this transaction */
|
|
24
24
|
reference?: string;
|
|
25
|
+
/** Optional idempotency key to safely retry or dedupe intent creation */
|
|
26
|
+
idempotencyKey?: string;
|
|
25
27
|
/** Additional metadata to attach to the payment */
|
|
26
28
|
metadata?: Record<string, unknown>;
|
|
27
29
|
/** Custom fields for payment links (if applicable) */
|
package/dist/index.js
CHANGED
|
@@ -180,7 +180,7 @@ var ReevitAPIClient = class {
|
|
|
180
180
|
const headers = {
|
|
181
181
|
"Content-Type": "application/json",
|
|
182
182
|
"X-Reevit-Client": "@reevit/react",
|
|
183
|
-
"X-Reevit-Client-Version": "0.
|
|
183
|
+
"X-Reevit-Client-Version": "0.5.9"
|
|
184
184
|
};
|
|
185
185
|
if (this.publicKey) {
|
|
186
186
|
headers["X-Reevit-Key"] = this.publicKey;
|
|
@@ -259,7 +259,7 @@ var ReevitAPIClient = class {
|
|
|
259
259
|
allowed_providers: options?.allowedProviders
|
|
260
260
|
};
|
|
261
261
|
}
|
|
262
|
-
const idempotencyKey = generateIdempotencyKey({
|
|
262
|
+
const idempotencyKey = config.idempotencyKey || generateIdempotencyKey({
|
|
263
263
|
amount: config.amount,
|
|
264
264
|
currency: config.currency,
|
|
265
265
|
customer: config.email || config.metadata?.customerId || "",
|
|
@@ -438,6 +438,72 @@ function normalizeBranding(branding) {
|
|
|
438
438
|
setIf("selectedBorderColor", getString(raw.selectedBorderColor ?? raw.selected_border_color));
|
|
439
439
|
return theme;
|
|
440
440
|
}
|
|
441
|
+
var INTENT_CACHE_TTL_MS = 10 * 60 * 1e3;
|
|
442
|
+
var intentCache = /* @__PURE__ */ new Map();
|
|
443
|
+
function pruneIntentCache(now = Date.now()) {
|
|
444
|
+
for (const [key, entry] of intentCache) {
|
|
445
|
+
if (entry.expiresAt <= now) {
|
|
446
|
+
intentCache.delete(key);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function getIntentCacheEntry(key) {
|
|
451
|
+
const entry = intentCache.get(key);
|
|
452
|
+
if (!entry) {
|
|
453
|
+
return void 0;
|
|
454
|
+
}
|
|
455
|
+
if (entry.expiresAt <= Date.now()) {
|
|
456
|
+
intentCache.delete(key);
|
|
457
|
+
return void 0;
|
|
458
|
+
}
|
|
459
|
+
return entry;
|
|
460
|
+
}
|
|
461
|
+
function setIntentCacheEntry(key, update) {
|
|
462
|
+
const now = Date.now();
|
|
463
|
+
const existing = getIntentCacheEntry(key);
|
|
464
|
+
const next = {
|
|
465
|
+
...existing,
|
|
466
|
+
...update,
|
|
467
|
+
expiresAt: now + INTENT_CACHE_TTL_MS
|
|
468
|
+
};
|
|
469
|
+
intentCache.set(key, next);
|
|
470
|
+
return next;
|
|
471
|
+
}
|
|
472
|
+
function clearIntentCacheEntry(key) {
|
|
473
|
+
intentCache.delete(key);
|
|
474
|
+
}
|
|
475
|
+
function buildIdempotencyPayload(config, method, options) {
|
|
476
|
+
const payload = {
|
|
477
|
+
amount: config.amount,
|
|
478
|
+
currency: config.currency,
|
|
479
|
+
email: config.email || "",
|
|
480
|
+
phone: config.phone || "",
|
|
481
|
+
customerName: config.customerName || "",
|
|
482
|
+
paymentLinkCode: config.paymentLinkCode || "",
|
|
483
|
+
paymentMethods: config.paymentMethods || [],
|
|
484
|
+
metadata: config.metadata || {},
|
|
485
|
+
customFields: config.customFields || {},
|
|
486
|
+
method: method || "",
|
|
487
|
+
preferredProvider: options?.preferredProvider || "",
|
|
488
|
+
allowedProviders: options?.allowedProviders || [],
|
|
489
|
+
publicKey: config.publicKey || ""
|
|
490
|
+
};
|
|
491
|
+
if (config.reference) {
|
|
492
|
+
payload.reference = config.reference;
|
|
493
|
+
}
|
|
494
|
+
return payload;
|
|
495
|
+
}
|
|
496
|
+
function resolveIntentIdentity(config, method, options) {
|
|
497
|
+
pruneIntentCache();
|
|
498
|
+
const idempotencyKey = config.idempotencyKey || generateIdempotencyKey(buildIdempotencyPayload(config, method, options));
|
|
499
|
+
const existing = getIntentCacheEntry(idempotencyKey);
|
|
500
|
+
const reference = config.reference || existing?.reference || generateReference();
|
|
501
|
+
const cacheEntry = setIntentCacheEntry(idempotencyKey, { reference });
|
|
502
|
+
return { idempotencyKey, reference, cacheEntry };
|
|
503
|
+
}
|
|
504
|
+
function isPaymentError(error) {
|
|
505
|
+
return typeof error === "object" && error !== null && "code" in error && "message" in error;
|
|
506
|
+
}
|
|
441
507
|
function mapToPaymentIntent(response, config) {
|
|
442
508
|
return {
|
|
443
509
|
id: response.id,
|
|
@@ -471,14 +537,22 @@ function useReevit(options) {
|
|
|
471
537
|
selectedMethod: config.initialPaymentIntent?.availableMethods?.length === 1 ? config.initialPaymentIntent.availableMethods[0] : null
|
|
472
538
|
});
|
|
473
539
|
const apiClientRef = react.useRef(null);
|
|
474
|
-
const
|
|
540
|
+
const stateRef = react.useRef(state);
|
|
541
|
+
react.useEffect(() => {
|
|
542
|
+
stateRef.current = state;
|
|
543
|
+
}, [state]);
|
|
544
|
+
const currentIntentKeyRef = react.useRef(
|
|
545
|
+
config.initialPaymentIntent ? `initial:${config.initialPaymentIntent.id}` : null
|
|
546
|
+
);
|
|
475
547
|
const initRequestIdRef = react.useRef(0);
|
|
476
548
|
react.useEffect(() => {
|
|
477
549
|
if (config.initialPaymentIntent) {
|
|
478
550
|
if (!state.paymentIntent || state.paymentIntent.id !== config.initialPaymentIntent.id) {
|
|
479
551
|
dispatch({ type: "INIT_SUCCESS", payload: config.initialPaymentIntent });
|
|
480
|
-
|
|
552
|
+
currentIntentKeyRef.current = `initial:${config.initialPaymentIntent.id}`;
|
|
481
553
|
}
|
|
554
|
+
} else if (currentIntentKeyRef.current?.startsWith("initial:")) {
|
|
555
|
+
currentIntentKeyRef.current = null;
|
|
482
556
|
}
|
|
483
557
|
}, [config.initialPaymentIntent, state.paymentIntent?.id]);
|
|
484
558
|
if (!apiClientRef.current) {
|
|
@@ -492,61 +566,60 @@ function useReevit(options) {
|
|
|
492
566
|
}, [state.status, onStateChange]);
|
|
493
567
|
const initialize = react.useCallback(
|
|
494
568
|
async (method, options2) => {
|
|
495
|
-
if (
|
|
569
|
+
if (config.initialPaymentIntent) {
|
|
496
570
|
return;
|
|
497
571
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
dispatch({ type: "INIT_START" });
|
|
572
|
+
let requestId = 0;
|
|
573
|
+
let intentKey = null;
|
|
501
574
|
try {
|
|
502
575
|
const apiClient = apiClientRef.current;
|
|
503
576
|
if (!apiClient) {
|
|
504
577
|
throw new Error("API client not initialized");
|
|
505
578
|
}
|
|
506
|
-
const reference = config.reference || generateReference();
|
|
507
579
|
const country = detectCountryFromCurrency(config.currency);
|
|
508
580
|
const defaultMethod = config.paymentMethods && config.paymentMethods.length === 1 ? config.paymentMethods[0] : void 0;
|
|
509
581
|
const paymentMethod = method ?? defaultMethod;
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
582
|
+
const identity = resolveIntentIdentity(config, paymentMethod, options2);
|
|
583
|
+
const { idempotencyKey, reference, cacheEntry } = identity;
|
|
584
|
+
intentKey = idempotencyKey;
|
|
585
|
+
if (currentIntentKeyRef.current === idempotencyKey && stateRef.current.paymentIntent) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
currentIntentKeyRef.current = idempotencyKey;
|
|
589
|
+
requestId = ++initRequestIdRef.current;
|
|
590
|
+
if (stateRef.current.status !== "loading") {
|
|
591
|
+
dispatch({ type: "INIT_START" });
|
|
592
|
+
}
|
|
593
|
+
const requestIntent = async () => {
|
|
594
|
+
if (config.paymentLinkCode) {
|
|
595
|
+
const response = await fetch(
|
|
596
|
+
`${apiBaseUrl || DEFAULT_PUBLIC_API_BASE_URL}/v1/pay/${config.paymentLinkCode}/pay`,
|
|
597
|
+
{
|
|
598
|
+
method: "POST",
|
|
599
|
+
headers: {
|
|
600
|
+
"Content-Type": "application/json",
|
|
601
|
+
"Idempotency-Key": idempotencyKey
|
|
602
|
+
},
|
|
603
|
+
body: JSON.stringify({
|
|
604
|
+
amount: config.amount,
|
|
605
|
+
email: config.email || "",
|
|
606
|
+
name: config.customerName || "",
|
|
607
|
+
phone: config.phone || "",
|
|
608
|
+
method: paymentMethod,
|
|
609
|
+
country,
|
|
610
|
+
provider: options2?.preferredProvider || options2?.allowedProviders?.[0],
|
|
611
|
+
custom_fields: config.customFields
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
);
|
|
615
|
+
const responseData = await response.json().catch(() => ({}));
|
|
616
|
+
if (!response.ok) {
|
|
617
|
+
throw buildPaymentLinkError(response, responseData);
|
|
539
618
|
}
|
|
540
|
-
|
|
541
|
-
const responseData = await response.json().catch(() => ({}));
|
|
542
|
-
if (!response.ok) {
|
|
543
|
-
error = buildPaymentLinkError(response, responseData);
|
|
544
|
-
} else {
|
|
545
|
-
data = responseData;
|
|
619
|
+
return responseData;
|
|
546
620
|
}
|
|
547
|
-
} else {
|
|
548
621
|
const result = await apiClient.createPaymentIntent(
|
|
549
|
-
{ ...config, reference },
|
|
622
|
+
{ ...config, reference, idempotencyKey },
|
|
550
623
|
paymentMethod,
|
|
551
624
|
country,
|
|
552
625
|
{
|
|
@@ -554,35 +627,43 @@ function useReevit(options) {
|
|
|
554
627
|
allowedProviders: options2?.allowedProviders
|
|
555
628
|
}
|
|
556
629
|
);
|
|
557
|
-
|
|
558
|
-
|
|
630
|
+
if (result.error) {
|
|
631
|
+
throw result.error;
|
|
632
|
+
}
|
|
633
|
+
if (!result.data) {
|
|
634
|
+
throw {
|
|
635
|
+
code: "INIT_FAILED",
|
|
636
|
+
message: "No data received from API",
|
|
637
|
+
recoverable: true
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
return result.data;
|
|
641
|
+
};
|
|
642
|
+
let data;
|
|
643
|
+
if (cacheEntry?.response) {
|
|
644
|
+
data = cacheEntry.response;
|
|
645
|
+
} else {
|
|
646
|
+
let intentPromise = cacheEntry?.promise;
|
|
647
|
+
if (!intentPromise) {
|
|
648
|
+
intentPromise = requestIntent();
|
|
649
|
+
setIntentCacheEntry(idempotencyKey, { promise: intentPromise });
|
|
650
|
+
}
|
|
651
|
+
data = await intentPromise;
|
|
652
|
+
setIntentCacheEntry(idempotencyKey, { response: data, promise: void 0 });
|
|
559
653
|
}
|
|
560
654
|
if (requestId !== initRequestIdRef.current) {
|
|
561
655
|
return;
|
|
562
656
|
}
|
|
563
|
-
|
|
564
|
-
dispatch({ type: "INIT_ERROR", payload: error });
|
|
565
|
-
onError?.(error);
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
if (!data) {
|
|
569
|
-
const noDataError = {
|
|
570
|
-
code: "INIT_FAILED",
|
|
571
|
-
message: "No data received from API",
|
|
572
|
-
recoverable: true
|
|
573
|
-
};
|
|
574
|
-
dispatch({ type: "INIT_ERROR", payload: noDataError });
|
|
575
|
-
onError?.(noDataError);
|
|
576
|
-
initializingRef.current = false;
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
const paymentIntent = mapToPaymentIntent(data, { ...config, reference });
|
|
657
|
+
const paymentIntent = mapToPaymentIntent(data, { ...config, reference, idempotencyKey });
|
|
580
658
|
dispatch({ type: "INIT_SUCCESS", payload: paymentIntent });
|
|
581
659
|
} catch (err) {
|
|
660
|
+
if (intentKey) {
|
|
661
|
+
clearIntentCacheEntry(intentKey);
|
|
662
|
+
}
|
|
582
663
|
if (requestId !== initRequestIdRef.current) {
|
|
583
664
|
return;
|
|
584
665
|
}
|
|
585
|
-
const error = {
|
|
666
|
+
const error = isPaymentError(err) ? err : {
|
|
586
667
|
code: "INIT_FAILED",
|
|
587
668
|
message: err instanceof Error ? err.message : "Failed to initialize checkout",
|
|
588
669
|
recoverable: true,
|
|
@@ -590,7 +671,6 @@ function useReevit(options) {
|
|
|
590
671
|
};
|
|
591
672
|
dispatch({ type: "INIT_ERROR", payload: error });
|
|
592
673
|
onError?.(error);
|
|
593
|
-
initializingRef.current = false;
|
|
594
674
|
}
|
|
595
675
|
},
|
|
596
676
|
[config, onError, apiBaseUrl]
|
|
@@ -671,7 +751,7 @@ function useReevit(options) {
|
|
|
671
751
|
} catch {
|
|
672
752
|
}
|
|
673
753
|
}
|
|
674
|
-
|
|
754
|
+
currentIntentKeyRef.current = null;
|
|
675
755
|
initRequestIdRef.current += 1;
|
|
676
756
|
dispatch({ type: "RESET" });
|
|
677
757
|
}, [state.paymentIntent, state.status]);
|
|
@@ -2251,19 +2331,6 @@ function ReevitCheckout({
|
|
|
2251
2331
|
selectMethod(activeProvider.methods[0]);
|
|
2252
2332
|
}
|
|
2253
2333
|
}, [activeProvider, selectedMethod, selectMethod]);
|
|
2254
|
-
react.useEffect(() => {
|
|
2255
|
-
if (isOpen && selectedMethod && paymentIntent && !showPSPBridge) {
|
|
2256
|
-
const psp = (selectedProvider || paymentIntent.recommendedPsp || "paystack").toLowerCase();
|
|
2257
|
-
const needsPhone = psp.includes("mpesa");
|
|
2258
|
-
if (selectedMethod === "card") {
|
|
2259
|
-
setShowPSPBridge(true);
|
|
2260
|
-
} else if (selectedMethod === "mobile_money") {
|
|
2261
|
-
if (!needsPhone || (momoData?.phone || phone)) {
|
|
2262
|
-
setShowPSPBridge(true);
|
|
2263
|
-
}
|
|
2264
|
-
}
|
|
2265
|
-
}
|
|
2266
|
-
}, [isOpen, selectedMethod, showPSPBridge, paymentIntent, momoData, phone, selectedProvider]);
|
|
2267
2334
|
const handleOpen = react.useCallback(() => {
|
|
2268
2335
|
if (controlledIsOpen !== void 0) return;
|
|
2269
2336
|
setIsOpen(true);
|