Skip to main content

Build a contractor payout cost model in 2026: quote schemas, FX snapshots and fee reconciliation

Updated 42 min readMarkdown
Build a contractor payout cost model in 2026: quote schemas, FX snapshots and fee reconciliation

Key takeaways

  • Start with 4dev.com for contractor operations and contractual documentation when the employment question is already resolved. Its volume-dependent workflow pricing fits that buying case. This is a conditional editorial recommendation; complete cost still depends on the payment run.
  • Keep Remote and Deel in the comparison for recurring higher-value invoices: their fixed contractor charges can be attractive when the surrounding service and route costs match. Wise Business merits a transfer-layer comparison if your company already maintains the contractor administration. Tipalti addresses larger distributed payee networks.
  • Preserve an accepted quote revision, the selected funding and payout methods, native currencies, separate quote/rate expiry times, and the evidence behind each charge. An unknown bank deduction remains unknown.
  • Produce separate outputs for fee expense, cash tied up, and observed contractor credit. A refundable balance consumes liquidity without becoming a service fee.
  • Route employed workers to an employer of record (EOR) or employee payroll. 4dev.com is a Contractor Platform and does not employ the worker. The copyable schema, CSV and Python below use synthetic amounts to test a model; they do not execute a provider payment.

Freeze the payment promise before collecting prices

Compare the same obligation, recipient outcome and service scope. A percentage fee, a contractor subscription and a transfer quote can each be accurate while describing different purchases.

Give the run a scenario ID. Record the legal payer and jurisdiction, payer eligibility, comparison month, invoice roster, contractor identities, contract types, invoice amounts and currencies, recipient countries and credit currencies. Add funding method, payout route, intended execution date, funding cutoff and expected receipt window. Store a reporting currency without replacing the native amounts.

Hold the payer budget or the recipient net constant

In source-budget mode, hold the company's available debit constant and compare what reaches the contractor. In target-net mode, hold the promised contractor receipt constant and compare the required company debit. A USD1,000 funding budget and a USD1,000 net promise are different scenarios, even with the same invoice label.

Take a synthetic USD1,000 principal with USD12 of separately charged platform expense and a USD3 recipient deduction. Funding USD1,012 produces a USD997 receipt in that example. If the promise is USD1,000 received, obtain a quote for that promise; do not silently treat the USD3 as the contractor's responsibility. Contract terms determine who bears it. The later fixture deliberately uses source-principal mode so each movement remains visible.

A roster also has several populations. Ten onboarded people, seven signed contracts, five invoice submitters and four recipients can produce different subscription operands. Preserve the event that made each person or contract billable, and count it within the provider's billing period. Two invoices to one contractor and a retried transfer need distinct identifiers.

Match the work included in each product

Add columns for contracting, statements of work, approvals, invoicing, document retention, optional rights provisions, tax-document collection or exports, support and implementation. Record responsibility as well as availability. A company that already supplies these functions can compare a transfer service against the incremental work it needs; a company building them must budget that work.

Separate contractor administration from Contractor of Record protection and employee products. A similar brand or dashboard does not make these interchangeable. If the actual relationship belongs in employment, switch to an EOR or employee payroll comparison before entering fees here. Worker status and tax obligations depend on the applicable jurisdictions.

For an engineering team, useful internal inputs include time spent approving invoices, resolving failed transfers, matching receipts and exporting records. Use your own loaded hourly rate. Keep migration, setup and eventual exit work apart from recurring monthly hours, so a cheap first month does not conceal implementation cash.

Store quotes as immutable revisions

Preserve the quote you accepted, including its original evidence, and append a new revision when terms change. A hash identifies saved bytes; acceptance, eligibility and contract terms still determine whether those bytes authorize a payment.

Give quotes, logical payments, attempts and events distinct identities. A new request after a timeout can concern the same payment. An actual retry fee is a charge event with its own ID; a second copy of the principal is an error. Wise's transfer documentation uses a stable customer transaction ID when retrying after an error or timeout. Your ledger also needs its own duplicate-event check.

Keep the billing base beside its evidence

Store the product identifier (SKU) and payer entity/jurisdiction with the billable unit, count, period and base. The public starting price is a discovery input. A route quote additionally needs the actual funding method, recipient and payout option, native amounts, included-charge IDs and tax treatment. A change of recipient or funding method can change fees and estimated delivery in Wise's quote workflow; refresh the final confirmation rather than reusing unmatched terms.

Wise distinguishes illustrative unauthenticated quotes from authenticated quotes usable for transfer creation. Its quote-validity and locked-rate-validity fields also represent different clocks. The neutral schema uses its own names and preserves both times. It is an authored model, not a provider API specification. The executable validation path rejects illustrative fixtures, even though the demonstration deliberately permits them for arithmetic.

The OECD's September 2026 disclosure guidance, section 5.1.5 separates payer gross in payer currency, payee net in payee currency, charges and applied FX, with uncertain information labelled as estimated. It concerns retail cross-border payments for individuals and small businesses, generally below USD100,000, and is non-binding guidance rather than a study of contractor-platform savings. Applying that disclosure structure here makes an omitted deduction visible before someone mistakes the quote for a complete cost.

A chain links the fixed payment scenario to a preserved quote revision, a named FX snapshot, the charge and event ledger, and reconciled outputs.

This authored record chain preserves native amounts, source evidence and revisions. Quote validity and rate validity remain separate; unknown charges keep the result incomplete.

Each record has a specific job: scenario fixes the promise, quote fixes terms, snapshot fixes the conversion input, events record movements, and receipt supports the result. A missing receipt cannot be repaired by a more detailed pricing record.

Unknown amounts need an explicit status

Known and estimated amounts use decimal strings. Unknown amounts use JSON null and the status unknown; a blank cell never silently becomes zero. The Python validator checks both the schema and the relationship between status and amount. Tax status is independent of the numerical fee: an unresolved tax basis keeps the total incomplete. The synthetic record declares a zero-tax assumption solely for the fixture.

