CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m52s
Cancelling a lesson that was already paid for now credits the student that money instead of leaving it as a manual refund, and the daily scheduled-billing scan applies any available credit against their due charges before emailing the notice. - New us_credits ledger + us_payments.credit_applied column (Payment::netDue). - PaymentService::creditForCancelledLesson issues a per-lesson share of the covering payment's total; wired into all three cancel paths (student self-cancel, instructor status update, admin student-detail cancel). - PaymentService::applyCredits draws credit down FIFO across a run's charges, marking a fully-covered charge paid-by-credit; the notice shows the credit applied and reduced total, and the admin queue shows net due. - Student detail page shows a student's credit balance and history. Ships as part of the unreleased 1.2.0 (same release as scheduled billing). Tests: composer test (585), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
381 lines
13 KiB
PHP
381 lines
13 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,
|
|
private CreditRepository $credits,
|
|
) {}
|
|
|
|
/**
|
|
* 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 );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Credit a student for a cancelled lesson they had already paid for. The credit
|
|
* is one lesson's share of the covering payment's total (including tax) — the
|
|
* whole total for a single-lesson payment, or `total ÷ lessons covered` for a
|
|
* payment that spans several (a monthly scheduled charge, or a weekly series paid
|
|
* upfront). The original payment is left untouched; the credit is applied to the
|
|
* student's future scheduled-billing charges. Returns null when the lesson was
|
|
* never paid, has no covering payment, or was already credited.
|
|
*/
|
|
public function creditForCancelledLesson( Lesson $lesson ): ?Credit {
|
|
if ( null === $lesson->id ) {
|
|
return null;
|
|
}
|
|
|
|
$paymentId = $lesson->paymentId;
|
|
if ( null === $paymentId && null !== $lesson->seriesId ) {
|
|
// Series lessons other than the anchor carry no payment_id of their own;
|
|
// the whole reservation is paid through the anchor's payment.
|
|
$anchor = $this->payments->findByRegistration( Payment::REG_LESSON, $lesson->seriesId );
|
|
$paymentId = $anchor?->id;
|
|
}
|
|
if ( null === $paymentId ) {
|
|
return null;
|
|
}
|
|
|
|
$payment = $this->payments->findById( $paymentId );
|
|
if ( null === $payment || ! $payment->isPaid() ) {
|
|
return null;
|
|
}
|
|
|
|
if ( $this->credits->existsForLesson( $lesson->id ) ) {
|
|
return null;
|
|
}
|
|
|
|
$share = round( $payment->total() / $this->coveredLessonCount( $lesson, $payment ), 2 );
|
|
if ( $share <= 0.0 ) {
|
|
return null;
|
|
}
|
|
|
|
$id = $this->credits->insert(
|
|
new Credit(
|
|
studentId: $payment->studentId,
|
|
amount: $share,
|
|
remaining: $share,
|
|
currency: $payment->currency,
|
|
sourcePaymentId: $payment->id,
|
|
sourceLessonId: $lesson->id,
|
|
reason: sprintf(
|
|
/* translators: %d: cancelled lesson id */
|
|
__( 'Credit for cancelled lesson #%d', 'unsupervised-schedular' ),
|
|
$lesson->id
|
|
),
|
|
)
|
|
);
|
|
|
|
return $this->credits->findById( $id );
|
|
}
|
|
|
|
/**
|
|
* How many lessons the covering payment was billed for, so its total can be split
|
|
* into a per-lesson credit. A weekly series paid upfront (unscheduled) covers the
|
|
* whole series; every other case — a single booking, a weekly scheduled lesson
|
|
* (one payment each), or a monthly scheduled charge (payment linked to each
|
|
* lesson) — is answered by how many lessons point at the payment. Never below one.
|
|
*/
|
|
private function coveredLessonCount( Lesson $lesson, Payment $payment ): int {
|
|
if ( ! $payment->isScheduled() && null !== $lesson->seriesId ) {
|
|
return max( 1, $this->bookings->countBySeries( $lesson->seriesId ) );
|
|
}
|
|
|
|
return max( 1, $this->bookings->countByPaymentId( (int) $payment->id ) );
|
|
}
|
|
|
|
/**
|
|
* Apply a student's available credit balance against a set of freshly-created
|
|
* pending payments (the ones a billing scan just generated for them), oldest
|
|
* charge first. Each payment's `credit_applied` is raised by the amount covered;
|
|
* a payment fully covered is marked paid-by-credit and its registration confirmed
|
|
* so it leaves the confirmation queue. The credit ledger is drawn down by the
|
|
* total applied. Returns a map of payment id to the credit applied to it, so the
|
|
* caller can reflect the reduction on the student's notice.
|
|
*
|
|
* @param list<Payment> $payments
|
|
* @return array<int, float>
|
|
*/
|
|
public function applyCredits( int $studentId, array $payments ): array {
|
|
$balance = $this->credits->availableBalance( $studentId );
|
|
if ( $balance <= 0.0 ) {
|
|
return [];
|
|
}
|
|
|
|
$applied = [];
|
|
$consumed = 0.0;
|
|
|
|
foreach ( $payments as $payment ) {
|
|
if ( null === $payment->id || $balance <= 0.0 ) {
|
|
continue;
|
|
}
|
|
|
|
$owing = $payment->netDue();
|
|
if ( $owing <= 0.0 ) {
|
|
continue;
|
|
}
|
|
|
|
$amount = round( min( $balance, $owing ), 2 );
|
|
if ( $amount <= 0.0 ) {
|
|
continue;
|
|
}
|
|
|
|
$this->payments->addCreditApplied( $payment->id, $amount );
|
|
|
|
// Fully covered by credit: settle it so it drops out of the pending queue.
|
|
if ( $amount >= $owing ) {
|
|
$this->payments->markPaid( $payment->id, 'USC-' . $payment->id );
|
|
$this->confirmRegistration( $payment->registrationType, $payment->registrationId );
|
|
}
|
|
|
|
$applied[ $payment->id ] = $amount;
|
|
$balance = round( $balance - $amount, 2 );
|
|
$consumed = round( $consumed + $amount, 2 );
|
|
}
|
|
|
|
if ( $consumed > 0.0 ) {
|
|
$this->credits->consume( $studentId, $consumed );
|
|
}
|
|
|
|
return $applied;
|
|
}
|
|
|
|
/**
|
|
* 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 );
|
|
}
|
|
}
|
|
}
|