# Changelog 2026-09-07

Your bank accounts are now in the API, metadata stops overwriting itself, and payloads we had started refusing are welcome again.

<!-- truncate -->

## Banking, opened up

Bank accounts and their transactions are now part of the API.

Accounts:

- `GET /v1/BankAccounts` and `GET /v1/BankAccounts/{id}`
- `POST /v1/BankAccounts` to create one, `PUT /v1/BankAccounts/{id}` to update it
- `DELETE /v1/BankAccounts/{id}`
- `POST /v1/BankAccounts/SetAsMain/{id}` — moves the main-account designation from whichever account holds it, since exactly one account is main at a time

Transactions, read-only:

- `GET /v1/BankAccounts/{id}/Transactions` — paged, with `from`, `to`, `searchTerm`, `hideExcluded` and `hideReconciliated`
- `GET /v1/BankTransactions/{id}`
- `POST /v1/BankTransactions/Exclude/{id}` and `POST /v1/BankTransactions/Include/{id}` — set a transaction aside so it stops being offered for reconciliation, or bring it back

## Connected accounts are read-only

An account synchronized from a provider mirrors what that provider reports, so Altoviz is not its system of record and neither are you.

Every account carries a `provider` object — `type`, `accountId`, `accountStatus`, `bankName` — which is `null` on an account you keep by hand, and a `connected` flag that says the same thing. One null check tells you whether an account is yours to write to.

- `PUT` and `DELETE` on a connected account return `400`. Disconnect it in Altoviz first.
- The provider fields are never writable, on any account. They are absent from the create and update bodies entirely.

Setting the main account and excluding or including a transaction still work on a connected account: those are Altoviz's own bookkeeping, not something the provider owns.

Deleting is deliberately hard to do by accident. Beyond the connected check, `DELETE` returns `409` when the account holds any transaction already reconciled with a receipt or a payment, and `400` when the account is the main one. What gets through is an account created by mistake or never used — which is the only kind you should be deleting through an API, because deletion also removes the account's accounting journal and every entry in it.

## Metadata that stops fighting you

`metadata` is now available on customers and products. It was already stored and already used — the WooCommerce import writes `woocommerce_product_id`, Quickbooks writes `qbo_id` — you just could not read it back.

More importantly, metadata is now a **patch** everywhere the API carries one:

- keys you send are added or updated
- keys you leave out keep whatever they held
- a key is removed only when you send it as `null`
- `""` stores an empty value, it does not remove anything
- omitting the property entirely leaves the whole bag untouched

The bag is shared. Your integration is not the only thing writing to it, and replacing it wholesale meant sending your own single key silently dropped everyone else's.

⚠️ **Breaking change**: if you relied on omitting a key to remove it, send that key as `null` instead. This affects invoices, quotes, credits, receipts, colleagues, `MarkAsPaid` and `MarkAsRefunded`, which previously replaced the whole bag.

## Finding documents you didn't create

`Find` accepted an internal id and nothing else — the one key a document created inside Altoviz never has. So documents made in the app were invisible to it.

- `GET /v1/SalesQuotes/Find`, `/v1/SalesInvoices/Find` and `/v1/SalesCredits/Find` now take a `number` alongside `internalId`, matching customers and products. When both are given, `internalId` wins.

A draft carries a provisional number from its own sequence (`000001`) until finalization assigns the real one (`FA001000`), so a search by number can match either.

`GetByInternalId` also spread beyond the three resources that had it:

- `GET /v1/Products/GetByInternalId/{internalId}`
- `GET /v1/Receipts/GetByInternalId/{internalId}`
- `GET /v1/SalesQuotes/GetByInternalId/{internalId}`, and the same for `SalesInvoices` and `SalesCredits`

## Sales, with an s

Sale documents were the only place in the API spelled without one. `/v1/Exports/SalesInvoices` always had it; `/v1/SaleInvoices` did not.

The canonical routes are now `/v1/SalesInvoices`, `/v1/SalesQuotes` and `/v1/SalesCredits`.

Nothing breaks. Every `/v1/Sale*` route is still served, returns exactly the same result, and is simply flagged deprecated in the specification, naming the route that replaces it. Move when it suits you.

## We stopped being picky

Some payloads that had worked for years started coming back `400`. Loosely-typed clients send what they send, and we had quietly narrowed what we accept. That is fixed, and now guarded by a test that re-types every field of every published model and asserts we still accept whatever we used to.

Accepted again:

- a number where a string is declared — `"woocommerce_order_id": 3587755`
- `"true"`, `"1"`, `"0"` or `""` where a boolean is expected
- `""` where an optional number, date or enum is expected

⚠️ One exception, on purpose: a quoted number carrying a group separator, like `"1,5"`, is now refused with a `400` naming the field. It used to be read as `15` — a silent factor of ten, on money. `"1.5"`, `"1000"` and `1000` are unaffected.