VRPlatformVRPlatform
Build a Product UI

Statements

Build owner-statement lifecycle, layout, payout, and editing UI

Mental Model

An owner statement presents owner-side financial activity for one ownership period, month, and currency. Listing plus month is not enough: a mid-month ownership change can produce several statements.

A statement's dates are the month clamped to its ownership period. When an ownership period's startAt or endAt changes, or a period is created or deleted next to it, the stored dates of that listing's draft statements move with it, so each owner's statement for the month of the change keeps its own start date. A draft whose whole month now lies before its ownership period is deleted, like the drafts of a deleted period. A draft after the period end keeps its full month window, because it collects payouts that cleared after the owner left. inReview and published statements keep their dates; a locked statement range blocks the ownership change instead. When another statement of the listing already starts on a draft's new date, the ownership change fails with Statement already exists for listing and start date.

Resources and Lifecycle

Lifecycle

StatusJournal attachmentNormal owner visibility
draftNoTeam setting can allow it
inReviewYesNo
publishedYesYes

Moving out of draft attaches eligible entries and closes owner-side activity for the period. Returning to draft removes attachment locking while preserving statement identity. Current writes use only these three statuses.

Status Transitions

Use Update statement status (PUT /statements/{id}) for every transition.

Ordinary statements can transition between draft, inReview, and published in both directions, subject to these enforced blockers:

TransitionBlocked when
Any status changeA later statement is inReview or published for the same ownership period
To inReview or publishedError issues or a non-current journal
Draft or inReview to publishedPrior month is unfinalized or has unattached owner activity
To draftScoped API caller lacks statements:publish
Imported historical statement to draftAlways; re-post the import

A later Draft does not block an earlier statement. It has no attached journal entries and remains persisted while the earlier statement is corrected. Reads recompute that Draft after the statement chain revision changes.

When a later Review or Published statement blocks the change, create and update return 409 CONFLICT. Display the API message. Use context.reason: futureStatementExists, futureStatementId, futureStatementStartAt, futureStatementStatus, and nextAction: returnStatementsToDraft to open the blocking statement and offer the recovery action. Retry the original change after the blocker is Draft.

Changing between inReview and published preserves the existing journal attachments because both statuses are locked. Repeating the current status is idempotent for journal attachments. It is accounting-read-only when the cached detail is current; if journal changes invalidated that detail, the request refreshes the statement and projection without detaching entries. A repeated published request with emailDelivery still schedules the requested delivery.

Call GET /statements/email-recipients with the ownershipPeriodId before publishing or resending to show who receives the email. recipients lists the owner contacts. Each receives a no-login link at its contact email and gets the team's ownerStatementLink email template. viewers lists the owners' active portal viewers. They get an email that links to the owner portal and use the ownerStatementPublished template. A type without a saved template uses its default text.

Owners and viewers who receive nothing stay in the lists with a skipReason: missingEmail when the owner has no email, unsubscribed when the owner used the unsubscribe link, and notificationsDisabled when statement emails are off. Each owner contact has a statementEmailEnabled switch. Until the team sets it with PUT /contacts/{id}, it is on while the owner has an active portal login and off otherwise. Setting it to true also resumes an owner who unsubscribed. A viewer is skipped after turning statement notifications off in the owner portal. A viewer with the same address as an emailed owner gets only the owner email.

Call GET /statements/{id}/email-history to see every email sent for a statement, newest first. Each send has its action (publish, resend, or newLink when an owner asked for a new statement link) and each recipient has an outcome: sent once the email provider accepted the email, skipped, failed, pending, or canceled. Skipped and failed recipients carry a reason, such as missingEmail, unsubscribed, or notificationsDisabled when statement emails are off for an owner or a viewer turned them off. Owner contact recipients also carry firstOpenedAt and lastOpenedAt once the owner opens the statement from that email's link; session renewals while the page stays open do not count.

Each recipient delivery is independent. Retrying one recipient does not resend an email that Postmark already accepted for another recipient. Permanent provider rejections are recorded once. Rate limits, provider server errors, and transport failures receive bounded retries.

Statement creation is serialized per ownership period before financial detail is calculated. Concurrent POST /statements retries for the same statement therefore reuse the committed statement and its attachments; they cannot apply an earlier empty calculation over the persisted financials. Use PUT /statements/{id} when the requested status differs.

