| Field | Value |
|---|---|
| Date | 11 Jun 2026 |
| Status | Complete (deployed both dev + prod) |
| GitHub HEAD | 7a99431 |
| Wiki | Updated through Session 23 |
Four small commits, two GitHub issues closed as session changelogs (#15 main, #16 follow-up), one new open issue (#14 — data anomaly), and a one-off dev data cleanup. None of the changes are big individually; together they tidy up several rough edges on the operator-facing pages and add a safety-net Beat schedule that should prevent a class of "silent stuck records" bug from recurring.
7dfa92fOn /customers, clicking Edit on a row opened the customer modal with most fields blank (address, ABN, billing contact, billing email, billing phone, payment terms, notes, Twilio account) — even when those fields were populated in the database. Clicking Edit from the Customer detail page (/customers/<id>) worked correctly.
CustomerViewSet.get_serializer_class (apps/customers/views.py:34) returns the lightweight CustomerListSerializer for the list action — only 4 fields: id, name, billing_email, status. The list page passed that thin row straight into CustomerForm as initial, so every other field rendered blank.
The detail page worked because it calls /customers/{id}/ separately, which uses the full CustomerSerializer.
Mirror the existing Delete button pattern on the same row: fetch /customers/{id}/ first, then open the modal with the hydrated record.
onClick={async e => {
e.stopPropagation()
try {
const full = await client.get(`/customers/${r.id}/`)
setModal({ mode: 'edit', data: full.data })
} catch (err) {
alert(err.response?.data?.detail || 'Failed to load customer')
}
}}
Watch-point added so future UI work uses the right pattern: "any list-row → edit/detail modal MUST hydrate from the detail endpoint, not the list payload" — the lightweight serializer is intentional and the same trap will catch every future feature that opens a modal from a list row.
d1ea270The Sync page's "Sync History" table was the last 20 SyncLog rows, baked into the /cdr/sync-status payload (a hardcoded [:20] slice). Celery Beat fires the 4-hour Twilio sync against 8 accounts × 2 regions = 16 rows every 4 hours = 96/day. The visible window covered well under a day. Operator couldn't look back any further without shelling into the DB.
Split logs into a dedicated paginated endpoint and keep /cdr/sync-status/ focused on the dashboard metrics.
Backend (apps/cdr/views.py, apps/cdr/serializers.py, apps/api/urls.py):
class SyncLogListView(generics.ListAPIView):
serializer_class = SyncLogSerializer
queryset = SyncLog.objects.all().order_by('-started_at')
filter_backends = [DjangoFilterBackend]
filterset_fields = ['source', 'status']
# apps/cdr/serializers.py
class SyncLogSerializer(serializers.ModelSerializer):
duration_seconds = serializers.IntegerField(read_only=True)
class Meta:
model = SyncLog
fields = [
'id', 'source', 'status',
'started_at', 'completed_at',
'date_from', 'date_to',
'imported', 'duplicates', 'unmatched', 'errors',
'filename', 'duration_seconds',
]
# apps/api/urls.py
path('cdr/sync-logs/', cdr_views.SyncLogListView.as_view(), name='cdr_sync_logs'),
DRF's default PageNumberPagination (page_size=50) kicks in automatically — response is the standard {count, next, previous, results} shape.
The recent_logs[:20] block was removed from sync_status so the dashboard endpoint stays lighter (counts + accounts + unmatched-numbers only).
frontend/src/pages/Sync.jsx)New query + page state, Prev/Next controls under the table, page count display:
const [logsPage, setLogsPage] = useState(1)
const LOGS_PAGE_SIZE = 50
const { data: logsData } = useQuery({
queryKey: ['sync-logs', logsPage],
queryFn: () => client.get(`/cdr/sync-logs/?page=${logsPage}&page_size=${LOGS_PAGE_SIZE}`).then(r => r.data),
refetchInterval: 10000,
keepPreviousData: true,
})
const logs = logsData?.results || []
const logsCount = logsData?.count ?? 0
const logsHasNext = !!logsData?.next
const logsHasPrev = !!logsData?.previous
const logsTotalPages = Math.max(1, Math.ceil(logsCount / LOGS_PAGE_SIZE))
apiSync and csvSync now invalidate ['sync-logs'] alongside ['sync-status'] so a new sync run shows up immediately without waiting for the 10s refetch.
Backend supports ?source=api&status=failed already via filterset_fields. UI doesn't expose it yet but no extra code needed to add filter controls later.
reprocess_unmatched_cdrs — 03e828aReprocess previously fired on three triggers only:
assign_cli endpoint (when a new CLI is added)There was no automatic schedule. Consequence: when an ingest-time matcher bug (like the missing to_number issue fixed in 64442f6) was patched forward, historical unmatched rows from before the fix stayed stuck forever. Nothing ever retried them.
This actually surfaced on dev this session: 83 inbound CDRs to CRIP PTY LTD's DID +61390000001 spanning 2025-06-16 to 2026-06-10 were sitting unmatched even though the CLI for that number existed — they pre-dated the 64442f6 fix.
A daily Beat task at 05:30 Australia/Sydney. Idempotent — runs as a no-op when nothing is unmatched. Scheduled 30 minutes before the existing overdue-invoice task at 06:00, both running after the 04:00 Twilio sync cycle so they catch any new unmatched rows the same morning.
Data migration following the same template as apps/billing/migrations/0004_seed_overdue_beat_schedule.py:
# apps/cdr/migrations/0006_seed_reprocess_beat_schedule.py
def seed_schedule(apps, schema_editor):
CrontabSchedule = apps.get_model('django_celery_beat', 'CrontabSchedule')
PeriodicTask = apps.get_model('django_celery_beat', 'PeriodicTask')
schedule, _ = CrontabSchedule.objects.get_or_create(
minute='30', hour='5',
day_of_week='*', day_of_month='*', month_of_year='*',
timezone='Australia/Sydney',
)
PeriodicTask.objects.update_or_create(
name='Reprocess unmatched CDRs',
defaults={
'task': 'cdr.reprocess_unmatched',
'crontab': schedule,
'enabled': True,
'description': 'Daily 05:30 Australia/Sydney — retries matching for unmatched CDRs. Safety net for ingest-time matcher bugs and CLIs added between sync runs.',
},
)
The reverse migration deletes the schedule.
Triggered reprocess manually on dev after deploying: 83 of 85 unmatched-billable rows matched immediately. The remaining 2 were blocked by the invoiced=True data anomaly tracked in GH #14 (see Section 5 below).
7a99431"On dev Twilio Sync, there's an unmatched entry
+61390000001. When I click + Add as CLI and select CRIP PTY LTD as customer + direction both + Add CLI, the button does nothing."
Two stacked issues:
a) The backend WAS responding correctly — 400 with { "error": "+61390000001 already registered as CLI for CRIP PTY LTD" } (since the CLI did exist; see Section 5).
b) The frontend hid the error. assignCli.onError called setResult({ error: ... }) which renders the page-level ResultBanner. But the modal sits on top of it (z-50), so the error message was invisible. The modal didn't auto-close on error either (intentional — gives the user a chance to fix and retry) — so visually nothing happened.
setResult from the onError handler (the 409 cli_owned_by_other branch keeps its window.confirm transfer prompt).assignCli.error?.response?.data:{assignCli.isError && assignCli.error?.response?.data?.error !== 'cli_owned_by_other' && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-800">
{assignCli.error?.response?.data?.error
|| assignCli.error?.response?.data?.detail
|| 'Failed to add CLI'}
</div>
)}
assignCli.reset() so reopening for a different number starts clean.Pattern added to watch-points: any mutation inside a modal that can fail without closing the modal MUST surface its error inline. Page-level ResultBanner is for outside-modal feedback only.
After the reprocess from Section 3, 83 of 85 dev unmatched CDRs matched. The remaining 2 were:
id=75b2e476-... direction=inbound from=+64207214797 to=+61390000001 invoiced=True
id=b24b86c6-... direction=inbound from=+61468030385 to=+61390000001 invoiced=True
Both had invoiced=True even though there are zero Invoices and zero BillingRuns in the DB (Session 22 wiped them). reprocess_unmatched_cdrs filters invoiced=False (apps/cdr/tasks.py:67), so they were permanently invisible to the matcher.
When a BillingRun is deleted with force=true, the InvoiceLineItems vanish but the related CallRecord rows keep invoiced=True. The delete flow doesn't reset the flag. Same issue if Invoices are deleted via ORM directly.
Cleared the flag on the 2 records and reran reprocess — both matched to CRIP PTY LTD on the next pass. Dev unmatched_billable is now 0.
CallRecord.objects.filter(id__in=[
'75b2e476-ad27-4607-8bf8-e56d3b3a5545',
'b24b86c6-16b8-4a00-b6df-7c8786f7c5f7',
]).update(invoiced=False)
reprocess_unmatched_cdrs()
Open GH issue #14 — when BillingRun or Invoice is deleted, also update(invoiced=False) on the related CallRecord rows. Wrap in the same transaction as the delete. Until then, the workaround above is the manual recovery procedure.
CallRecord.invoiced flag not reset when a BillingRun is deleted (root cause of the 2 stuck dev rows above)Discussion this session, captured here for the next operator who has to pick a model:
| Tier | When |
|---|---|
| Sonnet 4.6 | Default for everything. UI fixes, CRUD, modals, serializers, migrations, deploys, gh issue triage, wiki updates. Covers ~90% of work in this repo. |
| Fable 5 ($10/M / $50/M) | Genuinely thorny problems — Stripe race conditions, billing engine changes, cross-app architectural calls (GH #9 / #5 / refactors). Released by Anthropic 09 Jun 2026, claims to exceed any previous model. |
| Opus 4.7 | Not particularly recommended any more — Fable 5 is the new "expensive when it matters" tier. Opus stays useful while Fable 5 access is rolling out, but pick Fable 5 first for hard problems if you have access. |
The premium model only pays for itself when the problem is hard enough that you'd have spent multiple Sonnet iterations otherwise.
Both commits deployed cleanly to dev → prod.
Reprocess unmatched CDRs confirmed registered + enabled on both dev and prodindex-DhWnoUQh.jsNo data risk during deploy — the migration is a single PeriodicTask.objects.update_or_create and the reprocess task it schedules is idempotent.