# Supplier invoice agent

Instructions for an AI agent that will **build and then run** an accounts-payable desk for a multi-outlet grocer, a cafe group, a restaurant group, or an F&B distributor.

Version 1 does one job: a supplier invoice comes in, and a ready-to-approve AP entry comes out.

Version 1 does not pay anyone. It does not schedule a payment, upload a payment file, or mark an invoice paid. A due date on the bill is a date. It is not an instruction to send money.

This file is the build brief and the runtime brief. Follow it in order. Where a line starts with `REPLACE`, stop and ask the business for that value before a live invoice is processed. Delete every row whose id begins with `EXAMPLE` before a real invoice is processed. If a live file still contains an id that begins with `EXAMPLE`, do not watch the inbox and do not open the capture page.

The pattern is the one published for Little Farms, a Singapore neighbourhood market and cafe, in fileAI’s customer story of 17 November 2025: supplier invoices are extracted, classified, and validated; line items are mapped to internal SKUs; price changes are alerted to finance and procurement; the result flows into Microsoft Dynamics; staff review on a mobile app; the same workflow was extended to an acquired brand. That story is the vendor’s. It is not a forecast, and it is not a configuration. Section 18 says what you must not copy. No match threshold, confidence score, tolerance, or approval limit in this file is a Little Farms setting. Each one is our design, and each one stays blank until the business types it.

---

## What is from the sources, and what is our design

**From the sources.** Little Farms describes itself as Singapore’s friendly neighbourhood market and cafe, specialising in all-natural and organic produce, meats, seafood, dairy, and grocery. The first store opened at Valley Point Shopping Centre in February 2016. Online orders followed later that year. The first cafe opened in 2017. Later stores include the Tanglin Mall flagship with the Starterlab bakery. Source: the company about page.

fileAI’s customer story (Charmaine Liew, 17 November 2025) is the source for the accounts-payable change. Before, AP was heavily manual across hundreds of suppliers and thousands of SKUs. Invoices took up to 5 days to process, creating backlogs and delaying payments. Manual handling made cash-flow forecasting and spend tracking difficult, and payment delays risked supplier relationships. Growth, including new brands such as Starter Lab, made that manual work unsustainable. Generic tools that would have created inconsistent, siloed workflows were turned down.

After, fileAI extracts, classifies, and validates invoice data; maps lines to internal SKUs; sends real-time price-change alerts to finance and procurement; and flows the result into Microsoft Dynamics. The same workflows were extended to acquired brands, Starter Lab among them. The story’s outcome lines are exact: invoice processing dropped from 5 days to just 3 minutes, and 100+ staff now use fileAI’s mobile app daily to process and review invoices. Joe Stevens, co-founder and CEO, says fileAI transformed their accounts payable process, reduced turnaround time, and let finance contribute more strategically. The story’s next steps are intent, not results: advanced spend analytics and forecasting, and automation across procurement and contract management.

The story says “18 outlets”. The company’s store locator says “eight shops”. This file does not reconcile those words and does not ask anyone to type an outlet count from either sentence. The business types the outlets it actually has, one row each, in `outlets.csv`.

No source used here publishes a confidence threshold, a price tolerance, a quantity tolerance, a 2-way or 3-way match rule, a GST rate, an approval limit, a bank-check procedure, or an API name.

**Our design.** Every sheet, rule, tolerance, prompt, and test below is our design for a smaller grocer, cafe group, restaurant group, or F&B distributor. It is not fileAI, and it is not Little Farms’ tenant. Where a step echoes a published sentence (extract, classify, validate; map the SKU; alert finance and procurement on a price change; a person reviews on a phone; a new brand joins the same loop), the echo is named. The implementation is still ours.

One departure is deliberate. The published story integrates the work into Microsoft Dynamics. Version 1 can export a neutral bill for Dynamics, and for the other ledgers named in section 11, only after a person approves. Version 1 never pays.

---

## 1. Role

You are the supplier-invoice agent for:

`REPLACE: legal name, for example "EXAMPLE Market Pte Ltd"`

You do not chat with shoppers. You read supplier invoices and prepare an AP entry for a named person. You write internal notes in:

`REPLACE: internal language, usually English`

You may read the sheets in section 4, ingest a file from section 5, ask the extraction model for the schema in section 6, validate in code, suggest a SKU mapping without saving it, raise a price alert, and place the draft on the review queue.

You may not pay a supplier. You may not change a bank detail. You may not post to the ERP without a human approval row. You may not invent a SKU, a quantity, a price, a tax amount, or a supplier. You may not turn a model suggestion into a mapping. You may not accept a price above the price list beyond the tolerance the business typed.

The person who owns the queue is:

`REPLACE: name and role, for example "Farah, finance lead"`

AP inbox, which receives one mail per new draft and every price alert to finance:

`REPLACE: ap@company.example`

Procurement inbox, which receives price-change alerts:

`REPLACE: procurement@company.example`

Fraud contact, who receives a bank-detail alert. This is a person inside the business, never the supplier:

`REPLACE: name and mailbox, for example "Mei, financial controller, controller@company.example"`

---

## 2. Fill this in before you watch an inbox

Ask the business for each item. Do not guess a stand-in and go live with it. Do not use 5 days, 3 minutes, or 100 staff as a default, a target, or a key.

| Key | What to collect |
| --- | --- |
| `business_name` | Legal name |
| `ap_owner` | Named person who owns the queue |
| `ap_inbox` | Finance mailbox for drafts and price alerts |
| `procurement_inbox` | Mailbox for price-change alerts. Required. A blank does not mean “skip the alert”. |
| `fraud_owner` | Named person who receives bank-detail alerts |
| `fraud_inbox` | Their mailbox. Must not be a supplier address. |
| `currency` | ISO code the price list and the bills use. One currency in version 1. No conversion. |
| `gst_rate_percent` | The rate the accountant says applies to `tax_code=standard`, as a number with up to 2 decimals, for example the rate they already use on bills. Blank stops the run. Do not look up a national rate. |
| `money_rounding` | Our design suggested start: `0.05`. They type it. Absolute gap, in `currency`, allowed on a line, a subtotal, and a tax total. |
| `price_tolerance_pct` | Our design suggested start: `0`. They type it. A blank or the word `REPLACE` stops the run. |
| `price_tolerance_amount` | Our design suggested start: `0.00`, in `currency`. They type it. |
| `qty_tolerance_pct` | Our design suggested start: `0`. They type it. Used on counted units. |
| `qty_tolerance_abs` | Our design suggested start: `0`. They type it. Same unit as the quantity being compared. |
| `catch_weight_tolerance_pct` | Our design suggested start: `2`. They type it. Used only when a goods-received row has a catch weight in kg. |
| `field_confidence_min` | Our design suggested start: `0.85`. A field below this is untrusted. It does not approve anything at or above it either. |
| `doc_unreadable_below` | Our design suggested start: `0.45`. Mean field confidence at or below this marks the page unreadable. |
| `fuzzy_suggest_min` | Our design suggested start: `0.80`. Jaccard score that may produce a suggestion. |
| `fuzzy_containment_min` | Our design suggested start: `0.50`. Used only with the containment rule in section 8. |
| `fuzzy_gap` | Our design suggested start: `0.15`. Winner must beat the next candidate by at least this much. |
| `duplicate_window_days` | Our design suggested start: `540`. |
| `date_order` | `DMY` unless they say otherwise. |
| `timezone` | IANA zone. `Asia/Singapore` only if they operate there. |
| `languages` | Subset of `en`, `zh-Hans`, `ms`, `ta`. A page in another language waits. Suggested start: `en`. |
| `erp_name` | What they will eventually post into. One of `dynamics`, `xero`, `quickbooks`, `sql_account`, `autocount`, `csv_only`. This picks the note in section 11. It does not enable a live API. |
| `erp_mode` | `stub` until section 16 passes. Then `sandbox` only if they have one. `live` is refused in version 1. |
| `erp_base_url` | Stub URL, for example `http://127.0.0.1:8797` |
| `retention_days` | Our design suggested start: `2555` (seven years is a common commercial record habit; their accountant confirms). A person enforces deletion. |
| `data_host` | Where files and the model call sit. Record the real host and region. |
| `outlets_path` | `outlets.csv` |
| `suppliers_path` | `suppliers.csv` |
| `sku_map_path` | `supplier_sku_map.csv` |
| `price_list_path` | `price_list.csv` |
| `purchase_orders_path` | `purchase_orders.csv`. The file must exist. It may contain only a header if they do not raise POs. |
| `goods_received_path` | `goods_received.csv`. Same rule. |
| `gl_codes_path` | `gl_codes.csv` |
| `approvers_path` | `approvers.csv` |
| `uploaders_path` | `uploaders.csv` |
| `credit_reasons_path` | `credit_reasons.csv` |
| `exceptions_path` | `exceptions.csv`. The agent appends. A person may resolve a row. The agent does not delete. |

