Rate card SSOT consolidation — inventory and migration plan
Status: inventory and plan only. Nothing here is implemented, and this document authorises nothing. It exists so the go/no-go decision on retiring the per-environment rate-card mirrors can be made on measured facts.
This is the step in the egress / versioned-assets programme that is not reversible the same way as the others: Steps 1, 2, 3 and the Step 5 groundwork are additive, and the old paths keep working if something goes wrong. Deleting env rate-card objects and removing the SDK fallback is not. So it is gated separately: explicit sign-off is required before any code touching deletion, fallback retirement, or runtime path changes is written.
Goal, restated from Rate cards overview: one credit product
catalogue owned by credit-products-api, 100% event-driven products at
runtime, and no parallel env configs/finmatch-rate-card.json acting as a
reader.
1. Measured inventory
Produced by tools/rate-card-inventory.js (read-only, no credentials — every
object it reads is public). Figures below are from a run on 24 Aug 2026;
re-run before acting on them.
The catalogue
| Shared catalogue | env p | env s | env t | |
|---|---|---|---|---|
| Object | gs://finmatch-shared/finmatch-rate-card.json | gs://finmatch-p/configs/finmatch-rate-card.json | gs://finmatch-s/… | gs://finmatch-t/… |
| Size | 253,014 B | 52,816 B | 31,831 B | 6,311 B |
Cache-Control | public, max-age=300 | public, max-age=3600 | public, max-age=3600 | no-cache, max-age=0 |
| Last modified | 4 Aug 2026 | 1 Apr 2026 | 23 Jan 2026 | 19 Jun 2025 |
| Products | 166 | 87 | 71 | 20 |
With transaction_events | 117 (70.5%) | 0 | 0 | 0 |
| Writer | credit-products-api | none | none | none |
Shared catalogue by lender: zopa 49, humm 27, propensio 23, dividebuy 22, snap 18, aldermore 12, klarna 6, iwocapay 4, paymentassist 2, paypal 1, clearpay 1, hometree 1.
The env objects have not been written in 4, 7 and 14 months respectively. They are stale snapshots, exactly as source of truth §8 states — not a second writable SSOT.
Overlap and drift
env p | env s | env t | |
|---|---|---|---|
| Product ids in both | 60 | 46 | 15 |
| Only in env | 27 | 25 | 5 |
| Only in shared | 106 | 120 | 151 |
| Overlapping ids that differ | 39 / 60 | 40 / 46 | 15 / 15 |
Drift is structural, not numeric. Fields that differ on p:
| Field | Ids affected | What the difference is |
|---|---|---|
transaction_events | 33 | present in shared, absent in env |
first_payment | 39 | env carries {"type":"Upfront Instalment","due_days":null,"factor":null}; shared omits the field in favour of events |
rate_factor | 11 | env carries the placeholder 1e-8 where shared carries the real factor (e.g. hu0000-003M: shared 0.3333333, env 1e-8) |
apr | 0 | none — an earlier draft of the script reported 33, which was a formatting artefact ("0.00%" vs "0%"). Fixed; APR agrees on every overlapping id |
That APR agrees everywhere while rate_factor and the repayment shape do not
is the useful result: the two objects mostly describe the same commercial
terms in two different schema generations, and the env copies are the older
generation with placeholder factors.
Merchant assignment coverage
env p | env s | env t | |
|---|---|---|---|
| Merchants in the env config | 110 | 96 | 94 |
| With product assignments | 109 | 95 | 93 |
| Distinct assigned product ids | 171 | 168 | 168 |
| Assignment references | 1,012 | 790 | 769 |
| Assigned ids covered by shared | 149 | 146 | 146 |
| Covered by the env mirror only | 20 | 20 | 4 |
| Covered by neither | 2 | 2 | 18 |
| Assigned ids event-authored in shared | 100 / 171 | 97 / 168 | 96 / 168 |
2. What actually blocks retirement
Only four merchants on p are assigned any product that is missing from
the shared catalogue, and three of them are not commercial traffic:
| Merchant | Missing from shared | Present in env mirror? |
|---|---|---|
FM-1234-5678-9101 FINMATCH TEST SITE | 20 ids (hu0000-048M, hu0999-012M, hu1499-024M, hu1799-036M, the sn2990-* family, sn0000-004M, sn0000-012M-1D, kl0000-006M, kl0000-012M) | yes |
FM-1234-5678-9102 FINMATCH | 17 ids (same families) | yes |
M281700 Docs Merchant | sn2990-024M-0D, sn2990-036M, sn2990-048M | yes |
FM-2509-6384-1756 GREEN SQUIRREL ENERGY LTD | hu1597-048M, hu1597-060M | no — missing everywhere |
So the blocker set is 20 product ids across two families (sn2990-* Snap
deposit variants and four legacy Humm SKUs), plus two ids that exist nowhere.
A live data-integrity finding, independent of this migration
hu1597-048M and hu1597-060M are assigned to a real merchant and exist in
neither the shared catalogue nor the p env mirror. The nearest ids are
hu1590-024M/036M/048M, and there is no hu1590-060M at all.
Product resolution in finmatch-utils.js resolveRateCardProduct is exact
plus case-insensitive — there is no prefix or fuzzy fallback — so both ids
resolve to nothing today and are skipped with a
[WARNING] Product ID "…" not found in rate card database console warning.
Those two products have never rendered for that merchant.
This needs fixing regardless of whether the consolidation goes ahead: either
correct the assignment to the ids that exist (hu1590-048M, and a decision on
the 60-month term), or author the missing SKUs in credit-products-api. It is
a merchant-facing configuration bug, not a migration blocker created by this
plan.
3. Who reads what today
| Reader | Object | Notes |
|---|---|---|
| SDK primary | env configs/finmatch-rate-card.json via finmatchConfig.rateCardsUrl | Every page load |
| SDK top-up | shared root, via applySharedRateCardFallback | When assigned ids are missing from env, or when env products lack events while shared has them |
finance-container.js | shared root, Propensio and Snap top-ups | p only |
finmatch-finance-app.js | shared root, then env p as a second candidate | p only |
partner-api | shared catalogue via credit-products-api / GCS root | Already consolidated |
snap-rate-card-test.yml | env configs/finmatch-rate-card.json | Its path filter and its £280 anchor are pinned to the env object, so retiring that object requires updating this workflow in the same change |
The SDK's event-upgrade rule in applySharedRateCardFallback ("use the legacy
env product until transaction_events are authored in shared") is why 33
overlapping ids with events-in-shared/no-events-in-env behave correctly today
despite the drift: shared already wins for those.
4. Proposed migration, in gated phases
Each phase ends at a checkpoint. Phases 3 onward need the sign-off named in the header.
Phase 0 — this document (no sign-off needed)
Inventory + plan + read-only script. Done.
Phase 1 — close the assignment gaps (reversible)
- Fix
FM-2509-6384-1756's two nonexistent ids (§2). Admin action, not code. - For the 20 env-mirror-only ids, decide per family:
sn2990-*Snap deposit variants — author incredit-products-apiwithtransaction_events, or retire the assignments if the variants are obsolete. They are currently assigned only by two FinMatch test sites and the docs merchant, so this is a low-risk authoring exercise.hu0000-048M,hu0999-012M,hu1499-024M,hu1799-036M— same decision, same three merchants.kl0000-006M,kl0000-012M,sn0000-004M,sn0000-012M-1D— same.
- Re-run
tools/rate-card-inventory.jsand requirecoveredByEnvMirrorOnly === 0andcoveredByNeither === 0onp.
Exit criterion: every assigned product id on p exists in the shared
catalogue. Nothing has been deleted at this point, and every step is
reversible by re-adding an assignment.
Phase 2 — event coverage (reversible)
Bring the 71 assigned-but-not-event-authored ids on p (171 assigned, 100
event-authored) up to transaction_events, retaining legacy top-level fields
only as legacy_projection where an older consumer still needs them.
Exit criterion: assignedWithEventsInShared === distinctAssignedIds on p,
and snap-rate-card-test.yml still passes against its £280 anchor.
Phase 3 — cut the runtime over (needs sign-off)
- Point
finmatchConfig.rateCardsUrlat the shared catalogue (or the merchant-scoped slice described in the Step 3 storefront runtime contract, if that lands first). - Remove the
p-only container and finance-app shared top-ups, which exist only because the env object is missing products. - Keep
applySharedRateCardFallbackin place for one release as a safety net, then remove it.
Validation: sandbox preview per the Step 1 pattern (t → sandbox → p), plus
a quote-parity check on a sample of live merchants — same price in, same
monthly figure out, before and after.
Phase 4 — delete the mirrors (needs sign-off, irreversible)
- Update
snap-rate-card-test.ymlto test the shared catalogue. - Delete
gs://finmatch-{p,s,t}/configs/finmatch-rate-card.json. - Remove the
finmatch-rate-cardexclusion from thesync-to-gcs.ymlconfigsrsync filter, and the corresponding row from the protected-files table insource-of-truth.md.
Only after the Phase 1 and Phase 2 exit criteria hold, re-verified on the day.
5. Explicitly out of scope
- Creating a second GCS catalogue. The backing object stays
gs://finmatch-shared/finmatch-rate-card.json(or a successor path with the same single writer). - Reintroducing env-bucket rate-card writes via git sync. The env objects
are excluded from
sync-to-gcs.ymland must stay excluded; a git working copy onfinmatch-pdoes not deploy to the live object and must not start doing so. - Any deletion in this phase. Nothing is removed until the sign-off.
6. Verification tooling
node tools/rate-card-inventory.js # human-readable report
node tools/rate-card-inventory.js --json # machine-readable
node tools/rate-card-inventory.js --envs p # single environment
Read-only by construction: it issues GETs against public objects and writes
nothing. Use it as the parity gate before each phase, and re-run it
immediately before any deletion — the numbers in §1 are a snapshot, not a
contract.
The fields it compares (apr, rate_factor, term, first_payment,
deposit_percentage, presence of transaction_events) are the ones a
storefront quote depends on, so a clean parity report is the evidence that a
cutover will not change a merchant-visible figure.
7. Go/no-go checklist
- Phase 1 exit criteria met: no assigned id on
pis env-mirror-only or missing everywhere -
FM-2509-6384-1756's two nonexistent product ids resolved (§2) - Decision recorded on each of the 20 env-mirror-only ids: author in
credit-products-api, or retire the assignment - Phase 2 exit criteria met: every assigned id event-authored in shared
-
snap-rate-card-test.ymlmigration agreed (it currently pins the env object) - Quote-parity sample agreed: which merchants, which prices, what tolerance
- Sign-off recorded for Phase 3 (runtime path change) and Phase 4 (deletion) separately
8. Proposed merchant-scoped slice (not a live contract)
Follow-up once this consolidation is signed off. Nothing below is built.
The client never needs the whole 253 KB catalogue — only the products the
merchant is assigned. Recorded here so the resolver shape can be agreed
before Phase 3; it is not returned by getMerchantConfig today.
Proposed shape, as an additive sibling of configUrls:
"runtime": {
"rateCard": {
"mode": "inline" | "url",
"schemaVersion": 1,
"productIds": ["sn0001-24", "pr0001-36"],
"contentHash": "f2ba04d3bd40",
"url": "https://…/rate-card-slices/<merchant-hash>/<content-hash>.json"
}
}
mode: "inline"for a small slice, avoiding a request entirely;mode: "url"past a size threshold, at a content-hashed immutable URL.- Written by
credit-products-apifrom the shared catalogue — not a second catalogue, and never an env-bucket rate-card file. - Keyed by merchant assignment, so a slice changes when either the catalogue
or the merchant's
assigned_rate_card_productschanges. - Open questions: who writes the slice objects (resolver on-demand versus
credit-products-apion catalogue write), how the slice interacts withapplySharedRateCardFallbackwhile both paths exist, and whether the inline threshold is bytes or product count.
Payload reduction (253 KB → X KB) is measured in that follow-up, not here.
The in-menu Storefront runtime contract §8 is a pointer only.
9. Related docs
- Rate cards overview — catalogue SSOT and the event-based target
- Source of Truth Architecture §8 — writer vs readers
- Asset delivery and egress §3 — the dual rate-card fetch paths
- Storefront runtime contract §8 — operator pointer; the proposed slice lives in §8 above