# Review — the CPA sign-off marketplace

A business owner posts a request to have their books reviewed. A platform
reviewer (a CPA) claims it and quotes a price. Nothing is billed and **no books
are readable** until the client approves that quote. The reviewer then works,
records findings, and signs off — which locks the accounting period and revokes
their access.

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

---

## Two roles, two credential requirements

**Owner endpoints** work with any authenticated user of the tenant that owns
the books.

**Reviewer endpoints** (`/signoff/queue`, `/signoff/my-reviews`,
`/signoff/claim/*`, `/signoff/review/*`, `/signoff/findings/*`,
`/signoff/sign/*`, `/signoff/reject/*`, `/signoff/quote/*`) require the caller
to be a **platform owner** or a user whose role is `accountant`. Anyone else:

```json
{"detail": "Reviewer access required"}
```
→ `403`.

These endpoints deliberately read and write **across tenants** — a reviewer's own
tenant is never the one under review. That is exactly why they are gated.

---

## The state machine

`ReviewRequest.status` has exactly five values:

| Status | Set by | Meaning |
|---|---|---|
| `pending` | `POST /signoff/request` | Submitted; no reviewer has claimed it |
| `claimed` | `POST /signoff/claim/{id}` | A reviewer picked it up |
| `reviewing` | `POST /signoff/review/{id}` | Reviewer is actively working |
| `signed_off` | `POST /signoff/sign/{id}` | Done — books verified, period locked |
| `rejected` | `POST /signoff/reject/{id}` | Bounced back to the owner with notes |

`billing_status` runs on a separate track with three values:

`unbilled` → `quoted` (`PUT /signoff/quote/{id}`) → `approved`
(`POST /signoff/quote/{id}/approve`).

```
                  PUT /signoff/quote/{id}         POST /signoff/quote/{id}/approve
 unbilled ─────────────────────────► quoted ─────────────────────────────────► approved
                                                                    ▲
 pending ──claim──► claimed ──review──► reviewing ──sign──► signed_off
    ▲                  │                    │
    └───── reject ─────┴────────────────────┘
```

---

## What a reviewer (or their assistant) may read at each stage

This is the important part, and it is enforced in code, not by convention.

| Stage | Client's books readable? | What the reviewer can see |
|---|---|---|
| `pending` | **No** | On the marketplace queue: `tenant_id`, `tenant_name`, the owner's free-text `description`, `service_type`, `created_at`, `quote_cents`, `billing_status`. Nothing accounting. |
| `claimed` | **No** | Same as above, plus the request is now theirs. **Claiming grants no access to the books** — it records only the reviewer's half of the consent. |
| `quoted` (`billing_status`) | **No** | Still nothing. A quote is an offer. |
| `approved` (`billing_status`) | **Yes** | The client's approval creates a `reviewer` membership on their tenant. From here the reviewer can read the books through the normal API — statements, ledger, documents — scoped to that tenant. |
| `reviewing` | Yes, if the quote was approved | Same access; the reviewer is recording findings. |
| `signed_off` | **No — revoked** | The claim-time membership is removed. The certificate remains readable by the owner. |
| `rejected` | **No — revoked** | Same revocation. |

Two details worth building on:

- Access is granted **only** when it did not already exist, and the request
  records that it did so (`access_granted`). Signing off or rejecting therefore
  removes *only* the membership the engagement created — never a membership the
  accountant already had for other reasons.
- The gate is **mutual authorization**: the reviewer consents by claiming, the
  client consents by approving the quote. One alone is not enough.

**If you are an AI assistant working on a reviewer's behalf: before
`billing_status == "approved"`, the only client information available to you is
the tenant name and the owner's own description of the job. Do not attempt to
read the client's ledger before that point — you will be refused, and that
refusal is the product working correctly.**

---

## Owner endpoints

### `POST /signoff/request`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/signoff/request \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"description":"FY2026 book review","service_type":"book_sign_off"}'
```

```json
{"id": 9, "status": "pending", "tenant_id": 457,
 "created_at": "2026-08-11T00:25:28.752616"}
```

`service_type` is `book_sign_off` (default) or `tax_prep`. `description` is
free text and defaults to `""` — it is what reviewers browse, so write it well.

### `GET /signoff/requests`

Your tenant's own requests, newest first. Returns a **bare array**.

```json
[{"id": 9, "status": "pending", "description": "FY2026 book review",
  "service_type": "book_sign_off",
  "findings": null, "notes_to_owner": null, "adjustments_made": 0,
  "signed_off_at": null,
  "quote_cents": null, "billing_status": "unbilled",
  "created_at": "2026-08-11T00:25:28.752616",
  "updated_at": "2026-08-11T00:25:28.752620"}]
