added udner construction for file compaction, planning for unbloating
This commit is contained in:
64
api/src/services/api-errors.ts
Normal file
64
api/src/services/api-errors.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export class ApiError extends Error {
|
||||
statusCode: number;
|
||||
code: string;
|
||||
details?: unknown;
|
||||
|
||||
constructor(statusCode: number, code: string, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function badRequest(code: string, message: string, details?: unknown) {
|
||||
return new ApiError(400, code, message, details);
|
||||
}
|
||||
|
||||
export function unauthorized(code: string, message: string, details?: unknown) {
|
||||
return new ApiError(401, code, message, details);
|
||||
}
|
||||
|
||||
export function forbidden(code: string, message: string, details?: unknown) {
|
||||
return new ApiError(403, code, message, details);
|
||||
}
|
||||
|
||||
export function notFound(code: string, message: string, details?: unknown) {
|
||||
return new ApiError(404, code, message, details);
|
||||
}
|
||||
|
||||
export function conflict(code: string, message: string, details?: unknown) {
|
||||
return new ApiError(409, code, message, details);
|
||||
}
|
||||
|
||||
export function toErrorBody(err: unknown, requestId: string) {
|
||||
if (err instanceof ApiError) {
|
||||
return {
|
||||
statusCode: err.statusCode,
|
||||
body: {
|
||||
ok: false,
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
requestId,
|
||||
...(err.details !== undefined ? { details: err.details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fallback = err as { statusCode?: number; code?: string; message?: string };
|
||||
const statusCode =
|
||||
typeof fallback?.statusCode === "number" ? fallback.statusCode : 500;
|
||||
return {
|
||||
statusCode,
|
||||
body: {
|
||||
ok: false,
|
||||
code: fallback?.code ?? "INTERNAL",
|
||||
message:
|
||||
statusCode >= 500
|
||||
? "Something went wrong"
|
||||
: fallback?.message ?? "Bad request",
|
||||
requestId,
|
||||
},
|
||||
};
|
||||
}
|
||||
70
api/src/services/budget-session.ts
Normal file
70
api/src/services/budget-session.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
|
||||
type BudgetSessionAccessor =
|
||||
| Pick<PrismaClient, "budgetSession">
|
||||
| Pick<Prisma.TransactionClient, "budgetSession">;
|
||||
|
||||
function normalizeAvailableCents(value: number): bigint {
|
||||
if (!Number.isFinite(value)) return 0n;
|
||||
return BigInt(Math.max(0, Math.trunc(value)));
|
||||
}
|
||||
|
||||
export async function getLatestBudgetSession(
|
||||
db: BudgetSessionAccessor,
|
||||
userId: string
|
||||
) {
|
||||
return db.budgetSession.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { periodStart: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureBudgetSession(
|
||||
db: BudgetSessionAccessor,
|
||||
userId: string,
|
||||
fallbackAvailableCents = 0,
|
||||
now = new Date()
|
||||
) {
|
||||
const existing = await getLatestBudgetSession(db, userId);
|
||||
if (existing) return existing;
|
||||
|
||||
const start = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0)
|
||||
);
|
||||
const end = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0)
|
||||
);
|
||||
const normalizedAvailable = normalizeAvailableCents(fallbackAvailableCents);
|
||||
|
||||
return db.budgetSession.create({
|
||||
data: {
|
||||
userId,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
totalBudgetCents: normalizedAvailable,
|
||||
allocatedCents: 0n,
|
||||
fundedCents: 0n,
|
||||
availableCents: normalizedAvailable,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureBudgetSessionAvailableSynced(
|
||||
db: BudgetSessionAccessor,
|
||||
userId: string,
|
||||
availableCents: number
|
||||
) {
|
||||
const normalizedAvailable = normalizeAvailableCents(availableCents);
|
||||
const session = await ensureBudgetSession(
|
||||
db,
|
||||
userId,
|
||||
Number(normalizedAvailable)
|
||||
);
|
||||
|
||||
if ((session.availableCents ?? 0n) === normalizedAvailable) return session;
|
||||
|
||||
return db.budgetSession.update({
|
||||
where: { id: session.id },
|
||||
data: { availableCents: normalizedAvailable },
|
||||
});
|
||||
}
|
||||
200
api/src/services/category-shares.ts
Normal file
200
api/src/services/category-shares.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
export type PercentCategory = {
|
||||
id: string;
|
||||
percent: number;
|
||||
balanceCents: bigint | number | null;
|
||||
};
|
||||
|
||||
type ShareRow = {
|
||||
id: string;
|
||||
share: number;
|
||||
};
|
||||
|
||||
type ShareResult =
|
||||
| { ok: true; shares: ShareRow[] }
|
||||
| { ok: false; reason: "no_percent" | "insufficient_balances" };
|
||||
|
||||
function toWholeCents(value: bigint | number | null | undefined): number {
|
||||
if (typeof value === "bigint") return Number(value);
|
||||
if (typeof value === "number" && Number.isFinite(value)) return Math.trunc(value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function normalizedAmountCents(amountCents: number): number {
|
||||
if (!Number.isFinite(amountCents)) return 0;
|
||||
return Math.max(0, Math.trunc(amountCents));
|
||||
}
|
||||
|
||||
export function computePercentShares(
|
||||
categories: PercentCategory[],
|
||||
amountCents: number
|
||||
): ShareResult {
|
||||
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
|
||||
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
|
||||
|
||||
const shares = categories.map((cat) => {
|
||||
const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal;
|
||||
const floored = Math.floor(raw);
|
||||
return {
|
||||
id: cat.id,
|
||||
balanceCents: toWholeCents(cat.balanceCents),
|
||||
share: floored,
|
||||
frac: raw - floored,
|
||||
};
|
||||
});
|
||||
|
||||
let remainder =
|
||||
normalizedAmountCents(amountCents) - shares.reduce((sum, s) => sum + s.share, 0);
|
||||
shares
|
||||
.slice()
|
||||
.sort((a, b) => b.frac - a.frac)
|
||||
.forEach((s) => {
|
||||
if (remainder > 0) {
|
||||
s.share += 1;
|
||||
remainder -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (shares.some((s) => s.share > s.balanceCents)) {
|
||||
return { ok: false, reason: "insufficient_balances" };
|
||||
}
|
||||
|
||||
return { ok: true, shares: shares.map(({ id, share }) => ({ id, share })) };
|
||||
}
|
||||
|
||||
export function computeWithdrawShares(
|
||||
categories: PercentCategory[],
|
||||
amountCents: number
|
||||
): ShareResult {
|
||||
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
|
||||
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
|
||||
|
||||
const working = categories.map((cat) => ({
|
||||
id: cat.id,
|
||||
percent: cat.percent,
|
||||
balanceCents: toWholeCents(cat.balanceCents),
|
||||
share: 0,
|
||||
}));
|
||||
|
||||
let remaining = normalizedAmountCents(amountCents);
|
||||
let safety = 0;
|
||||
|
||||
while (remaining > 0 && safety < 1000) {
|
||||
safety += 1;
|
||||
const eligible = working.filter((c) => c.balanceCents > 0 && c.percent > 0);
|
||||
if (eligible.length === 0) break;
|
||||
|
||||
const totalPercent = eligible.reduce((sum, cat) => sum + cat.percent, 0);
|
||||
if (totalPercent <= 0) break;
|
||||
|
||||
const provisional = eligible.map((cat) => {
|
||||
const raw = (remaining * cat.percent) / totalPercent;
|
||||
const floored = Math.floor(raw);
|
||||
return {
|
||||
id: cat.id,
|
||||
raw,
|
||||
floored,
|
||||
remainder: raw - floored,
|
||||
};
|
||||
});
|
||||
|
||||
let sumBase = provisional.reduce((sum, p) => sum + p.floored, 0);
|
||||
let leftovers = remaining - sumBase;
|
||||
provisional
|
||||
.slice()
|
||||
.sort((a, b) => b.remainder - a.remainder)
|
||||
.forEach((p) => {
|
||||
if (leftovers > 0) {
|
||||
p.floored += 1;
|
||||
leftovers -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
let allocatedThisRound = 0;
|
||||
for (const p of provisional) {
|
||||
const entry = working.find((w) => w.id === p.id);
|
||||
if (!entry) continue;
|
||||
const take = Math.min(p.floored, entry.balanceCents);
|
||||
if (take > 0) {
|
||||
entry.balanceCents -= take;
|
||||
entry.share += take;
|
||||
allocatedThisRound += take;
|
||||
}
|
||||
}
|
||||
|
||||
remaining -= allocatedThisRound;
|
||||
if (allocatedThisRound === 0) break;
|
||||
}
|
||||
|
||||
if (remaining > 0) {
|
||||
return { ok: false, reason: "insufficient_balances" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
shares: working.map((c) => ({ id: c.id, share: c.share })),
|
||||
};
|
||||
}
|
||||
|
||||
export function computeOverdraftShares(
|
||||
categories: PercentCategory[],
|
||||
amountCents: number
|
||||
): ShareResult {
|
||||
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
|
||||
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
|
||||
|
||||
const shares = categories.map((cat) => {
|
||||
const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal;
|
||||
const floored = Math.floor(raw);
|
||||
return {
|
||||
id: cat.id,
|
||||
share: floored,
|
||||
frac: raw - floored,
|
||||
};
|
||||
});
|
||||
|
||||
let remainder =
|
||||
normalizedAmountCents(amountCents) - shares.reduce((sum, s) => sum + s.share, 0);
|
||||
shares
|
||||
.slice()
|
||||
.sort((a, b) => b.frac - a.frac)
|
||||
.forEach((s) => {
|
||||
if (remainder > 0) {
|
||||
s.share += 1;
|
||||
remainder -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: true, shares: shares.map(({ id, share }) => ({ id, share })) };
|
||||
}
|
||||
|
||||
export function computeDepositShares(
|
||||
categories: PercentCategory[],
|
||||
amountCents: number
|
||||
): ShareResult {
|
||||
const percentTotal = categories.reduce((sum, cat) => sum + cat.percent, 0);
|
||||
if (percentTotal <= 0) return { ok: false, reason: "no_percent" };
|
||||
|
||||
const shares = categories.map((cat) => {
|
||||
const raw = (normalizedAmountCents(amountCents) * cat.percent) / percentTotal;
|
||||
const floored = Math.floor(raw);
|
||||
return {
|
||||
id: cat.id,
|
||||
share: floored,
|
||||
frac: raw - floored,
|
||||
};
|
||||
});
|
||||
|
||||
let remainder =
|
||||
normalizedAmountCents(amountCents) - shares.reduce((sum, s) => sum + s.share, 0);
|
||||
shares
|
||||
.slice()
|
||||
.sort((a, b) => b.frac - a.frac)
|
||||
.forEach((s) => {
|
||||
if (remainder > 0) {
|
||||
s.share += 1;
|
||||
remainder -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: true, shares: shares.map(({ id, share }) => ({ id, share })) };
|
||||
}
|
||||
19
api/src/services/user-context.ts
Normal file
19
api/src/services/user-context.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
|
||||
export const DEFAULT_USER_TIMEZONE = "America/New_York";
|
||||
|
||||
type UserReader =
|
||||
| Pick<PrismaClient, "user">
|
||||
| Pick<Prisma.TransactionClient, "user">;
|
||||
|
||||
export async function getUserTimezone(
|
||||
db: UserReader,
|
||||
userId: string,
|
||||
fallback: string = DEFAULT_USER_TIMEZONE
|
||||
): Promise<string> {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
return user?.timezone ?? fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user