Claude Code is installed on the dev server. It reads files directly — no pasting needed.
Project: Yealin Billing Platform
Owner: Christopher Olsen (Crip)
Company: Yealin Communications Pty Ltd
ABN: 43 663 151 835
Purpose: Custom telecom billing platform replacing BillingBooth
Status: LIVE at https://billing.yealin.com.au
GitHub: https://github.com/ounyai/yealin-billing (PRIVATE)
Wiki: https://wiki.yealin.com.au
Twilio API (AU1/US1 regions) → CDR normaliser → CLI matcher → destination classifier
→ pricing engine (direction-aware inbound/outbound, live FX rate + 2% markup) → billing run (per calendar month)
→ WeasyPrint PDF invoice → Stripe payment → cPanel SMTP email
→ customer portal self-service
| Role | Hostname | IP | OS | User |
|---|---|---|---|---|
| Development | yealin-dev.bylaw.com.au | 10.0.0.41 (LAN) | Debian 13.5 | crip |
| Production | billing.yealin.com.au | 51.161.136.89 (OVH) | Debian 13.5 | crip |
| Wiki | wiki.yealin.com.au | 51.161.136.89 (Docker) | Wiki.js 2 | — |
App path: /var/www/yealinbilling
Wiki sync: python3 /var/www/yealinbilling/wiki_sync.py (on production only)
Dev
cd /var/www/yealinbilling && source venv/bin/activate && export DJANGO_SETTINGS_MODULE=config.settings.development
Prod
cd /var/www/yealinbilling && source venv/bin/activate && export DJANGO_SETTINGS_MODULE=config.settings.production
OS: Debian 13.5 — NEVER Ubuntu
PDF: WeasyPrint — NEVER Playwright/ReportLab
Email: cPanel SMTP aum.bylaw.com.au:465 SSL — NEVER SendGrid
GoCardless: DEFERRED
PDF charts: Inline SVG ONLY
EMAIL_BACKEND: smtp.EmailBackend BOTH dev and prod
Portal auth: @authentication_classes([]) MANDATORY on ALL portal views
Workflow: ALWAYS dev first → test → commit → pull to prod
Billing: Per CALENDAR MONTH — one invoice per month per customer
Inbound calls: Default $0.00 to customer (covered by line rental) — toggle per destination
Timezone: Australia/Sydney for ALL GUI displays and Celery Beat schedules
Django 5.1.4 / React 18 + Vite + Tailwind / PostgreSQL 16(dev) 17(prod) / Redis / Celery / WeasyPrint 69.0 / Stripe / Twilio SDK 9.3.8 / Nginx + Gunicorn / Let's Encrypt SSL
Date/time utility: frontend/src/utils/dateFormat.js — always use formatDateTime, formatDate, formatDateShort, formatMonthYear for ALL date displays. Never use raw toLocaleString without timezone.
Auth uses Master Account SID + region-specific Auth Tokens. DO NOT use API Keys.
| Region | API Endpoint | Auth Method |
|---|---|---|
| US1 (default) | api.twilio.com | Master Account SID + US1 Auth Token |
| AU1 (Australia) | api.sydney.au1.twilio.com | Master Account SID + AU1 Auth Token |
TWILIO_ACCOUNT_SID=AC5f6883303b4c430931a66b6cdcd48d92
TWILIO_AUTH_TOKEN_US1=... # US1 token from console.twilio.com
TWILIO_AUTH_TOKEN_AU1=... # AU1 token from console.twilio.com/au1
TWILIO_API_BASE_AU1=https://api.sydney.au1.twilio.com
CRITICAL LEARNING — Why NOT API Keys (Session 17, 09 Jun 2026):
API Keys (both Standard AND Main) created on the master account CANNOT scope to
subaccount resources via the client constructor — reading subaccount CDRs returns
HTTP 401. A Main API key can list/fetch subaccount metadata but cannot read their
call subresources. Only the Master Account SID + regional Auth Token reads subaccount
CDRs. To use API keys you would need a separate key on every subaccount. Auth Tokens
are region-specific (AU1 token cannot touch US1), so this remains a secure posture.
In apps/cdr/twilio_client.py get_twilio_client():
| Account | SID | Region | Customer |
|---|---|---|---|
| Yealin Communications (Master) | AC5f6883303b4c430931a66b6cdcd48d92 | us1 | Internal |
| ResolvIT Services | AC9f09393b8cb6298e05ad6d82c40cbd77 | au1 | ResolvIT Australia |
| Bridge Publishing | ACcefd37259313f040632a60649c5f39a2 | au1 | Bridge Publishing (Aust.) Pty Ltd |
| Campbells Legal Services | ACce42acfc45e7ed0ffef4e2621e4f6615 | au1 | CAMPBELL, PETER ANTHONY |
| CNG Law Pty Ltd | ACeb06e490686103d1b5e3d4e4368f44d1 | au1 | CNG Law Pty Ltd |
| Linkpoint Lawyers | AC880a0b39b08862e1416a380e0bea0947 | au1 | Linkpoint Lawyers Pty Ltd |
| Olsen Lawyers | AC1553e3e3a25aaa69d007cbe80d9f1e56 | au1 | Olsen Lawyers Pty Ltd |
| Queensland Traffic | AC98f8e148b6b53901da8233bb2befce2a | au1 | Queensland Traffic Offenders Program Pty Ltd |
Celery Beat: cdr.sync_all_accounts every 4 hours at :00 Australia/Sydney.
US1 401 errors for AU1-only accounts = EXPECTED, not a bug.
After code changes always restart celery: sudo systemctl restart celery celerybeat
AU National: $0.10 | AU Mobile: $0.22 | AU 1300: $0.30 | AU 1800: $0.109
USA/Canada: $0.12 | UK: $0.18 | NZ: $0.10 | China: $0.55 | UAE: $0.60
Philippines: $0.35 | Vietnam: $0.35
Default: free to customer (inbound_charge_enabled=False). Toggle per destination.
AU Local $6.50/mo | AU Mobile $14.95/mo | AU 1800 $34.95/mo
Sort orders MUST be correct:
+611800=200, +611300=190, +614=180, +612/3/7/8/9=170, international=50, catch-all(+)=1
POST /api/auth/token/
GET /api/dashboard/stats/
GET/POST /api/settings/
CRUD /api/destinations/ + POST seed-australian/
CRUD /api/products/, categories/, tariffs/, rate-cards/, service-charges/
CRUD /api/customers/, customer-groups/, customer-clis/
GET /api/cdr/ + summary/ + POST reprocess/
POST /api/cdr/sync-api/
POST /api/cdr/sync-csv/
GET /api/cdr/sync-status/
POST /api/cdr/assign-cli/
GET/PATCH /api/cdr/sync-schedule/
GET/POST /api/cdr/twilio-accounts/
POST /api/billing-runs/execute/ + GET /api/billing-runs/
DELETE /api/billing-runs/{id}/delete-run/
POST /api/billing-runs/{id}/send-all/
GET /api/invoices/ + download/ + POST send/ + regenerate-pdf/
GET /api/payments/ + POST checkout/ + manual/
POST /api/webhooks/stripe/
POST /api/portal/auth/token/ + GET invoices/ + download/ + POST pay/ + GET usage/
@api_view(['GET'])
@authentication_classes([])
@permission_classes([AllowAny])
cd /var/www/yealinbilling
git pull origin main
source venv/bin/activate && export DJANGO_SETTINGS_MODULE=config.settings.production
python manage.py migrate
cd frontend && npm run build && cd ..
sudo systemctl restart gunicorn celery celerybeat
git stash && git pull origin main && git stash drop || true
from apps.cdr.models import CallRecord
from apps.products.models import Destination
from apps.cdr.classifier import get_destination_map, classify_number
catchall = Destination.objects.get(sort_order=1)
destination_map = get_destination_map()
qs = CallRecord.objects.filter(destination=catchall)
updated = 0
for record in qs.iterator(chunk_size=500):
classify_num = record.to_number if record.direction == 'outbound' else record.from_number
new_dest = classify_number(classify_num, destination_map)
if new_dest and new_dest.id != catchall.id:
record.destination = new_dest
record.save(update_fields=['destination'])
updated += 1
print(f'Reclassified: {updated}')
| Item | Dev | Prod |
|---|---|---|
| Total CDRs | ~7,692 | ~7,692 |
| Unmatched billable | 0 | 0 |
| Invoices | varies (dev testing) | 0 |
| BillingRuns | varies (dev testing) | 0 |
| Payments | 0 | 0 |
| InvoiceLineItems | varies (dev testing) | 0 |
| CreditNotes | varies (dev testing) | 0 |
| TwilioAccounts | 8 | 8 |
| Customers | 12 (11 + Released Numbers) | 11 (10 + Released Numbers) |
| Destinations | 29 | 29 |
| Last commit | 7af78a8 (pushed) | 7a99431 (pull pending) |
Session 24 added Credit Notes (full backend + frontend). Dev is one commit ahead of prod (
7af78a8) — prod will be pulled forward on the next deploy. Dev's invoice/credit-note counts vary depending on smoke testing.
CAMPBELL, PETER ANTHONY · CRIP PTY LTD · Yealin Communications · Bridge Publishing (Aust.) Pty Ltd · CNG Law Pty Ltd · Linkpoint Lawyers Pty Ltd · Olsen Lawyers Pty Ltd · Queensland Traffic Offenders Program Pty Ltd · ResolvIT Services · Joanne Holland Designs · Released Numbers (new — pseudo-customer holding released DIDs).
Dev mirrors prod plus one test customer (DPR Legal Pty Ltd) and the Released Numbers pseudo-customer.
+61728015000 (active=True, billable=False)+61728015005 (active=True, billable=False, restored 10 Jun)+61483988944 (active=True, billable=False, restored 10 Jun)| # | Summary | Commit |
|---|---|---|
| 1-12 | Full platform built and deployed | various |
| 13 | Twilio Sync page, multi-account, AU1 regional fix | 0ecd0a4 |
| 14 | Inbound/outbound rates, destination fix, service charges, billing test | d306fdc |
| 15 | All 8 subaccounts, rates, timezone, pagination, billing UI, send all | 4d85a6e |
| 16 | Billing Feb–May 2026, engine fixes (inbound/direction/pricing), invoice improvements, CLI duplicate warning | 97e466a |
| 17 | Twilio security incident, credential rotation, auth migration (Account SID + Auth Token), post-billing fixes | 124e6dd |
| 18 | Stripe live migration, payment success page, webhook fix, static files, second security incident resolved | 1b45857 |
| 19 | Invoice delete (force), portal user management UI, password reset flow, dev SSH to prod | 190dc57 |
| 20 | Payment receipt emails, Stripe webhook idempotency (PI ↔ checkout), duplicate-checkout block, BCC email settings, invoice PDF DRAFT-after-send fix, new logo, portal rate limiting | 65ba68d |
| 21 | Product delete button (409 when in use), fix products page blank-screen crash (shared react-query key shape collision) | 531bcd0 |
| 22 | Rolling 12-month Twilio sync window, customer Edit on detail page (+ blank-SID fix), auto-overdue + escalating reminder emails with per-customer toggle, manual invoice status override (set-status endpoint), nginx /media/ public-PDF exposure closed, orphan PDFs cleaned, regional sync subtlety documented, historical spam attacker deleted, GH issues workflow established (#1–#8) | 0d2c666 |
| 22b | CLI Transfer endpoint + UI, assign_cli cross-customer 409 + Transfer prompt, CLI delete refusal with history + 3-option dialog (Release / Force / Cancel), sync-time matcher missing to_number FIXED (was silently dropping inbound DID calls), Released Numbers pseudo-customer pattern established, prod data cleanup for +61728015005 / +61483988944, GH issues #9 + #10 + closed Part-2 changelog #11 |
64442f6 |
| 22c | Stripe-side duplicate-checkout guard (find_paid_payment_intent_for_invoice + auto-reconcile, fires when DB does not yet know about a successful Stripe payment), Invoice post_delete signal removes PDF on EVERY delete path (closes GH #8), billing-run force-delete UX upgraded from window.confirm() to typed-confirmation modal (mirrors CLI delete pattern), GH #12 spawned (rollout typed-confirm across remaining destructive actions) + closed Part-3 changelog #13 | 9d9f576 |
| 23 | Customers list Edit fetches full customer record before opening modal (was passing CustomerListSerializer's 4 fields straight to CustomerForm → most inputs blank), new paginated /cdr/sync-logs/ endpoint + Prev/Next UI (history previously capped at 20 rows baked into sync-status), daily Beat task cdr.reprocess_unmatched at 05:30 Australia/Sydney (was only manual / on-assign-CLI — left historical unmatched rows stuck after ingest-matcher bugs), assign-CLI modal renders error inline (page-level ResultBanner was hidden behind modal overlay), one-off cleanup of 2 dev CDRs with stale invoiced=True, GH #14 opened (CDR invoiced flag not reset on BillingRun delete), GH #15+#16 closed as session changelogs |
7a99431 |
| Issue | Detail |
|---|---|
| Twilio AU1 | Use api.sydney.au1.twilio.com for AU1 accounts |
| TwilioAccount db_table | cdr_twilioacccount (3 c's) — don't change |
| Stripe dev webhook | Changes each stripe listen restart |
| Portal @authentication_classes | MANDATORY — without it returns 401 |
| EMAIL_BACKEND | Must be smtp — never console |
| Billing | Per calendar month — never mix months |
| Destination sort_order | Catch-all (+) MUST be sort_order=1 |
| inbound_charge_enabled | Default False — inbound = $0 to customer |
| Billing engine total_aud | Calculated from unit_price × quantity — NOT from cdr.sell_price_aud |
| CDR destination | Classified at ingest — reclassify manually if destinations change |
| WeasyPrint | Pinned to 69.0 / pydyf 0.12.1 — Python 3.13 compatible |
| Gunicorn restart | Required after any Python file changes |
| Invoice Pay Online | Links to /api/portal/payment-success/ after Stripe checkout — no auth needed |
| Stripe prod keys | LIVE keys on prod (sk_live_) — real payments. Dev stays on sk_test_. Never swap. |
| Stripe webhook dev | stripe listen --print-secret for exact secret — terminal wrapping can truncate it |
| Payment success URL | /api/portal/payment-success/?invoice=YC-... |
| Payment cancelled URL | /api/portal/payment-cancelled/?invoice=YC-... |
| Payment already paid URL | /api/portal/payment-already-paid/?invoice=YC-... |
| timezone.utc | Use dt_timezone.utc not django.utils.timezone.utc (Django 5.x) |
| dateFormat utility | Always use frontend/src/utils/dateFormat.js for all date displays |
| CLI uniqueness | Never assign same CLI to two customers — first match wins |
| Delete & Rerun | Blocked for paid invoices — use shell pattern if needed |
| Modal double-submit | Use isSaving pattern for any new modals with async operations |
| Invoice number sequence | Numbers restart at YC-YYYY-0001 after all invoices deleted — fix pending (invoice sequence table) |
| Portal user management | Now a UI at /portal-users — no shell required |
| Password reset token | Expires after 1 hour. Reset URL built from request host — works on dev and prod automatically |
| Dev SSH to prod | yealin-dev has SSH key authorised on prod — Claude Code can update wiki directly |
| Stripe webhook idempotency | checkout.session.completed and payment_intent.succeeded can both fire and arrive out of order — each handler checks for an existing Payment before creating one (no double-payment) |
| Payment receipt email | Sent once invoice marked paid, from whichever webhook arrives first (checkout OR payment_intent). Includes invoice #, amount, date, portal link |
| Duplicate checkout block | New checkout refused if invoice.status == paid OR a confirmed Payment already exists — closes the two-session race window |
| BCC email settings | core_setting keys BCC_INVOICE_ENABLED / BCC_INVOICE_EMAIL and BCC_PAYMENT_ENABLED / BCC_PAYMENT_EMAIL — read at send time, toggled on Settings page |
| Invoice PDF status | Status set to 'sent' and PDF regenerated BEFORE emailing — PDF no longer shows DRAFT after send |
| Portal rate limiting | 5/min on login, 3/min on forgot/reset-password via DRF AnonRateThrottle (keyed by IP), configured in config/settings/base.py |
| YealinLogo component | Reusable frontend/src/components/YealinLogo.jsx (useId for unique clip-path IDs). Email-template logo was reverted — frontend logo updates only |
| Product delete | DELETE /api/products/{id}/ returns 409 (not 500) when the product is assigned to any ServiceCharge — ServiceCharge.product is on_delete=PROTECT. Set the product Inactive to retire it instead |
| react-query shared keys | A queryKey must map to ONE data shape. ['products'] was shared by Products.jsx (array) and CustomerDetail.jsx (paginated object) → navigating between them fed a non-array to Table → blank page. Keep queryFn shape+params identical across pages sharing a key, or use distinct keys. Tracked in GH #2. |
| Table data prop | components/Table.jsx now coerces its data prop to an array — a non-array can no longer white-screen any page that uses the shared table |
| Twilio regional CDR querying | au1 and us1 endpoints return DIFFERENT call records for the same Master SID. Calls to AU DIDs (e.g. +613...) land in AU1's CDR store, not US1. Beat task (sync_account_task) syncs each account against BOTH regions automatically — any manual probe/script MUST do the same or it will silently undercount |
| nginx /media/ public exposure | FIXED in Session 22. Previously location /media/ { alias /var/www/yealinbilling/media/; } served every invoice PDF without auth, and sequential YC-YYYY-NNNN numbers made them enumerable. Block removed. PDF downloads continue via /api/invoices/{id}/download/ (staff) and /api/portal/invoices/{id}/download/ (portal) — both auth-checked Django endpoints serving from the same MEDIA_ROOT. GH #7 closed. |
| nginx symlink + sed -i | sed -i.bak on a symlinked nginx config (sites-enabled → sites-available) REPLACES the symlink with a regular file. Use cp + explicit ln -s for nginx edits. Lesson from Session 22 |
| ORM Invoice delete | Invoice.objects.all().delete() bypasses the per-action PDF cleanup branches in apps/billing/views.py:69 and :364, leaving orphan PDFs on disk. No longer a security issue post-#7 fix, but tidy with delete-invoice / delete-run endpoints when possible. GH #8 tracks a post_delete signal fix |
| Customer status semantics | customer.status (active/suspended/cancelled) is read in only 2 places and suspended==cancelled functionally — no portal block, no CDR sync stop, no ServiceCharge end-date. Treat as "skip next billing run" flag only. GH #1 details + suggested fix |
| 3 silent Twilio subaccounts | Bridge Publishing, Olsen Lawyers, ResolvIT all return ~0 CDRs after Session 22's full resync. Probably genuinely no traffic but worth verifying before any future billing. GH #3 |
| blocklist-call Twilio Function | Inverted logic — only rejects the single number in BLOCKLIST env var (+16508256802), redirects every other caller to Twilio's demo voice URL. The specific attacker stopped Aug 2025 because their CallerID matched the blocklist, but a new attacker would NOT be blocked. Less urgent now but GH #4 tracks the proper fix |
| Invoice status — overdue | Auto-set by billing.process_overdue_invoices (Beat, daily 06:00 Australia/Sydney) when status='sent' AND due_date < today AND amount_paid_aud < total_aud. Sends L1/L2/L3 reminder emails at 0/14/30 days overdue. Per-customer opt-out via Customer.overdue_reminders_enabled |
| Invoice status — manual override | POST /api/invoices/{id}/set-status/ body {"status": "<new>", "note": "<reason>"}. Resets overdue reminder counter on every change. Appends timestamped audit line to invoice.notes. UI: "Status" button on each Invoices-page row |
| Twilio sync DEFAULT_START | Now now() - 365 days rolling, NOT a hardcoded date. Only applies when last_synced_at IS NULL — incremental syncs continue from last_synced_at - 1h. Don't clear last_synced_at carelessly — triggers a full 12-month backfill which can pull back ~27k historical Master records (spam attacker era) until ~10 Aug 2026 |
| Spam attacker delete | The historical 26,345 attack records (from +16508256802 to +61390000001) are GONE from the DB but STILL exist in Twilio AU1 until they age past the 12-month window (~10 Aug 2026). Until then, a full backfill repulls them. Filter to delete: CallRecord.objects.filter(from_number='+16508256802').delete() |
| Sync dup-check perf | sync_from_api builds existing_sids = set(CallRecord.objects.filter(import_source='api').values_list('call_sid', flat=True)) per account-region invocation = 16 queries per Sync click. Linear in total CDR count. Feels fast at 7k, slow at 30k+, painful at 100k+. Filed concept: per-account dup-check or DB UNIQUE + IntegrityError catch |
| GitHub Issues workflow | gh CLI installed at ~/.local/bin/gh on dev (v2.93.0). PAT auth via the git remote URL. Use design-gap / tech-debt / latent-bug labels for non-urgent items that should outlive a single session. Closed-as-completed sessions for changelog (e.g. #6 = Session 22 changelog) |
| customer.overdue_reminders_enabled | BooleanField default True. Suppresses all reminders for that customer. Editable from Edit Customer dialog on list + detail pages |
| Invoice reminder tracking | Invoice.overdue_reminders_sent (0..3) + Invoice.last_overdue_reminder_at (DateTimeField). Used by the daily task for idempotency + threshold gating |
| TIME_ZONE in settings | config/settings/base.py:141 sets TIME_ZONE = 'Australia/Brisbane' (no DST). Beat schedules and explicit task code use Australia/Sydney (DST). They diverge by 1 hour during DST. timezone.localdate() follows TIME_ZONE = Brisbane. Worth resolving before next DST window |
| Sync-time matcher needs to_number | process_twilio_record historically built match_data with only direction+from_number+called_via, missing to_number. Inbound DID calls (where called_via is empty — normal for non-SIP traffic) fell through to unmatched at ingest; reprocess (which passes all four fields) silently rescued them. FIXED in commit 64442f6. If you ever touch process_twilio_record or the match_data shape again, KEEP ALL FOUR FIELDS — diff against reprocess_unmatched_cdrs at apps/cdr/tasks.py:89 to verify alignment |
| CLI Transfer (primary lifecycle primitive) | POST /api/customer-clis/{id}/transfer/ body {new_customer_id, label?, billable?}. Atomically updates the CLI's customer FK + sets active=True. Historical CallRecord rows are NEVER touched. Use this for any "this number changed owners" workflow — never delete + recreate, the cli UNIQUE constraint forbids it |
| CLI uniqueness | CustomerCLI.cli is unique=True at the DB level — only ONE row per number can ever exist. The CLI uniqueness watch-point from prior sessions referred to "first match wins" but that was wrong about the constraint. Use Transfer, not delete-then-create |
| CLI delete safety (cli_has_history) | DELETE /api/customer-clis/{id}/ returns 409 cli_has_history when CallRecord rows exist for that number on the CLI's current customer. Payload: cdr_count, customer_name, suggestion='transfer_to_released_numbers'. Pass ?force=true to override (operator accepts orphan CDRs). UI on CustomerDetail's Phone Numbers tab pre-checks via GET /api/customer-clis/{id}/cdr-count/ and opens a 3-option dialog when count > 0 (Release to Released Numbers / Delete anyway / Cancel). Pattern: shipped commit 71e363a, mirrors the Session 21 product-delete 409 |
| assign_cli cross-customer 409 | POST /api/cdr/assign-cli/ used to crash with IntegrityError when the number already belonged to a different customer. Now returns 409 cli_owned_by_other with structured payload (existing_owner_id/name, existing_cli_id, target_customer_id/name). Sync page's assignCli mutation catches it and prompts "Transfer it to instead?" → fires the /transfer/ endpoint. Shipped commit 2bfcf1a |
| Released Numbers pseudo-customer | Structural row on both dev (id 32582aad-...) and prod (id 39d34124-...). Holds CLIs for released DIDs (active=True, billable=False). overdue_reminders_enabled=False so it never gets reminder emails. Matcher attributes future stray calls to it — out of Unmatched panel — but billing engine never sees them. Don't delete the customer. New released numbers should be added as CLIs here, OR transferred from their previous customer via the Transfer button. See P2.1 of Session 22 wiki page |
| Released number future-handover | When a real customer takes over a released DID later: use the Transfer button (NOT delete + Add as CLI). Transfer keeps the CLI's history intact under Released Numbers via the CDR's existing FK; new CDRs match the new customer; reprocess_unmatched_cdrs cannot touch already-matched CDRs so the boundary holds without manual intervention |
| ORM delete vs CDR attribution | CDR.customer is a direct FK — deleting a CLI does NOT cascade to CDRs. Historical CDRs keep their attribution forever. This is "feature, not bug" for audit, but means CLI delete WITHOUT cli_has_history check leaves orphaned billable rows under the customer. Use the Trash icon (which now has the 409 guard) — direct ORM .delete() on CustomerCLI bypasses the guard |
| reprocess_unmatched_cdrs is safe by design | At apps/cdr/tasks.py:65 filters customer__isnull=True. Already-matched CDRs are NEVER re-evaluated against current CLIs. This is THE protection that lets Transfer work cleanly. Never write code that nullifies CallRecord.customer en masse before reprocessing — historical attributions will be replaced by current CLI ownership. GH #9 tracks adding an explicit guardrail (customer_locked field) so even a future mistaken bulk-update can't break this invariant |
| Sync dup-check perf | GH #10 — sync_from_api rebuilds existing_sids set per account×region (16× per Sync click). Linear in total CDR count. Feels fast at 7k, painful at 100k+. Suggested fix: filter by twilio_account_sid per pass, OR drop the in-memory check and use bulk_create(ignore_conflicts=True) against the existing DB UNIQUE on call_sid |
| GH issues workflow established | gh CLI at ~/.local/bin/gh (v2.93.0) on dev. PAT extracted from git remote URL. Labels: design-gap (yellow), tech-debt (green), latent-bug (orange) + standard bug/enhancement. Pattern: file open issues for long-tail debt; file closed issues as session changelogs (#6 = Session 22 Part 1, #11 = Session 22 Part 2, #13 = Session 22 Part 3). Check gh issue list -R ounyai/yealin-billing -s open at session start for the live backlog |
| Stripe-side duplicate-checkout guard | apps/payments/stripe_client.py:find_paid_payment_intent_for_invoice(invoice) queries Stripe (Search first, list+filter fallback) for a succeeded PI whose metadata.invoice_id matches. Wired into both PaymentViewSet.checkout (staff) and portal_invoice_pay. If found, auto-reconciles via reconcile_invoice_with_payment_intent(invoice, pi) and returns HTTP 409 already_paid_on_stripe. Defence-in-depth for when the webhook has not arrived — required on dev because the LAN box (10.0.0.41) is not reachable from Stripe's servers. Shipped commit e59548b |
| stripe listen mandatory on dev | Dev (10.0.0.41) is behind a firewall, unreachable from Stripe. The stripe listen --forward-to http://localhost/api/webhooks/stripe/ CLI must be running for webhooks to fire. When it is down, the duplicate-checkout guard from e59548b is the only thing preventing double charges. Run it in tmux/screen so it survives session loss |
| Invoice post_delete signal | apps/billing/signals.py removes pdf_path from MEDIA_ROOT on every Invoice delete path (single-instance, queryset, BillingRun cascade, admin bulk-action, ORM). Wired via BillingConfig.ready(). Do not re-add inline os.remove calls in delete endpoints — the signal is the single source of truth. Logs at info on success, warning on OSError, no-op on missing file. Closes GH #8. Shipped commit 9d9f576 |
| Typed-confirmation modal pattern | New canonical UX for destructive force-actions. Required where: (a) data loss is irreversible, (b) the action destroys money records or customer-visible records, (c) the backend already returns a 400/409 with paid_invoices or similar payload. Modal shows: red consequence banner with bullet list, scrollable list of items to be destroyed, text input requiring the operator to type "DELETE", red Force button gated on the typed string matching. Canonical examples: BillingRuns.jsx force delete + CustomerDetail.jsx CLI delete (Release/Delete/Cancel three-option). GH #12 tracks rollout across Invoices.jsx (per-invoice force delete), Products.jsx (product in-use delete), Sync.jsx (TwilioAccount delete). Avoid plain window.confirm() for any force=true path |
| Reconcile helpers reusable for outage recovery | find_paid_payment_intent_for_invoice + reconcile_invoice_with_payment_intent in apps/payments/stripe_client.py are decoupled from the checkout endpoints. If Stripe ever has an extended webhook outage, an admin action that iterates open/sent invoices and calls these for each would self-heal the entire DB. Not built yet, file an issue if it becomes a recurring need |
| Two Stripe sessions per invoice possible | Without the Session 20 DB-guard AND without the Session 22 P3 Stripe-side guard, an operator/customer can complete multiple Stripe Checkout sessions for the same invoice if the webhook is silent. With both guards active this is closed on prospective clicks. A duplicate ALREADY in Stripe history must be refunded manually in the dashboard. Watch for this when reconciling after any webhook downtime |
| CustomerListSerializer is a 4-field skim | apps/customers/serializers.py:CustomerListSerializer returns only id, name, billing_email, status (used by GET /api/customers/ list action via get_serializer_class). Any UI that opens an edit/detail modal from a list row MUST fetch /customers/{id}/ first to hydrate the full CustomerSerializer payload — the Customers list page's Edit button used to pass the thin row straight into CustomerForm and most fields rendered blank. Fixed in commit 7dfa92f by fetching on click (mirrors the Delete button's delete-impact pre-fetch on the same row) |
| Modal overlay hides ResultBanner | The Sync page's ResultBanner renders at the top of the page outside any modal. The assign-CLI / add-account / transfer / typed-confirm modal overlays sit at z-50, so any setResult({ error: ... }) from an onError handler that keeps the modal open is INVISIBLE to the user. Pattern: errors that can occur while a modal is open MUST render inline inside that modal (mutation.isError && mutation.error?.response?.data). Don't rely on the page-level banner. Fixed for assign-CLI in commit 7a99431; same pattern applies to any future modal mutations |
| /cdr/sync-logs paginated endpoint | New in d1ea270. GET /api/cdr/sync-logs/?page=N&page_size=50 — DRF PageNumberPagination default ({count, next, previous, results}). SyncLogListView in apps/cdr/views.py is a ListAPIView with filter_backends=[DjangoFilterBackend] and filterset_fields=['source', 'status'] so query params like ?source=api&status=failed filter the result. /cdr/sync-status/ no longer carries logs — pure dashboard metrics (counts/accounts/unmatched_numbers) |
| Daily reprocess Beat task | cdr.reprocess_unmatched scheduled at 05:30 Australia/Sydney by data migration apps/cdr/migrations/0006_seed_reprocess_beat_schedule.py. Safety net for the failure mode where an ingest-time matcher bug (like the to_number fix in 64442f6) leaves historical unmatched rows stuck forever even after the bug is fixed forward. Task is a no-op when nothing is unmatched, so cost is negligible. Scheduled 30 minutes before the overdue-invoice task at 06:00 — both run after the 04:00 Twilio sync cycle completes |
| reprocess_unmatched_cdrs skips invoiced=True | At apps/cdr/tasks.py:67 filters invoiced=False. CDRs with invoiced=True are NEVER re-evaluated even by the new daily Beat task. This means stale invoiced flags (the GH #14 anomaly where BillingRun delete doesn't reset CallRecord.invoiced) leave records permanently stuck in the unmatched-billable panel. Cleanup: CallRecord.objects.filter(id__in=[...]).update(invoiced=False) then either click Reprocess or wait for the next Beat firing. The GH #14 fix should reset invoiced=False at delete time so this never happens again |
| GH #14 — CDR invoiced flag persistence | When a BillingRun (or individual Invoice) is deleted, the InvoiceLineItems vanish but the associated CallRecord rows keep invoiced=True. Symptoms: Sync page's Unmatched Numbers panel shows entries that reprocess can never match (because of the watch-point above), and any future billing run skips them silently. Fix: reset CallRecord.invoiced=False (and clear invoice_line_item FK if present) in the BillingRun delete flow and Invoice post_delete signal. Workaround for known cases: manual update(invoiced=False) then reprocess |
| Claude model availability (post Jan 2026) | The fixed system prompt baked into Claude Code says "most recent family is Claude 4.X (Opus 4.7 / Sonnet 4.6 / Haiku 4.5)". As of 09 Jun 2026 Anthropic also released Claude Fable 5 and Claude Mythos 5 (Mythos = Fable with safeguards lifted, restricted via Project Glasswing). Pricing $10/M input, $50/M output — roughly 2–3× Opus 4.7. Anthropic claims "exceeds any model we've ever made generally available". Practical guidance for this project: stay on Sonnet 4.6 for routine work (UI, CRUD, migrations, deploys), escalate to Fable 5 (not Opus 4.7) for genuinely thorny tasks (Stripe race conditions, billing engine changes, cross-app architectural calls). Don't take a model's self-description of its rank at face value — it may be reading its own outdated system prompt |
| Item | Bitwarden Entry |
|---|---|
| Dev SSH | crip@10.0.0.41 |
| Prod SSH | crip@51.161.136.89 |
| Django admin (dev) | Yealin Billing Admin — dev |
| Django admin (prod) | Yealin Billing Admin — production |
| PostgreSQL | Yealin Billing — PostgreSQL DB |
| Twilio US1 API Key | Twilio API Key — yealin-billing-dev |
| Twilio AU1 Auth Token | Twilio Auth Token AU1 — yealin-billing |
| SMTP | Yealin Billing — SMTP billing@yealin.com.au |
| GitHub PAT | GitHub PAT — yealin-billing-servers |
| Stripe | Stripe — yealin-billing-dev |
| Wiki.js API Key | Wiki.js API Key — Claude |
Remaining short-term backlog below. Long-tail tech debt + design gaps tracked on GitHub Issues — see gh issue list -R ounyai/yealin-billing --label design-gap,latent-bug,tech-debt from ~/.local/bin/gh on dev. Run GH_TOKEN=$(git -C /var/www/yealinbilling config --get remote.origin.url | sed -n 's|https://[^:]*:\([^@]*\)@.*|\1|p') ~/.local/bin/gh ... for one-shot auth from the git remote.
| # | Item | Priority | Notes |
|---|---|---|---|
| A | Invoice sequence fix — prevent number reset after deletes | HIGH | GH #5. Same weakness now applies to credit-note sequence (CN-YYYY-NNNN). |
| B | Re-run billing Feb–May 2026 and send invoices to customers | HIGH | Need fresh CDR backfill (current DB is 7,692 post-wipe) |
| C | Auto-send invoices toggle in Settings (24hr delay) | MEDIUM | |
| D | Account dashboard in customer portal (balance, next due) | MEDIUM | |
| E | Expose Credit Notes in customer portal (admin-only today) | MEDIUM | New as of Session 24 |
| F | Monthly billing automation — Celery Beat on 1st of month | LOW | |
| G | Fix CDR invoiced=True not resetting on BillingRun delete |
MEDIUM | GH #14 — workaround: manual .update(invoiced=False) then reprocess |
| H | Reports: surface credit-note totals against revenue | LOW | New as of Session 24 |
| # | Title | Labels |
|---|---|---|
| #3 | 3 Twilio subaccounts return zero CDRs on both dev and prod | bug |
| #5 | Invoice number sequence resets after delete | enhancement |
| #9 | Guard against bulk re-match nuking historical CDR attributions | design-gap, latent-bug |
| #10 | Sync dup-check rebuilds entire call_sid set per account-region | tech-debt |
| #12 | Adopt typed-confirmation modal everywhere a destructive force action is gated by window.confirm() | design-gap, tech-debt |
| #14 | CallRecord.invoiced flag not reset when a BillingRun is deleted | latent-bug, tech-debt |
Closed this session: #17 (Session 24 main changelog — Credit Notes feature: backend viewset + PDF + email + sidebar submenu + Issue modal + dedicated list page + force-cascade on invoice/billing-run delete).
Closed Session 23: #15 (Session 23 main changelog — Customers Edit hydration + paginated Sync history + auto-reprocess Beat task), #16 (Session 23 follow-up — assign-CLI inline error + dev invoiced-flag cleanup).
Closed prior to Session 23: #1 (customer.status semantics), #2 (react-query shared queryKey), #4 (blocklist-call logic), #6 / #11 / #13 (Session 22 P1/P2/P3 changelogs), #7 (nginx media exposure), #8 (Invoice PDF orphan).
The ONLY correct copy of wiki_sync.py lives on production:
crip@51.161.136.89:/var/www/yealinbilling/wiki_sync.py
session_data list; Sessions 13+ are manual PAGES.append blocks.wiki_sync.py.bak-YYYYMMDD-HHMMSS) sit next to it on prod — make one beforeOption A — directly on prod (claude.ai / manual):
crip@51.161.136.89cp wiki_sync.py wiki_sync.py.bak-$(date +%Y%m%d-%H%M%S) (safety backup)PAGES.append((...)) session block before the # ── MAIN ── markersessions/index block (status-table row + Session Pages link)claude-context block (Sessions Completed, Current Data State, Watch Points, Next Items)python3 wiki_sync.py — expect "Done: N succeeded, 0 failed"Option B — from dev via Claude Code (preferred for editing):
ssh crip@51.161.136.89 'cp /var/www/yealinbilling/wiki_sync.py /var/www/yealinbilling/wiki_sync.py.bak-$(date +%Y%m%d-%H%M%S)' thenscp crip@51.161.136.89:/var/www/yealinbilling/wiki_sync.py /tmp/wiki_sync_prod.py/tmp/wiki_sync_prod.py locally; verify with python3 -m py_compile /tmp/wiki_sync_prod.pyscp /tmp/wiki_sync_prod.py crip@51.161.136.89:/var/www/yealinbilling/wiki_sync.pyssh crip@51.161.136.89 'python3 /var/www/yealinbilling/wiki_sync.py'curl -s -o /dev/null -w "%{http_code}\n" https://wiki.yealin.com.au/en/sessions/session-NA non-interactive ssh crip@51.161.136.89 'git ...' runs in the home directory, which is not a
git repo, so bare git commands fail with "fatal: not a git repository". Always target the repo
path explicitly:
# WRONG — runs in $HOME:
ssh crip@51.161.136.89 'git log --oneline -3'
# RIGHT — use git -C (no cd needed):
ssh crip@51.161.136.89 'git -C /var/www/yealinbilling log --oneline -3'
# or cd first:
ssh crip@51.161.136.89 'cd /var/www/yealinbilling && git log --oneline -3'
[State your session goal here when starting a new conversation]
Example: Session 22: [describe what you want to build]