Payment History Grid
A color-coded 24-cell visualization of every account's Payment History Profile. Spot bad PHP strings, explain delinquency patterns, and audit the codes you're sending to the bureaus - all without parsing a 24-character string by hand.
1. What it is
The Payment History Grid is a 24-cell strip that visualizes the Metro 2 Payment History Profile (PHP) - Field 18 in the CDIA Credit Reporting Resource Guide. Each cell represents one month, and each color/letter encodes the consumer's payment status for that month.
PHP is the field bureaus look at to tell, at a glance, whether a tradeline has been clean, late, or already worked. Metro2 stores PHP as a single 24-character string per record:
payment_history_profile: "0000000011220000000000BB"That string is hard to read and even harder to QA at scale. The grid turns it into something you can scan in under a second:
Metro2PHPGrid component, fed by the same code legend, with the same color palette. There is no second renderer that could disagree.2. Why it matters
The bureaus interpret PHP literally. A single wrong character is reported as fact to Experian, Equifax, and TransUnion, and from there it flows into every credit score that consumes your data.
- A leading
L(charge-off) when the account is actually current can knock 100+ points off a consumer's score and trigger an FCRA dispute that you have to investigate within 30 days. - A 24-string padded with
B(no history) when the account actually has 18 months of clean payments tells the bureaus you haven't been furnishing - which can fail bureau onboarding QA. - A code outside the CRRG-allowed alphabet (anything other than
0-6,B,D,E,G,H,J,K,L) will reject the entire base segment during bureau validation.
The grid exists so that bad PHP gets caught by a human eye before it leaves your system, not by a bureau rejection report two weeks later.
3. Reading the grid
Position 1 (most recent month) is on the left; position 24 (oldest month) is on the right. This matches the left-to-right order of the underlying PHP string and how the validator and generator read it.
The full code legend
There are 15 allowed CRRG codes. Metro2 uses an Okabe-Ito color-blind-safe palette so that red/green confusion never hides a delinquency.
| Code | Color | Meaning | Severity |
|---|---|---|---|
| 0 | Current / paid on time | 1 (good) | |
| 1 | 30 days past due | 2 (mild delinquency) | |
| 2 | 60 days past due | 3 (moderate) | |
| 3 | 90 days past due | 3 (moderate) | |
| 4 | 120 days past due | 4 (severe) | |
| 5 | 150 days past due | 4 (severe) | |
| 6 | 180+ days past due | 4 (severe) | |
| B | No history available (account didn't exist that month) | 0 (neutral) | |
| D | No history reported - account paid or closed | 0 (neutral) | |
| E | Zero balance and current account (informational) | 6 (dispute / info) | |
| G | Collection | 5 (terminal negative) | |
| H | Foreclosure completed | 5 (terminal negative) | |
| J | Voluntary surrender | 5 (terminal negative) | |
| K | Repossession | 5 (terminal negative) | |
| L | Charge-off | 5 (terminal negative, most severe) |
How to read a real example
Suppose a record's PHP looks like this:
"00000112200000000BBBBBBB"Reading left to right (most recent month first):
- Months 1-5: green (
0) - account is current and has been for the last 5 months. - Months 6-7: yellow (
1) - 30 days past due. The consumer was a month late twice. - Months 8-9: orange (
2) - 60 days past due. The delinquency deepened before they caught up. - Months 10-17: green (
0) - back to current. - Months 18-24: gray (
B) - account didn't exist yet, so there's no history to report.
That tells a coherent story: the account opened ~17 months ago, had a rough patch in months 6-9, and has been clean since. If the grid shows a jumble that doesn't tell a story like that - e.g. a stray charge-off (L) in the middle of green cells - that's a data-quality red flag.
Tooltips
Hover any cell to see its position number, the calendar month it represents, the code character, and the plain-English meaning. The month is derived from date_of_account_info on the record, so it always matches what gets reported to the bureau.
4. Where you'll see it
Per-record view (Records - Edit Record)
Open any record from the Records table and scroll to the Payment History Profile section in the form. You'll see a medium-size grid with a live preview that re-renders whenever you change one of the five driver fields (see section 6). The grid is read-only here; the PHP string itself is derived on save, not typed.
Per-record drill-down (Records table)
Expand any row in the records manager and you get a compact "sm" grid inline - useful for scanning a hundred accounts at once without opening each one. The "worst" code for each account is also shown as a colored chip so you can sort or filter by severity.
Per-file portfolio heatmap (Analytics)
From the Analytics tab (or the file-upload detail page), the Payment History Profile - Portfolio tile renders a 24-by-15 heatmap: 24 position columns (most recent on the left) by 15 code rows. Each cell shows what fraction of accounts in scope carry that code at that position. Hover any cell for the exact count and percentage.
Pass a fileUploadId to scope the heatmap to a single upload; omit it to see the entire company's portfolio. The tile defaults to portfolio-wide when no file is selected.
5. What "derived" vs "stored" means
Metro2 keeps two views of every PHP value on every record:
| View | What it is | Where it comes from |
|---|---|---|
| Stored | The 24-char string currently saved on the record and the value that will be written into the next generated Metro 2 file. | metro2_records.payment_history_profile |
| Derived | What PHP would be if we recomputed it from the driver fields right now. Used as a sanity-check overlay. | computePaymentHistoryProfileFromRecord() |
In Wave 2 / Phase 1 (where the product is today), stored and derived are kept in lockstep: the record handler re-derives PHP from the driver fields on every save and overwrites whatever was in the stored column. That's why effective = stored for now - there is no scenario where a manual edit causes the two to diverge.
value (stored) and a derived prop, any cell where the two differ is outlined in amber. In practice you should never see this in normal use; if you do, it's a strong signal the record was edited outside the normal save path (e.g. a backfill migration) and should be re-saved or investigated.6. Driver fields
Metro2 derives PHP from five fields on the record. Change any of these and the grid in the record form updates live:
| Field | What it tells the derivation |
|---|---|
| date_opened | Anchors the start of history. Months before this date are rendered as B (no history available) because the account didn't exist yet. |
| account_status | Drives the code for position 1 (and carry-forward for terminal states like charge-off and collection - see common questions below). |
| date_of_account_info | The "as-of" date for the entire PHP string. Position 1 is the month before this date; position 24 is 24 months before. |
| fcra_date_of_first_delinquency | The earliest month a delinquency can appear. Combined with account_status, used to back-fill the months between DOFD and the as-of date. |
| date_closed | Marks where the account stops reporting active history. After this date, cells become D (paid/closed) or follow a terminal carry-forward. |
Because PHP is strictly derived, the way to "edit" the grid is to edit these five fields. There is no PHP textbox; the string you see is always a function of those inputs.
7. Worst-code summary
Every record has a worst code - the single most severe code in its 24-month string, by severity bucket (0 = no history, 1 = current, ..., 5 = terminal negative). The grid surfaces this on the per-record view and the portfolio heatmap uses it to build a histogram across all accounts.
The portfolio heatmap displays worst-code distribution as a row of badges, sorted by severity:
Worst-code per account (severity)
L · Charge-off 42
G · Collection 17
3 · 90 days past due 8
1 · 30 days past due 115
0 · Current 4,238Two things to look at first: the count of accounts whose worst code is L, G, H, J, or K (anything terminal-negative), and any unexpected clustering. A sudden spike in L for a file you didn't expect to charge-off anything in is usually a mapping bug in the upstream system, not a real wave of charge-offs.
1 through 6 get progressively worse, then collection (G), foreclosure (H), surrender (J), and repossession (K) are all severity 5, with charge-off (L) being the most severe terminal state. The "no history" codes (B, D) are severity 0 and never count as worst - a brand-new account doesn't get flagged just because most of its cells are gray.8. Editing PHP
Why? Two reasons:
- Compliance. Manually overriding PHP is a regulated act under the FCRA. If an override is used to mask a delinquency (intentionally or not), the furnisher carries the liability. We want overrides to require a mandatory reason, an audit log entry, and ideally a legal sign-off on the policy - none of which exists in v1.
- Determinism. While PHP is strictly derived, you have a single source of truth: change the driver fields, get a new PHP. As soon as overrides exist, every screen that displays PHP has to distinguish "what the model says" from "what a human overrode", and every bureau dispute investigation has to chase both.
A roles-gated, audit-logged inline override workflow is on the roadmap (Phase 3 of the payment-history-grid plan), but is not scheduled until the compliance posture is signed off. Until then: edit the driver fields and let the derivation do the work.
If you have a legitimate need to correct PHP that derivation can't express (typically: a portfolio migration where the historical ledger doesn't fit Metro2's derivation model), contact support and we'll work through the records with you directly.
9. API reference
Both grid endpoints are read-only GET requests. Use a session cookie (from a logged-in browser) or an API key in the Authorization: Bearer header.
GET /api/metro2-record-php
Returns the stored, derived, and effective PHP for a single record, plus the driver fields it was derived from and the worst-code summary.
curl -s "https://metro2.switchlabs.dev/api/metro2-record-php?id=<RECORD_ID>" \
-H "Authorization: Bearer <API_KEY>"Response shape:
{
"success": true,
"data": {
"recordId": "9c7e...",
"accountNumber": "ACC-00042",
"stored": "00000112200000000BBBBBBB",
"derived": "00000112200000000BBBBBBB",
"effective": "00000112200000000BBBBBBB",
"overrides": [],
"driverFields": {
"date_opened": "2024-12-01",
"account_status": "11",
"date_of_account_info": "2026-05-31",
"fcra_date_of_first_delinquency": "2025-09-01",
"date_closed": null
},
"worstCode": {
"code": "2",
"meaning": "60 days past due",
"severity": 3
}
}
}The overrides array is reserved for Phase 3 and is always empty today. effective equals stored for the same reason.
GET /api/metro2-analytics/php-distribution
Returns the 24-by-15 aggregate matrix used by the portfolio heatmap, plus the worst-by-record histogram.
curl -s "https://metro2.switchlabs.dev/api/metro2-analytics/php-distribution?\
companyId=<COMPANY_ID>&fileUploadId=<FILE_UPLOAD_ID>" \
-H "Authorization: Bearer <API_KEY>"Response shape (trimmed):
{
"success": true,
"data": {
"scope": {
"companyId": "fa90...",
"fileUploadId": "8b1c..."
},
"totalRecords": 4420,
"totalCells": 106080,
"counts": [
{ "position": 1, "byCode": { "0": 4238, "1": 115, "2": 8, "3": 0, ... } },
{ "position": 2, "byCode": { "0": 4231, "1": 122, "2": 9, "3": 0, ... } },
...
{ "position": 24, "byCode": { "0": 2150, "1": 38, "B": 2200, ... } }
],
"worstByRecord": {
"0": 4238, "1": 115, "2": 8, "3": 0, "4": 0, "5": 0, "6": 0,
"B": 0, "D": 0, "E": 0, "G": 17, "H": 0, "J": 0, "K": 0, "L": 42
}
}
}companyIdis optional - defaults to the caller's own company. Superadmins can pass another company.fileUploadIdis optional - omit to get a portfolio-wide view across every record in the company.- Every code is seeded with zero in
byCodeso consumers can render a complete heatmap even for sparse buckets.
10. Common questions
Why does my grid show a code I didn't expect at position 1?
Position 1 is derived from the account's current account_status. If that's a terminal status (charge-off, collection, repo, foreclosure, surrender), the terminal code carries forward into position 1 even if no payment activity occurred that month. That's CRRG-compliant behavior - once an account is charged off it stays charged off until reported as paid.
If you're seeing a 1 when you expected 0, check fcra_date_of_first_delinquency - if it's set to the current month, the derivation will mark the most recent cell as the start of a delinquency.
Why are most of my cells gray?
Gray (B) means "no history available". The most common cause is an account that's less than 24 months old - everything before date_opened is gray because the account didn't exist yet. If you opened the account 6 months ago, you'll see 6 colored cells on the left and 18 gray cells on the right.
Lighter gray (D) means "no history reported - account paid or closed". That's the right code for months after date_closed.
Why is a single cell yellow when everything around it is green?
Most likely you have a single late-payment event in your driver data - typically reflected in fcra_date_of_first_delinquency - that the derivation is correctly translating into a 30-day-late code (1) for that month. If that's not what actually happened, audit the source data; the grid is showing you exactly what your record says.
Can I export the grid?
Not as an image today. You can hit the JSON endpoints above and render however you like, and PDF export is on the Phase 4 roadmap (will hook into the existing analytics-suite PDF pipeline). For now, screenshot is the fastest path.
Does the grid show what gets sent to the bureaus?
Yes - exactly. The grid is rendered from the same payment_history_profile string that gets written into Field 18 of the base segment of your generated Metro 2 file. There's no transformation in between.
Canadian (CN) jurisdiction - does the grid work the same?
Yes. The CRRG alphabet for PHP is identical between US and Canada, so the same 15 codes, the same colors, the same derivation rules all apply.
11. Troubleshooting
Stored and derived don't match
If you load a record and the grid has amber-ringed cells (meaning stored differs from derived), the most likely causes are:
- The record was bulk-imported or backfilled outside the normal save path, which skipped the re-derivation step.
- A driver field was edited directly in the database (via a SQL script, for example) without going through the record save handler.
- The record was created before a derivation rule change and hasn't been re-saved since.
Fix: open the record in the form and save it (no need to change anything). The save handler re-derives PHP and writes the result back. If you have a large backfill to fix, use the bulk update API with an empty patch body to trigger re-derivation across many records at once.
Some cells appear to be missing
The grid always renders exactly 24 cells. If you only see, say, 6 colored cells and the rest are gray, that's not "missing" - that's the grid correctly showing that you only have 6 months of history (account opened < 24 months ago) and the other 18 months are B.
The portfolio heatmap is empty
The heatmap will show an empty state if the scope filter returns zero records. Check:
- The
fileUploadIdparameter is valid and matches a file that has imported records (not a failed/empty upload). - The company actually has records (visit the Records tab and confirm).
- You haven't filtered to a date window with no records. The heatmap is unfiltered by date in v1 - it runs across every record in scope.
The tooltip shows a different month than I expect
Tooltip month labels are computed as date_of_account_info - position. Position 1 is the month before the as-of date, not the same month. So a record with date_of_account_info = 2026-05-31 will label position 1 as April 2026, position 2 as March 2026, etc. This matches how the validator and the generator interpret the field.
The grid renders blank or shows ? cells
A ? means the stored string contains a character not in the CRRG alphabet. This should never happen for records that pass validation; if you see one, the record was created outside the validator path. Re-save the record to trigger re-derivation and the bad character will be replaced.
12. Related guides
- AI Error Explanations - turn cryptic PHP validation codes into plain-English explanations and suggested fixes.
- Analytics & Reporting Suite - the dashboard that hosts the portfolio PHP heatmap alongside DQ metrics, audit reports, and submission reconciliation.
- Help Center - browse every Metro2 guide.
Still stuck? Contact support or browse the Help Center.