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',
......
This diff is collapsed.
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;
This diff is collapsed.
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