// Lawn Estimator — tenant / pricing / service-area data model // // Render at https://dbdiagram.io (paste this file) or with the dbml CLI. // // NOTE ON STORAGE: the pricing_configs/.json files remain the SEED. As of P1c the // config is loaded from the DB (db.TenantStore) — currently the `tenant_configs` table // below: one row per tenant holding that SAME nested JSON (start simple), seeded from the // files on first boot, then the read/write path the P2 self-serve editor uses. The // normalized tables below (tenants / services / service_areas / …) are the *logical* model // the JSON maps to and the eventual target once the config schema stabilizes (plan §3.2 // "start simple, scale later"). Keep in sync with tenants.py / pricing.py. // Refs: docs/product-design-plan.md §2/§3.2, ROADMAP_APP.md. Project lawn_estimator_tenants { database_type: 'PostgreSQL' Note: 'Multi-tenant pricing + ZIP service areas for the lawn-quoting SaaS.' } Table tenant_configs { tenant_id varchar [pk, ref: - tenants.id, note: 'API-key label = tenant id'] config json [not null, note: 'The full tenant config as the nested JSON the pricing_configs/.json file holds (company/currency/presentation/services/areas/service_area_zips/allowed_origins/email/branding). INTERIM storage (P1c) — normalizing into the tables below is the eventual target.'] updated_at timestamp [not null, default: `now()`, note: 'Last edit (P2 self-serve editor writes here).'] Note: 'Durable tenant config, db.TenantStore. Seeded from pricing_configs/.json on first boot; a stored edit then wins over the file. Becomes durable in prod with P1d (Neon).' } Table tenants { id varchar [pk, note: 'Tenant id = the ALLOWED_API_KEYS label (the company). JSON file: pricing_configs/.json'] company varchar [not null] currency varchar [not null, default: 'USD'] presentation varchar [not null, default: 'estimate', note: 'firm | estimate (labeled "starting at")'] email_from varchar [null, note: 'Per-tenant sender, e.g. "The Lawn Standard ". Emails send FROM the tenant-owned (ESP-authenticated) domain; ESP provider+key stay global. NULL → global EMAIL_FROM env.'] email_reply_to varchar [null] email_lead_notify varchar [null, note: 'Where new-lead notifications for this tenant go (their inbox).'] brand_name varchar [null, note: 'Widget branding (config "branding" block, W6) — applied client-side so the embed looks like the business.'] brand_logo varchar [null, note: 'Logo URL/data-URI shown in the widget header.'] brand_accent varchar [null, note: 'Accent color (hex) — overrides the widget theme.'] cta_label varchar [null, note: 'Call-to-action button label after the price.'] cta_url varchar [null, note: 'Where the CTA links (the business booking page, until W5 checkout).'] Note: 'One lawn-care business. Maps to tenants.Tenant; base package = its services rows with service_area_id NULL.' } Table api_keys { key varchar [pk, note: 'X-API-Key (token_urlsafe). kind=secret → full access; kind=publishable → widget, quote-only + domain-locked.'] tenant_id varchar [not null, ref: > tenants.id] kind varchar [not null, default: 'publishable', note: 'publishable | secret'] Note: 'IMPLEMENTED as the `api_keys` table (db.ApiKeyStore). /admin provisioning mints keys here at runtime (no restart); merged into the in-memory maps at startup ALONGSIDE the ALLOWED_API_KEYS / WIDGET_API_KEYS env entries (which still work). Was env-only before.' } Table tenant_origins { tenant_id varchar [not null, ref: > tenants.id] origin varchar [not null, note: 'Full origin, e.g. https://acme.com — where the tenant widget may call from.'] indexes { (tenant_id, origin) [pk] } Note: 'Allowed browser origins for the tenant widget. A publishable-key /quote is rejected (403) unless the request Origin/Referer host matches a row here. Empty ⇒ no widget origin authorized. Today: the config JSON "allowed_origins" list.' } Table service_areas { id integer [pk, increment] tenant_id varchar [not null, ref: > tenants.id] name varchar [not null] travel_time_factor decimal [not null, default: 1.0, note: 'Scales every line item for jobs in this area (1.0 = no change).'] Note: 'A named zone of the tenant service area (tenants.ServiceArea). NO rows for a tenant → it serves all of Coverage at base pricing. The union of the zips in this table forms the tenant overall service area (plan §2); a lead outside it is still captured but gets no bookable price. WS4: a tenant may ALSO have a map-drawn `service_area_polygon` (GeoJSON in the config JSON) that decides serve/don''t-serve by point-in-polygon on the geocoded address; pricing still resolves by ZIP as below.' } Table service_area_zips { service_area_id integer [not null, ref: > service_areas.id] zip varchar [not null, note: '5-digit ZIP (ZIP+4 normalized to the first 5).'] indexes { (service_area_id, zip) [pk] } } Table services { id integer [pk, increment] tenant_id varchar [not null, ref: > tenants.id] service_area_id integer [null, ref: > service_areas.id, note: 'NULL = tenant base package; set = per-area override used for that area only.'] service_id varchar [not null, note: 'Auto-generated slug of the label (WS2); stable once created so `requires` refs hold.'] label varchar [not null] rate decimal [not null, note: 'The rate NUMBER; meaning depends on `unit`. Stored as rate_per_1000_sqft for back-compat.'] unit varchar [not null, default: 'per_1000_sqft', note: 'per_1000_sqft | per_sqft | flat (WS2).'] min_charge decimal [not null, default: 0] measure varchar [not null, default: 'lawn_sqft', note: 'Which pipeline measurement drives this service.'] requires varchar [null, note: 'Another service_id this is always paired with (e.g. overseeding requires aeration).'] Note: 'A priced service (pricing.Service). Line price per `unit`: per_1000_sqft = rate/1000×sqft; per_sqft = rate×sqft; flat = rate — then ×area.travel_time_factor, floored at min_charge.' } Table bundles { id varchar [pk, note: 'Auto-generated slug of the name (WS5).'] tenant_id varchar [not null, ref: > tenants.id] name varchar [not null] mode varchar [not null, note: 'fixed (amount IS the bundle price) | percent (amount = % off the summed member services).'] amount decimal [not null] cadence varchar [not null, default: 'one_time', note: 'one_time | weekly | biweekly | monthly | seasonal — recurring metadata until Stripe (D5).'] Note: 'A named package of services bookable as one (pricing.Bundle). Member service_ids in bundle_services. Shown in the widget as a selectable package; a booked package is recorded on the lead (detail.bundle) → Overview "Top packages".' } Table bundle_services { bundle_id varchar [not null, ref: > bundles.id] service_id varchar [not null, note: 'Must reference a services.service_id for the same tenant.'] indexes { (bundle_id, service_id) [pk] } } // Resolution (tenants.Tenant.pricing_for_zip): find the service_area whose zips contain // the quote ZIP → use that area's services (or the tenant base if it has none) and its // travel_time_factor. No matching area → tenant base package at factor 1.0.