# Invoices

Create, send, and manage invoices with built-in partial payment support.

---

## Overview

The Invoices API enables you to:

- 📄 **Create invoices** - Line items, tax and discounts, totals calculated server-side
- 📧 **Send via email/SMS** - Deliver invoices to customers
- 💰 **Accept payments** - Every invoice gets its own hosted pay link
- 📊 **Partial payments** - Optional, with an optional minimum payment rule
- 📱 **QR codes** - Scannable code for the pay link
- 📋 **PDF generation** - Downloadable invoice PDFs
- 🔔 **Reminders** - Manual reminder emails

### Authentication

All `/api/v1/invoices` endpoints are admin REST endpoints:

```http
Authorization: Bearer YOUR_TOKEN
X-Tenant-ID: your-tenant-id
Content-Type: application/json
```

`X-Tenant-ID` is required and must match the tenant in the token (`401` otherwise). A server-side API key sent as `x-api-key` is also accepted; the gateway resolves the tenant (and, for a merchant-scoped key, the merchant) from the key. Endpoints that check a user permission (`external-payment`) reject API-key calls with `403 FORBIDDEN`.

### Invoices have no expiry

An invoice has an **issue date** and a **due date** only. It does not expire: past its due date it becomes `overdue` and stays payable. (Standalone payment links are different and do expire.) Do not send `expiresAt` / `expiresInDays` on invoices.

### Location

`invoiceLocationType` is the first-class sales-channel field. It accepts `physical`, `online`, or
`null` for legacy callers and rows. A physical Invoice Location uses its own active branch UUID as
the Invoice-level `stockLocationId`. The Invoice-level field is also retained for the existing
Online Send/Edit reservation contract and bounded legacy-client compatibility.

The portal also retains `metadata.location` as a legacy display/filter name (for example
`"Headquarters"`). It is not an identifier and is never used to choose fulfillment. The server
does not infer a physical branch from `metadata.location`, a merchant id, a default branch, a
branch name, or the word `Online`.

### Stock source for tracked products

An Online tracked Product line may persist its own optional writable
`lineItems[].stockLocationId`. The value must identify an active physical branch owned by the
Invoice merchant when newly supplied. Saving a Draft may omit it and has no stock effect. Services,
free-text lines, and untracked Products do not retain a line stock location. Changing a line's
Product clears its prior branch selection.

