Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions scripts/verify-order-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ const admin = db.select().from(user).limit(1).get();
assert(admin != null, "Need a user in the database");

const stfUnitCents = Math.round(stfData.unitCost * 100);
const stfTotal = orderTotalCents(stfData.quantity, stfUnitCents);
const stfTotal = orderTotalCents(stfData.quantity, stfUnitCents, "STF");
const stfBalance = validateOrderBalance("STF", stfData.stfBucketId, stfTotal);
assert(stfBalance.ok, "STF balance check should pass before insert");

Expand Down Expand Up @@ -171,7 +171,7 @@ assert(getGiftFundValueCents() === 50_000, "Gift fund adjustment failed");

const giftData = giftParsed.data!;
const giftUnitCents = Math.round(giftData.unitCost * 100);
const giftTotal = orderTotalCents(giftData.quantity, giftUnitCents);
const giftTotal = orderTotalCents(giftData.quantity, giftUnitCents, "Gift");

const giftOrder = db
.insert(order)
Expand Down
8 changes: 7 additions & 1 deletion src/app/api/orders/[id]/action/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { db } from "@/lib/db";
import { order, orderHistory, user } from "@/lib/db/schema";
import {
deductGiftFundForApproval,
ensureFinanceSettingsRow,
orderTotalCents,
sendOrderApprovedEmail,
sendOrderDeniedEmail,
Expand Down Expand Up @@ -45,8 +46,13 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: "Only pending orders can be reviewed" }, { status: 400 });
}

ensureFinanceSettingsRow();
const newStatus = ORDER_ACTION_STATUS[parsed.data.action];
const totalCostCents = orderTotalCents(existing.quantity, existing.unitCostCents);
const totalCostCents = orderTotalCents(
existing.quantity,
existing.unitCostCents,
existing.fundType
);

