Skip to content

Promo Code Module Plan

Free-plan promo codes: admin management, claim-mode (once or unlimited), redemption tracking, quota bump, and email flows.

Status: PLANNING (not implemented).

  1. Admin management for promo codes (create, list, edit, and deactivate only).
  2. Database tables for codes and redemptions.
  3. Access control: the promo admin module (CRUD + listing with claim details) is visible only to the hardcoded allowlisted email zainalam1001@gmail.com (normalized: trim + lowercase). No other user may load those routes or actions.
  4. Redemption rules:
    • Free plan only for now: paid shops cannot redeem (clear error message).
    • A shop may redeem many different promo codes over time (each successful redeem adds to free_credit_limit).
    • Each promo code must have a claim mode selected at create time:
      • once: globally single-use (first successful claim wins; no one else can claim it).
      • unlimited: can be claimed repeatedly by many shops.
  5. Listing: on the promo admin list, for each code show claim details when redeemed: Name, Email, Shop (domain), Date claimed. Name/email should come from whichever user identity fields are already available in DB for that shop/session at claim time.
  6. Benefit: each code grants X free replies added to the shop’s free-plan monthly reply allowance (today: shops.free_credit_limit, used with shops.free_credits_used per app/lib/limits.server.js).
  7. Email: on successful claim, send (a) a confirmation to the shop owner email and (b) a notification to info@appifire.com (reuse Brevo path in app/lib/email.server.js).

  • Free reply cap: Shop.freeCreditLimit, usage Shop.freeCreditsUsed; enforcement in checkReplyLimit / incrementFreeCreditsUsed (app/lib/limits.server.js). Monthly reset clears usage, not the cap (resetFreeCreditsUsedForFreePlanShops).
  • Email: Brevo transactional API via sendBrevoTransactionalEmail pattern in app/lib/email.server.js.
  • Admin shell: app/routes/app.jsx (s-app-nav links); new nav item must be conditional (only when viewer is promo admin).
  • Auth: authenticate.admin(request) across app routes; sessions stored in Prisma Session (often offline, with email frequently null in dumps—see §5).

FieldTypeNotes
idUUIDPK
codeStringUnique; store normalized uppercase format for lookups
freeRepliesIntNumber of replies to add to free_credit_limit on redeem
descriptionString?Internal label / notes
claimModeStringRequired enum-like value: once or unlimited
activeBooleanDefault true; inactive codes reject redemption
createdAt / updatedAtDateTimeStandard

Optional later: expiresAt, maxRedemptions (if you want capped multi-use beyond unlimited).

Promo code format (fixed):

  • Display format: APPI-XXXX-XXXX
  • Charset: uppercase A-Z and digits 0-9
  • Separator: hyphen (-) every 4 chars for readability
  • Normalized storage: uppercase with hyphens removed (example: APPI7K9Q4M2P)
  • Validation:
    • Minimum 8 and maximum 16 alphanumeric chars after normalization
    • Reject spaces/special characters
    • Case-insensitive input accepted, always normalized before lookup

One row per successful redemption.

FieldTypeNotes
idUUIDPK
promoCodeIdStringFK → PromoCode
shopIdStringFK → Shop
shopDomainStringDenormalized for fast listing / email
claimedByNameString?Name captured at claim time from the best available DB-backed user/shop identity fields
claimedByEmailString?Email captured at claim time from the best available DB-backed user/shop identity fields
claimedAtDateTimeDefault now()

Indexes:

  • Index(promoCodeId) — fast lookup for claim count and history by code.
  • Index(shopId) — useful for support (“which promos did this shop redeem?”).

Concurrency (must-have): use a transaction:

  1. Load promo by normalized code with active = true.
  2. If claimMode = 'once', reject when any redemption already exists for promoCodeId (or enforce with DB uniqueness strategy).
  3. If claimMode = 'unlimited', skip the one-time check.
  4. Insert redemption row.
  5. Increment Shop.freeCreditLimit by freeReplies.

For once, keep DB-level protection against race conditions (for example a unique mechanism that guarantees one redemption max for that code).

Claim count tracking (required):

  • Source of truth: PromoRedemption rows.
  • Per-code claim count: COUNT(*) WHERE promoCodeId = ?
  • No promo reference on a single user row is needed because claims are one-to-many (a shop/user can claim multiple different promo codes).
  • Optional optimization later: store a denormalized claimedCount on PromoCode and keep it synced in the same transaction.

