LMMA Backend API Docs

NestJS + TypeORM + PostgreSQL. Group deals with wallet escrow.
Public Docs Wallet docs

Overview

Base URL
http://localhost:3000
Pricing
1 OMR = 1 Knot = 100 points
Auth is enabled globally. Endpoints marked as Public work without a token.
Default platform seller fee: 3.00% (applied on escrow release; recorded in profit logs).
CORS: the API allows requests from any origin (useful for web apps during development).

Auth

POST/auth/register Public
curl -X POST http://localhost:3000/auth/register \
  -H 'Content-Type: application/json' \
  -d '{
    "email":"user@example.com",
    "password":"P@ssw0rd123",
    "firstName":"Zain",
    "lastName":"Ali"
  }'
Example response
{
  "userId": "<USER_UUID>",
  "expiresAt": "2026-01-12T00:10:00.000Z"
}
POST/auth/login Public
curl -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@example.com","password":"P@ssw0rd123"}'
Example response
{
  "requiresOtp": true,
  "expiresAt": "2026-01-12T00:10:00.000Z"
}
POST/auth/otp/verify Public
curl -X POST http://localhost:3000/auth/otp/verify \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@example.com","purpose":"login","code":"123456"}'
Use the returned accessToken in Authorization: Bearer ...
Example response
{
  "accessToken": "<JWT>",
  "user": {
    "id": "<USER_UUID>",
    "email": "user@example.com",
    "firstName": "Zain",
    "lastName": "Ali",
    "phone": null,
    "emailVerified": true,
    "isVendor": false,
    "isAdmin": false,
    "isCarrier": false,
    "vendorTaxPercent": "10.00",
    "vendorProfile": null,
    "createdAt": "2026-01-12T00:00:00.000Z",
    "updatedAt": "2026-01-12T00:00:00.000Z"
  }
}
Email verify variant
# purpose=email_verify
curl -X POST http://localhost:3000/auth/otp/verify \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@example.com","purpose":"email_verify","code":"123456"}'
{
  "verified": true,
  "accessToken": "<JWT>",
  "user": {
    "id": "<USER_UUID>",
    "email": "user@example.com",
    "emailVerified": true,
    "vendorProfile": null
  }
}
POST/auth/password/forgot Public
Send an OTP to the user's email for password reset.
curl -X POST http://localhost:3000/auth/password/forgot \
  -H 'Content-Type: application/json' \
  -d '{"email":"user@example.com"}'
Example response
{
  "expiresAt": "2026-01-12T00:10:00.000Z"
}
POST/auth/password/reset Public
Verify OTP and set a new password.
curl -X POST http://localhost:3000/auth/password/reset \
  -H 'Content-Type: application/json' \
  -d '{
    "email":"user@example.com",
    "code":"123456",
    "newPassword":"NewP@ssw0rd123"
  }'
Example response
{
  "success": true
}
POST/auth/password/change
Change password for the authenticated user by providing the old password.
curl -X POST http://localhost:3000/auth/password/change \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "oldPassword":"P@ssw0rd123",
    "newPassword":"NewP@ssw0rd123"
  }'
Example response
{
  "success": true
}

Users

Auth required unless marked Public. Admin endpoints require an admin token.
GET/users/me
Returns the authenticated user (sanitized; no passwordHash).
curl http://localhost:3000/users/me \
  -H 'Authorization: Bearer <TOKEN>'
PATCH/users/me
Updates your profile fields: firstName, lastName, phone.
curl -X PATCH http://localhost:3000/users/me \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"firstName":"Zain","lastName":"Ali","phone":"+966500000000"}'

Admin

GET/users/admin
Paginated users list. Optional search: ?q=
curl 'http://localhost:3000/users/admin?page=1&limit=20&q=user' \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'
GET/users/admin/<USER_UUID>
curl http://localhost:3000/users/admin/<USER_UUID> \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'
PATCH/users/admin/<USER_UUID>
Updates user flags/fields (examples: isAdmin, isVendor, emailVerified, vendorTaxPercent).
curl -X PATCH http://localhost:3000/users/admin/<USER_UUID> \
  -H 'Authorization: Bearer <ADMIN_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"isAdmin":true,"emailVerified":true}'
DELETE/users/admin/<USER_UUID>
curl -X DELETE http://localhost:3000/users/admin/<USER_UUID> \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'

Admin

Admin-only endpoints (requires @AdminOnly + JWT).
GET/admin/stats
Aggregated system stats for admin dashboards: users, deals, orders, wallet stats, and platform profits (OMR + points).
curl http://localhost:3000/admin/stats \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'
Example response
{
  "users": { "total": 123, "vendors": 12, "admins": 2 },
  "deals": { "total": 45, "open": 10, "closed": 30, "expired": 5 },
  "orders": { "total": 90, "pending": 20, "paid": 30, "shipped": 10, "delivered": 10, "completed": 15, "cancelled": 5 },
  "wallet": {
    "totalCirculatingPoints": 500000,
    "totalFrozenPoints": 20000,
    "totalUsersWithWallet": 120,
    "totalTopUpAmountInCurrency": 350,
    "currency": "OMR"
  },
  "profit": {
    "totalFeePoints": 300,
    "totalGrossPoints": 10000,
    "totalNetPoints": 9700,
    "totalFeeOmr": 3,
    "totalGrossOmr": 100,
    "totalNetOmr": 97,
    "currency": "OMR"
  },
  "timestamp": "2026-01-12T00:00:00.000Z"
}
POST/wallet/withdrawals
Create a withdrawal request. Bank details are now referenced via a saved bankAccountId (see Bank Accounts). Freezes the requested points immediately.
Optional fields: reason (enum), reasonNotes, attachmentUrl, userNote.
curl -X POST http://localhost:3000/wallet/withdrawals \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "amount": 1000,
    "bankAccountId": "<BANK_ACCOUNT_UUID>",
    "reason": "selling_various_materials",
    "userNote": "Please process ASAP"
  }'
