# Receipts

Push receipts, bills and invoices into Accounting Orbit as image or PDF files.
Orbit extracts merchant, date, total, tax and line items, holds the result in a
**pending review** queue, and posts a balanced journal entry only when you
confirm. Nothing an extractor produces reaches the ledger without an explicit
confirm call.

Base URL: `https://api.accountingorbit.com/api/v1`
Auth: `Authorization: Bearer ao_...` — see [/docs/auth.md](/docs/auth.md).
Errors, pagination and idempotency semantics: [/docs/conventions.md](/docs/conventions.md).

---

## The object model

A pending receipt lives in the `receipt` table — **not on disk**. It survives
restarts and redeploys. (Receipt *images* are stored separately in object
storage with a local-disk fallback; the extracted JSON and lifecycle state are
in the database.)

| Field | Type | Notes |
|---|---|---|
| `id` | integer | Receipt id. Also echoed as `file` (string) for backward compatibility — prefer `id`. |
| `merchant` | string | Extracted merchant/vendor name |
| `date` | string `YYYY-MM-DD` | Receipt date |
| `total` | number | Gross total |
| `tax` | number | Tax portion of `total` |
| `currency` | string | ISO code, default `"USD"` |
| `items` | array | `[{name, price, category, tax_free}]` |
| `status` | string | `pending_review` \| `posted` \| `rejected` \| `deleted` |
| `doc_type` | string | `receipt` (default) \| `bill_invoice` \| `sales_invoice` \| `bank_statement` |
| `image_url` | string | Storage key or path for the source image |
| `extraction_failed` | boolean | True when the extractor returned nothing usable |
| `needs_review` | boolean | Extraction confidence below threshold |
| `review_reason` | string \| null | Why it needs review |
| `fraud_score` / `fraud` / `fraud_flags` | int / bool / array | Fraud heuristics from extraction |
| `journal_entry_id` | integer \| null | Set once confirmed |
| `linked_bank_txn_id` | integer \| null | Set when matched to a bank transaction |

Anything the extractor returned that isn't in the table above is preserved
verbatim on the receipt payload — treat the object as open.

### Accepted file types

Receipt upload accepts **PDF and images only**:

`.pdf` `.png` `.jpg` `.jpeg` `.gif` `.webp` `.heic`

`.csv`, `.qif`, `.ofx`, `.iif` and `.txt` are **not** accepted here — those are
bank-statement formats and go to [/docs/bank-imports.md](/docs/bank-imports.md).
A `.docx`, `.zip` or anything else is rejected.

Validation is by magic bytes, not by extension — renaming a file does not get it
through. Max **10 MB** per file. Filenames containing `/`, `\`, control
characters or `..` are rejected outright rather than sanitised.

---

## Upload

### `POST /receipts/batch` — multipart, 1–20 files

This is the **only** file-upload entry point for receipts. There is no
base64/JSON upload endpoint.

```bash
curl -X POST https://api.accountingorbit.com/api/v1/receipts/batch \
  -H "Authorization: Bearer $ORBIT_KEY" \
  -F "files=@receipt-staples.png"
