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.

Listing drawers and listing-detail fee tabs show Archived when the fee definition is archived, even when its listing period includes today. Listing tables also mark archived definitions. The period dates and rates remain visible.

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.

Match by Stay Date or Booked Date

Each listing period has a matchBy setting. stayDate is the default and keeps the current behavior: the period is chosen by the date the fee posts on, which follows the fee's revenue recognition. bookedDate chooses the period by the reservation's booked date. A reservation with no booked date uses its created date, as the journal does for booked-date revenue recognition.

The setting changes only which period, and so which rate, a reservation gets. Posting dates and statement months do not change. Booked dates are read as UTC calendar days. A booked-date period has no effect on a reservation whose booked date falls outside its startAt and endAt.

Conflict handling compares only periods with the same matchBy: overlap checks, onConflict strategies, and the unique start date per fee and listing. A stay-date period and a booked-date period of one fee and listing may overlap. When a reservation matches both, the booked-date period applies. To give bookings from a date a new rate while earlier bookings keep the old one, add a booked-date period from that date next to the stay-date period.

matchBy cannot change on a period once an owner statement includes one of its fees: the update returns 400 USER_ERROR. A booked-date period's locked range is measured in booked dates, so its startAt and endAt can only move within the booked dates of its statement-attached fees. Creating, changing, or removing a booked-date period queues a fee refresh for unlocked reservations whose booked date matches. Statement-attached and books-closed reservations stay unchanged.

onLocked=archive on a locked booked-date period ends it today (UTC) instead of at the first open posting date. Later bookings stop matching. The archive deletes only open fees of reservations booked from today on. Open fees of earlier bookings stay.

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.

A formula can use "nights", "guests", "rate", and account references. Reference an account by ID, as "acc.<id>", or by its quoted name or reference; saving the definition stores the resolved account ID. A saved formula that does not reference "rate" is multiplied by "rate". A name or reference that matches no account is rejected when the definition is saved.

For a percentage fee, an account variable is the account's negated journal total in currency units, so revenue counts as a positive amount. "rate" is the rate as a fraction; 15000 evaluates to 0.15. An account with no qualifying entries evaluates to 0.

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. Preview also normalizes the formula as save does: quoted account names and codes become account IDs, and a formula without "rate" is multiplied by "rate". 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.

A stay can cross the GL start while its source rows use pro-rata recognition and the percentage fee uses check-in, check-out, or booked-date recognition, set on the fee or inherited from the team default. When the fee's own date falls before the GL start, the fee uses the same schedule as its included pro-rata source rows. It charges the active nights in the active period. The nights before the GL start stay out of the books, like the revenue they come from. A fee whose own date is on or after the GL start keeps that date. Flat fees and reservations with a posting date keep their single posting date.

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.

Tax behavior defaults to excluded. With excluded tax, the formula result is the net fee, and tax is that result times the tax rate, rounded to the cent. With included tax, the result already contains tax: the net fee is the result divided by one plus the tax rate, rounded to the cent, and tax is the difference.

An omitted creditParty defaults to manager, and an omitted debitParty defaults to owners.

uniqueRef (the fee code) and the name must each be unique within the team. A duplicate is rejected with reason: duplicateName. Create generates an omitted code as the next F### value, such as F004.

statusFilter=booked limits the fee to booked reservations, and statusFilter=canceled limits it to canceled ones. Without a filter, the fee applies to both. Inquiry and inactive reservations never receive fee postings. An archived fee, or a reservation on an inactive connection, also produces none.

bookingChannelsFilter compares channels case-insensitively after trimming. A value prefixed with ! excludes that channel and wins over a positive match. A filter with only exclusions matches every other channel.

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