Merge pull request 'Add live Stripe card charges (PaymentIntent + Elements + webhook)' (#28) from feature/payments-stripe into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 52s
CI / Tests (PHP 8.3) (push) Successful in 52s
CI / Build Plugin Zip (push) Successful in 57s
CI / Coding Standards (push) Successful in 55s
CI / PHPStan (push) Successful in 1m10s
CI / Tests (PHP 8.2) (push) Successful in 1m29s

Reviewed-on: #28
This commit was merged in pull request #28.
This commit is contained in:
2026-06-08 18:59:42 +00:00
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, '&')
.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.';
},
};
}());
+2 -1
View File
@@ -4,7 +4,8 @@
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.1"
"php": ">=8.1",
"stripe/stripe-php": "^17.0"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
+7 -5
View File
@@ -11,12 +11,13 @@ numbered receipt is emailed automatically when a payment is marked paid.
> **Implemented:** the payment ledger, studio settings, method resolution
> (e-transfer default with no Stripe; card default when configured; per-student
> override), the e-transfer/comp flow with admin confirmation + receipts, and
> override), the e-transfer/comp flow with admin confirmation + receipts,
> integration into booking/enrolment (a registration's `payment_id` is linked;
> comp auto-confirms; e-transfer stays pending until confirmed).
> **Deferred to a follow-up:** the live Stripe card charge (PaymentIntent +
> Stripe.js Elements + webhook + `stripe/stripe-php`). Until then a `card`
> payment is created `pending` and can be confirmed like an e-transfer.
> comp auto-confirms; e-transfer stays pending until confirmed), and the **live
> Stripe card charge** — a PaymentIntent created on `POST /payments/intent`,
> confirmed in the browser with Stripe.js Payment Elements, and finalised by the
> `POST /payments/webhook` handler (signature-verified) on
> `payment_intent.succeeded`. Uses the `stripe/stripe-php` SDK.
## Stripe Configuration
Stripe credentials live in WordPress options, managed on the **Studio Settings**
@@ -26,6 +27,7 @@ page (`manage_billing`, studio admin only):
|------------------------------|----------------------------------------|
| `us_stripe_publishable_key` | Stripe publishable key |
| `us_stripe_secret_key` | Stripe secret key |
| `us_stripe_webhook_secret` | Webhook signing secret (`whsec_…`) |
| `us_stripe_mode` | `test` or `live` |
| `us_currency` | Default ISO 4217 currency, e.g. `CAD` |
| `us_etransfer_email` | Studio-default e-transfer destination |
+72
View File
@@ -21,6 +21,78 @@ class PaymentEndpoint {
],
]
);
register_rest_route(
$route_namespace,
'/payments/intent',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'createIntent' ],
'permission_callback' => [ $this, 'canBook' ],
'args' => [
'registration_type' => [
'type' => 'string',
'required' => true,
'enum' => [ Payment::REG_LESSON, Payment::REG_ENROLLMENT ],
],
'registration_id' => [
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
],
],
],
]
);
register_rest_route(
$route_namespace,
'/payments/webhook',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'webhook' ],
'permission_callback' => '__return_true',
],
]
);
}
/**
* Create the client-side payment step for a registration the student just made
* (Stripe client secret for card; display data for e-transfer/comp).
*/
public function createIntent( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$type = (string) $request->get_param( 'registration_type' );
$registrationId = absint( $request->get_param( 'registration_id' ) );
$result = $this->service->createIntent( $type, $registrationId, get_current_user_id() );
if ( null === $result ) {
return new \WP_Error( 'intent_failed', __( 'Could not start payment for this registration.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
return new \WP_REST_Response( $result, 200 );
}
/**
* Stripe-to-server webhook. The body is verified against the signing secret
* before any ledger change; an invalid signature is rejected with 400 so Stripe
* retries are not silently accepted.
*/
public function webhook( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$payload = $request->get_body();
$signature = (string) $request->get_header( 'stripe_signature' );
if ( ! $this->service->handleWebhook( $payload, $signature ) ) {
return new \WP_Error( 'invalid_signature', __( 'Invalid webhook signature.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
return new \WP_REST_Response( [ 'received' => true ], 200 );
}
public function canBook(): bool {
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
}
/**
+25
View File
@@ -38,6 +38,31 @@ class PaymentRepository {
return $this->db->insert_id;
}
/**
* Attach the Stripe PaymentIntent id created for a card payment so the webhook
* can later reconcile the charge back to this row.
*/
public function setStripeIntentId( int $id, string $intentId ): bool {
return false !== $this->db->update(
$this->table,
[ 'stripe_payment_intent_id' => $intentId ],
[ 'id' => $id ],
[ '%s' ],
[ '%d' ]
);
}
public function findByStripeIntentId( string $intentId ): ?Payment {
$row = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1",
$intentId
)
);
return $row ? Payment::fromRow( $row ) : null;
}
public function updateEtransferEmail( int $id, ?string $email ): bool {
return false !== $this->db->update(
$this->table,
+79
View File
@@ -20,6 +20,7 @@ class PaymentService {
private BookingRepository $bookings,
private EnrollmentRepository $enrollments,
private StudioSettings $settings,
private StripeGateway $stripe,
) {}
/**
@@ -88,6 +89,84 @@ class PaymentService {
return true;
}
/**
* Resolve the client-side payment step for a freshly created registration.
* For a card payment a Stripe PaymentIntent is created (or replayed
* idempotently) and its client secret returned so the browser can confirm the
* card; e-transfer returns the destination and amount to display; comp/paid
* needs no further action. Returns null when the registration has no payment,
* the caller does not own it, or Stripe could not create the intent.
*
* @return array<string, mixed>|null
*/
public function createIntent( string $type, int $registrationId, int $studentId ): ?array {
$payment = $this->payments->findByRegistration( $type, $registrationId );
if ( null === $payment || null === $payment->id || $payment->studentId !== $studentId ) {
return null;
}
$base = [
'payment_id' => $payment->id,
'method' => $payment->method,
'status' => $payment->status,
'amount' => $payment->total(),
'currency' => $payment->currency,
];
// Comp (already paid) or anything else settled needs no client action.
if ( $payment->isPaid() || Payment::METHOD_CARD !== $payment->method ) {
if ( Payment::METHOD_ETRANSFER === $payment->method ) {
$base['etransfer_email'] = $payment->etransferEmail;
}
return $base;
}
$intent = $this->stripe->createIntent( $payment );
if ( null === $intent ) {
return null;
}
$this->payments->setStripeIntentId( $payment->id, (string) $intent->id );
$base['client_secret'] = (string) $intent->client_secret;
$base['publishable_key'] = $this->settings->publishableKey();
return $base;
}
/**
* Process a verified Stripe webhook. Returns false only when the signature
* fails verification (so the endpoint can reply 400); a true result means the
* event was authentic and has been acknowledged, whether or not it matched a
* ledger row. A succeeded intent finalises the matching payment exactly once;
* a failed intent marks an unpaid payment `failed`.
*/
public function handleWebhook( string $payload, string $signatureHeader ): bool {
$event = $this->stripe->verifyWebhook( $payload, $signatureHeader );
if ( null === $event ) {
return false;
}
$intent = $event->data->object ?? null;
if ( ! $intent instanceof \Stripe\PaymentIntent ) {
return true;
}
$payment = $this->payments->findByStripeIntentId( (string) $intent->id );
if ( null === $payment || null === $payment->id ) {
return true;
}
if ( 'payment_intent.succeeded' === $event->type && ! $payment->isPaid() ) {
$this->finalizePaid( $payment->id, $payment->registrationType, $payment->registrationId, $payment->studentId );
} elseif ( 'payment_intent.payment_failed' === $event->type && ! $payment->isPaid() ) {
$this->payments->updateStatus( $payment->id, Payment::STATUS_FAILED );
}
return true;
}
private function finalizePaid( int $paymentId, string $type, int $registrationId, int $studentId ): void {
$this->payments->markPaid( $paymentId, 'USC-' . $paymentId );
$this->confirmRegistration( $type, $registrationId );
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Stripe\Event;
use Stripe\PaymentIntent;
use Stripe\StripeClient;
use Stripe\Webhook;
/**
* Thin wrapper around the Stripe PHP SDK: creates PaymentIntents for card
* payments and verifies inbound webhook signatures. All Stripe-specific knowledge
* (amounts in cents, idempotency, signature checking) lives here so the rest of
* the payment domain stays gateway-agnostic.
*/
class StripeGateway {
private ?StripeClient $client;
/**
* Build the gateway. The Stripe client is created lazily from the configured
* secret key, or injected directly in tests.
*
* @param StripeClient|null $client Injectable for tests; built lazily when null.
*/
public function __construct( private StudioSettings $settings, ?StripeClient $client = null ) {
$this->client = $client;
}
public function isConfigured(): bool {
return $this->settings->isStripeConfigured();
}
/**
* Create (or, when one already exists, return) a PaymentIntent for the billed
* total of a payment. The payment id is stored in metadata so the webhook can
* reconcile the charge back to our ledger row. Returns null on any Stripe error
* so callers can fail gracefully.
*/
public function createIntent( Payment $payment ): ?PaymentIntent {
if ( ! $this->isConfigured() ) {
return null;
}
try {
return $this->paymentIntentsCreate(
[
'amount' => $this->toMinorUnits( $payment->total() ),
'currency' => strtolower( $payment->currency ),
'metadata' => [
'payment_id' => (string) $payment->id,
'registration_type' => $payment->registrationType,
'registration_id' => (string) $payment->registrationId,
'student_id' => (string) $payment->studentId,
],
'description' => sprintf( 'Lesson payment #%d', (int) $payment->id ),
],
[ 'idempotency_key' => 'usc-payment-' . $payment->id ]
);
} catch ( \Throwable $e ) {
return null;
}
}
/**
* Seam around the Stripe PaymentIntents create call so tests can stub the
* network request.
*
* @param array<string, mixed> $params
* @param array<string, mixed> $options
*/
protected function paymentIntentsCreate( array $params, array $options ): PaymentIntent {
return $this->client()->paymentIntents->create( $params, $options );
}
/**
* Verify a raw webhook payload against its signature header and return the
* decoded Stripe event, or null when verification fails or no signing secret is
* configured.
*/
public function verifyWebhook( string $payload, string $signatureHeader ): ?Event {
$secret = $this->settings->webhookSecret();
if ( '' === $secret || '' === $signatureHeader ) {
return null;
}
try {
return $this->constructEvent( $payload, $signatureHeader, $secret );
} catch ( \Throwable $e ) {
return null;
}
}
/**
* Seam around the static Stripe verifier so tests can stub signature checking.
*/
protected function constructEvent( string $payload, string $signatureHeader, string $secret ): Event {
return Webhook::constructEvent( $payload, $signatureHeader, $secret );
}
private function client(): StripeClient {
if ( null === $this->client ) {
$this->client = new StripeClient( $this->settings->secretKey() );
}
return $this->client;
}
/**
* Convert a dollar amount to the integer minor units (cents) Stripe expects.
*/
private function toMinorUnits( float $amount ): int {
return (int) round( $amount * 100 );
}
}
+12
View File
@@ -9,6 +9,7 @@ class StudioSettings {
public const OPT_PUBLISHABLE = 'us_stripe_publishable_key';
public const OPT_SECRET = 'us_stripe_secret_key';
public const OPT_WEBHOOK_SECRET = 'us_stripe_webhook_secret';
public const OPT_MODE = 'us_stripe_mode';
public const OPT_CURRENCY = 'us_currency';
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
@@ -22,6 +23,14 @@ class StudioSettings {
return (string) get_option( self::OPT_SECRET, '' );
}
/**
* The Stripe webhook signing secret (`whsec_…`) used to verify that incoming
* webhook requests genuinely came from Stripe. Empty until configured.
*/
public function webhookSecret(): string {
return (string) get_option( self::OPT_WEBHOOK_SECRET, '' );
}
public function mode(): string {
return 'live' === get_option( self::OPT_MODE, 'test' ) ? 'live' : 'test';
}
@@ -66,6 +75,8 @@ class StudioSettings {
$publishableKey = $this->publishableKey();
$secretKey = $this->secretKey();
$webhookSecret = $this->webhookSecret();
$webhookUrl = rest_url( 'us-scheduler/v1/payments/webhook' );
$mode = $this->mode();
$currency = $this->currency();
$etransferEmail = $this->etransferEmail();
@@ -81,6 +92,7 @@ class StudioSettings {
$mode = sanitize_key( wp_unslash( $_POST['mode'] ?? 'test' ) );
update_option( self::OPT_PUBLISHABLE, sanitize_text_field( wp_unslash( $_POST['publishable_key'] ?? '' ) ) );
update_option( self::OPT_SECRET, sanitize_text_field( wp_unslash( $_POST['secret_key'] ?? '' ) ) );
update_option( self::OPT_WEBHOOK_SECRET, sanitize_text_field( wp_unslash( $_POST['webhook_secret'] ?? '' ) ) );
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' );
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) );
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
+3 -1
View File
@@ -13,6 +13,7 @@ use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\ReceiptMailer;
use Unsupervised\Schedular\Payment\StripeGateway;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyRepository;
@@ -44,7 +45,8 @@ class Plugin {
$paymentRepo = new PaymentRepository( $wpdb );
$settings = new StudioSettings();
$resolver = new BillingMethodResolver( $settings );
$paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings );
$stripe = new StripeGateway( $settings );
$paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe );
( new RoleManager() )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
+23 -6
View File
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
@@ -43,15 +44,31 @@ class ShortcodeRegistrar {
public function enqueueAssets(): void {
wp_register_style( 'us-scheduler', USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
$settings = new StudioSettings();
// Stripe.js (loaded from Stripe's CDN per their terms) only when card billing
// is available; the payment helper degrades to e-transfer messaging without it.
$paymentDeps = [];
if ( $settings->isStripeConfigured() ) {
// Stripe pins the version in the URL path (/v3/) and forbids self-hosting,
// so no query-string version applies here.
wp_register_script( 'us-scheduler-stripe', 'https://js.stripe.com/v3/', [], null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
$paymentDeps[] = 'us-scheduler-stripe';
}
wp_register_script( 'us-scheduler-payment', USC_PLUGIN_URL . 'assets/js/payment.js', $paymentDeps, USC_VERSION, true );
$data = [
'restUrl' => rest_url( 'us-scheduler/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'restUrl' => rest_url( 'us-scheduler/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'stripeKey' => $settings->publishableKey(),
];
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [], USC_VERSION, true );
wp_localize_script( 'us-scheduler', 'usScheduler', $data );
// Attach the shared config to the payment helper so it is defined before the
// booking/group scripts (which depend on it) run.
wp_localize_script( 'us-scheduler-payment', 'usScheduler', $data );
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [], USC_VERSION, true );
wp_localize_script( 'us-scheduler-group', 'usScheduler', $data );
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
}
}
+13
View File
@@ -8,6 +8,8 @@ if (! defined('ABSPATH')) {
/**
* @var string $publishableKey
* @var string $secretKey
* @var string $webhookSecret
* @var string $webhookUrl
* @var string $mode
* @var string $currency
* @var string $etransferEmail
@@ -41,6 +43,17 @@ if (! defined('ABSPATH')) {
<th><label for="secret_key"><?php esc_html_e('Secret key', 'unsupervised-schedular'); ?></label></th>
<td><input type="password" name="secret_key" id="secret_key" class="regular-text" value="<?php echo esc_attr($secretKey); ?>" autocomplete="off"></td>
</tr>
<tr>
<th><label for="webhook_secret"><?php esc_html_e('Webhook signing secret', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="password" name="webhook_secret" id="webhook_secret" class="regular-text" value="<?php echo esc_attr($webhookSecret); ?>" autocomplete="off">
<p class="description">
<?php esc_html_e('In the Stripe Dashboard, add a webhook endpoint for the payment_intent.succeeded and payment_intent.payment_failed events pointing at:', 'unsupervised-schedular'); ?><br>
<code><?php echo esc_html($webhookUrl); ?></code><br>
<?php esc_html_e('then paste its signing secret (whsec_…) here. Card payments are only marked paid once Stripe confirms them via this webhook.', 'unsupervised-schedular'); ?>
</p>
</td>
</tr>
<tr>
<th><label for="mode"><?php esc_html_e('Mode', 'unsupervised-schedular'); ?></label></th>
<td>
@@ -98,6 +98,42 @@ class PaymentRepositoryTest extends TestCase
self::assertCount(0, $this->repo->findPaidBetween('2026-06-01 00:00:00', '2026-07-01 00:00:00'));
}
public function testSetStripeIntentIdUpdatesRow(): void
{
$this->db->shouldReceive('update')
->once()
->with(
'wp_us_payments',
['stripe_payment_intent_id' => 'pi_123'],
['id' => 50],
['%s'],
['%d']
)
->andReturn(1);
self::assertTrue($this->repo->setStripeIntentId(50, 'pi_123'));
}
public function testFindByStripeIntentIdReturnsPayment(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/stripe_payment_intent_id = %s/'), 'pi_123')
->andReturn('SELECT ...');
$this->db->shouldReceive('get_row')->andReturn($this->row());
self::assertInstanceOf(Payment::class, $this->repo->findByStripeIntentId('pi_123'));
}
public function testFindByStripeIntentIdReturnsNullWhenMissing(): void
{
$this->db->shouldReceive('prepare')->andReturn('SELECT ...');
$this->db->shouldReceive('get_row')->andReturn(null);
self::assertNull($this->repo->findByStripeIntentId('pi_missing'));
}
public function testFindByRegistrationReturnsPayment(): void
{
$this->db->shouldReceive('prepare')
+126 -1
View File
@@ -13,6 +13,7 @@ use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\ReceiptMailer;
use Unsupervised\Schedular\Payment\StripeGateway;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase;
@@ -24,6 +25,7 @@ class PaymentServiceTest extends TestCase
private BookingRepository $bookings;
private EnrollmentRepository $enrollments;
private StudioSettings $settings;
private StripeGateway $stripe;
private PaymentService $service;
protected function setUp(): void
@@ -36,6 +38,7 @@ class PaymentServiceTest extends TestCase
$this->bookings = Mockery::mock(BookingRepository::class);
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->settings = Mockery::mock(StudioSettings::class);
$this->stripe = Mockery::mock(StripeGateway::class);
$this->settings->shouldReceive('etransferEmail')->andReturn('');
$this->settings->shouldReceive('hstRate')->andReturn(0.0)->byDefault();
@@ -45,7 +48,8 @@ class PaymentServiceTest extends TestCase
$this->mailer,
$this->bookings,
$this->enrollments,
$this->settings
$this->settings,
$this->stripe
);
Functions\when('get_userdata')->justReturn(false);
@@ -166,4 +170,125 @@ class PaymentServiceTest extends TestCase
// Already paid → no markPaid/confirm calls.
self::assertTrue($this->service->markPaid(80));
}
public function testCreateIntentForCardReturnsClientSecret(): void
{
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90));
$intent = \Stripe\PaymentIntent::constructFrom(['id' => 'pi_abc', 'client_secret' => 'pi_abc_secret']);
$this->stripe->shouldReceive('createIntent')->once()
->with(Mockery::on(static fn (Payment $p): bool => $p->id === 90))
->andReturn($intent);
$this->payments->shouldReceive('setStripeIntentId')->once()->with(90, 'pi_abc')->andReturn(true);
$this->settings->shouldReceive('publishableKey')->andReturn('pk_test_123');
$result = $this->service->createIntent(Payment::REG_LESSON, 12, 5);
self::assertSame('card', $result['method']);
self::assertSame('pi_abc_secret', $result['client_secret']);
self::assertSame('pk_test_123', $result['publishable_key']);
}
public function testCreateIntentForEtransferReturnsDisplayDataWithoutStripe(): void
{
$payment = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, etransferEmail: 'pay@studio.test', id: 91);
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)->andReturn($payment);
$this->stripe->shouldNotReceive('createIntent');
$result = $this->service->createIntent(Payment::REG_LESSON, 12, 5);
self::assertSame('etransfer', $result['method']);
self::assertSame('pay@studio.test', $result['etransfer_email']);
self::assertArrayNotHasKey('client_secret', $result);
}
public function testCreateIntentReturnsNullWhenNotOwner(): void
{
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90));
// Student 999 does not own payment whose studentId is 5.
self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 999));
}
public function testCreateIntentReturnsNullWhenNoPayment(): void
{
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)->andReturn(null);
self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 5));
}
public function testCreateIntentReturnsNullWhenStripeFails(): void
{
$this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90));
$this->stripe->shouldReceive('createIntent')->once()->andReturn(null);
self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 5));
}
public function testHandleWebhookInvalidSignatureReturnsFalse(): void
{
$this->stripe->shouldReceive('verifyWebhook')->with('{}', 'bad-sig')->andReturn(null);
self::assertFalse($this->service->handleWebhook('{}', 'bad-sig'));
}
public function testHandleWebhookSucceededFinalizesPayment(): void
{
$event = $this->intentEvent('payment_intent.succeeded', 'pi_ok');
$this->stripe->shouldReceive('verifyWebhook')->andReturn($event);
$this->payments->shouldReceive('findByStripeIntentId')->with('pi_ok')
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90));
$this->payments->shouldReceive('markPaid')->once()->with(90, 'USC-90')->andReturn(true);
$this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true);
$this->payments->shouldReceive('findById')->with(90)->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PAID, 90));
$this->mailer->shouldReceive('send')->andReturn(false);
self::assertTrue($this->service->handleWebhook('{}', 'sig'));
}
public function testHandleWebhookSucceededIdempotentWhenAlreadyPaid(): void
{
$event = $this->intentEvent('payment_intent.succeeded', 'pi_ok');
$this->stripe->shouldReceive('verifyWebhook')->andReturn($event);
$this->payments->shouldReceive('findByStripeIntentId')->with('pi_ok')
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PAID, 90));
// Already paid → no markPaid/confirm.
self::assertTrue($this->service->handleWebhook('{}', 'sig'));
}
public function testHandleWebhookFailedMarksFailed(): void
{
$event = $this->intentEvent('payment_intent.payment_failed', 'pi_bad');
$this->stripe->shouldReceive('verifyWebhook')->andReturn($event);
$this->payments->shouldReceive('findByStripeIntentId')->with('pi_bad')
->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90));
$this->payments->shouldReceive('updateStatus')->once()->with(90, Payment::STATUS_FAILED)->andReturn(true);
self::assertTrue($this->service->handleWebhook('{}', 'sig'));
}
public function testHandleWebhookAcknowledgesUnknownIntent(): void
{
$event = $this->intentEvent('payment_intent.succeeded', 'pi_unknown');
$this->stripe->shouldReceive('verifyWebhook')->andReturn($event);
$this->payments->shouldReceive('findByStripeIntentId')->with('pi_unknown')->andReturn(null);
self::assertTrue($this->service->handleWebhook('{}', 'sig'));
}
private function intentEvent(string $type, string $intentId): \Stripe\Event
{
$intent = \Stripe\PaymentIntent::constructFrom(['id' => $intentId, 'object' => 'payment_intent']);
return \Stripe\Event::constructFrom(['type' => $type, 'data' => ['object' => $intent]]);
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Payment;
use Mockery;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\StripeGateway;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class StripeGatewayTest extends TestCase
{
private StudioSettings $settings;
protected function setUp(): void
{
parent::setUp();
$this->settings = Mockery::mock(StudioSettings::class);
}
private function partialGateway(): StripeGateway
{
return Mockery::mock(StripeGateway::class, [$this->settings])
->makePartial()
->shouldAllowMockingProtectedMethods();
}
private function payment(): Payment
{
return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_CARD, Payment::STATUS_PENDING, id: 90);
}
public function testCreateIntentReturnsNullWhenNotConfigured(): void
{
$this->settings->shouldReceive('isStripeConfigured')->andReturn(false);
$gateway = new StripeGateway($this->settings);
self::assertNull($gateway->createIntent($this->payment()));
}
public function testCreateIntentSendsAmountInCentsAndReturnsIntent(): void
{
$this->settings->shouldReceive('isStripeConfigured')->andReturn(true);
$intent = \Stripe\PaymentIntent::constructFrom(['id' => 'pi_1', 'client_secret' => 'cs_1']);
$gateway = $this->partialGateway();
$gateway->shouldReceive('paymentIntentsCreate')
->once()
->with(
Mockery::on(static fn (array $p): bool => $p['amount'] === 3500
&& $p['currency'] === 'cad'
&& $p['metadata']['payment_id'] === '90'),
Mockery::on(static fn (array $o): bool => $o['idempotency_key'] === 'usc-payment-90')
)
->andReturn($intent);
self::assertSame('pi_1', $gateway->createIntent($this->payment())->id);
}
public function testCreateIntentReturnsNullOnStripeError(): void
{
$this->settings->shouldReceive('isStripeConfigured')->andReturn(true);
$gateway = $this->partialGateway();
$gateway->shouldReceive('paymentIntentsCreate')->once()->andThrow(new \RuntimeException('declined'));
self::assertNull($gateway->createIntent($this->payment()));
}
public function testVerifyWebhookReturnsNullWithoutSecret(): void
{
$this->settings->shouldReceive('webhookSecret')->andReturn('');
$gateway = new StripeGateway($this->settings);
self::assertNull($gateway->verifyWebhook('{}', 'sig'));
}
public function testVerifyWebhookReturnsNullWithoutSignatureHeader(): void
{
$this->settings->shouldReceive('webhookSecret')->andReturn('whsec_123');
$gateway = new StripeGateway($this->settings);
self::assertNull($gateway->verifyWebhook('{}', ''));
}
public function testVerifyWebhookReturnsNullOnInvalidSignature(): void
{
$this->settings->shouldReceive('webhookSecret')->andReturn('whsec_123');
$gateway = $this->partialGateway();
$gateway->shouldReceive('constructEvent')->once()->andThrow(new \RuntimeException('bad signature'));
self::assertNull($gateway->verifyWebhook('{}', 'sig'));
}
public function testVerifyWebhookReturnsEventOnSuccess(): void
{
$this->settings->shouldReceive('webhookSecret')->andReturn('whsec_123');
$event = \Stripe\Event::constructFrom(['type' => 'payment_intent.succeeded']);
$gateway = $this->partialGateway();
$gateway->shouldReceive('constructEvent')->once()->with('{payload}', 'sig', 'whsec_123')->andReturn($event);
self::assertSame('payment_intent.succeeded', $gateway->verifyWebhook('{payload}', 'sig')->type);
}
}