A listing and effective statement start date can identify only one persisted statement. If that storage identity is already occupied by a different ownership-period, currency, party, or unique-reference identity, POST /statements returns 409 with the existing statement ID and requested identity details. Reload the listing's statements instead of retrying the create as a transient failure.

For scoped API credentials the statements:publish scope gates the published boundary in both directions: publishing a statement and moving a published statement back to draft or inReview both require it. Re-posting Create statement regenerates an existing statement but returns 409 if it would move a published statement away from published — status transitions always go through the update endpoint.

Publish order is enforced per ownership period and currency: finalize earlier months before publishing a later one. The status update does not check for an existing payout, so unpublishing a paid statement leaves its payout transaction in place; paying again without an explicit amount is rejected while an active payout exists. Edits made while it is draft change its balance end; the next statement's balance start follows, and the difference to the payout shows as balance forward.

DELETE /statements/{id} is permanent deletion, not a status transition. A statement referenced by a payout transaction cannot be deleted, including with onLocked=unlockAndDelete. Move it to draft through PUT /statements/{id} so the stable statement ID and payout association remain intact.

Layouts and Visibility

Layouts define sections, columns, formulas, account groups, and owner/manager visibility. Listing mappings choose the active layout.

Payouts and Unallocated Accounts are system section names. POST /statements/layouts and PUT /statements/layouts/{id} reject a net revenue or custom section with either name, ignoring case and surrounding spaces, with 400 and the message Section name is reserved for a system section. details.sectionNames lists the rejected names. Rows carry only the section name, so this keeps a custom section from sharing a name with a system section.

Two sections in one layout cannot share a name, net revenue and custom sections together. The same endpoints compare names ignoring case and surrounding spaces and reject a repeat with 400 and the message Duplicate section names. details.sectionNames lists the repeated names.

A Net Revenue column of type accounts or subTotal can carry the revenue tag rentsRevenue in tags. The owner dashboard reads rent from that column. The same endpoints reject a tag with 400 in these cases:

MessageCauseDetails
Column type cannot be taggedThe column is a field or formula column, or not in a Net Revenue sectioncolumnIds
Tagged column must be visible to ownersThe column or its section has visible: 'manager'columnIds
Tag used on multiple columnsA second column in the layout has the same tagtag, columnIds

A layout update that changes only tags keeps the layout version, so statements on the layout do not get layoutOutdated. Statement detail shows the current layout's tags, also on published statements. Owner views never include tags. When the team's showOwnerDashboard setting is on, GET /statements/{id} and GET /statements/by-period add the warning rentsRevenueTagMissing with context.layoutId to the manager view of a statement whose current layout has no column tagged rentsRevenue.

GET /statements/layouts filters by tag: rentsRevenue returns layouts with a Net Revenue column carrying the tag, !rentsRevenue layouts without one. isUsed=true keeps the layouts the team's active listings use, isUsed=false the others. An active listing uses its mapped layout, else the default layout. The app shows these layouts as a warning when a manager turns on the owner dashboard; turning it on is still allowed.

Read Model

GET /statements/by-period accepts either ownershipPeriodId or ownerId, plus month and optional currency. With ownerId, optional comma-separated listingIds select several listings; omitting them selects all of that owner's listings. Currency defaults to the team currency for owner selections. The response remains { data: [...] } with full statement details for every matching period, including separate periods within the same month.

GET /statements/by-period?ownerId=<owner-uuid>&month=2025-06&currency=usd

Owner selections always return the owner view, including manager previews. Unsaved drafts have id: null; periods with no saved statement, journal activity, or opening balance are omitted. Grouped child listings resolve to their parent statements without duplicates. Owner sessions cannot select another owner's contact or listings.

When ownerPortalShowDraftStatements is false, owner selections omit Draft and In Review statements. When true, both are visible. Owners can read this setting from GET /team. Owner detail views include owner-accessible expense attachments for drafts and exclude private attachments and manager-only columns. The existing single-period read retains its preview behavior, including returning a draft for an empty period.

