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.
Approved decision — Part B (free reset)
Section titled “Approved decision — Part B (free reset)”Part B — Global cron (Option 1).
- A scheduler (cron expression
10 0 1 * *) GETs/api/cron/reset-free-plan-repliesat 00:10 UTC on the 1st of each month (no auth header; UTC day-1 guard). - The route calls
resetFreeCreditsUsedForFreePlanShops(prisma)—updateManywhereplan = '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.
Decisions locked for implementation
Section titled “Decisions locked for implementation”| Topic | Decision |
|---|---|
| Calendar | UTC calendar month — reset on UTC day 1. Route enforces getUTCDate() === 1; misfires on other days return { skipped: true } without touching the DB. |
| Row scope | plan = 'free' only — matches incrementFreeCreditsUsed SQL (WHERE plan = 'free'). Paid shops are never written. |
Free plan + positive credit_balance | Reset 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 mechanism | HTTPS 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 columns | No new columns for v1. Double run on UTC day 1 is safe (0 stays 0). |
| Production helper name | resetFreeCreditsUsedForFreePlanShops (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””What the numbers mean
Section titled “What the numbers mean”| Concept | Storage | Meaning |
|---|---|---|
| Cap (“limit”) | shops.free_credit_limit | Maximum AI replies allowed in a usage period. Set from FREE_PLAN_REPLIES_CAP env at shop creation (default 50). Stays unchanged across resets. |
| Used count | shops.free_credits_used | AI 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.
Behavior
Section titled “Behavior”checkReplyLimitcomparesfree_credits_usedtofree_credit_limit. It does not auto-detect a new month; the cron drives all resets.resetFreeCreditsUsedForFreePlanShopsupdateManyscoped toplan = 'free'.resetFreeCreditsUsedForAllShops(legacy) kept for emergency manual use.
Part B — Cron route specification
Section titled “Part B — Cron route specification”Route: app/routes/api.cron.reset-free-plan-replies.jsx
| Behaviour | Detail |
|---|---|
| Method | GET. |
| Auth | None. Security is the UTC day-1 guard. |
| UTC day guard | new Date().getUTCDate() !== 1 → 200 { ok: true, skipped: true, reason: "not_utc_first_of_month" }, no DB write. |
| Reset | resetFreeCreditsUsedForFreePlanShops(db) → returns { ok: true, shopsReset: N }. |
| Error | Uncaught exception → 500 { error, detail } + console.error. |
| Idempotency | Running 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-repliesPart 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).
Triggers
Section titled “Triggers”| Trigger | When it runs |
|---|---|
| Subscription confirm | app/routes/app.billing.confirm.jsx — subscription ACTIVE + currentPeriodEnd |
| Webhook | app/routes/webhooks.app_subscriptions.update.jsx — status === "ACTIVE" + current_period_end |
| Billing page loader | app/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_lapseistrue, no further included grants run. See Credits wallet.
Part D — Quick comparison table
Section titled “Part D — Quick comparison table”| Topic | Cron / user action | What “resets” or “adds” |
|---|---|---|
| Free reply cap | Global cron — 10 0 1 * * UTC, route with day-1 guard | free_credits_used → 0 for plan = 'free' |
| Paid included USD | No cron; Shopify lifecycle + optional Billing load | Add PAID_PLAN_INCLUDED_CREDITS_USD once per currentPeriodEnd |
User stories (verification)
Section titled “User stories (verification)”- 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.
Test cases
Section titled “Test cases”Free reply cap + cron (Option 1)
Section titled “Free reply cap + cron (Option 1)”| ID | Scenario | Preconditions | Steps | Expected |
|---|---|---|---|---|
| T1 | Fresh period after reset | plan = free, free_credits_used >= free_credit_limit, wallet off | GET cron route on UTC day 1 | free_credits_used = 0; next reply succeeds |
| T2 | Mid-period no premature reset | plan = free, mid-period | GET cron route on UTC day 2–31 | 200 { skipped: true }; free_credits_used unchanged |
| T3 | Idempotent double run | Any free shop on UTC day 1 | Call reset twice | Still 0; no error |
| T4 | checkReplyLimit mid-period | used < limit | Send chats up to limit | Blocked only when used >= limit; no implicit reset |
| T5 | Free + USD wallet path | plan = free, credit_balance > 0 | Consume via wallet; reset on UTC day 1 | Wallet replies use wallet rules; free_credits_used = 0 after reset |
| T6 | Paid plan | plan = paid | Trigger cron on UTC day 1 | Row not updated by scoped updateMany |
Paid included credits (regression)
Section titled “Paid included credits (regression)”| ID | Scenario | Expected |
|---|---|---|
| T7 | Active subscription, new currentPeriodEnd | Credit added once; last_paid_included_credits_period_end updated |
| T8 | Same currentPeriodEnd replayed | Balance increases once only |
| T9 | Suppress flag set | No included grant |
Observability / ops
Section titled “Observability / ops”| ID | Scenario | Expected |
|---|---|---|
| T10 | Cron failure | Monitor for non-2xx; shops keep old counter until next UTC day 1 run; emergency: GET route on UTC day 1 or call resetFreeCreditsUsedForFreePlanShops directly |
Part E — Related docs
Section titled “Part E — Related docs”- Credits wallet: one-time & subscription
- Free plan credits — updated to reflect automated cron.
Part F — Engineering tasks (completed)
Section titled “Part F — Engineering tasks (completed)”Confirm decisions— locked above.Implement cron route—app/routes/api.cron.reset-free-plan-replies.jsx.Add—resetFreeCreditsUsedForFreePlanShopsapp/lib/limits.server.js.Fix misleading comments—limits.server.js,prisma/schema.prisma.Update docs—Free-Plan-Credits.mdupdated.Cron auth— None; UTC day-1 guard only (documented in.env.sample).- Remaining (ops): Wire the external scheduler; add monitoring/alerting for cron non-2xx on UTC day 1 (T10).