# Bank imports

Get bank and card activity into Accounting Orbit — by JSON, CSV, QIF or PDF
statement, or by a live Plaid connection — then match those lines against
ledger postings and confirm the matches. Importing never posts to the general
ledger by itself: a bank line becomes an accounting fact only when it is
matched and confirmed, or when you explicitly create an entry for it.

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

---

## The object model

### Bank transaction

| Field | Type | Notes |
|---|---|---|
| `id` | integer | |
| `gl_account_id` | integer | The GL account this feed represents (e.g. `1000` Cash) |
| `txn_date` | `YYYY-MM-DD` | |
| `amount` | number | **Signed. Positive = money in, negative = money out.** |
| `description` | string | |
| `external_id` | string \| null | The bank's own id. Unique per tenant — the dedup key. |
| `status` | enum | `unmatched` \| `suggested` \| `matched` \| `ignored` |
| `review_status` | enum | `unreviewed` \| `reviewed` \| `excluded` |
| `receipt_status` | enum | `awaiting_receipt` \| `receipt_pending` \| `receipt_linked` \| `no_receipt_needed` |
| `txn_type` | enum | 13-value taxonomy: `purchase`, `refund`, `income`, `processing_fee`, `chargeback`, `dispute_fee`, `internal_transfer`, `owner_contribution`, `owner_distribution`, `inter_entity`, `reimbursement_settlement`, `cash_expense`, `personal` |
| `matched_entry_id` / `matched_posting_id` | integer \| null | The GL entry/posting this line is matched to |
| `import_batch_id` | integer \| null | Which import produced it |
| `source_connection_id` | integer \| null | Which provider connection produced it. `null` = manual upload. |
| `receipt_file` | string \| null | `receipt:{id}` when a receipt is attached |
| `raw_data` | string | The source row, verbatim JSON |
| `category`, `suggested_account_id`, `flagged_for_review`, `flag_note`, `needs_review`, `review_reason`, `exclude_reason` | — | Review workflow fields |
| `revision_of_id` / `revision_detected_at` | integer / timestamp | Set when a provider revised a previously-imported row |
| `txn_status` | string | `pending` or `posted` at the bank |

### Import batch (a "statement")

Every import — JSON, CSV, QIF, PDF or provider sync — creates one batch.
`GET /bank/statements` lists them with rollups.

---

## Importing

All four import endpoints share the same guarantee: **each source row is either
imported, revised in place, or skipped as a duplicate — there is no fourth
outcome, and the counts always add up.** Dedup is by `(tenant, external_id)`
plus a content hash, so re-running the same import is a safe no-op.

### `POST /bank/import` — structured rows (JSON)

The most reliable path for an integrator: you control the parsing.

```bash
curl -X POST https://api.accountingorbit.com/api/v1/bank/import \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{
        "gl_account_id": 23267,
        "rows": [
          {"txn_date":"2026-08-05","amount":"-42.50","description":"STAPLES #123","external_id":"bank-txn-001"},
          {"txn_date":"2026-08-06","amount":"150.00","description":"ACME CORP PAYMENT","external_id":"bank-txn-002"}
        ]
      }'
```

```json
{"imported": 2, "updated": 0, "skipped": 0, "overlap_warning": null}
```

Re-sending row `bank-txn-001` unchanged:

```json
{"imported": 0, "updated": 0, "skipped": 1, "overlap_warning": null}
```

Row fields: `txn_date` (required), `amount` (required, signed), `description`
(default `""`), `external_id` (optional but strongly recommended — it is what
makes retries safe).

- `imported` — new rows created
- `updated` — rows that already existed under the same `external_id` and were
  revised (e.g. pending → posted, or a provider correction)
- `skipped` — rows recognised as duplicates
- `overlap_warning` — non-null when this import's date range overlaps an earlier
  batch on the same account

### `POST /bank/import-csv?gl_account_id=N` — multipart

```bash
curl -X POST "https://api.accountingorbit.com/api/v1/bank/import-csv?gl_account_id=23267" \
  -H "Authorization: Bearer $ORBIT_KEY" \
  -F "file=@august.csv"
```

