Skip to main content

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
  2. What this system does
  3. Where the code lives
  4. Architecture at a glance
  5. Data model
  6. How cart activity is tracked
  7. How updateCartActivity works
  8. How a cart becomes abandoned
  9. How notifications are sent
  10. How a cart is marked completed
  11. Scheduled jobs
  12. Flow maps
  13. Configuration and thresholds
  14. Examples
  15. How to extend the system
  16. How to debug issues
  17. Important behaviors and edge cases
  18. Known gaps and developer notes
  19. 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:

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

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

Jobs

Completion handling

Architecture at a glance

The system is state-driven. Main statuses stored in cart_activity.status: active · abandoned · completed · expired Status lifecycle: 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.

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.

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:

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

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):

Batching and retries

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

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):

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:

Flow maps

Flow map 1: Cart activity tracking

Flow map 2: Marking abandoned carts

Flow map 3: Notification sending

Flow map 4: Completion protection

Configuration and thresholds

Thresholds are currently hardcoded in both jobs:
💡 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

  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 configMINT_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

Quick reference

Main service methods

Job entry points

Main statuses

Contact / notification fields

email · user_id · notification_sent_at · notification_count · last_notification_type

External notification endpoint