diff --git a/browser-extension/tests/background.integration.test.cjs b/browser-extension/tests/background.integration.test.cjs index 8739fbe9..f66e80f3 100644 --- a/browser-extension/tests/background.integration.test.cjs +++ b/browser-extension/tests/background.integration.test.cjs @@ -318,6 +318,17 @@ test("connecting another project reuses the user-global browser credential", asy assert.equal(harness.storageValue(SESSION_KEY).project_id, "project_second"); }); +test("eventually waits for asynchronous work beyond 100 event-loop turns", async () => { + let ready = false; + const pending = settle(200).then(() => { ready = true; }); + try { + await eventually(() => ready); + assert.equal(ready, true); + } finally { + await pending; + } +}); + test("concurrent project connects serialize onto one browser credential", async () => { let releaseFirst; const firstResponse = new Promise((resolve) => { diff --git a/docs/payment-recovery.md b/docs/payment-recovery.md index 8be4e315..5073b837 100644 --- a/docs/payment-recovery.md +++ b/docs/payment-recovery.md @@ -90,7 +90,8 @@ After approval, Tin goes through the emails one by one, about 15 seconds apart: 1. If this invoice already has a send receipt for the project, it is skipped. 2. Stripe is asked again for the customer's open invoices; if this invoice is no longer open - (paid, voided or closed), it is skipped. + (paid, voided or closed), has no balance left, or no longer qualifies as a failed + automatic payment, it is skipped. 3. The email goes out from the connected Gmail address, plain text, with a Message-ID derived from the receipt so a retried attempt is looked up in Gmail before it is ever re-sent. diff --git a/src/tin_lite/payment_recovery_activities.py b/src/tin_lite/payment_recovery_activities.py index a4ed34b7..b68107a2 100644 --- a/src/tin_lite/payment_recovery_activities.py +++ b/src/tin_lite/payment_recovery_activities.py @@ -770,8 +770,14 @@ async def _send(self, run, scope, email, row, stripe) -> dict: execution_key=f"{key}:recheck:{run.id}", max_response_bytes=None, ) - if email["invoice_id"] not in {i["id"] for i in still_open["records"]}: - return {**row, "status": "skipped", "reason": "paid or closed since approval"} + if email["invoice_id"] not in { + i["id"] for i in payment_recovery.failed_invoices(still_open["records"]) + }: + return { + **row, + "status": "skipped", + "reason": "paid or closed since approval, or no longer eligible", + } await self.db.start_effect(conn, execution_key=key, operation=KEY) await self.db.save_effect_progress( conn, diff --git a/tests/test_payment_recovery.py b/tests/test_payment_recovery.py index 702abc82..47c52d99 100644 --- a/tests/test_payment_recovery.py +++ b/tests/test_payment_recovery.py @@ -1336,6 +1336,42 @@ def test_only_the_newest_invoice_counts_as_already_emailed(): assert pr.throwaway("x@mail.yopmail.com") and not pr.throwaway("x@yopmail.company") +@pytest.mark.parametrize( + "change", + [ + {"amount_remaining": 0}, + {"collection_method": "send_invoice"}, + {"attempt_count": 0}, + ], +) +async def test_recheck_requires_still_eligible_invoice(monkeypatch, change): + s = setup(monkeypatch, answer=model_answer()) + await run_to_review(s) + await s.activities.record_approval(s.run_id) + for invoice in s.integrations.stripe.fake.objects["invoices"]: + if invoice["id"] == "in_A": + invoice.update(change) + await s.activities.apply(s.run_id) + await s.activities.apply(s.run_id) + assert [m["recipient_email"] for m in s.integrations.sent] == ["sam@samlee.io"] + assert await s.activities._result(s.run_id, "applied") + + +async def test_partial_payment_still_gets_one_approved_email(monkeypatch): + s = setup(monkeypatch, answer=model_answer()) + await run_to_review(s) + await s.activities.record_approval(s.run_id) + for invoice in s.integrations.stripe.fake.objects["invoices"]: + if invoice["id"] == "in_A": + invoice.update(amount_remaining=100, amount_paid=2800) + await s.activities.apply(s.run_id) + await s.activities.apply(s.run_id) + assert [m["recipient_email"] for m in s.integrations.sent] == [ + "alex@rivera.dev", + "sam@samlee.io", + ] + + @pytest.mark.parametrize("code", ["project_limit", "insufficient_funds", "unpriced_model"]) async def test_a_billing_refused_draft_names_its_cause_and_is_not_bought_again(monkeypatch, code): from tin_lite.billing_contracts import BillingError