The per-line field currently provides a durable round-trip and the input to
[Inventory Preflight](#inventory-preflight). It does not by itself replace the Invoice-level
location used by the existing Send/Edit reservation mutation. A physical Invoice still uses its
Invoice-level branch as the effective source; any hidden Online per-line choices remain persisted
for a later switch back.

The server reserves tracked quantities when an Invoice enters `sent`. With zero-stock selling
disabled, insufficient Available stock rejects Send or the relevant pre-payment edit with
`409 INVOICE_INVENTORY_INSUFFICIENT`; `error.details.violations` supplies the Product, requested
quantity, server-calculated Available quantity, and physical location. With zero-stock selling
enabled, the server reserves only Available quantity and records the unreserved shortfall. Clients
must render the returned error/detail state and must not calculate availability themselves.

Once the first payment is recorded, the fulfillment location is locked. Later stock changes never
reject or reverse a successful payment. On the first fully-paid transition, the server consumes the
Invoice's own reservation, deducts only stock that can still be deducted without making inventory
negative, and records any remainder as not deducted for Pending Review.

### Additive location and detail contract

The location request change is additive. Before this contract, callers could provide only the
legacy display name:

```json
{
  "metadata": { "location": "Headquarters" }
}
```

After this contract, `invoiceLocationType` is added while the top-level `stockLocationId` continues
to carry the Invoice-level physical fulfillment ID:

```json
{
  "invoiceLocationType": "physical",
  "stockLocationId": "2d8b4e17-ec2e-41f2-b6e9-6a32c5fd1780",
  "metadata": { "location": "Headquarters" }
}
```

For an Online sales channel, each tracked Product line can separately persist a branch selection:

```json
{
  "invoiceLocationType": "online",
  "lineItems": [
    {
      "id": "li_1",
      "stableLineId": "li_1",
      "productId": "c126128b-7447-414f-80a9-61582fd8a608",
      "quantity": 5,
      "stockLocationId": "2d8b4e17-ec2e-41f2-b6e9-6a32c5fd1780"
    }
  ],
  "metadata": { "location": "Online" }
}
```

Existing callers may continue omitting `invoiceLocationType`, subject to the Send rules above.
Before this contract, responses had no first-class `invoiceLocationType` or server-derived detail
block. Create and write responses now include the persisted location fields below
(`stockLocationId` remains the physical branch field):

```json
{
  "invoiceLocationType": "online",
  "stockLocationId": "2d8b4e17-ec2e-41f2-b6e9-6a32c5fd1780",
  "stockLocationName": "Warehouse East",
  "inventoryVersion": 0
}
```

`GET /api/v1/invoices/:invoiceId` additionally returns server-derived fulfillment lock,
reservation, and inventory Activity fields. See [Get Invoice](#get-invoice) for the exact shape.

### Writable and response-only line location fields

Only `lineItems[].stockLocationId` is writable stock-location data. The other line location fields
are server-owned response projections and must not be copied into a write request as authority.

| Line field | Direction | Meaning |
|------------|-----------|---------|
| `stockLocationId` | Request and response | Persisted optional branch selection for the line. A legacy/effective fallback is never written into this field by a read. |
| `stockLocationName` | Response only | Current server-resolved name for the persisted selection, or `null`. |
| `effectiveStockLocationId` | Response only | Effective persisted-line, Physical, active-reservation, or legacy source without backfilling `stockLocationId`. |
| `effectiveStockLocationName` | Response only | Current server-resolved name for the effective source, or `null`. |
| `stockLocationSource` | Response only | `persisted_line`, `physical_invoice`, `active_reservation`, `legacy_invoice`, or `null`. |
| `reservedQuantity` | Response only | Quantity held by the stable Product line's active reservation, independently of the effective source; otherwise `0`. It is not writable availability. |

Client-supplied names, effective fields, provenance, reservation quantities, and availability are
discarded or overwritten. An old-client edit that omits line locations preserves existing values
only when stable line and Product identity match unambiguously; conflicting mixed Invoice-level
and per-line Online sources are rejected.

Unauthenticated public checkout responses omit the top-level and per-line internal branch IDs,
branch names, effective fields, provenance, and reservation quantities.

---

## Inventory Preflight

Evaluate the complete candidate Invoice against current branch availability without saving the
Invoice or changing inventory.

### Endpoint

```http
POST /api/v1/invoices/inventory-preflight
```

Without `invoiceId`, the caller needs `invoices.create`; merchant scope is resolved the same way as
Create. With `invoiceId`, the caller needs `invoices.manage`; the server loads the Invoice inside
the authenticated tenant/merchant scope. Do not send `merchantId` in the body. An inaccessible Edit
Invoice returns the normal non-disclosing `404 NOT_FOUND`, while a permission failure returns
`403 FORBIDDEN`.

### Request

```json
{
  "invoiceLocationType": "online",
  "lineItems": [
    {
      "lineId": "li_1",
      "productId": "c126128b-7447-414f-80a9-61582fd8a608",
      "quantity": 8,
      "stockLocationId": "2d8b4e17-ec2e-41f2-b6e9-6a32c5fd1780"
    }
  ]
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `invoiceId` | uuid | No | Include only for Edit. The server derives the merchant from this scoped Invoice. |
| `invoiceLocationType` | string | Yes | Exact lowercase `online` or `physical`. |
| `invoiceStockLocationId` | uuid \| null | No | Candidate effective branch for `physical`; ignored as a per-line Online selection. A missing required selection is reported in the evaluation. |
| `lineItems` | array | Yes | Complete candidate Draft, maximum 200 lines. |
| `lineItems[].lineId` | string | Yes | Nonblank stable identity, unique within the request. |
| `lineItems[].productId` | uuid \| null | No | Current Product identity. Product facts are loaded server-side. |
| `lineItems[].quantity` | number | Yes | Greater than `0`, at most `1000000`, and at most 3 decimal places. |
| `lineItems[].stockLocationId` | uuid \| null | No | Candidate Online branch selection. |

Unknown top-level or line fields are rejected with `400 VALIDATION_ERROR`, including attempted
client authority such as availability, policy, tracking state, branch names, tenant, or merchant.
Blank/duplicate line IDs, malformed quantities, more than 200 lines, and Products unavailable in
the resolved merchant scope also return the normal validation envelope.

### Response

`200 OK` returns `{ "success": true, "data": { "lines": [], "blockingErrors": [],
"advisories": [] } }` even when `blockingErrors` is nonempty:

| Response field | Meaning |
|----------------|---------|
| `data.lines[]` | Evaluated Product-backed candidate lines. Free-text lines are omitted. |
| `lineId`, `productId`, `selectedLocationId`, `allowZeroStockSale` | Stable identity, server-resolved Product policy, and candidate effective selection. |
| `branches[]` | Every active physical branch, with `locationId`, `locationName`, `availableQuantity`, `reservedQuantity`, `availableAfterOtherDraftLines`, `selectable`, and `reason` (`available` or `insufficient`). |
| `expectedReservedQuantity`, `shortfallQuantity` | Deterministic preview allocation for the selected branch; neither value is committed stock. |
| `lines[].blockingErrors`, `lines[].advisories` | Issues affecting that line. |
| `data.blockingErrors`, `data.advisories` | Whole-candidate issues, grouped by Product and branch. |

Each issue contains `type` (`missing_location`, `inactive_location`, or `insufficient_stock`),
`severity` (`error` or `warning`), `lineIds`, `productId`, `productName`, nullable `locationId` and
`locationName`, `requestedQuantity`, `availableQuantity`, and `reservedQuantity`.

Every active physical branch is returned, including branches with zero balance. For Edit,
`availableQuantity` adds back only that Invoice's own active holds before evaluating the candidate;
other Invoices' reservations remain unavailable. Duplicate Product lines sharing a branch are
grouped for issues and allocated in stable `lineId` order, so `expectedReservedQuantity`,
`shortfallQuantity`, and `availableAfterOtherDraftLines` are deterministic.

`missing_location`, `inactive_location`, and insufficient stock with zero-stock selling disabled
are blocking errors. Insufficient stock with zero-stock selling enabled is a warning advisory;
short branches remain selectable and the expected quantity is bounded by current Available.
Issues include affected `lineIds` plus Product, branch, Requested, Available, and Reserved facts.

Preflight is advisory, not a reservation or approval. It performs no Invoice, balance,
reservation, or Activity write, and its `expectedReservedQuantity` is not committed stock. A later
Create, Send, or Edit remains authoritative and may observe newer inventory.

---

## Create an Invoice

### Endpoint

```http
POST /api/v1/invoices
```

### Request

```bash
curl -X POST https://uat.auxcore.net/api/v1/invoices \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{
    "merchantId": "6f1c2a9e-3b7d-4e21-9a55-0c8f1d2b7e40",
    "customerName": "John Doe",
    "customerEmail": "john@example.com",
    "customerPhone": "5551234567",
    "lineItems": [
      {
        "id": "li_1",
        "name": "Web Design Services",
        "description": "Homepage redesign",
        "quantity": 10,
        "unit": "hour",
        "unitPrice": 150.00,
        "taxRate": 8
      },
      {
        "id": "li_2",
        "name": "Hosting (Annual)",
        "quantity": 1,
        "unitPrice": 299.00,
        "discount": 10,
        "discountType": "percent"
      }
    ],
    "issueDate": "2026-10-01",
    "dueDate": "2026-10-15",
    "discountAmount": 29.90,
    "allowPartialPayment": true,
    "minimumPaymentType": "percent",
    "minimumPaymentValue": 25,
    "notes": "Payment due within 15 days",
    "poNumber": "PO-456",
    "invoiceLocationType": "physical",
    "stockLocationId": "2d8b4e17-ec2e-41f2-b6e9-6a32c5fd1780",
    "metadata": { "location": "Headquarters" }
  }'
```

### Body Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `merchantId` | uuid | Yes | Merchant issuing the invoice. Filled from the key for a merchant-scoped API key |
| `lineItems` | array | Yes* | Line items (see below). *Or send `amount` instead |
| `amount` | number | Yes* | Manual amount when there are no line items; becomes a single "Payment" line |
| `customerName` / `customerEmail` / `customerPhone` | string | No | Customer contact. A Customer Vault record is found or created from these |
| `customerId` | uuid | No | Existing Customer Vault id, to link the invoice to that customer explicitly |
| `billingAddress`, `billingCity`, `billingState`, `billingPostalCode`, `billingCountry` | string | No | Billing address |
| `type` | string | No | `standard` (default), `recurring`, `text_to_pay`, `email_to_pay` |
| `status` | string | No | Initial status. Defaults to `sent`; send `draft` to save without issuing |
| `discountAmount` | number | No | Total discount in dollars (line discounts plus any invoice-level discount) |
| `taxRate` | number | No | Stored for display; tax is calculated from each line's `taxRate` |
| `allowPartialPayment` | boolean | No | Default `false` |
| `minimumPaymentType` | string | No | `amount` or `percent` (only with partial payments) |
| `minimumPaymentValue` | number | No | `percent`: greater than 0 and less than 100. `amount`: at least 0.50 and less than the total |
| `nextPaymentAfterDays` | integer | No | Days after issue date for the next automatic instalment (partial payments only); must not land after `dueDate` |
| `allowRescheduleNextPayment` | boolean | No | Let the payer move the next instalment date (partial payments only) |
| `issueDate` | date | No | `YYYY-MM-DD`. Defaults to today |
| `dueDate` | date | No | `YYYY-MM-DD` |
| `reminderDaysBefore` | integer | No | Default `0` |
| `notes`, `terms`, `poNumber`, `reference` | string | No | Free text |
| `webhookUrl` | string | No | URL that receives `invoice.paid` for this invoice |
| `invoiceLocationType` | string \| null | No | `physical` or `online`. `null`/omitted is retained for legacy callers and is never inferred from metadata |
| `stockLocationId` | uuid \| null | Conditional | Validated active physical fulfillment branch. Required for `physical`; required before issuing an Online Invoice with tracked Product lines |
| `metadata` | object | No | Free-form. Recognised keys: `location` (location name), `acceptCards` / `acceptAch` (default `true`; restrict pay methods), `recurringSchedule` (recurring invoices) |
| `autoSend` | boolean | No | When the invoice is not a draft and has an email, it is emailed after creation. Send `false` to deliver it yourself |

### Line Item Fields

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Your line id |
| `stableLineId` | string | Optional durable line identity. Keep it stable across Edit and preflight requests. |
| `productId` | uuid \| null | Catalog Product identity. Omit for a free-text line. |
| `stockLocationId` | uuid \| null | Persisted writable physical-branch selection for an Online tracked Product line. Optional on Draft; ignored/cleared for services, free-text lines, and untracked Products. |
| `name` | string | Line name |
| `description` | string | Line description |
| `quantity` | number | Defaults to 1. Greater than 0, up to 3 decimal places (`2.5`); otherwise `400 VALIDATION_ERROR` |
| `unit` | string | One of `piece`, `each`, `box`, `hour`, `day`, `kg`, `lb`, `ton`, `liter`, `gallon`, `metre`, `foot`, `mile`, `mil`, `other`. Optional. Common spellings are accepted and stored as the code (`gallons` → `gallon`, `meter` → `metre`, `lbs` → `lb`); an unknown unit is `400 VALIDATION_ERROR` |
| `unitPrice` | number | Price per unit |
| `discount` | number | Line discount |
| `discountType` | string | `fixed` (default) or `percent` |
| `taxRate` | number | Line tax percentage (e.g. `8` = 8%) |
| `freightAmount` | number | Per-line freight charge. It is included in the line amount and invoice total. |

See [Writable and response-only line location fields](#writable-and-response-only-line-location-fields)
for the server-derived fields that can appear when the Invoice is read.

`amount`, `taxAmount`, `subtotal` and `totalAmount` are always **calculated by the server**; any values you send are overwritten. Totals: `totalAmount = subtotal − discountAmount + taxAmount + freightAmount + convenienceFee`, where `subtotal` is quantity × unit price **before** any discount.

> **Line discounts must also be counted in `discountAmount`.** A line's `discount` lowers that line's taxable base, but the invoice total only subtracts the invoice-level `discountAmount`. Send `discountAmount` as the sum of all line discounts plus any invoice-level discount (the example above: 10% of 299.00 = 29.90), otherwise the line discount is not taken off the total.

### Response

`201 Created` with the invoice:

```json
{
  "success": true,
  "data": {
    "id": "0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10",
    "invoiceNumber": "INV-2026-000123",
    "displayInvoiceNumber": "INV-2026-000123",
    "linkId": "INV-1790812800000-A1B2C3D4",
    "shortLink": "https://uat.auxcore.net/s/Ab12Cd",
    "merchantId": "6f1c2a9e-3b7d-4e21-9a55-0c8f1d2b7e40",
    "type": "standard",
    "status": "sent",
    "customerId": "3d7f0c1e-8a2b-4f6d-b9c3-1e5a7d2f8b04",
    "customerName": "John Doe",
    "customerEmail": "john@example.com",
    "lineItems": [ ... ],
    "subtotal": 1799.00,
    "discountAmount": 29.90,
    "taxAmount": 120.00,
    "convenienceFee": 0,
    "totalAmount": 1889.10,
    "amountPaid": 0,
    "amountDue": 1889.10,
    "currency": "USD",
    "allowPartialPayment": true,
    "minimumPaymentType": "percent",
    "minimumPaymentValue": 25,
    "issueDate": "2026-10-01",
    "dueDate": "2026-10-15",
    "invoiceLocationType": "physical",
    "stockLocationId": "2d8b4e17-ec2e-41f2-b6e9-6a32c5fd1780",
    "stockLocationName": "Headquarters",
    "inventoryVersion": 0,
    "metadata": { "location": "Headquarters" },
    "createdAt": "2026-10-01T09:00:00.000Z"
  }
}
```

Money fields (`subtotal`, `discountAmount`, `taxAmount`, `convenienceFee`, `totalAmount`, `amountPaid`, `amountDue`, `minimumPaymentValue`) are numbers in this create response but **decimal strings** (e.g. `"1889.10"`) whenever the invoice is read back (get, list, lookup); write responses can mix both (e.g. after a payment `amountPaid` / `amountDue` are numbers while `totalAmount` is a string). Parse them before doing arithmetic. Amounts inside `paymentHistory` entries are numbers.

The customer pays at `shortLink`, or at `{host}/pay/invoice/{linkId}` when no short link was minted.

### Errors

| Status | Code | When |
|--------|------|------|
| `400` | `VALIDATION_ERROR` | Missing `merchantId`, no line items and no `amount`, or an invalid minimum payment / next-payment rule |
| `400` | `VALIDATION_ERROR` | Invalid `invoiceLocationType`, a physical Invoice Location without its active branch id, or an issued tracked Invoice without a Fulfillment location |
| `400` | `QUANTITY_LIMIT_EXCEEDED` | A catalog product's quantity, summed across the lines with its `productId`, is over its `maxQuantity`. `error.details.violations` lists each one — see [Products](./products.md), "Per-order quantity limit" |
| `403` | `MERCHANT_CLOSED` / `MERCHANT_RESTRICTED` | Merchant cannot issue invoices |
| `409` | `INVOICE_INVENTORY_INSUFFICIENT` | Send-time reservation failed with zero-stock selling disabled; `error.details.violations` contains Product and physical-location availability details |
| `422` | `LIMIT_EXCEEDED` | Total exceeds the merchant's per-transaction limit |

---

## Send an Invoice

Send invoice via email or SMS. See [Sending & Delivery](./sending.md) for all delivery endpoints.

### Endpoint

```http
POST /api/v1/invoices/:invoiceId/send
```

```bash
curl -X POST https://uat.auxcore.net/api/v1/invoices/0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10/send \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{ "method": "email", "to": "john@example.com" }'
```

`method` (`email` or `sms`) and `to` are both required, but `to` is only checked for presence: the invoice is always delivered to its stored `customerEmail` / `customerPhone`. To send to a different address, edit the invoice first.

When a draft first enters `sent`, the server validates the explicit physical fulfillment location
and reserves each tracked Product line atomically before delivery. A failed validation or
insufficient-stock result leaves the Invoice draft, balances, reservations, and Activity
unchanged. A later notification-delivery failure does not undo a reservation that the server has
already committed.

An insufficient-stock Send returns this structured error when zero-stock selling is disabled:

```json
{
  "success": false,
  "error": {
    "code": "INVOICE_INVENTORY_INSUFFICIENT",
    "message": "Inventory is not available for this invoice.",
    "details": {
      "violations": [
        {
          "productId": "c126128b-7447-414f-80a9-61582fd8a608",
          "productName": "Widget",
          "requestedQuantity": 5,
          "availableQuantity": 3,
          "locationId": "2d8b4e17-ec2e-41f2-b6e9-6a32c5fd1780",
          "locationName": "Warehouse East"
        }
      ]
    }
  }
}
```

---

## Record a Payment

Record money that has **already been collected** against an invoice (full or partial). This endpoint does not charge a card — see [Invoice Payments](./payments.md).

### Endpoint

```http
POST /api/v1/invoices/:invoiceId/payment
```

```bash
curl -X POST https://uat.auxcore.net/api/v1/invoices/0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10/payment \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 500.00,
    "paymentMethod": "card",
    "transactionId": "TXN-20261001-8F3A2C",
    "notes": "First installment"
  }'
