# Partial & Installment Payment Recording

**Date:** 2026-05-31  
**Status:** Draft  
**Spec:** 80  
**Tier:** All tiers  
**Depends on:** `invoices-core`, `billing-module`, `payment-gateway-adapters`, `foundation-auth-rbac`  
**Referenced by:** `invoices-core`, `payment-gateway-adapters`, `invoice-receipt-document`, `payment-reconciliation`

---

## Overview

Mechanism to record multiple partial payments against a single invoice. Today spec 15 has a single-payment model (PAID / not PAID). This spec adds: an `invoice_payments` table, a new `PARTIALLY_PAID` status, a staff UI for recording manual payments, and automatic status management as payments accumulate toward the total.

---

## Status Extension

Current enum: `DRAFT | SENT | APPROVED | TAX_ISSUED | PAID | REJECTED`

Extended (spec 15 delta): `DRAFT | SENT | APPROVED | TAX_ISSUED | PARTIALLY_PAID | PAID | REJECTED`

- `PARTIALLY_PAID`: at least one payment recorded, `amount_paid < total`
- `PAID`: `amount_paid >= total` (auto-transitions from TAX_ISSUED or PARTIALLY_PAID)
- Reminders (spec 79) treat `PARTIALLY_PAID` same as `TAX_ISSUED` (still sending for balance)

---

## Data Model

```sql
-- invoices.amount_paid is owned by invoices-core (wave 6); consumed here, not re-added.
-- Updated in the same transaction as each payment record insert.

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',  -- 'manual' | 'gateway' | 'bank_transfer' | 'auto_billing'
  reference       TEXT,                            -- bank ref, cheque number, gateway tx id
  recorded_by     UUID REFERENCES users(id),       -- NULL for gateway-auto
  note            TEXT,
  created_at      TIMESTAMPTZ 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);
```

---

## Invoice Balance Logic

On each `invoice_payments` insert/delete (same transaction):
```sql
-- CTE prevents CASE from reading pre-update amount_paid
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),
  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  -- preserve TAX_ISSUED and other states on payment deletion
  END,
  paid_at = CASE
    WHEN (SELECT total_paid FROM new_amount) >= total THEN now()
    ELSE NULL
  END
WHERE id = :invoiceId;
```

`PARTIALLY_PAID` cross-spec note: spec 53 (payment UX) treats it like `TAX_ISSUED` (payment button visible); spec 49 (gateway) processes payment against outstanding balance; spec 26 (portal) shows partial payment status to customer with balance due.

---

## Invoice Detail UI Extension

`/invoices/:id` — existing invoice detail gains "Payments" section below line items:

```
┌──────────────────────────────────────────────────────────────┐
│  Payments                                                    │
│                                                              │
│  Date          Amount    Source       Ref        By       │  │
│  ─────────────────────────────────────────────────────────── │
│  2026-05-20   ₪5,000   Bank transfer  TXN-001  Alex K. [⋯] │
│  2026-05-27   ₪3,000   Manual         —        Dana L.  [⋯] │
│                                        ──────────────────── │
│  Total paid:  ₪8,000              Balance: ₪4,700          │
│                                                              │
│  [+ Record payment]                                          │
└──────────────────────────────────────────────────────────────┘
```

Per-row `[⋯]` menu → **[Reverse payment]** (correct a mis-keyed entry). Disabled with a tooltip ("A receipt was issued for this payment — void the receipt first") when the row has an issued receipt (`invoice_payments.receipt_id IS NOT NULL`, spec 179). Choosing it opens a confirm dialog:

```
┌──────────────────────────────────────────────────────────────┐
│  Reverse this payment?                                       │
│  ₪3,000 · Manual · 2026-05-27 · recorded by Dana L.         │
│  The payment record is removed and the invoice balance       │
│  recalculated. This cannot be undone.                        │
│  Reason  [mis-keyed amount___________________]               │
│  [Cancel]                              [Reverse payment]     │
└──────────────────────────────────────────────────────────────┘
```

Confirming calls `DELETE /api/invoices/:id/payments/:paymentId`, which recomputes `amount_paid` + status and writes an audit entry. When a payment is reversed, the invoice may drop back from `PAID` to `PARTIALLY_PAID` (or to its prior `TAX_ISSUED`/`SENT` state if it falls to zero).

Badge on invoice header: `PARTIALLY PAID • ₪8,000 of ₪12,700`

---

## Record Payment Modal