3.3 PromoCodeSendLog (admin send-email audit)

Section titled “3.3 PromoCodeSendLog (admin send-email audit)”

One row per admin-triggered “send promo code by email” action.

FieldTypeNotes
idUUIDPK
promoCodeIdStringFK → PromoCode
recipientEmailStringDestination email
recipientNameString?Optional
customNoteString?Optional
sentByEmailStringAdmin identity (should be zainalam1001@gmail.com for now)
sentAtDateTimeDefault now()
providerMessageIdString?Optional provider id for support/debug
sendStatusStringe.g. sent / failed
sendErrorString?Error text when failed

Indexes:

  • Index(promoCodeId)
  • Index(recipientEmail)
  • Index(sentAt)

  • New route, e.g. app/routes/app.promo-redeem.jsx (path /app/promo-redeem), authenticated for any shop user (still only free shop.plan; see §6).
  • UI: text field + submit; success/error banners.
  • Action: validate code (exists, active), confirm shop is free plan, apply claim-mode logic (once vs unlimited), resolve name + email from whichever identity fields are already present in DB for this shop/user context, then transaction + emails.

Placement: link from Billing & Usage (app/routes/app.billing.jsx) as “Have a promo code?” so merchants find it naturally.

  • New route(s), e.g. app/routes/app.promo-codes.jsx (path /app/promo-codes), authenticated + requirePromoAdmin(request) (§5).
  • UI/style compliance: promo CRUD screens must follow existing Appifire application style guidelines and reuse established admin patterns (Polaris layout, spacing, form styles, table patterns, banners, empty states, and loading/error states).
  • List: table of codes: code, free replies, active, created, and claim columns (Name, Email, Shop domain, Claimed date) or “Unclaimed”.
  • List: table of codes: code, free replies, claim mode, active, Claimed count, created, and claim details (Name, Email, Shop domain, Claimed date) for recent/latest claims or drill-down.
  • Create / edit: form fields: code (or auto-generate), freeReplies, claimMode (once or unlimited), description, active.
  • Deactivate only (no delete): admin can set active = false to stop new claims while preserving full history. Hard-delete is out of scope and should not be exposed in UI or server actions.

4.3 Admin-facing: send promo code by email

Section titled “4.3 Admin-facing: send promo code by email”
  • New route, e.g. app/routes/app.promo-send.jsx (path /app/promo-send), authenticated + requirePromoAdmin(request).
  • Purpose: send a selected promo code to a target recipient email from Appifire admin.
  • UI fields:
    • Promo code selector (active codes only)
    • Recipient email
    • Optional recipient name
    • Optional custom note
  • Action:
    • Validate promo code exists and is active.
    • Validate email format.
    • Send email using Brevo helper (app/lib/email.server.js).
    • Write a send log row (see §3.3) for audit and support visibility.
  • This screen only sends a code by email; it does not redeem/claim the code.
  • In app/routes/app.jsx, render <s-link href="/app/promo-codes">Promo codes</s-link> only when loader or root layout knows showPromoAdminNav === true.
  • In the same gated nav, add <s-link href="/app/promo-send">Send promo</s-link> for the email-send screen.
  • Implement via a small shared loader helper or pass flag from a parent loader (avoid N+1: optional dedicated endpoint or embed flag in app loader after you add root loader data).

5) Access control: zainalam1001@gmail.com only

Section titled “5) Access control: zainalam1001@gmail.com only”

Requirement: only that email may access promo admin routes and POST actions.

