CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 2m50s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / PHPStan (pull_request) Successful in 2m49s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
A weekly booking on a per-lesson (one_time) priced offering was creating its single upfront payment for one week's price while reserving up to 12 weeks, and settling that payment confirmed only the anchor lesson, leaving the rest of the series pending forever. - BookingEndpoint now charges price x claimed occurrences for one_time billing; a full_term price is still charged once since it covers the term. - PaymentService::confirmRegistration resolves the anchor lesson's series and confirms every non-cancelled row via the new BookingRepository::updateStatusForSeries(). Closes #79 Co-Authored-By: Claude Fable 5 <[email protected]>
222 lines
7.7 KiB
PHP
222 lines
7.7 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Payment;
|
|
|
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
|
use Unsupervised\Schedular\Booking\Lesson;
|
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
|
|
|
/**
|
|
* Orchestrates payment creation and confirmation across the payment ledger and
|
|
* the registrations (lessons / enrolments) they pay for.
|
|
*/
|
|
class PaymentService {
|
|
|
|
public function __construct(
|
|
private PaymentRepository $payments,
|
|
private BillingMethodResolver $resolver,
|
|
private ReceiptMailer $mailer,
|
|
private BookingRepository $bookings,
|
|
private EnrollmentRepository $enrollments,
|
|
private StudioSettings $settings,
|
|
private StripeGateway $stripe,
|
|
) {}
|
|
|
|
/**
|
|
* Create the payment for a new registration and link it. Comped students are
|
|
* marked paid and confirmed immediately; everyone else gets a pending payment
|
|
* (card via Stripe — coming soon; e-transfer confirmed manually). The
|
|
* e-transfer destination is frozen now from the offering override or the studio
|
|
* default. Returns null when the registration has no price to charge.
|
|
*/
|
|
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null ): ?Payment {
|
|
if ( $amount <= 0.0 ) {
|
|
return null;
|
|
}
|
|
|
|
$method = $this->resolver->resolve( $studentId );
|
|
$status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING;
|
|
|
|
$etransferEmail = null !== $offeringEtransferEmail && '' !== $offeringEtransferEmail
|
|
? $offeringEtransferEmail
|
|
: ( '' !== $this->settings->etransferEmail() ? $this->settings->etransferEmail() : null );
|
|
|
|
// HST is frozen from the studio default at booking; comped students are not taxed.
|
|
$taxRate = Payment::METHOD_COMP === $method ? 0.0 : $this->settings->hstRate();
|
|
$taxAmount = round( $amount * $taxRate / 100, 2 );
|
|
|
|
$id = $this->payments->insert(
|
|
new Payment(
|
|
studentId: $studentId,
|
|
instructorId: $instructorId,
|
|
registrationType: $type,
|
|
registrationId: $registrationId,
|
|
amount: $amount,
|
|
currency: $currency,
|
|
method: $method,
|
|
status: $status,
|
|
taxRate: $taxRate,
|
|
taxAmount: $taxAmount,
|
|
etransferEmail: $etransferEmail,
|
|
)
|
|
);
|
|
|
|
$this->linkPayment( $type, $registrationId, $id );
|
|
|
|
if ( Payment::STATUS_PAID === $status ) {
|
|
$this->finalizePaid( $id, $type, $registrationId, $studentId );
|
|
}
|
|
|
|
return $this->payments->findById( $id );
|
|
}
|
|
|
|
/**
|
|
* Studio-admin confirmation that a pending payment (e-transfer) was received.
|
|
* Marks it paid, confirms the registration, and emails the receipt.
|
|
*/
|
|
public function markPaid( int $paymentId ): bool {
|
|
$payment = $this->payments->findById( $paymentId );
|
|
if ( null === $payment ) {
|
|
return false;
|
|
}
|
|
if ( $payment->isPaid() ) {
|
|
return true;
|
|
}
|
|
|
|
$this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->studentId );
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Void the still-pending payment of a cancelled registration so it drops
|
|
* out of the confirmation queue. Paid payments are left alone — refunds
|
|
* are a manual, admin-side decision.
|
|
*/
|
|
public function voidPending( ?int $paymentId ): void {
|
|
if ( null === $paymentId ) {
|
|
return;
|
|
}
|
|
|
|
$payment = $this->payments->findById( $paymentId );
|
|
if ( null !== $payment && Payment::STATUS_PENDING === $payment->status ) {
|
|
$this->payments->updateStatus( $paymentId, Payment::STATUS_FAILED );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 );
|
|
|
|
$paid = $this->payments->findById( $paymentId );
|
|
$user = get_userdata( $studentId );
|
|
if ( null !== $paid && $this->mailer->send( $paid, $user instanceof \WP_User ? $user : null ) ) {
|
|
$this->payments->markReceiptSent( $paymentId );
|
|
}
|
|
}
|
|
|
|
private function confirmRegistration( string $type, int $registrationId ): void {
|
|
if ( Payment::REG_LESSON !== $type ) {
|
|
// Group enrolments are already `active`; no status change on payment.
|
|
return;
|
|
}
|
|
|
|
// A weekly reservation's payment is linked to its anchor lesson but pays
|
|
// for the whole series, so settling it confirms every lesson in the series.
|
|
$lesson = $this->bookings->findById( $registrationId );
|
|
if ( null !== $lesson && null !== $lesson->seriesId ) {
|
|
$this->bookings->updateStatusForSeries( $lesson->seriesId, Lesson::STATUS_CONFIRMED );
|
|
return;
|
|
}
|
|
|
|
$this->bookings->updateStatus( $registrationId, Lesson::STATUS_CONFIRMED );
|
|
}
|
|
|
|
private function linkPayment( string $type, int $registrationId, int $paymentId ): void {
|
|
if ( Payment::REG_LESSON === $type ) {
|
|
$this->bookings->setPaymentId( $registrationId, $paymentId );
|
|
} elseif ( Payment::REG_ENROLLMENT === $type ) {
|
|
$this->enrollments->setPaymentId( $registrationId, $paymentId );
|
|
}
|
|
}
|
|
}
|