```json
{
  "imported": 34, "updated": 0, "skipped": 2,
  "batch_id": 182,
  "control_total": "-1284.55",
  "imported_total": "-1284.55",
  "control_matched": true,
  "overlap_warning": null
}
```

Required columns: **`date`** and **`amount`**. Optional: `description`,
`external_id`. Header matching is case- and whitespace-insensitive
(`DATE`, ` date `, `Date` all work). Missing a required column →
`422 "CSV missing required columns: amount"`.

Dates are parsed as `YYYY-MM-DD`, `MM/DD/YYYY`, `DD/MM/YYYY`, `MM-DD-YYYY` or
`DD-MM-YYYY`. Amounts handle US (`1,234.56`) and European (`1.234,56`)
notation. Encoding is auto-detected: UTF-8 (with or without BOM), UTF-16, then
Latin-1 fallback.

`control_total` is the sum of every amount in your file; `imported_total` is
the sum of what landed. **Check `control_matched` on every import** — it is
`false` whenever rows were dropped or deduped, which is exactly when you want to
look at `batch_id`'s rejects.

### `POST /bank/import-qif?gl_account_id=N` — multipart

```json
{"imported": 18, "batch_id": 184, "skipped": 0, "overlap_warning": null}
```

No transactions parsed → `{"imported": 0, "skipped": 0, "error": "no valid transactions found"}`.
An unparseable file → `422 "invalid data"`.

### `POST /bank/import-pdf?gl_account_id=N` — multipart

AI extraction of a PDF statement. There is no lower-quality fallback: no rows
parsed means no rows.

```json
{
  "rows_parsed": 41, "imported": 41, "updated": 0,
  "batch_id": 185,
  "skipped_duplicates": 0,
  "control_total": "-3921.14",
  "imported_total": "-3921.14",
  "control_matched": true,
  "overlap_warning": null
}
```

Re-uploading the same PDF is caught by a document-level hash, because AI
extraction is not deterministic and per-row dedup can miss a re-read:

```json
{
  "duplicate": true,
  "existing_filename": "august-statement.pdf",
  "rows_parsed": 0, "imported": 0, "skipped_duplicates": 0,
  "detail": "This statement was already imported (august-statement.pdf, 2026-08-04). Nothing was re-imported."
}
```

Scanned/image-only or unsupported layouts →
`422 "no transactions found in PDF — the statement may be scanned (image-only) or in an unsupported layout"`.

### Accepted file types per endpoint

| Endpoint | Accepts |
|---|---|
| `import-csv`, `import-qif` | `.csv` `.txt` `.ofx` `.qif` `.iif` |
| `import-pdf` | `.pdf` only |
| `POST /receipts/batch` | `.pdf` and images — see [/docs/receipts.md](/docs/receipts.md) |

Max 10 MB per file, validated by magic bytes. Wrong type →
`400 {"detail": "invalid file"}`.

**Dropping a PDF statement on the receipt uploader also works** — it is
classified as `bank_statement`, held as a pending receipt with its rows
extracted, and imported when you confirm it with a `gl_account_id`. That path is
documented in [/docs/receipts.md](/docs/receipts.md); it reaches the same
importer, not a second one.

### Sign convention

Outflow is negative on every path, resolved against the account type — a credit
card statement that prints charges as positives is normalised, so a card charge
does not import with the sign of a bank withdrawal. Rows imported before this
rule existed can be re-signed with
`POST /bank/repair-statement-signs?gl_account_id=N` (unmatched rows only):

```json
{"repaired": 12, "total_scanned": 40, "message": "12 transaction(s) re-signed (out of 40 unmatched)"}
```

---

## Plaid sync

Plaid is optional server configuration. If it is not configured, both endpoints
return `503 "Plaid is not configured on this server"`. Both also require the
`bank_feeds` feature and settings-edit permission.

1. `POST /bank/plaid/link-token` → `{"link_token": "link-sandbox-..."}`.
   Open Plaid Link in a browser with it. Upstream failure → `502 "upstream service error"`.
2. `POST /bank/plaid/exchange` with `{"public_token": "public-...", "gl_account_id": 23267}`
   → `{"connection_id": 7, "item_id": "..."}`.
   Unknown account → `404 "GL account not found"`.
   The same bank login already linked to another tenant →
   `409 "This bank account is already connected to another entity..."`.
   The Plaid access token is encrypted at rest and never returned by any
   endpoint — `GET /bank/connections` redacts `config`.
