/**
|
* HelcimCheckout — extends CartCheckout for HelcimPay.js payments
|
*
|
* Payment flow:
|
* 1. User clicks checkout → extractOrderData()
|
* 2. Server call to /helcim/initialize-checkout → returns checkoutToken
|
* 3. Call appendHelcimPayIframe(checkoutToken) → Helcim renders modal
|
* 4. Listen for window 'message' event → SUCCESS / CANCELLED / ERROR
|
* 5. On SUCCESS, validate transaction server-side
|
*
|
* @see https://devdocs.helcim.com/docs/helcim-pay-js
|
*/
|
class CheckoutHelcim extends window.jvbCheckout {
|
constructor(config = {}) {
|
super({
|
...window.helcimConfig,
|
...config,
|
});
|
this.pendingSecretToken = null;
|
}
|
|
/*****************************************************************
|
* INIT — HelcimPay.js SDK (loaded externally)
|
*****************************************************************/
|
|
async init() {
|
// HelcimPay.js is loaded via <script> tag, no SDK init needed.
|
// We just need the global appendHelcimPayIframe function.
|
if (typeof window.appendHelcimPayIframe !== 'function') {
|
console.warn('HelcimPay.js SDK not loaded — payment will initialize on first checkout');
|
}
|
|
this.isInitialized = true;
|
|
// Listen for HelcimPay.js message events
|
window.addEventListener('message', (e) => this.handleHelcimMessage(e));
|
|
document.dispatchEvent(new CustomEvent('checkoutReady', {
|
detail: { checkout: this, provider: 'helcim' }
|
}));
|
}
|
|
/*****************************************************************
|
* PAYMENT FLOW
|
*****************************************************************/
|
|
async processPayment(orderData) {
|
// If using a saved card, process server-side directly
|
if (this.selectedCardId) {
|
return this.submitToServer({
|
card_id: this.selectedCardId,
|
is_saved: true,
|
}, orderData);
|
}
|
|
// Otherwise, initialize HelcimPay.js checkout
|
const session = await this.initializeCheckoutSession(orderData);
|
if (!session.success) {
|
throw new Error(session.message || 'Failed to initialize checkout');
|
}
|
|
// Store secretToken for server-side validation after payment
|
this.pendingSecretToken = session.secretToken;
|
this.pendingOrderData = orderData;
|
|
// Open HelcimPay.js iframe modal
|
window.appendHelcimPayIframe(session.checkoutToken, {
|
type: 'modal', // 'modal' or 'inline'
|
});
|
|
// The flow continues in handleHelcimMessage() when the iframe posts back
|
// Return a promise that resolves when payment completes
|
return new Promise((resolve, reject) => {
|
this._paymentResolve = resolve;
|
this._paymentReject = reject;
|
});
|
}
|
|
/**
|
* Server call: initialize a HelcimPay.js checkout session
|
*/
|
async initializeCheckoutSession(orderData) {
|
const response = await fetch(this.config.api_url + 'initialize-checkout', {
|
method: 'POST',
|
headers: {
|
'Content-Type': 'application/json',
|
'X-WP-Nonce': this.config.nonce,
|
},
|
body: JSON.stringify({
|
amount: orderData.total / 100, // Convert cents back to dollars
|
customer: orderData.customer,
|
items: orderData.items,
|
cart_id: this.getCartId(),
|
}),
|
});
|
|
return response.json();
|
}
|
|
/**
|
* Handle postMessage events from HelcimPay.js iframe
|
*/
|
handleHelcimMessage(event) {
|
const data = event.data;
|
|
// HelcimPay.js sends messages with specific event types
|
if (!data || typeof data !== 'object') return;
|
|
// Helcim sends eventStatus: 'ABORTED' | 'SUCCESS' | 'FAILED'
|
if (data.eventStatus === 'SUCCESS') {
|
this.handleHelcimSuccess(data);
|
} else if (data.eventStatus === 'ABORTED') {
|
this.handleHelcimCancelled();
|
} else if (data.eventStatus === 'FAILED') {
|
this.handleHelcimError(data);
|
}
|
}
|
|
async handleHelcimSuccess(data) {
|
try {
|
// Validate the transaction server-side using secretToken
|
const result = await this.submitToServer({
|
transaction_id: data.transactionId,
|
secret_token: this.pendingSecretToken,
|
event_data: data,
|
}, this.pendingOrderData);
|
|
this.clearPending();
|
this._paymentResolve?.(result);
|
} catch (error) {
|
this.clearPending();
|
this._paymentReject?.(error);
|
}
|
}
|
|
handleHelcimCancelled() {
|
this.clearPending();
|
window.jvbLoading?.hideLoading?.();
|
this.a11y.announce('Payment cancelled');
|
this._paymentReject?.(new Error('Payment cancelled by user'));
|
}
|
|
handleHelcimError(data) {
|
this.clearPending();
|
window.jvbLoading?.hideLoading?.();
|
const message = data.errorMessage || 'Payment failed';
|
this._paymentReject?.(new Error(message));
|
}
|
|
clearPending() {
|
this.pendingSecretToken = null;
|
this.pendingOrderData = null;
|
}
|
|
/*****************************************************************
|
* SERVER COMMUNICATION
|
*****************************************************************/
|
|
async submitToServer(paymentData, orderData) {
|
if (!this.isOpen) {
|
throw new Error('Store is currently closed');
|
}
|
|
const endpoint = paymentData.is_saved
|
? 'process-saved-payment'
|
: 'validate-transaction';
|
|
const response = await fetch(this.config.api_url + endpoint, {
|
method: 'POST',
|
headers: {
|
'Content-Type': 'application/json',
|
'X-WP-Nonce': this.config.nonce,
|
},
|
body: JSON.stringify({
|
...paymentData,
|
cart_id: this.getCartId(),
|
amount: orderData.total,
|
items: orderData.items,
|
customer: {
|
email: this.isLoggedIn ? this.userEmail : orderData.customer.email,
|
name: orderData.customer.name,
|
phone: orderData.customer.phone,
|
},
|
note: orderData.note,
|
pickup_time: orderData.pickup_time,
|
}),
|
});
|
|
const result = await response.json();
|
|
if (!response.ok) {
|
throw new Error(result.message || 'Payment processing failed');
|
}
|
|
this.clearCart();
|
return result;
|
}
|
|
/*****************************************************************
|
* SAVED CARDS
|
*****************************************************************/
|
|
async loadSavedCards() {
|
try {
|
const response = await fetch(this.config.api_url + 'saved-cards', {
|
method: 'GET',
|
headers: { 'X-WP-Nonce': this.config.nonce },
|
});
|
|
const result = await response.json();
|
|
if (result.success && result.cards) {
|
this.savedCards = result.cards;
|
this.renderSavedCards();
|
}
|
} catch (error) {
|
console.error('Failed to load saved cards:', error);
|
}
|
}
|
|
/*****************************************************************
|
* INVOICES — Helcim-specific (source of truth is Helcim)
|
*****************************************************************/
|
|
async loadInvoices() {
|
try {
|
const response = await fetch(this.config.api_url + 'invoices', {
|
headers: { 'X-WP-Nonce': this.config.nonce },
|
});
|
const result = await response.json();
|
if (result.success) {
|
return result.invoices || [];
|
}
|
} catch (error) {
|
console.error('Failed to load invoices:', error);
|
}
|
return [];
|
}
|
|
async payInvoice(invoiceId) {
|
const session = await fetch(this.config.api_url + 'initialize-checkout', {
|
method: 'POST',
|
headers: {
|
'Content-Type': 'application/json',
|
'X-WP-Nonce': this.config.nonce,
|
},
|
body: JSON.stringify({
|
invoice_id: invoiceId,
|
}),
|
}).then(r => r.json());
|
|
if (!session.success) {
|
throw new Error(session.message || 'Failed to initialize invoice payment');
|
}
|
|
this.pendingSecretToken = session.secretToken;
|
this.pendingOrderData = { total: 0, items: [], customer: {} };
|
|
window.appendHelcimPayIframe(session.checkoutToken, { type: 'modal' });
|
|
return new Promise((resolve, reject) => {
|
this._paymentResolve = resolve;
|
this._paymentReject = reject;
|
});
|
}
|
}
|
|
document.addEventListener('DOMContentLoaded', () => {
|
// Only init if Helcim is the active provider
|
const form = document.querySelector('#checkout[data-provider="helcim"]');
|
if (form) {
|
window.jvbHelcim = new CheckoutHelcim();
|
}
|
});
|