# Expense Personal/Business Split — Implementation Plan

**Spec:** docs/specs/2026-05-31-expense-personal-business-split.md  ·  **Slug:** expense-personal-business-split  ·  **Wave:** 5
**Depends on:** expenses-module, foundation-auth-rbac

## Goal
Add a per-expense business/personal split so a single receipt can be partly business and partly personal (e.g. a ₪500 dinner where ₪150 is personal). A new `business_percent` integer column (0–100, default 100) is stored on the existing `expenses` table; the business and personal portions are computed at query time. All financial calculations (profitability, project cost, reimbursement claims) use the business portion only; the personal portion is excluded from every financial figure. The expense create/edit form gains a slider + presets to set the split, and the list shows a split badge for non-100% rows.

## Architecture
This is a delta on the `expenses` table owned by **expenses-module**. It adds exactly one stored column (`business_percent`) and changes no other stored columns. The business amount is **derived, never stored**: `business_amount = invoice_total * business_percent / 100.0` and `personal_amount = invoice_total - business_amount`, computed in SQL at read time and in TypeScript serializers at the API boundary.

Data flow:
- **Write path:** `POST /api/expenses` and `PATCH /api/expenses/:id` (both owned by expenses-module) accept an optional `business_percent`. This plan extends the existing Zod schemas and Drizzle insert/update calls in those handlers; it does not create new write routes. Per-diem creation (`POST /api/expenses/per-diem`) defaults to 100.
- **Read path:** `GET /api/expenses`, `GET /api/expenses/:id`, and all report endpoints return `business_percent` and the computed `business_amount` / `personal_amount`. The list/report SQL switches `SUM(amount)`-style aggregates to `SUM(amount * business_percent / 100.0)` for business figures.
- **Reporting consumers:** `profitability-reports` (downstream, Referenced-by) reads `business_percent` to scope project cost to business use only; this plan exposes the column and a shared helper so that consumer can use it.

Upstream consumed (exact names): the `expenses` table and its `amount` / `invoice_total` / `currency` columns; `tenantQuery` and `systemQuery` from `@zync/db`; `requirePermission` and the `expenses:read` / `expenses:write` / `reports:read` permissions from `@zync/auth`; `Expense` type, `serializeExpense` (expenses-module serializer), `ExpenseListResponse`, and the existing `createExpenseSchema` / `updateExpenseSchema` Zod schemas; `Slider`/`Input`/`Badge`/`Tooltip` from `@zync/ui`; `LocaleProvider` / `translations` from `@zync/config` for i18n labels; `useDirection` for RTL.

## Tech Stack
- **DB:** Neon Postgres via Cloudflare Hyperdrive, Drizzle ORM. Migration adds one column to `expenses`.
- **API:** Hono routes in `apps/zync-api` (expenses router, owned by expenses-module) — extended here.
- **Types:** `@zync/types` (Expense type, split helper).
- **UI:** Vite + React app `apps/zync-app` (expense form, expense list), `@zync/ui` primitives (`Slider`, `Input`, `Badge`, `Tooltip`).
- **i18n / RTL:** `@zync/config` translations; `useDirection`. Slider must be keyboard-operable and announce its value (a11y, see cross-cutting). `prefers-reduced-motion`: slider snap on preset click must not animate when reduced-motion is set.
- No new Cloudflare bindings.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 5.a | Task 1 (schema migration), Task 2 (types + split helper) | `packages/db/src/schema/expenses.ts`, `packages/db/migrations/*`, `packages/types/src/expense.ts`, `packages/types/src/expense-split.ts` | Task 1 and Task 2 parallel |
| 5.b | Task 3 (write API), Task 4 (read API + serializer), Task 5 (report SQL) | `apps/zync-api/src/routes/expenses.ts`, `apps/zync-api/src/services/expenses/*`, `apps/zync-api/src/services/expenses/reports.ts` | After 5.a; Task 3/4/5 mostly parallel (shared file → coordinate) |
| 5.c | Task 6 (split form field), Task 7 (list badge/columns) | `apps/zync-app/src/features/expenses/ExpenseForm.tsx`, `apps/zync-app/src/features/expenses/SplitField.tsx`, `apps/zync-app/src/features/expenses/ExpenseListTable.tsx` | After 5.b; Task 6/7 parallel |
| 5.d | Task 8 (reimbursement claim note), Task 9 (i18n strings) | reimbursement export module, `packages/config/src/translations/*` | After 5.c; parallel |

## Tasks

