VRPlatformVRPlatform
Build a Product UI

Transactions

Build transaction forms with valid types, party, payment, and locks

Mental Model

Transactions record financial activity that is not represented solely by a reservation. Their lines provide the account, amount, attribution, and related resource context from which VRPlatform creates balanced journal entries.

party means accounting attribution. It does not identify who created, paid, or approved a transaction.

Resources and Lifecycle

Create transaction (POST /transactions) accepts four public types:

TypePurposePrimary shape
depositMoney received or matched to guest/channel activityOne or more lines
expenseCost or billContact, optional payment, markup, attachments
transferMovement between internal banksOne line plus root source account
payoutOwner or manager distributionOne line, optional statement link

Transactions can be active or inactive and remain in history after archive. Recurring transaction templates create scheduled expenses and have their own active state, recurrence, preview, and run lifecycle.

Read Model

Render the returned transaction, payment, latest providerPayment, line attribution, reconciled bank records, attachments, calculated totals, and granular locks. Use resolved listing context from a linked reservation rather than preserving a competing manual listing selection.

Use integer cents. Transaction totals are the sum of line amounts. The server normalizes transfer source direction, and a transfer without a description gets a generated one ("Transfer from ... to ..."); every other type requires an explicit description.

Decision Table

Party Defaults and Options

Transaction and contextDefaultValid public options
Deposit without listing or reservationmanagermanager
Deposit with listing or reservationownersBoth unless a forced rule below applies
Opening-balance depositContext defaultBoth
ExpensemanagerBoth unless a forced rule below applies
Strict internal transferParty-neutral bank postingsHide control
PayoutownersBoth

"Context default" means the same default as a normal deposit in that context: manager without listing or reservation context, owners with it.

owners participates in ownership allocation, owner balances, statements, and owner-side locks. manager records manager-side activity and normally avoids owner-statement period and inactive-ownership guards. The internal journal party none is not valid transaction-line input.

Recalculate visible options whenever type, account, system assignment, category, listing, reservation, or owner-statement context changes. A reservation matcher is not owner context until it resolves.

Forced Party Rules

  • Lodging or occupancy tax payable forces owners for deposits and expenses.
  • deposit_coHostPayout forces owners when the line has a listing or reservation. Without that owner context it defaults to manager.
  • Resolved system accounts-receivable and deferred-revenue accounts force owners.
  • deposit_reserve forces manager.
  • Merchant-fee, channel-fee, and VAT deposits default to manager; an explicit owners value needs listing or reservation context.

Custom accounts with similar categories do not automatically inherit a forced system-assignment rule.

Type Constraints

  • Deposits do not accept line markup. Non-expense transactions, including opening-balance deposits, are always marked paid on the transaction date (the stored date when an update omits it); only expenses carry a separate payment state.
  • Expenses require contactId unless they are opening balances. They alone accept attachmentIds; their lines can carry markup and tax application.
  • Recurring expense templates require a root contactId. Template line party accepts only owners or manager; other values are rejected before the template is written.
  • Transfers require exactly one line, two different internal bank accounts, no statement link, and no markup. The root account is the source and line account is the destination.
  • Payouts require exactly one line. Statement linkage belongs on the line as ownerStatementId.

Editability

Transaction reads expose:

  • transaction.lock for root changes
  • transaction.payment.lock for expense payment changes
  • transaction.lines[].lock for individual line changes

An expense can contain both locked and unlocked lines. Keep independent line controls available, but preflight shared date, currency, type, status, listing, or payment changes because they can affect all postings.

Owner-side listing lines are rejected in an inactive ownership period. Manager-side lines can retain listing attribution when the operation permits it.

For deposits and expenses, a bank-record match is a field-level constraint. The aggregate transaction.lock.status remains locked and transaction.lock.reconciled.bankRecordIds identifies the reason. Do not use the aggregate status alone to disable the complete edit form.