Save this complete block as cost-model.schema.json. Its currency list deliberately covers only the demonstration currencies; extend currency precision and validation together before using another currency. The standard-library validator implements the exact schema vocabulary below, including timezone checks, and adds the financial invariants the schema cannot express.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "urn:example:contractor-cost-quote:v1",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "schema_version",
    "quote_id",
    "scenario_id",
    "product",
    "sku",
    "payer_entity",
    "payer_jurisdiction",
    "document_version",
    "funding",
    "route",
    "recipient_id",
    "snapshot_id",
    "revision",
    "payer_eligible",
    "source_uri",
    "source_sha256",
    "retrieved_at",
    "issued_at",
    "accepted_at",
    "quote_expires_at",
    "rate_expires_at",
    "status",
    "source_principal",
    "recipient_gross",
    "population",
    "included_charge_ids",
    "tax",
    "charges",
    "rate_direction"
  ],
  "properties": {
    "schema_version": {
      "type": "string"
    },
    "quote_id": {
      "type": "string"
    },
    "scenario_id": {
      "type": "string"
    },
    "product": {
      "type": "string"
    },
    "sku": {
      "type": "string"
    },
    "payer_entity": {
      "type": "string"
    },
    "payer_jurisdiction": {
      "type": "string"
    },
    "document_version": {
      "type": "string"
    },
    "funding": {
      "type": "string"
    },
    "route": {
      "type": "string"
    },
    "recipient_id": {
      "type": "string"
    },
    "snapshot_id": {
      "type": "string"
    },
    "revision": {
      "type": "integer",
      "minimum": 1
    },
    "payer_eligible": {
      "type": "boolean"
    },
    "source_uri": {
      "type": "string",
      "format": "uri"
    },
    "source_sha256": {
      "type": "string",
      "pattern": "[a-f0-9]{64}"
    },
    "retrieved_at": {
      "type": "string",
      "format": "date-time"
    },
    "issued_at": {
      "type": "string",
      "format": "date-time"
    },
    "accepted_at": {
      "type": "string",
      "format": "date-time"
    },
    "quote_expires_at": {
      "type": "string",
      "format": "date-time"
    },
    "rate_expires_at": {
      "type": "string",
      "format": "date-time"
    },
    "status": {
      "enum": [
        "illustrative",
        "executable"
      ]
    },
    "source_principal": {
      "$ref": "#/$defs/money"
    },
    "recipient_gross": {
      "$ref": "#/$defs/money"
    },
    "population": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "unit",
        "count",
        "period",
        "base",
        "evidence"
      ],
      "properties": {
        "unit": {
          "type": "string"
        },
        "count": {
          "type": "integer",
          "minimum": 0
        },
        "period": {
          "type": "string"
        },
        "base": {
          "type": "string"
        },
        "evidence": {
          "type": "string",
          "format": "uri"
        }
      }
    },
    "included_charge_ids": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "tax": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "status",
        "basis"
      ],
      "properties": {
        "status": {
          "enum": [
            "known",
            "estimated",
            "unknown"
          ]
        },
        "basis": {
          "type": "string"
        }
      }
    },
    "charges": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/charge"
      }
    },
    "rate_direction": {
      "enum": [
        "target_per_source"
      ]
    }
  },
  "$defs": {
    "money": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "amount",
        "currency",
        "status"
      ],
      "properties": {
        "amount": {
          "type": [
            "string",
            "null"
          ],
          "pattern": "[0-9]+(\\.[0-9]+)?"
        },
        "currency": {
          "enum": [
            "USD",
            "EUR",
            "JPY"
          ]
        },
        "status": {
          "enum": [
            "known",
            "estimated",
            "unknown"
          ]
        }
      }
    },
    "charge": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "id",
        "layer",
        "mode",
        "bearer",
        "money"
      ],
      "properties": {
        "id": {
          "type": "string"
        },
        "layer": {
          "type": "string"
        },
        "mode": {
          "enum": [
            "separate",
            "embedded",
            "deducted"
          ]
        },
        "bearer": {
          "enum": [
            "payer",
            "recipient"
          ]
        },
        "money": {
          "$ref": "#/$defs/money"
        }
      }
    }
  }
}

This is one valid synthetic quote record. The identical record is the QUOTE constant in the complete Python block; the hash is computed there from the stated synthetic source bytes. Every charge inherits the quote's source provenance in this small model. A production adapter should retain a charge-specific reference when the bank statement or invoice supplies that charge.

{
  "schema_version": "1",
  "quote_id": "anonymous-a",
  "revision": 1,
  "scenario_id": "s1",
  "product": "Anonymous A",
  "sku": "synthetic-monthly",
  "payer_entity": "Example Ltd",
  "payer_jurisdiction": "synthetic",
  "payer_eligible": true,
  "source_uri": "urn:example:synthetic-quote",
  "source_sha256": "93364e5178d2a08de0eb4b076254dc6d5316e3747a40d08bd984dc7b74810da1",
  "document_version": "fixture-1",
  "retrieved_at": "2026-10-21T08:00:00Z",
  "issued_at": "2026-10-21T08:00:00Z",
  "accepted_at": "2026-10-21T08:01:00Z",
  "quote_expires_at": "2026-10-21T09:00:00Z",
  "rate_expires_at": "2026-10-21T08:45:00Z",
  "status": "illustrative",
  "funding": "bank",
  "route": "synthetic-local",
  "recipient_id": "r1",
  "source_principal": {
    "amount": "1000.00",
    "currency": "USD",
    "status": "known"
  },
  "recipient_gross": {
    "amount": "1000.00",
    "currency": "USD",
    "status": "known"
  },
  "population": {
    "unit": "paid_contractor",
    "count": 1,
    "period": "2026-10",
    "base": "one synthetic payment",
    "evidence": "urn:example:roster-s1"
  },
  "included_charge_ids": [],
  "snapshot_id": "usd-eur-1",
  "rate_direction": "target_per_source",
  "tax": {
    "status": "known",
    "basis": "synthetic zero tax; no real tax conclusion"
  },
  "charges": [
    {
      "id": "platform",
      "layer": "platform",
      "mode": "separate",
      "bearer": "payer",
      "money": {
        "amount": "12.00",
        "currency": "USD",
        "status": "known"
      }
    },
    {
      "id": "bank",
      "layer": "funding",
      "mode": "separate",
      "bearer": "payer",
      "money": {
        "amount": "5.00",
        "currency": "USD",
        "status": "known"
      }
    },
    {
      "id": "recipient",
      "layer": "recipient_bank",
      "mode": "deducted",
      "bearer": "recipient",
      "money": {
        "amount": "3.00",
        "currency": "USD",
        "status": "known"
      }
    }
  ]
}

The sample snapshot ID points to the rate record in the Python block. Its direction is target units per source unit. The USD-only baseline retains this reference without applying a conversion; the EUR reporting branch uses it explicitly. Contract accounting redenomination belongs in a separate obligation record when relevant. It does not establish that the platform sold an exchange service.

Attach a direction and timestamp to every FX input

Define a rate as target units per source unit, then multiply the source amount by that rate. Preserve both currencies, the observation timestamp with timezone, value date, rate side, evidence source and snapshot ID. A decimal value without those fields cannot reproduce the calculation.

Reference rates and executable conversions

The ECB publishes euro foreign exchange reference rates for information and discourages their use for transactions. The rates are normally updated around 16:00 CET on working days. A reference observation supplies a comparison baseline; the accepted provider rate and its validity determine the executable conversion.

For a purely hypothetical same-time reference table, suppose one EUR equals 1.10 source units and 1.20 target units. The target-per-source cross-rate is 1.20 / 1.10, or approximately 1.090909. Reverse direction uses 1.10 / 1.20. Their product equals one before rounding. Multiplying a USD amount by a rate recorded as USD per EUR would invert the economic result.

Keep a benchmark diagnostic separate from cash expense. A recipient amount below a benchmark conversion may reflect a different timestamp, rate side, charge, method or conversion leg. It does not by itself identify a provider markup. To attribute a spread, match the provider's contractual pricing reference, execution time and rate convention, then state the comparison's limits.

The demonstration uses a synthetic executed snapshot of EUR0.90 per USD1 for reporting USD fees in EUR. It is unrelated to current market rates. Each USD12 platform fee converts to EUR10.80, and each USD5 bank fee to EUR4.50. The ledger still holds USD movements. Changing the reporting currency cannot change the original cash balance.

Keep each conversion leg traceable

A payer can fund in one currency, maintain a balance in another and send to a third. Keep one record per conversion leg with its own source/target amounts, snapshot and rounding rule. If several fills execute at different times, retain the fills and their weights; an unqualified average can conceal both timing and the amount converted.

Quote expiry answers whether the pricing object can still be used. Rate expiry answers how long the quoted conversion remains locked. Wise's authenticated-quote workflow distinguishes these clocks and the funding deadline. Read the returned timestamps for the selected option. A recipient attachment or funding-method change can require a refreshed confirmation; copying the old fee into the new route loses the connection between evidence and price.

In practice, retain requested, quoted, accepted, funded, executed and credited times. If the company misses a funding cutoff, save the replacement quote as a new revision and attribute the resulting change to that revision. Avoid silently restating the old quote at today's rate. For contractual redenomination or accounting revaluation, retain the original obligation and the reference-rate clause separately from the execution ledger. A reference provision alone supplies no bank conversion fee.