```

```json
{
  "total": 1,
  "receipts": 1,
  "duplicates": 0,
  "errors": 0,
  "files": [
    {
      "filename": "receipt-staples.png",
      "type": "receipt",
      "merchant": "Staples Office Supply",
      "total": 42.5,
      "receipt_id": 310,
      "receipt": {
        "merchant": "Staples Office Supply",
        "date": "2026-07-15",
        "total": 42.5,
        "tax": 3.5,
        "currency": "USD",
        "image_url": "/receipts/457/receipt-staples.png",
        "items": [
          {"name": "Office Chair",   "price": 35.0, "category": "Office Supplies", "tax_free": false},
          {"name": "Printer Paper",  "price": 7.5,  "category": "Office Supplies", "tax_free": false}
        ],
        "status": "pending_review",
        "fraud_score": 0,
        "fraud_flags": [],
        "fraud": false,
        "confidence": 0.0,
        "needs_review": true,
        "review_reason": "AI confidence below threshold (0%)",
        "id": 310,
        "file": "310",
        "extraction_failed": false
      }
    }
  ]
}
```

Repeat the `-F "files=@..."` flag for each file, up to 20. More than 20 →
`400 {"detail": "Maximum 20 files per batch. Got 21."}`.

The response is **always 200** even when individual files fail. Read
`files[].type` per file — the batch does not fail as a unit:

| `files[].type` | Meaning | Other keys |
|---|---|---|
| `receipt` | Extracted cleanly, now pending review | `merchant`, `total`, `receipt_id`, `receipt` |
| `receipt_with_errors` | Saved but extraction was incomplete or failed validation — still confirmable after you fix it | `merchant`, `receipt_id`, `receipt` |
| `bill_invoice` | Classified as a vendor bill | `vendor`, `amount_due`, `receipt_id`, `stored` |
| `bank_statement` | Classified as a statement; rows held for review, nothing imported yet | `receipt_id`, `transactions_pending`, `receipt`, `account_candidates` |
| `probable_duplicate` | Same merchant + date + items as an existing receipt; **nothing was saved** | `merchant`, `existing_receipt` |
| `duplicate` | Byte-identical to a file already processed; **no-op by design** | `existing_filename` |
| `corrupted` | Failed an integrity check | `error` |
| `rejected` | Failed file validation (type, size, filename) | `error` |
| `error` | Could not be read at all | `error` |

Real rejection example:

```json
{"filename": "r.png", "type": "corrupted", "error": "File too small — appears corrupted or truncated"}
```

### Dedup: re-uploading the same bytes is a deliberate no-op

Every upload is fingerprinted by SHA-256 of its content. Re-uploading a file
Orbit has already processed returns `type: "duplicate"` and creates nothing:

```json
{"total":1,"receipts":0,"duplicates":1,"errors":0,
 "files":[{"filename":"receipt-staples.png","type":"duplicate","existing_filename":"receipt-staples.png"}]}
```

This is the intended retry-safety behaviour, not an error — see the
idempotency section of [/docs/conventions.md](/docs/conventions.md). A retried
upload after a network timeout cannot double-book.

A weaker, second check catches *near*-duplicates (same merchant, same date,
same items but different bytes) and reports `type: "probable_duplicate"` with
the existing receipt inline. Resolve those with the duplicate endpoints below.

### `POST /receipts/manual` — create a receipt without a file

Accepts JSON, or multipart with the JSON in a `body` form field plus an
optional `image` file.

```bash
curl -X POST https://api.accountingorbit.com/api/v1/receipts/manual \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"merchant":"Corner Cafe","total":18.40,"tax":1.40,"date":"2026-08-09",
       "currency":"USD","category":"Meals",
       "items":[{"name":"Team lunch","price":18.40,"category":"Meals","tax_free":false}]}'
```

```json
{"status": "pending_review", "file": "311", "id": 311, "receipt": { ... }}
```

`merchant` and `total` are required (`422` otherwise). `doc_type` defaults to
`receipt`; pass `"bill_invoice"` with `invoice_number` for a vendor bill.
`gl_account_id` optionally pins the target account — it is validated against
your tenant and `422`s if it isn't yours.

### `POST /bank/{txn_id}/upload-receipt` — attach a receipt to a bank line

Multipart, one file, with `?mode=gemini` (default, extract) or
`?mode=upload_only` (store the image, no extraction). Covered in
[/docs/bank-imports.md](/docs/bank-imports.md).

---

## Review

### `GET /receipts/pending`

The review queue. Paginated, filterable.

```bash
curl -G https://api.accountingorbit.com/api/v1/receipts/pending \
  -H "Authorization: Bearer $ORBIT_KEY" \
  -d limit=50 -d offset=0 -d date_from=2026-08-01
