Vendor Module — Admin
HTTP surface for the platform-admin side of vendor onboarding: list / inspect vendor applications, approve them (provisioning a Better-Auth organization + vendor profile) or…
HTTP surface for the platform-admin side of vendor onboarding: list / inspect vendor applications, approve them (provisioning a Better-Auth organization + vendor profile) or reject them with a reason; and list / inspect existing approved vendors via the vendor directory. The vendor-self application submit / "my applications" endpoints live in the same module but are out of scope here (see vendor/vendor.md).
Source:
api-modules/vendor/src/controllers/vendor-admin.controller.ts,api-modules/vendor/src/controllers/vendor-management.controller.ts. The vendor-selfvendor-application.controller.tsbelongs to the vendor folder, not this one.Approving an application provisions a Better-Auth organization (the vendor's tenant within the platform) and creates the vendor profile row. The applicant becomes the organization's first
ownermember.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching vendor:* permission.
| Endpoint group | Permission |
|---|---|
GET /admin/vendor/applications, GET /admin/vendor/applications/:id | vendor: view |
POST /admin/vendor/applications/:id/approve, POST /admin/vendor/applications/:id/reject | vendor: approve |
GET /admin/vendors, GET /admin/vendors/:id, GET /admin/vendors/:id/analytics/dashboard | vendor: view |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT, UNIQUE_VIOLATION (slug taken at approval time) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Application lifecycle
vendor_application.status values:
| Status | Set by | Reversible |
|---|---|---|
pending | POST /vendor/registration (vendor-self) | yes — admin acts on it |
approved | POST /admin/vendor/applications/:id/approve | no |
rejected | POST /admin/vendor/applications/:id/reject | no — applicant must submit a new application |
A user may have at most one application in pending (enforced on the vendor-self submit side).
Domain types
ApplicationResponse
type ApplicationStatus = "pending" | "approved" | "rejected";
type ApplicationResponse = {
id: string;
userId: string; // applicant's user id
applicant: { id: string; name: string; email: string } | null;
businessName: string; // shop name from the registration wizard
slug: string; // derived from the name; unique on approval
businessEmail: string;
businessPhone: string;
businessDescription: string;
country: string | null; // shop country
motivation: string | null; // "what brings you" onboarding answer
categories: string[]; // category ids of interest
bank: { // payout snapshot; null until collected
bankName: string;
accountHolderName: string;
accountNumber: string; // masked to last 4 in the list; raw in detail
routingNumber: string | null;
swiftCode: string | null;
} | null;
status: ApplicationStatus;
rejectionReason: string | null;
reviewedBy: string | null; // admin user id
reviewer: { id: string; name: string; email: string } | null;
reviewedAt: string | null; // ISO
createdAt: string;
updatedAt: string;
};Account-number masking: the list endpoint masks
bank.accountNumberto its last 4 digits; the application-detail endpoint returns the raw row (full number) for vetting.
Vendor profile (admin reads)
The admin vendor list returns the Better-Auth organization joined with the vendor profile plus a team-member count. Exact shape lives in VendorProfileService; at minimum:
type VendorProfileSummary = {
id: string; // == organization id (the vendor id used everywhere else)
businessName: string;
slug: string;
businessEmail: string;
businessPhone: string;
businessDescription: string;
memberCount: number;
createdAt: string;
updatedAt: string;
members?: Array<{ // only on the detail endpoint
userId: string;
email: string;
name: string;
role: string; // "owner" | "admin" | "member"
joinedAt: string;
}>;
};Applications
Base path: /admin/vendor/applications.
GET /admin/vendor/applications — List applications
Required permission: vendor: view. Standard QueryDto (page / limit / search / filters[]).
Response 200 — paginated envelope of ApplicationResponse[].
GET /admin/vendor/applications/:id — Application detail
Required permission: vendor: view. Returns ApplicationResponse plus the applicant's user details (name, email, image) — joined in the service. Also returns a top-level reviewer: { id: string; name: string; email: string } | null (the admin who reviewed the application; null while pending).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/vendor/applications/:id/approve — Approve
Required permission: vendor: approve. Allowed only from pending.
Side effects
- Validates
sluguniqueness against the organization slug column. On collision → 409UNIQUE_VIOLATION. - Creates a new Better-Auth organization with
slug = application.slugandname = businessName. - Adds the applicant as the organization's
ownermember. - Creates the
vendor_profilerow pointing at the organization (carryingcountryfrom the application). - When the application captured payout bank details, materialises a
vendor_bank_accountrow from the snapshot (1:1), in the same transaction. - Stamps
status="approved",reviewedBy = current admin user id,reviewedAt. - Emits
vendor.application.approved(consumed by notifications — sends a welcome email).
The applicant's existing session does not automatically gain access to the new organization — they need to renew the session so Better-Auth surfaces the new activeOrganizationId.
Response 200 — updated ApplicationResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown application id |
| 409 | CONFLICT | Application is not pending |
| 409 | UNIQUE_VIOLATION | slug already taken by another organization since submission |
POST /admin/vendor/applications/:id/reject — Reject
Required permission: vendor: approve. Allowed only from pending.
Body
{ "reason": "Required documents not provided" }| Field | Type | Constraints |
|---|---|---|
reason | string | 1..2000 |
Side effects
- Stamps
status="rejected",rejectionReason,reviewedBy,reviewedAt. - Emits
vendor.application.rejected(consumed by notifications — sends an email with the reason).
Response 200 — updated ApplicationResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Empty / oversized reason |
| 404 | NOT_FOUND | Unknown application id |
| 409 | CONFLICT | Application is not pending |
Vendor directory
Base path: /admin/vendors.
GET /admin/vendors — List approved vendors
Required permission: vendor: view. Standard QueryDto. Returns approved vendors with profile + member count.
Response 200 — paginated envelope of VendorProfileSummary[] (without the members[] field).
GET /admin/vendors/:id — Vendor detail
Required permission: vendor: view. Returns the full VendorProfileSummary with members[] populated.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown vendor id |
GET /admin/vendors/:id/analytics/dashboard — Vendor analytics (admin scope)
Required permission: vendor: view. Admin-scoped read of a single vendor's precomputed analytics snapshot — the same payload the vendor sees on their own dashboard (GET /vendor/analytics/dashboard). No new computation: served from the vendor_analytics_snapshot row maintained by the vendor-analytics refresh job.
Query
| Param | Type | Default | Notes |
|---|---|---|---|
window | today | 7d | 30d | all_time | 30d | Snapshot window |
Response 200 — VendorAnalyticsDashboard (headline KPIs: grossSales, netEarnings, commissionPaid, ordersCount, unitsSold, aov, fulfillment timing, product/inventory counts; plus salesTrend, statusBreakdown, topProductsByRevenue/ByUnits, lowStockList, deltas). A vendor with no snapshot yet returns a zeroed payload with computedAt: null.
Domain events
Emitted via EventEmitter2. Listeners include the notifications module (onboarding / rejection emails).
| Event | Fired when |
|---|---|
vendor.application.approved | POST /admin/vendor/applications/:id/approve |
vendor.application.rejected | POST /admin/vendor/applications/:id/reject |
Related modules
admin-rbac— gates every endpoint viavendor:view/vendor:approve. Seeadmin-rbac.md.auth— owns the Better-Authuser+organization+membertables. Approval creates an organization row.settings— vendor self-service settings (shipping, tax, etc.) are scoped to the organization id created here. The platform-admin override endpoints insettings.md/shipping.md/tax.mduse the same vendor id.notifications— consumes thevendor.application.*events to email the applicant.
Tax Module — Admin
HTTP surface for the platform-admin override of any vendor's flat-tax config — the list of tax lines (e.g. { type: "GST", rate: 18 }) that the flat tax provider applies to that…
Analytics Module — Vendor surface
Vendor-facing HTTP surface for precomputed dashboard snapshots (sales, earnings, fulfilment, inventory KPIs for today / 7d / 30d / all-time) and a live SSE paid-order feed.