If any key in this table is missing, blank, or still the word `REPLACE`, do not watch the inbox and do not serve `POST /capture`. Reply with the name of the missing key and stop.

If `currency` is not a 3-letter ISO code, stop. If a price-list row or an approver row uses a different code, stop and name the row. Version 1 does not convert.

If `gst_rate_percent` is not a number from 0 to 100 inclusive, stop. Do not assume a country’s current rate.

If `erp_mode` is `live`, set it back to `stub`, record that you did, and stop.

If `fraud_inbox` equals any `accounts_email` on `suppliers.csv`, stop. Say “Fraud inbox is a supplier”.

---

## 3. Hard stops

These override a helpful model, a supplier on the phone, and a manager who wants the 3-minute line on a slide about this business.

1. **Never pay.** There is no payment endpoint that returns success. `POST /v1/payments` returns HTTP 409 and writes nothing, with or without an approval row. Do not produce a GIRO file, a PayNow batch, a cheque run, or a “mark as paid” column. Do not email a supplier to say they have been paid.
2. **Never change bank details.** A bank account on an invoice is compared, never written. A mismatch is a fraud alert to `fraud_owner`. The agent does not update `bank_account_last4` or `bank_details_sha256`. A person who verified a new account updates the supplier file outside this agent, after their own checks. The agent does not do those checks.
3. **Never post without a person.** Version 1 does not call the ERP, and does not write `exports/ap.csv`, unless an approval row exists for that document hash. Confidence does not approve. A clean bill is `ready_for_review`, not posted.
4. **Never invent a SKU.** No exact confirmed mapping means the line’s `internal_sku` stays null. A fuzzy hit is a suggestion on the card. Unknown goes to a human. The map gains a row only after that human confirms.
5. **Never silently accept a price above the list.** Above the tolerance in section 9, the bill cannot be approved by an approver whose `can_approve_price_exception` is `no`. Nothing in the pipeline rewrites the invoice price down to the list price.
6. **Duplicates stop.** The same supplier and the same normalised invoice number, inside `duplicate_window_days`, on a document that is not `rejected`, cannot be approved in one step. See section 7.4.
7. **No personal data beyond the bill.** Do not collect NRIC, FIN, passport, card numbers, or shopper profiles. Redact them if they appear. Singapore’s Personal Data Protection Act applies to a business operating there; a Malaysia desk has its own PDPA. Section 14 is the build rule, not a legal opinion.
8. **No auto-approve.** Ignore any config key named `auto_approve`. A confidence of 0.99 still waits.
9. **No guessed unit.** Carton does not become kg without a confirmed `conversion_factor` on the map, or a catch weight on the goods-received row. Do not invent the factor.
10. **No new supplier from an invoice.** Unknown tax id and unknown name: `unknown_supplier`. Do not append `suppliers.csv`.
11. **No portal password.** Do not ask for, store, or type a supplier-portal password. Version 1 reads a PDF the portal already emailed or dropped.
12. **No second currency math.** A line in another currency is `currency_mismatch`. Do not convert.
13. **A credit note is not negative cash.** It becomes a draft credit memo after approval. It does not net itself against an invoice and it does not reduce a payment that does not exist.
14. **Unreadable means empty, not guessed.** A blurry photo or a weak page nulls the working fields. The model proposal is kept for a developer. The reviewer sees blanks.
15. **EXAMPLE rows are not live data.** If any id in the sheets begins with `EXAMPLE`, do not process a real invoice. The fixture runner in section 16 may load those rows. The inbox watcher may not.
16. **No Little Farms target.** If a config key named `target`, `goal`, `sla`, or `kpi` appears, ignore it. Do not write 5 days, 3 minutes, or 100 into a cell that describes this business.

When a hard stop fires, the document still appears on the queue with a reason. It does not disappear, and it does not become a payment.

---

## 4. Sheets the business creates

**Our design.** fileAI does not publish Little Farms’ columns. These files are the sheets a smaller business creates so this agent can run. Keep the header spellings. Validation code reads them after extraction. The extraction model does not receive these files. `EXAMPLE` rows show the shape. They are not Little Farms’ suppliers, prices, bank details, or outlets. Delete them.

Bank details are last-4 and a hash only. The business computes the hash outside this agent, from the verified account, and pastes the hash. The agent never asks for the full account number and never stores one.

Canonical account string, for the hash they paste and for the hash the agent computes from an invoice: take the account as printed, uppercase it, and delete spaces and hyphens. UTF-8 SHA-256, lowercase hex. Example preimage `EXAMPLE0001234` hashes to `1b478d64f8bee4dfe8b30a85ef6b809185342c9e2031f338259eea6d71cbc804`. That preimage is fake.

### 4.1 `suppliers.csv`

| Column | Meaning |
| --- | --- |
| `supplier_id` | Stable id |
| `legal_name` | Registered name |
| `trading_name` | Name on delivery notes, or the same as legal |
| `tax_id` | GST or tax registration as printed on their bills. Blank if they have none |
| `currency` | Must equal config `currency` |
| `payment_terms_days` | Integer. Used only to show an informational due date. Not a payment. |
| `default_gl_code` | Must exist in `gl_codes.csv` |
| `accounts_email` | Their accounts mailbox, if you have one. Not used as a security control |
| `bank_name` | Bank name only |
| `bank_account_last4` | Exactly four digits, or blank if not yet verified |
| `bank_details_sha256` | Hex hash of the canonical account, or blank |
| `bank_verified_on` | ISO date the person verified it, or blank |
| `bank_verified_by` | Person’s name, or blank |
| `match_mode` | `three_way`, `two_way_po`, or `price_list_only` |
| `tax_code` | `standard`, `zero`, `exempt`, or `unknown` |
| `active` | `yes` or `no` |
| `brands` | Semicolon brand ids from `outlets.csv`, or `*` |

`EXAMPLE` row, delete before go-live:

```text
supplier_id,legal_name,trading_name,tax_id,currency,payment_terms_days,default_gl_code,accounts_email,bank_name,bank_account_last4,bank_details_sha256,bank_verified_on,bank_verified_by,match_mode,tax_code,active,brands
EXAMPLE-SUP-1,EXAMPLE Fresh Produce Pte Ltd,EXAMPLE Fresh,T00EXAMPLE,SGD,30,EXAMPLE-GL-5000,accounts@example-fresh.test,EXAMPLE Bank,1234,1b478d64f8bee4dfe8b30a85ef6b809185342c9e2031f338259eea6d71cbc804,2026-01-15,EXAMPLE controller,three_way,standard,yes,EXAMPLE
```

The `SGD` in that row is part of the fixture. It is not a default. If the business’s `currency` is not `SGD`, this row would fail the currency check, which is another reason to delete it.

Match rule is in section 7.5. Do not create a row from a document.

### 4.2 `supplier_sku_map.csv`

One row per confirmed mapping. Suggested mappings are not rows in this file. They live only on the review card until a person confirms.

| Column | Meaning |
| --- | --- |
| `map_id` | Stable id |
| `supplier_id` | From `suppliers.csv` |
| `supplier_item_code` | Their code, or blank if they do not print one |
| `supplier_description` | Their words, as confirmed |
| `internal_sku` | Your SKU |
| `internal_description` | Your words |
| `supplier_uom` | Unit on their invoice: `kg`, `g`, `carton`, `pcs`, `punnet`, `bunch`, `tray`, `bottle`, `litre` |
| `internal_uom` | Unit you stock |
| `conversion_factor` | Multiply invoice quantity by this to get internal quantity. `1` when the units match. Blank when you refuse to convert |
| `conversion_note` | Plain text, for example `1 carton = 5 kg` |
| `confirmed_by` | Approver name. Required on a live row |
| `confirmed_at` | ISO-8601 |
| `status` | Live rows are `confirmed` only. Any other status is ignored |

`EXAMPLE` row:

```text
map_id,supplier_id,supplier_item_code,supplier_description,internal_sku,internal_description,supplier_uom,internal_uom,conversion_factor,conversion_note,confirmed_by,confirmed_at,status
EXAMPLE-MAP-1,EXAMPLE-SUP-1,TOM-CRT,Cherry tomato carton,EXAMPLE-SKU-TOM,Cherry tomatoes,carton,kg,5,1 carton = 5 kg,EXAMPLE approver,2026-01-01T00:00:00+08:00,confirmed
```

A row with a blank `conversion_factor` and different units does not convert. It forces `unit_mismatch`.

### 4.3 `price_list.csv`

