VRPlatformVRPlatform
Build a Product UI

Recurring Fees

Build fee definition, listing-period, calculation, and preview flows

Mental Model

Recurring fees calculate financial lines from reservation data. A reusable fee definition describes the formula and postings; a listing period activates that definition for one listing and date range.

Resources and Lifecycle

Definitions cover management, additional, booking-channel, cleaning, and merchant fees. They can be active or archived. Listing periods preserve time-based rate changes without rewriting historical configuration.

Changing either resource can refresh fee journals for multiple reservations. Historical books and statement locks still apply to affected postings.

A definition with no active listing period matches no new reservations. A definition change can still refresh reservations whose existing journal entries already reference that fee so obsolete postings are removed safely.

Definition create and update queue one durable planning effect before expanding high-cardinality reservation refreshes. A successful mutation means that work is durably queued, not that every reservation journal has already settled. Journal-relevant updates return a reservation-journal-refresh operation. Poll that operation, then re-read the fee and affected resources. Their journalStatus remains authoritative: current means every known scoped change is applied; recalculating, failed, and stale require visible UI state instead of assuming the stored journals are current.

Read Model

Render returned includedFinancials, linkedAccounts, resolved rate, conditions, taxes, recognition, fee lines, locks, and issues. Do not parse a formula to reconstruct its calculated display.

Use journalStatus for journal-derived freshness and operations to discover the work that can change it. A fee can be stale without an active operation, so do not derive freshness from operation presence.

Use GET /reservations?journalChangesFromFeeId={feeId} to review reservations with pending changes from that fee. The paginated list excludes applied changes and completed accounting-lock skips, matching fee freshness. Each reservation retains its own live journalStatus, recovery action, and permissions. This is the affected set, not the fee calculator's sample reservation preview. It includes inactive reservations and sources unless an explicit status filter narrows the result; pending accounting work can outlive its source's active status.

A fee with no known journal invalidations is current, with null freshness timestamps. It does not need a refresh merely to populate a timestamp. This status describes tracked changes; it does not independently validate every historical fee amount.

Listing-period reads accept UUID values for recurringFeeId and listingId. Malformed identifiers return 400 BAD_REQUEST before the API queries listing periods.

Calculation Order

When one fee formula reads an account produced by another fee, the producing fee is calculated first. That dependency applies only when both fees have overlapping listing periods for the same listing. Listing periods are half-open: startAt is included and endAt is excluded, so one period ending on a date does not overlap another period starting on that date.

This ordering is shared by journal refresh and full-journal calculation. A refresh must therefore produce the same amount as Preview after all dependent fees settle. Existing stored journals are not rewritten by a code deployment; refresh affected, unlocked reservations after a calculation-order correction.

Creating, changing, or removing a recurring-fee adjustment also recalculates fees whose formulas depend on the affected posting accounts. The targeted refresh follows those dependencies transitively, so a cleaning-fee adjustment can update Management Commission without rebuilding unrelated fee history. That recalculation applies only while the affected fee group is fully editable. If any existing row for a dependent fee is statement-attached, the adjustment still posts its own balanced correction, but every historical row for that locked fee remains unchanged. Books-close and statement locks still apply to every changed posting.

An ordinary reservation adjustment with a custom posting date is narrower. It creates its own fee effect on that date without normalizing a recurring fee's historical base rows. Statement-attached fee history therefore remains unchanged even when a current rebuild would round that historical amount differently. Its posting status is independent of an inactive base reservation; ordinary reservation and fee postings inherit the reservation's GL status.

Account-total conditions are evaluated against each calculation group. A posting-dated adjustment must satisfy the fee's conditions using that adjustment's grouped entries before its formula runs. For example, a negative adjustment cannot create a fee whose source-account condition is greater than zero, even when the reservation's historical account total was positive.

Fee formulas use active journal rows. Inactive reference-account rows remain available only when their inactive reason is nonPostingAccount, because those accounts intentionally carry formula inputs. Every other inactive reason is excluded, including GL start boundaries, inactive transactions, inactive listings, inactive accounts, currency mismatches, and cancelled reservation lines. Historical-ledger rows and reservation rows without an effective ownership period are also excluded under their explicit reasons. If a row used by the formula or an account-total condition is inactive without a reason, preview fails and journal refresh leaves the existing fee postings unchanged with an integrity issue. Preview and persisted fee calculation apply the same rule. Formula variables are parsed exactly, so an account ID cannot match a different account merely because one ID contains the other.

