@voxepay/checkout 0.3.11 → 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/api/client.d.ts +32 -1
- package/dist/components/modal.d.ts +30 -2
- package/dist/index.cjs.js +339 -29
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.esm.js +339 -29
- package/dist/index.esm.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/voxepay-checkout.min.js +1 -1
- package/dist/voxepay-checkout.min.js.map +1 -1
- package/package.json +1 -1
- package/src/api/client.ts +84 -3
- package/src/components/modal.ts +343 -27
- package/src/index.ts +3 -0
- package/src/types.ts +2 -0
- package/src/utils/encryption.ts +2 -2
package/src/api/client.ts
CHANGED
|
@@ -16,6 +16,17 @@ export interface InitiatePaymentRequest {
|
|
|
16
16
|
paymentMethod: 'CARD' | 'BANK_TRANSFER';
|
|
17
17
|
authData: string;
|
|
18
18
|
narration?: string;
|
|
19
|
+
/** Virtual account validity in minutes (5-1440). Only for BANK_TRANSFER. Default: 30 */
|
|
20
|
+
durationMinutes?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface VirtualAccountResponse {
|
|
24
|
+
account_number: string;
|
|
25
|
+
account_name: string;
|
|
26
|
+
bank_name: string;
|
|
27
|
+
bank_code: string;
|
|
28
|
+
expires_at: string;
|
|
29
|
+
status: string;
|
|
19
30
|
}
|
|
20
31
|
|
|
21
32
|
export interface InitiatePaymentResponse {
|
|
@@ -25,6 +36,33 @@ export interface InitiatePaymentResponse {
|
|
|
25
36
|
status: string;
|
|
26
37
|
message: string;
|
|
27
38
|
eciFlag?: string;
|
|
39
|
+
otpRequired?: boolean;
|
|
40
|
+
virtualAccount?: VirtualAccountResponse;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type DVAPaymentStatus =
|
|
45
|
+
| 'INITIATED'
|
|
46
|
+
| 'PENDING_PAYMENT'
|
|
47
|
+
| 'PAYMENT_RECEIVED'
|
|
48
|
+
| 'COMPLETED'
|
|
49
|
+
| 'PARTIAL_PAYMENT'
|
|
50
|
+
| 'OVERPAID'
|
|
51
|
+
| 'EXPIRED'
|
|
52
|
+
| 'FAILED'
|
|
53
|
+
| 'CANCELLED';
|
|
54
|
+
|
|
55
|
+
export interface PaymentStatusResponse {
|
|
56
|
+
id: string;
|
|
57
|
+
transactionRef: string;
|
|
58
|
+
status: DVAPaymentStatus;
|
|
59
|
+
amount: number;
|
|
60
|
+
paymentMethod: string;
|
|
61
|
+
completedAt?: string;
|
|
62
|
+
virtualAccount?: {
|
|
63
|
+
account_number: string;
|
|
64
|
+
status: string;
|
|
65
|
+
};
|
|
28
66
|
[key: string]: unknown;
|
|
29
67
|
}
|
|
30
68
|
|
|
@@ -78,7 +116,32 @@ export class VoxePayApiClient {
|
|
|
78
116
|
if (!response.ok) {
|
|
79
117
|
const errorMessage = data?.message || data?.error || `Request failed with status ${response.status}`;
|
|
80
118
|
const error: any = new Error(errorMessage);
|
|
81
|
-
error.code = data?.code || `HTTP_${response.status}`;
|
|
119
|
+
error.code = data?.errorCode || data?.code || `HTTP_${response.status}`;
|
|
120
|
+
error.status = response.status;
|
|
121
|
+
error.data = data;
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return data as T;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async get<T>(endpoint: string): Promise<T> {
|
|
129
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
130
|
+
|
|
131
|
+
const response = await fetch(url, {
|
|
132
|
+
method: 'GET',
|
|
133
|
+
headers: {
|
|
134
|
+
'X-API-Key': this.apiKey,
|
|
135
|
+
'Accept': 'application/json',
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const data = await response.json();
|
|
140
|
+
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
const errorMessage = data?.message || data?.error || `Request failed with status ${response.status}`;
|
|
143
|
+
const error: any = new Error(errorMessage);
|
|
144
|
+
error.code = data?.errorCode || data?.code || `HTTP_${response.status}`;
|
|
82
145
|
error.status = response.status;
|
|
83
146
|
error.data = data;
|
|
84
147
|
throw error;
|
|
@@ -88,10 +151,18 @@ export class VoxePayApiClient {
|
|
|
88
151
|
}
|
|
89
152
|
|
|
90
153
|
/**
|
|
91
|
-
* Initiate a card
|
|
154
|
+
* Initiate a payment (card or bank transfer)
|
|
92
155
|
*/
|
|
93
156
|
async initiatePayment(data: InitiatePaymentRequest): Promise<InitiatePaymentResponse> {
|
|
94
|
-
|
|
157
|
+
const response = await this.request<{ success?: boolean; data?: InitiatePaymentResponse } & InitiatePaymentResponse>(
|
|
158
|
+
'/api/v1/payments/initiate',
|
|
159
|
+
data
|
|
160
|
+
);
|
|
161
|
+
// Handle both wrapped { success, data: {...} } and flat response formats
|
|
162
|
+
if (response.data && typeof response.data === 'object' && 'transactionRef' in response.data) {
|
|
163
|
+
return response.data;
|
|
164
|
+
}
|
|
165
|
+
return response;
|
|
95
166
|
}
|
|
96
167
|
|
|
97
168
|
/**
|
|
@@ -107,4 +178,14 @@ export class VoxePayApiClient {
|
|
|
107
178
|
async resendOTP(data: ResendOTPRequest): Promise<void> {
|
|
108
179
|
await this.request<unknown>('/api/v1/payments/resend-otp', data);
|
|
109
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get payment status by transaction reference (used for polling DVA payments)
|
|
184
|
+
*/
|
|
185
|
+
async getPaymentStatus(transactionRef: string): Promise<PaymentStatusResponse> {
|
|
186
|
+
const response = await this.get<{ success: boolean; data: PaymentStatusResponse }>(
|
|
187
|
+
`/api/v1/payments/${encodeURIComponent(transactionRef)}`
|
|
188
|
+
);
|
|
189
|
+
return response.data;
|
|
190
|
+
}
|
|
110
191
|
}
|
package/src/components/modal.ts
CHANGED
|
@@ -150,6 +150,7 @@ export class VoxePayModal {
|
|
|
150
150
|
*/
|
|
151
151
|
close(): void {
|
|
152
152
|
this.stopTransferTimer();
|
|
153
|
+
this.stopStatusPolling();
|
|
153
154
|
this.overlay?.classList.remove('voxepay-visible');
|
|
154
155
|
|
|
155
156
|
setTimeout(() => {
|
|
@@ -762,24 +763,58 @@ export class VoxePayModal {
|
|
|
762
763
|
}
|
|
763
764
|
|
|
764
765
|
/**
|
|
765
|
-
* Load bank transfer details (from callback or
|
|
766
|
+
* Load bank transfer details (from callback or via API)
|
|
766
767
|
*/
|
|
767
768
|
private async loadBankTransferDetails(): Promise<void> {
|
|
768
769
|
try {
|
|
769
770
|
let details: BankTransferDetails;
|
|
770
771
|
|
|
771
772
|
if (this.options.onBankTransferRequested) {
|
|
773
|
+
// Use merchant-provided callback
|
|
772
774
|
details = await this.options.onBankTransferRequested();
|
|
773
|
-
} else {
|
|
774
|
-
//
|
|
775
|
-
|
|
775
|
+
} else if (this.apiClient && this.options._sdkConfig) {
|
|
776
|
+
// Call real DVA API
|
|
777
|
+
const transactionRef = `VP-${Date.now().toString(36).toUpperCase()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`;
|
|
778
|
+
|
|
779
|
+
const response = await this.apiClient.initiatePayment({
|
|
780
|
+
organizationId: this.options._sdkConfig.organizationId,
|
|
781
|
+
transactionRef,
|
|
782
|
+
customerId: this.options.customerEmail || '',
|
|
783
|
+
customerEmail: this.options.customerEmail,
|
|
784
|
+
customerPhone: this.options.customerPhone,
|
|
785
|
+
amount: this.options.amount / 100, // Convert from kobo/cents to main unit
|
|
786
|
+
currency: this.options.currency,
|
|
787
|
+
paymentMethod: 'BANK_TRANSFER',
|
|
788
|
+
authData: btoa(JSON.stringify({ source: 'web', method: 'bank_transfer' })),
|
|
789
|
+
narration: this.options.description,
|
|
790
|
+
durationMinutes: 30,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// Store transaction ref for status polling
|
|
794
|
+
// API may return 'id' (DVA) or 'paymentId' (card) depending on payment method
|
|
795
|
+
this.state.transactionRef = response.transactionRef || transactionRef;
|
|
796
|
+
this.state.paymentId = response.paymentId || (response as any).id || null;
|
|
797
|
+
|
|
798
|
+
const va = response.virtualAccount;
|
|
799
|
+
if (!va) {
|
|
800
|
+
throw { code: 'MISSING_VIRTUAL_ACCOUNT', message: 'No virtual account returned from server.' };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Compute expiresIn from the ISO timestamp
|
|
804
|
+
const expiresAtDate = new Date(va.expires_at);
|
|
805
|
+
const nowMs = Date.now();
|
|
806
|
+
const expiresInSeconds = Math.max(0, Math.floor((expiresAtDate.getTime() - nowMs) / 1000));
|
|
807
|
+
|
|
776
808
|
details = {
|
|
777
|
-
accountNumber:
|
|
778
|
-
bankName:
|
|
779
|
-
accountName:
|
|
780
|
-
reference:
|
|
781
|
-
expiresIn:
|
|
809
|
+
accountNumber: va.account_number,
|
|
810
|
+
bankName: va.bank_name,
|
|
811
|
+
accountName: va.account_name,
|
|
812
|
+
reference: this.state.transactionRef!,
|
|
813
|
+
expiresIn: expiresInSeconds,
|
|
814
|
+
expiresAt: va.expires_at,
|
|
782
815
|
};
|
|
816
|
+
} else {
|
|
817
|
+
throw { code: 'SDK_ERROR', message: 'SDK not properly initialized. Missing API key or organization ID.' };
|
|
783
818
|
}
|
|
784
819
|
|
|
785
820
|
this.state.bankTransferDetails = details;
|
|
@@ -792,15 +827,62 @@ export class VoxePayModal {
|
|
|
792
827
|
this.attachBankTransferListeners();
|
|
793
828
|
this.startTransferTimer();
|
|
794
829
|
}
|
|
795
|
-
} catch (error) {
|
|
830
|
+
} catch (error: any) {
|
|
831
|
+
const errorCode = error?.code || 'BANK_TRANSFER_INIT_FAILED';
|
|
832
|
+
const errorMessage = error?.message || 'Could not generate bank transfer details. Please try again.';
|
|
833
|
+
|
|
834
|
+
// Show error in the loading area
|
|
835
|
+
const formContainer = this.container?.querySelector('#voxepay-form-container');
|
|
836
|
+
if (formContainer) {
|
|
837
|
+
formContainer.innerHTML = `
|
|
838
|
+
<div class="voxepay-transfer-view">
|
|
839
|
+
<div style="text-align: center; padding: 40px 16px;">
|
|
840
|
+
<div style="font-size: 2.5rem; margin-bottom: 16px;">⚠️</div>
|
|
841
|
+
<h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 8px; color: var(--voxepay-text);">${this.getDVAErrorTitle(errorCode)}</h3>
|
|
842
|
+
<p style="font-size: 0.875rem; color: var(--voxepay-text-muted); margin-bottom: 24px;">${errorMessage}</p>
|
|
843
|
+
<button class="voxepay-submit-btn" id="voxepay-retry-transfer"><span>Try Again</span></button>
|
|
844
|
+
</div>
|
|
845
|
+
</div>
|
|
846
|
+
`;
|
|
847
|
+
const retryBtn = formContainer.querySelector('#voxepay-retry-transfer');
|
|
848
|
+
retryBtn?.addEventListener('click', () => {
|
|
849
|
+
formContainer.innerHTML = `
|
|
850
|
+
<div class="voxepay-transfer-view">
|
|
851
|
+
<div class="voxepay-transfer-loading">
|
|
852
|
+
<div class="voxepay-spinner"></div>
|
|
853
|
+
<p style="margin-top: 16px; color: var(--voxepay-text-muted);">Generating account details...</p>
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
`;
|
|
857
|
+
this.loadBankTransferDetails();
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
796
861
|
this.options.onError({
|
|
797
|
-
code:
|
|
798
|
-
message:
|
|
862
|
+
code: errorCode,
|
|
863
|
+
message: errorMessage,
|
|
799
864
|
recoverable: true,
|
|
865
|
+
details: error?.data,
|
|
800
866
|
});
|
|
801
867
|
}
|
|
802
868
|
}
|
|
803
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Get user-friendly error title for DVA error codes
|
|
872
|
+
*/
|
|
873
|
+
private getDVAErrorTitle(code: string): string {
|
|
874
|
+
const titles: Record<string, string> = {
|
|
875
|
+
'ACCOUNT_CREATION_FAILED': 'Account Generation Failed',
|
|
876
|
+
'ACCOUNT_EXPIRED': 'Account Expired',
|
|
877
|
+
'INVALID_AMOUNT': 'Invalid Amount',
|
|
878
|
+
'UNAUTHORIZED': 'Authentication Failed',
|
|
879
|
+
'ORGANIZATION_NOT_FOUND': 'Configuration Error',
|
|
880
|
+
'MISSING_VIRTUAL_ACCOUNT': 'Account Generation Failed',
|
|
881
|
+
'SDK_ERROR': 'Configuration Error',
|
|
882
|
+
};
|
|
883
|
+
return titles[code] || 'Something Went Wrong';
|
|
884
|
+
}
|
|
885
|
+
|
|
804
886
|
/**
|
|
805
887
|
* Copy text to clipboard with visual feedback
|
|
806
888
|
*/
|
|
@@ -829,8 +911,13 @@ export class VoxePayModal {
|
|
|
829
911
|
}
|
|
830
912
|
}
|
|
831
913
|
|
|
914
|
+
/** Polling interval ID for DVA status checks */
|
|
915
|
+
private statusPollingInterval: number | null = null;
|
|
916
|
+
/** Polling timeout (auto-stop after 30 minutes) */
|
|
917
|
+
private statusPollingTimeout: number | null = null;
|
|
918
|
+
|
|
832
919
|
/**
|
|
833
|
-
* Handle "I've sent the money" confirmation
|
|
920
|
+
* Handle "I've sent the money" confirmation — starts polling for payment status
|
|
834
921
|
*/
|
|
835
922
|
private async handleTransferConfirm(): Promise<void> {
|
|
836
923
|
const confirmBtn = this.container?.querySelector('#voxepay-transfer-confirm') as HTMLButtonElement;
|
|
@@ -839,14 +926,11 @@ export class VoxePayModal {
|
|
|
839
926
|
confirmBtn.innerHTML = '<div class="voxepay-spinner"></div><span>Confirming transfer...</span>';
|
|
840
927
|
}
|
|
841
928
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
// Simulate transfer verification
|
|
846
|
-
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
847
|
-
|
|
929
|
+
// If no API client or no transaction ref, fall back to pending result
|
|
930
|
+
if (!this.apiClient || !this.state.transactionRef) {
|
|
931
|
+
this.stopTransferTimer();
|
|
848
932
|
const result: PaymentResult = {
|
|
849
|
-
id: `pay_transfer_${Date.now()}`,
|
|
933
|
+
id: this.state.paymentId || `pay_transfer_${Date.now()}`,
|
|
850
934
|
status: 'pending',
|
|
851
935
|
amount: this.options.amount,
|
|
852
936
|
currency: this.options.currency,
|
|
@@ -854,20 +938,252 @@ export class VoxePayModal {
|
|
|
854
938
|
reference: this.state.bankTransferDetails?.reference,
|
|
855
939
|
paymentMethod: 'bank_transfer',
|
|
856
940
|
};
|
|
857
|
-
|
|
858
941
|
this.state.isSuccess = true;
|
|
859
942
|
this.renderSuccessView();
|
|
860
943
|
this.options.onSuccess(result);
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Show polling UI
|
|
948
|
+
this.renderPollingView();
|
|
949
|
+
this.startStatusPolling();
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Render the "waiting for payment confirmation" polling view
|
|
954
|
+
*/
|
|
955
|
+
private renderPollingView(): void {
|
|
956
|
+
const formContainer = this.container?.querySelector('#voxepay-form-container');
|
|
957
|
+
if (!formContainer) return;
|
|
958
|
+
|
|
959
|
+
const formattedAmount = formatAmount(this.options.amount, this.options.currency);
|
|
960
|
+
|
|
961
|
+
formContainer.innerHTML = `
|
|
962
|
+
<div class="voxepay-transfer-view">
|
|
963
|
+
<div style="text-align: center; padding: 32px 16px;">
|
|
964
|
+
<div class="voxepay-spinner" style="width: 40px; height: 40px; margin: 0 auto 20px; border-width: 3px;"></div>
|
|
965
|
+
<h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 8px; color: var(--voxepay-text);">Waiting for Payment</h3>
|
|
966
|
+
<p style="font-size: 0.875rem; color: var(--voxepay-text-muted); margin-bottom: 4px;">
|
|
967
|
+
We're checking for your transfer of <strong>${formattedAmount}</strong>
|
|
968
|
+
</p>
|
|
969
|
+
<p style="font-size: 0.813rem; color: var(--voxepay-text-subtle); margin-bottom: 24px;" id="voxepay-polling-status">
|
|
970
|
+
This usually takes a few seconds...
|
|
971
|
+
</p>
|
|
972
|
+
<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;">
|
|
973
|
+
<span>Cancel</span>
|
|
974
|
+
</button>
|
|
975
|
+
</div>
|
|
976
|
+
</div>
|
|
977
|
+
`;
|
|
978
|
+
|
|
979
|
+
const cancelBtn = formContainer.querySelector('#voxepay-cancel-polling');
|
|
980
|
+
cancelBtn?.addEventListener('click', () => {
|
|
981
|
+
this.stopStatusPolling();
|
|
982
|
+
// Re-render bank transfer view so user can try again
|
|
983
|
+
if (this.state.bankTransferDetails) {
|
|
984
|
+
const amt = formatAmount(this.options.amount, this.options.currency);
|
|
985
|
+
formContainer.innerHTML = this.getBankTransferHTML(amt);
|
|
986
|
+
this.attachBankTransferListeners();
|
|
987
|
+
this.startTransferTimer();
|
|
865
988
|
}
|
|
866
|
-
|
|
867
|
-
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Start polling payment status every 5 seconds
|
|
994
|
+
*/
|
|
995
|
+
private startStatusPolling(): void {
|
|
996
|
+
this.stopStatusPolling(); // clear any existing poll
|
|
997
|
+
|
|
998
|
+
const poll = async () => {
|
|
999
|
+
if (!this.apiClient || !this.state.transactionRef) return;
|
|
1000
|
+
|
|
1001
|
+
try {
|
|
1002
|
+
const statusData = await this.apiClient.getPaymentStatus(this.state.transactionRef);
|
|
1003
|
+
this.handlePollingStatusUpdate(statusData.status, statusData);
|
|
1004
|
+
} catch (error: any) {
|
|
1005
|
+
console.error('[VoxePay] Status polling error:', error);
|
|
1006
|
+
// Don't stop polling on transient errors — let it retry
|
|
1007
|
+
const statusEl = this.container?.querySelector('#voxepay-polling-status');
|
|
1008
|
+
if (statusEl) {
|
|
1009
|
+
statusEl.textContent = 'Still checking... Please wait.';
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
// Initial check immediately
|
|
1015
|
+
poll();
|
|
1016
|
+
|
|
1017
|
+
// Then poll every 5 seconds
|
|
1018
|
+
this.statusPollingInterval = window.setInterval(poll, 5000);
|
|
1019
|
+
|
|
1020
|
+
// Auto-stop after 30 minutes
|
|
1021
|
+
this.statusPollingTimeout = window.setTimeout(() => {
|
|
1022
|
+
this.stopStatusPolling();
|
|
1023
|
+
this.handlePollingStatusUpdate('EXPIRED', null);
|
|
1024
|
+
}, 30 * 60 * 1000);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Stop payment status polling
|
|
1029
|
+
*/
|
|
1030
|
+
private stopStatusPolling(): void {
|
|
1031
|
+
if (this.statusPollingInterval) {
|
|
1032
|
+
clearInterval(this.statusPollingInterval);
|
|
1033
|
+
this.statusPollingInterval = null;
|
|
1034
|
+
}
|
|
1035
|
+
if (this.statusPollingTimeout) {
|
|
1036
|
+
clearTimeout(this.statusPollingTimeout);
|
|
1037
|
+
this.statusPollingTimeout = null;
|
|
868
1038
|
}
|
|
869
1039
|
}
|
|
870
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* Handle status updates from polling
|
|
1043
|
+
*/
|
|
1044
|
+
private handlePollingStatusUpdate(status: string, data: any): void {
|
|
1045
|
+
const terminalStatuses = ['COMPLETED', 'FAILED', 'EXPIRED', 'CANCELLED'];
|
|
1046
|
+
|
|
1047
|
+
if (terminalStatuses.includes(status)) {
|
|
1048
|
+
this.stopStatusPolling();
|
|
1049
|
+
this.stopTransferTimer();
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
switch (status) {
|
|
1053
|
+
case 'COMPLETED': {
|
|
1054
|
+
const result: PaymentResult = {
|
|
1055
|
+
id: data?.id || this.state.paymentId || `pay_transfer_${Date.now()}`,
|
|
1056
|
+
status: 'success',
|
|
1057
|
+
amount: this.options.amount,
|
|
1058
|
+
currency: this.options.currency,
|
|
1059
|
+
timestamp: data?.completedAt || new Date().toISOString(),
|
|
1060
|
+
reference: this.state.transactionRef || undefined,
|
|
1061
|
+
paymentMethod: 'bank_transfer',
|
|
1062
|
+
data: data || undefined,
|
|
1063
|
+
};
|
|
1064
|
+
this.state.isSuccess = true;
|
|
1065
|
+
this.renderSuccessView();
|
|
1066
|
+
this.options.onSuccess(result);
|
|
1067
|
+
break;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
case 'PAYMENT_RECEIVED': {
|
|
1071
|
+
// Almost done — update the polling UI message
|
|
1072
|
+
const statusEl = this.container?.querySelector('#voxepay-polling-status');
|
|
1073
|
+
if (statusEl) {
|
|
1074
|
+
statusEl.textContent = 'Payment received! Processing...';
|
|
1075
|
+
}
|
|
1076
|
+
break;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
case 'PARTIAL_PAYMENT': {
|
|
1080
|
+
this.stopStatusPolling();
|
|
1081
|
+
this.renderTransferErrorView(
|
|
1082
|
+
'Partial Payment Received',
|
|
1083
|
+
'The amount transferred is less than the required amount. Please transfer the remaining balance or contact support.'
|
|
1084
|
+
);
|
|
1085
|
+
break;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
case 'OVERPAID': {
|
|
1089
|
+
// Overpayment is still successful — show success but note the overpayment
|
|
1090
|
+
const result: PaymentResult = {
|
|
1091
|
+
id: data?.id || this.state.paymentId || `pay_transfer_${Date.now()}`,
|
|
1092
|
+
status: 'success',
|
|
1093
|
+
amount: this.options.amount,
|
|
1094
|
+
currency: this.options.currency,
|
|
1095
|
+
timestamp: data?.completedAt || new Date().toISOString(),
|
|
1096
|
+
reference: this.state.transactionRef || undefined,
|
|
1097
|
+
paymentMethod: 'bank_transfer',
|
|
1098
|
+
data: { ...data, overpaid: true },
|
|
1099
|
+
};
|
|
1100
|
+
this.state.isSuccess = true;
|
|
1101
|
+
this.renderSuccessView();
|
|
1102
|
+
this.options.onSuccess(result);
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
case 'EXPIRED': {
|
|
1107
|
+
this.renderTransferErrorView(
|
|
1108
|
+
'Payment Expired',
|
|
1109
|
+
'The virtual account has expired. Please try again to generate a new account.'
|
|
1110
|
+
);
|
|
1111
|
+
this.options.onError({
|
|
1112
|
+
code: 'ACCOUNT_EXPIRED',
|
|
1113
|
+
message: 'Virtual account expired before payment was received.',
|
|
1114
|
+
recoverable: true,
|
|
1115
|
+
});
|
|
1116
|
+
break;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
case 'FAILED': {
|
|
1120
|
+
this.renderTransferErrorView(
|
|
1121
|
+
'Payment Failed',
|
|
1122
|
+
data?.message || 'The payment could not be processed. Please try again.'
|
|
1123
|
+
);
|
|
1124
|
+
this.options.onError({
|
|
1125
|
+
code: 'PAYMENT_FAILED',
|
|
1126
|
+
message: data?.message || 'Payment failed.',
|
|
1127
|
+
recoverable: true,
|
|
1128
|
+
});
|
|
1129
|
+
break;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
case 'CANCELLED': {
|
|
1133
|
+
this.renderTransferErrorView(
|
|
1134
|
+
'Payment Cancelled',
|
|
1135
|
+
'This payment has been cancelled.'
|
|
1136
|
+
);
|
|
1137
|
+
this.options.onError({
|
|
1138
|
+
code: 'PAYMENT_CANCELLED',
|
|
1139
|
+
message: 'Payment was cancelled.',
|
|
1140
|
+
recoverable: false,
|
|
1141
|
+
});
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
default:
|
|
1146
|
+
// INITIATED, PENDING_PAYMENT — keep polling
|
|
1147
|
+
break;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Render error view for transfer issues with retry option
|
|
1153
|
+
*/
|
|
1154
|
+
private renderTransferErrorView(title: string, message: string): void {
|
|
1155
|
+
const formContainer = this.container?.querySelector('#voxepay-form-container');
|
|
1156
|
+
if (!formContainer) return;
|
|
1157
|
+
|
|
1158
|
+
formContainer.innerHTML = `
|
|
1159
|
+
<div class="voxepay-transfer-view">
|
|
1160
|
+
<div style="text-align: center; padding: 40px 16px;">
|
|
1161
|
+
<div style="font-size: 2.5rem; margin-bottom: 16px;">❌</div>
|
|
1162
|
+
<h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 8px; color: var(--voxepay-text);">${title}</h3>
|
|
1163
|
+
<p style="font-size: 0.875rem; color: var(--voxepay-text-muted); margin-bottom: 24px;">${message}</p>
|
|
1164
|
+
<button class="voxepay-submit-btn" id="voxepay-retry-transfer"><span>Try Again</span></button>
|
|
1165
|
+
</div>
|
|
1166
|
+
</div>
|
|
1167
|
+
`;
|
|
1168
|
+
|
|
1169
|
+
const retryBtn = formContainer.querySelector('#voxepay-retry-transfer');
|
|
1170
|
+
retryBtn?.addEventListener('click', () => {
|
|
1171
|
+
// Reset state for new attempt
|
|
1172
|
+
this.state.bankTransferDetails = null;
|
|
1173
|
+
this.state.transactionRef = null;
|
|
1174
|
+
this.state.paymentId = null;
|
|
1175
|
+
formContainer.innerHTML = `
|
|
1176
|
+
<div class="voxepay-transfer-view">
|
|
1177
|
+
<div class="voxepay-transfer-loading">
|
|
1178
|
+
<div class="voxepay-spinner"></div>
|
|
1179
|
+
<p style="margin-top: 16px; color: var(--voxepay-text-muted);">Generating account details...</p>
|
|
1180
|
+
</div>
|
|
1181
|
+
</div>
|
|
1182
|
+
`;
|
|
1183
|
+
this.loadBankTransferDetails();
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
|
|
871
1187
|
/**
|
|
872
1188
|
* Start the transfer countdown timer
|
|
873
1189
|
*/
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface BankTransferDetails {
|
|
|
49
49
|
reference: string;
|
|
50
50
|
/** Expiry time in seconds (how long the account is valid for, e.g. 1800 = 30 minutes) */
|
|
51
51
|
expiresIn: number;
|
|
52
|
+
/** ISO timestamp when the virtual account expires (from API response) */
|
|
53
|
+
expiresAt?: string;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
/** Supported payment method types */
|
package/src/utils/encryption.ts
CHANGED
|
@@ -446,8 +446,8 @@ export const generateAuthData = async ({
|
|
|
446
446
|
cvv,
|
|
447
447
|
}: AuthDataParams): Promise<string> => {
|
|
448
448
|
try {
|
|
449
|
-
// Build the cipher text: version + "
|
|
450
|
-
const authDataCipher = `${version}
|
|
449
|
+
// Build the cipher text: version + "Z" + pan + "Z" + pin + ...
|
|
450
|
+
const authDataCipher = `${version}Z${pan}Z${pin}Z${expiryDate}Z${cvv}`;
|
|
451
451
|
const messageBytes = hexToBytes(toHex(authDataCipher));
|
|
452
452
|
|
|
453
453
|
// Parse RSA public key
|