Example response
{
  "id": "<WITHDRAWAL_UUID>",
  "userId": "<USER_UUID>",
  "amount": "1000",
  "bankAccountId": "<BANK_ACCOUNT_UUID>",
  "bankAccount": {
    "id": "<BANK_ACCOUNT_UUID>",
    "bankName": "Bank Muscat",
    "accountNumber": "1234567890",
    "accountHolderName": "Zain Ali",
    "iban": "OM810080000000123456789"
  },
  "status": "pending",
  "currencyAmount": "10000",
  "currency": "OMR",
  "reason": "selling_various_materials",
  "userNote": "Please process ASAP",
  "createdAt": "2026-01-12T00:00:00.000Z"
}

Localization

The platform supports DB-backed localization using a localizations table with: key, language, value.
Client language is determined from the Accept-Language header (example: en-US,en;q=0.9,ar;q=0.8). The server falls back to DEFAULT_LANGUAGE (defaults to en).

Localized response messages

Success and error responses include a messageKey and a localized human message message. The message is resolved from localizations using the request language.
curl http://localhost:3000/products \
  -H 'Accept-Language: ar'
Example (error) response
{
  "statusCode": 404,
  "error": "Not Found",
  "messageKey": "errors.product_not_found",
  "message": "Product not found"
}

Admin: manage localization entries

Admin-only endpoints (requires @AdminOnly + JWT).
POST/localizations
Creates a localization row (unique by key + language).
curl -X POST http://localhost:3000/localizations \
  -H 'Authorization: Bearer <ADMIN_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "key":"errors.product_not_found",
    "language":"ar",
    "value":"المنتج غير موجود"
  }'
PATCH/localizations/<LOCALIZATION_UUID>
Updates a localization row by id.
DELETE/localizations/<LOCALIZATION_UUID>
Deletes a localization row by id.
GET/localizations/translate?key=<KEY>&language=<LANG>
Direct lookup for a key/language pair.
curl 'http://localhost:3000/localizations/translate?key=errors.product_not_found&language=ar' \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'

Categories

Public endpoints support localization for name and description using Accept-Language.
GET/categories Public
curl http://localhost:3000/categories \
  -H 'Accept-Language: ar'
Example response
[
  {
    "id": "<CATEGORY_UUID>",
    "slug": "electronics",
    "name": "الكترونيات",
    "description": "جوالات ولابتوبات واكسسوارات",
    "isActive": true,
    "localizations": [
      { "id": "<LOCALIZATION_UUID>", "key": "categories.<CATEGORY_UUID>.name", "language": "ar", "value": "الكترونيات" },
      { "id": "<LOCALIZATION_UUID>", "key": "categories.<CATEGORY_UUID>.description", "language": "ar", "value": "جوالات ولابتوبات واكسسوارات" }
    ]
  }
]
GET/categories/slug/<SLUG> Public
curl http://localhost:3000/categories/slug/electronics \
  -H 'Accept-Language: en'
GET/categories/<CATEGORY_UUID> Public
curl http://localhost:3000/categories/<CATEGORY_UUID> \
  -H 'Accept-Language: en'

Admin

Admin-only endpoints (requires @AdminOnly + JWT).
POST/categories
Creates a category. Optional localized fields can be passed as JSON maps.
curl -X POST http://localhost:3000/categories \
  -H 'Authorization: Bearer <ADMIN_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "slug":"electronics",
    "name":"Electronics",
    "description":"Phones, laptops, and accessories",
    "nameLocalized": {"ar":"الكترونيات"},
    "descriptionLocalized": {"ar":"جوالات ولابتوبات واكسسوارات"},
    "isActive": true
  }'
PATCH/categories/<CATEGORY_UUID>
Updates slug/name/description/isActive and/or localized maps.
DELETE/categories/<CATEGORY_UUID>
Deletes a category. Deals using it will have categoryId set to null.

Addresses

Auth required. Addresses are scoped to the authenticated user. Only one address can be isDefault=true.
When selecting a point on the map (lat and lng provided), only label, phone, and additionalDetails are required. Otherwise, addressLine1, city, and country are required.
GET/addresses
Lists your addresses (default first).
curl http://localhost:3000/addresses \
  -H 'Authorization: Bearer <TOKEN>'