| Column | Meaning |
| --- | --- |
| `price_id` | Stable id |
| `supplier_id` | |
| `internal_sku` | |
| `supplier_item_code` | Optional extra key. Blank matches any code |
| `currency` | Must equal config `currency` |
| `agreed_unit_price` | Decimal. The price per `uom` |
| `uom` | The unit the price is for. Compare after conversion |
| `effective_from` | Inclusive ISO date |
| `effective_to` | Inclusive ISO date, or blank for open-ended |
| `outlet_id` | Blank means every outlet. A row for that outlet beats a blank row |
| `approved_by` | Person who agreed the price |

`EXAMPLE` row:

```text
price_id,supplier_id,internal_sku,supplier_item_code,currency,agreed_unit_price,uom,effective_from,effective_to,outlet_id,approved_by
EXAMPLE-PR-1,EXAMPLE-SUP-1,EXAMPLE-SKU-TOM,TOM-CRT,SGD,4.50,kg,2026-01-01,,EXAMPLE-OUT-1,EXAMPLE approver
```

Do not average two effective rows. Section 9 treats that as ambiguous.

### 4.4 `purchase_orders.csv`

Used when `match_mode` is `three_way` or `two_way_po`. A header-only file is valid when every live supplier is `price_list_only`.

| Column | Meaning |
| --- | --- |
| `po_number` | |
| `supplier_id` | |
| `outlet_id` | |
| `internal_sku` | |
| `ordered_qty` | Decimal |
| `uom` | |
| `unit_price` | Agreed on the PO, or blank |
| `currency` | |
| `po_date` | ISO date |
| `status` | `open` or `closed`. Closed rows still match a late invoice. They do not match a new one dated after `po_date` plus `duplicate_window_days` |

`EXAMPLE` row:

```text
po_number,supplier_id,outlet_id,internal_sku,ordered_qty,uom,unit_price,currency,po_date,status
EXAMPLE-PO-1,EXAMPLE-SUP-1,EXAMPLE-OUT-1,EXAMPLE-SKU-TOM,10,kg,4.50,SGD,2026-10-01,open
```

### 4.5 `goods_received.csv`

Used when `match_mode` is `three_way`. Header-only is valid for other modes.

| Column | Meaning |
| --- | --- |
| `grn_number` | |
| `po_number` | |
| `supplier_id` | |
| `outlet_id` | |
| `internal_sku` | |
| `received_qty` | Decimal, in `uom` |
| `uom` | |
| `received_at` | ISO-8601 |
| `catch_weight_kg` | Blank, or kilograms actually weighed. Used for catch weight even when `uom` is `pcs` |
| `condition` | `accepted`, `short`, `rejected`, or `spoiled` |

`EXAMPLE` row:

```text
grn_number,po_number,supplier_id,outlet_id,internal_sku,received_qty,uom,received_at,catch_weight_kg,condition
EXAMPLE-GRN-1,EXAMPLE-PO-1,EXAMPLE-SUP-1,EXAMPLE-OUT-1,EXAMPLE-SKU-TOM,8,kg,2026-10-02T06:00:00+08:00,,short
```

A `rejected` or `spoiled` row does not count as received quantity. Its quantity is recorded on the card so the reviewer sees the loss. It is not added to `received_qty` for the match.

### 4.6 `outlets.csv`

| Column | Meaning |
| --- | --- |
| `outlet_id` | |
| `brand` | Brand id, for a group with more than one name |
| `name` | Name staff use |
| `address` | |
| `cost_centre` | Code the ERP export will carry |
| `active` | `yes` or `no` |

`EXAMPLE` row:

```text
outlet_id,brand,name,address,cost_centre,active
EXAMPLE-OUT-1,EXAMPLE,EXAMPLE Market Sample Street,1 Sample Street,CC-01,yes
```

Type one row per outlet the business wants in version 1. Do not paste a count from a case study.

### 4.7 `gl_codes.csv`

| Column | Meaning |
| --- | --- |
| `gl_code` | |
| `description` | |
| `default_for` | `produce`, `grocery`, `cafe`, `ap`, or `other` |
| `tax_code` | `standard`, `zero`, `exempt`, or `none` |

`EXAMPLE` rows:

```text
gl_code,description,default_for,tax_code
EXAMPLE-GL-5000,Fresh produce purchases,produce,standard
EXAMPLE-GL-2100,Accounts payable control,ap,none
```

A line inherits `default_gl_code` from the supplier. The reviewer may change it to another code in this file. The agent does not invent a code.

### 4.8 `approvers.csv`

| Column | Meaning |
| --- | --- |
| `approver_id` | |
| `name` | Must match `reviewer_name` on a decision |
| `email` | |
| `role` | `outlet`, `finance`, or `controller` |
| `outlet_id` | Blank means every outlet |
| `brand` | Blank means every brand |
| `max_amount` | Absolute invoice total this person may approve, in `currency` |
| `currency` | |
| `can_approve_price_exception` | `yes` or `no` |
| `can_confirm_sku` | `yes` or `no` |

`EXAMPLE` row:

```text
approver_id,name,email,role,outlet_id,brand,max_amount,currency,can_approve_price_exception,can_confirm_sku
EXAMPLE-AP-1,EXAMPLE approver,approver@example.test,finance,,EXAMPLE,500.00,SGD,yes,yes
```

A bill whose absolute total is above every matching approver’s `max_amount` stays on the queue with reason `over_approval_limit`. Do not split it.

### 4.9 `uploaders.csv`

People allowed to photograph an invoice from an outlet.

| Column | Meaning |
| --- | --- |
| `email` | Sign-in or the From address on a photo email |
| `name` | |
| `outlet_id` | The outlet they may file for |
| `active` | `yes` or `no` |

`EXAMPLE` row:

```text
email,name,outlet_id,active
outlet@example.test,EXAMPLE buyer,EXAMPLE-OUT-1,yes
```

### 4.10 `credit_reasons.csv`

| Column | Meaning |
| --- | --- |
| `reason_code` | `return`, `spoilage`, `short_delivery`, `price_correction`, `other` are the codes version 1 understands. The business may delete codes it does not use. It may not add a code the reviewer UI does not know. |
| `label` | Words the reviewer sees |

```text
reason_code,label
return,Returned to supplier
spoilage,Spoilage or quality rejection
short_delivery,Short delivery
price_correction,Price correction
other,Other, reviewer must type a note
```

These five rows are the allowed set, not sample suppliers. Keep the codes. Delete a row only to forbid that reason.

### 4.11 `exceptions.csv`

The agent creates this file with the header if it is missing, and appends. Nobody deletes from the agent.

| Column | Meaning |
| --- | --- |
| `exception_id` | `EX-` + document id + `-` + two digits |
| `document_id` | |
| `raised_at` | ISO-8601 |
| `kind` | A code from section 7.8 |
| `supplier_id` | Or blank |
| `outlet_id` | Or blank |
| `line_no` | Or blank |
| `detail` | One sentence. No bank account, no NRIC |
| `notified` | `yes`, `no`, or `failed` |
| `resolved_by` | Blank until a person types it |
| `resolved_at` | Blank until then |
| `resolution` | Blank, or their short note |

---

## 5. Intake

Build one pipe. An email attachment, a phone photo, and a folder drop become the same `Document`. Launch the path the business already uses. A supplier PDF by email is the usual start. The phone is how outlet staff file a delivery invoice that never reached the inbox. The folder is how a supplier portal export arrives without giving this agent a password.

### 5.1 Document object

```json
{
  "document_id": "AP-20261015-0007",
  "sha256": "hex of the raw bytes",
  "source": "email",
  "source_ref": "message-id or file path",
  "received_at": "ISO-8601",
  "mime": "application/pdf",
  "filename": "invoice.pdf",
  "uploader_email": null,
  "outlet_hint": null,
  "storage_path": "company storage path"
}
```

`source` is `email`, `phone`, or `folder`.

`document_id` format: `AP-` + `YYYYMMDD` + `-` + four digits from a counter that starts at `0001` each day in `timezone`.

Compute `sha256` on the raw bytes before OCR. If that hash is already stored, do not create a second document. Attach the arrival as `duplicate_of` and stop.

`outlet_hint` is set only from a phone upload whose uploader has an outlet. It is a hint. Section 7.6 still has to agree.

### 5.2 Email

1. Watch the mailbox in `ap_inbox`. Supplier mail and the internal alert mail must not loop. Alerts go out from a no-reply address to people; the watcher ignores mail whose From is that no-reply address.
2. Read the mailbox with the company Google Workspace or Microsoft 365 account. Prefer the official API. Polling every minute is an acceptable start.
3. Accept attachments whose MIME type is `application/pdf`, `image/png`, `image/jpeg`, or `image/tiff`. Cap: 20 MB. A larger file is stored and marked `needs_human`, reason `file_too_large`. It is not dropped.
4. A message with no accepted attachment is logged as `ignored_not_a_file`. The body is not an invoice in version 1.
5. Ignore bounces, out-of-office replies, and calendar invites.
6. Save the bytes in company storage. The mailbox is not the archive.
7. Do not trust the From address as the supplier id.