When a percentage fee uses pro-rata recognition, its generated schedule covers the complete window from the first through the last included revenue-recognition source date. This keeps the fee postings on the same effective date range as the formula after a GL start boundary excludes source rows. Flat pro-rata fees keep the full reservation schedule.

Each generated pro-rata or deferred posting uses the ownership period effective on its own posting date. A date with no ownership period remains unassigned so ownership validation can report the configuration gap; it is never assigned to a period from another date. If one inactive leg would otherwise leave a posting group unbalanced, companion legs use the explicit group-propagation reason while the source leg retains its own reason.

Formula results use banker rounding at the cent boundary. Exact half-cent results round to the nearest even cent, including when floating-point evaluation lands immediately beside the mathematical half-cent value. Preview and persisted fee postings use the same rounding boundary.

Decision Table

Fee Definitions

Field or contextRule
TypemanagementFee, additionalFee, bookingChannelFee, cleaningFee, or merchantFee
Flat ratedefaultRate is integer cents
Percentage ratedefaultRate is basis points; 100000 is 100%
Partial definition updateOmitted fields preserve their stored values
PostingActive debit/credit accounts plus `owners
TaxTax rate plus included/excluded behavior
Management-fee taxPayable account splits tax; missing debit override uses fee debit
RecognitionOptional fee-specific override
ConditionsBooking channel, reservation status, and account totals
Period rateOptional override; a value equal to the definition default remains inherited
Period overlapChoose one documented conflict strategy

Account, category, party, fee type, formula, and tax validity must be evaluated as one configuration. An active fee requires both direct posting accounts, and all direct, tax, and formula-input accounts must be active and owned by the same team. Every account-total condition must also reference an account owned by the same team. A missing account reference returns 400 BAD_REQUEST before the fee is written. An Account used anywhere by an active fee cannot be deleted or archived. An archived fee can retain historical account references, but those accounts must be active before the definition is reactivated. Every posting-affecting change to a tax rate used by an active fee revalidates its effective accounts, even when the account IDs do not change.

For Management Commission, the payable tax account controls whether tax is a separate posting. This applies to both included and excluded tax behavior. If the tax rate has no debit-side override, the tax debit uses the fee definition's debit account. Other recurring-fee types continue to require both tax account overrides for a separate tax posting; otherwise their tax remains embedded.

Listing Periods

Listing-period strategies are error, updateExisting, adjustInsertingItem, and closeExistingPeriods. Explain that a strategy can change stored neighboring periods, not just the visible row.

Listing-period reads return the effective rate. When a create or update sends that same value as the current definition default, the API stores inheritance instead of a redundant override. A later definition-default change carries those inherited periods forward. A period rate different from the definition default remains an explicit override and does not change with the default.

startAt and endAt must be parseable date strings. The API rejects invalid values with a 400 validation response before reading or writing fee periods. Two overlapping open-ended periods cannot both omit boundary dates because no strategy can determine where one period ends and the other begins. The API returns a 400 user error instead of inventing a boundary.

Editability

Locked periods cannot be deleted normally. onLocked=archive closes the open portion while preserving locked journal history. Definition changes can also be rejected when affected reservation fee entries are in closed or statement-attached history.

A fee definition can be hard-deleted only before it has generated journal or payment history. Once related records exist, the default delete returns a controlled response with suggestedOnLocked=archive. Retry with onLocked=archive to deactivate the definition and persist one planning effect that expands into lock-aware reservation refreshes. The API response does not wait for every affected reservation journal to settle.

Books closing protects every journal entry a listing-period delete would remove, including inactive history. Making an entry inactive does not make a closed posting removable.

Preview and Preflight

Use fee preview to answer what one proposed definition would calculate for one reservation. It returns projected lines with account, party, amount, and fee metadata; projection IDs are not persisted.

Use mutation dry run to validate whether a definition or listing period can be created, changed, or removed. A successful calculation preview does not prove that persistence is currently writable.

Re-run preview after rate, formula, account, party, tax, recognition, filter, listing-period, or reservation changes.

Mutation Recipe

  1. Load eligible accounts, tax rates, channels, and existing periods.
  2. Select fee type and rate model.
  3. Resolve formula inputs and both posting sides.
  4. Add conditions and recognition only when needed.
  5. Preview against a representative reservation.
  6. Configure the listing period and explain its conflict strategy.
  7. Dry-run the complete definition or period mutation.
  8. Confirm calculated and persistence effects separately.
  9. Apply and re-read affected fee and period resources.

Failure and Recovery

stale means known work remains unapplied without an active recalculation. recalculating means work is queued or running. Re-read the fee after its operation finishes. failed means a non-lock recalculation failed. Completed accounting-lock skips do not make the fee fail because retrying cannot change protected history. The affected reservation still reports the lock.

A fee-triggered journal-refresh operation completes when each reservation was refreshed or explicitly skipped because accounting history is locked. A pending, running, or failed non-lock refresh retains its operation status. A direct reservation refresh that encounters a lock still reports journalLocked.

Every non-current status includes a typed reason and action. Wait for journalRefreshInProgress, retry journalRefreshPending or journalRefreshFailed, correct configuration for journalConfigurationInvalid, and contact support for journalUnbalanced. For journalLocked, review whether the intended correction belongs in an open period; do not unlock published history to clear a status.

For a fee update blocked by accounting locks, list the reservations that still need review:

GET /reservations?journalReviewOperationId=act_h8zoT8QuSnaU_KEX9ADAMw&page=1&limit=25

Use the ID from the fee's operations or its update acknowledgement. The response uses the standard reservation rows and pagination: each id links to reservation detail, and pagination.total counts distinct matching reservations. It includes only unapplied refreshes whose latest attempt was blocked by accounting locks and which have no active retry. Applied changes, active retries, and reservations from other operations are excluded. A later successful full refresh removes the covered reservations from this list.

The filter accepts action IDs (act_), stored operation IDs (op_), and legacy UUIDs. It preserves tenant and owner access. Inactive reservations, sources, and connections remain visible for this targeted review; explicit status, listing, date, and search filters still narrow the result. An unknown operation or one outside the caller's access returns an empty list. An invalid identifier returns 400 BAD_REQUEST.

Use this count for the review action. An operation's original reservation count or number of refresh attempts can differ from the number still requiring review. Keep the fee and update identified in the warning so it does not imply that unrelated reservations are outdated.

Inspect the linked operation and affected reservations before retrying. Fix reported mapping or fee-configuration errors, then request a full journal refresh for the affected editable reservations. Books-close and statement locks still apply. Do not unlock published history merely to clear a fee badge; review whether the intended change needs an adjustment in an open period. A successful full refresh covers earlier changes for that reservation. A partial fee refresh does not establish that every older change has been applied.

Keep formula and account errors beside the posting configuration. When periods overlap, preserve the proposed range while the user chooses a strategy; a lock should surface the affected history and the supported archive outcome. If a posting account is archived, repoint or deactivate the fee before archiving the account. Deactivating a fee cannot inactivate one of its generated accounts while another active fee still uses that account. Repoint or deactivate the dependent fee first, then retry and re-preview.

Common Recipes

Percentage management fee

Choose managementFee, percentage basis points, the included financials, owner/manager posting sides, tax and recognition behavior, then preview it against a reservation before saving.

This example calculates a 15% fee from one linked revenue account:

POST /recurring-fees/preview
Content-Type: application/json

{
  "name": "Management fee",
  "type": "managementFee",
  "rateType": "percentage",
  "defaultRate": 15000,
  "formula": "\"22222222-2222-4222-8222-222222222222\" * \"rate\"",
  "creditAccountId": "33333333-3333-4333-8333-333333333333",
  "creditParty": "manager",
  "debitAccountId": "44444444-4444-4444-8444-444444444444",
  "debitParty": "owners",
  "reservationId": "88888888-8888-4888-8888-888888888888"
}

Change a fee rate next month

Keep the definition stable and create a new listing period with the new rate and non-overlapping boundary. Dry-run the selected conflict strategy.

POST /recurring-fees/listing-periods?dryRun=true
Content-Type: application/json

{
  "recurringFeeId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
  "listingId": "11111111-1111-4111-8111-111111111111",
  "startAt": "2026-08-01",
  "endAt": null,
  "rate": 18000,
  "onConflict": "closeExistingPeriods"
}

API Reference

On this page