> ## Documentation Index
> Fetch the complete documentation index at: https://build.practicehub.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Accounting export

> A nightly export of invoices, line items, payments and allocations to an accounting system in PHP — incremental by updated, voids and refunds handled, money and time done right

**Goal:** every night, push the day's billing activity to the accounting system (Xero, QuickBooks, Sage — the shape is the same): new and changed invoices with their lines, payments received, how payments were allocated to invoices, and anything voided. Re-runnable, gap-free, and never double-posting.

**Key:** read-only — this integration writes nothing to PracticeHub. **Resources used:** `invoices`, `line_items`, `payments`, `payment_allocations`, `payment_methods`, `/me` (plus `payments.deleted` / `payment_allocations.deleted` webhooks if you want same-day reversals).

## The design

* **Incremental by `updated`.** Every record carries `created` and `updated` (UTC). Each run asks for everything with `updated >= last watermark`, sorted by `updated`, walked by cursor. The watermark is the newest `updated` you processed, minus a small overlap.
* **Voided invoices are updates; voided payments and released allocations disappear.** Voiding an invoice sets `state: "void"` (and bumps `updated`, so it re-enters your window). Voiding a payment, or the allocations an invoice void releases, soft-deletes those rows: they stop being returned by the API (a `GET` by id is `404`) and fire `payments.deleted` / `payment_allocations.deleted` webhooks. Reconcile those either from the webhooks or from the invoice void itself (below).
* **Post the money, not the arithmetic.** Amounts are fixed 2-dp strings (`"45.00"`); keep them as decimals in your language, never floats. Currency comes from `/me`.
* **Idempotent posting** on your side keyed by PracticeHub id (`invoice-4821`, `payment-9002`), so a re-run of a window updates rather than duplicates.

## Client (PHP 8.2, Guzzle)

```php theme={null}
<?php
// PracticeHub.php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;

final class ApiError extends RuntimeException {
    public function __construct(public readonly int $status, public readonly string $code, string $message, public readonly array $errors = []) {
        parent::__construct("$status $code: $message");
    }
}

final class PracticeHub {
    private Client $http;

    public function __construct(private readonly string $baseUrl, string $key) {
        $this->http = new Client([
            'base_uri' => rtrim($baseUrl, '/') . '/',
            'headers' => ['Authorization' => "Bearer $key", 'Accept' => 'application/json'],
            'timeout' => 30,
        ]);
    }

    /** GET a path (or an absolute links.next URL); retries 429/5xx with backoff. */
    public function get(string $pathOrUrl): array {
        for ($attempt = 0; ; $attempt++) {
            try {
                return json_decode((string) $this->http->get($pathOrUrl)->getBody(), true, 512, JSON_THROW_ON_ERROR);
            } catch (BadResponseException $e) {
                $res = $e->getResponse();
                if (($res->getStatusCode() === 429 || $res->getStatusCode() >= 500) && $attempt < 4) {
                    sleep((int) ($res->getHeaderLine('Retry-After') ?: 2 ** $attempt));
                    continue;
                }
                $body = json_decode((string) $res->getBody(), true) ?: [];
                throw new ApiError($res->getStatusCode(), $body['code'] ?? 'unknown', $body['message'] ?? $res->getReasonPhrase(), $body['errors'] ?? []);
            }
        }
    }

    /** Walk a list by cursor, yielding rows. $query must NOT contain cursor/page. */
    public function walk(string $resource, array $query): Generator {
        $query['cursor'] = '';
        $query['page_size'] = 100;
        $url = $resource . '?' . http_build_query($query);
        while ($url) {
            $page = $this->get($url);
            yield from $page['data'];
            $url = $page['links']['next'];          // absolute URL or null
        }
    }
}
```

## 1. Once: account context and reference data