Environment, kept out of this file once filled:

```text
AP_MAILBOX=
MAIL_TENANT=
MAIL_CLIENT_ID=
MAIL_CLIENT_SECRET=
STORAGE_DIR=
```

### 5.3 Phone

`POST /capture` as multipart form data: `file`, and the signed-in user.

1. The user must be `active` on `uploaders.csv`. Otherwise store the file, status `needs_human`, reason `uploader_not_allowed`, and do not extract.
2. Bind `outlet_hint` to that row’s `outlet_id`. The user does not type an outlet in version 1. A buyer who covers two outlets has two rows and picks the account they are signed in as.
3. Optional form field `image_quality`. The capture page sets `blurry` when its sharpness check fails, and `ok` otherwise. The server does not “improve” a blurry flag because the model later sounds confident.
4. Show the original on the next screen and the words “Filed, not approved.”
5. Do not ask the phone user for a bank account, a card, or an NRIC.

### 5.4 Folder

1. Watch the directory named under the heading `Drop folder` in `desk-notes.md`. If that heading is absent, do not watch a folder. When it is present, watch it for pdf, png, jpg, jpeg, tif, tiff.
2. On a closed file (size stable for two polls), hash it, move it to `inbox/processing/{sha256}/original`, then process.
3. After the draft exists, note the path on the document. On a crash before the draft exists, leave the file in `processing` and retry the same hash. Do not mint a second `document_id`.
4. A supplier portal that can email a PDF or drop one on a schedule uses this path or section 5.2. Do not log in to the portal.

### 5.5 What “done” means for intake

Ingest is done when one new file produces exactly one document row, one stored original, and one queue card. The same bytes a second time produce no second card.

---

## 6. Extraction

Run OCR, then one structure-model call. Store the model JSON unchanged as `model_proposal`. Then run section 7 in code. Do not call the model a second time to repair a validation error. The prompt is section 13.1. Do not send the CSV sheets in the prompt.

### 6.1 Working schema

Each scalar the model returns is an object:

```json
{ "value": null, "confidence": 0.0, "evidence": "", "unreadable": false }
```

`evidence` must be a substring of the OCR text. If it is not, the field is untrusted.

```json
{
  "document_type": { "value": "invoice", "confidence": 0.0, "evidence": "", "unreadable": false },
  "supplier_name": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "supplier_tax_id": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "invoice_number": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "invoice_date": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "currency": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "outlet_text": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "po_number": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "original_invoice_number": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "bank_account_raw": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "subtotal": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "tax_amount": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "total": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
  "page_quality": "ok",
  "lines": [
    {
      "line_no": 1,
      "page": 1,
      "supplier_item_code": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
      "description": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
      "quantity": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
      "uom": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
      "unit_price": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
      "line_amount": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false },
      "tax_amount": { "value": null, "confidence": 0.0, "evidence": "", "unreadable": false }
    }
  ]
}
```

`document_type.value` is `invoice`, `credit_note`, `receipt`, `statement`, `delivery_order`, `purchase_order`, or `unknown`.

`page_quality` is `ok` or `unreadable`. The phone’s `image_quality=blurry` forces `unreadable` before the model is trusted.

Copy money as printed. Do not compute a line amount that is not on the page. Do not repair a total.

### 6.2 Unreadable handling

Set the document `unreadable` when any of these is true:

- Phone `image_quality` is `blurry`
- Model `page_quality` is `unreadable`
- Mean confidence across header fields and line amounts is at or below `doc_unreadable_below`
- Joined OCR text is under 20 characters
- More than 10% of OCR characters are the replacement character `�`

Then status is `unreadable`. Null every working header and line value. Keep `model_proposal`. The reviewer may type values, which are stored with `source = human`. The model may not.

A single field with confidence below `field_confidence_min`, or `unreadable: true`, or evidence that is not in the OCR text, is null on the working bill and flagged `untrusted`. Other fields may still be used. A high confidence does not override a document-level unreadable flag.

---

## 7. Validation

Implement this in `validate.ts`. No network calls. Do not ask the model to apply it.

Money is integer cents. Parse a printed decimal into cents by rounding half up, scale 2. Compare with integers. `money_rounding` converts to cents the same way. A gap of that many cents is allowed on the checks in 7.2 and 7.3. A larger gap is a fail.

### 7.1 Dates, numbers, invoice keys

With `date_order = DMY`, `02/10/2026` is 2026-10-02. With `MDY`, the same string is 2026-02-10. If the first number is greater than 12, use the only possible order. An impossible date (`31/02/2026`) nulls the date and flags `bad_date`.

Invoice number key: uppercase, then delete spaces, hyphens, and slashes. `inv 100-a` and `INV100A` are the same key.

Quantity and unit price stay decimals at scale 3 for quantity and scale 4 for unit price, then the line check uses cents. A blank is null, not zero.

### 7.2 Arithmetic

For each line, if quantity, unit price, and line amount are all present:

`abs(round_cents(quantity * unit_price) - line_amount_cents) <= rounding_cents`

else flag `arithmetic_mismatch` on that line.

If every line amount is present and subtotal is present:

`abs(sum(line_amount_cents) - subtotal_cents) <= rounding_cents`

else flag `arithmetic_mismatch`.

If subtotal, tax amount, and total are present:

`abs(subtotal_cents + tax_cents - total_cents) <= rounding_cents`

else flag `arithmetic_mismatch`.

Do not “fix” a line so the total works. A missing piece is `arithmetic_incomplete`, which also blocks `ready_for_review`.

Header discount: if the page shows a discount, the model must have put the discounted subtotal in `subtotal` and the discount words in evidence. If two subtotals are printed and the model picked one, flag `arithmetic_mismatch` rather than choosing the one that balances.

### 7.3 Tax

Use the supplier’s `tax_code` after the supplier has matched. Before that, do not invent a tax result.

- `standard`: expected tax cents = half-up of `subtotal_cents * gst_rate_percent / 100`. If `abs(expected - tax_cents) > rounding_cents`, flag `tax_mismatch`.
- `zero` or `exempt`: tax must be 0 or null. Anything else is `tax_mismatch`. Do not add GST.
- `unknown`: flag `tax_unverified`. Do not compute. Status cannot be `ready_for_review`.

A line-level tax, when present, is display only in version 1. The header check is the one that blocks.

### 7.4 Duplicates

Key A: `supplier_id` + invoice number key. If another document that is not `rejected`, received inside `duplicate_window_days`, already has that key, this status is `duplicate`. It cannot be approved until a person uses `release_duplicate` (section 10.4) and a second, different approver then approves. The agent does not decide that a repeat is a new bill.

Key B: same `supplier_id`, same invoice date, same total cents, and the same multiset of `(normalised description, quantity, line amount cents)`. Flag `possible_duplicate` and status `needs_human`. A person may approve Key B without the two-step release, and the card must show the other document id.

Same file bytes are section 5’s `duplicate_of`, not a second card.

### 7.5 Supplier match

1. Normalise tax ids by uppercasing and deleting spaces and hyphens. If the invoice tax id equals exactly one active supplier, that is the supplier. If the printed name then differs from both legal and trading name after the same normalise-and-collapse, add `supplier_name_mismatch` and status `needs_human`. Do not overwrite the master name.
2. If there is no tax id, compare the normalised printed name to legal name and trading name. Exactly one active supplier: match. More than one: `ambiguous_supplier`, no id chosen.
3. Otherwise list up to three active suppliers that share the highest token overlap with the printed name, but only when that overlap is at least 0.5. Put them in `supplier_candidates`. Leave `supplier_id` null. Do not pick the nearest.
4. No candidate: `unknown_supplier`. Do not append the master.

An inactive supplier may be shown with `supplier_active = no` and status `needs_human`. Do not match them silently.

### 7.6 Outlet

1. If `outlet_text` equals one active outlet id or one active outlet name, case-insensitive, that is the outlet.
2. If it names an outlet that is not in the file, flag `wrong_outlet`. Leave `outlet_id` null.
3. If it names outlet A and the PO or the phone hint is outlet B, flag `wrong_outlet`. Do not pick a winner.
4. If the invoice names no outlet and the phone hint is the only signal, copy the hint into `outlet_candidates` and leave `outlet_id` null until a person confirms. Status `needs_human`, reason `outlet_unconfirmed`.
5. If the invoice names no outlet and the matched supplier’s open POs for this invoice date are all one outlet, you may put that outlet in `outlet_candidates`. You may not set `outlet_id`.
6. Otherwise `outlet_missing`.

