Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions browser-extension/tests/background.integration.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
3 changes: 2 additions & 1 deletion docs/payment-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 8 additions & 2 deletions src/tin_lite/payment_recovery_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions tests/test_payment_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading