VRPlatformVRPlatform
Build a Product UI

Banking

Build accounts, bank records, reconciliation, and bank-rule workflows

Mental Model

Accounts define where amounts post. An Account with type=bank is the bank account used by transactions, journals, statements, and balances.

An AccountConnection is an optional external account identity discovered inside a Connection. It links imported activity to a bank Account but is not a second bank account. Bank records represent imported financial activity; reconciliation links them to transactions, and bank rules turn matching records into proposals or automatic actions.

Resources and Lifecycle

Accounts and Assignments

Account types are ledger, bank, recurring fee, and non-posting. Categories, system assignments, active status, and banking configuration add behavior.

Bank Accounts distinguish trust, operating, and external categories plus deposit and credit-card behavior. account.banking.connections[] contains AccountConnections; each item id is an accountConnectionId, while its nested connection.id is the Connection ID.

Account and AccountConnection reads expose capabilities.achPayments as { enabled, disabledReasons }. AccountConnection reasons distinguish an unsupported provider, inactive connection or account identity, unsupported account type, and missing provider reference. Account reads aggregate their embedded AccountConnections and enable ACH when any identity is ready. For Ramp, readiness requires an active deposit AccountConnection with its provider account ID in uniqueRef. persistentRef remains the replacement-stable Plaid identity and is not a Ramp ACH requirement.

Bank records can be transaction or balance records, active or inactive, reconciled, or matched by a rule. A CSV-imported bank record can reference only the canonical accountId; its accountConnectionId remains null.

Plaid Connect Sessions

Plaid setup is a three-operation session workflow. Start Link with POST /plaid/connect, complete Link and receive discovered bank accounts with POST /plaid/connect/complete, then submit every account decision together with POST /plaid/connect/configure.

Session start accepts optional environment=sandbox|production and defaults to production. The selected environment is persisted on connectId; completion and configuration do not accept it again. Update and replacement sessions with reusable credentials retain the existing connection's environment.

New create and replace sessions request Plaid Transactions only. Plaid Auth is not part of reconciliation and would exclude credit-card accounts from Link. update sessions omit product selection and retain the existing Item's products.

The returned connectId owns idempotency and recovery. Completion fetches account metadata synchronously, but never waits for transaction history. Its response is the complete configuration read model: accounts contains eligible existing GL bank accounts, bankAccountCategories reflects team capabilities, and each bankAccounts row contains configuration status, suggestions, capabilities, and typed issues. The final configuration creates one durable initial sync after all selected GL account mappings and banking initialization have committed.

Production sessions reserve last four digits across active and inactive GL bank accounts. Plaid sandbox institutions reuse fixed test masks, so sandbox sessions do not use last-four matches for suggestions, duplicate-account issues, or canConnect and canCreateAccount capabilities. Provider account identity duplicate checks still apply. Sandbox masks remain visible on the AccountConnection but are not copied onto a created or linked GL bank account; an existing GL last-four value is preserved, and the intentional difference does not produce an account-connection drift warning.

Plaid credit feeds default to credit-card behavior. Relay Financial accounts whose names end in Credit Account receive the same classification even when Plaid reports them as checking/depository accounts.

Bank Records and Matching

Imported records retain source and account identity. Reconciliation and rule matching are relationships with their own state and recovery actions.

Batch ingest warns about possible cross-source duplicates between CSV stopgap imports and bank feeds. When a new record's account, date, and exact amount match an active record from the other source type (and no identity match by sourceId/uniqueRef exists), the batch response reports a static issue. Its context identifies the candidate record and source type. Because those values are not a unique transaction identity, neither record is merged or skipped. Warning candidates are paired one-to-one per batch. Balance records and opening balances are exempt.

Read Model

Render returned account category and assignments, banking capability issues, bank-record source and reconciliation state, delete locks, matching candidates, and rule preview results. Historical postings retain inactive account identity. Matched sorting evaluates the full filtered set before pagination, so page boundaries preserve the same matched-first or unmatched-first ordering.

Use GET /account-connections?connectionAppId=ramp or filter by connectionId to discover provider-backed identities that can fund ACH requests. Each row's capabilities.achPayments reports readiness and typed disabled reasons. Use only rows where that capability is enabled; a Ramp Connection by itself does not make every embedded account ACH-capable. No provider-specific funding-account endpoint is required. Payment execution also validates that the AccountConnection source contains one Ramp entityId and bankAccountId, and that the provider bank ID matches the AccountConnection uniqueRef. Those IDs become the bill's entity_id and source_bank_account_id.

