# Billing Module

**Date:** 2026-05-30  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `invoices-core`, `invoices-adapters`, `customers-module`, `projects-module`  
**Referenced by:** `reports-analytics`, `settings-module`, `tenant-portals`

---

## Overview

Subscription and recurring payment management for tenant customers. Tenants configure payment terms per project or per customer; the system generates invoices automatically and records payments. Supports Israeli payment processors: Morning (Green Invoice Pay), Isracard direct debit, Upay, and iCount Pay.

This is Zync's billing of *its tenants' customers* — not Zync's own subscription billing (which is a separate internal concern).

### OS route title

OS and mobile frames MUST title `/settings/billing` and its upgrade deep link "Billing".

Rationale: preserve billing deep-link wayfinding inside the settings app frame.

---

## Data Model

```sql
payment_methods (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  customer_id UUID NOT NULL,
  type TEXT NOT NULL,                   -- 'credit_card' | 'direct_debit' | 'bank_transfer' | 'check'
  provider TEXT,                        -- 'morning' | 'isracard' | 'upay' | 'icount_pay'
  provider_token TEXT,                  -- encrypted (AES-256-GCM, INTEGRATION_ENCRYPTION_KEY)
  last_four TEXT,                       -- last 4 digits (display only)
  expiry_month INTEGER,
  expiry_year INTEGER,
  is_default BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now()
)

payment_plans (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  customer_id UUID NOT NULL,
  project_id UUID,                      -- nullable (customer-level plan)
  name TEXT NOT NULL,
  type TEXT NOT NULL,                   -- 'one_time' | 'recurring' | 'installments'
  amount NUMERIC(12,2) NOT NULL,
  currency TEXT DEFAULT 'ILS',
  -- recurring
  interval TEXT,                        -- 'monthly' | 'quarterly' | 'annual'
  next_billing_date DATE,
  -- installments
  installment_count INTEGER,
  installments_paid INTEGER DEFAULT 0,
  -- status
  status TEXT DEFAULT 'ACTIVE',         -- 'ACTIVE' | 'PAUSED' | 'CANCELLED' | 'COMPLETED'
  payment_method_id UUID,               -- FK to payment_methods
  auto_charge BOOLEAN DEFAULT false,    -- charge card automatically vs send invoice
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
)

payments (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  customer_id UUID NOT NULL,
  invoice_id UUID,                      -- FK to invoices (nullable for direct payments)
  payment_plan_id UUID,                 -- FK to payment_plans (nullable)
  payment_method_id UUID,               -- FK to payment_methods
  amount NUMERIC(12,2) NOT NULL,
  currency TEXT DEFAULT 'ILS',
  status TEXT DEFAULT 'PENDING',        -- 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'REFUNDED'
  provider TEXT,
  provider_transaction_id TEXT,
  provider_reference TEXT,
  failure_reason TEXT,
  paid_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
)
```

---

## Payment Adapters

### Interface

```ts
interface PaymentAdapter {
  id: 'morning' | 'isracard' | 'upay' | 'icount_pay'
  name: string

  // Tokenize card — card data NEVER flows through the Zync Worker.
  // Provider's hosted iframe/redirect handles card entry on provider's domain.
  // Worker receives only the resulting token from provider's postMessage/redirect callback.
  // Zync is NOT in PCI scope for card data; only token storage (encrypted in DB) applies.
  tokenizeCard(params: TokenizeParams): Promise<{ token: string; lastFour: string; expiry: string }>

  // Charge a tokenized card
  charge(params: ChargeParams): Promise<{ transactionId: string; reference: string; status: 'completed' | 'failed'; failureReason?: string }>

  // Refund a completed transaction
  refund(transactionId: string, amount: number, credentials: AdapterCredentials): Promise<{ refundId: string }>

  // Verify inbound webhook signature
  verifyWebhook(payload: unknown, signature: string, secret: string): boolean
}
```

### Morning (Green Invoice Pay)

- Tokenization: Morning hosted payment page / iframe. Card token returned via redirect or postMessage.
- Charge: `POST https://api.greeninvoice.co.il/api/v1/payments`
- Inbound webhooks: payment status updates → `POST /api/webhooks/billing/morning`
- Same credentials as invoice adapter (if tenant uses both Morning products, single credential set).

### Isracard Direct Debit (הוראת קבע)

- **Not a card token.** הוראת קבע is a bank mandate (direct debit authorization), not a card. `provider_token` stores the mandate ID or bank account reference — not a card PAN token. Type stored as `'direct_debit'`; `last_four` / `expiry_*` columns unused for this type.
- Mandate setup: customer signs debit mandate form (PDF or digital). Mandate reference stored encrypted in `provider_token`.
- Debit processing: `POST https://gateway.isracard.co.il/api/debit` (bank-to-bank, not card network)
- Common for monthly retainers in Israeli B2B context.

### Upay

- Payment page iframe hosted on Upay's domain.
- Tokenization + charge via Upay REST API.
- Inbound webhooks: `POST /api/webhooks/billing/upay`

### iCount Pay

- Uses same `adapter_credentials` as iCount invoice adapter (if configured).
- `POST https://api.icount.co.il/api/v3.php?action=charge`

---

## Features

### Payment Plans (`/billing/plans`)

Table: Customer, Project, Plan type, Amount, Interval, Next billing date, Status, Auto-charge.

