@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 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@0.5.0
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.3.2"
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 initializingRef = react.useRef(!!config.initialPaymentIntent);
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
- initializingRef.current = true;
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 (initializingRef.current) {
569
+ if (config.initialPaymentIntent) {
496
570
  return;
497
571
  }
498
- initializingRef.current = true;
499
- const requestId = ++initRequestIdRef.current;
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
- let data;
511
- let error;
512
- if (config.paymentLinkCode) {
513
- const idempotencyKey = generateIdempotencyKey({
514
- paymentLinkCode: config.paymentLinkCode,
515
- amount: config.amount,
516
- email: config.email || "",
517
- phone: config.phone || "",
518
- method: paymentMethod || "",
519
- provider: options2?.preferredProvider || options2?.allowedProviders?.[0] || ""
520
- });
521
- const response = await fetch(
522
- `${apiBaseUrl || DEFAULT_PUBLIC_API_BASE_URL}/v1/pay/${config.paymentLinkCode}/pay`,
523
- {
524
- method: "POST",
525
- headers: {
526
- "Content-Type": "application/json",
527
- "Idempotency-Key": idempotencyKey
528
- },
529
- body: JSON.stringify({
530
- amount: config.amount,
531
- email: config.email || "",
532
- name: config.customerName || "",
533
- phone: config.phone || "",
534
- method: paymentMethod,
535
- country,
536
- provider: options2?.preferredProvider || options2?.allowedProviders?.[0],
537
- custom_fields: config.customFields
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
- data = result.data;
558
- error = result.error;
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
- if (error) {
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
- initializingRef.current = false;
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);