```

```json
{"items": [], "receipts": [], "total": 0, "limit": 50, "offset": 0, "has_more": false}
```

`items` and `receipts` are the same array; `receipts` is a legacy alias.

Query params: `limit` (default 200, capped 500), `offset`, `q` (text search),
`min_amount`, `max_amount`, `date_from`, `date_to`, `source`,
`sort_field` (default `created_at`), `sort_dir` (default `desc`).
Negative `min_amount`/`max_amount`, or `min_amount > max_amount`, → `422`.

### `PUT /receipts/{id}/review` — full override before posting

Replaces any field you send. Does **not** post. Allowed on `pending_review` and
`posted` receipts; anything else → `409 "Can only override pending or posted receipts"`.

```bash
curl -X PUT https://api.accountingorbit.com/api/v1/receipts/310/review \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"merchant":"Staples","total":42.50,"tax":3.50,"date":"2026-07-15",
       "category":"Office Supplies",
       "items":[{"name":"Office Chair","price":35.00,"category":"Office Supplies","tax_free":false},
                {"name":"Printer Paper","price":7.50,"category":"Office Supplies","tax_free":false}]}'
```

```json
{"status": "pending_review", "id": 310, "receipt": { ... }}
```

Accepted fields: `doc_type`, `merchant`, `vendor`, `date`, `total`, `tax`,
`currency`, `invoice_number`, `image_url`, `items`, `category`. Sending
`category` applies it to every item.

### `POST /receipts/{id}/rescan` — re-run extraction on the stored image

```bash
curl -X POST https://api.accountingorbit.com/api/v1/receipts/310/rescan \
  -H "Authorization: Bearer $ORBIT_KEY"
```

```json
{
  "rescanned": true,
  "doc_type": "receipt",
  "merchant": "Staples Office Supply",
  "date": "2026-07-15",
  "total": 42.5,
  "items": [
    {"name": "Office Chair",  "price": 35.0, "category": "Office Supplies", "tax_free": false},
    {"name": "Printer Paper", "price": 7.5,  "category": "Office Supplies", "tax_free": false}
  ]
}
```

If extraction still fails you get `200` with
`{"rescanned": true, "merchant": "Unknown", "total": 0, "message": "Still could not extract"}`
— and a receipt that already had a good extraction is **not** repainted as
broken. A failed rescan only sets `extraction_failed` when there was never a
usable total.

Rescan consumes an AI call from your plan quota. Over quota →
`429 "AI call limit reached this month. Upgrade your plan."`
Server has no extraction provider configured → `503`.

### `POST /receipts/{id}/reject`

```bash
curl -X POST "https://api.accountingorbit.com/api/v1/receipts/310/reject?reason=not+a+receipt" \
  -H "Authorization: Bearer $ORBIT_KEY"
```

```json
{"status": "rejected", "reason": "not a receipt", "file": "310", "reupload_allowed": true}
```

Rejecting **releases the dedup fingerprint** — `reupload_allowed: true` means the
same file can now be uploaded again. That is the supported way to un-stick a
misclassified document.

Rejecting a receipt that is already `posted` but unmatched does something
different: it dismisses it from the orphan list and keeps the ledger entry.
Response: `{"status": "dismissed", "reason": "...", "file": "310"}`.

---

## Confirm — the step that touches the ledger

### `POST /receipts/{id}/confirm`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/receipts/310/confirm \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"category":"Office Supplies"}'
```

```json
{
  "status": "posted",
  "journal_entry_id": 578,
  "merchant": "Staples Office Supply",
  "linked_bank_txn_id": null
}
```

The body is an optional override object. Recognised keys:

| Key | Effect |
|---|---|
| `category` | Applied to **every** item |
| `items` | Array of per-item patches, applied positionally |
| `direction` | `"in"` (money received) or `"out"` (default) — decides Dr/Cr |
| `linked_bank_txn_id` | Link this bank transaction instead of auto-matching |
| `payment_method_id` | Record which card/account paid |
| `force` | `true` bypasses the post-time duplicate guard |

