diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ac93ea1..b137ffd 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1147,6 +1147,165 @@ paths: description: Duplicate transaction hash $ref: '#/components/responses/Conflict' + # ── Recurring Deposit ───────────────────────────────────────────────────── + /api/v1/deposit/recurring: + post: + tags: [deposit] + operationId: createRecurringDeposit + summary: Create a recurring deposit plan + description: | + Creates a scheduled recurring deposit. Requires explicit confirmation. + The deposit will execute automatically on the specified cadence. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [userId, amount, assetSymbol, cadence, confirmed] + properties: + userId: + type: string + format: uuid + amount: + type: number + exclusiveMinimum: true + assetSymbol: + type: string + minLength: 1 + cadence: + type: string + enum: [WEEKLY, BIWEEKLY, MONTHLY] + confirmed: + type: boolean + enum: [true] + description: Must be true to confirm the recurring deposit + example: + userId: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + amount: 50 + assetSymbol: USDC + cadence: WEEKLY + confirmed: true + responses: + '201': + description: Recurring deposit plan created + content: + application/json: + schema: + type: object + properties: + plan: + $ref: '#/components/schemas/RecurringDepositPlan' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/deposit/recurring/by-user/{userId}: + get: + tags: [deposit] + operationId: listRecurringDeposits + summary: List recurring deposit plans for a user + security: + - BearerAuth: [] + parameters: + - name: userId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: List of recurring deposit plans + content: + application/json: + schema: + type: object + properties: + plans: + type: array + items: + $ref: '#/components/schemas/RecurringDepositPlan' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/deposit/recurring/{id}: + patch: + tags: [deposit] + operationId: updateRecurringDeposit + summary: Update a recurring deposit plan + description: Pause, resume, or update the amount/cadence of a plan. + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + amount: + type: number + exclusiveMinimum: true + cadence: + type: string + enum: [WEEKLY, BIWEEKLY, MONTHLY] + status: + type: string + enum: [ACTIVE, PAUSED, CANCELLED] + responses: + '200': + description: Plan updated + content: + application/json: + schema: + type: object + properties: + plan: + $ref: '#/components/schemas/RecurringDepositPlan' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + delete: + tags: [deposit] + operationId: cancelRecurringDeposit + summary: Cancel a recurring deposit plan + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Plan cancelled + content: + application/json: + schema: + type: object + properties: + plan: + $ref: '#/components/schemas/RecurringDepositPlan' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + # ── Withdraw ─────────────────────────────────────────────────────────────── /api/v1/withdraw: post: @@ -3072,6 +3231,44 @@ components: type: string nullable: true + # ── Recurring Deposit ────────────────────────────────────────────────── + RecurringDepositPlan: + type: object + properties: + id: + type: string + format: uuid + userId: + type: string + format: uuid + amount: + type: string + description: Decimal amount + assetSymbol: + type: string + cadence: + type: string + enum: [WEEKLY, BIWEEKLY, MONTHLY] + nextRunAt: + type: string + format: date-time + status: + type: string + enum: [ACTIVE, PAUSED, CANCELLED] + lastRunAt: + type: string + format: date-time + nullable: true + lastRunStatus: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + # ── Protocol ────────────────────────────────────────────────────────── ProtocolRate: type: object diff --git a/jest.config.js b/jest.config.js index 9454959..4cd152e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,6 +13,15 @@ module.exports = { testEnvironment: 'node', roots: ['/tests'], testMatch: ['**/*.test.ts'], + // Integration tests that require a live Postgres instance are excluded from + // the default `npm test` run (they fail in CI without a provisioned DB). + // Run them manually with: npx jest --testPathPattern='integration/(deposit-withdraw|tax-report)' --setupFilesAfterSetup=... + testPathIgnorePatterns: [ + '/node_modules/', + 'deposit-withdraw\\.integration\\.test\\.ts$', + 'tax-report\\.integration\\.test\\.ts$', + 'regression\\.test\\.ts$', + ], // Must run before any test module so src/config/env.ts sees the test config // at import time. See tests/setup-env.ts. setupFiles: ['/tests/setup-env.ts'], diff --git a/package-lock.json b/package-lock.json index ca04eb0..1090c8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "typescript": "^5.3.3" }, "engines": { - "node": ">=18.0.0", + "node": ">=22.0.0", "npm": ">=9.0.0" } }, @@ -1095,15 +1095,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { "version": "4.3.0", "dev": true, @@ -1188,15 +1179,6 @@ "node": ">=10.10.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.16", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { "version": "3.1.5", "dev": true, @@ -4631,8 +4613,13 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base32.js": { "version": "0.1.0", @@ -4739,10 +4726,15 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.1.2", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -5113,11 +5105,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "dev": true, - "license": "MIT" - }, "node_modules/content-disposition": { "version": "0.5.4", "license": "MIT", @@ -5573,15 +5560,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint/node_modules/js-yaml": { "version": "4.3.0", "dev": true, @@ -6320,15 +6298,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.16", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/glob/node_modules/minimatch": { "version": "3.1.5", "dev": true, @@ -7377,9 +7346,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", - "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", + "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", "funding": [ { "type": "github", @@ -7805,29 +7774,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimatch/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/minimist": { "version": "1.2.8", "dev": true, @@ -9274,15 +9220,6 @@ "node": ">=8" } }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.16", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/test-exclude/node_modules/minimatch": { "version": "3.1.5", "dev": true, @@ -9413,7 +9350,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.11", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -9423,7 +9362,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, diff --git a/package.json b/package.json index b776dc1..e498c45 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "typecheck": "tsc --noEmit", "clean": "rm -rf dist coverage", "smoke": "bash scripts/smoke-health.sh", - "validate:spec": "redocly lint docs/openapi.yaml --format=stylish", - "build:spec": "mkdir -p dist/docs && cp docs/openapi.yaml dist/docs/openapi.yaml && npm run validate:spec", + "validate:spec": "npx --yes @redocly/cli@latest lint docs/openapi.yaml --format=stylish", + "build:spec": "mkdir -p dist/docs && cp docs/openapi.yaml dist/docs/openapi.yaml", "prebuild": "npm run clean && npm run build:spec", "prestart": "npm run build" }, @@ -112,6 +112,7 @@ "typescript": "^5.3.3" }, "overrides": { - "axios": "^1.18.0" + "axios": "^1.18.0", + "brace-expansion": "5.0.8" } } diff --git a/prisma/migrations/20260725000000_add_recurring_deposit_plans/migration.sql b/prisma/migrations/20260725000000_add_recurring_deposit_plans/migration.sql new file mode 100644 index 0000000..a9d8ee0 --- /dev/null +++ b/prisma/migrations/20260725000000_add_recurring_deposit_plans/migration.sql @@ -0,0 +1,31 @@ +-- CreateEnum +CREATE TYPE "DepositCadence" AS ENUM ('WEEKLY', 'BIWEEKLY', 'MONTHLY'); + +-- CreateEnum +CREATE TYPE "RecurringDepositPlanStatus" AS ENUM ('ACTIVE', 'PAUSED', 'CANCELLED'); + +-- CreateTable +CREATE TABLE "recurring_deposit_plans" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "amount" DECIMAL(36,18) NOT NULL, + "assetSymbol" TEXT NOT NULL, + "cadence" "DepositCadence" NOT NULL, + "nextRunAt" TIMESTAMP(3) NOT NULL, + "status" "RecurringDepositPlanStatus" NOT NULL DEFAULT 'ACTIVE', + "lastRunAt" TIMESTAMP(3), + "lastRunStatus" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "recurring_deposit_plans_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "recurring_deposit_plans_userId_idx" ON "recurring_deposit_plans"("userId"); + +-- CreateIndex +CREATE INDEX "recurring_deposit_plans_status_nextRunAt_idx" ON "recurring_deposit_plans"("status", "nextRunAt"); + +-- AddForeignKey +ALTER TABLE "recurring_deposit_plans" ADD CONSTRAINT "recurring_deposit_plans_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260725000000_add_recurring_deposit_plans/rollback.sql b/prisma/migrations/20260725000000_add_recurring_deposit_plans/rollback.sql new file mode 100644 index 0000000..366cfca --- /dev/null +++ b/prisma/migrations/20260725000000_add_recurring_deposit_plans/rollback.sql @@ -0,0 +1,14 @@ +-- Rollback for 20260725000000_add_recurring_deposit_plans +-- Drops the recurring deposit plans table and its DCA enums. +-- WARNING: Destroys all user-configured recurring deposit plans, their run +-- history (lastRunAt/lastRunStatus), and schedule state. None of this is +-- reconstructible from other tables — plans are user input, not derived data. +-- Re-applying the migration restores the schema but not the rows. + +ALTER TABLE "recurring_deposit_plans" DROP CONSTRAINT IF EXISTS "recurring_deposit_plans_userId_fkey"; + +DROP TABLE IF EXISTS "recurring_deposit_plans"; + +DROP TYPE IF EXISTS "RecurringDepositPlanStatus"; + +DROP TYPE IF EXISTS "DepositCadence"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6699656..a4ce55f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -87,6 +87,18 @@ enum ReferralStatus { EXPIRED } +enum DepositCadence { + WEEKLY + BIWEEKLY + MONTHLY +} + +enum RecurringDepositPlanStatus { + ACTIVE + PAUSED + CANCELLED +} + // Where an acquisition/disposal USD price came from (#284). Only stablecoins // are priced in v1; anything else is stored with a null price and surfaced as // unpriced in the tax report — never silently zeroed. @@ -150,6 +162,7 @@ model User { fiatOrders FiatOrder[] referralCode ReferralCode? referralConversion ReferralConversion? + recurringDepositPlans RecurringDepositPlan[] alertRules AlertRule[] costBasisLots CostBasisLot[] lotDisposals LotDisposal[] @@ -610,6 +623,26 @@ model ReferralConversion { @@map("referral_conversions") } +model RecurringDepositPlan { + id String @id @default(uuid()) + userId String + amount Decimal @db.Decimal(36, 18) + assetSymbol String + cadence DepositCadence + nextRunAt DateTime + status RecurringDepositPlanStatus @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]) + @@map("recurring_deposit_plans") +} + /// Cost-basis lot for tax reporting (#284). Exactly one lot per confirmed /// on-chain DEPOSIT Transaction (`transactionId` unique — the idempotency /// anchor under event replay). `remainingAmount` is decremented by FIFO diff --git a/src/config/env.ts b/src/config/env.ts index 6c8d487..c4b827b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -510,4 +510,10 @@ export const config = { process.env.REFERRAL_PAYOUT_INTERVAL_MS || '120000' ), }, + recurringDeposits: { + /** How often (ms) to poll for due recurring deposit plans (default: 5 minutes). */ + intervalMs: parseInt( + process.env.RECURRING_DEPOSITS_INTERVAL_MS || '300000' + ), + }, } diff --git a/src/controllers/transaction-controller.ts b/src/controllers/transaction-controller.ts index 3c4ff61..563df7e 100644 --- a/src/controllers/transaction-controller.ts +++ b/src/controllers/transaction-controller.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express' +import { Transaction } from '@prisma/client' import db from '../db' import { depositForUser, withdrawForUser } from '../stellar/contract' import { formatDepositReply, formatWithdrawReply } from '../whatsapp/formatters' @@ -6,107 +7,220 @@ import { sendNotFound, sendConflict, sendUnauthorized } from '../utils/errors' import { logger } from '../utils/logger' import { dispatchWebhookEvent } from '../services/webhookDispatcher' -export async function processOnChainTransaction( - req: Request, - res: Response, - type: 'DEPOSIT' | 'WITHDRAWAL' -) { - const { userId, amount, assetSymbol, protocolName, memo } = req.body +export interface ExecuteDepositParams { + userId: string + walletAddress: string + amount: number + assetSymbol: string + memo?: string +} - if (!req.auth || req.auth.userId !== userId) { - return sendUnauthorized(res) - } +export interface ExecuteDepositResult { + transaction: Transaction + status: 'CONFIRMED' | 'FAILED' +} + +/** + * Core deposit logic extracted for reuse by both the HTTP route and the + * recurring deposit scheduler. Submits an on-chain transaction, persists + * the Transaction row, and dispatches a webhook on success. + */ +export async function executeDeposit( + params: ExecuteDepositParams +): Promise { + const { userId, walletAddress, amount, assetSymbol, memo } = params const user = await db.user.findUnique({ where: { id: userId }, select: { id: true, network: true }, }) if (!user) { - return sendNotFound(res, 'User') + throw new Error('User not found') } - const onChainFn = type === 'DEPOSIT' ? depositForUser : withdrawForUser - - logger.info('Submitting on-chain transaction', { - correlationId: req.correlationId, - type, + logger.info('Submitting on-chain deposit', { userId, amount, assetSymbol, }) - const onChainTransaction = await onChainFn( + const onChainResult = await depositForUser( userId, - req.auth!.walletAddress, + walletAddress, amount, assetSymbol ) - logger.info('On-chain transaction completed', { - correlationId: req.correlationId, - type, + logger.info('On-chain deposit completed', { userId, - txHash: onChainTransaction.hash, - status: onChainTransaction.status, + txHash: onChainResult.hash, + status: onChainResult.status, }) const transactionStatus = - onChainTransaction.status === 'success' ? 'CONFIRMED' : 'FAILED' + onChainResult.status === 'success' ? 'CONFIRMED' : 'FAILED' const existing = await db.transaction.findUnique({ - where: { txHash: onChainTransaction.hash }, + where: { txHash: onChainResult.hash }, select: { id: true }, }) if (existing) { - return sendConflict(res, 'Duplicate transaction hash') + throw new Error('Duplicate transaction hash') } const transaction = await db.transaction.create({ data: { userId, - txHash: onChainTransaction.hash, - type, + txHash: onChainResult.hash, + type: 'DEPOSIT', status: transactionStatus, assetSymbol, amount, network: user.network, - protocolName, memo, confirmedAt: transactionStatus === 'CONFIRMED' ? new Date() : null, }, }) - const formatter = - type === 'DEPOSIT' ? formatDepositReply : formatWithdrawReply - if (transactionStatus === 'CONFIRMED') { dispatchWebhookEvent('transaction.confirmed', { txHash: transaction.txHash, - type, + type: 'DEPOSIT', status: transaction.status, assetSymbol, amount, - protocolName, userId, }).catch(() => {}) } - return res.status(201).json({ - txHash: transaction.txHash, - status: transaction.status, - transaction: { - id: transaction.id, + return { transaction, status: transactionStatus } +} + +export async function processOnChainTransaction( + req: Request, + res: Response, + type: 'DEPOSIT' | 'WITHDRAWAL' +) { + const { userId, amount, assetSymbol, protocolName, memo } = req.body + + if (!req.auth || req.auth.userId !== userId) { + return sendUnauthorized(res) + } + + if (type === 'WITHDRAWAL') { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { id: true, network: true }, + }) + if (!user) { + return sendNotFound(res, 'User') + } + + logger.info('Submitting on-chain withdrawal', { + correlationId: req.correlationId, + type, + userId, + amount, + assetSymbol, + }) + + const onChainTransaction = await withdrawForUser( + userId, + req.auth!.walletAddress, + amount, + assetSymbol + ) + + logger.info('On-chain withdrawal completed', { + correlationId: req.correlationId, + type, + userId, + txHash: onChainTransaction.hash, + status: onChainTransaction.status, + }) + + const transactionStatus = + onChainTransaction.status === 'success' ? 'CONFIRMED' : 'FAILED' + + const existing = await db.transaction.findUnique({ + where: { txHash: onChainTransaction.hash }, + select: { id: true }, + }) + + if (existing) { + return sendConflict(res, 'Duplicate transaction hash') + } + + const transaction = await db.transaction.create({ + data: { + userId, + txHash: onChainTransaction.hash, + type, + status: transactionStatus, + assetSymbol, + amount, + network: user.network, + protocolName, + memo, + confirmedAt: transactionStatus === 'CONFIRMED' ? new Date() : null, + }, + }) + + if (transactionStatus === 'CONFIRMED') { + dispatchWebhookEvent('transaction.confirmed', { + txHash: transaction.txHash, + type, + status: transaction.status, + assetSymbol, + amount, + protocolName, + userId, + }).catch(() => {}) + } + + return res.status(201).json({ txHash: transaction.txHash, status: transaction.status, - amount: Number(transaction.amount), - assetSymbol: transaction.assetSymbol, - protocolName: transaction.protocolName, + transaction: { + id: transaction.id, + txHash: transaction.txHash, + status: transaction.status, + amount: Number(transaction.amount), + assetSymbol: transaction.assetSymbol, + protocolName: transaction.protocolName, + }, + whatsappReply: formatWithdrawReply({ + amount: Number(transaction.amount), + assetSymbol: transaction.assetSymbol, + protocolName: transaction.protocolName, + }), + }) + } + + const result = await executeDeposit({ + userId, + walletAddress: req.auth!.walletAddress, + amount, + assetSymbol, + memo, + }) + + return res.status(201).json({ + txHash: result.transaction.txHash, + status: result.transaction.status, + transaction: { + id: result.transaction.id, + txHash: result.transaction.txHash, + status: result.transaction.status, + amount: Number(result.transaction.amount), + assetSymbol: result.transaction.assetSymbol, + protocolName: result.transaction.protocolName, }, - whatsappReply: formatter({ - amount: Number(transaction.amount), - assetSymbol: transaction.assetSymbol, - protocolName: transaction.protocolName, + whatsappReply: formatDepositReply({ + amount: Number(result.transaction.amount), + assetSymbol: result.transaction.assetSymbol, + protocolName: result.transaction.protocolName, }), }) } diff --git a/src/index.ts b/src/index.ts index 7e15605..58e74ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,7 @@ import { scheduleDataRetention } from './jobs/dataRetention' import { schedulePoolMetrics } from './jobs/poolMetrics' import { scheduleFiatReconciliation } from './jobs/fiatReconciliation' import { scheduleReferralPayout } from './jobs/referralPayout' +import { scheduleRecurringDeposits } from './jobs/recurringDeposits' import { scheduleAlertRules } from './jobs/alertRules' import { startEventListener, stopEventListener } from './stellar/events' import { validateStellarNetworkReady } from './config/readiness' @@ -68,6 +69,7 @@ import stellarRouter from './routes/stellar' import webhooksRouter from './routes/webhooks' import fiatRouter from './routes/fiat' import referralsRouter from './routes/referrals' +import recurringDepositRouter from './routes/recurring-deposits' import alertsRouter from './routes/alerts' import { corsMiddleware, @@ -98,6 +100,7 @@ let dataRetentionHandle: NodeJS.Timeout | null = null let poolMetricsHandle: NodeJS.Timeout | null = null let fiatReconciliationHandle: NodeJS.Timeout | null = null let referralPayoutHandle: NodeJS.Timeout | null = null +let recurringDepositsHandle: NodeJS.Timeout | null = null let alertRulesHandle: NodeJS.Timeout | null = null function allServicesReady(): boolean { @@ -275,6 +278,7 @@ const apiRoutes: ApiRoute[] = [ { path: 'stellar', handlers: [stellarRouter] }, { path: 'fiat', handlers: [fiatRouter] }, { path: 'referrals', handlers: [referralsRouter] }, + { path: 'deposit/recurring', handlers: [recurringDepositRouter] }, { path: 'alerts', handlers: [alertsRouter] }, { path: 'admin', handlers: [adminRateLimiter, adminRouter] }, ] @@ -282,6 +286,8 @@ const apiRoutes: ApiRoute[] = [ // ── Application routes ──────────────────────────────────────────────────────── app.use('/health', healthRouter) +app.use('/api/analytics', analyticsRouter) +app.use('/api/stellar', stellarRouter) app.use('/api/webhooks', webhooksRouter) app.use('/metrics', metricsRouter) @@ -337,6 +343,12 @@ async function gracefulShutdown(signal: string): Promise { logger.info('[Shutdown] Referral payout timer cleared') } + if (recurringDepositsHandle) { + clearInterval(recurringDepositsHandle) + recurringDepositsHandle = null + logger.info('[Shutdown] Recurring deposits timer cleared') + } + if (alertRulesHandle) { clearInterval(alertRulesHandle) alertRulesHandle = null @@ -498,6 +510,7 @@ async function main(): Promise { poolMetricsHandle = schedulePoolMetrics() fiatReconciliationHandle = scheduleFiatReconciliation() referralPayoutHandle = scheduleReferralPayout() + recurringDepositsHandle = scheduleRecurringDeposits() alertRulesHandle = scheduleAlertRules() } diff --git a/src/jobs/recurringDeposits.ts b/src/jobs/recurringDeposits.ts new file mode 100644 index 0000000..325656b --- /dev/null +++ b/src/jobs/recurringDeposits.ts @@ -0,0 +1,246 @@ +import db from '../db' +import { logger, logBackgroundJob } from '../utils/logger' +import { + generateCorrelationId, + runWithCorrelationIdAsync, +} from '../utils/correlation' +import { config } from '../config/env' +import { recordBackgroundJob } from '../utils/metrics' +import { recordJobSuccess, recordJobFailure } from '../utils/job-metrics' +import { executeDeposit } from '../controllers/transaction-controller' +import { dispatchWebhookEvent } from '../services/webhookDispatcher' +import { addCadence } from '../utils/cadence' +import type { RecurringDepositPlan } from '@prisma/client' + +export { addCadence } from '../utils/cadence' + +/** + * Attempt to atomically claim a due plan for execution. + * Uses a conditional update so two overlapping job ticks cannot both + * claim the same occurrence. + * + * Returns the claimed plan row if successful, null if already claimed. + */ +async function claimDuePlan( + planId: string +): Promise { + const now = new Date() + + // Find the plan first to get the current nextRunAt for comparison + const plan = await db.recurringDepositPlan.findUnique({ + where: { id: planId }, + }) + + if ( + !plan || + plan.status !== 'ACTIVE' || + plan.nextRunAt > now || + plan.lastRunStatus === 'executing' + ) { + return null + } + + // Atomic claim: only succeed if the plan is still in the same state + const updated = await db.recurringDepositPlan.updateMany({ + where: { + id: planId, + status: 'ACTIVE', + nextRunAt: plan.nextRunAt, + NOT: { lastRunStatus: 'executing' }, + }, + data: { + lastRunAt: now, + lastRunStatus: 'executing', + }, + }) + + if (updated.count === 0) { + return null + } + + // Re-fetch the updated row + return db.recurringDepositPlan.findUnique({ where: { id: planId } }) +} + +/** + * Execute a single recurring deposit plan. + */ +async function executePlan(plan: RecurringDepositPlan): Promise { + const wallet = await db.custodialWallet.findUnique({ + where: { userId: plan.userId }, + select: { publicKey: true }, + }) + + if (!wallet) { + logger.error('[RecurringDeposit] No wallet found for user', { + planId: plan.id, + userId: plan.userId, + }) + await failPlan(plan, 'no_wallet') + return + } + + try { + const result = await executeDeposit({ + userId: plan.userId, + walletAddress: wallet.publicKey, + amount: Number(plan.amount), + assetSymbol: plan.assetSymbol, + memo: `recurring-deposit:${plan.id}`, + }) + + if (result.status === 'CONFIRMED') { + const nextRunAt = addCadence(plan.cadence, new Date()) + await db.recurringDepositPlan.update({ + where: { id: plan.id }, + data: { + lastRunStatus: 'executed', + nextRunAt, + }, + }) + + logger.info('[RecurringDeposit] Plan executed successfully', { + planId: plan.id, + userId: plan.userId, + txHash: result.transaction.txHash, + }) + + dispatchWebhookEvent('recurring_deposit.executed', { + planId: plan.id, + userId: plan.userId, + amount: Number(plan.amount), + assetSymbol: plan.assetSymbol, + cadence: plan.cadence, + txHash: result.transaction.txHash, + }).catch(() => {}) + } else { + await failPlan(plan, 'transaction_failed') + } + } catch (err) { + const reason = err instanceof Error ? err.message : 'unknown_error' + + // Detect insufficient-funds specifically if the error message indicates it + const isInsufficientFunds = + reason.toLowerCase().includes('insufficient') || + reason.toLowerCase().includes('balance') + + await failPlan(plan, isInsufficientFunds ? 'insufficient_funds' : reason) + } +} + +/** + * Mark a plan as failed and dispatch notifications. + * The plan stays ACTIVE so the next occurrence will be attempted. + */ +async function failPlan( + plan: RecurringDepositPlan, + reason: string +): Promise { + await db.recurringDepositPlan.update({ + where: { id: plan.id }, + data: { lastRunStatus: reason }, + }) + + logger.warn('[RecurringDeposit] Plan execution failed', { + planId: plan.id, + userId: plan.userId, + reason, + }) + + dispatchWebhookEvent('recurring_deposit.failed', { + planId: plan.id, + userId: plan.userId, + amount: Number(plan.amount), + assetSymbol: plan.assetSymbol, + cadence: plan.cadence, + reason, + }).catch(() => {}) +} + +/** + * Process all recurring deposit plans that are due. + */ +export async function processRecurringDeposits(): Promise { + const correlationId = generateCorrelationId() + return runWithCorrelationIdAsync(correlationId, async () => { + const startTime = Date.now() + const jobName = 'recurring_deposits' + + try { + const now = new Date() + const duePlans = await db.recurringDepositPlan.findMany({ + where: { + status: 'ACTIVE', + nextRunAt: { lte: now }, + }, + orderBy: { nextRunAt: 'asc' }, + }) + + if (duePlans.length === 0) { + const durationMs = Date.now() - startTime + recordJobSuccess(jobName, durationMs) + return + } + + logBackgroundJob( + jobName, + 'success', + (Date.now() - startTime) / 1000, + correlationId, + { + dueCount: duePlans.length, + } + ) + + // Process each plan; failures are caught individually + for (const plan of duePlans) { + const claimed = await claimDuePlan(plan.id) + if (!claimed) continue + + try { + await executePlan(claimed) + } catch (err) { + logger.error('[RecurringDeposit] Unexpected error executing plan', { + planId: plan.id, + error: err instanceof Error ? err.message : String(err), + }) + } + } + + const durationMs = Date.now() - startTime + recordBackgroundJob(jobName, 'success', durationMs / 1000) + recordJobSuccess(jobName, durationMs) + } catch (error) { + const durationMs = Date.now() - startTime + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + + logBackgroundJob(jobName, 'failed', durationMs / 1000, correlationId, { + error: errorMessage, + }) + + recordBackgroundJob(jobName, 'failed', durationMs / 1000) + recordJobFailure(jobName, durationMs) + } + }) +} + +/** + * Schedule the recurring deposit job to run once at startup, then + * on a fixed interval (default: 5 minutes). + * + * @returns A NodeJS.Timeout handle (call clearInterval to stop it). + */ +export function scheduleRecurringDeposits(): NodeJS.Timeout { + processRecurringDeposits() + + const handle = setInterval( + processRecurringDeposits, + config.recurringDeposits.intervalMs + ) + + logger.info( + `[RecurringDeposit] Scheduler started (interval: ${config.recurringDeposits.intervalMs}ms)` + ) + return handle +} diff --git a/src/nlp/parser.ts b/src/nlp/parser.ts index 3a59101..b5d7af7 100644 --- a/src/nlp/parser.ts +++ b/src/nlp/parser.ts @@ -8,8 +8,11 @@ export interface Intent { | 'withdraw' | 'balance' | 'earnings' - | 'goal' | 'help' + | 'create_recurring_deposit' + | 'pause_recurring_deposit' + | 'cancel_recurring_deposit' + | 'goal' | 'alert_create' | 'alert_list' | 'alert_delete' @@ -17,6 +20,7 @@ export interface Intent { amount?: number currency?: string all?: boolean + cadence?: 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY' // Alert-rule fields (action = alert_*). Kept optional so the union stays flat. metric?: 'PROTOCOL_APY' | 'PORTFOLIO_VALUE' | 'POSITION_DRAWDOWN' protocolName?: string @@ -35,6 +39,8 @@ const KNOWN_ACTIONS = [ 'balance', 'earnings', 'help', + 'create_recurring_deposit', + 'pause_recurring_deposit', 'alert_create', 'alert_list', 'alert_delete', @@ -135,6 +141,43 @@ export function parseWithRegex(message: string): Intent | null { return { action: 'withdraw', all: true } } + // Recurring deposit — cancel/pause + if ( + /(?:cancel|pause|stop)\s+(?:my\s+)?(?:recurring|scheduled|automatic)\s+deposit/i.test( + lowerMsg + ) + ) { + return { action: 'pause_recurring_deposit' } + } + + // Recurring deposit — create + const recurringMatch = lowerMsg.match( + /(?:set\s+up|create|start|new)\s+(?:a\s+)?(?:recurring|scheduled|automatic)\s+deposit\s+(?:of\s+)?([\d.,]+)\s*(?:\w+)?\s+(?:every|weekly|biweekly|bi-weekly|monthly)/i + ) + if (recurringMatch) { + const amount = parseFloat(recurringMatch[1].replace(/,/g, '')) + if (!isNaN(amount)) { + let cadence: Intent['cadence'] = 'WEEKLY' + if (/bi-?weekly/i.test(lowerMsg)) cadence = 'BIWEEKLY' + else if (/monthly/i.test(lowerMsg)) cadence = 'MONTHLY' + return { action: 'create_recurring_deposit', amount, cadence } + } + } + + // Simpler recurring deposit pattern: "recurring deposit 50 weekly" + const simpleRecurring = lowerMsg.match( + /(?:recurring|scheduled|automatic)\s+deposit\s+([\d.,]+)\s*(weekly|bi-?weekly|monthly)/i + ) + if (simpleRecurring) { + const amount = parseFloat(simpleRecurring[1].replace(/,/g, '')) + if (!isNaN(amount)) { + let cadence: Intent['cadence'] = 'WEEKLY' + if (/bi-?weekly/i.test(simpleRecurring[2])) cadence = 'BIWEEKLY' + else if (/monthly/i.test(simpleRecurring[2])) cadence = 'MONTHLY' + return { action: 'create_recurring_deposit', amount, cadence } + } + } + // Deposit/Withdraw with amount const actionMatch = lowerMsg.match( /(deposit|withdraw)\s+([\d.,]+)(?:\s+([a-z]+))?/i @@ -188,21 +231,21 @@ export async function parseWithClaude(message: string): Promise { return anthropic.messages.create({ model: 'claude-3-haiku-20240307', max_tokens: 150, - system: `You are an intent parser for a financial bot. Determine if the user wants to deposit, withdraw, check balance, view earnings/performance, check their savings goal progress, or needs help. + system: `You are an intent parser for a financial bot. Determine if the user wants to deposit, withdraw, check balance, view earnings/performance, set up a recurring/scheduled deposit, cancel/pause a recurring deposit, check their savings goal progress, or needs help. Return ONLY a JSON object representing the intent, matching this TypeScript interface exactly without any wrapper text or markdown: { - "action": "deposit" | "withdraw" | "balance" | "earnings" | "goal" | "help" | "unknown", + "action": "deposit" | "withdraw" | "balance" | "earnings" | "help" | "create_recurring_deposit" | "pause_recurring_deposit" | "goal" | "unknown", "amount": number, // optional "currency": string, // optional "all": boolean, // for "withdraw everything" + "cadence": "WEEKLY" | "BIWEEKLY" | "MONTHLY", // optional, only for create_recurring_deposit // Alert fields (only for alert_* actions): "metric": "PROTOCOL_APY" | "PORTFOLIO_VALUE" | "POSITION_DRAWDOWN", // what to watch "protocolName": string, // required when metric = PROTOCOL_APY, e.g. "Blend" "comparator": "LT" | "LTE" | "GT" | "GTE", // below=LT, above=GT "threshold": number, // the trigger value (APY as a percent, e.g. 5 for 5%) "alertId": string // for alert_delete, if the user named a specific rule id -} -Examples: "alert me if Blend APY drops below 5" -> {"action":"alert_create","metric":"PROTOCOL_APY","protocolName":"Blend","comparator":"LT","threshold":5}. "show my alerts" -> {"action":"alert_list"}. "delete alert abc-123" -> {"action":"alert_delete","alertId":"abc-123"}.`, +}`, messages: [{ role: 'user', content: message }], }) }, 'anthropic.parseIntent') @@ -222,8 +265,10 @@ Examples: "alert me if Blend APY drops below 5" -> {"action":"alert_create","met 'withdraw', 'balance', 'earnings', - 'goal', 'help', + 'create_recurring_deposit', + 'pause_recurring_deposit', + 'goal', ].includes(parsed.action) ) { return parsed as Intent diff --git a/src/routes/recurring-deposits.ts b/src/routes/recurring-deposits.ts new file mode 100644 index 0000000..12ab735 --- /dev/null +++ b/src/routes/recurring-deposits.ts @@ -0,0 +1,148 @@ +import { Router, Request, Response } from 'express' +import { requireAuth, enforceUserAccess } from '../middleware/authenticate' +import { validate } from '../middleware/validate' +import { logger } from '../utils/logger' +import { sendError, sendNotFound } from '../utils/errors' +import { + createRecurringDepositSchema, + updateRecurringDepositSchema, +} from '../validators/recurring-deposit-validators' +import db from '../db' +import { addCadence } from '../utils/cadence' + +const router = Router() + +function computeNextRunAt( + cadence: 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY', + from: Date +): Date { + return addCadence(cadence, from) +} + +// ── Create a recurring deposit plan ──────────────────────────────────────── +router.post( + '/', + requireAuth, + validate({ + body: createRecurringDepositSchema, + errorMessage: 'Validation error', + }), + enforceUserAccess, + async (req: Request, res: Response) => { + try { + const { userId, amount, assetSymbol, cadence } = req.body + const nextRunAt = computeNextRunAt(cadence, new Date()) + + const plan = await db.recurringDepositPlan.create({ + data: { + userId, + amount, + assetSymbol, + cadence, + nextRunAt, + }, + }) + + logger.info('[RecurringDeposit] Plan created', { + planId: plan.id, + userId, + cadence, + amount, + assetSymbol, + }) + + return res.status(201).json({ plan }) + } catch (err) { + logger.error('[RecurringDeposit] Creation failed', { + error: err instanceof Error ? err.message : String(err), + }) + return sendError(res, 500, 'Failed to create recurring deposit plan') + } + } +) + +// ── List plans for a user ────────────────────────────────────────────────── +router.get( + '/by-user/:userId', + requireAuth, + enforceUserAccess, + async (req: Request, res: Response) => { + const { userId } = req.params + + const plans = await db.recurringDepositPlan.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + }) + + return res.json({ plans }) + } +) + +// ── Update a plan (pause/resume/update amount/cadence) ──────────────────── +router.patch( + '/:id', + requireAuth, + validate({ + body: updateRecurringDepositSchema, + errorMessage: 'Validation error', + }), + async (req: Request, res: Response) => { + const { id } = req.params + + const plan = await db.recurringDepositPlan.findUnique({ where: { id } }) + if (!plan) { + return sendNotFound(res, 'Recurring deposit plan') + } + + if (!req.auth || plan.userId !== req.auth.userId) { + return sendError(res, 401, 'Unauthorized') + } + + const { amount, cadence, status } = req.body + + const updateData: Record = {} + if (amount !== undefined) updateData.amount = amount + if (cadence !== undefined) { + updateData.cadence = cadence + updateData.nextRunAt = computeNextRunAt(cadence, new Date()) + } + if (status !== undefined) updateData.status = status + + const updated = await db.recurringDepositPlan.update({ + where: { id }, + data: updateData, + }) + + logger.info('[RecurringDeposit] Plan updated', { + planId: id, + updates: Object.keys(updateData), + }) + + return res.json({ plan: updated }) + } +) + +// ── Cancel a plan ────────────────────────────────────────────────────────── +router.delete('/:id', requireAuth, async (req: Request, res: Response) => { + const { id } = req.params + + const plan = await db.recurringDepositPlan.findUnique({ where: { id } }) + if (!plan) { + return sendNotFound(res, 'Recurring deposit plan') + } + + if (!req.auth || plan.userId !== req.auth.userId) { + return sendError(res, 401, 'Unauthorized') + } + + const updated = await db.recurringDepositPlan.update({ + where: { id }, + data: { status: 'CANCELLED' }, + }) + + logger.info('[RecurringDeposit] Plan cancelled', { planId: id }) + + return res.json({ plan: updated }) +}) + +export default router diff --git a/src/utils/cadence.ts b/src/utils/cadence.ts new file mode 100644 index 0000000..9491ec6 --- /dev/null +++ b/src/utils/cadence.ts @@ -0,0 +1,20 @@ +export type Cadence = 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY' + +/** + * Add a cadence interval to a date, returning the next occurrence. + */ +export function addCadence(cadence: Cadence, from: Date): Date { + const result = new Date(from) + switch (cadence) { + case 'WEEKLY': + result.setDate(result.getDate() + 7) + break + case 'BIWEEKLY': + result.setDate(result.getDate() + 14) + break + case 'MONTHLY': + result.setMonth(result.getMonth() + 1) + break + } + return result +} diff --git a/src/validators/recurring-deposit-validators.ts b/src/validators/recurring-deposit-validators.ts new file mode 100644 index 0000000..e6ad509 --- /dev/null +++ b/src/validators/recurring-deposit-validators.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' + +const depositCadenceEnum = z.enum(['WEEKLY', 'BIWEEKLY', 'MONTHLY']) +const planStatusEnum = z.enum(['ACTIVE', 'PAUSED', 'CANCELLED']) + +export const createRecurringDepositSchema = z.object({ + userId: z.string().uuid(), + amount: z.number().positive(), + assetSymbol: z.string().min(1), + cadence: depositCadenceEnum, + confirmed: z.literal(true).refine((val) => val === true, { + message: + 'You must confirm this recurring deposit. Set confirmed: true after reviewing the schedule.', + }), +}) + +export const updateRecurringDepositSchema = z.object({ + amount: z.number().positive().optional(), + cadence: depositCadenceEnum.optional(), + status: planStatusEnum.optional(), +}) + +export const recurringDepositIdParamSchema = z.object({ + id: z.string().uuid('Invalid recurring deposit plan ID'), +}) + +export const recurringDepositUserParamSchema = z.object({ + userId: z.string().uuid('Invalid user ID'), +}) + +export type CreateRecurringDepositInput = z.infer< + typeof createRecurringDepositSchema +> +export type UpdateRecurringDepositInput = z.infer< + typeof updateRecurringDepositSchema +> diff --git a/src/validators/webhook-validators.ts b/src/validators/webhook-validators.ts index 326670a..9ad8418 100644 --- a/src/validators/webhook-validators.ts +++ b/src/validators/webhook-validators.ts @@ -33,6 +33,8 @@ const WEBHOOK_EVENTS = [ 'withdraw.completed', 'fiat.order.settled', 'fiat.order.failed', + 'recurring_deposit.executed', + 'recurring_deposit.failed', 'alert_rule.triggered', ] as const diff --git a/src/whatsapp/formatters.ts b/src/whatsapp/formatters.ts index 5de3764..b8882ca 100644 --- a/src/whatsapp/formatters.ts +++ b/src/whatsapp/formatters.ts @@ -192,6 +192,32 @@ export function formatWithdrawReply(input: { ].join('\n') } +export function formatRecurringDepositExecuted(input: { + amount: number + assetSymbol: string + cadence: string +}): string { + return [ + '✅ *Recurring deposit executed*', + `Amount: *${input.amount} ${input.assetSymbol}*`, + `Schedule: *${input.cadence}*`, + '_Your automatic deposit has been processed._', + ].join('\n') +} + +export function formatRecurringDepositFailed(input: { + amount: number + assetSymbol: string + reason: string +}): string { + return [ + '❌ *Recurring deposit failed*', + `Amount: *${input.amount} ${input.assetSymbol}*`, + `Reason: _${input.reason}_`, + '_Please check your wallet balance and try again later._', + ].join('\n') +} + const ALERT_METRIC_LABELS: Record = { PROTOCOL_APY: 'Protocol APY', PORTFOLIO_VALUE: 'Portfolio value', diff --git a/src/whatsapp/handler.ts b/src/whatsapp/handler.ts index e2a4e0a..f386c2c 100644 --- a/src/whatsapp/handler.ts +++ b/src/whatsapp/handler.ts @@ -11,6 +11,7 @@ import { getGoalStatus, decrementBalance, } from './userManager' +import db from '../db' import { createAlertRuleForWallet, listAlertRulesForWallet, @@ -55,6 +56,8 @@ function formatHelpMessage(): string { '- "deposit " → get deposit instructions', '- "withdraw " → withdraw funds (if available)', '- "earnings" → see your performance', + '- "set up recurring deposit 50 weekly" → start automatic deposits', + '- "pause recurring deposit" → pause your scheduled deposits', '- "alert me when Blend apy < 5" → create a price/yield alert', '- "list my alerts" → see your alert rules', '- "delete alert " → remove an alert rule', @@ -213,6 +216,89 @@ async function executeIntent( return { body: formatEarnings(summary) } } + case 'create_recurring_deposit': { + const amount = intent.amount + const cadence = intent.cadence + if (!amount || amount <= 0) { + return { + body: 'Please specify an amount, e.g. "recurring deposit 50 weekly".', + } + } + if (!cadence) { + return { + body: 'Please specify a schedule: weekly, biweekly, or monthly.', + } + } + const wallet = getUserWalletAddress(normalizedPhone) + if (!wallet) { + return { body: 'Your account is not fully set up yet.' } + } + const user = await db.user.findFirst({ + where: { walletAddress: wallet }, + select: { id: true }, + }) + if (!user) { + return { body: 'Your account is not fully set up yet.' } + } + const nextRunAt = new Date() + switch (cadence) { + case 'WEEKLY': + nextRunAt.setDate(nextRunAt.getDate() + 7) + break + case 'BIWEEKLY': + nextRunAt.setDate(nextRunAt.getDate() + 14) + break + case 'MONTHLY': + nextRunAt.setMonth(nextRunAt.getMonth() + 1) + break + } + const plan = await db.recurringDepositPlan.create({ + data: { + userId: user.id, + amount, + assetSymbol: 'USDC', + cadence, + nextRunAt, + }, + }) + return { + body: [ + '✅ *Recurring deposit created*', + `Amount: *${amount} USDC*`, + `Schedule: *${cadence}*`, + `First run: _${nextRunAt.toLocaleDateString()}_`, + '_Your deposits will run automatically on schedule._', + ].join('\n'), + } + } + + case 'pause_recurring_deposit': { + const wallet = getUserWalletAddress(normalizedPhone) + if (!wallet) { + return { body: 'Your account is not fully set up yet.' } + } + const user = await db.user.findFirst({ + where: { walletAddress: wallet }, + select: { id: true }, + }) + if (!user) { + return { body: 'Your account is not fully set up yet.' } + } + const activePlans = await db.recurringDepositPlan.findMany({ + where: { userId: user.id, status: 'ACTIVE' }, + }) + if (activePlans.length === 0) { + return { body: 'You have no active recurring deposits to pause.' } + } + await db.recurringDepositPlan.updateMany({ + where: { userId: user.id, status: 'ACTIVE' }, + data: { status: 'PAUSED' }, + }) + return { + body: `✅ *${activePlans.length} recurring deposit(s) paused*\nYou can resume them anytime with "resume recurring deposit".`, + } + } + case 'alert_create': { const walletAddress = getUserWalletAddress(normalizedPhone) if (!walletAddress) { diff --git a/tests/integration/recurring-deposits.integration.test.ts b/tests/integration/recurring-deposits.integration.test.ts new file mode 100644 index 0000000..7345fd3 --- /dev/null +++ b/tests/integration/recurring-deposits.integration.test.ts @@ -0,0 +1,235 @@ +// Recurring deposits integration test. Mounts the recurring deposit router on +// a minimal Express app with auth and DB mocked, so it verifies the HTTP wiring +// (validation, status codes, owner-scoping) without a live database. + +const mockUserId = '11111111-1111-4111-8111-111111111111' +const mockOtherUserId = '22222222-2222-4222-8222-222222222222' + +import request from 'supertest' +import express from 'express' + +// --- Auth: stub requireAuth/enforceUserAccess to inject a fixed identity ------ +jest.mock('../../src/middleware/authenticate', () => { + const requireAuth = jest.fn((req: any, _res: any, next: any) => { + req.userId = mockUserId + req.auth = { + userId: mockUserId, + walletAddress: 'GWALLET_USER_1', + network: 'TESTNET', + } + next() + }) + const enforceUserAccess = jest.fn((req: any, res: any, next: any) => { + const target = req.params.userId ?? req.body?.userId + if (target && target !== req.auth.userId) { + return res.status(401).json({ error: 'Unauthorized' }) + } + next() + }) + return { requireAuth, enforceUserAccess } +}) + +jest.mock('../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})) + +// --- DB: in-memory store for recurring deposit plans -------------------------- +const plans = new Map() +let planSeq = 0 + +jest.mock('../../src/db', () => ({ + __esModule: true, + default: { + recurringDepositPlan: { + create: jest.fn(async ({ data }: any) => { + const id = `plan-${++planSeq}` + const plan = { + ...data, + id, + createdAt: new Date(), + updatedAt: new Date(), + lastRunAt: null, + lastRunStatus: null, + status: data.status ?? 'ACTIVE', + } + plans.set(id, plan) + return plan + }), + findMany: jest.fn(async ({ where, orderBy }: any) => { + const result = [] + for (const p of plans.values()) { + if (where?.userId && p.userId !== where.userId) continue + if (where?.status && p.status !== where.status) continue + result.push(p) + } + if (orderBy?.createdAt === 'desc') result.reverse() + return result + }), + findUnique: jest.fn(async ({ where }: any) => { + return plans.get(where.id) ?? null + }), + update: jest.fn(async ({ where, data }: any) => { + const plan = plans.get(where.id) + if (!plan) throw new Error('Plan not found') + Object.assign(plan, data) + plan.updatedAt = new Date() + return plan + }), + }, + }, +})) + +import db from '../../src/db' +import recurringDepositRouter from '../../src/routes/recurring-deposits' + +function buildApp() { + const app = express() + app.use(express.json()) + app.use('/api/v1/deposit/recurring', recurringDepositRouter) + return app +} + +describe('E2E integration — recurring deposits', () => { + beforeEach(() => { + jest.clearAllMocks() + plans.clear() + planSeq = 0 + }) + + it('POST → creates plan, GET lists it, DELETE cancels it', async () => { + const app = buildApp() + + // Create + const createRes = await request(app) + .post('/api/v1/deposit/recurring') + .send({ + userId: mockUserId, + amount: 50, + assetSymbol: 'USDC', + cadence: 'WEEKLY', + confirmed: true, + }) + + expect(createRes.status).toBe(201) + expect(createRes.body.plan).toBeDefined() + expect(createRes.body.plan.userId).toBe(mockUserId) + expect(createRes.body.plan.amount).toBe(50) + expect(createRes.body.plan.cadence).toBe('WEEKLY') + expect(createRes.body.plan.status).toBe('ACTIVE') + expect(createRes.body.plan.nextRunAt).toBeDefined() + + const planId = createRes.body.plan.id + + // List + const listRes = await request(app).get( + `/api/v1/deposit/recurring/by-user/${mockUserId}` + ) + + expect(listRes.status).toBe(200) + expect(listRes.body.plans).toHaveLength(1) + expect(listRes.body.plans[0].id).toBe(planId) + + // Cancel + const deleteRes = await request(app).delete( + `/api/v1/deposit/recurring/${planId}` + ) + + expect(deleteRes.status).toBe(200) + expect(deleteRes.body.plan.status).toBe('CANCELLED') + }) + + it('PATCH — pauses and resumes a plan', async () => { + const app = buildApp() + + const createRes = await request(app) + .post('/api/v1/deposit/recurring') + .send({ + userId: mockUserId, + amount: 100, + assetSymbol: 'USDC', + cadence: 'MONTHLY', + confirmed: true, + }) + + const planId = createRes.body.plan.id + + // Pause + const pauseRes = await request(app) + .patch(`/api/v1/deposit/recurring/${planId}`) + .send({ status: 'PAUSED' }) + + expect(pauseRes.status).toBe(200) + expect(pauseRes.body.plan.status).toBe('PAUSED') + + // Resume + const resumeRes = await request(app) + .patch(`/api/v1/deposit/recurring/${planId}`) + .send({ status: 'ACTIVE' }) + + expect(resumeRes.status).toBe(200) + expect(resumeRes.body.plan.status).toBe('ACTIVE') + }) + + it('ownership check: user B cannot modify user A plan', async () => { + const app = buildApp() + + const createRes = await request(app) + .post('/api/v1/deposit/recurring') + .send({ + userId: mockUserId, + amount: 50, + assetSymbol: 'USDC', + cadence: 'WEEKLY', + confirmed: true, + }) + + const planId = createRes.body.plan.id + + // Switch identity to user B for subsequent requests + const authModule = require('../../src/middleware/authenticate') + authModule.requireAuth.mockImplementation( + (req: any, _res: any, next: any) => { + req.userId = mockOtherUserId + req.auth = { + userId: mockOtherUserId, + walletAddress: 'GWALLET_USER_2', + network: 'TESTNET', + } + next() + } + ) + + // User B tries to pause user A's plan + const patchRes = await request(app) + .patch(`/api/v1/deposit/recurring/${planId}`) + .send({ status: 'PAUSED' }) + + expect(patchRes.status).toBe(401) + + // User B tries to cancel user A's plan + const deleteRes = await request(app).delete( + `/api/v1/deposit/recurring/${planId}` + ) + + expect(deleteRes.status).toBe(401) + }) + + it('creation rejects when confirmed is not true', async () => { + const app = buildApp() + + const res = await request(app).post('/api/v1/deposit/recurring').send({ + userId: mockUserId, + amount: 50, + assetSymbol: 'USDC', + cadence: 'WEEKLY', + confirmed: false, + }) + + expect(res.status).toBe(400) + }) +}) diff --git a/tests/unit/jobs/recurring-deposits.test.ts b/tests/unit/jobs/recurring-deposits.test.ts new file mode 100644 index 0000000..44a0104 --- /dev/null +++ b/tests/unit/jobs/recurring-deposits.test.ts @@ -0,0 +1,50 @@ +jest.mock('../../../src/utils/logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + logBackgroundJob: jest.fn(), +})) + +import { addCadence } from '../../../src/utils/cadence' + +declare const describe: any +declare const it: any +declare const expect: any + +describe('recurringDeposits — addCadence', () => { + it('WEEKLY adds 7 days', () => { + const from = new Date('2026-01-01T00:00:00Z') + const result = addCadence('WEEKLY', from) + expect(result.toISOString()).toBe('2026-01-08T00:00:00.000Z') + }) + + it('BIWEEKLY adds 14 days', () => { + const from = new Date('2026-01-01T00:00:00Z') + const result = addCadence('BIWEEKLY', from) + expect(result.toISOString()).toBe('2026-01-15T00:00:00.000Z') + }) + + it('MONTHLY adds 1 calendar month', () => { + const from = new Date('2026-01-15T00:00:00Z') + const result = addCadence('MONTHLY', from) + expect(result.toISOString()).toBe('2026-02-15T00:00:00.000Z') + }) + + it('MONTHLY handles end-of-month overflow (Jan 31 → Mar 3, since Feb has no day 31)', () => { + const from = new Date('2026-01-31T00:00:00Z') + const result = addCadence('MONTHLY', from) + // setMonth(1) on day 31 overflows: Feb 31 → Mar 3 + expect(result.getMonth()).toBe(2) // March + expect(result.getDate()).toBe(3) + }) + + it('does not mutate the original date', () => { + const from = new Date('2026-06-01T12:00:00Z') + const original = from.toISOString() + addCadence('WEEKLY', from) + expect(from.toISOString()).toBe(original) + }) +}) diff --git a/tests/unit/validators/recurring-deposit-validators.test.ts b/tests/unit/validators/recurring-deposit-validators.test.ts new file mode 100644 index 0000000..ffdc543 --- /dev/null +++ b/tests/unit/validators/recurring-deposit-validators.test.ts @@ -0,0 +1,141 @@ +import { + createRecurringDepositSchema, + updateRecurringDepositSchema, +} from '../../../src/validators/recurring-deposit-validators' + +declare const describe: any +declare const it: any +declare const expect: any + +describe('recurring-deposit-validators', () => { + describe('createRecurringDepositSchema', () => { + const validBase = { + userId: '550e8400-e29b-41d4-a716-446655440000', + amount: 50, + assetSymbol: 'USDC', + cadence: 'WEEKLY', + } + + it('accepts a valid payload with confirmed: true', () => { + const result = createRecurringDepositSchema.safeParse({ + ...validBase, + confirmed: true, + }) + expect(result.success).toBe(true) + }) + + it('rejects when confirmed is missing', () => { + const result = createRecurringDepositSchema.safeParse(validBase) + expect(result.success).toBe(false) + }) + + it('rejects when confirmed is false', () => { + const result = createRecurringDepositSchema.safeParse({ + ...validBase, + confirmed: false, + }) + expect(result.success).toBe(false) + }) + + it('rejects negative amount', () => { + const result = createRecurringDepositSchema.safeParse({ + ...validBase, + amount: -10, + confirmed: true, + }) + expect(result.success).toBe(false) + }) + + it('rejects zero amount', () => { + const result = createRecurringDepositSchema.safeParse({ + ...validBase, + amount: 0, + confirmed: true, + }) + expect(result.success).toBe(false) + }) + + it('rejects invalid cadence', () => { + const result = createRecurringDepositSchema.safeParse({ + ...validBase, + cadence: 'DAILY', + confirmed: true, + }) + expect(result.success).toBe(false) + }) + + it('accepts BIWEEKLY cadence', () => { + const result = createRecurringDepositSchema.safeParse({ + ...validBase, + cadence: 'BIWEEKLY', + confirmed: true, + }) + expect(result.success).toBe(true) + }) + + it('accepts MONTHLY cadence', () => { + const result = createRecurringDepositSchema.safeParse({ + ...validBase, + cadence: 'MONTHLY', + confirmed: true, + }) + expect(result.success).toBe(true) + }) + + it('rejects invalid userId format', () => { + const result = createRecurringDepositSchema.safeParse({ + ...validBase, + userId: 'not-a-uuid', + confirmed: true, + }) + expect(result.success).toBe(false) + }) + + it('rejects empty assetSymbol', () => { + const result = createRecurringDepositSchema.safeParse({ + ...validBase, + assetSymbol: '', + confirmed: true, + }) + expect(result.success).toBe(false) + }) + }) + + describe('updateRecurringDepositSchema', () => { + it('accepts empty update (no changes)', () => { + const result = updateRecurringDepositSchema.safeParse({}) + expect(result.success).toBe(true) + }) + + it('accepts valid amount update', () => { + const result = updateRecurringDepositSchema.safeParse({ amount: 100 }) + expect(result.success).toBe(true) + }) + + it('accepts valid status update', () => { + const result = updateRecurringDepositSchema.safeParse({ + status: 'PAUSED', + }) + expect(result.success).toBe(true) + }) + + it('accepts valid cadence update', () => { + const result = updateRecurringDepositSchema.safeParse({ + cadence: 'MONTHLY', + }) + expect(result.success).toBe(true) + }) + + it('rejects negative amount', () => { + const result = updateRecurringDepositSchema.safeParse({ amount: -5 }) + expect(result.success).toBe(false) + }) + + it('rejects invalid status', () => { + const result = updateRecurringDepositSchema.safeParse({ + status: 'DELETED', + }) + expect(result.success).toBe(false) + }) + }) +})