Skip to main content

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/writes customer-mapping.json, the resumability ledger (ratesAppliedAt for the rates phase).
    • rates.ts — pure: classify services[], infer plan code, build per-branch fee/FEL patches.
    • legacy-meta.ts — pure planners for backfilling legacy_db_name / legacy_subdomain / legacy_location_code on 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 — adds business.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

  1. Target Postgres reachable with platform-billing schema migrated (including 2026-07-25t00-00-00-add-legacy-rpa-fields-to-business):

    • Local: port 5435pnpm --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.
  2. DATABASE_URL set to that target (see §2.1). There is no --env=staging|production flag — the write target is entirely the connection string.

  3. gcloud authenticated with access to barto-prod (for extract only):

    gcloud auth list
    gcloud config list

    If not, gcloud auth application-default login (Application Default Credentials — the extraction script uses firebase-admin's credential.applicationDefault(), not a service-account key file).

  4. tsx — not a direct dependency of apps/backend; the binary lives in packages/backend/scripts/node_modules/.bin/tsx. All importer commands below are run from inside apps/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.

EnvironmentHow to point the CLI
LocalUse apps/backend/.env DATABASE_URL, or omit it (default above).
Stagingexport DATABASE_URL='…staging connection…' (Cloud SQL Auth Proxy, VPN, or Doppler — same path you use for other operator DB access).
ProductionSame pattern with the production connection string.

Notes:

  • .env.example documents DATABASE_URL_STAGING / DATABASE_URL_PRODUCTION as reference names — this script does not read those keys. Copy or export the value you need as DATABASE_URL.
  • Use a separate --state= directory per environment so customer-mapping.json from local never drives staging/prod (and vice versa). --input can share the same Firestore export JSON across envs; --state must not.
  • Confirm the URL before a live run (e.g. psql "$DATABASE_URL" -c 'select current_database();').
  • Prefer --dry-run on 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):

  1. Extract (once; refresh if Firestore source changed)
  2. --phase=provision (dry-run, then live)
  3. --phase=legacy-meta (required — dry-run, then live)
  4. --phase=history --scope=unmatched then (deliberate) --scope=matched
  5. --phase=verify
  6. --phase=rates (dry-run, then live; default scope unmatched)

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 business by normalized taxId; creates a shell business + 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-code let you chunk a large run or resume from a specific point (sorted by customerCode).

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 / legacyLocationCode into BusinessesService / LocationsService with as 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-meta writes 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_subdomain and location.legacy_location_code from 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 branchByLegacyLocationCode entries into the mapping file so matched accounts can resolve rates without re-deriving the map.
  • Customers with empty dbName / subdomain in 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.json to already exist (run provision first) — throws otherwise.
  • --scope defaults to unmatched. Always run unmatched first, review, then run matched as a separate deliberate stepmatched attaches 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 UUID entity_id (see §4); receipts via a notes = 'legacy-payment:<id>' marker (AR receipts have no generic entity_type/entity_id pair the way invoices do).
  • Creates one FIXX payment_method per distinct legacy bank account (canonicalized — see §5).
  • Progress: logs every 100 docs by default (--progress-every=N). Receipt progress includes zeroBalance=paid/total so you can see drawdown working mid-run. If many receipts exist but zero invoices are paid down, it warns that applyReceiptDrawdown may be broken.
  • Incomplete-run guard: live history writes history-run.json (startedAt / completedAt). If a previous run was interrupted (startedAt without completedAt), 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=verify at the end and prints a matches/mismatches summary. Use --skip-verify-sample to skip that. Limited smoke tests (--limit=N) never auto-verify (balances would look wrong for the rest).
  • Dry-run limitation: receipt counts in --dry-run are 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 have branchByLegacyLocationCode). --scope=matched prints 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 ratesAppliedAt on each mapping entry; use --force to 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.json is a bare array: [{dbName, locationCode, salesAmount, documentCount?}, ...] — the same row shape the HTTP endpoint accepts. No customer-mapping.json involved — resolution is business.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.

  • salesAmount is T_Tra_M — tax-inclusive, net of voids (Estado_Documento = 1 filters those out already). This is deliberately not the same basis as native sale.total_base_amount (pre-tax) — FIXX has always billed the legacy 1% fee on the tax-inclusive figure, and PA_Monthly_Sales_1 keeps doing that on purpose (confirmed 2026-07-28, see spec §4.6). Two consequences worth knowing, not fixing: a dual-run account's salesAmount for 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_1 for 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 the Venta=1 sum entirely, not subtracted. For POS_CTE_1_005 this 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:locationCode pair 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. PeriodCloser will not bill (and will not create an invoice for) a variable_sales account with a legacy-marked branch (location.legacy_location_code set) that has zero native sales and no row here yet for the period — it defers instead of billing zero. Check GET /platform-billing/jobs/projection for accounts showing waiting_legacy_sales; that status clears the moment a row lands, even a 0. 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 imported salesAmount against 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 fieldSourceFlowPOS destination