Render server-calculated detail, totals, rows, source records, issues, locks, and the returned layout for the requested view. Do not recompute allocation, totals, or owner visibility in the browser.

Statement list, by-period, and detail reads attach live journalStatus and operations metadata after loading the statement projection. This metadata is not cached with the financial payload. Planned impact includes reservations that may enter the statement after recalculation, even when no current journal entry places them in the statement yet. Render the last committed statement with a recalculating, failed, or stale indicator. current means no outstanding refresh requires attention in the statement scope, excluding completed lock skips. The statement operations list excludes refreshes that completed as lock skips, the same rule as current.

Importing an older reservation does not by itself make its import month part of the accounting scope. The creation date supplies the booking date only when the reservation has no booking date. Existing journal postings and explicit posting dates still contribute to the affected months.

A statement scope with no known invalidations is current, even when its freshness timestamps are null. An empty refresh history alone does not block publication or require recalculation. Existing statement validation and accounting locks still apply.

journalStatus.staleSince is the oldest unapplied change in the statement's scope, excluding completed lock skips. calculatedAt is the latest successful reconciliation of any included reservation. A later calculatedAt does not clear an earlier outstanding change on another reservation. Use state to determine freshness; do not compare these timestamps to decide whether to hide a warning.

Completed refreshes whose latest attempt was skipped because of accounting locks do not produce statement freshness warnings or prevent review, publication, or payout. If only these skips remain, the statement returns current with a null staleSince. Pending refreshes, other failures, and unknown outcomes still affect the returned status and freshness guard, including when they share a statement with lock skips. A retry becomes outstanding again while it is running.

The skipped changes remain unapplied in the reservation history. The reservation returns reason: journalLocked and action: reviewAccountingLock. This statement filter does not change posted amounts or release accounting locks. Inspect the reservation before deciding whether a correction is needed; a rejected refresh alone does not establish that posted amounts are wrong.

Every non-current statement status blocks finalization and payment where the freshness guard is enabled, journalLocked included. That reason reports the reservation's current lock, which can be an entry from an unrelated month, so it cannot prove that the outstanding refresh was declined rather than failed. A refresh the locks genuinely declined completes as a lock skip, is excluded from the statement's outstanding set, and reports current.

The freshness guard is a per-team switch that VRPlatform staff set. Enabling it requires current journals for finalization and payment of that team's statements only. Clearing it restores the earlier behavior and changes no journal, statement, or payout. Each change is recorded in the internal audit trail and is not part of the public audit event catalog.

A failed statement status carries an optional failures array naming the reservations behind it: reservationId, confirmationCode, invalidationId, the month the failed change belongs to as YYYY-MM, its own reason, and a fixed message to display. Entries appear on every failed reason, including journalLocked, where they name the unresolved build failure sitting behind the lock. Completed accounting-lock skips are never listed. Entries are scoped to the statement's own ownership period, month and currency, and an entry's reason can differ from the statement's, because a statement aggregates several reservations. Treat the array as optional: it is absent when nothing outstanding is a terminal failure.

Statement detail includes a Period activity drilldown for the complete statement and every rendered row. Eligible numeric, non-field cells include a more specific drilldown; subtotal cells expose one only when they carry an account scope. Use this metadata as returned instead of rebuilding journal filters in the browser. If a cached detail response is missing required drilldowns, the API rebuilds it before returning the statement.

Projection persistence does not replace canonical report computation. When a fresh statement or owner-summary result is ready and only its derived-cache write exhausts a database connection, the API returns that fresh result and records the infrastructure failure. It never returns a stale cached payload; other database failures retain their normal error behavior.

Statement list filters named statementIds, periodIds, listingIds, and ownerIds accept comma-separated UUIDs only. Display references such as 2026-4360 are not interchangeable with statement IDs; invalid identifier formats return 400 BAD_REQUEST before any statement query runs. CSV list exports enforce all four filters, while totals enforce their supported listingIds and ownerIds filters.

An owner user who sends an ownerIds value outside their own owner access gets 403 FORBIDDEN on GET /statements, GET/POST /statements/csv, and GET/POST /statements/detail/csv. The response context lists the rejectedContactIds and the caller's own ownerAccessContactIds.