Calculate fees without losing the cash trail

Calculate service expense, funding needs and recipient receipt in separate ledgers. Preserve each native currency until an explicit reporting conversion. The principal funds the contractor obligation; counting it as a platform fee would overwhelm the comparison before any pricing analysis begins.

Resolve billable populations and minimum rules

Count unique contractors and unique contracts separately. Then derive activity from the exact product rule: onboarding, signature, work submission, invoice, payment or active contract-month. Deduplicate the relevant identity within its billing period. Count transfer attempts only when the tariff makes an attempt billable. A monthly seat fee should not multiply just because one person has four invoices.

Two common rules require different formulas:

Anonymous rule Formula Synthetic input Result
Base subscription plus usage base + unit price × quantity USD40 + USD2 × 10 USD60
Credited usage minimum max(minimum, unit price × quantity) max(USD40, USD2 × 10) USD40
Same credited minimum, more usage max(USD40, USD2 × 30) 30 billable units USD60

The minimum in the second row is a floor against usage. Adding it again changes the contract. Treat included units, volume tiers and allowances as explicit documented rules; a headline starting price supplies none of them. Store the population and tariff version used to compute the quantity, so a later invoice can be traced to the same operands.

An annual commitment needs two views: cash due now and cost allocated to the comparison period. A synthetic USD1,200 annual payment may be allocated at USD100 per month for a management comparison, while the first month's cash requirement remains USD1,200. Keep the contractual commitment, cancellation rules and unconsumed balance visible. Your accounting policy determines recognition; the comparison worksheet does not replace it.

Convert each cost layer exactly once

Use decimal arithmetic and currency-specific rounding. The example uses half-up rounding at two decimals for USD/EUR and zero for JPY. That is a declared modelling rule; use the actual provider's line-level or total-level rounding when reconciling its statement. A rounded sum of lines can differ from a single rounding of the unrounded total.

Separate the cost layers by economic role:

  • Platform subscription, usage and optional product modules.
  • Payer funding charges and any funding conversion.
  • Actual execution debit, identifying principal and any charges already embedded in that debit.
  • Payout-route and exception charges, including billable retries where applicable.
  • Recipient-bank, correspondent or nonbank-method deductions that reduce contractor credit.
  • Taxes and recoverability assumptions, followed by internal hours, setup, integration and exit work.

These layers can have different bearers and currencies. A recipient-borne USD3 deduction reduces receipt; it does not automatically debit the payer's platform balance. A USD12 fee embedded in a USD1,012 execution debit is already in that cash movement. Adding a second USD12 fee event would create a duplicate expense and a false cash outflow.

Wise's authenticated-quote definitions illustrate why adapters need explicit mappings: sourceAmount includes the amount sent and fees; fee.total is a source-currency fee summary; the selected option's price.total covers taxes, fees and discounts. That nested total is not the principal-inclusive debit. Preserve its currency and selected funding/payout option rather than mapping every field named total to one amount.

Choose one reporting currency and a named snapshot for each required conversion. Keep benchmark FX shortfall as a diagnostic outside cash expense, because execution cash already captures the actual conversion. Keep refundable reserves and unused balances outside service expense too; report how much cash remains unavailable and for how long. Settlement timing, funding cutoffs and pending refunds can change the liquidity requirement even when the fee is identical.

An unknown component makes the complete-cost result incomplete. A bounded estimate can still support a decision: with a synthetic USD1,000 gross receipt and an unknown deduction between USD0 and USD8, the possible net is USD992–1,000. That interval does not prove either endpoint occurred. Replace it with the observed receipt and retain any unexplained difference as a variance.

Choose five products before filling the cost columns

Use 4dev.com as the first contractor-operations candidate, then compare the same run against the products below. The order reflects the stated workflow buying case. A complete-price winner requires matched buyer quotes, including funding, FX, route, deductions and tax treatment. Technical API detail is a modelling input, not a ranking criterion.

Public price inputs as of 21 October 2026:

Product Advertised platform input Billing base to preserve Scope
4dev.com Contractor Platform 3% or less; monthly-volume dependent; no subscription Completed workflows; management fee on task budget Contractor operations and documents
Remote Contractor Management Starts at USD29 per contractor/month Standard CM activity within the month Contracts, invoices and contractor administration
Deel Contractor Management Starts at USD49 per contractor/month Active contract-month, with contract-type rules Contractor administration and multicurrency payments
Wise Business, US pricing Sending from 0.23%; converting from 0.23%; USD31 setup/features Actual transaction option; one setup event Transfer/account layer
Tipalti Mass Payments Starts at $249/month plus payment transactions Subscription, payment usage and contracted modules Distributed payee operations

Tax/VAT basis and recoverability depend on the buyer's quote and invoice for every row. Dollar list prices also need the actual billing currency and jurisdiction in the accepted record. The transfer/conversion minima in the Wise row describe separate service headings and cannot be added automatically.

4dev.com

  • Product and price: 4dev.com Contractor Platform charges 3% or less for completed contractor workflows, with the rate dependent on monthly volume and no subscription. Under Master Services Agreement (MSA) §1.3.1, the contractual Fee combines the Task budget and the management fee calculated on that budget. Keep the budget/principal and management component as separate model inputs; the management component is platform expense. Under the Service Agreement (SA) §1.3.1, Fee instead means the end contractor's Task budget. Preserve the agreed currency and rate alongside these amounts.
  • Work included: Contractor operations, contractual documents and IP-rights provisions fit a founder consolidating the workflow. Mass payments are base functionality; API access and documentation come through a personal manager.
  • Cash and receipt: The hiring client bears applicable funding/banking charges under MSA §1.4.2. Under SA §1.4.4, the operator Company, Fourdev LLC, bears fees charged by its own bank or payment service providers. SWIFT transfers use SHA instructions: correspondent and recipient-bank/payment-institution deductions reduce the end contractor's payable even on unsuccessful transfers. Nonbank-method costs are notified in the contractor account under §1.4.5. A zero contractor platform fee does not promise full bank credit.
  • Withholding condition: If the hiring client's law or tax authority requires withholding from its payment to 4dev.com, MSA §1.9.1 requires the client to increase that payment so 4dev.com, the MSA Contractor, receives the otherwise agreed net amount. Record applicability and any resulting gross-up in the buyer-specific cost inputs; no jurisdiction or rate is assumed here. This obligation concerns payment to 4dev.com and leaves the end contractor's bank-deduction and receipt questions separate.
  • Currency and timing: MSA reference-rate redenomination concerns accounting obligations; it does not establish a client currency-exchange service or a spread. Record funding, completion, settlement and actual receipt separately, using the selected route's timing and exception terms.
  • Decision: First choice to evaluate for contractor operations and documentation with volume-dependent platform pricing. Employment requires EOR or employee payroll. Populate the same run-specific tax, funding, route and receipt fields used for every provider before comparing complete cost.