3. `POST /bank/pull` pulls every active connection for your tenant:

```json
{"pulled": 26, "errors": 0,
 "details": [{"connection_id": 7, "tenant_id": 457, "provider": "plaid",
              "imported": 26, "error": null}]}
```

Manual connections are managed with `GET/POST /bank/connections` and
`DELETE /bank/connections/{id}`. Deleting with cascade removes only the rows
that connection can be proven to have produced (`source_connection_id`), never
your manual uploads.

---

## Matching and confirming

### `POST /bank/auto-match?gl_account_id=N&window_days=5`

Scans unmatched lines on the account and proposes matches against existing GL
postings. Matches are **suggestions** — nothing is confirmed here.

```bash
curl -X POST "https://api.accountingorbit.com/api/v1/bank/auto-match?gl_account_id=23267&window_days=5" \
  -H "Authorization: Bearer $ORBIT_KEY"
```

```json
{"gl_account": "1000", "exact_matches": 0, "fuzzy_matches": 1,
 "split_matches": 0, "unmatched": 1, "total": 2}
```

`window_days` is the date tolerance for a fuzzy match. A `split_match` is one
bank line covering several postings (e.g. a deposit of three invoices).

### `GET /bank/transactions`

```bash
curl -G https://api.accountingorbit.com/api/v1/bank/transactions \
  -H "Authorization: Bearer $ORBIT_KEY" \
  -d gl_account_id=23267 -d match_status=suggested -d limit=100
```

```json
{
  "items": [
    {
      "id": 683, "gl_account_id": 23267,
      "txn_date": "2026-08-05", "amount": -42.5,
      "description": "STAPLES #123", "external_id": "bank-txn-001",
      "status": "unmatched", "review_status": "unreviewed",
      "receipt_status": "awaiting_receipt", "receipt_file": null,
      "txn_type": "purchase", "txn_status": "posted",
      "matched_entry_id": null, "matched_posting_id": null,
      "import_batch_id": 182, "source_connection_id": null,
      "category": null, "suggested_account_id": null,
      "flagged_for_review": false, "flag_note": null,
      "needs_review": false, "review_reason": null, "exclude_reason": null,
      "revision_of_id": null, "revision_detected_at": null,
      "raw_data": "{\"txn_date\": \"2026-08-05\", \"amount\": \"-42.50\", \"description\": \"STAPLES #123\", \"external_id\": \"bank-txn-001\"}",
      "imported_at": "2026-08-11T00:24:28.092539",
      "viewed_at": "2026-08-11T00:24:28.136069+00:00",
      "reviewed_at": null, "reviewed_by": null,
      "is_fee": false, "group_id": null, "parent_txn_id": null,
      "linked_transaction_id": null, "processor": null,
      "dispute_status": null, "modified_at": null, "last_edited_at": null,
      "tenant_id": 457
    }
  ],
  "total": 2, "limit": 100, "offset": 0, "has_more": false
}
```

Filters: `gl_account_id`, `start_date`, `end_date`, `review_status` (plus the
pseudo-value `not_viewed`), `match_status`, `receipt_status`, `account_code`,
`category`, `q` (description contains), `min_amount`, `max_amount` (absolute
values, non-negative, min ≤ max), `sort_field` (default `txn_date`),
`sort_dir` (default `asc`), `limit` (default 200), `offset`.

### `POST /bank/transactions/{id}/confirm`

Promotes a suggested match to `matched`. Returns the full transaction.

```json
{"id": 684, "status": "matched", "matched_entry_id": 575,
 "matched_posting_id": 1170, "amount": "150.00",
 "description": "ACME CORP PAYMENT", "...": "..."}
```

`POST /bank/confirm-all?gl_account_id=N` confirms every suggestion on the
account: `{"confirmed": 12}`.

If your tenant has a duty policy for `reconcile`, confirming is refused for
users who fail it.

### `POST /bank/transactions/{id}/unmatch`