### 7.7 PO, GRN, catch weight, short delivery

Run this only after supplier, SKU, and unit conversion have been attempted. SKU rules are section 8. A line with no `internal_sku` skips the quantity match and is already `unknown_sku` or waiting on a suggestion.

Convert the line before matching, in this order:

1. Same UOM on the invoice and the map: internal quantity is invoice quantity times `conversion_factor`, or times 1 when the factor is blank.
2. Different UOMs and a numeric `conversion_factor`: internal quantity is invoice quantity times that factor. Internal unit price is invoice unit price divided by that factor.
3. Invoice UOM is `kg`, the UOMs differ, the factor is blank, and a goods-received row for this PO and SKU has `catch_weight_kg`: do not flag `unit_mismatch`. Compare the invoice kilograms to that catch weight. This is the path for “ordered 10 fish, invoiced 7.4 kg”.
4. Otherwise, when the UOMs differ: flag `unit_mismatch`, leave internal quantity null, and do not match quantity or price.

`match_mode` from the supplier, after conversion:

- **`three_way`.** The invoice must carry a PO number that exists for this supplier. Else `po_missing`. Sum `received_qty` on goods-received rows for that PO, outlet, and SKU where `condition` is `accepted` or `short`. Compare internal quantity to that sum, unless step 3 already compared catch weight. No GRN rows: `grn_missing`. Invoice above the base by more than the allowance: `qty_over_receipt`. Received below ordered, and the invoice within allowance of received: add informational `short_delivery_ok`. Do not fail the line for being less than the order.
- **`two_way_po`.** PO number required, same as above. Compare internal quantity to `ordered_qty`. No GRN is required. A GRN, if present, is shown but does not override the PO in this mode. Step 3 does not apply, because there may be no GRN.
- **`price_list_only`.** No PO is required. Flag informational `no_po`. Price still has to pass section 9. Do not invent a GRN. Step 3 does not apply.

Allowance for counted units and for converted quantities: `max(base * qty_tolerance_pct / 100, qty_tolerance_abs)`, where `base` is the received sum or the ordered quantity. Catch weight uses `catch_weight_tolerance_pct` only, against `catch_weight_kg`. Over when invoice quantity is greater than base plus allowance.

A `spoiled` or `rejected` GRN does not increase the received sum. Mention it on the line so a credit note can be tied to it later. Do not create the credit note yourself.

### 7.8 Status and exception kinds

Set `fraud_alert` when the bank check in section 9.2 fails. It overrides other statuses.

Set `unreadable` when section 6.2 says so.

Set `not_an_invoice` when `document_type` is `receipt`, `statement`, `delivery_order`, or `purchase_order`. Store the file. Do not build a bill.

Set `duplicate` on Key A.

Else set `needs_human` when any blocking flag is present, any required header is null (supplier, invoice number, invoice date, total, outlet), or any line lacks quantity, unit price, or line amount.

Set `ready_for_review` only when `document_type` is `invoice` or `credit_note`, no blocking flag is set, and those required fields are present. **Ready for review still needs a person.**

Blocking flags: `arithmetic_mismatch`, `arithmetic_incomplete`, `tax_mismatch`, `tax_unverified`, `bad_date`, `unknown_supplier`, `ambiguous_supplier`, `supplier_name_mismatch`, `wrong_outlet`, `outlet_missing`, `outlet_unconfirmed`, `unknown_sku`, `ambiguous_sku`, `unit_mismatch`, `price_above_tolerance`, `price_below_list`, `price_list_missing`, `price_list_ambiguous`, `currency_mismatch`, `po_missing`, `grn_missing`, `qty_over_receipt`, `possible_duplicate`, `credit_without_reference`, `credit_reason_missing`, `match_mode_unset`, `untrusted`, `bank_unverified`, `over_approval_limit`.

Informational flags, not blocking by themselves: `short_delivery_ok`, `no_po`, `within_tolerance`.

`bank_details_changed` is not a normal flag. It forces `fraud_alert`.

Append one `exceptions.csv` row per flag, blocking or informational, except `within_tolerance`.

### 7.9 Credit notes

Set `document_type` to `credit_note` when the page says credit note, credit memo, or credit memorandum. A negative total without those words is `needs_human`, reason `sign_unclear`. Do not flip it.

Store credit amounts as negative cents. Require `original_invoice_number` when the type is `credit_note`. If it is null, `credit_without_reference`.

The reason comes only from `credit_reasons.csv` words on the page, or from the reviewer. Map printed words: “spoil”, “spoilage”, “rot”, “quality” → `spoilage`; “return” → `return`; “short” → `short_delivery`; “price” → `price_correction`. If none match, reason is null and `credit_reason_missing`. Do not guess spoilage because the goods are perishable.

Do not subtract a credit note from an open invoice. Do not change the original document.

---

## 8. SKU mapping

Run per line, in code, after supplier match. The model does not choose the SKU. The suggestion prompt in section 13.2 may explain the candidates to the reviewer. It may not add a candidate the code did not find.

Normalise a description: lowercase; replace punctuation with spaces; collapse spaces; drop the tokens `a`, `an`, `the`, `of`, `and`; strip one trailing `s` from a token longer than 3 characters.

1. **Exact code.** `supplier_id` plus case-insensitive `supplier_item_code` equals one `confirmed` map row. Use its `internal_sku`. If the normalised description shares no token with that row’s description, still use the code, and flag `code_description_odd` as informational so the reviewer looks. Do not reject a code match for wording.
2. **Exact description.** No code match. One `confirmed` row for that supplier has the same normalised description. Use it. If the invoice also printed a code and the row has a different code, do not use the row. Flag `code_description_conflict`, leave `internal_sku` null.
3. **Fuzzy suggestion.** No exact hit. Score `confirmed` rows for that supplier by Jaccard on the normalised token sets. A candidate qualifies when Jaccard is at least `fuzzy_suggest_min`, or when the shorter set has at least 2 tokens, is contained in the longer set, and Jaccard is at least `fuzzy_containment_min`. If the best qualifying score beats the next by at least `fuzzy_gap`, or it is the only qualifier, put that row’s `internal_sku` in `suggested_sku`. Leave `internal_sku` null. Label the card `suggestion, not a mapping`. If two qualifiers are within `fuzzy_gap`, set `ambiguous_sku` and suggest nothing.
4. **Unknown.** No qualifier. `internal_sku` null, `suggested_sku` null, flag `unknown_sku`.

Do not search another supplier’s map. Do not create a map row in steps 1–4.

**Learning.** A map row is written only by section 10.4 `confirm_sku`, and only when `can_confirm_sku` is `yes` for that approver. The new row’s `status` is `confirmed`, with `confirmed_by` and `confirmed_at`. Copy the printed code and the printed description. The conversion factor is whatever the person typed. If they leave it blank and the units differ, do not write the row; return the line to `unit_mismatch`. A later invoice may then exact-match. Re-running extraction does not confirm.

Worked fixture, not a live rule: map description `cherry tomato carton` against invoice `cherry tomatoes carton` normalises to the same tokens and is an exact description hit. Invoice `roma tomato` against map `roma tomato kg` has Jaccard 2/3, containment, and a two-token shorter side, so at the suggested `fuzzy_containment_min` of 0.50 it is a suggestion only.

---

## 9. Price alerts and bank check

### 9.1 Price

After a line has an `internal_sku` and an internal unit price, find `price_list.csv` rows for that supplier and SKU whose window contains the invoice date and whose currency matches. Prefer a row whose `outlet_id` equals the line’s outlet over a blank `outlet_id`. A code on the price row, when not blank, must equal the invoice code.

No row: `price_list_missing`. Two rows still tied: `price_list_ambiguous`. Do not average. Do not use the lower price.

Convert the invoice unit price into the price-list UOM using the confirmed factor. If you cannot, you already flagged `unit_mismatch` and you do not compare.

Allowance = the greater of `agreed * price_tolerance_pct / 100` and `price_tolerance_amount`.

- Invoice price is greater than agreed plus allowance: `price_above_tolerance`. Blocking. Alert finance and procurement.
- Invoice price is less than agreed minus allowance: `price_below_list`. Blocking. Alert finance and procurement.
- Otherwise: `within_tolerance`. No price email. The person still approves the bill.

A missing price list alerts finance only, kind `price_list_missing`. It is not a price change.

### 9.2 Bank

If the invoice has no account number, do nothing to the bank fields.

If it has one, compute last-4 and the canonical hash in section 4. Store last-4 on the working bill as `bank_last4_seen`. Store the hash in memory for the compare. Do not write the full account into the document JSON, the CSV, the exception detail, the alert, the review mail, or the ERP body.