```

**Response:** the updated invoice, e.g. `"status": "partial"`, `"amountPaid": 500`, `"amountDue": 1389.1`, with the payment appended to `paymentHistory`.

---

## Invoice Statuses

| Status | Description |
|--------|-------------|
| `draft` | Saved, not issued. Can be updated or deleted |
| `sent` | Issued to the customer |
| `viewed` | Customer opened the pay link |
| `partial` | Partially paid |
| `paid` | Fully paid |
| `overdue` | Past due date and unpaid — still payable |
| `pending` | An ACH payment is processing |
| `cancelled` | Voided |
| `expired` | Legacy only — invoices created before expiry was removed |

`sent`, `viewed`, `partial`, and `overdue` Invoices retain their active inventory reservation.
Becoming overdue does not release stock. Cancellation/void releases active reservations; payment
conversion consumes them.

---

## Get Invoice

### Endpoint

```http
GET /api/v1/invoices/:invoiceId
```

```bash
curl https://uat.auxcore.net/api/v1/invoices/0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10 \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id"
```

**Response:** the full invoice object (same shape as create), including `paymentHistory`,
`deliveryHistory`, `editHistory`, `viewCount`, `sentAt`, `viewedAt`, `paidAt`, and these
server-derived detail additions:

- `fulfillmentLocationLocked` and `fulfillmentLocationLockReason` (`first_payment` or `null`)
- `inventoryReservation.state`: `none`, `active`, `partial`, `converted_to_sale`, or `released`
- `inventoryReservation.lines[]`: Product, physical location, requested/reserved/unreserved
  quantities, persisted line state, and `updatedAt`
- `inventoryActivity[]`: server-recorded reservation and settlement quantities; clients must not
  infer Activity from balance totals
- Historical rows always include `locationId`. `locationName` is nullable when the row belongs to
  an earlier fulfillment location whose name is not available from the Invoice's current snapshot;
  clients must not display the UUID as though it were a branch name.

```json
{
  "success": true,
  "data": {
    "id": "0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10",
    "invoiceNumber": "INV-2026-000123",
    "status": "partial",
    "totalAmount": "1889.10",
    "amountPaid": "500.00",
    "amountDue": "1389.10",
    "invoiceLocationType": "online",
    "stockLocationId": "2d8b4e17-ec2e-41f2-b6e9-6a32c5fd1780",
    "stockLocationName": "Warehouse East",
    "inventoryVersion": 0,
    "fulfillmentLocationLocked": true,
    "fulfillmentLocationLockReason": "first_payment",
    "inventoryReservation": {
      "state": "active",
      "lines": [
        {
          "lineId": "li_1",
          "productId": "c126128b-7447-414f-80a9-61582fd8a608",
          "productName": "Widget",
          "locationId": "2d8b4e17-ec2e-41f2-b6e9-6a32c5fd1780",
          "locationName": "Warehouse East",
          "requestedQuantity": 5,
          "reservedQuantity": 5,
          "unreservedQuantity": 0,
          "state": "active",
          "updatedAt": "2026-10-01T09:01:00.000Z"
        }
      ]
    },
    "inventoryActivity": [
      {
        "id": "c983fef5-37d8-4a2e-a766-c034220f3001",
        "type": "reservation_created",
        "productId": "c126128b-7447-414f-80a9-61582fd8a608",
        "locationId": "2d8b4e17-ec2e-41f2-b6e9-6a32c5fd1780",
        "locationName": "Warehouse East",
        "requestedQuantity": 5,
        "reservedQuantity": 5,
        "deductedQuantity": null,
        "notDeductedQuantity": 0,
        "reservationDelta": 5,
        "reservedAfter": 5,
        "createdAt": "2026-10-01T09:01:00.000Z"
      }
    ],
    "paymentHistory": [
      {
        "amount": 500.00,
        "paymentMethod": "card",
        "paidAt": "2026-10-02T17:00:00.000Z",
        "transactionId": "TXN-20261001-8F3A2C",
        "isExternal": false,
        "balanceAfter": 1389.10
      }
    ],
    "dueDate": "2026-10-15"
  }
}
```

Returns `404 NOT_FOUND` when the invoice does not exist in your tenant.

---

## Look Up an Invoice

Find one invoice by any reference — invoice number, external (Aux365 / QuickBooks) number or id, pay link id, PO number, or customer contact.

### Endpoint

```http
GET /api/v1/invoices/lookup
```

| Parameter | Description |
|-----------|-------------|
| `value` (or `q`) | Value to match |
| `field` | Restrict to one field: `invoiceId`, `invoiceNumber`, `aux365InvoiceNumber`, `quickbooksDocNumber`, `aux365InvoiceId`, `quickbooksInvoiceId`, `linkId`, `poNumber`, `customerName`, `customerEmail`, `customerPhone` |
| `merchantId` | Required for platform admins; a merchant user may only pass their own |

The field names above can also be passed directly as query parameters (e.g. `?invoiceNumber=INV-2026-000123`).

**Response:** `{ "success": true, "data": { ...invoice }, "matchedOn": "invoiceNumber", "results": [ ... ] }`. Without `field`, an inexact value falls back to a partial search and `results` lists every match. The fallback search only covers collectable invoices. Errors: `404 INVOICE_NOT_FOUND` when nothing matches, `400 MERCHANT_ID_REQUIRED` when a platform admin omits `merchantId`, `403 MERCHANT_SCOPE_VIOLATION` when a merchant user asks for another merchant.

---

## List Invoices

### Endpoint

```http
GET /api/v1/invoices
```

### Query Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `page` | integer | Page number (default 1) |
| `limit` | integer | Items per page (default 50) |
| `merchantId` | uuid | Filter by merchant |
| `status` | string | Filter by one status |
| `statuses` | string | Comma-separated statuses (overrides `status`) |
| `type` | string | `standard`, `recurring`, `text_to_pay`, `email_to_pay` |
| `customerId` | uuid | Filter by Customer Vault id |
| `customerEmail` | string | Partial, case-insensitive match |
| `search` | string | Matches customer name, email, invoice number or link id; a money value also matches the total |
| `fromDate` / `toDate` | date | Filter on creation date |
| `delivery` | string | `email`, `text`, or `link` |
| `location` | string | Invoice Location display name. First-class physical rows match `stockLocationName` and retain legacy `metadata.location` / `metadata.locationName` matching; unclassified legacy rows use metadata fallback. `Online` matches first-class Online rows and legacy rows with no physical display location |
| `sort` | string | `status` to group by status; otherwise newest first |

```bash
curl "https://uat.auxcore.net/api/v1/invoices?status=overdue&limit=25" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id"
```

**Response:**

```json
{
  "success": true,
  "data": [ { "id": "0b8e6a3c-...", "invoiceNumber": "INV-2026-000123", "status": "overdue" } ],
  "meta": {
    "page": 1,
    "limit": 25,
    "total": 1,
    "statusCounts": { "all": 42, "sent": 10, "overdue": 1, "paid": 31 }
  }
}
```

---

## Invoice Summary

KPIs and status counts for the invoices dashboard.

### Endpoint

```http
GET /api/v1/invoices/summary
```

Optional query: `merchantId`.

```json
{
  "success": true,
  "data": {
    "statusCounts": { "all": 42, "sent": 10, "overdue": 1, "paid": 31 },
    "outstandingTotal": 12500.00,
    "outstandingCount": 11,
    "overdueTotal": 1889.10,
    "overdueCount": 1,
    "paidMonthTotal": 8200.00,
    "paidMonthCount": 9,
    "avgTimeToPayDays": 6.4,
    "avgTimeToPaySampleSize": 31
  }
}
```

---

## Update Invoice

Update a **draft** invoice. Any other status returns `400 VALIDATION_ERROR` ("Only draft invoices can be updated") — to change an invoice that has already been sent, viewed or partially paid, use **Edit Invoice** below, which rotates the payment link and re-sends the updated invoice.

### Endpoint

```http
PUT /api/v1/invoices/:invoiceId
```

```bash
curl -X PUT https://uat.auxcore.net/api/v1/invoices/0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10 \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{
    "lineItems": [ ... ],
    "notes": "Updated payment terms"
  }'
