@reevit/core 0.9.0 → 0.9.1

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
@@ -8,6 +8,25 @@ The foundation for all Reevit payment SDKs. It provides the shared API client, s
8
8
  npm install @reevit/core
9
9
  ```
10
10
 
11
+ ## Compatibility
12
+
13
+ `@reevit/core` is a regular `dependencies` entry of each framework SDK, so it is
14
+ installed for you. Install it directly only when you are building against the
15
+ low-level API.
16
+
17
+ | `@reevit/core` | Required by |
18
+ |---|---|
19
+ | 0.9.x | `@reevit/react` 0.9.x–0.10.x, `@reevit/vue` 0.9.x–0.10.x, `@reevit/svelte` 0.9.x–0.10.x |
20
+
21
+ 0.9.1 is a patch release on purpose: it carries the idempotency-key and
22
+ zero-decimal-currency fixes, and the framework SDKs pick it up through their
23
+ existing `^0.9.0` range without a coordinated bump.
24
+
25
+ On a `0.x` package a caret range pins the **minor**, not the major:
26
+ `^0.9.0` resolves to `>=0.9.0 <0.10.0`. A future `@reevit/core` 0.10.0 is
27
+ therefore not picked up automatically — the React, Vue and Svelte manifests
28
+ have to be bumped together in the same release.
29
+
11
30
  ## Features
12
31
 
13
32
  - **ReevitAPIClient**: A lightweight, promise-based client for interacting with the Reevit backend.
@@ -74,11 +93,30 @@ if (result.error) {
74
93
  ```typescript
75
94
  import { formatAmount, validatePhone, detectNetwork } from '@reevit/core';
76
95
 
77
- console.log(formatAmount(10000, 'GHS')); // "GH₵ 100.00"
96
+ console.log(formatAmount(10000, 'GHS')); // "GH₵100.00"
78
97
  console.log(validatePhone('0241234567')); // true
79
98
  console.log(detectNetwork('0241234567')); // "mtn"
80
99
  ```
81
100
 
101
+ ### Amounts and currency exponents
102
+
103
+ Amounts are always integers in the smallest unit of the currency, and that unit
104
+ is **not** always 1/100. GHS, NGN and USD have two decimals; XOF, XAF, RWF, UGX,
105
+ JPY and KRW have none — 5,000 XOF is 5000, not 500000.
106
+
107
+ ```typescript
108
+ import { currencyExponent, formatAmount, toMinorUnits } from '@reevit/core';
109
+
110
+ currencyExponent('GHS'); // 2
111
+ currencyExponent('XOF'); // 0
112
+
113
+ formatAmount(4500, 'GHS'); // "GH₵45.00"
114
+ formatAmount(5000, 'XOF'); // "F CFA 5,000" (not "XOF 50.00")
115
+
116
+ toMinorUnits(45, 'GHS'); // 4500
117
+ toMinorUnits(5000, 'XOF'); // 5000
118
+ ```
119
+
82
120
  ### Intent Identity & Idempotency
83
121
 
84
122
  Core exports helpers to stabilize intent creation and dedupe in-flight requests.
@@ -86,7 +124,7 @@ Core exports helpers to stabilize intent creation and dedupe in-flight requests.
86
124
  ```typescript
87
125
  import { resolveIntentIdentity } from '@reevit/core';
88
126
 