What confirm does, in order: applies overrides → validates → converts to your
base currency → checks for an already-posted twin → posts one balanced journal
entry → creates the AR/AP subledger row when `doc_type` is `sales_invoice` or
`bill_invoice` → links a bank transaction (explicit or auto-matched within the
match window). Bank linking is best-effort and never blocks the post.

The posting is idempotent per receipt: the entry carries the idempotency key
`receipt-confirm-{receipt_id}`, so a retried confirm cannot double-post.

### Confirming a bank statement is a different operation

If `doc_type == "bank_statement"`, confirm does **not** post a journal entry —
it imports the reviewed rows into the bank feed. Rows with `include: false` are
dropped. You must supply the target account:

```bash
curl -X POST https://api.accountingorbit.com/api/v1/receipts/312/confirm \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"gl_account_id": 23267}'
```

```json
{
  "status": "posted",
  "doc_type": "bank_statement",
  "account": {"id": 23267, "code": "1000", "name": "Cash"},
  "batch_id": 182,
  "rows_selected": 34,
  "imported": 34,
  "skipped_duplicates": 0
}
```

Without an account, and with none suggested at extraction time:

```json
{"detail": {"message": "Choose which account this statement belongs to.",
            "code": "STATEMENT_NEEDS_ACCOUNT", "receipt_id": 312}}
```

---

## After confirm: posted, and possibly an orphan

A confirmed receipt whose auto-match found no bank transaction is an **orphan**:
real ledger entry, no bank line behind it.

### `GET /receipts/orphans`

```bash
curl -G https://api.accountingorbit.com/api/v1/receipts/orphans \
  -H "Authorization: Bearer $ORBIT_KEY" -d limit=50
```

```json
{
  "items": [
    {"id": 310, "file": "310", "merchant": "Staples Office Supply",
     "date": "2026-07-15", "total": 42.5, "items": 2, "journal_entry_id": 578}
  ],
  "receipts": [ "...same array..." ],
  "total": 1, "limit": 50, "offset": 0, "has_more": false
}
```

Pending receipts are never orphans — only confirmed ones that failed to match.
The mirror-image list (bank lines with no receipt) is `GET /bank/orphans`, in
[/docs/bank-imports.md](/docs/bank-imports.md).

To resolve an orphan, call confirm again on the already-posted receipt with the
bank transaction id — it links without re-posting:

```bash
curl -X POST https://api.accountingorbit.com/api/v1/receipts/310/confirm \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"linked_bank_txn_id": 684}'
```

```json
{"status": "posted", "journal_entry_id": 578,
 "merchant": "Staples Office Supply", "linked_bank_txn_id": 684}
```

Or drop it from the list with `POST /receipts/{id}/reject`.

---

## Duplicate resolution

| Endpoint | Use |
|---|---|
| `POST /receipts/duplicates/{hash_key}/resolve?action=preview` | Show the merge plan without executing |
| `POST /receipts/duplicates/{hash_key}/resolve?action=merge` | Merge the newer pending copy's items into the older one |
| `POST /receipts/duplicates/{hash_key}/resolve?action=delete_new` | Keep the older, delete the newer |
| `POST /receipts/duplicates/{hash_key}/resolve?action=not_duplicates` | Keep both |
| `POST /receipts/{id}/merge-duplicate` | Merge a near-duplicate into its `duplicate_of` target |
| `POST /receipts/{id}/mark-not-duplicate` | Clear the `duplicate_of` flag |

`hash_key` is a **prefix** of the content hash. The oldest matching pending
receipt is "old"; a later one is "new". No match → `404 "Duplicate pair not found"`.
An unknown `action` → `400 "Unknown action: <x>"`.

`preview` response:

