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
26 changes: 12 additions & 14 deletions docs/reference/api.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions docs/reference/daily-sales-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Backend owns all logic; the frontend never computes totals.
- **`net_collected = gross_collected - refunds_issued`.**
- **Counts:** both `order_count` (distinct orders) and `paid_bill_count`.
- **Payment methods:** `paymentMethodBreakdown(paidOnly, refunds-by-created_at, keyByPaidAt)` so `Σ payment totals = net_collected`. Only totals are exported (no per-method `count` - that count covers payment+refund lines, not payments). Summary keys use stable snake_case (`payment_visa_terminal_2` for `Visa Terminal 2`).
- **Items:** products on paid bills, split via `bill_items`; exclude `status IN ('cancelled','voided','void_adjustment')`; include `refunded` originals (they were collected; cash reversal lives only in `refunds_issued`). Group by `product_id` + snapshot `product_name` + `product_sku`. Add-ons fold into the parent item. Order-level discounts are Summary-only.
- **Items:** products on paid bills, split via `bill_items`; exclude `status IN ('cancelled','voided','void_adjustment')`; include `refunded` originals (they were collected; cash reversal lives only in `refunds_issued`). Group by `product_id` + snapshot `product_name` + `product_sku`. Add-ons fold into the parent item. Order-level discounts are Summary-only. Ingredient cost, profit, and margin use frozen `order_items.ingredient_cost` on lines with `ingredient_cost_known = 1` (allocated by `bi.quantity / oi.quantity` on split bills) against ex-tax net item sales of those known-cost lines (`known_cost_net_item_sales`); `cost_status` and `cost_coverage_status` are `known`, `partial`, or `unknown` (when `unknown`, cost, profit, and margin are `null`; see [product invariants](product-invariants.md#supplies-stock-and-recipe-depletion)).
- **Payments:** one row per `bills.payment_details` ledger line of every bill paid in the window, including lines recorded on an earlier day for a bill settled in this one. `payment_time` is the line's timestamp in the store timezone (`YYYY-MM-DD HH:MM:SS`); `method` resolves custom methods to their current name; `transaction_id` is the stored external reference verbatim (empty when none), so card lines can be matched against the card terminal's batch report. Refunds are not rows here.
- **`discount_total`** = order-level (`bills.discount_amount`) + item-level discounts.
- **No** tips, no alternate refund attribution, no `net_ex_tax` metric.
Expand All @@ -45,17 +45,17 @@ Backend owns all logic; the frontend never computes totals.

## File formats

- **XLSX:** sheets `Summary`, `Items`, then `Payments`. Money cells are numeric with `moneyNumFmt(fractionDigits)`; no formulas. Frozen header row.
- **XLSX:** sheets `Summary`, `Items`, then `Payments`. Money cells are numeric with `moneyNumFmt(fractionDigits)` and percentage cells (`*_percent`) use `'0.00'`; `null` cost, profit, and margin values are empty string `""`; no formulas. Frozen header row.
- **CSV:** stable English snake_case headers; ASCII `.` decimals; formula-neutralized via shared `toCsvRow`.
- **Filenames:** `daily-sales-YYYY-MM-DD.xlsx`, `daily-sales-YYYY-MM-DD-summary.csv`, `daily-sales-YYYY-MM-DD-items.csv`, `daily-sales-YYYY-MM-DD-payments.csv` (from `dailySalesExportFilename`).

### Summary metric field order

`business_date`, `timezone`, `business_day_start`, `currency`, `order_count`, `paid_bill_count`, `gross_collected`, `refunds_issued`, `net_collected`, `tax_total`, `discount_total`, `service_charge_total`, `packaging_charge_total`, `delivery_charge_total`, then one `payment_<method>` total per method (snake_case key via `dailySalesPaymentMetricKey`).
`business_date`, `timezone`, `business_day_start`, `currency`, `order_count`, `paid_bill_count`, `gross_collected`, `refunds_issued`, `net_collected`, `tax_total`, `discount_total`, `service_charge_total`, `packaging_charge_total`, `delivery_charge_total`, `known_cost_net_item_sales`, `ingredient_cost_total`, `gross_profit_total`, `gross_margin_percent`, `cost_coverage_status`, then one `payment_<method>` total per method (snake_case key via `dailySalesPaymentMetricKey`).

### Items columns

`product_id`, `product_name`, `product_sku`, `quantity`, `gross_item_sales`, `item_discounts`, `net_item_sales`, `tax_amount`.
`product_id`, `product_name`, `product_sku`, `quantity`, `gross_item_sales`, `item_discounts`, `net_item_sales`, `tax_amount`, `ingredient_cost`, `profit`, `margin_percent`, `cost_status`.

### Payments columns

Expand Down
32 changes: 28 additions & 4 deletions docs/reference/product-invariants.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,16 +326,39 @@ tables (`supplies`, `supply_movements`) with a signed ledger, separate from prod
to later sales after a recipe becomes active. Portions must be positive finite numbers; catalog
writes and database imports reject invalid supplied values, and recipe snapshot creation rejects
an invalid portion before depletion.
8. **Supply purchase costs use Weighted Average Cost (WAC) per base unit and stay separate from the
expenses ledger.** A supply's `unit_cost` is stored in major currency units per `base_unit`
(rounded to 10 decimal places so sub-minor-unit gram and millilitre costs never collapse to zero,
including 0-decimal currencies such as JPY). When a `receive` movement includes a batch cost
(`unit_cost` per movement unit or `total_cost` for the batch), it is normalized to `base_unit`.
If `stock_before <= 0` or `supplies.unit_cost IS NULL`, `unit_cost` resets to the new batch cost;
otherwise it blends by WAC (`(stock_before * old_cost + delta * batch_cost) / stock_after`).
Unpriced `receive` movements leave `supplies.unit_cost` unchanged. Recording a supply receive cost
updates inventory valuation only and never creates an `expenses` row.
9. **Order-item ingredient cost is recipe-derived, frozen at sale time, and never understated.**
When an order line is created, `buildRecipeSnapshot()` computes the line's ingredient cost from
its active recipe and variant `recipe_multiplier`, rounds the line total to the tenant currency's
fraction digits (`0`, `2`, or `3`), and freezes `ingredient_cost` and `ingredient_cost_known` on
`order_items` alongside `recipe_snapshot`. If a product has no active recipe or **any** recipe
component supply has `unit_cost IS NULL`, `ingredient_cost` is `NULL` and `ingredient_cost_known`
is `0` ("cost unknown" - never a partial cost and never 100% margin); legacy `products.cost` is
not used. Subsequent supply cost or recipe edits never alter existing `order_items`, and pending
or started quantity reductions scale the frozen cost from the line's held `recipe_snapshot`. In
sales reports and daily sales exports (gated by `reports.financial.view` and
`reports.daily-sales.export`), profit and margin are computed strictly over ex-tax net item sales
of known-cost lines (`known_cost_revenue`).

**Reason:** POS availability during a stockout matters more than ledger neatness. A store that
cannot sell because flour has not been counted in yet is worse than a negative row to reconcile
later. Snapshots keep historical cancellations correct under recipe edits, mirroring the existing
`tax_snapshot` pattern.
later. Snapshots keep historical cancellations and historical profitability reports accurate under
recipe or supply cost edits, mirroring the existing `tax_snapshot` pattern.

**Enforced by:** `applySupplyStockChange()` in `main/services/supplies.ts`, which does not clamp;
`buildRecipeSnapshot()` and `applyRecipeSnapshot()` in `main/services/recipes.ts`; the order
`buildRecipeSnapshot()`, `scaleRecipeSnapshot()`, and `applyRecipeSnapshot()` in
`main/services/recipes.ts`; the order
creation, item append, order and item cancel, item edit, and item restore paths in
`main/routes/orders.ts`. Restore acts only on a non-superseded `cancelled` item, re-deducts its
`main/routes/orders.ts`; and profitability aggregation in `main/routes/reports.ts` and
`main/services/daily-sales-export.ts`. Restore acts only on a non-superseded `cancelled` item, re-deducts its
inventory and recipe components, and returns it to `pending`; a `voided` or superseded item is
never restored. The
portion comes from
Expand All @@ -348,6 +371,7 @@ before depletion. The migration gives existing variants a default portion of 1 i

```sh
npm run test:recipe-order-lifecycle # deplete, restore, void-no-restore, snapshot immutability
npm run test:ingredient-cost # WAC, negative-stock reset, frozen cost, profitability reports
npm run test:order-item-edit # pending vs started item edits, waste, superseded restore rejection
npm run test:supplies-service # negative stock, ledger, pagination
npm run test:product-variants-orders # half / whole / double portions across create, append, void
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/database-export-import.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ test.describe('Database Settings JSON Export and Import', () => {

const contaminatedPayload = {
app: 'FloDesktop',
schema_version: '116',
schema_version: '117',
data: {
settings: [],
categories: [],
Expand Down
125 changes: 117 additions & 8 deletions frontend/src/app/(dashboard)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ interface RefundActivity {
approved_by_name: string;
}

interface PeriodProfitability {
total_quantity: number;
net_revenue: number;
known_cost_revenue: number;
known_cost_quantity: number;
unknown_cost_quantity: number;
cost_status: 'known' | 'partial' | 'unknown';
ingredient_cost: number | null;
profit: number | null;
margin_percent: number | null;
}

interface FinancialSummary {
startDate: string;
endDate: string;
Expand All @@ -101,14 +113,23 @@ interface FinancialSummary {
averageOrderValue: number;
paymentMethods: PaymentMethodBreakdown[];
refunds: RefundActivity[];
profitability?: PeriodProfitability;
}

interface TopProduct {
product_id: number;
product_id: number | string;
product_name: string;
total_quantity: number;
total_revenue: number;
order_count: number;
net_revenue?: number;
known_cost_revenue?: number;
known_cost_quantity?: number;
unknown_cost_quantity?: number;
cost_status?: 'known' | 'partial' | 'unknown';
ingredient_cost?: number | null;
profit?: number | null;
margin_percent?: number | null;
}

interface RecentOrder {
Expand Down Expand Up @@ -811,6 +832,57 @@ export default function DashboardPage() {
</div>
)}

{canViewFinancials && financialSummary?.profitability && (
<div className="mt-5 grid grid-cols-1 overflow-hidden rounded-2xl border border-border bg-card shadow-sm sm:grid-cols-3">
<div className="flex items-center gap-3 p-4 sm:p-5">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300">
<ChefHat size={18} />
</span>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{t('ingredientCost')}</p>
<p className="mt-1 text-lg font-semibold text-foreground">
{financialSummary.profitability.ingredient_cost !== null
? fmt(Number(financialSummary.profitability.ingredient_cost))
: t('costUnknown')}
</p>
</div>
</div>
<div className="flex items-center gap-3 border-t border-border p-4 sm:border-s sm:border-t-0 sm:p-5">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300">
<TrendingUp size={18} />
</span>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{t('grossProfit')}</p>
<p className="mt-1 text-lg font-semibold text-foreground">
{financialSummary.profitability.profit !== null
? fmt(Number(financialSummary.profitability.profit))
: t('costUnknown')}
</p>
</div>
</div>
<div className="flex items-center gap-3 border-t border-border p-4 sm:border-s sm:border-t-0 sm:p-5">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
<BarChart3 size={18} />
</span>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{t('grossMargin')}</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<p className="text-lg font-semibold text-foreground">
{financialSummary.profitability.margin_percent !== null
? `${financialSummary.profitability.margin_percent}%`
: t('costUnknown')}
</p>
{financialSummary.profitability.cost_status === 'partial' && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-950/60 dark:text-amber-300">
{t('partialCost')}
</span>
)}
</div>
</div>
</div>
</div>
)}

<div className="mt-5 grid grid-cols-1 gap-5 lg:grid-cols-2">
{/* Recent Orders */}
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
Expand Down Expand Up @@ -912,22 +984,59 @@ export default function DashboardPage() {
{topProducts.map((product) => (
<div
key={product.product_id}
className="flex items-center justify-between px-5 py-3"
className="flex items-center justify-between gap-3 px-5 py-3"
>
<div className="min-w-0">
<span className="text-sm font-medium text-foreground">
{product.product_name}
</span>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">
{product.product_name}
</span>
{canViewFinancials && product.cost_status === 'partial' && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-950/60 dark:text-amber-300">
{t('partialCost')}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{t('productSoldOrders', {
quantity: product.total_quantity,
orders: product.order_count,
})}
</p>
</div>
<span className="text-sm font-semibold text-foreground shrink-0">
{fmt(Number(product.total_revenue))}
</span>
<div className="shrink-0 text-end">
<span className="text-sm font-semibold text-foreground">
{fmt(Number(product.total_revenue))}
</span>
{canViewFinancials && product.cost_status && (
<div className="text-xs text-muted-foreground">
{product.cost_status === 'unknown' ||
product.ingredient_cost === null ||
product.ingredient_cost === undefined ? (
<span>{t('costUnknown')}</span>
) : (
<span>
{t('ingredientCostLabel', {
amount: fmt(Number(product.ingredient_cost)),
})}
{product.profit !== null && product.profit !== undefined && (
<>
{' · '}
{t('profitMarginLabel', {
profit: fmt(Number(product.profit)),
margin:
product.margin_percent !== null &&
product.margin_percent !== undefined
? `${product.margin_percent}%`
: '—',
})}
</>
)}
</span>
)}
</div>
)}
</div>
</div>
))}
</div>
Expand Down
Loading
Loading