customers/{id}.customerCodeFirestore customerscustomer.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 (1flat, else→percentagestored 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 accountsReceivableaccounts_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: CANCELEDvoid (balance_due forced to 0), else→submitted.
payments/{id}.detail[] ({appliedToDocument, amount})Firestore paymentsaccounts_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:

  1. services[].type is a JSON number, not a string. Every real record observed is 1 (flat) or 2 (percentage) as a number. An early version of mappers.ts compared against the string "1", which silently defaulted every branch with a rate-card entry to percentage. Fixed: Number(match.type) === LEGACY_FIXED_CHARGE_TYPE (numeric compare). If you add new services[] entries by hand, either a number or a numeric string works — non-numeric junk falls through to percentage.
  2. extract-legacy-rpa-data.ts's doc spread order. ~25 of 227 customer docs carry their own stray id field 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.id must come last). If you see a customer with id: "" after extraction, the extractor is regressed, not the source data.
  3. Receipt balance-drawdown was never wired. Direct-instantiating services bypasses Nest's DiscoveryService, so OnCreateAccountsReceivableReceiptEvent's listener never actually gets subscribed to the emitter — importer.ts now calls the handler deterministically right after creating each receipt (applyReceiptDrawdown). If you ever see a migrated invoice stuck at submitted with its full original balance_due despite having a receipt, that's this bug recurring (check that applyReceiptDrawdown is still being called).
  4. customer.balance/totalCredit/totalDebit drift from the real ledger. Confirmed on a real customer: totalCredit was 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 from accountsReceivable/payments directly, same as runVerifyPhase does.
  5. Bank account name variants. Only 4 real bank accounts exist but 6 raw bankAccountDescription strings appear (e.g. "BAC - FIXX, S. A." vs "BAC - FIXX, S.A."). matching.ts#canonicalBankAccountNames groups 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 — check normalizeBankAccountKey.
  6. Duplicate taxId across legacy customers. 20 groups of legacy customer records share a taxId — each is still imported as its own distinct unit, but naturally converges onto the same FlowPOS business/customer/account (matched by taxId, not deduplicated against each other up front). This is intentional, not a bug — see the runVerifyPhase aggregation-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.
  7. Floating-point noise. Two invoices ended up with balance_due around -2.8e-14 after applying receipts (IEEE754 summation artifact, not a real fraction-of-a-cent overpayment). Rounded to exactly 0 by hand; not something runVerifyPhase's < 0.01 tolerance 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 / the notes marker 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-run provision and history — already-migrated records are skipped; only new/changed legacy ids that aren't yet in customer-mapping.json or don't yet have a matching entity_id/notes marker 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 manual UPDATE, not something the importer does for you.
  • To force one customer to be fully redone: delete its entry from customer-mapping.json, delete the resulting accounts_receivable_invoice/ _receipt rows (match by reference_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 by taxId, 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-code are 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 + --phase sequence; there is still no automated Cloud Run / CI job for this importer — operators set DATABASE_URL and 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. Unclassified services[] product names show up in the dry-run report; extend rates.ts#classifyService and re-run with --force.
  • --phase=legacy-meta cannot invent location-code matches when FlowPOS location names diverge from legacy locationName — those stay null until renamed or set by hand. Customers with no Firestore dbName / subdomain also stay null.
  • Optionally wire legacyDbName / legacySubdomain / legacyLocationCode into BusinessesService / LocationsService so new unmatched shells persist them at create time — still keep legacy-meta for matched accounts and any missed fills.
  • rpaSalesHistory/rpaCustomersSales/apps/rpa-api/src/rpa/externalApi/managerApi.ts in fixxrepo/rpa-platform look like they resolve the original platform-billing spec's blocked legacy-sales-percentage-basis importer — flagged, not investigated further, not part of this migration. Resolved: implemented as 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.