# Vendor Wallet, Returns & Payouts

Four connected features on top of the Razorpay integration
([API_RAZORPAY_PAYMENTS.md](API_RAZORPAY_PAYMENTS.md)):

0. **Platform commission** — the app's cut of every paid order
1. **Wallet** — a paid order credits the vendor's wallet, net of that cut
2. **Returns & refunds** — the buyer sends an item back, the money goes back
3. **Payouts** — the vendor withdraws their balance

## Setup

```bash
node src/scripts/create-wallet-return-tables.js
node src/scripts/create-platform-commission.js
```

`.env`:

```
PLATFORM_COMMISSION_PERCENT=5   # only a fallback; the live rate is in the database
RETURN_WINDOW_DAYS=7            # return window, and how long an earning stays pending
MIN_WITHDRAWAL_AMOUNT=100

# Optional — only for automated payouts. Without these, withdrawals are manual.
RAZORPAYX_KEY_ID=
RAZORPAYX_KEY_SECRET=
RAZORPAYX_ACCOUNT_NUMBER=       # the RazorpayX current account money is debited from
RAZORPAYX_WEBHOOK_SECRET=
RAZORPAYX_PAYOUT_MODE=IMPS
```

---

## 0. Platform commission

The buyer always pays the full order amount. The platform's fee comes out of the
**vendor's** side — the buyer never sees a separate line, and the vendor's payout
is what shrinks.

```
₹2000 order at 5%
   buyer pays          2000   (unchanged — this is what Razorpay collects)
   platform keeps       100   -> platform_earnings
   vendor's wallet     1900   -> vendor_wallets.pending_balance
```

### Which rate applies

Resolved most-specific-first, in [commissionModel.js](src/models/commissionModel.js):

| level | where | use it for |
|---|---|---|
| vendor | `vendors.commission_percent` | a negotiated rate for one shop |
| category | `vendor_categories.commission_percent` | bats 5%, jerseys 10% |
| default | `platform_settings['commission_percent']` | everything else (starts at 5%) |
| fallback | `PLATFORM_COMMISSION_PERCENT` env | only if the settings row is missing |

`NULL` means "no override, fall through". A stored **0 is a real rate** — a
zero-fee deal for a vendor — and does not fall through.

The resolved rate and both amounts are **snapshotted onto the order** when it is
placed (`commission_percent`, `commission_amount`, `vendor_earning`). Raising the
fee tomorrow never rewrites what an existing order owed.

### Admin endpoints

| method | path | |
|---|---|---|
| `GET` | `/api/vendors/admin/commission` | current default + every override |
| `PUT` | `/api/vendors/admin/commission` | `{ user_id, percent }` — set the default |
| `PUT` | `/api/vendors/admin/commission/category/:category_id` | `{ user_id, percent }`, `null` clears it |
| `PUT` | `/api/vendors/admin/commission/vendor/:vendor_id` | `{ user_id, percent }`, `null` clears it |
| `GET` | `/api/vendors/admin/revenue?from=&to=&vendor_id=` | what the platform kept |
| `GET` | `/api/vendors/admin/earnings` | the raw commission ledger |
| `GET` | `/api/vendors/:vendor_id/commission` | the vendor's own rate + a worked example |

### Admin panel

Two pages, linked from `/admin`:

| page | |
|---|---|
| `/admin/commission` | revenue summary, the default rate, and per-category / per-vendor overrides |
| `/admin/payouts` | withdrawal queue: pay manually with a UTR, pay via RazorpayX, reject, sync |

Both act as a real admin user resolved from `users.is_admin` (the panel's own
login is a separate hardcoded account, so `GET /admin/api/context` hands the page
an id to act as). If no user has `is_admin = true`, the pages say so up front
rather than failing later with a bare 403.

Rates are capped at 50% and admin-only. To run the 5%/10% split you described:

```bash
# default stays 5%
curl -X PUT .../api/vendors/admin/commission -d '{"user_id":1,"percent":5}'
# apparel at 10%
curl -X PUT .../api/vendors/admin/commission/category/3 -d '{"user_id":1,"percent":10}'
```

`GET /admin/revenue`:

