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
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:
co-authored by
anthropic/claude-opus-4-8
parent
6c92fc35fd
commit
e389e40843
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user