```json
{"action": "preview", "old": "310", "old_merchant": "Staples Office Supply",
 "old_total": 42.5, "new": "313", "new_merchant": "Staples Office Supply",
 "new_total": 42.5, "items_to_add": 1, "new_item_names": ["Stapler"]}
```

`POST /receipts/{id}/merge-duplicate` requires the receipt to be pending
(`409` otherwise) and to carry `duplicate_of` (`400` otherwise).

---

## Failure cases specific to receipts

| Status | Body | Cause |
|---|---|---|
| 400 | `{"detail": "invalid file"}` | Wrong type, bad magic bytes, unsafe filename, or over 10 MB |
| 400 | `{"detail": "Maximum 20 files per batch. Got N."}` | Too many files in one call |
| 404 | `{"detail": "Receipt 999999 not found"}` | No such receipt in your tenant |
| 404 | `{"detail": "Receipt image not found: ... — the image could not be retrieved; re-upload it"}` | Rescan could not fetch the stored image |
| 409 | `{"detail": "Receipt already processed"}` | Confirm/reject on a non-pending receipt. Two concurrent confirms: one wins, the other gets this. |
| 409 | `{"detail": "This receipt is already linked to another bank transaction.", "code": "RECEIPT_ALREADY_LINKED"}` | Re-linking a posted receipt to a different bank line |
| 409 | `{"detail": "Possible duplicate: <merchant> $<total> on <date> matches receipt #N which is already posted. Use \"Confirm anyway\" to post it regardless."}` | Post-time duplicate guard. Retry with `{"force": true}`. |
| 422 | `{"detail": "Receipt has validation errors — fix or discard: ..."}` | Confirm on a receipt that is still incomplete. Fix with `PUT /receipts/{id}/review` first. |
| 422 | `{"detail": {"message": "...", "code": "STATEMENT_NEEDS_ACCOUNT"}}` | Statement confirm with no target account |
| 422 | `{"detail": {"message": "...", "code": "STATEMENT_NO_ROWS"}}` | Statement confirm with every row excluded |
| 422 | `{"detail": {"message": "...", "code": "<LEDGER_CODE>"}}` | The ledger refused the posting (closed period, inactive account, unbalanced) |
| 429 | `{"detail": "AI call limit reached this month. Upgrade your plan."}` | Plan AI quota exhausted (rescan and extraction paths) |
| 500 | `{"detail": {"message": "Could not post this receipt.", "error_type": "...", "receipt_id": N}}` | Server fault during posting. `error_type` is the exception class only — no row data. |

Note: some ledger and statement errors return `detail` as an **object**, not a
string. Handle both shapes.

---

## Typical integration sequence

1. `POST /receipts/batch` with the file(s). Keep `files[].receipt_id` for each
   entry whose `type` is `receipt`, `receipt_with_errors`, `bill_invoice`, or
   `bank_statement`.
2. If `type` is `duplicate` or `probable_duplicate`, stop — the document is
   already in Orbit. Nothing to do.
3. If `needs_review` is true or `extraction_failed` is true, fetch the queue
   with `GET /receipts/pending` and correct the receipt with
   `PUT /receipts/{id}/review` — or re-extract with `POST /receipts/{id}/rescan`.
4. `POST /receipts/{id}/confirm`, with `{"category": "..."}` if you want to
   force categorisation. Keep `journal_entry_id` from the response.
5. If the response has `linked_bank_txn_id: null`, the receipt is an orphan.
   Either leave it (it is still correctly posted), or find the bank line via
   `GET /bank/orphans` and link it with a second confirm carrying
   `{"linked_bank_txn_id": N}`.
6. Verify the books moved as expected with
   `GET /statements/trial-balance?as_of=YYYY-MM-DD` —
   see [/docs/reports.md](/docs/reports.md).

For a document classified as `bank_statement`, skip step 4's shape and read
[/docs/bank-imports.md](/docs/bank-imports.md) instead — confirm imports rows,
it does not post an entry.