```json
{
  "gross_sales": 125000.00,
  "commission_earned": 7500.00,
  "commission_reversed": 400.00,
  "net_revenue": 7100.00,
  "paid_orders": 62,
  "by_vendor": [{ "shop_name": "Cric Store", "earned": 3200.00, "net": 3200.00, "orders": 24 }]
}
```

### Refunds give the fee back

The platform does not keep its cut on money the buyer got back. A refund is split
the same way the sale was:

```
₹400 refund on a ₹1000 order at 5%
   buyer gets back      400
   vendor debited       380
   platform returns      20   -> platform_earnings ('commission_reversed')
```

The vendor's share is worked out from **cumulative** totals, not per-refund
percentages, so a series of partial refunds always adds up to exactly
`vendor_earning` — three ₹333.33-ish refunds on a ₹1000 order debit 950 in total,
never 949.99.

Orders placed before commission existed carry `vendor_earning = total_amount`, so
they credit in full and nothing about them changes.

---

## 1. How the money moves

```
order paid ──► minus platform fee ──► pending_balance   (earned, still returnable)
   delivered + RETURN_WINDOW_DAYS, no open return
           └───────────────► available_balance    (withdrawable)
withdrawal requested ──────► locked_balance       (held, cannot be asked for twice)
   payout confirmed ───────► leaves the wallet
   rejected / failed ──────► back to available_balance
refund issued ─────────────► taken off pending first, then available
```

Every movement writes one `vendor_wallet_transactions` row carrying the balances
as they stand afterwards, so the wallet is always re-derivable from its ledger.
Nothing writes `vendor_wallets` except [walletModel.js](src/models/walletModel.js).

**The ledger is the source of truth after the credit.** The release and any
refund read what the credit actually put in (`creditedEarning()`) rather than
recomputing from the order. An order row can drift after it is credited — a
migration backfill, a manual correction, a row written by a build that predates
the commission columns — and recomputing would then move an amount that never
went in. If a wallet was affected before this was fixed:

```bash
node src/scripts/fix-wallet-earning-mismatch.js         # report
node src/scripts/fix-wallet-earning-mismatch.js --fix   # post corrections
```

It posts a correcting `adjustment` entry rather than editing history, so the
ledger still shows what actually happened.

**Why earnings wait.** Crediting straight to available would let a vendor withdraw
money for an order the buyer is still entitled to return. The pending bucket is
released by a nightly job ([releaseVendorEarnings.js](src/jobs/releaseVendorEarnings.js),
02:30) only once the order is delivered, the window has closed, and no return is open.

**Negative balances are allowed.** A refund on an order whose earning was already
withdrawn pushes `available_balance` below zero. That debt nets off against the
vendor's next sale — the alternative is a refund that fails because the vendor
already took the money.

### Wallet endpoints

| method | path | who |
|---|---|---|
| `GET` | `/api/vendors/:vendor_id/wallet?user_id=` | vendor / admin |
| `GET` | `/api/vendors/:vendor_id/wallet/transactions?user_id=&page=&limit=&type=` | vendor / admin |

```json
{
  "available_balance": 4200.00,
  "pending_balance": 1800.00,
  "locked_balance": 500.00,
  "withdrawable_balance": 4200.00,
  "total_earned": 12500.00,
  "total_withdrawn": 6000.00,
  "total_refunded": 500.00,
  "min_withdrawal_amount": 100,
  "pending_orders": 3,
  "pending_withdrawals": { "count": 1, "amount": 500.00 }
}
```

---

## 2. Returns & refunds

```
requested ──► approved ──► picked_up ──► received ──► refunded
     │            │                          └── item restocked here
     │            └──► rejected
     └──► cancelled (buyer)
```

Only a **delivered** order inside the return window can be returned. Anything
earlier is a cancellation, which `PUT /api/vendors/store/my-orders/:id/cancel`
already handles — and that route now issues the refund too.

