# Partial & Installment Payment Recording — Implementation Plan

**Spec:** docs/specs/2026-05-31-partial-payment-recording.md  ·  **Slug:** partial-payment-recording  ·  **Wave:** 11
**Depends on:** billing-module, foundation-auth-rbac, invoices-core, payment-gateway-adapters

## Goal
Add the ability to record multiple partial payments against a single invoice. This introduces an `invoice_payments` ledger table, the denormalized `overpayment_amount` column on `invoices` (the `amount_paid` denorm column is a base column owned by `invoices-core`, maintained here), a new `PARTIALLY_PAID` status (already present in the invoices status CHECK from invoices-core), automatic invoice status/balance recomputation in the same transaction as each payment insert/delete, a staff "Payments" UI section on the invoice detail page with a Record-Payment modal and per-row reversal, overpayment detection with a credit-note path, and a single canonical payment-recording service that both staff and the payment gateway webhook (spec 49) call so there is one source of truth.

## Architecture
The feature is centered on a new `invoice_payments` table (UUID PK, `tenant_id`/`invoice_id`/`recorded_by` UUID FKs) plus the denormalized `overpayment_amount` column added to the existing upstream `invoices` table (`amount_paid` is a base column owned by `invoices-core`, maintained here). A shared service `recordInvoicePayment` / `reverseInvoicePayment` (in `@zync/db` invoice payments module) performs the insert/delete AND the balance+status recompute inside one Drizzle transaction, using the CTE-based UPDATE from the spec to avoid reading the pre-update `amount_paid`. The Hono API (`apps/zync-api`) exposes `GET/POST/DELETE /api/invoices/:id/payments`, guarded by `requirePermission('invoices:write')` and (for DELETE) an OWNER/ADMIN role check; every mutation calls `logAuditEvent(ctx, event)` (tenant-audit-log) inside the same transaction and `createNotification`/`deliverNotification` (system-communications-notifications) for the `payment_received` notification. The gateway webhook from payment-gateway-adapters is refactored to call `recordInvoicePayment` with `source='gateway'` instead of writing `invoices.status` directly, so partial gateway payments are handled identically. The React invoice detail page (`apps/zync-app`) gains a Payments section, Record-Payment modal, reverse-payment confirm dialog, header status badge, and an overpayment banner. All money rendered through the existing `Intl.NumberFormat` ILS pattern from invoices-core; UI consumes `serializeInvoice` extended with the new balance fields.

Upstream consumed: tables `invoices`, `invoice_lines`, `tenants`, `users`; exports `serializeInvoice`, `InvoiceStatus`, `InvoiceObject`, `tenantQuery`, `requirePermission`, `authMiddleware`, `buildPaginated`, `createDb`/`DB`, `logAuditEvent`, `createNotification`, `deliverNotification`, `Button`, `Dialog`, `Sheet`, `Table`, `DataTable`, `Form`, `Input`, `Select`, `Badge`, `Alert`, `toast`, `EmptyState`, `useDirection`. The `receipt_id` column on `invoice_payments` referenced for the reversal guard is owned by spec 179 (invoice-receipt-document); this plan creates the column as nullable so the guard compiles, and spec 179 wires the FK target.

## Tech Stack
- `packages/db` (`@zync/db`): Drizzle schema for `invoice_payments`, ALTER columns on `invoices`, and the `recordInvoicePayment` / `reverseInvoicePayment` / `listInvoicePayments` / `getInvoiceBalance` transaction helpers.
- `packages/types` (`@zync/types`): `InvoicePaymentObject`, `PaymentSource`, request/response types; extend `InvoiceObject` serialization shape with `amountPaid`, `balance`, `overpaymentAmount`.
- `apps/zync-api` (Hono on Cloudflare Workers): payment routes mounted under the existing invoices router; gateway webhook refactor.
- `apps/zync-app` (Vite + React): invoice detail Payments UI, modal, dialogs, banner, badge.
- Bindings: Neon Postgres via Hyperdrive (`DB`), `QUEUE` (audit-log-queue via `logAuditEvent`). No new bindings.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11a | Task 1 (schema migration), Task 2 (types) | `packages/db/src/schema/invoice-payments.ts`, `packages/db/migrations/*`, `packages/types/src/invoice-payment.ts` | Task 1 & 2 parallel |
| 11b | Task 3 (balance service), Task 4 (serializer) | `packages/db/src/invoice-payments.ts`, `packages/db/src/invoices/serialize.ts` | Sequential after 11a |
| 11c | Task 5 (API routes), Task 6 (gateway webhook refactor) | `apps/zync-api/src/routes/invoice-payments.ts`, `apps/zync-api/src/webhooks/payment.ts` | Parallel after Task 3 |
| 11d | Task 7 (Payments section + badge), Task 8 (Record modal), Task 9 (reverse dialog), Task 10 (overpayment banner) | `apps/zync-app/src/features/invoices/*` | Parallel after Task 4/5 |

