Skip to content

Plan: Free reply monthly reset & paid included credits delivery

Implementation plan: global HTTP cron (Option 1) for free-plan free_credits_used reset on UTC calendar month boundary; PAID_PLAN_INCLUDED_CREDITS_USD unchanged (no cron). All decisions locked — implemented.

Plan: Free reply monthly reset & paid included credits delivery

Section titled “Plan: Free reply monthly reset & paid included credits delivery”

Status: IMPLEMENTED.
All decisions are locked. This document is retained as an architecture reference.


Part B — Global cron (Option 1).

  • A scheduler (cron expression 10 0 1 * *) GETs /api/cron/reset-free-plan-replies at 00:10 UTC on the 1st of each month (no auth header; UTC day-1 guard).
  • The route calls resetFreeCreditsUsedForFreePlanShops(prisma)updateMany where plan = 'free'.
  • Paid included credits are unchanged: no cron for PAID_PLAN_INCLUDED_CREDITS_USD (Part C).
  • Options 2 (lazy per-shop) and 3 (manual only) are out of scope.

TopicDecision
CalendarUTC calendar month — reset on UTC day 1. Route enforces getUTCDate() === 1; misfires on other days return { skipped: true } without touching the DB.
Row scopeplan = 'free' only — matches incrementFreeCreditsUsed SQL (WHERE plan = 'free'). Paid shops are never written.
Free plan + positive credit_balanceReset free_credits_used to 0 for those shops too. While wallet is non-empty chat uses the wallet path; when balance reaches 0, the free cap counter is already clean for the new period.
Trigger mechanismHTTPS GET — no auth header required. Security is the UTC day-1 guard: calling outside UTC day 1 is a no-op. Any external monthly scheduler (GitHub Actions, Cloudflare Cron, UptimeRobot, etc.) can call it.
Idempotency / DB columnsNo new columns for v1. Double run on UTC day 1 is safe (0 stays 0).
Production helper nameresetFreeCreditsUsedForFreePlanShops (new). resetFreeCreditsUsedForAllShops kept for emergency/manual use only.

Part A — Free plan: FREE_PLAN_REPLIES_CAP and “how much resets”

Section titled “Part A — Free plan: FREE_PLAN_REPLIES_CAP and “how much resets””
ConceptStorageMeaning
Cap (“limit”)shops.free_credit_limitMaximum AI replies allowed in a usage period. Set from FREE_PLAN_REPLIES_CAP env at shop creation (default 50). Stays unchanged across resets.
Used countshops.free_credits_usedAI replies consumed in the current period. Incremented by 1 per qualifying reply. Reset to 0 on UTC day 1.

“How much resets?” The merchant gets the full free_credit_limit allowance again. Implementation: free_credits_used → 0. The cap is unchanged.

  • checkReplyLimit compares free_credits_used to free_credit_limit. It does not auto-detect a new month; the cron drives all resets.
  • resetFreeCreditsUsedForFreePlanShops updateMany scoped to plan = 'free'.
  • resetFreeCreditsUsedForAllShops (legacy) kept for emergency manual use.

Route: app/routes/api.cron.reset-free-plan-replies.jsx

BehaviourDetail
MethodGET.
AuthNone. Security is the UTC day-1 guard.
UTC day guardnew Date().getUTCDate() !== 1 → 200 { ok: true, skipped: true, reason: "not_utc_first_of_month" }, no DB write.
ResetresetFreeCreditsUsedForFreePlanShops(db) → returns { ok: true, shopsReset: N }.
ErrorUncaught exception → 500 { error, detail } + console.error.
IdempotencyRunning twice on UTC day 1 is fine; 0 → 0.

Scheduler config (ops):

cron: 10 0 1 * *
url: GET https://ai-chat.appifire.com/api/cron/reset-free-plan-replies

Part C — PAID_PLAN_INCLUDED_CREDITS_USD: how credits are added each billing cycle

Section titled “Part C — PAID_PLAN_INCLUDED_CREDITS_USD: how credits are added each billing cycle”

No cron job is required for subscription included credits.

grantPaidPlanIncludedCreditsForPeriod in app/lib/paid-plan-included-credits.server.js increments shops.credit_balance by PAID_PLAN_INCLUDED_CREDITS_USD at most once per AppSubscription.currentPeriodEnd (idempotency via shops.last_paid_included_credits_period_end).

TriggerWhen it runs
Subscription confirmapp/routes/app.billing.confirm.jsx — subscription ACTIVE + currentPeriodEnd
Webhookapp/routes/webhooks.app_subscriptions.update.jsxstatus === "ACTIVE" + current_period_end
Billing page loaderapp/routes/app.billing.jsx — active subscription present
  • The webhook is the normal production path; opening Billing is a safety net for missed webhooks.
  • If suppress_included_credits_after_subscription_lapse is true, no further included grants run. See Credits wallet.

TopicCron / user actionWhat “resets” or “adds”
Free reply capGlobal cron — 10 0 1 * * UTC, route with day-1 guardfree_credits_used → 0 for plan = 'free'
Paid included USDNo cron; Shopify lifecycle + optional Billing loadAdd PAID_PLAN_INCLUDED_CREDITS_USD once per currentPeriodEnd

  • US-1 — Free-plan merchant gets full allowance back after each UTC month boundary.
  • US-2 — Free-plan merchant who hit the cap can send replies again after reset.
  • US-3 — Paid merchant rows are never written by the free reset cron.
  • US-4 — Reset happens automatically; no manual SQL needed.
  • US-5 — Paid merchant included credits granted once per Shopify billing period (no double-credit).
  • US-6 — Suppressed-lapse shops receive no included grants per product rules.

IDScenarioPreconditionsStepsExpected
T1Fresh period after resetplan = free, free_credits_used >= free_credit_limit, wallet offGET cron route on UTC day 1free_credits_used = 0; next reply succeeds
T2Mid-period no premature resetplan = free, mid-periodGET cron route on UTC day 2–31200 { skipped: true }; free_credits_used unchanged
T3Idempotent double runAny free shop on UTC day 1Call reset twiceStill 0; no error
T4checkReplyLimit mid-periodused < limitSend chats up to limitBlocked only when used >= limit; no implicit reset
T5Free + USD wallet pathplan = free, credit_balance > 0Consume via wallet; reset on UTC day 1Wallet replies use wallet rules; free_credits_used = 0 after reset
T6Paid planplan = paidTrigger cron on UTC day 1Row not updated by scoped updateMany
IDScenarioExpected
T7Active subscription, new currentPeriodEndCredit added once; last_paid_included_credits_period_end updated
T8Same currentPeriodEnd replayedBalance increases once only
T9Suppress flag setNo included grant
IDScenarioExpected
T10Cron failureMonitor for non-2xx; shops keep old counter until next UTC day 1 run; emergency: GET route on UTC day 1 or call resetFreeCreditsUsedForFreePlanShops directly


  1. Confirm decisions — locked above.
  2. Implement cron routeapp/routes/api.cron.reset-free-plan-replies.jsx.
  3. Add resetFreeCreditsUsedForFreePlanShopsapp/lib/limits.server.js.
  4. Fix misleading commentslimits.server.js, prisma/schema.prisma.
  5. Update docsFree-Plan-Credits.md updated.
  6. Cron authNone; UTC day-1 guard only (documented in .env.sample).
  7. Remaining (ops): Wire the external scheduler; add monitoring/alerting for cron non-2xx on UTC day 1 (T10).