Discounts, Segments & Abandonment Nudges
The last slice of the ecommerce arc. Where the audit log stops being a defensive tool and starts driving revenue.
Every prior slice let the merchant *see* their business honestly. This slice lets them *act* on it — coupon codes to close price-sensitive customers, segments to send targeted offers, and the killer view: abandonment nudges that recover carts most storefronts write off as lost.
Four entities, seventeen tools, four views. Small surface. Big lever.
The three parts
Discount codes
Three kinds — percentage, fixed, free_shipping. Every field a Stripe-style coupon has: min-subtotal gate, start/end dates, per-code usage cap. Codes are tenant-unique + uppercased on save. Deactivating a code preserves its redemption history for accounting; it just stops accepting new uses.
The killer detail: redemptions are audit-logged. DiscountRedemption records the cart or order, the customer, the amount taken off, and the exact code. Marketing can prove attribution to the accountant, and the accountant can prove the discount was applied at the customer's request (not a merchant-side fudge).
Customer segments
Six built-in rule kinds — all_customers, repeat_buyer, first_time, high_value, lapsed, cart_abandoner. A segment is a *named query*, not a stored membership list. That's the audit-log-thesis at work: membership derives from customer state, so a lapsed customer who orders again automatically leaves the "Lapsed" segment. No manual sync to forget.
Params tune the threshold — {"min_orders": 3} for repeat_buyer, {"days_since_last_order": 60} for lapsed. The member_count field is a denormalized cache refreshed on-demand via refresh_segment_member_count.
Abandonment nudges
The recovery workflow. scan_abandonment_candidates finds open carts idle >24 hours (default) with no nudge in the last 72 hours (default). The merchant selects candidates, optionally attaches a discount code (usually a higher-percentage one reserved for recovery), and fires send_nudge. Each nudge is a record; the record IS the send.
State machine: sent → clicked → recovered, with expired as the terminal timeout path. When the customer completes an order after clicking, mark_nudge_recovered stamps order.from_nudge_id — revenue attribution flows from the abandoned cart to the recovered order via one field.
The killer dashboard signal
The marketing dashboard surfaces one signal that most ecom platforms bury:
Abandonment candidates: N
That's how many carts, right now, are sitting open waiting to be reclaimed. Not "estimated recoverable revenue" — the actual carts, with actual customers, with actual subtotals. One click to scan, one click to send. The dashboard also surfaces recovered revenue over the last 30 days — closed-loop attribution back to nudged carts, so the merchant can see *this workflow paid for itself*.
Named refuses
Every gate refuses with a named rule:
| Attempt | Refuse | |---|---| | Create a percentage discount with value 150 | percentage_value_out_of_range | | Create a duplicate code | discount_code_already_exists | | Apply a code to a cart that isn't open | cart_not_open | | Apply an expired code | discount_invalid:expired | | Apply below min_subtotal | discount_invalid:min_subtotal_not_met | | Apply a code past its usage cap | discount_invalid:usage_cap_reached | | Send a nudge on a converted cart | nudge_requires_open_cart | | Skip clicked and mark expired twice | nudge_transition_illegal:expired→expired |
Every refuse writes a rejection event to the audit log. Merchants see exactly which rule fired.
Idempotency at every seam
- Applying a discount —
apply_discount_to_cartshort-circuits if a redemption for the same cart+discount already exists. Uses counter doesn't double-increment; cart state isn't rewritten. - Marking clicked —
mark_nudge_clickedon an already-clicked nudge returns the existing record without transitioning again. - Discount codes — uppercase-on-save means
welcome10andWELCOME10collide, which is what customers expect ("I typed the code from the email, why doesn't it work").
The full ecommerce arc
Slice O closes the arc. What was built:
| Slice | Entity | Killer view | |---|---|---| | K | Product / ProductVariant / InventoryAdjustment | Adjustment reasons — the primary defense against shrinkage | | L | Cart / CartLine / EcomOrder / EcomOrderLine | Snapshot-not-reference on OrderLines | | M | StripeConfig / PaymentEvent | Idempotent-by-event-id webhook handler | | N | Shipment / ReturnAuthorization | RMA workflow — inventory-restore gated on resellable | | O | Discount / DiscountRedemption / CustomerSegment / AbandonmentNudge | Nudge funnel + revenue-recovery attribution |
Every entity is a .agi file. Every write is a git commit. Every refuse has a name. Every transition has a rule. The complete customer journey — cart placement → payment → fulfillment → return or reorder → segment change → discount redemption → nudged recovery — is one filesystem walk away.
That's the point. That's the whole point.
What's NOT in this slice
- Email/SMS delivery —
send_nudgecreates the record. The actual outreach lives in a caller-side integration. The audit log records the send *intent*; whether an ESP delivers is out of scope for the framework. - Multiple discount stacking — one discount per cart. No compound coupons. If merchants need stacking they can layer discounts via segments (VIP + first_time_buyer, priced with the higher single value).
- Discount codes generated per-customer — the discount is a broadcast code; per-customer codes are a use-case-side layer on top (encode customer_id in the code string).
- Automated nudge scheduling —
scan_abandonment_candidatesis the cron entry point, but the actual scheduler (cron trigger → scan → send) lives inworker.ts. The demo environment (ecommerce) hascrons = []due to the free-tier 5-cron account cap; production wire-up would addnightly_abandonment_scan.