CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped
Offerings can now bill weekly (a pending payment 24h before each lesson) or monthly (one payment on the 1st for that month's lessons), alongside one-time and full-term. Applies to both private lessons and group classes. - Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()` - Booking/enrolment defer payment for scheduled modes; a single lesson booked after its due date has passed (e.g. an add-on in an already-billed month) is charged at booking instead - ScheduledBillingRunner: daily WP-Cron scan generates due payments across four cases (private/group × weekly/monthly), deduped via lesson.payment_id and payments.period_key - PaymentDueMailer: one consolidated itemised email per student per scan - Notice batch: payments emailed together share a reference; the admin Payments queue groups them with a lump-sum total for e-transfer reconciliation - Cancellation never voids a scheduled payment (Payment::isScheduled()) - Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0 composer test, composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
251 lines
9.0 KiB
PHP
251 lines
9.0 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.
|
|
*
|
|
* A `$dueDate`/`$periodKey` mark a payment generated later by the daily billing
|
|
* scan (weekly / monthly) rather than taken at registration; both stay null for
|
|
* the pay-now flow.
|
|
*/
|
|
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = 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,
|
|
dueDate: $dueDate,
|
|
periodKey: $periodKey,
|
|
etransferEmail: $etransferEmail,
|
|
)
|
|
);
|
|
|
|
$this->linkPayment( $type, $registrationId, $id );
|
|
|
|
if ( Payment::STATUS_PAID === $status ) {
|
|
$this->finalizePaid( $id, $type, $registrationId, $studentId );
|
|
}
|
|
|
|
return $this->payments->findById( $id );
|
|
}
|
|
|
|
/**
|
|
* Whether a scheduled payment already exists for a registration and billing
|
|
* period — the daily billing scan's dedup check for group enrolments (whose one
|
|
* row maps to many periodic charges). Delegates to the ledger.
|
|
*/
|
|
public function scheduledPaymentExists( string $type, int $registrationId, string $periodKey ): bool {
|
|
return $this->payments->existsForPeriod( $type, $registrationId, $periodKey );
|
|
}
|
|
|
|
/**
|
|
* Tag the payments the daily scan emailed a student together with a shared
|
|
* notice-batch reference, so a lump-sum e-transfer can be reconciled to the
|
|
* pending payments it covers. Delegates to the ledger.
|
|
*
|
|
* @param list<int> $ids
|
|
*/
|
|
public function assignNoticeBatch( array $ids, string $batch ): void {
|
|
$this->payments->assignNoticeBatch( $ids, $batch );
|
|
}
|
|
|
|
/**
|
|
* 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. Scheduled payments (weekly / monthly)
|
|
* are also left alone: a monthly charge can cover several lessons and may
|
|
* already be collected, so cancelling one lesson must never void it or
|
|
* trigger a rebill.
|
|
*/
|
|
public function voidPending( ?int $paymentId ): void {
|
|
if ( null === $paymentId ) {
|
|
return;
|
|
}
|
|
|
|
$payment = $this->payments->findById( $paymentId );
|
|
if ( null !== $payment && ! $payment->isScheduled() && 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 );
|
|
}
|
|
}
|
|
}
|