@voxepay/checkout 0.3.12 → 0.4.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/dist/index.esm.js CHANGED
@@ -630,7 +630,27 @@ class VoxePayApiClient {
630
630
  if (!response.ok) {
631
631
  const errorMessage = (data === null || data === void 0 ? void 0 : data.message) || (data === null || data === void 0 ? void 0 : data.error) || `Request failed with status ${response.status}`;
632
632
  const error = new Error(errorMessage);
633
- error.code = (data === null || data === void 0 ? void 0 : data.code) || `HTTP_${response.status}`;
633
+ error.code = (data === null || data === void 0 ? void 0 : data.errorCode) || (data === null || data === void 0 ? void 0 : data.code) || `HTTP_${response.status}`;
634
+ error.status = response.status;
635
+ error.data = data;
636
+ throw error;
637
+ }
638
+ return data;
639
+ }
640
+ async get(endpoint) {
641
+ const url = `${this.baseUrl}${endpoint}`;
642
+ const response = await fetch(url, {
643
+ method: 'GET',
644
+ headers: {
645
+ 'X-API-Key': this.apiKey,
646
+ 'Accept': 'application/json',
647
+ },
648
+ });
649
+ const data = await response.json();
650
+ if (!response.ok) {
651
+ const errorMessage = (data === null || data === void 0 ? void 0 : data.message) || (data === null || data === void 0 ? void 0 : data.error) || `Request failed with status ${response.status}`;
652
+ const error = new Error(errorMessage);
653
+ error.code = (data === null || data === void 0 ? void 0 : data.errorCode) || (data === null || data === void 0 ? void 0 : data.code) || `HTTP_${response.status}`;
634
654
  error.status = response.status;
635
655
  error.data = data;
636
656
  throw error;
@@ -638,10 +658,15 @@ class VoxePayApiClient {
638
658
  return data;
639
659
  }
640
660
  /**
641
- * Initiate a card payment
661
+ * Initiate a payment (card or bank transfer)
642
662
  */
643
663
  async initiatePayment(data) {
644
- return this.request('/api/v1/payments/initiate', data);
664
+ const response = await this.request('/api/v1/payments/initiate', data);
665
+ // Handle both wrapped { success, data: {...} } and flat response formats
666
+ if (response.data && typeof response.data === 'object' && 'transactionRef' in response.data) {
667
+ return response.data;
668
+ }
669
+ return response;
645
670
  }
646
671
  /**
647
672
  * Validate OTP for a payment
@@ -655,6 +680,13 @@ class VoxePayApiClient {
655
680
  async resendOTP(data) {
656
681
  await this.request('/api/v1/payments/resend-otp', data);
657
682
  }
683
+ /**
684
+ * Get payment status by transaction reference (used for polling DVA payments)
685
+ */
686
+ async getPaymentStatus(transactionRef) {
687
+ const response = await this.get(`/api/v1/payments/${encodeURIComponent(transactionRef)}`);
688
+ return response.data;
689
+ }
658
690
  }
659
691
 
660
692
  /**
@@ -701,6 +733,10 @@ class VoxePayModal {
701
733
  this.container = null;
702
734
  this.overlay = null;
703
735
  this.apiClient = null;
736
+ /** Polling interval ID for DVA status checks */
737
+ this.statusPollingInterval = null;
738
+ /** Polling timeout (auto-stop after 30 minutes) */
739
+ this.statusPollingTimeout = null;
704
740
  this.handleEscape = (e) => {
705
741
  if (e.key === 'Escape') {
706
742
  this.close();
@@ -755,6 +791,7 @@ class VoxePayModal {
755
791
  close() {
756
792
  var _a;
757
793
  this.stopTransferTimer();
794
+ this.stopStatusPolling();
758
795
  (_a = this.overlay) === null || _a === void 0 ? void 0 : _a.classList.remove('voxepay-visible');
759
796
  setTimeout(() => {
760
797
  var _a, _b, _c;
@@ -1321,26 +1358,56 @@ class VoxePayModal {
1321
1358
  }
1322
1359
  }
1323
1360
  /**
1324
- * Load bank transfer details (from callback or generate mock)
1361
+ * Load bank transfer details (from callback or via API)
1325
1362
  */
1326
1363
  async loadBankTransferDetails() {
1327
- var _a;
1364
+ var _a, _b;
1328
1365
  try {
1329
1366
  let details;
1330
1367
  if (this.options.onBankTransferRequested) {
1368
+ // Use merchant-provided callback
1331
1369
  details = await this.options.onBankTransferRequested();
1332
1370
  }
1333
- else {
1334
- // Mock/default bank details for testing
1335
- await new Promise(resolve => setTimeout(resolve, 1500));
1371
+ else if (this.apiClient && this.options._sdkConfig) {
1372
+ // Call real DVA API
1373
+ const transactionRef = `VP-${Date.now().toString(36).toUpperCase()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
1374
+ const response = await this.apiClient.initiatePayment({
1375
+ organizationId: this.options._sdkConfig.organizationId,
1376
+ transactionRef,
1377
+ customerId: this.options.customerEmail || '',
1378
+ customerEmail: this.options.customerEmail,
1379
+ customerPhone: this.options.customerPhone,
1380
+ amount: this.options.amount / 100, // Convert from kobo/cents to main unit
1381
+ currency: this.options.currency,
1382
+ paymentMethod: 'BANK_TRANSFER',
1383
+ authData: btoa(JSON.stringify({ source: 'web', method: 'bank_transfer' })),
1384
+ narration: this.options.description,
1385
+ durationMinutes: 30,
1386
+ });
1387
+ // Store transaction ref for status polling
1388
+ // API may return 'id' (DVA) or 'paymentId' (card) depending on payment method
1389
+ this.state.transactionRef = response.transactionRef || transactionRef;
1390
+ this.state.paymentId = response.paymentId || response.id || null;
1391
+ const va = response.virtualAccount;
1392
+ if (!va) {
1393
+ throw { code: 'MISSING_VIRTUAL_ACCOUNT', message: 'No virtual account returned from server.' };
1394
+ }
1395
+ // Compute expiresIn from the ISO timestamp
1396
+ const expiresAtDate = new Date(va.expires_at);
1397
+ const nowMs = Date.now();
1398
+ const expiresInSeconds = Math.max(0, Math.floor((expiresAtDate.getTime() - nowMs) / 1000));
1336
1399
  details = {
1337
- accountNumber: '0123456789',
1338
- bankName: 'VoxePay Bank',
1339
- accountName: 'VoxePay Collections',
1340
- reference: `VP-${Date.now().toString(36).toUpperCase()}`,
1341
- expiresIn: 1800, // 30 minutes
1400
+ accountNumber: va.account_number,
1401
+ bankName: va.bank_name,
1402
+ accountName: va.account_name,
1403
+ reference: this.state.transactionRef,
1404
+ expiresIn: expiresInSeconds,
1405
+ expiresAt: va.expires_at,
1342
1406
  };
1343
1407
  }
1408
+ else {
1409
+ throw { code: 'SDK_ERROR', message: 'SDK not properly initialized. Missing API key or organization ID.' };
1410
+ }
1344
1411
  this.state.bankTransferDetails = details;
1345
1412
  // Re-render the bank transfer view
1346
1413
  const formContainer = (_a = this.container) === null || _a === void 0 ? void 0 : _a.querySelector('#voxepay-form-container');
@@ -1352,13 +1419,57 @@ class VoxePayModal {
1352
1419
  }
1353
1420
  }
1354
1421
  catch (error) {
1422
+ const errorCode = (error === null || error === void 0 ? void 0 : error.code) || 'BANK_TRANSFER_INIT_FAILED';
1423
+ const errorMessage = (error === null || error === void 0 ? void 0 : error.message) || 'Could not generate bank transfer details. Please try again.';
1424
+ // Show error in the loading area
1425
+ const formContainer = (_b = this.container) === null || _b === void 0 ? void 0 : _b.querySelector('#voxepay-form-container');
1426
+ if (formContainer) {
1427
+ formContainer.innerHTML = `
1428
+ <div class="voxepay-transfer-view">
1429
+ <div style="text-align: center; padding: 40px 16px;">
1430
+ <div style="font-size: 2.5rem; margin-bottom: 16px;">⚠️</div>
1431
+ <h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 8px; color: var(--voxepay-text);">${this.getDVAErrorTitle(errorCode)}</h3>
1432
+ <p style="font-size: 0.875rem; color: var(--voxepay-text-muted); margin-bottom: 24px;">${errorMessage}</p>
1433
+ <button class="voxepay-submit-btn" id="voxepay-retry-transfer"><span>Try Again</span></button>
1434
+ </div>
1435
+ </div>
1436
+ `;
1437
+ const retryBtn = formContainer.querySelector('#voxepay-retry-transfer');
1438
+ retryBtn === null || retryBtn === void 0 ? void 0 : retryBtn.addEventListener('click', () => {
1439
+ formContainer.innerHTML = `
1440
+ <div class="voxepay-transfer-view">
1441
+ <div class="voxepay-transfer-loading">
1442
+ <div class="voxepay-spinner"></div>
1443
+ <p style="margin-top: 16px; color: var(--voxepay-text-muted);">Generating account details...</p>
1444
+ </div>
1445
+ </div>
1446
+ `;
1447
+ this.loadBankTransferDetails();
1448
+ });
1449
+ }
1355
1450
  this.options.onError({
1356
- code: 'BANK_TRANSFER_INIT_FAILED',
1357
- message: 'Could not generate bank transfer details. Please try again.',
1451
+ code: errorCode,
1452
+ message: errorMessage,
1358
1453
  recoverable: true,
1454
+ details: error === null || error === void 0 ? void 0 : error.data,
1359
1455
  });
1360
1456
  }
1361
1457
  }
1458
+ /**
1459
+ * Get user-friendly error title for DVA error codes
1460
+ */
1461
+ getDVAErrorTitle(code) {
1462
+ const titles = {
1463
+ 'ACCOUNT_CREATION_FAILED': 'Account Generation Failed',
1464
+ 'ACCOUNT_EXPIRED': 'Account Expired',
1465
+ 'INVALID_AMOUNT': 'Invalid Amount',
1466
+ 'UNAUTHORIZED': 'Authentication Failed',
1467
+ 'ORGANIZATION_NOT_FOUND': 'Configuration Error',
1468
+ 'MISSING_VIRTUAL_ACCOUNT': 'Account Generation Failed',
1469
+ 'SDK_ERROR': 'Configuration Error',
1470
+ };
1471
+ return titles[code] || 'Something Went Wrong';
1472
+ }
1362
1473
  /**
1363
1474
  * Copy text to clipboard with visual feedback
1364
1475
  */
@@ -1389,7 +1500,7 @@ class VoxePayModal {
1389
1500
  }
1390
1501
  }
1391
1502
  /**
1392
- * Handle "I've sent the money" confirmation
1503
+ * Handle "I've sent the money" confirmation — starts polling for payment status
1393
1504
  */
1394
1505
  async handleTransferConfirm() {
1395
1506
  var _a, _b;
@@ -1398,12 +1509,11 @@ class VoxePayModal {
1398
1509
  confirmBtn.disabled = true;
1399
1510
  confirmBtn.innerHTML = '<div class="voxepay-spinner"></div><span>Confirming transfer...</span>';
1400
1511
  }
1401
- this.stopTransferTimer();
1402
- try {
1403
- // Simulate transfer verification
1404
- await new Promise(resolve => setTimeout(resolve, 3000));
1512
+ // If no API client or no transaction ref, fall back to pending result
1513
+ if (!this.apiClient || !this.state.transactionRef) {
1514
+ this.stopTransferTimer();
1405
1515
  const result = {
1406
- id: `pay_transfer_${Date.now()}`,
1516
+ id: this.state.paymentId || `pay_transfer_${Date.now()}`,
1407
1517
  status: 'pending',
1408
1518
  amount: this.options.amount,
1409
1519
  currency: this.options.currency,
@@ -1414,16 +1524,216 @@ class VoxePayModal {
1414
1524
  this.state.isSuccess = true;
1415
1525
  this.renderSuccessView();
1416
1526
  this.options.onSuccess(result);
1527
+ return;
1417
1528
  }
1418
- catch (error) {
1419
- if (confirmBtn) {
1420
- confirmBtn.disabled = false;
1421
- confirmBtn.innerHTML = "<span>I've sent the money</span>";
1529
+ // Show polling UI
1530
+ this.renderPollingView();
1531
+ this.startStatusPolling();
1532
+ }
1533
+ /**
1534
+ * Render the "waiting for payment confirmation" polling view
1535
+ */
1536
+ renderPollingView() {
1537
+ var _a;
1538
+ const formContainer = (_a = this.container) === null || _a === void 0 ? void 0 : _a.querySelector('#voxepay-form-container');
1539
+ if (!formContainer)
1540
+ return;
1541
+ const formattedAmount = formatAmount(this.options.amount, this.options.currency);
1542
+ formContainer.innerHTML = `
1543
+ <div class="voxepay-transfer-view">
1544
+ <div style="text-align: center; padding: 32px 16px;">
1545
+ <div class="voxepay-spinner" style="width: 40px; height: 40px; margin: 0 auto 20px; border-width: 3px;"></div>
1546
+ <h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 8px; color: var(--voxepay-text);">Waiting for Payment</h3>
1547
+ <p style="font-size: 0.875rem; color: var(--voxepay-text-muted); margin-bottom: 4px;">
1548
+ We're checking for your transfer of <strong>${formattedAmount}</strong>
1549
+ </p>
1550
+ <p style="font-size: 0.813rem; color: var(--voxepay-text-subtle); margin-bottom: 24px;" id="voxepay-polling-status">
1551
+ This usually takes a few seconds...
1552
+ </p>
1553
+ <button class="voxepay-submit-btn" id="voxepay-cancel-polling" style="background: var(--voxepay-surface); border: 1px solid var(--voxepay-border); color: var(--voxepay-text-muted); box-shadow: none;">
1554
+ <span>Cancel</span>
1555
+ </button>
1556
+ </div>
1557
+ </div>
1558
+ `;
1559
+ const cancelBtn = formContainer.querySelector('#voxepay-cancel-polling');
1560
+ cancelBtn === null || cancelBtn === void 0 ? void 0 : cancelBtn.addEventListener('click', () => {
1561
+ this.stopStatusPolling();
1562
+ // Re-render bank transfer view so user can try again
1563
+ if (this.state.bankTransferDetails) {
1564
+ const amt = formatAmount(this.options.amount, this.options.currency);
1565
+ formContainer.innerHTML = this.getBankTransferHTML(amt);
1566
+ this.attachBankTransferListeners();
1567
+ this.startTransferTimer();
1568
+ }
1569
+ });
1570
+ }
1571
+ /**
1572
+ * Start polling payment status every 5 seconds
1573
+ */
1574
+ startStatusPolling() {
1575
+ this.stopStatusPolling(); // clear any existing poll
1576
+ const poll = async () => {
1577
+ var _a;
1578
+ if (!this.apiClient || !this.state.transactionRef)
1579
+ return;
1580
+ try {
1581
+ const statusData = await this.apiClient.getPaymentStatus(this.state.transactionRef);
1582
+ this.handlePollingStatusUpdate(statusData.status, statusData);
1583
+ }
1584
+ catch (error) {
1585
+ console.error('[VoxePay] Status polling error:', error);
1586
+ // Don't stop polling on transient errors — let it retry
1587
+ const statusEl = (_a = this.container) === null || _a === void 0 ? void 0 : _a.querySelector('#voxepay-polling-status');
1588
+ if (statusEl) {
1589
+ statusEl.textContent = 'Still checking... Please wait.';
1590
+ }
1591
+ }
1592
+ };
1593
+ // Initial check immediately
1594
+ poll();
1595
+ // Then poll every 5 seconds
1596
+ this.statusPollingInterval = window.setInterval(poll, 5000);
1597
+ // Auto-stop after 30 minutes
1598
+ this.statusPollingTimeout = window.setTimeout(() => {
1599
+ this.stopStatusPolling();
1600
+ this.handlePollingStatusUpdate('EXPIRED', null);
1601
+ }, 30 * 60 * 1000);
1602
+ }
1603
+ /**
1604
+ * Stop payment status polling
1605
+ */
1606
+ stopStatusPolling() {
1607
+ if (this.statusPollingInterval) {
1608
+ clearInterval(this.statusPollingInterval);
1609
+ this.statusPollingInterval = null;
1610
+ }
1611
+ if (this.statusPollingTimeout) {
1612
+ clearTimeout(this.statusPollingTimeout);
1613
+ this.statusPollingTimeout = null;
1614
+ }
1615
+ }
1616
+ /**
1617
+ * Handle status updates from polling
1618
+ */
1619
+ handlePollingStatusUpdate(status, data) {
1620
+ var _a;
1621
+ const terminalStatuses = ['COMPLETED', 'FAILED', 'EXPIRED', 'CANCELLED'];
1622
+ if (terminalStatuses.includes(status)) {
1623
+ this.stopStatusPolling();
1624
+ this.stopTransferTimer();
1625
+ }
1626
+ switch (status) {
1627
+ case 'COMPLETED': {
1628
+ const result = {
1629
+ id: (data === null || data === void 0 ? void 0 : data.id) || this.state.paymentId || `pay_transfer_${Date.now()}`,
1630
+ status: 'success',
1631
+ amount: this.options.amount,
1632
+ currency: this.options.currency,
1633
+ timestamp: (data === null || data === void 0 ? void 0 : data.completedAt) || new Date().toISOString(),
1634
+ reference: this.state.transactionRef || undefined,
1635
+ paymentMethod: 'bank_transfer',
1636
+ data: data || undefined,
1637
+ };
1638
+ this.state.isSuccess = true;
1639
+ this.renderSuccessView();
1640
+ this.options.onSuccess(result);
1641
+ break;
1642
+ }
1643
+ case 'PAYMENT_RECEIVED': {
1644
+ // Almost done — update the polling UI message
1645
+ const statusEl = (_a = this.container) === null || _a === void 0 ? void 0 : _a.querySelector('#voxepay-polling-status');
1646
+ if (statusEl) {
1647
+ statusEl.textContent = 'Payment received! Processing...';
1648
+ }
1649
+ break;
1650
+ }
1651
+ case 'PARTIAL_PAYMENT': {
1652
+ this.stopStatusPolling();
1653
+ this.renderTransferErrorView('Partial Payment Received', 'The amount transferred is less than the required amount. Please transfer the remaining balance or contact support.');
1654
+ break;
1655
+ }
1656
+ case 'OVERPAID': {
1657
+ // Overpayment is still successful — show success but note the overpayment
1658
+ const result = {
1659
+ id: (data === null || data === void 0 ? void 0 : data.id) || this.state.paymentId || `pay_transfer_${Date.now()}`,
1660
+ status: 'success',
1661
+ amount: this.options.amount,
1662
+ currency: this.options.currency,
1663
+ timestamp: (data === null || data === void 0 ? void 0 : data.completedAt) || new Date().toISOString(),
1664
+ reference: this.state.transactionRef || undefined,
1665
+ paymentMethod: 'bank_transfer',
1666
+ data: { ...data, overpaid: true },
1667
+ };
1668
+ this.state.isSuccess = true;
1669
+ this.renderSuccessView();
1670
+ this.options.onSuccess(result);
1671
+ break;
1672
+ }
1673
+ case 'EXPIRED': {
1674
+ this.renderTransferErrorView('Payment Expired', 'The virtual account has expired. Please try again to generate a new account.');
1675
+ this.options.onError({
1676
+ code: 'ACCOUNT_EXPIRED',
1677
+ message: 'Virtual account expired before payment was received.',
1678
+ recoverable: true,
1679
+ });
1680
+ break;
1681
+ }
1682
+ case 'FAILED': {
1683
+ this.renderTransferErrorView('Payment Failed', (data === null || data === void 0 ? void 0 : data.message) || 'The payment could not be processed. Please try again.');
1684
+ this.options.onError({
1685
+ code: 'PAYMENT_FAILED',
1686
+ message: (data === null || data === void 0 ? void 0 : data.message) || 'Payment failed.',
1687
+ recoverable: true,
1688
+ });
1689
+ break;
1690
+ }
1691
+ case 'CANCELLED': {
1692
+ this.renderTransferErrorView('Payment Cancelled', 'This payment has been cancelled.');
1693
+ this.options.onError({
1694
+ code: 'PAYMENT_CANCELLED',
1695
+ message: 'Payment was cancelled.',
1696
+ recoverable: false,
1697
+ });
1698
+ break;
1422
1699
  }
1423
- const paymentError = error;
1424
- this.options.onError(paymentError);
1425
1700
  }
1426
1701
  }
1702
+ /**
1703
+ * Render error view for transfer issues with retry option
1704
+ */
1705
+ renderTransferErrorView(title, message) {
1706
+ var _a;
1707
+ const formContainer = (_a = this.container) === null || _a === void 0 ? void 0 : _a.querySelector('#voxepay-form-container');
1708
+ if (!formContainer)
1709
+ return;
1710
+ formContainer.innerHTML = `
1711
+ <div class="voxepay-transfer-view">
1712
+ <div style="text-align: center; padding: 40px 16px;">
1713
+ <div style="font-size: 2.5rem; margin-bottom: 16px;">❌</div>
1714
+ <h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 8px; color: var(--voxepay-text);">${title}</h3>
1715
+ <p style="font-size: 0.875rem; color: var(--voxepay-text-muted); margin-bottom: 24px;">${message}</p>
1716
+ <button class="voxepay-submit-btn" id="voxepay-retry-transfer"><span>Try Again</span></button>
1717
+ </div>
1718
+ </div>
1719
+ `;
1720
+ const retryBtn = formContainer.querySelector('#voxepay-retry-transfer');
1721
+ retryBtn === null || retryBtn === void 0 ? void 0 : retryBtn.addEventListener('click', () => {
1722
+ // Reset state for new attempt
1723
+ this.state.bankTransferDetails = null;
1724
+ this.state.transactionRef = null;
1725
+ this.state.paymentId = null;
1726
+ formContainer.innerHTML = `
1727
+ <div class="voxepay-transfer-view">
1728
+ <div class="voxepay-transfer-loading">
1729
+ <div class="voxepay-spinner"></div>
1730
+ <p style="margin-top: 16px; color: var(--voxepay-text-muted);">Generating account details...</p>
1731
+ </div>
1732
+ </div>
1733
+ `;
1734
+ this.loadBankTransferDetails();
1735
+ });
1736
+ }
1427
1737
  /**
1428
1738
  * Start the transfer countdown timer
1429
1739
  */