Fix two duplicate-charge bugs in scheduled billing #201

Merged
thatguygriff merged 2 commits from fix/duplicate-payment-due-notice into main 2026-09-17 18:25:19 +00:00
21 changed files with 641 additions and 12 deletions
+6
View File
@@ -11,6 +11,12 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work. each change under the current top section as you work.
## [1.5.8]
### Fixed
- **A student is no longer emailed the same "Payment due" notice twice.** The daily billing scan runs whenever the site gets traffic, and on a busy day two copies of it could end up running at the same time. Neither knew about the other, so each would send its own notice for the same charge — one payment on the books, but the family saw two identical requests to pay and reasonably read it as being billed twice. Each payment is now stamped the moment its notice goes out, and a second run that reaches the same payment sees the stamp and stays quiet, so exactly one notice is sent no matter how the scan is triggered. Payments already noticed before this update are marked as such on upgrade, so nobody gets a fresh round of reminders for charges they were already told about.
- **Switching a group class to monthly billing no longer charges students who already paid up front a second time.** When a class was set up to be paid once at sign-up and later changed to bill monthly, the daily scan did not recognise the payment already taken at enrolment — it carried no billing month — and raised a fresh charge for the current month on top of it. Families who had already paid were billed again, sometimes for a month they had covered. Changing a class to monthly now marks each enrolled student's up-front payment as covering the current month, so the scan bills them from the following month on and never doubles up on the month already paid. (Enrolments made after the switch, and classes that were always monthly, were never affected.)
## [1.5.7] ## [1.5.7]
## [1.5.6] ## [1.5.6]
+3 -2
View File
@@ -25,6 +25,7 @@ use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController; use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\GroupClass\SessionSchedule; use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Offering\BillingModeReconciler;
use Unsupervised\Schedular\Offering\ClassSlotReconciler; use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingController;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -70,7 +71,7 @@ class AdminMenu {
private PaymentController $paymentController; private PaymentController $paymentController;
private PaymentReportController $paymentReportController; private PaymentReportController $paymentReportController;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians, LessonBooker $booker, RegistrationGate $gate ) { public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians, LessonBooker $booker, RegistrationGate $gate, BillingModeReconciler $billingModeReconciler ) {
// One audit presenter and one recorder, shared by the lesson and enrolment // One audit presenter and one recorder, shared by the lesson and enrolment
// detail views: intake is the same thing whichever registration it hangs off. // detail views: intake is the same thing whichever registration it hangs off.
$intakeAudit = new IntakeAudit( $answers, $questions, $acceptances, $policies, $policyVersions ); $intakeAudit = new IntakeAudit( $answers, $questions, $acceptances, $policies, $policyVersions );
@@ -78,7 +79,7 @@ class AdminMenu {
$this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) ); $this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) );
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, $intakeAudit, new AdminBooking( $availability, $offerings, $booker ), $intakeRecording ); $this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, $intakeAudit, new AdminBooking( $availability, $offerings, $booker ), $intakeRecording );
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) ); $this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ), $billingModeReconciler );
$this->questionController = new QuestionController( $questions, $offerings ); $this->questionController = new QuestionController( $questions, $offerings );
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
$this->registrationController = new RegistrationController( $invites ); $this->registrationController = new RegistrationController( $invites );
+18
View File
@@ -82,6 +82,24 @@ class EnrollmentRepository {
return $count > 0; return $count > 0;
} }
/**
* Active enrolments in one offering, oldest first.
*
* @return list<Enrollment>
*/
public function findActiveByOffering( int $offeringId ): array {
$rows = $this->db->get_results(
$this->db->prepare(
'SELECT * FROM %i WHERE offering_id = %d AND status = %s ORDER BY id ASC',
$this->table,
$offeringId,
Enrollment::STATUS_ACTIVE
)
);
return array_map( Enrollment::fromRow( ... ), $rows ?? [] );
}
/** /**
* A student's enrolments, newest first. * A student's enrolments, newest first.
* *
+85
View File
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Val;
/**
* Keeps existing enrolments from being billed twice when a group class is
* switched from a pay-now billing mode (one-time / full-term) to a scheduled one
* (weekly / monthly).
*
* A student who enrols while a class is pay-now is charged once at enrolment, and
* that charge carries no `period_key`. The daily scan dedups scheduled charges by
* `period_key`, so once the class becomes scheduled the scan does not see the
* up-front charge and bills the enrolment again for the same period. This adopts
* each such up-front charge into the current billing period so the scan treats it
* as already billed; later periods bill normally.
*
* Only the transition *into* scheduled billing is handled — going the other way,
* or editing an already-scheduled class, needs no reconciliation. Weekly is left
* alone: a weekly class is billed per session as sessions come due, so there is
* no single up-front period a prior charge maps onto.
*/
class BillingModeReconciler {
public function __construct(
private EnrollmentRepository $enrollments,
private PaymentRepository $payments,
) {}
/**
* Reconcile an offering edit. Given the offering as it was and as it now is,
* adopt up-front enrolment charges into the current month when the class has
* just become monthly. Returns the number of charges adopted (0 when the edit
* is not a pay-now -> monthly transition, or nothing needed adopting).
*/
public function reconcile( Offering $before, Offering $after ): int {
if ( null === $after->id || Offering::KIND_GROUP_CLASS !== $after->kind ) {
return 0;
}
// Only a fresh switch into monthly scheduling can strand an up-front charge.
$becameMonthly = Offering::BILLING_MONTHLY === $after->billingMode
&& Offering::BILLING_MONTHLY !== $before->billingMode;
if ( ! $becameMonthly ) {
return 0;
}
$period = $this->currentMonth();
$dueDate = $period . '-01';
$adopted = 0;
foreach ( $this->enrollments->findActiveByOffering( $after->id ) as $enrollment ) {
$enrollmentId = (int) $enrollment->id;
// Nothing to adopt unless the enrolment holds an up-front (unscheduled)
// charge, and never when the scan has already billed this month for it —
// adopting then would leave two charges for the month, the opposite of
// the fix.
if ( ! $this->payments->hasUnscheduledCharge( Payment::REG_ENROLLMENT, $enrollmentId )
|| $this->payments->existsForPeriod( Payment::REG_ENROLLMENT, $enrollmentId, $period )
) {
continue;
}
$adopted += $this->payments->claimPeriodForUnscheduled( Payment::REG_ENROLLMENT, $enrollmentId, $period, $dueDate );
}
return $adopted;
}
/**
* The current calendar month as a `Y-m` period key, from WordPress site time
* so it matches how the billing scan derives its periods.
*/
private function currentMonth(): string {
$mysql = Val::string( current_time( 'mysql' ) );
return ( false !== strtotime( $mysql ) ? new \DateTimeImmutable( $mysql ) : new \DateTimeImmutable() )->format( 'Y-m' );
}
}
+6
View File
@@ -12,6 +12,7 @@ class OfferingController {
public function __construct( public function __construct(
private OfferingRepository $repository, private OfferingRepository $repository,
private ClassSlotReconciler $reconciler, private ClassSlotReconciler $reconciler,
private BillingModeReconciler $billingModeReconciler,
private AccessSettings $access = new AccessSettings(), private AccessSettings $access = new AccessSettings(),
) {} ) {}
@@ -81,6 +82,11 @@ class OfferingController {
if ( null !== $offering ) { if ( null !== $offering ) {
$this->repository->update( $offeringId, $offering ); $this->repository->update( $offeringId, $offering );
// Adopt any up-front enrolment charge into the current period when
// this edit switched the class to monthly, so the daily scan does
// not bill those enrolments a second time for the month.
$this->billingModeReconciler->reconcile( $existing, $offering );
return $this->reconcileNotice( $offering ); return $this->reconcileNotice( $offering );
} }
} }
+6
View File
@@ -13,6 +13,7 @@ class OfferingEndpoint {
public function __construct( public function __construct(
private OfferingRepository $repository, private OfferingRepository $repository,
private GroupAccessRepository $access, private GroupAccessRepository $access,
private BillingModeReconciler $billingModeReconciler,
) {} ) {}
/** /**
@@ -237,6 +238,11 @@ class OfferingEndpoint {
$this->repository->update( $id, $offering ); $this->repository->update( $id, $offering );
// Adopt any up-front enrolment charge into the current period when this edit
// switched the class to monthly, so the daily scan does not bill those
// enrolments a second time for the month.
$this->billingModeReconciler->reconcile( $existing, $offering );
return new \WP_REST_Response( $offering->toArray(), 200 ); return new \WP_REST_Response( $offering->toArray(), 200 );
} }
+8
View File
@@ -59,6 +59,13 @@ class Payment {
public readonly ?string $stripePaymentIntentId = null, public readonly ?string $stripePaymentIntentId = null,
public readonly ?string $receiptNumber = null, public readonly ?string $receiptNumber = null,
public readonly ?string $receiptSentAt = null, public readonly ?string $receiptSentAt = null,
/**
* When the daily billing scan emailed this payment's due notice, or null
* if it has not been noticed yet. Gates the notice so a payment is emailed
* exactly once even if the scan runs more than once (WP-Cron fires on
* request and can overlap under concurrent traffic).
*/
public readonly ?string $noticeSentAt = null,
public readonly ?string $paidAt = null, public readonly ?string $paidAt = null,
public readonly ?string $createdAt = null, public readonly ?string $createdAt = null,
public readonly ?int $id = null, public readonly ?int $id = null,
@@ -85,6 +92,7 @@ class Payment {
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ), stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ),
receiptNumber: Val::stringOrNull( $row->receipt_number ), receiptNumber: Val::stringOrNull( $row->receipt_number ),
receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ), receiptSentAt: Val::stringOrNull( $row->receipt_sent_at ),
noticeSentAt: Val::stringOrNull( $row->notice_sent_at ?? null ),
paidAt: Val::stringOrNull( $row->paid_at ), paidAt: Val::stringOrNull( $row->paid_at ),
createdAt: Val::stringOrNull( $row->created_at ), createdAt: Val::stringOrNull( $row->created_at ),
id: Val::int( $row->id ), id: Val::int( $row->id ),
+117 -1
View File
@@ -34,10 +34,11 @@ class PaymentRepository {
'stripe_payment_intent_id' => $payment->stripePaymentIntentId, 'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
'receipt_number' => $payment->receiptNumber, 'receipt_number' => $payment->receiptNumber,
'receipt_sent_at' => $payment->receiptSentAt, 'receipt_sent_at' => $payment->receiptSentAt,
'notice_sent_at' => $payment->noticeSentAt,
'paid_at' => $payment->paidAt, 'paid_at' => $payment->paidAt,
'created_at' => current_time( 'mysql' ), 'created_at' => current_time( 'mysql' ),
], ],
[ '%d', '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ] [ '%d', '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
); );
return $this->db->insert_id; return $this->db->insert_id;
@@ -271,6 +272,121 @@ class PaymentRepository {
); );
} }
/**
* Whether a registration has an unscheduled, un-voided charge one taken at
* registration (`period_key IS NULL`, `status != failed`). The billing-mode
* reconciler uses this to spot an up-front charge that a newly-scheduled
* offering would otherwise cause the scan to bill a second time.
*/
public function hasUnscheduledCharge( string $registrationType, int $registrationId ): bool {
$found = $this->db->get_var(
$this->db->prepare(
'SELECT id FROM %i WHERE registration_type = %s AND registration_id = %d AND period_key IS NULL AND status != %s LIMIT 1',
$this->table,
$registrationType,
$registrationId,
Payment::STATUS_FAILED
)
);
return null !== $found;
}
/**
* Stamp a registration's existing unscheduled charge with a billing period and
* due date so the daily scan treats that period as already billed. Used when
* an offering is switched to scheduled billing: the charge taken at enrolment
* (which carries no `period_key`) would otherwise never match the scan's
* per-period dedup, and the enrolment would be billed a second time for the
* period the up-front charge already covers.
*
* Only ever adopts a charge that is genuinely unscheduled (`period_key IS
* NULL`) and not voided (`status != failed`) so it cannot overwrite a real
* scheduled charge or revive a cancelled one. The caller guards against a
* period that already has a scheduled charge (see {@see existsForPeriod}).
* Returns the number of rows adopted (0 or 1).
*/
public function claimPeriodForUnscheduled( string $registrationType, int $registrationId, string $periodKey, string $dueDate ): int {
$sql = $this->db->prepare(
'UPDATE %i SET period_key = %s, due_date = %s
WHERE registration_type = %s AND registration_id = %d
AND period_key IS NULL AND status != %s
ORDER BY id ASC LIMIT 1',
$this->table,
$periodKey,
$dueDate,
$registrationType,
$registrationId,
Payment::STATUS_FAILED
);
if ( null === $sql ) {
return 0;
}
return (int) $this->db->query( $sql );
}
/**
* Atomically claim a payment for its one due-payment notice. Stamps
* `notice_sent_at` only if it is still null, and returns whether *this* call
* won the claim (one row updated). The billing scan calls this before
* emailing so a payment's notice is sent exactly once: WP-Cron fires on
* request and can overlap under concurrent traffic, so two scans may both
* reach the send step for the same payment the loser here updates zero rows
* and skips the email. The conditional `WHERE ... IS NULL` is the guard, not a
* prior read, so there is no check-then-act race.
*/
public function markNoticed( int $id ): bool {
$sql = $this->db->prepare(
'UPDATE %i SET notice_sent_at = %s WHERE id = %d AND notice_sent_at IS NULL',
$this->table,
current_time( 'mysql' ),
$id
);
if ( null === $sql ) {
return false;
}
return (int) $this->db->query( $sql ) === 1;
}
/**
* One-time repair for sites upgraded before `notice_sent_at` existed: dbDelta
* adds the column, and this backfills it so the historical payments those
* sites already emailed notices for are not re-noticed on the next scan.
* Every pre-existing pending scheduled row is treated as already noticed.
* Guarded by its own option flag in {@see \Unsupervised\Schedular\Plugin},
* not the version gate, since affected sites may already be on the current
* version. Returns false when the column is absent so the caller does not set
* its flag before dbDelta has run.
*/
public function backfillNoticeSent(): bool {
$column = $this->db->get_var(
$this->db->prepare(
'SHOW COLUMNS FROM %i LIKE %s',
$this->table,
'notice_sent_at'
)
);
if ( null === $column ) {
return false;
}
$sql = $this->db->prepare(
'UPDATE %i SET notice_sent_at = created_at WHERE notice_sent_at IS NULL AND period_key IS NOT NULL',
$this->table
);
if ( null !== $sql ) {
$this->db->query( $sql );
}
return true;
}
public function updateStatus( int $id, string $status ): bool { public function updateStatus( int $id, string $status ): bool {
if ( ! in_array( $status, Payment::VALID_STATUSES, true ) ) { if ( ! in_array( $status, Payment::VALID_STATUSES, true ) ) {
return false; return false;
+10
View File
@@ -106,6 +106,16 @@ class PaymentService {
$this->payments->assignNoticeBatch( $ids, $batch ); $this->payments->assignNoticeBatch( $ids, $batch );
} }
/**
* Atomically claim a payment for its single due-payment notice, returning
* whether this call won the claim. The daily scan calls this before emailing
* so a payment is noticed exactly once even when WP-Cron overlaps. Delegates
* to the ledger.
*/
public function markNoticed( int $paymentId ): bool {
return $this->payments->markNoticed( $paymentId );
}
/** /**
* Studio-admin confirmation that a pending payment (e-transfer) was received. * Studio-admin confirmation that a pending payment (e-transfer) was received.
* Marks it paid, confirms the registration, and emails the receipt. * Marks it paid, confirms the registration, and emails the receipt.
+18
View File
@@ -352,6 +352,24 @@ class ScheduledBillingRunner {
*/ */
private function sendNotices( array $buckets ): void { private function sendNotices( array $buckets ): void {
foreach ( $buckets as $payerId => $entries ) { foreach ( $buckets as $payerId => $entries ) {
// Claim each payment's one-and-only notice up front. markNoticed stamps
// notice_sent_at only if still null and reports whether this run won —
// so an overlapping scan that also created/collected these payments
// finds them already claimed and drops them here, and no payer is
// emailed the same charge twice. Only the claimed entries go on to be
// credited, batched and listed.
$entries = array_values(
array_filter(
$entries,
fn( array $entry ): bool => null !== $entry['payment']->id
&& $this->payments->markNoticed( (int) $entry['payment']->id )
)
);
if ( [] === $entries ) {
continue;
}
$payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries ); $payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
$applied = $this->payments->applyCredits( $payerId, $payments ); $applied = $this->payments->applyCredits( $payerId, $payments );
+20 -2
View File
@@ -24,6 +24,7 @@ use Unsupervised\Schedular\Guardian\ChildLoginGate;
use Unsupervised\Schedular\Guardian\FamilyPage; use Unsupervised\Schedular\Guardian\FamilyPage;
use Unsupervised\Schedular\Guardian\GuardianRepository; use Unsupervised\Schedular\Guardian\GuardianRepository;
use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\BillingModeReconciler;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\BillingMethodResolver; use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\CreditRepository; use Unsupervised\Schedular\Payment\CreditRepository;
@@ -96,6 +97,18 @@ class Plugin {
$guardians = new GuardianService( $guardianRepo, $bookings, $enrollments ); $guardians = new GuardianService( $guardianRepo, $bookings, $enrollments );
$paymentRepo = new PaymentRepository( $wpdb ); $paymentRepo = new PaymentRepository( $wpdb );
// One-time backfill of us_payments.notice_sent_at, which dbDelta adds
// defaulting to NULL — leaving every already-noticed scheduled payment
// looking un-noticed, which the billing scan would email again. Backfills
// existing scheduled rows to their created_at so only genuinely new
// payments get a notice from here on. Guarded by its own flag rather than
// the version gate, since affected sites may already be on the current
// version; set only once the column exists and the update runs.
if ( '1' !== get_option( 'us_payments_notice_sent_backfilled', '' ) && $paymentRepo->backfillNoticeSent() ) {
update_option( 'us_payments_notice_sent_backfilled', '1' );
}
$creditRepo = new CreditRepository( $wpdb ); $creditRepo = new CreditRepository( $wpdb );
$settings = new StudioSettings(); $settings = new StudioSettings();
$resolver = new BillingMethodResolver( $settings ); $resolver = new BillingMethodResolver( $settings );
@@ -117,6 +130,11 @@ class Plugin {
$familyPage = new FamilyPage( $guardians, $questions, $answers ); $familyPage = new FamilyPage( $guardians, $questions, $answers );
$accountPage = new AccountPage(); $accountPage = new AccountPage();
// Adopts an up-front enrolment charge into the current billing period when a
// group class is switched to monthly, so the daily scan does not bill it a
// second time for a month the up-front charge already covers.
$billingModeReconciler = new BillingModeReconciler( $enrollments, $paymentRepo );
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register(); ( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
( new UpdateChecker() )->register(); ( new UpdateChecker() )->register();
@@ -126,8 +144,8 @@ class Plugin {
( new StudentAdminGuard() )->register(); ( new StudentAdminGuard() )->register();
( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService, $guardianRepo, $guardians ) )->register(); ( new DeletedUserCleanup( $bookings, $availability, $enrollments, $paymentService, $guardianRepo, $guardians ) )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register(); ( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians, $lessonBooker, $registrationGate ) )->register(); ( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians, $lessonBooker, $registrationGate, $billingModeReconciler ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians, $lessonBooker ) )->register(); ( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians, $lessonBooker, $billingModeReconciler ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register(); ( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register(); ( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
} }
+3 -2
View File
@@ -15,6 +15,7 @@ use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository; use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\SessionSchedule; use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService; use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\BillingModeReconciler;
use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\PaymentEndpoint; use Unsupervised\Schedular\Payment\PaymentEndpoint;
@@ -40,10 +41,10 @@ class RestRegistrar {
private EnrollmentEndpoint $enrollmentEndpoint; private EnrollmentEndpoint $enrollmentEndpoint;
private PaymentEndpoint $paymentEndpoint; private PaymentEndpoint $paymentEndpoint;
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians, LessonBooker $booker ) { public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians, LessonBooker $booker, BillingModeReconciler $billingModeReconciler ) {
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) ); $this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) );
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, $booker, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) ); $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, $booker, new CancellationPolicy( new StudioSettings() ), $guardians, new SessionSchedule( $enrollments, $offerings ) );
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess ); $this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess, $billingModeReconciler );
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService ); $this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians ); $this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians );
+1
View File
@@ -207,6 +207,7 @@ class Schema {
stripe_payment_intent_id VARCHAR(255) DEFAULT NULL, stripe_payment_intent_id VARCHAR(255) DEFAULT NULL,
receipt_number VARCHAR(50) DEFAULT NULL, receipt_number VARCHAR(50) DEFAULT NULL,
receipt_sent_at DATETIME DEFAULT NULL, receipt_sent_at DATETIME DEFAULT NULL,
notice_sent_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
paid_at DATETIME DEFAULT NULL, paid_at DATETIME DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
@@ -110,6 +110,35 @@ class EnrollmentRepositoryTest extends TestCase
self::assertInstanceOf(Enrollment::class, $all[0]); self::assertInstanceOf(Enrollment::class, $all[0]);
} }
public function testFindActiveByOfferingReturnsActiveEnrolmentsOldestFirst(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/offering_id = %d AND status = %s ORDER BY id ASC/'),
'wp_us_group_enrollments',
9,
Enrollment::STATUS_ACTIVE
)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_results')->andReturn([
(object) [
'id' => '7',
'offering_id' => '9',
'student_id' => '5',
'instructor_id' => '3',
'status' => Enrollment::STATUS_ACTIVE,
'payment_id' => null,
],
]);
$found = $this->repo->findActiveByOffering(9);
self::assertCount(1, $found);
self::assertSame(7, $found[0]->id);
}
public function testFindActiveByBillingModesJoinsOfferingAndFiltersModes(): void public function testFindActiveByBillingModesJoinsOfferingAndFiltersModes(): void
{ {
$this->db->shouldReceive('prepare') $this->db->shouldReceive('prepare')
@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Offering;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Offering\BillingModeReconciler;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class BillingModeReconcilerTest extends TestCase
{
private EnrollmentRepository&Mockery\MockInterface $enrollments;
private PaymentRepository&Mockery\MockInterface $payments;
private BillingModeReconciler $reconciler;
protected function setUp(): void
{
parent::setUp();
Functions\when('current_time')->justReturn('2026-09-15 09:00:00');
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->payments = Mockery::mock(PaymentRepository::class);
$this->reconciler = new BillingModeReconciler($this->enrollments, $this->payments);
}
private function offering(string $billingMode, ?int $id = 9): Offering
{
return new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Ensemble',
price: 150.0,
billingMode: $billingMode,
id: $id,
);
}
private function enrollment(int $id, int $studentId = 5): Enrollment
{
return new Enrollment(offeringId: 9, studentId: $studentId, instructorId: 3, id: $id);
}
public function testAdoptsUpfrontChargeIntoCurrentMonthWhenClassBecomesMonthly(): void
{
$this->enrollments->shouldReceive('findActiveByOffering')->with(9)->andReturn([$this->enrollment(7)]);
$this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(true);
$this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 7, '2026-09')->andReturn(false);
// The up-front charge is stamped into 2026-09, due on the 1st, so the scan
// treats September as already billed for this enrolment.
$this->payments->shouldReceive('claimPeriodForUnscheduled')
->once()
->with(Payment::REG_ENROLLMENT, 7, '2026-09', '2026-09-01')
->andReturn(1);
self::assertSame(1, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY)));
}
public function testDoesNothingWhenBillingModeDidNotChangeIntoMonthly(): void
{
$this->enrollments->shouldNotReceive('findActiveByOffering');
$this->payments->shouldNotReceive('claimPeriodForUnscheduled');
// Already monthly -> monthly (e.g. an unrelated title edit): no transition.
self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_MONTHLY), $this->offering(Offering::BILLING_MONTHLY)));
}
public function testDoesNothingWhenSwitchingToWeekly(): void
{
$this->enrollments->shouldNotReceive('findActiveByOffering');
$this->payments->shouldNotReceive('claimPeriodForUnscheduled');
// Weekly bills per session as sessions come due; there is no single upfront
// period an enrolment charge maps onto, so it is left alone.
self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_WEEKLY)));
}
public function testSkipsEnrolmentWithoutAnUpfrontCharge(): void
{
$this->enrollments->shouldReceive('findActiveByOffering')->with(9)->andReturn([$this->enrollment(7)]);
// Enrolled after the class was already scheduled, or never charged upfront:
// nothing to adopt.
$this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(false);
$this->payments->shouldNotReceive('claimPeriodForUnscheduled');
self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY)));
}
public function testSkipsEnrolmentAlreadyBilledForThisMonthByTheScan(): void
{
$this->enrollments->shouldReceive('findActiveByOffering')->with(9)->andReturn([$this->enrollment(7)]);
$this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(true);
// The scan already made a 2026-09 charge: adopting the upfront one too would
// leave two charges for the month — the very thing we are preventing.
$this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 7, '2026-09')->andReturn(true);
$this->payments->shouldNotReceive('claimPeriodForUnscheduled');
self::assertSame(0, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY)));
}
public function testReconcilesEveryActiveEnrolment(): void
{
$this->enrollments->shouldReceive('findActiveByOffering')->with(9)
->andReturn([$this->enrollment(7, 5), $this->enrollment(8, 6)]);
$this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 7)->andReturn(true);
$this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 7, '2026-09')->andReturn(false);
$this->payments->shouldReceive('claimPeriodForUnscheduled')->with(Payment::REG_ENROLLMENT, 7, '2026-09', '2026-09-01')->andReturn(1);
$this->payments->shouldReceive('hasUnscheduledCharge')->with(Payment::REG_ENROLLMENT, 8)->andReturn(true);
$this->payments->shouldReceive('existsForPeriod')->with(Payment::REG_ENROLLMENT, 8, '2026-09')->andReturn(false);
$this->payments->shouldReceive('claimPeriodForUnscheduled')->with(Payment::REG_ENROLLMENT, 8, '2026-09', '2026-09-01')->andReturn(1);
self::assertSame(2, $this->reconciler->reconcile($this->offering(Offering::BILLING_ONE_TIME), $this->offering(Offering::BILLING_MONTHLY)));
}
public function testIgnoresNonGroupClassOfferings(): void
{
$before = new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Piano', billingMode: Offering::BILLING_ONE_TIME, id: 9);
$after = new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Piano', billingMode: Offering::BILLING_MONTHLY, id: 9);
$this->enrollments->shouldNotReceive('findActiveByOffering');
self::assertSame(0, $this->reconciler->reconcile($before, $after));
}
}
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Offering;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Offering\BillingModeReconciler;
use Unsupervised\Schedular\Offering\ClassSlotReconciler; use Unsupervised\Schedular\Offering\ClassSlotReconciler;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingController; use Unsupervised\Schedular\Offering\OfferingController;
@@ -15,6 +16,7 @@ class OfferingControllerTest extends TestCase
{ {
private OfferingRepository&Mockery\MockInterface $repository; private OfferingRepository&Mockery\MockInterface $repository;
private ClassSlotReconciler&Mockery\MockInterface $reconciler; private ClassSlotReconciler&Mockery\MockInterface $reconciler;
private BillingModeReconciler&Mockery\MockInterface $billingModeReconciler;
private OfferingController $controller; private OfferingController $controller;
protected function setUp(): void protected function setUp(): void
@@ -24,7 +26,9 @@ class OfferingControllerTest extends TestCase
$this->repository = Mockery::mock(OfferingRepository::class); $this->repository = Mockery::mock(OfferingRepository::class);
$this->reconciler = Mockery::mock(ClassSlotReconciler::class); $this->reconciler = Mockery::mock(ClassSlotReconciler::class);
$this->reconciler->shouldReceive('reconcile')->andReturn(['removed' => 0, 'conflicts' => []])->byDefault(); $this->reconciler->shouldReceive('reconcile')->andReturn(['removed' => 0, 'conflicts' => []])->byDefault();
$this->controller = new OfferingController($this->repository, $this->reconciler); $this->billingModeReconciler = Mockery::mock(BillingModeReconciler::class);
$this->billingModeReconciler->shouldReceive('reconcile')->andReturn(0)->byDefault();
$this->controller = new OfferingController($this->repository, $this->reconciler, $this->billingModeReconciler);
$_POST = []; $_POST = [];
$_GET = []; $_GET = [];
+5 -1
View File
@@ -7,6 +7,7 @@ use Brain\Monkey\Functions;
use Mockery; use Mockery;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Offering\BillingModeReconciler;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingEndpoint; use Unsupervised\Schedular\Offering\OfferingEndpoint;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
@@ -16,6 +17,7 @@ class OfferingEndpointTest extends TestCase
{ {
private OfferingRepository&Mockery\MockInterface $repository; private OfferingRepository&Mockery\MockInterface $repository;
private GroupAccessRepository&Mockery\MockInterface $access; private GroupAccessRepository&Mockery\MockInterface $access;
private BillingModeReconciler&Mockery\MockInterface $billingModeReconciler;
private OfferingEndpoint $endpoint; private OfferingEndpoint $endpoint;
protected function setUp(): void protected function setUp(): void
@@ -27,7 +29,9 @@ class OfferingEndpointTest extends TestCase
$this->repository = Mockery::mock(OfferingRepository::class); $this->repository = Mockery::mock(OfferingRepository::class);
$this->access = Mockery::mock(GroupAccessRepository::class); $this->access = Mockery::mock(GroupAccessRepository::class);
$this->endpoint = new OfferingEndpoint($this->repository, $this->access); $this->billingModeReconciler = Mockery::mock(BillingModeReconciler::class);
$this->billingModeReconciler->shouldReceive('reconcile')->andReturn(0)->byDefault();
$this->endpoint = new OfferingEndpoint($this->repository, $this->access, $this->billingModeReconciler);
} }
private function group(int $id, string $access): Offering private function group(int $id, string $access): Offering
@@ -124,6 +124,107 @@ class PaymentRepositoryTest extends TestCase
self::assertTrue($this->repo->markPaid(50, 'USC-50')); self::assertTrue($this->repo->markPaid(50, 'USC-50'));
} }
public function testMarkNoticedStampsOnlyUnnoticedRowAndReportsTheWin(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00');
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/SET notice_sent_at = %s WHERE id = %d AND notice_sent_at IS NULL/'), 'wp_us_payments', '2026-06-08 12:00:00', 50)
->andReturn('UPDATE ...');
// One row updated -> this call won the claim.
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1);
self::assertTrue($this->repo->markNoticed(50));
}
public function testMarkNoticedReturnsFalseWhenAlreadyClaimed(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-08 12:00:00');
$this->db->shouldReceive('prepare')->once()->andReturn('UPDATE ...');
// Zero rows updated -> another run already stamped notice_sent_at.
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(0);
self::assertFalse($this->repo->markNoticed(50));
}
public function testBackfillNoticeSentStampsExistingScheduledRowsWhenColumnPresent(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/SHOW COLUMNS FROM %i LIKE %s/'), 'wp_us_payments', 'notice_sent_at')
->andReturn('SHOW ...');
$this->db->shouldReceive('get_var')->once()->with('SHOW ...')->andReturn('notice_sent_at');
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/SET notice_sent_at = created_at WHERE notice_sent_at IS NULL AND period_key IS NOT NULL/'), 'wp_us_payments')
->andReturn('UPDATE ...');
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(3);
self::assertTrue($this->repo->backfillNoticeSent());
}
public function testBackfillNoticeSentIsNoopWhenColumnAbsent(): void
{
$this->db->shouldReceive('prepare')->once()->andReturn('SHOW ...');
$this->db->shouldReceive('get_var')->once()->with('SHOW ...')->andReturn(null);
// Column not there yet: do not attempt the UPDATE, and report not-done so
// the caller does not set its one-time flag before dbDelta has run.
$this->db->shouldNotReceive('query');
self::assertFalse($this->repo->backfillNoticeSent());
}
public function testHasUnscheduledChargeTrueWhenUpfrontChargeExists(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(Mockery::pattern('/period_key IS NULL AND status != %s/'), 'wp_us_payments', Payment::REG_ENROLLMENT, 7, Payment::STATUS_FAILED)
->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->once()->with('SELECT ...')->andReturn('3');
self::assertTrue($this->repo->hasUnscheduledCharge(Payment::REG_ENROLLMENT, 7));
}
public function testHasUnscheduledChargeFalseWhenNoneOrOnlyVoided(): void
{
$this->db->shouldReceive('prepare')->once()->andReturn('SELECT ...');
$this->db->shouldReceive('get_var')->once()->andReturn(null);
self::assertFalse($this->repo->hasUnscheduledCharge(Payment::REG_ENROLLMENT, 7));
}
public function testClaimPeriodForUnscheduledStampsPeriodAndDueDate(): void
{
$this->db->shouldReceive('prepare')
->once()
->with(
Mockery::pattern('/SET period_key = %s, due_date = %s.*period_key IS NULL AND status != %s/s'),
'wp_us_payments',
'2026-09',
'2026-09-01',
Payment::REG_ENROLLMENT,
7,
Payment::STATUS_FAILED
)
->andReturn('UPDATE ...');
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(1);
self::assertSame(1, $this->repo->claimPeriodForUnscheduled(Payment::REG_ENROLLMENT, 7, '2026-09', '2026-09-01'));
}
public function testClaimPeriodForUnscheduledAdoptsNothingWhenNoMatch(): void
{
$this->db->shouldReceive('prepare')->once()->andReturn('UPDATE ...');
$this->db->shouldReceive('query')->once()->with('UPDATE ...')->andReturn(0);
self::assertSame(0, $this->repo->claimPeriodForUnscheduled(Payment::REG_ENROLLMENT, 7, '2026-09', '2026-09-01'));
}
public function testUpdateTaxRecomputesAmountFromRate(): void public function testUpdateTaxRecomputesAmountFromRate(): void
{ {
$this->db->shouldReceive('prepare') $this->db->shouldReceive('prepare')
+2
View File
@@ -45,6 +45,7 @@ class PaymentTest extends TestCase
'stripe_payment_intent_id' => null, 'stripe_payment_intent_id' => null,
'receipt_number' => 'USC-7', 'receipt_number' => 'USC-7',
'receipt_sent_at' => null, 'receipt_sent_at' => null,
'notice_sent_at' => '2026-06-07 08:00:00',
'paid_at' => '2026-06-08 10:00:00', 'paid_at' => '2026-06-08 10:00:00',
'created_at' => '2026-06-08 09:00:00', 'created_at' => '2026-06-08 09:00:00',
]); ]);
@@ -56,6 +57,7 @@ class PaymentTest extends TestCase
self::assertSame(Payment::METHOD_COMP, $payment->method); self::assertSame(Payment::METHOD_COMP, $payment->method);
self::assertTrue($payment->isPaid()); self::assertTrue($payment->isPaid());
self::assertSame('USC-7', $payment->receiptNumber); self::assertSame('USC-7', $payment->receiptNumber);
self::assertSame('2026-06-07 08:00:00', $payment->noticeSentAt);
self::assertSame('2026-06-08 09:00:00', $payment->createdAt); self::assertSame('2026-06-08 09:00:00', $payment->createdAt);
} }
@@ -44,6 +44,9 @@ class ScheduledBillingRunnerTest extends TestCase
$this->payments->shouldReceive('assignNoticeBatch')->byDefault(); $this->payments->shouldReceive('assignNoticeBatch')->byDefault();
// No account credit unless a test says otherwise. // No account credit unless a test says otherwise.
$this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault(); $this->payments->shouldReceive('applyCredits')->andReturn([])->byDefault();
// Every payment wins its notice claim unless a test simulates an
// overlapping run that already claimed it.
$this->payments->shouldReceive('markNoticed')->andReturn(true)->byDefault();
Functions\when('wp_generate_uuid4')->justReturn('abcdef12-3456-7890-abcd-ef1234567890'); Functions\when('wp_generate_uuid4')->justReturn('abcdef12-3456-7890-abcd-ef1234567890');
@@ -324,6 +327,62 @@ class ScheduledBillingRunnerTest extends TestCase
$this->runner->run(); $this->runner->run();
} }
/**
* A payment is emailed exactly once. WP-Cron fires on request and can run the
* scan twice concurrently; the second run reaching the send step for a
* payment already claimed by the first (markNoticed returns false) must not
* email it again, nor re-batch it. This is the double-notice regression.
*/
public function testDoesNotEmailAPaymentWhoseNoticeWasAlreadyClaimed(): void
{
$this->now('2026-07-15 09:00:00');
$this->bookings->shouldReceive('findUnbilledScheduledLessons')
->andReturn([ $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0) ]);
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pending(500, '2026-07-14'));
// The competing run already stamped notice_sent_at, so the claim loses.
$this->payments->shouldReceive('markNoticed')->with(500)->once()->andReturn(false);
// No credit, no batch, no email for an already-noticed payment.
$this->payments->shouldNotReceive('applyCredits');
$this->payments->shouldNotReceive('assignNoticeBatch');
$this->mailer->shouldNotReceive('send');
$this->runner->run();
}
/**
* When only some of a payer's charges are already claimed, the run notices
* the rest the still-unclaimed payment is emailed and batched on its own.
*/
public function testEmailsOnlyTheStillUnclaimedPaymentsInABucket(): void
{
$this->now('2026-07-15 09:00:00');
$one = $this->lessonRow(101, Offering::BILLING_WEEKLY, '2026-07-15 18:00:00', 35.0);
$two = $this->lessonRow(102, Offering::BILLING_WEEKLY, '2026-07-15 19:00:00', 35.0);
$this->bookings->shouldReceive('findUnbilledScheduledLessons')->andReturn([$one, $two]);
$this->payments->shouldReceive('createForRegistration')
->andReturn($this->pending(500, '2026-07-14'), $this->pending(501, '2026-07-14'));
// 500 already claimed by an overlapping run; 501 is this run's to send.
$this->payments->shouldReceive('markNoticed')->with(500)->once()->andReturn(false);
$this->payments->shouldReceive('markNoticed')->with(501)->once()->andReturn(true);
$this->payments->shouldReceive('applyCredits')
->once()
->with(5, Mockery::on(static fn (array $p): bool => count($p) === 1 && (int) $p[0]->id === 501))
->andReturn([]);
$this->payments->shouldReceive('assignNoticeBatch')->once()->with([501], Mockery::type('string'));
$this->mailer->shouldReceive('send')
->once()
->with(Mockery::type(\WP_User::class), Mockery::on(static fn (array $items): bool => count($items) === 1), Mockery::type('string'), 0.0);
$this->runner->run();
}
private function groupOffering(string $mode, string $termStart, string $termEnd): Offering private function groupOffering(string $mode, string $termStart, string $termEnd): Offering
{ {
return new Offering( return new Offering(
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler * Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler * Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress. * Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.5.7 * Version: 1.5.8
* Requires at least: 6.2 * Requires at least: 6.2
* Requires PHP: 8.1 * Requires PHP: 8.1
* Author: Unsupervised * Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit; exit;
} }
define('USC_VERSION', '1.5.7'); define('USC_VERSION', '1.5.8');
define('USC_PLUGIN_FILE', __FILE__); define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));