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.
Resources and Lifecycle
Lifecycle
| Status | Journal attachment | Normal owner visibility |
|---|---|---|
draft | No | Team setting can allow it |
inReview | Yes | No |
published | Yes | Yes |
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:
| Transition | Blocked when |
|---|---|
| Any status change | A later statement is inReview or published for the same ownership period |
To inReview or published | Error issues or a non-current journal |
Draft or inReview to published | Prior month is unfinalized or has unattached owner activity |
To draft | Scoped API caller lacks statements:publish |
Imported historical statement to draft | Always; 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.
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.
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.
Read Model
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.
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.
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.
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.
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.
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.
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 latest providerPayment, when present. Its
normalized status is accompanied by Ramp's status summary, payment reference,
payment method, requested arrival target, scheduled initiation, actual
initiation, and completion timestamps when available. Use the payout
transaction's provider-payment history for older attempts and retry or unlink
actions.
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
| Decision | Rule |
|---|---|
| Identity | Ownership period + month + currency |
| Owner display | Request owner view; do not hide manager data client-side |
| Manager display | Request manager view |
| Persist history | Use returned frozen layout/detail |
| Refresh layout | Explicit refresh, never automatic on read |
| Owner payout | Pay owner statements with one selection per owner |
| Manager payout | Pay manager statements |
| Summary payout attribution | Owner contact in full; non-owner payee by ownership split |
| Inline description | Require 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 is the only generated artifact route that can render in
the public request Worker. Statement CSV routes and queued PDF batches render
in Trigger and return only artifact metadata through the Worker.
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:
ownershipPeriodIdsplus onemonth; or- one
listingIdplus an inclusivestartMonthandendMonth.
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. If
that selected 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.
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
- Load statement identity, requested view, detail, status, locks, and issues.
- Render returned totals and frozen layout.
- Determine the exact status action or narrow row edit.
- Dry-run the complete supported mutation.
- Explain new attachment or period-lock consequences.
- Confirm and apply.
- Re-read the same statement ID and view.
- 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
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 webhooks identify the changed resource and may arrive out of order. The
integration fetches the current bill before applying a payment event and does
not move an attempt from processing back to requested. A completed payment
may still become returned, which is a valid later ACH outcome.
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 dispatch),
recorded (book-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 dispatch
syncId with its owner contact. 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
terminal provider failure through the existing payout transaction rather
than creating another full-statement payout.
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
- Get statement detail —
GET /statements/{id} - Update statement status —
PUT /statements/{id} POST /statements/pay— Pay owner statementsPOST /statements/pay/preview— Preview payout plans without writingPOST /statements/manager/pay— Pay manager statementsPOST /statements/{id}/refresh-layout— Refresh a statement layoutPUT /statements/lines/{lineId}/description— Update a line descriptionGET /statements/layouts— List statement layouts
