You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Every deposit today is a single manual action initiated by the user in the moment. There's no way to say "invest $50 every Friday" and have it just happen. Dollar-cost averaging is one of the most commonly requested behaviors for an automated investment product, and building it properly requires more than a cron job — it requires a durable authorization model (the user is pre-approving future charges against their custodial wallet), idempotent execution (a retried job run must not double-charge), and a distinct failure-handling path from the existing one-shot deposit flow, since a failure here happens with no user in the loop to notice.
Current State
The codebase has two different, inconsistent scheduling mechanisms worth being deliberate about here rather than adding a third:
src/jobs/*.ts (e.g. sessionCleanup.ts, dataRetention.ts, poolMetrics.ts) use plain setInterval: each exports a scheduleX(): NodeJS.Timeout that runs once immediately, then on an interval; the handle is registered in src/index.ts's main() and cleared in gracefulShutdown().
src/agent/loop.ts uses node-cron for its hourly/daily jobs (cron.schedule('0 * * * *', ...)), pushing each ScheduledTask onto a module-level cronJobs array for shutdown handling.
Neither is a shared abstraction — there's no jobs/index.ts registry, and each job independently wraps itself with runWithCorrelationIdAsync (src/utils/correlation.ts) and job metrics (recordBackgroundJob/recordJobSuccess/recordJobFailure from src/utils/job-metrics.ts). This issue should follow the src/jobs/*.tssetInterval pattern (simpler, matches the majority of existing jobs) rather than introducing node-cron as a second dependency for a third scheduling style, unless a cron-like "run at 9am every day" cadence is specifically required — in which case, match src/agent/loop.ts's existing node-cron usage instead of adding a third approach.
Follows the requireAuth → validate(schema) chain from src/routes/deposit.ts:
POST /api/deposit/recurring — create a plan; require an explicit confirmation flag in the body on first creation (see Security).
GET /api/deposit/recurring/:userId — enforceUserAccess, list plans.
PATCH /api/deposit/recurring/:id — pause/resume/update amount; ownership check against plan.userId (same pattern as issue (Backend) PostgreSQL Setup with Prisma ORM & Full Schema #1's goal routes, since this isn't a :userId-keyed route).
DELETE /api/deposit/recurring/:id — cancel.
Integration Points
New src/jobs/recurringDeposits.ts following the existing src/jobs/*.ts shape: a scheduleRecurringDeposits() exported and registered in src/index.ts's main()/gracefulShutdown() alongside scheduleSessionCleanup/scheduleDataRetention. On each tick, query RecurringDepositPlan where status = ACTIVE and nextRunAt <= now().
Execution reuses the existing deposit path (the same handler src/routes/deposit.ts calls, e.g. processOnChainTransaction(..., 'DEPOSIT')), not a parallel Stellar-submission code path — this keeps Transaction/Position bookkeeping identical to a manual deposit.
Idempotency: before executing, the job must mark the specific due occurrence as "claimed" (e.g. compare-and-swap nextRunAt forward, or a per-occurrence row) so a retried job tick — or two overlapping instances if ever scaled horizontally — cannot execute the same occurrence twice. This is more than the existing jobs need, since none of them move money.
Failure handling: a failed execution (insufficient balance, Stellar RPC failure) should notify the user rather than retry silently forever. Use dispatchWebhookEvent from src/services/webhookDispatcher.ts with a new recurring_deposit.failed event (added to the WEBHOOK_EVENTS const in src/validators/webhook-validators.ts, alongside the existing transaction.confirmed / agent.rebalanced / deposit.received / withdraw.completed), and a WhatsApp message via src/whatsapp/formatters.ts. Do not route failures through the on-chain-event DLQ (src/stellar/dlq.ts) — that's for malformed/unprocessable ledger events, not scheduler-level failures like insufficient funds.
NLP: add 'recurring_deposit'-style intents to src/nlp/parser.ts's closed union (create/pause/cancel), following the same two-tier regex-then-Claude pattern and manual schema sync described in issue (Backend) PostgreSQL Setup with Prisma ORM & Full Schema #1.
Edge Cases & Failure Modes
Insufficient balance at execution time — mark lastRunStatus = 'insufficient_funds', notify, and leave the plan ACTIVE for the next occurrence rather than auto-cancelling.
Two overlapping job ticks (e.g. a slow previous run still in flight) — must not double-execute the same due plan; the claiming mechanism above must be atomic at the database level (a conditional update, not a read-then-write race).
User cancels a plan while its execution is already in flight — the in-flight execution should complete or fail cleanly rather than being interrupted mid-transaction; cancellation should only prevent future occurrences.
Plan amount exceeds the wallet's practical balance by design (user under-funds on purpose to "top up later") — this is a valid, expected state, not an error to alarm on every tick.
Security & Privacy Considerations
Since this is a standing authorization to move the user's funds without their real-time presence, the creation endpoint should require an explicit confirmation step (distinct from a normal deposit's implicit one-time consent) — e.g. a confirmed: true field the client must set after showing the user the cadence/amount, mirroring the "confirmation-before-execution" pattern used for voice-originated commands in issue Voice Message Support for WhatsApp #288.
Problem Statement
Every deposit today is a single manual action initiated by the user in the moment. There's no way to say "invest $50 every Friday" and have it just happen. Dollar-cost averaging is one of the most commonly requested behaviors for an automated investment product, and building it properly requires more than a cron job — it requires a durable authorization model (the user is pre-approving future charges against their custodial wallet), idempotent execution (a retried job run must not double-charge), and a distinct failure-handling path from the existing one-shot deposit flow, since a failure here happens with no user in the loop to notice.
Current State
The codebase has two different, inconsistent scheduling mechanisms worth being deliberate about here rather than adding a third:
src/jobs/*.ts(e.g.sessionCleanup.ts,dataRetention.ts,poolMetrics.ts) use plainsetInterval: each exports ascheduleX(): NodeJS.Timeoutthat runs once immediately, then on an interval; the handle is registered insrc/index.ts'smain()and cleared ingracefulShutdown().src/agent/loop.tsusesnode-cronfor its hourly/daily jobs (cron.schedule('0 * * * *', ...)), pushing eachScheduledTaskonto a module-levelcronJobsarray for shutdown handling.Neither is a shared abstraction — there's no
jobs/index.tsregistry, and each job independently wraps itself withrunWithCorrelationIdAsync(src/utils/correlation.ts) and job metrics (recordBackgroundJob/recordJobSuccess/recordJobFailurefromsrc/utils/job-metrics.ts). This issue should follow thesrc/jobs/*.tssetIntervalpattern (simpler, matches the majority of existing jobs) rather than introducingnode-cronas a second dependency for a third scheduling style, unless a cron-like "run at 9am every day" cadence is specifically required — in which case, matchsrc/agent/loop.ts's existingnode-cronusage instead of adding a third approach.Proposed Solution
Data Model
API Surface
Follows the
requireAuth→validate(schema)chain fromsrc/routes/deposit.ts:POST /api/deposit/recurring— create a plan; require an explicit confirmation flag in the body on first creation (see Security).GET /api/deposit/recurring/:userId—enforceUserAccess, list plans.PATCH /api/deposit/recurring/:id— pause/resume/update amount; ownership check againstplan.userId(same pattern as issue (Backend) PostgreSQL Setup with Prisma ORM & Full Schema #1's goal routes, since this isn't a:userId-keyed route).DELETE /api/deposit/recurring/:id— cancel.Integration Points
src/jobs/recurringDeposits.tsfollowing the existingsrc/jobs/*.tsshape: ascheduleRecurringDeposits()exported and registered insrc/index.ts'smain()/gracefulShutdown()alongsidescheduleSessionCleanup/scheduleDataRetention. On each tick, queryRecurringDepositPlanwherestatus = ACTIVEandnextRunAt <= now().src/routes/deposit.tscalls, e.g.processOnChainTransaction(..., 'DEPOSIT')), not a parallel Stellar-submission code path — this keepsTransaction/Positionbookkeeping identical to a manual deposit.nextRunAtforward, or a per-occurrence row) so a retried job tick — or two overlapping instances if ever scaled horizontally — cannot execute the same occurrence twice. This is more than the existing jobs need, since none of them move money.dispatchWebhookEventfromsrc/services/webhookDispatcher.tswith a newrecurring_deposit.failedevent (added to theWEBHOOK_EVENTSconst insrc/validators/webhook-validators.ts, alongside the existingtransaction.confirmed/agent.rebalanced/deposit.received/withdraw.completed), and a WhatsApp message viasrc/whatsapp/formatters.ts. Do not route failures through the on-chain-event DLQ (src/stellar/dlq.ts) — that's for malformed/unprocessable ledger events, not scheduler-level failures like insufficient funds.'recurring_deposit'-style intents tosrc/nlp/parser.ts's closed union (create/pause/cancel), following the same two-tier regex-then-Claude pattern and manual schema sync described in issue (Backend) PostgreSQL Setup with Prisma ORM & Full Schema #1.Edge Cases & Failure Modes
lastRunStatus = 'insufficient_funds', notify, and leave the planACTIVEfor the next occurrence rather than auto-cancelling.Security & Privacy Considerations
confirmed: truefield the client must set after showing the user the cadence/amount, mirroring the "confirmation-before-execution" pattern used for voice-originated commands in issue Voice Message Support for WhatsApp #288.PATCH/DELETEownership checks must compareplan.userIdtoreq.auth.userIdexplicitly, same reasoning as issue (Backend) PostgreSQL Setup with Prisma ORM & Full Schema #1.Out of Scope
Suggested Implementation Plan
RecurringDepositPlanmodel, migration, CRUD API with the confirmation-gated creation flow.recurring_deposit.*webhook events.Acceptance Criteria
RecurringDepositPlanmodel + migration and CRUD API, with ownership checks on:id-keyed routesnextRunAtadvanced correctlydocs/openapi.yamlupdated