Skip to content

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.

LevelGuardAdmits
ViewrequireArea('b2b')super_admin, or an admin whose managedAreas include b2b
ManagerequireAreaManage('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.

GroupReadsWrites
/admin/companiesRole + b2b ViewRole + b2b Manage
/admin/companies/invitessuper_admin only
/admin/quotesRole + b2b ViewRole + b2b Manage
/admin/contractsRole + b2b ViewRole + b2b Manage
/admin/linksRole + 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.

FromActionToWho
POST /b2b/quotesdraftCompany admin
draftPOST /b2b/quotes/:id/submitsubmittedCompany admin
submittedPOST /admin/quotes/:id/start-reviewunder_reviewInternal admin
submitted, under_reviewPOST /admin/quotes/:id/countercountered (this version) + a new under_review versionInternal admin
under_review (admin-authored)POST /b2b/quotes/:id/acceptacceptedCompany admin
under_review (admin-authored)POST /b2b/quotes/:id/rejectrejectedCompany admin
draft, submitted, under_reviewPOST /admin/quotes/:id/rejectrejectedInternal admin
company-authored submitted/under_reviewPOST /admin/quotes/:id/confirm-contractaccepted (implicitly) + a new contractInternal admin
acceptedPOST /admin/quotes/:id/confirm-contractunchanged + a new contractInternal 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

ParameterTypeDefaultDescription
statusstringpending, approved, rejected or suspended
searchstringCase-insensitive substring match on company name or CR number
pagenumber1Page number
limitnumber201–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

FieldTypeRequiredDescription
phonestringConditionalSaudi E.164. At least one of phone or email.
emailstringConditionalAt least one of phone or email.
namestringYes1–255 characters
rolestringYesadmin or member — the company-level role
companyIdstringConditionalRequired when role is member

Response

201 Created

json
{
  "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

CodeStatusCondition
VALIDATION_ERROR400Body invalid, including the member + companyId rule
AUTH_FORBIDDEN403Caller is not super_admin
COMPANY_NOT_FOUND404The given companyId does not exist
ADMIN_USER_PHONE_EXISTS409Phone already registered
ADMIN_USER_EMAIL_EXISTS409Email already registered

GET /admin/companies/:id

One company.

Requires role + b2b View.

Error Responses

CodeStatusCondition
VALIDATION_ERROR400id is not a valid UUID
COMPANY_NOT_FOUND404No such company

POST /admin/companies/:id/status

Move a company through its lifecycle.

Requires role + b2b Manage.

Lifecycle

ActionFromTo
approvependingapproved
rejectpendingrejected
suspendapprovedsuspended
reactivatesuspendedapproved

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

FieldTypeRequiredDescription
actionstringYesapprove, reject, suspend or reactivate
reasonstringNo500 characters or less. Goes to the audit row only — it is not stored on the company.

Response

200 OK — the updated company.

Error Responses

CodeStatusCondition
VALIDATION_ERROR400Body or id invalid
AUTH_FORBIDDEN403Caller lacks b2b Manage
COMPANY_NOT_FOUND404No such company
COMPANY_INVALID_TRANSITION409The 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

ParameterTypeDefaultDescription
statusstringMatches the chain's live version. draft is not accepted.
companyIdstringRestrict to one company
pagenumber1Page number
limitnumber201–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

CodeStatusCondition
VALIDATION_ERROR400id is not a valid UUID
QUOTE_NOT_FOUND404No such quote, or it is a draft

POST /admin/quotes/:id/start-review

submittedunder_review. Stamps reviewedBy/reviewedAt. No request body.

Requires role + b2b Manage.

This is an explicit action, never implicit on read.

Error Responses

CodeStatusCondition
QUOTE_NOT_FOUND404No such quote
QUOTE_INVALID_STATE409The 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

FieldTypeRequiredDescription
itemsarrayYes1–200 entries of { packageId, qty }. A counter with no items is not a counter.
notestringNo500 characters or less

Response

200 OK — the new version's detail.

Error Responses

CodeStatusCondition
VALIDATION_ERROR400Body or id invalid
QUOTE_PACKAGE_NOT_FOUND400Unknown or not-B2B-orderable packageId
QUOTE_NOT_FOUND404No such quote
QUOTE_INVALID_STATE409Not submitted or under_review
QUOTE_COUNTER_FAILED502The 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

FieldTypeRequiredDescription
notestringNo500 characters or less. Replaces the existing note when given. Never touches companyNote.

Error Responses

CodeStatusCondition
QUOTE_NOT_FOUND404No such quote
QUOTE_INVALID_STATE409The 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: totalSar is the quote's declared needs.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

CodeStatusCondition
VALIDATION_ERROR400id invalid
CONTRACT_PACKAGE_PRICE_MISSING400Prepaid: a quoted package has no priceSarB2b (the packageId is in the details)
QUOTE_NOT_FOUND404No such quote
QUOTE_INVALID_STATE409The 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_EXISTS409The company already has an active contract
CONTRACT_CREATE_FAILED502The 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

json
{
  "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 }
    ]
  }
}
FieldMeaning
paymentModeThe 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
drawnSarRealized charges — one per successful activation, the sum over this company's paid orders whose activation is still alive
committedSarSeats already consumed but with no live charge yet
availableSarfundedSar - drawnSar — funded minus drawn. committedSar is advisory only: the link-mint gate projects with it, the activation gate does not
activeLinesOne 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 — amountSar must 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

FieldTypeRequiredDescription
methodstringYesbank_transfer or cheque
amountSarnumberYesPositive, 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.
documentobjectYesThe proof file
document.namestringYes1–255 characters
document.mimestringYesapplication/pdf, image/png or image/jpeg
document.dataBase64stringYesValid base64, decoding to 5MB or less
idempotencyKeystringYes8–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.

json
{
  "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

CodeStatusCondition
VALIDATION_ERROR400Body 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_MIME400Mime not in the allowed set — the service's post-decode re-check
CONTRACT_PAYMENT_DOCUMENT_TOO_LARGE400Decoded bytes exceed 5MB — the service's post-decode re-check
AUTH_FORBIDDEN403Caller lacks b2b Manage
CONTRACT_NOT_FOUND404No such contract
CONTRACT_PAYMENT_MODE_INVALID409The contract's payment mode is neither prepaid nor flexible (legacy/invalid data — every contract is one or the other today)
CONTRACT_PAYMENT_BELOW_TOTAL409Prepaid only: amountSar is below the contract total
CONTRACT_PAYMENT_IDEMPOTENCY_CONFLICT409The idempotencyKey was already used with a different method or amount
CONTRACT_PAYMENT_RECORD_FAILED502The 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.pdf concerns the same payment but returns something else entirely — a generated metadata receipt that deliberately never reads document_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.pdf

The 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

CodeStatusCondition
VALIDATION_ERROR400id or paymentId is not a valid UUID
AUTH_FORBIDDEN403Caller lacks b2b Manage
CONTRACT_PAYMENT_DOCUMENT_NOT_FOUND404No such payment on that contract, or it has no stored document

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

ParameterTypeDefaultDescription
statusstringissued, sent, exported, used, expired, failed or revoked
searchstringMatches person name or company name
pagenumber1Page number
limitnumber201–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

json
{
  "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.


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: true

Columns: 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.

Internal documentation - Activation System