Remote

  • Product and price: Remote Contractor Management starts at USD29 per contractor/month. The client contracts with the contractor. Standard CM bills a month when the contractor signs a contractor services agreement (CSA) or statement of work (SOW), submits an invoice or receives an invoice payment. Company signature and signing a non-disclosure agreement (NDA) or other document do not trigger that rule. Remote Platform has no base fee; Remote states no setup/onboarding fee.
  • Strength: Localized contracts, invoice approval or auto-pay, screening, expenses and 1099-NEC generation provide substantial administration. This fixed-fee model deserves attention for regular higher-value invoices, provided route and scope costs match. Contractor Management Plus and Contractor of Record add different protection/responsibility and have separate pricing.
  • Funding, FX and receipt: Invoice currency and withdrawal setup change who bears conversion and transaction costs. A matching local-currency payout has a contractor-fee-free case, while withdrawal in another currency from a client-currency invoice places costs on the contractor. Non-local wires can add intermediary deductions. The payroll/statutory FX policy is a different product context.
  • Timing and limitation: The contractor guidance distinguishes invoice-time guaranteed and execution-time non-guaranteed currency options; the guarantee has conditions and exclusions. Obtain the chosen branch's rate, expiry and terms rather than assuming a universal lock. Remote's Paid out status records disbursement before recipient account credit.
  • Fill before comparing: Exact CM activity count, billing currency, invoice/local-credit currencies, funding method, payer-versus-contractor charge allocation, tax basis, guarantee branch, delivery estimate and retry/return terms. A low subscription leaves these route costs unresolved until the run is quoted.

Deel

  • Product and price: Deel Contractor Management starts at USD49 per contractor/month; its billing guidance specifies USD49 per active contract/month. Fixed contracts follow active contract-months. Pay-As-You-Go bills a month with work submitted, an adjustment or a one-off payment. Milestone contracts bill each month with an active milestone, including partial months. Count the relevant contracts and activity rather than only people paid.
  • Strength: Centralized contractor administration, automated invoicing, tax-form guidance/document collection and multicurrency payments suit a team coordinating recurring contractor work within a wider workforce system. Fixed contract pricing is a meaningful countercase for repeated higher-value invoices when other charges and obligations match.
  • Funding, FX and receipt: Multiple methods and advertised competitive rates do not supply an executable spread or complete withdrawal price. Preserve company funding costs, actual debit, selected conversion, withdrawal method and recipient deductions as separate quote inputs.
  • Timing and limitation: Contract type affects billing before money reaches a contractor. An inactive payment month therefore cannot automatically be priced as free. Obtain applicable billing exceptions, expected arrival and failed/returned/retried transfer terms. The contractor administration price does not buy the separately priced Contractor of Record or employee EOR/PEO scope.
  • Fill before comparing: Exact SKU, contract type, active contract-month count, order-form term/inclusions, tax treatment, fee currency, funding and withdrawal methods, quote/rate validity, expected credit and observed receipt. Existing Deel administration can make the combined workflow attractive; a complete-cost comparison still needs the route quote.

Wise Business

  • Product and price: For an eligible business under US Wise Business pricing, sending and converting each start at 0.23%, varying by currency. The USD31 setup/features/account-details price is one activation input. Record an actual setup event once. An incoming USD wire/Swift receiving charge describes money arriving into Wise and should not be copied onto every outgoing contractor transfer.
  • Strength: Business transfers, account/team/accounting features and integration resources fit a company that already operates its contracting and invoice administration. Quote-level detail supports a route comparison, while the cost of that outside administration stays in the worksheet.
  • Funding, FX and receipt: Use the selected source/target currencies, source-budget or target-amount mode, pay-in method and payout option. A quoted mid-market conversion does not eliminate outside bank deductions. For the documented global-currency Swift route, Wise does not guarantee full receipt under either SHA or OUR; intermediary fees can also reduce a refund.
  • Timing and limitation: Wise Platform distinguishes illustrative quotes from authenticated quotes, and quote expiry from locked-rate expiry. Recipient attachment can change pricing and delivery estimates. These integration documents do not establish every US Business account's API entitlement. Estimated delivery still needs later bank-credit evidence.
  • Fill before comparing: Payer eligibility, setup activation, profile/product, final quote identity, selected option, included charges, tax status, quote/rate timestamps, funding deadline, route deductions and receipt. It is a strong transfer-layer candidate when existing administration covers the remaining work; it prices a narrower purchase than a new contractor-operations workflow.

Tipalti

  • Product and price: Tipalti Mass Payments starts at $249/month on its US-facing price list, with transaction pricing for payments. Confirm the contract billing currency. Accounts Payable is a separate SKU and its advertised $99/month starting price cannot substitute for the Mass Payments base. Quote factors include payments, entities, methods/currencies and enabled modules.
  • Strength: A payee portal, core payment automation and automated tax compliance suit a larger distributed payee network. Centralized finance workflows and integrations can justify evaluating a subscription-plus-usage model across entities. Tipalti states no per-user or per-approver fee.
  • Funding, FX and receipt: Method and destination affect fees. Preserve base subscription, contracted payment usage, optional modules, funding, FX, recipient deductions and taxes individually. Workflow FX marketing supplies no executable corridor spread.
  • Timing and limitation: Standard implementations cover common accounts payable and payment workflows. Multiple enterprise resource planning (ERP) systems, custom integrations or advanced requirements can involve additional professional services. Include that cash and work separately; optional suite modules do not automatically belong in the starting subscription. Obtain delivery, funding-cutoff and exception terms for the actual routes.
  • Fill before comparing: Mass Payments SKU, billing period/currency, entity/payment counts, enabled modules, usage tariff, inclusions, implementation scope, tax basis, conversion snapshot, route charges and receipt evidence. This is a substantive operations alternative for scale; a base price alone cannot determine the economics of a small run.

Run the model with complete synthetic inputs

Copy three files: the schema above, the CSV below and the complete Python implementation. Python 3.10 or newer and its standard library are sufficient. The program validates the schema, checks financial invariants and prints the reconciliation. --test executes the same model under deliberate mutations.

The model is intentionally small: one logical payment per run, one recipient-credit currency and a single reporting conversion per fee. Run it once per payment and aggregate native-currency outputs in a larger worksheet. It is a reproducible calculation fixture, with no live API calls, durable database or contractual quote authorization. The in-memory revision check demonstrates an invariant; a production evidence store must retain it durably.

Copy the CSV fixture and Python implementation

The QUOTE constant supplies scenario s1, quote anonymous-a revision 1 and the three charge IDs. SCENARIO fixes the cutoff, method and reporting currency. RATES supplies usd-eur-1, a synthetic EUR-per-USD snapshot. These embedded inputs keep the example runnable without a fourth file.

Save as cost-model.example.csv:

id,kind,at,payment,attempt,charge,currency,amount,embedded
fund,deposit,2026-10-21T08:02:00Z,,,,USD,1100.00,
principal,payout,2026-10-21T08:10:00Z,p1,attempt-1,,USD,1000.00,
platform-fee,fee,2026-10-21T08:10:00Z,p1,attempt-1,platform,USD,12.00,
bank-fee,fee,2026-10-21T08:10:00Z,p1,attempt-1,bank,USD,5.00,
recipient-fee,deduction,2026-10-21T08:15:00Z,p1,attempt-1,recipient,USD,3.00,
provider-settled,settled,2026-10-21T08:15:00Z,p1,attempt-1,,USD,0.00,
credit,receipt,2026-10-22T09:00:00Z,p1,attempt-1,,USD,997.00,
refund-later,refund,2026-11-02T09:00:00Z,adjustment-1,,,USD,20.00,

The deposit funds USD1,100. The principal event sends USD1,000; payer fee events debit USD12 and USD5. The recipient's USD3 bank deduction is outside that payer balance. A provider-settled event precedes the observed USD997 receipt. The USD20 adjustment refund occurs after October's cutoff and enters cash only in the later period. It does not also reverse the principal.

Save as reconcile_cost_model.py. The complete tests are included, so changes to a fee mapping or currency convention can be tested with the public copy.

"""Synthetic contractor cost model. Python 3.10+, standard library only."""
import argparse
import copy
import csv
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
import hashlib
import json
from pathlib import Path
import re
import unittest

