Platform Schema — lock before build

The underlying data model of each module and how it connects to the existing RoverPass system (the CRS). Key tables + key columns + relationships shown; full column-level DDL in the Data-Model-*.md vault docs. This is the engineering surface to ratify before Phase 0. · back to the app

Connection mapMonolithCRM serviceLedger & BooksThe 5 seams▶ Open the app
PKprimary keyforeign key (same module)⇄ CRScrosses into existing RoverPass⇄ CRMcross-module⇄ Bookscross-modulenoteconstraint / behavior

How tenancy + reservations + books combine (decided 2026-07-08)

MONOLITH — leave alonereservations · availability (calendar_events) · recurring rent invoice · payments. The "invoice module" is a billing ENGINE, not a client ledger.
── CDC / logical replication ──▶monolith team writes ~0 code
LEDGER & BOOKS serviceper-client ledger = the AR subledger of the GL · deposits · delinquency · partial allocation · tenant billing · the books themselves

The monolith's billing engine makes and collects charges; it is not a client ledger. We keep it, we don't extend it. Its facts flow out via CDC (Postgres logical replication, Debezium/Fivetran-class), and the client ledger + GL live together in one service — where the per-client ledger is the AR subledger of the books, so it rolls into bookkeeping by construction, not by a sync. Charges the monolith can't make (deposits, one-offs, move-out disposition) originate in the service, which is the single source of truth for any client account. The only genuine monolith touch: a lease places a BlackoutDate site hold (existing GraphQL API). Revises the earlier "extend tenancy in the monolith" + app-outbox plan — velocity tips to wrap, don't extend.

Connection map

what exists in the monolith · what’s new · how each attaches

The hardest part of this project is how it all connects. This is the ground truth, from the actual code: for every capability, what the reservation-system monolith already does, what is genuinely new build, and the exact seam between them. VERIFIED = confirmed in the repo; CONFIRM = decision or spike, not yet in code.

CapabilityIn the monolith todayNew buildThe connection (seam)
Reservations & availability
VERIFIED
Reservation (flexible; to=2099 for long-term), calendar_events = the single availability truth, BlackoutDate (bulk GraphQL), inventory_hold— reused as-isA lease places a BlackoutDate hold on its site; availability always read from calendar_events
Monthly / long-term pricing
VERIFIED
PricePeriodLongTerm (12-month rate table via site.rates), seasonal/holiday periods, rate_site, dynamic_pricing_rule, pricing_tierLease-driven rent, statutory rent increases, move-in/out prorationReuse pricing to seed the lease rent; the lease then owns the recurring amount
Recurring billing
VERIFIED
ScheduleBillingPeriodInstallmentInstallmentItem, autopayTenant ledger (= AR subledger), partial-payment allocation (a payment attaches to one installment today, payment.rb:35)Installments/payments flow to the Ledger & Books service via CDC
Fees & late fees
VERIFIED
ExtraFee (pet/vehicle/extra-person), LateFeeConfig (dollar or %, 3/5/7-day grace, per campground)Per-lease / per-charge fee & late-fee rulesReuse ExtraFee + LateFeeConfig; assessments post to the ledger
Payments & payouts
CONFIRM
Payment STI (Stripe/External/None), Stripe Connect destination charges, stripe_payouts, refundsTenant payment surface on the park’s Connect account; payout decomposition into the booksPayments → books via CDC; service reuses the park’s Connect account (spike)
Deposits
VERIFIED
none — no deposit model existsDeposits held as a liability + itemized move-out dispositionOriginate in the service; post to the books (liability 2100)
Identity / guests
VERIFIED
User + :guest role; phone on the reservation (no email column); users_campgrounds accessCRM contact spine (phone-first dedup, personas)CDC builds contacts; crm_contact_id written back; one fail-open flag lookup at booking
Guest portal / login
VERIFIED
The Accounts area (AccountsAutopay / Payment / PaymentMethods / ReservationPayments), guest activation (routes.rb:61)Tenant panels: lease, deposit, requests, move-out notice, usageSame login; tenancy lights up tenant panels inside the existing portal
Ops: tasks / maintenance
VERIFIED
Task / TaskHousekeeping (priority, due, 10 photos, assignment, flips site.cleaning_status)Full work-order desk: vendors, parts, cost, bill-to-tenantExtend Task; make-ready reuses cleaning_status. Lives in Reservations & Ops
Utilities / meters
VERIFIED
Utility / Meter / MeterRead (manual invoice attach)Rate plans, auto-rebill to the ledger, exception queueRead the existing reads; rebilling posts to the tenant ledger
Accounting / GL
VERIFIED
none — installments/payments/payouts are the fact stream; there is no GLLedger & Books service: native double-entry GL; tenant ledger = the AR subledgerCDC → posting rules → journal (append-only, sum-zero)
Reporting / warehouse
VERIFIED
bigquery_client.rb, reports/builder.rb, AdminReports; integrations via Zapier/Mailchimp (AdminIntegrations)Insights layer (report + chat builder)Stream to the existing BigQuery warehouse
Integration transport
CONFIRM
No event bus — a reservation-notifications hook + Zapier / n8n pollingDecision: CDC / Postgres logical replicationCDC is the one door facts leave the monolith through — ~0 monolith code

