Skip to content

Recurring Deposits (Dollar-Cost Averaging Scheduler) #282

Description

@robertocarlous

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 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/*.ts setInterval 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.

Proposed Solution

Data Model

model RecurringDepositPlan {
  id             String        @id @default(uuid())
  userId         String
  amount         Decimal
  assetSymbol    String
  cadence        DepositCadence
  nextRunAt      DateTime
  status         PlanStatus    @default(ACTIVE)
  lastRunAt      DateTime?
  lastRunStatus  String?
  createdAt      DateTime      @default(now())
  updatedAt      DateTime      @updatedAt
  user           User          @relation(fields: [userId], references: [id], onDelete: Cascade)
  @@index([userId])
  @@index([status, nextRunAt])
}
enum DepositCadence { WEEKLY BIWEEKLY MONTHLY }
enum PlanStatus { ACTIVE PAUSED CANCELLED }

API Surface

Follows the requireAuthvalidate(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/:userIdenforceUserAccess, 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.
  • PATCH/DELETE ownership checks must compare plan.userId to req.auth.userId explicitly, same reasoning as issue (Backend) PostgreSQL Setup with Prisma ORM & Full Schema #1.

Out of Scope

  • Variable/conditional cadences (e.g. "deposit more when APY is high") — fixed cadence only for v1.
  • Cross-currency/FX-adjusted recurring deposits.

Suggested Implementation Plan

  1. RecurringDepositPlan model, migration, CRUD API with the confirmation-gated creation flow.
  2. Scheduler job with an atomic due-plan claiming mechanism; wire into the existing deposit execution path.
  3. Failure notification via webhook + WhatsApp; new recurring_deposit.* webhook events.
  4. WhatsApp create/pause/cancel commands.

Acceptance Criteria

  • RecurringDepositPlan model + migration and CRUD API, with ownership checks on :id-keyed routes
  • Creation requires explicit confirmation before the first charge is scheduled
  • Scheduler executes due plans exactly once per occurrence even under overlapping/retried job ticks (idempotency test required)
  • Failed executions notify the user via webhook and WhatsApp, are logged with a reason, and do not silently retry forever
  • WhatsApp create/pause/cancel commands work through the existing intent-parsing pipeline
  • Integration test covering a full cadence cycle: create → due → executed → nextRunAt advanced correctly
  • docs/openapi.yaml updated

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions