Files
Cibello-app/apps/api/src/routes/budget.ts
T
2026-08-05 19:21:11 +07:00

131 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FastifyInstance } from "fastify";
import { and, eq, gte, inArray, sql } from "drizzle-orm";
import { schema } from "@app/database";
import { requireActiveHousehold } from "../lib/helpers.js";
/** Budget & matsvinn (spec §26): vecka/månad, kostnad per måltid, svinnvärde. */
export async function budgetRoutes(app: FastifyInstance) {
const auth = { preHandler: [app.authenticate] };
app.get("/v1/budget/summary", auth, async (req) => {
const householdId = await requireActiveHousehold(app.db, req.userId);
const now = new Date();
// UTC-kalender (samma policy som motorerna): identiska summor oavsett serverns tidszon.
const mondayOffset = (now.getUTCDay() + 6) % 7; // 0 = måndag
const weekStart = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - mondayOffset),
);
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
// Inköpskostnad = summa purchase-transaktioner med värde
const [weekPurchases] = await app.db
.select({ total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)` })
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "purchase"),
gte(schema.inventoryTransactions.createdAt, weekStart),
),
);
const [monthPurchases] = await app.db
.select({ total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)` })
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "purchase"),
gte(schema.inventoryTransactions.createdAt, monthStart),
),
);
// Matsvinnsvärde = summa discard-transaktioner (spec §12, §26)
const [weekWaste] = await app.db
.select({
total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)`,
count: sql<number>`count(*)`,
})
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "discard"),
gte(schema.inventoryTransactions.createdAt, weekStart),
),
);
const [monthWaste] = await app.db
.select({
total: sql<number>`coalesce(sum(${schema.inventoryTransactions.valueMinor}), 0)`,
count: sql<number>`count(*)`,
})
.from(schema.inventoryTransactions)
.where(
and(
eq(schema.inventoryTransactions.householdId, householdId),
eq(schema.inventoryTransactions.type, "discard"),
gte(schema.inventoryTransactions.createdAt, monthStart),
),
);
// Kostnad per hemlagad måltid: lagade recept denna månad med kostnadsdata
const cooks = await app.db
.select({
recipeId: schema.recipeCooks.recipeId,
portions: schema.recipeCooks.portionsCooked,
})
.from(schema.recipeCooks)
.where(
and(
eq(schema.recipeCooks.householdId, householdId),
gte(schema.recipeCooks.cookedAt, monthStart),
),
);
let mealCostTotal = 0;
let mealPortions = 0;
if (cooks.length > 0) {
const recipes = await app.db
.select({ id: schema.recipes.id, cost: schema.recipes.estimatedCostMinorPerPortion })
.from(schema.recipes)
.where(inArray(schema.recipes.id, [...new Set(cooks.map((c) => c.recipeId))]));
const costMap = new Map(recipes.map((r) => [r.id, r.cost]));
for (const cook of cooks) {
const cost = costMap.get(cook.recipeId);
if (cost != null) {
mealCostTotal += cost * cook.portions;
mealPortions += cook.portions;
}
}
}
const [household] = await app.db
.select({
budget: schema.households.weeklyBudgetMinor,
currencyCode: schema.households.currencyCode,
})
.from(schema.households)
.where(eq(schema.households.id, householdId))
.limit(1);
// Alla belopp i minor units (heltal) i hushållets valuta (i18n-spec §20).
const currency = household?.currencyCode ?? "SEK";
return {
currency,
week: {
purchasedMinor: Math.round(Number(weekPurchases?.total ?? 0)),
wasteMinor: Math.round(Number(weekWaste?.total ?? 0)),
wasteCount: Number(weekWaste?.count ?? 0),
budgetMinor: household?.budget ?? null,
},
month: {
purchasedMinor: Math.round(Number(monthPurchases?.total ?? 0)),
wasteMinor: Math.round(Number(monthWaste?.total ?? 0)),
wasteCount: Number(monthWaste?.count ?? 0),
estimatedCostPerPortionMinor:
mealPortions > 0 ? Math.round(mealCostTotal / mealPortions) : null,
cookedPortions: mealPortions,
},
note: "Kostnader bygger på kvitton, angivna priser och schablonpriser uppskattningar, inte bokföring.",
};
});
}