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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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