Skip to main content
Unlisted page
This page is unlisted. Search engines will not index it, and only users having a direct link can access it.

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 catalogueenv penv senv t
Objectgs://finmatch-shared/finmatch-rate-card.jsongs://finmatch-p/configs/finmatch-rate-card.jsongs://finmatch-s/…gs://finmatch-t/…
Size253,014 B52,816 B31,831 B6,311 B
Cache-Controlpublic, max-age=300public, max-age=3600public, max-age=3600no-cache, max-age=0
Last modified4 Aug 20261 Apr 202623 Jan 202619 Jun 2025
Products166877120
With transaction_events117 (70.5%)000
Writercredit-products-apinonenonenone

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 penv senv t
Product ids in both604615
Only in env27255
Only in shared106120151
Overlapping ids that differ39 / 6040 / 4615 / 15

Drift is structural, not numeric. Fields that differ on p:

FieldIds affectedWhat the difference is
transaction_events33present in shared, absent in env
first_payment39env carries {"type":"Upfront Instalment","due_days":null,"factor":null}; shared omits the field in favour of events
rate_factor11env carries the placeholder 1e-8 where shared carries the real factor (e.g. hu0000-003M: shared 0.3333333, env 1e-8)
apr0none — 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 penv senv t
Merchants in the env config1109694
With product assignments1099593
Distinct assigned product ids171168168
Assignment references1,012790769
Assigned ids covered by shared149146146
Covered by the env mirror only20204
Covered by neither2218
Assigned ids event-authored in shared100 / 17197 / 16896 / 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:

MerchantMissing from sharedPresent in env mirror?
FM-1234-5678-9101 FINMATCH TEST SITE20 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 FINMATCH17 ids (same families)yes
M281700 Docs Merchantsn2990-024M-0D, sn2990-036M, sn2990-048Myes
FM-2509-6384-1756 GREEN SQUIRREL ENERGY LTDhu1597-048M, hu1597-060Mno — 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​

ReaderObjectNotes
SDK primaryenv configs/finmatch-rate-card.json via finmatchConfig.rateCardsUrlEvery page load
SDK top-upshared root, via applySharedRateCardFallbackWhen assigned ids are missing from env, or when env products lack events while shared has them
finance-container.jsshared root, Propensio and Snap top-upsp only
finmatch-finance-app.jsshared root, then env p as a second candidatep only
partner-apishared catalogue via credit-products-api / GCS rootAlready consolidated
snap-rate-card-test.ymlenv configs/finmatch-rate-card.jsonIts 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)​

  1. Fix FM-2509-6384-1756's two nonexistent ids (§2). Admin action, not code.
  2. For the 20 env-mirror-only ids, decide per family:
    • sn2990-* Snap deposit variants — author in credit-products-api with transaction_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.
  3. Re-run tools/rate-card-inventory.js and require coveredByEnvMirrorOnly === 0 and coveredByNeither === 0 on p.

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)​

  1. Point finmatchConfig.rateCardsUrl at the shared catalogue (or the merchant-scoped slice described in the Step 3 storefront runtime contract, if that lands first).
  2. Remove the p-only container and finance-app shared top-ups, which exist only because the env object is missing products.
  3. Keep applySharedRateCardFallback in 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)​

  1. Update snap-rate-card-test.yml to test the shared catalogue.
  2. Delete gs://finmatch-{p,s,t}/configs/finmatch-rate-card.json.
  3. Remove the finmatch-rate-card exclusion from the sync-to-gcs.yml configs rsync filter, and the corresponding row from the protected-files table in source-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.yml and must stay excluded; a git working copy on finmatch-p does 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 p is 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.yml migration 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-api from 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_products changes.
  • Open questions: who writes the slice objects (resolver on-demand versus credit-products-api on catalogue write), how the slice interacts with applySharedRateCardFallback while 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.