For month-based statement lists, search performs a case-insensitive partial match against the listing name, listing Unique Ref, listing Short Ref, and each ownership member's first name, last or company name, and combined full name.

Statement list and totals aggregates sum every matching statement per currency before pagination. paymentReceived is the sum of each statement's payment.received, so it matches the total of the list's paid amounts.

Statement lists propose no Draft for months before the team's statementStartAt month. Saved statements in those months still appear. Creating a statement for a month that starts before statementStartAt returns 400 BAD_REQUEST with reason: beforeStatementStartDate.

A month of an ownership period with setListingInactive=true is left out of statement lists when it has no activity, no carried-forward or opening balance, and no In Review or Published statement. Otherwise the statement is listed, and its detail returns the error issue listingInactive, which blocks moving it to inReview or published.

Draft statement reads use current journal totals, including changes made after the draft was saved. Journal changes invalidate cached reads automatically; no status change or manual refresh is required. Differences from an older draft snapshot do not produce locked-financial mismatch warnings.

Non-draft statements preserve their saved totals and effective presentation. A later layout change does not silently rewrite old statements. Statement PDF and CSV exports show the same saved totals as GET /statements/{id}.

When current journal data no longer matches the saved totals of an In Review or Published statement, the statement still returns the saved totals and adds a warning issue for each differing figure: balanceMismatch_start, netRevenueMismatch, expensesMismatch, payoutsMismatch, or balanceMismatch_end. Imported historical statements are not compared. Each warning's context holds statementAmount (saved), journalAmount (current), and delta (journalAmount - statementAmount) in cents. balanceMismatch_end compares the closing balance, or net income when only net income differs. Its context.figure is balanceEnd or netIncome.

Rows in layout otherSections preserve source-line identity. Reservation adjustments with different line IDs render as separate rows even when they share the same reservation and posting date; each row keeps its own description, account category, and amount. Net-revenue sections can still combine reservation activity into one reservation row according to the layout.

A first statement's opening balance uses the active opening-balance transaction for the listing owned by its ownership period and currency. The listing balance remains authoritative when that ownership period covers the statement start boundary, even if the opening-balance journal references the preceding period. For a listing group, the parent balance is authoritative. Legacy child opening balances do not contribute even if their journals still reference the parent's ownership period. Saved non-draft balances are not silently rewritten during reads.

A statement starts from the ending balance of the latest live In Review or Published statement of the same ownership period and currency. A new ownership period therefore starts at zero unless an opening balance applies.

Year and date-range lists carry each draft month's ending balance into the next draft month of the same ownership period and currency. Summary amounts and payment balances reflect that carried balance.

When a managed ownership period starts on the day a co_host period of the same listing ended, its first unpublished statement starts with the co_host period's last saved ending balance. Statement detail, create, the month list, and the year and range lists all use this balance. Saved published statements keep their stored balances.

An imported historical statement is a snapshot of what was previously sent, not the source of the first live statement's starting balance. The first live statement uses the configured opening balance, so its starting balance does not need to equal the final historical ending balance. The API does not report that historical-to-live difference as a balance mismatch. It continues to report ordinary balance disagreements between live statements.

Each returned payout includes its payment balance and its payments with provider, method, status, settled and posting dates, amounts, masked destination suffix, allocations, and any reversal. Use GET /transactions/{id}/payments on the payout for server-owned actions.

To read an owner's balance on a date that is not a statement boundary, use GET /reports/owner-balances. It includes activity not yet on a published statement and does not change payouts or paid status.

A statement's payouts array lists the payouts made for that statement, even though a later statement's totals usually include them. It also lists the statement's payouts that still owe money, such as an ACH payout whose Ramp payment has not settled; those carry their open amount and payments but move no statement total until they settle. An owner receipt that records a returned payout carries the original payout's statement, so it is listed on that statement and counts in the totals of the month it posts in. It is empty for an unsaved Draft. financials.payouts is a different figure: the payout activity included in this statement's own totals, usually payments of an earlier statement.

Owner-summary payouts use the payout journal contact when it identifies a member of the ownership period. That owner receives the complete tagged payout. A listing payout tagged to a non-owner payee is shared across the period's owners by their ownership percentages. Transfer rows without any contact remain excluded because they do not provide safe payout attribution. For an allocated payout, use the returned drilldown as-is; it keeps the source payee contact needed to open the supporting journal activity.

