Legacy RPA-ERP Customer Migration Runbook
Operational runbook for importing FIXX's legacy billing data — the
"RPA-ERP" app (github.com/fixxrepo/rpa-platform, Firestore project
barto-prod) — into the AR-backed platform-billing schema. Covers how to
run every phase, how to target local / staging / production, how to
fix/re-import data safely, the exact legacy → FlowPOS field mapping, and
every data-quality bug already found and fixed so you don't re-discover
them.
This is a one-time, operator-run CLI (not a Cloud Run job and not a live
sync). You run it from a laptop or jump host with network access to the
target Postgres. Firestore extract is always read-only against barto-prod;
the write target is whichever database DATABASE_URL points at.
Scope
Code covered:
packages/backend/scripts/src/extract-legacy-rpa-data.ts— read-only Firestore extraction (separate package, no@/NestJS aliases needed).apps/backend/src/scripts/migrate-legacy-rpa-customers/— the importer:types.ts— legacy Firestore doc shapes + target payload shapes.matching.ts— pure: taxId normalization/matching, bank-account name canonicalization, deterministic legacy-id → UUID mapping.mappers.ts— pure: legacy customer/charge → business/location/invoice payloads.allocation.ts— pure: payment → receipt allocation lines (the highest-risk logic, since it's real money).context.ts— direct-instantiates the real NestJS services (bypasses Nest's TestingModule/bootstrap, which hangs on Firebase/Bull/Redis transitive deps in this codebase).importer.ts— orchestrates the five phases against the real DB (provision,legacy-meta,history,verify,rates).state.ts— reads/writescustomer-mapping.json, the resumability ledger (ratesAppliedAtfor the rates phase).rates.ts— pure: classifyservices[], infer plan code, build per-branch fee/FEL patches.legacy-meta.ts— pure planners for backfillinglegacy_db_name/legacy_subdomain/legacy_location_codeon already-mapped accounts.index.ts— CLI entrypoint.__tests__/*.spec.ts— permanent Jest unit tests for the pure modules (allocation, matching, mappers, rates, legacy-meta).
- Migration
packages/backend/database/src/migrations/2026-07-25t00-00-00-add-legacy-rpa-fields-to-business.mjs— addsbusiness.legacy_db_name,business.legacy_subdomain,location.legacy_location_code.
1) How the pieces fit together
barto-prod Firestore (customers / accountsReceivable / payments)
│ read-only, gcloud ADC
▼
extract-legacy-rpa-data.ts → <scratch>/customers.json
<scratch>/accountsReceivable.json
<scratch>/payments.json
│
▼
migrate-legacy-rpa-customers (phase=provision)
→ business (shell) → OnCreateBusinessEvent → customer + platform_account
→ location (shell) → OnCreateLocationEvent → platform_account_branch
writes <scratch>/customer-mapping.json (legacy id → FlowPOS ids)
│
▼
migrate-legacy-rpa-customers (phase=legacy-meta)
→ backfills null business.legacy_db_name / legacy_subdomain
→ backfills null location.legacy_location_code (name match)
→ enriches customer-mapping.json branchByLegacyLocationCode for matched
│
▼
migrate-legacy-rpa-customers (phase=history, --scope=unmatched then --scope=matched)
→ accounts_receivable_invoice (one per legacy charge)
→ accounts_receivable_receipt (one per legacy payment, allocated across invoices)
│
▼
migrate-legacy-rpa-customers (phase=verify)
→ recomputes each customer's balance from the raw legacy ledger and
diffs it against the migrated AR balanceDue sum
│
▼
migrate-legacy-rpa-customers (phase=rates)
→ assigns planId from services[] (monthly_fixed / variable_sales)
→ per-branch rate_override: subscription fee + FEL flat_monthly
→ writes ratesAppliedAt on customer-mapping.json (idempotent unless --force)
Everything reuses existing application services
(BusinessesService, LocationsService, AccountsReceivableInvoicesService,
AccountsReceivableReceiptsService, PaymentMethodsService) and the
existing auto-provisioning listeners
(PlatformAccountProvisioningService, PlatformAccountBranchProvisioningService)
built earlier in the platform-billing work. No bespoke tables — see the
field mapping in §4.
2) Prerequisites
-
Target Postgres reachable with platform-billing schema migrated (including
2026-07-25t00-00-00-add-legacy-rpa-fields-to-business):- Local: port 5435 —
pnpm --filter @flowpos-workspace/backend-database run migration:local:push - Staging / production: apply migrations via the usual deploy / migration pipeline for that environment before running the importer. The CLI does not run migrations itself.
- Local: port 5435 —
-
DATABASE_URLset to that target (see §2.1). There is no--env=staging|productionflag — the write target is entirely the connection string. -
gcloud authenticated with access to
barto-prod(for extract only):gcloud auth list
gcloud config listIf not,
gcloud auth application-default login(Application Default Credentials — the extraction script usesfirebase-admin'scredential.applicationDefault(), not a service-account key file). -
tsx— not a direct dependency ofapps/backend; the binary lives inpackages/backend/scripts/node_modules/.bin/tsx. All importer commands below are run from insideapps/backend/(required so the@/path alias resolves — the importer imports real backend services directly).
2.1 Targeting local / staging / production
Run the importer from your local machine (or any host that can reach
Cloud SQL / the DB). The importer reads only process.env.DATABASE_URL
(context.ts); if unset it defaults to local
postgresql://flowpos:flowpos@localhost:5435/flowpos_dev.
| Environment | How to point the CLI |
|---|---|
| Local | Use apps/backend/.env DATABASE_URL, or omit it (default above). |
| Staging | export DATABASE_URL='…staging connection…' (Cloud SQL Auth Proxy, VPN, or Doppler — same path you use for other operator DB access). |
| Production | Same pattern with the production connection string. |
Notes:
.env.exampledocumentsDATABASE_URL_STAGING/DATABASE_URL_PRODUCTIONas reference names — this script does not read those keys. Copy or export the value you need asDATABASE_URL.- Use a separate
--state=directory per environment socustomer-mapping.jsonfrom local never drives staging/prod (and vice versa).--inputcan share the same Firestore export JSON across envs;--statemust not. - Confirm the URL before a live run (e.g.
psql "$DATABASE_URL" -c 'select current_database();'). - Prefer
--dry-runon every phase the first time you hit a new env.
Example (staging):
cd apps/backend
export DATABASE_URL='postgresql://…staging…' # your real staging URL
TSX=../../packages/backend/scripts/node_modules/.bin/tsx
"$TSX" src/scripts/migrate-legacy-rpa-customers/index.ts \
--phase=legacy-meta \
--input=/path/to/legacy-rpa-export \
--state=/path/to/legacy-rpa-state-staging \
--dry-run
3) Running it
Recommended order (every environment):
- Extract (once; refresh if Firestore source changed)
--phase=provision(dry-run, then live)--phase=legacy-meta(required — dry-run, then live)--phase=history --scope=unmatchedthen (deliberate)--scope=matched--phase=verify--phase=rates(dry-run, then live; default scopeunmatched)
3.1 Extract (read-only, from packages/backend/scripts/)
cd packages/backend/scripts
npx tsx src/extract-legacy-rpa-data.ts \
[--out=<dir>] \
[--business-id=<legacy FIXX businessID>]
Defaults: --business-id=hx8vMvRZETPZUXnFEv7n (FIXX's legacy id in
barto-prod), --out= the Claude Code session scratchpad (a path outside
the repo — the export contains real customer PII and financial data,
never commit it). Writes customers.json, accountsReceivable.json,
payments.json.
Safe to re-run any time to pick up data-quality fixes made in Firestore — it's a plain read, always overwrites the 3 output files fresh.
3.2 Provision (business/location/customer/account/branch shells)
cd apps/backend
/path/to/packages/backend/scripts/node_modules/.bin/tsx \
src/scripts/migrate-legacy-rpa-customers/index.ts \
--phase=provision \
--input=<export dir> \
[--state=<dir, defaults to --input>] \
[--limit=N] \
[--after-customer-code=<code>] \
[--dry-run]
- Matches each legacy customer to an existing FlowPOS
businessby normalizedtaxId; creates a shellbusiness+location(s) only when unmatched. - Idempotent per legacy customer id via
customer-mapping.json— a customer already present there is skipped (alreadyMapped), so re-running the full set after a partial run or a crash is always safe. --limit/--after-customer-codelet you chunk a large run or resume from a specific point (sorted bycustomerCode).
3.3 Legacy meta (backfill db name / subdomain / location codes)
Required after every provision (local, staging, and production). Do not skip this phase.
/path/to/tsx src/scripts/migrate-legacy-rpa-customers/index.ts \
--phase=legacy-meta \
--input=<export dir> \
[--state=<per-env state dir>] \
[--scope=all|matched|unmatched] \
[--dry-run]
Why it is required:
- Provision passes
legacyDbName/legacySubdomain/legacyLocationCodeintoBusinessesService/LocationsServicewithas never, but those services do not currently persist those columns — so new shells can land with nulls even after the schema migration exists. - Matched (pre-existing) businesses never get location codes from provision; only DB/subdomain may be filled via a direct SQL update.
legacy-metawrites with direct Kysely updates, fills only null values, and is safe to re-run.
Behavior:
- Requires
customer-mapping.json(run provision first). Default--scope=all. - Fills null
business.legacy_db_name/legacy_subdomainandlocation.legacy_location_codefrom the Firestore export. Never overwrites non-null values. - Location codes are matched by normalized location name (same rules as rates' name fallback). Unmatched names are logged; they need a manual rename or hand-set code.
- Also writes missing
branchByLegacyLocationCodeentries into the mapping file so matched accounts can resolve rates without re-deriving the map. - Customers with empty
dbName/subdomainin Firestore stay null (nothing to backfill).
3.4 History (charges → invoices, payments → receipts)
/path/to/tsx src/scripts/migrate-legacy-rpa-customers/index.ts \
--phase=history \
--scope=unmatched|matched|all \
--input=<export dir> \
[--limit=N] [--progress-every=N] [--force] [--skip-verify-sample] [--dry-run]
- Requires
customer-mapping.jsonto already exist (run provision first) — throws otherwise. --scopedefaults tounmatched. Always rununmatchedfirst, review, then runmatchedas a separate deliberate step —matchedattaches real historical balances to businesses that already existed before this migration (higher blast radius; the CLI prints a warning when you do this without--dry-run).- Idempotent per legacy doc id: invoices via
entity_type = 'platform_billing_legacy_import'+ a deterministic UUIDentity_id(see §4); receipts via anotes = 'legacy-payment:<id>'marker (AR receipts have no generic entity_type/entity_id pair the way invoices do). - Creates one FIXX
payment_methodper distinct legacy bank account (canonicalized — see §5). - Progress: logs every 100 docs by default (
--progress-every=N). Receipt progress includeszeroBalance=paid/totalso you can see drawdown working mid-run. If many receipts exist but zero invoices are paid down, it warns thatapplyReceiptDrawdownmay be broken. - Incomplete-run guard: live history writes
history-run.json(startedAt/completedAt). If a previous run was interrupted (startedAtwithoutcompletedAt), the next live run refuses to start unless you pass--force(after wiping partial AR — see below). - Auto-verify: a full live run (no
--limit) runs--phase=verifyat the end and prints a matches/mismatches summary. Use--skip-verify-sampleto skip that. Limited smoke tests (--limit=N) never auto-verify (balances would look wrong for the rest). - Dry-run limitation: receipt counts in
--dry-runare estimated from the legacy allocation data alone (no real invoice ids exist yet to resolve against) — trust the dry-run's invoice count and payment-method count; receipt count is illustrative, not exact, in dry-run mode.
Wipe partial / bad history AR (staging)
If verify shows widespread mismatches or a run was interrupted, wipe
only legacy-import AR rows (keeps businesses, plans, rates), then
re-run history with --force:
BEGIN;
DELETE FROM accounts_receivable_receipt
WHERE notes LIKE 'legacy-payment:%';
DELETE FROM accounts_receivable_invoice
WHERE entity_type = 'platform_billing_legacy_import';
COMMIT;
# optional smoke test first
"$TSX" …/index.ts --phase=history --scope=unmatched --limit=100 --force \
--input="$EXPORT" --state="$STATE"
# full re-import (do not interrupt)
"$TSX" …/index.ts --phase=history --scope=unmatched --force \
--input="$EXPORT" --state="$STATE"
3.5 Verify (balance reconciliation)
/path/to/tsx src/scripts/migrate-legacy-rpa-customers/index.ts \
--phase=verify \
--input=<export dir>
Recomputes each migrated FlowPOS customer's balance directly from the raw
legacy ledger (non-canceled charges minus applied, non-canceled-charge
payment allocations) and diffs it against the sum of migrated
accounts_receivable_invoice.balance_due. Does not compare against
customer.totalCredit - customer.totalDebit — that field drifts from the
real ledger in the legacy data (see §5). Multiple legacy customers sharing
a taxId (and therefore merged onto one FlowPOS account) are aggregated
together before comparing, not checked individually.
Last real run: 223/223 accounts matched exactly, zero mismatches.
3.6 Rates (plan + branch/FEL amounts from services[])
/path/to/tsx src/scripts/migrate-legacy-rpa-customers/index.ts \
--phase=rates \
--scope=unmatched|matched|all \
--input=<export dir> \
[--force] [--limit=N] [--dry-run]
- Requires
customer-mapping.json(run provision first). - Default
--scope=unmatched— only shell businesses created by this migration (they havebranchByLegacyLocationCode).--scope=matchedprints a warning (can overwrite plan/overrides on pre-existing businesses). - Dry-run prints a classification summary (branch_fee / fel / sales_percentage / unknown counts) plus unknown-service samples and the planned plan + per-branch fee/FEL amounts — review unknowns before a live run.
- Idempotent via
ratesAppliedAton each mapping entry; use--forceto re-apply. - Does not change account status. Does not rewrite historical AR.
- Plan inference: any percentage service →
variable_sales; else billable flat fee/FEL →monthly_fixed; else leave current plan (usually Free Tier). Annual prepaid is not inferred — assign manually if needed. - FEL overrides are always branch-scoped (only locations that had a FEL service get a flat FEL entry).
3.7 Sales totals (monthly base for variable_sales percentage billing)
Closes the gap left by rates: a variable_sales branch bills
% × period sales, but percentage billing normally reads native
sale/order_bill — a client still running the legacy POS has none, so
without this phase they'd never be billed. See
platform-billing-architecture.md §4.6
for the full design, and
Legacy Sales Totals for the
Jobs-page APIs, related tables, and ops curl examples.
Production path — SQL Server job, not this CLI. A SQL Server Agent job
(PA_Monthly_Sales_1 per client POS_CTE* database, aggregated by
UploadMonthlySales) POSTs monthly branch totals directly to
POST /platform-billing/legacy-sales-totals (auth: Authorization: Bearer <LEGACY_SALES_INGEST_SECRET>, optionally LEGACY_SALES_INGEST_IP_ALLOWLIST).
This CLI phase exists for bootstrap, backfill, and reconcile — it is not
part of the recurring monthly flow.
/path/to/tsx src/scripts/migrate-legacy-rpa-customers/index.ts \
--phase=sales-totals \
--input=<dir with sales-totals.json> \
[--billing-period=YYYY-MM] (defaults to previous calendar month, America/Guatemala) \
[--dry-run]
-
sales-totals.jsonis a bare array:[{dbName, locationCode, salesAmount, documentCount?}, ...]— the same row shape the HTTP endpoint accepts. Nocustomer-mapping.jsoninvolved — resolution isbusiness.legacy_db_name+location.legacy_location_code, both plain columns already on the live schema (backfilled by--phase=legacy-meta), not anything tracked in the mapping file. -
salesAmountisT_Tra_M— tax-inclusive, net of voids (Estado_Documento = 1filters those out already). This is deliberately not the same basis as nativesale.total_base_amount(pre-tax) — FIXX has always billed the legacy 1% fee on the tax-inclusive figure, andPA_Monthly_Sales_1keeps doing that on purpose (confirmed 2026-07-28, see spec §4.6). Two consequences worth knowing, not fixing: a dual-run account'ssalesAmountfor the period blends a pre-tax native figure with a tax-inclusive legacy one, and any client's fee base drops ~12% (the IVA rate) the moment their last branch finishes migrating to native — not a bug, an accepted transition effect. -
Returns/credit-notes are not netted by
PA_Monthly_Sales_1for every client — verify per client, don't assume. The confirmed return-shaped document types (Devolución de Producto,Acta de Devolución,Nota de Credito Interno/Fiscal,Ingreso/Egreso por Cambio de Producto) are excluded from theVenta=1sum entirely, not subtracted. ForPOS_CTE_1_005this was empirically zero-volume over 12 months, so it didn't matter there — check volume on each new client before assuming the same:SELECT T2.Descripcion, T1.Tipo_Documento, COUNT(*) AS doc_count, SUM(T1.T_Tra_M) AS total
FROM tbl_Documento T1
JOIN tbl_Tipo_Documento T2 ON T1.Tipo_Documento = T2.Tipo_Documento
WHERE T1.Estado_Documento = 1
AND T2.Descripcion IN ('Devolución de Producto', 'Acta de Devolución', 'Nota de Credito Interno',
'Ingreso por Devolucion', 'Ingreso por Cambio de Producto',
'Egreso por Cambio de Producto', 'Nota de Credito Fiscal')
AND T1.Fecha_Hora >= DATEADD(month, -12, GETDATE())
GROUP BY T2.Descripcion, T1.Tipo_Documento
ORDER BY doc_count DESC; -
Dry-run prints every row that would upsert, every unmatched
dbName:locationCodepair with a reason (unmatched_branch|invalid_amount), and duplicate-key count. Always dry-run before a live import. -
Idempotent — upserts on
(platform_account_branch_id, billing_period); re-running the same file is safe. -
The close job gates on this data.
PeriodCloserwill not bill (and will not create an invoice for) avariable_salesaccount with a legacy-marked branch (location.legacy_location_codeset) that has zero native sales and no row here yet for the period — it defers instead of billing zero. CheckGET /platform-billing/jobs/projectionfor accounts showingwaiting_legacy_sales; that status clears the moment a row lands, even a0. A branch that fully cuts over to FlowPOS POS clears the gate on its own once native sales resume — no manual flag to unset. A branch that stops trading permanently should be deactivated (platform_account_branch.is_active = false) rather than left waiting forever. -
Reconcile before trusting a new source. Pick 3–5
POS_CTE*databases and compare the importedsalesAmountagainst what that client was actually invoiced under the legacy system for the same month — this catches branch-resolution mistakes, rounding drift, and any client whose return volume turned out not to be negligible. A ~12% gap is expected now (tax-inclusive legacy vs. pre-tax native, see above) — don't mistake that for a bug; look for anything beyond it. -
Native and legacy sales simply sum — the feed must report only what the legacy POS recorded, never anything already rung up in FlowPOS. A branch reporting nonzero on both sides logs a warning (check for double counting) but does not block the close. Remember the sum mixes bases during a dual-run month (see above) — that blended figure is expected, not a defect to chase.
4) Field mapping (legacy → FlowPOS)
| Legacy field | Source | FlowPOS destination |
|---|---|---|
customers/{id}.customerCode | Firestore customers | customer.customer_code |
customers/{id}.taxId | " | business.tax_id (match key) |
customers/{id}.taxName | " | business.legal_name |
customers/{id}.name | " | business.name |
customers/{id}.dbName | " | business.legacy_db_name (new column) |
customers/{id}.subdomain | " | business.legacy_subdomain (new column) |
customers/{id}.locations[].locationName | " | location.name |
customers/{id}.locations[].locationCode | " | location.legacy_location_code (new column), and used to resolve services[] per branch |
customers/{id}.services[].type | " | platform_account_branch.billing_mode (1→flat, else→percentage — stored as a JSON number, see §5) |
customers/{id}.services[].amount + product name/description | " | Rates phase: classifies as branch fee / FEL / unknown; writes platform_account_branch.rate_override (fee + optional fel_invoice flat_monthly). Plan code inferred → platform_account.plan_id (monthly_fixed or variable_sales). |
accountsReceivable/{id} (isCredit: true) | Firestore accountsReceivable | accounts_receivable_invoice — one per charge. entity_type='platform_billing_legacy_import', entity_id = deterministic UUID of the legacy doc id (matching.ts#legacyIdToUuid), reference_number = the raw legacy doc id (for human lookup — entity_id itself isn't a real UUID from the source, it's derived). status: CANCELED→void (balance_due forced to 0), else→submitted. |
payments/{id}.detail[] ({appliedToDocument, amount}) | Firestore payments | accounts_receivable_receipt.detail.items[] — one allocation line per entry, directly (no need to cross-reference the ledger's own debit rows — detail[] already is them). notes = 'legacy-payment:<id>'. |
payments/{id}.bankAccountDescription | " | payment_method.name (one per canonicalized bank account, see §5) |
Not migrated (no schema home, purely informational, or explicitly out of
scope): customer.balance/totalCredit/totalDebit (dead/drifted rollup,
see §5), felDocument sub-objects, payments with empty detail[]
("pending invoicing" — no invoice to attach a receipt to).
5) Data-quality findings already fixed (read this before touching data)
If you're going into Firestore to fix source data, know what's already handled so you don't chase a phantom:
services[].typeis a JSON number, not a string. Every real record observed is1(flat) or2(percentage) as a number. An early version ofmappers.tscompared against the string"1", which silently defaulted every branch with a rate-card entry topercentage. Fixed:Number(match.type) === LEGACY_FIXED_CHARGE_TYPE(numeric compare). If you add newservices[]entries by hand, either a number or a numeric string works — non-numeric junk falls through topercentage.extract-legacy-rpa-data.ts's doc spread order. ~25 of 227 customer docs carry their own strayidfield in their Firestore data (usually empty), which used to silently overwrite the real doc id ({id: doc.id, ...doc.data()}→{...doc.data(), id: doc.id}fixed this —id: doc.idmust come last). If you see a customer withid: ""after extraction, the extractor is regressed, not the source data.- Receipt balance-drawdown was never wired. Direct-instantiating
services bypasses Nest's
DiscoveryService, soOnCreateAccountsReceivableReceiptEvent's listener never actually gets subscribed to the emitter —importer.tsnow calls the handler deterministically right after creating each receipt (applyReceiptDrawdown). If you ever see a migrated invoice stuck atsubmittedwith its full originalbalance_duedespite having a receipt, that's this bug recurring (check thatapplyReceiptDrawdownis still being called). customer.balance/totalCredit/totalDebitdrift from the real ledger. Confirmed on a real customer:totalCreditwas short by 685 units versus the actual sum of that customer's non-canceled charge documents — a Firestore increment-trigger bug in the legacy app itself, not a migration bug. Don't use these fields as ground truth for anything — always recompute fromaccountsReceivable/paymentsdirectly, same asrunVerifyPhasedoes.- Bank account name variants. Only 4 real bank accounts exist but 6
raw
bankAccountDescriptionstrings appear (e.g."BAC - FIXX, S. A."vs"BAC - FIXX, S.A.").matching.ts#canonicalBankAccountNamesgroups by a purely mechanical key (strip non-alphanumeric, uppercase) and picks the most-frequent raw variant as the display name — never a semantic guess. If a genuinely new bank account's description happens to collapse onto an existing one (or vice versa, a genuine duplicate doesn't collapse), that's this normalization's known boundary — checknormalizeBankAccountKey. - Duplicate
taxIdacross legacy customers. 20 groups of legacy customer records share ataxId— each is still imported as its own distinct unit, but naturally converges onto the same FlowPOS business/customer/account (matched bytaxId, not deduplicated against each other up front). This is intentional, not a bug — see therunVerifyPhaseaggregation-by-resolved-customerId logic in §3.4. If a balance mismatch shows up for two customer codes that turn out to share an account, that's this, not a real discrepancy. - Floating-point noise. Two invoices ended up with
balance_duearound-2.8e-14after applying receipts (IEEE754 summation artifact, not a real fraction-of-a-cent overpayment). Rounded to exactly0by hand; not somethingrunVerifyPhase's< 0.01tolerance would even flag, but worth knowing if you inspect raw rows directly.
6) Fixing/re-importing data safely
- Re-running any phase is safe — every phase checks
customer-mapping.json/entity_id/ thenotesmarker before writing, so nothing duplicates on a re-run with the same export. - To pick up a source-data fix: re-run
extract-legacy-rpa-data.ts(overwrites the 3 JSON files), then re-runprovisionandhistory— already-migrated records are skipped; only new/changed legacy ids that aren't yet incustomer-mapping.jsonor don't yet have a matchingentity_id/notesmarker get processed. Existing migrated rows are never updated in place by a re-run — if you need to correct an already-migrated invoice/receipt, that's a manualUPDATE, not something the importer does for you. - To force one customer to be fully redone: delete its entry from
customer-mapping.json, delete the resultingaccounts_receivable_invoice/_receiptrows (match byreference_number/notes), and re-run provision + history — the shell business/location this creates will be a new row, not an update to the old one, since matching is bytaxId, not by legacy id. Clean up the old shell business/location/customer/ account/branch first if you don't want an orphan. --limit/--after-customer-codeare your friends for testing a fix against one or two customers before re-running the full set.- Always dry-run first, especially for
--phase=history --scope=matched— it touches businesses that existed before this migration.
7) Useful verification queries
-- Migrated business/location counts
select count(*) from business where legacy_db_name is not null;
select count(*) from location where legacy_location_code is not null;
-- Invoice/receipt counts for this migration specifically
select status, count(*) from accounts_receivable_invoice
where entity_type = 'platform_billing_legacy_import' group by status;
select count(*) from accounts_receivable_receipt where notes like 'legacy-payment:%';
-- Branch billing_mode distribution (sanity check against the real
-- 154 flat : 30 percentage services[] ratio)
select billing_mode, count(*) from platform_account_branch group by billing_mode;
-- Find a specific legacy customer's resulting FlowPOS ids
-- (read customer-mapping.json directly, or:)
select b.id, b.name, b.tax_id, b.legacy_db_name, b.legacy_subdomain
from business b where b.tax_id = '<legacy taxId>';
8) What's still open
- Staging / production runs use the same CLI +
--phasesequence; there is still no automated Cloud Run / CI job for this importer — operators setDATABASE_URLand run from a trusted machine (see §2.1). - No incremental re-sync path — if RPA-ERP is still being used day-to-day, charges/payments created after an export was taken won't be reflected until you re-extract and re-run.
- Rates phase does not infer
annual_prepaid— assign that plan manually after rates when needed. Unclassifiedservices[]product names show up in the dry-run report; extendrates.ts#classifyServiceand re-run with--force. --phase=legacy-metacannot invent location-code matches when FlowPOS location names diverge from legacylocationName— those stay null until renamed or set by hand. Customers with no FirestoredbName/subdomainalso stay null.- Optionally wire
legacyDbName/legacySubdomain/legacyLocationCodeintoBusinessesService/LocationsServiceso new unmatched shells persist them at create time — still keeplegacy-metafor matched accounts and any missed fills. Resolved: implemented asrpaSalesHistory/rpaCustomersSales/apps/rpa-api/src/rpa/externalApi/managerApi.tsinfixxrepo/rpa-platformlook like they resolve the original platform-billing spec's blocked legacy-sales-percentage-basis importer — flagged, not investigated further, not part of this migration.platform_legacy_sales_total— see §3.7. The legacy mainframe endpoint those files call was not reused; the production feed is a SQL Server job pushing directly to FlowPOS instead of a Firestore-cached pull. Dual-run months (client billed on both systems during cutover) still need a repeat extract/import each month until fully migrated to FlowPOS POS — not automated by this work.