D = Decimal
PRECISION = {'USD': 2, 'EUR': 2, 'JPY': 0}

class ModelError(ValueError):
    pass

def require(condition, message):
    if not condition:
        raise ModelError(message)

def time(value):
    result = datetime.fromisoformat(value.replace('Z', '+00:00'))
    require(result.tzinfo is not None, 'timezone required')
    return result

def rounded(value, currency):
    require(currency in PRECISION, 'unsupported currency precision')
    return D(value).quantize(D(1).scaleb(-PRECISION[currency]), rounding=ROUND_HALF_UP)

def validate(value, rule, root):
    """Validate exactly the JSON Schema vocabulary used in the companion schema."""
    if '$ref' in rule:
        node = root
        for key in rule['$ref'].split('/')[1:]:
            node = node[key]
        return validate(value, node, root)
    if 'anyOf' in rule:
        for branch in rule['anyOf']:
            try:
                validate(value, branch, root)
                return
            except ModelError:
                pass
        raise ModelError('no anyOf branch matches')
    types = {'object': dict, 'array': list, 'string': str, 'integer': int,
             'null': type(None), 'boolean': bool}
    if 'type' in rule:
        names = rule['type'] if isinstance(rule['type'], list) else [rule['type']]
        require(any(type(value) is types[n] for n in names), 'schema type')
    if 'enum' in rule:
        require(value in rule['enum'], 'schema enum')
    if isinstance(value, dict):
        require(set(rule.get('required', [])) <= value.keys(), 'schema required')
        props = rule.get('properties', {})
        if rule.get('additionalProperties') is False:
            require(value.keys() <= props.keys(), 'schema extra field')
        for key in value.keys() & props.keys():
            validate(value[key], props[key], root)
    if isinstance(value, list):
        for item in value:
            validate(item, rule.get('items', {}), root)
    if isinstance(value, str):
        if 'pattern' in rule:
            require(re.fullmatch(rule['pattern'], value) is not None, 'schema pattern')
        if rule.get('format') == 'date-time':
            time(value)
        if rule.get('format') == 'uri':
            require(value.startswith(('https://', 'urn:')), 'schema URI')
    if type(value) is int and 'minimum' in rule:
        require(value >= rule['minimum'], 'schema minimum')

def money(value):
    require(value['currency'] in PRECISION, 'unknown money currency')
    if value['status'] == 'unknown':
        require(value['amount'] is None, 'unknown money must be null')
    else:
        require(isinstance(value['amount'], str), 'known money needs decimal string')
        require(D(value['amount']).is_finite() and D(value['amount']) >= 0,
                'invalid money amount')

def preserve(history, quote):
    key = (quote['quote_id'], quote['revision'])
    frozen = json.dumps(quote, sort_keys=True)
    require(key not in history or history[key] == frozen, 'overwritten quote revision')
    history[key] = frozen

def check_quote(q, schema, scenario, production=False):
    validate(q, schema, schema)
    require(q['schema_version'] == '1', 'unsupported schema version')
    for key in ('source_principal', 'recipient_gross'):
        money(q[key])
    for charge in q['charges']:
        money(charge['money'])
    require(q['rate_direction'] == 'target_per_source', 'quote rate direction')
    require(q['snapshot_id'] == scenario['snapshot'], 'changed snapshot')
    require(all(q[k]['status'] == 'known' for k in
                ('source_principal', 'recipient_gross')), 'principal/gross must be known')
    now = time(scenario['at'])
    require(time(q['issued_at']) <= time(q['accepted_at']) <= now,
            'quote chronology')
    require(now < time(q['quote_expires_at']), 'quote expired')
    require(now < time(q['rate_expires_at']), 'rate expired')
    if production:
        require(q['status'] == 'executable', 'illustrative quote cannot execute')
    for key in ('scenario_id', 'funding', 'route', 'recipient_id'):
        require(q[key] == scenario[key], 'changed ' + key)
    require(q['payer_eligible'], 'payer ineligible')
    ids = [c['id'] for c in q['charges']]
    require(len(set(ids)) == len(ids), 'duplicate charge id')
    require(set(q['included_charge_ids']) <= set(ids), 'unknown included charge')

def convert(amount, source, target, rates, snapshot):
    if source == target:
        return rounded(amount, target)
    require(snapshot in rates, 'missing rate snapshot')
    r = rates[snapshot]
    require((r['source'], r['target']) == (source, target), 'rate currency direction')
    require(r['direction'] == 'target_per_source', 'inverse rate convention')
    require(D(r['value']).is_finite() and D(r['value']) > 0, 'invalid rate')
    time(r['observed_at'])
    require(r['side'] in ('mid', 'bid', 'ask', 'executed'), 'rate side')
    return rounded(D(amount) * D(r['value']), target)

def pricing(base, unit, count, minimum, mode):
    usage = D(unit) * count
    if mode == 'floor':
        return max(D(minimum), usage)
    require(mode == 'additive', 'minimum mode')
    return D(base) + usage

def reconcile(q, schema, scenario, rates, events, production=False):
    check_quote(q, schema, scenario, production)
    cutoff = time(scenario['cutoff'])
    charges = {c['id']: c for c in q['charges']}
    balances, expenses = {}, {}
    ids, paid, used, received, settled = set(), set(), set(), {}, set()
    reported = D('0')
    incomplete = (q['tax']['status'] != 'known' or
                  any(c['money']['status'] != 'known' for c in q['charges']))
    deductions = D('0')
    deferred = []
    receipt_currency = q['recipient_gross']['currency']
    for e in events:
        require(e['id'] not in ids, 'duplicate event')
        ids.add(e['id'])
        at = time(e['at'])
        currency = e['currency']
        require(currency in PRECISION, 'event currency')
        amount = D(e['amount'])
        require(amount.is_finite() and amount >= 0, 'event amount')
        require(rounded(amount, currency) == amount, 'event precision')
        if at > cutoff:
            deferred.append(e['id'])
            continue
        kind = e['kind']
        require(kind in ('opening', 'deposit', 'payout', 'fee', 'deduction',
                         'settled', 'receipt', 'refund'), 'event kind')
        sign = D('0')
        if kind in ('opening', 'deposit', 'refund'):
            sign = D('1')
        if kind == 'payout':
            require(e['payment'] not in paid, 'duplicate logical principal')
            require(not paid, 'fixture supports one logical payment per run')
            require(currency == q['source_principal']['currency'], 'principal currency')
            paid.add(e['payment'])
            sign = D('-1')
            embedded = [x for x in e['embedded'].split('|') if x]
            require(set(embedded) == set(q['included_charge_ids']), 'embedded mapping')
            embedded_total = D('0')
            for cid in embedded:
                require(cid in charges, 'unknown embedded fee')
                c = charges[cid]
                require(c['mode'] == 'embedded' and c['bearer'] == 'payer',
                        'embedded charge mode')
                require(c['money']['currency'] == currency, 'embedded currency')
                require(cid not in used, 'double charged fee')
                require(c['money']['status'] == 'known', 'unknown embedded debit')
                used.add(cid)
                embedded_total += D(c['money']['amount'])
                expenses[currency] = expenses.get(currency, D('0')) + D(c['money']['amount'])
                reported += convert(c['money']['amount'], currency,
                                    scenario['reporting_currency'], rates, scenario['snapshot'])
            require(amount == D(q['source_principal']['amount']) + embedded_total,
                    'execution debit differs from principal plus embedded charges')
        if kind in ('fee', 'deduction'):
            cid = e['charge']
            require(cid in charges and cid not in used, 'double charged fee')
            c = charges[cid]
            require(c['money']['currency'] == currency, 'charge currency')
            if c['money']['status'] == 'known':
                require(amount == D(c['money']['amount']), 'charge amount mismatch')
            else:
                incomplete = True
            used.add(cid)
            if kind == 'fee':
                require(c['mode'] == 'separate' and c['bearer'] == 'payer', 'fee mode')
                sign = D('-1')
                expenses[currency] = expenses.get(currency, D('0')) + amount
                reported += convert(amount, currency, scenario['reporting_currency'],
                                    rates, scenario['snapshot'])
            else:
                require(c['mode'] == 'deducted' and c['bearer'] == 'recipient',
                        'deduction mode')
                require(currency == receipt_currency, 'deduction currency')
                deductions += amount
        if kind == 'settled':
            settled.add(e['payment'])
        if kind == 'receipt':
            require(e['payment'] not in received, 'duplicate receipt')
            require(currency == receipt_currency, 'receipt currency')
            received[e['payment']] = amount
        if sign:
            balances[currency] = balances.get(currency, D('0')) + sign * amount
    require(all(x >= 0 for x in balances.values()), 'negative closing balance')
    require(received.keys() <= paid, 'receipt without principal')
    unresolved = sorted(paid - received.keys())
    # This small fixture is one logical payment; do not sum different receipt currencies.
    credit = sum(received.values(), D('0')) if received else None
    expected = None if incomplete else D(q['recipient_gross']['amount']) - deductions
    variance = None if expected is None or credit is None else credit - expected
    missing_charges = sorted(set(charges) - used)
    return {'cash': balances, 'fee_expense': expenses, 'reporting_expense': reported,
            'reporting_currency': scenario['reporting_currency'],
            'recipient_credit': credit, 'expected_credit': expected, 'variance': variance,
            'credit_currency': receipt_currency, 'unresolved': unresolved,
            'settled': sorted(settled), 'deferred': deferred,
            'complete': (not incomplete and not unresolved and not missing_charges
                         and variance == 0),
            'missing_charges': missing_charges}