Owner contact reads expose only masked ACH metadata: provider, connection, last four digits, and notConfigured | pending | ready | failed. Submit full legal name, routing number, and account number only to POST /contacts/{id}/payment-methods/ach. Raw bank details travel to the targeted Ramp flow and are never stored in VRPlatform. Do not submit a connectionId: the server selects the single active Ramp Connection whose pushRampVendors flow listens for contact updates. The first ACH setup activates that flow when its status has never been configured; an explicitly inactive flow remains unavailable. Missing setup returns 422; multiple eligible setups return 409. A successful submission is pending until provider confirmation; owners may update only their own contact.

Statement payout preview also treats a missing transfersToBills status as an eligible, unconfigured Ramp flow without mutating it. The first otherwise-valid POST /statements/pay Ramp payout activates that flow before dispatch. Dry runs roll back the activation, and an explicitly inactive flow remains unavailable.

Decision Table

Accounts and Feeds

DecisionRule
Immutable categoryDisable category selector
Credit-card beginning balanceNegative feed checkpoint; liability-credit journal
Account deletiononLocked=error (default) or archive; active fee references block both
Attach conflictonConflict=error (default), replace, or replace-and-detach-history
Detachmode=preserve-history (default) or detach-history
Plaid createStart a new Item; omit connectionId; environment defaults to production
Plaid updateRepair an Item; require connectionId and reuse credentials
Plaid replaceReplace credentials; require connectionId and create a new Item
Plaid feed not selectedSubmit action=archive; the feed becomes inactive

When initializing banking (POST /accounts/{id}/banking-init), a credit-card beginning balance has two representations. The bank feed stores the checkpoint as a negative balance so later charges increase the displayed liability. The opening-balance transaction keeps a positive line total so the journal credits the credit-card account and debits the opening-balance equity account. Deposit accounts retain the submitted sign for both representations. Initialization writes the account state, opening transaction, and feed checkpoint atomically and returns the complete transaction. Concurrent account deletion is serialized with initialization: if deletion wins first, initialization returns the standard not-found response; otherwise the complete initialization response is retained even if the account is deleted immediately afterward.

Accounts return categoryLock with status: locked and reason: immutable; respect it and disable the category selector instead of submitting a classification change. A deletable account removes its allowed bank-record history atomically. Protected journal, reconciliation, and other dependencies retain the existing delete lock; use onLocked=archive only when the user chooses to preserve the account as inactive. An account used as a direct or tax posting account by an active recurring fee cannot be deleted or archived. Repoint or deactivate the fee first; historical postings remain available after the account is archived. An attach conflict occurs when the target bank Account already has an active AccountConnection. replace deactivates the old connection, while replace-and-detach-history also detaches its unreconciled bank-record history. On detach, preserve-history keeps imported records attached while detach-history detaches the unreconciled ones.

Bank Rules