Bank match is the only lockEditability
Transaction and line descriptionsEditable
Line account and system assignmentEditable
Line party (the form's bill-to attribution)Editable
Line listing and reservationEditable
Expense line markupEditable
Line amountLocked; unmatch first
Transaction dateLocked; unmatch first
Root bank or credit-card accountLocked; unmatch first
Add or remove linesLocked; unmatch first
Other transaction or line fieldsLocked unless another documented flow allows them

The rule applies to each touched line. A books-closed period, attached owner statement, or saved statement period still wins and returns its normal lock reason. Transfers are outside this field-level exception. Saving the editable fields preserves the bank-record link.

Changing a deposit line's reservation moves it between reservation and non-reservation items. A bank match alone does not block either direction, and an owner-statement lock on another line does not block the move. Preflight both the source and destination listing periods: the API rejects the move if it would pull an owner posting into or out of a saved statement period.

Preview and Preflight

Create, update transaction (PUT /transactions/{id}), remove, batch, update transaction line (PUT /transactions/{transactionId}/lines/{id}), and recurring-template create/update/remove/run support dry run when the generated OpenAPI declares the dryRun parameter. Transaction journal regeneration intentionally does not; it needs a preview of inserts, updates, deletes, skips, locks, and amount deltas.

Use Preview recurring transactions (POST /transactions/recurring-templates/preview) for occurrence dates and proposed transactions. A dry-run Run recurring template (POST /transactions/recurring-templates/{id}/run) returns provisional generated transactions and rolls them back.

Mutation Recipe

  1. Load eligible accounts, assignments, contacts, listings, reservations, and current granular locks.
  2. Choose type and apply its exact shape.
  3. Resolve owner context and recompute party default, forced value, and options.
  4. Validate linked resource ownership, currency, unique reference, and totals.
  5. Validate payment and reconciliation records.
  6. Request a schedule preview for recurring templates when relevant.
  7. Dry-run the exact complete payload.
  8. Render structured lock or rule failures and discard provisional IDs.
  9. Confirm and apply the same payload.
  10. Re-read transaction, payment, lines, attachments, and locks.

Failure and Recovery

Do not preserve an invalid previous party after a dependent field changes. A value that matched the old default can move to the new default. Valid explicit overrides remain in place, and a forced rule replaces anything that conflicts.

Linked accounts, contacts, listings, reservations, bank records, and statements must belong to the selected team. Re-read after a concurrency failure. On a locked delete, offer archive only when structured context supports it. If two requests delete the same transaction concurrently, one removes it and the other returns the standard NOT_FOUND response. Treat that response as an already-removed resource and re-read the collection.

uniqueRef identifies a transaction within its team. Every line uniqueRef must also be unique inside that transaction; omitted line references are assigned deterministic line1, line2, and later values. Avoid explicit references that collide with those generated values.

Connection-scoped batch retries with the same transaction uniqueRef are serialized. A repeated transaction or duplicate line reference is returned as that item's structured issue rather than aborting the batch with a database constraint error.

Payments and Reconciliation

Expense payment state lives under payment. Linked bank-record totals must equal the transaction total. Transfer bank records must each match the transfer magnitude. A paid expense without a payment date uses transaction date.

Provider Payments

POST /transactions/{id}/pay has two explicit paths:

  • provider=null records an active expense as paid outside Ramp through the normal transaction update path. Books-closed, statement, and reconciliation locks still apply, and the payment date defaults to today.
  • provider="ramp" requests ACH execution for an active vendor expense or an existing owner payout. Expenses dispatch transaction.updated to expensesToBills; payouts dispatch their linked ownerStatement.updated to transfersToBills. The local paid state changes only after provider settlement is ingested.

Ramp execution requires exactly one active matching Ramp flow, a recipient contact with paymentMethods.ach.status=ready, and a ready selected funding account identified by fundingAccountId. The selected AccountConnection must resolve to one imported Ramp entity and source bank account; those exact provider IDs are pinned to the dispatch instead of choosing a default Ramp entity or account. A request returns syncId plus the latest provider attempt. Reusing an idempotencyKey returns the same request only when its transaction and funding account match; using it for a different payment returns 409.

Each outbound Ramp bill line uses the account assigned to that VRPlatform transaction line as its Ramp Category. Payout and payout-style transfer lines without an explicit account use the team's payout_distribution account, matching the journal engine. These accounts must exist in Ramp through the VRPlatform chart-of-accounts push flow.

Transaction reads expose the latest attempt as providerPayment with requested | processing | paid | failed | returned | canceled, provider reference, failure reason, and stable attempt ID. Use GET /transactions/{id}/provider-payments for the complete attempt history. A terminal failed, returned, or canceled attempt can be retried through the same pay endpoint. requested attempts can be unlinked before dispatch; terminal attempts can also be unlinked. Processing and paid attempts cannot be unlinked.

The endpoint supports dryRun=true; dry runs persist no sync or accounting change and dispatch no provider work.

Attachments

Upload expense attachments first with POST /transactions/attachments, then send returned IDs in attachmentIds. Upload is an external storage operation and is not part of mutation dry run.

On update, omit attachmentIds to keep the set, send an empty array to remove all, or send the complete desired set. Owner visibility is stored per attachment. Only expenses accept attachments.

Recurring Templates

Templates need a contact, expense and line configuration, payment status, start and optional end, plus daily, weekly, monthly, or yearly recurrence. Inactive templates and dates that are not due cannot run.

Common Recipes

Owner expense

Select an expense account and listing, offer both parties unless an account rule narrows them, choose owners, set payment data if known, then dry-run the complete expense.

POST /transactions?dryRun=true
Content-Type: application/json

{
  "type": "expense",
  "date": "2026-07-18",
  "currency": "usd",
  "description": "Pool cleaning",
  "contactId": "33333333-3333-4333-8333-333333333333",
  "payment": {
    "status": "unpaid"
  },
  "lines": [
    {
      "description": "Pool cleaning",
      "amount": 12500,
      "accountId": "22222222-2222-4222-8222-222222222222",
      "listingId": "11111111-1111-4111-8111-111111111111",
      "party": "owners"
    }
  ]
}

Internal transfer

Select two different internal bank accounts, hide party and markup controls, enter one destination line, validate any bank-record magnitude, and preflight.

POST /transactions?dryRun=true
Content-Type: application/json

{
  "type": "transfer",
  "date": "2026-07-18",
  "currency": "usd",
  "accountId": "22222222-2222-4222-8222-222222222222",
  "lines": [
    {
      "description": "Move funds to operating account",
      "amount": 50000,
      "accountId": "66666666-6666-4666-8666-666666666666"
    }
  ]
}

Statement payout

Prefer the statement pay operation. The payouts it creates carry the statement reference on their lines, even when the payout journal date belongs to a later statement month.

POST /statements/pay?dryRun=true
Content-Type: application/json

{
  "date": "2026-08-08",
  "statements": [
    {
      "statementId": "55555555-5555-4555-8555-555555555555",
      "payouts": [
        {
          "contactId": "77777777-7777-4777-8777-777777777777",
          "provider": null,
          "bankAccountId": "66666666-6666-4666-8666-666666666666"
        }
      ]
    }
  ]
}

Webhook Reconciliation

transaction.changed is emitted only after the externally visible aggregate and required journal, payment, reconciliation, or attachment effects reach their final state. It covers create, material update, archive, and deletion; no-ops and journal-only changes that leave the public response unchanged do not emit. The body contains only resource ID, change type, version, and href—no type, description, amount, currency, contact, account, listing, bank, statement, or journal detail. Recurring templates are a separate resource and do not emit this event until they create or update a transaction.

API Reference

On this page