89
- const { idempotencyKey, reference } = resolveIntentIdentity({
127
+ const { idempotencyKey, lookupKey, reference } = resolveIntentIdentity({
90
128
  config: {
91
129
  amount: 5000,
92
130
  currency: 'GHS',
@@ -97,8 +135,34 @@ const { idempotencyKey, reference } = resolveIntentIdentity({
97
135
  });
98
136
  ```
99
137
 
138
+ **Pass your own order-scoped `idempotencyKey` for retry safety across page
139
+ loads.** Without one, the SDK generates a per-tab attempt key: a UUID minted on
140
+ the first request and kept in `sessionStorage`, so a repeated "Continue" click
141
+ in the same tab is deduped by the API, while a reload or a different shopper
142
+ starts a new attempt.
143
+
144
+ Two keys are in play and they must not be confused:
145
+
146
+ | | Value | Where it goes |
147
+ |---|---|---|
148
+ | `idempotencyKey` | UUID from `newIdempotencyKey()` / `attemptIdempotencyKey()`, or the one you supplied | the `Idempotency-Key` request header |
149
+ | `lookupKey` | deterministic djb2 hash from `generateIdempotencyKey()` | local in-flight cache only — **never** the wire |
150
+
151
+ `generateIdempotencyKey()` stays exported for existing callers, but a 32-bit
152
+ hash is not safe as a wire key: two unrelated shoppers can collide and be handed
153
+ each other's `client_secret`. Use `newIdempotencyKey()` if you need to mint one
154
+ yourself, and `clearIdempotencyAttemptKeys()` to start a fresh attempt after a
155
+ completed checkout.
156
+
100
157
  ## Release Notes
101
158
 
159
+ ### v0.9.1
160
+
161
+ - The wire `Idempotency-Key` is now a per-attempt UUID instead of a djb2 hash
162
+ - Zero-decimal currencies (XOF, XAF, RWF, UGX, JPY, …) are no longer divided by 100
163
+ - Added `currencyExponent`, `toMinorUnits`, `newIdempotencyKey`, `attemptIdempotencyKey`, `clearIdempotencyAttemptKeys`
164
+ - First test suite for this package; CI runs `npm test`
165
+
102
166
  ### v0.9.0
103
167
 
104
168
  - Version alignment across all Reevit SDKs
package/dist/index.d.mts CHANGED
@@ -331,11 +331,43 @@ interface ReevitAPIClientConfig {
331
331
  declare function createPaymentError(response: Response, errorData: APIErrorResponse): PaymentError;
332
332
  declare function isPaymentError(error: unknown): error is PaymentError;
333
333
  /**
334
- * Generates a deterministic idempotency key based on input parameters
335
- * Uses a simple hash function suitable for browser environments
336
- * Exported for use by SDK hooks (e.g., payment link flows)
334
+ * Generates a deterministic **cache/lookup** key from input parameters.
335
+ *
336
+ * NEVER SEND THIS ON THE WIRE. It is a 32-bit djb2 hash bucketed into
337
+ * 5-minute windows, so two unrelated shoppers can collide and be handed each
338
+ * other's payment intent (and therefore each other's `client_secret`), and a
339
+ * shopper legitimately buying the same item twice inside one window would be
340
+ * charged once. Its only job is to identify "the same checkout attempt" inside
341
+ * a single browser tab so the in-flight intent cache can dedupe a repeated
342
+ * "Continue" click.
343
+ *
344
+ * The value actually sent as `Idempotency-Key` is produced by
345
+ * {@link newIdempotencyKey} / {@link attemptIdempotencyKey}.
346
+ *
347
+ * Exported for use by SDK hooks (e.g. payment link flows).
337
348
  */
338
349
  declare function generateIdempotencyKey(params: Record<string, unknown>): string;
350
+ /**
351
+ * Generates a fresh, globally unique `Idempotency-Key` (RFC 4122 v4 UUID).
352
+ * This is the only value that should ever be sent on the wire.
353
+ */
354
+ declare function newIdempotencyKey(): string;
355
+ /**
356
+ * Resolves the stable per-checkout-attempt wire key for a deterministic
357
+ * lookup key (see {@link generateIdempotencyKey}).
358
+ *
359
+ * The first call for a lookup key mints a UUID and stores it in
360
+ * `sessionStorage` (falling back to a module-level map when storage is
361
+ * unavailable); every later call in the same tab returns that same UUID, so a
362
+ * repeated "Continue" click is still deduped by the backend. A different tab,
363
+ * a different shopper or a cleared store yields a different UUID.
364
+ */
365
+ declare function attemptIdempotencyKey(lookupKey: string): string;
366
+ /**
367
+ * Forgets every stored per-attempt key, so the next checkout attempt gets a
368
+ * fresh `Idempotency-Key`. Call it after a completed checkout (and in tests).
369
+ */
370
+ declare function clearIdempotencyAttemptKeys(): void;
339
371
  /**
340
372
  * Reevit API Client
341
373
  */
@@ -418,6 +450,19 @@ declare function createReevitClient(config: ReevitAPIClientConfig): ReevitAPICli
418
450
  * Shared utilities for Reevit SDKs
419
451
  */
420
452
 
453
+ /**
454
+ * Returns how many decimal places a currency's minor unit uses: 2 for GHS and
455
+ * NGN, 0 for XOF, XAF, RWF, UGX, JPY and friends.
456
+ *
457
+ * Dividing every amount by 100 renders a 5,000 XOF charge as "XOF 50.00" while
458
+ * the shopper is actually charged 5,000 — which is why this exists.
459
+ */
460
+ declare function currencyExponent(currency: string): number;
461
+ /**
462
+ * Converts a major-unit amount (what a shopper types) into the minor units the
463
+ * API expects: `toMinorUnits(45, 'GHS') === 4500`, `toMinorUnits(5000, 'XOF') === 5000`.
464
+ */
465
+ declare function toMinorUnits(major: number, currency: string): number;
421
466
  /**
422
467
  * Formats an amount from smallest currency unit to display format
423
468
  */
@@ -453,6 +498,18 @@ declare function detectCountryFromCurrency(currency: string): string;
453
498
 
454
499
  /**
455
500
  * Intent identity + cache helpers
501
+ *
502
+ * Two different keys are in play here and mixing them up is a money bug:
503
+ *
504
+ * - the **lookup key** is the deterministic djb2 hash of the checkout
505
+ * parameters (`generateIdempotencyKey`). It identifies "the same checkout
506
+ * attempt" for the in-flight cache and is never sent to the API.
507
+ * - the **wire key** is the per-attempt UUID (`attemptIdempotencyKey`) that
508
+ * goes out as the `Idempotency-Key` header.
509
+ *
510
+ * The cache is keyed by the lookup key and remembers the wire key it minted.
511
+ * The public helpers accept either key so callers that only ever saw the
512
+ * `idempotencyKey` field keep working unchanged.
456
513
  */
457
514
 
458
515
  interface IntentIdentityOptions {
@@ -467,16 +524,21 @@ interface IntentCacheEntry {
467
524
  response?: PaymentIntentResponse;
468
525
  expiresAt: number;
469
526
  reference?: string;
527
+ /** The `Idempotency-Key` sent on the wire for this attempt. */
528
+ idempotencyKey?: string;
470
529
  }
471
530
  declare function resolveIntentIdentity(options: IntentIdentityOptions): {
531
+ /** The value to send as `Idempotency-Key`. */
472
532
  idempotencyKey: string;
533
+ /** The local cache key. Never send this on the wire. */
534
+ lookupKey: string;
473
535
  reference: string;
474
536
  cacheEntry?: IntentCacheEntry;
475
537
  };
476
- declare function getIntentCacheEntry(idempotencyKey: string): IntentCacheEntry | undefined;
477
- declare function cacheIntentPromise(idempotencyKey: string, promise: Promise<PaymentIntentResponse>): IntentCacheEntry;
478
- declare function cacheIntentResponse(idempotencyKey: string, response: PaymentIntentResponse): IntentCacheEntry;
479
- declare function clearIntentCacheEntry(idempotencyKey: string): void;
538
+ declare function getIntentCacheEntry(key: string): IntentCacheEntry | undefined;
539
+ declare function cacheIntentPromise(key: string, promise: Promise<PaymentIntentResponse>): IntentCacheEntry;
540
+ declare function cacheIntentResponse(key: string, response: PaymentIntentResponse): IntentCacheEntry;
541
+ declare function clearIntentCacheEntry(key: string): void;
480
542
 
481
543
  /**
482
544
  * Reevit State Machine
@@ -523,4 +585,4 @@ declare function createInitialState(): ReevitState;
523
585
  */
524
586
  declare function reevitReducer(state: ReevitState, action: ReevitAction): ReevitState;
525
587
 
526
- export { type APIErrorResponse, type CardFormData, type CheckoutProviderOption, type CheckoutSessionResponse, type CheckoutState, type ConfirmPaymentRequest, type CreatePaymentIntentRequest, type HubtelSessionResponse, type IntentCacheEntry, type MobileMoneyFormData, type MobileMoneyNetwork, type PSPConfig, type PSPType, type PaymentDetailResponse, type PaymentError, type PaymentIntent, type PaymentIntentResponse, type PaymentMethod, type PaymentResult, type PaymentSource, ReevitAPIClient, type ReevitAPIClientConfig, type ReevitAPIResult, type ReevitAction, type ReevitCheckoutCallbacks, type ReevitCheckoutConfig, type ReevitState, type ReevitTheme, cacheIntentPromise, cacheIntentResponse, clearIntentCacheEntry, cn, createInitialState, createPaymentError, createReevitClient, createThemeVariables, detectCountryFromCurrency, detectNetwork, formatAmount, formatPhone, generateIdempotencyKey, generateReference, getIntentCacheEntry, isPaymentError, reevitReducer, resolveIntentIdentity, validatePhone };
588
+ export { type APIErrorResponse, type CardFormData, type CheckoutProviderOption, type CheckoutSessionResponse, type CheckoutState, type ConfirmPaymentRequest, type CreatePaymentIntentRequest, type HubtelSessionResponse, type IntentCacheEntry, type MobileMoneyFormData, type MobileMoneyNetwork, type PSPConfig, type PSPType, type PaymentDetailResponse, type PaymentError, type PaymentIntent, type PaymentIntentResponse, type PaymentMethod, type PaymentResult, type PaymentSource, ReevitAPIClient, type ReevitAPIClientConfig, type ReevitAPIResult, type ReevitAction, type ReevitCheckoutCallbacks, type ReevitCheckoutConfig, type ReevitState, type ReevitTheme, attemptIdempotencyKey, cacheIntentPromise, cacheIntentResponse, clearIdempotencyAttemptKeys, clearIntentCacheEntry, cn, createInitialState, createPaymentError, createReevitClient, createThemeVariables, currencyExponent, detectCountryFromCurrency, detectNetwork, formatAmount, formatPhone, generateIdempotencyKey, generateReference, getIntentCacheEntry, isPaymentError, newIdempotencyKey, reevitReducer, resolveIntentIdentity, toMinorUnits, validatePhone };
package/dist/index.d.ts CHANGED
@@ -331,11 +331,43 @@ interface ReevitAPIClientConfig {
331
331
  declare function createPaymentError(response: Response, errorData: APIErrorResponse): PaymentError;
332
332
  declare function isPaymentError(error: unknown): error is PaymentError;
333
333
  /**
334
- * Generates a deterministic idempotency key based on input parameters
335
- * Uses a simple hash function suitable for browser environments
336
- * Exported for use by SDK hooks (e.g., payment link flows)
334
+ * Generates a deterministic **cache/lookup** key from input parameters.
335
+ *
336
+ * NEVER SEND THIS ON THE WIRE. It is a 32-bit djb2 hash bucketed into
337
+ * 5-minute windows, so two unrelated shoppers can collide and be handed each
338
+ * other's payment intent (and therefore each other's `client_secret`), and a
339
+ * shopper legitimately buying the same item twice inside one window would be
340
+ * charged once. Its only job is to identify "the same checkout attempt" inside
341
+ * a single browser tab so the in-flight intent cache can dedupe a repeated
342
+ * "Continue" click.
343
+ *
344
+ * The value actually sent as `Idempotency-Key` is produced by
345
+ * {@link newIdempotencyKey} / {@link attemptIdempotencyKey}.
346
+ *
347
+ * Exported for use by SDK hooks (e.g. payment link flows).
337
348
  */
338
349
  declare function generateIdempotencyKey(params: Record<string, unknown>): string;
350
+ /**
351
+ * Generates a fresh, globally unique `Idempotency-Key` (RFC 4122 v4 UUID).
352
+ * This is the only value that should ever be sent on the wire.
353
+ */
354
+ declare function newIdempotencyKey(): string;
355
+ /**
356
+ * Resolves the stable per-checkout-attempt wire key for a deterministic
357
+ * lookup key (see {@link generateIdempotencyKey}).
358
+ *
359
+ * The first call for a lookup key mints a UUID and stores it in
360
+ * `sessionStorage` (falling back to a module-level map when storage is
361
+ * unavailable); every later call in the same tab returns that same UUID, so a
362
+ * repeated "Continue" click is still deduped by the backend. A different tab,
363
+ * a different shopper or a cleared store yields a different UUID.
364
+ */
365
+ declare function attemptIdempotencyKey(lookupKey: string): string;
366
+ /**
367
+ * Forgets every stored per-attempt key, so the next checkout attempt gets a
368
+ * fresh `Idempotency-Key`. Call it after a completed checkout (and in tests).
369
+ */
370
+ declare function clearIdempotencyAttemptKeys(): void;
339
371
  /**
340
372
  * Reevit API Client
341
373
  */
@@ -418,6 +450,19 @@ declare function createReevitClient(config: ReevitAPIClientConfig): ReevitAPICli
418
450
  * Shared utilities for Reevit SDKs
419
451
  */
420
452
 
453
+ /**
454
+ * Returns how many decimal places a currency's minor unit uses: 2 for GHS and
455
+ * NGN, 0 for XOF, XAF, RWF, UGX, JPY and friends.
456
+ *
457
+ * Dividing every amount by 100 renders a 5,000 XOF charge as "XOF 50.00" while
458
+ * the shopper is actually charged 5,000 — which is why this exists.
459
+ */
460
+ declare function currencyExponent(currency: string): number;
461
+ /**
462
+ * Converts a major-unit amount (what a shopper types) into the minor units the
463
+ * API expects: `toMinorUnits(45, 'GHS') === 4500`, `toMinorUnits(5000, 'XOF') === 5000`.
464
+ */
465
+ declare function toMinorUnits(major: number, currency: string): number;
421
466
  /**
422
467
  * Formats an amount from smallest currency unit to display format
423
468
  */
@@ -453,6 +498,18 @@ declare function detectCountryFromCurrency(currency: string): string;
453
498
 
454
499
  /**
455
500
  * Intent identity + cache helpers
501
+ *
502
+ * Two different keys are in play here and mixing them up is a money bug:
503
+ *
504
+ * - the **lookup key** is the deterministic djb2 hash of the checkout
505
+ * parameters (`generateIdempotencyKey`). It identifies "the same checkout
506
+ * attempt" for the in-flight cache and is never sent to the API.
507
+ * - the **wire key** is the per-attempt UUID (`attemptIdempotencyKey`) that
508
+ * goes out as the `Idempotency-Key` header.
509
+ *
510
+ * The cache is keyed by the lookup key and remembers the wire key it minted.
511
+ * The public helpers accept either key so callers that only ever saw the
512
+ * `idempotencyKey` field keep working unchanged.
456
513
  */
457
514
 
458
515
  interface IntentIdentityOptions {
@@ -467,16 +524,21 @@ interface IntentCacheEntry {
467
524
  response?: PaymentIntentResponse;
468
525
  expiresAt: number;
469
526
  reference?: string;
527
+ /** The `Idempotency-Key` sent on the wire for this attempt. */
528
+ idempotencyKey?: string;
470
529
  }
471
530
  declare function resolveIntentIdentity(options: IntentIdentityOptions): {
531
+ /** The value to send as `Idempotency-Key`. */
472
532
  idempotencyKey: string;
533
+ /** The local cache key. Never send this on the wire. */
534
+ lookupKey: string;
473
535
  reference: string;
474
536
  cacheEntry?: IntentCacheEntry;
475
537
  };
476
- declare function getIntentCacheEntry(idempotencyKey: string): IntentCacheEntry | undefined;
477
- declare function cacheIntentPromise(idempotencyKey: string, promise: Promise<PaymentIntentResponse>): IntentCacheEntry;
478
- declare function cacheIntentResponse(idempotencyKey: string, response: PaymentIntentResponse): IntentCacheEntry;
479
- declare function clearIntentCacheEntry(idempotencyKey: string): void;
538
+ declare function getIntentCacheEntry(key: string): IntentCacheEntry | undefined;
539
+ declare function cacheIntentPromise(key: string, promise: Promise<PaymentIntentResponse>): IntentCacheEntry;
540
+ declare function cacheIntentResponse(key: string, response: PaymentIntentResponse): IntentCacheEntry;
541
+ declare function clearIntentCacheEntry(key: string): void;
480
542
 
481
543
  /**
482
544
  * Reevit State Machine
@@ -523,4 +585,4 @@ declare function createInitialState(): ReevitState;
523
585
  */
524
586
  declare function reevitReducer(state: ReevitState, action: ReevitAction): ReevitState;
525
587
 
526
- export { type APIErrorResponse, type CardFormData, type CheckoutProviderOption, type CheckoutSessionResponse, type CheckoutState, type ConfirmPaymentRequest, type CreatePaymentIntentRequest, type HubtelSessionResponse, type IntentCacheEntry, type MobileMoneyFormData, type MobileMoneyNetwork, type PSPConfig, type PSPType, type PaymentDetailResponse, type PaymentError, type PaymentIntent, type PaymentIntentResponse, type PaymentMethod, type PaymentResult, type PaymentSource, ReevitAPIClient, type ReevitAPIClientConfig, type ReevitAPIResult, type ReevitAction, type ReevitCheckoutCallbacks, type ReevitCheckoutConfig, type ReevitState, type ReevitTheme, cacheIntentPromise, cacheIntentResponse, clearIntentCacheEntry, cn, createInitialState, createPaymentError, createReevitClient, createThemeVariables, detectCountryFromCurrency, detectNetwork, formatAmount, formatPhone, generateIdempotencyKey, generateReference, getIntentCacheEntry, isPaymentError, reevitReducer, resolveIntentIdentity, validatePhone };
588
+ export { type APIErrorResponse, type CardFormData, type CheckoutProviderOption, type CheckoutSessionResponse, type CheckoutState, type ConfirmPaymentRequest, type CreatePaymentIntentRequest, type HubtelSessionResponse, type IntentCacheEntry, type MobileMoneyFormData, type MobileMoneyNetwork, type PSPConfig, type PSPType, type PaymentDetailResponse, type PaymentError, type PaymentIntent, type PaymentIntentResponse, type PaymentMethod, type PaymentResult, type PaymentSource, ReevitAPIClient, type ReevitAPIClientConfig, type ReevitAPIResult, type ReevitAction, type ReevitCheckoutCallbacks, type ReevitCheckoutConfig, type ReevitState, type ReevitTheme, attemptIdempotencyKey, cacheIntentPromise, cacheIntentResponse, clearIdempotencyAttemptKeys, clearIntentCacheEntry, cn, createInitialState, createPaymentError, createReevitClient, createThemeVariables, currencyExponent, detectCountryFromCurrency, detectNetwork, formatAmount, formatPhone, generateIdempotencyKey, generateReference, getIntentCacheEntry, isPaymentError, newIdempotencyKey, reevitReducer, resolveIntentIdentity, toMinorUnits, validatePhone };