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:
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.';
|
||||
},
|
||||
};
|
||||
}());
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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]]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user