Existing RoverPass monolith

leave it alone5 tables

Reservations, site availability (calendar_events), recurring-rent invoicing and payments already run here in production. Its money facts leave via CDC / logical replication — the monolith team writes ~zero new code. The only genuine touch is a lease placing a BlackoutDate site hold so a leased lot can never double-book.

reservations (CRS)
idPK
first_name / last_name / phone
email⚠ in data jsonb, no column
user_id→ users (nullable)
campground_id→ campgrounds
to (=2099 → flexible)
state / checked_in_at / checked_out_at
crm_contact_id⇄ CRM contacts ← written back
sites + calendar_events (CRS)
sites.idPK
sites.usage_mode← lot flag (tenancy)
calendar_events= the ONE availability truth
reservation_id / inventory_hold_id / blackout_date_idpolymorphic
installments / installment_items (CRS)
installment.idPK
reservation_id→ reservations
schedule_template_idrecurring loop
installment_items.category= proto charge-code
pricings.installment_idmoney split
payments / stripe_payouts / tax_configs (CRS)
payments STI
applies_to / installment_id
stripe_payouts.balance_transaction_key→ decomposition
tax_configs.rate
organizations / users_campgrounds (CRS)
organizations.id
users_campgrounds= actual access + roles

CRM service

separate service10 tables

Phone-first identity spine (reservations have no email column). Consumes the CDC stream to build contacts + timelines and writes crm_contact_id back. One synchronous call back into booking: a fail-open do-not-rent flag lookup at checkout.

contacts
idPK
org_id→ orgs
monolith_user_id⇄ CRS users
merged_into_contact_id→ contacts (tombstone)
persona_roles[]guest/tenant/investor/vendor
lifetime_nights / spend / open_balancecomputed
contact_identities
idPK
contact_id→ contacts
kindphone|email
value(NOT globally unique — shared phones)
verified
occupants
idPK
contact_id→ contacts
reservation_id⇄ CRS reservations
tenancy_id→ tenancies (LTT)
roleprimary|occupant
leads / pipelines / stages
lead.idPK
contact_id→ contacts
pipeline_id→ pipelines
stage_id→ pipeline_stages
lost_reasonenum
source
waitlist_entries
idPK
contact_id→ contacts
site_type
rank
requirementsjsonb
conversations / messages / calls
message.idPK
conversation_id→ conversations
contact_id→ contacts
direction / delivery_state
address / numberliteral provenance
intake_items
idPK
channelvoicemail|email|web|walkin
parse_confidence
payloadjsonb
→ lead→ leads on accept
suppressions
idPK
org_id→ orgs (org-global)
valuevalue-keyed, survives merge
applies_tomarketing|all_sms
automations / segments / campaigns
idPK
park/org scope
segment defnjsonb
attribution → reservations⇄ CRS reservations
inbound_events
event_id
typereservation.* etc
status

Ledger & Books service

one service — the long-term ledger and the books are the same system33 tables

