# Products Management Service

# Product Management

**Author**: Josh S. Sakweli, Backend Lead Team  
**Last Updated**: 2026-07-07  
**Version**: v3.0

**Short Description**: The Product Management API provides comprehensive functionality for managing products within shops on the NextGate platform. It supports group buying, installment payment plans, color variations, specifications, digital product file delivery, product preview media (video, PDF, 3D, image), and comprehensive search/filter capabilities with role-based access control.

**Hints**:
- All shop-related endpoints use the prefix `api/v1/e-commerce/shops/{shopId}/products`
- Shop owners and system admins have full product management access
- Public users can only view active products from approved, active shops
- Products have a `productType`: `PHYSICAL` (shipping + delivery confirmation) or `DIGITAL` (instant download after payment)
- Action parameter (`SAVE_DRAFT`/`SAVE_PUBLISH`) determines product status on create/update
- Search supports multi-word queries with enhanced pattern matching across multiple fields
- Pagination is 1-indexed (page parameter starts from 1)
- Products use soft-delete (marked as deleted rather than permanently removed, except drafts)
- Product slugs are unique within each shop scope
- SKUs are auto-generated using shop, category, brand, and sequence information
- Group buying requires a maximum participant count, a group price, and a time limit
- Installment plans are managed separately via the Installment Plan Config endpoints
- Color variations can have individual price adjustments and separate image sets
- Digital products require uploading a file via the Digital File Management endpoint before buyers can download
- Products can have a single preview (video, PDF, 3D model, or image) attached via the Product Preview endpoints — visible publicly before purchase

**All product media (product images/videos, color images, digital files, previews) go through FileThunder — no raw URLs are ever sent by the client**:
1. Client calls `POST api/v1/files/request-upload` with a `context` (`PRODUCT_IMAGE`, `PRODUCT_VIDEO`, `DIGITAL_PRODUCT`, `PRODUCT_PREVIEW_IMAGE`/`_VIDEO`/`_DOCUMENT`) → gets back a presigned upload URL and a `fileId`
2. Client `PUT`s the raw file bytes directly to that URL (not through this API)
3. Client references that `fileId` in the relevant product field (`mediaFileIds`, `colors[].imageFileIds`, `digitalFileId`, preview's `ftFileId`, ...) — the file is still processing (virus scan + variant generation) at this point, so validation only checks Redis-cached ownership/context, not readiness
4. FileThunder finishes processing asynchronously and webhooks back into the product/preview/digital-file "updater" services, which upgrade the stored record to `READY` with resolved `variants` (a map of variant name → CDN URL)
- Responses never return raw `productImages`/`previewUrl` strings — image-bearing fields are `ProductMediaItem` objects (`fileId`, `mediaType`, `status`, `variants`, `mimeType`, `order`) or similarly-shaped preview/file objects, and URLs are assembled fresh on every read via `FileThunderUrlAssembler`
- **Known gap**: color `imageFileIds` are stored as raw file IDs and are *not* validated/resolved through FileThunder like top-level product media — the `colors[].images` field in responses currently echoes back the file ID strings as-is, not resolved variant URLs

---

## Endpoints

## 1. Create Product
**Purpose**: Creates a new product in a shop, supporting group buying, color variations, specifications, and digital product rules.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `api/v1/e-commerce/shops/{shopId}/products`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| Authorization | string | Yes | Bearer token |
| Content-Type | string | Yes | application/json |

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| action | ReqAction | Yes | — | `SAVE_DRAFT` or `SAVE_PUBLISH` |

**Request Body Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| productType | ProductType | Yes | `PHYSICAL` or `DIGITAL` | Required |
| productName | string | Yes | Unique name within shop | Min: 2, Max: 100 chars |
| productDescription | string | Yes | Detailed description | Min: 10, Max: 1000 chars |
| price | decimal | Yes | Selling price | Min: 0.01, max 8 digits + 2 decimal places |
| stockQuantity | integer | Yes | Available stock | Min: 0 |
| categoryId | UUID | Yes | Product category | Must exist and be active |
| comparePrice | decimal | No | Original price for discount display | Must be > price if provided |
| lowStockThreshold | integer | No | Low stock alert threshold | Min: 1, Max: 1000, Default: 5 |
| condition | ProductCondition | No | Product condition | `NEW`, `USED_LIKE_NEW`, `USED_GOOD`, `USED_FAIR`, `REFURBISHED`, `FOR_PARTS` |
| status | ProductStatus | No | Initial status (overridden by `action`) | Default: `ACTIVE` |
| mediaFileIds | array\<UUID\> | Yes | FileThunder file IDs for product images/videos, in display order | Not null; each ID must belong to the caller and have been uploaded with context `PRODUCT_IMAGE` or `PRODUCT_VIDEO` |
| specifications | object | No | Key-value specs | Key max 100 chars, Value max 500 chars |
| colors | array | No | Color variations | See Color object below |
| minOrderQuantity | integer | No | Minimum order qty | Min: 1, Default: 1 |
| maxOrderQuantity | integer | No | Maximum order qty per order | Min: 1, must be ≥ minOrderQuantity |
| groupBuyingEnabled | boolean | No | Enable group buying | Default: false |
| groupMaxSize | integer | No | Maximum group participants | Min: 2, required if groupBuyingEnabled |
| groupPrice | decimal | No | Discounted group price | Must be < price |
| groupTimeLimitHours | integer | No | Group formation time limit | Min: 1, Max: 8760 |
| digitalFileId | UUID | Conditional | FileThunder file ID of the downloadable file | Required when `productType=DIGITAL`; uploaded with context `DIGITAL_PRODUCT` |
| previewFileId | UUID | No | FileThunder file ID of a public preview (DIGITAL only) | Uploaded with context `PRODUCT_PREVIEW_IMAGE`/`_VIDEO`/`_DOCUMENT` |
| previewDownloadable | boolean | No | Whether buyers can download the preview file itself | Default: false |
| downloadExpiryDays | integer | No | Download link expiry (DIGITAL only) | Min: 1, Default: 7 |
| maxDownloadsPerBuyer | integer | No | Download attempts per buyer (DIGITAL only) | Min: 1 |
| maxQuantityForDigital | integer | No | Purchase cap per buyer (DIGITAL only) | Min: 1 |

**Color Object**:
| Field | Type | Required | Validation |
|-------|------|----------|------------|
| name | string | Yes | Max: 50 chars |
| hex | string | Yes | Valid `#RRGGBB` format |
| imageFileIds | array\<UUID\> | No | FileThunder file IDs for this color's images — stored as-is, **not** validated/resolved against FileThunder (see top-of-doc note) |
| priceAdjustment | decimal | No | Min: 0.0, Default: 0 |

**Request JSON Sample (PHYSICAL)**:
```json
{
  "productType": "PHYSICAL",
  "productName": "iPhone 15 Pro Max 256GB",
  "productDescription": "The most advanced iPhone featuring the A17 Pro chip and titanium design.",
  "price": 1199.00,
  "comparePrice": 1299.00,
  "stockQuantity": 25,
  "lowStockThreshold": 5,
  "categoryId": "123e4567-e89b-12d3-a456-426614174000",
  "condition": "NEW",
  "mediaFileIds": [
    "f11e4567-e89b-12d3-a456-426614174111",
    "f22e4567-e89b-12d3-a456-426614174222"
  ],
  "specifications": {
    "Display": "6.7-inch Super Retina XDR OLED",
    "Chip": "A17 Pro"
  },
  "colors": [
    {
      "name": "Natural Titanium",
      "hex": "#F5F5DC",
      "imageFileIds": ["f33e4567-e89b-12d3-a456-426614174333"],
      "priceAdjustment": 0.00
    }
  ],
  "minOrderQuantity": 1,
  "maxOrderQuantity": 3,
  "groupBuyingEnabled": true,
  "groupMaxSize": 50,
  "groupPrice": 1099.00,
  "groupTimeLimitHours": 72
}
```

**Request JSON Sample (DIGITAL)**:
```json
{
  "productType": "DIGITAL",
  "productName": "UI Design Kit Pro",
  "productDescription": "A comprehensive Figma component library with 500+ components.",
  "price": 49.00,
  "stockQuantity": 1000,
  "categoryId": "123e4567-e89b-12d3-a456-426614174000",
  "mediaFileIds": ["f44e4567-e89b-12d3-a456-426614174444"],
  "digitalFileId": "f55e4567-e89b-12d3-a456-426614174555",
  "previewFileId": "f66e4567-e89b-12d3-a456-426614174666",
  "previewDownloadable": false,
  "downloadExpiryDays": 30,
  "maxDownloadsPerBuyer": 5,
  "maxQuantityForDigital": 1
}
```

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Product created successfully",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "productSlug": "iphone-15-pro-max-256gb",
    "productName": "iPhone 15 Pro Max 256GB",
    "status": "ACTIVE",
    "shopSlug": "techstore-pro"
  }
}
```

**Business Rules**:
- Product name must be unique within the shop
- `comparePrice` must be greater than `price`
- `groupPrice` must be less than `price`
- All group buying settings (`groupMaxSize`, `groupPrice`, `groupTimeLimitHours`) required when `groupBuyingEnabled=true`
- `maxOrderQuantity` must be ≥ `minOrderQuantity`
- Digital download fields (`downloadExpiryDays`, `maxDownloadsPerBuyer`, `maxQuantityForDigital`) only apply to `DIGITAL` products
- `mediaFileIds` are resolved from the FileThunder Redis cache at request time — each file must have been requested via `POST api/v1/files/request-upload` (context `PRODUCT_IMAGE`/`PRODUCT_VIDEO`), be owned by the caller, and be at least `PROCESSING` (a still-uploading or expired/never-started file is rejected). Files that finish processing after the product is created are silently upgraded to `READY` in place via a FileThunder webhook
- `DIGITAL` products require `digitalFileId` — creation fails immediately if it's missing
- After creation, add installment plans via [Installment Plan Config](#15-installment-plan-config); digital files and previews can also be (re)managed later via [Digital File Management](#16-digital-file-management) and [Product Preview Management](#17-product-preview-management)

**Error Responses**:
- `400`: Validation errors or business rule violations
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or category not found, or a referenced `mediaFileIds`/`digitalFileId`/`previewFileId` was never uploaded (upload expired or never started)
- `409`: Product with same name already exists in shop
- `422`: Field-level validation errors

---

## 2. Update Product
**Purpose**: Updates an existing product. Only provided fields are updated.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the product |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| action | ReqAction | Yes | — | `SAVE_DRAFT` or `SAVE_PUBLISH` |

**Request Body Parameters** (all optional):
| Parameter | Type | Description | Validation |
|-----------|------|-------------|------------|
| productName | string | Updated name | Min: 2, Max: 100 chars |
| productDescription | string | Updated description | Min: 10, Max: 1000 chars |
| price | decimal | Updated selling price | Min: 0.01 |
| comparePrice | decimal | Updated compare price | Must be > price |
| stockQuantity | integer | Updated stock | Min: 0 |
| lowStockThreshold | integer | Updated low stock threshold | Min: 1, Max: 1000 |
| condition | ProductCondition | Updated condition | See enum values |
| status | ProductStatus | Updated status | Overridden by `action` |
| urgencyTag | UrgencyTag | Urgency badge on product | `NONE`, `NEW_ARRIVAL`, `LIMITED_EDITION`, `LIMITED_OFFER`, `FEW_REMAINS` |
| categoryId | UUID | Updated category | Must exist and be active |
| mediaFileIds | array\<UUID\> | Updated list of FileThunder file IDs for product images/videos, in order | Same validation as Create; **merged**, not replaced — see note below |
| specifications | object | Updated specifications | Completely replaces existing |
| colors | array | Updated color variations | Completely replaces existing — see Color object in [Create Product](#1-create-product) (same `imageFileIds` shape) |
| minOrderQuantity | integer | Updated min order qty | Min: 1 |
| maxOrderQuantity | integer | Updated max order qty | Min: 1, must be ≥ min |
| groupBuyingEnabled | boolean | Enable/disable group buying | — |
| groupMaxSize | integer | Updated group max | Min: 2 |
| groupPrice | decimal | Updated group price | Must be < price |
| groupTimeLimitHours | integer | Updated group time limit | Min: 1, Max: 8760 |
| installmentEnabled | boolean | Enable/disable installment feature toggle | — |
| maxQuantityForInstallment | integer | Max qty a buyer can purchase on installment | Min: 1 |
| showStockAvailableToPublic | boolean | Show available stock count publicly | — |
| showSoldCountToPublic | boolean | Show sold count publicly | — |
| clearPreview | boolean | ⚠️ Currently only clears legacy preview fields — does **not** remove an active preview (see note below) | — |
| previewDownloadable | boolean | Allow/disallow viewers from downloading the preview file | — |

**Notes on `mediaFileIds`**:
- Unlike Create, Update **merges** by fileId: any ID already present in the product's stored media that has finished processing (has resolved `variants`) is kept as-is and just re-ordered; any new ID is resolved fresh from the FileThunder Redis cache (same ownership/context checks as Create)
- To remove an image, omit its fileId from the array — there is no separate "delete image" endpoint
- Digital file and preview are **not** updatable through this endpoint — there is no `digitalFileId`/`previewFileId` field here. Manage them via [Digital File Management](#16-digital-file-management) and [Product Preview Management](#17-product-preview-management)

**Note on `clearPreview`**: this flag only nulls the legacy `previewType`/`previewUrl` entity fields, which are already dead (never populated by the current preview pipeline). It does **not** clear `previewFtFileId`/`previewContext`/`previewStatus`/`previewVariants`. To actually remove a product's preview, call `DELETE .../products/{productId}/preview` ([17c](#17c-remove-preview)).

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Product updated successfully and published",
  "data": {
    "...": "full ProductDetailedResponse — same shape as Get Product Detailed (#6), rebuilt from the saved product"
  }
}
```

