| Field | Value |
|---|---|
| Date | 10 Jun 2026 |
| Status | Complete (deploy + service restarts done) |
| GitHub HEAD | 0d2c666 |
| Wiki | Updated through Session 22 |
Four commits shipped this session, plus a major operational sweep — orphan PDF cleanup, a serious nginx information-disclosure fix, discovery and explanation of a regional Twilio sync subtlety, and a surgical delete of historical spam CDRs. Six GitHub issues filed for ongoing tracking (#1–#5 retroactive design gaps / latent bugs / enhancements, #6 closed as session changelog, #7 closed after fix, #8 still open for orphan-PDF signal). Backlog item E (overdue reminders) was implemented end-to-end and is now live.
1ed7c02get_last_sync_time() in apps/cdr/twilio_client.py previously fell back to the hardcoded constant datetime(2024, 1, 1, tzinfo=dt_timezone.utc) when a TwilioAccount had last_synced_at = NULL. That date drifted further behind real Twilio retention (~13 months) as time passed. Replaced with django_timezone.now() - timedelta(days=365) so the lookback stays meaningful, matches Twilio's actual retention, and stops re-pulling stale records on a wipe.
Behaviour change is invisible during normal incremental syncs (those still resume from last_synced_at - 1h). It only matters when an account's last_synced_at is reset to NULL — first-ever sync, or a deliberate full backfill after a wipe.
96ffcddThe Customer detail page (/customers/<uuid>) now has a primary Edit button next to Back in the Topbar. Opens the same CustomerForm used by the list page (the form was promoted to a named export from Customers.jsx). Eliminates the round-trip back to /customers for a small edit.
Also fixed a pre-existing bug surfaced by adding the new button: the Account SID field was rendering blank when editing an existing customer because the customer API returns the linked Twilio account on customer.twilio_accounts[] (array), while the form read form.twilio_account_sid (scalar). Form now pre-populates from initial.twilio_accounts[0].account_sid and .region. Account SID and Region inputs are disabled in edit mode with a hint pointing to the Sync page as the source of truth — the customer PATCH endpoint never accepted those fields, so any edits were being silently dropped before this fix.
d65c310The 'overdue' invoice status was previously unreachable — no code path assigned it. Wired up end-to-end via a new daily Celery Beat task.
billing.process_overdue_invoices runs daily at 06:00 Australia/Sydney via a Beat schedule seeded by data migration. Two passes:
status='sent' AND due_date < today AND amount_paid_aud < total_aud to status='overdue'.| Level | Days overdue | Subject prefix | Hero colour |
|---|---|---|---|
| L1 | ≥ 0 (same day as overdue flip) | Payment overdue | Amber |
| L2 | ≥ 14 | Second reminder | Red |
| L3 | ≥ 30 | Final notice | Deep red |
Each invoice tracks overdue_reminders_sent (0..3) and last_overdue_reminder_at so the task is idempotent. Re-running the same day is a no-op for any invoice already at its current level.
Customer.overdue_reminders_enabled (BooleanField, default True) suppresses reminders entirely for that customer. Editable from the Edit Customer dialog on both the list and detail pages.
templates/email/overdue_reminder.html + .txtBCC_INVOICE_ENABLED / BCC_INVOICE_EMAIL Settings rows — no new Settings UIcustomers/0004_add_overdue_reminders_enabled.pybilling/0003_add_overdue_reminder_tracking.pybilling/0004_seed_overdue_beat_schedule.py (data migration creating the PeriodicTask)19/19 assertions passed via override_settings(EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend') covering status transitions, counter increments, threshold logic, and the customer-toggle skip path. Then a real 3-email send (L1 + L2 + L3) to a test customer's billing_email via real SMTP succeeded — no SMTP errors.
0d2c666Operator-driven status transitions that bypass the lifecycle (draft → sent → paid → overdue).
POST /api/invoices/{id}/set-status/ body {"status": "<new>", "note": "<optional reason>"}
Invoice.STATUS_CHOICES (400).overdue_reminders_sent + last_overdue_reminder_at so the daily Beat task re-evaluates cleanly after a manual flip (e.g. a due-date extension that reverts overdue → sent is allowed to re-fire reminders if the invoice ages again).[YYYY-MM-DD HH:MM] Status changed: <old> → <new> — <note> to invoice.notes as a timestamped audit trail.New "Status" button on each Invoices-page row alongside Send / PDF / Delete. Modal shows current status, new-status dropdown excluding the current value, optional note, and inline warnings for the riskier transitions:
paid: no Payment record is created and no receipt email is sent — use Record Payment flow if both are needed.void: existing confirmed payments are not refunded.overdue: reminder counter will reset.4/4 API assertions on dev: valid transition with audit, invalid status rejected, same-status rejected, transition to void with audit append.
While verifying the new 12-month window after wiping CDRs, an initial manual test sync (called sync_from_api(account_sid=Master, region='us1') directly per account) returned 6,580 CDRs total on both dev and prod. The dev/prod equality was reassuring but the absolute number looked suspiciously low — previous syncs had been pulling 40k+ for the Master account during the May–Jul 2025 attack window.
A direct probe of Twilio's US1 endpoint for Master in the last 365 days returned 54 calls and to='+61390000001' returned zero. I initially concluded Twilio had aged out the spam burst data.
After Beat fired its catch-up sync (on celery restart), dev and prod ended up at 34,037 CDRs instead of 6,580. Investigation revealed why:
api.twilio.com): 54 calls in 365 daysapi.sydney.au1.twilio.com): 27,430 calls in 365 daysThe historic spam-burst CDRs are stored in the AU1 (Sydney) regional database, not US1. The Beat task's sync_account_task syncs each account against both us1 and au1, which is correct — my single-region manual probe was the bug. Twilio's CDR retention isn't shorter than advertised; the data is exactly where it should be once you query the right region.
This is now permanently captured in the Claude Context "Known Watch Points" table — calls to Australian DIDs (and AU1-routed Twilio numbers in general) live in the AU1 region's CDR store and require explicit AU1 querying.
After two wipes that used Invoice.objects.all().delete() via the Django shell, several media/invoices/YC-2026-NNNN.pdf files remained on disk — the ORM delete bypassed the per-action PDF-cleanup branches in apps/billing/views.py:69 and :364. 9 orphans on dev, 4 on prod. All deleted.
Tracked permanently as GH issue #8 (open) — proposed fix: a post_delete signal on Invoice that removes the file regardless of delete path. Then drop the duplicated cleanup in views.py so the signal becomes the single source of truth.
/media/ exposure — serious info leak, fixedWhile investigating the orphans we discovered the prod nginx config had:
location /media/ {
alias /var/www/yealinbilling/media/;
}
…serving the entire media/ tree publicly with no auth check. Combined with sequential invoice numbers (YC-2026-0001, 0002, …), every invoice PDF was URL-enumerable. Demonstrated with one anonymous curl returning a 110 KB customer-invoice PDF.
location /media/ block from both nginx configs.GET /api/invoices/{id}/download/ (staff DRF auth) — apps/billing/views.py:200GET /api/portal/invoices/{id}/download/ (portal token via get_portal_customer) — apps/portal/views.py:228FileResponse(open(MEDIA_ROOT/pdf_path)) so the file is still served by Django, just behind auth.Planted a real PDF in media/invoices/LEAK-TEST.pdf on each side, then curl-ed the corresponding /media/ URL:
Content-Type: application/pdf, the actual PDF bytes.Content-Type: text/html, 458 bytes — the React SPA's index.html falling through try_files.GH issue #7 closed.
On prod the original sed-based attempt (the suggested one-liner) replaced the /etc/nginx/sites-enabled/yealinbilling symlink with a regular file. The follow-up cp plus explicit rm + ln -s restored the canonical symlink-to-sites-available layout. Worth knowing if a future change uses sed -i.bak on a symlinked nginx config — it silently breaks the symlink.
The Master account's 27,484 records were 96% attacker noise — 26,345 calls from +16508256802 to +61390000001, the lone number in the (otherwise inverted) blocklist-call Twilio Function.
| Month | Calls from +16508256802 |
|---|---|
| Jun 2025 | 10,734 |
| Jul 2025 | 15,413 |
| Aug 2025 | 198 |
| Sep 2025 → today | 0 |
The attacker went silent in August 2025 — almost certainly because their CallerID was in the blocklist and every call was getting <Reject/>'d server-side. They eventually gave up.
CallRecord.objects.filter(from_number='+16508256802').delete()
Run on both dev and prod: 26,345 deleted on each side, identical results. Post-cleanup:
+61390000001 preserved — random Australian landlines, low-volume misdialsBeat is incremental (last_synced_at − 1h), so deleted records don't come back unless someone explicitly clears last_synced_at and forces a full backfill within the 12-month rolling window. After ~10 Aug 2026 the entire attacker window falls outside the rolling window and a full backfill is safe to run.
The user noticed the Sync Twilio button in the UI completes much faster post-cleanup. Confirmed cause: sync_from_api() at apps/cdr/twilio_client.py:291 rebuilds existing_sids = set(...) of every API-sourced call_sid at the start of every sync, and this runs 8 accounts × 2 regions = 16 times per click. With 34,037 records that's ~545k UUIDs loaded per click into Python sets; with 7,692 it's ~123k — about 4-5× less work. The Twilio API call itself is unchanged; the speedup is entirely the per-account dup-check.
Worth flagging as a latent scaling issue: at 100k+ CDRs the same UI delay will return. Cleaner pattern: filter the dup-check by twilio_account_sid per task, or rely on the DB UNIQUE constraint and catch IntegrityError per row.
| # | Title | Labels | Status |
|---|---|---|---|
| 1 | customer.status (active/suspended/cancelled) is mostly cosmetic | design-gap, enhancement | open |
| 2 | react-query shared queryKey can crash pages with mismatched shapes | latent-bug, tech-debt | open |
| 3 | 3 Twilio subaccounts return zero CDRs on both dev and prod | bug | open |
| 4 | blocklist-call Twilio Function has inverted logic | bug | open (lower priority — attacker stopped) |
| 5 | Invoice number sequence resets to YC-YYYY-0001 after delete | enhancement | open |
| 6 | Session 22 changelog | enhancement | closed (completed) |
| 7 | Invoice PDFs publicly served via /media/ | bug, latent-bug | closed (fixed) |
| 8 | ORM Invoice delete leaves PDF orphan on disk | design-gap, tech-debt | open |
New labels created: design-gap (yellow), tech-debt (green), latent-bug (orange).
gh CLI installed user-locally at ~/.local/bin/gh (v2.93.0) on dev so future sessions can file issues without sudo. Auth via the existing PAT extracted from the git remote URL.
0d2c666. Migrations applied on both. Frontend rebuilt on both. nginx /media/ exposure closed on both.last_synced_at set incrementally; spam attacker is gone and won't return unless last_synced_at is forcibly cleared inside the next two months.apps/cdr/twilio_client.py:67 and the celery [tasks] list at startup.sed -i.bak on a symlinked nginx config replaces the symlink with a regular file. Use cp + explicit ln -s for nginx config edits.Invoice.objects.all().delete() leaves orphan PDFs on disk. Use delete_invoice / delete_run endpoints when possible. GH #8 tracks the proper signal-based fix.alias a media path that contains tenant-sensitive files without an auth filter.The first part of Session 22 cleared the deck on overdue automation and the prod data wipe. Part 2 focused on a class of problems that surfaced while the user was trying to use the freshly-clean system: a stray test customer had received an old released DID, the "Add as CLI" flow on the Sync page crashed when a number already lived elsewhere, deleting a CLI silently orphaned customer-attributed CDRs, and inbound DID calls were quietly landing as unmatched after every sync.
Four further commits landed (all on 0d2c666 → 64442f6), two more GitHub issues (#9 design-gap, latent-bug for the bulk-rematch guardrail and #10 tech-debt for the dup-check perf), and an extensive operational repair of CRIP PTY LTD's CLI/CDR state on prod.
A single bookkeeping customer named Released Numbers (status='active', overdue_reminders_enabled=False) holds every DID that was once held by Yealin or a customer and has since been released back to the carrier. CLIs there are active=True, billable=False — the matcher continues to attribute any stray future call to the pseudo-customer (out of the Unmatched panel) but the billing engine never sees them (billable=False). Historical CDRs are kept for audit.
Created on both dev (id 32582aad-...) and prod (id 39d34124-...). Initial CLIs populated:
+61728015000, +61728015005, +61483988944+61483988944 (test bed)If a real customer ever takes one of these numbers back, the CLI Transfer endpoint (below) moves it cleanly; historical CDRs remain attributed to whoever held the number at the time, which is exactly what audit needs.
d972018CustomerCLI.cli is unique=True at the database level. The mental model "deactivate the old CLI then create a new one for the new owner" fails on that constraint. The correct primitive is swapping the customer FK on the existing row inside one transaction.
POST /api/customer-clis/{id}/transfer/ body {"new_customer_id": "<uuid>", "label": "<optional>", "billable": <optional bool>} (apps/customers/views.py:CustomerCLIViewSet.transfer).
transaction.atomic().active=True on the destination so the matcher picks up the new ownership immediately.customer FK pointing at the old owner. reprocess_unmatched_cdrs only touches customer__isnull=True, so safe historical attribution can never be undone by a transfer.new_customer_id (400), nonexistent target (404), same-customer (400).frontend/src/pages/CustomerDetail.jsx — new Transfer button (ArrowRightLeft icon) on each row in the Phone Numbers tab, between Edit and Delete. Modal shows the current owner, a dropdown of every other customer (loaded via the existing /customers/?page_size=200 endpoint), an optional new label, a billable toggle, and an inline blue notice spelling out the historical-CDR semantics:
Historical CDRs already attributed to {customer} will NOT be re-assigned. Only new calls (and any currently-unmatched ones touched by reprocess) will go to the new owner.
The button is also the foundation for the assign-CLI 409 prompt (next section) and the CLI delete dialog (section P2.4) — one endpoint, three entry points.
9/9 assertions including the critical safety claim: historical CDR's customer FK stays IDENTICAL through two transfers (Released Numbers → CRIP → Released Numbers).
2bfcf1aThe Sync page's + Add as CLI button hit POST /api/cdr/assign-cli/, which used CustomerCLI.objects.get_or_create(customer=customer, cli=number). When the number already existed for a different customer, the (customer, cli) lookup missed; the implicit insert tripped the DB UNIQUE constraint on cli alone; the unhandled IntegrityError bubbled out as HTTP 500.
Pre-check by cli alone before any get_or_create:
cli_owned_by_other with structured payload: existing_owner_id, existing_owner_name, existing_cli_id, target_customer_id, target_customer_name.The Sync page's assignCli onError handler catches the 409 and prompts:
+61...is already a CLI for "Released Numbers". Transfer it to "CRIP PTY LTD" instead? Historical CDRs already attributed to Released Numbers will NOT be re-assigned.
Confirming fires POST /api/customer-clis/{existing_cli_id}/transfer/ via the endpoint from d972018. One UI button, two safe paths, no more crashes.
3/3 cases: cross-customer 409 with the right payload, same-customer 400, brand-new 200.
71e363aPlain DELETE /api/customer-clis/{id}/ hard-deletes the CLI row. CallRecord.customer is a direct FK to Customer (not via CLI), so the historical CDRs stay attributed to the original customer with billable=True. The customer keeps being billed for residual calls on a number the system can no longer explain. No undo, no audit, no warning.
The user lived this exact scenario this evening on prod with +61728015005 (deleted from CRIP, the historical CDR sat as billable=True on CRIP until the manual cleanup in P2.6).
CustomerCLIViewSet.destroy now refuses the delete with HTTP 409 cli_has_history when CallRecord rows exist matching the CLI's number AND customer=cli.customer:
def _related_cdr_qs(self, cli):
return CallRecord.objects.filter(
(Q(from_number=cli.cli) | Q(to_number=cli.cli)),
customer=cli.customer,
)
Payload: cdr_count, customer_name, customer_id, cli, cli_id, suggestion='transfer_to_released_numbers'.
?force=true query parameter bypasses the guard for cases where the operator has accepted the consequence. The CDRs themselves are never touched.
Also new: GET /api/customer-clis/{id}/cdr-count/ returns the same count without attempting a delete. Lets the frontend pre-check and skip the dialog entirely for fresh-typo CLIs with zero history.
Trash icon on the Phone Numbers tab now:
GET /cdr-count/ first.confirm() → DELETE flow. No friction for the common case./transfer/ with billable=False. Historical CDRs stay where they are; future calls go to Released Numbers; the CLI is preserved for audit.?force=true. Historical CDRs remain billable to the original customer; future calls become unmatched on Sync page. Explicitly described as such.Pattern matches the Session 21 product-delete 409 (ProductViewSet.destroy returns 409 when ServiceCharges reference the product). Consistent design language across delete-with-history surfaces.
3/3 cases: clean CLI deletes silently; CLI with 1 CDR returns 409 with the right structured payload; ?force=true deletes and CDR survives untouched.
to_number — 64442f6User's bug report. "After an automated Twilio sync, I see +61738493066 appear on the Sync page as unmatched and I have to manually click the Match button."
Investigation. The number is a perfectly normal CLI on Campbells Legal Services (direction='both', active=True, billable=True) with thousands of correctly-attributed CDRs. Inbound calls to it kept landing as customer=NULL at ingest; reprocess_unmatched_cdrs always rescued them on click.
Root cause. apps/cdr/twilio_client.py:179-183 built match_data for the matcher as:
match_data = {
'direction': direction,
'from_number': from_number,
'called_via': called_via,
}
Missing to_number. The matcher (apps/cdr/matching.py:69-78) uses to_number as the primary field for inbound calls:
if direction == 'inbound':
for candidate in [to_number, called_via]:
if candidate and candidate in cli_map:
...
For direct-DID inbound (no SIP routing → called_via is empty, which is the standard pattern for non-SIP Twilio voice), the matcher had nothing to look up against the cli_map. Returned None. CDR ingested as unmatched.
The reprocess path (apps/cdr/tasks.py:88-94) builds match_data with all four fields. That's why clicking Match worked. The diff between the two call sites was the entire bug.
Fix. One-line: add 'to_number': to_number to the sync-time match_data dict.
Verified. Three direct assertions on dev: matcher with corrected dict shape matches via to_number; matcher with the original buggy shape returns None (confirms the bug class existed); end-to-end process_twilio_record with a synthetic inbound CDR (empty called_via) now ingests with the correct customer set.
Impact. Long-standing bug — present since the matcher was written. Inbound calls to any DID with no SIP routing have been silently failing at ingest and only being recovered by reprocess. After the prod celery restart this evening, the Unmatched Numbers panel should stay near-empty across all subaccounts. The Match button reverts to being a "shouldn't normally need this" tool — its actual design intent.
During the iteration, two numbers ended up in an inconsistent state. Both had been added to CRIP PTY LTD via the Sync page's Add as CLI at some point earlier, then the CLI rows were hard-deleted from CRIP. The historical CDRs sat orphaned: still attributed to CRIP, still billable=True, but with no CLI explaining the relationship — exactly the scenario 71e363a (above) was built to prevent on future deletes.
Manual cleanup performed inside one atomic transaction for +61728015005 and +61483988944:
CustomerCLI on Released Numbers (active=True, billable=False, label Released DID (restored)).customer=Released Numbers, billable=False).Verification post-cleanup on prod: both numbers report CDRs 1/1 on Released Numbers, no residual CRIP attribution, no future stray-call leakage.
+61728015000 was already correctly placed (active=True, billable=False on Released Numbers) from earlier in the session, so it didn't need touching.
to_number. If you ever change process_twilio_record or the match_data shape, keep all four fields aligned with the reprocess path. The two were divergent for months until 64442f6.cli alone (unique=True). Two rows with the same number can never coexist. The transfer pattern is "update the customer FK on the one existing row", not "deactivate + create new". Suggestions in earlier conversations to use deactivate+create were wrong; the Transfer endpoint encodes the correct behaviour.reprocess_unmatched_cdrs is safe by design because it filters customer__isnull=True. Nothing else in the codebase enforces "never null an already-matched CDR's customer FK". GH #9 tracks adding that guardrail.overdue_reminders_enabled=False is set explicitly so it never receives reminder emails. Add new released DIDs as CLIs there with active=True, billable=False.Two further incidents on dev drove Part 3: a paid invoice that did not show as paid in the app, and a billing-run delete that silently destroyed paid invoices + their Payment records. Both produced durable fixes plus a design-gap follow-up (#12) tracking adoption of the typed-confirmation modal pattern across the rest of the destructive UI surfaces. Two commits this part; one new open GH issue (#12); one closed (#8 — the orphan PDF problem is now properly fixed).
| Commit | Title |
|---|---|
e59548b |
Check Stripe directly for paid PI before creating a duplicate checkout |
9d9f576 |
Auto-clean Invoice PDF on delete + harden billing-run force-delete UX |
e59548bYC-2026-0001 was paid via Stripe Checkout on dev but the invoice stayed at status=sent in the app. The operator clicked Pay Now again and completed a SECOND successful Stripe payment for the same invoice. Two paid Stripe Checkout Sessions ended up against one invoice with no Payment row in our DB to show for either.
Root cause: stripe listen was not running on the LAN dev box (10.0.0.41 cannot accept inbound webhook traffic from Stripe's servers — firewalled, not publicly routable). The checkout.session.completed webhook was never delivered, so the webhook handler at /api/webhooks/stripe/ never created the Payment row that would have flipped the invoice to paid. The existing Session-20 duplicate-checkout guard at apps/payments/views.py:108 checks for a confirmed Payment row in our DB — which never existed — so the second checkout was approved.
A one-off shell script queried Stripe for cs_test_... sessions matching the invoice's metadata.invoice_id and client_reference_id. Found two paid sessions for the same invoice. Reconciled against the most recent paid PI (pi_3Tggro5kIKns1mGJ17f97M16), created the Payment row, updated invoice.status='paid', amount_paid_aud=156.64. The other PI was flagged as a duplicate test-mode payment to refund manually in the Stripe dashboard.
Two new helpers added to apps/payments/stripe_client.py:
metadata.invoice_id matches, or None. Uses stripe.PaymentIntent.search() first, falls back to stripe.PaymentIntent.list() + client-side metadata filter if Search is unavailable.Payment.objects.get_or_create(stripe_payment_intent_id=...). Creates the Payment row if missing, updates invoice.status='paid', amount_paid_aud, stripe_payment_intent_id.Wired into both checkout entry points, immediately before create_checkout_session(...) is called: apps/payments/views.py (staff Pay Now flow) and apps/portal/views.py (customer portal Pay Now flow). The endpoint asks Stripe whether there is already a succeeded PaymentIntent for this invoice. If yes, auto-reconciles (mirrors what the webhook handler would have done) and returns HTTP 409 already_paid_on_stripe with the PI id and amount.
When stripe listen is healthy and the webhook fires quickly, the existing DB checks (invoice.status=='paid' and Payment.exists()) return 400 first — the new guard never runs in the happy path. The guard only adds one Stripe API call (Search) on the failure path: webhook delayed, down, or pointed at a server Stripe cannot reach. Cheap defence-in-depth.
Rolled YC-2026-0001 back to sent with no Payment row. POST /api/payments/checkout/ returned HTTP 409 already_paid_on_stripe, invoice auto-reconciled to paid, Payment row created with the correct PI id. Re-hit → 400 from the existing status=='paid' check (idempotent).
9d9f576 part 1PDF cleanup was implemented inline in InvoiceViewSet.delete_invoice at apps/billing/views.py:373-377. Every other Invoice delete path silently skipped it:
inv.delete() from a Django shell or management commandInvoice.objects.filter(...).delete() queryset bulk deleteBillingRunViewSet.delete_run's cascadeAfter tonight's billing-run delete, two orphan PDFs (YC-2026-0001.pdf, YC-2026-0002.pdf) were sitting in media/invoices/ with no DB rows referencing them. Functionally inert post-#7 (nginx /media/ exposure closed), but disk clutter and a real regression risk if /media/ ever returns.
apps/billing/signals.py (new file) — a single post_delete receiver on Invoice that removes pdf_path from disk. Wired in BillingConfig.ready(). Logs at info on success, warning on OSError, silent no-op on missing file.
InvoiceViewSet.delete_invoice had its inline os.remove branch removed — the signal is now the single source of truth so no double-delete, no spurious "file not found" warnings. The endpoint still returns pdf_deleted in its response based on whether the invoice had a pdf_path at all.
The signal fires on every Invoice delete path, period. Verified on dev across three scenarios: single-instance delete, queryset bulk delete, missing-file no-op (didn't raise).
The 2 existing orphans on dev were cleaned by hand before the deploy. Prod media/invoices/ was empty at deploy time.
9d9f576 part 2BillingRunsViewSet.delete_run returns HTTP 400 with paid_invoices when the run contains paid invoices, requiring ?force=true to override. The frontend at frontend/src/pages/BillingRuns.jsx previously handled this via a one-shot window.confirm() that said "This billing run contains paid invoice(s)... Force delete will permanently remove N payment record(s) along with the invoices and billing run. This cannot be undone. Proceed with force delete?"
The dialog did spell out consequences. But it was a native confirm — easy to dismiss with a habituated OK click or a stray Enter while the OK button had focus. The operator deleted paid invoices, paid Payment records, and the manually-reconciled YC-2026-0001 payment from P3.1 without consciously authorising force=true.
Replaced confirm() with a structured Modal:
DELETE for the red Force button to enable.Backend unchanged — the 400 + paid_invoices payload was already correct. Only the frontend's force-trigger path was too easy.
Same pattern as the CLI delete dialog from 71e363a. Future destructive actions should adopt this — issue #12 tracks the rollout across remaining confirm() call sites in Invoices.jsx, Products.jsx, Sync.jsx.
| # | Title | Labels | Status |
|---|---|---|---|
| #8 | ORM Invoice delete leaves PDF orphan on disk | design-gap, tech-debt | closed (fixed in 9d9f576) |
| #12 | Adopt typed-confirmation modal everywhere a destructive force action is gated by window.confirm() | design-gap, tech-debt | open |
| #13 | Session 22 Part 3 changelog | enhancement | closed (completed) |
stripe listen is mandatory for the webhook flow on dev — 10.0.0.41 LAN isn't reachable from Stripe's servers. When it's down, the duplicate-checkout guard from e59548b is the only protection against double charges.find_paid_payment_intent_for_invoice(invoice) and reconcile_invoice_with_payment_intent(invoice, pi) in apps/payments/stripe_client.py are reusable. Useful for any future "reconcile all invoices" admin action after an extended webhook outage.invoice.stripe_checkout_session_id being non-empty.post_delete signal on Invoice is the single source of truth for PDF cleanup. Don't re-add inline os.remove calls in delete endpoints — let the signal own it.DELETE to enable the red button) is now the standard for destructive force-actions. CLI delete and billing-run delete are the canonical examples.e59548b closes this on prospective Pay Now clicks. A duplicate already in Stripe (from before the deploy) still needs manual refund.