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
- Overview
- What this system does
- Where the code lives
- Architecture at a glance
- Data model
- How cart activity is tracked
- How updateCartActivity works
- How a cart becomes abandoned
- How notifications are sent
- How a cart is marked completed
- Scheduled jobs
- Flow maps
- Configuration and thresholds
- Examples
- How to extend the system
- How to debug issues
- Important behaviors and edge cases
- Known gaps and developer notes
- Quick reference
Overview
This project implements a custom abandoned cart system on top of Medusa using:- a dedicated
cart_activitymodule - 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
- 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
What this system does
The abandoned cart system handles four responsibilities:- Track cart activity
- store latest cart interaction per cart
- denormalize key cart details like item count, amount, email, currency
- Classify stale active carts
- if a cart has been inactive long enough, it is marked
abandoned
- if a cart has been inactive long enough, it is marked
- Send notifications
- abandoned carts with contact info and no previous notification are batched and sent to external notification service
- Prevent false abandoned state after order completion
- completed carts are explicitly marked
completed
- completed carts are explicitly marked
Where the code lives
Core module
Migrations
src/modules/cart-activity/migrations/Migration20260210000000.tssrc/modules/cart-activity/migrations/Migration20260210000001.tssrc/modules/cart-activity/migrations/Migration20260224180851.ts
Tracking entry points
Jobs
Completion handling
Architecture at a glance
The system is state-driven. Main statuses stored incart_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 insrc/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 oncart_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.createdatsrc/subscribers/cart-activity-tracker.ts:62cart.updatedatsrc/subscribers/cart-activity-tracker.ts:63
cart_activity service → calls updateCartActivity.
Cart ID extraction is defensive and checked in this order (see src/subscribers/cart-activity-tracker.ts:31):
eventData.cart_ideventData.cart?.ideventData.line_item?.cart_id- if the event name starts with
cart., fall back toeventData.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:- validates input
- fetches latest cart details via
REMOTE_QUERY - denormalizes cart data into a single record
- updates the existing row if present
- creates a row if missing
- 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_QUERYis application-scoped- so the app container must be passed in
Denormalized fields it computes
user_idfrom customeremailitem_counttotal_amountcurrency_coderegion_iditem_idsinside metadata
:110) · item count (:146) · total amount calculation (:167) · metadata enrichment (:189)
Status behavior
💡 Every call toupdateCartActivitysetsstatus: "active"(src/modules/cart-activity/service.ts:213) — even on a cart that was previouslyabandoned. 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 atsrc/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 requiresstatus: "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 alreadyabandoned, or throws if status becamecompleted/expired(:402).
How notifications are sent
Notification sending is handled by scheduled jobsrc/jobs/send-abandoned-checkout-notifications.ts.
Selection logic
The job callsgetUnnotifiedAbandonedCheckouts (: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
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 callsmarkNotificationSent(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
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
- Customer creates cart
- Adds products
- Cart activity row becomes
active - No activity for 30 minutes
- Mark-abandoned job changes status to
abandoned - Notification job sends batch payload
- DB marks
notification_sent_at
Example 2: Cart completes before the abandonment job updates it
- Mark-abandoned job selects a stale active cart
- Checkout completes before the status update executes
markCheckoutAsCompletedchanges status tocompletedmarkCheckoutAsAbandonedatomic update fails — status is no longeractive- System does not incorrectly overwrite the completed cart as abandoned
Example 3: Notification API succeeds but DB marking fails
- External API accepts the batch
- One cart ID is returned as successful
markNotificationSentfails repeatedly- Log warns of duplicate-send risk (see callout above)
- Next job run may resend that cart
How to extend the system
Add more tracked cart actions
Add middleware entries insrc/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 markedmulti-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 withnotification_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 fromupdateCartActivity(service.ts:264). - 2. Check current status — should be
active,abandoned, orcompleted. If alreadycompleted, the abandoned job will not pick it up. - 3. Check last activity timestamp — both selection methods rely on
last_activity_atbeing older than the threshold (getAbandonedCheckoutsat:308,getUnnotifiedAbandonedCheckoutsat:578). - 4. Check item count — both jobs require
item_count >= 1. A row withitem_count = 0never enters the abandoned flow. - 5. Check contactability — notification selection requires
emailoruser_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_idsandfailed_cart_idsarrays 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
markCheckoutAsCompletedactually runs (complete-v2/route.ts:301).
Important behaviors and edge cases
- One cart = one activity row. The unique
cart_idindex guarantees one logical row per cart. - Fresh activity reactivates the cart.
updateCartActivityalways writesstatus: "active"(:213). getAbandonedCheckoutsname is slightly misleading. It returns carts that should be marked abandoned, not carts already marked abandoned (documented at:275).- Notifications send once by default. Selection requires
notification_sent_at = null(:584) — no reminder sequence yet. - Completion marking is idempotent. Safe to call multiple times (
:467). - Abandon marking is race-safe. Only updates rows still in
activestate (:393). - 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