**Error Responses**:
- `400`: Invalid update data or validation errors
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found, or a referenced `mediaFileIds` entry was never uploaded
- `409`: Updated product name already exists

---

## 3. Publish Product
**Purpose**: Publishes a draft product making it active and publicly available.

**Endpoint**: <span style="background-color: #6f42c1; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/publish`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the draft product |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Product 'iPhone 15 Pro Max' published successfully",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "productName": "iPhone 15 Pro Max",
    "status": "ACTIVE",
    "publishedAt": "2026-05-19T14:45:00Z"
  }
}
```

**Publishing Requirements**:
- Product name, description, price, stock quantity, category, and at least one image must be present
- Group buying settings complete if enabled
- Installment plans present if installment is enabled

**Error Responses**:
- `400`: Product already published or missing required publish fields
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

## 4. Delete Product
**Purpose**: Deletes a product. Draft products are hard-deleted; published products are soft-deleted.

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the product |

**Response JSON Sample (Soft Delete)**:
```json
{
  "success": true,
  "message": "Product 'iPhone 15 Pro Max' has been deleted and will be permanently removed after 30 days",
  "data": {
    "productName": "iPhone 15 Pro Max",
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "previousStatus": "ACTIVE",
    "deletedAt": "2026-05-19T15:00:00Z",
    "deletionType": "SOFT_DELETE"
  }
}
```

**Response JSON Sample (Hard Delete)**:
```json
{
  "success": true,
  "message": "Draft product 'iPhone 15 Pro Max' has been permanently deleted",
  "data": null
}
```

**Deletion Logic**:
- **Draft products**: Hard delete (permanently removed)
- **Published products**: Soft delete (status → ARCHIVED, 30-day recovery window)

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

## 5. Restore Product
**Purpose**: Restores a soft-deleted product back to draft status.

**Endpoint**: <span style="background-color: #6f42c1; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/restore`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the soft-deleted product |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Product 'iPhone 15 Pro Max' has been restored successfully",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "productName": "iPhone 15 Pro Max",
    "status": "DRAFT",
    "restoredAt": "2026-05-19T15:30:00Z",
    "note": "Product restored as draft. Publish to make it active again."
  }
}
```

**Error Responses**:
- `400`: Product is not deleted
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

## 6. Get Product Detailed (Owner/Admin View)
**Purpose**: Retrieves comprehensive product details including all management information.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/detailed`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the product |

**Response JSON Sample** (`ProductDetailedResponse`):
```json
{
  "success": true,
  "message": "Product details retrieved successfully",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "productName": "iPhone 15 Pro Max 512GB",
    "productType": "PHYSICAL",
    "productSlug": "iphone-15-pro-max-512gb",
    "productDescription": "The most advanced iPhone ever...",
    "productMedia": [
      {
        "fileId": "f11e4567-e89b-12d3-a456-426614174111",
        "mediaType": "IMAGE",
        "status": "READY",
        "variants": {
          "original": "https://cdn.example.com/products/f11e4567/original.jpg",
          "thumb": "https://cdn.example.com/products/f11e4567/thumb.jpg"
        },
        "mimeType": "image/jpeg",
        "order": 0
      }
    ],
    "price": 1199.00,
    "comparePrice": 1299.00,
    "discountPercentage": 7.69,
    "isOnSale": true,
    "stockQuantity": 25,
    "lowStockThreshold": 5,
    "isInStock": true,
    "isLowStock": false,
    "stockInfo": {
      "stockTotal": 25,
      "stockHeld": 2,
      "stockAvailable": 23,
      "soldCount": 140,
      "holdBreakdown": {
        "heldForCheckout": 1,
        "heldForGroupPurchase": 1,
        "heldForInstallment": 0,
        "nearestCheckoutExpiry": "2026-07-08T16:00:00"
      },
      "showStockAvailableToPublic": true,
      "showSoldCountToPublic": true
    },
    "condition": "NEW",
    "status": "ACTIVE",
    "urgencyTag": "NONE",
    "shopId": "123e4567-e89b-12d3-a456-426614174000",
    "shopName": "TechStore Pro",
    "shopSlug": "techstore-pro",
    "categoryId": "789e0123-e89b-12d3-a456-426614174002",
    "categoryName": "Smartphones",
    "createdAt": "2026-05-19T10:30:00",
    "updatedAt": "2026-05-19T14:30:00",
    "createdBy": "111e4567-e89b-12d3-a456-426614174aaa",
    "editedBy": "111e4567-e89b-12d3-a456-426614174aaa",
    "specifications": {
      "Display": "6.7-inch Super Retina XDR OLED",
      "Chip": "A17 Pro"
    },
    "hasSpecifications": true,
    "specificationCount": 2,
    "colors": [
      {
        "name": "Natural Titanium",
        "hex": "#F5F5DC",
        "images": ["f33e4567-e89b-12d3-a456-426614174333"],
        "priceAdjustment": 0.00,
        "finalPrice": 1199.00,
        "hasExtraFee": false,
        "extraFeeReason": null
      }
    ],
    "hasMultipleColors": false,
    "colorCount": 1,
    "priceRange": {
      "minPrice": 1199.00,
      "maxPrice": 1199.00,
      "priceStartsFrom": 1199.00,
      "hasPriceVariations": false
    },
    "orderingLimits": {
      "minOrderQuantity": 1,
      "maxOrderQuantity": 3,
      "canOrderQuantity": 3,
      "maxAllowedQuantity": 3,
      "hasOrderingLimits": true
    },
    "groupBuying": {
      "isEnabled": true,
      "maxGroupSize": 50,
      "groupPrice": 1099.00,
      "groupDiscount": 100.00,
      "groupDiscountPercentage": 8.34,
      "timeLimitHours": 72,
      "canJoinGroup": true
    },
    "installmentOptions": {
      "isEnabled": true,
      "isAvailable": true,
      "downPaymentRequired": true,
      "minDownPaymentPercentage": 20.00,
      "plans": [],
      "eligibilityStatus": "ELIGIBLE",
      "creditCheckRequired": false
    },
    "purchaseOptions": {
      "canBuyNow": true,
      "canJoinGroup": true,
      "canPayInstallment": true,
      "recommendedOption": "GROUP_BUYING",
      "bestDeal": { "option": "GROUP_BUYING", "savings": 100.00, "finalPrice": 1099.00 }
    },
    "preview": {
      "type": null,
      "variants": { "thumb": "https://cdn.example.com/preview/f66e4567/thumb.jpg" },
      "downloadable": false
    }
  }
}
```

**Notes on media/preview in this response**:
- `productMedia` items may be `status: "PROCESSING"` with `variants: null` right after creation — poll this endpoint (or re-fetch) until `status` flips to `"READY"`
- `colors[].images` currently holds the raw FileThunder file ID strings supplied at create/update time, **not** resolved URLs — see the "known gap" note at the top of this doc
- `preview.type` is always `null` here regardless of whether a preview exists — it's wired to a legacy field the current preview pipeline never sets. Use `GET .../products/{productId}/preview` ([17b](#17b-get-preview)) for the authoritative preview `context`/`status`/`variants`

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

## 7. Get Shop Products (Management View)
**Purpose**: Retrieves all products for a shop with summary statistics.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/all`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Retrieved 47 products from shop: TechStore Pro",
  "data": {
    "shop": {
      "shopId": "123e4567-e89b-12d3-a456-426614174000",
      "shopName": "TechStore Pro",
      "isVerified": true,
      "isMyShop": true
    },
    "summary": {
      "totalProducts": 47,
      "activeProducts": 35,
      "draftProducts": 8,
      "outOfStockProducts": 4,
      "lowStockProducts": 6,
      "productsWithGroupBuying": 18,
      "productsWithInstallments": 25
    },
    "products": [
      {
        "productId": "456e7890-e89b-12d3-a456-426614174001",
        "productName": "iPhone 15 Pro Max 512GB",
        "productType": "PHYSICAL",
        "productSlug": "iphone-15-pro-max-512gb",
        "productPrimaryMedia": {
          "fileId": "f11e4567-e89b-12d3-a456-426614174111",
          "mediaType": "IMAGE",
          "status": "READY",
          "thumbUrl": "https://cdn.example.com/products/f11e4567/thumb.jpg",
          "mimeType": "image/jpeg"
        },
        "price": 1199.00,
        "stockQuantity": 25,
        "status": "ACTIVE",
        "isInStock": true,
        "hasGroupBuying": true,
        "hasInstallments": true,
        "createdAt": "2026-05-19T10:30:00Z"
      }
    ],
    "totalProducts": 47
  }
}
```

