925a4b79ba
CI / No Debug Code (pull_request) Successful in 40s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Coding Standards (pull_request) Successful in 1m0s
CI / PHPStan (pull_request) Successful in 1m13s
CI / Tests (PHP 8.1) (pull_request) Successful in 2m9s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m8s
CI / Build Plugin Zip (pull_request) Has been skipped
Completes the deferred half of payments: real credit-card processing on top of the existing ledger/e-transfer/comp foundation. - StripeGateway wraps stripe/stripe-php: creates idempotent PaymentIntents (amount in cents, registration ids in metadata) and verifies webhook signatures. Stripe calls sit behind protected seams for unit testing. - PaymentService::createIntent resolves the client-side step for a new registration (card → client secret; e-transfer → display data; comp → none) with caller-ownership enforcement. - PaymentService::handleWebhook finalises a payment exactly once on payment_intent.succeeded (mark paid → confirm → receipt) and marks it failed on payment_intent.payment_failed. - PaymentEndpoint: POST /payments/intent (book_lesson) and public, signature-verified POST /payments/webhook. - PaymentRepository: setStripeIntentId / findByStripeIntentId. - StudioSettings: us_stripe_webhook_secret option, with the webhook URL and required events surfaced on the settings page. - Front end: shared payment.js mounts Stripe Payment Elements and confirms the card (or shows e-transfer instructions); Stripe.js enqueued only when configured. Wired into booking and group-class flows. Tests: new StripeGatewayTest; PaymentService card-intent + webhook cases; repository coverage. composer test/lint/cs all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
122 lines
4.8 KiB
JavaScript
122 lines
4.8 KiB
JavaScript
/* global usScheduler, Stripe */
|
|
(function () {
|
|
'use strict';
|
|
|
|
const { restUrl, nonce, stripeKey } = usScheduler;
|
|
|
|
function apiFetch(path, options = {}) {
|
|
return fetch(restUrl + path, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-WP-Nonce': nonce,
|
|
...(options.headers || {}),
|
|
},
|
|
}).then(async (res) => {
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.message || 'Request failed');
|
|
return data;
|
|
});
|
|
}
|
|
|
|
function escHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function money(amount, currency) {
|
|
return `${Number(amount).toFixed(2)} ${escHtml(currency || '')}`.trim();
|
|
}
|
|
|
|
// Render Stripe's Payment Element into mountEl and resolve once the card has
|
|
// been confirmed. The server is told the result out-of-band via webhook, so a
|
|
// successful confirm here just means "the charge is on its way".
|
|
function confirmCard(intent, mountEl) {
|
|
return new Promise((resolve, reject) => {
|
|
if (typeof Stripe === 'undefined') {
|
|
reject(new Error('Card payment is unavailable right now. Please try again later.'));
|
|
return;
|
|
}
|
|
|
|
const stripe = Stripe(intent.publishable_key || stripeKey);
|
|
const elements = stripe.elements({ clientSecret: intent.client_secret });
|
|
const paymentEl = elements.create('payment');
|
|
|
|
mountEl.innerHTML = `
|
|
<div class="us-pay">
|
|
<h3>Payment</h3>
|
|
<p>Amount due: ${money(intent.amount, intent.currency)}</p>
|
|
<div id="us-card-element"></div>
|
|
<div id="us-card-error" role="alert" class="us-card-error" style="display:none;"></div>
|
|
<p><button type="button" id="us-pay-btn" class="us-book-btn">Pay now</button></p>
|
|
</div>`;
|
|
paymentEl.mount('#us-card-element');
|
|
|
|
const payBtn = mountEl.querySelector('#us-pay-btn');
|
|
const cardError = mountEl.querySelector('#us-card-error');
|
|
|
|
payBtn.addEventListener('click', () => {
|
|
payBtn.disabled = true;
|
|
cardError.style.display = 'none';
|
|
stripe.confirmPayment({ elements, redirect: 'if_required' })
|
|
.then((result) => {
|
|
if (result.error) {
|
|
cardError.textContent = result.error.message;
|
|
cardError.style.display = 'block';
|
|
payBtn.disabled = false;
|
|
return;
|
|
}
|
|
resolve(intent);
|
|
})
|
|
.catch((err) => {
|
|
cardError.textContent = err.message;
|
|
cardError.style.display = 'block';
|
|
payBtn.disabled = false;
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Public helper used by the booking and group-class flows. Given a freshly
|
|
// created registration, drive its payment step and resolve with the intent
|
|
// result (method card/etransfer/comp) so the caller can show a message.
|
|
window.usPayment = {
|
|
collect(type, registrationId, mountEl) {
|
|
if (!registrationId) {
|
|
return Promise.resolve(null);
|
|
}
|
|
|
|
return apiFetch('payments/intent', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ registration_type: type, registration_id: registrationId }),
|
|
}).then((intent) => {
|
|
if (intent.method === 'card' && intent.client_secret) {
|
|
return confirmCard(intent, mountEl);
|
|
}
|
|
return intent;
|
|
});
|
|
},
|
|
|
|
// Human-readable confirmation copy for a completed payment step.
|
|
message(result) {
|
|
if (!result) {
|
|
return 'Your registration is confirmed.';
|
|
}
|
|
if (result.method === 'etransfer') {
|
|
const where = result.etransfer_email
|
|
? ` to ${escHtml(result.etransfer_email)}`
|
|
: '';
|
|
return `Please send an e-transfer of ${money(result.amount, result.currency)}${where}. `
|
|
+ 'Your spot is reserved and will be confirmed once payment is received.';
|
|
}
|
|
if (result.method === 'card') {
|
|
return 'Thank you — your payment is processing. Your registration will be confirmed shortly.';
|
|
}
|
|
return 'Your registration is confirmed.';
|
|
},
|
|
};
|
|
}());
|