```

Takes the create fields. When `lineItems` is sent, totals are recalculated, and a product whose
total quantity rises above its `maxQuantity` is refused with `400 QUANTITY_LIMIT_EXCEEDED` (a
quantity already on the invoice before the limit was set or lowered is kept).

Saving a draft still has no inventory effect. If `invoiceLocationType` is `physical`,
`stockLocationId` must be that Invoice Location's active branch id even while draft.

---

## Edit Invoice

Edit an existing invoice **in place** — the same invoice id and invoice number are returned. The behaviour depends on the invoice's **current** status, which is re-checked in the API against the latest database row at the moment of the call:

- **`draft` / `sent` / `viewed`** → **full edit**. The payment link is **rotated**: the old `linkId` is invalidated (it now serves a "link no longer available" message) and a new `linkId` / `shortLink` is issued. Status is set to `sent` and the invoice is **auto-resent** via Email (when it has an email) and SMS (when it has a phone). Channels are chosen by the system — there is no channel picker.
- **Full edit with `"resend": false`** → the invoice is updated in place; the link, status and balance are kept and nothing is sent.
- **`partial`** → **due date, notes and `allowRescheduleNextPayment` only**. The existing link is kept, amounts and payment history are untouched, and **nothing is resent**. Any amount-affecting field in the body (`lineItems`, `taxRate`, `discountAmount`, `convenienceFee`, `currency`, `totalAmount`, `amount`, `allowPartialPayment`, `minimumPaymentType`, `minimumPaymentValue`) is rejected with `400`.
- **`paid`** → `409 CONFLICT`. **`cancelled` / `expired` / `overdue` / `pending`** → `400 VALIDATION_ERROR`.

### Endpoint

```http
POST /api/v1/invoices/:invoiceId/edit
```

### Full edit (draft / sent / viewed)

```bash
curl -X POST https://uat.auxcore.net/api/v1/invoices/0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10/edit \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{
    "customerName": "Acme Corp",
    "customerEmail": "ap@acme.example",
    "customerPhone": "+15551234567",
    "lineItems": [
      { "id": "li_1", "name": "Consulting", "quantity": 2, "unitPrice": 125.00, "taxRate": 8.5 }
    ],
    "discountAmount": 0,
    "allowPartialPayment": true,
    "dueDate": "2026-10-31",
    "notes": "Updated scope"
  }'
