feat(transactions): add transaction detail page with live energy and cost estimation
feat(transactions): implement active transaction checks and idTag validation feat(id-tag): enhance idTag card with disabled state for active transactions fix(transactions): improve error handling and user feedback for transaction actions
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import dayjs from "dayjs";
|
||||
import { useDrizzle } from "@/lib/db.js";
|
||||
import { idTag } from "@/db/schema.js";
|
||||
import { idTag, transaction } from "@/db/schema.js";
|
||||
import type {
|
||||
AuthorizeRequest,
|
||||
AuthorizeResponse,
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
export async function resolveIdTagInfo(
|
||||
idTagValue: string,
|
||||
checkBalance = true,
|
||||
checkConcurrent = true,
|
||||
): Promise<IdTagInfo> {
|
||||
const db = useDrizzle();
|
||||
const [tag] = await db.select().from(idTag).where(eq(idTag.idTag, idTagValue)).limit(1);
|
||||
@@ -31,6 +32,17 @@ export async function resolveIdTagInfo(
|
||||
if (tag.status !== "Accepted") {
|
||||
return { status: tag.status as IdTagInfo["status"] };
|
||||
}
|
||||
|
||||
// Enforce single active transaction per idTag.
|
||||
if (checkConcurrent) {
|
||||
const [activeTx] = await db
|
||||
.select({ id: transaction.id })
|
||||
.from(transaction)
|
||||
.where(and(eq(transaction.idTag, idTagValue), isNull(transaction.stopTimestamp)))
|
||||
.limit(1);
|
||||
if (activeTx) return { status: "ConcurrentTx" };
|
||||
}
|
||||
|
||||
// Reject if balance is zero or negative
|
||||
if (checkBalance && tag.balance <= 0) {
|
||||
return { status: "Blocked" };
|
||||
|
||||
@@ -183,7 +183,9 @@ export async function handleStopTransaction(
|
||||
);
|
||||
|
||||
// Resolve idTagInfo for the stop tag if provided (no balance check — charging already occurred)
|
||||
const idTagInfo = payload.idTag ? await resolveIdTagInfo(payload.idTag, false) : undefined;
|
||||
const idTagInfo = payload.idTag
|
||||
? await resolveIdTagInfo(payload.idTag, false, false)
|
||||
: undefined;
|
||||
|
||||
return { idTagInfo };
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ 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, meterValue } from "@/db/schema.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<HonoEnv>();
|
||||
@@ -42,15 +43,33 @@ app.post("/remote-start", async (c) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Non-admin: verify idTag belongs to current user and is Accepted
|
||||
// Non-admin: verify idTag belongs to current user
|
||||
if (currentUser.role !== "admin") {
|
||||
const [tag] = await db
|
||||
.select({ status: idTag.status })
|
||||
.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);
|
||||
if (tag.status !== "Accepted") return c.json({ error: "idTag is not accepted" }, 400);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -224,21 +243,64 @@ app.get("/:id", async (c) => {
|
||||
.select({
|
||||
transaction,
|
||||
chargePointIdentifier: chargePoint.chargePointIdentifier,
|
||||
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,
|
||||
connectorNumber: row.connectorNumber,
|
||||
energyWh:
|
||||
row.transaction.stopMeterValue != null
|
||||
? row.transaction.stopMeterValue - row.transaction.startMeterValue
|
||||
: null,
|
||||
liveEnergyWh,
|
||||
estimatedCost,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user