```

Poll this to follow the engagement. `quote_cents` appearing with
`billing_status: "quoted"` is your cue to approve.

### `POST /signoff/quote/{request_id}/approve`

The client authorizes the price. **This is the call that grants the reviewer
access to the books.**

```json
{"id": 9, "billing_status": "approved",
 "quote_approved_at": "2026-08-11T00:31:04.220000"}
```

Refused (`409`) when:

- `billing_status` is not `quoted` → `"nothing to approve (billing is unbilled)"`
- the approver is the reviewer who claimed it →
  `"duty-separation: the reviewer cannot approve the quote"`
- the request belongs to another tenant → `"request not found"`

### `GET /signoff/certificate/{request_id}`

Available only once `status == "signed_off"`.

```json
{
  "certificate": {
    "tenant_id": 457,
    "service": "book_sign_off",
    "status": "books_verified",
    "reviewed_by": "cpa@firm.test",
    "signed_off_at": "2026-08-11T01:02:03.400000",
    "findings": "Reclassified 3 miscoded expenses; AR agreed to subledger.",
    "adjustments_made": 3
  }
}
```

Not yet signed → `400 "not yet signed off"`. Another tenant's request →
`404 "request not found"`.

---

## Reviewer endpoints

### `GET /signoff/queue`

Open requests (`pending` + `claimed`) across all tenants, oldest first.

```json
[{"id": 9, "tenant_id": 457, "tenant_name": "Acme Books",
  "status": "pending", "description": "FY2026 book review",
  "service_type": "book_sign_off",
  "claimed_by_user_id": null,
  "quote_cents": null, "billing_status": "unbilled",
  "created_at": "2026-08-11T00:25:28.752616"}]
```

This is the *whole* marketplace view before a claim — note there is no financial
data in it.

### `POST /signoff/claim/{request_id}`

```json
{"id": 9, "status": "claimed", "claimed_at": "2026-08-11T00:29:11.100000"}
```

Refused (`409`) when:

- the request is not `pending` → `"request is claimed, not pending"`
- the claimant submitted it →
  `"duty-separation: the requester cannot claim their own review request"`
- the reviewer is at the marketplace cap →
  `"you already have 5 open engagements (limit 5) — finish or release one first"`

The cap counts requests in `claimed` or `reviewing`. It is **5**.

### `PUT /signoff/quote/{request_id}`

```bash
curl -X PUT https://api.accountingorbit.com/api/v1/signoff/quote/9 \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"quote_cents": 45000}'
```

```json
{"id": 9, "quote_cents": 45000, "billing_status": "quoted"}
```

`quote_cents` is an integer in cents with a **marketplace floor of 5000
($50.00)**. Below it → `409 "quote must be at least $50.00 (marketplace minimum)"`.
Not your request → `409 "not your request"`. Already signed off → `409 "already
signed off"`.

A review is a professional-services engagement billed separately from the
subscription. Nothing is charged before the client approves.

### `POST /signoff/review/{request_id}`

`claimed` → `reviewing`.

```json
{"id": 9, "status": "reviewing"}
```

`409 "not claimed by this accountant"` if it is not yours.

### `PUT /signoff/findings/{request_id}`

```bash
curl -X PUT https://api.accountingorbit.com/api/v1/signoff/findings/9 \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"findings":"Reclassified 3 miscoded expenses; AR agreed to subledger.",
       "adjustments_made":3,
       "notes_to_owner":"Please attach receipts for the two August card charges."}'
```

```json
{"id": 9, "status": "reviewing",
 "findings": "Reclassified 3 miscoded expenses; AR agreed to subledger."}
```

Fields: `findings` (default `""`), `adjustments_made` (integer, default `0`),
`notes_to_owner` (nullable). Each call **replaces** all three — send the full
text every time, not a delta.

### `GET /signoff/my-reviews`

Requests you claimed or signed off, most recently updated first.

### `POST /signoff/sign/{request_id}`

The terminal action.

```json
{"id": 9, "status": "signed_off",
 "signed_off_by": "cpa@firm.test",
 "signed_off_at": "2026-08-11T01:02:03.400000",
 "message": "Books verified. Period locked — no further edits allowed."}
```

Signing does three things atomically:

1. Marks the request `signed_off`.
2. **Revokes** the reviewer's claim-time membership on the client tenant.
3. **Closes the client's current fiscal period** — the books become immutable.
   Later postings dated inside that period are refused by the ledger.

If the period cannot be locked the whole sign-off is rolled back, so a
certificate is never issued without real immutability:

```json
{"detail": "sign-off failed: could not lock the accounting period — books may have overlapping closed periods or missing retained-earnings accounts"}
```
→ `409`.

