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')