# Shipping Campaigns API

**Author**: Josh S. Sakweli, Backend Lead Team
**Last Updated**: 2026-10-24
**Version**: v1.0

**Base URL**: `https://api.nexgate.co/api/v1`

**Short Description**: A shipping campaign is **someone paying part of a buyer's delivery**, for buyers who meet some
rules, for some time — "Free delivery in Dar this weekend", "I pay 50% of delivery", "25% off my deliveries into
Kinondoni". Three kinds of creator can run one: a **shipper** (a discount on its own price), a **seller** (paid from
the order's own items money) and **NexGate** (paid from a pot of money held in escrow). This doc explains how they are
worked out, paid, refunded and managed.

**Related docs**: `docs/shipping/shipping_api_doc.md` (options, cart, checkout, orders) · `docs/location/location_api_doc.md` (areas) ·
`docs/shipping/campaigns/SHIPPING_CAMPAIGNS_DESIGN.md` (full design with every decision and example) ·
`commerce_logistics_changes.md` §64–§68 (fields added to existing responses).

**Hints**:
- **`cost.total` everywhere stays the shipper's table price.** What the buyer pays is `offer.buyerPays`; show
  ~~cost.total~~ **buyerPays** and the shares ("Shop pays 3,000 · NexGate pays 1,800").
- The campaign is **shown** at estimation, **worked out** at the quotation, **paid** at payment and **frozen** after.
  Nothing is held at quotation time — the buyer may learn at payment that a campaign has just ended (`CAMPAIGN_ENDED`).
- A campaign **never pays more after payment**.
- Coded refusals are HTTP **409** with `data.code`.

---

## Standard Response Format