Other refusals (`409`): `"not your request"`,
`"duty-separation: the requester cannot sign off their own review"`,
`"already signed off"`.

### `POST /signoff/reject/{request_id}`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/signoff/reject/9 \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"reason":"Bank feed for August is missing — cannot verify cash."}'
```

```json
{"id": 9, "status": "rejected",
 "notes_to_owner": "Bank feed for August is missing — cannot verify cash."}
```

Also revokes the claim-time access. Nothing is locked.

---

## Adjacent: the month-end close checklist

Owner-side, unrelated to the marketplace but often run before requesting a
review:

| Endpoint | Purpose |
|---|---|
| `GET /signoff/close-checklist` | Steps with `completed` **and independently `verified`** status against the real ledger |
| `POST /signoff/close-checklist/complete` | `{"step": "..."}` — verified before it is accepted; a bogus step → `400` |
| `POST /signoff/close-checklist/execute` | Locks the period; books become immutable |

```json
{"checklist_id": 4, "period_start": "2026-08-01", "period_end": "2026-08-31",
 "status": "open", "done": 2, "total": 7,
 "steps": [{"step": "reconcile_bank", "completed": true, "verified": true,
            "detail": "...", "completed_at": "2026-08-30T10:00:00"}]}
```

`execute` returns:

```json
{"message": "Period locked — books are now immutable.",
 "period": "...", "closing_entry_ids": [...], "locked": true}
```

Marking a step complete does not make it true — `verified` is recomputed from
the ledger on every read. Trust `verified`, not `completed`.

---

## Failure cases specific to review

| Status | Body | Cause |
|---|---|---|
| 400 | `{"detail": "not yet signed off"}` | Certificate requested too early |
| 400 | (service message) | Bad checklist step |
| 403 | `{"detail": "Reviewer access required"}` | Reviewer endpoint called by a non-reviewer |
| 404 | `{"detail": "request not found"}` | Unknown id, or another tenant's request on an owner endpoint |
| 409 | `{"detail": "request is claimed, not pending"}` | Claiming a taken request |
| 409 | `{"detail": "duty-separation: the requester cannot claim their own review request"}` | Self-claim |
| 409 | `{"detail": "duty-separation: the reviewer cannot approve the quote"}` | Reviewer approving their own quote |
| 409 | `{"detail": "duty-separation: the requester cannot sign off their own review"}` | Self sign-off |
| 409 | `{"detail": "you already have 5 open engagements (limit 5) — finish or release one first"}` | Claim cap |
| 409 | `{"detail": "quote must be at least $50.00 (marketplace minimum)"}` | Quote below floor |
| 409 | `{"detail": "nothing to approve (billing is unbilled)"}` | Approve before a quote exists |
| 409 | `{"detail": "not your request"}` / `"not claimed by this accountant"` | Acting on someone else's engagement |
| 409 | `{"detail": "already signed off"}` | Terminal state |
| 409 | `{"detail": "sign-off failed: could not lock the accounting period — ..."}` | Period lock failed; the sign-off was rolled back |

---

## Typical integration sequence

### As the client (owner side)

1. `POST /signoff/request` with a description that tells a reviewer what they
   are taking on. Keep `id`.
2. Poll `GET /signoff/requests` until the entry shows
   `billing_status: "quoted"` with a `quote_cents` you accept.
3. `POST /signoff/quote/{id}/approve`. **This grants the reviewer access** —
   do not call it until you mean to.
4. Poll until `status` is `signed_off` or `rejected`. If `rejected`, read
   `notes_to_owner`, fix, and submit a fresh request.
5. `GET /signoff/certificate/{id}` for the verification record. Note that
   sign-off locked the period: postings dated inside it are now refused.

### As the reviewer (or a reviewer's assistant)

1. `GET /signoff/queue`. Pick a `pending` request. You have no financial data
   at this point and should not act as though you do.
2. `POST /signoff/claim/{id}` (you may hold at most 5 open engagements).
3. Scope the job from `description`, then `PUT /signoff/quote/{id}` with
   `quote_cents` ≥ 5000.
4. **Wait.** Poll `GET /signoff/my-reviews` until `billing_status` is
   `approved`. Only now are the client's books readable.
5. `POST /signoff/review/{id}` → `reviewing`. Do the work using the normal API
   against the client tenant — [/docs/reports.md](/docs/reports.md),
   [/docs/bank-imports.md](/docs/bank-imports.md).
6. `PUT /signoff/findings/{id}` with the full findings text and the count of
   adjustments. Re-send the complete text on each update.
7. `POST /signoff/sign/{id}` to verify and lock, or
   `POST /signoff/reject/{id}` with a `reason`. Either way your access to the
   client's books ends at that moment — finish reading before you call it.
