Skip to content

PromoCodeValidationManager

Validates promo codes against eligibility rules and atomically records usage to prevent race conditions.

Purpose

Implements the core verification and redemption logic for promo codes. Supports two flows — subscription (first-time redemption) and renewal (applying an existing discount to subsequent billing cycles) — through a single validateAndRecordUsage entry point. Also exposes a read-only verifyPromoCode for UI discount previews without recording usage.

High-Level Design

Two Flows: Subscription vs Renewal

Subscription flow — First-time redemption. Runs the full validation chain (11 checks, ordered for fast rejection), then atomically records usage via PromoCodeStorageManager (which captures a snapshot of the discount terms).

Renewal flow — Subsequent billing cycles. Bypasses most validation. Instead:

  1. Verifies the code and existing usage record exist
  2. Loads the snapshot taken at first redemption (users keep original terms even if the code is later modified)
  3. Checks discountCycles limit (null = unlimited) using totalBillingCycles from caller (falls back to stored totalBillingCyclesApplied)
  4. Increments renewal cycle count via incrementRenewalCycle()
SubscriptionManager.subscribe()          (UserPaperTradePortfolio DO)

  ├─ Creates local subscription record

  ├─ Cross-DO RPC ──► PromoCodeRegistry.validateAndRecordUsage()
  │                      │
  │                      ├─ flow="subscription": validate → record usage → return discount
  │                      └─ flow="renewal": load snapshot → check cycles → increment → return discount

  ├─ On failure: DELETE local subscription (rollback)
  └─ On success: store discount in payment history, record redemption locally

Validation Chain (Subscription Flow)

Checks are ordered to reject invalid codes as early as possible:

  1. Code exists (after normalization for case-insensitive lookup)
  2. Code is active (isActive)
  3. Not yet valid (validFrom)
  4. Expired (validUntil)
  5. Global usage limit (maxUsageLimit vs currentUsageCount)
  6. Self-referral prevention (wallet address comparison, case-insensitive)
  7. Per-user usage (has this user already redeemed this code?)
  8. Per-user usage limit (enforced by UNIQUE index; placeholder for future maxUsagePerUser)
  9. Plan applicability (applicablePlans)
  10. User type applicability ("new" | "returning")
  11. Payment method applicability (applicablePaymentMethods)

Atomicity & Race Condition Prevention

Validation and recording happen in the same DO method call. Since a Durable Object processes one request at a time (single-threaded), concurrent redemption attempts are serialized. As a safety net, SQLite's UNIQUE constraint on promo_code_usages catches any edge case, surfaced as CODE_ALREADY_USED.

Referrer Eligibility as a Separate Concern

Referral codes (referralWalletUserId !== null) require the referrer to have an active subscription. Since subscription status lives in the referrer's UserPaperTradePortfolio DO (not in PromoCodeRegistry), the caller must check this externally and call validateReferrerEligibility separately. This avoids a cross-DO call from within PromoCodeRegistry.

Discount Calculation

Delegated to utils/discount.ts. Two modes:

  • Percentage: e.g. 0.10 = 10% off, computed as original × value
  • Dollar off: discount amount capped at original price, then subtracted (final price floored at $0)

All math uses BigNumber with 2 decimal places.

Edge Cases & Error Handling

  • UNIQUE constraint violation on promo_code_usages caught and returned as CODE_ALREADY_USED
  • Renewal with missing snapshot logs an error and returns CODE_NOT_FOUND
  • discountCycles = null means unlimited renewal discounts
  • Renewal totalBillingCycles parameter is optional; falls back to stored totalBillingCyclesApplied
  • validateReferrerEligibility is a separate call because the referrer's subscription status is not available within this DO

See Also