B2B Administration API
The internal side of the B2B portal: company approval, quote review, contract confirmation, funding balance oversight, payment recording, and magic-link oversight. Three routers share the /admin mount — companies.ts, contracts.ts and links.ts.
For the company-facing half see B2B Portal.
Authorization
Every group applies requireRole('super_admin', 'admin') before requireArea('b2b'). The order is load-bearing: the external portal role b2b_admin also holds the b2b area, so an area check alone would admit external company staff into oversight.
| Level | Guard | Admits |
|---|---|---|
| View | requireArea('b2b') | super_admin, or an admin whose managedAreas include b2b |
| Manage | requireAreaManage('b2b') | super_admin, or an admin whose managedAreasWrite include b2b |
Area membership is resolved from the database at request time, never frozen in the JWT. See Admin Users for how areas are assigned.
| Group | Reads | Writes |
|---|---|---|
/admin/companies | Role + b2b View | Role + b2b Manage |
/admin/companies/invites | — | super_admin only |
/admin/quotes | Role + b2b View | Role + b2b Manage |
/admin/contracts | Role + b2b View | Role + b2b Manage |
/admin/links | Role + b2b View | — (read-only group) |
Inviting portal users is stricter than the section's own gate: platform admins do not hold that power, only the owner does.
The payment-document download is a read but uses Manage, because those files carry financial and personal content.
Quote State Machine
Read from quote-service.ts. A quote is versioned and immutable once submitted. countered, accepted, rejected and expired are terminal.
| From | Action | To | Who |
|---|---|---|---|
| — | POST /b2b/quotes | draft | Company admin |
draft | POST /b2b/quotes/:id/submit | submitted | Company admin |
submitted | POST /admin/quotes/:id/start-review | under_review | Internal admin |
submitted, under_review | POST /admin/quotes/:id/counter | countered (this version) + a new under_review version | Internal admin |
under_review (admin-authored) | POST /b2b/quotes/:id/accept | accepted | Company admin |
under_review (admin-authored) | POST /b2b/quotes/:id/reject | rejected | Company admin |
draft, submitted, under_review | POST /admin/quotes/:id/reject | rejected | Internal admin |
company-authored submitted/under_review | POST /admin/quotes/:id/confirm-contract | accepted (implicitly) + a new contract | Internal admin |
accepted | POST /admin/quotes/:id/confirm-contract | unchanged + a new contract | Internal admin |
A counter is one transaction: the old version flips to countered (which is exactly what "superseded" means) and a new version is created with version + 1, parentId set to the old version, and status under_review. parentId is server-set and never client input.
Because only a counter ever sets parentId, parentId === null doubles as "company-authored" and parentId !== null as "admin-authored". No extra column is needed.
expired exists in the status union and in the terminal-status list, but no code path writes it. There is no quote expiry mechanism.
Within a chain, the tip version — the one with no child — is the only actionable one. Status filters on the list endpoints match that live version, which is why a filter on countered can never match a chain.
Drafts are the company's private working state. They are excluded from the admin review queue, and GET /admin/quotes/:id returns QUOTE_NOT_FOUND for one so a direct URL cannot leak it.
GET /admin/companies
List companies, newest first.
Requires role + b2b View.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | pending, approved, rejected or suspended |
search | string | — | Case-insensitive substring match on company name or CR number |
page | number | 1 | Page number |
limit | number | 20 | 1–100 |
Response
200 OK — { "data": { "companies": [ ... ], "total": 12, "page": 1, "limit": 20 } }.
POST /admin/companies/invites
Provision a portal user with the b2b_admin role.
Requires super_admin. The section-wide role + b2b View guard runs first, then a route-level requireRole('super_admin') — so a platform admin holding the b2b area is still rejected here.
Creation goes through the same provisioning path as Admin Users — duplicate checks, rollback on failure, and invitation delivery included.
The first admin invite has no company yet: their own registration creates it. A member viewer or an additional admin joins an existing company, so companyId is required for a member.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | Conditional | Saudi E.164. At least one of phone or email. |
email | string | Conditional | At least one of phone or email. |
name | string | Yes | 1–255 characters |
role | string | Yes | admin or member — the company-level role |
companyId | string | Conditional | Required when role is member |
Response
201 Created
{
"data": {
"user": { "id": "...", "phone": "+966501234567", "email": null, "name": "Layla Ahmed", "role": "b2b_admin", "isActive": true, "createdAt": "2026-07-18T08:00:00.000Z" },
"companyId": null,
"companyRole": "admin"
}
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Body invalid, including the member + companyId rule |
AUTH_FORBIDDEN | 403 | Caller is not super_admin |
COMPANY_NOT_FOUND | 404 | The given companyId does not exist |
ADMIN_USER_PHONE_EXISTS | 409 | Phone already registered |
ADMIN_USER_EMAIL_EXISTS | 409 | Email already registered |
GET /admin/companies/:id
One company.
Requires role + b2b View.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id is not a valid UUID |
COMPANY_NOT_FOUND | 404 | No such company |
POST /admin/companies/:id/status
Move a company through its lifecycle.
Requires role + b2b Manage.
Lifecycle
| Action | From | To |
|---|---|---|
approve | pending | approved |
reject | pending | rejected |
suspend | approved | suspended |
reactivate | suspended | approved |
Anything else is 409 COMPANY_INVALID_TRANSITION with { from, action } in the error details, and nothing is written.
approve is the only action that stamps approvedBy/approvedAt, and the only one that notifies. It sends a bilingual notice to the company's admin members over both email and SMS, best-effort — a delivery failure does not fail the action. reactivate does neither: it moves the status back to approved without re-stamping the approver.
suspend revokes every company member's live sessions through the Redis blocklist and blocks new provisioning. Suspension is access-level only and never touches live SIMs.
reject and reactivate send no notification.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
action | string | Yes | approve, reject, suspend or reactivate |
reason | string | No | 500 characters or less. Goes to the audit row only — it is not stored on the company. |
Response
200 OK — the updated company.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Body or id invalid |
AUTH_FORBIDDEN | 403 | Caller lacks b2b Manage |
COMPANY_NOT_FOUND | 404 | No such company |
COMPANY_INVALID_TRANSITION | 409 | The action is not legal from the current status |
GET /admin/quotes
The review queue, grouped for display by company: company name A→Z, then latest activity first within a company. Each chain carries companyName so the client can render group headers.
Requires role + b2b View.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | Matches the chain's live version. draft is not accepted. |
companyId | string | — | Restrict to one company |
page | number | 1 | Page number |
limit | number | 20 | 1–100 |
Response
200 OK — { "data": { "chains": [ ... ], "total": 7, "page": 1, "limit": 20 } }. Each chain is { ref, live, history, companyName }, matching the portal list shape plus companyName.
GET /admin/quotes/catalog
The same B2B-priced catalog the portal wizard uses, for the counter-quote line-item picker. Names resolve from Accept-Language.
Requires role + b2b View.
Registered before /admin/quotes/:id so the literal catalog segment never matches the id parameter.
Response
200 OK — the array documented at GET /b2b/catalog.
GET /admin/quotes/:id
One version with its items and version chain.
The version summary carries companyNote alongside note. note is this side's — set by a counter or an admin rejection. companyNote is the company's own reason for declining a counter, written from the portal, and is read-only here: no admin route writes it. Both can be populated on the same version, since a declined counter keeps the counter note in note and the decline reason in companyNote.
Requires role + b2b View.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id is not a valid UUID |
QUOTE_NOT_FOUND | 404 | No such quote, or it is a draft |
POST /admin/quotes/:id/start-review
submitted → under_review. Stamps reviewedBy/reviewedAt. No request body.
Requires role + b2b Manage.
This is an explicit action, never implicit on read.
Error Responses
| Code | Status | Condition |
|---|---|---|
QUOTE_NOT_FOUND | 404 | No such quote |
QUOTE_INVALID_STATE | 409 | The quote is not submitted |
POST /admin/quotes/:id/counter
Counter a quote. Allowed from submitted or under_review.
Requires role + b2b Manage.
One transaction: the old version becomes countered, and a new admin-authored version is created under_review with the countered items. The needs-form answers carry over unchanged so they stay visible on every version.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
items | array | Yes | 1–200 entries of { packageId, qty }. A counter with no items is not a counter. |
note | string | No | 500 characters or less |
Response
200 OK — the new version's detail.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Body or id invalid |
QUOTE_PACKAGE_NOT_FOUND | 400 | Unknown or not-B2B-orderable packageId |
QUOTE_NOT_FOUND | 404 | No such quote |
QUOTE_INVALID_STATE | 409 | Not submitted or under_review |
QUOTE_COUNTER_FAILED | 502 | The new version insert returned no row |
POST /admin/quotes/:id/reject
Any non-terminal status → rejected, with an optional reviewer note.
This is the internal admin's rejection. A company declining an admin counter is the separate POST /b2b/quotes/:id/reject — same destination status, different writer, a different audit action (company.quote_decline rather than company.quote_reject), and it writes companyNote rather than note.
Requires role + b2b Manage.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
note | string | No | 500 characters or less. Replaces the existing note when given. Never touches companyNote. |
Error Responses
| Code | Status | Condition |
|---|---|---|
QUOTE_NOT_FOUND | 404 | No such quote |
QUOTE_INVALID_STATE | 409 | The quote is already in a final state |
POST /admin/quotes/:id/confirm-contract
Turn an agreed quote version into a contract, atomically.
Requires role + b2b Manage.
Eligible when the version is accepted, or it is company-authored and submitted/under_review — submitting your own numbers is accepting them. An admin-authored counter the company has not explicitly accepted is rejected: the company's consent to what it is charged for is required.
No request body. The payment mode is no longer client input — the contract inherits the company's own pre-picked companies.paymentMode (set on the portal via PATCH /b2b/company/payment-mode). Confirming before the company has picked a mode is rejected.
Prepaid and flexible compute the total differently, both server-side and both snapshotted at this moment — no client input either way:
- Prepaid: line items, unit prices and the total are computed from the quote's items, snapshotting
packages.priceSarB2b. Every quoted package must carry a non-null B2B price or the whole confirm fails — there is no partial contract. - Flexible:
totalSaris the quote's declaredneeds.fundingSar, and the contract gets zero line items, ever. A flexible contract IS the funded amount — packages are picked later from the catalog while staffing, at magic-link mint time, never on the contract itself.
The single-active-contract rule
The service refuses to create a second contract while the company has one with status active, returning 409 CONTRACT_ALREADY_EXISTS. This also blocks re-confirming a later flexible top-up request into a second contract — a top-up's funds are added by recording a payment against the existing contract instead, not by confirming a new one.
This rule is enforced at the database layer. A partial unique index, idx_contracts_one_active_per_company, is defined on contracts (company_id) WHERE status = 'active' — two concurrent confirmations for the same company serialize on it, and the loser gets a constraint violation rather than a second active contract. The application-level check above runs first only to turn that failure mode into the friendly 409 CONTRACT_ALREADY_EXISTS instead of a raw database error reaching the client.
Response
201 Created — the contract detail, shape as at GET /b2b/contract.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id invalid |
CONTRACT_PACKAGE_PRICE_MISSING | 400 | Prepaid: a quoted package has no priceSarB2b (the packageId is in the details) |
QUOTE_NOT_FOUND | 404 | No such quote |
QUOTE_INVALID_STATE | 409 | The company has not accepted this version, the company has not chosen a payment mode yet, prepaid has no items to confirm, or flexible has no declared needs.fundingSar |
CONTRACT_ALREADY_EXISTS | 409 | The company already has an active contract |
CONTRACT_CREATE_FAILED | 502 | The contract insert returned no row |
GET /admin/contracts/company/:companyId
The company's active contract, or null. Same shape as the portal's read.
Requires role + b2b View.
GET /admin/contracts/company/:companyId/readiness
The three readiness gates for a company. Same shape as GET /b2b/readiness.
Requires role + b2b View.
An unknown companyId returns all-false rather than a 404 — the read never verifies the company exists.
GET /admin/contracts/company/:companyId/balance
Flexible pay's funding balance — the same read the flexible gates and both billing screens share.
Requires role + b2b View. No Manage gate, so a View-only admin sees the numbers behind an incoming payment.
Response
200 OK
{
"data": {
"paymentMode": "flexible",
"fundedSar": 50000.0,
"drawnSar": 1548.0,
"committedSar": 387.0,
"availableSar": 48452.0,
"activeLines": [
{ "packageId": "e5f6a7b8-c901-2345-efab-345678901234", "nameEn": "Business 20GB", "nameAr": "أعمال 20 جيجابايت", "count": 12, "drawnSar": 1548.0 }
]
}
}| Field | Meaning |
|---|---|
paymentMode | The active contract's mode. Defaults to prepaid when there is no active contract, from the company's own picked mode |
fundedSar | Σ recorded contract_payments on the active contract — the initial funding plus every top-up |
drawnSar | Realized charges — one per successful activation, the sum over this company's paid orders whose activation is still alive |
committedSar | Seats already consumed but with no live charge yet |
availableSar | fundedSar - drawnSar — funded minus drawn. committedSar is advisory only: the link-mint gate projects with it, the activation gate does not |
activeLines | One entry per package with a live activated line: count and drawnSar over that package |
An order whose activation is failed or cancelled is excluded from drawnSar. Its seat flows into committedSar instead, so each consumed seat contributes exactly once.
A company with no active contract reports zero for every figure, activeLines: [], and paymentMode falling back to the company's own picked mode (prepaid if unpicked) — nothing to gate. A prepaid company returns the same zeroed shape harmlessly. An unknown companyId also returns zeros rather than a 404.
POST /admin/contracts/:id/payments
Record a payment. Manual channels only — there is no online payment anywhere in this flow. The same route serves both modes: a prepaid pay-in-full and every flexible funding or top-up amount.
Requires role + b2b Manage.
- Prepaid: pay-in-full —
amountSarmust be at least the contract total. There are no partial records and no balance ledger. - Flexible: any positive amount is accepted. The initial funding and every later top-up are the same call — there is no separate "confirm this top-up" step. A company can request a top-up from its portal (
/b2b/funding, a zero-item quote request declaring the amount), but recording is never blocked on a request existing — money simply lands when the admin records it.
The document is required — every manual payment carries its proof. Bytes are stored in the database, size-capped at 5MB. The mime type and decoded size are re-checked server-side as defense in depth even though the schema already restricts both.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
method | string | Yes | bank_transfer or cheque |
amountSar | number | Yes | Positive, at most 99,999,999.99 (the numeric(10,2) column ceiling). Prepaid additionally requires at least the contract total — enforced in the service, not the schema. |
document | object | Yes | The proof file |
document.name | string | Yes | 1–255 characters |
document.mime | string | Yes | application/pdf, image/png or image/jpeg |
document.dataBase64 | string | Yes | Valid base64, decoding to 5MB or less |
idempotencyKey | string | Yes | 8–128 characters. Replay guard on a screen with no void or delete path: the payment row id is derived from this key, so replaying the same key returns the original record instead of inserting a second one. |
Response
201 Created — the payment metadata. documentBytes is never returned.
{
"data": {
"id": "c9012345-6789-abcd-ef01-789012345678",
"contractId": "a7b8c901-2345-6789-abcd-567890123456",
"method": "bank_transfer",
"amountSar": 5160.0,
"documentName": "transfer-receipt.pdf",
"documentMime": "application/pdf",
"hasDocument": true,
"confirmedBy": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"confirmedByName": null,
"confirmedAt": "2026-07-19T13:30:00.000Z",
"createdAt": "2026-07-19T13:30:00.000Z"
}
}confirmedByName is only resolved by the list endpoint's join, so it is null here.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Body or id invalid. A missing document or missing idempotencyKey fails here, as a plain schema error with no business code — as do malformed base64 and the schema's own 5MB check. |
CONTRACT_PAYMENT_DOCUMENT_INVALID_MIME | 400 | Mime not in the allowed set — the service's post-decode re-check |
CONTRACT_PAYMENT_DOCUMENT_TOO_LARGE | 400 | Decoded bytes exceed 5MB — the service's post-decode re-check |
AUTH_FORBIDDEN | 403 | Caller lacks b2b Manage |
CONTRACT_NOT_FOUND | 404 | No such contract |
CONTRACT_PAYMENT_MODE_INVALID | 409 | The contract's payment mode is neither prepaid nor flexible (legacy/invalid data — every contract is one or the other today) |
CONTRACT_PAYMENT_BELOW_TOTAL | 409 | Prepaid only: amountSar is below the contract total |
CONTRACT_PAYMENT_IDEMPOTENCY_CONFLICT | 409 | The idempotencyKey was already used with a different method or amount |
CONTRACT_PAYMENT_RECORD_FAILED | 502 | The insert returned no row |
GET /admin/contracts/:id/payments
The contract's payments, newest first. Metadata only — documentBytes is not selected.
Requires role + b2b View.
The read never verifies the contract exists. An unknown id returns { "data": [] }, not a 404.
Each row adds confirmedByName, resolved by a left join onto users. It is null when confirmedBy is null or no longer resolves.
hasDocument is derived from documentName being present, since a document's three columns are always written together.
GET /admin/contracts/:id/payments/:paymentId/document
Download the uploaded proof file — the exact bytes the admin attached when recording the payment. This is the one route that ever streams documentBytes.
Requires role + b2b Manage — the same level as the payment writes, because these files carry financial and personal content.
Not the same resource as the generated receipt.
GET /documents/contract_payment/:id.pdfconcerns the same payment but returns something else entirely — a generated metadata receipt that deliberately never readsdocument_bytes. This route returns the stored upload. Swapping one for the other yields the wrong bytes. See Documents.
Response
200 OK — the raw stored bytes.
Content-Type is the stored mime, so the response is not always a PDF. It is whichever of application/pdf, image/png or image/jpeg was uploaded.
Content-Disposition: attachment; filename="transfer-receipt.pdf"; filename*=UTF-8''transfer-receipt.pdfThe filename was client-supplied at upload with no character restriction, so the header carries an ASCII-sanitized quoted fallback plus the real name in RFC 5987 filename*. Clients must prefer filename* — the name can be Arabic.
Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | id or paymentId is not a valid UUID |
AUTH_FORBIDDEN | 403 | Caller lacks b2b Manage |
CONTRACT_PAYMENT_DOCUMENT_NOT_FOUND | 404 | No such payment on that contract, or it has no stored document |
GET /admin/links
Cross-company magic-link delivery oversight: a filterable table plus per-status tiles.
Requires role + b2b View. Read-only group.
Destinations are masked server-side. Raw tokens and token hashes never appear.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | issued, sent, exported, used, expired, failed or revoked |
search | string | — | Matches person name or company name |
page | number | 1 | Page number |
limit | number | 20 | 1–100 |
The status filter applies the same lazy-expiry rule the rows display: filtering issued never surfaces a link already past its expiresAt, and filtering expired catches those too, not only rows already written expired.
Response
200 OK
{
"data": {
"links": [
{
"id": "e0123456-789a-bcde-f012-901234567890",
"personName": "Omar Saleh",
"companyName": "Darmi Logistics",
"channel": "email",
"destination": "o•••@darmi.example",
"status": "sent",
"expiresAt": "2026-07-21T10:00:00.000Z",
"lastUpdate": "2026-07-20T10:00:00.000Z"
}
],
"total": 1,
"page": 1,
"limit": 20,
"stats": { "sent24h": 34, "used": 12, "exported": 8, "pending": 18, "failed": 1, "expired": 3 }
}
}channel is one of email, sms, email_sms, export or null. It is derived from the raw stored status: exported is the only mode the schema still distinguishes once a link is redeemed, so used, revoked and failed rows fall back to contact-presence derivation. The schema stores neither channel nor destination.
Masking keeps the email domain in full and only masks the local part with a fixed bullet count, so the mask never leaks the real length. A phone keeps a short prefix and the last two digits. An export channel has no contact to mask and renders the literal marker company_channel.
stats is cross-company and independent of the current filters. sent24h is a rolling window and approximates "delivery, not export" via the raw status.
GET /admin/links/export
The same filtered rows as CSV, newest first, with the same masking.
Requires role + b2b View. Audited in the service.
Accepts status and search. There is no pagination — the result is capped at 5000 rows server-side.
Response
200 OK
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="links.csv"
X-Truncated: trueColumns: person,company,channel,destination,status,expires_at,last_update.
X-Truncated: true is present only when the cap clipped the result. There is no marker row in the CSV body.