"New plan" button → sheet form:
- Customer, project (optional)
- Type: one-time / recurring / installments
- Amount, currency
- If recurring: interval (monthly/quarterly/annual), start date
- If installments: total amount, count, interval
- Payment method (pick from customer's saved methods, or "set up new")
- Auto-charge toggle: on = charge automatically on billing date; off = create invoice draft instead

### Payments History (`/billing/payments`)

Table: Date, Customer, Amount, Invoice #, Status, Method, Provider ref.

Filterable: date range, customer, status, provider. Export CSV.

"Record manual payment" button (for bank transfers, checks not processed via adapter).

### Customer Payment Methods

Managed from customer detail page (CRM or customer module). Staff can:
- Add a new card → opens payment provider's hosted tokenization flow
- Remove a method
- Set as default

Methods displayed with type icon, last 4, expiry. Masked in UI (never show full number).

### Auto-Billing Cron

`/api/cron/billing-charge` — runs daily.

For each `payment_plans` where `status = 'ACTIVE' AND auto_charge = true AND next_billing_date <= today`:
1. Look up payment method + decrypt provider token
2. Enqueue `payment.charge` job (cron returns immediately — does NOT advance date)

Consumer:
1. Call `adapter.charge(params, credentials)`
2. On success:
   - Call `POST /api/invoices/auto-issue` (spec 15 internal endpoint) with plan details → gets `{ invoiceId, invoiceNumber }`. This creates DRAFT + assigns gap-free sequential number + transitions to TAX_ISSUED in one transaction. **Do NOT INSERT directly into `invoices` — bypasses numbering.**
   - Create `payments` record (`status = 'COMPLETED'`, `invoice_id = invoiceId`)
   - Advance `next_billing_date` by interval (advance on success only — failed charge must retry same period)
   - Fire `payment.completed` webhook
3. On failure: create `payments` record (`status = 'FAILED'`), fire `payment.failed` webhook, create in-app notification for tenant admin. **Do NOT advance `next_billing_date`.**
4. Retry: 3× with exponential backoff before final failure

Non-auto-charge plans: on billing date, auto-create invoice draft for staff to review + send.

---

## Inbound Payment Webhooks

Each provider sends status updates to Zync:

```
POST /api/webhooks/billing/morning    → Morning payment status
POST /api/webhooks/billing/upay       → Upay payment status
POST /api/webhooks/billing/icount_pay → iCount Pay status
```

Each handler:
1. Verify signature (`adapter.verifyWebhook`)
2. Find matching `payments` record via `provider_transaction_id`
3. Update status
4. If `COMPLETED`: update linked invoice to `PAID`, fire webhook `payment.completed`

---

## Permissions

| Action | Required permission |
|--------|-------------------|
| View billing/payments | `billing:read` |
| Create/edit payment plans | `billing:write` |
| Record manual payments | `billing:write` |
| Manage customer payment methods | `billing:write` |
| Refund | `billing:write` |
| Configure adapters | `settings:write` |

---

## API Endpoints

```
GET    /api/billing/plans                   → list payment plans
POST   /api/billing/plans                   → create plan
GET    /api/billing/plans/:id               → plan detail
PATCH  /api/billing/plans/:id               → update (pause, cancel, change amount)
DELETE /api/billing/plans/:id               → cancel plan

GET    /api/billing/payments                → payment history (filterable)
POST   /api/billing/payments                → record manual payment
GET    /api/billing/payments/:id            → payment detail

GET    /api/customers/:id/payment-methods   → list payment methods for customer
POST   /api/customers/:id/payment-methods   → add payment method (tokenization flow)
DELETE /api/customers/:id/payment-methods/:mid → remove method

POST   /api/webhooks/billing/:provider      → inbound payment status webhook
```

---

## Webhooks

| Event | Payload |
|-------|---------|
| `payment.completed` | `{ paymentId, customerId, invoiceId, amount, provider }` |
| `payment.failed` | `{ paymentId, customerId, planId, failureReason }` |
| `payment.refunded` | `{ paymentId, refundId, amount }` |

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Payment provider tokens encrypted in DB | `INTEGRATION_ENCRYPTION_KEY` | Same pattern as SMTP/invoice adapter creds; PCI scope reduction |
| Card data never touches Worker | Provider hosted iframe/redirect only | Zync not in PCI scope; worker receives token, not card data |
| Isracard token = mandate ID, not card token | Separate type='direct_debit' | הוראת קבע is a bank mandate; last_four/expiry unused; different API and lifecycle |
| Cron advances next_billing_date on success only | Not on enqueue | Failed charge must retry same period; advancing on enqueue permanently skips a billing cycle |
| Auto-charge routes through invoices-core issue-tax | Not direct INSERT | Gap-free sequential numbering required by IL law (spec 15); bypassing it creates illegal gaps |
| Async charge via queue | Not sync in cron handler | Charge calls take 1–5s; cron handler should return fast; queue handles retry + DLQ |
| Auto-charge creates `invoices` record | Not just `payments` | IL law: every payment needs a tax invoice; billing drives invoice creation, not the reverse |
| Inbound webhooks verify signature | Per-adapter verify method | Prevents spoofed payment confirmations; each provider has own scheme |
| Non-auto-charge creates invoice draft | Not skip | Tenant may want manual approval before sending invoice for certain customers |
