Planning corpus: this is the real design-discussion doc for epic promotions-discounts, synced verbatim from this repo’s own internal planning process.
Design Discussion — Epic 20: promotions-discounts
0. Prelude
Source: backlog epic 20 (.pHive/planning/epic-backlog.md), identified by epic 17’s
commerce-gap audit, backlogged 2026-09-08, planned 2026-09-08. No .pHive/CONTEXT.md /
kg_why prior-decision hits for “promotions” or “discounts” — clean slate. No
north_star block conflicts.
Prior-decision constraint found in an existing subsystem doc (binding, not a proposal):
docs/subsystems/07-cart.md lines 25-29, “Explicitly NOT this subsystem’s job”: “Pricing
rules/discounts beyond price snapshotting — if/when a promotions concept exists, it’s either
part of checkout (09) or its own subsystem, not bolted onto cart.” This pre-decides half of
this epic’s central design question before any research began: cart is off the table as a
host for discount logic. The only real question is checkout-orders extension vs. new peer
subsystem.
1. Goal
Ship coupon codes and percentage/fixed discounts, both cart-level (whole-order) and
product-level (specific SKUs), consulted synchronously at checkout time (and previewable
before checkout, for display), plus an admin UI to create and manage them — without touching
cart at all and without weakening checkout-orders’ @mercatus-liber/core-only dependency
contract.
2. Research findings (grounding)
- No total/discount concept exists anywhere today. Cart has no
getTotal();Orderhas nosubtotal/total/discountTotalfield; the one place a total is computed is inline inapps/reference-storefront/app/cart/page.tsxper-line, with no grand total ever displayed. This epic is the first thing in the repo that needs an “order total” concept at all. - The exact seam:
packages/checkout-orders/src/service.tsstartCheckout(), between the cart snapshot (cart.getCart(input.cartId), line 70) and payment-session creation (payments.createPaymentSession(...), line 93). Bothorder.items[].priceAtPurchaseand Stripe’slineItems[].unitAmountare built directly fromitem.priceSnapshottoday, with no adjustment step. This is where a pricing adjustment must be inserted. - No generic synchronous extension point exists. Doc 12 (plugins) is explicit: every
adapter interface a subsystem declares in its own
types.tsis the plugin point by construction; there is no second, parallel “consult all handlers, get a value back” mechanism. The event bus is async pub/sub only (fire-and-forget side effects) — unusable for a computation that must return an adjusted total before Stripe is called. So a discount mechanism can only be wired in the same wayCartLookup/SkuLookup/PaymentSessionCreatorare today: a named structural interface declared in the consuming subsystem’s owntypes.ts, satisfied by a service wired in atservices.tsDI time. - Admin UI convention: every existing
/admin/*page (catalog, cms, orders, plugins) is a read-only<table>with zero create/edit forms anywhere in the app. A promotions admin CRUD UI is a new precedent (first admin-side mutation), not a copy of an existing pattern — it follows the one existing form-mutation convention in the app instead: a"use server"action inlib/actions.ts+revalidatePath, currently only used shopper-side (addToCartAction,startCheckoutAction). - Contrast with epic 22 (upsell/cross-sell): marketing-catalog’s
SuggestionRule(packages/marketing-catalog/src/types.ts:43-47) is a category-suggestion rule (“if SKU has attribute X, suggest category Y”), explicitly “assistive… never auto-applied.” Unrelated to pricing; confirms this epic’s scope doesn’t overlap 22’s.
3. The design question, resolved: own subsystem, not a checkout-orders extension
Decision: new subsystem, promotions (package @mercatus-liber/promotions), subsystem 16
in docs/subsystems/. checkout-orders gains a narrow, optional structural interface
(PricingAdjuster) in its own types.ts that promotions’ service satisfies — the same pattern
CartLookup/PaymentSessionCreator already use. checkout-orders never imports
@mercatus-liber/promotions.
Why own-subsystem over checkout-extension:
- State ownership. A promotion is a durable, independently-manageable business entity
(code, type, scope, target SKUs, date range, usage limit, redemption count) with its own
admin CRUD lifecycle, wholly independent of any single order. That’s a repository +
service + adapter-pattern subsystem in this codebase’s own vocabulary, not a stateless
computation checkout-orders would have to own and persist itself. Bolting a
PromotionRepositoryinto checkout-orders would violate checkout-orders’ documented@mercatus-liber/core-only dependency contract by growing its own scope, not by importing another package — but it would also break subsystem 09’s single responsibility (“cart snapshot → shipping info → payment session → confirmation”) the same way stock-decrementing was deliberately kept out of it (09’s own “Explicitly NOT this subsystem’s job” list) in favor of a sibling subsystem. - Reusability beyond checkout. Product-level discounts need to be visible on the PDP and cart-preview surfaces too (not just at the final checkout step), and the eventual internal-BI epic (promotion redemption rates, per the wider backlog) needs a promotions read surface independent of any single order. A subsystem with its own repository serves all of those; a checkout-orders-internal feature would serve only checkout.
- Precedent. Every prior “new business concept” in this repo (inventory, analytics,
service-areas, plugins) became its own subsystem behind a narrow structural interface
consulted by whichever subsystem needed it, never grew inside an existing one. Promotions
fits the same shape exactly: a
Promotion/Coupondomain model +PromotionsService. - Cost is genuinely small. The “extension point” checkout-orders needs either way is one
new optional interface (
PricingAdjuster) in itstypes.ts— identical amount of checkout-orders code change under either design. Own-subsystem doesn’t cost more at the integration seam; it only adds the (necessary, real) promotions package itself.
What checkout-orders gains, concretely:
// packages/checkout-orders/src/types.ts (new)
export interface PricingAdjustment {
items: { skuId: string; quantity: number; unitAmount: Money }[]; // post-discount unit price
discountTotal: Money;
total: Money; // sum(items[].unitAmount * quantity)
appliedCode: string | null;
}
/**
* The narrowest dependency checkout-orders has on pricing adjustments -- a
* structural interface, not an import of @mercatus-liber/promotions.
* @mercatus-liber/promotions's PromotionsService satisfies this shape.
* Optional: when no PricingAdjuster is wired at services.ts DI time,
* startCheckout/previewCheckout fall through to a pass-through adjustment
* (zero discount, unitAmount === priceSnapshot) -- zero behavior change for
* any deployment that hasn't adopted promotions yet.
*/
export interface PricingAdjuster {
computeAdjustment(input: {
items: { skuId: string; quantity: number; priceSnapshot: Money }[];
couponCode?: string | null;
}): Promise<PricingAdjustment>;
}createCheckoutOrdersService(deps) gains an optional pricing?: PricingAdjuster dep. A
built-in pass-through ({ items: items.map(i => ({...i, unitAmount: i.priceSnapshot})), discountTotal: {amount:0,...}, total: sum, appliedCode: null }) is used when deps.pricing
is undefined, so every existing test/deployment keeps working byte-identically. Order gains
discountTotal: Money (default {amount:0,currency:...}) and appliedPromotionCode: string | null fields — additive, same backward-compatibility posture as customerId was added in
acct-01. A new previewCheckout(input) read method returns a PricingAdjustment without
creating an order, for cart/checkout-summary display.
Cart stays untouched — no new method, no new dependency, no import of promotions. The
“discounted total before you pay” experience is served by checkout-orders’
previewCheckout, called from the storefront’s cart/checkout page the same way catalog is
already called from CartPage today (a second async service call in the same Server
Component), not by extending CartService itself.
4. What a Promotion looks like
// packages/promotions/src/types.ts
export type PromotionKind = "percentage" | "fixed";
export type PromotionScope = "cart" | "product";
export interface Promotion {
id: string;
code: string | null; // null = automatically applied to every eligible cart, no code needed
kind: PromotionKind;
scope: PromotionScope;
value: number; // percentage: 0-100; fixed: minor-unit amount in `currency`
currency: string; // only meaningful when kind === "fixed"
targetSkuIds: string[]; // only meaningful when scope === "product"; empty = cart scope
minCartAmount: Money | null; // eligibility floor, cart scope only
startsAt: string | null; // ISO 8601; null = active immediately
endsAt: string | null; // ISO 8601; null = no expiry
usageLimit: number | null; // null = unlimited
redemptionCount: number;
status: "active" | "inactive";
}PromotionRepository (adapter pattern, in-memory reference impl matching
createInMemoryCartRepository’s shape). PromotionsService.evaluate(input) is the function
PricingAdjuster.computeAdjustment delegates to; it also publishes promotions.redeemed on
a successful, code-bearing evaluation reaching checkout completion (see open question 2) for
future BI consumption — fire-and-forget, checkout-orders never awaits or depends on this
event existing.
Validation cases evaluate() must reject cleanly (no throw — return the pass-through
adjustment plus a reason so the storefront can show “coupon code invalid”): unknown code,
inactive/expired/not-yet-started, usage limit reached, cart below minCartAmount. Stacking:
one promotion per checkout for this epic (either a coupon code, or the single
highest-value auto-applied non-coded promotion if none was entered) — multi-stacking is
flagged as an explicit non-goal, not a gap, mirroring how doc 09 scopes shipping/refunds out.
5. Risks / open questions
- Redemption-count race. Two concurrent checkouts both using the last unit of a usage-limited coupon. Reference in-memory repository does a read-modify-write with no locking (same posture as every other in-memory reference repo in this codebase — sqlite/ postgres adapters get real transactional guarantees, tracked as an adapter concern, not a blocker for the reference implementation). Documented as an open item, not silently ignored.
- When is
promotions.redeemedactually published — atstartCheckout(a payment session was merely started) or atcheckout.order.paid(payment actually succeeded)? Resolved: atcheckout.order.paid, mirroring howredemptionCountshould only reflect completed purchases, not abandoned checkouts. checkout-orders’ existingpayments.payment.succeededsubscriber (service.ts lines 46-58) is the natural place to also publishcheckout.order.paid’s existing downstream — promotions subscribes tocheckout.order.paiditself (event-driven, same pattern inventory uses) rather than checkout-orders calling back into promotions synchronously a second time. This keeps checkout-orders’ only synchronous promotions dependency at the onePricingAdjustercall. - Price re-validation at checkout (doc 07 open question 2, stale cart price snapshot) is explicitly out of scope for this epic — unchanged pre-existing behavior, not something promotions needs to solve first.
6. Scale assessment
Medium. Multi-file (new package + checkout-orders types/service + storefront UI + admin UI), multiple layers (domain/service, integration seam, two UI surfaces), single new subsystem — not a migration, not multi-system. Proceeding directly to story decomposition (no separate H/V planning phase) given the seam is already fully pinned down above; the four stories below are effectively the vertical slices.
7. Version bump
minor — new package, new optional capability, zero breaking change to any existing
subsystem’s public contract (Order’s two new fields are additive with safe defaults;
CheckoutOrdersService’s new pricing dep is optional).