Add weekly and monthly scheduled billing for offerings
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
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
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]>
This commit is contained in:
@@ -259,7 +259,16 @@ class BookingEndpoint {
|
||||
$payment = null;
|
||||
$status = Lesson::STATUS_PENDING;
|
||||
|
||||
if ( $offering->price > 0.0 ) {
|
||||
// Scheduled billing (weekly / monthly) normally defers payment to the daily
|
||||
// scan, but a single lesson booked once its scheduled due date has already
|
||||
// passed — e.g. an extra lesson added to a month that was already billed — is
|
||||
// charged at booking instead, so it is never missed or billed late.
|
||||
$chargeAtBooking = $offering->price > 0.0 && (
|
||||
! $offering->isScheduledBilling()
|
||||
|| ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) )
|
||||
);
|
||||
|
||||
if ( $chargeAtBooking ) {
|
||||
// A full-term price already covers the whole reservation; a per-lesson
|
||||
// (one_time) price is owed once per occurrence actually claimed, so a
|
||||
// weekly reservation cannot hold a term while paying for one week.
|
||||
@@ -273,8 +282,10 @@ class BookingEndpoint {
|
||||
$status = Lesson::STATUS_CONFIRMED;
|
||||
}
|
||||
} else {
|
||||
// Free offering: there is no payment step that would confirm these
|
||||
// lessons later, so they are confirmed at booking time.
|
||||
// Either a free offering, or scheduled billing (weekly / monthly) whose
|
||||
// payment is deferred to the daily billing scan. Either way there is no
|
||||
// payment step now to confirm the lessons, so the reserved slots are
|
||||
// confirmed at booking time; the billing scan bills them when they come due.
|
||||
foreach ( $ids as $lessonId ) {
|
||||
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
|
||||
}
|
||||
@@ -306,6 +317,23 @@ class BookingEndpoint {
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled-billing offering's due date for a given session has
|
||||
* already passed at booking time. Weekly bills 24 hours before the lesson;
|
||||
* monthly bills on the 1st, so its due moment has passed once "now" is in the
|
||||
* lesson's month or later. Only meaningful for weekly / monthly offerings.
|
||||
*/
|
||||
private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool {
|
||||
$now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) );
|
||||
$start = new \DateTimeImmutable( $slotStart );
|
||||
|
||||
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
|
||||
return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' );
|
||||
}
|
||||
|
||||
return $now >= $start->modify( '-1 day' );
|
||||
}
|
||||
|
||||
private function clientIp(): ?string {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
|
||||
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
|
||||
|
||||
@@ -204,6 +204,45 @@ class BookingRepository {
|
||||
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Not-yet-billed lessons on a scheduled-billing (weekly / monthly) offering:
|
||||
* status not cancelled and no payment attached yet. Each row carries the slot
|
||||
* start time and the offering's billing fields so the daily billing scan can
|
||||
* decide what is due without a second query per lesson. Ordered by student,
|
||||
* offering and time so the scan can group a student's monthly lessons cheaply.
|
||||
*
|
||||
* @return list<\stdClass> Rows: id, student_id, instructor_id, offering_id,
|
||||
* start_dt, billing_mode, title, price, currency,
|
||||
* etransfer_email.
|
||||
*/
|
||||
public function findUnbilledScheduledLessons(): array {
|
||||
$avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
|
||||
$offTable = str_replace( 'us_lessons', 'us_offerings', $this->table );
|
||||
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT l.id, l.student_id, l.instructor_id, l.offering_id,
|
||||
a.start_dt,
|
||||
o.billing_mode, o.title, o.price, o.currency, o.etransfer_email
|
||||
FROM %i l
|
||||
JOIN %i a ON a.id = l.slot_id
|
||||
JOIN %i o ON o.id = l.offering_id
|
||||
WHERE l.status != %s
|
||||
AND l.payment_id IS NULL
|
||||
AND o.billing_mode IN ( %s, %s )
|
||||
ORDER BY l.student_id ASC, l.offering_id ASC, a.start_dt ASC',
|
||||
$this->table,
|
||||
$avTable,
|
||||
$offTable,
|
||||
Lesson::STATUS_CANCELLED,
|
||||
\Unsupervised\Schedular\Offering\Offering::BILLING_WEEKLY,
|
||||
\Unsupervised\Schedular\Offering\Offering::BILLING_MONTHLY
|
||||
)
|
||||
);
|
||||
|
||||
return $rows ?? [];
|
||||
}
|
||||
|
||||
public function setPaymentId( int $id, int $paymentId ): bool {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
|
||||
@@ -129,8 +129,11 @@ class EnrollmentEndpoint {
|
||||
$this->access->markEnrolled( $offeringId, $studentId );
|
||||
}
|
||||
|
||||
// Scheduled billing (weekly / monthly) is generated later by the daily
|
||||
// billing scan, so nothing is charged at enrolment; the enrolment is active
|
||||
// regardless of payment.
|
||||
$payment = null;
|
||||
if ( $offering->price > 0.0 ) {
|
||||
if ( $offering->price > 0.0 && ! $offering->isScheduledBilling() ) {
|
||||
$payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail );
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,39 @@ class EnrollmentRepository {
|
||||
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Active enrolments whose group class bills on a scheduled mode (weekly /
|
||||
* monthly) — the source rows for the daily billing scan. Filtered by joining
|
||||
* the offering so only classes actually on a scheduled plan are returned.
|
||||
*
|
||||
* @param list<string> $modes Billing modes to include (e.g. weekly, monthly).
|
||||
* @return list<Enrollment>
|
||||
*/
|
||||
public function findActiveByBillingModes( array $modes ): array {
|
||||
if ( [] === $modes ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$offTable = str_replace( 'us_group_enrollments', 'us_offerings', $this->table );
|
||||
$placeholders = implode( ', ', array_fill( 0, count( $modes ), '%s' ) );
|
||||
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
"SELECT e.* FROM %i e
|
||||
JOIN %i o ON o.id = e.offering_id
|
||||
WHERE e.status = %s
|
||||
AND o.billing_mode IN ( {$placeholders} )
|
||||
ORDER BY e.student_id ASC, e.offering_id ASC",
|
||||
$this->table,
|
||||
$offTable,
|
||||
Enrollment::STATUS_ACTIVE,
|
||||
...$modes
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
public function setPaymentId( int $id, int $paymentId ): bool {
|
||||
return false !== $this->db->update(
|
||||
$this->table,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||
|
||||
class Installer {
|
||||
|
||||
@@ -12,10 +13,22 @@ class Installer {
|
||||
$this->createTables();
|
||||
$this->migrateData();
|
||||
( new RoleManager() )->createRoles();
|
||||
$this->scheduleBilling();
|
||||
flush_rewrite_rules();
|
||||
update_option( 'us_schedular_version', USC_VERSION );
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the daily scheduled-billing scan is registered with WP-Cron. Runs on
|
||||
* activation and on every version-bump re-install, so an existing site that
|
||||
* predates the feature picks the event up on its next deploy.
|
||||
*/
|
||||
private function scheduleBilling(): void {
|
||||
if ( false === wp_next_scheduled( ScheduledBillingRunner::HOOK ) ) {
|
||||
wp_schedule_event( time(), 'daily', ScheduledBillingRunner::HOOK );
|
||||
}
|
||||
}
|
||||
|
||||
private function createTables(): void {
|
||||
global $wpdb;
|
||||
if ( ! $wpdb instanceof \wpdb ) {
|
||||
|
||||
@@ -20,12 +20,26 @@ class Offering {
|
||||
public const BILLING_ONE_TIME = 'one_time';
|
||||
public const BILLING_FULL_TERM = 'full_term';
|
||||
|
||||
/** Billed 24 hours before each lesson, on a recurring schedule (see scheduled-billing.md). */
|
||||
public const BILLING_WEEKLY = 'weekly';
|
||||
|
||||
/** Billed on the first of each month for every lesson that falls in the month. */
|
||||
public const BILLING_MONTHLY = 'monthly';
|
||||
|
||||
/**
|
||||
* All valid billing modes.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ];
|
||||
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM, self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
|
||||
|
||||
/**
|
||||
* Billing modes whose payment is generated later by the daily billing scan
|
||||
* rather than taken at registration.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const SCHEDULED_BILLING_MODES = [ self::BILLING_WEEKLY, self::BILLING_MONTHLY ];
|
||||
|
||||
/** Listed in the public catalogue; anyone with `book_lesson` may enrol. */
|
||||
public const ACCESS_PUBLIC = 'public';
|
||||
@@ -71,6 +85,14 @@ class Offering {
|
||||
return self::ACCESS_INVITE_ONLY === $this->accessMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this offering's payment is deferred to the daily billing scan
|
||||
* (weekly / monthly) instead of being taken at registration.
|
||||
*/
|
||||
public function isScheduledBilling(): bool {
|
||||
return in_array( $this->billingMode, self::SCHEDULED_BILLING_MODES, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* The last day on which a student may enrol in this group class. Defaults to
|
||||
* the first day of the class (`term_start`) when the instructor has not set an
|
||||
|
||||
@@ -44,6 +44,9 @@ class Payment {
|
||||
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,
|
||||
@@ -65,6 +68,9 @@ class Payment {
|
||||
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 ),
|
||||
@@ -79,6 +85,15 @@ class Payment {
|
||||
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.
|
||||
*/
|
||||
@@ -120,6 +135,9 @@ class Payment {
|
||||
'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,
|
||||
|
||||
@@ -33,22 +33,59 @@ class PaymentController {
|
||||
}
|
||||
}
|
||||
|
||||
$rows = array_map(
|
||||
static function ( Payment $payment ): array {
|
||||
$student = get_userdata( $payment->studentId );
|
||||
|
||||
return [
|
||||
'id' => (int) $payment->id,
|
||||
'student' => $student ? $student->display_name : (string) $payment->studentId,
|
||||
'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency,
|
||||
'method' => $payment->method,
|
||||
'for' => $payment->registrationType . ' #' . $payment->registrationId,
|
||||
'etransfer_email' => (string) $payment->etransferEmail,
|
||||
];
|
||||
},
|
||||
$this->payments->findPending()
|
||||
);
|
||||
$groups = $this->groupPending( $this->payments->findPending() );
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/payments.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Group pending payments by their shared notice batch, so payments the daily
|
||||
* scan emailed a student together (and which a single lump-sum e-transfer
|
||||
* covers) are shown as one group with a combined total. Payments with no batch
|
||||
* — legacy at-registration e-transfers — are each their own single-item group.
|
||||
*
|
||||
* @param list<Payment> $pending
|
||||
* @return list<array{reference: string, is_group: bool, total: string, rows: list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}>}>
|
||||
*/
|
||||
private function groupPending( array $pending ): array {
|
||||
$groups = [];
|
||||
|
||||
foreach ( $pending as $payment ) {
|
||||
$batch = (string) $payment->noticeBatch;
|
||||
$key = '' !== $batch ? 'b:' . $batch : 's:' . (string) $payment->id;
|
||||
|
||||
if ( ! isset( $groups[ $key ] ) ) {
|
||||
$groups[ $key ] = [
|
||||
'reference' => $batch,
|
||||
'currency' => $payment->currency,
|
||||
'total_raw' => 0.0,
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$student = get_userdata( $payment->studentId );
|
||||
|
||||
$groups[ $key ]['total_raw'] += $payment->total();
|
||||
$groups[ $key ]['rows'][] = [
|
||||
'id' => (int) $payment->id,
|
||||
'student' => $student ? $student->display_name : (string) $payment->studentId,
|
||||
'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency,
|
||||
'method' => $payment->method,
|
||||
'for' => $payment->registrationType . ' #' . $payment->registrationId,
|
||||
'etransfer_email' => (string) $payment->etransferEmail,
|
||||
];
|
||||
}
|
||||
|
||||
return array_values(
|
||||
array_map(
|
||||
static fn( array $group ): array => [
|
||||
'reference' => $group['reference'],
|
||||
'is_group' => count( $group['rows'] ) > 1,
|
||||
'total' => number_format( $group['total_raw'], 2 ) . ' ' . $group['currency'],
|
||||
'rows' => $group['rows'],
|
||||
],
|
||||
$groups
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
/**
|
||||
* Emails a student a single itemised notice for every payment the daily billing
|
||||
* scan generated for them in one run, so a student billed for several lessons on
|
||||
* the same day receives one email with a line per item and a grand total — never
|
||||
* one email per lesson.
|
||||
*/
|
||||
class PaymentDueMailer {
|
||||
|
||||
/**
|
||||
* Send one student their consolidated due-payment notice for the current scan.
|
||||
* The optional `$reference` is the shared notice-batch code the student can quote
|
||||
* on a lump-sum e-transfer so the studio can reconcile it to these payments.
|
||||
*
|
||||
* @param list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}> $items
|
||||
* @return bool False when there is no recipient or nothing to bill.
|
||||
*/
|
||||
public function send( \WP_User $student, array $items, string $reference = '' ): bool {
|
||||
if ( '' === (string) $student->user_email || [] === $items ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currency = (string) $items[0]['currency'];
|
||||
$total = 0.0;
|
||||
$lines = [];
|
||||
$emails = [];
|
||||
|
||||
foreach ( $items as $item ) {
|
||||
$amount = (float) $item['amount'];
|
||||
$total += $amount;
|
||||
|
||||
$lines[] = sprintf(
|
||||
/* translators: 1: item description, 2: due date, 3: currency, 4: amount */
|
||||
__( '- %1$s (due %2$s): %3$s %4$s', 'unsupervised-schedular' ),
|
||||
(string) $item['label'],
|
||||
$this->formatDate( $item['due_date'] ?? null ),
|
||||
$currency,
|
||||
number_format( $amount, 2 )
|
||||
);
|
||||
|
||||
$etransfer = (string) ( $item['etransfer_email'] ?? '' );
|
||||
if ( '' !== $etransfer ) {
|
||||
$emails[ $etransfer ] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n"
|
||||
. implode( "\n", $lines ) . "\n\n"
|
||||
. sprintf(
|
||||
/* translators: 1: currency, 2: total amount */
|
||||
__( 'Total due: %1$s %2$s', 'unsupervised-schedular' ),
|
||||
$currency,
|
||||
number_format( $total, 2 )
|
||||
);
|
||||
|
||||
if ( [] !== $emails ) {
|
||||
$body .= "\n\n" . sprintf(
|
||||
/* translators: %s: e-transfer destination email address(es) */
|
||||
__( 'Please send your e-transfer to: %s', 'unsupervised-schedular' ),
|
||||
implode( ', ', array_keys( $emails ) )
|
||||
);
|
||||
}
|
||||
|
||||
if ( '' !== $reference ) {
|
||||
$body .= "\n\n" . sprintf(
|
||||
/* translators: %s: payment reference code */
|
||||
__( 'Please include this reference with your payment: %s', 'unsupervised-schedular' ),
|
||||
$reference
|
||||
);
|
||||
}
|
||||
|
||||
return (bool) wp_mail( $student->user_email, __( 'Payment due', 'unsupervised-schedular' ), $body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Present a stored `Y-m-d` due date in a friendlier form; falls back to the
|
||||
* raw value (or an empty string) when it is not a parseable date.
|
||||
*/
|
||||
private function formatDate( ?string $date ): string {
|
||||
if ( null === $date || '' === $date ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parsed = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date );
|
||||
|
||||
return false !== $parsed ? $parsed->format( 'M j, Y' ) : $date;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ class PaymentRepository {
|
||||
'status' => $payment->status,
|
||||
'tax_rate' => $payment->taxRate,
|
||||
'tax_amount' => $payment->taxAmount,
|
||||
'due_date' => $payment->dueDate,
|
||||
'period_key' => $payment->periodKey,
|
||||
'notice_batch' => $payment->noticeBatch,
|
||||
'etransfer_email' => $payment->etransferEmail,
|
||||
'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
|
||||
'receipt_number' => $payment->receiptNumber,
|
||||
@@ -32,7 +35,7 @@ class PaymentRepository {
|
||||
'paid_at' => $payment->paidAt,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
@@ -119,6 +122,51 @@ class PaymentRepository {
|
||||
return $row ? Payment::fromRow( $row ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag a set of payments with a shared notice-batch reference — the payments the
|
||||
* daily scan emailed a student together, so the admin can see which pending
|
||||
* payments a single lump-sum e-transfer covers. No-op for an empty id list.
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*/
|
||||
public function assignNoticeBatch( array $ids, string $batch ): void {
|
||||
if ( [] === $ids ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
|
||||
$sql = $this->db->prepare(
|
||||
"UPDATE %i SET notice_batch = %s WHERE id IN ( {$placeholders} )",
|
||||
$this->table,
|
||||
$batch,
|
||||
...$ids
|
||||
);
|
||||
|
||||
if ( null !== $sql ) {
|
||||
$this->db->query( $sql );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled payment already exists for a registration and billing
|
||||
* period. The daily billing scan uses this to avoid double-billing an
|
||||
* enrolment for the same session (weekly) or month (monthly). A voided
|
||||
* (`failed`) row still counts so a cancelled charge is not silently re-created.
|
||||
*/
|
||||
public function existsForPeriod( string $registrationType, int $registrationId, string $periodKey ): bool {
|
||||
$found = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT id FROM %i WHERE registration_type = %s AND registration_id = %d AND period_key = %s LIMIT 1',
|
||||
$this->table,
|
||||
$registrationType,
|
||||
$registrationId,
|
||||
$periodKey
|
||||
)
|
||||
);
|
||||
|
||||
return null !== $found;
|
||||
}
|
||||
|
||||
public function findByRegistration( string $registrationType, int $registrationId ): ?Payment {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare(
|
||||
|
||||
@@ -29,8 +29,12 @@ class PaymentService {
|
||||
* (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 ): ?Payment {
|
||||
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;
|
||||
}
|
||||
@@ -58,6 +62,8 @@ class PaymentService {
|
||||
status: $status,
|
||||
taxRate: $taxRate,
|
||||
taxAmount: $taxAmount,
|
||||
dueDate: $dueDate,
|
||||
periodKey: $periodKey,
|
||||
etransferEmail: $etransferEmail,
|
||||
)
|
||||
);
|
||||
@@ -71,6 +77,26 @@ class PaymentService {
|
||||
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.
|
||||
@@ -92,7 +118,10 @@ class PaymentService {
|
||||
/**
|
||||
* 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.
|
||||
* 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 ) {
|
||||
@@ -100,7 +129,7 @@ class PaymentService {
|
||||
}
|
||||
|
||||
$payment = $this->payments->findById( $paymentId );
|
||||
if ( null !== $payment && Payment::STATUS_PENDING === $payment->status ) {
|
||||
if ( null !== $payment && ! $payment->isScheduled() && Payment::STATUS_PENDING === $payment->status ) {
|
||||
$this->payments->updateStatus( $paymentId, Payment::STATUS_FAILED );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Payment;
|
||||
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Generates the pending payments that scheduled-billing offerings (weekly /
|
||||
* monthly) owe as they come due, then emails each student one itemised notice.
|
||||
*
|
||||
* Runs from the daily WP-Cron action `us_generate_due_payments`. It is
|
||||
* self-healing: every run re-scans from the current ledger state, so a missed
|
||||
* day is simply picked up the next time. Dedup keeps a second run from
|
||||
* double-billing — private lessons via `us_lessons.payment_id`, group enrolments
|
||||
* via `us_payments.period_key`.
|
||||
*/
|
||||
class ScheduledBillingRunner {
|
||||
|
||||
public const HOOK = 'us_generate_due_payments';
|
||||
|
||||
public function __construct(
|
||||
private PaymentService $payments,
|
||||
private BookingRepository $bookings,
|
||||
private EnrollmentRepository $enrollments,
|
||||
private OfferingRepository $offerings,
|
||||
private PaymentDueMailer $mailer,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
add_action( self::HOOK, [ $this, 'run' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate every payment now due and send the consolidated notices.
|
||||
*/
|
||||
public function run(): void {
|
||||
$now = $this->now();
|
||||
|
||||
// One notice bucket per student, filled as pending payments are created and
|
||||
// flushed to a single email at the end, so a student billed for several
|
||||
// lessons on one day is emailed once — never once per lesson. $batchIds
|
||||
// tracks the payment ids behind each student's bucket so they can be tagged
|
||||
// with a shared reference for lump-sum e-transfer reconciliation.
|
||||
$buckets = [];
|
||||
$batchIds = [];
|
||||
|
||||
$this->billPrivateLessons( $now, $buckets, $batchIds );
|
||||
$this->billGroupEnrollments( $now, $buckets, $batchIds );
|
||||
|
||||
$this->sendNotices( $buckets, $batchIds );
|
||||
}
|
||||
|
||||
/**
|
||||
* Private-lesson billing. Weekly lessons are billed one payment each once they
|
||||
* are within 24 hours; monthly lessons are grouped per calendar month and billed
|
||||
* one payment for the month once its 1st has arrived.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void {
|
||||
$today = $now->format( 'Y-m-d' );
|
||||
$monthly = [];
|
||||
|
||||
foreach ( $this->bookings->findUnbilledScheduledLessons() as $row ) {
|
||||
$price = Val::float( $row->price ?? 0 );
|
||||
if ( $price <= 0.0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$startRaw = Val::string( $row->start_dt ?? '' );
|
||||
$start = false !== strtotime( $startRaw ) ? new \DateTimeImmutable( $startRaw ) : null;
|
||||
if ( null === $start ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lessonId = Val::int( $row->id );
|
||||
$studentId = Val::int( $row->student_id );
|
||||
$instructorId = Val::int( $row->instructor_id );
|
||||
$currency = Val::string( $row->currency ?? 'CAD' );
|
||||
$etransfer = Val::stringOrNull( $row->etransfer_email ?? null );
|
||||
$title = Val::string( $row->title ?? '' );
|
||||
|
||||
if ( Offering::BILLING_MONTHLY === Val::string( $row->billing_mode ?? '' ) ) {
|
||||
$monthly[ $studentId . ':' . Val::int( $row->offering_id ) . ':' . $start->format( 'Y-m' ) ][] = [
|
||||
'lesson_id' => $lessonId,
|
||||
'student_id' => $studentId,
|
||||
'instructor_id' => $instructorId,
|
||||
'currency' => $currency,
|
||||
'etransfer' => $etransfer,
|
||||
'title' => $title,
|
||||
'price' => $price,
|
||||
'start' => $start,
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Weekly: due 24 hours before the lesson.
|
||||
$due = $start->modify( '-1 day' );
|
||||
if ( $due->format( 'Y-m-d H:i:s' ) > $now->format( 'Y-m-d H:i:s' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_LESSON,
|
||||
$lessonId,
|
||||
$studentId,
|
||||
$instructorId,
|
||||
$price,
|
||||
$currency,
|
||||
$etransfer,
|
||||
$due->format( 'Y-m-d' ),
|
||||
$start->format( 'Y-m-d' ),
|
||||
$title . ' — ' . $start->format( 'M j, Y' )
|
||||
);
|
||||
}
|
||||
|
||||
$this->billMonthlyLessonGroups( $today, $monthly, $buckets, $batchIds );
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill each month's worth of monthly private lessons as one payment (count ×
|
||||
* fee), once the month's 1st has arrived. The payment links to the earliest
|
||||
* lesson in the group; the rest are pointed at it so they are not re-billed.
|
||||
*
|
||||
* @param array<string, list<array{lesson_id: int, student_id: int, instructor_id: int, currency: string, etransfer: ?string, title: string, price: float, start: \DateTimeImmutable}>> $monthly
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets, array &$batchIds ): void {
|
||||
foreach ( $monthly as $group ) {
|
||||
$first = $group[0]['start'];
|
||||
$monthStart = $first->format( 'Y-m-01' );
|
||||
|
||||
// Not billable until the 1st of the lesson's month has arrived.
|
||||
if ( $monthStart > $today ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lessonIds = array_map( static fn( array $l ): int => $l['lesson_id'], $group );
|
||||
$anchorId = $lessonIds[0];
|
||||
$count = count( $group );
|
||||
|
||||
$payment = $this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_LESSON,
|
||||
$anchorId,
|
||||
$group[0]['student_id'],
|
||||
$group[0]['instructor_id'],
|
||||
$group[0]['price'] * $count,
|
||||
$group[0]['currency'],
|
||||
$group[0]['etransfer'],
|
||||
$monthStart,
|
||||
$first->format( 'Y-m' ),
|
||||
sprintf(
|
||||
/* translators: 1: offering title, 2: month, 3: number of lessons */
|
||||
_n( '%1$s (%2$s): %3$d lesson', '%1$s (%2$s): %3$d lessons', $count, 'unsupervised-schedular' ),
|
||||
$group[0]['title'],
|
||||
$first->format( 'F Y' ),
|
||||
$count
|
||||
)
|
||||
);
|
||||
|
||||
if ( null === $payment ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// createForRegistration links the anchor; point the rest of the month at
|
||||
// the same payment so the next scan sees them as billed.
|
||||
foreach ( array_slice( $lessonIds, 1 ) as $extraId ) {
|
||||
$this->bookings->setPaymentId( $extraId, (int) $payment->id );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group-class billing off each active enrolment's concrete session windows.
|
||||
* Weekly bills one payment per session (24h before); monthly bills one payment
|
||||
* per month (on the 1st) for that month's sessions. Dedup is by `period_key`
|
||||
* since a single enrolment maps to many periodic charges.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): void {
|
||||
$today = $now->format( 'Y-m-d' );
|
||||
$offerings = [];
|
||||
|
||||
foreach ( $this->enrollments->findActiveByBillingModes( Offering::SCHEDULED_BILLING_MODES ) as $enrollment ) {
|
||||
$offeringId = $enrollment->offeringId;
|
||||
if ( ! array_key_exists( $offeringId, $offerings ) ) {
|
||||
$offerings[ $offeringId ] = $this->offerings->findById( $offeringId );
|
||||
}
|
||||
$offering = $offerings[ $offeringId ];
|
||||
if ( null === $offering || $offering->price <= 0.0 ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$windows = $offering->sessionWindows();
|
||||
if ( [] === $windows ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
|
||||
$this->billGroupMonthly( $now, $today, $enrollment, $offering, $windows, $buckets, $batchIds );
|
||||
} else {
|
||||
$this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets, $batchIds );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill one payment per group-class session that is now within 24 hours.
|
||||
*
|
||||
* @param list<array{start: string, end: string}> $windows
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void {
|
||||
foreach ( $windows as $window ) {
|
||||
$start = new \DateTimeImmutable( $window['start'] );
|
||||
$due = $start->modify( '-1 day' );
|
||||
if ( $due->format( 'Y-m-d H:i:s' ) > $now->format( 'Y-m-d H:i:s' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$periodKey = $start->format( 'Y-m-d' );
|
||||
if ( $this->payments->scheduledPaymentExists( Payment::REG_ENROLLMENT, (int) $enrollment->id, $periodKey ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_ENROLLMENT,
|
||||
(int) $enrollment->id,
|
||||
$enrollment->studentId,
|
||||
$enrollment->instructorId,
|
||||
$offering->price,
|
||||
$offering->currency,
|
||||
$offering->etransferEmail,
|
||||
$due->format( 'Y-m-d' ),
|
||||
$periodKey,
|
||||
$offering->title . ' — ' . $start->format( 'M j, Y' )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill one payment per calendar month of a group class, once its 1st arrives.
|
||||
*
|
||||
* @param list<array{start: string, end: string}> $windows
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||
// Count this enrolment's sessions per calendar month.
|
||||
$months = [];
|
||||
foreach ( $windows as $window ) {
|
||||
$start = new \DateTimeImmutable( $window['start'] );
|
||||
$months[ $start->format( 'Y-m' ) ] = ( $months[ $start->format( 'Y-m' ) ] ?? 0 ) + 1;
|
||||
}
|
||||
|
||||
foreach ( $months as $month => $count ) {
|
||||
$monthStart = ( new \DateTimeImmutable( $month . '-01' ) )->format( 'Y-m-d' );
|
||||
if ( $monthStart > $today ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $this->payments->scheduledPaymentExists( Payment::REG_ENROLLMENT, (int) $enrollment->id, $month ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->bill(
|
||||
$buckets,
|
||||
$batchIds,
|
||||
Payment::REG_ENROLLMENT,
|
||||
(int) $enrollment->id,
|
||||
$enrollment->studentId,
|
||||
$enrollment->instructorId,
|
||||
$offering->price * $count,
|
||||
$offering->currency,
|
||||
$offering->etransferEmail,
|
||||
$monthStart,
|
||||
$month,
|
||||
sprintf(
|
||||
/* translators: 1: offering title, 2: month, 3: number of sessions */
|
||||
_n( '%1$s (%2$s): %3$d session', '%1$s (%2$s): %3$d sessions', $count, 'unsupervised-schedular' ),
|
||||
$offering->title,
|
||||
( new \DateTimeImmutable( $month . '-01' ) )->format( 'F Y' ),
|
||||
$count
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one scheduled payment and, when it is pending (not a comp auto-pay),
|
||||
* add an itemised line to the student's notice bucket and record its payment id
|
||||
* for the shared notice batch. Returns the created payment, or null when there
|
||||
* was nothing to charge.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function bill( array &$buckets, array &$batchIds, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment {
|
||||
$payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey );
|
||||
|
||||
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
|
||||
$buckets[ $studentId ][] = [
|
||||
'label' => $label,
|
||||
'amount' => $payment->total(),
|
||||
'currency' => $payment->currency,
|
||||
'due_date' => $payment->dueDate,
|
||||
'etransfer_email' => $payment->etransferEmail,
|
||||
];
|
||||
$batchIds[ $studentId ][] = $payment->id;
|
||||
}
|
||||
|
||||
return $payment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag each student's payments with a shared batch reference and email them one
|
||||
* itemised notice quoting it, so a lump-sum e-transfer can be reconciled to the
|
||||
* exact pending payments it covers.
|
||||
*
|
||||
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
|
||||
* @param array<int, list<int>> $batchIds
|
||||
*/
|
||||
private function sendNotices( array $buckets, array $batchIds ): void {
|
||||
foreach ( $buckets as $studentId => $items ) {
|
||||
$reference = $this->reference();
|
||||
$this->payments->assignNoticeBatch( $batchIds[ $studentId ] ?? [], $reference );
|
||||
|
||||
$user = get_userdata( $studentId );
|
||||
if ( $user instanceof \WP_User ) {
|
||||
$this->mailer->send( $user, $items, $reference );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, human-quotable reference shared by every payment in one student's
|
||||
* notice, printed on the email and shown in the admin payments queue.
|
||||
*/
|
||||
private function reference(): string {
|
||||
return strtoupper( substr( str_replace( '-', '', Val::string( wp_generate_uuid4() ) ), 0, 10 ) );
|
||||
}
|
||||
|
||||
private function now(): \DateTimeImmutable {
|
||||
$mysql = Val::string( current_time( 'mysql' ) );
|
||||
|
||||
return false !== strtotime( $mysql ) ? new \DateTimeImmutable( $mysql ) : new \DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,10 @@ use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentDueMailer;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Payment\ReceiptMailer;
|
||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||
use Unsupervised\Schedular\Payment\StripeGateway;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
@@ -77,6 +79,8 @@ class Plugin {
|
||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
|
||||
$groupClassPage = new GroupClassPage();
|
||||
|
||||
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer() ) )->register();
|
||||
|
||||
( new UpdateChecker() )->register();
|
||||
( new RoleManager() )->register();
|
||||
( new RegistrationLoginGate() )->register();
|
||||
|
||||
@@ -159,6 +159,9 @@ class Schema {
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
tax_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||
tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
due_date DATE DEFAULT NULL,
|
||||
period_key VARCHAR(20) DEFAULT NULL,
|
||||
notice_batch VARCHAR(32) DEFAULT NULL,
|
||||
etransfer_email VARCHAR(191) DEFAULT NULL,
|
||||
stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
|
||||
receipt_number VARCHAR(50) DEFAULT NULL,
|
||||
|
||||
Reference in New Issue
Block a user