### Success
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Campaign created",
  "action_time": "2026-10-24T10:30:45",
  "data": { }
}
```

### Error (coded refusal)
```json
{
  "success": false,
  "httpStatus": "CONFLICT",
  "message": "Say the most you pay per delivery (the cap)",
  "action_time": "2026-10-24T10:30:45",
  "data": { "code": "CAMPAIGN_CAP_REQUIRED", "reason": "Say the most you pay per delivery (the cap)" }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `success` | boolean | `true` for success, `false` for errors |
| `httpStatus` | string | HTTP status name |
| `message` | string | Human-readable result, safe to show |
| `action_time` | string | When the response was made |
| `data` | object/string | Payload, or `{ code, reason, details? }` |

---

## HTTP Method Badge Standards

- **GET** - <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> - read only
- **POST** - <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> - create / act
- **PUT** - <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> - replace

---

# Part 1 — How campaigns work

## 1.1 Who can run what

| Creator | Covers | Kinds | Money comes from | Who manages it |
|---|---|---|---|---|
| **SHIPPER** | its own deliveries, from any shop | `PERCENT`, `FIXED` (no `FREE`) | nowhere — it simply charges less | the business owner only (not admins) |
| **SELLER** | deliveries of its own shop's orders | `FREE`, `PERCENT`, `FIXED` | the order's own items money, at payment | the shop owner (or a super admin) |
| **NEXGATE** | deliveries of any shop, or chosen shops | `FREE`, `PERCENT`, `FIXED` | a **pot** held in escrow, funded from `SHIPPING-CAMPAIGNS` | super admins |

| Kind | Means | Applies to |
|---|---|---|
| `FREE` | the whole delivery, up to a `cap` per order | the **cheapest qualifying option** becomes free; a faster one costs the buyer the difference |
| `PERCENT` | `value`% of the delivery, optional `cap` | the option the buyer picks |
| `FIXED` | `value` TZS off | the option the buyer picks |

## 1.2 Who qualifies

| Rule | Field | Notes |
|---|---|---|
| **Where the parcel goes** (main) | `areas` | the buyer's destination: regions / districts / wards; empty = everywhere |
| **When** (main) | `startsAt`, `endsAt` | start defaults to now; NexGate needs an end |
| Where it starts | `fromAreas` | shipper only |
| Minimum order | `minOrder` | this shop's items |
| Audience | `audience` | `EVERYONE` or `FIRST_ORDER` |
| Shops | `shopIds` | NexGate only (empty = all shops) |
| Products | `productIds` / `excludedProductIds` | seller only; a mixed parcel qualifies only if every item does |
| Per buyer | `perBuyerLimit` | e.g. 1 = once per buyer |
| Cap per order | `cap` | `FREE` and `PERCENT` |
| Spending limit | `spendLimit` | seller only — a counter, not money held ("stop after 200,000") |

A creator may run several campaigns at once; per order only **one campaign per creator** applies — the one that gives
the buyer the most, worked out per option.

## 1.3 Stacking — when several apply

1. Order: **shipper discount → seller → NexGate**.
2. The shipper's discount sets the real price (`price`); every other share is worked out from **that full price**,
   never from "what is left".
3. Shares together never exceed the price; the **last one is trimmed** (normally NexGate). The buyer never pays
   below zero.

| Case | Seller | NexGate | Buyer |
|---|---|---|---|
| 6,000 · seller 50% · NexGate 30% | 3,000 | 1,800 | **1,200** |
| 10,000 · seller 50% at most 3,000 · NexGate 30% | 3,000 | 3,000 | **4,000** |
| 6,000 · seller 70% · NexGate 50% | 4,200 | 1,800 (trimmed) | **0 · Free** |
| 8,000, shipper 25% off → 6,000 · seller 50% · NexGate 30% | 3,000 | 1,800 | **1,200** (the shipper earns 6,000) |
| 6,000 · NexGate 50% but the pot holds 1,000 | — | **0** (full share or nothing) | **6,000** |

Ranking uses **what the buyer pays** (+ arrival time), so a campaign can make a shipper ⭐ Recommended or 💰 Cheapest.

## 1.4 The offer on an option

Every delivery option (quotation, cart, checkout, order) gains `offer` when a campaign applies (absent otherwise):

```json
"offer": {
  "listPrice": 7000,
  "price": 7000,
  "buyerPays": 1900,
  "shares": [
    { "campaignId": "…", "funder": "SELLER",  "name": "Half delivery in Dar", "amount": 3000 },
    { "campaignId": "…", "funder": "NEXGATE", "name": "Dar 30 percent",       "amount": 2100 }
  ]
}
```

| Field | Meaning |
|---|---|
| `listPrice` | the shipper's table price (= `cost.total`) |
| `price` | after the shipper's own discount — what the shipper is paid |
| `shares` | who pays part of `price`; a `SHIPPER` share is the discount (not money) |
| `buyerPays` | the rest |

Product cards show a badge from the estimation's `campaign` field: `{ "listFrom": 7000, "paidBy": ["SELLER", "NEXGATE"] }`
→ "Delivery from ~~7,000~~ 1,900 · Shop + NexGate pay part"; `freeDelivery: true` when the cheapest option is free.
The cart shows a **nudge** under a shop when a minimum order is near: `"Add 4,995 more from My Shop for free delivery"`.

## 1.5 Where the money sits

**Shipper** — nothing set aside; it earns less per job. Refunds return nothing.

**Seller** — taken **from the order's own money at payment**: items 50,000 + delivery 6,000, seller pays all of the
delivery → the buyer pays 50,000, split in escrow into **44,000** items + **6,000** delivery. The shipper is fully covered
from payment. Guards so a seller never ends below zero:
- every seller `FREE` / `PERCENT` campaign needs a **cap**;
- with a minimum order, the cap must fit inside it after commission (`CAMPAIGN_CAP_ABOVE_MINIMUM`);
- at the quotation and again at payment the share must fit **this order's items money minus commission**, else the
  seller's campaign does not apply to that option (full share or nothing);
- with a `spendLimit`, the share must fit what is left of it.

**NexGate** — a **pot** per campaign, held in escrow:

```
real money ──top-up──► PLATFORM-REVENUE ──transfer──► SHIPPING-CAMPAIGNS ──fund / top-up──► campaign pot ──at payment──► the order's delivery part
                         (the only door                       ▲                                   │
                          for outside money)                  └──── leftovers, late refunds ──────┘
```
- Creating a NexGate campaign moves its `budget` from `SHIPPING-CAMPAIGNS` into its pot (`CAMPAIGN_BUDGET_LOW` if
  short); a **top-up** adds more.
- Each use moves the share from the pot into the order's delivery part **under a lock** — parallel checkouts can never
  take a pot below zero. **Full share or nothing.**
- Pot empty → **Ran out** (stops by itself); a top-up or a refund restarts it.
- Ended / stopped → whatever is left goes back to `SHIPPING-CAMPAIGNS` at once.

## 1.6 One order, start to finish

| Stage | What happens |
|---|---|
| Estimation (card) | badge only — matching campaigns, nothing exact, nothing held |
| Quotation (sheet / cart / checkout) | the rules run on every option; best campaign per creator; the quotation remembers which it used; **nothing held** |
| Payment | checked again under a lock: still running? pot / limit / items still cover the share? **Yes** → the buyer pays only their part, the shares move into the delivery part. **No** → payment refused `CAMPAIGN_ENDED` (`details`: shopId, businessId, businessName, before, after); nothing charged; `PATCH` the checkout session (same pick) for today's price, then pay |
| Recorded | buyer · seller · NexGate amounts frozen on the delivery escrow — they drive every refund and report |
| Delivered + confirmed | the shipper gets its full price; the seller gets items minus its share (commission on the full items price) |

## 1.7 After payment

| Change | Campaign money |
|---|---|
| Address change (before a shipper accepts) | each share kept **up to what it paid**; one that no longer applies goes back to its source and the buyer pays it; the buyer tops up / gets back only their own part |
| Cheaper shipper on reassignment | the difference comes back in proportion (pot, seller's items part, buyer) |
| Dearer shipper | shares stay; the buyer pays the extra |
| Switch to pickup / cancel / refund | every share goes back where it came from; the buyer gets back all they paid |
| Pickup → delivery after payment | campaigns apply like at checkout (`CAMPAIGN_ENDED` possible) |
| Admin cancel while the rider is heading | the 50% wasted trip is paid pro rata by everyone in the delivery part |

Where a NexGate share goes on a refund:

| Campaign state at refund | NexGate's share | Seller's limit |
|---|---|---|
| Running / Paused | back to the pot | restored |
| Ran out | back to the pot → **Running again** | restored |
| Ended / Cancelled | back to the pot, moved on to `SHIPPING-CAMPAIGNS` within a minute | nothing to do |

## 1.8 Installments and group purchase

- **Immediate installment:** the seller's share comes from the **down payment** (qualifying uses the full price; the
  share + commission must fit the down payment, else payment refuses `CAMPAIGN_ENDED` "… pay a bigger down payment").
  NexGate and shipper campaigns work as usual.
- **Layaway:** campaigns apply when the buyer chooses delivery after the last instalment.
- **Group purchase:** each member's delivery gets its own campaigns; leaving or a failed group refunds in full and
  every share goes back.
- A station route (several businesses) gets seller and NexGate shares but no shipper discount.

## 1.9 States

| `status` (stored) | `state` (shown) | Means |
|---|---|---|
| `SCHEDULED` | `SCHEDULED` | starts later (`startsAt`) |
| `RUNNING` | `RUNNING` / **`RAN_OUT`** | live; `RAN_OUT` = pot empty or spending limit used up |
| `PAUSED` | `PAUSED` | stopped by the creator; resume any time |
| `ENDED` | `ENDED` | the end date passed (timer, every minute) |
| `CANCELLED` | `CANCELLED` | stopped for good by the creator |

A closed campaign (ended / cancelled) cannot be edited or restarted — create a new one (`CAMPAIGN_CLOSED`). Edits
change **new quotations only**; paid orders keep what they got.

**Notifications** to whoever runs it (in-app + push, opens `SHIPPING_CAMPAIGN` with the `campaignId`):
`SHIPPING_CAMPAIGN_RAN_OUT` ("Dar 30 percent" ran out · the pot is empty) and `SHIPPING_CAMPAIGN_ENDED`
("Dar 30 percent" has ended · It paid 184,000 towards delivery on 96 orders.).

---

# Part 2 — Endpoints

## 1. Create a campaign
**Purpose**: Start a campaign for a shop, a delivery business, or NexGate.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/campaigns`

**Access Level**: 🔒 Protected — SELLER: the shop owner · SHIPPER: the business owner · NEXGATE: `SUPER_ADMIN`

**Authentication**: Bearer token

**Request JSON Sample** (seller):
```json
{
  "creator": "SELLER",
  "shopId": "aaaa0000-0000-4000-8000-000000000001",
  "name": "Free delivery over 50,000",
  "kind": "FREE",
  "cap": 5000,
  "areas": [ { "level": "REGION", "regionCode": "07" } ],
  "startsAt": "2026-10-25T00:00:00",
  "endsAt": "2026-11-30T23:59:00",
  "minOrder": 50000,
  "audience": "EVERYONE",
  "excludedProductIds": ["77a1…"],
  "perBuyerLimit": 2,
  "spendLimit": 200000
}
```

**Request JSON Sample** (NexGate):
```json
{
  "creator": "NEXGATE",
  "name": "Dar 30 percent",
  "kind": "PERCENT", "value": 30, "cap": 3000,
  "areas": [ { "level": "REGION", "regionCode": "07" } ],
  "endsAt": "2026-11-02T23:59:00",
  "shopIds": [],
  "budget": 500000
}
```

**Request Body Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| creator | enum | Yes | `SHIPPER`, `SELLER`, `NEXGATE` | cannot change later |
| shopId | UUID | SELLER | the shop running it | your shop |
| businessId | UUID | SHIPPER | the delivery business running it | your business |
| name | string | Yes | shown to buyers in the shares | max 80 |
| kind | enum | Yes | `FREE`, `PERCENT`, `FIXED` | shipper: no `FREE` |
| value | number | PERCENT / FIXED | percent 1–100, or TZS off | none for `FREE` |
| cap | number | FREE / PERCENT for sellers | most paid per delivery | ≥ 1 |
| areas | array | No | buyer's destination areas (empty = everywhere) | ≤ 100 |
| fromAreas | array | No | shipper only: where the parcel starts | ≤ 100 |
| startsAt / endsAt | datetime | endsAt: NEXGATE | the period | end after start, in the future |
| minOrder | number | No | this shop's items at least | ≥ 0 |
| audience | enum | No | `EVERYONE` (default), `FIRST_ORDER` | |
| shopIds | array | No | NexGate only: chosen shops | ≤ 500 |
| productIds / excludedProductIds | array | No | seller only: only these / never these | own shop's products |
| perBuyerLimit | integer | No | uses per buyer | ≥ 1 |
| spendLimit | number | No | seller only: stop after this much | ≥ 1 |
| budget | number | NEXGATE | moved from `SHIPPING-CAMPAIGNS` into the pot | ≥ 1 |

**Success Response JSON Sample**:
```json
{
  "success": true, "httpStatus": "OK", "message": "Campaign created", "action_time": "2026-10-24T10:30:45",
  "data": {
    "campaignId": "…", "creator": "NEXGATE", "name": "Dar 30 percent", "kind": "PERCENT", "value": 30, "cap": 3000,
    "areas": [ { "level": "REGION", "regionCode": "07", "name": "Dar es Salaam", "label": "Dar es Salaam" } ],
    "startsAt": "2026-10-24T10:30:45", "endsAt": "2026-11-02T23:59:00", "audience": "EVERYONE",
    "state": "RUNNING",
    "numbers": { "orders": 0, "buyers": 0, "givenAway": 0, "returned": 0 },
    "pot": { "accountNumber": "POT-3dd27daf", "funded": 500000, "used": 0, "returned": 0, "left": 500000 },
    "createdAt": "2026-10-24T10:30:45"
  }
}
```

**Success Response Fields**:
| Field | Description |
|-------|-------------|
| `state` | §1.9 |
| `numbers` | orders and buyers that used it; `givenAway` (net of refunds), `returned` |
| `pot` | NexGate only: funded, used, returned, left |
| `limit` | seller with a spending limit: `{ limit, used }` |
| `endedAt` | when it ended / was stopped |

Empty fields are left out of the response.

**Errors**: `CAMPAIGN_KIND_NOT_ALLOWED`, `CAMPAIGN_VALUE_INVALID`, `CAMPAIGN_CAP_REQUIRED`, `CAMPAIGN_CAP_ABOVE_MINIMUM`,
`CAMPAIGN_END_REQUIRED`, `CAMPAIGN_DATES_INVALID`, `CAMPAIGN_FIELD_NOT_ALLOWED` (message says which field),
`CAMPAIGN_BUDGET_REQUIRED`, `CAMPAIGN_BUDGET_LOW`, `SHOP_REQUIRED`, `BUSINESS_REQUIRED`, `NOT_YOUR_SHOP`,
`NOT_YOUR_BUSINESS`, `ADMIN_ONLY`, `AREA_NOT_FOUND`, `SHOP_NOT_FOUND`, `PRODUCT_NOT_IN_SHOP`.

---

## 2. List one creator's campaigns
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/campaigns?creator=SELLER&shopId=…`

**Access Level**: 🔒 Protected (same as create)

**Query Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| creator | enum | Yes | `SELLER`, `SHIPPER`, `NEXGATE` |
| shopId | UUID | SELLER | the shop |
| businessId | UUID | SHIPPER | the business |

**Success Response**: a list of campaigns (as create), newest first.

---

## 3. Get / edit one campaign
- <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/campaigns/{campaignId}`
- <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/shipping/campaigns/{campaignId}` — the whole campaign (same body as create; `creator` must stay
  the same; `budget` is ignored — use top-up)

**Access Level**: 🔒 Protected (whoever runs it)

Edits apply to new quotations only. Moving `startsAt` into the future makes it `SCHEDULED` again.

**Errors**: as create, plus `CAMPAIGN_CLOSED`, `CAMPAIGN_CREATOR_FIXED`.

---

## 4. Orders that used it
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/campaigns/{campaignId}/uses?page=1&size=20`

**Access Level**: 🔒 Protected (whoever runs it)

**Success Response** `data` (a page, newest first):
```json
{ "content": [ { "orderId": "…", "orderNumber": "ORD-2026-21597", "amount": 2100, "returned": 0, "at": "2026-10-24T11:02:10" } ],
  "totalElements": 1, "totalPages": 1, "…": "…" }
```
`orderId` / `orderNumber` are null until the order exists (a group purchase only when the group fills).

---

## 5. Pause / resume / stop
| Endpoint | Does |
|---|---|
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/campaigns/{campaignId}/pause` | stops for new orders; paid orders keep it; idempotent |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/campaigns/{campaignId}/resume` | back to `RUNNING` (or `SCHEDULED`); idempotent |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/campaigns/{campaignId}/stop` | `CANCELLED` for good; a NexGate pot's leftover goes back to `SHIPPING-CAMPAIGNS` at once |

**Access Level**: 🔒 Protected (whoever runs it). Each answers with the campaign. **Errors**: `CAMPAIGN_CLOSED`.

---

## 6. Top up a NexGate pot
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/campaigns/{campaignId}/top-up`

**Access Level**: 🔒 Protected (`SUPER_ADMIN`)

**Request**: `{ "amount": 200000 }` — moved from `SHIPPING-CAMPAIGNS` into the pot; a `RAN_OUT` campaign runs again.
Audit-logged.

**Errors**: `CAMPAIGN_HAS_NO_POT` (seller / shipper campaigns), `CAMPAIGN_CLOSED`, `CAMPAIGN_BUDGET_LOW`.

---

## 7. Admin: every campaign
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/admin/shipping/campaigns?creator=&status=&page=1&size=20`

**Access Level**: 🔒 Protected (`STAFF_ADMIN`, `SUPER_ADMIN`) — read only

**Query Parameters**: `creator` (`SHIPPER` / `SELLER` / `NEXGATE`), `status` (`SCHEDULED`, `RUNNING`, `PAUSED`,
`ENDED`, `CANCELLED`), `page` (from 1), `size` (≤ 100).

**Success Response** `data`:
```json
{
  "items": [ { "campaignId": "…", "creator": "SELLER", "name": "Free delivery over 50,000", "state": "RUNNING", "…": "…" } ],
  "page": 1, "size": 20, "totalElements": 7, "totalPages": 1,
  "totals": { "byState": { "RUNNING": 4, "ENDED": 2, "RAN_OUT": 1 }, "givenAway": 184000, "returned": 6300, "potsLeft": 412000 }
}
```
`totals` cover everything matching the filter (not just this page); `potsLeft` = money still in pots of campaigns not
closed.

---

## 8. Funding: NexGate system accounts — `{base_url}/admin/finance/system-accounts`

**Access Level**: 🔒 Protected (`SUPER_ADMIN`) — every move needs a reason and is audit-logged.

| Endpoint | Does |
|---|---|
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/system-accounts` | `PLATFORM-REVENUE`, `SHIPPING-CAMPAIGNS`, `PROVIDER-BOLT`, … with balances; revenue also shows `putIn` (owner top-ups) and `transferredOut` |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/system-accounts/{accountNumber}/entries?page=1&size=20` | history: `{ entryNumber, type, direction: IN \| OUT, amount, other, description, metadata, at }` |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/system-accounts/top-up` | **real money** NexGate put in → `PLATFORM-REVENUE`: `{ "amount": 1000000, "reference": "<bank / M-Pesa id>", "reason": "…" }` — record only when the money really arrived |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/system-accounts/transfer` | `{ "fromAccount": "PLATFORM-REVENUE", "toAccount": "SHIPPING-CAMPAIGNS", "amount": 500000, "reason": "…" }` — the only allowed pair for now |

**Errors**: `REFERENCE_ALREADY_USED` (the same reference twice), `TRANSFER_NOT_ALLOWED`, `INSUFFICIENT_BALANCE`.
Nothing leaves `SHIPPING-CAMPAIGNS` except into a campaign pot; no account goes below zero.

---

# Part 3 — Values and error codes

| Value | Options |
|---|---|
| `CampaignCreator` | `SHIPPER`, `SELLER`, `NEXGATE` (also the stacking order) |
| `CampaignKind` | `FREE`, `PERCENT`, `FIXED` |
| `CampaignAudience` | `EVERYONE`, `FIRST_ORDER` |
| `CampaignStatus` (filter) | `SCHEDULED`, `RUNNING`, `PAUSED`, `ENDED`, `CANCELLED` |
| `CampaignState` (shown) | the statuses + `RAN_OUT` |

| Code | When |
|------|------|
| `CAMPAIGN_KIND_NOT_ALLOWED` | a shipper asked for `FREE` |
| `CAMPAIGN_VALUE_INVALID` | `FREE` with a value, a percent outside 1–100, `FIXED` without an amount |
| `CAMPAIGN_CAP_REQUIRED` | a seller `FREE` / `PERCENT` without a cap |
| `CAMPAIGN_CAP_ABOVE_MINIMUM` | a seller's cap (or fixed amount) is bigger than the minimum order after commission |
| `CAMPAIGN_END_REQUIRED` | a NexGate campaign without an end date |
| `CAMPAIGN_DATES_INVALID` | end not after the start, or in the past |
| `CAMPAIGN_FIELD_NOT_ALLOWED` | a field this creator cannot use (from areas, shops, products, spending limit) |
| `CAMPAIGN_BUDGET_REQUIRED` | a NexGate campaign without a budget |
| `CAMPAIGN_BUDGET_LOW` | `SHIPPING-CAMPAIGNS` holds less than the budget / top-up (message says how much) |
| `CAMPAIGN_CLOSED` | editing, pausing, resuming or topping up an ended / cancelled campaign |
| `CAMPAIGN_CREATOR_FIXED` | changing `creator` on edit |
| `CAMPAIGN_HAS_NO_POT` | top-up on a seller / shipper campaign |
| `CAMPAIGN_ENDED` | at payment: a campaign ended, ran out or no longer fits since checkout (`details` = `{ shopId, businessId, businessName, before, after }`) |
| `SHOP_REQUIRED` / `BUSINESS_REQUIRED` | seller / shipper campaign without its shop / business |
| `NOT_YOUR_SHOP` / `NOT_YOUR_BUSINESS` / `ADMIN_ONLY` | not allowed to run this creator's campaigns |
| `AREA_NOT_FOUND` · `SHOP_NOT_FOUND` · `PRODUCT_NOT_IN_SHOP` | a bad area, shop or product id |
| `REFERENCE_ALREADY_USED` · `TRANSFER_NOT_ALLOWED` · `INSUFFICIENT_BALANCE` | system accounts |