Data model
In one line: there is no separate ticket database — each ticket is a JSON file attached to the tenant's Business Central customer record, with a small set of Lua
Datacollections for bookkeeping, and the ticket moves through a 13-state lifecycle.
What it is
For ticket MT-2026-117 belonging to a tenant, BC holds two attachments on that customer: ticket-MT-2026-117.json (all the ticket data) and audit-MT-2026-117.json (the event log). Vendors carry a vendor-data.json attachment for their extended profile. Because BC's standard API has no "find ticket by ID" for attachments, the agent scans all customers once and keeps an in-memory ticketId → customerId map (bc-entities.ts:492-571).
A handful of small bookkeeping tables live in Lua's own Data store (email-thread mapping, rate-limit counters, daily/weekly "already sent" guards).
Under the hood — tickets are NOT BC Projects.
bcProjectIdis dead (bc-types.ts:342); theBCProjecttype survives only as a virtual shim (toVirtualProject,bc-data-adapter.ts:30-38) whoseidIS the ticketId, so legacy tool code didn't have to change.
Reference — the Ticket entity
Tickets are TicketAttachmentData, stored as ticket-{id}.json (bc-types.ts:339-520; storage at bc-entities.ts:492-499,627-669). Selected key fields:
| Field | Notes / cite |
|---|---|
ticketId | MT-YYYY-NNN (bc-types.ts:340) |
companyId | BC company GUID |
bcProjectId | Legacy — unused (:342) |
propertyCode / propertyName / propertyType / unit | Property identity (:345-348) |
tenantCustomerId | Ownership key for tenants (:351) |
tenantCustomerNumber / tenantName / tenantUserId / tenantPhone / tenantEmail | Tenant identity (:352-356) |
issueType / urgency / description / location / tenantAccessNotes / images[] / relatedTicketId? | Issue detail (:359-365) |
status | Lifecycle (see below) (:368) |
createdAt / updatedAt | ISO timestamps |
assignedVendorId | Ownership key for vendors (:373) |
assignedVendorNumber/Name/Phone/UserId, vendorAssignedAt, estimatedArrival, workStartedAt, arrivalNotes | Vendor assignment (:374-382) |
estimatedCost | (:385) |
quote | Cost + scheduling; incl. visitDateTimeIso, slot fields offeredSlots/selectedSlot/slotMenuSentAt, tenantMultipleAccept (:386-408) |
previousQuotes[]? | Revision history (:411-418) |
approval | required, approvedBy, approvedAt, approvedAmount, conditions[], justification? (:421-431) |
tenantTimeConfirmed? | Second, independent gate — time confirmation, separate from cost (:433-437) |
rescheduleRelayCount? | Tenant reschedule cap = 2 (:439-441) |
bcPurchaseOrderId/Number, bcPurchaseInvoiceId/Number, bcSalesInvoiceId/Number | BC financial linkage (:443-449) |
completionNotes, completionPhotos[], invoiceNumber/Url, actualCost, costVariance(Percent), completedAt | Completion (:451-462) |
payment | method, reference, amount, status (:465-473) |
tenantSatisfied / tenantRating / tenantFeedback | (:482-484) |
feedbackTenantSentAt? / startReminderSentAt? / completionReminderSentAt? | Job idempotency stamps (:486-490) |
closure | closedAt, totalDaysOpen, managerOverride (:493-501) |
escalatedAt? / photoSaveFailedAt? / lockedInAt? | Stamps (:504-514) |
cancelledBy / cancelReason / cancelledAt | (:516-519) |
Status lifecycle
13 states (constants.ts:6-20): reported, vendor_contacted, quoted, pending_approval, pending_tenant_confirmation, approved, rejected, in_progress, completed, closed, cancelled, on_hold, disputed.
Legal transitions are defined in VALID_TRANSITIONS (constants.ts:102-116). New tickets start at reported. System auto-cancel reasons: reschedule_limit_reached, tenant_no_response_timeout (system-cancel.ts:21).
⚠️ No central state-machine guard.
VALID_TRANSITIONSdocuments the contract, but tools setstatusdirectly with only ad-hoc per-tool checks — illegal transitions are not centrally rejected. Treat the table as the intended contract.
Ticket-ID scheme (MT-YYYY-NNN)
- Validated by
/^MT-\d{4}-\d{3,}$/(ticket-id.ts:14-17). - Next ID = max sequence for the current year + 1, zero-padded to 3 (
bc-entities.ts:1251-1258). - Atomic allocation:
generateTicketIdholds a per-company promise lock and reserves the new ID in the cache immediately (bc-entities.ts:1270-1288) — fixed the 2026-06-05 duplicate-MT-2026-117race. Cross-process collisions are backstopped by BC's "already exists" retry.
Reference — other entities & Lua Data collections
BC-backed entities (bc-types.ts)
| Entity | Where stored |
|---|---|
| Company / Customer (tenant) / Vendor | BC standard; customer holds ticket+audit attachments |
Vendor extended profile (vendor-data.json: specialties, ratings, serviceAreas, active…) | attachment on the BC vendor |
| Property (a BC dimension; types COM/RES/DEV a second dimension) | BC dimension values |
| Purchase Order / Invoice, Sales Invoice, Journal/GL | BC standard |
Audit log ({ events: [{ ticketId, eventType, actorType, timestamp, details }] }) | audit-{id}.json attachment |
Escalation (custom extension table; EscalationType 8 values, status open/in_review/resolved/closed) | BC custom table |
| Quote | embedded in ticket.quote; also dual-written via syncQuote |
Lua Data collections
| Collection | Purpose |
|---|---|
email_msgid_ticket | Inbound Message-ID → ticketId (thread resolution tier 3); 30-day staleness |
user_active_ticket | Per-user "most recent active ticket" guess (tier 4) |
email_thread | Stored thread history (inbound+outbound bodies) |
message-buffer | Per-user buffer to coalesce rapid follow-ups |
auto_reply_rate | Per-sender reply timestamps (loop-prevention cap) |
daily_report_sent / weekly_report_sent | Brief once-per-period claim guards |
The COLLECTIONS constant (maintenance_tickets, etc.) is explicitly legacy — now mapped to BC entities and is not live storage.
Gotchas & failure modes
- Ticket-location cache is per-company. A warm worker that built the map for company A returns nothing for company B until rebuilt (
bc-entities.ts:562-571) — the daily-brief "all zeros" bug. - Dual-write FK ordering. Image/quote/approval/escalation rows must be written after the parent ticket row or BC 400s on the FK — handled by chaining +
withParentRetrybackoff (1s/2s/4s). Fire-and-forget means a silent BC desync is possible if every retry fails. - Two independent gates to lock a job. Cost approval AND
tenantTimeConfirmedmust both pass;lockedInAtstamps the first time both pass. Re-approvals must not re-ask the tenant. - Vendor duplicate-record mismatch. A shared phone/email can sit on both a live and a stale vendor record; resolution fetches
$top:25+pickPreferredVendor(prefer active) to avoid the "this isn't your ticket" mismatch.