# Test your SaaS replacement before cancelling the original

> Run a homemade SaaS replacement beside the original with read-only inputs, compare real outputs, and rehearse recovery before switching over.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-10-31 | Topics: [Business](https://flaviocopes.com/tags/business/) | Canonical: https://flaviocopes.com/parallel-saas-replacement/

Keep the existing product working while you test its replacement.

That overlap gives you a way to discover missing behavior without turning every defect into an urgent migration problem. In [build versus buy](https://flaviocopes.com/build-or-buy/), I discussed what a homemade replacement leaves you responsible for. Here we'll compare the outputs before transferring that responsibility.

Our example is a reporting tool that summarizes invoices. The old service remains where invoices are entered. The replacement reads copies and generates the same report. It cannot charge anyone or send messages.

## Give the trial a finish line

Write down which work the replacement needs to support. Include the awkward cases from your own workflow.

For an invoice report, I would include an ordinary invoice, a refund, a foreign-currency invoice, and a corrected record. Add an empty reporting period and enough records to exercise pagination.

Decide what has to match. Invoice identifiers, totals, currencies, and meaningful statuses usually do. A report-generation timestamp can differ without changing the report's meaning.

Give the trial a review date. A few weeks can cover ordinary work if that includes a representative reporting cycle. If you bill quarterly, two quiet weeks won't exercise the job you're replacing. Pick events to observe rather than treating elapsed time as proof.

## Keep one system responsible for writes

During the comparison, enter and correct invoices in the old system only. Take exports or use a read-only API credential to feed the replacement.

Disable the replacement's schedules for sending email, charging cards, or changing other systems. Prefer an environment with no credentials for those actions. A “dry run” label means little if an overlooked code path can still send a request.

Give the copies a snapshot identifier and time range. Both reports must describe the same source data. Comparing yesterday's export with today's live account creates differences the replacement didn't cause.

Keep each run's inputs and outputs in its own folder. You should be able to repeat a comparison after correcting a defect.

## Compare the meaning of the output

Let's build a tiny comparison script using Node's standard library. It checks selected invoice fields and reports missing or changed records. It normalizes integer cents and equivalent timestamp representations, while preserving meaningful differences.

Create `old.json`:

```json
[
  {
    "id": "invoice_41",
    "totalCents": "1250",
    "currency": "EUR",
    "status": "paid",
    "issuedAt": "2026-10-15T10:00:00+02:00"
  }
]
```

Save the replacement's equivalent output in `new.json`:

```json
[
  {
    "id": "invoice_41",
    "totalCents": 1250,
    "currency": "EUR",
    "status": "paid",
    "issuedAt": "2026-10-15T08:00:00Z"
  }
]
```

Our comparison ignores row order and extra fields. Adapt its selected fields to the report you actually need. Save the complete script as `compare.mjs`:

```javascript
import { readFile } from 'node:fs/promises'

function normalize(rows) {
  if (!Array.isArray(rows)) throw new Error('Expected an array')
  const result = new Map()

  for (const row of rows) {
    if (!row || typeof row.id !== 'string' || !row.id) {
      throw new Error('Every invoice needs an ID')
    }
    if (result.has(row.id)) throw new Error(`Duplicate ID: ${row.id}`)

    const amount = row.totalCents
    const validAmount = typeof amount === 'number' ||
      (typeof amount === 'string' && /^-?\d+$/.test(amount))
    const totalCents = validAmount ? Number(amount) : NaN
    if (!Number.isSafeInteger(totalCents)) {
      throw new Error(`Invalid cents for ${row.id}`)
    }

    for (const field of ['currency', 'status']) {
      if (typeof row[field] !== 'string' || !row[field]) {
        throw new Error(`Missing ${field} for ${row.id}`)
      }
    }

    const date = typeof row.issuedAt === 'string'
      ? Date.parse(row.issuedAt)
      : NaN
    if (!Number.isFinite(date)) {
      throw new Error(`Invalid date for ${row.id}`)
    }

    result.set(row.id, {
      totalCents,
      currency: row.currency,
      status: row.status,
      issuedAt: new Date(date).toISOString()
    })
  }

  return result
}

const [oldPath, newPath] = process.argv.slice(2)
if (!oldPath || !newPath) {
  throw new Error('Run: node compare.mjs old.json new.json')
}

const oldRows = normalize(JSON.parse(await readFile(oldPath, 'utf8')))
const newRows = normalize(JSON.parse(await readFile(newPath, 'utf8')))
const ids = [...new Set([...oldRows.keys(), ...newRows.keys()])].sort()
const differences = []

for (const id of ids) {
  const oldValue = oldRows.get(id) ?? null
  const newValue = newRows.get(id) ?? null
  if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
    differences.push({ id, old: oldValue, new: newValue })
  }
}

console.log(JSON.stringify({ differences }, null, 2))
process.exitCode = differences.length ? 1 : 0
```

Run it:

```bash
node compare.mjs old.json new.json
```

The result should contain an empty `differences` array. Both timestamps describe the same instant, and both amounts represent 1250 cents.

Change the replacement's amount to `1300` and run it again. You should see both values for `invoice_41`, with a failing exit status. Remove that row entirely and the comparison should report `new: null`.

This script assumes the source exports use valid, timezone-qualified timestamps. Validate the vendor's format before adapting it; [`Date.parse()` accepts inputs you may not want](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse). Keep accounting-specific rules in your adapter, including how refunds and credits appear. Don't convert decimal currency values to cents through an unexplained rounding rule.

## Investigate differences before changing the comparison

A mismatch is a question to answer.

Perhaps the new report omitted a refund. Perhaps the old report groups it into another period. You need to understand which behavior you want before deciding which system is correct.

Record the cause beside the comparison. If a difference is acceptable, describe the rule that makes it acceptable and test that rule with a concrete case. Avoid adding a broad filter just to make the report green.

Also record manual work. A matching output that requires repairing the import every morning isn't equivalent to an unattended report. Count failed runs and the time needed to investigate them, using measurements from the trial.

Try an unavailable source API too. The replacement should identify a failed refresh and preserve the last successful report with its original timestamp. Silently presenting yesterday's figures as current would make a matching historical comparison look much better than the live experience. Include the recovery run after access returns.

## Rehearse the switch with a copy

Export the old service's data and restore it into a fresh replacement instance. Open the resulting report and a few individual records. Check attachments and historical periods too.

Keep a separate backup of the export. A successful download doesn't establish that you can restore it.

Before switching, identify which data could be created afterward and how it would get back to the old system if you reverse the decision. For a read-only reporting replacement, this may be easy because the source invoices remain unchanged. Replacing the invoicing system itself would require a different migration plan.

Choose one moment to stop the old report's schedule and enable the new one. Verify their actual scheduler states. Keeping both accounts available for recovery doesn't require running both outward-facing automations.

## Cancel after the comparison answers your questions

At the review date, examine the accepted cases, unresolved differences, and operating work. Decide whether to switch, extend the trial for a specific missing case, or keep the service.

Before cancelling, verify the provider's export and access terms. Preserve the records and documentation you need while you can still retrieve them. Cancellation may stop future renewal without immediately closing the account; check the actual result in the billing interface.

If you keep the subscription, the experiment can still be useful. You learned which work the vendor was doing and what your replacement would need. Save the fixtures and comparison notes so another attempt can start from that evidence.