| method | path | who |
|---|---|---|
| `POST` | `/api/returns` | buyer |
| `GET` | `/api/returns/eligibility/:order_id?user_id=` | buyer |
| `GET` | `/api/returns/user/:user_id` | buyer |
| `GET` | `/api/returns/vendor/:vendor_id?status=` | vendor |
| `GET` | `/api/returns/admin?status=` | admin |
| `GET` | `/api/returns/:return_id` | anyone |
| `POST` | `/api/returns/:return_id/approve` | vendor / admin |
| `POST` | `/api/returns/:return_id/reject` | vendor / admin |
| `POST` | `/api/returns/:return_id/picked-up` | vendor / admin |
| `POST` | `/api/returns/:return_id/received` | vendor / admin |
| `POST` | `/api/returns/:return_id/cancel` | buyer |
| `POST` | `/api/returns/:return_id/refund` | admin (retry) |

**Raise a return** — `POST /api/returns` (**`multipart/form-data`**, not JSON)

| part | |
|---|---|
| `user_id`, `order_id`, `reason` | required text fields |
| `description`, `quantity` | optional text fields |
| `images` | up to 5 files — photos of what arrived (optional) |

```bash
curl -X POST http://host/api/returns \
  -H "X-API-Key: ..." \
  -F user_id=12 -F order_id=104 -F reason=damaged \
  -F "description=Handle cracked" -F quantity=1 \
  -F images=@bat-front.jpg -F images=@bat-handle.jpg
```

Photos upload the same way product and vendor images do (`upload.array('images', 5)`
→ `uploads/`), and `order_returns.images` stores their paths. Files are deleted
again if the request is rejected, so a failed attempt leaves nothing behind.

`reason` must be one of `damaged`, `wrong_item`, `not_as_described`, `size_issue`,
`quality_issue`, `defective`, `missing_parts`, `changed_mind`, `other`.

The refund amount is fixed here from the order snapshot (`unit_price × quantity`,
capped at what is still un-refunded) — a later price edit cannot change it.

Ask `/eligibility/:order_id` before showing a Return button; it returns
`eligible`, the reason it isn't, when the window closes, and the reason list.

**Receive the item** — `POST /api/returns/:return_id/received`

This is the step that does everything: stock goes back (variant-aware, `restock: false`
to skip for a damaged item), the order becomes `returned`, and the refund fires.

The refund is started **after** the transaction commits, because it calls Razorpay
and a gateway call must never be made with a transaction held open. If the gateway
leg fails, the return still stands as `received` and the response says so — retry
with `/refund`.

### Refunds

| order paid via | refund |
|---|---|
| Razorpay | `payments.refund` back to the source card/UPI; `order_refunds.status` goes `processing` → `completed` on the `refund.processed` webhook |
| COD | recorded as `method: 'manual'` and waits for an admin to send the money and confirm |

| method | path |
|---|---|
| `GET` | `/api/payments/refunds/order/:order_id` |
| `POST` | `/api/payments/refunds/:refund_id/complete` (admin, COD only) |

The order's `payment_status` becomes `partially_refunded` or `refunded`, and the
vendor's wallet is debited, at the moment the money is actually committed — the
gateway accepting it, or the admin confirming a manual transfer.

**Ordering matters.** The `order_refunds` row is committed *before* Razorpay is
called, so a refund can never leave the gateway without a record of it here. If
the gateway succeeds but the booking transaction then fails, the `refund.processed`
webhook reconciles it — the refund call attaches our row id in `notes` exactly so
that reply can be matched back.

---

## 3. Payouts

```
vendor: POST /:vendor_id/payout-account      (bank account or UPI)
vendor: POST /:vendor_id/withdrawals         -> available moves to locked
admin:  POST /admin/withdrawals/:id/process  -> money actually sent
        POST /admin/withdrawals/:id/reject   -> locked moves back to available
```

| method | path | who |
|---|---|---|
| `GET`/`POST` | `/api/vendors/:vendor_id/payout-account` | vendor |
| `POST` | `/api/vendors/:vendor_id/withdrawals` | vendor |
| `GET` | `/api/vendors/:vendor_id/withdrawals?status=` | vendor |
| `POST` | `/api/vendors/withdrawals/:withdrawal_id/cancel` | vendor |
| `GET` | `/api/vendors/admin/withdrawals?status=` | admin |
| `POST` | `/api/vendors/admin/withdrawals/:withdrawal_id/process` | admin |
| `POST` | `/api/vendors/admin/withdrawals/:withdrawal_id/reject` | admin |
| `POST` | `/api/vendors/admin/withdrawals/:withdrawal_id/sync` | admin |

