--- /dev/null
+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();
+ }
+ },
+ };
+}
--- /dev/null
+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} ║
+ ╚══════════════════════════════════════════╝`);
+});
--- /dev/null
+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);
+ }
+ },
+ };
+}