POST/addresses
Creates a new address. If isDefault=true, it becomes the only default address.
For map point selection: provide lat and lng, and addressLine1, city, country become optional.
# Full address with all fields
curl -X POST http://localhost:3000/addresses \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "label":"Home",
    "fullName":"John Doe",
    "phone":"+966500000000",
    "additionalDetails":"Near the park",
    "addressLine1":"Riyadh, King Fahd Rd",
    "addressLine2":"Apt 12",
    "city":"Riyadh",
    "country":"SA",
    "zip":"12345",
    "lat":24.713551,
    "lng":46.675296,
    "isDefault":true
  }'

# Map point selection (minimal fields)
curl -X POST http://localhost:3000/addresses \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "label":"Home",
    "phone":"+966500000000",
    "additionalDetails":"Near the park",
    "lat":24.713551,
    "lng":46.675296,
    "isDefault":true
  }'
GET/addresses/<ADDRESS_UUID>
curl http://localhost:3000/addresses/<ADDRESS_UUID> \
  -H 'Authorization: Bearer <TOKEN>'
PATCH/addresses/<ADDRESS_UUID>
Updates an address. Setting isDefault=true clears default from others.
curl -X PATCH http://localhost:3000/addresses/<ADDRESS_UUID> \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"city":"Jeddah"}'
PATCH/addresses/<ADDRESS_UUID>/default
Sets the address as default (clears other defaults).
curl -X PATCH http://localhost:3000/addresses/<ADDRESS_UUID>/default \
  -H 'Authorization: Bearer <TOKEN>'
DELETE/addresses/<ADDRESS_UUID>
Deletes an address. If it was default, the newest remaining address becomes default.
curl -X DELETE http://localhost:3000/addresses/<ADDRESS_UUID> \
  -H 'Authorization: Bearer <TOKEN>'

Vendors

Vendor status lifecycle: pending_document_uploadpending_admin_approvalactive. Admin can set rejected or blocked.
GET/vendors Public
curl http://localhost:3000/vendors
Example response
[
  {
    "id": "<VENDOR_UUID>",
    "displayName": "iPhone Store",
    "commercialRegistrationNumber": "1234567890",
    "profileImageUrl": "/uploads/<UUID>.png",
    "isActive": true,
    "status": "active",
    "ratingAvg": "4.50",
    "ratingCount": 10,
    "user": { "id": "<USER_UUID>", "email": "vendor@example.com" }
  }
]
POST/vendors/signup Public
Creates a new user and a vendor profile request in one step. The vendor starts as isActive=false and requires admin acceptance.
curl -X POST http://localhost:3000/vendors/signup \
  -H 'Content-Type: application/json' \
  -d '{
    "email":"vendor@example.com",
    "password":"P@ssw0rd123",
    "firstName":"Zain",
    "lastName":"Ali",
    "displayName":"iPhone Store",
    "commercialRegistrationNumber":"1234567890"
  }'
Example response
{
  "userId": "<USER_UUID>",
  "vendorId": "<VENDOR_UUID>",
  "expiresAt": "2026-01-12T00:10:00.000Z"
}
GET/vendors/me/products
Auth required (Vendor). Lists your products.
curl http://localhost:3000/vendors/me/products \
  -H 'Authorization: Bearer <TOKEN>'
GET/vendors/me/deals
Auth required (Vendor). Lists your deals. Optional filters: ?type=b2b|b2c|both (default: both).
Pagination: ?page=1 (default) and ?limit=20 (default).
# all your deals
curl http://localhost:3000/vendors/me/deals \
  -H 'Authorization: Bearer <TOKEN>'

# second page
curl 'http://localhost:3000/vendors/me/deals?page=2&limit=20' \
  -H 'Authorization: Bearer <TOKEN>'

# only your B2B deals
curl 'http://localhost:3000/vendors/me/deals?type=b2b' \
  -H 'Authorization: Bearer <TOKEN>'
GET/vendors/me/documents
Auth required. Lists your uploaded vendor documents (useful before admin acceptance).
curl http://localhost:3000/vendors/me/documents \
  -H 'Authorization: Bearer <TOKEN>'
POST/vendors/me/documents
Auth required. Upload a vendor document as multipart/form-data with field name file and provide document type. Allowed types: cr, occl, sme, other. Max file size: 10MB.
curl -X POST http://localhost:3000/vendors/me/documents \
  -H 'Authorization: Bearer <TOKEN>' \
  -F 'type=cr' \
  -F 'file=@/path/to/document.pdf'
Example response
{
  "id": "<DOCUMENT_UUID>",
  "type": "cr",
  "url": "/uploads/<FILENAME>",
  "filename": "<FILENAME>",
  "mimeType": "application/pdf",
  "sizeBytes": 12345,
  "status": "pending",
  "createdAt": "2026-01-12T00:00:00.000Z",
  "updatedAt": "2026-01-12T00:00:00.000Z"
}
DELETE/vendors/me/documents/<DOCUMENT_UUID>
Auth required. Deletes one of your uploaded vendor documents.
curl -X DELETE http://localhost:3000/vendors/me/documents/<DOCUMENT_UUID> \
  -H 'Authorization: Bearer <TOKEN>'