Breaks a match. A transaction whose posting has already been reconciled →
`409 "Cannot unmatch: this transaction is reconciled. Reopen the reconciliation
session first."`

### `POST /bank/transactions/{id}/create-entry`

For a bank line with no ledger counterpart, create one:

```bash
curl -X POST https://api.accountingorbit.com/api/v1/bank/transactions/683/create-entry \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"counter_account_code":"5001"}'
```

```json
{"journal_entry_id": 601}
```

This is the endpoint that turns an unexplained bank line into accounting.

### `POST /bank/{txn_id}/move-without-receipt`

Marks a line `no_receipt_needed`, and — if it already has a journal entry —
re-points the expense posting at the category you name.

```bash
curl -X POST "https://api.accountingorbit.com/api/v1/bank/683/move-without-receipt?category=Office%20Supplies&description=Paper"
```

```json
{"status": "no_receipt_needed", "txn_id": 683}
```

---

## Unmatched lines become orphans

An imported line that never matched anything is an **orphan** on the bank side.
The mirror image — a posted receipt with no bank line — is an orphan on the
receipt side.

### `GET /bank/orphans`

Bank transactions whose `receipt_status` is `awaiting_receipt` or
`receipt_pending`:

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

```json
{
  "items": [
    {"id": 684, "date": "2026-08-06", "description": "ACME CORP PAYMENT",
     "amount": 150.0, "receipt_status": "awaiting_receipt",
     "receipt_file": null, "matched_entry_id": 575},
    {"id": 683, "date": "2026-08-05", "description": "STAPLES #123",
     "amount": -42.5, "receipt_status": "awaiting_receipt",
     "receipt_file": null, "matched_entry_id": null}
  ],
  "transactions": [ "...same array..." ],
  "total": 2, "limit": 50, "offset": 0, "has_more": false
}
```

`GET /receipts/orphans` is the receipt-side list — see
[/docs/receipts.md](/docs/receipts.md).

Four ways to clear a bank orphan:

1. **Attach a receipt** — `POST /bank/{txn_id}/upload-receipt` (multipart, one
   file; `?mode=gemini` extracts, `?mode=upload_only` just stores the image).
   Sets `receipt_status: receipt_pending` and creates a pending receipt to
   confirm. Extraction failure returns `200` with
   `{"status": "extraction_failed", "receipt_id": N, "choices": ["keep","scan_again"], "message": "..."}`.
2. **Link an existing posted receipt** — `POST /receipts/{id}/confirm` with
   `{"linked_bank_txn_id": 684}`.
3. **Create the entry directly** — `POST /bank/transactions/{id}/create-entry`.
4. **Declare it needs none** — `POST /bank/{txn_id}/move-without-receipt`.

Deleting a statement batch (`DELETE /bank/statements/{batch_id}`) never destroys
linked receipts: they are unlinked and returned to the orphan pool.

---

## Reviewing a whole statement

| Endpoint | Purpose |
|---|---|
| `GET /bank/statements` | Every import batch with rollups |
| `GET /bank/statements/{batch_id}` | One batch's rows — the review workspace |
| `POST /bank/statements/{batch_id}/confirm-all` | Mark every unreviewed row reviewed → `{"confirmed": 34}` |
| `GET /bank/statements/{batch_id}/rejects` | Rows the importer refused, with reasons |
| `PATCH /bank/rejects/{reject_id}/resolve` | `{"resolution": "pending"\|"reviewed"\|"fixed"\|"ignored"}` |
| `POST /bank/statements/{batch_id}/resolve-overlap` | `{"decision": "skip"\|"replace"\|"merge"}` — recorded on the batch, auditable |
| `DELETE /bank/statements/{batch_id}` | Delete a batch. `?reverse_matched=true` reverses matched entries first; `?forget=true` also drops the dedup hash so the file can be re-imported |

`GET /bank/statements` rollups:

```json
{
  "items": [
    {"id": 182, "source": "csv", "filename": "august.csv",
     "period_start": "2026-08-05", "period_end": "2026-08-06",
     "created_at": "2026-08-11T00:24:28.097235",
     "account_name": "Cash",
     "txn_count": 2, "imported_count": 2, "skipped_count": 0,
     "reviewed_count": 0, "excluded_count": 0, "matched_count": 0,
     "unreviewed_count": 2,
     "total_in": "150.00", "total_out": "-42.50",
     "confirmed": false}
  ],
  "total": 2, "offset": 0, "limit": 100, "has_more": false
}
```