QUOTE = {
    'schema_version': '1', 'quote_id': 'anonymous-a', 'revision': 1,
    'scenario_id': 's1', 'product': 'Anonymous A', 'sku': 'synthetic-monthly',
    'payer_entity': 'Example Ltd', 'payer_jurisdiction': 'synthetic',
    'payer_eligible': True, 'source_uri': 'urn:example:synthetic-quote',
    'source_sha256': hashlib.sha256(b'synthetic quote revision 1').hexdigest(),
    'document_version': 'fixture-1', 'retrieved_at': '2026-10-21T08:00:00Z',
    'issued_at': '2026-10-21T08:00:00Z', 'accepted_at': '2026-10-21T08:01:00Z',
    'quote_expires_at': '2026-10-21T09:00:00Z',
    'rate_expires_at': '2026-10-21T08:45:00Z', 'status': 'illustrative',
    'funding': 'bank', 'route': 'synthetic-local', 'recipient_id': 'r1',
    'source_principal': {'amount': '1000.00', 'currency': 'USD', 'status': 'known'},
    'recipient_gross': {'amount': '1000.00', 'currency': 'USD', 'status': 'known'},
    'population': {'unit': 'paid_contractor', 'count': 1, 'period': '2026-10',
                   'base': 'one synthetic payment', 'evidence': 'urn:example:roster-s1'},
    'included_charge_ids': [], 'snapshot_id': 'usd-eur-1', 'rate_direction': 'target_per_source',
    'tax': {'status': 'known', 'basis': 'synthetic zero tax; no real tax conclusion'},
    'charges': [
        {'id': 'platform', 'layer': 'platform', 'mode': 'separate', 'bearer': 'payer',
         'money': {'amount': '12.00', 'currency': 'USD', 'status': 'known'}},
        {'id': 'bank', 'layer': 'funding', 'mode': 'separate', 'bearer': 'payer',
         'money': {'amount': '5.00', 'currency': 'USD', 'status': 'known'}},
        {'id': 'recipient', 'layer': 'recipient_bank', 'mode': 'deducted',
         'bearer': 'recipient',
         'money': {'amount': '3.00', 'currency': 'USD', 'status': 'known'}}]}

SCENARIO = {'scenario_id': 's1', 'at': '2026-10-21T08:05:00Z',
            'cutoff': '2026-10-31T23:59:59Z', 'funding': 'bank',
            'route': 'synthetic-local', 'recipient_id': 'r1',
            'reporting_currency': 'USD', 'snapshot': 'usd-eur-1'}
RATES = {'usd-eur-1': {'source': 'USD', 'target': 'EUR', 'value': '0.90',
          'direction': 'target_per_source', 'observed_at': '2026-10-21T08:00:00Z',
          'side': 'executed', 'value_date': '2026-10-21',
          'source_uri': 'urn:example:synthetic-rate'}}

