From e1a33b6301ae0448df3b1aae497ff55b000dabf2 Mon Sep 17 00:00:00 2001 From: Hamit Arslan Date: Mon, 12 Oct 2026 11:12:25 +0300 Subject: [PATCH 1/3] fix: recheck failed-payment eligibility before sending --- docs/payment-recovery.md | 3 +- src/tin_lite/payment_recovery_activities.py | 10 ++++-- tests/test_payment_recovery.py | 36 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) 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 341e086c..60e7d561 100644 --- a/src/tin_lite/payment_recovery_activities.py +++ b/src/tin_lite/payment_recovery_activities.py @@ -748,8 +748,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 f038827f..2e071dd5 100644 --- a/tests/test_payment_recovery.py +++ b/tests/test_payment_recovery.py @@ -1333,3 +1333,39 @@ def test_only_the_newest_invoice_counts_as_already_emailed(): old = {"to": "a@b.dev", "state": "sent", "invoice": "in_old", "at": NOW - 60 * DAY} assert pr.held_back(person, [old], NOW) is None 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", + ] From c6b4af19b008aea4ea99d02d20f8c438f9ab4f9b Mon Sep 17 00:00:00 2001 From: Hamit Arslan Date: Tue, 13 Oct 2026 03:01:14 +0300 Subject: [PATCH 2/3] test: wait for async extension work by elapsed time --- .../tests/background.integration.test.cjs | 11 +++++++++++ .../tests/helpers/extension-script-harness.cjs | 6 ++++-- 2 files changed, 15 insertions(+), 2 deletions(-) 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/browser-extension/tests/helpers/extension-script-harness.cjs b/browser-extension/tests/helpers/extension-script-harness.cjs index be7a1b78..4406ff16 100644 --- a/browser-extension/tests/helpers/extension-script-harness.cjs +++ b/browser-extension/tests/helpers/extension-script-harness.cjs @@ -4,6 +4,7 @@ const fs = require("node:fs"); const path = require("node:path"); const vm = require("node:vm"); const { webcrypto } = require("node:crypto"); +const { setTimeout: delay } = require("node:timers/promises"); const EXTENSION_ROOT = path.resolve(__dirname, "../.."); const BROWSER_CONTEXT_KEY = "tin.linkedin.browser-context.v1"; @@ -36,9 +37,10 @@ async function settle(turns = 6) { } async function eventually(predicate, message = "condition was not met") { - for (let attempt = 0; attempt < 100; attempt += 1) { + const deadline = performance.now() + 5_000; + while (performance.now() < deadline) { if (await predicate()) return; - await settle(1); + await delay(5); } throw new Error(message); } From 77862d53bde7fe5f8e1d4ecd7f3f44bc354e03db Mon Sep 17 00:00:00 2001 From: Hamit Arslan Date: Tue, 13 Oct 2026 03:11:22 +0300 Subject: [PATCH 3/3] test: wait for lock page baseline before advancing setup --- web/lock-page.browser.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/lock-page.browser.test.js b/web/lock-page.browser.test.js index 086f8f11..f0fdded5 100644 --- a/web/lock-page.browser.test.js +++ b/web/lock-page.browser.test.js @@ -175,6 +175,8 @@ test("lock page: the steps tick as the agent connects, sets up, and the plan ope writes.length = 0; const {page, context, errors} = await open(browser, base, {theme}); await page.locator(".lock-step").first().waitFor(); + // Wait for the first poll before changing the mocked server state. + await page.waitForFunction(() => state.lockSetup !== null); if (shots) await page.screenshot({path: path.join(shots, `lock-a-${theme}.png`)}); // B: the agent's first signed-in project call. The install line folds away.