Partner Portfolio Counts

Use List partner owner statements (GET /partner/owner-statements) to build a partner-level statement queue. The response contains every child team stored in VRT (storageRealm=vrtrust) in the selected regional partition, including teams with zero statements. Each team includes its identity, optional logo, creation timestamp, and persisted owner-statement counts for draft, inReview, and published. Use page and limit, or offset and limit, to traverse the response; pagination.total counts all matching VRT teams before pagination. Set sort to draft, inReview, or published to order by that status count ascending, or prefix the value with - for descending order. Equal counts retain deterministic team name and ID ordering. Use search to filter teams by a case-insensitive partial name match; filtering is applied before pagination.total is calculated.

The counts exclude manager statements and VRI teams. For a multi-region portfolio, call each region advertised by GET /me and retain region identity when combining the results.

Decision Table

Payouts and Description Editing

DecisionRule
IdentityOwnership period + month + currency
Owner displayRequest owner view; do not hide manager data client-side
Manager displayRequest manager view
Persist historyUse returned frozen layout/detail
Refresh layoutExplicit refresh, never automatic on read
Owner payoutPay owner statements with one selection per owner
Manager payoutPay manager statements
Summary payout attributionOwner contact in full; non-owner payee by ownership split
Inline descriptionRequire descriptionEdit, no descriptionLock; edit

Exports

Statement CSV, PDF, and ZIP routes follow the generated export contract. Every artifact path exposes GET and durable POST. GET takes the selector in query parameters and checks admission. POST takes the same selector fields in its JSON body.

An explicit ownership-period selector fails with 400 BAD_REQUEST when any selected period has no owner. The response context contains reason: ownershipPeriodsWithoutOwners and the affected ownershipPeriodIds. Remove those periods or assign the intended owners before retrying. Date and listing range exports retain their existing discovery rules.

Combined statement CSV and ZIP range exports omit unattached journal months entirely before the ownership period starts. Those entries remain in the ledger. Published and in-review statements keep their saved month, and post-ownership adjustments remain eligible.

For a single PDF, call GET /statements/pdf with the ownership period and month. contactId is optional; when provided, it must identify an owner in the selected ownership period. A manager mismatch returns 400 BAD_REQUEST; reload the period's current owners before retrying. Owner credentials receive an opaque access denial for contacts outside their scope before rendering.

GET /statements/pdf renders in the API request. Statement CSV routes and statement PDF batches render in Trigger and return only artifact metadata through the API. Report exports that render in the API request are listed in Reports. An unavailable optional team logo is omitted from PDF exports; the statement's financial content still renders.

POST /statements/pdf accepts the same selection when the caller needs a durable operation. For a batch ZIP, use either:

  • ownershipPeriodIds plus one month; or
  • one listingId plus an inclusive startMonth and endMonth.

Batch ZIPs require POST /statements/pdf/batch, including single-period and single-month selections. A valid batch GET returns 422 EXPORT_REQUIRES_POST before rendering; submit the same selector with POST. POST creates an operation that exposes an authenticated file link after completion. Batches support up to 100 unique ownership periods or listing-months, with a date span of at most 60 months. The batch renderer removes duplicate ownership period IDs. A failed render or upload marks the operation as failed and does not publish a partial archive.

Manager-statement PDF exports produce a PDF for one unique month and a ZIP for multiple months. Use the completed operation's file resource metadata rather than assuming a .pdf extension.

Owner-summary detail and batch PDF exports accept an optional layoutId. A batch ZIP with layoutId contains only the owners whose summaries use that layout. If no selected owner uses it, for example because the layout was removed after the report loaded, the export operation fails. Reload the report's layouts before retrying; do not repeat the stale layout ID. An export without layoutId uses the first layout returned by the report.

Editability

Non-draft statements create attachment and owner-period locks. Manager-side transaction lines normally do not participate in the owner-statement period lock. Use statement IDs and lock hits, not month alone, to explain coverage.

Row descriptions can have narrower editability than the surrounding financial resource. A row backed by ambiguous source lines is not safely editable.

Preview and Preflight

