Add live Stripe card charges (PaymentIntent + Elements + webhook)
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
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>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/* 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.';
|
||||
},
|
||||
};
|
||||
}());
|
||||
Reference in New Issue
Block a user