Files
unsupervised-scheduler/src/Payment/PaymentService.php
T
thatguygriffandClaude Opus 4.8 e8e66eef3c
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
Credit students for cancelled paid lessons
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]>
2026-07-24 15:32:20 -03:00

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 );
}
}
}