**Note**: each product card is a `ProductSummaryResponse` — `productPrimaryMedia` is a `PrimaryMediaResponse` (`fileId`, `mediaType`, `status`, `thumbUrl`, `mimeType`), or `null` if no media was uploaded at all. It resolves to the product's first `IMAGE` item; if the product has no image (e.g. video-only), it falls back to that video's `poster`/`thumb` frame instead — so `mediaType` here can be `VIDEO` even though `thumbUrl` is a still image. `thumbUrl` is only populated once the underlying file's `status` is `READY`; while `PROCESSING`, `productPrimaryMedia` still comes back (so the client can show a placeholder) but `thumbUrl` is `null`. Every response type that surfaces a card thumbnail (`ProductSummaryResponse`, `MarketplaceProductResponse`, cart/wishlist items, installment plan's `ProductBasicInfo`) uses this same shape. Similarly, the accompanying shop logo is always `ShopLogoPrimaryMedia` (`fileId`, `status`, `thumbUrl`, `mimeType`) under the field name `shopLogoMedia` in these same list/card responses — full `ShopMedia` (with the raw `variants` map) is reserved for single-entity detail views only (`ShopResponse`, `ProductPublicResponse`).

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop not found

---

## 8. Get Shop Products Paginated (Management View)
**Purpose**: Retrieves shop products with pagination for management dashboard.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/all-paged`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| page | integer | No | 1 | Page number (1-indexed) |
| size | integer | No | 10 | Items per page (max: 100) |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Retrieved 10 products from shop: TechStore Pro (Page 1 of 5)",
  "data": {
    "contents": { "...same structure as /all..." },
    "currentPage": 1,
    "pageSize": 10,
    "totalElements": 47,
    "totalPages": 5,
    "hasNext": true,
    "hasPrevious": false
  }
}
```

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop not found

---

## 9. Get Public Product by ID
**Purpose**: Retrieves a single active product for public viewing.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}`

**Access Level**: 🌐 Public

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | Shop must be active and approved |
| productId | UUID | Yes | Product must be active |

**Response JSON Sample** (`ProductPublicResponse`):
```json
{
  "success": true,
  "message": "Product retrieved successfully",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "productName": "iPhone 15 Pro Max 512GB",
    "productSlug": "iphone-15-pro-max-512gb",
    "productDescription": "The most advanced iPhone ever...",
    "productMedia": [
      {
        "fileId": "f11e4567-e89b-12d3-a456-426614174111",
        "mediaType": "IMAGE",
        "status": "READY",
        "variants": {
          "original": "https://cdn.example.com/products/f11e4567/original.jpg",
          "thumb": "https://cdn.example.com/products/f11e4567/thumb.jpg"
        },
        "mimeType": "image/jpeg",
        "order": 0
      }
    ],
    "price": 1199.00,
    "comparePrice": 1299.00,
    "discountPercentage": 7.69,
    "isOnSale": true,
    "groupPurchasePrice": 1099.00,
    "isInStock": true,
    "isLowStock": false,
    "stockInfo": {
      "stockAvailable": 23,
      "soldCount": 140
    },
    "condition": "NEW",
    "urgencyTag": "NONE",
    "shopId": "123e4567-e89b-12d3-a456-426614174000",
    "shopName": "TechStore Pro",
    "shopSlug": "techstore-pro",
    "shopLogoMedia": {
      "fileId": "s11e4567-e89b-12d3-a456-426614174001",
      "status": "READY",
      "variants": { "thumb": "https://cdn.example.com/shops/s11e4567/thumb.jpg" },
      "mimeType": "image/png"
    },
    "categoryId": "789e0123-e89b-12d3-a456-426614174002",
    "categoryName": "Smartphones",
    "specifications": { "Display": "6.7-inch OLED" },
    "hasSpecifications": true,
    "colors": [
      {
        "name": "Natural Titanium",
        "hex": "#F5F5DC",
        "images": ["f33e4567-e89b-12d3-a456-426614174333"],
        "priceAdjustment": 0.00,
        "finalPrice": 1199.00
      }
    ],
    "hasMultipleColors": false,
    "priceRange": {
      "minPrice": 1199.00,
      "maxPrice": 1199.00,
      "hasPriceVariations": false
    },
    "groupBuying": {
      "isAvailable": true,
      "maxGroupSize": 50,
      "groupPrice": 1099.00,
      "groupDiscount": 100.00,
      "groupDiscountPercentage": 8.34,
      "timeLimitHours": 72
    },
    "installmentOptions": {
      "isAvailable": true,
      "downPaymentRequired": true,
      "minDownPaymentPercentage": 20.00,
      "plans": [
        {
          "installmentPlanId": "aa1e4567-e89b-12d3-a456-426614174aaa",
          "duration": 6,
          "interval": "MONTHLY",
          "interestRate": 0.00,
          "description": "6-Month Interest-Free"
        }
      ]
    },
    "previewType": null,
    "previewUrl": null,
    "previewDownloadable": true,
    "isInCart": null,
    "cartQuantity": null,
    "isInWishlist": null,
    "wishlistItemId": null,
    "wishlistGroupId": null,
    "wishlistGroupName": null,
    "createdAt": "2026-05-19T10:30:00"
  }
}
```

**Notes**:
- `previewType` and `previewUrl` are wired to legacy fields that the current preview pipeline never populates — they will always be `null`, even when the product has an active, ready preview. There is currently no public field exposing the resolved preview media/variants; `previewDownloadable` is the one real preview field here. To surface preview media publicly, the frontend must call the seller-facing `GET .../preview` endpoint ([17b](#17b-get-preview)) — note that endpoint currently requires shop-owner/admin auth, so a fully public preview-viewing path doesn't exist yet
- `colors[].images` are raw FileThunder file ID strings, not resolved URLs (same gap as the detailed response)
- `isInCart`/`cartQuantity`/`isInWishlist`/`wishlistItemId`/`wishlistGroupId`/`wishlistGroupName` are only populated on this single-product view (not on list responses like #10/#11), and only when the request is authenticated

**Error Responses**:
- `404`: Shop not found/not approved, or product not found/not active

---

## 10. Get Public Shop Products
**Purpose**: Retrieves all active products from a shop for public browsing.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/public-view/all`

**Access Level**: 🌐 Public

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | Shop must be active and approved |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Retrieved 23 products from TechStore Pro",
  "data": {
    "shop": {
      "shopId": "123e4567-e89b-12d3-a456-426614174000",
      "shopName": "TechStore Pro",
      "isVerified": true
    },
    "products": [
      {
        "productId": "456e7890-e89b-12d3-a456-426614174001",
        "productName": "iPhone 15 Pro Max",
        "productPrimaryMedia": {
          "fileId": "f11e4567-e89b-12d3-a456-426614174111",
          "mediaType": "IMAGE",
          "status": "READY",
          "thumbUrl": "https://cdn.example.com/products/f11e4567/thumb.jpg",
          "mimeType": "image/jpeg"
        },
        "price": 1199.00,
        "isOnSale": true,
        "isInStock": true,
        "hasGroupBuying": true,
        "hasInstallments": true
      }
    ],
    "totalProducts": 23
  }
}
```

**Error Responses**:
- `404`: Shop not found, not approved, or not active

---

## 11. Get Public Shop Products Paginated
**Purpose**: Retrieves active products from a shop with pagination for public browsing.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/public-view/all-paged`

**Access Level**: 🌐 Public

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | Shop must be active and approved |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| page | integer | No | 1 | Page number (1-indexed) |
| size | integer | No | 10 | Items per page (max: 50) |

**Error Responses**:
- `404`: Shop not found, not approved, or not active

---

## 12. Search Products
**Purpose**: Searches products within a shop using multi-word query matching across multiple fields.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/search`

**Access Level**: 🌐 Public (Enhanced features for authenticated users)

**Authentication**: Optional Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| q | string | Yes | — | Search query (min: 2, max: 100 chars) |
| status | ProductStatus[] | No | ACTIVE | Statuses to search (owners/admins only for non-ACTIVE) |
| page | integer | No | 1 | Page number |
| size | integer | No | 10 | Items per page (max: 50) |
| sortBy | string | No | relevance | `relevance`, `createdAt`, `updatedAt`, `productName`, `price`, `stockQuantity`, `brand` |
| sortDir | string | No | desc | `asc` or `desc` |

**Search Behavior**:
| Feature | Description |
|---------|-------------|
| Multi-word | Searches for products containing ALL words |
| Partial match | `"iph"` matches `"iPhone"` |
| Cross-field | Matches against name, description, brand, tags, specifications |
| Case-insensitive | `"APPLE"` matches `"apple"` |

**User Access**:
| User Type | Searchable Statuses |
|-----------|-------------------|
| Public / Authenticated | ACTIVE only |
| Shop Owner / Admin | All statuses |

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Found 12 products matching 'iphone'",
  "data": {
    "contents": {
      "shop": { "shopId": "...", "shopName": "TechStore Pro" },
      "products": [ { "...product summary fields..." } ],
      "totalProducts": 12,
      "searchMetadata": {
        "searchQuery": "iphone",
        "searchedStatuses": ["ACTIVE"],
        "userType": "PUBLIC"
      }
    },
    "currentPage": 1,
    "pageSize": 10,
    "totalElements": 12,
    "totalPages": 2,
    "hasNext": true,
    "hasPrevious": false
  }
}
```

**Error Responses**:
- `400`: Query too short or too long
- `404`: Shop not found or not accessible

---

## 13. Advanced Product Filter
**Purpose**: Filters products using multiple criteria with combined AND/OR logic.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/advanced-filter`

**Access Level**: 🌐 Public (Enhanced features for authenticated users)

**Authentication**: Optional Bearer Token

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |

**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| minPrice | decimal | No | — | Minimum price |
| maxPrice | decimal | No | — | Maximum price (must be ≥ minPrice) |
| condition | ProductCondition | No | — | Condition filter |
| categoryId | UUID | No | — | Category filter |
| inStock | boolean | No | — | Filter by availability |
| onSale | boolean | No | — | Filter by sale status |
| hasGroupBuying | boolean | No | — | Filter by group buying |
| hasInstallments | boolean | No | — | Filter by installments |
| hasMultipleColors | boolean | No | — | Filter by color variations |
| status | ProductStatus[] | No | ACTIVE | Status filter (owners/admins for non-ACTIVE) |
| page | integer | No | 1 | Page number |
| size | integer | No | 10 | Items per page (max: 50) |
| sortBy | string | No | createdAt | `createdAt`, `updatedAt`, `productName`, `price`, `stockQuantity` |
| sortDir | string | No | desc | `asc` or `desc` |

**Filter Logic**:
| Filter Type | Logic |
|-------------|-------|
| Price range | AND (minPrice AND maxPrice) |
| Feature flags | AND (all must match) |
| Multiple statuses | OR |

**Error Responses**:
- `400`: Invalid filter values or price range error
- `404`: Shop or category not found

---

## 14. Get Public Product by Slug
**Purpose**: Retrieves a single active product by its slug (same response shape as [Get Public Product by ID](#9-get-public-product-by-id)).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/find-by-slug/{slug}`