### Task 1: Add `business_percent` column to `expenses` (schema + migration)
**Blocks:** Task 3, Task 4, Task 5  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/expenses.ts` (Drizzle table definition for `expenses`)
- Create: `packages/db/migrations/<timestamp>_expense_business_percent.sql`
**Steps:**
- [ ] Add the `business_percent` column to the Drizzle `expenses` table definition: `businessPercent: integer('business_percent').notNull().default(100)`.
- [ ] Add a Drizzle check constraint (or raw SQL in the migration) enforcing the 0–100 range.
- [ ] Write the forward migration SQL (DDL below). Existing rows backfill to 100 via the `DEFAULT`, preserving current behavior.
- [ ] Confirm no other column is altered; this is an additive migration only.
**Schema / Interfaces:**
```sql
ALTER TABLE expenses
  ADD COLUMN business_percent INTEGER NOT NULL DEFAULT 100
  CHECK (business_percent >= 0 AND business_percent <= 100);
-- Split between business and personal use.
-- 100 = fully business (default), 0 = fully personal, 70 = 70% business.
-- Existing expenses default to 100 (no change in behavior).
```
Drizzle column:
```ts
businessPercent: integer('business_percent').notNull().default(100),
// + table-level: check('expenses_business_percent_range',
//     sql`business_percent >= 0 AND business_percent <= 100`)
```
**Acceptance:**
- [ ] Migration applies cleanly on a DB with existing `expenses` rows; all pre-existing rows have `business_percent = 100`.
- [ ] Inserting a row with `business_percent = -1` or `101` is rejected by the CHECK constraint.

### Task 2: `Expense` type extension + shared split helper
**Blocks:** Task 3, Task 4, Task 5, Task 6, Task 7  ·  **Blocked by:** —
**Files:**
- Modify: `packages/types/src/expense.ts` (the `Expense` interface / type)
- Create: `packages/types/src/expense-split.ts`
- Modify: `packages/types/src/index.ts` (export the helper + new fields)
**Steps:**
- [ ] Add `business_percent: number` (integer 0–100) to the `Expense` type and to the serialized expense response type (e.g. `ExpenseObject`).
- [ ] Add computed read-only fields to the serialized type: `business_amount: number` and `personal_amount: number`.
- [ ] Create `computeSplit(invoiceTotal, businessPercent)` returning `{ businessAmount, personalAmount }`, rounding to 2 decimals (currency precision). Personal = total − business so the two always sum exactly to the total (avoid independent rounding drift).
- [ ] Export `computeSplit` from the package index.
**Schema / Interfaces:**
```ts
// packages/types/src/expense.ts (additions to existing Expense type)
business_percent: number; // integer 0–100, default 100

// serialized expense response additions (read-only, derived)
business_amount: number;  // invoice_total * business_percent / 100, 2dp
personal_amount: number;  // invoice_total - business_amount, 2dp

