Skip to content

Latest commit

 

History

History
256 lines (194 loc) · 19.2 KB

File metadata and controls

256 lines (194 loc) · 19.2 KB

v1.1.0 — Promo codes via loyalty-cli integration (targeted + non-targeted)

⚠ v1.1.1 amendments — read before trusting this doc

Three things shipped differently from the original plan below (full rationale in DECISIONS 2026-05-22):

  1. 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_eligibility list. offer.issue / offer.advance_to_claimed are retired; the consume path is ONE offer.claim-by-code for both audiences. assign_targeted writes eligibility rows; resolve_eligible_promo replaces the offer-discovery path. So Flow 3 + decision #6 below are superseded (Flow 3 is rewritten inline).
  2. loyalty-cli is OPTIONAL. catalog/COM/CRM degrade gracefully (no boot failure) when BSS_LOYALTY_API_TOKEN is unset — promos off, everything else works. CRM also mirrors customers into loyalty's registry on create.
  3. A friendly promotion.name is 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 new bss-clients adapter; 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.

What this is NOT

  • Not a new promotions engine in BSS. The only genuinely new BSS domain object is the catalog promotion row (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/default only — never customer_self_serve. A customer can type a non-targeted code, but cannot enumerate or self-issue.

Division of responsibility

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."

Which BSS services hold the loyalty relationship

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

Decisions locked (full rationale in DECISIONS 2026-05-21)

  1. Integrate, don't build. loyalty-cli is the promo engine; BSS adapts only the catalog.
  2. Join key = offer_definition_id. One catalog promotion row 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.
  3. Duration: activation = period 1. A multi-period promo decrements a per-subscription counter; price_amount stays the full base, effective is computed at charge time. A plan change ends the promo. Perpetual = counter never decrements.
  4. 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.
  5. 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.
  6. Targeted = codeless assigned offer (offer.issue). SUPERSEDED by v1.1.1 (DECISIONS 2026-05-22): targeted = a real loyalty code + a BSS promotion_eligibility list (eligibility-gated, auto-applied). The original rationale (no customer_id on promo_code.register) still holds — that's why the pairing lives in BSS, not loyalty — but codeless offer.issue is retired. See the amendment banner + rewritten Flow 3.

Schema delta

catalog — new promotion table (migration 00NN_v110_promotion)

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 separate catalog.promotion_eligibility table (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 NULL
  • currency 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 TIMESTAMPTZ
  • state TEXT NOT NULL (pending_linkactiveretired)
  • created_by TEXT NOT NULL, plus TenantMixin + TimestampMixin

No FK to loyalty (HTTP boundary). loyalty is the assignment ledger; BSS does not duplicate per-customer assignment rows.

subscription — new discount columns (same migration family)

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 by renew() while > 0; -1 sentinel = 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.)

COM — new order_item discount columns

packages/bss-models/bss_models/order_mgmt.py, order_item table:

  • discount_code TEXT, promo_offer_definition_id TEXT
  • discount_type TEXT, discount_value NUMERIC(12,2), discount_periods_total SMALLINT
  • promo_offer_id TEXT (the loyalty offer id captured at claim, used for redeem/revoke)

Flow 1 — bss promo create (two-system saga)

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.

Flow 2 — non-targeted: customer types a code

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)

Flow 3 — targeted: an eligibility-gated code (v1.1.1 — supersedes the codeless plan)

Originally this flow used codeless offer.issue per customer. v1.1.1 reversed that: a targeted promo is one real loyalty code + a BSS eligibility list. loyalty's promo_code.register has no customer field, so the per-customer pairing lives in catalog.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).

Targeting — bss promo assign (operator-only) + seed_targeted_campaign.py

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.

Eligibility — the gate, plus a usage check

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

Order-time flow (same consume path as a typed code)

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)

Renewal lifetime (both flows)

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 face (demo + test surface)

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-memory SignupSession (session.py); extracted in POST /signup; passed through POST /signup/step/ordercom.create_order(..., promo_code=...).
  • Live discounted-price preview: new portal route GET /promo/preview?code=&offering=CatalogClient.preview_promo(code, offering) (catalog does promo_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_view surfaces it; partials/line_card.html renders "🎁 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 ."

Change inventory

Component Change
bss-clients new LoyaltyClient + BearerAuthProvider (reads BSS_LOYALTY_API_TOKEN; maps X-BSS-ActorX-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

Sequencing

  1. This doc + DECISIONS entry + Phase 0 sign-off. ← you are here.
  2. LoyaltyClient + BearerAuthProvider in bss-clients — live smoke (promo_code.show) against the running :8080.
  3. Catalog promotion model + create saga + preview_promo (bss promo create works; codes appear in loyalty).
  4. COM non-targeted order-with-promo + claim/redeem/revoke (one hero scenario).
  5. Subscription duration enforcement (1 / X / perpetual; renewal-worker test).
  6. Portal: code entry + preview + dashboard discount — first end-to-end test moment.
  7. Targeted: offer.issue assignment, bss promo assign + seed_targeted_campaign.py simulator, COM assigned-offer discovery, dashboard entitlement display.
  8. Polish: cockpit promo views, scenarios, docs/runbook.

Risks & mitigations

  • Burn-on-revoke — loyalty's revoke restores 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_elsewhere rather than silently charging full price. Rare.
  • Idempotency across the seam — a BSS-crash retry of handle_service_order_completed must reuse the same loyalty Idempotency-Key (derive from order_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_link state + bss promo reconcile.
  • Subscription TMF contract drift — exposing discount fields touches the API response + tests; flagged in step 5.

Doctrine fit

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.