class Tests(unittest.TestCase):
    def setUp(self):
        self.q, self.s, self.r, self.e = map(copy.deepcopy, (QUOTE, SCENARIO, RATES, EVENTS))
    def run_model(self, production=False):
        return reconcile(self.q, SCHEMA, self.s, self.r, self.e, production)
    def rejects(self, phrase, production=False):
        with self.assertRaisesRegex(ModelError, phrase):
            self.run_model(production)
    def test_baseline(self):
        x = self.run_model()
        self.assertEqual(x['cash'], {'USD': D('83')})
        self.assertEqual(x['fee_expense'], {'USD': D('17')})
        self.assertEqual(x['reporting_expense'], D('17'))
        self.assertEqual(x['recipient_credit'], D('997'))
        self.assertEqual(x['variance'], D('0'))
        self.assertTrue(x['complete'])
        self.assertEqual(x['deferred'], ['refund-later'])
    def test_reporting_currency(self):
        self.s['reporting_currency'] = 'EUR'
        self.assertEqual(self.run_model()['reporting_expense'], D('15.30'))
        self.assertEqual(self.run_model()['cash']['USD'], D('83'))
    def test_illustrative_rejected_for_production(self):
        self.rejects('illustrative', True)
    def test_expired_quote(self):
        self.q['quote_expires_at'] = '2026-10-21T08:04:00Z'
        self.rejects('quote expired')
    def test_expired_rate(self):
        self.q['rate_expires_at'] = '2026-10-21T08:04:00Z'
        self.rejects('rate expired')
    def test_funding_change(self):
        self.s['funding'] = 'card'
        self.rejects('changed funding')
    def test_recipient_change(self):
        self.s['recipient_id'] = 'r2'
        self.rejects('changed recipient_id')
    def test_immutable_revision(self):
        history = {}
        preserve(history, self.q)
        self.q['product'] = 'altered'
        with self.assertRaisesRegex(ModelError, 'overwritten'):
            preserve(history, self.q)
        self.q['revision'] = 2
        preserve(history, self.q)
        self.assertEqual(len(history), 2)
    def test_missing_rate(self):
        self.s['reporting_currency'] = 'EUR'
        self.r.clear()
        self.rejects('missing rate')
    def test_inverse_rate_convention(self):
        self.s['reporting_currency'] = 'EUR'
        self.r['usd-eur-1']['direction'] = 'source_per_target'
        self.rejects('inverse')
    def test_wrong_currency_pair(self):
        self.s['reporting_currency'] = 'EUR'
        self.r['usd-eur-1']['source'] = 'JPY'
        self.rejects('currency direction')
    def test_embedded_and_separate_fee(self):
        self.q['included_charge_ids'] = ['platform']
        self.q['charges'][0]['mode'] = 'embedded'
        self.e[1]['embedded'] = 'platform'
        self.e[1]['amount'] = '1012.00'
        self.rejects('double charged')
    def test_embedded_fee_only(self):
        self.q['included_charge_ids'] = ['platform']
        self.q['charges'][0]['mode'] = 'embedded'
        self.e[1]['embedded'] = 'platform'
        self.e[1]['amount'] = '1012.00'
        self.e = [e for e in self.e if e['id'] != 'platform-fee']
        self.assertEqual(self.run_model()['cash']['USD'], D('83'))
        self.assertEqual(self.run_model()['reporting_expense'], D('17'))
    def test_duplicate_event(self):
        self.e.append(copy.deepcopy(self.e[0]))
        self.rejects('duplicate event')
    def test_retry_cannot_repeat_principal(self):
        retry = copy.deepcopy(self.e[1])
        retry.update(id='retry-principal', attempt='attempt-2')
        self.e.append(retry)
        self.rejects('duplicate logical principal')
    def test_actual_retry_fee_once(self):
        charge = copy.deepcopy(self.q['charges'][0])
        charge.update(id='retry', layer='exception')
        charge['money']['amount'] = '2.00'
        self.q['charges'].append(charge)
        fee = copy.deepcopy(self.e[2])
        fee.update(id='retry-fee', charge='retry', attempt='attempt-2', amount='2.00')
        self.e.append(fee)
        self.assertEqual(self.run_model()['cash']['USD'], D('81'))
        self.assertEqual(self.run_model()['reporting_expense'], D('19'))
        fee2 = copy.deepcopy(fee)
        fee2['id'] = 'retry-fee-again'
        self.e.append(fee2)
        self.rejects('double charged')
    def test_minimum_and_roster(self):
        self.assertEqual(pricing('40', '2', 10, '40', 'floor'), D('40'))
        self.assertEqual(pricing('40', '2', 10, '0', 'additive'), D('60'))
        self.assertEqual(pricing('40', '2', 30, '40', 'floor'), D('60'))
    def test_rounding_and_inverse_identity(self):
        self.assertEqual(rounded('1.005', 'USD'), D('1.01'))
        self.assertEqual(rounded('1.5', 'JPY'), D('2'))
        r = D('1.2') / D('1.1')
        self.assertAlmostEqual(r * (D('1') / r), D('1'))
    def test_unknown_deduction(self):
        self.q['charges'][2]['money'].update(amount=None, status='unknown')
        x = self.run_model()
        self.assertFalse(x['complete'])
        self.assertIsNone(x['expected_credit'])
        self.assertEqual(x['recipient_credit'], D('997'))
    def test_settled_without_receipt(self):
        self.e = [e for e in self.e if e['kind'] != 'receipt']
        x = self.run_model()
        self.assertEqual(x['settled'], ['p1'])
        self.assertEqual(x['unresolved'], ['p1'])
        self.assertIsNone(x['recipient_credit'])
        self.assertFalse(x['complete'])
    def test_unused_cash_is_not_expense(self):
        self.e[0]['amount'] = '2100.00'
        x = self.run_model()
        self.assertEqual(x['cash']['USD'], D('1083'))
        self.assertEqual(x['reporting_expense'], D('17'))
    def test_refund_cutoff_and_currency(self):
        self.s['cutoff'] = '2026-11-30T23:59:59Z'
        x = self.run_model()
        self.assertEqual(x['cash']['USD'], D('103'))
        self.assertEqual(x['reporting_expense'], D('17'))
        self.e[-1].update(currency='EUR', amount='5.00')
        x = self.run_model()
        self.assertEqual(x['cash'], {'USD': D('83'), 'EUR': D('5')})
    def test_schema_malformed_money(self):
        self.q['source_principal']['amount'] = 'not-money'
        self.rejects('schema pattern')
    def test_schema_invalid_status(self):
        self.q['source_principal']['status'] = 'probably'
        self.rejects('schema enum')
    def test_unknown_with_number(self):
        self.q['source_principal']['status'] = 'unknown'
        self.rejects('unknown money')
    def test_missing_charge_is_incomplete(self):
        self.e = [e for e in self.e if e['id'] != 'bank-fee']
        self.assertFalse(self.run_model()['complete'])
    def test_receipt_variance_is_visible(self):
        self.e[6]['amount'] = '996.00'
        self.assertEqual(self.run_model()['variance'], D('-1'))
        self.assertFalse(self.run_model()['complete'])

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--schema', default='cost-model.schema.json')
    parser.add_argument('--csv', default='cost-model.example.csv')
    parser.add_argument('--test', action='store_true')
    args = parser.parse_args()
    SCHEMA = json.loads(Path(args.schema).read_text())
    with open(args.csv, newline='') as stream:
        EVENTS = list(csv.DictReader(stream))
    if args.test:
        suite = unittest.defaultTestLoader.loadTestsFromTestCase(Tests)
        result = unittest.TextTestRunner(verbosity=2).run(suite)
        raise SystemExit(not result.wasSuccessful())
    print(json.dumps(reconcile(QUOTE, SCHEMA, SCENARIO, RATES, EVENTS),
                     default=str, indent=2))

From the directory containing all three files, run:

python3 reconcile_cost_model.py
python3 reconcile_cost_model.py --test

The first command prints this synthetic result:

{
  "cash": {"USD": "83.00"},
  "fee_expense": {"USD": "17.00"},
  "reporting_expense": "17.00",
  "reporting_currency": "USD",
  "recipient_credit": "997.00",
  "expected_credit": "997.00",
  "variance": "0.00",
  "credit_currency": "USD",
  "unresolved": [],
  "settled": ["p1"],
  "deferred": ["refund-later"],
  "complete": true,
  "missing_charges": []
}

The unused USD83 remains cash. It is absent from the USD17 service/bank expense. Reporting the same fees in EUR uses the named 0.90 snapshot and produces EUR15.30; the USD83 cash balance stays in USD. This branch tests a reporting conversion, not a claim that the contractor's USD payment executed through EUR.

Change assumptions that can change the choice

Use anonymous alternatives for sensitivity before applying actual commercial terms. Suppose Anonymous A charges USD40 per month plus USD2 per billable transfer, while Anonymous B charges USD8 per billable contractor-month. These invented models represent no named provider and include no undisclosed service or route assumptions.

Synthetic run Anonymous A Anonymous B
Ten billable contractors, one transfer each USD60 USD80
Ten billable contractors, four transfers each USD120 USD80
Three billable contractors, one transfer each USD46 USD24

The roster and payment frequency reverse the platform-fee ordering. Contractor-count and transfer-count tariffs react to different inputs. A percentage tariff also reacts to invoice value; preserve that value even if a fixed platform input stays flat. Route, tax and outside administration still need their own lines before either model becomes a complete-cost estimate.

Keep internal hours beside these results. With an explicitly hypothetical two hours at USD45 per hour, internal effort is USD90. Show it separately from the cash fees and substitute actual hours/rate rather than assigning the same saving to every product. A one-time migration estimate belongs in another column with its comparison period.

For recipient uncertainty, enter an unknown charge as null/status unknown. The test then returns an incomplete estimate even if a receipt has already been observed. If commercial terms support only a USD0–8 deduction range, a USD1,000 gross promise produces a USD992–1,000 estimated interval. Receipt evidence settles the amount received; it may still leave the reason for the deduction unresolved.

Close cash balances and recipient credits separately

Close each currency's cash balance from actual movements, then reconcile the recipient's gross-to-net bridge. A zero balance difference does not prove the contractor received the quoted amount. Conversely, a confirmed receipt does not prove that every payer funding cost has been recorded.

Bridge the quote to the statement

Use this currency-specific identity:

opening cash + deposits − execution debits − separate fees + actual refunds = closing cash