DecisionRule
Internal transfer accountInternal bank account; never external
Rule conditionDescription or amount comparison
Multiple conditions`matchMode=all
AllocationFlat, percentage, or one remainder line
Automatic modeCreate and match only after valid preview/configuration

Flat and percentage rule lines cannot be mixed, and a percentage rule must satisfy the full percentage allocation without a remainder line. Every line needs an account or supported assignment.

Editability

Bank-record lock.delete.reasons[] can include notCsvSource, plaidSource, reconciled, balanceRecord, or bankRuleMatched. Disable deletion and show the corresponding recovery instead of trying the request repeatedly.

Reconciliation constrains transaction payment edits independently from line locks. Account assignment and category changes can affect future journal construction and party rules.

Preview and Preflight

Use Preview a bank rule (POST /bank-rules/preview) to show matching records and allocations. Run a saved bank rule (POST /bank-rules/{id}/run) supports dry run. Rule configuration has schema validation but no generic mutation dry run.

Database-backed AccountConnection and bank-record mutations expose dry run where declared. Plaid and other external-system actions have side effects and do not.

Mutation Recipe

  1. Load account, category, assignment, capability, and current issue data.
  2. Validate bank classification and internal/external eligibility.
  3. Load bank-record locks and reconciliation candidates.
  4. Validate rule conditions and allocation shape locally.
  5. Preview the rule or matching proposal.
  6. Dry-run the final supported database mutation or saved rule run.
  7. Confirm and apply.
  8. Re-read account, record, transaction payment, and sync state.

Failure and Recovery

Use account issues[] for setup repair, such as a last-four mismatch or an operating bank account on a team where the Operations Accounting feature is not enabled (see the issue catalog). For delete locks, direct the user to unreconcile, preserve imported history, or remove the matching relationship as the reason requires. External synchronization failures belong in connection health, not a local database rollback flow.

Plaid completion and provider calls have finite deadlines. A retryable PLAID_PROVIDER_UNAVAILABLE or PLAID_CONNECT_IN_PROGRESS response keeps the same connectId; retry the same completion action instead of creating another Link session or polling bank accounts. If completion returns PLAID_CONNECT_REQUIRES_NEW_LINK, follow its strict context directive:

  • { "restartMode": "create" } starts a new create session without a connectionId.
  • { "restartMode": "replace", "connectionId": "..." } starts replace mode for exactly that tenant-scoped connection.

Never infer the restart mode from local UI state or reuse the failed connectId.

If final configuration returns PLAID_CONNECT_CONFIGURATION_CHANGED, replace the screen model with context.result and ask the user to confirm the refreshed choices. The session is immediately ready for another configuration request; do not reopen Link or wait for the old lease. This 409 is a discriminated OpenAPI response: context.result has the same schema as successful Plaid completion/configuration, while optional accountIds and bankAccountIds identify the changed Account and AccountConnection inputs.

Plaid Link renders embedded provider errors before completion. In particular, INVALID_UPDATED_USERNAME means update mode cannot repair the Item; start a new replace session for the same connection.

Common Recipes

Match an imported expense

Load candidates, select the transaction, verify record totals, dry-run the supported match/update, apply, and re-read both the record and payment lock.

PUT /transactions/{transactionId}?dryRun=true
Content-Type: application/json

{
  "payment": {
    "status": "paid",
    "date": "2026-07-18",
    "bankRecordIds": ["55555555-5555-4555-8555-555555555555"]
  }
}

Create a percentage bank rule

Choose conditions and source accounts, use only percentage allocation lines, validate the full allocation, preview matches, then create the rule (POST /bank-rules) and test it.

Send this payload to /bank-rules/preview first, then to /bank-rules after the proposed matches and allocations are confirmed:

POST /bank-rules/preview
Content-Type: application/json

{
  "name": "Split cleaning charges",
  "transactionType": "expense",
  "mode": "suggest",
  "matchMode": "all",
  "conditions": [
    {
      "type": "description",
      "operator": "contains",
      "value": "cleaning"
    }
  ],
  "vendorContactId": "33333333-3333-4333-8333-333333333333",
  "accountIds": ["22222222-2222-4222-8222-222222222222"],
  "lines": [
    {
      "type": "percentage",
      "amount": 60000,
      "accountId": "44444444-4444-4444-8444-444444444444"
    },
    {
      "type": "percentage",
      "amount": 40000,
      "accountId": "66666666-6666-4666-8666-666666666666"
    }
  ]
}

Connect Plaid

  1. Start create, update, or replace mode, optionally select the environment, and open the returned Link token.
  2. Complete the same connectId; render accounts, bankAccountCategories, and bankAccounts from that response without another setup read.
  3. Use each row's configuration.status, capabilities, suggestions, and issues; do not reconstruct eligibility or duplicate detection in the UI.
  4. Preserve accounts already mapped by an unambiguous update or replacement.
  5. Submit one connect or archive decision for every row whose configuration status is required.
  6. Send optional opening balances as { date, centAmount }.
  7. Observe the returned sync through the normal sync APIs.

Do not poll GET /account-connections for discovery and do not call the individual feed mapping endpoint in a loop. Do not fetch /accounts to populate the session configuration screen. For entry screens outside a connect session, GET /account-connections?connectionAppId=plaid&status=active&linked=false returns the current unlinked Plaid feeds; configuration still re-reads and validates the completion model.

API Reference

On this page