HextorStore domain model
Products, variants, carts, orders, customers and discounts, plus integer-safe money arithmetic. Shared by the storefront and the dashboard.
npx shadcn@latest add @hextor/store-modelSource
/**
* The HextorStore domain model — shared by the storefront and the dashboard.
*
* One module rather than per-page types on purpose: a storefront that renders
* an order and a dashboard that edits the same order must agree on what an
* order is. Two definitions of `Order` drift the moment someone adds a field to
* one of them, and the disagreement surfaces as a support ticket, not a type
* error.
*
* Shopify's object graph is the reference for the shapes here — product with
* variants and options, orders with separate payment and fulfilment state,
* money as an amount plus a currency. Where this diverges it is noted.
*/
// ─────────────────────────── money ───────────────────────────
/**
* Money is an INTEGER in the currency's minor unit (satang, cents), never a
* float. `0.1 + 0.2 !== 0.3` is not an abstract concern in a cart: sum ten
* line items in floating point and the total a customer is charged can differ
* from the total they were shown, which is the one arithmetic bug that becomes
* a chargeback.
*
* Currencies without minor units (JPY) use `minorUnits: 0` and the amount is
* whole yen — do not assume two decimal places anywhere.
*/
export interface Money {
amount: number;
currency: CurrencyCode;
}
export type CurrencyCode = "THB" | "USD" | "EUR" | "GBP" | "JPY" | "SGD";
const MINOR_UNITS: Record<CurrencyCode, number> = {
THB: 2,
USD: 2,
EUR: 2,
GBP: 2,
JPY: 0,
SGD: 2,
};
export function money(amount: number, currency: CurrencyCode = "THB"): Money {
return { amount, currency };
}
/** Adds money of the same currency. Mixing currencies is a bug, not a conversion. */
export function addMoney(a: Money, b: Money): Money {
if (a.currency !== b.currency) {
throw new Error(`cannot add ${a.currency} to ${b.currency} without a rate`);
}
return { amount: a.amount + b.amount, currency: a.currency };
}
export function multiplyMoney(m: Money, factor: number): Money {
return { amount: Math.round(m.amount * factor), currency: m.currency };
}
export function formatMoney(m: Money, locale = "th-TH"): string {
const minor = MINOR_UNITS[m.currency];
return new Intl.NumberFormat(locale, {
style: "currency",
currency: m.currency,
minimumFractionDigits: minor,
maximumFractionDigits: minor,
}).format(m.amount / 10 ** minor);
}
// ─────────────────────────── catalogue ───────────────────────────
export type ProductStatus = "active" | "draft" | "archived";
export interface ProductImage {
url: string;
alt: string;
width: number;
height: number;
}
/**
* An option is the axis (Size); its values are the choices (S, M, L). Variants
* name one value per option. Keeping the axis separate from the selection is
* what lets a product page render a picker without inspecting every variant.
*/
export interface ProductOption {
name: string;
values: string[];
}
export interface ProductVariant {
id: string;
title: string;
sku: string;
price: Money;
/** The struck-through "was" price. Absent means not on sale — never equal to `price`. */
compareAtPrice?: Money;
/** One entry per ProductOption, in the same order. */
selectedOptions: { name: string; value: string }[];
inventoryQuantity: number;
/** Sell past zero. A variant can be purchasable while out of stock. */
continueSellingWhenOutOfStock?: boolean;
image?: ProductImage;
}
export interface Product {
id: string;
/** URL-safe identity. Stable across renames — the title is not. */
handle: string;
title: string;
description: string;
status: ProductStatus;
vendor: string;
tags: string[];
images: ProductImage[];
options: ProductOption[];
variants: ProductVariant[];
collectionIds: string[];
createdAt: string;
}
export interface Collection {
id: string;
handle: string;
title: string;
description?: string;
image?: ProductImage;
productIds: string[];
}
/** Cheapest and dearest variant, for the "from ฿X" label on a card. */
export function priceRange(product: Product): { min: Money; max: Money } {
const prices = product.variants.map((v) => v.price);
const min = prices.reduce((a, b) => (b.amount < a.amount ? b : a));
const max = prices.reduce((a, b) => (b.amount > a.amount ? b : a));
return { min, max };
}
export function isAvailable(variant: ProductVariant): boolean {
return variant.inventoryQuantity > 0 || variant.continueSellingWhenOutOfStock === true;
}
export function productAvailable(product: Product): boolean {
return product.variants.some(isAvailable);
}
// ─────────────────────────── cart ───────────────────────────
export interface CartLine {
id: string;
productId: string;
variantId: string;
/** Denormalised for rendering: a cart must survive a product being unpublished. */
title: string;
variantTitle: string;
image?: ProductImage;
unitPrice: Money;
quantity: number;
}
export interface Cart {
id: string;
lines: CartLine[];
currency: CurrencyCode;
discountCode?: string;
}
export interface CartTotals {
subtotal: Money;
discount: Money;
shipping: Money;
tax: Money;
total: Money;
}
// ─────────────────────────── orders ───────────────────────────
/**
* Payment and fulfilment are separate axes, as in Shopify. An order can be paid
* and unfulfilled, or fulfilled and refunded. Collapsing them into one "status"
* enum is the modelling mistake that makes every reporting query wrong later.
*/
export type PaymentStatus =
| "pending"
| "authorized"
| "paid"
| "partially_refunded"
| "refunded"
| "voided";
export type FulfillmentStatus =
| "unfulfilled"
| "partially_fulfilled"
| "fulfilled"
| "returned"
| "cancelled";
export interface Address {
name: string;
line1: string;
line2?: string;
city: string;
province?: string;
postalCode: string;
country: string;
phone?: string;
}
export interface OrderLine {
id: string;
productId: string;
variantId: string;
title: string;
variantTitle: string;
image?: ProductImage;
unitPrice: Money;
quantity: number;
fulfilledQuantity: number;
}
export interface Order {
id: string;
/** What the customer quotes on the phone. Sequential and human-sized. */
number: string;
customerId?: string;
email: string;
lines: OrderLine[];
totals: CartTotals;
paymentStatus: PaymentStatus;
fulfillmentStatus: FulfillmentStatus;
shippingAddress?: Address;
billingAddress?: Address;
note?: string;
createdAt: string;
}
// ─────────────────────────── customers ───────────────────────────
export interface Customer {
id: string;
name: string;
email: string;
phone?: string;
/** Marketing consent is opt-in and its absence is not consent. */
acceptsMarketing: boolean;
ordersCount: number;
totalSpent: Money;
defaultAddress?: Address;
createdAt: string;
}
// ─────────────────────────── discounts ───────────────────────────
export type DiscountKind = "percentage" | "fixed_amount" | "free_shipping";
export interface Discount {
id: string;
code: string;
kind: DiscountKind;
/** Percent (0-100) for `percentage`; minor units for `fixed_amount`. */
value: number;
minimumSubtotal?: Money;
usageLimit?: number;
usageCount: number;
startsAt: string;
endsAt?: string;
active: boolean;
}
// ─────────────────────────── totals ───────────────────────────
export function lineTotal(line: { unitPrice: Money; quantity: number }): Money {
return multiplyMoney(line.unitPrice, line.quantity);
}
export function subtotal(lines: { unitPrice: Money; quantity: number }[], currency: CurrencyCode): Money {
return lines.reduce((sum, l) => addMoney(sum, lineTotal(l)), money(0, currency));
}
/**
* Why a code was rejected. Returned rather than swallowed because "that code
* didn't work" is the message that makes people abandon a cart — telling them
* they need ฿200 more, or that the sale ended yesterday, is a different
* conversation.
*/
export type DiscountIneligibility =
| "inactive"
| "not_started"
| "expired"
| "usage_limit_reached"
| "below_minimum";
/**
* Full eligibility, in one place. `null` means the code applies.
*
* Every field on `Discount` that can disqualify a code is checked here. An
* earlier version of `cartTotals` checked only `active` while the type also
* carried `minimumSubtotal`, dates and a usage limit — which is worse than
* checking nothing, because a merchant setting a spend threshold would watch it
* be ignored on every cart below it with no error anywhere.
*
* `now` is a parameter, not `new Date()` inside: a totals calculation that
* reads the clock cannot be tested, and renders differently on the server and
* the client for a code that expires between the two.
*/
export function discountIneligibility(
discount: Discount,
subtotalAmount: Money,
now: Date,
): DiscountIneligibility | null {
if (!discount.active) return "inactive";
if (new Date(discount.startsAt) > now) return "not_started";
if (discount.endsAt && new Date(discount.endsAt) <= now) return "expired";
if (discount.usageLimit !== undefined && discount.usageCount >= discount.usageLimit) {
return "usage_limit_reached";
}
if (
discount.minimumSubtotal &&
subtotalAmount.amount < discount.minimumSubtotal.amount
) {
return "below_minimum";
}
return null;
}
/**
* Discount is computed against the subtotal and floored at it — a fixed-amount
* code worth more than the cart must not produce a negative total or, worse, a
* refund. Shipping and tax are inputs rather than derivations because both are
* jurisdiction-specific and belong to whatever backend you plug in; a template
* that invented tax rules would be wrong everywhere it shipped.
*
* Pass `now` to make the result deterministic. It defaults to the current time
* so the common call stays short, but any server-rendered total should pass the
* request's timestamp so the server and the client agree.
*/
export function cartTotals(
lines: { unitPrice: Money; quantity: number }[],
currency: CurrencyCode,
opts: { discount?: Discount; shipping?: Money; tax?: Money; now?: Date } = {},
): CartTotals {
const sub = subtotal(lines, currency);
const shipping = opts.shipping ?? money(0, currency);
const now = opts.now ?? new Date();
const eligible =
opts.discount !== undefined &&
discountIneligibility(opts.discount, sub, now) === null;
let discountAmount = 0;
if (eligible && opts.discount) {
if (opts.discount.kind === "percentage") {
discountAmount = Math.round((sub.amount * opts.discount.value) / 100);
} else if (opts.discount.kind === "fixed_amount") {
discountAmount = opts.discount.value;
}
}
const discount = money(Math.min(discountAmount, sub.amount), currency);
const freeShipping = eligible && opts.discount?.kind === "free_shipping";
const shippingCharged = freeShipping ? money(0, currency) : shipping;
const tax = opts.tax ?? money(0, currency);
return {
subtotal: sub,
discount,
shipping: shippingCharged,
tax,
total: money(sub.amount - discount.amount + shippingCharged.amount + tax.amount, currency),
};
}
Docs
One module rather than per-page types: a storefront rendering an order and a dashboard editing it must agree on what an order is. Two definitions drift the moment someone adds a field to one, and the disagreement shows up as a support ticket, not a type error.
Money is an INTEGER in the currency minor unit, never a float. Summing ten line items in floating point can make the total charged differ from the total shown — the one arithmetic bug that becomes a chargeback. Currencies without minor units (JPY) carry 0 decimals, so do not assume two anywhere.
Payment and fulfilment are separate axes, as in Shopify. An order can be paid and unfulfilled, or fulfilled and refunded. Collapsing them into one status enum is the modelling mistake that makes every later reporting query wrong.
Shipping and tax are inputs to cartTotals, not derivations: both are jurisdiction-specific and belong to your backend. A template that invented tax rules would be wrong everywhere it shipped.
Discount eligibility lives in one function, discountIneligibility(), and cartTotals() refuses a code it rejects. An earlier version checked only active while the type also carried a minimum subtotal, a date window and a usage limit — partial enforcement is worse than none, because a merchant setting a spend threshold watches it be honoured nowhere with no error to notice. The function returns WHY a code failed rather than a boolean: "that code did not work" is the message that makes people abandon a cart.
now is a parameter, never new Date() inside. A totals calculation that reads the clock cannot be tested, and renders differently on the server and the client for a code that expires between the two.