```

Fields you omit keep their current value; `metadata` is merged into the existing metadata.
Before the first payment, changing tracked lines or `stockLocationId` on an issued Invoice causes
the server to reconcile the existing reservation atomically. After the first payment,
`stockLocationId` and `invoiceLocationType` are locked; a change returns
`409 FULFILLMENT_LOCATION_LOCKED` with `error.details.reason: "first_payment"`.

**Response (200):** the same invoice with a new `linkId`, the rotated-out id in `previousLinkId`, and a `meta.delivery` block reporting the resend outcome (`sent`, `failed` or `skipped` per channel).

```json
{
  "success": true,
  "data": {
    "id": "0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10",
    "invoiceNumber": "INV-2026-000123",
    "status": "sent",
    "linkId": "INV-1790899200000-NEW12345",
    "shortLink": "https://uat.auxcore.net/s/Xy98Zw",
    "previousLinkId": "INV-1790812800000-A1B2C3D4",
    "totalAmount": "271.25",
    "amountPaid": "0.00",
    "amountDue": "271.25",
    "dueDate": "2026-10-31"
  },
  "meta": { "delivery": { "email": "sent", "sms": "sent" } }
}
```

> `shortLink` may be missing if the shortener was unavailable; fall back to `{host}/pay/invoice/{linkId}`. If a resend fails the invoice and the new link stay active (`meta.delivery` reports `failed`); call `send-email` / `send-sms` to retry delivery.

### Partial edit (due date + notes only)

```bash
curl -X POST https://uat.auxcore.net/api/v1/invoices/0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10/edit \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -H "Content-Type: application/json" \
  -d '{ "dueDate": "2026-11-15", "notes": "Extended terms" }'