## Tasks

### Task 1: Database schema — `invoice_payments` table + `invoices` balance columns
**Blocks:** 3, 4, 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/invoice-payments.ts`
- Modify: `packages/db/src/schema/invoices.ts` (add columns to existing invoices table definition)
- Create: `packages/db/migrations/0080_invoice_payments.sql`
- Modify: `packages/db/src/schema/index.ts` (export new table)
**Steps:**
- [ ] Add the `overpayment_amount` column to the existing `invoices` Drizzle table (do NOT redefine the table; extend it). `amount_paid` is already on the `invoices` definition (owned by invoices-core); do NOT redeclare it.
- [ ] Define the `invoice_payments` Drizzle table with all columns, the `amount > 0` CHECK, the `source` CHECK, and both indexes.
- [ ] Add the nullable `receipt_id UUID` column on `invoice_payments` (FK target owned by spec 179; create as plain nullable UUID now so the reversal guard compiles — no FK constraint added here).
- [ ] Write the raw SQL migration mirroring the Drizzle schema exactly (Postgres dialect).
- [ ] Export `invoicePayments` from the schema barrel.
**Schema / Interfaces:**
```sql
-- Balance denorm columns on the existing invoices table.
-- amount_paid is a base column owned by invoices-core (wave 6) — this plan only maintains it.
ALTER TABLE invoices ADD COLUMN overpayment_amount NUMERIC(12,2) NOT NULL DEFAULT 0;