```php theme={null}
$ph = new PracticeHub(getenv('PRACTICEHUB_BASE_URL'), getenv('PRACTICEHUB_API_KEY'));

$me = $ph->get('me')['data'];
$currency = $me['account']['currency'];          // "GBP" — the currency of every amount below
$timezone = $me['account']['timezone'];          // "Europe/London" — for invoice_date / payment_date, which are clinic-local dates

// payment methods → your accounting "payment account" mapping (Cash, Card, BACS, …)
$methods = [];
foreach ($ph->walk('payment_methods', []) as $m) {
    $methods[$m['id']] = $m['name'];
}
```

`invoice_date` and `payment_date` are **clinic-local calendar dates** (`YYYY-MM-DD`) — that is what the practice sees on the invoice, so post those. `created` / `updated` are UTC timestamps and are only for windowing.

## 2. Each night: the window

```php theme={null}
$since = $store->get('watermark') ?? '2026-01-01 00:00:00';       // UTC, from your last run
$overlap = (new DateTimeImmutable($since, new DateTimeZone('UTC')))->modify('-15 minutes')->format('Y-m-d H:i:s');
$runStartedAt = gmdate('Y-m-d H:i:s');

$window = ['updated' => "gte:$overlap", 'sort' => 'updated:asc'];
```

Overlap by a few minutes so a row that committed just after your previous read isn't missed; the idempotent posting below makes reprocessing harmless. Set the new watermark to `$runStartedAt` at the end (not "now" — anything updated during the run is picked up next time).

## 3. Invoices with their lines

```php theme={null}
foreach ($ph->walk('invoices', $window) as $inv) {
    // $inv: id, number, state (paid|unpaid|void), type, invoice_date, patient_id, practitioner_id, location_id,
    //       subtotal, total, balance, patient_balance, third_party_balance, note, created, updated, metadata

    if ($inv['state'] === 'void') {
        $ledger->voidInvoice("invoice-{$inv['id']}");               // idempotent: no-op if already void
        $ledger->unallocateAllFor("invoice-{$inv['id']}");         // the void released its allocations in PracticeHub too
        continue;
    }

    $lines = [];
    foreach ($ph->walk('line_items', ['invoice_id' => "eq:{$inv['id']}"]) as $li) {
        // $li: id, description, quantity, price, tax_amount, subtotal, total, billable_item_id, provider_id, currency
        $lines[] = [
            'external_id' => "line-{$li['id']}",
            'description' => $li['description'],
            'quantity'    => $li['quantity'],
            'unit_price'  => $li['price'],          // "45.00" — keep as decimal string
            'tax'         => $li['tax_amount'],
            'total'       => $li['total'],
        ];
    }

    $ledger->upsertInvoice([
        'external_id' => "invoice-{$inv['id']}",
        'number'      => $inv['number'],
        'contact_ref' => "patient-{$inv['patient_id']}",       // map to a customer once, see below
        'date'        => $inv['invoice_date'],
        'currency'    => $currency,
        'total'       => $inv['total'],
        'balance'     => $inv['balance'],
        'lines'       => $lines,
        'reference'   => $inv['metadata']['ledger_ref'] ?? null,
    ]);
}
```

An invoice's lines can be edited after it was raised (the practice corrects a description, adds a product) — that bumps the invoice's `updated`, so it re-enters your window and you upsert lines by `line-{id}`. Lines removed in PracticeHub simply don't come back; if your ledger needs an explicit delete, diff against what you last posted.

## 4. Payments and allocations

A payment is money received; an allocation is that money being applied to an invoice. One payment can be split across invoices; an unallocated payment is credit on account. Post payments as receipts and allocations as the invoice ↔ receipt link.