Statement create, update, delete, pay, manager pay, description mutation, and historical import all take dryRun where the generated OpenAPI declares it. Layout mutations do not. Render returned validation and issues before applying an explicit layout refresh.

Before paying, call POST /statements/pay/preview. It returns each statement's authoritative owner allocations after rounding, default bank accounts, ACH payment-method readiness, and typed blocking reasons without creating payout transactions. Submit the intended owner selections to validate them, or omit payouts to receive server-computed defaults. The pay endpoint revalidates the same rules; the preview is advisory. Preview and payment return 409 JOURNAL_RECALCULATION_PENDING when an affected journal is not current. Keep the statement visible, show its journalStatus, and poll the safe operation IDs from the error context or statement response before retrying. The error context also returns the same typed journalReason and journalAction as the blocked status. When an explicit positive amount exceeds the statement's remaining available balance, preview returns a non-blocking amountExceedsAvailableBalance warning with availableBalance and excessAmount. Show that warning next to the amount, but keep payment available because an owner advance is allowed. When the statement already has an active payout that owes money, preview returns a statementPayoutInFlight warning (a Ramp payment is still running) or a statementPayoutOpen warning (nothing settled after failed or canceled attempts), each with the payout's transactionId. The warning does not block: a statement can receive another payout of any amount, and only one payout cannot be paid twice. If the selected payout date falls before the listing's first open date, preview returns statementPeriodLocked with openFrom. Keep the selected owners and accounts, move the payout date to openFrom or later, and preview again. Do not unpublish a later statement or bypass its lock implicitly. Offer provider: "ramp" only when the selected account's embedded Ramp AccountConnection reports capabilities.achPayments.enabled and the selected owner's ACH payment method is ready. Treat notConfigured, pending, and failed as blockers and do not show an ACH payment count while any selected Ramp owner is blocked. Keep ineligible accounts available for provider: null when they are otherwise valid bank accounts.

Mutation Recipe

  1. Load statement identity, requested view, detail, status, locks, and issues.
  2. Render returned totals and frozen layout.
  3. Determine the exact status action or narrow row edit.
  4. Dry-run the complete supported mutation.
  5. Explain new attachment or period-lock consequences.
  6. Confirm and apply.
  7. Re-read the same statement ID and view.
  8. For payouts, follow the returned transaction into its accounting month.

Failure and Recovery

If an owner-side mutation hits a persisted period, identify the locking statement and recovery action; never silently switch attribution. Return a statement to draft only through an explicit authorized workflow. For an ambiguous description row, edit the underlying source instead.

If statement deletion reports a linked payout, keep the statement and use the status update endpoint. Do not delete and recreate it: a replacement row has a different identity and cannot inherit the original payout association. If two clients delete the same statement concurrently, one succeeds and the other receives the normal statement-not-found response. Reload the statement list instead of retrying the delete.

For owner payouts, render statementPeriodLocked as a date conflict and use its structured openFrom value as the earliest selectable payout date. The payment request skips that statement and writes no payout transaction while the date remains locked.

Common Recipes

Render a statement from JSON

Use this recipe to draw an owner statement in your own UI or PDF and to replace guest names with data you hold. One call returns everything:

GET /statements/<statement-id>?viewAs=owner

viewAs defaults to manager for manager and partner credentials. The manager view adds manager-only sections and columns and the Unallocated Accounts rows. Request viewAs=owner for output that goes to owners.

Build the page from these fields:

PartField
Headerlisting, ownership.members[].contact, startAt, endAt, currency, uniqueRef, status
Totalsfinancials: balanceStart, netRevenue, expenses, reserve, netIncome, payouts, balanceEnd
Summary blocksummary[], or the leading summary row, not both
Payments made for this statementpayouts[]
Warningsissues[] and each row's issues[]
FreshnessjournalStatus. Show a recalculating, failed, or stale indicator

Amounts are integer cents in currency. text and totalFormatted fields are display strings. Use the numeric fields for calculation.

Per-statement views and individual-owner PDFs add an Owner Split (X%) row directly below the payable summary amount for the owner in view. The payable amount is Available balance when the summary includes a reserve holdback, otherwise Balance end. The share uses that row's numeric amount and the ownership member's split, where full ownership is 100000, or 100 when the period's splits total 100. Round the share to whole cents using half-to-even rounding. Keep the statement's existing summary amounts unchanged.