Execution debit includes principal and charges explicitly embedded in that movement. Separate fee events contain only amounts debited outside it. If a statement supplies a combined debit, map its included-charge IDs and remove the separate cash events for those charges. Keep the charge expense once. Add other statement movements only with an identified event and economic role.

Trace the fixture by its event IDs:

Evidence identity Movement or state October output
s1 / anonymous-a, revision 1 USD1,000 source principal; three charge IDs Preserved terms
fund USD1,100 deposited Cash +USD1,100
principal / p1 / attempt-1 USD1,000 principal Cash −USD1,000
platform-fee, bank-fee USD12 + USD5 separately debited Cash −USD17; fee expense USD17
recipient-fee USD3 recipient deduction Expected credit USD997
credit USD997 observed Variance USD0
refund-later USD20 received in November Deferred from October

The October balance is USD83. The recipient bridge is USD1,000 gross less USD3 documented deduction equals USD997 expected, compared with USD997 observed. If the bank credit were USD996, retain a negative USD1 variance and an incomplete reconciliation. Assigning that difference to a guessed bank fee would hide the open question.

The OECD's 2026 guidance, pages 46–47 recommends comparing estimates with later actual amounts and distinguishes intermediary processing from credit to the payee account where tracking is available. Applied here, this gives the operations team two checks: whether the quote predicted the outcome and whether the recipient credit is evidenced. It supplies no corporate accounting recognition rule or refund entitlement.

Keep pending receipts and refunds visible

Store provider settlement and recipient credit as separate events. In the fixture, deleting credit leaves p1 settled, recipient credit null and the result incomplete. The expected amount remains an estimate. This distinction matters operationally when a contractor asks about a transfer already marked disbursed.

After recording provider settlement, check for evidence of recipient credit. Missing evidence leaves credit unresolved; observed credit can be compared with the estimate.

Provider settlement and recipient credit need separate evidence. Compare an observed credit with the estimate and retain unexplained differences; missing receipt evidence leaves credit unresolved.

A settlement status leads to a receipt check. With no bank-credit evidence, keep the item unresolved. With evidence, compare observed and expected amounts, retaining any unexplained variance. The original quote, rate snapshot, execution and receipt should remain linked after a later correction.

An actual refund enters its own currency and period. A promised refund is a pending item until it arrives. If the principal was already recorded as sent, do not both reverse that original outflow and add the same refund inflow unless the statement supports two distinct movements. Keep any fee reversal as its own evidenced event. Refundable reserves, unspent funding and future commitments each need a separate liquidity view; none becomes a platform fee merely because it consumes cash temporarily.

Make financial errors fail before the next run

Assert exact cash, expense and receipt states, then mutate the inputs that could silently change them. The public implementation contains 27 executable tests. They check financial amounts, currency conversions, quote validity and ledger transitions.

Assert amounts and transitions

The baseline test requires USD83 closing cash, USD17 payer fee expense, USD17 reporting expense, USD997 observed credit, zero variance and a complete reconciliation. The EUR branch requires EUR15.30 reporting expense while preserving the original USD83 balance. These assertions would catch a change that accidentally added native USD and EUR amounts or counted unused funding as an expense.

The remaining tests focus on failures with financial consequences:

Test group Required behavior Error prevented
Illustrative quote in executable mode Reject Treating a calculator response as payment authority
Quote expiry and rate expiry Reject each independently Executing stale pricing or conversion terms
Funding or recipient change Reject unmatched input Applying a bank-funded quote to a card-funded or different-recipient payment
Accepted revision mutation Reject overwrite; accept a new revision Erasing the terms used for the earlier decision
Missing snapshot, inverse convention or wrong pair Reject Converting with an absent or reversed currency relationship
Embedded fee plus separate fee event Reject Double cash debit and double expense
Embedded fee only Preserve USD83 cash and USD17 expense Losing the fee while removing duplicate cash
Duplicate event or repeated retry principal Reject Paying or recording one obligation twice
One actual USD2 retry fee USD81 cash and USD19 expense; reject a second charge Losing a legitimate exception cost or billing it twice
Floor versus additive minimum USD40 versus USD60 at ten units Turning a credited minimum into an extra base charge
Currency precision USD1.005 rounds to USD1.01; JPY1.5 to JPY2 Applying one precision to every currency
Unknown deduction or missing charge event Incomplete Reporting an unproven complete cost
Settlement with no receipt Credit null; unresolved payment retained Marking a disbursement as received
More unused funding Expense stays USD17 Charging refundable cash to fees
Post-cutoff refund Later cash only; native currency preserved Moving a future refund into October or summing USD and EUR
Malformed money/status Reject Accepting nonnumeric money, invented statuses or unknown amounts with a number
Credit differs from estimate Expose variance; incomplete Hiding an unexplained receipt shortfall

The inverse-rate test checks the recorded convention and currency pair. No program can infer that a plausible numeric rate was copied incorrectly when its provenance is also wrong. Preserve the actual source snapshot and review its direction when onboarding an adapter. The reciprocal arithmetic test catches the formula, while the evidence record supports the input.

The retry test treats the logical payment as stable across attempts. A second principal for p1 fails even with a new event ID; a distinct retry fee is allowed once. In a production integration, persist event IDs and request identity across restarts, then reconcile external statements to that durable ledger. These in-memory fixtures do not prove a live provider API's behavior.

Record the command and the actual result

The command executed against the copyable files was:

python3 reconcile_cost_model.py --test

Actual result: 27 tests ran and all passed. The test runner's elapsed time depends on the machine. The normal run also produced the USD83/USD17/USD997 baseline shown above. Copying the schema or CSV incorrectly can cause a validation or reconciliation failure before any assertion succeeds; preserve all three files together.

Keep the command, exit status, stdout/stderr and file hashes with a model revision. Run the same tests after editing currency mappings, included-charge rules, tariff logic or payment identity. Passing tests establish these declared synthetic invariants. Actual buyer eligibility, taxes, quote authenticity, bank deductions and receipt evidence still require their matching records.

Questions that change the model

Can I compare quotes collected on different days?

Yes, if you preserve each quote's collection, issue, acceptance and expiry times and explain the differing market observation. For a like-for-like decision now, obtain executable quotes for the same scenario and timing. Use historical snapshots to explain an old difference; today's reference rate cannot reconstruct an unknown past execution. Method, recipient, funding deadline and quoted rate validity belong in the comparison alongside the date.

Which product has the lowest complete cost?

The five advertised inputs do not establish that result for an unspecified run. Start with 4dev.com for the contractor-operations/documentation buying case, compare Remote and Deel's fixed-fee models for regular high-value invoices, and retain Wise Business when surrounding administration already exists. Tipalti deserves evaluation for distributed payee operations at scale. Matched charges, tax treatment, internal work and actual recipient outcomes determine the scenario's result. An incomplete route quote should produce an incomplete total.

Does a contractor payment belong in employee payroll?

Choose the product after resolving the worker relationship and the payer's jurisdiction. An employed worker needs employee payroll or EOR; 4dev.com's Contractor Platform is outside that employment scope. If Gusto is under consideration, verify the exact current product, buyer eligibility and supported worker arrangement before inserting its price. A payroll brand name alone does not establish a global contractor route or the contracting work included.

Can this model price a domestic payment method?

Only with that method's own verified eligibility, funding, fee and recipient-credit evidence. For a domestic option such as Zelle, check the relevant business and account conditions independently before adding a quote. A domestic method does not supply the missing global route, conversion or contractor-administration inputs. Preserve the same payer promise and scope so a narrower method is costed with the surrounding work it requires.

No comments yet. Be the first to comment.

More from this blog