> ## Documentation Index
> Fetch the complete documentation index at: https://engineering.clinikally.work/llms.txt
> Use this file to discover all available pages before exploring further.

# abandon-checkout-overview

# Abandoned Cart Developer Guide

> 📋 **Handoff doc** — read this to understand the abandoned-cart lifecycle, change thresholds safely, debug notification issues, and extend the system.
>
> Diagrams below are written in **Mermaid**. After importing this file into Notion (**File → Import → Markdown & CSV**), each diagram block will initially show up as plain code text — click into the block and set its language to **Mermaid** from the language dropdown to render it visually. It's a one-time click per diagram, not automatic on import.

## Table of Contents

1. [Overview](#overview)
2. [What this system does](#what-this-system-does)
3. [Where the code lives](#where-the-code-lives)
4. [Architecture at a glance](#architecture-at-a-glance)
5. [Data model](#data-model)
6. [How cart activity is tracked](#how-cart-activity-is-tracked)
7. [How updateCartActivity works](#how-updatecartactivity-works)
8. [How a cart becomes abandoned](#how-a-cart-becomes-abandoned)
9. [How notifications are sent](#how-notifications-are-sent)
10. [How a cart is marked completed](#how-a-cart-is-marked-completed)
11. [Scheduled jobs](#scheduled-jobs)
12. [Flow maps](#flow-maps)
13. [Configuration and thresholds](#configuration-and-thresholds)
14. [Examples](#examples)
15. [How to extend the system](#how-to-extend-the-system)
16. [How to debug issues](#how-to-debug-issues)
17. [Important behaviors and edge cases](#important-behaviors-and-edge-cases)
18. [Known gaps and developer notes](#known-gaps-and-developer-notes)
19. [Quick reference](#quick-reference)

## Overview

This project implements a custom abandoned cart system on top of Medusa using:

* a dedicated `cart_activity` module
* cart activity tracking from events and API middleware
* scheduled jobs to mark stale carts as abandoned
* scheduled jobs to send notification batches to an external API
* completion handling to stop future abandoned-cart notifications

At a high level:

* cart actions update one row in `cart_activity`
* inactive carts with items are later marked `abandoned`
* abandoned carts that have not been notified are sent to a notification API
* once checkout completes, the cart is marked `completed`

**The whole system in one picture:**

```mermaid theme={null}
flowchart LR
    A[Cart action happens] --> B["cart_activity row<br/>one row per cart"]
    B --> C{Inactive 30+ min<br/>and item_count at least 1?}
    C -- No --> B
    C -- Yes --> D[status: abandoned]
    D --> E{Has email or user_id<br/>and not yet notified?}
    E -- Yes --> F[Notification sent<br/>to external API]
    B --> G{Checkout completes?}
    D --> G
    G -- Yes --> H[status: completed]
```

## What this system does

The abandoned cart system handles four responsibilities:

1. **Track cart activity**
   * store latest cart interaction per cart
   * denormalize key cart details like item count, amount, email, currency
2. **Classify stale active carts**
   * if a cart has been inactive long enough, it is marked `abandoned`
3. **Send notifications**
   * abandoned carts with contact info and no previous notification are batched and sent to external notification service
4. **Prevent false abandoned state after order completion**
   * completed carts are explicitly marked `completed`

## Where the code lives

### Core module

| File                                                | Purpose                                                               |
| --------------------------------------------------- | --------------------------------------------------------------------- |
| `src/modules/cart-activity/index.ts`                | Module definition and registration                                    |
| `src/modules/cart-activity/service.ts`              | Core service logic — tracking, abandonment, notifications, completion |
| `src/modules/cart-activity/models/cart-activity.ts` | `cart_activity` data model, incl. the unique index                    |
| `src/modules/cart-activity/types.ts`                | Type contracts, incl. the `CartActivityDetails` status enum           |

### Migrations

* `src/modules/cart-activity/migrations/Migration20260210000000.ts`
* `src/modules/cart-activity/migrations/Migration20260210000001.ts`
* `src/modules/cart-activity/migrations/Migration20260224180851.ts`

### Tracking entry points

| File                                       | Purpose                                                                      |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| `src/subscribers/cart-activity-tracker.ts` | Subscribes to `cart.created` / `cart.updated` events                         |
| `src/api/middlewares.ts`                   | `cartActivityMiddleware` — tracks activity on promotion and line-item routes |

### Jobs

| File                                                | Purpose                                                                |
| --------------------------------------------------- | ---------------------------------------------------------------------- |
| `src/jobs/mark-abandoned-checkouts.ts`              | Runs every minute — marks stale active carts as `abandoned`            |
| `src/jobs/send-abandoned-checkout-notifications.ts` | Runs every 5 minutes — sends batched notifications for abandoned carts |

### Completion handling

| File                                                 | Purpose                                             |
| ---------------------------------------------------- | --------------------------------------------------- |
| `src/api/store/carts/[cart_id]/complete-v2/route.ts` | Marks the cart `completed` on a successful checkout |

## Architecture at a glance

The system is state-driven. Main statuses stored in `cart_activity.status`:

`active` · `abandoned` · `completed` · `expired`

**Status lifecycle:**

```mermaid theme={null}
stateDiagram-v2
    [*] --> active : cart created or updated
    active --> active : new activity re-triggers update
    active --> abandoned : inactive 30+ min, item_count at least 1
    active --> completed : checkout completes
    abandoned --> completed : checkout completes
    active --> expired : handled outside this module
    abandoned --> expired : handled outside this module
    completed --> [*]
    expired --> [*]
```

Each cart is represented by **one row** in `cart_activity` because `cart_id` is unique.

## Data model

Defined in `src/modules/cart-activity/models/cart-activity.ts:3`.

```mermaid theme={null}
classDiagram
    class CartActivity {
        +string id
        +string cart_id
        +string user_id
        +string email
        +datetime last_activity_at
        +string status
        +string event_type
        +json event_data
        +json metadata
        +number item_count
        +number total_amount
        +string currency_code
        +string region_id
        +datetime notification_sent_at
        +number notification_count
        +string last_notification_type
    }
    note for CartActivity "cart_id has a unique index: one row per cart"
```

### Main fields

`id` · `cart_id` · `user_id` · `email` · `last_activity_at` · `status` · `event_type` · `event_data` · `metadata` · `item_count` · `total_amount` · `currency_code` · `region_id`

### Notification tracking fields

`notification_sent_at` · `notification_count` · `last_notification_type`

### Important index behavior

The most important constraint is a unique index on `cart_id`, at `src/modules/cart-activity/models/cart-activity.ts:23`. This prevents duplicate activity rows for the same cart.

### Status type contract

`CartActivityDetails` in `src/modules/cart-activity/types.ts:32` defines status as one of: `active`, `abandoned`, `completed`, `expired`.

## How cart activity is tracked

Tracking happens in two ways.

### 1. Subscriber-based tracking

File: `src/subscribers/cart-activity-tracker.ts`

This subscriber listens to:

* `cart.created` at `src/subscribers/cart-activity-tracker.ts:62`
* `cart.updated` at `src/subscribers/cart-activity-tracker.ts:63`

Flow: Medusa emits event → subscriber extracts cart ID → resolves `cart_activity` service → calls `updateCartActivity`.

Cart ID extraction is defensive and checked in this order (see `src/subscribers/cart-activity-tracker.ts:31`):

1. `eventData.cart_id`
2. `eventData.cart?.id`
3. `eventData.line_item?.cart_id`
4. if the event name starts with `cart.`, fall back to `eventData.id`

### 2. Middleware-based tracking

File: `src/api/middlewares.ts`

A custom middleware called `cartActivityMiddleware` is defined near `src/api/middlewares.ts:19`. It tracks activity only **after** successful responses, via `res.on("finish")` at `src/api/middlewares.ts:28`.

| Method | Route                                            | Event emitted            |
| ------ | ------------------------------------------------ | ------------------------ |
| POST   | `/store/carts/:id/promotions` (`:1875`)          | `cart.promotion_applied` |
| DELETE | `/store/carts/:id/promotions` (`:1881`)          | `cart.promotion_removed` |
| POST   | `/store/carts/:id/line-items/:line_id` (`:1887`) | `cart.line_item_updated` |
| DELETE | `/store/carts/:id/line-items/:line_id` (`:1893`) | `cart.line_item_deleted` |

## How `updateCartActivity` works

Core implementation: `src/modules/cart-activity/service.ts:87`

### What it does

When called, the service:

1. validates input
2. fetches latest cart details via `REMOTE_QUERY`
3. denormalizes cart data into a single record
4. updates the existing row if present
5. creates a row if missing
6. handles unique-constraint races by retrying as an update

### Why `REMOTE_QUERY` is passed through the input container

The comment at `src/modules/cart-activity/service.ts:104` explains this clearly:

* Medusa V2 module services are isolated
* `REMOTE_QUERY` is application-scoped
* so the app container must be passed in

### Denormalized fields it computes

* `user_id` from customer
* `email`
* `item_count`
* `total_amount`
* `currency_code`
* `region_id`
* `item_ids` inside metadata

Relevant logic: cart fetch (`:110`) · item count (`:146`) · total amount calculation (`:167`) · metadata enrichment (`:189`)

### Status behavior

> 💡 Every call to `updateCartActivity` sets `status: "active"` (`src/modules/cart-activity/service.ts:213`) — even on a cart that was previously `abandoned`. Fresh activity always reactivates it.

### Race condition handling

If two processes try to create the same cart row, the service catches unique constraint errors and retries as an update at `src/modules/cart-activity/service.ts:238`.

## How a cart becomes abandoned

The abandoned classification is a two-step process.

### Step 1: find stale active carts

Method: `getAbandonedCheckouts` in `src/modules/cart-activity/service.ts:283`

> 💡 Despite the name, this method does **not** return carts already marked abandoned. It returns active carts that are stale enough that they *should now* be treated as abandoned.

Selection criteria:

* `status = active` (`:307`)
* `last_activity_at < threshold` (`:308`)
* `item_count >= minimum` (`:311`)

### Step 2: mark them abandoned

Method: `markCheckoutAsAbandoned` in `src/modules/cart-activity/service.ts:362`

Flow: load cart activity row with `status = active` → update to `status = abandoned` → use an atomic selector to avoid overwriting completed carts.

> 🔒 The atomic selector requires `status: "active"` at write time (`:391`) — this is what protects against a race with checkout completion. If another process changed the status first, the method re-fetches and: returns the row if already `abandoned`, or throws if status became `completed`/`expired` (`:402`).

## How notifications are sent

Notification sending is handled by scheduled job `src/jobs/send-abandoned-checkout-notifications.ts`.

### Selection logic

The job calls `getUnnotifiedAbandonedCheckouts` (`:174`), implemented at `src/modules/cart-activity/service.ts:554`. It selects carts where:

| Condition        | Rule                           | Reference |
| ---------------- | ------------------------------ | --------- |
| Status           | `status = abandoned`           | `:577`    |
| Staleness        | `last_activity_at < threshold` | —         |
| Size             | `item_count >= minimum`        | —         |
| Not yet notified | `notification_sent_at = null`  | `:584`    |
| Contactable      | `email` or `user_id` exists    | `:585`    |

### Payload sent externally

Sent to `${API_BASE_URL}/vector/api/v1/vector/abandoned-checkout/notify-batch`, constructed at `:163`. Each cart payload includes:

`cart_id` · `email` · `user_id` · `customer_id` · `item_count` · `total_amount` · `currency_code` · `last_activity_at` · `cart_url`

See payload build at `src/jobs/send-abandoned-checkout-notifications.ts:37`.

### Cart URL format

```text theme={null}
${STORE_URL}/checkout/review?cartId=${cart.cart_id}
```

At `src/jobs/send-abandoned-checkout-notifications.ts:47`.

### Request signing

The external API call is HMAC signed (`src/jobs/send-abandoned-checkout-notifications.ts:54`):

```text theme={null}
headers: x-signature, x-timestamp
signature = sha256(timestamp + body, secret: MINT_WEBHOOK_SECRET)
```

### Batching and retries

| Aspect                | Value           | Reference |
| --------------------- | --------------- | --------- |
| Batch size            | up to 100 carts | `:154`    |
| Delay between batches | 500ms           | `:155`    |
| Retries per batch     | up to 3         | `:156`    |
| Backoff               | exponential     | `:226`    |

### Marking notification as sent

If the external API returns success for a cart, the job calls `markNotificationSent(cartId, "multi-channel")` (`:205`), implemented at `src/modules/cart-activity/service.ts:499`. This updates `notification_sent_at`, `notification_count`, and `last_notification_type`.

> ⚠️ **Duplicate-send risk:** if the external API call succeeds but the DB update fails, the job logs a critical warning at `:136`. The customer may receive a duplicate notification on a later run.

## How a cart is marked completed

File: `src/api/store/carts/[cart_id]/complete-v2/route.ts`

The route resolves the cart activity service (`:300`) and calls `markCheckoutAsCompleted(cartId)` (`:301`), implemented at `src/modules/cart-activity/service.ts:441`.

### Behavior

`markCheckoutAsCompleted`:

* loads the current cart row
* if already `completed`, returns the existing row unchanged
* otherwise updates status to `completed`

This makes completion **idempotent**.

## Scheduled jobs

| Job                                     | Cron          | Frequency       | Threshold         | Min items |
| --------------------------------------- | ------------- | --------------- | ----------------- | --------- |
| `mark-abandoned-checkouts`              | `* * * * *`   | every minute    | 30 min inactivity | 1         |
| `send-abandoned-checkout-notifications` | `*/5 * * * *` | every 5 minutes | 30 min inactivity | 1         |

### 1. Mark abandoned checkouts job

File: `src/jobs/mark-abandoned-checkouts.ts` · name at `:71` · cron at `:72`

**Thresholds:** `MINUTES_THRESHOLD = 30` (`:7`) · `MIN_ITEM_COUNT = 1` (`:8`)

**What it does:** load stale active carts → collect cart IDs → mark them abandoned in parallel → log per-cart success/failure

**Timeout safety** (wrapped in a timeout helper at `:12`):

| Operation                          | Timeout     |
| ---------------------------------- | ----------- |
| `getAbandonedCheckouts`            | 10s (`:28`) |
| `markCheckoutAsAbandoned` per cart | 5s (`:42`)  |

### 2. Send notifications job

File: `src/jobs/send-abandoned-checkout-notifications.ts` · name at `:267` · cron at `:268`

**Thresholds:** `MINUTES_THRESHOLD = 30` (`:151`) · `MIN_ITEM_COUNT = 1` (`:153`)

**Env vars:**

| Variable              | Required? | Notes                               |
| --------------------- | --------- | ----------------------------------- |
| `MINT_WEBHOOK_SECRET` | Required  | Job exits early if missing (`:166`) |
| `API_BASE_URL`        | Optional  | Has a default (`:162`)              |
| `STORE_URL`           | Optional  | Has a default (`:165`)              |

## Flow maps

### Flow map 1: Cart activity tracking

```mermaid theme={null}
flowchart TD
    A[Cart change happens] --> B[Subscriber or middleware triggers]
    B --> C["cartActivityService.updateCartActivity()"]
    C --> D["Cart data fetched via REMOTE_QUERY"]
    D --> E[cart_activity row created or updated]
    E --> F[status becomes active]
```

### Flow map 2: Marking abandoned carts

```mermaid theme={null}
flowchart TD
    A[Scheduled job runs every minute] --> B[Get stale active carts older than threshold]
    B --> C[For each cart]
    C --> D["Atomic update: active to abandoned"]
    D --> E[Cart becomes eligible for notification stage]
```

### Flow map 3: Notification sending

```mermaid theme={null}
flowchart TD
    A[Scheduled notification job runs every 5 minutes] --> B[Fetch abandoned carts with no notification_sent_at]
    B --> C[Batch up to 100 carts]
    C --> D[POST signed request to external vector API]
    D --> E{Cart marked successful?}
    E -- Yes --> F[Mark notification_sent_at, increment notification_count]
    E -- No --> G[Retry with exponential backoff, up to 3 attempts]
```

### Flow map 4: Completion protection

```mermaid theme={null}
flowchart TD
    A[Checkout completes successfully] --> B["complete-v2 route runs"]
    B --> C["markCheckoutAsCompleted(cartId)"]
    C --> D[status becomes completed]
    D --> E[Abandoned notification flow no longer picks this cart]
```

## Configuration and thresholds

Thresholds are currently hardcoded in both jobs:

| Setting               | Current value | Files                                                                           |
| --------------------- | ------------- | ------------------------------------------------------------------------------- |
| Abandonment threshold | 30 minutes    | `mark-abandoned-checkouts.ts:7`, `send-abandoned-checkout-notifications.ts:151` |
| Minimum cart size     | 1 item        | `mark-abandoned-checkouts.ts:8`, `send-abandoned-checkout-notifications.ts:153` |

> 💡 **Recommendation:** if thresholds need to change, update both jobs together so abandoned-marking and notification-selection stay aligned.

## Examples

### Example 1: Normal abandoned-cart lifecycle

1. Customer creates cart
2. Adds products
3. Cart activity row becomes `active`
4. No activity for 30 minutes
5. Mark-abandoned job changes status to `abandoned`
6. Notification job sends batch payload
7. DB marks `notification_sent_at`

### Example 2: Cart completes before the abandonment job updates it

```mermaid theme={null}
sequenceDiagram
    participant J as mark-abandoned-checkouts job
    participant DB as cart_activity table
    participant R as complete-v2 route

    J->>DB: getAbandonedCheckouts()
    DB-->>J: cart X returned, status active
    R->>DB: markCheckoutAsCompleted(X)
    DB-->>R: status updated to completed
    J->>DB: markCheckoutAsAbandoned(X)
    Note over J,DB: atomic selector requires status = active
    DB-->>J: no rows matched, status already changed
    J->>DB: re-fetch cart X
    DB-->>J: status is completed
    Note over J,DB: job exits without overwriting the completed cart
```

1. Mark-abandoned job selects a stale active cart
2. Checkout completes before the status update executes
3. `markCheckoutAsCompleted` changes status to `completed`
4. `markCheckoutAsAbandoned` atomic update fails — status is no longer `active`
5. System does **not** incorrectly overwrite the completed cart as abandoned

### Example 3: Notification API succeeds but DB marking fails

1. External API accepts the batch
2. One cart ID is returned as successful
3. `markNotificationSent` fails repeatedly
4. Log warns of duplicate-send risk (see callout above)
5. Next job run may resend that cart

## How to extend the system

### Add more tracked cart actions

Add middleware entries in `src/api/middlewares.ts`, or subscribe to more events in `src/subscribers/cart-activity-tracker.ts:60`. Good candidates:

* shipping method selection
* address update
* payment session selection

### Add richer notification channels

Notifications are currently marked `multi-channel` at `:125`. For channel-specific tracking, update: the external payload shape, the notification API contract, and the `markNotificationSent` call type.

### Add repeated reminders

Only carts with `notification_sent_at = null` are selected today (`:584`). A reminder sequence would need to also consider: `notification_count`, elapsed time since the last notification, and a max reminder limit.

### Expose admin analytics or APIs

The module already stores what's needed to build: abandoned cart dashboards, recovery conversion reports, and abandoned cart listing APIs.

## How to debug issues

* **1. Check whether activity is being recorded** — verify a row exists in `cart_activity`. If not, check subscriber firing (`cart-activity-tracker.ts:19`), middleware route matching (`middlewares.ts:1875`), and failures from `updateCartActivity` (`service.ts:264`).
* **2. Check current status** — should be `active`, `abandoned`, or `completed`. If already `completed`, the abandoned job will not pick it up.
* **3. Check last activity timestamp** — both selection methods rely on `last_activity_at` being older than the threshold (`getAbandonedCheckouts` at `:308`, `getUnnotifiedAbandonedCheckouts` at `:578`).
* **4. Check item count** — both jobs require `item_count >= 1`. A row with `item_count = 0` never enters the abandoned flow.
* **5. Check contactability** — notification selection requires `email` or `user_id` (`:585`). If both are missing, notifications are skipped.
* **6. Check env config** — `MINT_WEBHOOK_SECRET`, `API_BASE_URL`, `STORE_URL`. If the secret is missing, the job exits early at `:166`.
* **7. Check external API result handling** — the job expects `successful_cart_ids` and `failed_cart_ids` arrays in the response (`:94`). If the API shape changes, carts may not be marked correctly.
* **8. Check the completion path** — confirm the custom completion route is used and that `markCheckoutAsCompleted` actually runs (`complete-v2/route.ts:301`).

## Important behaviors and edge cases

1. **One cart = one activity row.** The unique `cart_id` index guarantees one logical row per cart.
2. **Fresh activity reactivates the cart.** `updateCartActivity` always writes `status: "active"` (`:213`).
3. **`getAbandonedCheckouts` name is slightly misleading.** It returns carts that *should be* marked abandoned, not carts already marked abandoned (documented at `:275`).
4. **Notifications send once by default.** Selection requires `notification_sent_at = null` (`:584`) — no reminder sequence yet.
5. **Completion marking is idempotent.** Safe to call multiple times (`:467`).
6. **Abandon marking is race-safe.** Only updates rows still in `active` state (`:393`).
7. **Duplicate notification risk exists** after API success + DB mark failure — logged as critical (`:136`).

## Known gaps and developer notes

| Gap                                | Detail                                                                                                                                             |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Thresholds duplicated              | Both jobs hardcode 30 min / 1 item independently (`mark-abandoned-checkouts.ts:7`, `send-abandoned-checkout-notifications.ts:151`) — risk of drift |
| No reminder cadence                | Schema has `notification_count`, but selection logic only ever sends once                                                                          |
| Limited event coverage             | Subscriber only listens to `cart.created` / `cart.updated`; other mutations need explicit middleware                                               |
| Custom completion route dependency | Relies on `complete-v2`; another checkout path would leave state stale                                                                             |
| No recovery attribution            | Tracks abandonment and notification send, but not whether it led to a completed order                                                              |

## Quick reference

### Main service methods

| Method                            | Location                                   |
| --------------------------------- | ------------------------------------------ |
| `updateCartActivity`              | `src/modules/cart-activity/service.ts:87`  |
| `getAbandonedCheckouts`           | `src/modules/cart-activity/service.ts:283` |
| `markCheckoutAsAbandoned`         | `src/modules/cart-activity/service.ts:362` |
| `markCheckoutAsCompleted`         | `src/modules/cart-activity/service.ts:441` |
| `markNotificationSent`            | `src/modules/cart-activity/service.ts:499` |
| `getUnnotifiedAbandonedCheckouts` | `src/modules/cart-activity/service.ts:554` |

### Job entry points

| Job                                     | Location                                                |
| --------------------------------------- | ------------------------------------------------------- |
| `mark-abandoned-checkouts`              | `src/jobs/mark-abandoned-checkouts.ts:21`               |
| `send-abandoned-checkout-notifications` | `src/jobs/send-abandoned-checkout-notifications.ts:148` |

### Main statuses

| Status      | Meaning                                            |
| ----------- | -------------------------------------------------- |
| `active`    | Cart has recent activity                           |
| `abandoned` | Inactive past threshold, not yet completed         |
| `completed` | Checkout finished successfully                     |
| `expired`   | Terminal state, not driven by the jobs in this doc |

### Contact / notification fields

`email` · `user_id` · `notification_sent_at` · `notification_count` · `last_notification_type`

### External notification endpoint

```text theme={null}
/vector/api/v1/vector/abandoned-checkout/notify-batch
```