**Access Level**: 🌐 Public

**Path Parameters**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | Shop must be active and approved |
| slug | string | Yes | Product slug |

**Error Responses**:
- `404`: Shop not found/not approved, or product not found/not active

---

## 15. Installment Plan Config
**Purpose**: CRUD for installment plans attached to a product. Plans are created separately after the product, and linked to it by `productId`.

**Base URL**: `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters (shared)**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the product |

---

### 15a. Create Installment Plan
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans`

**Request Body**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| planName | string | Yes | Display name for the plan | Min: 3, Max: 100 chars |
| paymentFrequency | PaymentFrequency | Yes | Payment interval | `DAILY`, `WEEKLY`, `BI_WEEKLY`, `SEMI_MONTHLY`, `MONTHLY`, `QUARTERLY`, `CUSTOM_DAYS` |
| customFrequencyDays | integer | Conditional | Days between payments | Required when `paymentFrequency=CUSTOM_DAYS`, min: 1 |
| numberOfPayments | integer | Yes | Total number of payments | Min: 2, Max: 120 |
| apr | decimal | Yes | Annual percentage rate | Min: 0.0, Max: 36.0, 2 decimal places |
| minDownPaymentPercent | integer | Yes | Minimum down payment % | Min: 10, Max: 50 |
| fulfillmentTiming | FulfillmentTiming | Yes | When to ship | `IMMEDIATE` (ship after down payment), `AFTER_PAYMENT` (layaway — ship after final payment) |
| displayOrder | integer | No | Sort order in UI | Min: 0, Default: 0 |
| isFeatured | boolean | No | Highlight as recommended plan | Default: false |
| isActive | boolean | No | Plan is available to buyers | Default: true |

**Request JSON Sample**:
```json
{
  "planName": "6-Month Interest-Free",
  "paymentFrequency": "MONTHLY",
  "numberOfPayments": 6,
  "apr": 0.00,
  "minDownPaymentPercent": 20,
  "fulfillmentTiming": "IMMEDIATE",
  "displayOrder": 1,
  "isFeatured": true,
  "isActive": true
}
```

**Error Responses**:
- `400`: Validation errors
- `404`: Shop or product not found

---

### 15b. Get All Installment Plans
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans`

Returns a list of all installment plans for the product.

**Error Responses**:
- `404`: Shop or product not found

---

### 15c. Get Installment Plan by ID
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}`

**Additional Path Parameter**:
| Parameter | Type | Description |
|-----------|------|-------------|
| planId | UUID | ID of the installment plan |

---

### 15d. Update Installment Plan
**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}`

All fields are optional — only provided fields are updated. Same field structure as Create.

---

### 15e. Delete Installment Plan
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}`

---

### 15f. Activate / Deactivate Plan
**Activate**: <span style="background-color: #6f42c1; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}/activate`

**Deactivate**: <span style="background-color: #6f42c1; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}/deactivate`

Toggles `isActive` on the plan without changing any other fields.

---

### 15g. Set Featured Plan
**Endpoint**: <span style="background-color: #6f42c1; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `api/v1/e-commerce/products/{shopId}/{productId}/installment-plans/{planId}/set-featured`

Marks the specified plan as the featured (recommended) plan for this product.

---

## 16. Digital File Management
**Purpose**: Manages the single downloadable file for a `DIGITAL` product. The client uploads through the generic FileThunder pipeline first (see the file/media note at the top of this doc), then registers the resulting `ftFileId` with the product here. There is exactly **one** file per product — registering a new `ftFileId` replaces the current pending/active file.

**Base URL**: `api/v1/e-commerce/shops/{shopId}/products/{productId}/digital-file`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role)

**Authentication**: Bearer Token

**Path Parameters (shared)**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the digital product |

---

### 16a. Register File
**Purpose**: Links a FileThunder file (already uploaded with context `DIGITAL_PRODUCT` via `POST api/v1/files/request-upload`) to this product. The file starts in a "pending" shadow state and is promoted to the live/active file once FileThunder finishes virus-scanning it (webhook-driven) — or immediately, if it was already `READY` by the time this call lands (race-condition-safe).

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/digital-file/register`

**Request Body**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| ftFileId | UUID | Yes | FileThunder file ID from `POST api/v1/files/request-upload` (context `DIGITAL_PRODUCT`), owned by the caller |

**Request JSON Sample**:
```json
{
  "ftFileId": "f55e4567-e89b-12d3-a456-426614174555"
}
```

**Response JSON Sample** (`DigitalFileResponse`):
```json
{
  "success": true,
  "message": "File registered — scanning in progress",
  "data": {
    "productId": "456e7890-e89b-12d3-a456-426614174001",
    "fileName": null,
    "contentType": null,
    "fileSize": null,
    "fileVersion": 0,
    "status": null,
    "uploadedAt": null,
    "pending": {
      "status": "PROCESSING",
      "failureReason": null,
      "uploadedAt": "2026-07-07T10:45:00"
    }
  }
}
```

**Upload Flow**:
1. `POST api/v1/files/request-upload` with `context: "DIGITAL_PRODUCT"` → receive `uploadUrl` and `fileId`
2. `PUT {uploadUrl}` with the raw file bytes (client-to-storage, not through this API)
3. `POST .../digital-file/register` with `{ "ftFileId": fileId }` — do this immediately, you don't need to wait for the upload/scan to finish
4. Poll `GET .../digital-file` until `status` (or `pending.status`) is `READY`; `FAILED` means re-upload and register again

**Notes**:
- The top-level `fileName`/`contentType`/`fileSize`/`status`/`uploadedAt` describe the **active** (already-promoted) file; `pending` describes a file that was just registered and hasn't finished processing/replacing the active one yet. Both can be non-null at once while a replacement is in flight
- Can also happen implicitly during [Create Product](#1-create-product) via the `digitalFileId` field — same underlying registration logic

**Error Responses**:
- `400`: File not found in Redis (upload expired/never started), or file does not belong to the caller
- `401`: Authentication required
- `403`: Insufficient permissions, or product is not `DIGITAL`
- `404`: Shop or product not found

---

### 16b. Get Product File
**Purpose**: Retrieves the current state of the product's digital file (active + any pending replacement).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/digital-file`