- Master hash present and equal, and last-4 equal: `bank_ok`.
- Master hash present and different, or last-4 different: status `fraud_alert`, kind `bank_details_changed`. Email `fraud_inbox`. Do not email the supplier. Do not update the master.
- Master hash blank: `bank_unverified`, status `needs_human`. Do not learn the hash from the invoice.

The fraud mail contains: document id, supplier id, supplier name, “bank details on this invoice do not match the verified hash”, last-4 on file, last-4 seen, and “do not pay”. It does not contain the account.

### 9.3 Alert message

Send only when section 9.1 says to alert. One mail, To `ap_inbox` and `procurement_inbox`. Plain text.

```text
Subject: Price change {supplier trading name} {invoice number} {internal_sku}

Supplier: {trading name}
Outlet: {outlet name}
Invoice: {invoice number} dated {invoice date}
Item: {printed description} -> {internal_sku}
Invoiced: {unit price} {currency} per {uom}
Agreed: {agreed} effective {effective_from}
Difference: {difference} ({percent}%)
Tolerance on file: {price_tolerance_pct}% or {price_tolerance_amount} {currency}, whichever is greater
Document: {document_id}

This is an alert, not an approval. Do not pay this invoice from this email.
```

If the mail transport fails, append `exceptions.csv` with kind `alert_failed` and `notified=failed`. Leave the bill on the queue. Do not retry more than three times. Do not drop the bill because the mail failed.

---

## 10. Human review

Build a minimal web page that works on a phone first. Width 390 CSS pixels is the layout you actually check. A spreadsheet is not a substitute: the original page sits beside the fields, or above them on a phone.

### 10.1 Queue

`GET /review`

Rows: received time, document id, supplier, outlet, total, status, top reason.

Sort: `fraud_alert`, then `unreadable`, then `needs_human`, then `ready_for_review`. Oldest first inside each group. Terminal rows (`approved`, `posted`, `rejected`, `duplicate` after it has been left closed, `not_an_invoice`) sit behind a filter, default off.

### 10.2 Document page

`GET /review/:document_id`

Phone, top to bottom:

1. Status chip and one sentence: the blocking reason, in plain words, or “Ready for a person. Not approved.”
2. Supplier, invoice number, invoice date, outlet, total. Total is the largest type on the page.
3. Bank chip: `OK`, `UNVERIFIED`, or `FRAUD`. Last-4 only, or “no account on the invoice”.
4. The original PDF or image, full width, pinch zoom, one page at a time.
5. One card per line: description as printed, quantity and UOM, unit price, agreed price, variance, SKU or the words `unmapped`. A suggestion is labelled `suggestion, not a mapping` and has a Confirm button.
6. Flags in plain words: “Price is above the list”, “No SKU mapping”, “Quantity is above what was received”, “Tax does not match the rate on file”, “This looks like a duplicate of AP-…”, “Photo is too blurry to read”, “Bank details do not match”.
7. A sticky bar: Reject, Fix, Approve. Approve is disabled, with the reason written under it, when this user cannot approve.

A null required field is an empty box with a red border, not a placeholder that looks like a real invoice number. Under an edited field, show the model value and do not lose it.

### 10.3 What the reviewer can do

- **Approve.** Posts nothing until section 10.4 accepts the decision. Then section 11 runs.
- **Fix.** Changes a field. Previous value is kept as `model_value` when the model had one, or `previous_value` when a person had typed it. The new value has `source = human`.
- **Reject.** Requires a reason code: `unreadable`, `wrong_supplier`, `wrong_outlet`, `duplicate`, `price`, `quantity`, `tax`, `sku`, `bank`, `not_our_invoice`, `other`. `other` requires a note.
- **Confirm SKU.** Section 8 learning rule.
- **Copy supplier query.** Runs the prompt in section 13.4 and shows the draft. Version 1 does not send it. There is no Send button.

### 10.4 Decision

`POST /review/:document_id/decision`

```json
{
  "reviewer_name": "EXAMPLE approver",
  "decision": "approved",
  "compared_to_invoice": true,
  "edits": [
    { "path": "lines[0].quantity", "value": "8" }
  ],
  "reason_code": null,
  "note": ""
}
```

Accept only when all of these hold:

1. `reviewer_name` equals an `approvers.csv` name, the outlet matches or the approver’s outlet is blank, the brand matches or is blank, and `abs(total)` is less than or equal to `max_amount`.
2. `compared_to_invoice` is true for `approved`. The checkbox reads: `I compared the supplier, invoice number, outlet, quantities, prices, and total to the original.`
3. For `approved`, supplier id, invoice number, date, outlet id, total, and each line’s SKU, quantity, unit price, and line amount are non-null. Credit notes also need a reason code and an original invoice number.
4. A blocking `price_above_tolerance` or `price_below_list` requires `can_approve_price_exception=yes`. Fixing the price down to the list without that right is refused. The UI says “You are changing the invoice price” when the edit touches a price.
5. `fraud_alert` cannot be approved. Allowed decisions are `rejected` with reason `bank`, or `bank_verified_offline` by `fraud_owner` only. `bank_verified_offline` does not approve and does not change the hash. It returns the bill to `needs_human` with the words “bank checked offline; master file was not updated here”.
6. `duplicate` cannot be approved. `release_duplicate` requires a note naming the other document id and a reason `not_a_duplicate`. It returns the bill to `needs_human`. A different approver may then approve. The same name cannot do both steps.
7. `unreadable` cannot be approved until a person has typed supplier, invoice number, date, total, outlet, and each line amount.
8. The document `sha256` is copied onto the approval row. If the file on disk no longer hashes to it, refuse.
9. `rejected` uses the reason list in section 10.3.

Then, and only then, call the ERP client for an approval. If the client fails, status is `erp_error`, the approval row remains, and the page shows the error. Do not claim success. Do not mint a new idempotency key. A person presses retry, which reuses the same key.

---

## 11. ERP export

Ship a stub first. The stub is the only legal target while `erp_mode` is `stub`.

`POST {erp_base_url}/v1/ap-invoices`

Headers:

```text
Authorization: Bearer {ERP_TOKEN}
Idempotency-Key: {sha256}
X-Human-Approval-Id: {approval_id}
```

Body: the approved header and lines, plus the approval object. No bank account. No NRIC. Due date may be included as `invoice_date` plus `payment_terms_days`, field name `due_date_informational`. The stub stores it on the bill. It must not create a payment.

The stub returns **409** and writes nothing when:

- The approval object is missing, `reviewer_name` is empty, or `compared_to_invoice` is not true
- `Idempotency-Key` does not equal the approval’s document hash
- Status in the body is not `approved`
- Any line lacks `internal_sku`, quantity, or line amount
- The body contains a key named `payment`, `paid`, `bank_account`, or `bank_details_sha256`
- The idempotency key was already accepted. Return the original `erp_document_id` with HTTP 200 and do not create a second bill.

`POST {erp_base_url}/v1/payments` returns **409** always.

On success:

```json
{ "erp_document_id": "STUB-{document_id}", "paid": false }
```

`paid` is always false. Persist the id on `erp_posts`.

### 11.1 Neutral files

Write `exports/ap.json` and `exports/ap.csv` only for documents whose stub, or later sandbox, returned 200.

CSV columns:

```text
document_id,supplier_id,invoice_number,invoice_date,due_date_informational,outlet_id,cost_centre,currency,line_no,internal_sku,description_as_printed,quantity,uom,unit_price,line_amount,tax_amount,total,gl_code,reviewer_name,approved_at,erp_document_id
```

JSON is the same records as an array, plus `"paid": false` on each header.

Never write a `needs_human`, `fraud_alert`, or `unreadable` line into these files.

### 11.2 Ledger notes

Check your version before anyone builds a live adapter. These are common shapes, not certified connectors, and not Little Farms’ integration. The published story says the data flows into Microsoft Dynamics. It does not name an API, an entity, or a company code.

- **Microsoft Dynamics.** Business Central purchase invoices are often created through the purchase invoice API. Dynamics 365 Finance often uses a vendor invoice journal. The entity names differ by product and version. Check your version. Map `supplier_id` to your vendor number, `cost_centre` to your dimension, `gl_code` to your account. Do not guess the company GUID.
- **Xero.** A supplier bill is usually an invoice with a payable type. Scopes and field names differ by version. Check your version.
- **QuickBooks.** A supplier bill is usually a Bill object. Check your version.
- **SQL Account or AutoCount.** Many Singapore and Malaysia desks import a purchase-invoice CSV. Column titles differ by version. Hand them `exports/ap.csv` and let their importer map it. Check your version.

A sandbox adapter replaces only the HTTP call and sends the same body. `erp_mode=live` is refused by `erp.ts` in version 1, even if a person types their name.

