Affiliate Module — Admin
HTTP surface for platform staff managing the affiliate plugin: review applications, manage affiliates (incl. suspend/resume + per-affiliate commission overrides), configure…
HTTP surface for platform staff managing the affiliate plugin: review applications, manage affiliates (incl. suspend/resume + per-affiliate commission overrides), configure per-entity commission overrides (product/brand/vendor/category/tag), and process payouts. Four new RBAC resources gate the surface (affiliateApplication, affiliateProfile, affiliateOverride, affiliatePayout) — granted by default to the built-in superAdmin and admin roles.
Source:
api-modules/affiliate/src/controllers/admin-affiliate-application.controller.tsapi-modules/affiliate/src/controllers/admin-affiliate.controller.tsapi-modules/affiliate/src/controllers/admin-affiliate-override.controller.tsapi-modules/affiliate/src/controllers/admin-affiliate-payout.controller.tsPlugin configuration (default commission rate, repeat-order policy, TDS rate, approval cron, etc.) is not managed here — those are platform settings under group
affiliate, served by the generic/admin/settings/adminsurface (settings.md). The admin Affiliate section drives the operational surface only.
Conventions
Authentication
All endpoints require a Better-Auth admin session and the matching RBAC permission:
| Endpoint group | Permission |
|---|---|
GET /admin/affiliate/applications* | affiliateApplication: read |
POST /admin/affiliate/applications/:id/approve | affiliateApplication: review |
POST /admin/affiliate/applications/:id/reject | affiliateApplication: review |
GET /admin/affiliate/affiliates* (incl. /audit) | affiliateProfile: read |
PATCH /admin/affiliate/affiliates/:id | affiliateProfile: manage |
POST /admin/affiliate/affiliates/:id/suspend | affiliateProfile: suspend |
POST /admin/affiliate/affiliates/:id/resume | affiliateProfile: suspend |
GET /admin/affiliate/overrides/<entity>/:id | affiliateOverride: read |
PUT /admin/affiliate/overrides/<entity>/:id | affiliateOverride: manage |
DELETE /admin/affiliate/overrides/<entity>/:id | affiliateOverride: manage |
GET /admin/affiliate/payouts* | affiliatePayout: read |
POST /admin/affiliate/payouts | affiliatePayout: create |
POST /admin/affiliate/payouts/:id/mark-paid | affiliatePayout: process |
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* on paginated lists */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN (missing permission) |
| 404 | NOT_FOUND (application, affiliate, payout, override target) |
| 409 | CONFLICT (status transition not allowed; payout method missing; balance below threshold) |
| 500 | INTERNAL_SERVER_ERROR |
Money + units
| Field | Unit |
|---|---|
*Subunits | integer subunits (paise / cents) |
commissionRateBps / commissionValue (PERCENTAGE) | basis points, 0–10000 |
commissionValue (FIXED) | integer subunits per qty |
| Dates | ISO 8601 strings |
Domain types
ApplicationResponse
type ApplicationResponse = {
id: string;
customerId: string;
status: "PENDING" | "APPROVED" | "REJECTED";
websiteUrl: string | null;
instagramUrl: string;
additionalInfo: string | null;
rejectedReason: string | null;
reviewedBy: string | null;
reviewedAt: string | null;
createdAt: string;
updatedAt: string;
platforms: Array<{
platform: "INSTAGRAM" | "YOUTUBE" | "TIKTOK" | "FACEBOOK"
| "X_TWITTER" | "BLOG" | "NEWSLETTER" | "PODCAST" | "OTHER";
detailsText: string | null;
}>;
socialLinks: Array<{ url: string }>;
};AffiliateResponse
type AffiliateResponse = {
id: string;
customerId: string;
code: string; // unique 8-char referral code
promotedLandingUrl: string | null;
suspendedAt: string | null;
suspendedBy: string | null;
suspendReason: string | null;
// Payout identity (any subset may be null until first payout)
payoutMethod: "UPI" | "BANK" | null;
upiId: string | null;
bankAccountName: string | null;
bankAccountNumber: string | null;
bankIfsc: string | null;
panNumber: string | null;
gstin: string | null;
// Per-affiliate commission override — sits at the top of the
// precedence chain. null fields = inherit from chain.
commissionEnabled: boolean | null;
commissionType: "PERCENTAGE" | "FIXED" | null;
commissionValue: number | null;
// Lifetime aggregates, denormalized + bumped at order placement
lifetimeClicks: number;
lifetimeOrders: number;
lifetimeRevenueSubunits: number;
lifetimeCommissionSubunits: number;
createdAt: string;
updatedAt: string;
};OverrideResponse
type OverrideResponse = {
targetId: string;
enabled: boolean | null;
commissionType: "PERCENTAGE" | "FIXED" | null;
commissionValue: number | null;
};All three fields are nullable per the inheritance semantics. An all-null response is returned by GET when no override row exists for the target — i.e. "this entity inherits everything from the chain."
AuditResponse
type AuditResponse = {
id: string;
affiliateId: string;
/** Known values:
* AFFILIATE_CREATED on approve / auto-approve
* AFFILIATE_PROFILE_UPDATE PATCH endpoint + storefront payout-details PATCH
* AFFILIATE_SUSPEND
* AFFILIATE_RESUME
* COMMISSION_SKIP_SELF_REFERRAL order placed by affiliate themselves
* COMMISSION_SKIP_REPEAT_POLICY order blocked by repeat-order policy
*/
action: string;
actorId: string | null; // admin user.id, or null for system actions
before: Record<string, unknown> | null;
after: Record<string, unknown> | null;
reason: string | null;
createdAt: string;
};PayoutResponse
type PayoutResponse = {
id: string;
affiliateId: string;
status: "DRAFT" | "PROCESSING" | "PAID" | "FAILED";
method: "UPI" | "BANK";
grossSubunits: number;
tdsSubunits: number;
netSubunits: number; // grossSubunits = tdsSubunits + netSubunits (schema CHECK)
externalReference: string | null; // UTR / cheque / wire id; set on PAID
paidAt: string | null;
createdAt: string;
updatedAt: string;
};EligibleAffiliateResponse
type EligibleAffiliateResponse = {
affiliateId: string;
customerId: string;
eligibleSubunits: number; // sum of APPROVED commission_amount_subunits
commissionRowCount: number;
};Endpoints
Base path: /admin/affiliate.
Applications
GET /admin/affiliate/applications — Paginated application list
Permission: affiliateApplication: read.
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..50 |
status | PENDING | APPROVED | REJECTED | — | optional filter |
Response 200 — paginated ApplicationResponse[] with metadata: { total, limit, offset, hasMore }. Newest first. Each row carries its platforms[] and socialLinks[] collections inline.
GET /admin/affiliate/applications/:id — Application detail
Permission: affiliateApplication: read.
Response 200 — ApplicationResponse.
Response 404 — NOT_FOUND when the id is unknown.
POST /admin/affiliate/applications/:id/approve — Approve a PENDING application
Permission: affiliateApplication: review.
In a single transaction: flips the application status to APPROVED, writes an audit row, and creates the affiliate row with a freshly generated unique 8-char code. Fires affiliate.application.approved after commit (drives the customer email).
Response 200 — ApplicationResponse (now status: "APPROVED").
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown application id |
| 409 | CONFLICT | Application is not in PENDING |
| 409 | CONFLICT | Customer already has an affiliate row (defense-in-depth — the submit endpoint blocks this too) |
POST /admin/affiliate/applications/:id/reject — Reject a PENDING application
Permission: affiliateApplication: review.
Body
{ "reason": "Doesn't meet the audience-size requirement." }reason is required (1–1000 chars), surfaced verbatim in the customer email and persisted on affiliate_application.rejected_reason + the audit row.
Response 200 — ApplicationResponse (now status: "REJECTED", rejectedReason set).
Errors: same shape as approve.
Affiliates
GET /admin/affiliate/affiliates — Paginated affiliate list
Permission: affiliateProfile: read.
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..50 |
state | active | suspended | — | optional filter (omit for all) |
Response 200 — paginated AffiliateResponse[]. Newest first.
GET /admin/affiliate/affiliates/:id — Affiliate detail
Permission: affiliateProfile: read.
Response 200 — AffiliateResponse.
Response 404 — NOT_FOUND.
PATCH /admin/affiliate/affiliates/:id — Update affiliate profile
Permission: affiliateProfile: manage.
Mutable fields (all optional, all nullable — omit = leave unchanged, null = clear). Each request that changes at least one value writes an AFFILIATE_PROFILE_UPDATE audit row with a diff of the changed columns only.
{
"promotedLandingUrl": "https://shop.example.com",
"commissionEnabled": true,
"commissionType": "PERCENTAGE",
"commissionValue": 1500, // 15% in basis points
"payoutMethod": "UPI",
"upiId": "alice@upi",
"bankAccountName": null,
"bankAccountNumber": null,
"bankIfsc": null,
"panNumber": "ABCDE1234F",
"gstin": null
}Validation
commissionType+commissionValuemust be both set or both null (partial overrides aren't allowed — the column pair travels together)- When
commissionType === "PERCENTAGE",commissionValuemust be0..10000 - Pairing for payoutMethod ↔ details is checked at payout-creation time, not here
Response 200 — AffiliateResponse (updated).
POST /admin/affiliate/affiliates/:id/suspend — Suspend an affiliate
Permission: affiliateProfile: suspend.
Body
{ "reason": "Detected fraudulent traffic." }In one transaction: sets suspendedAt, suspendedBy, suspendReason; writes AFFILIATE_SUSPEND audit row.
Effects:
/r/:codefor any of this affiliate's links returns 404 with no click recorded- Daily approval sweep skips this affiliate's PENDING commissions
- Payout creation blocks (
createForAffiliateerrors withCONFLICT) - Storefront dashboard still works — affiliate sees the suspended banner with reason
Response 200 — AffiliateResponse.
POST /admin/affiliate/affiliates/:id/resume — Resume a suspended affiliate
Permission: affiliateProfile: suspend.
Clears suspendedAt/By/Reason. Writes AFFILIATE_RESUME audit row with the prior suspended_at / reason captured in before.
Response 200 — AffiliateResponse.
GET /admin/affiliate/affiliates/:id/audit — Audit log for one affiliate
Permission: affiliateProfile: read.
Every mutation to the affiliate row (creation, profile updates, suspend, resume) plus commission-skip forensic rows (self-referral, repeat policy) appear here.
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..50 |
Response 200 — paginated AuditResponse[]. Newest first.
Overrides
Five parallel resources, one per entity dimension. Shape is identical; only the target lookup table differs.
GET /admin/affiliate/overrides/<entity>/:targetId
Permission: affiliateOverride: read.
Where <entity> ∈ product | brand | vendor | category | tag and :targetId is the id in the corresponding catalog table.
Response 200 — OverrideResponse. All-null fields ({ enabled: null, commissionType: null, commissionValue: null }) indicate "no override row for this target — inherits the rest of the chain."
PUT /admin/affiliate/overrides/<entity>/:targetId — Upsert override
Permission: affiliateOverride: manage.
Body — OverrideResponse shape (without targetId):
{
"enabled": true, // or false / null (inherit)
"commissionType": "PERCENTAGE", // or "FIXED" / null
"commissionValue": 1000 // 10% in bps; or null
}Validation
commissionType+commissionValuemust be both set or both null- PERCENTAGE:
commissionValue ∈ 0..10000 - Schema CHECKs (
<table>_commission_value_range) enforce the same on the DB side
Response 200 — OverrideResponse (upserted).
DELETE /admin/affiliate/overrides/<entity>/:targetId — Remove override
Permission: affiliateOverride: manage.
Removes the override row. The entity then inherits from the rest of the precedence chain at calc time.
Response 204 — empty body.
Payouts
GET /admin/affiliate/payouts/eligible — Affiliates ready for payout
Permission: affiliatePayout: read.
Returns affiliates whose APPROVED commission balance ≥ admin.affiliate.min_payout_subunits. Sorted by balance descending so high-value affiliates surface first. Suspended affiliates are excluded.
Response 200 — EligibleAffiliateResponse[] (not paginated).
GET /admin/affiliate/payouts — Paginated payout list
Permission: affiliatePayout: read.
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..50 |
status | DRAFT | PROCESSING | PAID | FAILED | — | optional filter |
affiliateId | string | — | optional filter |
Response 200 — paginated PayoutResponse[]. Newest first.
GET /admin/affiliate/payouts/:id — Payout detail
Permission: affiliatePayout: read.
Response 200 — PayoutResponse.
Response 404 — NOT_FOUND.
POST /admin/affiliate/payouts — Build a batch
Permission: affiliatePayout: create.
Creates one DRAFT payout per affiliateId. Per-affiliate failures don't abort the batch — the response carries succeeded[] and errors[].
Body
{ "affiliateIds": ["aff_1", "aff_2"] } // 1..500 entriesFor each affiliate the service:
- Validates the affiliate exists, is not suspended, and has matching payout method + identity fields (UPI requires
upiId; BANK requiresbankAccountNumber+bankIfsc). - Sums APPROVED commission amounts. Rejects with
CONFLICTif balance <min_payout_subunits. - Computes TDS via
admin.affiliate.tds_rate_bps(default 0 = no deduction). India:- 500 = 5% with PAN (sec 194H)
- 2000 = 20% without PAN
- v1 uses a single flat tenant rate; pan-based derivation is a v1.5 follow-up
- In one transaction: inserts
affiliate_payout(status: DRAFT) +affiliate_payout_itemrows linking each commission + flips those commissions toPAIDwithpayoutItemIdset.
Response 201
{
"data": {
"succeeded": [
{ "id": "...", "affiliateId": "aff_1", "status": "DRAFT", "method": "UPI",
"grossSubunits": 17325, "tdsSubunits": 0, "netSubunits": 17325, ... }
],
"errors": [
{ "affiliateId": "aff_2", "error": "Affiliate has not set a payout method..." }
]
},
"message": "Success",
"statusCode": 201
}Per-affiliate error messages
| Message fragment | Cause |
|---|---|
Affiliate not found | Unknown id |
Cannot payout to a suspended affiliate | affiliate.suspendedAt IS NOT NULL |
has not set a payout method | payoutMethod IS NULL |
upi_id is empty | UPI method but upiId is null/empty |
bank details are incomplete | BANK method but bank fields missing |
has no APPROVED commissions | Balance is zero |
is below min_payout | Balance < min_payout_subunits |
POST /admin/affiliate/payouts/:id/mark-paid — Record disbursement
Permission: affiliatePayout: process.
Transitions DRAFT or PROCESSING → PAID. Records the external reference (UTR / cheque / wire id) returned by the bank/PSP. Emits affiliate.payout.processed after commit — the customer receives the breakdown email.
Body
{ "externalReference": "UTR-2026-05-16-XXXXXX" }externalReference is required (1–200 chars). Trim is automatic.
Response 200 — PayoutResponse (status now PAID).
Errors
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Empty externalReference |
| 404 | NOT_FOUND | Unknown payout id |
| 409 | CONFLICT | Payout is not in DRAFT or PROCESSING |
Plugin configuration (admin settings)
The full plugin is gated by admin.affiliate.enabled. Every other knob lives in the same affiliate settings group, served by /admin/settings/admin. Summary of the keys this admin section depends on:
| Key | Default | Purpose |
|---|---|---|
enabled | false | Master kill switch |
auto_approve_applications | false | When true, submit → APPROVED in one step |
default_commission_rate_bps | 500 (5%) | Bottom of the precedence chain |
default_commission_type | PERCENTAGE | Companion to the default rate |
min_payout_subunits | 300000 (₹3,000) | Eligibility threshold for batch creation |
cookie_duration_days | 30 | Click attribution window |
repeat_order_policy | FIRST_ONLY | FIRST_ONLY / FIRST_PER_LINK / ALL_WITHIN_WINDOW |
repeat_order_window_days | 30 | Used by ALL_WITHIN_WINDOW |
tds_rate_bps | 0 | Flat TDS deducted from every payout |
commission_approval_after_return_window | true | Gate sweep on return-window-closed in addition to delivered |
approval_cron | 0 3 * * * | Daily approval sweep schedule |
Commission lifecycle (for context)
The admin surface exposes the start- and end-of-lifecycle transitions; the middle (PENDING → APPROVED) is driven by the daily cron and isn't a manual operation here.
- PENDING — created by the
order.placedlistener for each eligible order_line. Affiliate's lifetime stats are bumped at this point. - APPROVED — the daily approval sweep promotes PENDING rows whose sub-order is
deliveredand the return window has closed. No admin endpoint. - REJECTED — the cancel/return event listener flips PENDING/APPROVED → REJECTED on
order.cancelled,order.vendor.cancelled, orreturn.refunded; reverses lifetime stats. PAID commissions are NOT auto-rejected on returns — that's a v2 clawback workstream. - PAID — admin batch creation flips APPROVED → PAID via this surface.
The affiliate_audit + affiliate_commission_audit tables capture every transition with actor + before/after snapshot. The admin audit endpoint above covers the affiliate-row side; commission-side audit reads are deliberately not surfaced via a separate endpoint (commission rows are listable directly when that endpoint lands).
Admin RBAC Module
HTTP surface for managing platform-admin roles (custom role definitions with a permission map) and reading the catalog of available permissions. Also exports the PermissionsGuard…
Banner Module — Admin
HTTP surface for managing promotional banners attached to catalog entities (categories, brands, tags, ingredients). Banner targets a single entity via a polymorphic (entityType,…