POST/vendors/me/setup
Admin only. Allows an admin to create their own vendor profile with isActive=true and status=active without requiring document upload or admin approval. Enables the admin to use vendor endpoints immediately.
curl -X POST http://localhost:3000/vendors/me/setup \
  -H 'Authorization: Bearer <ADMIN_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "displayName":"Admin Store",
    "commercialRegistrationNumber":"1234567890"
  }'
Example response
{
  "id": "<VENDOR_UUID>",
  "displayName": "Admin Store",
  "commercialRegistrationNumber": "1234567890",
  "profileImageUrl": null,
  "isActive": true,
  "status": "active",
  "ratingAvg": "0.00",
  "ratingCount": 0
}
POST/vendors Public
Creates a vendor profile request. New vendors start as isActive=false and require admin acceptance before vendor-only endpoints work.
curl -X POST http://localhost:3000/vendors \
  -H 'Content-Type: application/json' \
  -d '{"userId":"<USER_UUID>","displayName":"iPhone Store","commercialRegistrationNumber":"1234567890"}'
Example response
{
  "id": "<VENDOR_UUID>",
  "displayName": "iPhone Store",
  "commercialRegistrationNumber": "1234567890",
  "profileImageUrl": null,
  "isActive": false,
  "status": "pending_document_upload",
  "ratingAvg": "0.00",
  "ratingCount": 0
}

Admin

Admin-only endpoints (requires admin JWT).
GET/vendors/admin/<VENDOR_UUID>
Admin-only. Returns the vendor profile including uploaded documents.
curl http://localhost:3000/vendors/admin/<VENDOR_UUID> \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'
Example response
{
  "id": "<VENDOR_UUID>",
  "displayName": "iPhone Store",
  "commercialRegistrationNumber": "1234567890",
  "profileImageUrl": "/uploads/<UUID>.png",
  "isActive": true,
  "status": "active",
  "ratingAvg": "4.50",
  "ratingCount": 10,
  "user": { "id": "<USER_UUID>", "email": "vendor@example.com" },
  "documents": [
    {
      "id": "<DOCUMENT_UUID>",
      "type": "cr",
      "url": "/uploads/<FILENAME>",
      "filename": "<FILENAME>",
      "mimeType": "application/pdf",
      "sizeBytes": 12345,
      "status": "pending",
      "createdAt": "2026-01-12T00:00:00.000Z",
      "updatedAt": "2026-01-12T00:00:00.000Z"
    }
  ]
}
POST/vendors/<VENDOR_UUID>/accept
Accept vendor request. Sets vendor.isActive=true and marks the user as vendor for RBAC. Optionally set vendorTaxPercent (0..100) on the vendor user.
curl -X POST http://localhost:3000/vendors/<VENDOR_UUID>/accept \
  -H 'Authorization: Bearer <ADMIN_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"vendorTaxPercent":15}'
POST/vendors/<VENDOR_UUID>/reject
Reject vendor request. Sets vendor status=rejected and ensures the user is not marked as vendor.
curl -X POST http://localhost:3000/vendors/<VENDOR_UUID>/reject \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'
POST/vendors/<VENDOR_UUID>/block
Block a vendor. Sets status=blocked, isActive=false, and removes vendor role from the user.
curl -X POST http://localhost:3000/vendors/<VENDOR_UUID>/block \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'
POST/vendors/<VENDOR_UUID>/unblock
Unblock a vendor. Sets status=pending_admin_approval and keeps isActive=false until accepted again.
curl -X POST http://localhost:3000/vendors/<VENDOR_UUID>/unblock \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'
POST/vendors/<VENDOR_UUID>/profile-image Public
Upload vendor profile image as multipart/form-data using field name file. Max file size: 10MB.
curl -X POST http://localhost:3000/vendors/<VENDOR_UUID>/profile-image \
  -F 'file=@/path/to/image.png'
Example response
{
  "id": "<VENDOR_UUID>",
  "displayName": "iPhone Store",
  "commercialRegistrationNumber": "1234567890",
  "profileImageUrl": "/uploads/<UUID>.png",
  "isActive": true,
  "status": "active",
  "ratingAvg": "0.00",
  "ratingCount": 0
}

Ratings

GET/vendors/<VENDOR_UUID>/ratings Public
curl http://localhost:3000/vendors/<VENDOR_UUID>/ratings
Example response
[
  {
    "id": "<RATING_UUID>",
    "rating": 5,
    "comment": "Fast delivery",
    "user": { "id": "<USER_UUID>", "email": "user@example.com" },
    "createdAt": "2026-01-12T00:00:00.000Z",
    "updatedAt": "2026-01-12T00:00:00.000Z"
  }
]
POST/vendors/ratings
Auth required. Creates or updates your rating for a vendor (1..5).
curl -X POST http://localhost:3000/vendors/ratings \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "vendorId":"<VENDOR_UUID>",
    "rating":5,
    "comment":"Fast delivery"
  }'
Example response
{
  "id": "<RATING_UUID>",
  "rating": 5,
  "comment": "Fast delivery",
  "createdAt": "2026-01-12T00:00:00.000Z",
  "updatedAt": "2026-01-12T00:00:00.000Z"
}
DELETE/vendors/<VENDOR_UUID>/ratings/me
Auth required. Deletes your rating for the vendor.
curl -X DELETE http://localhost:3000/vendors/<VENDOR_UUID>/ratings/me \
  -H 'Authorization: Bearer <TOKEN>'
