Vendor Lifecycle: Coordination → Job Done → Paid
1. The arc
Coordination is the beginning. Job done is the middle. Payment is the end. One job, one thread, no re-typing. That is the whole product thesis for vendors: a PM should never have to re-key a job into a second system because it moved from "we booked it" to "they finished it" to "we owe them $1,038.61".
What we found, in one paragraph
Camellia's mailbox already carries the entire back half of the lifecycle in structured, parseable form — 443 vendor emails, 220 PDF attachments, 44 unique invoices, $39,583.61. The classifier already reads those emails; it just throws them away. The PDF parser that would extract the amounts already runs in the production inbound-email Lambda. The UI stepper that would display the lifecycle is already mounted on the work-order page. Almost none of this is a build — it is wiring, plus one policy decision about which vendor senders we trust.
2. What Camellia's 2026 mail actually contains
Window: 2026-01-01 → 2026-07-29. Sources: production DynamoDB (read-only) plus the live
Camellia mailbox via the app's production Microsoft Graph integration (Mail.Read only).
Every dollar figure below was extracted literally from downloaded PDF text — none inferred,
none estimated.
Categories in the mail stream
| Category | Count | What it is / real example |
|---|---|---|
| Vendor emails identified | 443 | The categorized universe for 2026 |
| Schedule confirmations | 101 | "we'll be out Tuesday" — Miracle Method, Western States Fire Protection, Conservice prebill scheduling |
| Job-done signals | 32 | Chem-Aqua monthly Field Service Report (site ID 429108, narrative in the body); American Garage Door line items reading COMPLETE - 6/22/26 - AO/JA; 7 vendor SMS; 4 typed into a work order |
| Invoice emails | 51 | 47 with an amount literally extracted; 44 unique after de-duplicating vendor re-sends |
| Quotes / estimates | 22 | 8 priced, $7,229.36 — not committed spend. Royal Textile is quote-heavy: 10 quotes vs 2 invoices |
| Aggregate statements | 14 | Anchor's statements_bill.pdf, TK's Statement of Account — excluded to avoid double counting |
| High-volume, zero-billing | — | Comm-Fit (20), EcoFlo Supply (22), Ally Waste (21), EnergyScapes (17), Surface Experts (14) — cold outreach only |
Spend by vendor — 2026 YTD, unique invoices only
| Vendor | Invoices | Total | Character |
|---|---|---|---|
| MTech Mechanical (plumbing) | 4 | $10,820.87 | Project + per-call plumbing; best attribution in the portfolio |
| Apartments.com (CoStar) via Billtrust | 7 | $9,653.00 | Flat $1,379.00/mo advertising |
| TK Elevator | 4 | $5,721.05 | Monthly maintenance contract |
| The Home Depot | 5 | $3,148.25 | On-site tech material purchases |
| Anchor Pest Control & Wildlife | 15 | $2,961.00 | $333.00/mo recurring + $12–$215 callouts |
| American Garage Door | 5 | $2,598.61 | Per service call |
| Conservice (Denver Water pass-through) | 1 | $2,323.44 | Utility, property-level |
| Royal Textile Products | 1 | $1,370.39 | Install after quote |
| Alpine Glass | 2 | $987.00 | Glass repair |
| Total | 44 | $39,583.61 |
Spend by month
| Month | Invoices | Total |
|---|---|---|
| 2026-01 | 4 | $2,249.00 |
| 2026-02 | 3 | $2,097.00 |
| 2026-03 | 8 | $6,414.54 |
| 2026-04 | 8 | $7,111.95 |
| 2026-05 | 6 | $5,072.64 |
| 2026-06 | 7 | $11,882.03 |
| 2026-07 (to 07-29) | 8 | $4,756.45 |
| Total | 44 | $39,583.61 |
* July is partial (through 07-29). June is inflated by a single property-level job: MTech invoice 158334-1, $8,387.00 — "Replace Meter Pit Valves".
Proof: 12 real invoices parsed end to end
Fields extracted mechanically from downloaded PDF text — no manual entry, no guessing. Amount
evidence strings are stored per row (e.g. PDF JORDAN PERLMUTTER & CO 162425.pdf: Total 1,616.00).
| Vendor | Date | Invoice # | Amount | PO / job ref | Unit |
|---|---|---|---|---|---|
| MTech Mechanical | 2026-04-02 | 162425 | $1,616.00 | Customer PO 591 | 322 |
| MTech Mechanical | 2026-05-22 | 164430 | $553.45 | Customer PO 262 | 207 |
| MTech Mechanical | 2026-06-16 | 165501 | $264.42 | — | 422 |
| American Garage Door | 2026-01-08 | 93390 | $440.00 | — | — |
| American Garage Door | 2026-06-23 | 95245 | $1,038.61 | — | — |
| TK Elevator | 2026-05-21 | 3009463147 | $1,378.80 | PO # (blank on doc) | — |
| Apartments.com / CoStar | 2026-06-02 | 124138040 | $1,379.00 | — | — |
| Anchor Pest Control | 2026-04-02 | 79616 | $128.00 | — | 222 |
| Royal Textile Products | 2026-05-20 | 251742 | $1,370.39 | PO Number 590 | 117 |
| The Home Depot | 2026-04-02 | H1505-716699 | $949.36 | PO / Job Name 596 | — |
| Alpine Glass | 2026-03-20 | 9974 | $602.00 | — | — |
| Denver Water (via Conservice) | 2026-04-16 | — | $2,323.44 | — | — |
Attribution: how much of the spend attaches to a job
| Attribution confidence | Invoices | Amount |
|---|---|---|
exact_po — document carries a purchase-order / job number | 8 | $6,688.09 |
unit_and_vendor — unit ref + matching work order / turnover | 1 | $264.42 |
vendor_and_date — unit ref, no matching job in window | 2 | $2,451.44 |
none | 33 | $30,179.66 |
| Actually attributed to a turnover | 1 | $264.42 |
The one attributed invoice is MTech 165501, unit 422, against turnover
turnover_d188d1f2… (opened 2026-07-08). Everything else is either recurring contract
spend with no job reference, or carries a purchase-order number we cannot resolve — which is the
headline finding.
Headline callout: vendor invoices carry AppFolio purchase-order numbers, and we have zero purchase-order rows.
Real PO numbers printed on real Camellia invoices in 2026: 262, 587, 590, 591, 592, 593, 595, 596. The Home Depot alone had 5 of 5 receipts carrying a job/PO number.
The PurchaseOrder entity
(PK=PROP#<propertyId>, SK=PO#<externalPurchaseOrderId>) has
0 rows in production, portfolio-wide. listPurchaseOrders has never
been populated for any property.
Consequence: $6,688.09 of 2026 spend already carries a hard join key that resolves to nothing. Turning on PO sync for Camellia converts that — plus every future MTech, Home Depot, and Royal Textile invoice — from "unattributed" to hard job attribution, with no parsing improvement required. This is the single highest-leverage gap in the dataset.
Other gaps worth knowing before anyone builds on this
- DDB email coverage starts 2026-03-17. The
EmailIngestionlog has no rows before that date. Jan 1 – Mar 16 in this report came from Graph, not from our data. The mailbox itself goes back to 2017-12-07. - Bodies and attachments are not durably ours on the Graph path. Graph-ingested
mail stores metadata only; bodies are lazily re-fetched from Microsoft; every
saveIngestion(...)writesattachments: []. Of 2,513 CamelliaEmailIngestionrows, only 31 carry attachment metadata — all from the legacy SES era. - No AppFolio cost data in DDB. 27 Camellia work orders carry
appfolioEnrichment;invoices,billableItems,laborItems,estimatesare empty on every one.Turnover.totalActualCostisnullon all 26 Camellia turnovers. There is no actual-cost figure anywhere in our data model today. - No Camellia vendor voice records. 528 Camellia voice conversations in 2026,
zero
VendorCallSessionrows — all 56 table-wide rows belong to the Willows test property. All Camellia job-done signal arrives by SMS (7) or is typed into a work order (4). - "Preferred vendor" is not a modelled concept.
VendorMembershipfor org_jpco holds all 816 AppFolio vendors with no preference flag, andVendorCompanyrows store no email address and no phone. The vendor list in this report was derived empirically from mail traffic and fuzzy-matched back to the roster. - De-duplication is material. Three invoices were re-mailed within 2026
(TK 6000845175, AGD 95048, Royal Textile 251742) worth $2,696.39. Dedupe on
(vendor, invoiceNumber, amount).
3. Detection architecture
email_user:<address> claim fails phone normalization outright, so there is no path
from brenda@americangaragedoor.net to a vendor company record, and vendor rows carry
no email at all. Measured: only 6 of 2,513 Camellia inbound emails exact-match a
known vendor email claim. That is why slice 0 below is an index, not a feature.
3a. The signal ladder
| Rung | Signal | Where it lives today | 2026 volume |
|---|---|---|---|
| 1 | Schedule confirmation — "we'll be out Tuesday" | Inbound email, classified then discarded. Voice/SMS already write through the channel-agnostic outcome machinery. | 101 |
| 2 | Job done — completion narrative, FSR, "COMPLETE - 6/22/26" | WorkOrder.completionNotes / completionPhotos /
resolvedAt; vendor SMS on CONV# threads; the deterministic
assistant marker "Work order <id> … has been marked complete." | 32 |
| 3 | Invoice + amount | PDF attachment. Not persisted on the Graph path. Extraction happens nowhere today. | 44 |
3b. Parser lanes and cost
There is nothing to rebuild. PropFlow already has a production multi-strategy
PDF→JSON parser whose extraction prompt is literally titled "You are a vendor invoice/quote
parser", and both of its legs already execute inside the production inbound-email
Lambda — pdf-parse (pure JS, bundled, no layer) for text-layer documents and
native Claude PDF document blocks for scanned ones. The only local-only piece is an optional free
Ollama pre-pass, which must be bypassed in Lambda rather than merely allowed to fail.
| Lane | When | Credential | Runtime | Cost |
|---|---|---|---|---|
| (a) Subscription runner | NOW — proof + 2026 backfill | Claude subscription bearer, resolved automatically from the runtime; runner aborts with
exit 2 if chooseCredential() returns anything but bearer |
Local tsx, sequential, resumable, throttle-aware |
$0 metered — subscription tokens only |
| (b) Metered Lambda | LATER — live per-email extraction, ships dormant behind an off-by-default flag | Metered key; AWS_LAMBDA_FUNCTION_NAME forces metered and there is deliberately
no force-bearer override |
Existing inbound-email Lambda | $0.003–$0.009 / invoice; <$1/month at Camellia volume |
Blended per-invoice metered cost at 70% text-layer / 30% scanned: $0.0029 all-Haiku, $0.0059 Haiku-text + Sonnet-scanned, $0.0087 all-Sonnet. At an estimated 60 invoices/month that is $0.17–$0.52/month; a full ~700-document 2026 backfill batched is $1–$3 one-time. The approval ask for lane (b) is well under a dollar a month — so the model should be chosen on accuracy, not price.
Volume of 40–80 invoices/month (midpoint 60) is the one
soft number here — it is an estimate, not a measurement. The 44 unique invoices this mining pass
found are Camellia only, one property. Count the real
s3://propflow-photos/email-attachments/ prefix before quoting the cost figure onward.
Parse proof — already run, on the subscription lane
15 of 15 parses succeeded across two runs against
real vendor PDFs: Run A = 13/13 documents through the pdf-parse → Haiku text leg,
0 failures, 0 exceptions, 0 retries, no escalation triggered. Run B forced 2 of those documents
through the native-PDF vision leg to exercise the branch the corpus never hit.
Both legs agreed on the money where they overlapped (3575 and 3650, exact match). That agreement is the most reassuring number in the run, because a wrong total is the expensive failure mode.
Combined spend: 45,895 subscription tokens
(44,569 in / 1,326 out). $0 metered — ANTHROPIC_API_KEY was deleted
from the environment before the client module was imported. Dry-run throughout; nothing written,
no Camellia data touched.
Separately, the mining pass parsed 220 of 220 Camellia PDFs — 181 via text layer, 39 via local OCR, 0 unparseable, 0 model tokens spent.
unitReference has a real false-positive mode — four documents returned
Suite 665, almost certainly the vendor's own letterhead suite rather than the work
location. The same class of error as American Garage Door's own Unit #113 in the
Camellia corpus. The PO field has a "P.O. Box" guard; the unit field has no guard yet. Fix: require
unit refs to validate against the Camellia roster (120 units) and sit adjacent to a
GRANT ST / UNIT / S/M: UNIT marker.
(2) The text leg is not always the better leg — on one document vision recovered an invoice date
and number the text leg returned null for, while on another the text leg won. The ladder currently
always prefers text when a text layer exists.
3c. What ships dormant vs. what runs as backfill
- Runs now (lane a): a guarded
--property-id-scoped, dry-run-default backfill script that reads PDFs, extracts, and writes only detection records (PK=PROP#<id>,SK=INV#<id>). Never touches accounting, AppFolio, or any live saga. - Ships dormant (lane b): the inbound-email hook, behind
INVOICE_DETECTION_ENABLEDdefaulting false — reviewed once in the same PR rather than written twice. Keep the parser import dynamic; the existing dynamic-import block exists specifically so heavy parser trees don't deserialize on every inbound email. - One shared extraction module, two lanes. Do not fork the prompt or the strategy ladder per lane — that drift is what previously let a fix sit dead for a release.
3d. Per-vendor format profiles (the parser spec, summarized)
| Vendor | Delivery | Key fields | Quirk to guard |
|---|---|---|---|
| MTech Mechanical | PDF, JORDAN PERLMUTTER & CO <inv#>.pdf; invoice # also in subject |
Work Order, Service Location … #<unit>, Customer PO, Total |
Best attribution in the portfolio — the only vendor reliably carrying both unit and customer PO. Total has no $. They misspell their own "JORDON/JORDAN". |
| Anchor Pest Control | Two families: per-service Invoice #<n>.pdf and monthly statements_bill.pdf |
Invoice #, Service date, Total / Amount due |
Treat the statement as a roll-up — never add it to spend, it re-states counted invoices. |
| TK Elevator | <invoice#>.pdf, plus plain-text Statement of Account and amount-free dunning notices |
Clean labelled-colon layout: INVOICE #:, SERVICE DATE:, TOTAL DUE: $x |
Easiest vendor to parse. Same invoice number re-mailed on statement runs — dedupe required. |
| Apartments.com / Billtrust | One invoice per month, Acct No. 29316887 |
Invoice Number, Service Period, Invoice Amount USD |
Parse the PDF, not the body. A naive columnar PO scrape picks up 379 out of 1,379.00. |
| The Home Depot | POS receipt PDFs, H1505-<order#> Receipt.pdf |
PO / Job Name <n> ← the AppFolio PO number; per-SKU lines; Order Total |
Best PO coverage of any vendor (5/5). Receipt names the buying tech — a weak identity signal. |
| American Garage Door | Invoice per call; the body restates Amount Due: $x, parseable without opening the PDF |
Invoice #, Project: 1235 N Grant St., Balance Due |
Their own address contains Unit #113 — a naive unit regex mis-attributes. Line items carry a completion date, a job-done signal inside an invoice. |
| Royal Textile Products | Quote first, invoice after install (10 quotes vs 2 invoices in 2026) | PO Number (columnar — value under the header, not after a colon), S/M: UNIT <n> |
Total is right-aligned on a line whose left side is item text. Multiple units per document is normal. |
| Alpine Glass | Estimates are scanned image PDFs with no text layer; invoices are native text | Project: CR_Camellia-<unit> ← unit ref lives in the Project field, not line items |
Estimates need OCR; invoices don't. The clean split the two-leg ladder was built for. |
| Conservice | Monthly report packages, prebill approvals, forwarded Denver Water bills | AMOUNT DUE on the Denver Water bill; account key ca734 in nearly every subject |
Highest email volume of any vendor (52) but almost all coordination, not billing. Reports packages are informational. |
| Chem-Aqua | Monthly Field Service Report, site ID 429108 | Technician findings narrative in the email body | Pure job-done signal — no amounts. The cleanest rung-2 source in the mailbox. |
4. UI placement options
Almost nothing new needs to be invented. The app already has a shared progress rail driven by a
pure server-side step derivation, a vendor-invoice rollup page, a dashboard stat-card grammar, and
a registered mtdVendorSpend metric. All four mockups below are extensions of one of
those.
sublabel slot on StepperStep.Why it's S: two entries in WoProgressStepKey, two in
LABELS, two in doneFlags, and three new fields on the derivation input.
UI diff: zero — toStepperSteps maps state 1:1 already. The one thing
to eyeball is eight horizontal steps on a phone; the rail already wraps in
overflow-x-auto and supports vertical orientation.
gridClassName goes
sm:grid-cols-3 → sm:grid-cols-2 lg:grid-cols-4; identity color is the
maintenance-family cyan so it reads as maintenance rather than generic vendor spend. Zero new
components.Why it's S: one ClickableStatCard inserted into an existing group
plus a grid-class change — roughly ten lines. The card doubles as the honest scoreboard for the
attribution gap: today it reads 0.7%, and it is the number PO sync is supposed to move.
Why it's S: a header total beside the existing percent counter and a per-row amount opposite the existing "View work order →" link. No new component. Em-dash for unknowns — never fabricate a number.
| Title | Unit | Vendor | Status | Invoiced | Created |
|---|---|---|---|---|---|
| Garage door opener fault | — | American Garage Door | Complete | $1,038.61 | Jun 18 |
| Elevator monthly maintenance | — | TK Elevator | Complete | $1,378.80 | May 19 |
| Kitchen drain backup | 422 | MTech Mechanical | Complete | $264.42 | Jun 14 |
| Blind replacement | 117 | Royal Textile Products | Complete | $1,370.39 | May 12 |
| Monthly pest service | — | Anchor Pest Control | Complete | $333.00 | Jul 1 |
| Common-area glass panel | — | Alpine Glass | Scheduled | — | Jul 24 |
tabular-nums, faint em-dash
when absent — the exact shape of the turnover charges cell.| Unit | Stage | Work orders | Charges (est.) | Invoiced (actual) | Days |
|---|---|---|---|---|---|
| 422 | Make-ready | 2/3 | $1,850 | $264.42 | 21 |
| 117 | Ready to lease | 3/3 | $1,400 | $1,370.39 | 34 |
| 207 | Make-ready | 1/2 | $600 | — | 9 |
Turnover.totalActualCost is null on all 26 Camellia turnovers.Why it's M, not S: not design — the work-order list fetches
?view=meta (scalars only), so the new field has to be added to the meta projection,
and the mobile card renderer must move in lockstep with the column array. Estimated figures in the
turnover mock are illustrative; only the invoiced column carries mined values.
5. Effort ladder and decisions needed
| Slice | What it delivers | Why it's the prerequisite it is | Effort | Blocked on |
|---|---|---|---|---|
| 0 | Email → vendor reverse index + PM-confirmed domain backfill | Vendor recognition is phone-only and VendorCompany stores no email, so an email
sender cannot resolve to a vendor at all. Nothing downstream can attribute without this. |
S | PM confirmation of preferred-vendor domains |
| 1 | Detection + surface — classify the schedule/job-done/invoice signal and show it (options A, B, D1) | Vendor email is understood today and then discarded on the skip arm. Slice 1 stops throwing the signal away and gives it a place to appear. | S | Slice 0 |
| 2 | Schedule-write parity — email-sourced schedule confirmations write through the same channel-agnostic outcome machinery voice and SMS already use | 101 schedule confirmations arrived by email in 2026 and none of them moved a work order.
The machinery is already channel-agnostic by mandate —
VendorCallSessionChannel includes 'email' — so this is mostly reuse,
not new code. Email is simply not wired into it. |
M | Slice 1 |
| 3 | Attachment fetch + invoice extract + AP handoff | The Graph path persists no attachments, so the bytes have to be fetched before they can be parsed. The parser itself is already in the Lambda. | M | Trusted-sender gate policy; metered spend approval for lane (b) |
| 4 | Invoice → purchase-order matching | Converts the $6,688.09 of exact_po spend into hard job attribution using PO
numbers already printed on the documents. |
M | Hard-blocked — PurchaseOrder has 0 rows portfolio-wide |
Decisions needed from Fede
- Trusted-sender gate policy for vendor attachments. The inbound path currently hard-rejects an untrusted sender carrying a data attachment. Random vendors will not be on the trusted-data-sender list, so as written, slice 3 rejects almost every real invoice at the gate. Decide whether vendor invoices get their own sender-trust rule (e.g. trust follows the PM-confirmed vendor domain from slice 0) or whether the gate stays absolute. This is the likeliest blocker to a working end-to-end demo.
- Enable purchase-order sync for Camellia. Unblocks slice 4 and converts $6,688.09 of already-collected 2026 spend — plus every future MTech, Home Depot, and Royal Textile invoice — into hard attribution. Nothing else in the ladder moves the 0.7% attribution number nearly as far.
- Preferred-vendor domain confirmations. There is no preferred-vendor concept:
org_jpco's membership list is all 816 AppFolio vendors with no flag, and the nine-vendor list in
this brief was derived empirically from mail traffic by fuzzy name/domain match. Slice 0 needs a
PM confirm screen — "is
brenda@americangaragedoor.netAmerican Garage Door?" — to turn that fuzzy match into a fact. Decide whether that screen is a one-time backfill sweep or an ongoing inbox affordance. - Metered-spend approval for the Lambda lane. Until that approval lands,
extraction runs subscription-only (lane a) and the inbound-email hook ships
dormant behind an off-by-default flag. The ask is small — $0.003–$0.009 per invoice,
well under $1/month at Camellia volume, with a one-time backfill of $1–$3 — but it
is a real gate:
AWS_LAMBDA_FUNCTION_NAMEforces the metered credential and there is deliberately no force-bearer override, so "Lambda on the subscription" is not an option.