```

**Response (200):** the same invoice in place — `linkId`, amounts and payment history are all unchanged; only `dueDate` (and optionally `notes`) change. No resend is performed.

### Old payment link after a full edit

Once a full edit rotates the link, the old pay URL (`/pay/invoice/{oldLinkId}`) resolves to:

```json
{
  "success": false,
  "error": {
    "code": "LINK_ROTATED",
    "message": "This invoice link is no longer available. Please use the latest invoice link."
  }
}
```
returned with HTTP `410 Gone`. A link that was never issued still returns `404`.

### Errors

| Status | When |
|--------|------|
| `400` | Amount-affecting field on a partial invoice, a non-editable status (`cancelled`/`expired`/`overdue`/`pending`), or an invalid minimum payment / next-payment rule |
| `400` | `QUANTITY_LIMIT_EXCEEDED` — the edit raises a product's quantity above its `maxQuantity`. Quantities already on the invoice before the limit was set or lowered are kept |
| `403` | Full edit blocked by merchant status (`MERCHANT_CLOSED` / `MERCHANT_RESTRICTED`) |
| `404` | Invoice not found for this tenant |
| `409` | Invoice is **paid**, or became **paid** / **partial** before a full edit was saved |
| `422` | Full-edit total exceeds the merchant per-transaction limit (`LIMIT_EXCEEDED`) |

---

## Cancel Invoice

Void an issued invoice. Only issued, unpaid invoices (`sent`, `viewed`) can be cancelled; there is no request body.

### Endpoint

```http
POST /api/v1/invoices/:invoiceId/cancel
```

```bash
curl -X POST https://uat.auxcore.net/api/v1/invoices/0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10/cancel \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id"
```

**Response:** the invoice with `"status": "cancelled"`, `cancelledAt` and `cancelledBy`. The
server releases every active inventory reservation before committing the terminal status. The
void endpoint follows the same release rule.

| Rejected (400) | Message |
|----------------|---------|
| `paid` | Cannot cancel paid invoices |
| `cancelled` | Invoice is already cancelled |
| `partial` | Cannot cancel an invoice that has received partial payment |
| `overdue` | Cannot cancel an overdue invoice |
| `draft` / `pending` | A draft invoice has not been issued and cannot be voided — delete it instead |

---

## Delete Invoice

Soft-delete a `draft` or `cancelled` invoice. **Response:** `{ "success": true, "data": { "message": "Invoice deleted successfully" } }`.

Any other status is refused, but the API currently reports it as `500 INTERNAL_ERROR` ("Failed to delete invoice") rather than a `400`; `404 NOT_FOUND` when the invoice does not exist.

### Endpoint

```http
DELETE /api/v1/invoices/:invoiceId
```

```bash
curl -X DELETE https://uat.auxcore.net/api/v1/invoices/0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10 \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id"
```

---

## Generate QR Code

Get a QR code for the invoice's pay link.

### Endpoint

```http
GET /api/v1/invoices/:invoiceId/qr-code
```

```bash
curl https://uat.auxcore.net/api/v1/invoices/0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10/qr-code \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id"
```

**Response:**
```json
{
  "success": true,
  "data": {
    "qrCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg..."
  }
}
```

---

## Download PDF

Get invoice as PDF (`Content-Type: application/pdf`, sent as an attachment).

### Endpoint

```http
GET /api/v1/invoices/:invoiceId/pdf
```

```bash
curl https://uat.auxcore.net/api/v1/invoices/0b8e6a3c-5d2f-4c71-9e1a-7f3b2c9d4e10/pdf \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "X-Tenant-ID: your-tenant-id" \
  -o invoice.pdf
