Send each scheduled payment's due notice exactly once
CI / Coding Standards (pull_request) Successful in 28s
CI / Tests (PHP 8.1) (pull_request) Successful in 35s
CI / No Debug Code (pull_request) Successful in 9s
CI / Tests (PHP 8.3) (pull_request) Successful in 44s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / Tests (PHP 8.5) (pull_request) Successful in 48s
CI / Static Analysis (pull_request) Successful in 52s
CI / Build Plugin Zip (pull_request) Skipped

The daily billing scan runs on request via WP-Cron and can overlap
itself under concurrent traffic. Each run emailed the payments it
created with no record that a notice had gone out, so two overlapping
runs could send a payer two identical "Payment due" emails for one
charge — read by families as being billed twice, though only one row
exists.

Stamp us_payments.notice_sent_at atomically before emailing: the scan
now claims each payment with a conditional UPDATE ... WHERE
notice_sent_at IS NULL and only notices, credits and batches the rows
it won. A competing run finds them claimed and stays quiet, so exactly
one notice is sent regardless of how the scan is triggered. A one-time
backfill stamps existing scheduled rows on upgrade so already-noticed
charges are not re-emailed.

Co-authored-by: anthropic/claude-opus-4-8
This commit is contained in:
2026-09-17 13:19:25 -03:00
co-authored by anthropic/claude-opus-4-8
parent 6c92fc35fd
commit e389e40843
11 changed files with 235 additions and 4 deletions
@@ -124,6 +124,61 @@ class PaymentRepositoryTest extends TestCase
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 testUpdateTaxRecomputesAmountFromRate(): void
{
$this->db->shouldReceive('prepare')
+2
View File
@@ -45,6 +45,7 @@ class PaymentTest extends TestCase
'stripe_payment_intent_id' => null,
'receipt_number' => 'USC-7',
'receipt_sent_at' => null,
'notice_sent_at' => '2026-06-07 08:00:00',
'paid_at' => '2026-06-08 10: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::assertTrue($payment->isPaid());
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);
}
@@ -44,6 +44,9 @@ class ScheduledBillingRunnerTest extends TestCase
$this->payments->shouldReceive('assignNoticeBatch')->byDefault();
// No account credit unless a test says otherwise.
$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');
@@ -324,6 +327,62 @@ class ScheduledBillingRunnerTest extends TestCase
$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
{
return new Offering(