Returns the same `DigitalFileResponse` shape as [16a](#16a-register-file).

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop/product not found, or no digital file has ever been registered for this product

---

### 16c. Delete File
**Purpose**: Removes the digital file record from the product entirely (both active and pending state).

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/digital-file`

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Digital file removed",
  "data": null
}
```

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop/product not found, or no digital file exists for this product

---

## 17. Product Preview Management
**Purpose**: Manages a single preview file per product — a teaser shown to buyers before purchase (works for any `productType`, not just `DIGITAL`). Distinct from the private [Digital File](#16-digital-file-management). Supports VIDEO, PDF, 3D model, and IMAGE previews. Like all product media, previews go through the generic FileThunder upload pipeline (see the note at the top of this doc) — there is no preview-specific presign/confirm flow anymore.

**Base URL**: `api/v1/e-commerce/shops/{shopId}/products/{productId}/preview`

**Access Level**: 🔒 Protected (Requires shop owner or system admin role) — **except** [17d](#17d-get-preview-document-url), which only requires authentication (any buyer)

**Authentication**: Bearer Token

**Path Parameters (shared)**:
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| shopId | UUID | Yes | ID of the shop |
| productId | UUID | Yes | ID of the product (any type — PHYSICAL or DIGITAL) |

---

### 17a. Register Preview
**Purpose**: Links a FileThunder file (already uploaded via `POST api/v1/files/request-upload` with context `PRODUCT_PREVIEW_IMAGE`, `PRODUCT_PREVIEW_VIDEO`, or `PRODUCT_PREVIEW_DOCUMENT`) to this product as its preview. The preview's context/type is inferred server-side from the file's MIME type — there is no client-supplied `previewType` anymore. If the product already has a preview, it's replaced. Processing is async and webhook-driven, with the same immediate-promotion race-condition guard as the digital file.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/preview/register`

**Request Body** (`RegisterPreviewRequest`):
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| ftFileId | UUID | Yes | FileThunder file ID, owned by the caller |
| previewDownloadable | boolean | No | Whether buyers can download the raw preview file itself. Default: `false` (view/stream only) |

**Request JSON Sample**:
```json
{
  "ftFileId": "f66e4567-e89b-12d3-a456-426614174666",
  "previewDownloadable": false
}
```

**Response JSON Sample** (`PreviewResponse`):
```json
{
  "success": true,
  "message": "Preview registered — processing in background",
  "data": {
    "previewFtFileId": "f66e4567-e89b-12d3-a456-426614174666",
    "previewContext": "PRODUCT_PREVIEW_VIDEO",
    "previewStatus": "PROCESSING",
    "previewFailureReason": null,
    "previewVariants": null,
    "previewDownloadable": false
  }
}
```

**Upload Flow**:
1. `POST api/v1/files/request-upload` with `context: "PRODUCT_PREVIEW_VIDEO"` (or `_IMAGE`/`_DOCUMENT`) → receive `uploadUrl` and `fileId`
2. `PUT {uploadUrl}` with the raw file bytes (client-to-storage, not through this API)
3. `POST .../preview/register` with `{ "ftFileId": fileId }`
4. Poll `GET .../preview` ([17b](#17b-get-preview)) until `previewStatus` is `READY` — `previewVariants` will then hold the resolved, playable CDN URLs (image/video previews only; documents have no variants — see [17d](#17d-get-preview-document-url))

**Can also happen implicitly** during [Create Product](#1-create-product) via the `previewFileId`/`previewDownloadable` fields (DIGITAL products only there) — same underlying registration logic.

**Error Responses**:
- `400`: File not found in Redis (upload expired/never started), or file does not belong to the caller
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

### 17b. Get Preview
**Purpose**: Retrieves the current preview state for the product.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/preview`

Returns the same `PreviewResponse` shape as [17a](#17a-register-preview). All fields are `null` if no preview has ever been registered.

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

### 17c. Remove Preview
**Purpose**: Fully clears the product's preview — `previewFtFileId`, `previewContext`, `previewStatus`, `previewFailureReason`, `previewVariants`, and `previewDownloadable` are all reset. This is the only way to actually clear a preview (the `clearPreview` flag on [Update Product](#2-update-product) does **not** do this — see the note there).

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/preview`

**Response JSON Sample**:
```json
{
  "success": true,
  "message": "Preview removed from product",
  "data": null
}
```

**Error Responses**:
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Shop or product not found

---

### 17d. Get Preview Document URL
**Purpose**: For document previews only (`previewContext = PRODUCT_PREVIEW_DOCUMENT`, e.g. a PDF) — generates a short-lived, direct download URL. Documents don't get resolved `variants` like images/videos do, so this is the only way to actually fetch a document preview's bytes. **Access differs from the rest of this section**: any authenticated user can call it (no shop-owner/admin check) — it's meant for buyers evaluating the product.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `api/v1/e-commerce/shops/{shopId}/products/{productId}/preview/document-url`

**Response JSON Sample** (`PreviewDocumentUrlResponse`):
```json
{
  "success": true,
  "message": "Preview document URL generated — valid for 15 minutes",
  "data": {
    "url": "https://cdn.example.com/preview/f66e4567/sample.pdf?X-Amz-Signature=...",
    "expiresInSeconds": 900
  }
}
```

**Error Responses**:
- `400`: No preview registered, preview is not a document, preview is not `READY` yet, or `previewDownloadable` is `false` for this product
- `401`: Authentication required
- `404`: Product not found

---

## Quick Reference

### Common HTTP Status Codes
| Code | Meaning |
|------|---------|
| `200 OK` | Successful GET/PUT/PATCH |
| `201 Created` | Successful POST (resource created) |
| `400 Bad Request` | Invalid data, validation errors, business rule violations |
| `401 Unauthorized` | Authentication required or invalid token |
| `403 Forbidden` | Insufficient permissions |
| `404 Not Found` | Resource not found or not accessible |
| `409 Conflict` | Duplicate product name or business constraint violation |
| `422 Unprocessable Entity` | Field-level validation errors |
| `500 Internal Server Error` | Server error |

### User Access Levels
| User Type | Product Management | Status Access |
|-----------|-------------------|---------------|
| **Public** | View active products only | ACTIVE only |
| **Authenticated** | View active products only | ACTIVE only |
| **Shop Owner** | Full CRUD on own shop | All statuses |
| **System Admin** | Full CRUD on all shops | All statuses |

### Product Type Fulfillment Flows
| Type | Flow |
|------|------|
| `PHYSICAL` | Payment → `PENDING_SHIPMENT` → Seller ships → Buyer confirms with 6-digit code → Escrow releases → `COMPLETED` |
| `DIGITAL` | Payment → `COMPLETED` immediately → Escrow released → `DigitalDownloadAccess` records created → Buyer downloads |

### Product Status Lifecycle
```
DRAFT → ACTIVE → INACTIVE → ARCHIVED
  ↑                            ↓
  └───────── RESTORE ──────────┘

OUT_OF_STOCK ←→ ACTIVE (automatic based on inventory)
```

| Status | Public Visibility | Available Actions |
|--------|-------------------|-------------------|
| `DRAFT` | Hidden | Edit, Publish, Hard Delete |
| `ACTIVE` | Visible | Edit, Deactivate, Soft Delete |
| `INACTIVE` | Hidden | Edit, Activate, Soft Delete |
| `OUT_OF_STOCK` | Visible (out of stock badge) | Restock (auto-activates) |
| `ARCHIVED` | Hidden | Restore |

### Enums Reference

**ProductType**: `PHYSICAL`, `DIGITAL`

**PreviewType**: `VIDEO`, `PDF`, `THREE_D`, `IMAGE` — legacy enum, no longer populated by the preview pipeline (always `null` in responses). The real preview media type is inferred from FileThunder's `FtMediaContext`: `PRODUCT_PREVIEW_IMAGE`, `PRODUCT_PREVIEW_VIDEO`, `PRODUCT_PREVIEW_DOCUMENT` (exposed as `previewContext` — see [17b](#17b-get-preview))

**FtFileStatus** (file/media processing status — `mediaFileIds`, digital file, and preview all use this): `PENDING`, `UPLOADING`, `UPLOADED`, `SCANNING`, `PROCESSING`, `LIVE_PARTIAL`, `READY`, `FAILED`

**ProductMediaType** (top-level product media only): `IMAGE`, `VIDEO`

**ProductCondition**: `NEW`, `USED_LIKE_NEW`, `USED_GOOD`, `USED_FAIR`, `REFURBISHED`, `FOR_PARTS`

**ReqAction**: `SAVE_DRAFT` (→ DRAFT status), `SAVE_PUBLISH` (→ ACTIVE status)

**UrgencyTag**: `NONE`, `NEW_ARRIVAL`, `LIMITED_EDITION`, `LIMITED_OFFER`, `FEW_REMAINS`

**PaymentFrequency**: `DAILY`, `WEEKLY`, `BI_WEEKLY`, `SEMI_MONTHLY`, `MONTHLY`, `QUARTERLY`, `CUSTOM_DAYS`

**FulfillmentTiming**: `IMMEDIATE` (ship after down payment), `AFTER_PAYMENT` (layaway — ship after final payment)

### SKU Format
`SHP[8-CHAR-UUID]-[CATEGORY-3]-[BRAND-3]-[ATTRIBUTE-3]-[SEQUENCE-4]`

Example: `SHP12345678-ELE-APP-512-0001`

### Data Format Standards
- **Dates**: ISO 8601 (`2026-05-19T14:30:00Z`)
- **Prices**: Decimal with 2 decimal places, stored as BigDecimal
- **UUIDs**: Standard UUID format
- **Pagination**: 1-indexed page parameter
- **Colors**: Hex format `#RRGGBB`
- **Percentages**: Decimal format (`20.00` = 20%)

### Error Response Format
```json
{
  "success": false,
  "message": "Human-readable error message",
  "error": {
    "code": "ERROR_CODE",
    "details": "Detailed information",
    "field": "fieldName (if field-specific)",
    "timestamp": "2026-05-19T14:30:00Z"
  }
}
```

### Product Creation Flow
```
0. POST /api/v1/files/request-upload (context=PRODUCT_IMAGE/PRODUCT_VIDEO, one call per file)
   PUT {uploadUrl} (direct to storage, one per file)
   — repeat for the digital file (context=DIGITAL_PRODUCT) and preview (context=PRODUCT_PREVIEW_*) if needed
   — collect the resulting fileIds; no need to wait for processing to finish

1. POST /shops/{shopId}/products?action=SAVE_DRAFT
   body includes mediaFileIds, colors[].imageFileIds, digitalFileId, previewFileId
   — create the product shell; product/digital-file/preview media all start as PROCESSING
   and flip to READY asynchronously once FileThunder finishes scanning/transcoding

2. POST /products/{shopId}/{productId}/installment-plans
   — add installment plans (if installmentEnabled)

3. (optional, if not supplied at create, or to replace later)
   POST /shops/{shopId}/products/{productId}/digital-file/register  { "ftFileId": ... }
   — attach/replace the private digital file (DIGITAL products only)

4. (optional, if not supplied at create, or to replace later)
   POST /shops/{shopId}/products/{productId}/preview/register  { "ftFileId": ..., "previewDownloadable": false }
   — attach/replace the preview teaser (any product type)

5. PATCH /shops/{shopId}/products/{productId}/publish
   — publish when ready
```

### Preview vs Digital File
| Aspect | Preview | Digital File |
|--------|---------|---------------|
| **Who sees it** | Everyone (before purchase) | Buyers only (after payment) |
| **Upload context** | `PRODUCT_PREVIEW_IMAGE`/`_VIDEO`/`_DOCUMENT` | `DIGITAL_PRODUCT` |
| **Access** | `previewDownloadable` gates raw download; documents get a 15-min presigned URL via [17d](#17d-get-preview-document-url) | Buyer-only download, expires per `downloadExpiryDays` (see order/download endpoints) |
| **Count** | One per product | One per product |
| **Products** | PHYSICAL or DIGITAL | DIGITAL only |
| **Registration endpoint** | `POST .../preview/register` | `POST .../digital-file/register` |
| **Purpose** | Teaser/sample before buying | Actual purchased content |

# Wishlist Management

**Author**: Josh S. Sakweli, Backend Lead Team  
**Last Updated**: 2026-06-04  
**Version**: v1.0

**Base URL**: `{base_url}/api/v1/e-commerce/wishlist`

**Short Description**: The Wishlist API lets authenticated users save products for later, organize them into named groups, transfer items between groups, and move items directly to the cart. Every wishlist operation is strictly private — users can only read and modify their own wishlist.

**Hints**:
- All endpoints require a valid JWT Bearer token — there are no public wishlist endpoints
- When adding a product, pass either `groupId` (existing group) or `groupName` (creates a new group) — passing both returns `400`
- Group names are unique per user — attempting to create a duplicate name returns `400`
- Deleting a group with `?deleteProducts=false` (default) moves all items in that group to Ungrouped; `?deleteProducts=true` permanently removes those items from the wishlist
- `PATCH /{itemId}/group` with `groupId: null` moves an item to Ungrouped without deleting it
- `isInWishlist`, `wishlistItemId`, `wishlistGroupId`, and `wishlistGroupName` are populated on the single product detail response (`GET /api/v1/e-commerce/products/{slug}`) for authenticated users

---

## Standard Response Format

### Success Response Structure
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Operation completed successfully",
  "action_time": "2025-09-23T10:30:45",
  "data": {}
}
```

### Error Response Structure
```json
{
  "success": false,
  "httpStatus": "BAD_REQUEST",
  "message": "Error description",
  "action_time": "2025-09-23T10:30:45",
  "data": "Error description"
}
```

### Standard Response Fields
| Field | Type | Description |
|-------|------|-------------|
| `success` | boolean | `true` for successful operations, `false` for errors |
| `httpStatus` | string | HTTP status name (OK, BAD_REQUEST, NOT_FOUND, etc.) |
| `message` | string | Human-readable message describing the result |
| `action_time` | string | ISO 8601 timestamp of when the response was generated |
| `data` | object/string | Response payload on success, error details on failure |

### Standard Error Types
- `400 BAD_REQUEST`: Business rule violation (duplicate product, duplicate group name, ambiguous group fields)
- `401 UNAUTHORIZED`: Missing, expired, or invalid JWT token
- `404 NOT_FOUND`: Product, wishlist item, or group not found
- `422 UNPROCESSABLE_ENTITY`: Validation errors with field-level detail
- `500 INTERNAL_SERVER_ERROR`: Unexpected server error

---

## Endpoints

## 1. Add Product to Wishlist

**Purpose**: Adds a product to the authenticated user's wishlist. Optionally places it in an existing group (via `groupId`) or creates a new group on the fly (via `groupName`). If neither is provided the item lands in Ungrouped.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/api/v1/e-commerce/wishlist/add`

**Access Level**: 🔒 Protected (Requires valid JWT)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Authorization` | string | Yes | `Bearer <token>` |

**Request JSON Sample — add to Ungrouped (no group)**:
```json
{
  "productId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```

**Request JSON Sample — add to an existing group**:
```json
{
  "productId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "groupId": "f9e8d7c6-b5a4-3210-fedc-ba9876543210"
}
```

**Request JSON Sample — add and create a new group**:
```json
{
  "productId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "groupName": "Birthday Gifts"
}
```

**Request Body Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `productId` | UUID | Yes | ID of the product to add | Must be a valid, non-deleted product |
| `groupId` | UUID | No | Add to an existing group | Must belong to the authenticated user. Cannot be combined with `groupName` |
| `groupName` | string | No | Create a new group with this name and add the product to it | Must not already exist for this user. Cannot be combined with `groupId` |

**Success Response JSON Sample — added to Ungrouped**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Product added to wishlist successfully",
  "action_time": "2026-06-04T14:22:10",
  "data": null
}
```

**Success Response JSON Sample — added to existing group**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Product added to wishlist in group 'Electronics'",
  "action_time": "2026-06-04T14:22:10",
  "data": null
}
```

**Success Response JSON Sample — new group created and product added**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Product added to wishlist in group 'Birthday Gifts'",
  "action_time": "2026-06-04T14:22:10",
  "data": null
}
```

**Success Response Fields**:
| Field | Description |
|-------|-------------|
| `message` | Confirms the product was added; includes group name when a group is involved |

**Error Response JSON Sample**:
```json
{
  "success": false,
  "httpStatus": "BAD_REQUEST",
  "message": "'Sony WH-1000XM5' is already in your wishlist",
  "action_time": "2026-06-04T14:22:10",
  "data": "'Sony WH-1000XM5' is already in your wishlist"
}
```

---

## 2. Get Wishlist (Flat)

**Purpose**: Returns the authenticated user's full wishlist as a flat list. Each item includes its group ID and group name (or `null` if Ungrouped). Useful for list views where grouping is handled client-side.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/api/v1/e-commerce/wishlist`

**Access Level**: 🔒 Protected (Requires valid JWT)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Authorization` | string | Yes | `Bearer <token>` |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Wishlist retrieved successfully",
  "action_time": "2026-06-04T14:22:10",
  "data": {
    "user": {
      "userId": "uuid",
      "userName": "josh_dev",
      "name": "Josh Sakweli"
    },
    "wishlistSummary": {
      "totalItems": 3,
      "totalValue": 749.97,
      "inStockItems": 2,
      "outOfStockItems": 1
    },
    "wishlistItems": [
      {
        "wishlistId": "uuid",
        "productId": "uuid",
        "productName": "Sony WH-1000XM5",
        "productSlug": "sony-wh-1000xm5",
        "productPrimaryMedia": {
          "fileId": "9c1a2b3d-4e5f-6789-abcd-ef0123456789",
          "mediaType": "IMAGE",
          "status": "READY",
          "thumbUrl": "https://cdn.example.com/products/sony-wh-1000xm5/thumb.webp",
          "mimeType": "image/webp"
        },
        "unitPrice": 349.99,
        "isOnSale": false,
        "shop": {
          "shopId": "uuid",
          "shopName": "Tech Haven",
          "shopSlug": "tech-haven",
          "shopLogoMedia": {
            "fileId": "1d2e3f4a-5b6c-7890-defa-bc1234567890",
            "status": "READY",
            "thumbUrl": "https://cdn.example.com/shops/tech-haven/thumb.webp",
            "mimeType": "image/jpeg"
          }
        },
        "availability": {
          "inStock": true,
          "stockQuantity": 12
        },
        "groupId": "uuid",
        "groupName": "Birthday Gifts",
        "addedAt": "2026-06-04T10:00:00"
      }
    ],
    "updatedAt": "2026-06-04T10:00:00"
  }
}
```

**Success Response Fields**:
| Field | Description |
|-------|-------------|
| `user.userId` | UUID of the authenticated user |
| `user.userName` | System username |
| `user.name` | Full name |
| `wishlistSummary.totalItems` | Total number of items in the wishlist |
| `wishlistSummary.totalValue` | Sum of unit prices of all items |
| `wishlistSummary.inStockItems` | Count of items currently in stock |
| `wishlistSummary.outOfStockItems` | Count of items out of stock |
| `wishlistItems[].wishlistId` | UUID of the wishlist entry (used for remove/transfer) |
| `wishlistItems[].productId` | UUID of the product |
| `wishlistItems[].productName` | Product display name |
| `wishlistItems[].productSlug` | URL-friendly product identifier |
| `wishlistItems[].productPrimaryMedia` | Resolved primary media of the product — `{fileId, mediaType, status, thumbUrl, mimeType}`. `null` if the product has no media yet |
| `wishlistItems[].unitPrice` | Current price of the product |
| `wishlistItems[].isOnSale` | Whether the product is currently on sale |
| `wishlistItems[].shop` | Shop that sells the product — `{shopId, shopName, shopSlug, shopLogoMedia}`, where `shopLogoMedia` is `{fileId, status, thumbUrl, mimeType}` or `null` if the shop has no logo |
| `wishlistItems[].availability.inStock` | Whether the product is in stock |
| `wishlistItems[].availability.stockQuantity` | Current stock count |
| `wishlistItems[].groupId` | UUID of the group this item belongs to (`null` if Ungrouped) |
| `wishlistItems[].groupName` | Name of the group (`null` if Ungrouped) |
| `wishlistItems[].addedAt` | Timestamp when the product was added |
| `updatedAt` | Timestamp of the most recently added item |

---

## 3. Get Wishlist (Grouped)

**Purpose**: Returns the wishlist organized into sections — one section per named group plus a separate Ungrouped section. Useful for grouped display views.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/api/v1/e-commerce/wishlist/grouped`

**Access Level**: 🔒 Protected (Requires valid JWT)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Authorization` | string | Yes | `Bearer <token>` |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Grouped wishlist retrieved successfully",
  "action_time": "2026-06-04T14:22:10",
  "data": {
    "user": {
      "userId": "uuid",
      "userName": "josh_dev",
      "name": "Josh Sakweli"
    },
    "wishlistSummary": {
      "totalItems": 3,
      "totalValue": 749.97,
      "inStockItems": 2,
      "outOfStockItems": 1
    },
    "groups": [
      {
        "groupId": "uuid",
        "groupName": "Birthday Gifts",
        "itemCount": 2,
        "items": [ ]
      }
    ],
    "ungrouped": {
      "groupId": null,
      "groupName": "Ungrouped",
      "itemCount": 1,
      "items": [ ]
    },
    "updatedAt": "2026-06-04T10:00:00"
  }
}
```

**Success Response Fields**:
| Field | Description |
|-------|-------------|
| `user` | Same user summary as flat response |
| `wishlistSummary` | Same summary totals across all items |
| `groups` | Array of named group sections, ordered by creation date (oldest first) |
| `groups[].groupId` | UUID of the group |
| `groups[].groupName` | Name of the group |
| `groups[].itemCount` | Number of items in this group |
| `groups[].items` | Array of wishlist item responses (same shape as flat list items) |
| `ungrouped` | Section for items with no group assigned |
| `ungrouped.groupId` | Always `null` |
| `ungrouped.groupName` | Always `"Ungrouped"` |
| `ungrouped.itemCount` | Count of items with no group |
| `ungrouped.items` | Array of ungrouped wishlist items |
| `updatedAt` | Timestamp of the most recently added item |

---

## 4. Remove Item from Wishlist

**Purpose**: Permanently removes a specific item from the authenticated user's wishlist.

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/api/v1/e-commerce/wishlist/{itemId}`

**Access Level**: 🔒 Protected (Requires valid JWT — owns the item)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Authorization` | string | Yes | `Bearer <token>` |

**Path Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `itemId` | UUID | Yes | The `wishlistId` of the item to remove | Must belong to the authenticated user |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Product removed from wishlist successfully",
  "action_time": "2026-06-04T14:22:10",
  "data": null
}
```

---

## 5. Clear Wishlist

**Purpose**: Permanently removes all items from the authenticated user's wishlist. Groups are not deleted — only the items inside them.

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/api/v1/e-commerce/wishlist/clear`

**Access Level**: 🔒 Protected (Requires valid JWT)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Authorization` | string | Yes | `Bearer <token>` |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Wishlist cleared successfully",
  "action_time": "2026-06-04T14:22:10",
  "data": null
}
```

---

## 6. Move Item to Cart

**Purpose**: Adds a wishlist item to the user's cart at the specified quantity. The item remains in the wishlist — it is not automatically removed.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/api/v1/e-commerce/wishlist/move-to-cart/{itemId}`

**Access Level**: 🔒 Protected (Requires valid JWT)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Authorization` | string | Yes | `Bearer <token>` |

**Path Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `itemId` | UUID | Yes | The `wishlistId` of the item to move | Must belong to the authenticated user |

**Query Parameters**:
| Parameter | Type | Required | Description | Validation | Default |
|-----------|------|----------|-------------|------------|---------|
| `quantity` | integer | No | Quantity to add to cart | Min: 1 | `1` |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Product moved to cart successfully",
  "action_time": "2026-06-04T14:22:10",
  "data": null
}
```

---

## 7. Transfer Item to Group

**Purpose**: Moves a wishlist item to a different group, or removes it from its current group by setting `groupId` to `null` (moves to Ungrouped). The item stays in the wishlist — only its group assignment changes.

**Endpoint**: <span style="background-color: #fd7e14; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `{base_url}/api/v1/e-commerce/wishlist/{itemId}/group`

**Access Level**: 🔒 Protected (Requires valid JWT — owns the item)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Authorization` | string | Yes | `Bearer <token>` |

**Path Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `itemId` | UUID | Yes | The `wishlistId` of the item to transfer | Must belong to the authenticated user |

**Request JSON Sample — move to a group**:
```json
{
  "groupId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```

**Request JSON Sample — remove from group (move to Ungrouped)**:
```json
{
  "groupId": null
}
```

**Request Body Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `groupId` | UUID or null | Yes | Target group UUID, or `null` to move to Ungrouped | When a UUID, must be a group that belongs to the authenticated user |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Item moved to group 'Electronics'",
  "action_time": "2026-06-04T14:22:10",
  "data": null
}
```

---

## 8. Create Group

**Purpose**: Creates a new named wishlist group for the authenticated user. Group names must be unique per user.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/api/v1/e-commerce/wishlist/groups`

**Access Level**: 🔒 Protected (Requires valid JWT)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Authorization` | string | Yes | `Bearer <token>` |

**Request JSON Sample**:
```json
{
  "name": "Electronics"
}
```

**Request Body Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `name` | string | Yes | Display name for the group | Must not be blank. Must be unique for the authenticated user |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Wishlist group created successfully",
  "action_time": "2026-06-04T14:22:10",
  "data": {
    "groupId": "uuid",
    "name": "Electronics",
    "itemCount": 0,
    "createdAt": "2026-06-04T14:22:10"
  }
}
```

**Success Response Fields**:
| Field | Description |
|-------|-------------|
| `groupId` | UUID of the newly created group |
| `name` | Name of the group as saved |
| `itemCount` | Always `0` on creation |
| `createdAt` | Timestamp of group creation |

**Error Response JSON Sample**:
```json
{
  "success": false,
  "httpStatus": "BAD_REQUEST",
  "message": "A group named 'Electronics' already exists",
  "action_time": "2026-06-04T14:22:10",
  "data": "A group named 'Electronics' already exists"
}
```

---

## 9. Get Groups

**Purpose**: Returns all wishlist groups created by the authenticated user, ordered by creation date (oldest first). Each group includes a live item count.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/api/v1/e-commerce/wishlist/groups`

**Access Level**: 🔒 Protected (Requires valid JWT)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Authorization` | string | Yes | `Bearer <token>` |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Wishlist groups retrieved successfully",
  "action_time": "2026-06-04T14:22:10",
  "data": [
    {
      "groupId": "uuid",
      "name": "Birthday Gifts",
      "itemCount": 3,
      "createdAt": "2026-06-01T09:00:00"
    },
    {
      "groupId": "uuid",
      "name": "Electronics",
      "itemCount": 1,
      "createdAt": "2026-06-03T11:30:00"
    }
  ]
}
```

**Success Response Fields**:
| Field | Description |
|-------|-------------|
| `[].groupId` | UUID of the group |
| `[].name` | Display name of the group |
| `[].itemCount` | Current number of wishlist items in this group |
| `[].createdAt` | Timestamp of group creation |

---

## 10. Delete Group

**Purpose**: Deletes a wishlist group. Controls what happens to the items inside via the `deleteProducts` query parameter.

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/api/v1/e-commerce/wishlist/groups/{groupId}`

**Access Level**: 🔒 Protected (Requires valid JWT — owns the group)

**Authentication**: Bearer Token

**Request Headers**:
| Header | Type | Required | Description |
|--------|------|----------|-------------|
| `Authorization` | string | Yes | `Bearer <token>` |

**Path Parameters**:
| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `groupId` | UUID | Yes | UUID of the group to delete | Must belong to the authenticated user |

**Query Parameters**:
| Parameter | Type | Required | Description | Validation | Default |
|-----------|------|----------|-------------|------------|---------|
| `deleteProducts` | boolean | No | `true` → permanently delete all items in the group from wishlist. `false` → move items to Ungrouped, then delete group | `true` or `false` | `false` |

**Success Response JSON Sample — items moved to Ungrouped**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Group deleted, products moved to Ungrouped",
  "action_time": "2026-06-04T14:22:10",
  "data": null
}
```

**Success Response JSON Sample — items permanently deleted**:
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Group and all its products deleted from wishlist",
  "action_time": "2026-06-04T14:22:10",
  "data": null
}
```

---

## Quick Reference

### All Endpoints Summary

| # | Method | Path | Description |
|---|--------|------|-------------|
| 1 | POST | `/wishlist/add` | Add product to wishlist |
| 2 | GET | `/wishlist` | Get flat wishlist |
| 3 | GET | `/wishlist/grouped` | Get grouped wishlist |
| 4 | DELETE | `/wishlist/{itemId}` | Remove item from wishlist |
| 5 | DELETE | `/wishlist/clear` | Clear entire wishlist |
| 6 | POST | `/wishlist/move-to-cart/{itemId}` | Move item to cart |
| 7 | PATCH | `/wishlist/{itemId}/group` | Transfer item to group |
| 8 | POST | `/wishlist/groups` | Create group |
| 9 | GET | `/wishlist/groups` | Get all groups |
| 10 | DELETE | `/wishlist/groups/{groupId}` | Delete group |

### Group Behavior Rules

| Scenario | Behavior |
|----------|----------|
| Add with no group | Item lands in Ungrouped |
| Add with `groupId` | Item added to that existing group |
| Add with `groupName` (new) | New group created, item added to it |
| Add with `groupName` (exists) | `400` — duplicate group name |
| Add with both `groupId` and `groupName` | `400` — ambiguous request |
| Delete group `?deleteProducts=false` | Items move to Ungrouped, group deleted |
| Delete group `?deleteProducts=true` | Items permanently removed, group deleted |
| Transfer item with `groupId: null` | Item moved to Ungrouped |

# NextGate Product Ecosystem — Architecture & Purchase Flows

## Overview

NextGate's e-commerce layer handles two fundamentally different kinds of products: **physical products** (tangible goods that require shipping and delivery confirmation) and **digital products** (files that a buyer downloads). Both types share the same payment infrastructure, checkout session system, financial rails, and **inventory system** — but diverge completely at the fulfillment stage.

---

## 1. Product Types

### Physical Products

A physical product has real-world stock. The platform tracks inventory, holds it during checkout, ships it through a seller, and releases escrow only after the buyer confirms physical receipt. The entire lifecycle can take days or weeks.

### Digital Products

A digital product is a file (or collection of files) that the seller uploads to a private, access-controlled storage bucket. There is no shipping. There is no confirmation code. The moment payment clears, the buyer can download. Escrow releases immediately. The lifecycle is measured in seconds.

A single product can be either physical or digital — never both. The `productType` field on the product record is the authoritative signal that drives every downstream decision.

---

## 2. Inventory — Shared by Both Product Types

> **Key rule:** Every product has a `stockQuantity`. Digital and physical products are treated identically by the inventory system. The only difference is what happens after payment.

Every product — physical or digital — requires a `stockQuantity` set by the seller at creation time. The seller decides what that number is:

```
10        →  limited edition digital art, exclusive release
500       →  cohort-based course intake
1,000,000 →  effectively open — seller still sets a real number
```

There is no "unlimited" mode and no `trackInventory` toggle. All inventory mechanics apply to both product types without exception:

```
- stockQuantity check on add-to-cart
- Inventory hold at checkout session creation
- Hold released on session expiry or cancellation
- stockQuantity decremented on successful payment
- Low stock threshold warnings apply to both
- isInStock() and canOrderQuantity() run identically
```

This means zero special-casing in the codebase. Every product behaves the same through cart, checkout, and payment. The divergence only begins at fulfillment.

### Why sellers set quantity for digital products

The business reasons vary:

```
Exclusivity / scarcity    →  "Only 500 copies ever sold" — creates urgency and perceived value
License seat control      →  Software licensed for exactly N seats
Cohort control            →  Course intake capped at N students for direct purchase
Business guardrail        →  Seller wants a hard ceiling as a safety net
```

Even if a seller sets 1,000,000, the system enforces it as a real number. The seller always sees and manages a stock figure.

---

## 3. The Only Real Difference Between Physical and Digital

```
                           Physical        Digital
─────────────────────────────────────────────────────────────
stockQuantity              ✓               ✓
Inventory hold at checkout ✓               ✓
Stock decrements on buy    ✓               ✓
Low stock warnings         ✓               ✓
Requires shipping          ✓               ✗
Delivery confirmation code ✓               ✗
Escrow held until delivery ✓               ✗
Order → PENDING_SHIPMENT   ✓               ✗
Escrow released immediately✗               ✓
Order → COMPLETED          ✗               ✓
DigitalDownloadAccess      ✗               ✓
```

Everything above the dividing line is shared. Everything below is where the paths split.

---

## 4. How Sellers Prepare Digital Products

Before a digital product can be purchased, the seller must upload its files through a two-step process designed to handle large files without routing them through the API server.

```
Seller → Request presigned upload URL
       → Platform generates time-limited URL (MinIO private bucket)
       → Seller uploads file DIRECTLY to MinIO (API server bypassed)
       → Seller confirms upload to API
       → Platform records file metadata and links to product
```

A product can have multiple files. Each gets its own record. Buyers get access to all files on purchase.

Sellers configure per product:

```
stockQuantity      →  required · how many units can ever be sold
lowStockThreshold  →  when to trigger low stock warning
maxQuantityForDigital → max a single buyer can purchase in one order
                        (1 for personal-use content, higher for licenses/gifts)

Download rules:
  expiryDays       →  days download links stay active after purchase (default: 7)
  downloadCap      →  max downloads per buyer (default: unlimited)
  clockStart       →  expiry from purchase time OR from first download
```

---

## 5. The Cart

The cart is a persistent bag of products. It does not distinguish between physical and digital items — both coexist freely. Stock availability is checked on add-to-cart for both types.

> The physical/digital split only becomes relevant at fulfillment — not at cart, not at payment.

---

## 6. Checkout Session Types

Every purchase flows through a checkout session — a short-lived record holding purchase intent before payment. All four session types work for both physical and digital products.

| Session Type | Description |
|---|---|
| `REGULAR_DIRECTLY` | Single-item purchase from the product page |
| `REGULAR_CART` | Multi-item purchase from a cart |
| `GROUP_PURCHASE` | Coordinated group buy at a discounted group price |
| `INSTALLMENT` | Down payment now, remainder paid over time |

Sessions expire (typically 15–30 minutes). During this window, inventory is held for both physical and digital products — preventing overselling while the buyer completes payment.

---

## 7. Shipping Logic

```
All items digital?  →  Skip shipping entirely. Cost = 0.
                        Inventory hold still applies.
Any item physical?  →  Shipping address + method required. Inventory held.

Mixed cart (physical + digital, same shop)?
    →  Engine splits into TWO sessions automatically:
           Session 1: physical items  →  shipping lifecycle
           Session 2: digital items   →  download lifecycle
```

---

## 8. Payment Infrastructure (Shared by All Types)

Payment works identically regardless of product type:

```
Buyer wallet  →  debited
Escrow        →  credited (money held, not yet with seller)
Ledger entry  →  recorded (double-entry, full audit trail)
Tx history    →  updated for buyer
```

For installment purchases, only the down payment moves at session time. Each subsequent payment creates its own ledger entry.

> **Escrow is the control point.**
> Physical → held until buyer confirms delivery.
> Digital  → released immediately at order creation.

---

## 9. Post-Payment Fulfillment — The Core Split

### Physical path

```
Payment completes
  → stockQuantity decremented
  → Inventory hold released
  → Order created  [PENDING_SHIPMENT]
  → Seller notified
  → Seller ships → marks order SHIPPED
  → 6-digit confirmation code generated → sent to buyer
  → Buyer enters code in app
  → Escrow releases to seller
  → Order  [COMPLETED]
```

> Escrow is held the entire shipping period. The code is the handshake — seller cannot claim money without buyer confirming receipt.

---

### Digital path

```
Payment completes
  → stockQuantity decremented
  → Inventory hold released
  → Order created  [COMPLETED immediately]
  → Escrow released to seller immediately
  → DigitalDownloadAccess records created (one per file)
  → Buyer notified with download link
  → Buyer downloads within expiry window
```

> No shipping. No confirmation code. Delivery = access records created.

---

## 10. Group Purchase — Physical vs Digital

Both product types support group purchase. The financial hold (escrow per participant) and inventory hold are identical in both cases during the waiting period. What differs is post-completion fulfillment.

### Physical group purchase

```
Each participant pays → Escrow held + Inventory held per participant
Group fills → COMPLETED
  → Physical orders created for all participants  [PENDING_SHIPMENT]
  → stockQuantity decremented for all participants
  → Each participant goes through shipping lifecycle individually
  → Each escrow releases on individual delivery confirmation

Group expires without filling:
  → All escrows refunded
  → All inventory holds released
```

### Digital group purchase

```
Each participant pays → Escrow held + Inventory held per participant
Group fills → COMPLETED
  → Orders created for all participants  [COMPLETED immediately]
  → stockQuantity decremented for all participants
  → All escrows released immediately
  → DigitalDownloadAccess records created for every participant
  → All buyers can download immediately

Group expires without filling:
  → All escrows refunded
  → All inventory holds released  (identical to physical)
```

> The seller's participant cap on the group instance is separate from `stockQuantity`. Both are enforced — a buyer cannot join if either the group is full or the product stock is exhausted.

---

## 11. Installment Purchase — Physical vs Digital

Both product types support installment. The payment schedule, ledger entries, agreement lifecycle, early payoff, and flexible payment features are identical. What differs is fulfillment timing.

### Fulfillment timing options

**IMMEDIATE** — order and access created after the down payment. Buyer gets the product now, pays over time.
- Physical: seller ships after down payment, takes risk on future payments.
- Digital: buyer downloads immediately. If buyer defaults, downloaded files cannot be revoked. Seller has no recourse.

**AFTER_PAYMENT** — order and access created only after the final payment clears.
- Physical: layaway — seller holds stock, ships at the end.
- Digital: safest model — access never opens until fully paid. On default, access records simply never created.

> **Platform recommendation:** AFTER_PAYMENT is the default for digital installment products. Sellers must explicitly opt into IMMEDIATE with acknowledgment that pre-delivery means no recourse on default.

### On default — digital IMMEDIATE

```
Buyer stops paying → Agreement DEFAULTED
  → No new access records created for future files
  → Already-downloaded files cannot be revoked  ← known limitation, seller accepts this
```

### On default — digital AFTER_PAYMENT

```
Buyer stops paying → Agreement DEFAULTED
  → Access records never created
  → No content ever delivered  ← clean outcome
```

---

## 12. The Download System

On every successful digital purchase (any session type, any fulfillment trigger):

```
Platform creates DigitalDownloadAccess record per file:
  - buyer ID + order ID + file ID
  - downloadCount (starts at 0)
  - maxDownloads (null = unlimited, or seller-set cap)
  - accessExpiresAt (now + expiry window)
  - firstDownloadAt (set on first use)
```

On every download request:

```
Buyer hits authenticated endpoint
  → Check 1: Does buyer own an active access record for this file?
  → Check 2: Has expiry window passed?
  → Check 3: Has download cap been reached?

All pass → Platform generates presigned GET URL (5-min TTL)
         → Buyer browser downloads DIRECTLY from MinIO private bucket
         → Access record downloadCount incremented
         → API server is NOT in the file transfer path
```

> The 5-minute TTL means a leaked URL is useless within minutes. The raw MinIO object key is never exposed to the buyer.

---

## 13. Scenario Walkthrough — All Combinations

### Scenario A — Direct purchase, physical product

> Buyer finds a T-shirt. Clicks "Buy Now". Quantity: 1.

```
REGULAR_DIRECTLY session created
  → Stock check passes · Inventory held
  → Shipping address + method required
  → Buyer pays → Escrow funded
  → stockQuantity decremented · hold released
  → Order created  [PENDING_SHIPMENT]
  → Seller ships → marks SHIPPED
  → 6-digit code sent to buyer
  → Buyer confirms → Escrow released
  → Order  [COMPLETED]
```

---

### Scenario B — Direct purchase, digital product

> Buyer finds a PDF course. Clicks "Buy Now". Quantity: 1.

```
REGULAR_DIRECTLY session created
  → Stock check passes · Inventory held
  → No shipping fields collected
  → Buyer pays → Escrow funded and immediately released
  → stockQuantity decremented · hold released
  → Order created  [COMPLETED]
  → 3 DigitalDownloadAccess records created (one per chapter PDF)
  → Buyer receives download link · downloads within 7 days
```

---

### Scenario C — Direct purchase, digital product, quantity 3

> Buyer wants 3 software licenses to distribute to colleagues.

```
REGULAR_DIRECTLY session created  (quantity: 3)
  → Stock check: stockQuantity >= 3? passes · 3 units held
  → No shipping fields collected
  → Buyer pays (unitPrice × 3) → Escrow funded and immediately released
  → stockQuantity decremented by 3 · hold released
  → Order created  [COMPLETED]
  → 6 DigitalDownloadAccess records created (3 sets × 2 files per product)
  → Each set is independent — buyer can share with colleagues
```

---

### Scenario D — Cart, physical only, multiple shops

> Phone case from Shop A + charger from Shop B.

```
REGULAR_CART session created
  → Inventory held for both items
  → Buyer pays once
  → Order engine groups by shop:
       Order 1: Shop A  [PENDING_SHIPMENT]
       Order 2: Shop B  [PENDING_SHIPMENT]
  → Each seller ships independently
  → Each escrow releases on individual buyer confirmation
```

---

### Scenario E — Cart, digital only

> Video course + design template pack.

```
REGULAR_CART session created
  → Stock check + inventory hold for both digital products
  → No shipping. 
  → Buyer pays once
  → Order 1: video course     [COMPLETED] → 4 access records
  → Order 2: template pack    [COMPLETED] → 2 access records
  → Buyer downloads all 6 files within 7 days
```

---

### Scenario F — Cart, mixed physical + digital, same shop

> Printed book (physical) + PDF supplement (digital), same shop.

```
Engine detects mixed cart → splits automatically:
  Session 1: printed book  →  shipping lifecycle · inventory held
  Session 2: PDF           →  download lifecycle · inventory held

Buyer sees one checkout flow, gets two orders in history:
  Order 1 (book):  [PENDING_SHIPMENT] → shipping → confirmation → escrow releases
  Order 2 (PDF):   [COMPLETED]        → download access immediate
```

---

### Scenario G — Group purchase, physical product

> 5 buyers collectively buy a speaker.

```
Buyer 1 initiates → GROUP_PURCHASE session → pays → group instance created (timer starts)
Buyers 2–5 join   → each pays → inventory held + escrow held per participant
Group fills → COMPLETED
  → Physical orders created for all 5  [PENDING_SHIPMENT]
  → stockQuantity decremented for all 5
  → Each participant ships independently
  → Each escrow releases on individual delivery confirmation

Timer expires before filling:
  → All 5 escrows refunded · all inventory holds released
```

---

### Scenario H — Group purchase, digital product

> 30 buyers join a cohort-based online course. Seller stock: 30.

```
Buyers 1–30 join → each pays → inventory held + escrow held per participant
Group fills (30th seat) → COMPLETED
  → Orders created for all 30  [COMPLETED immediately]
  → stockQuantity decremented by 30 (now 0 — sold out)
  → All 30 escrows released immediately
  → DigitalDownloadAccess records created for every participant
  → All 30 buyers can download immediately

Timer expires before filling:
  → All escrows refunded · all inventory holds released
```

---

### Scenario I — Installment, physical, IMMEDIATE fulfillment

> Laptop, 6-month plan, 20% down, seller ships immediately.

```
INSTALLMENT session created
  → Stock check · inventory held
  → Down payment charged (20%)
  → Agreement created (6 scheduled payments)
  → Order created  [PENDING_SHIPMENT]  ← immediately after down payment
  → stockQuantity decremented · hold released
  → Seller ships
  → Monthly payments auto-process via scheduled jobs
```

---

### Scenario J — Installment, physical, AFTER_PAYMENT fulfillment

> Same laptop, layaway model.

```
INSTALLMENT session created
  → Stock check · inventory held
  → Down payment charged
  → Agreement created
  → NO order created yet · inventory held until final payment
  → Monthly payments auto-process
  → Final payment clears → Agreement COMPLETED
  → Order created  [PENDING_SHIPMENT]  ← only now
  → stockQuantity decremented · hold released
  → Shipping lifecycle begins
```

---

### Scenario K — Installment, digital, AFTER_PAYMENT (recommended)

> Video course library, 500,000 TZS, 3-month plan.

```
INSTALLMENT session created
  → Stock check · inventory held
  → Down payment charged (30%)
  → Agreement created (3 scheduled payments)
  → NO order, NO download access yet · inventory held
  → Monthly payments auto-process
  → Final payment clears → Agreement COMPLETED
  → stockQuantity decremented · hold released
  → Order created  [COMPLETED]
  → DigitalDownloadAccess records created  ← only now
  → Buyer downloads

If buyer defaults:
  → Agreement DEFAULTED
  → Inventory hold released · stockQuantity restored
  → Access records never created · no content delivered
```

---

### Scenario L — Installment, digital, IMMEDIATE fulfillment

> Same course, seller opts into IMMEDIATE.

```
INSTALLMENT session created
  → Stock check · inventory held
  → Down payment charged (30%)
  → Agreement created
  → stockQuantity decremented · hold released
  → Order created  [COMPLETED]           ← immediately
  → DigitalDownloadAccess records created ← immediately
  → Buyer downloads now

Monthly payments continue auto-processing.

If buyer defaults:
  → Agreement DEFAULTED
  → Already-downloaded files CANNOT be revoked  ← seller accepted this risk
```

---

## 14. Escrow Behavior Summary

| Scenario | Inventory Held | Escrow Released |
|---|---|---|
| Physical — direct | Yes, at session creation | On delivery confirmation |
| Digital — direct | Yes, at session creation | Immediately at order creation |
| Physical — group | Yes, per participant | Per delivery confirmation |
| Digital — group | Yes, per participant | Immediately when group completes |
| Group — expired / failed | Released to stock | Refunded to all participants |
| Physical — installment IMMEDIATE | Yes, until down pmt | Per installment via direct ledger |
| Physical — installment AFTER_PAYMENT | Yes, until final pmt | After final payment |
| Digital — installment IMMEDIATE | Yes, until down pmt | After down payment |
| Digital — installment AFTER_PAYMENT | Yes, until final pmt | After final payment |

---

## 15. MinIO Bucket Architecture

```
nextgate-public  (world-readable)
  → Product images · shop logos · avatars · category images
  → URLs are permanent · no expiry

nextgate-digital-content  (private · no public access)
  → Paid digital product files only
  → Accessible only via presigned URLs (5-min TTL)
  → Generated by API after access verification
  → Raw object key never exposed to buyers
```

> This separation ensures a seller can never accidentally expose a paid file by uploading to the wrong location.

---

## 16. Key Invariants

```
- Every money movement goes through the double-entry ledger.
  No direct wallet balance adjustments exist.

- Escrow receives the full payment before any fulfillment action begins.

- Every product has a stockQuantity — physical and digital alike.
  There is no "unlimited" mode. Sellers set a real number.

- Inventory is held for both physical and digital products during the
  checkout session window. Released on expiry, cancellation, or payment.

- Orders are never created before:
    regular purchase  → payment completes
    group purchase    → group fills
    installment       → fulfillment trigger (down pmt or final pmt)

- Download links never expose the raw MinIO object key.
  Only opaque access identifiers resolve to presigned URLs via authenticated API.

- A checkout session produces orders exactly once.
  Idempotency checks prevent duplicates under retry or failure.

- Session expiry is enforced before payment processing.
  An expired session cannot be paid.

- For digital installment IMMEDIATE: already-downloaded files cannot be
  revoked on default. Sellers must explicitly accept this risk.

- For digital installment AFTER_PAYMENT: default means access records
  are never created and stockQuantity is restored. Clean outcome.
```