import { Hono } from "hono"; import { desc, eq, isNull, isNotNull, sql } from "drizzle-orm"; import { useDrizzle } from "@/lib/db.js"; import { transaction, chargePoint, connector, idTag } from "@/db/schema.js"; import { ocppConnections } from "@/ocpp/handler.js"; import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js"; const app = new Hono(); /** GET /api/transactions?page=1&limit=20&status=active|completed */ app.get("/", async (c) => { const page = Math.max(1, Number(c.req.query("page") ?? 1)); const limit = Math.min(100, Math.max(1, Number(c.req.query("limit") ?? 20))); const status = c.req.query("status"); // 'active' | 'completed' const offset = (page - 1) * limit; const db = useDrizzle(); const whereClause = status === "active" ? isNull(transaction.stopTimestamp) : status === "completed" ? isNotNull(transaction.stopTimestamp) : undefined; const [{ total }] = await db .select({ total: sql`count(*)::int` }) .from(transaction) .where(whereClause); const rows = await db .select({ transaction, chargePointIdentifier: chargePoint.chargePointIdentifier, connectorNumber: connector.connectorId, }) .from(transaction) .leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id)) .leftJoin(connector, eq(transaction.connectorId, connector.id)) .where(whereClause) .orderBy(desc(transaction.startTimestamp)) .limit(limit) .offset(offset); return c.json({ data: rows.map((r) => ({ ...r.transaction, chargePointIdentifier: r.chargePointIdentifier, connectorNumber: r.connectorNumber, energyWh: r.transaction.stopMeterValue != null ? r.transaction.stopMeterValue - r.transaction.startMeterValue : null, })), total, page, totalPages: Math.max(1, Math.ceil(total / limit)), }); }); /** GET /api/transactions/:id */ app.get("/:id", async (c) => { const db = useDrizzle(); const id = Number(c.req.param("id")); const [row] = await db .select({ transaction, chargePointIdentifier: chargePoint.chargePointIdentifier, }) .from(transaction) .leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id)) .where(eq(transaction.id, id)) .limit(1); if (!row) return c.json({ error: "Not found" }, 404); return c.json({ ...row.transaction, chargePointIdentifier: row.chargePointIdentifier, energyWh: row.transaction.stopMeterValue != null ? row.transaction.stopMeterValue - row.transaction.startMeterValue : null, }); }); /** * POST /api/transactions/:id/stop * Manually stop an active transaction. * 1. If the charge point is connected, send OCPP RemoteStopTransaction. * 2. In either case (online or offline), settle the transaction in the DB immediately * so the record is always finalised from the admin side. */ app.post("/:id/stop", async (c) => { const db = useDrizzle(); const id = Number(c.req.param("id")); // Load the transaction const [row] = await db .select({ transaction, chargePointIdentifier: chargePoint.chargePointIdentifier, feePerKwh: chargePoint.feePerKwh, }) .from(transaction) .leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id)) .where(eq(transaction.id, id)) .limit(1); if (!row) return c.json({ error: "Not found" }, 404); if (row.transaction.stopTimestamp) return c.json({ error: "Transaction already stopped" }, 409); const now = new Date(); // Try to send RemoteStopTransaction via OCPP if the charge point is online const ws = row.chargePointIdentifier ? ocppConnections.get(row.chargePointIdentifier) : null; if (ws) { const uniqueId = crypto.randomUUID(); ws.send( JSON.stringify([ OCPP_MESSAGE_TYPE.CALL, uniqueId, "RemoteStopTransaction", { transactionId: row.transaction.id }, ]), ); console.log(`[OCPP] Sent RemoteStopTransaction txId=${id} to ${row.chargePointIdentifier}`); } // Settle in DB regardless (charge point may be offline or slow to respond) // Use startMeterValue as stopMeterValue when the real value is unknown (offline case) const stopMeterValue = row.transaction.startMeterValue; const energyWh = 0; // cannot know actual energy without stop meter value const feePerKwh = row.feePerKwh ?? 0; const feeFen = feePerKwh > 0 && energyWh > 0 ? Math.ceil((energyWh * feePerKwh) / 1000) : 0; const [updated] = await db .update(transaction) .set({ stopTimestamp: now, stopMeterValue, stopReason: "Remote", chargeAmount: feeFen, updatedAt: now, }) .where(eq(transaction.id, id)) .returning(); if (feeFen > 0) { await db .update(idTag) .set({ balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`, updatedAt: now, }) .where(eq(idTag.idTag, row.transaction.idTag)); } return c.json({ ...updated, chargePointIdentifier: row.chargePointIdentifier, online: !!ws, energyWh, }); }); /** DELETE /api/transactions/:id — delete a transaction record */ app.delete("/:id", async (c) => { const db = useDrizzle(); const id = Number(c.req.param("id")); const [row] = await db .select({ transaction, connectorId: transaction.connectorId }) .from(transaction) .where(eq(transaction.id, id)) .limit(1); if (!row) return c.json({ error: "Not found" }, 404); // If the transaction is still active, reset the connector to Available if (!row.transaction.stopTimestamp) { await db .update(connector) .set({ status: "Available", updatedAt: new Date() }) .where(eq(connector.id, row.transaction.connectorId)); } await db.delete(transaction).where(eq(transaction.id, id)); return c.json({ success: true }); }); export default app;