Three things shipped differently from the original plan below (full rationale in DECISIONS 2026-05-22):
- Targeted promos are eligibility-gated CODES, not codeless offers. A targeted promo registers a real loyalty code (derived from the id if omitted) + a BSS
catalog.promotion_eligibilitylist.offer.issue/offer.advance_to_claimedare retired; the consume path is ONEoffer.claim-by-code for both audiences.assign_targetedwrites eligibility rows;resolve_eligible_promoreplaces the offer-discovery path. So Flow 3 + decision #6 below are superseded (Flow 3 is rewritten inline).- loyalty-cli is OPTIONAL. catalog/COM/CRM degrade gracefully (no boot failure) when
BSS_LOYALTY_API_TOKENis unset — promos off, everything else works. CRM also mirrors customers into loyalty's registry on create.- A friendly
promotion.nameis shown to customers; an already-used single-use targeted promo is hidden from the dashboard/auto-apply.
The release where BSS-CLI grows promotions without building a promotions engine. A separate, already-running system —
chiam-ck/loyalty-cli(~/claude/loyalty-cli, dockerized on:8080) — is the entitlement brain. BSS owns the money. loyalty owns "can this customer claim this offer, is there inventory, was the order cancelled." The two compose over HTTP through a newbss-clientsadapter; loyalty ships unmodified. Non-targeted = a customer types a shared/multi-use code at checkout. Targeted = an operator pre-assigns an eligibility-gated code (v1.1.1; originally a codeless offer) to chosen customers, which auto-applies at order time and shows on the dashboard. The portal is the demo + test surface: a code field with live discounted-price preview, and a "your discount / your offer" display.
- Not a new promotions engine in BSS. The only genuinely new BSS domain object is the catalog
promotionrow (the money terms + the join key to loyalty). Everything else is plumbing (one client) and small extensions to the existing snapshot/renewal/portal machinery. - Not a modification of loyalty-cli. v1.1 calls loyalty's existing tool surface over HTTP. Zero loyalty code changes. (The one capability loyalty lacks for one alternative — pre-bound personalized codes — is exactly why we chose codeless targeting instead. See DECISIONS 2026-05-21.)
- Not proration. A promo is a discounted price snapshot, charged over whole bundle periods. Motto #1 holds.
- Not a customer-discoverable surface. Promo create/assign tools are
operator_cockpit/defaultonly — nevercustomer_self_serve. A customer can type a non-targeted code, but cannot enumerate or self-issue.
| Concern | Owner |
|---|---|
| Code/offer existence, validity window, usage & per-customer limits, inventory, targeting | loyalty-cli (unchanged) |
OfferDefinition ↔ codes/offers, claim/redeem/revoke/issue entitlement FSM |
loyalty-cli |
| Discount terms: type (percent/absolute), value, applicable offerings, duration (1 / X / perpetual) | BSS catalog (promotion table) |
| Money math, price snapshot, charge, per-period discount enforcement | BSS COM + subscription |
| The customer "face": code entry, preview, entitlement display | self-serve portal |
loyalty is a pure entitlement engine — its OfferDefinition carries no money (verified: only a free-form characteristics: dict[str,str]). BSS never asks loyalty "how much" — only "is this customer entitled."
Two BSS services construct a LoyaltyClient; neither token leaves a BSS process (same posture as the OpenRouter key never leaving the orchestrator):
| Service | loyalty calls | Why |
|---|---|---|
| catalog | offer_definition.register, promo_code.register (create saga); offer.issue (targeted assignment); promo_code.show, offer.list (validation / preview / entitlement reads) |
Owns the money terms + the "what is this promo / who has it" questions |
| COM | offer.claim, offer.advance_to_claimed, offer.redeem, offer.revoke (consume lifecycle) |
Owns the order-state transitions these react to |
| subscription | none | Just charges the snapshot + decrements the per-period discount counter |
| self-serve portal | none | Calls catalog (preview/entitlement reads) + COM (order) + subscription (read) only — never holds the loyalty token |
- Integrate, don't build. loyalty-cli is the promo engine; BSS adapts only the catalog.
- Join key =
offer_definition_id. One catalogpromotionrow per loyalty OfferDefinition; many codes/offers share it. Matches loyalty's own many-codes→one-OD model and the many-targeted-offers→one-campaign reality. - Duration: activation = period 1. A multi-period promo decrements a per-subscription counter;
price_amountstays the full base, effective is computed at charge time. A plan change ends the promo. Perpetual = counter never decrements. - Stacking: compose. The catalog's existing lowest-active-price-wins still selects the snapshot base; the promo discount then applies on top of that snapshot.
- Claim-at-activation. Consume the entitlement immediately before the activation charge (after SOM succeeds), so a provisioning failure never burns a single-use code; only a payment decline can.
Targeted = codeless assigned offer (SUPERSEDED by v1.1.1 (DECISIONS 2026-05-22): targeted = a real loyalty code + a BSSoffer.issue).promotion_eligibilitylist (eligibility-gated, auto-applied). The original rationale (nocustomer_idonpromo_code.register) still holds — that's why the pairing lives in BSS, not loyalty — but codelessoffer.issueis retired. See the amendment banner + rewritten Flow 3.
packages/bss-models/bss_models/catalog.py, schema catalog:
id TEXT PRIMARY KEY(e.g.PROMO_SUMMER25)code TEXT(the loyalty code — present for BOTH audiences in v1.1.1; targeted derives it from the id if omitted)audience TEXT(v1.1.1 —public|targeted) +name TEXT(v1.1.1 — friendly label) + a separatecatalog.promotion_eligibilitytable (promotion↔customer, migrations 0025/0026)offer_definition_id TEXT(the loyalty join key; set when the saga completes)discount_type TEXT NOT NULL(percent|absolute)discount_value NUMERIC(12,2) NOT NULLcurrency TEXT NOT NULL DEFAULT 'SGD'(for absolute)applicable_offering_ids TEXT[](NULL = all sellable offerings)duration_kind TEXT NOT NULL(single|multi|perpetual)periods_total SMALLINT(NULL for single/perpetual; N for multi)valid_from TIMESTAMPTZ,valid_to TIMESTAMPTZstate TEXT NOT NULL(pending_link→active→retired)created_by TEXT NOT NULL, plusTenantMixin+TimestampMixin
No FK to loyalty (HTTP boundary). loyalty is the assignment ledger; BSS does not duplicate per-customer assignment rows.
packages/bss-models/bss_models/subscription.py, subscription table:
discount_type TEXT(nullable),discount_value NUMERIC(12,2)(nullable)discount_periods_remaining SMALLINT NOT NULL DEFAULT 0(decremented byrenew()while > 0;-1sentinel = perpetual)promo_code TEXT(nullable — forensic),promo_offer_definition_id TEXT(nullable — forensic / join)
price_amount stays the full base snapshot; effective is computed at charge. This mirrors the existing pending_* plan-change fields exactly — same pattern, not a new one.
TMF subscription response must expose these so the portal dashboard can render them. (Touches the API contract + its tests.)
packages/bss-models/bss_models/order_mgmt.py, order_item table:
discount_code TEXT,promo_offer_definition_id TEXTdiscount_type TEXT,discount_value NUMERIC(12,2),discount_periods_total SMALLINTpromo_offer_id TEXT(the loyalty offer id captured at claim, used for redeem/revoke)
1. catalog.promotion row written, state='pending_link' (money terms; no OD yet)
2. → loyalty offer_definition.register → OD-xyz (Idempotency-Key = promotion id)
3. → loyalty promo_code.register(code → OD-xyz, kind) (non-targeted only; targeted skips this)
4. catalog.promotion updated: offer_definition_id=OD-xyz, state='active'
A half-failure is harmless: codes/offers do nothing until the row is active. bss promo reconcile relinks by OD if step 4 dies. The saga ordering (BSS row first, loyalty next, BSS confirm last) means a crash never leaves a live code pointing at missing money terms.
COM.create_order(customer, offering, discount_code=SUMMER25):
promo_code.show(code) → offer_definition_id, state, binding [READ, no consume]
catalog.promotion by OD → discount terms
validate: applicable to offering? window open?
base = lowest-active snapshot; effective = apply(discount, base) [COMPOSE]
stamp order_item with code + terms (INTENT, not yet claimed)
... SOM provisioning ... (a failure here NEVER burns the code)
COM.handle_service_order_completed:
offer.claim(source=promo_code, code, customer, idem=order_id) → offer_id [CONSUME — the gate]
subscription.create(snapshot{ base, effective, terms, periods_total })
charges EFFECTIVE (period 1); discount_periods_remaining = total − 1
on decline → raises → COM catches → offer.revoke(offer_id, ORDER_FAILED)
on success → offer.redeem(offer_id, order_ref=order_id)
Originally this flow used codeless
offer.issueper customer. v1.1.1 reversed that: a targeted promo is one real loyalty code + a BSS eligibility list. loyalty'spromo_code.registerhas no customer field, so the per-customer pairing lives incatalog.promotion_eligibility; BSS is the eligibility gate.
A targeted promo is created exactly like a public one but with audience=targeted
(a code is derived from the promotion id if not supplied; default kind
single_use_unique_per_customer). It is not advertised — it only applies for
customers on its eligibility list, and a typed attempt by anyone else is rejected
(not_eligible).
Lives in catalog (holds the LoyaltyClient). Operator picks the audience manually:
bss promo create --id PROMO_VIP --type percent --value 20 --duration single --audience targeted
bss promo assign --promo PROMO_VIP --customers CUST-001,CUST-007,CUST-042
assign_targeted inserts a promotion_eligibility(promotion_id, customer_id) row
per customer (idempotent — already-eligible reported under already). No
offer.issue. seed_targeted_campaign.py (repo root) produces repeatable demo
data the same way.
A targeted code is looked up by the authenticated customer_id: it auto-applies
only for customers with an eligibility row, and validate_for_order(customer_id)
rejects a typed targeted code from anyone else (not_eligible). The dashboard /
auto-apply also drop a promo the customer has already claimed/redeemed (so a
single-use voucher disappears after use — checked via loyalty offer.list).
COM.create_order(customer, offering): # no code typed
catalog.resolve_eligible_promo(customer, offering) # eligibility lookup
→ best applicable targeted promo's CODE + effective terms (or none)
stamp order_item with the code + terms (INTENT)
COM.handle_service_order_completed:
offer.claim(source=promo_code, code, customer, idem=order_id) → offer_id # the gate
subscription.create(... effective ...) # charge, set discount_periods_remaining
on success → offer.redeem(offer_id, order_ref=order_id)
on decline → offer.revoke(offer_id, order_cancelled)
renew():
pending plan-change → clear discount fields (promo dropped — DECISIONS 2026-05-21)
elif discount_periods_remaining > 0 → charge apply(discount, price_amount); decrement
else → charge price_amount (full)
Worked example (PLAN_M base $25, 20% off, 3 periods):
create: charge $20 (period 1 of 3) discount_periods_remaining = 2
renew 1: charge $20 (period 2 of 3) → 1
renew 2: charge $20 (period 3 of 3) → 0
renew 3: charge $25 (full — promo done)
plan change before exhausted → discount dropped immediately
The renewal worker is untouched — it calls service.renew(sub_id) and nothing else (v0.18 doctrine). All the discount logic lives inside renew().
The portal holds CatalogClient, COMClient, SubscriptionClient — and no loyalty token. It touches promos only through BSS endpoints.
Non-targeted — code entry + live preview (in the signup funnel):
- "Have a promo code?" field in
templates/signup.html; carried on the in-memorySignupSession(session.py); extracted inPOST /signup; passed throughPOST /signup/step/order→com.create_order(..., promo_code=...). - Live discounted-price preview: new portal route
GET /promo/preview?code=&offering=→CatalogClient.preview_promo(code, offering)(catalog doespromo_code.show+ promotion lookup, returns{valid, label, base, effective}), rendered as an HTMX partial swapped in below the field. Triggered on blur/button, not per-keystroke. An invalid/expired code shows an inline error and never blocks the order. - No step-up — confirmed this is the pre-login signup funnel, treated like the other
SIGNUP_ACTION_LABELS.
Targeted — entitlement display (on the account dashboard):
- New BSS read-proxy →
offer.list(customer, state=issued);routes/landing.py::_line_viewsurfaces it;partials/line_card.htmlrenders "🎁 You have a 20%-off offer — applies to your next order." - The applied discount on an active subscription is read straight off the subscription row (authoritative for what's actually charged): "20% off · 2 of 3 periods remaining · reverts to SGD 25.00 on ."
| Component | Change |
|---|---|
bss-clients |
new LoyaltyClient + BearerAuthProvider (reads BSS_LOYALTY_API_TOKEN; maps X-BSS-Actor→X-Actor-Id; derives Idempotency-Key from order id; translates loyalty 422 {refused,code,detail} → PolicyViolationFromServer) |
| catalog model/migration | promotion table |
| catalog service | create_promotion (saga; audience+name), assign_targeted (writes promotion_eligibility rows — v1.1.1), validate_for_order (eligibility-gated), preview_promo, resolve_eligible_promo (replaces offer-discovery), list_customer_offers (eligibility query); holds an OPTIONAL LoyaltyClient |
| catalog routes | TMF671 promo routes + GET /promo/preview + GET /promo/customer-offers (portal-facing reads) |
| COM | order_item discount cols; create_order(discount_code=, skip_assigned_offer=) + eligible-promo discovery; ONE consume path — offer.claim-by-code at handle_service_order_completed, offer.redeem on success / offer.revoke(order_cancelled) on decline (v1.1.1: no advance_to_claimed); discount in price-snapshot payload; holds an OPTIONAL LoyaltyClient |
| subscription | discount cols; create charges effective + sets counter; renew() decrement; plan-change clears; TMF response exposes discount fields |
| tools + CLI | promo.create, promo.assign, promo.show + order.create(+discount_code); bss promo Typer group; TOOL_SURFACE.md (drift test) — default/operator_cockpit only |
| portal | signup promo field + SignupSession + pass-through; GET /promo/preview route + HTMX partial; dashboard discount_info + assigned-offer block in _line_view/line_card.html |
| CRM (v1.1.1) | mirrors customers into loyalty's registry on customer.create (best-effort) + backfill_loyalty_customers.py; holds an OPTIONAL LoyaltyClient |
| seed/demo | seed_targeted_campaign.py at repo root (creates a targeted promo + eligibility) |
| deploy/env | BSS_LOYALTY_BASE_URL, BSS_LOYALTY_API_TOKEN; loyalty-http points at the shared Postgres instance (loyalty schema) to stay inside the 4GB budget |
- This doc + DECISIONS entry + Phase 0 sign-off. ← you are here.
LoyaltyClient+BearerAuthProviderin bss-clients — live smoke (promo_code.show) against the running:8080.- Catalog
promotionmodel + create saga +preview_promo(bss promo createworks; codes appear in loyalty). - COM non-targeted order-with-promo + claim/redeem/revoke (one hero scenario).
- Subscription duration enforcement (1 / X / perpetual; renewal-worker test).
- Portal: code entry + preview + dashboard discount — first end-to-end test moment.
- Targeted:
offer.issueassignment,bss promo assign+seed_targeted_campaign.pysimulator, COM assigned-offer discovery, dashboard entitlement display. - Polish: cockpit promo views, scenarios, docs/runbook.
- Burn-on-revoke — loyalty's
revokerestores inventory but does not un-consume a code; a claimed-then-revoked single-use code is spent. Mitigated by claim-at-activation (only a payment decline burns it). Accepted; operator re-issues if needed. - Shared-code race (non-targeted, create→activation window) — if another order consumes the code first, the activation claim fails. Default: hard-fail the activation with
promo.claimed_elsewhererather than silently charging full price. Rare. - Idempotency across the seam — a BSS-crash retry of
handle_service_order_completedmust reuse the same loyaltyIdempotency-Key(derive fromorder_id) or risk a double-claim. loyalty caches(actor_id, key)results, so a stable key is the whole fix. - Two-system creation — handled by the
pending_linkstate +bss promo reconcile. - Subscription TMF contract drift — exposing discount fields touches the API response + tests; flagged in step 5.
Write-through-policy ✅ (COM/catalog policies, admin gate). S2S chokepoint ✅ (LoyaltyClient in bss-clients). No shared DB across boundaries ✅ (HTTP only; loyalty owns its loyalty schema). No proration ✅ (discounted snapshot over whole periods). CLI-first / LLM-native ✅ (bss promo verbs + promo.* tools). Lightweight ✅ (one extra small container; same Postgres instance — measure against 4GB). Portal rules ✅ (request.state.customer_id only; one route → one write; signup not step-up-gated; loyalty token never reaches the portal). TMF671 (Promotion Management) ✅ — genuine new coverage via the adapter rather than naming theater.
This phase is a Phase 0 amendment — promos are a scope addition CLAUDE.md reserves for explicit human approval. The DECISIONS 2026-05-21 entries are that record.