Files
unsupervised-scheduler/src/Payment/PaymentService.php
T
thatguygriffandClaude Opus 4.8 4328e8fb5f
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped
Add weekly and monthly scheduled billing for offerings
Offerings can now bill weekly (a pending payment 24h before each lesson)
or monthly (one payment on the 1st for that month's lessons), alongside
one-time and full-term. Applies to both private lessons and group classes.

- Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()`
- Booking/enrolment defer payment for scheduled modes; a single lesson
  booked after its due date has passed (e.g. an add-on in an already-billed
  month) is charged at booking instead
- ScheduledBillingRunner: daily WP-Cron scan generates due payments across
  four cases (private/group × weekly/monthly), deduped via lesson.payment_id
  and payments.period_key
- PaymentDueMailer: one consolidated itemised email per student per scan
- Notice batch: payments emailed together share a reference; the admin
  Payments queue groups them with a lump-sum total for e-transfer reconciliation
- Cancellation never voids a scheduled payment (Payment::isScheduled())
- Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 12:06:37 -03:00

251 lines
9.0 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,
) {}
/**
* 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 );
}
}
/**
* 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 );
}
}
}