Reality check: with offline sessions, Session.email is often null (databases/*.sql examples). You must not rely on DB session email alone without verification.

Recommended approach (pick one and implement consistently):

  1. Session token / online identity (preferred): Use whatever authenticate.admin exposes for the current staff user in your stack (session token claims or online session). Normalize email and compare to the allowlist (and optionally move allowlist to process.env.PROMO_ADMIN_EMAILS comma-separated while keeping your address as default).
  2. Admin GraphQL: If the API version and scopes allow reading the authenticated staff member’s email, use that in the loader guard.
  3. Interim fallback: If email cannot be resolved, return 403 and log—do not fall open.

Promo redeem route: no email allowlist; any authenticated staff for the shop can submit a redeem for that shop, and persisted Name / Email on the redemption row and in notifications must use whichever DB identity fields are available (shop/user/session), with deterministic fallback order in implementation.

Server-side: every loader and action on /app/promo-codes must call requirePromoAdmin; do not rely on hiding the nav alone.


  • Eligibility: shop.plan === 'free' only. If the shop is on a paid plan (or any non-free plan you add later), return a clear error and do not mutate quota or create a redemption.

  • In the same transaction as creating PromoRedemption, run:

    freeCreditLimit: { increment: promo.freeReplies }

  • Do not change freeCreditsUsed on redeem (the merchant gets more headroom immediately).

  • Multiple codes: the same shopId may redeem different codes in separate transactions; each code still redeems at most once globally.

Cron / reset: resetFreeCreditsUsedForFreePlanShops only resets usage; higher freeCreditLimit from promos remains until you manually adjust or add separate logic.


TopicDecision
PlanFree plan only for now; paid shops cannot redeem.
Claim modeAdmin selects once (single-use globally) or unlimited (many claims allowed) at promo creation.
Reuse of codesA shop may redeem many distinct codes over time. For a once code, only the first claim succeeds globally.
Promo code formatUse APPI-XXXX-XXXX display format; normalize to uppercase alphanumeric without hyphens in DB.
Claim trackingCount claims from PromoRedemption by promoCodeId; no user-table promo reference required.
Name / Email on listingUse whichever name/email is available in DB at claim time (shop/user/session identity fields).

Still open for implementation detail: human-entered vs auto-generated codes; admin allowlist via env vs hardcode.


Add helpers in app/lib/email.server.js (same Brevo requirements as welcome mail: BREVO_API_KEY, BREVO_FROM_EMAIL, etc.):

To merchant (recipient: claimedByEmail from redemption; fallback to DB shop email field if needed):

  • Subject, e.g. “Your Appifire promo code was applied”
  • Body: code (masked optional), X replies added, new monthly allowance (or “your cap was increased by X”), shop domain, support link.

To info@appifire.com:

  • Subject, e.g. “Promo redeemed: {code} — {shopDomain}”
  • Body: code, shop domain, captured claim name/email (from DB identity fields), free replies granted, timestamp.

Admin send screen email (new):

  • Subject, e.g. “Your Appifire promo code”
  • Body includes: promo code, free replies value, redeem instructions (/app/promo-redeem path context), and optional custom note.
  • On each send attempt, persist PromoCodeSendLog with status/error details.

Fire-and-forget with logging on failure; redemption should not roll back if email fails (or make this explicit: if you require strong consistency, queue retries—default is usually “DB committed, email best effort”).


  • Prisma: PromoCode, PromoRedemption + migration.
  • lib/promo.server.js (or similar): normalize code, redeemPromo({ shopId, code, claimedByName, claimedByEmail }), assertPromoAdmin(request); resolve claim identity from existing DB-backed user/shop/session fields (and validate plan === 'free').
  • app/routes/app.promo-redeem.jsx + link from billing.
  • app/routes/app.promo-codes.jsx CRUD + guarded loaders/actions.
  • Promo list query: include claimedCount per code from PromoRedemption aggregation.
  • app/routes/app.promo-send.jsx + guarded loader/action for sending promo code emails.
  • Root nav: conditional Promo links (Promo codes, Send promo).
  • UI review: promo screens must pass Appifire admin style-guideline compliance before merge.
  • email.server.js: merchant + internal notification.
  • PromoCode actions: remove any delete handler; allow deactivate only.
  • Prisma: add PromoCodeSendLog table for outbound send audit.
  • PromoCode model/UI: add claimMode with allowed values once | unlimited.
  • Promo code validation: enforce format/normalization rules for create and redeem paths.
  • Tests or manual QA (redeem): double-submit redeem, inactive code, wrong code, paid shop rejected, same shop two different codes OK, once-code second claim fails, unlimited-code multiple claims succeed, concurrent redeem race on once mode.
  • Tests or manual QA (send screen): invalid email blocked, inactive code blocked, send success logged, send failure logged.
  • Document env vars if allowlist moves to configuration.