feat(wallets): implement wallet management features including create, update, delete, deposit, transfer, and list movements
- Added createWallet, updateWallet, deleteWallet, depositWallet, transferWallet, and listMovements handlers. - Created corresponding routes for wallet operations. - Developed frontend components for wallet management including dialogs for creating, editing, depositing, adjusting balance, transferring, and viewing movements. - Integrated wallet management into the authenticated routes.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "wallets" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" VARCHAR(100) NOT NULL,
|
||||
"currency" VARCHAR(10) NOT NULL,
|
||||
"balance" DECIMAL(14,4) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "wallets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "wallet_movements" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"walletId" INTEGER NOT NULL,
|
||||
"type" VARCHAR(20) NOT NULL,
|
||||
"amount" DECIMAL(14,4) NOT NULL,
|
||||
"description" VARCHAR(255),
|
||||
"referenceWalletId" INTEGER,
|
||||
"transferGroupId" UUID,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "wallet_movements_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wallet_movements_walletId_idx" ON "wallet_movements"("walletId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "wallet_movements_transferGroupId_idx" ON "wallet_movements"("transferGroupId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wallet_movements" ADD CONSTRAINT "wallet_movements_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "wallets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -129,6 +129,35 @@ enum PaymentStatus {
|
||||
PAYED
|
||||
}
|
||||
|
||||
model Wallet {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @db.VarChar(100)
|
||||
currency String @db.VarChar(10)
|
||||
balance Decimal @db.Decimal(14, 4)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
movements WalletMovement[]
|
||||
|
||||
@@map("wallets")
|
||||
}
|
||||
|
||||
model WalletMovement {
|
||||
id Int @id @default(autoincrement())
|
||||
walletId Int
|
||||
wallet Wallet @relation(fields: [walletId], references: [id])
|
||||
type String @db.VarChar(20)
|
||||
amount Decimal @db.Decimal(14, 4)
|
||||
description String? @db.VarChar(255)
|
||||
referenceWalletId Int?
|
||||
transferGroupId String? @db.Uuid
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([walletId])
|
||||
@@index([transferGroupId])
|
||||
@@map("wallet_movements")
|
||||
}
|
||||
|
||||
model PeriodicExpense {
|
||||
id Int @id @default(autoincrement())
|
||||
description String @db.VarChar(50)
|
||||
|
||||
@@ -12,6 +12,7 @@ import expensesRouter from "./modules/expenses/expenses.routes";
|
||||
import { startQuoteJob } from "./modules/quotes/quote.job";
|
||||
import quotesRouter from "./modules/quotes/quotes.routes";
|
||||
import telegramRouter from "./modules/telegram/telegram.router";
|
||||
import walletsRouter from "./modules/wallets/wallets.routes";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -33,6 +34,7 @@ app.use("/api/*", async (c, next) => {
|
||||
app.route("/api/quotes", quotesRouter);
|
||||
app.route("/api", expensesRouter);
|
||||
app.route("/api/telegram", telegramRouter);
|
||||
app.route("/api/wallets", walletsRouter);
|
||||
|
||||
app.use("/assets/*", serveStatic({ root: "./web" }));
|
||||
app.get("*", serveStatic({ path: "./web/index.html" }));
|
||||
|
||||
13
apps/backend/src/modules/wallets/handlers/adjustBalance.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/adjustBalance.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
adjustBalance,
|
||||
adjustBalanceSchema,
|
||||
} from "../wallets.service";
|
||||
|
||||
export async function adjustBalanceHandler(c: Context) {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = await c.req.json();
|
||||
const parsed = adjustBalanceSchema.parse(body);
|
||||
const result = await adjustBalance(id, parsed);
|
||||
return c.json(result);
|
||||
}
|
||||
12
apps/backend/src/modules/wallets/handlers/createWallet.ts
Normal file
12
apps/backend/src/modules/wallets/handlers/createWallet.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
createWallet,
|
||||
createWalletSchema,
|
||||
} from "../wallets.service";
|
||||
|
||||
export async function createWalletHandler(c: Context) {
|
||||
const body = await c.req.json();
|
||||
const parsed = createWalletSchema.parse(body);
|
||||
const result = await createWallet(parsed);
|
||||
return c.json(result, 201);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Context } from "hono";
|
||||
import { deleteWallet } from "../wallets.service";
|
||||
|
||||
export async function deleteWalletHandler(c: Context) {
|
||||
const id = Number(c.req.param("id"));
|
||||
await deleteWallet(id);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
13
apps/backend/src/modules/wallets/handlers/depositWallet.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/depositWallet.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
depositWallet,
|
||||
depositSchema,
|
||||
} from "../wallets.service";
|
||||
|
||||
export async function depositWalletHandler(c: Context) {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = await c.req.json();
|
||||
const parsed = depositSchema.parse(body);
|
||||
const result = await depositWallet(id, parsed);
|
||||
return c.json(result);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Context } from "hono";
|
||||
import { listMovements } from "../wallets.service";
|
||||
|
||||
export async function listMovementsHandler(c: Context) {
|
||||
const id = Number(c.req.param("id"));
|
||||
const result = await listMovements(id);
|
||||
return c.json(result);
|
||||
}
|
||||
7
apps/backend/src/modules/wallets/handlers/listWallets.ts
Normal file
7
apps/backend/src/modules/wallets/handlers/listWallets.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Context } from "hono";
|
||||
import { listWallets } from "../wallets.service";
|
||||
|
||||
export async function listWalletsHandler(c: Context) {
|
||||
const result = await listWallets();
|
||||
return c.json(result);
|
||||
}
|
||||
12
apps/backend/src/modules/wallets/handlers/transferWallet.ts
Normal file
12
apps/backend/src/modules/wallets/handlers/transferWallet.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
transferSchema,
|
||||
transferWallet,
|
||||
} from "../wallets.service";
|
||||
|
||||
export async function transferWalletHandler(c: Context) {
|
||||
const body = await c.req.json();
|
||||
const parsed = transferSchema.parse(body);
|
||||
const result = await transferWallet(parsed);
|
||||
return c.json(result);
|
||||
}
|
||||
13
apps/backend/src/modules/wallets/handlers/updateWallet.ts
Normal file
13
apps/backend/src/modules/wallets/handlers/updateWallet.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
updateWallet,
|
||||
updateWalletSchema,
|
||||
} from "../wallets.service";
|
||||
|
||||
export async function updateWalletHandler(c: Context) {
|
||||
const id = Number(c.req.param("id"));
|
||||
const body = await c.req.json();
|
||||
const parsed = updateWalletSchema.parse(body);
|
||||
const result = await updateWallet(id, parsed);
|
||||
return c.json(result);
|
||||
}
|
||||
22
apps/backend/src/modules/wallets/wallets.routes.ts
Normal file
22
apps/backend/src/modules/wallets/wallets.routes.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Hono } from "hono";
|
||||
import { adjustBalanceHandler } from "./handlers/adjustBalance";
|
||||
import { createWalletHandler } from "./handlers/createWallet";
|
||||
import { deleteWalletHandler } from "./handlers/deleteWallet";
|
||||
import { depositWalletHandler } from "./handlers/depositWallet";
|
||||
import { listMovementsHandler } from "./handlers/listMovements";
|
||||
import { listWalletsHandler } from "./handlers/listWallets";
|
||||
import { transferWalletHandler } from "./handlers/transferWallet";
|
||||
import { updateWalletHandler } from "./handlers/updateWallet";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", listWalletsHandler);
|
||||
app.post("/", createWalletHandler);
|
||||
app.put("/:id", updateWalletHandler);
|
||||
app.delete("/:id", deleteWalletHandler);
|
||||
app.post("/:id/deposit", depositWalletHandler);
|
||||
app.post("/:id/adjust", adjustBalanceHandler);
|
||||
app.post("/transfer", transferWalletHandler);
|
||||
app.get("/:id/movements", listMovementsHandler);
|
||||
|
||||
export default app;
|
||||
164
apps/backend/src/modules/wallets/wallets.service.ts
Normal file
164
apps/backend/src/modules/wallets/wallets.service.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "../../generated/prisma/client";
|
||||
import { prisma } from "../../lib/prisma";
|
||||
|
||||
export const createWalletSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
currency: z.string().min(1).max(10),
|
||||
initialBalance: z.number().optional().default(0),
|
||||
});
|
||||
|
||||
export const updateWalletSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
currency: z.string().min(1).max(10),
|
||||
});
|
||||
|
||||
export const depositSchema = z.object({
|
||||
amount: z.number().positive(),
|
||||
description: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
export const adjustBalanceSchema = z.object({
|
||||
newBalance: z.number().min(0),
|
||||
description: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
export const transferSchema = z.object({
|
||||
fromWalletId: z.number().int().positive(),
|
||||
toWalletId: z.number().int().positive(),
|
||||
outgoingAmount: z.number().positive(),
|
||||
incomingAmount: z.number().positive(),
|
||||
description: z.string().max(255).optional(),
|
||||
});
|
||||
|
||||
export type CreateWalletInput = z.infer<typeof createWalletSchema>;
|
||||
export type UpdateWalletInput = z.infer<typeof updateWalletSchema>;
|
||||
export type DepositInput = z.infer<typeof depositSchema>;
|
||||
export type AdjustBalanceInput = z.infer<typeof adjustBalanceSchema>;
|
||||
export type TransferInput = z.infer<typeof transferSchema>;
|
||||
|
||||
export async function listWallets() {
|
||||
const wallets = await prisma.wallet.findMany({ orderBy: { createdAt: "desc" } });
|
||||
return wallets.map((w) => ({ ...w, balance: Number(w.balance) }));
|
||||
}
|
||||
|
||||
export async function createWallet(input: CreateWalletInput) {
|
||||
const wallet = await prisma.wallet.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
currency: input.currency,
|
||||
balance: input.initialBalance ?? 0,
|
||||
},
|
||||
});
|
||||
return { ...wallet, balance: Number(wallet.balance) };
|
||||
}
|
||||
|
||||
export async function updateWallet(id: number, input: UpdateWalletInput) {
|
||||
const wallet = await prisma.wallet.update({
|
||||
where: { id },
|
||||
data: { name: input.name, currency: input.currency },
|
||||
});
|
||||
return { ...wallet, balance: Number(wallet.balance) };
|
||||
}
|
||||
|
||||
export async function deleteWallet(id: number) {
|
||||
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id } });
|
||||
if (Number(wallet.balance) !== 0) {
|
||||
throw new Error("No se puede eliminar una billetera con saldo distinto de cero");
|
||||
}
|
||||
await prisma.walletMovement.deleteMany({ where: { walletId: id } });
|
||||
await prisma.wallet.delete({ where: { id } });
|
||||
}
|
||||
|
||||
export async function depositWallet(id: number, input: DepositInput) {
|
||||
const [wallet] = await prisma.$transaction([
|
||||
prisma.wallet.update({
|
||||
where: { id },
|
||||
data: { balance: { increment: input.amount } },
|
||||
}),
|
||||
prisma.walletMovement.create({
|
||||
data: {
|
||||
walletId: id,
|
||||
type: "DEPOSIT",
|
||||
amount: input.amount,
|
||||
description: input.description ?? null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { ...wallet, balance: Number(wallet.balance) };
|
||||
}
|
||||
|
||||
export async function adjustBalance(id: number, input: AdjustBalanceInput) {
|
||||
const wallet = await prisma.wallet.findUniqueOrThrow({ where: { id } });
|
||||
const currentBalance = Number(wallet.balance);
|
||||
const difference = input.newBalance - currentBalance;
|
||||
|
||||
const [updated] = await prisma.$transaction([
|
||||
prisma.wallet.update({
|
||||
where: { id },
|
||||
data: { balance: input.newBalance },
|
||||
}),
|
||||
prisma.walletMovement.create({
|
||||
data: {
|
||||
walletId: id,
|
||||
type: "ADJUSTMENT",
|
||||
amount: difference,
|
||||
description: input.description ?? null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return { ...updated, balance: Number(updated.balance) };
|
||||
}
|
||||
|
||||
export async function transferWallet(input: TransferInput) {
|
||||
const groupId = crypto.randomUUID();
|
||||
|
||||
const [fromWallet, toWallet] = await prisma.$transaction(async (tx) => {
|
||||
const from = await tx.wallet.update({
|
||||
where: { id: input.fromWalletId },
|
||||
data: { balance: { decrement: input.outgoingAmount } },
|
||||
});
|
||||
|
||||
const to = await tx.wallet.update({
|
||||
where: { id: input.toWalletId },
|
||||
data: { balance: { increment: input.incomingAmount } },
|
||||
});
|
||||
|
||||
await tx.walletMovement.create({
|
||||
data: {
|
||||
walletId: input.fromWalletId,
|
||||
type: "TRANSFER_OUT",
|
||||
amount: -input.outgoingAmount,
|
||||
description: input.description ?? null,
|
||||
referenceWalletId: input.toWalletId,
|
||||
transferGroupId: groupId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletMovement.create({
|
||||
data: {
|
||||
walletId: input.toWalletId,
|
||||
type: "TRANSFER_IN",
|
||||
amount: input.incomingAmount,
|
||||
description: input.description ?? null,
|
||||
referenceWalletId: input.fromWalletId,
|
||||
transferGroupId: groupId,
|
||||
},
|
||||
});
|
||||
|
||||
return [from, to];
|
||||
});
|
||||
|
||||
return {
|
||||
from: { ...fromWallet, balance: Number(fromWallet.balance) },
|
||||
to: { ...toWallet, balance: Number(toWallet.balance) },
|
||||
};
|
||||
}
|
||||
|
||||
export async function listMovements(walletId: number) {
|
||||
const movements = await prisma.walletMovement.findMany({
|
||||
where: { walletId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return movements.map((m) => ({ ...m, amount: Number(m.amount) }));
|
||||
}
|
||||
Reference in New Issue
Block a user