```

---

## Partial Payment Flow

AuxCORE fully supports partial payments:

1. **Enable partial payments** with `allowPartialPayment: true` (optionally with a minimum payment rule)
2. **Customer pays any amount** between the minimum and the remaining balance
3. **Status updates automatically**:
   - `sent` / `viewed` → `partial` (after first payment)
   - `partial` → `paid` (when fully paid)
4. **Every payment is recorded** in `paymentHistory`

Inventory does not re-check availability to approve a payment. Stock that changed after Send does
not block the payment. At the first fully-paid transition, any quantity that cannot be deducted is
recorded as `notDeductedQuantity` and surfaced through inventory Pending Review; the Invoice stays
paid.

---

## Migration and Legacy Open Invoices

The location/reservation migration is additive. `invoiceLocationType` is nullable and existing
Invoices are not backfilled from `metadata.location` or any default. Existing open
`sent`/`viewed`/`partial`/`overdue` Invoices are not silently given reservations during rollout.
They remain payable. A new reservation starts only when an explicit post-rollout Send or eligible
pre-payment edit supplies and validates the fulfillment contract. If a legacy open Invoice reaches
full payment without an owned reservation, the server follows the compatibility settlement path;
stock shortfall or a missing/inactive fulfillment source becomes a durable not-deducted/Pending
Review outcome and never reverses the payment.

### Example Flow

```bash
# Create invoice with partial payments enabled
POST /api/v1/invoices
{"merchantId": "6f1c2a9e-...", "amount": 1000.00, "allowPartialPayment": true}
# Status: sent, amountDue: 1000.00

