first commit
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { env } from '../../config/env.js';
|
||||
import { logger } from '../../config/logger.js';
|
||||
import {
|
||||
listTransactions,
|
||||
createTransaction,
|
||||
getTransactionById,
|
||||
updateTransaction,
|
||||
deleteTransaction,
|
||||
ingestNotification
|
||||
} from './transactions.service.js';
|
||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||
|
||||
export const listTransactionsHandler = async (_req: Request, res: Response) => {
|
||||
const data = await listTransactions();
|
||||
return res.json({ data });
|
||||
};
|
||||
|
||||
export const createTransactionHandler = async (req: Request, res: Response) => {
|
||||
const payload = req.body as CreateTransactionInput;
|
||||
const transaction = await createTransaction(payload);
|
||||
return res.status(201).json({ data: transaction });
|
||||
};
|
||||
|
||||
export const getTransactionHandler = async (req: Request, res: Response) => {
|
||||
const transaction = await getTransactionById(req.params.id);
|
||||
if (!transaction) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
return res.json({ data: transaction });
|
||||
};
|
||||
|
||||
export const updateTransactionHandler = async (req: Request, res: Response) => {
|
||||
const payload = req.body as UpdateTransactionInput;
|
||||
const transaction = await updateTransaction(req.params.id, payload);
|
||||
if (!transaction) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
return res.json({ data: transaction });
|
||||
};
|
||||
|
||||
export const deleteTransactionHandler = async (req: Request, res: Response) => {
|
||||
const removed = await deleteTransaction(req.params.id);
|
||||
if (!removed) {
|
||||
return res.status(404).json({ message: 'Transaction not found' });
|
||||
}
|
||||
return res.status(204).send();
|
||||
};
|
||||
|
||||
const verifySignature = (signature: string, parts: string[]): boolean => {
|
||||
if (!env.NOTIFICATION_WEBHOOK_SECRET) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = parts.join('|');
|
||||
const computed = createHmac('sha256', env.NOTIFICATION_WEBHOOK_SECRET).update(payload).digest('hex');
|
||||
return computed === signature;
|
||||
};
|
||||
|
||||
export const createTransactionFromNotificationHandler = async (req: Request, res: Response) => {
|
||||
const { packageName, title, body, receivedAt, signature } = req.body as {
|
||||
packageName: string;
|
||||
title: string;
|
||||
body: string;
|
||||
receivedAt: Date;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
const payloadParts = [packageName, title, body, new Date(receivedAt).getTime().toString()];
|
||||
if (!verifySignature(signature, payloadParts)) {
|
||||
logger.warn({ packageName }, 'Notification signature mismatch');
|
||||
return res.status(401).json({ message: 'Invalid notification signature' });
|
||||
}
|
||||
|
||||
const transaction = await ingestNotification({
|
||||
packageName,
|
||||
title,
|
||||
body,
|
||||
receivedAt: new Date(receivedAt)
|
||||
});
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Notification ingested and awaiting confirmation',
|
||||
data: transaction
|
||||
});
|
||||
};
|
||||
27
apps/backend/src/modules/transactions/transactions.router.ts
Normal file
27
apps/backend/src/modules/transactions/transactions.router.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express';
|
||||
import { validateRequest } from '../../middlewares/validate-request.js';
|
||||
import {
|
||||
createTransactionSchema,
|
||||
updateTransactionSchema,
|
||||
transactionIdSchema,
|
||||
notificationSchema
|
||||
} from './transactions.schema.js';
|
||||
import {
|
||||
listTransactionsHandler,
|
||||
createTransactionHandler,
|
||||
getTransactionHandler,
|
||||
updateTransactionHandler,
|
||||
deleteTransactionHandler,
|
||||
createTransactionFromNotificationHandler
|
||||
} from './transactions.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', listTransactionsHandler);
|
||||
router.post('/', validateRequest({ body: createTransactionSchema }), createTransactionHandler);
|
||||
router.get('/:id', validateRequest({ params: transactionIdSchema }), getTransactionHandler);
|
||||
router.patch('/:id', validateRequest({ params: transactionIdSchema, body: updateTransactionSchema }), updateTransactionHandler);
|
||||
router.delete('/:id', validateRequest({ params: transactionIdSchema }), deleteTransactionHandler);
|
||||
router.post('/notification', validateRequest({ body: notificationSchema }), createTransactionFromNotificationHandler);
|
||||
|
||||
export default router;
|
||||
35
apps/backend/src/modules/transactions/transactions.schema.ts
Normal file
35
apps/backend/src/modules/transactions/transactions.schema.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const baseTransactionSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
amount: z.coerce.number().min(0),
|
||||
currency: z.string().length(3).default('CNY'),
|
||||
category: z.string().min(1),
|
||||
type: z.enum(['expense', 'income']),
|
||||
source: z.enum(['manual', 'notification', 'ocr', 'ai']).default('manual'),
|
||||
status: z.enum(['pending', 'confirmed', 'rejected']).default('confirmed'),
|
||||
occurredAt: z.coerce.date(),
|
||||
notes: z.string().max(300).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
userId: z.string().optional()
|
||||
});
|
||||
|
||||
export const createTransactionSchema = baseTransactionSchema;
|
||||
|
||||
export const updateTransactionSchema = baseTransactionSchema.partial();
|
||||
|
||||
export const transactionIdSchema = z.object({
|
||||
id: z.string().min(1)
|
||||
});
|
||||
|
||||
export const notificationSchema = z.object({
|
||||
packageName: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
body: z.string().min(1),
|
||||
receivedAt: z.coerce.date(),
|
||||
signature: z.string().min(32),
|
||||
extras: z.record(z.any()).optional()
|
||||
});
|
||||
|
||||
export type CreateTransactionInput = z.infer<typeof createTransactionSchema>;
|
||||
export type UpdateTransactionInput = z.infer<typeof updateTransactionSchema>;
|
||||
240
apps/backend/src/modules/transactions/transactions.service.ts
Normal file
240
apps/backend/src/modules/transactions/transactions.service.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { TransactionModel, type TransactionDocument } from '../../models/transaction.model.js';
|
||||
import { createId } from '../../utils/id.js';
|
||||
import { logger } from '../../config/logger.js';
|
||||
import { getDefaultUserId } from '../../services/user-context.js';
|
||||
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
|
||||
|
||||
export interface TransactionDto {
|
||||
id: string;
|
||||
title: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
category: string;
|
||||
type: 'expense' | 'income';
|
||||
source: 'manual' | 'notification' | 'ocr' | 'ai';
|
||||
status: 'pending' | 'confirmed' | 'rejected';
|
||||
occurredAt: string;
|
||||
notes?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
userId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface MemoryTransaction extends TransactionDto {}
|
||||
|
||||
const memoryTransactions: MemoryTransaction[] = [];
|
||||
|
||||
const isDatabaseReady = () => mongoose.connection.readyState === 1;
|
||||
|
||||
const toDto = (doc: TransactionDocument & { _id?: mongoose.Types.ObjectId } & { id?: string }): TransactionDto => {
|
||||
const rawId = typeof doc.id === 'string' ? doc.id : doc._id ? doc._id.toString() : createId();
|
||||
|
||||
return {
|
||||
id: rawId,
|
||||
title: doc.title,
|
||||
amount: doc.amount,
|
||||
currency: doc.currency ?? 'CNY',
|
||||
category: doc.category,
|
||||
type: doc.type,
|
||||
source: doc.source,
|
||||
status: doc.status,
|
||||
occurredAt: new Date(doc.occurredAt).toISOString(),
|
||||
notes: doc.notes,
|
||||
metadata: doc.metadata ?? undefined,
|
||||
userId: doc.userId ? doc.userId.toString() : undefined,
|
||||
createdAt: new Date(doc.createdAt ?? Date.now()).toISOString(),
|
||||
updatedAt: new Date(doc.updatedAt ?? Date.now()).toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
const seedMemoryTransactions = () => {
|
||||
if (memoryTransactions.length > 0) return;
|
||||
const now = new Date();
|
||||
memoryTransactions.push(
|
||||
{
|
||||
id: createId(),
|
||||
title: '星巴克咖啡',
|
||||
amount: 32.5,
|
||||
currency: 'CNY',
|
||||
category: '报销',
|
||||
type: 'expense',
|
||||
source: 'notification',
|
||||
status: 'confirmed',
|
||||
occurredAt: now.toISOString(),
|
||||
notes: '餐饮 | 早餐',
|
||||
metadata: { packageName: 'com.eg.android.AlipayGphone' },
|
||||
userId: 'demo-user',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
title: '地铁充值',
|
||||
amount: 100,
|
||||
currency: 'CNY',
|
||||
category: '交通',
|
||||
type: 'expense',
|
||||
source: 'manual',
|
||||
status: 'confirmed',
|
||||
occurredAt: new Date(now.getTime() - 86_400_000).toISOString(),
|
||||
userId: 'demo-user',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
title: '午餐报销',
|
||||
amount: 58,
|
||||
currency: 'CNY',
|
||||
category: '餐饮',
|
||||
type: 'income',
|
||||
source: 'ocr',
|
||||
status: 'pending',
|
||||
occurredAt: new Date(now.getTime() - 172_800_000).toISOString(),
|
||||
notes: '审批中',
|
||||
userId: 'demo-user',
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
seedMemoryTransactions();
|
||||
|
||||
export async function listTransactions(): Promise<TransactionDto[]> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const docs = await TransactionModel.find({ userId })
|
||||
.sort({ occurredAt: -1 })
|
||||
.lean()
|
||||
.exec();
|
||||
return (docs as Array<TransactionDocument & { _id: mongoose.Types.ObjectId }>).map(toDto);
|
||||
}
|
||||
return memoryTransactions.slice().sort((a, b) => (a.occurredAt < b.occurredAt ? 1 : -1));
|
||||
}
|
||||
|
||||
export async function getTransactionById(id: string): Promise<TransactionDto | null> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const doc = await TransactionModel.findOne({ _id: id, userId }).lean().exec();
|
||||
return doc ? toDto(doc as TransactionDocument & { _id: mongoose.Types.ObjectId }) : null;
|
||||
}
|
||||
return memoryTransactions.find((txn) => txn.id === id) ?? null;
|
||||
}
|
||||
|
||||
export async function createTransaction(payload: CreateTransactionInput): Promise<TransactionDto> {
|
||||
const normalizedAmount = Math.abs(payload.amount);
|
||||
const occuredDate = payload.occurredAt instanceof Date ? payload.occurredAt : new Date(payload.occurredAt);
|
||||
|
||||
if (isDatabaseReady()) {
|
||||
const userId = payload.userId ?? (await getDefaultUserId());
|
||||
const created = await TransactionModel.create({
|
||||
...payload,
|
||||
source: payload.source ?? 'manual',
|
||||
currency: payload.currency ?? 'CNY',
|
||||
amount: normalizedAmount,
|
||||
occurredAt: occuredDate,
|
||||
userId
|
||||
});
|
||||
return toDto(created);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const transaction: MemoryTransaction = {
|
||||
id: createId(),
|
||||
title: payload.title,
|
||||
amount: normalizedAmount,
|
||||
currency: payload.currency ?? 'CNY',
|
||||
category: payload.category,
|
||||
type: payload.type,
|
||||
source: payload.source ?? 'manual',
|
||||
status: payload.status,
|
||||
occurredAt: occuredDate.toISOString(),
|
||||
notes: payload.notes,
|
||||
metadata: payload.metadata,
|
||||
userId: payload.userId,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString()
|
||||
};
|
||||
|
||||
memoryTransactions.unshift(transaction);
|
||||
logger.debug({ id: transaction.id }, 'Transaction created (memory)');
|
||||
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export async function updateTransaction(id: string, payload: UpdateTransactionInput): Promise<TransactionDto | null> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const updated = await TransactionModel.findOneAndUpdate(
|
||||
{ _id: id, userId },
|
||||
{
|
||||
...payload,
|
||||
currency: payload.currency ?? undefined,
|
||||
source: payload.source ?? undefined,
|
||||
amount: payload.amount !== undefined ? Math.abs(payload.amount) : undefined,
|
||||
occurredAt: payload.occurredAt ? new Date(payload.occurredAt) : undefined
|
||||
},
|
||||
{ new: true }
|
||||
);
|
||||
return updated ? toDto(updated) : null;
|
||||
}
|
||||
|
||||
const index = memoryTransactions.findIndex((txn) => txn.id === id);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existing = memoryTransactions[index];
|
||||
const updated: MemoryTransaction = {
|
||||
...existing,
|
||||
...payload,
|
||||
amount: payload.amount !== undefined ? Math.abs(payload.amount) : existing.amount,
|
||||
occurredAt: payload.occurredAt ? new Date(payload.occurredAt).toISOString() : existing.occurredAt,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
memoryTransactions[index] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteTransaction(id: string): Promise<boolean> {
|
||||
if (isDatabaseReady()) {
|
||||
const userId = await getDefaultUserId();
|
||||
const result = await TransactionModel.findOneAndDelete({ _id: id, userId });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
const index = memoryTransactions.findIndex((txn) => txn.id === id);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
memoryTransactions.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
interface NotificationPayload {
|
||||
packageName: string;
|
||||
title: string;
|
||||
body: string;
|
||||
receivedAt: Date;
|
||||
}
|
||||
|
||||
export async function ingestNotification(payload: NotificationPayload): Promise<TransactionDto> {
|
||||
const now = payload.receivedAt;
|
||||
return createTransaction({
|
||||
title: payload.title,
|
||||
amount: 0,
|
||||
currency: 'CNY',
|
||||
category: '待分类',
|
||||
type: 'expense',
|
||||
source: 'notification',
|
||||
status: 'pending',
|
||||
occurredAt: now,
|
||||
notes: payload.body,
|
||||
metadata: { packageName: payload.packageName }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user