Example response
{
  "deleted": true
}

Deals

POST/products/with-deal
Auth required (Vendor/Admin). Creates a product + deal and uploads images in the same request (multipart/form-data).
File fields: productImages (0..10), dealImages (0..10), and dealVideo (0..1). Max file size: 50MB.
Alternative to uploading dealVideo: provide videoUrl field with an external video URL (e.g., YouTube, Vimeo, or CDN link). If both are provided, the uploaded file takes precedence.
On successful creation, the API triggers an async WhatsApp broadcast to all users with a phone number if Twilio env vars are configured: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_WHATSAPP_FROM. Optional: APP_PUBLIC_URL to include a clickable deal link.
# With uploaded video file
curl -X POST http://localhost:3000/products/with-deal \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: multipart/form-data' \
  -F 'vendorId=<VENDOR_UUID>' \
  -F 'categoryId=<CATEGORY_UUID>' \
  -F 'name=iPhone 16' \
  -F 'description=128GB' \
  -F 'nameLocalized={"ar":"ايفون 16"}' \
  -F 'descriptionLocalized={"ar":"128 جيجا"}' \
  -F 'unitPriceSar=1.00' \
  -F 'minOrderQuantity=1' \
  -F 'stockQuantity=100' \
  -F 'isActive=true' \
  -F 'targetQuantity=50' \
  -F 'minQuantityToStartShipping=0' \
  -F 'expiresAt=2026-02-10T00:00:00.000Z' \
  -F 'dealType=b2c' \
  -F 'productImages=@/path/to/product-1.png' \
  -F 'productImages=@/path/to/product-2.png' \
  -F 'dealImages=@/path/to/deal-1.png' \
  -F 'dealVideo=@/path/to/deal.mp4'

# With external video URL instead of uploading
curl -X POST http://localhost:3000/products/with-deal \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: multipart/form-data' \
  -F 'vendorId=<VENDOR_UUID>' \
  -F 'categoryId=<CATEGORY_UUID>' \
  -F 'name=iPhone 16' \
  -F 'description=128GB' \
  -F 'unitPriceSar=1.00' \
  -F 'stockQuantity=100' \
  -F 'targetQuantity=50' \
  -F 'expiresAt=2026-02-10T00:00:00.000Z' \
  -F 'videoUrl=https://youtube.com/watch?v=abc123'
Example response
{
  "product": { "id": "<PRODUCT_UUID>", "localizations": [ { "key": "products.<PRODUCT_UUID>.name", "language": "ar", "value": "ايفون 16" } ] },
  "deal": { "id": "<DEAL_UUID>", "status": "open" },
  "productImages": [ { "id": "<IMAGE_UUID>", "url": "/uploads/...", "filename": "..." } ],
  "dealImages": [ { "id": "<IMAGE_UUID>", "url": "/uploads/...", "filename": "..." } ],
  "dealVideo": { "id": "<VIDEO_UUID>", "url": "/uploads/...", "filename": "..." }
}
GET/deals Public
Optional filters: ?type=b2b|b2c|both (default: both) and ?categoryId=<CATEGORY_UUID>.
Pagination: ?page=1 (default) and ?limit=20 (default).
# all deal types (default)
curl http://localhost:3000/deals

# second page
curl 'http://localhost:3000/deals?page=2&limit=20'

# only B2B deals
curl 'http://localhost:3000/deals?type=b2b'

# only B2C deals
curl 'http://localhost:3000/deals?type=b2c'

# filter by category
curl 'http://localhost:3000/deals?categoryId=<CATEGORY_UUID>'

# filter by type + category
curl 'http://localhost:3000/deals?type=b2c&categoryId=<CATEGORY_UUID>'
Example response
[
  {
    "id": "<DEAL_UUID>",
    "dealType": "b2c",
    "targetQuantity": 100,
    "reservedQuantity": 10,
    "minQuantityToStartShipping": 0,
    "status": "open",
    "expiresAt": "2026-01-15T10:00:00.000Z",
    "product": {
      "id": "<PRODUCT_UUID>",
      "localizations": [
        { "id": "<LOCALIZATION_UUID>", "key": "products.<PRODUCT_UUID>.name", "language": "ar", "value": "ايفون 16" }
      ]
    },
    "vendor": { "id": "<VENDOR_UUID>" },
    "category": {
      "id": "<CATEGORY_UUID>",
      "slug": "electronics",
      "name": "Electronics",
      "localizations": [
        { "id": "<LOCALIZATION_UUID>", "key": "categories.<CATEGORY_UUID>.name", "language": "ar", "value": "الكترونيات" }
      ]
    },
    "images": []
  }
]
PATCH/deals/<DEAL_UUID>
Auth required (Admin or Vendor). Vendors can only update their own deals.
Supports updating deal fields and the linked product fields via nested product object.
To set or update the deal video URL, include videoUrl field (external video link). Setting videoUrl will create or update the video record for the deal.
curl -X PATCH http://localhost:3000/deals/<DEAL_UUID> \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "targetQuantity": 50,
    "minQuantityToStartShipping": 0,
    "expiresAt": "2026-02-10T00:00:00.000Z",
    "status": "open",
    "dealType": "b2c",
    "categoryId": "<CATEGORY_UUID>",
    "videoUrl": "https://youtube.com/watch?v=abc123",
    "product": {
      "name": "iPhone 16",
      "description": "128GB",
      "unitPriceSar": "1.00",
      "minOrderQuantity": 1,
      "stockQuantity": 100,
      "isActive": true,
      "nameLocalized": {"ar":"ايفون 16"},
      "descriptionLocalized": {"ar":"128 جيجا"}
    }
  }'