```
┌──────────────────────────────────────────────────────────────┐
│  Record payment — Invoice #INV-0042                          │
│                                                              │
│  Amount *      ₪ [4,700____]   (balance: ₪4,700)           │
│  Date *        [2026-05-31_]                                 │
│  Source        [Bank transfer ▾]                             │
│                Manual / Bank transfer / Cheque / Other       │
│  Reference     [________________________]                    │
│  Note          [________________________]                    │
│                                                              │
│  [Cancel]                        [Record payment]            │
└──────────────────────────────────────────────────────────────┘
```

Amount defaults to remaining balance. If amount entered = balance → invoice transitions to PAID immediately.

---

## Overpayment Handling

When recorded `amount > remaining balance` (i.e. `amount_paid` would exceed `invoices.total`):

### Detection

```ts
// In POST /api/invoices/:id/payments handler
const overpayment = (existingAmountPaid + recordedAmount) - invoice.total
if (overpayment > 0) {
  // Overpayment scenario
}
```

### Behavior

1. **Manual payment recording (staff):** The system accepts the payment and marks the invoice `PAID`. The overpayment amount is stored in `invoices.overpayment_amount`. A warning toast appears: "Invoice overpaid by ₪{overpaymentAmount}." Staff can then either:
   - Issue a **credit note** for the overpaid amount (creates a negative-total invoice linked via `parent_invoice_id`) — button shown in invoice detail when `overpayment_amount > 0`
   - Leave as-is (common when customer rounds up or sends slightly more)

2. **Gateway payment:** Same logic. If gateway reports amount > invoice total, invoice transitions to PAID and `overpayment_amount` is recorded. A notification (`payment_received`) is sent to staff with overpayment flag.

### Schema delta

```sql
ALTER TABLE invoices ADD COLUMN overpayment_amount NUMERIC(12,2) DEFAULT 0;
-- Populated when amount_paid > total; zero otherwise
-- Credit note creation clears this to 0 once the overpayment is formally reconciled
```

### Invoice detail UI — overpayment banner

```
┌─────────────────────────────────────────────────────────────────────┐
│  ⚠ Overpaid by ₪300.00                                              │
│  This invoice was paid in excess of its total amount.               │
│  [Issue credit note for ₪300.00]   [Dismiss]                        │
└─────────────────────────────────────────────────────────────────────┘
```

Shown when `invoices.overpayment_amount > 0`. Dismissing just hides the banner (no DB change); issuing the credit note resets `overpayment_amount = 0`.

---

## Gateway Integration

Spec 49 (payment-gateway-adapters) webhook on successful payment: `POST /api/invoices/:id/payments` with `source='gateway'`, `reference=gatewayTransactionId`. Auto-recorded, no staff action needed.

---

## API

```
GET  /api/invoices/:id/payments
     → list payments for invoice
       returns: { payments: [...], amountPaid, balance, total }

POST /api/invoices/:id/payments
     → record a payment
       body: { amount, paidAt, source, reference?, note? }
       Requires: invoices:write
       Side effect: updates invoices.amount_paid + status in same transaction

DELETE /api/invoices/:id/payments/:paymentId
     → reverse / void a payment record (correction path for a mis-keyed entry)
       body: { reason?: string }
       Requires: invoices:write + OWNER or ADMIN
       Guard: rejected (HTTP 422) when a receipt was issued for this payment
              (invoice_payments.receipt_id IS NOT NULL, spec 179) — the receipt
              must be voided first (POST /api/receipts/:id/void), which itself
              reverses the linked payment. Prevents a live קבלה pointing at a
              deleted payment row.
       Side effect: recomputes amount_paid + status in the same transaction
                    (per Invoice Balance Logic above); writes an audit entry
                    capturing the reason and the reversed amount.
```

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Separate `invoice_payments` table | Not a `payments` column on invoices | Multiple payments per invoice; history required; gateway and manual coexist |
| `amount_paid` denorm on invoices | Not always SUM-queried | Every invoice list query needs balance; SUM on every list load is O(n×m) |
| `PARTIALLY_PAID` status | Not external flag | Status is used for display, reminders (spec 79), and portal (spec 26); status must be accurate |
| Gateway writes same endpoint | Not a separate gateway-payment API | Single source of truth; gateway webhook calls same `POST /payments` as staff; source field distinguishes |
| Reverse = delete the row, not a negative payment | `DELETE` recomputes balance | A mis-keyed entry has no legitimate accounting trace; removing it and recomputing `amount_paid` is the correct correction. Audit log preserves the history. |
| Block reversal when a receipt exists | Guard on `receipt_id` | A receipt (קבלה) is a legal document tied to the payment; deleting the payment under a live receipt would orphan it. The receipt must be voided first, which reverses the payment atomically (spec 179). |