`confirmed` is true only when every row is reviewed or excluded.

### Tie-out

`GET /bank/tie-out` returns, per account, the feed balance vs the last
reconciled statement balance and the drift between them:

```json
[{"account_id": 23267, "account_name": "Cash", "account_code": "1000",
  "feed_balance": 107.5, "statement_balance": 107.5,
  "drift": 0.0, "tied_out": true, "last_statement_date": "2026-07-31"}]
```

`GET /bank/accounts-registry` is the richer version — one row per money account
with its source (`linked` / `upload_only` / `manual`), connections, feed
balance, statement count, drift and mapped cards.

---

## Failure cases specific to bank imports

| Status | Body | Cause |
|---|---|---|
| 400 | `{"detail": "invalid file"}` | Wrong type, bad magic bytes, unsafe filename, or over 10 MB |
| 400 | `{"detail": "N transaction(s) in this statement are confirmed/matched — pass reverse_matched=true to reverse their entries first"}` | Deleting a batch with matched rows |
| 400 | `{"detail": "decision must be skip, replace, or merge"}` | Bad overlap decision |
| 400 | `{"detail": "invalid resolution"}` | Bad reject resolution |
| 404 | `{"detail": "statement not found"}` / `"import batch not found"` / `"reject not found"` / `"transaction not found"` / `"Bank transaction not found"` / `"GL account not found"` / `"Account not found"` | No such object **or it belongs to another tenant** |
| 409 | `{"detail": "Cannot unmatch: this transaction is reconciled. Reopen the reconciliation session first."}` | Unmatching a reconciled posting |
| 409 | `{"detail": "This bank account is already connected to another entity. ..."}` | Same Plaid item linked to two tenants |
| 422 | `{"detail": "CSV missing required columns: amount"}` | Missing `date` or `amount` header |
| 422 | `{"detail": "invalid CSV data: unrecognized date format: '08.05.26'"}` | Unparseable row |
| 422 | `{"detail": "no transactions found in PDF — the statement may be scanned (image-only) or in an unsupported layout"}` | PDF extraction found nothing |
| 422 | `{"detail": "invalid data"}` | The ledger refused an operation (closed period, bad account) |
| 429 | `{"detail": "Statement import limit reached this month. Upgrade your plan."}` | Plan statement-import quota |
| 429 | `{"detail": "AI call limit reached this month. Upgrade your plan."}` | Plan AI quota (PDF import, receipt extraction) |
| 502 | `{"detail": "upstream service error"}` | Plaid returned an error |
| 503 | `{"detail": "Plaid is not configured on this server"}` | Plaid credentials absent |

---

## Typical integration sequence

1. Find the GL account for the feed. `GET /bank/accounts-registry` lists your
   money accounts with `account_id` and `account_code` (Cash is usually `1000`).
2. Import. Prefer `POST /bank/import` with structured rows and a stable
   `external_id` per row — retries then cost nothing. Otherwise
   `POST /bank/import-csv?gl_account_id=N` with a file.
3. Read `imported` / `updated` / `skipped`, and — on CSV and PDF —
   check `control_matched`. If it is `false`, fetch
   `GET /bank/statements/{batch_id}/rejects` and deal with the rejected rows.
4. `POST /bank/auto-match?gl_account_id=N&window_days=5`.
5. `GET /bank/transactions?gl_account_id=N&match_status=suggested` and review.
   Confirm the ones you accept — `POST /bank/transactions/{id}/confirm`, or
   `POST /bank/confirm-all?gl_account_id=N` for all of them.
6. `GET /bank/orphans` for what is left over. Attach a receipt
   (`POST /bank/{txn_id}/upload-receipt`), create an entry
   (`POST /bank/transactions/{id}/create-entry`), or mark it
   `move-without-receipt`.
7. `GET /bank/tie-out` to confirm the feed balance matches the last statement,
   then `GET /statements/trial-balance?as_of=today` —
   see [/docs/reports.md](/docs/reports.md).