**Requesting** validates: approved vendor, payout account on file, amount is a
number, at least `MIN_WITHDRAWAL_AMOUNT`, and no more than `available_balance`.
It holds the money in the same transaction, so two requests can never both be
funded by the same balance.

Statuses: `requested` → `approved` → `processing` → `paid`, or `rejected` /
`failed` / `cancelled`. Only `requested` can be cancelled by the vendor.

### Manual payout (default)

The admin transfers from the business account and records the UTR:

```json
POST /api/vendors/admin/withdrawals/7/process
{ "user_id": 1, "payout_method": "manual", "reference_id": "UTR123456789" }
```

`reference_id` is required — a manual payout with no bank reference is
unauditable. The withdrawal becomes `paid` and the held amount leaves the wallet.

### RazorpayX payout

```json
POST /api/vendors/admin/withdrawals/7/process
{ "user_id": 1, "payout_method": "razorpayx" }
```

Creates the contact and fund account on first use (ids cached on
`vendor_payout_accounts`), then fires the payout. The withdrawal goes to
`processing` **before** the API call, so a crash mid-payout leaves it visibly in
flight rather than looking untouched and inviting a second one. The payout carries
`X-Payout-Idempotency` keyed on the withdrawal id, so a retry after a timeout
returns the original payout instead of paying twice.

`paid` only when RazorpayX reports `processed` — via the `payout.processed`
webhook, or `/sync` if the webhook was missed. A `failed`/`reversed` payout puts
the money back in the vendor's available balance.

Add `https://<host>/webhooks/razorpay` to the **RazorpayX** dashboard as well,
with `payout.processed`, `payout.failed` and `payout.reversed`. If that dashboard
uses a different secret, set `RAZORPAYX_WEBHOOK_SECRET` — both secrets are tried.

Rejecting is refused while a payout is `processing`, since that could double-pay
a vendor whose transfer is already moving. Sync first.

### Why not Razorpay Route

Route is a different architecture, not a third switch on this workflow. It splits
money **at capture time**: the buyer's payment is transferred to the vendor's
linked account as part of the payment itself, and Razorpay holds the vendor's
balance. That removes the thing this feature is built around — a wallet we control,
which we can hold pending against returns, debit on refund, and pay out on request.

Route also requires each vendor to complete Razorpay's linked-account onboarding
(KYC, account activation) before they can sell at all, which is a much heavier
vendor signup than the current admin approval.

`vendor_payout_accounts.razorpay_route_account_id` exists for a future migration.
Switching would mean transferring at settlement in
[paymentController.js](src/controllers/paymentController.js) instead of crediting
the wallet, and treating the wallet as a read-only mirror of Route balances.

---

## Tables

| table | holds |
|---|---|
| `platform_settings` | editable defaults, currently just the commission rate |
| `platform_earnings` | the platform's own ledger: commission earned and reversed |
| `vendor_wallets` | one row per vendor: available / pending / locked + lifetime totals |
| `vendor_wallet_transactions` | the ledger; one row per movement, with balances after |
| `vendor_payout_accounts` | bank account or UPI id, plus cached RazorpayX ids |
| `vendor_withdrawals` | withdrawal requests and their payout state |
| `order_returns` | return requests and their lifecycle |
| `order_refunds` | one row per refund attempt, gateway or manual |

`vendor_orders` gains `delivered_at`, `refunded_amount`, `return_status`,
`commission_percent`, `commission_amount` and `vendor_earning`, and `returned`
joins its allowed statuses.

Two partial unique indexes carry real weight:

- `uniq_wallet_tx_order_earning` — an order can only ever be credited once,
  whatever calls it (payment verification, the webhook, and the COD paid path all do)
- `uniq_open_return_per_order` — one live return per order; a rejected or
  cancelled one may be re-raised
- `uniq_platform_commission_order` — the platform's fee is booked once per order,
  whichever path credits the wallet

## Concurrency

Every balance change locks the wallet row (`SELECT … FOR UPDATE`). Callers lock
the order row first, then the wallet — always in that order, which is what keeps
a settle, a refund and a withdrawal running at the same time off each other's toes.