Example response
{
  "id": "<DEAL_UUID>",
  "dealType": "b2c",
  "targetQuantity": 50,
  "reservedQuantity": 0,
  "minQuantityToStartShipping": 0,
  "status": "open",
  "expiresAt": "2026-02-10T00:00:00.000Z",
  "product": { "id": "<PRODUCT_UUID>" },
  "vendor": { "id": "<VENDOR_UUID>" },
  "category": { "id": "<CATEGORY_UUID>", "slug": "electronics", "name": "Electronics" },
  "images": [
    { "id": "<IMAGE_UUID>", "url": "/uploads/deal-image.png", "filename": "deal-image.png" },
    { "id": "<VIDEO_UUID>", "url": "https://youtube.com/watch?v=abc123", "filename": "external-video", "mimeType": "video/mp4" }
  ]
}
POST/deals/<DEAL_UUID>/join
Auth required. Use Authorization: Bearer <TOKEN>.
For dealType=b2b deals, only users with a vendor account can join.
Vendors cannot join their own deals.
If authenticated, the deal list and single-deal responses include an isParticipated: boolean field indicating whether the current user has already joined that deal.
# WALLET (escrow freezes points immediately)
curl -X POST http://localhost:3000/deals/<DEAL_UUID>/join \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"paymentMethod":"wallet","quantity":1,"addressId":"<ADDRESS_UUID>"}'

# WALLET with explicit gateway + termUrl3ds (for auto top-up redirect)
curl -X POST http://localhost:3000/deals/<DEAL_UUID>/join \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "paymentMethod": "wallet",
    "quantity": 1,
    "addressId": "<ADDRESS_UUID>",
    "termUrl3ds": "https://yourapp.com/payment/callback",
    "gateway": "thawani"
  }'

# COD
curl -X POST http://localhost:3000/deals/<DEAL_UUID>/join \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "paymentMethod": "cod",
    "quantity": 1,
    "addressId": "<ADDRESS_UUID>"
  }'
Successful join reserves quantity and freezes points in escrow immediately.
Success response (sufficient balance)
{
  "dealId": "<DEAL_UUID>",
  "reservedQuantity": 11,
  "targetQuantity": 100,
  "orderId": "<ORDER_UUID>",
  "escrowId": "<ESCROW_UUID_OR_NULL>"
}
Auto top-up response (insufficient wallet balance)
When paymentMethod=wallet and the user does not have enough points, the API automatically creates a top-up for the exact shortfall and returns a payment URL instead of an error. After the user completes payment the webhook credits their wallet; they then retry the join request.
{
  "paymentRequired": true,
  "paymentUrl": "https://checkout.thawani.om/pay/...",
  "topUpId": "<TOP_UP_UUID>",
  "requiredPoints": 500,
  "availablePoints": 200,
  "shortfallPoints": 300
}
Fields: termUrl3ds (optional, redirect URL after 3DS), gateway (optional, thawani or edfapay, defaults to thawani).

Bank Accounts

Auth required. Each user can manage their saved bank accounts. Only one account can be the default at a time. Bank accounts are linked to withdrawal requests via bankAccountId.
POST/bank-accounts
Creates a new bank account. Automatically becomes default if it is the first account, or if isDefault=true is passed (clears default from others).
curl -X POST http://localhost:3000/bank-accounts \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{
    "bankName": "Bank Muscat",
    "countryName": "Oman",
    "countryCode": "OM",
    "accountNumber": "1234567890",
    "accountHolderName": "Zain Ali",
    "iban": "OM810080000000123456789",
    "isDefault": true
  }'
Example response
{
  "id": "<BANK_ACCOUNT_UUID>",
  "userId": "<USER_UUID>",
  "bankName": "Bank Muscat",
  "countryName": "Oman",
  "countryCode": "OM",
  "accountNumber": "1234567890",
  "accountHolderName": "Zain Ali",
  "iban": "OM810080000000123456789",
  "isDefault": true,
  "createdAt": "2026-01-12T00:00:00.000Z",
  "updatedAt": "2026-01-12T00:00:00.000Z"
}
GET/bank-accounts
Lists all bank accounts for the authenticated user. Default account is returned first.
curl http://localhost:3000/bank-accounts \
  -H 'Authorization: Bearer <TOKEN>'
GET/bank-accounts/<BANK_ACCOUNT_UUID>
curl http://localhost:3000/bank-accounts/<BANK_ACCOUNT_UUID> \
  -H 'Authorization: Bearer <TOKEN>'