// packages/types/src/expense-split.ts
export function computeSplit(
  invoiceTotal: number,
  businessPercent: number,
): { businessAmount: number; personalAmount: number } {
  const clamped = Math.min(100, Math.max(0, Math.round(businessPercent)));
  const businessAmount = Math.round(invoiceTotal * clamped) / 100; // total*pct/100, 2dp
  const personalAmount = Math.round((invoiceTotal - businessAmount) * 100) / 100;
  return { businessAmount, personalAmount };
}
```
**Acceptance:**
- [ ] `computeSplit(500, 70)` returns `{ businessAmount: 350, personalAmount: 150 }`.
- [ ] `computeSplit(500, 100)` returns `{ businessAmount: 500, personalAmount: 0 }`; `businessAmount + personalAmount === invoiceTotal` for any input.

### Task 3: Accept `business_percent` on create/update write API
**Blocks:** Task 6  ·  **Blocked by:** Task 1, Task 2
**Files:**
- Modify: `apps/zync-api/src/routes/expenses.ts` (the `POST /api/expenses` and `PATCH /api/expenses/:id` handlers)
- Modify: `apps/zync-api/src/services/expenses/createExpense.ts` / `updateExpense.ts` (or equivalent service modules)
**Steps:**
- [ ] Extend the existing `createExpenseSchema` Zod schema with `business_percent: z.number().int().min(0).max(100).default(100).optional()`.
- [ ] Extend the existing `updateExpenseSchema` Zod schema with `business_percent: z.number().int().min(0).max(100).optional()` (no default on PATCH — omitted means unchanged).
- [ ] Pass `business_percent` through to the Drizzle insert/update inside the existing `tenantQuery` call; when omitted on create, the DB default of 100 applies.
- [ ] Keep the `requirePermission('expenses:write')` guard on both routes (unchanged).
- [ ] In `POST /api/expenses/per-diem`, set `business_percent = 100` explicitly (per-diem is fully business).
**Schema / Interfaces:**
```ts
// create body addition
business_percent: z.number().int().min(0).max(100).default(100).optional()
// patch body addition
business_percent: z.number().int().min(0).max(100).optional()
```
**Acceptance:**
- [ ] `POST /api/expenses` with `business_percent: 70` stores 70; without the field stores 100.
- [ ] `PATCH /api/expenses/:id` with `business_percent: 50` updates only that field; omitting it leaves the stored value unchanged.
- [ ] `business_percent: 150` is rejected with a 400 validation error.

### Task 4: Return `business_percent` + computed amounts on read API
**Blocks:** Task 7  ·  **Blocked by:** Task 1, Task 2
**Files:**
- Modify: `apps/zync-api/src/services/expenses/serializeExpense.ts` (the existing `serializeExpense`)
- Modify: `apps/zync-api/src/routes/expenses.ts` (`GET /api/expenses`, `GET /api/expenses/:id`)
**Steps:**
- [ ] In `serializeExpense`, include `business_percent` from the row and call `computeSplit(row.invoice_total ?? row.amount, row.business_percent)` to attach `business_amount` and `personal_amount`. Use `invoice_total` per the spec formula; when `invoice_total` is null (unprocessed), fall back to `amount` and still expose the percent.
- [ ] Ensure `GET /api/expenses` (cursor-paginated `ExpenseListResponse`) and `GET /api/expenses/:id` both return the new fields via the shared serializer — no separate code path.
- [ ] Keep `requirePermission('expenses:read')` (unchanged).
**Schema / Interfaces:**
```ts
// serializeExpense output gains:
business_percent: row.business_percent,
...computeSplit(row.invoice_total ?? row.amount ?? 0, row.business_percent),
// (mapped to business_amount / personal_amount snake_case keys)
```
**Acceptance:**
- [ ] `GET /api/expenses/:id` for an expense with `invoice_total = 500, business_percent = 70` returns `business_percent: 70, business_amount: 350, personal_amount: 150`.
- [ ] Every item in `GET /api/expenses` list response carries `business_percent`, `business_amount`, `personal_amount`.

### Task 5: Apply business-only scoping to report SQL
**Blocks:** —  ·  **Blocked by:** Task 1, Task 2
**Files:**
- Modify: `apps/zync-api/src/services/expenses/reports.ts` (expense report, vendor analysis aggregations — `GET /api/expenses/reports/expense`, `/reports/vendors`)
- Modify: `apps/zync-api/src/services/expenses/reports.ts` (VAT/PCN874 partial-input-VAT path if it aggregates expense totals)
**Steps:**
- [ ] In every report aggregate that sums an expense monetary total for **business/profitability** purposes, change `SUM(amount)` (or `SUM(invoice_total)`) to `SUM(amount * business_percent / 100.0)` so only the business portion is counted.
- [ ] Add `business_percent` (and computed `business_amount`) to the detailed expense report row output so accountants see the split.
- [ ] Do NOT alter VAT input totals by `business_percent` — VAT deductibility is governed by `vat_deductible` / `deduction_pct` (owned by expenses-module), a separate concern from personal/business split. Only the gross-amount business/profitability sums change.
- [ ] Expose a documented SQL fragment/helper (comment) so `profitability-reports` reuses the identical `SUM(amount * business_percent / 100.0)` expression.
**Schema / Interfaces:**
```sql
-- Business-portion aggregate (replaces bare SUM(amount) in profitability/cost rollups)
SUM(amount * business_percent / 100.0) AS business_amount
```
**Acceptance:**
- [ ] Expense report business-total for a tenant with one ₪500 / 70% expense reports ₪350, not ₪500.
- [ ] VAT input-VAT totals are unchanged by `business_percent` (still driven by `vat_amount` / `vat_deductible` / `deduction_pct`).

### Task 6: Split field (slider + presets) in expense create/edit form
**Blocks:** —  ·  **Blocked by:** Task 2, Task 3
**Files:**
- Create: `apps/zync-app/src/features/expenses/SplitField.tsx`
- Modify: `apps/zync-app/src/features/expenses/ExpenseForm.tsx` (wire the field into create/edit)
**Steps:**
- [ ] Build `SplitField` with a `business_percent` slider (0–100, step 1) bound to a number input; both stay in sync. Default 100.
- [ ] Render quick-select preset buttons below the slider: `100% Business`, `75/25`, `50/50`, `Custom`. Clicking a preset snaps `business_percent` to 100 / 75 / 50; `Custom` enables manual number entry. Snap must NOT animate when `prefers-reduced-motion: reduce` is set.
- [ ] Show live-calculated `Business amount: ₪{businessAmount}` and `Personal: ₪{personalAmount}` using `computeSplit(totalAmount, businessPercent)`; recompute on every slider/input change and on total-amount change.
- [ ] Style the slider track to show business portion green and personal portion red; when `business_percent < 100`, show the "Personal" label + amount in muted text.
- [ ] a11y: slider element exposes `role="slider"` semantics with `aria-valuemin="0" aria-valuemax="100" aria-valuenow="{business_percent}"` and `aria-label` (localized "Business percentage"); fully keyboard-operable (Arrow keys ±1, Home/End to 0/100). Live business/personal amount lives in a `role="status"` `aria-live="polite"` region so screen readers hear the recalculation. Use `@zync/ui` `Slider` (which should already carry these roles) where possible.
- [ ] Pass `business_percent` in the form submit body to `POST`/`PATCH /api/expenses`.
**Acceptance:**
- [ ] Dragging the slider to 70 with a ₪500 total shows "Business amount: ₪350.00" and "Personal: ₪150.00" live.
- [ ] Clicking `50/50` snaps the slider to 50; `Custom` lets the user type an arbitrary 0–100 value.
- [ ] Slider is operable by keyboard and announces its value; amount recalculation is announced via the live region.
- [ ] Submitting persists `business_percent`; reopening the edit form shows the stored value.

### Task 7: Split badge + total column in expense list
**Blocks:** —  ·  **Blocked by:** Task 2, Task 4
**Files:**
- Modify: `apps/zync-app/src/features/expenses/ExpenseListTable.tsx`
**Steps:**
- [ ] Make the primary amount column show `business_amount` (the business portion) — always.
- [ ] When `business_percent < 100`, render a second muted "of ₪{invoice_total} total" affordance with a `Tooltip` showing the full receipt total.
- [ ] When `business_percent < 100`, render a split `Badge` on the row, e.g. `70% biz`, using `@zync/ui` `Badge`. The badge text must be localized and direction-aware (RTL).
- [ ] When `business_percent === 100`, show neither the muted total nor the badge (keeps the common case clean).
- [ ] Read `business_percent`, `business_amount`, `invoice_total` straight from the list API response (Task 4); no client recomputation needed beyond display formatting.
**Acceptance:**
- [ ] A 70% expense row shows the business amount, a `70% biz` badge, and a muted "of ₪X.XX total" with tooltip.
- [ ] A 100% expense row shows only the amount — no badge, no muted total.
- [ ] Badge and total render correctly in RTL (Hebrew) layout.

### Task 8: Business-amount reimbursement claim export + split note
**Blocks:** —  ·  **Blocked by:** Task 4, Task 5
**Files:**
- Modify: the reimbursement claim PDF/export generator within the expenses reports module (`apps/zync-api/src/services/expenses/reports.ts` or the dedicated reimbursement export module)
**Steps:**
- [ ] In the reimbursement claim PDF/export, show the **business amount per expense** (`computeSplit(...).businessAmount`), not the total receipt amount.
- [ ] When `business_percent < 100`, append a per-line note: `Receipt total: ₪{invoice_total} ({business_percent}% business use)`. Localize the note (Hebrew/English) and keep it RTL-correct in the Excel/PDF output.
- [ ] When `business_percent === 100`, no note is appended.
- [ ] Ensure the claim total sums business amounts only.
**Acceptance:**
- [ ] A reimbursement export containing a ₪500 / 70% expense lists ₪350 for that line and the note "Receipt total: ₪500.00 (70% business use)".
- [ ] The claim grand total equals the sum of business amounts, excluding all personal portions.

### Task 9: i18n strings for split UI
**Blocks:** —  ·  **Blocked by:** —
**Files:**
- Modify: `packages/config/src/translations/en.ts` (or the expenses namespace file)
- Modify: `packages/config/src/translations/he.ts`
**Steps:**
- [ ] Add localized keys (English + Hebrew) for: "Business %", "Business amount", "Personal", preset labels ("100% Business", "75/25", "50/50", "Custom"), badge template "{pct}% biz", muted total template "of {total} total", and the reimbursement note "Receipt total: {total} ({pct}% business use)".
- [ ] Ensure Hebrew strings read naturally RTL and use the `₪` shekel symbol placement matching existing expense translations.
- [ ] Reference these keys from `SplitField`, `ExpenseListTable`, and the reimbursement export rather than hardcoding strings.
**Acceptance:**
- [ ] Switching locale to Hebrew renders all split UI labels and the badge in Hebrew, RTL.
- [ ] No hardcoded English split strings remain in the new components.