```php theme={null}
foreach ($ph->walk('payments', $window) as $pay) {
    // $pay: id, amount, payment_type_id, patient_id, location_id, payment_date, status, note, third_party, created, updated
    // status: completed (or empty for older rows), pending, failed, refunded, requires_action — only post settled money
    if (! in_array($pay['status'], ['completed', '', null], true)) {
        $ledger->reverseReceiptIfPosted("payment-{$pay['id']}");
        continue;
    }
    $ledger->upsertReceipt([
        'external_id' => "payment-{$pay['id']}",
        'contact_ref' => "patient-{$pay['patient_id']}",
        'date'        => $pay['payment_date'],
        'amount'      => $pay['amount'],
        'account'     => $methods[$pay['payment_type_id']] ?? 'Other',   // e.g. "Card", "Cash", "BACS"
        'reference'   => $pay['note'],
    ]);
}

foreach ($ph->walk('payment_allocations', $window) as $alloc) {
    // $alloc: id, payment_id, invoice_id, patient_id, amount, created, updated
    $ledger->allocate("payment-{$alloc['payment_id']}", "invoice-{$alloc['invoice_id']}", $alloc['amount'], "allocation-{$alloc['id']}");
}
```

**Voided payments** are soft-deleted: they no longer appear in `payments`, so a nightly window cannot see them. Two ways to catch them — pick one:

* **Webhooks (once available):** subscribe to `payments.deleted` and `payment_allocations.deleted`; the handler reverses `payment-{id}` / `allocation-{id}` in the ledger. See the [webhook receiver](/examples/webhook-receiver).
* **Nightly re-check:** for receipts you posted in the last N days, `GET /payments/{id}` — a `404` means it was voided; reverse it. Cheap for a small clinic, wasteful for a large one.

Allocations released by an invoice void are handled in the invoice loop above (`unallocateAllFor`), because the void is what you see in the window.

## 5. Customers

Post each patient once as a customer, keyed `patient-{id}`, on first sight. Fetch the name lazily:

```php theme={null}
function customerFor(PracticeHub $ph, Ledger $ledger, int $patientId): void {
    if ($ledger->hasContact("patient-$patientId")) return;
    $p = $ph->get("patients/$patientId")['data'];
    $ledger->upsertContact(['external_id' => "patient-$patientId", 'name' => trim("{$p['first_name']} {$p['last_name']}"), 'email' => $p['email']]);
}
```

Whether to send patient names/emails to the accounting system at all is a decision for the practice (it is personal data). Many exports use `"Patient {id}"` as the customer name and keep the mapping in PracticeHub — the API supports either.

## 6. Finish

```php theme={null}
$store->set('watermark', $runStartedAt);
```

## Gotchas

| Symptom                                     | Cause                                                          | Fix                                                                                    |
| ------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Totals off by pennies                       | Amounts parsed as floats                                       | Keep the 2-dp strings as decimals (`bcmath`, `Money`, `Decimal`)                       |
| Invoice dated the wrong day                 | Used `created` (UTC) instead of `invoice_date` (clinic-local)  | Post `invoice_date` / `payment_date`; use `created`/`updated` only for windowing       |
| Missed a record                             | Window used `gt` on the last `updated` with no overlap         | `gte` from watermark − 15 min; watermark = run start time                              |
| Duplicated a receipt after a re-run         | Posting not keyed by PracticeHub id                            | Upsert on `payment-{id}` / `invoice-{id}` / `allocation-{id}`                          |
| Void invoice still open in ledger           | Treated `state` as immutable                                   | A void is `invoices.updated` with `state: "void"` — handle it in the invoice loop      |
| Voided payment still posted                 | Voided payments vanish from lists rather than changing         | Subscribe to `payments.deleted`, or re-check recent receipts by id                     |
| Walk restarts from the beginning            | Reused an old cursor with different filters/sort               | Cursors are bound to their query; build the URL fresh each run and follow `links.next` |
| `403` on `line_items` from a connector user | The team member's role lacks *View all patient financial data* | Use an API key for exports; connectors carry the person's permissions                  |

## Going further

* Trigger the run from `invoices.updated` / `payments.created` [webhooks](/guides/webhooks) instead of nightly, using the same idempotent handlers.
* Tag posted invoices from your side with `metadata.ledger_ref` (needs a write key) so staff can see the accounting reference on the PracticeHub record — the loop above already reads it back.