The manager view uses the selected owner, the portal uses the logged-in owner, and previews and PDFs use their selected contact. Batch PDFs apply the rule separately for each recipient. Hide the row for full ownership or when the contact is not an ownership member. Payment status does not replace the displayed payable amount with amounts from payout transfers. This display row does not change payments, journals, CSV exports, or owner-summary rollups.

Render rows in the order returned and group them by section. The first row has type: summary and the section Summary, or Invoice for invoice layouts. Every other section equals the name of a section in layout.netRevenueSections, layout.otherSections, or layout.systemSections. System sections include Payouts. Use the layout section's columns for the headings. Each row carries columns[] in the layout's column order. Render each cell's text.

Each row has a total. The response has no section totals. Add up total across the rows of a section when you need one. Do not add up formula.percentage cells, and do not recompute financials.

Branch on row.type:

typesource carries
reservationThe reservation, see below
transactiontype of deposit, expense, or payout, plus description and lines[]
summaryOnly id. The amounts are in columns[]

Join guest names

Each reservation row identifies its reservation in source:

FieldUse
idVRPlatform reservation ID. Join on it against the IDs from GET /reservations
confirmationCodeBooking confirmation code
pmsReferenceCodeReference in the property management system. Join on it when the other system keys reservations by its own ID

A net revenue section with aggregate set collapses its reservations into one row in the owner view. That row has an empty source.id, no codes, and no guest name. A layout that must support the join cannot use aggregation for that section.

source.guestName holds the name VRPlatform stored. Replace it with your own value after the join. The same name also appears in the cells of the row:

  • A field column whose value is guestName or reservation.guestName.
  • A description field column of a net revenue section.
  • A description field column of any other section. Replace it only when its text equals source.guestName, because the cell otherwise holds the transaction description, such as a cleaning fee.

Find these cells through the layout column's type and value, not through its display name. One reservation can appear in several sections, so join once per row.

descriptionEdit and descriptionLock matter only when you offer line editing. A description edit on a recurring fee line of a draft statement resets to the generated fee text when the reservation re-syncs, and sticks once the statement is inReview or published. A draft recalculates on every read. A draft or inReview statement can still change, so mark it as a preview.

Publish a statement

Load current detail, dry-run the status update, show issues and lock impact, confirm, publish, then re-read owner view.

PUT /statements/{statementId}?dryRun=true
Content-Type: application/json

{
  "status": "published"
}

Pay a published statement

Use POST /statements/pay with one explicit selection per ownership member. The payout line references the paid statement, while its journal entries use the payout transaction date and can appear in 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"
        }
      ]
    }
  ]
}

provider is the only ACH trigger. provider: "ramp" dispatches an ACH payment funded through the Ramp account connection of the selected bank account; provider: null records the payout without dispatching, even when the bank account is Ramp-backed. Owners of the same statement can mix methods. Owner ACH details are configured separately; the pay request never accepts account or routing numbers. Ramp funding identities use the imported AccountConnection source: its provider bank-account ID must match uniqueRef, and the source must identify exactly one Ramp entity. Preview and execution reject missing, stale, or ambiguous routing before creating local payout transactions or queueing provider work.

The submitted payout date remains the VRPlatform transaction and accounting date. For Ramp ACH, the integration separately calculates a supported requested arrival date and uses it as both the Ramp bill due date and payment arrival target. Ramp's returned scheduled and actual initiation dates remain the provider timing source of truth; the submitted arrival date is a target, not a provider-confirmed delivery estimate.

Ramp updates arrive through the integration as payment events and may arrive out of order; older evidence never moves a payment backwards. A payment becomes succeeded only when the provider reports the cash movement, which is also when the payout becomes paid.

The statement payment dialogs preselect each owner's payout account (payoutAccountId on the contact). Only an owner without one gets the account that holds the payout_bankAccount assignment. Credit cards are never payout destinations: the dialogs, the owner form and the payment method tab do not list them, and preview rejects a card as bankAccountInvalid.

