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')
|
||||
|
||||
Reference in New Issue
Block a user