# Customer pays $300 (recorded after the charge)
POST /api/v1/invoices/0b8e6a3c-.../payment
{"amount": 300.00, "paymentMethod": "card"}
# Status: partial, amountPaid: 300.00, amountDue: 700.00

# Customer pays $700
POST /api/v1/invoices/0b8e6a3c-.../payment
{"amount": 700.00, "paymentMethod": "card"}
# Status: paid, amountPaid: 1000.00, amountDue: 0.00
```

---

## Webhooks

When an invoice has a `webhookUrl`, AuxCORE POSTs one event to it after a successful customer card or ACH payment through the pay link. No other invoice events are emitted.

### invoice.paid
```json
{
  "event": "invoice.paid",
  "invoice": { "id": "0b8e6a3c-...", "invoiceNumber": "INV-2026-000123", "status": "paid", "amountDue": 0 },
  "transaction": { "transactionId": "TXN-20261002-9C1D4E", "amount": 1889.10 }
}
```

The event fires for partial payments too — check `invoice.status` and `invoice.amountDue`. The request is not signed and is not retried.

---

## Best Practices

### ✅ DO:
- **Use clear descriptions** in line items
- **Set reasonable due dates** (15-30 days)
- **Enable partial payments** for flexibility
- **Send reminders** for overdue invoices
- **Let the server calculate totals** - send quantities, prices, discounts and tax rates

### ❌ DON'T:
- **Don't send without reviewing** - save as `draft` first
- **Don't use vague descriptions** - be specific
- **Don't skip due dates**
- **Don't try to `PUT`-update a sent invoice** - use Edit Invoice instead; it rotates the payment link and re-sends the updated invoice so the customer always pays through the latest link

---

## Next Steps

- [Invoice Payments →](./payments.md)
- [Send Invoices →](./sending.md)
- [Products →](./products.md)
- [Set up webhooks →](../getting-started/webhooks.md)

---

**Need help?** Contact support@auxcore.net
