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 $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 $payments * @return array */ 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|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 ); } } }