Commit 00379526 authored by ThinhNC's avatar ThinhNC

feat(saving-goals): add saving goals and contribution management

parent 180233b3
......@@ -23,6 +23,10 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
`CUSTOM`, `WEEKLY`, `MONTHLY`, `YEARLY` và archive để giữ lịch sử. Mức sử dụng,
phần trăm cùng cảnh báo ngưỡng được tổng hợp trực tiếp từ Transaction `EXPENSE`
trong khoảng thời gian `[startDate, endDate)` khi đọc API.
- Saving Goal có trạng thái `ACTIVE`, `PAUSED`, `COMPLETED`, dùng archive để giữ lịch sử
và tổng hợp tiến độ từ Saving Contribution. Trạng thái hoàn thành được đồng bộ tự
động trong transaction Serializable khi contribution hoặc số tiền mục tiêu thay đổi;
contribution không tự động thay đổi số dư Wallet.
## Trạng thái đã biết
......@@ -37,7 +41,9 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
`20260728190000_add_category_management` đồng bộ thay đổi của Wallet và Category;
`20260728210000_add_transaction_management` đồng bộ Decimal, receipt/location và index
của Transaction; `20260729100000_add_budget_management` đồng bộ Decimal, loại,
chu kỳ, ngưỡng cảnh báo, archive và index của Budget.
chu kỳ, ngưỡng cảnh báo, archive và index của Budget;
`20260731100000_add_saving_goals_management` thêm Saving Goal, Saving Contribution,
lifecycle, constraint và index phục vụ theo dõi tiến độ.
Migration history cũ vẫn chưa phản ánh đầy đủ các thay đổi schema của auth đã
được commit trước đó.
......
-- Add saving goals and their contribution history.
CREATE TYPE "SavingGoalStatus" AS ENUM ('ACTIVE', 'PAUSED', 'COMPLETED');
CREATE TABLE "saving_goals" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"target_amount" DECIMAL(18, 2) NOT NULL,
"currency" VARCHAR(3) NOT NULL DEFAULT 'VND',
"target_date" TIMESTAMP(3) NOT NULL,
"description" TEXT,
"icon" TEXT,
"color" TEXT,
"status" "SavingGoalStatus" NOT NULL DEFAULT 'ACTIVE',
"completed_at" TIMESTAMP(3),
"is_archived" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "saving_goals_pkey" PRIMARY KEY ("id"),
CONSTRAINT "saving_goals_target_amount_positive_check"
CHECK ("target_amount" > 0),
CONSTRAINT "saving_goals_currency_format_check"
CHECK ("currency" ~ '^[A-Z]{3}$'),
CONSTRAINT "saving_goals_completion_check"
CHECK (
("status" = 'COMPLETED' AND "completed_at" IS NOT NULL)
OR ("status" <> 'COMPLETED' AND "completed_at" IS NULL)
)
);
CREATE TABLE "saving_contributions" (
"id" UUID NOT NULL,
"saving_goal_id" UUID NOT NULL,
"amount" DECIMAL(18, 2) NOT NULL,
"contributed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"note" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "saving_contributions_pkey" PRIMARY KEY ("id"),
CONSTRAINT "saving_contributions_amount_positive_check"
CHECK ("amount" > 0)
);
CREATE INDEX "saving_goals_user_id_is_archived_idx"
ON "saving_goals"("user_id", "is_archived");
CREATE INDEX "saving_goals_user_id_status_idx"
ON "saving_goals"("user_id", "status");
CREATE INDEX "saving_goals_user_id_target_date_idx"
ON "saving_goals"("user_id", "target_date");
CREATE INDEX "saving_contributions_saving_goal_id_contributed_at_idx"
ON "saving_contributions"("saving_goal_id", "contributed_at");
ALTER TABLE "saving_goals"
ADD CONSTRAINT "saving_goals_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "saving_contributions"
ADD CONSTRAINT "saving_contributions_saving_goal_id_fkey"
FOREIGN KEY ("saving_goal_id") REFERENCES "saving_goals"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
......@@ -24,6 +24,12 @@ enum BudgetPeriod {
YEARLY
}
enum SavingGoalStatus {
ACTIVE
PAUSED
COMPLETED
}
model User {
id String @id @default(uuid()) @db.Uuid
email String? @unique
......@@ -44,6 +50,7 @@ model User {
categories Category[]
transactions Transaction[]
budgets Budget[]
savingGoals SavingGoal[]
refreshTokens RefreshToken[]
socialAccounts UserSocial[]
verificationTokens VerificationToken[]
......@@ -164,6 +171,46 @@ model Budget {
@@map("budgets")
}
model SavingGoal {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
name String
targetAmount Decimal @map("target_amount") @db.Decimal(18, 2)
currency String @default("VND") @db.VarChar(3)
targetDate DateTime @map("target_date")
description String?
icon String?
color String?
status SavingGoalStatus @default(ACTIVE)
completedAt DateTime? @map("completed_at")
isArchived Boolean @default(false) @map("is_archived")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
contributions SavingContribution[]
@@index([userId, isArchived])
@@index([userId, status])
@@index([userId, targetDate])
@@map("saving_goals")
}
model SavingContribution {
id String @id @default(uuid()) @db.Uuid
savingGoalId String @map("saving_goal_id") @db.Uuid
amount Decimal @db.Decimal(18, 2)
contributedAt DateTime @default(now()) @map("contributed_at")
note String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
savingGoal SavingGoal @relation(fields: [savingGoalId], references: [id], onDelete: Cascade)
@@index([savingGoalId, contributedAt])
@@map("saving_contributions")
}
model RefreshToken {
id String @id @default(uuid()) @db.Uuid
token String @unique
......
......@@ -19,6 +19,10 @@ export const ERROR_CODE = {
TRANSACTION_CATEGORY_TYPE_MISMATCH: 'TRANSACTION_CATEGORY_TYPE_MISMATCH',
BUDGET_ARCHIVED: 'BUDGET_ARCHIVED',
BUDGET_CATEGORY_TYPE_INVALID: 'BUDGET_CATEGORY_TYPE_INVALID',
SAVING_GOAL_ARCHIVED: 'SAVING_GOAL_ARCHIVED',
SAVING_GOAL_PAUSED: 'SAVING_GOAL_PAUSED',
SAVING_GOAL_COMPLETED: 'SAVING_GOAL_COMPLETED',
SAVING_GOAL_CURRENCY_LOCKED: 'SAVING_GOAL_CURRENCY_LOCKED',
FILE_TYPE_UNSUPPORTED: 'FILE_TYPE_UNSUPPORTED',
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
RECEIPT_NOT_FOUND: 'RECEIPT_NOT_FOUND',
......
......@@ -14,7 +14,7 @@ export const swaggerSpec = {
info: {
title: 'FinWise API',
version: '1.0.0',
description: 'Backend API cho FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính (Zalo Mini App). API cung cấp xác thực, quản lý người dùng, ví, danh mục, giao dịch và ngân sách.',
description: 'Backend API cho FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính (Zalo Mini App). API cung cấp xác thực, quản lý người dùng, ví, danh mục, giao dịch, ngân sách và mục tiêu tiết kiệm.',
contact: { name: 'FinWise Team' },
},
servers: [
......@@ -640,6 +640,224 @@ export const swaggerSpec = {
},
],
},
SavingGoalProgress: {
type: 'object',
required: [
'savedAmount',
'remainingAmount',
'progressPercentage',
'contributionCount',
'daysRemaining',
'isOverdue',
],
properties: {
savedAmount: {
type: 'string',
pattern: '^\\d+(\\.\\d{2})$',
example: '5000000.00',
},
remainingAmount: {
type: 'string',
pattern: '^\\d+(\\.\\d{2})$',
example: '15000000.00',
},
progressPercentage: {
type: 'string',
pattern: '^\\d+(\\.\\d{2})$',
example: '25.00',
description: 'May exceed 100 when contributions exceed the target.',
},
contributionCount: { type: 'integer', example: 5 },
lastContributionAt: {
type: 'string',
format: 'date-time',
nullable: true,
},
daysRemaining: {
type: 'integer',
minimum: 0,
description: 'Whole calendar-day estimate, clamped to zero.',
},
isOverdue: { type: 'boolean' },
},
},
SavingGoal: {
type: 'object',
required: [
'id',
'name',
'targetAmount',
'currency',
'targetDate',
'status',
'isArchived',
'createdAt',
'updatedAt',
'progress',
],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', example: 'Buy a laptop' },
targetAmount: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
example: '20000000.00',
},
currency: { type: 'string', pattern: '^[A-Z]{3}$', example: 'VND' },
targetDate: { type: 'string', format: 'date-time' },
description: { type: 'string', nullable: true },
icon: { type: 'string', nullable: true },
color: { type: 'string', nullable: true, example: '#2563EB' },
status: {
type: 'string',
enum: ['ACTIVE', 'PAUSED', 'COMPLETED'],
description: 'COMPLETED is managed automatically from contribution totals.',
},
completedAt: {
type: 'string',
format: 'date-time',
nullable: true,
},
isArchived: { type: 'boolean' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
progress: { $ref: '#/components/schemas/SavingGoalProgress' },
},
},
CreateSavingGoalBody: {
type: 'object',
required: ['name', 'targetAmount', 'targetDate'],
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
targetAmount: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
example: '20000000.00',
},
currency: {
type: 'string',
pattern: '^[A-Za-z]{3}$',
default: 'VND',
},
targetDate: {
type: 'string',
format: 'date-time',
description: 'Must be in the future.',
},
description: { type: 'string', nullable: true, maxLength: 500 },
icon: { type: 'string', nullable: true, maxLength: 100 },
color: {
type: 'string',
nullable: true,
pattern: '^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$',
},
},
},
UpdateSavingGoalBody: {
type: 'object',
minProperties: 1,
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
targetAmount: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
},
currency: { type: 'string', pattern: '^[A-Za-z]{3}$' },
targetDate: {
type: 'string',
format: 'date-time',
description: 'Must be in the future.',
},
description: { type: 'string', nullable: true, maxLength: 500 },
icon: { type: 'string', nullable: true, maxLength: 100 },
color: {
type: 'string',
nullable: true,
pattern: '^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$',
},
status: {
type: 'string',
enum: ['ACTIVE', 'PAUSED'],
description: 'Clients cannot set COMPLETED directly.',
},
},
},
SavingContribution: {
type: 'object',
required: [
'id',
'savingGoalId',
'amount',
'contributedAt',
'createdAt',
'updatedAt',
],
properties: {
id: { type: 'string', format: 'uuid' },
savingGoalId: { type: 'string', format: 'uuid' },
amount: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
example: '1000000.00',
},
contributedAt: { type: 'string', format: 'date-time' },
note: { type: 'string', nullable: true, maxLength: 500 },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
},
CreateSavingContributionBody: {
type: 'object',
required: ['amount'],
properties: {
amount: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
example: '1000000.00',
},
contributedAt: {
type: 'string',
format: 'date-time',
description: 'Defaults to now and cannot be in the future.',
},
note: { type: 'string', nullable: true, maxLength: 500 },
},
},
UpdateSavingContributionBody: {
type: 'object',
minProperties: 1,
properties: {
amount: {
type: 'string',
pattern: '^(?:0|[1-9]\\d{0,15})(?:\\.\\d{1,2})?$',
},
contributedAt: {
type: 'string',
format: 'date-time',
description: 'Cannot be in the future.',
},
note: { type: 'string', nullable: true, maxLength: 500 },
},
},
SavingGoalResponse: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: { $ref: '#/components/schemas/SavingGoal' },
},
},
],
},
SavingContributionMutation: {
type: 'object',
required: ['contribution', 'goal'],
properties: {
contribution: { $ref: '#/components/schemas/SavingContribution' },
goal: { $ref: '#/components/schemas/SavingGoal' },
},
},
},
parameters: {
PageParam: { in: 'query', name: 'page', schema: { type: 'integer', default: 1 } },
......@@ -673,6 +891,20 @@ export const swaggerSpec = {
schema: { type: 'string', format: 'uuid' },
description: 'Budget ID',
},
SavingGoalIdParam: {
in: 'path',
name: 'id',
required: true,
schema: { type: 'string', format: 'uuid' },
description: 'Saving goal ID',
},
SavingContributionIdParam: {
in: 'path',
name: 'contributionId',
required: true,
schema: { type: 'string', format: 'uuid' },
description: 'Saving contribution ID',
},
},
responses: {
Unauthorized: { description: 'Chưa đăng nhập', content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } } },
......@@ -699,6 +931,10 @@ export const swaggerSpec = {
name: 'Budgets',
description: 'Authenticated budget limits, real-time usage, and threshold alerts',
},
{
name: 'Saving Goals',
description: 'Authenticated saving goals, progress, and contribution history',
},
],
paths: {
'/health': {
......@@ -2215,5 +2451,402 @@ export const swaggerSpec = {
},
},
},
'/saving-goals': {
get: {
tags: ['Saving Goals'],
summary: 'List saving goals with contribution progress',
security: [{ BearerAuth: [] }],
parameters: [
{
in: 'query',
name: 'search',
schema: { type: 'string', minLength: 1, maxLength: 200 },
},
{
in: 'query',
name: 'status',
schema: {
type: 'string',
enum: ['ACTIVE', 'PAUSED', 'COMPLETED'],
},
},
{
in: 'query',
name: 'dueFrom',
schema: { type: 'string', format: 'date-time' },
},
{
in: 'query',
name: 'dueTo',
schema: { type: 'string', format: 'date-time' },
},
{
in: 'query',
name: 'includeArchived',
schema: { type: 'string', enum: ['true', 'false'], default: 'false' },
},
{
in: 'query',
name: 'sortBy',
schema: {
type: 'string',
enum: [
'name',
'targetAmount',
'targetDate',
'createdAt',
'updatedAt',
],
default: 'targetDate',
},
},
{
in: 'query',
name: 'order',
schema: { type: 'string', enum: ['asc', 'desc'], default: 'asc' },
},
{ $ref: '#/components/parameters/PageParam' },
{ $ref: '#/components/parameters/LimitParam' },
],
responses: {
200: {
description: 'Paginated saving goals with aggregated progress',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/SavingGoal' },
},
meta: { $ref: '#/components/schemas/PaginationMeta' },
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
422: { $ref: '#/components/responses/Validation' },
},
},
post: {
tags: ['Saving Goals'],
summary: 'Create a saving goal',
description: 'Creates an ACTIVE goal. Contributions are tracked separately and do not change wallet balances.',
security: [{ BearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreateSavingGoalBody' },
},
},
},
responses: {
201: {
description: 'Saving goal created',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SavingGoalResponse' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/saving-goals/{id}': {
get: {
tags: ['Saving Goals'],
summary: 'Get a saving goal with progress',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/SavingGoalIdParam' }],
responses: {
200: {
description: 'Saving goal details',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SavingGoalResponse' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
put: {
tags: ['Saving Goals'],
summary: 'Update a saving goal',
description: 'Archived goals are read-only. COMPLETED is derived from total contributions; clients may only pause or resume a goal. Currency is locked after the first contribution.',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/SavingGoalIdParam' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/UpdateSavingGoalBody' },
},
},
},
responses: {
200: {
description: 'Saving goal updated',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SavingGoalResponse' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
409: { $ref: '#/components/responses/Conflict' },
422: { $ref: '#/components/responses/Validation' },
},
},
delete: {
tags: ['Saving Goals'],
summary: 'Archive a saving goal',
description: 'Keeps the goal and all contribution history for reporting.',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/SavingGoalIdParam' }],
responses: {
200: {
description: 'Saving goal archived',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SavingGoalResponse' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/saving-goals/{id}/restore': {
patch: {
tags: ['Saving Goals'],
summary: 'Restore an archived saving goal',
description: 'Recalculates completion status from the preserved contributions.',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/SavingGoalIdParam' }],
responses: {
200: {
description: 'Saving goal restored',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/SavingGoalResponse' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/saving-goals/{id}/contributions': {
get: {
tags: ['Saving Goals'],
summary: 'List contributions for a saving goal',
security: [{ BearerAuth: [] }],
parameters: [
{ $ref: '#/components/parameters/SavingGoalIdParam' },
{
in: 'query',
name: 'dateFrom',
schema: { type: 'string', format: 'date-time' },
},
{
in: 'query',
name: 'dateTo',
schema: { type: 'string', format: 'date-time' },
},
{
in: 'query',
name: 'sortBy',
schema: {
type: 'string',
enum: ['amount', 'contributedAt', 'createdAt', 'updatedAt'],
default: 'contributedAt',
},
},
{ $ref: '#/components/parameters/OrderParam' },
{ $ref: '#/components/parameters/PageParam' },
{ $ref: '#/components/parameters/LimitParam' },
],
responses: {
200: {
description: 'Paginated contribution history',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: {
type: 'array',
items: {
$ref: '#/components/schemas/SavingContribution',
},
},
meta: { $ref: '#/components/schemas/PaginationMeta' },
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
422: { $ref: '#/components/responses/Validation' },
},
},
post: {
tags: ['Saving Goals'],
summary: 'Add a contribution',
description: 'Allowed only for an active, non-archived, incomplete goal. Progress and completion status update atomically.',
security: [{ BearerAuth: [] }],
parameters: [{ $ref: '#/components/parameters/SavingGoalIdParam' }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/CreateSavingContributionBody',
},
},
},
},
responses: {
201: {
description: 'Contribution created with refreshed goal progress',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: {
$ref: '#/components/schemas/SavingContributionMutation',
},
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
409: { $ref: '#/components/responses/Conflict' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
'/saving-goals/{id}/contributions/{contributionId}': {
put: {
tags: ['Saving Goals'],
summary: 'Update a saving contribution',
description: 'Recalculates goal progress and completion status atomically.',
security: [{ BearerAuth: [] }],
parameters: [
{ $ref: '#/components/parameters/SavingGoalIdParam' },
{ $ref: '#/components/parameters/SavingContributionIdParam' },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/UpdateSavingContributionBody',
},
},
},
},
responses: {
200: {
description: 'Contribution and goal progress updated',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: {
$ref: '#/components/schemas/SavingContributionMutation',
},
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
409: { $ref: '#/components/responses/Conflict' },
422: { $ref: '#/components/responses/Validation' },
},
},
delete: {
tags: ['Saving Goals'],
summary: 'Delete a saving contribution',
description: 'Deletes a correction entry and recalculates goal progress atomically.',
security: [{ BearerAuth: [] }],
parameters: [
{ $ref: '#/components/parameters/SavingGoalIdParam' },
{ $ref: '#/components/parameters/SavingContributionIdParam' },
],
responses: {
200: {
description: 'Contribution deleted and goal progress refreshed',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
goal: {
$ref: '#/components/schemas/SavingGoal',
},
},
},
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { $ref: '#/components/responses/NotFound' },
409: { $ref: '#/components/responses/Conflict' },
422: { $ref: '#/components/responses/Validation' },
},
},
},
},
};
import { NextFunction, Request, Response } from 'express';
import {
CreateSavingContributionDto,
CreateSavingGoalDto,
SavingContributionQueryDto,
SavingGoalQueryDto,
UpdateSavingContributionDto,
UpdateSavingGoalDto,
} from './saving-goal.dto';
import { SavingGoalService } from './saving-goal.service';
export class SavingGoalController {
private readonly service = new SavingGoalService();
findAll = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findAll(
req.user.id,
req.query as unknown as SavingGoalQueryDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findById = async (req: Request, res: Response, next: NextFunction) => {
try {
const goal = await this.service.findById(req.user.id, req.params.id);
res.json({ success: true, data: goal });
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const goal = await this.service.create(
req.user.id,
req.body as CreateSavingGoalDto,
);
res.status(201).json({ success: true, data: goal });
} catch (error) {
next(error);
}
};
update = async (req: Request, res: Response, next: NextFunction) => {
try {
const goal = await this.service.update(
req.user.id,
req.params.id,
req.body as UpdateSavingGoalDto,
);
res.json({ success: true, data: goal });
} catch (error) {
next(error);
}
};
archive = async (req: Request, res: Response, next: NextFunction) => {
try {
const goal = await this.service.archive(req.user.id, req.params.id);
res.json({
success: true,
message: 'Saving goal archived successfully',
data: goal,
});
} catch (error) {
next(error);
}
};
restore = async (req: Request, res: Response, next: NextFunction) => {
try {
const goal = await this.service.restore(req.user.id, req.params.id);
res.json({ success: true, data: goal });
} catch (error) {
next(error);
}
};
findContributions = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const result = await this.service.findContributions(
req.user.id,
req.params.id,
req.query as unknown as SavingContributionQueryDto,
);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
createContribution = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const result = await this.service.createContribution(
req.user.id,
req.params.id,
req.body as CreateSavingContributionDto,
);
res.status(201).json({ success: true, data: result });
} catch (error) {
next(error);
}
};
updateContribution = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const result = await this.service.updateContribution(
req.user.id,
req.params.id,
req.params.contributionId,
req.body as UpdateSavingContributionDto,
);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
deleteContribution = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const result = await this.service.deleteContribution(
req.user.id,
req.params.id,
req.params.contributionId,
);
res.json({
success: true,
message: 'Saving contribution deleted successfully',
data: result,
});
} catch (error) {
next(error);
}
};
}
import { SavingGoalStatus } from '@prisma/client';
export type SavingGoalSortField =
'name' | 'targetAmount' | 'targetDate' | 'createdAt' | 'updatedAt';
export type SavingContributionSortField =
'amount' | 'contributedAt' | 'createdAt' | 'updatedAt';
export type SortOrder = 'asc' | 'desc';
export interface SavingGoalQueryDto {
search?: string;
status?: SavingGoalStatus;
dueFrom?: Date;
dueTo?: Date;
includeArchived: boolean;
sortBy: SavingGoalSortField;
order: SortOrder;
page: number;
limit: number;
}
export interface CreateSavingGoalDto {
name: string;
targetAmount: string;
currency: string;
targetDate: Date;
description?: string | null;
icon?: string | null;
color?: string | null;
}
export interface UpdateSavingGoalDto {
name?: string;
targetAmount?: string;
currency?: string;
targetDate?: Date;
description?: string | null;
icon?: string | null;
color?: string | null;
status?: Extract<SavingGoalStatus, 'ACTIVE' | 'PAUSED'>;
}
export interface SavingContributionQueryDto {
dateFrom?: Date;
dateTo?: Date;
sortBy: SavingContributionSortField;
order: SortOrder;
page: number;
limit: number;
}
export interface CreateSavingContributionDto {
amount: string;
contributedAt: Date;
note?: string | null;
}
export interface UpdateSavingContributionDto {
amount?: string;
contributedAt?: Date;
note?: string | null;
}
export interface SavingGoalProgressDto {
savedAmount: string;
remainingAmount: string;
progressPercentage: string;
contributionCount: number;
lastContributionAt: Date | null;
daysRemaining: number;
isOverdue: boolean;
}
export interface SavingGoalResponseDto {
id: string;
name: string;
targetAmount: string;
currency: string;
targetDate: Date;
description: string | null;
icon: string | null;
color: string | null;
status: SavingGoalStatus;
completedAt: Date | null;
isArchived: boolean;
createdAt: Date;
updatedAt: Date;
progress: SavingGoalProgressDto;
}
export interface SavingContributionResponseDto {
id: string;
savingGoalId: string;
amount: string;
contributedAt: Date;
note: string | null;
createdAt: Date;
updatedAt: Date;
}
import { Prisma, SavingGoalStatus } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import {
CreateSavingContributionDto,
CreateSavingGoalDto,
SavingContributionQueryDto,
SavingContributionResponseDto,
SavingGoalQueryDto,
UpdateSavingContributionDto,
UpdateSavingGoalDto,
} from './saving-goal.dto';
const savingGoalSelect = {
id: true,
name: true,
targetAmount: true,
currency: true,
targetDate: true,
description: true,
icon: true,
color: true,
status: true,
completedAt: true,
isArchived: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.SavingGoalSelect;
const savingContributionSelect = {
id: true,
savingGoalId: true,
amount: true,
contributedAt: true,
note: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.SavingContributionSelect;
export type SavingGoalRecord = Prisma.SavingGoalGetPayload<{
select: typeof savingGoalSelect;
}>;
type SavingContributionRecord = Prisma.SavingContributionGetPayload<{
select: typeof savingContributionSelect;
}>;
export type SavingGoalTransaction = Prisma.TransactionClient;
export interface SavingContributionSummary {
savingGoalId: string;
amount: Prisma.Decimal;
contributionCount: number;
lastContributionAt: Date | null;
}
type SavingGoalUpdateData = Omit<UpdateSavingGoalDto, 'status'> & {
status?: SavingGoalStatus;
completedAt?: Date | null;
};
function client(transaction?: SavingGoalTransaction) {
return transaction ?? prisma;
}
function toContributionResponse(
contribution: SavingContributionRecord,
): SavingContributionResponseDto {
return {
...contribution,
amount: contribution.amount.toFixed(2),
};
}
export class SavingGoalRepository {
async runSerializable<T>(
operation: (transaction: SavingGoalTransaction) => Promise<T>,
): Promise<T> {
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await prisma.$transaction(operation, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
});
} catch (error) {
const shouldRetry =
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2034' &&
attempt < maxAttempts;
if (!shouldRetry) {
throw error;
}
}
}
throw new Error('Serializable transaction retry limit reached');
}
async findAll(userId: string, query: SavingGoalQueryDto) {
const {
search,
status,
dueFrom,
dueTo,
includeArchived,
sortBy,
order,
page,
limit,
} = query;
const where: Prisma.SavingGoalWhereInput = {
userId,
...(status ? { status } : {}),
...(includeArchived ? {} : { isArchived: false }),
...(dueFrom || dueTo
? {
targetDate: {
...(dueFrom ? { gte: dueFrom } : {}),
...(dueTo ? { lte: dueTo } : {}),
},
}
: {}),
...(search
? {
OR: [
{
name: { contains: search, mode: Prisma.QueryMode.insensitive },
},
{
description: {
contains: search,
mode: Prisma.QueryMode.insensitive,
},
},
],
}
: {}),
};
const skip = (page - 1) * limit;
const [goals, total] = await prisma.$transaction([
prisma.savingGoal.findMany({
where,
select: savingGoalSelect,
orderBy: [{ [sortBy]: order }, { id: order }],
skip,
take: limit,
}),
prisma.savingGoal.count({ where }),
]);
return {
data: goals,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
findById(userId: string, id: string, transaction?: SavingGoalTransaction) {
return client(transaction).savingGoal.findFirst({
where: { id, userId },
select: savingGoalSelect,
});
}
create(userId: string, data: CreateSavingGoalDto) {
return prisma.savingGoal.create({
data: {
userId,
...data,
},
select: savingGoalSelect,
});
}
update(
id: string,
data: SavingGoalUpdateData,
transaction: SavingGoalTransaction,
) {
return transaction.savingGoal.update({
where: { id },
data,
select: savingGoalSelect,
});
}
archive(id: string, transaction: SavingGoalTransaction) {
return transaction.savingGoal.update({
where: { id },
data: { isArchived: true },
select: savingGoalSelect,
});
}
restore(
id: string,
status: SavingGoalStatus,
completedAt: Date | null,
transaction: SavingGoalTransaction,
) {
return transaction.savingGoal.update({
where: { id },
data: {
isArchived: false,
status,
completedAt,
},
select: savingGoalSelect,
});
}
async findSummaries(
savingGoalIds: string[],
transaction?: SavingGoalTransaction,
): Promise<SavingContributionSummary[]> {
if (savingGoalIds.length === 0) {
return [];
}
const summaries = await client(transaction).savingContribution.groupBy({
by: ['savingGoalId'],
where: { savingGoalId: { in: savingGoalIds } },
_sum: { amount: true },
_count: { _all: true },
_max: { contributedAt: true },
});
return summaries.map((summary) => ({
savingGoalId: summary.savingGoalId,
amount: summary._sum.amount ?? new Prisma.Decimal(0),
contributionCount: summary._count._all,
lastContributionAt: summary._max.contributedAt,
}));
}
async findSummary(
savingGoalId: string,
transaction?: SavingGoalTransaction,
): Promise<SavingContributionSummary> {
const result = await client(transaction).savingContribution.aggregate({
where: { savingGoalId },
_sum: { amount: true },
_count: { _all: true },
_max: { contributedAt: true },
});
return {
savingGoalId,
amount: result._sum.amount ?? new Prisma.Decimal(0),
contributionCount: result._count._all,
lastContributionAt: result._max.contributedAt,
};
}
async findContributions(
savingGoalId: string,
query: SavingContributionQueryDto,
) {
const { dateFrom, dateTo, sortBy, order, page, limit } = query;
const where: Prisma.SavingContributionWhereInput = {
savingGoalId,
...(dateFrom || dateTo
? {
contributedAt: {
...(dateFrom ? { gte: dateFrom } : {}),
...(dateTo ? { lte: dateTo } : {}),
},
}
: {}),
};
const skip = (page - 1) * limit;
const [contributions, total] = await prisma.$transaction([
prisma.savingContribution.findMany({
where,
select: savingContributionSelect,
orderBy: [{ [sortBy]: order }, { id: order }],
skip,
take: limit,
}),
prisma.savingContribution.count({ where }),
]);
return {
data: contributions.map(toContributionResponse),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
findContributionById(
savingGoalId: string,
id: string,
transaction: SavingGoalTransaction,
) {
return transaction.savingContribution.findFirst({
where: { id, savingGoalId },
select: savingContributionSelect,
});
}
async createContribution(
savingGoalId: string,
data: CreateSavingContributionDto,
transaction: SavingGoalTransaction,
) {
const contribution = await transaction.savingContribution.create({
data: {
savingGoalId,
...data,
},
select: savingContributionSelect,
});
return toContributionResponse(contribution);
}
async updateContribution(
id: string,
data: UpdateSavingContributionDto,
transaction: SavingGoalTransaction,
) {
const contribution = await transaction.savingContribution.update({
where: { id },
data,
select: savingContributionSelect,
});
return toContributionResponse(contribution);
}
deleteContribution(id: string, transaction: SavingGoalTransaction) {
return transaction.savingContribution.delete({
where: { id },
select: { id: true },
});
}
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { SavingGoalController } from './saving-goal.controller';
import {
createSavingContributionSchema,
createSavingGoalSchema,
findSavingContributionsSchema,
findSavingGoalsSchema,
savingContributionParamsSchema,
savingGoalParamsSchema,
updateSavingContributionSchema,
updateSavingGoalSchema,
} from './saving-goal.validation';
const router = Router();
const controller = new SavingGoalController();
router.use(authMiddleware);
router.get('/', validate(findSavingGoalsSchema, 'query'), controller.findAll);
router.post('/', validate(createSavingGoalSchema), controller.create);
router.get(
'/:id/contributions',
validate(savingGoalParamsSchema, 'params'),
validate(findSavingContributionsSchema, 'query'),
controller.findContributions,
);
router.post(
'/:id/contributions',
validate(savingGoalParamsSchema, 'params'),
validate(createSavingContributionSchema),
controller.createContribution,
);
router.put(
'/:id/contributions/:contributionId',
validate(savingContributionParamsSchema, 'params'),
validate(updateSavingContributionSchema),
controller.updateContribution,
);
router.delete(
'/:id/contributions/:contributionId',
validate(savingContributionParamsSchema, 'params'),
controller.deleteContribution,
);
router.get(
'/:id',
validate(savingGoalParamsSchema, 'params'),
controller.findById,
);
router.put(
'/:id',
validate(savingGoalParamsSchema, 'params'),
validate(updateSavingGoalSchema),
controller.update,
);
router.patch(
'/:id/restore',
validate(savingGoalParamsSchema, 'params'),
controller.restore,
);
router.delete(
'/:id',
validate(savingGoalParamsSchema, 'params'),
controller.archive,
);
export default router;
import { Prisma, SavingGoalStatus } from '@prisma/client';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import {
CreateSavingContributionDto,
CreateSavingGoalDto,
SavingContributionQueryDto,
SavingGoalQueryDto,
SavingGoalResponseDto,
UpdateSavingContributionDto,
UpdateSavingGoalDto,
} from './saving-goal.dto';
import {
SavingContributionSummary,
SavingGoalRecord,
SavingGoalRepository,
SavingGoalTransaction,
} from './saving-goal.repository';
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
export class SavingGoalService {
private readonly repository = new SavingGoalRepository();
async findAll(userId: string, query: SavingGoalQueryDto) {
const result = await this.repository.findAll(userId, query);
const summaries = await this.repository.findSummaries(
result.data.map((goal) => goal.id),
);
const summariesByGoal = new Map(
summaries.map((summary) => [summary.savingGoalId, summary]),
);
const data = result.data.map((goal) =>
this.toResponse(
goal,
summariesByGoal.get(goal.id) ?? this.emptySummary(goal.id),
),
);
return { data, meta: result.meta };
}
async findById(userId: string, id: string) {
const goal = await this.findRecord(userId, id);
const summary = await this.repository.findSummary(id);
return this.toResponse(goal, summary);
}
async create(userId: string, data: CreateSavingGoalDto) {
const goal = await this.repository.create(userId, data);
return this.toResponse(goal, this.emptySummary(goal.id));
}
update(userId: string, id: string, data: UpdateSavingGoalDto) {
return this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, id, transaction);
this.ensureMutable(current);
const summary = await this.repository.findSummary(id, transaction);
if (
data.currency &&
data.currency !== current.currency &&
summary.contributionCount > 0
) {
throw new AppError(
'Currency cannot be changed after contributions have been recorded',
409,
ERROR_CODE.SAVING_GOAL_CURRENCY_LOCKED,
);
}
const targetAmount = new Prisma.Decimal(
data.targetAmount ?? current.targetAmount,
);
const completion = this.resolveCompletion(
current,
summary,
targetAmount,
data.status,
);
const goal = await this.repository.update(
id,
{
...data,
...completion,
},
transaction,
);
return this.toResponse(goal, summary);
});
}
archive(userId: string, id: string) {
return this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, id, transaction);
const goal = current.isArchived
? current
: await this.repository.archive(id, transaction);
const summary = await this.repository.findSummary(id, transaction);
return this.toResponse(goal, summary);
});
}
restore(userId: string, id: string) {
return this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, id, transaction);
const summary = await this.repository.findSummary(id, transaction);
if (!current.isArchived) {
return this.toResponse(current, summary);
}
const completion = this.resolveCompletion(
current,
summary,
current.targetAmount,
);
const goal = await this.repository.restore(
id,
completion.status,
completion.completedAt,
transaction,
);
return this.toResponse(goal, summary);
});
}
async findContributions(
userId: string,
savingGoalId: string,
query: SavingContributionQueryDto,
) {
await this.findRecord(userId, savingGoalId);
return this.repository.findContributions(savingGoalId, query);
}
createContribution(
userId: string,
savingGoalId: string,
data: CreateSavingContributionDto,
) {
return this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, savingGoalId, transaction);
this.ensureCanContribute(current);
const contribution = await this.repository.createContribution(
savingGoalId,
data,
transaction,
);
const { goal, summary } = await this.reconcileProgress(
current,
transaction,
);
return {
contribution,
goal: this.toResponse(goal, summary),
};
});
}
updateContribution(
userId: string,
savingGoalId: string,
contributionId: string,
data: UpdateSavingContributionDto,
) {
return this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, savingGoalId, transaction);
this.ensureMutable(current);
await this.findContributionRecord(
savingGoalId,
contributionId,
transaction,
);
const contribution = await this.repository.updateContribution(
contributionId,
data,
transaction,
);
const { goal, summary } = await this.reconcileProgress(
current,
transaction,
);
return {
contribution,
goal: this.toResponse(goal, summary),
};
});
}
deleteContribution(
userId: string,
savingGoalId: string,
contributionId: string,
) {
return this.repository.runSerializable(async (transaction) => {
const current = await this.findRecord(userId, savingGoalId, transaction);
this.ensureMutable(current);
await this.findContributionRecord(
savingGoalId,
contributionId,
transaction,
);
const deleted = await this.repository.deleteContribution(
contributionId,
transaction,
);
const { goal, summary } = await this.reconcileProgress(
current,
transaction,
);
return {
id: deleted.id,
goal: this.toResponse(goal, summary),
};
});
}
private async findRecord(
userId: string,
id: string,
transaction?: SavingGoalTransaction,
) {
const goal = await this.repository.findById(userId, id, transaction);
if (!goal) {
throw new AppError('Saving goal not found', 404, ERROR_CODE.NOT_FOUND);
}
return goal;
}
private async findContributionRecord(
savingGoalId: string,
contributionId: string,
transaction: SavingGoalTransaction,
) {
const contribution = await this.repository.findContributionById(
savingGoalId,
contributionId,
transaction,
);
if (!contribution) {
throw new AppError(
'Saving contribution not found',
404,
ERROR_CODE.NOT_FOUND,
);
}
return contribution;
}
private ensureMutable(goal: SavingGoalRecord) {
if (goal.isArchived) {
throw new AppError(
'Restore the saving goal before modifying it',
409,
ERROR_CODE.SAVING_GOAL_ARCHIVED,
);
}
}
private ensureCanContribute(goal: SavingGoalRecord) {
this.ensureMutable(goal);
if (goal.status === SavingGoalStatus.PAUSED) {
throw new AppError(
'Resume the saving goal before adding contributions',
409,
ERROR_CODE.SAVING_GOAL_PAUSED,
);
}
if (goal.status === SavingGoalStatus.COMPLETED) {
throw new AppError(
'The saving goal is already completed',
409,
ERROR_CODE.SAVING_GOAL_COMPLETED,
);
}
}
private async reconcileProgress(
current: SavingGoalRecord,
transaction: SavingGoalTransaction,
) {
const summary = await this.repository.findSummary(current.id, transaction);
const completion = this.resolveCompletion(
current,
summary,
current.targetAmount,
);
const goal = await this.repository.update(
current.id,
completion,
transaction,
);
return { goal, summary };
}
private resolveCompletion(
current: SavingGoalRecord,
summary: SavingContributionSummary,
targetAmount: Prisma.Decimal,
requestedStatus?: Extract<SavingGoalStatus, 'ACTIVE' | 'PAUSED'>,
) {
if (summary.amount.greaterThanOrEqualTo(targetAmount)) {
return {
status: SavingGoalStatus.COMPLETED,
completedAt: current.completedAt ?? new Date(),
};
}
return {
status:
requestedStatus ??
(current.status === SavingGoalStatus.COMPLETED
? SavingGoalStatus.ACTIVE
: current.status),
completedAt: null,
};
}
private toResponse(
goal: SavingGoalRecord,
summary: SavingContributionSummary,
): SavingGoalResponseDto {
const remainingAmount = Prisma.Decimal.max(
goal.targetAmount.minus(summary.amount),
new Prisma.Decimal(0),
);
const progressPercentage = summary.amount
.dividedBy(goal.targetAmount)
.times(100);
const now = new Date();
return {
...goal,
targetAmount: goal.targetAmount.toFixed(2),
progress: {
savedAmount: summary.amount.toFixed(2),
remainingAmount: remainingAmount.toFixed(2),
progressPercentage: progressPercentage.toFixed(2),
contributionCount: summary.contributionCount,
lastContributionAt: summary.lastContributionAt,
daysRemaining: Math.max(
0,
Math.ceil(
(goal.targetDate.getTime() - now.getTime()) / MILLISECONDS_PER_DAY,
),
),
isOverdue:
goal.status !== SavingGoalStatus.COMPLETED && goal.targetDate < now,
},
};
}
private emptySummary(savingGoalId: string): SavingContributionSummary {
return {
savingGoalId,
amount: new Prisma.Decimal(0),
contributionCount: 0,
lastContributionAt: null,
};
}
}
import { Prisma } from '@prisma/client';
import { z } from 'zod';
const amountSchema = z
.string()
.trim()
.regex(
/^(?:0|[1-9]\d{0,15})(?:\.\d{1,2})?$/,
'Amount must be a positive decimal string with at most 16 integer digits and 2 decimal places',
)
.refine(
(value) => new Prisma.Decimal(value).greaterThan(0),
'Amount must be greater than zero',
);
const dateSchema = z
.string()
.datetime({
offset: true,
message: 'Date must be a valid ISO 8601 date-time',
})
.transform((value) => new Date(value));
const currencySchema = z
.string()
.trim()
.length(3, 'Currency must contain exactly 3 letters')
.regex(/^[A-Za-z]{3}$/, 'Currency must contain only letters')
.transform((value) => value.toUpperCase());
const nullableText = (max: number) =>
z.string().trim().max(max).nullable().optional();
export const savingGoalParamsSchema = z.object({
id: z.string().uuid('Invalid saving goal id'),
});
export const savingContributionParamsSchema = z.object({
id: z.string().uuid('Invalid saving goal id'),
contributionId: z.string().uuid('Invalid saving contribution id'),
});
export const findSavingGoalsSchema = z
.object({
search: z.string().trim().min(1).max(200).optional(),
status: z.enum(['ACTIVE', 'PAUSED', 'COMPLETED']).optional(),
dueFrom: dateSchema.optional(),
dueTo: dateSchema.optional(),
includeArchived: z
.enum(['true', 'false'])
.transform((value) => value === 'true')
.optional()
.default('false'),
sortBy: z
.enum(['name', 'targetAmount', 'targetDate', 'createdAt', 'updatedAt'])
.optional()
.default('targetDate'),
order: z.enum(['asc', 'desc']).optional().default('asc'),
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
})
.refine(
(data) => !data.dueFrom || !data.dueTo || data.dueTo >= data.dueFrom,
{ path: ['dueTo'], message: 'dueTo must be on or after dueFrom' },
);
export const createSavingGoalSchema = z
.object({
name: z.string().trim().min(1, 'Name is required').max(100),
targetAmount: amountSchema,
currency: currencySchema.optional().default('VND'),
targetDate: dateSchema,
description: nullableText(500),
icon: nullableText(100),
color: z
.string()
.trim()
.regex(
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,
'Color must be a valid hex color',
)
.nullable()
.optional(),
})
.refine((data) => data.targetDate > new Date(), {
path: ['targetDate'],
message: 'targetDate must be in the future',
});
export const updateSavingGoalSchema = z
.object({
name: z.string().trim().min(1, 'Name cannot be empty').max(100).optional(),
targetAmount: amountSchema.optional(),
currency: currencySchema.optional(),
targetDate: dateSchema.optional(),
description: nullableText(500),
icon: nullableText(100),
color: z
.string()
.trim()
.regex(
/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,
'Color must be a valid hex color',
)
.nullable()
.optional(),
status: z.enum(['ACTIVE', 'PAUSED']).optional(),
})
.refine((data) => Object.keys(data).length > 0, {
message: 'At least one field is required',
})
.refine((data) => !data.targetDate || data.targetDate > new Date(), {
path: ['targetDate'],
message: 'targetDate must be in the future',
});
export const findSavingContributionsSchema = z
.object({
dateFrom: dateSchema.optional(),
dateTo: dateSchema.optional(),
sortBy: z
.enum(['amount', 'contributedAt', 'createdAt', 'updatedAt'])
.optional()
.default('contributedAt'),
order: z.enum(['asc', 'desc']).optional().default('desc'),
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
})
.refine(
(data) => !data.dateFrom || !data.dateTo || data.dateTo >= data.dateFrom,
{ path: ['dateTo'], message: 'dateTo must be on or after dateFrom' },
);
export const createSavingContributionSchema = z
.object({
amount: amountSchema,
contributedAt: dateSchema.optional(),
note: nullableText(500),
})
.transform((data) => ({
...data,
contributedAt: data.contributedAt ?? new Date(),
}))
.refine((data) => data.contributedAt <= new Date(), {
path: ['contributedAt'],
message: 'contributedAt cannot be in the future',
});
export const updateSavingContributionSchema = z
.object({
amount: amountSchema.optional(),
contributedAt: dateSchema.optional(),
note: nullableText(500),
})
.refine((data) => Object.keys(data).length > 0, {
message: 'At least one field is required',
})
.refine((data) => !data.contributedAt || data.contributedAt <= new Date(), {
path: ['contributedAt'],
message: 'contributedAt cannot be in the future',
});
......@@ -5,6 +5,7 @@ import walletRoute from '../modules/wallets/wallet.route';
import categoryRoute from '../modules/categories/category.route';
import transactionRoute from '../modules/transactions/transaction.route';
import budgetRoute from '../modules/budgets/budget.route';
import savingGoalRoute from '../modules/saving-goals/saving-goal.route';
const router = Router();
......@@ -18,5 +19,6 @@ router.use('/wallets', walletRoute);
router.use('/categories', categoryRoute);
router.use('/transactions', transactionRoute);
router.use('/budgets', budgetRoute);
router.use('/saving-goals', savingGoalRoute);
export default router;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment