Skip to main content

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 Data collections 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. bcProjectId is dead (bc-types.ts:342); the BCProject type survives only as a virtual shim (toVirtualProject, bc-data-adapter.ts:30-38) whose id IS 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:

FieldNotes / cite
ticketIdMT-YYYY-NNN (bc-types.ts:340)
companyIdBC company GUID
bcProjectIdLegacy — unused (:342)
propertyCode / propertyName / propertyType / unitProperty identity (:345-348)
tenantCustomerIdOwnership key for tenants (:351)
tenantCustomerNumber / tenantName / tenantUserId / tenantPhone / tenantEmailTenant identity (:352-356)
issueType / urgency / description / location / tenantAccessNotes / images[] / relatedTicketId?Issue detail (:359-365)
statusLifecycle (see below) (:368)
createdAt / updatedAtISO timestamps
assignedVendorIdOwnership key for vendors (:373)
assignedVendorNumber/Name/Phone/UserId, vendorAssignedAt, estimatedArrival, workStartedAt, arrivalNotesVendor assignment (:374-382)
estimatedCost(:385)
quoteCost + scheduling; incl. visitDateTimeIso, slot fields offeredSlots/selectedSlot/slotMenuSentAt, tenantMultipleAccept (:386-408)
previousQuotes[]?Revision history (:411-418)
approvalrequired, 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/NumberBC financial linkage (:443-449)
completionNotes, completionPhotos[], invoiceNumber/Url, actualCost, costVariance(Percent), completedAtCompletion (:451-462)
paymentmethod, reference, amount, status (:465-473)
tenantSatisfied / tenantRating / tenantFeedback(:482-484)
feedbackTenantSentAt? / startReminderSentAt? / completionReminderSentAt?Job idempotency stamps (:486-490)
closureclosedAt, 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_TRANSITIONS documents the contract, but tools set status directly 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: generateTicketId holds 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-117 race. Cross-process collisions are backstopped by BC's "already exists" retry.

Reference — other entities & Lua Data collections

BC-backed entities (bc-types.ts)

EntityWhere stored
Company / Customer (tenant) / VendorBC 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/GLBC 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
Quoteembedded in ticket.quote; also dual-written via syncQuote

Lua Data collections

CollectionPurpose
email_msgid_ticketInbound Message-ID → ticketId (thread resolution tier 3); 30-day staleness
user_active_ticketPer-user "most recent active ticket" guess (tier 4)
email_threadStored thread history (inbound+outbound bodies)
message-bufferPer-user buffer to coalesce rapid follow-ups
auto_reply_ratePer-sender reply timestamps (loop-prevention cap)
daily_report_sent / weekly_report_sentBrief 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 + withParentRetry backoff (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 tenantTimeConfirmed must both pass; lockedInAt stamps 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.