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

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:
2026-06-08 15:51:37 -03:00
parent 2aa0d5ad5d
commit 925a4b79ba
16 changed files with 762 additions and 22 deletions
+8 -4
View File
@@ -179,13 +179,17 @@
accepted_policy_version_ids: accepted,
}),
})
.then(() => {
slotList.style.display = 'none';
confirm.style.display = 'block';
})
.then((res) => window.usPayment.collect('lesson', (res.ids || [])[0], slotList))
.then((result) => showConfirmation(window.usPayment.message(result)))
.catch((err) => showError(err.message));
}
function showConfirmation(message) {
confirm.textContent = message;
slotList.style.display = 'none';
confirm.style.display = 'block';
}
function loadSlots() {
clearError();
slotList.style.display = 'block';
+8 -4
View File
@@ -142,13 +142,17 @@
accepted_policy_version_ids: accepted,
}),
})
.then(() => {
list.style.display = 'none';
confirm.style.display = 'block';
})
.then((res) => window.usPayment.collect('enrollment', res.id, list))
.then((result) => showConfirmation(window.usPayment.message(result)))
.catch((err) => showError(err.message));
}
function showConfirmation(message) {
confirm.textContent = message;
list.style.display = 'none';
confirm.style.display = 'block';
}
function loadClasses() {
clearError();
list.style.display = 'block';
+121
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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.';
},
};
}());