How tenancy + reservations + books combine (decided 2026-07-08)
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 attachesThe 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.
| Capability | In the monolith today | New build | The 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-is | A 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_tier | Lease-driven rent, statutory rent increases, move-in/out proration | Reuse pricing to seed the lease rent; the lease then owns the recurring amount |
| Recurring billing VERIFIED | Schedule → BillingPeriod → Installment → InstallmentItem, autopay | Tenant 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 rules | Reuse ExtraFee + LateFeeConfig; assessments post to the ledger |
| Payments & payouts CONFIRM | Payment STI (Stripe/External/None), Stripe Connect destination charges, stripe_payouts, refunds | Tenant payment surface on the park’s Connect account; payout decomposition into the books | Payments → books via CDC; service reuses the park’s Connect account (spike) |
| Deposits VERIFIED | none — no deposit model exists | Deposits held as a liability + itemized move-out disposition | Originate in the service; post to the books (liability 2100) |
| Identity / guests VERIFIED | User + :guest role; phone on the reservation (no email column); users_campgrounds access | CRM 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, usage | Same 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-tenant | Extend 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 queue | Read the existing reads; rebilling posts to the tenant ledger |
| Accounting / GL VERIFIED | none — installments/payments/payouts are the fact stream; there is no GL | Ledger & Books service: native double-entry GL; tenant ledger = the AR subledger | CDC → 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 polling | Decision: CDC / Postgres logical replication | CDC is the one door facts leave the monolith through — ~0 monolith code |
Existing RoverPass monolith
leave it alone5 tablesReservations, 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.
idPKfirst_name / last_name / phoneemail⚠ in data jsonb, no columnuser_id→ users (nullable)campground_id→ campgroundsto (=2099 → flexible)state / checked_in_at / checked_out_atcrm_contact_id⇄ CRM contacts ← written backsites.idPKsites.usage_mode← lot flag (tenancy)calendar_events= the ONE availability truthreservation_id / inventory_hold_id / blackout_date_idpolymorphicinstallment.idPKreservation_id→ reservationsschedule_template_idrecurring loopinstallment_items.category= proto charge-codepricings.installment_idmoney splitpayments STIapplies_to / installment_idstripe_payouts.balance_transaction_key→ decompositiontax_configs.rateorganizations.idusers_campgrounds= actual access + rolesCRM service
separate service10 tablesPhone-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.
idPKorg_id→ orgsmonolith_user_id⇄ CRS usersmerged_into_contact_id→ contacts (tombstone)persona_roles[]guest/tenant/investor/vendorlifetime_nights / spend / open_balancecomputedidPKcontact_id→ contactskindphone|emailvalue(NOT globally unique — shared phones)verifiedidPKcontact_id→ contactsreservation_id⇄ CRS reservationstenancy_id→ tenancies (LTT)roleprimary|occupantlead.idPKcontact_id→ contactspipeline_id→ pipelinesstage_id→ pipeline_stageslost_reasonenumsourceidPKcontact_id→ contactssite_typerankrequirementsjsonbmessage.idPKconversation_id→ conversationscontact_id→ contactsdirection / delivery_stateaddress / numberliteral provenanceidPKchannelvoicemail|email|web|walkinparse_confidencepayloadjsonb→ lead→ leads on acceptidPKorg_id→ orgs (org-global)valuevalue-keyed, survives mergeapplies_tomarketing|all_smsidPKpark/org scopesegment defnjsonbattribution → reservations⇄ CRS reservationsevent_idtypereservation.* etcstatusLedger & Books service
one service — the long-term ledger and the books are the same system33 tablesThe 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)
idPKreservation_id⇄ CRS reservations UNIQUE ← WRAPS the flexible reservationlot (site) ⇄ CRS sitesstatusapplicant→active→moved_outterm / rent / depositsite_idkindMH|LT-RV|POH|storageidPKpayment_id⇄ CRS paymentsinstallment_id⇄ CRS installmentsamount / order← PaymentAllocator choke pointidPKtenancy_id→ tenanciesheld_cents (liability)dispositionitemized at move-outidPKpark_id⇄ CRS campgroundsgrace / pct / recurring / cap(supersedes 3/5/7 enum)application.idPKlead_id⇄ CRM leadsscreening_resultdecision + FCRA notice→ tenancy on approve→ tenanciesidPKutility_id⇄ CRS utilitieseffective_fromtiers / base_fee / pass_throughidPKmeter_read_id⇄ CRS meter_readsreasonspike|negative|missingidPKtenancy_id→ tenanciesbarcodePayNearMesuspense_entry← blocked partialsGeneral ledger & accounting (~50 tables)
idPKein / fiscal_year / basisorg_id⇄ CRS organizationsidPKentity_id→ entitiescampground_id⇄ CRS campgroundseffective rangedaterange EXCLUDEidPKparent_entity_id→ entitieschild_entity_id→ entitiesownership— entity-owns-entity (fund)idPKentity_id→ entitiestype / normal_sideis_systempinnedparent_id→ accounts ≤3 deepentry.idPKentity_id→ entitiesline.account_id→ accountsdebit/creditSUM=0 trigger, no UPDATE/DELETEsource_event_id→ ingested_eventsreverses_entry_id→ journal_entriesevent_idsource (CRS outbox)⇄ CRS installments/payments/payoutsrule: charge_code+method→accountseffective-datedidPKtypeunmapped|locked-periodevent_id→ ingested_eventsidPKentity_id→ entitiesgl_account_id→ accountsplaid_itemcategorization_rule_ididPKstripe_payout⇄ CRS stripe_payouts→ gross/refunds/feesmatched_bank_txn_id→ bank_transactionsbill.idPKvendor_id→ vendorsentity_id→ entitiesapproval_statepayment (rails partner)ACH/check/cardidPKentity_id→ entitiescontact_id⇄ CRM contacts (LP)classA|B|GPidPKpartner_position_id→ partner_positionsentry_typecontribution|distribution|pref|allocation← single-writer; investor module VIEWS thisidPKfrom_entity / to_entity→ entitiesnets to zeroinvariantidPKentity_id→ entitiesdepreciation → journal→ journal_entriesInvestor / fund — beside accounting
idPKkinddeal | fundentity_id⇄ Books entities (1:1)deal.vehicle_id→ vehiclescampground_id(s)⇄ CRS campgroundsfund.vehicle_id→ vehiclesidPKvehicle_id→ vehiclesholder_contact_id⇄ CRM contactsholder_vehicle_id→ vehicles (fund-in-deal)unitsidPKposition_id→ positionskindsubscription|transfer|dilution (append-only)config.idvehicle_id→ vehiclescash_typeoperating|capital_eventtranche.characterpref|ROC|profit|carryrun.idline.position_id→ positionssources → deal line → fund runcascade provenanceposts → capital_account_entries⇄ Books capital_account_entriesidPKvehicle_id→ vehiclescommitted/called/receivedcascade to fund LPsraise.idPKvehicle_id→ vehiclessubscription → position on close→ positionsesign_envelopefunding_receiptidPKposition_id→ positionsbasis (frozen jsonb)recompute-and-diffidPKvehicle_id→ vehiclesas_of / method— segregated from ledgerThe 5 seams
every join between new work and the monolith| Seam | How it connects |
|---|---|
| 1 · Identity | CRM 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 · Inventory | Tenancy 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 · Money | Monolith 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 registry | The 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 · Warehouse | Everything streams to the existing BigQuery warehouse → the Intelligence layer (reports, chat builder). |