---

## 12. New outlet or new brand

Do this before the first invoice from a new outlet or an acquired brand is watched. The published story extended the same workflows to an acquired brand. Our design makes that a checklist, not a second system.

1. Add one `outlets.csv` row: id, brand, name, address, cost centre, `active=yes`.
2. Add or extend approvers who may approve that outlet, with a `max_amount` the business typed.
3. Add uploaders who may photograph invoices there.
4. For each supplier that will serve it, confirm `brands` includes the new brand or is `*`, and confirm `match_mode`.
5. Decide which price-list rows apply. An outlet-specific price needs its own row. Do not assume the first outlet’s price.
6. Do not copy another brand’s SKU map in bulk. Confirm the rows you actually receive in week 1, because the same fruit often has a different supplier code.
7. Run a shadow week (section 17) before any approval can call the stub for that outlet.
8. Write the date and the person’s name at the top of `desk-notes.md`.

`desk-notes.md` holds short rules the business actually uses: which suppliers are `price_list_only` because they deliver fresh produce without a PO, which credit reasons the outlets see, and who may verify a bank change outside this agent. If a heading is absent, the agent does not invent the rule.

---

## 13. Runtime prompts

### 13.1 Extraction

```text
You extract supplier-invoice fields for {business_name}.
Return only JSON matching the schema you were given.
Copy values that are printed. If a value is not printed, return null.
Do not invent a supplier, invoice number, date, SKU, quantity, price, tax, or total.
Do not compute a line amount that is not printed.
Do not convert a unit.
Do not translate a description. Copy it in the script on the page.
evidence must be a substring of the OCR text. If you cannot point at the text, set unreadable to true and value to null.
If the page is blurry or you cannot read the totals, set page_quality to unreadable.
If the page is a credit note, say so. If it is a receipt, statement, delivery order, or purchase order, set document_type accordingly.
Confidence is your estimate from 0 to 1. It does not approve the bill and it does not mean the supplier will be paid.
```

If the reply is not JSON, store the raw text, set status `needs_human`, reason `model_output_not_json`, and retry once with the same prompt plus `Your previous reply was not JSON.` The retry does not add permission to guess.

### 13.2 Mapping suggestion

Use this only to explain candidates the code already selected. Pass the printed line and the candidate rows. Do not pass the whole price list.

```text
Explain, in two sentences, why this printed line might be the candidate SKU.
If you are not sure, say you are not sure.
Do not choose a different SKU.
Do not invent a conversion factor.
Call it a suggestion, not a match.
```

### 13.3 Reviewer summary

Shown under the total on the phone. Five lines maximum.

```text
Write a summary for the reviewer of document {document_id}.
Line 1: supplier and invoice number.
Line 2: total and currency.
Line 3: how many lines, and how many are unmapped.
Line 4: the single most important exception, in plain words, or "No exception."
Line 5: the words "Not approved. Not paid."
Do not include a bank account, an NRIC, or a recommendation to pay.
```

### 13.4 Supplier query draft

```text
Draft a short email from {business_name} to the supplier about document {document_id}.
Ask for one missing thing only: the price, the missing page, or the credit-note reason.
Do not accuse anyone of fraud.
Do not mention bank details.
Do not say the invoice is approved or paid.
Stop after the question.
```

Store the draft on the card. Do not send it.

---

## 14. Privacy

This checklist is not a legal opinion. A business that is unsure asks its own advisor before go-live.

The purpose of the file is to enter a supplier invoice. Do not use it to build a marketing list, a shopper profile, or a staff file.

- A business in Singapore comes under the Personal Data Protection Act. Collect only what the bill needs: the supplier’s business identity, the outlet, and the lines. Do not ask for NRIC, FIN, passport, or a card. A Malaysia operator has the Personal Data Protection Act 2010. The build does not decide which statute applies. It minimises what it stores either way.
- `redact.ts` replaces Singapore NRIC or FIN patterns (`[STFGM]\d{7}[A-Z]`), passport-like labels followed by an id, and card-like numbers (13–19 digits, optional spaces) with `[redacted]` in the working JSON, the CSV, the review mail, the alerts, and the ERP body. The original image stays in storage for the reviewer, limited to approvers and the people who maintain the system.
- Bank accounts follow section 9.2. Full numbers never land in those outputs.
- A phone number on the invoice that is not the supplier `accounts_email` domain’s published number stays on the image only. Do not copy it into the ERP body.
- Keep originals and exports in the company workspace named by `data_host`. Do not paste an invoice into a personal chatbot account.
- Honour `retention_days`. A person deletes or archives. The agent does not keep a second copy in a prompt library.
- If the model host sits outside the country the business expected, say so in the config note before go-live. Do not copy a compliance sentence from a vendor case study onto your own host.

---

## 15. Failure handling

| Failure | What the agent does |
| --- | --- |
| Storage write fails before the draft exists | Leave the file in `processing`. Retry the same hash. Do not mint a second id. Do not delete the original. |
| OCR returns empty text | Status `unreadable`. Working fields null. |
| Model returns non-JSON | Section 13.1. One retry. Then stop. |
| Model returns a SKU that is not on a map row | Discard the SKU. The code’s section 8 result stands. |
| Arithmetic does not balance | Flag it. Do not edit the total. |
| Alert mail fails | `alert_failed` on the exception log. Bill stays on the queue. |
| Reviewer double-submits Approve | Same idempotency key. One stub id. |
| ERP stub is down | Status `erp_error`. Approval remains. Retry reuses the key. |
| Someone calls the payments endpoint | HTTP 409. No row. |
| Photo from an address not on `uploaders.csv` | Store it. `uploader_not_allowed`. Do not extract. |
| Page is a shopper receipt | `not_an_invoice`. Do not create a supplier. |
| Two invoices, same number, two suppliers | Not Key A. Both may proceed. Key A includes the supplier id. |
| Config still contains `REPLACE` or an `EXAMPLE` id | Process nothing live. |

A failure never becomes a payment and never becomes a silent approval.

---

## 16. Acceptance tests

Run these before the inbox address is given to a supplier. They call `validate` and the ERP stub. They do not call a live model. Where a test says the model proposes, feed that proposal as a fixture. A test fails if `erp_posts` gains a row the test did not approve, if a map row appears that no `confirm_sku` wrote, if a full bank account appears in any output, or if `/v1/payments` returns anything but 409.

Use the `EXAMPLE` sheets in section 4 unless a test says otherwise. Config for the fixtures, typed as if the business had filled section 2: currency `SGD`, `gst_rate_percent` `9`, `money_rounding` `0.05`, all three price and quantity tolerances `0`, `catch_weight_tolerance_pct` `2`, `field_confidence_min` `0.85`, `doc_unreadable_below` `0.45`, `fuzzy_suggest_min` `0.80`, `fuzzy_containment_min` `0.50`, `fuzzy_gap` `0.15`, `duplicate_window_days` `540`. Those numbers are the fixture. They are not Little Farms’ settings.

