]> git.digitality.be Git - pdw25-26/commitdiff
feat: wallet module - CRUD wallets + events, BigInt transactions
authorSteph Ponzo <ponzo.stephane2@gmail.com>
Fri, 27 Feb 2026 19:59:49 +0000 (20:59 +0100)
committerSteph Ponzo <ponzo.stephane2@gmail.com>
Fri, 27 Feb 2026 19:59:49 +0000 (20:59 +0100)
Wallette/server/modules/wallet/adapters/mysql.adapter.js [new file with mode: 0644]
Wallette/server/modules/wallet/index.js [new file with mode: 0644]
Wallette/server/modules/wallet/server.js [new file with mode: 0644]
Wallette/server/modules/wallet/wallet.controller.js [new file with mode: 0644]
Wallette/server/modules/wallet/wallet.repo.js [new file with mode: 0644]
Wallette/server/modules/wallet/wallet.router.js [new file with mode: 0644]
Wallette/server/modules/wallet/wallet.service.js [new file with mode: 0644]

diff --git a/Wallette/server/modules/wallet/adapters/mysql.adapter.js b/Wallette/server/modules/wallet/adapters/mysql.adapter.js
new file mode 100644 (file)
index 0000000..856fb9b
--- /dev/null
@@ -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 (file)
index 0000000..1417a1e
--- /dev/null
@@ -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 (file)
index 0000000..2ebc66a
--- /dev/null
@@ -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 (file)
index 0000000..b6f6a45
--- /dev/null
@@ -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 (file)
index 0000000..743c12a
--- /dev/null
@@ -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 (file)
index 0000000..4980631
--- /dev/null
@@ -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 (file)
index 0000000..c6bedf6
--- /dev/null
@@ -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(),
+        });
+        },
+    };
+}