They join on
(gateway_msg_id, part_no). The cdr_pnl view left-joins submit and final, so records
with no outcome yet surface as PENDING — in-flight exposure stays visible rather than being
silently excluded from your numbers.
Why progress receipts live in their own table
A vendor may send several receipts for one message:ACCEPTD when it takes it, BUFFRED while it
retries, then DELIVRD or a failure. Only the last is an outcome.
Every reader takes the earliest cdr_final row for a message — the message list, the portal
dashboard, the revenue queries and the cdr_pnl view all do. So writing progress receipts into
cdr_final would make an ACCEPTD outrank the DELIVRD that followed it, in every list, every chart
and the P&L.
The expiry sweep depends on the same thing: it finds unresolved messages by anti-joining cdr_final,
so a progress receipt there would make an open message look closed and it would never be revisited.
The two tables also have different lifetimes. cdr_final is billing history and lives as long as the
CDR. cdr_interim is quality history — how fast a vendor reports, and whether it reports at all —
which is a question about recent traffic, so it is pruned on its own shorter window
(smsg.cdr.interim.retention.days, default 30). Losing it costs a report, not an invoice.
Both sides of the trade
Rate, units and total are recorded separately per side, because the two can legitimately bill different unit counts for one message — a vendor bills per segment where you may bill per message.
For a concatenated message the money lives entirely on part 1, whose cost already covers every
segment. Later segments are delivery rows with no money — writing amounts on every segment would
multiply both sides by the segment count.
A refused message carries no money
Anything that reached a vendor has acdr_submit row, accepted or not. A message the vendor refused
is recorded in full — identity, timing, vendor, addresses — plus a cdr_final and a cdr_rejected
row carrying the vendor’s status code.
But price, cost and margin are null, because the customer’s credit was returned at the
moment of refusal.
The three TLV columns
Three points on one pipeline. Every useful question is a comparison between two of them.NULL means nothing recorded the column; {} means recorded and there were none. tlvs_sent is
NULL for every non-SMPP vendor.
Reading a retry storm
Every attempt at a vendor now leaves its owncdr_submit row — not only the one that finally
succeeded or terminally failed. vendor_attempt numbers them, submit_status carries what the vendor
actually said on each one, and NULL there means “not recorded”, not “accepted”. See
Call record tables for the exact shape.
The message body
Recorded only if you switch it on.
An unrecognised value is treated as
off — the failure that matters is storing content nobody asked
to store, so a typo must not turn recording on.
Outcomes
dlr_state keeps the raw SMPP value alongside the coarse outcome, because EXPIRED, REJECTD
and UNDELIV all bill as failures but are worth telling apart.
The expiry sweep closes anything past its deadline as EXPIRED, leaving billing to decide what an
unknown outcome is worth. The deadline is capped below two days: past the correlation window a
receipt can no longer be matched even if it arrives, so waiting longer cannot change the answer.
If a message appears in no list at all
The writer is usually pointed at a different database from the panel. Checksmsg.cdr.sink and the
gateway’s FIREFLO_DB_URL against the panel’s METRICS_DATABASE_URL before looking anywhere else.
Related
Usage and retention
What survives when the call records are purged.
How pricing works
Where each side of the trade is settled.