415 lines
14 KiB
JavaScript
415 lines
14 KiB
JavaScript
import { writeFileSync } from "fs";
|
|
|
|
// ─── rules.service.ts (Prisma) ───────────────────────────────────────────────
|
|
writeFileSync("src/rules/rules.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common";
|
|
import { Prisma } from "@prisma/client";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
|
|
@Injectable()
|
|
export class RulesService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async list(userId: string) {
|
|
return this.prisma.rule.findMany({
|
|
where: { userId, isActive: true },
|
|
orderBy: { priority: "asc" },
|
|
});
|
|
}
|
|
|
|
async create(
|
|
userId: string,
|
|
payload: {
|
|
name: string;
|
|
priority?: number;
|
|
conditions: Record<string, unknown>;
|
|
actions: Record<string, unknown>;
|
|
isActive?: boolean;
|
|
},
|
|
) {
|
|
const count = await this.prisma.rule.count({ where: { userId } });
|
|
return this.prisma.rule.create({
|
|
data: {
|
|
userId,
|
|
name: payload.name ?? "Untitled rule",
|
|
priority: payload.priority ?? count + 1,
|
|
conditions: payload.conditions as Prisma.InputJsonValue,
|
|
actions: payload.actions as Prisma.InputJsonValue,
|
|
isActive: payload.isActive !== false,
|
|
},
|
|
});
|
|
}
|
|
|
|
async update(
|
|
userId: string,
|
|
id: string,
|
|
payload: {
|
|
name?: string;
|
|
priority?: number;
|
|
conditions?: Record<string, unknown>;
|
|
actions?: Record<string, unknown>;
|
|
isActive?: boolean;
|
|
},
|
|
) {
|
|
const existing = await this.prisma.rule.findFirst({ where: { id, userId } });
|
|
if (!existing) throw new BadRequestException("Rule not found.");
|
|
return this.prisma.rule.update({
|
|
where: { id },
|
|
data: {
|
|
...(payload.name !== undefined && { name: payload.name }),
|
|
...(payload.priority !== undefined && { priority: payload.priority }),
|
|
...(payload.conditions !== undefined && { conditions: payload.conditions as Prisma.InputJsonValue }),
|
|
...(payload.actions !== undefined && { actions: payload.actions as Prisma.InputJsonValue }),
|
|
...(payload.isActive !== undefined && { isActive: payload.isActive }),
|
|
},
|
|
});
|
|
}
|
|
|
|
private matchesRule(
|
|
conditions: Record<string, unknown>,
|
|
tx: { description: string; amount: number | string },
|
|
): boolean {
|
|
const textContains = typeof conditions.textContains === "string" ? conditions.textContains : "";
|
|
const amountGt = typeof conditions.amountGreaterThan === "number" ? conditions.amountGreaterThan : null;
|
|
const amountLt = typeof conditions.amountLessThan === "number" ? conditions.amountLessThan : null;
|
|
|
|
if (textContains && !tx.description.toLowerCase().includes(textContains.toLowerCase())) {
|
|
return false;
|
|
}
|
|
const amount = Number(tx.amount);
|
|
if (amountGt !== null && amount <= amountGt) return false;
|
|
if (amountLt !== null && amount >= amountLt) return false;
|
|
return true;
|
|
}
|
|
|
|
async execute(userId: string, id: string) {
|
|
const rule = await this.prisma.rule.findFirst({ where: { id, userId } });
|
|
if (!rule || !rule.isActive) return { id, status: "skipped" };
|
|
|
|
const conditions = rule.conditions as Record<string, unknown>;
|
|
const actions = rule.actions as Record<string, unknown>;
|
|
|
|
const transactions = await this.prisma.transactionRaw.findMany({
|
|
where: { account: { userId } },
|
|
include: { derived: true },
|
|
});
|
|
|
|
let applied = 0;
|
|
for (const tx of transactions) {
|
|
if (!this.matchesRule(conditions, { description: tx.description, amount: tx.amount })) {
|
|
continue;
|
|
}
|
|
|
|
await this.prisma.transactionDerived.upsert({
|
|
where: { rawTransactionId: tx.id },
|
|
update: {
|
|
userCategory: typeof actions.setCategory === "string" ? actions.setCategory : tx.derived?.userCategory ?? null,
|
|
isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : tx.derived?.isHidden ?? false,
|
|
modifiedAt: new Date(),
|
|
modifiedBy: "rule",
|
|
},
|
|
create: {
|
|
rawTransactionId: tx.id,
|
|
userCategory: typeof actions.setCategory === "string" ? actions.setCategory : null,
|
|
isHidden: typeof actions.setHidden === "boolean" ? actions.setHidden : false,
|
|
modifiedAt: new Date(),
|
|
modifiedBy: "rule",
|
|
},
|
|
});
|
|
|
|
await this.prisma.ruleExecution.create({
|
|
data: {
|
|
ruleId: rule.id,
|
|
transactionId: tx.id,
|
|
result: { applied: true } as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
|
|
applied += 1;
|
|
}
|
|
|
|
return { id: rule.id, status: "completed", applied };
|
|
}
|
|
|
|
async suggest(userId: string) {
|
|
const derived = await this.prisma.transactionDerived.findMany({
|
|
where: {
|
|
raw: { account: { userId } },
|
|
userCategory: { not: null },
|
|
},
|
|
include: { raw: { select: { description: true } } },
|
|
take: 200,
|
|
});
|
|
|
|
const bucket = new Map<string, { category: string; count: number }>();
|
|
for (const item of derived) {
|
|
const key = item.raw.description.toLowerCase();
|
|
const category = item.userCategory ?? "Uncategorized";
|
|
const entry = bucket.get(key) ?? { category, count: 0 };
|
|
entry.count += 1;
|
|
bucket.set(key, entry);
|
|
}
|
|
|
|
return Array.from(bucket.entries())
|
|
.filter(([, value]) => value.count >= 2)
|
|
.slice(0, 5)
|
|
.map(([description, value], index) => ({
|
|
id: \`suggestion_\${index + 1}\`,
|
|
name: \`Auto: \${value.category}\`,
|
|
conditions: { textContains: description },
|
|
actions: { setCategory: value.category },
|
|
confidence: Math.min(0.95, 0.5 + value.count * 0.1),
|
|
}));
|
|
}
|
|
}
|
|
`);
|
|
|
|
// ─── rules.controller.ts ─────────────────────────────────────────────────────
|
|
writeFileSync("src/rules/rules.controller.ts", `import { Body, Controller, Get, Param, Post, Put } from "@nestjs/common";
|
|
import { ok } from "../common/response";
|
|
import { RulesService } from "./rules.service";
|
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
|
|
|
@Controller("rules")
|
|
export class RulesController {
|
|
constructor(private readonly rulesService: RulesService) {}
|
|
|
|
@Get()
|
|
async list(@CurrentUser() userId: string) {
|
|
const data = await this.rulesService.list(userId);
|
|
return ok(data);
|
|
}
|
|
|
|
@Post()
|
|
async create(
|
|
@CurrentUser() userId: string,
|
|
@Body()
|
|
payload: {
|
|
name: string;
|
|
priority?: number;
|
|
conditions: Record<string, unknown>;
|
|
actions: Record<string, unknown>;
|
|
isActive?: boolean;
|
|
},
|
|
) {
|
|
const data = await this.rulesService.create(userId, payload);
|
|
return ok(data);
|
|
}
|
|
|
|
@Put(":id")
|
|
async update(
|
|
@CurrentUser() userId: string,
|
|
@Param("id") id: string,
|
|
@Body()
|
|
payload: {
|
|
name?: string;
|
|
priority?: number;
|
|
conditions?: Record<string, unknown>;
|
|
actions?: Record<string, unknown>;
|
|
isActive?: boolean;
|
|
},
|
|
) {
|
|
const data = await this.rulesService.update(userId, id, payload);
|
|
return ok(data);
|
|
}
|
|
|
|
@Post(":id/execute")
|
|
async execute(@CurrentUser() userId: string, @Param("id") id: string) {
|
|
const data = await this.rulesService.execute(userId, id);
|
|
return ok(data);
|
|
}
|
|
|
|
@Get("suggestions")
|
|
async suggestions(@CurrentUser() userId: string) {
|
|
const data = await this.rulesService.suggest(userId);
|
|
return ok(data);
|
|
}
|
|
}
|
|
`);
|
|
|
|
// ─── rules.module.ts ─────────────────────────────────────────────────────────
|
|
writeFileSync("src/rules/rules.module.ts", `import { Module } from "@nestjs/common";
|
|
import { RulesController } from "./rules.controller";
|
|
import { RulesService } from "./rules.service";
|
|
|
|
@Module({
|
|
controllers: [RulesController],
|
|
providers: [RulesService],
|
|
})
|
|
export class RulesModule {}
|
|
`);
|
|
|
|
// ─── tax.service.ts (Prisma) ─────────────────────────────────────────────────
|
|
writeFileSync("src/tax/tax.service.ts", `import { BadRequestException, Injectable } from "@nestjs/common";
|
|
import { Prisma } from "@prisma/client";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { CreateTaxReturnDto } from "./dto/create-return.dto";
|
|
import { UpdateTaxReturnDto } from "./dto/update-return.dto";
|
|
|
|
@Injectable()
|
|
export class TaxService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async listReturns(userId: string) {
|
|
return this.prisma.taxReturn.findMany({
|
|
where: { userId },
|
|
include: { documents: true },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
}
|
|
|
|
async createReturn(userId: string, payload: CreateTaxReturnDto) {
|
|
return this.prisma.taxReturn.create({
|
|
data: {
|
|
userId,
|
|
taxYear: payload.taxYear,
|
|
filingType: payload.filingType,
|
|
jurisdictions: payload.jurisdictions as Prisma.InputJsonValue,
|
|
status: "draft",
|
|
summary: {},
|
|
},
|
|
});
|
|
}
|
|
|
|
async updateReturn(userId: string, id: string, payload: UpdateTaxReturnDto) {
|
|
const existing = await this.prisma.taxReturn.findFirst({ where: { id, userId } });
|
|
if (!existing) throw new BadRequestException("Tax return not found.");
|
|
return this.prisma.taxReturn.update({
|
|
where: { id },
|
|
data: {
|
|
...(payload.status !== undefined && { status: payload.status }),
|
|
...(payload.summary !== undefined && { summary: payload.summary as Prisma.InputJsonValue }),
|
|
},
|
|
});
|
|
}
|
|
|
|
async addDocument(
|
|
userId: string,
|
|
returnId: string,
|
|
docType: string,
|
|
metadata: Record<string, unknown>,
|
|
) {
|
|
const taxReturn = await this.prisma.taxReturn.findFirst({ where: { id: returnId, userId } });
|
|
if (!taxReturn) throw new BadRequestException("Tax return not found.");
|
|
return this.prisma.taxDocument.create({
|
|
data: {
|
|
taxReturnId: returnId,
|
|
docType,
|
|
metadata: metadata as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
}
|
|
|
|
async exportReturn(userId: string, id: string) {
|
|
const taxReturn = await this.prisma.taxReturn.findFirst({
|
|
where: { id, userId },
|
|
include: { documents: true },
|
|
});
|
|
if (!taxReturn) throw new BadRequestException("Tax return not found.");
|
|
|
|
await this.prisma.taxReturn.update({
|
|
where: { id },
|
|
data: { status: "exported" },
|
|
});
|
|
|
|
return { return: { ...taxReturn, status: "exported" }, documents: taxReturn.documents };
|
|
}
|
|
}
|
|
`);
|
|
|
|
// ─── tax.controller.ts ───────────────────────────────────────────────────────
|
|
writeFileSync("src/tax/tax.controller.ts", `import { Body, Controller, Get, Param, Patch, Post } from "@nestjs/common";
|
|
import { ok } from "../common/response";
|
|
import { CreateTaxReturnDto } from "./dto/create-return.dto";
|
|
import { UpdateTaxReturnDto } from "./dto/update-return.dto";
|
|
import { TaxService } from "./tax.service";
|
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
|
|
|
@Controller("tax")
|
|
export class TaxController {
|
|
constructor(private readonly taxService: TaxService) {}
|
|
|
|
@Get("returns")
|
|
async listReturns(@CurrentUser() userId: string) {
|
|
const data = await this.taxService.listReturns(userId);
|
|
return ok(data);
|
|
}
|
|
|
|
@Post("returns")
|
|
async createReturn(@CurrentUser() userId: string, @Body() payload: CreateTaxReturnDto) {
|
|
const data = await this.taxService.createReturn(userId, payload);
|
|
return ok(data);
|
|
}
|
|
|
|
@Patch("returns/:id")
|
|
async updateReturn(
|
|
@CurrentUser() userId: string,
|
|
@Param("id") id: string,
|
|
@Body() payload: UpdateTaxReturnDto,
|
|
) {
|
|
const data = await this.taxService.updateReturn(userId, id, payload);
|
|
return ok(data);
|
|
}
|
|
|
|
@Post("returns/:id/documents")
|
|
async addDocument(
|
|
@CurrentUser() userId: string,
|
|
@Param("id") id: string,
|
|
@Body() payload: { docType: string; metadata?: Record<string, unknown> },
|
|
) {
|
|
const data = await this.taxService.addDocument(userId, id, payload.docType, payload.metadata ?? {});
|
|
return ok(data);
|
|
}
|
|
|
|
@Post("returns/:id/export")
|
|
async exportReturn(@CurrentUser() userId: string, @Param("id") id: string) {
|
|
const data = await this.taxService.exportReturn(userId, id);
|
|
return ok(data);
|
|
}
|
|
}
|
|
`);
|
|
|
|
// ─── tax.module.ts ───────────────────────────────────────────────────────────
|
|
writeFileSync("src/tax/tax.module.ts", `import { Module } from "@nestjs/common";
|
|
import { TaxController } from "./tax.controller";
|
|
import { TaxService } from "./tax.service";
|
|
|
|
@Module({
|
|
controllers: [TaxController],
|
|
providers: [TaxService],
|
|
})
|
|
export class TaxModule {}
|
|
`);
|
|
|
|
// ─── tax DTOs (updated to class-validator) ───────────────────────────────────
|
|
writeFileSync("src/tax/dto/create-return.dto.ts", `import { IsArray, IsIn, IsInt, IsString } from "class-validator";
|
|
|
|
export class CreateTaxReturnDto {
|
|
@IsInt()
|
|
taxYear!: number;
|
|
|
|
@IsString()
|
|
@IsIn(["individual", "business"])
|
|
filingType!: "individual" | "business";
|
|
|
|
@IsArray()
|
|
@IsString({ each: true })
|
|
jurisdictions!: string[];
|
|
}
|
|
`);
|
|
|
|
writeFileSync("src/tax/dto/update-return.dto.ts", `import { IsIn, IsObject, IsOptional, IsString } from "class-validator";
|
|
|
|
export class UpdateTaxReturnDto {
|
|
@IsOptional()
|
|
@IsString()
|
|
@IsIn(["draft", "ready", "exported"])
|
|
status?: "draft" | "ready" | "exported";
|
|
|
|
@IsOptional()
|
|
@IsObject()
|
|
summary?: Record<string, unknown>;
|
|
}
|
|
`);
|
|
|
|
console.log("rules + tax files written");
|