PATCH/bank-accounts/<BANK_ACCOUNT_UUID>
Updates any field. Setting isDefault=true clears default from others.
curl -X PATCH http://localhost:3000/bank-accounts/<BANK_ACCOUNT_UUID> \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"bankName": "HSBC Oman"}'
POST/bank-accounts/<BANK_ACCOUNT_UUID>/set-default
Sets the specified account as the default (clears default from all others).
curl -X POST http://localhost:3000/bank-accounts/<BANK_ACCOUNT_UUID>/set-default \
  -H 'Authorization: Bearer <TOKEN>'
DELETE/bank-accounts/<BANK_ACCOUNT_UUID>
curl -X DELETE http://localhost:3000/bank-accounts/<BANK_ACCOUNT_UUID> \
  -H 'Authorization: Bearer <TOKEN>'
Example response
{ "deleted": true }

Orders

GET/orders
Auth required. Lists your orders (as buyer or seller). Admin can use ?scope=all.
# My orders
curl http://localhost:3000/orders \
  -H 'Authorization: Bearer <TOKEN>'

# Admin: all orders
curl 'http://localhost:3000/orders?scope=all' \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'
Example response
[
  {
    "id": "<ORDER_UUID>",
    "quantity": 1,
    "totalPricePoints": "100",
    "taxPoints": "10",
    "paymentMethod": "wallet",
    "escrowId": "<ESCROW_UUID>",
    "address": { "id": "<ADDRESS_UUID>" },
    "orderStatus": "pending",
    "buyerUser": { "id": "<USER_UUID>", "email": "user@example.com", "vendorProfile": null },
    "sellerUser": { "id": "<VENDOR_USER_UUID>", "email": "vendor@example.com", "vendorProfile": null },
    "deal": { "id": "<DEAL_UUID>", "product": { "id": "<PRODUCT_UUID>" } },
    "createdAt": "2026-01-12T00:00:00.000Z",
    "updatedAt": "2026-01-12T00:00:00.000Z"
  }
]
GET/orders/<ORDER_UUID>
curl http://localhost:3000/orders/<ORDER_UUID> \
  -H 'Authorization: Bearer <TOKEN>'
Example response
{
  "id": "<ORDER_UUID>",
  "quantity": 1,
  "totalPricePoints": "100",
  "taxPoints": "10",
  "paymentMethod": "wallet",
  "escrowId": "<ESCROW_UUID>",
  "address": { "id": "<ADDRESS_UUID>" },
  "orderStatus": "pending",
  "buyerUser": { "id": "<USER_UUID>", "email": "user@example.com", "vendorProfile": null },
  "sellerUser": { "id": "<VENDOR_USER_UUID>", "email": "vendor@example.com", "vendorProfile": null },
  "deal": { "id": "<DEAL_UUID>", "product": { "id": "<PRODUCT_UUID>" } },
  "createdAt": "2026-01-12T00:00:00.000Z",
  "updatedAt": "2026-01-12T00:00:00.000Z"
}
GET/orders/<ORDER_UUID>/activities
Auth required. Allowed for Admin, Buyer, or Seller of the order. Returns newest first. Each row has createdAt timestamp.
curl http://localhost:3000/orders/<ORDER_UUID>/activities \
  -H 'Authorization: Bearer <TOKEN>'
Example response
[
  {
    "id": "<ACTIVITY_UUID>",
    "orderId": "<ORDER_UUID>",
    "actorUserId": "<USER_UUID_OR_NULL>",
    "action": "order_created",
    "metadata": { "orderStatus": "pending" },
    "createdAt": "2026-01-12T00:00:00.000Z",
    "updatedAt": "2026-01-12T00:00:00.000Z"
  }
]
PATCH/orders/<ORDER_UUID>
Admin can update addressId and/or override orderStatus. Seller can only manage shipping lifecycle via action endpoints.
curl -X PATCH http://localhost:3000/orders/<ORDER_UUID> \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"addressId":"<ADDRESS_UUID>"}'
Example response
{
  "id": "<ORDER_UUID>",
  "address": { "id": "<ADDRESS_UUID>" },
  "orderStatus": "pending"
}
POST/orders/<ORDER_UUID>/ship
Seller/Admin marks order shipped. If a shipment exists, shipping fee must be approved first.
curl -X POST http://localhost:3000/orders/<ORDER_UUID>/ship \
  -H 'Authorization: Bearer <TOKEN>'
Example response
{
  "id": "<ORDER_UUID>",
  "orderStatus": "shipped"
}

Shipment flow

Buyer creates shipment request to the order address, seller sets shipping fee, then buyer approves/rejects.
POST/orders/<ORDER_UUID>/shipment
Buyer/Admin creates shipment request. Shipment status becomes orders_shipping.
curl -X POST http://localhost:3000/orders/<ORDER_UUID>/shipment \
  -H 'Authorization: Bearer <TOKEN>'
PATCH/orders/<ORDER_UUID>/shipment/fee
Seller/Admin sets shipping fee (points). Shipment status becomes seller_set_the_price.
curl -X PATCH http://localhost:3000/orders/<ORDER_UUID>/shipment/fee \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"shippingFeePoints":"500"}'
POST/orders/<ORDER_UUID>/shipment/approve
Buyer/Admin approves shipping fee. Shipment status becomes buyer_approved_price.
curl -X POST http://localhost:3000/orders/<ORDER_UUID>/shipment/approve \
  -H 'Authorization: Bearer <TOKEN>'