CREATE TABLE invoice_payments (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    UUID NOT NULL REFERENCES tenants(id),
  invoice_id   UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
  amount       NUMERIC(12,2) NOT NULL CHECK (amount > 0),
  currency     TEXT NOT NULL DEFAULT 'ILS',
  paid_at      TIMESTAMPTZ NOT NULL,
  source       TEXT NOT NULL DEFAULT 'manual'
                 CHECK (source IN ('manual', 'gateway', 'bank_transfer', 'auto_billing')),
  reference    TEXT,
  recorded_by  UUID REFERENCES users(id),
  note         TEXT,
  receipt_id   UUID,                       -- nullable; FK target wired by spec 179 (invoice-receipt-document)
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_invoice_payments_invoice ON invoice_payments(invoice_id);
CREATE INDEX idx_invoice_payments_tenant  ON invoice_payments(tenant_id, paid_at DESC);
```
**Acceptance:**
- [ ] `pnpm --filter @zync/db drizzle:generate` produces a migration matching `0080_invoice_payments.sql`; `pnpm --filter @zync/db typecheck` passes.
- [ ] Every FK is UUID→UUID; `source` enum CHECK matches the spec's four values; `amount > 0` CHECK present.

### Task 2: Types — `InvoicePaymentObject`, `PaymentSource`, request/response shapes
**Blocks:** 3, 4, 5, 7, 8  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/invoice-payment.ts`
- Modify: `packages/types/src/index.ts` (re-export)
- Modify: `packages/types/src/invoice.ts` (extend `InvoiceObject` balance fields)
**Steps:**
- [ ] Define `PaymentSource` union and `InvoicePaymentObject`.
- [ ] Define the `GET /payments` response shape and the `POST`/`DELETE` body shapes.
- [ ] Extend `InvoiceObject` with `amountPaid`, `balance`, `overpaymentAmount` (numbers).
- [ ] Re-export all new symbols from the package barrel.
**Schema / Interfaces:**
```ts
export type PaymentSource = 'manual' | 'gateway' | 'bank_transfer' | 'auto_billing';

export interface InvoicePaymentObject {
  id: string;
  invoiceId: string;
  amount: number;
  currency: string;
  paidAt: string;          // ISO-8601
  source: PaymentSource;
  reference: string | null;
  recordedBy: string | null;
  recordedByName: string | null;
  note: string | null;
  receiptId: string | null; // non-null => reversal blocked (spec 179)
  createdAt: string;
}

export interface InvoicePaymentsResponse {
  payments: InvoicePaymentObject[];
  amountPaid: number;
  balance: number;          // total - amountPaid (clamped >= 0)
  total: number;
  overpaymentAmount: number;
}

export interface RecordPaymentBody {
  amount: number;
  paidAt: string;
  source: PaymentSource;
  reference?: string;
  note?: string;
}

export interface ReversePaymentBody {
  reason?: string;
}
```
**Acceptance:**
- [ ] `pnpm --filter @zync/types typecheck` passes; `InvoiceObject` exposes `amountPaid`/`balance`/`overpaymentAmount`.

### Task 3: Balance service — record / reverse / list inside one transaction
**Blocks:** 5, 6  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/db/src/invoice-payments.ts`
- Modify: `packages/db/src/index.ts` (export helpers)
**Steps:**
- [ ] Implement `listInvoicePayments(db, tenantId, invoiceId)` joining `users` for `recordedByName`, ordered by `paid_at DESC`.
- [ ] Implement `recordInvoicePayment(db, args)`: open a transaction, `INSERT` the payment row, then run the CTE-based balance recompute UPDATE, then set `overpayment_amount`, then return the refreshed invoice balance. Pass the same `tx` to `logAuditEvent` so the audit write is in-transaction (`require-audit-in-transaction`).
- [ ] Implement `reverseInvoicePayment(db, args)`: in a transaction, re-read the target payment row; if `receipt_id IS NOT NULL` throw `ReceiptIssuedError` (maps to HTTP 422); else `DELETE` the row, run the same recompute UPDATE, reset `overpayment_amount`, write the audit entry capturing `reason` and reversed amount, return refreshed balance.
- [ ] Implement `getInvoiceBalance(db, tenantId, invoiceId)` returning `{ total, amountPaid, balance, overpaymentAmount, status }`.
- [ ] Use `tenantQuery` scoping for all reads; never raw Drizzle from routes (`no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```ts
export class ReceiptIssuedError extends Error {}        // -> 422 in route layer

export async function recordInvoicePayment(db: DB, args: {
  tenantId: string; invoiceId: string; amount: number; paidAt: string;
  source: PaymentSource; reference?: string; note?: string; recordedBy: string | null;
  ctx: AppContext;                                       // for in-tx logAuditEvent
}): Promise<{ amountPaid: number; balance: number; overpaymentAmount: number; status: InvoiceStatus; paymentId: string; }>;

export async function reverseInvoicePayment(db: DB, args: {
  tenantId: string; invoiceId: string; paymentId: string; reason?: string; ctx: AppContext;
}): Promise<{ amountPaid: number; balance: number; overpaymentAmount: number; status: InvoiceStatus; }>;

export async function listInvoicePayments(db: DB, tenantId: string, invoiceId: string): Promise<InvoicePaymentObject[]>;
export async function getInvoiceBalance(db: DB, tenantId: string, invoiceId: string): Promise<{ total: number; amountPaid: number; balance: number; overpaymentAmount: number; status: InvoiceStatus; }>;
```
Balance recompute (run inside the same transaction, after INSERT or DELETE):
```sql
WITH new_amount AS (
  SELECT COALESCE(SUM(amount), 0) AS total_paid
  FROM invoice_payments
  WHERE invoice_id = :invoiceId
)
UPDATE invoices
SET
  amount_paid = (SELECT total_paid FROM new_amount),
  overpayment_amount = GREATEST((SELECT total_paid FROM new_amount) - total, 0),
  status = CASE
    WHEN (SELECT total_paid FROM new_amount) >= total THEN 'PAID'
    WHEN (SELECT total_paid FROM new_amount) > 0      THEN 'PARTIALLY_PAID'
    ELSE status
  END,
  paid_at = CASE
    WHEN (SELECT total_paid FROM new_amount) >= total THEN now()
    ELSE NULL
  END
WHERE id = :invoiceId AND tenant_id = :tenantId;
```
**Acceptance:**
- [ ] Recording payments summing to `total` sets status `PAID`; a partial sets `PARTIALLY_PAID`; reversing the last payment back to zero preserves the prior `TAX_ISSUED`/`SENT` status (ELSE branch).
- [ ] `recordInvoicePayment` exceeding `total` sets `overpayment_amount = amount_paid - total` and status `PAID`.
- [ ] `reverseInvoicePayment` on a row with non-null `receipt_id` throws `ReceiptIssuedError`; audit row written in the same transaction for both record and reverse.

### Task 4: Serializer — extend `serializeInvoice` with balance fields
**Blocks:** 7, 8, 10  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `packages/db/src/invoices/serialize.ts` (the `serializeInvoice` function from invoices-core)
**Steps:**
- [ ] Map `amount_paid` → `amountPaid`, compute `balance = max(total - amount_paid, 0)`, map `overpayment_amount` → `overpaymentAmount` onto the serialized object.
- [ ] Keep all existing `InvoiceObject` fields unchanged.
**Acceptance:**
- [ ] `serializeInvoice` output includes `amountPaid`, `balance`, `overpaymentAmount`; existing invoices-core consumers still typecheck.

### Task 5: API routes — `GET/POST/DELETE /api/invoices/:id/payments`
**Blocks:** 7, 8, 9  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/invoice-payments.ts`
- Modify: `apps/zync-api/src/routes/invoices.ts` (mount sub-router)
- Create: `apps/zync-api/src/routes/invoice-payments.schema.ts` (zod)
**Steps:**
- [ ] Define zod schemas for `RecordPaymentBody` (amount positive, paidAt ISO date, source enum, optional reference/note) and `ReversePaymentBody` (`require-zod-validation-in-routes`).
- [ ] `GET /api/invoices/:id/payments` → `requirePermission('invoices:write')`; call `listInvoicePayments` + `getInvoiceBalance`; return `InvoicePaymentsResponse`.
- [ ] `POST /api/invoices/:id/payments` → `requirePermission('invoices:write')`; validate body; call `recordInvoicePayment` with `recordedBy = ctx.user.id`; if returned `overpaymentAmount > 0` enqueue `createNotification` of type `payment_received` with an overpayment flag and `deliverNotification`; return updated balance.
- [ ] `DELETE /api/invoices/:id/payments/:paymentId` → `requirePermission('invoices:write')` AND role check `OWNER` or `ADMIN` (else 403); call `reverseInvoicePayment`; map `ReceiptIssuedError` → HTTP 422 with message referencing voiding the receipt first; return updated balance.
- [ ] All handlers scope by `tenantId` from the session; use `tenantQuery`; no raw Drizzle in handlers.
**Schema / Interfaces:**
```
GET    /api/invoices/:id/payments              -> InvoicePaymentsResponse            (invoices:write)
POST   /api/invoices/:id/payments              body RecordPaymentBody -> balance     (invoices:write)
DELETE /api/invoices/:id/payments/:paymentId   body ReversePaymentBody -> balance    (invoices:write + OWNER|ADMIN)
                                               -> 422 when receipt_id IS NOT NULL
```
**Acceptance:**
- [ ] POST with `amount = balance` returns status `PAID`; with `amount < balance` returns `PARTIALLY_PAID`.
- [ ] DELETE as a non-OWNER/ADMIN returns 403; DELETE on a receipted payment returns 422; successful DELETE returns recomputed balance and writes an audit entry.
- [ ] Overpaying POST emits a `payment_received` notification carrying the overpayment flag.

### Task 6: Gateway webhook refactor — route gateway payments through `recordInvoicePayment`
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/src/webhooks/payment.ts` (the `POST /webhooks/payment/:gateway` handler from payment-gateway-adapters)
**Steps:**
- [ ] Replace the direct `UPDATE invoices SET status='PAID'` write with a call to `recordInvoicePayment({ source: 'gateway', reference: gatewayTransactionId, recordedBy: null, amount: webhookAmount, paidAt: webhookPaidAt })`.
- [ ] Preserve existing idempotency: before recording, check `invoice_payment_sessions.status` + `session_id` so webhook re-delivery does not double-insert a payment (skip if already recorded for that session/reference).
- [ ] Keep the existing `invoice.paid` outbound-webhook enqueue and customer receipt email; gate `invoice.paid` enqueue on the returned status being `PAID` (a partial gateway payment must not fire `invoice.paid`).
- [ ] On returned `overpaymentAmount > 0`, send the staff `payment_received` notification with the overpayment flag (same path as Task 5).
**Acceptance:**
- [ ] A gateway webhook for the full balance produces a `gateway`-source `invoice_payments` row and `PAID` status; a webhook for less than the balance produces `PARTIALLY_PAID` and does NOT enqueue `invoice.paid`.
- [ ] Re-delivered webhook with the same `session_id` does not create a duplicate payment row.

### Task 7: Invoice detail — Payments section + header status badge
**Blocks:** —  ·  **Blocked by:** 4, 5
**Files:**
- Create: `apps/zync-app/src/features/invoices/PaymentsSection.tsx`
- Modify: `apps/zync-app/src/features/invoices/InvoiceDetail.tsx`
- Create: `apps/zync-app/src/features/invoices/useInvoicePayments.ts` (react-query hook)
**Steps:**
- [ ] `useInvoicePayments(invoiceId)` queries `GET /api/invoices/:id/payments`; expose `payments`, `amountPaid`, `balance`, `total`, `overpaymentAmount`.
- [ ] Render the Payments `Table` below line items: columns Date, Amount (ILS via `Intl.NumberFormat`), Source, Reference, By; footer rows "Total paid" and "Balance".
- [ ] Per-row `[⋯]` `DropdownMenu` with "Reverse payment"; disabled with a `Tooltip` ("A receipt was issued for this payment — void the receipt first") when `payment.receiptId !== null`.
- [ ] `[+ Record payment]` button opens the modal from Task 8.
- [ ] Header `Badge`: when status `PARTIALLY_PAID` show `PARTIALLY PAID • {amountPaid} of {total}` (ILS-formatted, both LTR and RTL via `useDirection`).
- [ ] Use `EmptyState` when there are no payments yet.
**Acceptance:**
- [ ] Payments table renders rows with correct totals/balance; reverse action disabled with tooltip when `receiptId` is set; partial-paid badge shows the "X of Y" balance; RTL layout mirrors correctly.

### Task 8: Record Payment modal
**Blocks:** —  ·  **Blocked by:** 5, 7
**Files:**
- Create: `apps/zync-app/src/features/invoices/RecordPaymentModal.tsx`
**Steps:**
- [ ] Build a `Dialog`/`Sheet` `Form` with fields: Amount (required, defaults to remaining `balance`), Date (required, defaults today), Source `Select` (Manual / Bank transfer / Cheque / Other — map "Cheque"/"Other" to `bank_transfer`/`manual` source values as the spec's four DB enums allow; label set matches the spec modal), Reference, Note.
- [ ] Submit `POST /api/invoices/:id/payments`; on success invalidate the payments query and the invoice query.
- [ ] If submitted amount exceeds balance, allow submit (overpayment is valid) and on success show the warning toast "Invoice overpaid by ₪{overpaymentAmount}." using `toast`.
- [ ] Show inline `FormError` from zod/server validation; disable submit while pending.
**Acceptance:**
- [ ] Amount pre-fills with balance; submitting equal to balance flips the invoice to PAID in the UI without reload; overpayment shows the warning toast; validation errors surface inline.

### Task 9: Reverse Payment confirm dialog
**Blocks:** —  ·  **Blocked by:** 5, 7
**Files:**
- Create: `apps/zync-app/src/features/invoices/ReversePaymentDialog.tsx`
**Steps:**
- [ ] `Dialog` summarizing the payment (amount · source · date · recorded-by), a Reason `Input`, and "This cannot be undone." copy.
- [ ] Confirm calls `DELETE /api/invoices/:id/payments/:paymentId` with `{ reason }`; on 422 show the receipt-issued error toast; on success invalidate payments + invoice queries.
- [ ] Reflect the possible status drop (`PAID` → `PARTIALLY_PAID`, or back to prior state at zero) by re-rendering from the refreshed invoice query.
**Acceptance:**
- [ ] Confirming removes the row and recomputes balance/status in the UI; a 422 from a receipted payment shows the guard message and leaves the row intact.

### Task 10: Overpayment banner + credit-note action
**Blocks:** —  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/invoices/OverpaymentBanner.tsx`
- Modify: `apps/zync-app/src/features/invoices/InvoiceDetail.tsx`
**Steps:**
- [ ] Render an `Alert` (warning, `role="alert"`) when `invoice.overpaymentAmount > 0`: "⚠ Overpaid by ₪{overpaymentAmount}." with actions `[Issue credit note for ₪{overpaymentAmount}]` and `[Dismiss]`.
- [ ] "Issue credit note" calls the existing invoices-core `POST /api/invoices/:id/credit-note` to create the negative-total invoice linked via `parent_invoice_id`; on success the server resets `overpayment_amount = 0` and the banner disappears on refetch.
- [ ] "Dismiss" hides the banner client-side only (no DB change) for the current view.
- [ ] Respect `prefers-reduced-motion` for any banner entrance animation.
**Acceptance:**
- [ ] Banner appears only when `overpaymentAmount > 0`; issuing a credit note clears it after refetch; dismiss hides it without a DB write; banner carries an `alert` aria role.
