A parent registers once and manages lessons for one or more children, who need no login of their own. A child is a real wp_users row with the student role but no usable login — so student_id keeps meaning "a WordPress user" on every table, and booking, credits, policies and enrolments work unchanged. A us_guardians link table maps guardian to child. The signup form gains a parent/guardian tick that reveals a block per child, with the account-signup questions asked per child rather than per guardian — they describe the student, not the account holder. Signup policies are recorded once per child with the guardian as the acceptor, which is the record that actually means something. A family that half-creates is rolled back entirely rather than leaving a guardian who cannot re-register. The booking and enrolment forms gain a "Who is this for?" picker listing children first, so the default selection is never the parent — booking for the wrong child is correctable, quietly billing a parent for their kid's lesson is not. POST /bookings and POST /enrollments take an optional student_id honoured only for that child's guardian; anything else is a 403. That check is the authorisation boundary of the feature. Payments and credits gain a payer: the charge names the child it was for and the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step reach the parent. Credit is held by the payer, so one child's cancellation can settle a sibling's charge, and the daily billing scan sends a guardian one notice covering every child. Closes #132 Co-Authored-By: Claude Opus 5 <[email protected]>
404 lines
15 KiB
PHP
404 lines
15 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.
|
|
*
|
|
* `$payerId` is who owes it — a child's guardian, or 0 (the default) when the
|
|
* student pays for themselves. The billing method resolves against the payer,
|
|
* so comping or card-billing a family is one setting on the guardian.
|
|
*/
|
|
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null, int $payerId = 0 ): ?Payment {
|
|
if ( $amount <= 0.0 ) {
|
|
return null;
|
|
}
|
|
|
|
$payerId = $payerId > 0 ? $payerId : $studentId;
|
|
$method = $this->resolver->resolve( $payerId );
|
|
$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,
|
|
payerId: $payerId,
|
|
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 ) {
|
|
// The receipt goes to whoever paid, which for a child's lesson is the
|
|
// guardian — a child's own address is an undeliverable placeholder.
|
|
$this->finalizePaid( $id, $type, $registrationId, $payerId );
|
|
}
|
|
|
|
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->payerOrStudent() );
|
|
|
|
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;
|
|
}
|
|
|
|
// The credit records the child it was earned for, but the balance itself
|
|
// lands on whoever paid — so a family's credits pool on the guardian and
|
|
// one child's cancellation can settle a sibling's next charge.
|
|
$id = $this->credits->insert(
|
|
new Credit(
|
|
studentId: $payment->studentId,
|
|
amount: $share,
|
|
remaining: $share,
|
|
payerId: $payment->payerOrStudent(),
|
|
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 payer'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 payer's notice.
|
|
*
|
|
* Keyed on the payer, so a guardian's balance settles charges raised against
|
|
* any of their children — the payments passed in may name several students.
|
|
*
|
|
* @param list<Payment> $payments
|
|
* @return array<int, float>
|
|
*/
|
|
public function applyCredits( int $payerId, array $payments ): array {
|
|
$balance = $this->credits->availableBalance( $payerId );
|
|
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( $payerId, $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.
|
|
*
|
|
* `$userId` is the caller: either the student the registration is for, or the
|
|
* guardian who owes it — anyone else gets null rather than a payment step for
|
|
* a charge that is not theirs.
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function createIntent( string $type, int $registrationId, int $userId ): ?array {
|
|
$payment = $this->payments->findByRegistration( $type, $registrationId );
|
|
if ( null === $payment || null === $payment->id ) {
|
|
return null;
|
|
}
|
|
|
|
if ( $payment->studentId !== $userId && $payment->payerOrStudent() !== $userId ) {
|
|
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->payerOrStudent() );
|
|
} 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 $payerId ): void {
|
|
$this->payments->markPaid( $paymentId, 'USC-' . $paymentId );
|
|
$this->confirmRegistration( $type, $registrationId );
|
|
|
|
$paid = $this->payments->findById( $paymentId );
|
|
$user = get_userdata( $payerId );
|
|
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 );
|
|
}
|
|
}
|
|
}
|