POST/orders/<ORDER_UUID>/shipment/reject
Buyer/Admin rejects shipping fee. Shipment status becomes buyer_rejected_price.
curl -X POST http://localhost:3000/orders/<ORDER_UUID>/shipment/reject \
  -H 'Authorization: Bearer <TOKEN>'
POST/orders/<ORDER_UUID>/deliver
Seller/Admin marks order delivered.
curl -X POST http://localhost:3000/orders/<ORDER_UUID>/deliver \
  -H 'Authorization: Bearer <TOKEN>'
Example response
{
  "id": "<ORDER_UUID>",
  "orderStatus": "delivered"
}
POST/orders/<ORDER_UUID>/cod-collected
Only for COD orders. Marks COD collected (sets status to PAID) after delivered.
curl -X POST http://localhost:3000/orders/<ORDER_UUID>/cod-collected \
  -H 'Authorization: Bearer <TOKEN>'
Example response
{
  "id": "<ORDER_UUID>",
  "paymentMethod": "cod",
  "orderStatus": "paid"
}
POST/orders/<ORDER_UUID>/cancel
Buyer/Seller/Admin can cancel only while PENDING. Wallet orders will refund escrow.
curl -X POST http://localhost:3000/orders/<ORDER_UUID>/cancel \
  -H 'Authorization: Bearer <TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"reason":"Customer requested cancellation"}'
Example response
{
  "id": "<ORDER_UUID>",
  "orderStatus": "cancelled"
}

Uploads

Public endpoints used to upload images and associate them to a Product or Deal.
Uploaded files are stored in public/uploads and are accessible via /uploads/<filename>.
GET/uploads/images Public
Lists uploaded images (newest first). Includes related product and deal when present.
curl http://localhost:3000/uploads/images
Example response
[
  {
    "id": "<IMAGE_UUID>",
    "filename": "<UUID>.png",
    "url": "/uploads/<UUID>.png",
    "mimeType": "image/png",
    "sizeBytes": 12345,
    "product": { "id": "<PRODUCT_UUID>" },
    "deal": null,
    "createdAt": "2026-01-12T00:00:00.000Z",
    "updatedAt": "2026-01-12T00:00:00.000Z"
  }
]
POST/uploads/image Public
Upload a single file as multipart/form-data using field name file.
Optional body fields: productId (UUID) or dealId (UUID). Provide at most one.
Max file size: 10MB.
curl -X POST http://localhost:3000/uploads/image \
  -F 'file=@/path/to/image.png' \
  -F 'productId=<PRODUCT_UUID>'
Example response
{
  "id": "<IMAGE_UUID>",
  "url": "/uploads/<UUID>.png",
  "filename": "<UUID>.png",
  "mimeType": "image/png",
  "sizeBytes": 12345
}
Possible errors
400: Only one of productId or dealId can be provided
404: Product not found / Deal not found
DELETE/uploads/images/<IMAGE_UUID> Public
Deletes the image DB record.
curl -X DELETE http://localhost:3000/uploads/images/<IMAGE_UUID>
Example response
{
  "deleted": true
}

Platform Fees & Profit Tracking

Admin-only endpoints (requires @AdminOnly + JWT).
GET/platform/config
curl http://localhost:3000/platform/config \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'
Example response
{
  "sellerFeePercent": 3,
  "taxPercent": 10
}
PATCH/platform/config
Updates seller fee percent and tax percent (0..100). Example sets fee to 3% and tax to 10%.
curl -X PATCH http://localhost:3000/platform/config \
  -H 'Authorization: Bearer <ADMIN_TOKEN>' \
  -H 'Content-Type: application/json' \
  -d '{"sellerFeePercent":3,"taxPercent":10}'
Example response
{
  "sellerFeePercent": 3,
  "taxPercent": 10
}
GET/platform/profits
Paginated profit records created per escrow release.
curl 'http://localhost:3000/platform/profits?page=1&limit=20' \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'
Example response
{
  "total": 1,
  "page": 1,
  "limit": 20,
  "items": [
    {
      "id": "<UUID>",
      "escrowId": "<ESCROW_UUID>",
      "orderId": "<ORDER_UUID>",
      "grossPoints": "10000",
      "feePoints": "300",
      "netPoints": "9700",
      "feePercentApplied": "3.00",
      "createdAt": "2026-01-12T00:00:00.000Z",
      "updatedAt": "2026-01-12T00:00:00.000Z"
    }
  ]
}
GET/platform/profits/summary
Totals across all profit records (gross/fee/net in points).
curl http://localhost:3000/platform/profits/summary \
  -H 'Authorization: Bearer <ADMIN_TOKEN>'
Example response
{
  "totalFeePoints": 300,
  "totalGrossPoints": 10000,
  "totalNetPoints": 9700
}

Notes

- Deals auto-expire in a scheduler and refund escrow.
- Some endpoints are currently Public; RBAC will tighten this.
- You can view wallet-specific docs at /wallet-docs.