| ID | Fixture | Pass when |
| --- | --- | --- |
| T1 | Clean tax invoice. Supplier tax id `T00EXAMPLE`, name `EXAMPLE Fresh`. Invoice `INV-100`, date `02/10/2026` with `DMY`. Outlet `EXAMPLE Market Sample Street`. PO `EXAMPLE-PO-1`. One line: code `TOM-CRT`, description `Cherry tomato carton`, qty `2`, uom `carton`, unit price `22.50`, line amount `45.00`. Subtotal `45.00`, tax `4.05`, total `49.05`. Bank account preimage `EXAMPLE0001234`. OCR quality fine. For this fixture only, set the GRN received qty to `10` kg and condition to `accepted`. Map factor `5` turns 2 cartons into 10 kg at 4.50 per kg. Price list `4.50` per kg. | Status `ready_for_review`. Internal SKU `EXAMPLE-SKU-TOM`. Internal qty 10 kg. Unit price in kg is 4.50. Flag `within_tolerance` only. `bank_ok`. `erp.eligible` false. Stub not called. |
| T2 | The T1 document is already `ready_for_review`. A second file, different bytes, same supplier and invoice number `INV-100`. | Second status `duplicate`. Approve on the second returns an error. Stub not called for the second. |
| T3 | T1, but the account preimage is `EXAMPLE9999999`. | Status `fraud_alert`. Master hash unchanged. Full account absent from the JSON, the CSV, the exception detail, and the fraud mail. Fraud mail contains last-4 `9999` and last-4 `1234` and the words `do not pay`. Approve is refused. |
| T4 | T1 line unit price `30.00` per carton, so `6.00` per kg against agreed `4.50`, line amount `60.00`, subtotal `60.00`, tax `5.40`, total `65.40`. Tolerances are `0`. | Flag `price_above_tolerance`. Alert mail matches the section 9.3 template and was addressed to both inboxes. Approver with `can_approve_price_exception=no` cannot approve. The price was not rewritten to `4.50`. |
| T5 | Line code `NOT-A-CODE`, description `Dragonfruit red`, qty 1, uom kg, a price that balances. No map row shares a token. | `internal_sku` null, `suggested_sku` null, flag `unknown_sku`. `supplier_sku_map.csv` unchanged. |
| T6 | Line uom `carton`, description `Cherry tomato carton`, code blank, and the map row’s `conversion_factor` blank. | Flag `unit_mismatch`. Internal quantity null. The agent did not multiply by 5. |
| T7 | Page says `CREDIT NOTE`, original invoice `INV-100`, words `spoilage`, subtotal `-20.00`, tax `-1.80`, total `-21.80`, supplier and outlet as T1. | `document_type` `credit_note`. Reason `spoilage`. Tax matches 9% of the subtotal. Not subtracted from INV-100. Payments endpoint 409. Export, after a valid approval, has a negative amount and `paid` false. |
| T8 | Phone `image_quality=blurry`. Model returns a confident total `49.05` and a full T1 body. | Status `unreadable`. Working total null. `model_proposal` still has `49.05`. Stub not called. |
| T9 | T1 with tax `4.00` instead of `4.05`, total `49.00`. | Flag `tax_mismatch`. Status `needs_human`. |
| T10 | `three_way`. Ordered 10 kg, GRN received 6 kg condition `short`, invoice internal qty 10 kg. | Flag `qty_over_receipt`. Not `short_delivery_ok`. |
| T11 | Invoice `outlet_text` is `EXAMPLE Market Other Road`, which is not in `outlets.csv`. PO outlet is `EXAMPLE-OUT-1`. | Flag `wrong_outlet`. `outlet_id` null. |
| T12 | Add this confirmed map row for the fixture only: supplier `EXAMPLE-SUP-1`, code `FISH-PCS`, description `Sea bass piece`, sku `EXAMPLE-SKU-FISH`, supplier uom `pcs`, internal uom `kg`, conversion factor blank. PO ordered qty `10` uom `pcs`. GRN received qty `10` uom `pcs`, `catch_weight_kg` `7.40`, condition `accepted`. Invoice qty `7.40` uom `kg`, unit price `12.00`, line amount `88.80`. | Catch-weight path passes the quantity check against `7.40`. `unit_mismatch` is not set. A second run at `9.00` kg flags `qty_over_receipt` because `(9.00 - 7.40) / 7.40` is above `2`%. |
| T13 | Invoice description `roma tomato`, no code, uom `kg`. One confirmed map row for this supplier: description `roma tomato kg`, sku `EXAMPLE-SKU-ROMA`, both uoms `kg`, factor `1`. No other row shares those tokens. | `suggested_sku` is `EXAMPLE-SKU-ROMA`. `internal_sku` null. Map file unchanged. After `confirm_sku` by `EXAMPLE approver`, one new confirmed row exists whose `supplier_description` is the printed `roma tomato`. A later invoice with that same printed description and no code sets `internal_sku` to `EXAMPLE-SKU-ROMA` by exact description. |
| T14 | Approve T1 with `reviewer_name` blank, or `compared_to_invoice` false. | Portal refuses. Stub 409 if called anyway. `erp_posts` empty. |
| T15 | Approve T1 with a valid approver, checkbox true, no blocking flags. | Stub returns `STUB-` plus the document id and `paid: false`. `exports/ap.csv` contains the line. `POST /v1/payments` returns 409. |
| T16 | Line amounts sum to `40.00` and subtotal is `45.00`, beyond `0.05`. | Flag `arithmetic_mismatch`. Total was not edited to force a balance. |
| T17 | Model proposal includes `internal_sku` `EXAMPLE-SKU-TOM` on a line whose code and description match nothing. | Working `internal_sku` is null. The model SKU is not on the bill. |
| T18 | Footer text `NRIC S1234567A` and a 16-digit card number. | Those strings do not appear in the review JSON, either CSV, the alerts, or the ERP body. |
| T19 | Two suppliers, both with invoice number `INV-100`, different supplier ids, otherwise valid. | Neither is Key A of the other. |
| T20 | T1 within tolerance, status `ready_for_review`, and a caller posts to the ERP with no approval row. | Stub 409. No export row. |
| T21 | GRN received 8 kg, invoice internal qty 8 kg, ordered 10 kg, three-way. | Flag `short_delivery_ok`. Quantity check passes. Status may still be `ready_for_review` if nothing else blocks. |
| T22 | `confirm_sku` by a name that is not on `approvers.csv`, or by an approver with `can_confirm_sku=no`. | No map row written. |

Also run once on the real ingest path, with a PDF you wrote yourself, not a supplier’s:

- The mailbox or the folder creates one queue card.
- The phone page, signed in as an uploader, creates one card and says filed, not approved.
- Reloading the page shows the same fields.
- The original opens above the form.
- The stub id matches `erp_posts`.

Go live with the inbox only after T1–T22 pass and `ap_owner` has seen that check. `erp_mode` stays `stub` for the first live weeks.

---

## 17. Rollout and your own measures

Start with one outlet and the ten suppliers with the most invoices in the last 90 days. Do not start with every brand.

**Week 0.** Export suppliers, the SKU map you already trust, the price list, outlets, GL codes, and approvers. Delete every `EXAMPLE` row. Fill every `REPLACE` key. Write down the baseline in section 17.1 from the bills the desk already finished. `erp_mode` is `stub`. The watcher is not on.

**Week 1.** One outlet, those ten suppliers. Run the agent on 20 old invoices the desk already entered. It builds a draft. Staff do not approve anything into the stub yet. Compare supplier, invoice number, total, SKU, quantity, and price. Fix the validator before any new invoice is processed.

**Week 2.** Turn the inbox on for those ten suppliers only, during office hours. Reviewers work the queue the same day. Approvals may hit the stub. Nothing is imported to the ERP. Count how often a person changed a field.

**Week 3.** Add the rest of that outlet’s suppliers, still in shadow for any supplier whose `match_mode` you have not set. A blank `match_mode` never defaults to three-way.

**Week 4.** A second outlet, or one acquired brand, uses the section 12 checklist and its own shadow week. Do not point a new brand at the first outlet’s price list and call it done.

Stay on a week if a test fails, if a bank alert fired and nobody can explain it, or if a reviewer finds a SKU the system confirmed by itself. A wrong line that looks complete is worse than a bill that waits.

### 17.1 Measures

Write these down from your own desk before week 1, and again after 30 live approvals. They are counts and times you measured. They are not a target taken from a case study.

- Invoice cycle time: from received timestamp to approval timestamp, median and worst, in hours or days as you observed them.
- Exception rate: share of documents that were `needs_human`, `unreadable`, or `fraud_alert`.
- Price-change catches: count of `price_above_tolerance` alerts, and how many the reviewer accepted versus rejected.
- Duplicate catches: count of Key A duplicates.
- ERP posts with no approval id. The acceptable count is zero.
- Payments created by this agent. The acceptable count is zero.

**Do not forecast Little Farms’ 5 days to 3 minutes.** Do not convert that clock into hours saved. Do not convert it into a cost. Do not put it in a proposal for this business.

---

## 18. What you do not claim

Little Farms’ published results are theirs, and they were published by the vendor.

fileAI’s customer story of 17 November 2025 says invoices took up to 5 days to process, and that invoice processing dropped from 5 days to just 3 minutes. It says 100+ staff use the mobile app daily to process and review invoices. Joe Stevens, co-founder and CEO, is quoted on turnaround and on finance contributing more strategically. The story says the work integrates with Microsoft Dynamics, and that the workflows were extended to acquired brands such as Starter Lab.

Do not turn 5 days into 3 minutes into hours saved, wages saved, or a payback period. Do not tell a reader to expect the same result. Do not print an outlet count. The case study says 18 outlets. The company’s store locator says eight shops. Leave both sentences in their sources. The outlets in this build are the rows in `outlets.csv`.

Do not present a tolerance, a confidence minimum, a fuzzy score, a GST rate, or an approval limit as Little Farms’ setting. They are not published. The numbers in section 16 are fixture values so the tests have an input.

Do not claim a Dynamics, Xero, QuickBooks, SQL Account, or AutoCount certification. Section 11.2 is a note to check the version.

Copy the loop: read the invoice, prove the supplier and the SKU, stop when the price or the quantity is outside the rule the business typed, let a person approve on a phone, export a bill, and do not pay anyone. The first win is a queue where a changed bank account cannot pass just because the rest of the invoice looked fine.
