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