Files
unsupervised-scheduler/src/Payment/Payment.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

147 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Unsupervised\Schedular\Val;
class Payment {
public const METHOD_CARD = 'card';
public const METHOD_ETRANSFER = 'etransfer';
public const METHOD_COMP = 'comp';
/**
* All valid payment methods.
*
* @var list<string>
*/
public const VALID_METHODS = [ self::METHOD_CARD, self::METHOD_ETRANSFER, self::METHOD_COMP ];
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
public const STATUS_FAILED = 'failed';
public const STATUS_REFUNDED = 'refunded';
/**
* All valid payment statuses.
*
* @var list<string>
*/
public const VALID_STATUSES = [ self::STATUS_PENDING, self::STATUS_PAID, self::STATUS_FAILED, self::STATUS_REFUNDED ];
public const REG_LESSON = 'lesson';
public const REG_ENROLLMENT = 'enrollment';
public function __construct(
public readonly int $studentId,
public readonly int $instructorId,
public readonly string $registrationType,
public readonly int $registrationId,
public readonly float $amount,
public readonly string $currency = 'CAD',
public readonly string $method = self::METHOD_ETRANSFER,
public readonly string $status = self::STATUS_PENDING,
public readonly float $taxRate = 0.0,
public readonly float $taxAmount = 0.0,
public readonly ?string $dueDate = null,
public readonly ?string $periodKey = null,
public readonly ?string $noticeBatch = null,
public readonly ?string $etransferEmail = null,
public readonly ?string $stripePaymentIntentId = null,
public readonly ?string $receiptNumber = null,
public readonly ?string $receiptSentAt = null,
public readonly ?string $paidAt = null,
public readonly ?string $createdAt = null,
public readonly ?int $id = null,
) {}
public static function fromRow( \stdClass $row ): self {
return new self(
studentId: Val::int( $row->student_id ),
instructorId: Val::int( $row->instructor_id ),
registrationType: Val::string( $row->registration_type ),
registrationId: Val::int( $row->registration_id ),
amount: Val::float( $row->amount ),
currency: Val::string( $row->currency ),
method: Val::string( $row->method ),
status: Val::string( $row->status ),
taxRate: Val::float( $row->tax_rate ),
taxAmount: Val::float( $row->tax_amount ),
dueDate: Val::stringOrNull( $row->due_date ?? null ),
periodKey: Val::stringOrNull( $row->period_key ?? null ),
noticeBatch: Val::stringOrNull( $row->notice_batch ?? null ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ),
receiptNumber: Val::stringOrNull( $row->receipt_number ),
receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ),
paidAt: Val::stringOrNull( $row->paid_at ),
createdAt: Val::stringOrNull( $row->created_at ),
id: Val::int( $row->id ),
);
}
public function isPaid(): bool {
return self::STATUS_PAID === $this->status;
}
/**
* Whether this payment was generated by the daily billing scan (weekly /
* monthly) rather than taken at registration. Scheduled payments carry a due
* date, can cover several lessons, and are never auto-voided on cancellation.
*/
public function isScheduled(): bool {
return null !== $this->dueDate;
}
/**
* Amount billed including tax.
*/
public function total(): float {
return round( $this->amount + $this->taxAmount, 2 );
}
/**
* Minimal payment info embedded in registration-creation responses: enough
* for the front end to decide whether (and how) to run the payment step.
*
* @return array<string, mixed>
*/
public function toSummaryArray(): array {
return [
'id' => $this->id,
'method' => $this->method,
'status' => $this->status,
];
}
/**
* Returns a plain array representation of the payment.
*
* @return array<string, mixed>
*/
public function toArray(): array {
return [
'id' => $this->id,
'student_id' => $this->studentId,
'instructor_id' => $this->instructorId,
'registration_type' => $this->registrationType,
'etransfer_email' => $this->etransferEmail,
'registration_id' => $this->registrationId,
'amount' => $this->amount,
'tax_rate' => $this->taxRate,
'tax_amount' => $this->taxAmount,
'total' => $this->total(),
'currency' => $this->currency,
'method' => $this->method,
'status' => $this->status,
'due_date' => $this->dueDate,
'period_key' => $this->periodKey,
'notice_batch' => $this->noticeBatch,
'receipt_number' => $this->receiptNumber,
'paid_at' => $this->paidAt,
'created_at' => $this->createdAt,
];
}
}