The per-client (tenant) ledger IS the AR subledger of the general ledger: the sum of every resident balance equals the AR control account (1200) by construction, not by a nightly sync. Deposits, one-offs and credits originate here (the monolith can't create them); recurring rent + autopay flow in via CDC. Accounting boundary = the entity (LLC), NOT the park — entity_park_mappings routes each park's money to exactly one entity (effective-dated), the park rides every journal line as a dimension for by-park P&L, and a holdco entity consolidates multiple entities via entity_positions. Investor / fund lives beside accounting, sharing the entity registry.

Tenant ledger — long-term (this IS the AR subledger)

tenancies
idPK
reservation_id⇄ CRS reservations UNIQUE ← WRAPS the flexible reservation
lot (site) ⇄ CRS sites
statusapplicant→active→moved_out
term / rent / deposit
lot_details
site_id
kindMH|LT-RV|POH|storage
payment_applications
idPK
payment_id⇄ CRS payments
installment_id⇄ CRS installments
amount / order← PaymentAllocator choke point
deposit_records / deductions
idPK
tenancy_id→ tenancies
held_cents (liability)
dispositionitemized at move-out
late_fee_rules / assessments
idPK
park_id⇄ CRS campgrounds
grace / pct / recurring / cap(supersedes 3/5/7 enum)
applications / applicants / screening
application.idPK
lead_id⇄ CRM leads
screening_result
decision + FCRA notice
→ tenancy on approve→ tenancies
utility_rate_plans / tiers
idPK
utility_id⇄ CRS utilities
effective_from
tiers / base_fee / pass_through
meter_read_exceptions
idPK
meter_read_id⇄ CRS meter_reads
reasonspike|negative|missing
cash_payment_accounts / suspense
idPK
tenancy_id→ tenancies
barcodePayNearMe
suspense_entry← blocked partials

General ledger & accounting (~50 tables)

entities
idPK
ein / fiscal_year / basis
org_id⇄ CRS organizations
entity_park_mappings
idPK
entity_id→ entities
campground_id⇄ CRS campgrounds
effective rangedaterange EXCLUDE
entity_positions
idPK
parent_entity_id→ entities
child_entity_id→ entities
ownership— entity-owns-entity (fund)
accounts (CoA)
idPK
entity_id→ entities
type / normal_side
is_systempinned
parent_id→ accounts ≤3 deep
journal_entries / journal_lines
entry.idPK
entity_id→ entities
line.account_id→ accounts
debit/creditSUM=0 trigger, no UPDATE/DELETE
source_event_id→ ingested_events
reverses_entry_id→ journal_entries
ingested_events / posting_rules
event_id
source (CRS outbox)⇄ CRS installments/payments/payouts
rule: charge_code+method→accountseffective-dated
posting_exceptions
idPK
typeunmapped|locked-period
event_id→ ingested_events
bank_accounts / transactions
idPK
entity_id→ entities
gl_account_id→ accounts
plaid_item
categorization_rule_id
payout_decompositions
idPK
stripe_payout⇄ CRS stripe_payouts
→ gross/refunds/fees
matched_bank_txn_id→ bank_transactions
vendors / bills / bill_lines / payments
bill.idPK
vendor_id→ vendors
entity_id→ entities
approval_state
payment (rails partner)ACH/check/card
equity_classes / partner_positions
idPK
entity_id→ entities
contact_id⇄ CRM contacts (LP)
classA|B|GP
capital_account_entries
idPK
partner_position_id→ partner_positions
entry_typecontribution|distribution|pref|allocation
← single-writer; investor module VIEWS this
intercompany_pairs / mgmt_fee_runs
idPK
from_entity / to_entity→ entities
nets to zeroinvariant
fixed_assets / budgets / close_checklists
idPK
entity_id→ entities
depreciation → journal→ journal_entries

Investor / fund — beside accounting

vehicles
idPK
kinddeal | fund
entity_id⇄ Books entities (1:1)
deals / funds
deal.vehicle_id→ vehicles
campground_id(s)⇄ CRS campgrounds
fund.vehicle_id→ vehicles
positions
idPK
vehicle_id→ vehicles
holder_contact_id⇄ CRM contacts
holder_vehicle_id→ vehicles (fund-in-deal)
units
position_events
idPK
position_id→ positions
kindsubscription|transfer|dilution (append-only)
waterfall_configs / tranches
config.id
vehicle_id→ vehicles
cash_typeoperating|capital_event
tranche.characterpref|ROC|profit|carry
distribution_runs / lines / sources
run.id
line.position_id→ positions
sources → deal line → fund runcascade provenance
posts → capital_account_entries⇄ Books capital_account_entries
capital_calls / sources
idPK
vehicle_id→ vehicles
committed/called/received
cascade to fund LPs
raises / subscriptions / esign
raise.idPK
vehicle_id→ vehicles
subscription → position on close→ positions
esign_envelope
funding_receipt
pref_accrual_entries
idPK
position_id→ positions
basis (frozen jsonb)recompute-and-diff
valuations
idPK
vehicle_id→ vehicles
as_of / method— segregated from ledger

The 5 seams

every join between new work and the monolith
SeamHow it connects
1 · IdentityCRM issues contact_id; the CDC stream carries reservation/guest rows → CRM resolves + dedupes (phone-first) → crm_contact_id written back. One sync call back at booking (fail-open flag).
2 · InventoryTenancy lots = sites + usage_mode. calendar_events stays the single availability truth. A lease places a BlackoutDate hold on its site — the only write the monolith takes.
3 · MoneyMonolith installments/payments/payouts flow out via CDC → the Ledger & Books service derives the tenant ledger (= AR subledger) and the GL. Nobody edits; everybody reverses (append-only).
4 · Entity registryThe entity (LLC), not the park, is the accounting boundary. entity_park_mappings (effective-dated) routes each park → one entity; entity_positions consolidates entity-owns-entity for funds. Shared by Accounting + Investor.
5 · WarehouseEverything streams to the existing BigQuery warehouse → the Intelligence layer (reports, chat builder).