From: Steph Ponzo Date: Fri, 27 Feb 2026 19:59:49 +0000 (+0100) Subject: feat: wallet module - CRUD wallets + events, BigInt transactions X-Git-Url: https://git.digitality.be/?a=commitdiff_plain;h=ed899e7325f7089bd484c8932caaf474eb42179d;p=pdw25-26 feat: wallet module - CRUD wallets + events, BigInt transactions --- diff --git a/Wallette/server/modules/wallet/adapters/mysql.adapter.js b/Wallette/server/modules/wallet/adapters/mysql.adapter.js new file mode 100644 index 0000000..856fb9b --- /dev/null +++ b/Wallette/server/modules/wallet/adapters/mysql.adapter.js @@ -0,0 +1,261 @@ +import { v4 as uuidv4 } from "uuid"; + +/** + * Adapter MySQL pour wallets + wallet_events + * - Contient TOUT le SQL + * - Méthodes transactionnelles pour garantir la cohérence (FOR UPDATE) + */ +export function createWalletMySQLAdapter(db, options = {}) { + const tables = { + wallets: options.walletsTable || "wallets", + events: options.eventsTable || "wallet_events", + }; + + const SCALE = 10n; + const TEN_POW = 10n ** SCALE; + + function toScaledInt(value) { + if (value === null || value === undefined || value === "") return 0n; + const s = String(value).trim(); + const neg = s.startsWith("-"); + const raw = neg ? s.slice(1) : s; + const [ip, fp = ""] = raw.split("."); + const frac = (fp + "0".repeat(Number(SCALE))).slice(0, Number(SCALE)); + const bi = BigInt(ip || "0") * TEN_POW + BigInt(frac || "0"); + return neg ? -bi : bi; + } + + function fromScaledInt(bi) { + const neg = bi < 0n; + const v = neg ? -bi : bi; + const ip = v / TEN_POW; + const fp = (v % TEN_POW).toString().padStart(Number(SCALE), "0"); + return (neg ? "-" : "") + ip.toString() + "." + fp; + } + + function mulPriceQty(priceInt, qtyInt) { + // priceInt (scaled10) * qtyInt (scaled10) => scaled20 + // pour obtenir "montant quote" scaled10 => / 1e10 + return (priceInt * qtyInt) / TEN_POW; // résultat scaled10 + } + + function computeNextWalletState(walletRow, event) { + const oldQty = toScaledInt(walletRow.quantity); + const oldAvg = walletRow.avg_buy_price == null ? 0n : toScaledInt(walletRow.avg_buy_price); + + const evQty = toScaledInt(event.quantity_base || "0"); + const evPrice = event.price_quote == null ? 0n : toScaledInt(event.price_quote); + const evFeeQuote = toScaledInt(event.fee_quote || "0"); + + let newQty = oldQty; + let newAvg = walletRow.avg_buy_price == null ? null : oldAvg; + let newBuyDate = walletRow.buy_date_ms; + + switch (event.event_type) { + case "BUY": { + if (evQty <= 0n) throw new Error("QTY_INVALID"); + if (event.quote_symbol == null) throw new Error("QUOTE_SYMBOL_REQUIRED"); + if (event.price_quote == null) throw new Error("PRICE_REQUIRED"); + + const totalOldCost = mulPriceQty(oldAvg, oldQty); // scaled10 + const buyCost = mulPriceQty(evPrice, evQty); // scaled10 + const totalNewCost = totalOldCost + buyCost + evFeeQuote; // scaled10 + + newQty = oldQty + evQty; + + // avg scaled10 = (totalNewCost / newQty) * 1e10 + const avgScaled = (totalNewCost * TEN_POW) / newQty; + newAvg = avgScaled; + + if (oldQty === 0n) newBuyDate = event.executed_at_ms; + break; + } + + case "SELL": { + if (evQty <= 0n) throw new Error("QTY_INVALID"); + if (oldQty - evQty < 0n) throw new Error("INSUFFICIENT_FUNDS"); + newQty = oldQty - evQty; + if (newQty === 0n) newAvg = null; + break; + } + + case "TRANSFER_IN": { + if (evQty <= 0n) throw new Error("QTY_INVALID"); + newQty = oldQty + evQty; + break; + } + + case "TRANSFER_OUT": { + if (evQty <= 0n) throw new Error("QTY_INVALID"); + if (oldQty - evQty < 0n) throw new Error("INSUFFICIENT_FUNDS"); + newQty = oldQty - evQty; + if (newQty === 0n) newAvg = null; + break; + } + + case "FEE": + case "ADJUSTMENT": + default: + // MVP: pas d’impact direct sur quantity (sauf si vous décidez) + break; + } + + return { + quantity: fromScaledInt(newQty), + avg_buy_price: newAvg == null ? null : fromScaledInt(newAvg), + buy_date_ms: newBuyDate ?? null, + }; + } + + return { + async listWalletsByUser(userId) { + const sql = ` + SELECT wallet_id, user_id, asset_symbol, quantity, avg_buy_price, buy_date_ms, + wallet_address, notes, created_at_ms, updated_at_ms + FROM ${tables.wallets} + WHERE user_id = ? + ORDER BY asset_symbol ASC + `; + const [rows] = await db.execute(sql, [userId]); + return rows; + }, + + async getWalletById(walletId) { + const sql = `SELECT * FROM ${tables.wallets} WHERE wallet_id = ?`; + const [rows] = await db.execute(sql, [walletId]); + return rows[0] || null; + }, + + async createWallet({ userId, assetSymbol, walletAddress = null, notes = null }) { + const now = Date.now(); + const walletId = uuidv4(); + + const sql = ` + INSERT INTO ${tables.wallets} ( + wallet_id, user_id, asset_symbol, quantity, avg_buy_price, buy_date_ms, + wallet_address, notes, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, '0.0000000000', NULL, NULL, ?, ?, ?, ?) + `; + await db.execute(sql, [walletId, userId, assetSymbol, walletAddress, notes, now, now]); + return this.getWalletById(walletId); + }, + + async updateWallet(walletId, patch) { + const allowed = ["wallet_address", "notes"]; + const fields = []; + const values = []; + for (const k of allowed) { + if (patch[k] !== undefined) { + fields.push(`${k} = ?`); + values.push(patch[k]); + } + } + if (fields.length === 0) return this.getWalletById(walletId); + + const sql = ` + UPDATE ${tables.wallets} + SET ${fields.join(", ")}, updated_at_ms = ? + WHERE wallet_id = ? + `; + values.push(Date.now(), walletId); + await db.execute(sql, values); + return this.getWalletById(walletId); + }, + + async listWalletEvents(walletId, limit = 50, offset = 0) { + const safeLimit = Math.min(Math.max(parseInt(limit) || 50, 1), 200); + const safeOffset = Math.max(parseInt(offset) || 0, 0); + + const sql = ` + SELECT * + FROM ${tables.events} + WHERE wallet_id = ? + ORDER BY executed_at_ms DESC + LIMIT ${safeLimit} OFFSET ${safeOffset} + `; + const [rows] = await db.execute(sql, [walletId]); + return rows; + }, + + /** + * Transaction atomique: + * - lock wallet (FOR UPDATE) + * - insert wallet_event + * - update wallet.quantity & wallet.avg_buy_price + */ + async addEventAndUpdateWallet(walletId, eventInput) { + const conn = await db.getConnection(); + try { + await conn.beginTransaction(); + + const [walletRows] = await conn.execute( + `SELECT * FROM ${tables.wallets} WHERE wallet_id = ? FOR UPDATE`, + [walletId] + ); + const wallet = walletRows[0]; + if (!wallet) { + const err = new Error("WALLET_NOT_FOUND"); + err.code = "WALLET_NOT_FOUND"; + throw err; + } + + const now = Date.now(); + const walletEventId = uuidv4(); + + const event = { + wallet_event_id: walletEventId, + wallet_id: walletId, + event_type: eventInput.event_type, + status: eventInput.status || "CONFIRMED", + executed_at_ms: eventInput.executed_at_ms || now, + quantity_base: eventInput.quantity_base ?? "0.0000000000", + quote_symbol: eventInput.quote_symbol ?? null, + price_quote: eventInput.price_quote ?? null, + fee_quote: eventInput.fee_quote ?? "0.0000000000", + fee_asset_symbol: eventInput.fee_asset_symbol ?? null, + exchange_name: eventInput.exchange_name ?? null, + wallet_address: eventInput.wallet_address ?? null, + tx_hash: eventInput.tx_hash ?? null, + notes: eventInput.notes ?? null, + created_at_ms: now, + updated_at_ms: now, + }; + + // calcul next state wallet + const next = computeNextWalletState(wallet, event); + + // insert event + const sqlIns = ` + INSERT INTO ${tables.events} ( + wallet_event_id, wallet_id, event_type, status, executed_at_ms, + quantity_base, quote_symbol, price_quote, fee_quote, fee_asset_symbol, + exchange_name, wallet_address, tx_hash, notes, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + await conn.execute(sqlIns, [ + event.wallet_event_id, event.wallet_id, event.event_type, event.status, event.executed_at_ms, + event.quantity_base, event.quote_symbol, event.price_quote, event.fee_quote, event.fee_asset_symbol, + event.exchange_name, event.wallet_address, event.tx_hash, event.notes, event.created_at_ms, event.updated_at_ms + ]); + + // update wallet + const sqlUpd = ` + UPDATE ${tables.wallets} + SET quantity = ?, avg_buy_price = ?, buy_date_ms = ?, updated_at_ms = ? + WHERE wallet_id = ? + `; + await conn.execute(sqlUpd, [next.quantity, next.avg_buy_price, next.buy_date_ms, now, walletId]); + + await conn.commit(); + + const updatedWallet = await this.getWalletById(walletId); + return { wallet: updatedWallet, event_id: walletEventId }; + } catch (e) { + await conn.rollback(); + throw e; + } finally { + conn.release(); + } + }, + }; +} diff --git a/Wallette/server/modules/wallet/index.js b/Wallette/server/modules/wallet/index.js new file mode 100644 index 0000000..1417a1e --- /dev/null +++ b/Wallette/server/modules/wallet/index.js @@ -0,0 +1,5 @@ +export { createWalletMySQLAdapter } from "./adapters/mysql.adapter.js"; +export { createWalletRepo } from "./wallet.repo.js"; +export { createWalletService } from "./wallet.service.js"; +export { createWalletController } from "./wallet.controller.js"; +export { createWalletRouter } from "./wallet.router.js"; diff --git a/Wallette/server/modules/wallet/server.js b/Wallette/server/modules/wallet/server.js new file mode 100644 index 0000000..2ebc66a --- /dev/null +++ b/Wallette/server/modules/wallet/server.js @@ -0,0 +1,49 @@ +import cors from "cors"; +import dotenv from "dotenv"; +import express from "express"; +import path from "path"; +import { fileURLToPath } from "url"; + +// Charger .env depuis Wallette/server/.env +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +dotenv.config({ path: path.resolve(__dirname, "../../.env") }); + +import db from "../../config/db.js"; +import { createWalletMySQLAdapter } from "./adapters/mysql.adapter.js"; +import { createWalletController } from "./wallet.controller.js"; +import { createWalletRepo } from "./wallet.repo.js"; +import { createWalletRouter } from "./wallet.router.js"; +import { createWalletService } from "./wallet.service.js"; + +const PORT = process.env.WALLET_PORT || 3004; + +const app = express(); +app.use(cors()); +app.use(express.json()); + +app.get("/health", (req, res) => { + res.json({ ok: true, service: "wallet-service", port: PORT }); +}); + +const adapter = createWalletMySQLAdapter(db); +const repo = createWalletRepo(adapter); +const service = createWalletService(repo); +const controller = createWalletController(service); +const router = createWalletRouter(controller); + +app.use("/api/wallets", router); + +app.use((req, res) => res.status(404).json({ ok: false, error: { code: "NOT_FOUND", message: "Route non trouvée" } })); +app.use((err, req, res, next) => { + console.error("Wallet server error:", err); + res.status(500).json({ ok: false, error: { code: "SERVER_ERROR", message: err.message } }); +}); + +app.listen(PORT, () => { + console.log(` + ╔══════════════════════════════════════════╗ + ║ WALLET SERVICE ║ + ║ HTTP : http://localhost:${PORT} ║ + ╚══════════════════════════════════════════╝`); +}); diff --git a/Wallette/server/modules/wallet/wallet.controller.js b/Wallette/server/modules/wallet/wallet.controller.js new file mode 100644 index 0000000..b6f6a45 --- /dev/null +++ b/Wallette/server/modules/wallet/wallet.controller.js @@ -0,0 +1,92 @@ +export function createWalletController(walletService) { + const ok = (res, data, status = 200) => res.status(status).json({ ok: true, data }); + const fail = (res, code, message, status = 400) => res.status(status).json({ ok: false, error: { code, message } }); + + return { + async listWallets(req, res) { + try { + const { userId } = req.query; + if (!userId) return fail(res, "MISSING_USER_ID", "Le paramètre userId est requis", 400); + const wallets = await walletService.listWallets(userId); + return ok(res, { wallets, count: wallets.length }); + } catch (e) { + console.error("listWallets error:", e); + return fail(res, "SERVER_ERROR", e.message, 500); + } + }, + + async createWallet(req, res) { + try { + const { userId, assetSymbol, walletAddress, notes } = req.body || {}; + if (!userId) return fail(res, "MISSING_USER_ID", "userId est requis", 400); + if (!assetSymbol) return fail(res, "MISSING_ASSET", "assetSymbol est requis", 400); + + const wallet = await walletService.createWallet({ userId, assetSymbol, walletAddress, notes }); + return ok(res, { wallet }, 201); + } catch (e) { + console.error("createWallet error:", e); + // FK user_id -> users peut faire tomber ici si user inexistant + return fail(res, "SERVER_ERROR", e.message, 500); + } + }, + + async getWallet(req, res) { + try { + const { walletId } = req.params; + const wallet = await walletService.getWallet(walletId); + if (!wallet) return fail(res, "WALLET_NOT_FOUND", "Wallet introuvable", 404); + return ok(res, { wallet }); + } catch (e) { + console.error("getWallet error:", e); + return fail(res, "SERVER_ERROR", e.message, 500); + } + }, + + async patchWallet(req, res) { + try { + const { walletId } = req.params; + const patch = req.body || {}; + const wallet = await walletService.updateWallet(walletId, patch); + if (!wallet) return fail(res, "WALLET_NOT_FOUND", "Wallet introuvable", 404); + return ok(res, { wallet }); + } catch (e) { + console.error("patchWallet error:", e); + return fail(res, "SERVER_ERROR", e.message, 500); + } + }, + + async listEvents(req, res) { + try { + const { walletId } = req.params; + const { limit, offset } = req.query; + const wallet = await walletService.getWallet(walletId); + if (!wallet) return fail(res, "WALLET_NOT_FOUND", "Wallet introuvable", 404); + + const events = await walletService.listEvents(walletId, limit, offset); + return ok(res, { events, count: events.length }); + } catch (e) { + console.error("listEvents error:", e); + return fail(res, "SERVER_ERROR", e.message, 500); + } + }, + + async addEvent(req, res) { + try { + const { walletId } = req.params; + const body = req.body || {}; + if (!body.event_type) return fail(res, "MISSING_EVENT_TYPE", "event_type est requis", 400); + + const result = await walletService.addEvent(walletId, body); + return ok(res, result, 201); + } catch (e) { + console.error("addEvent error:", e); + if (e.message === "INSUFFICIENT_FUNDS") return fail(res, "INSUFFICIENT_FUNDS", "Solde insuffisant", 409); + if (e.code === "WALLET_NOT_FOUND") return fail(res, "WALLET_NOT_FOUND", "Wallet introuvable", 404); + if (e.message === "PRICE_REQUIRED") return fail(res, "PRICE_REQUIRED", "price_quote requis pour BUY", 400); + if (e.message === "QUOTE_SYMBOL_REQUIRED") return fail(res, "QUOTE_SYMBOL_REQUIRED", "quote_symbol requis pour BUY", 400); + if (e.message === "QTY_INVALID") return fail(res, "QTY_INVALID", "quantity_base invalide", 400); + return fail(res, "SERVER_ERROR", e.message, 500); + } + }, + }; +} diff --git a/Wallette/server/modules/wallet/wallet.repo.js b/Wallette/server/modules/wallet/wallet.repo.js new file mode 100644 index 0000000..743c12a --- /dev/null +++ b/Wallette/server/modules/wallet/wallet.repo.js @@ -0,0 +1,10 @@ +export function createWalletRepo(adapter) { + return { + listWalletsByUser: (userId) => adapter.listWalletsByUser(userId), + getWalletById: (walletId) => adapter.getWalletById(walletId), + createWallet: (data) => adapter.createWallet(data), + updateWallet: (walletId, patch) => adapter.updateWallet(walletId, patch), + listWalletEvents: (walletId, limit, offset) => adapter.listWalletEvents(walletId, limit, offset), + addEventAndUpdateWallet: (walletId, event) => adapter.addEventAndUpdateWallet(walletId, event), + }; +} diff --git a/Wallette/server/modules/wallet/wallet.router.js b/Wallette/server/modules/wallet/wallet.router.js new file mode 100644 index 0000000..4980631 --- /dev/null +++ b/Wallette/server/modules/wallet/wallet.router.js @@ -0,0 +1,16 @@ +import express from "express"; + +export function createWalletRouter(controller) { + const router = express.Router(); + + router.get("/", controller.listWallets); + router.post("/", controller.createWallet); + + router.get("/:walletId", controller.getWallet); + router.patch("/:walletId", controller.patchWallet); + + router.get("/:walletId/events", controller.listEvents); + router.post("/:walletId/events", controller.addEvent); + + return router; +} diff --git a/Wallette/server/modules/wallet/wallet.service.js b/Wallette/server/modules/wallet/wallet.service.js new file mode 100644 index 0000000..c6bedf6 --- /dev/null +++ b/Wallette/server/modules/wallet/wallet.service.js @@ -0,0 +1,29 @@ +export function createWalletService(walletRepo) { + return { + listWallets(userId) { + return walletRepo.listWalletsByUser(userId); + }, + getWallet(walletId) { + return walletRepo.getWalletById(walletId); + }, + createWallet(data) { + // asset en uppercase + return walletRepo.createWallet({ + ...data, + assetSymbol: String(data.assetSymbol || "").trim().toUpperCase(), + }); + }, + updateWallet(walletId, patch) { + return walletRepo.updateWallet(walletId, patch); + }, + listEvents(walletId, limit, offset) { + return walletRepo.listWalletEvents(walletId, limit, offset); + }, + addEvent(walletId, event) { + return walletRepo.addEventAndUpdateWallet(walletId, { + ...event, + event_type: String(event.event_type || "").trim().toUpperCase(), + }); + }, + }; +}