if (parsed.data.action === "approve") {
const balanceCheck = validateOrderBalance(
Expand Down
10 changes: 8 additions & 2 deletions src/app/api/orders/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NextResponse, type NextRequest } from "next/server";
import { db } from "@/lib/db";
import { order, orderHistory } from "@/lib/db/schema";
import {
ensureFinanceSettingsRow,
getActiveQuarter,
orderTotalCents,
restoreGiftFundForDeletion,
Expand Down Expand Up @@ -55,8 +56,9 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
}

const d = parsed.data;
ensureFinanceSettingsRow();
const unitCostCents = Math.round(d.unitCost * 100);
const totalCostCents = orderTotalCents(d.quantity, unitCostCents);
const totalCostCents = orderTotalCents(d.quantity, unitCostCents, d.fundType);

const balanceCheck = validateOrderBalance(d.fundType, d.stfBucketId, totalCostCents);
if (!balanceCheck.ok) {
Expand Down Expand Up @@ -132,7 +134,11 @@ export async function DELETE(_req: NextRequest, { params }: { params: Promise<{
}

if (isLockedOrderStatus(existing.status) && existing.fundType === "Gift") {
const totalCostCents = orderTotalCents(existing.quantity, existing.unitCostCents);
const totalCostCents = orderTotalCents(
existing.quantity,
existing.unitCostCents,
existing.fundType
);
restoreGiftFundForDeletion(orderId, totalCostCents, user.id);
}

Expand Down
10 changes: 8 additions & 2 deletions src/app/api/orders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { NextResponse, type NextRequest } from "next/server";

import { db } from "@/lib/db";
import { order } from "@/lib/db/schema";
import { getActiveQuarter, orderTotalCents, validateOrderBalance } from "@/lib/finance/finance";
import {
getActiveQuarter,
ensureFinanceSettingsRow,
orderTotalCents,
validateOrderBalance,
} from "@/lib/finance/finance";
import { getSessionUser } from "@/lib/auth/session";
import { orderInputSchema } from "@/lib/validation";

Expand All @@ -22,8 +27,9 @@ export async function POST(req: NextRequest) {
}

const d = parsed.data;
ensureFinanceSettingsRow();
const unitCostCents = Math.round(d.unitCost * 100);
const totalCostCents = orderTotalCents(d.quantity, unitCostCents);
const totalCostCents = orderTotalCents(d.quantity, unitCostCents, d.fundType);

const balanceCheck = validateOrderBalance(d.fundType, d.stfBucketId, totalCostCents);
if (!balanceCheck.ok) {
Expand Down
12 changes: 9 additions & 3 deletions src/components/orders/AdminOrderQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
formatOrderForExcel,
} from "@/lib/finance/order-export";
import {
computeOrderTotalCents,
orderChargeCents,
displayPercentToBps,
type OrderPricingSettings,
} from "@/lib/finance/order-pricing";
Expand Down Expand Up @@ -459,7 +459,8 @@ function OrderSection({
</TableCell>
<TableCell className="hidden text-right md:table-cell">
{formatPriceCents(
computeOrderTotalCents(
orderChargeCents(
o.fundType,
o.quantity,
o.unitCostCents,
pricingSettings
Expand Down Expand Up @@ -526,7 +527,12 @@ function OrderDetail({
}) {
const pricingSettings = toPricingSettings(orderPricing);
const excelRow = formatOrderForExcel(order, false, pricingSettings);
const total = computeOrderTotalCents(order.quantity, order.unitCostCents, pricingSettings);
const total = orderChargeCents(
order.fundType,
order.quantity,
order.unitCostCents,
pricingSettings
);

return (
<div className="space-y-4 py-2">
Expand Down
11 changes: 6 additions & 5 deletions src/components/orders/OrderForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
StfBucketSelectItemContent,
} from "@/components/BalanceAmount";
import type { FundType, OrderStatus } from "@/lib/db/schema";
import { computeOrderTotalCents, displayPercentToBps } from "@/lib/finance/order-pricing";
import { orderChargeCents, displayPercentToBps } from "@/lib/finance/order-pricing";
import { cn, formatPriceCents } from "@/lib/utils";

type StfBucketBalance = {
Expand Down Expand Up @@ -166,13 +166,14 @@ export function OrderForm({ initialOrder }: { initialOrder?: OrderFormInitial })
const cost = Number(unitCost);
const pricing = balances?.orderPricing;
if (!Number.isFinite(qty) || !Number.isFinite(cost) || qty < 1 || cost <= 0) return null;
if (!pricing) return null;
if (!pricing || !fundType) return null;
const unitCostCents = Math.round(cost * 100);
return computeOrderTotalCents(qty, unitCostCents, {
const settings = {
taxPercentBps: displayPercentToBps(pricing.taxPercent),
shippingPercentBps: displayPercentToBps(pricing.shippingPercent),
});
}, [quantity, unitCost, balances?.orderPricing]);
};
return orderChargeCents(fundType, qty, unitCostCents, settings);
}, [quantity, unitCost, balances?.orderPricing, fundType]);

const balanceError = useMemo(() => {
if (!fundType || totalCostCents == null || !balances) return null;
Expand Down
6 changes: 3 additions & 3 deletions src/components/orders/OrderTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from "@/components/ui/table";
import type { FundType, OrderStatus } from "@/lib/db/schema";
import {
computeOrderTotalCents,
orderChargeCents,
displayPercentToBps,
type OrderPricingSettings,
} from "@/lib/finance/order-pricing";
Expand All @@ -49,10 +49,10 @@ type FundFilter = "all" | FundType;
type SortKey = "newest" | "oldest" | "item-asc" | "item-desc" | "total-desc" | "total-asc";

function totalCostCents(
row: { quantity: number; unitCostCents: number },
row: { fundType: FundType; quantity: number; unitCostCents: number },
pricing: OrderPricingSettings
) {
return computeOrderTotalCents(row.quantity, row.unitCostCents, pricing);
return orderChargeCents(row.fundType, row.quantity, row.unitCostCents, pricing);
}

function canModifyOrder(status: OrderStatus) {
Expand Down
6 changes: 3 additions & 3 deletions src/components/orders/TeamOrderTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
} from "@/components/ui/table";
import type { FundType, OrderStatus } from "@/lib/db/schema";
import {
computeOrderTotalCents,
orderChargeCents,
displayPercentToBps,
type OrderPricingSettings,
} from "@/lib/finance/order-pricing";
Expand All @@ -45,10 +45,10 @@ type FundFilter = "all" | FundType;
type SortKey = "newest" | "oldest" | "item-asc" | "item-desc" | "total-desc" | "total-asc";

function totalCostCents(
row: { quantity: number; unitCostCents: number },
row: { fundType: FundType; quantity: number; unitCostCents: number },
pricing: OrderPricingSettings
) {
return computeOrderTotalCents(row.quantity, row.unitCostCents, pricing);
return orderChargeCents(row.fundType, row.quantity, row.unitCostCents, pricing);
}

function requesterLabel(row: TeamOrderRow) {
Expand Down
21 changes: 13 additions & 8 deletions src/lib/finance/finance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,24 @@ afterEach(() => {
});

describe("orderTotalCents", () => {
it("includes default tax and shipping on the subtotal", () => {
expect(orderTotalCents(2, 5000)).toBe(13_100);
it("includes default tax and shipping on the gift subtotal", () => {
expect(orderTotalCents(2, 5000, "Gift")).toBe(13_100);
});

it("includes flux, tax, and shipping on the STF subtotal", () => {
expect(orderTotalCents(2, 5000, "STF")).toBe(15_720);
});

it("returns 0 when quantity is 0", () => {
expect(orderTotalCents(0, 5000)).toBe(0);
expect(orderTotalCents(0, 5000, "Gift")).toBe(0);
});

it("returns 0 when unit cost is 0", () => {
expect(orderTotalCents(5, 0)).toBe(0);
expect(orderTotalCents(5, 0, "STF")).toBe(0);
});

it("handles large quantities and costs without overflow", () => {
const result = orderTotalCents(9999, 999999);
const result = orderTotalCents(9999, 999999, "Gift");
expect(Number.isFinite(result)).toBe(true);
expect(result).toBe(
computeExpectedTotal(
Expand All @@ -76,7 +80,8 @@ describe("updateOrderPricingSettings", () => {
updateOrderPricingSettings({ taxPercentBps: 500, shippingPercentBps: 1000 });

expect(getOrderPricingSettings()).toEqual({ taxPercentBps: 500, shippingPercentBps: 1000 });
expect(orderTotalCents(1, 10_000)).toBe(11_500);
expect(orderTotalCents(1, 10_000, "Gift")).toBe(11_500);
expect(orderTotalCents(1, 10_000, "STF")).toBe(13_800);

updateOrderPricingSettings(previous);
expect(getOrderPricingSettings()).toEqual(previous);
Expand Down Expand Up @@ -117,7 +122,7 @@ describe("getBucketApprovedSpendCents", () => {
.get();

const spendAfterApproved = getBucketApprovedSpendCents(bucketRecord.id, quarter.id);
expect(spendAfterApproved - spendBefore).toBe(orderTotalCents(1, 1000));
expect(spendAfterApproved - spendBefore).toBe(orderTotalCents(1, 1000, "STF"));

db.update(order).set({ status: "ordered" }).where(eq(order.id, approved.id)).run();
const spendAfterOrdered = getBucketApprovedSpendCents(bucketRecord.id, quarter.id);
Expand Down Expand Up @@ -151,7 +156,7 @@ describe("restoreGiftFundForDeletion", () => {
.returning()
.get();

const total = orderTotalCents(1, 5000);
const total = orderTotalCents(1, 5000, "Gift");
deductGiftFundForApproval(giftOrder.id, total, requester.id);

const afterDeduction = db
Expand Down
13 changes: 9 additions & 4 deletions src/lib/finance/finance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,21 @@ import {
type FundType,
} from "@/lib/db/schema";
import {
computeOrderTotalCents,
DEFAULT_ORDER_PRICING,
orderChargeCents,
stfOrderTotalCents,
type OrderPricingSettings,
} from "@/lib/finance/order-pricing";

export const GIFT_FUND_ID = 1;
export const FINANCE_SETTINGS_ID = 1;

export function orderTotalCents(quantity: number, unitCostCents: number): number {
return computeOrderTotalCents(quantity, unitCostCents, getOrderPricingSettings());
export function orderTotalCents(
quantity: number,
unitCostCents: number,
fundType: FundType
): number {
return orderChargeCents(fundType, quantity, unitCostCents, getOrderPricingSettings());
}

export function getOrderPricingSettings(): OrderPricingSettings {
Expand Down Expand Up @@ -93,7 +98,7 @@ export function getBucketApprovedSpendCents(bucketId: number, quarterId: number)
.all();

return rows.reduce(
(sum, row) => sum + computeOrderTotalCents(row.quantity, row.unitCostCents, settings),
(sum, row) => sum + stfOrderTotalCents(row.quantity, row.unitCostCents, settings),
0
);
}
Expand Down
8 changes: 6 additions & 2 deletions src/lib/finance/order-export.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { FundType, OrderStatus } from "@/lib/db/schema";
import { DEFAULT_ORDER_PRICING, type OrderPricingSettings } from "@/lib/finance/order-pricing";
import {
DEFAULT_ORDER_PRICING,
STF_PRICE_FLUX,
type OrderPricingSettings,
} from "@/lib/finance/order-pricing";

export const STF_PRICE_FLUX = 1.2;
export { STF_PRICE_FLUX };

export type OrderExportRow = {
itemName: string;
Expand Down
18 changes: 18 additions & 0 deletions src/lib/finance/order-pricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
computeOrderTotalCents,
DEFAULT_ORDER_PRICING,
displayPercentToBps,
orderChargeCents,
percentBpsToDisplay,
stfOrderTotalCents,
} from "./order-pricing";

describe("computeOrderTotalCents", () => {
Expand All @@ -23,6 +25,22 @@ describe("computeOrderTotalCents", () => {
});
});

describe("stfOrderTotalCents", () => {
it("applies flux before tax and shipping", () => {
expect(stfOrderTotalCents(2, 5000, DEFAULT_ORDER_PRICING)).toBe(15_720);
});
});

describe("orderChargeCents", () => {
it("uses the gift formula for gift orders", () => {
expect(orderChargeCents("Gift", 2, 5000, DEFAULT_ORDER_PRICING)).toBe(13_100);
});

it("uses the STF formula for STF orders", () => {
expect(orderChargeCents("STF", 2, 5000, DEFAULT_ORDER_PRICING)).toBe(15_720);
});
});

describe("percent conversions", () => {
it("converts between display percent and basis points", () => {
expect(displayPercentToBps(11)).toBe(1100);
Expand Down
25 changes: 25 additions & 0 deletions src/lib/finance/order-pricing.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const STF_PRICE_FLUX = 1.2;

export const DEFAULT_TAX_PERCENT_BPS = 1100;
export const DEFAULT_SHIPPING_PERCENT_BPS = 2000;

Expand Down Expand Up @@ -33,3 +35,26 @@ export function computeOrderTotalCents(
const shipping = Math.round((subtotal * settings.shippingPercentBps) / 10_000);
return subtotal + tax + shipping;
}

export function stfOrderTotalCents(
quantity: number,
unitCostCents: number,
settings: OrderPricingSettings = DEFAULT_ORDER_PRICING
): number {
const preTaxTotal = Math.round(orderSubtotalCents(quantity, unitCostCents) * STF_PRICE_FLUX);
const tax = Math.round((preTaxTotal * settings.taxPercentBps) / 10_000);
const shipping = Math.round((preTaxTotal * settings.shippingPercentBps) / 10_000);
return preTaxTotal + tax + shipping;
}

export function orderChargeCents(
fundType: "STF" | "Gift",
quantity: number,
unitCostCents: number,
settings: OrderPricingSettings = DEFAULT_ORDER_PRICING
): number {
if (fundType === "STF") {
return stfOrderTotalCents(quantity, unitCostCents, settings);
}
return computeOrderTotalCents(quantity, unitCostCents, settings);
}