import { Hono } from "hono"; import { and, desc, eq, isNull, isNotNull, sql } from "drizzle-orm"; import dayjs from "dayjs"; import { useDrizzle } from "@/lib/db.js"; import { transaction, chargePoint, connector, idTag } from "@/db/schema.js"; import type { SampledValue } from "@/db/schema.js"; import { user } from "@/db/auth-schema.js"; import { ocppConnections } from "@/ocpp/handler.js"; import { OCPP_MESSAGE_TYPE } from "@/ocpp/types.js"; import { resolveIdTagInfo } from "@/ocpp/actions/authorize.js"; import type { HonoEnv } from "@/types/hono.ts"; const app = new Hono(); /** * POST /api/transactions/remote-start * Send RemoteStartTransaction to a charge point. * Non-admin users can only use their own id-tags. */ app.post("/remote-start", async (c) => { const currentUser = c.get("user"); if (!currentUser) return c.json({ error: "Unauthorized" }, 401); const db = useDrizzle(); const body = await c.req .json<{ chargePointIdentifier: string; connectorId: number; idTag: string; }>() .catch(() => null); if ( !body || !body.chargePointIdentifier?.trim() || !Number.isInteger(body.connectorId) || body.connectorId < 1 || !body.idTag?.trim() ) { return c.json( { error: "chargePointIdentifier, connectorId (>=1), and idTag are required" }, 400, ); } // Non-admin: verify idTag belongs to current user if (currentUser.role !== "admin") { const [tag] = await db .select({ idTag: idTag.idTag }) .from(idTag) .where(and(eq(idTag.idTag, body.idTag.trim()), eq(idTag.userId, currentUser.id))) .limit(1); if (!tag) return c.json({ error: "idTag not found or not authorized" }, 403); } // Reuse the same authorization logic as Authorize/StartTransaction. const tagInfo = await resolveIdTagInfo(body.idTag.trim()); if (tagInfo.status !== "Accepted") { if (tagInfo.status === "ConcurrentTx") { return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409); } return c.json({ error: `idTag rejected: ${tagInfo.status}` }, 400); } // One idTag can only have one active transaction at a time. const [activeTx] = await db .select({ id: transaction.id }) .from(transaction) .where(and(eq(transaction.idTag, body.idTag.trim()), isNull(transaction.stopTimestamp))) .limit(1); if (activeTx) { return c.json({ error: "ConcurrentTx: idTag already has an active transaction" }, 409); } // Verify charge point exists and is Accepted const [cp] = await db .select({ id: chargePoint.id, registrationStatus: chargePoint.registrationStatus }) .from(chargePoint) .where(eq(chargePoint.chargePointIdentifier, body.chargePointIdentifier.trim())) .limit(1); if (!cp) return c.json({ error: "ChargePoint not found" }, 404); if (cp.registrationStatus !== "Accepted") { return c.json({ error: "ChargePoint is not accepted" }, 400); } // Require the charge point to be online const ws = ocppConnections.get(body.chargePointIdentifier.trim()); if (!ws) return c.json({ error: "ChargePoint is offline" }, 503); const uniqueId = crypto.randomUUID(); ws.send( JSON.stringify([ OCPP_MESSAGE_TYPE.CALL, uniqueId, "RemoteStartTransaction", { connectorId: body.connectorId, idTag: body.idTag.trim() }, ]), ); console.log( `[OCPP] RemoteStartTransaction cp=${body.chargePointIdentifier} ` + `connector=${body.connectorId} idTag=${body.idTag} user=${currentUser.id}`, ); return c.json({ success: true }); }); /** GET /api/transactions?page=1&limit=20&status=active|completed&chargePointId=... */ 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 chargePointId = c.req.query("chargePointId"); const offset = (page - 1) * limit; const db = useDrizzle(); const currentUser = c.get("user"); const isAdmin = currentUser?.role === "admin"; const statusCondition = status === "active" ? isNull(transaction.stopTimestamp) : status === "completed" ? isNotNull(transaction.stopTimestamp) : undefined; // For non-admin users, restrict to transactions matching their id-tags const userCondition = !isAdmin && currentUser ? sql`${transaction.idTag} in (select id_tag from id_tag where user_id = ${currentUser.id})` : undefined; const cpCondition = chargePointId ? eq(transaction.chargePointId, chargePointId) : undefined; const whereClause = and(statusCondition, userCondition, cpCondition); const [{ total }] = await db .select({ total: sql`count(*)::int` }) .from(transaction) .where(whereClause); const rows = await db .select({ transaction, chargePointIdentifier: chargePoint.chargePointIdentifier, chargePointDeviceName: chargePoint.deviceName, feePerKwh: chargePoint.feePerKwh, pricingMode: chargePoint.pricingMode, connectorNumber: connector.connectorId, idTagUserId: idTag.userId, idTagUserName: user.name, }) .from(transaction) .leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id)) .leftJoin(connector, eq(transaction.connectorId, connector.id)) .leftJoin(idTag, eq(transaction.idTag, idTag.idTag)) .leftJoin(user, eq(idTag.userId, user.id)) .where(whereClause) .orderBy(desc(transaction.startTimestamp)) .limit(limit) .offset(offset); // For active transactions, fetch the latest meter reading to show live energy const activeTxIds = rows.filter((r) => !r.transaction.stopTimestamp).map((r) => r.transaction.id); // Map: transactionId -> latest cumulative Wh from meterValue const latestMeterMap = new Map(); if (activeTxIds.length > 0) { // DISTINCT ON picks the most recent row per transaction_id const latestRows = await db.execute<{ transaction_id: number; sampled_values: SampledValue[]; }>(sql` SELECT DISTINCT ON (transaction_id) transaction_id, sampled_values FROM meter_value WHERE transaction_id IN (${sql.join( activeTxIds.map((id) => sql`${id}`), sql`, `, )}) ORDER BY transaction_id, timestamp DESC `); for (const row of latestRows.rows) { const svList = row.sampled_values as SampledValue[]; const energySv = svList.find( (sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase, ); if (energySv != null) { // Unit defaults to Wh; kWh is also common const raw = parseFloat(energySv.value); if (!Number.isNaN(raw)) { const wh = energySv.unit === "kWh" ? raw * 1000 : raw; latestMeterMap.set(Number(row.transaction_id), wh); } } } } return c.json({ data: rows.map((r) => { const isActive = !r.transaction.stopTimestamp; const latestMeterWh = isActive ? (latestMeterMap.get(r.transaction.id) ?? null) : null; const liveEnergyWh = latestMeterWh != null ? latestMeterWh - r.transaction.startMeterValue : null; // Estimated cost: only for fixed pricing (TOU requires full interval analysis) let estimatedCost: number | null = null; if ( isActive && liveEnergyWh != null && liveEnergyWh > 0 && r.pricingMode === "fixed" && (r.feePerKwh ?? 0) > 0 ) { estimatedCost = Math.ceil((liveEnergyWh * (r.feePerKwh ?? 0)) / 1000); } return { ...r.transaction, chargePointIdentifier: r.chargePointIdentifier, chargePointDeviceName: r.chargePointDeviceName, connectorNumber: r.connectorNumber, idTagUserId: r.idTagUserId, idTagUserName: r.idTagUserName, energyWh: r.transaction.stopMeterValue != null ? r.transaction.stopMeterValue - r.transaction.startMeterValue : null, liveEnergyWh, estimatedCost, }; }), 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, chargePointDeviceName: chargePoint.deviceName, connectorNumber: connector.connectorId, feePerKwh: chargePoint.feePerKwh, pricingMode: chargePoint.pricingMode, }) .from(transaction) .leftJoin(chargePoint, eq(transaction.chargePointId, chargePoint.id)) .leftJoin(connector, eq(transaction.connectorId, connector.id)) .where(eq(transaction.id, id)) .limit(1); if (!row) return c.json({ error: "Not found" }, 404); let liveEnergyWh: number | null = null; let estimatedCost: number | null = null; // For active transactions, return live estimated energy/cost like the list endpoint. if (!row.transaction.stopTimestamp) { const latestRows = await db.execute<{ sampled_values: SampledValue[]; }>(sql` SELECT sampled_values FROM meter_value WHERE transaction_id = ${id} ORDER BY timestamp DESC LIMIT 1 `); const latest = latestRows.rows[0]; if (latest) { const svList = latest.sampled_values as SampledValue[]; const energySv = svList.find( (sv) => (!sv.measurand || sv.measurand === "Energy.Active.Import.Register") && !sv.phase, ); if (energySv != null) { const raw = parseFloat(energySv.value); if (!Number.isNaN(raw) && row.transaction.startMeterValue != null) { const latestMeterWh = energySv.unit === "kWh" ? raw * 1000 : raw; liveEnergyWh = latestMeterWh - row.transaction.startMeterValue; if (liveEnergyWh > 0 && row.pricingMode === "fixed" && (row.feePerKwh ?? 0) > 0) { estimatedCost = Math.ceil((liveEnergyWh * (row.feePerKwh ?? 0)) / 1000); } } } } } return c.json({ ...row.transaction, chargePointIdentifier: row.chargePointIdentifier, chargePointDeviceName: row.chargePointDeviceName, connectorNumber: row.connectorNumber, energyWh: row.transaction.stopMeterValue != null ? row.transaction.stopMeterValue - row.transaction.startMeterValue : null, liveEnergyWh, estimatedCost, }); }); /** * 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 = dayjs(); // 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.toDate(), stopMeterValue, stopReason: "Remote", chargeAmount: feeFen, updatedAt: now.toDate(), }) .where(eq(transaction.id, id)) .returning(); if (feeFen > 0) { await db .update(idTag) .set({ balance: sql`GREATEST(0, ${idTag.balance} - ${feeFen})`, updatedAt: now.toDate(), }) .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) => { if (c.get("user")?.role !== "admin") return c.json({ error: "Forbidden" }, 403); 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: dayjs().toDate() }) .where(eq(connector.id, row.transaction.connectorId)); } await db.delete(transaction).where(eq(transaction.id, id)); return c.json({ success: true }); }); export default app;