The selected bank account supplies only the payout's cash leg and does not need the payout_distribution assignment. Statement allocation uses the team's separately assigned payout-distribution account. Adding or removing that assignment invalidates cached statement reads, so list and detail use the same current Payouts section. The same payout-distribution account is sent as the Ramp bill line Category; the selected bank account remains only the cash and ACH funding source. Ramp payout readiness also requires a completed pushRampAccounts run that started after the current payout-distribution account was created or last changed. A full run proves the complete chart; an account event proves only that exact account. Preview returns rampCategoryAccountNotReady and payment writes nothing until that evidence exists. Run the Ramp chart-of-accounts flow, then preview again.

Per-statement results are requested (at least one Ramp submission), recorded (offline payments only), or skipped. Each statement validates and executes atomically, and a blocked statement never rolls back earlier successful results. Every result associates each payout transaction and its paymentId with its owner contact; providerOperation is the operation of the first submitted Ramp payment. Omitting amount pays the full net income and requires the statement to have no prior payout; an explicit signed amount records an additional partial payment or an owner receipt. Retry a failed Ramp payment through POST /transactions/{id}/payments with replacesPaymentId on the existing payout rather than creating another full-statement payout. To pay a payout whose Ramp payments all failed or were canceled without Ramp, record an offline payment on it with the same endpoint (provider: null, accountId, requestedDate); the payout menu offers this as Mark as paid when actions.recordOffline is available. Until a payment settles, such a payout shows Not recorded: no money moved and nothing is in the books.

Pay a larger selection

Preview and payment accept up to 1,000 statements with the same payout date. Omitting date or sending null uses the date the submission is accepted. Queued execution and retries retain that date, including across midnight. Payment revalidates each statement before recording its payouts. Dry-run stays synchronous, accepts at most 100 statements, and rolls back all writes.

Both POST routes use the completed-or-pending contract:

ResponseWhat to do
200Read the final preview or payment result.
202Save operationId, wait for completion, then retrieve its result.

Preview returns {data, failure}; payment returns {results, providerOperation, failure}. Wait through GET /operations/{id}?waitSeconds=20, then read GET /operations/{id}/result.

The result endpoint returns the same body as a completed POST. A terminal failure still returns recorded outcomes with failure populated. A completed operation may include skipped statements; inspect each result.

Submit preview first, show its allocations and blockers, then send the confirmed owner selections to pay. POST waits up to five seconds for accepted work by default; set waitSeconds from 0 to 20 to change the wait. Waiting does not cancel the work. Payment of up to four statements runs during the request. Larger payments run in a background job, and the POST returns 202 when the job does not finish within waitSeconds. A run that stops without finishing restarts automatically, after about fifteen minutes for a run inside the request and up to about two hours for a background job. The restart skips statements already paid.

Send a UUID in Idempotency-Key. It is required for payments above 100 statements and recommended for every payment, including manual recordings.

POST /statements/pay?waitSeconds=5
Content-Type: application/json
Idempotency-Key: 7b6e2a95-c83b-4f31-9fc1-33e418f29fd4

{
  "date": "2026-09-15",
  "statements": [{
    "statementId": "11274d80-7d86-4a09-adfe-920c51f1287b",
    "amount": 25000,
    "payouts": [{
      "contactId": "75a9dc02-76dc-42ea-b883-fd692ed584c2",
      "bankAccountId": "9d0779c4-36c4-4d9b-a911-78090cf2beac",
      "provider": null
    }]
  }]
}

After a lost response, retry the same body and header. The API returns the existing operation or result without recording another payout. A changed body with the same key returns 409 CONFLICT. Keep the key until the outcome is known. A new key represents a new payment. Preview also accepts this header.

providerOperation tracks the first Ramp payment's submission. Completion of statement-payout means payouts were recorded or skipped; it does not mean ACH funds have arrived. Follow each payout's paymentId for settlement.

Preview requires statements:read; recording requires statements:record-payout, plus ach-payments:execute for Ramp selections. Operation status requires operations:read; result reads also require statements:read.

Webhook Reconciliation

statement.status.changed is emitted only when a persisted owner statement is created in or transitions among stored statuses, or is deleted. Draft projections do not emit. The body contains previous/current status and a monotonic version, but no amounts, rows, owner, payout, or template data.

API Reference

On this page