Docs menu

Query Cookbook

Every query on this page follows the conventions - filtered by business_id, excluding cancelled documents, and using the real column names documented in the domain pages. Replace :business_id with a real business UUID before running.

Sales by branch, over a date range

SELECT b.name AS branch_name,
       COUNT(*) AS invoice_count,
       SUM(ai.grand_total) AS total_sales
FROM ar_invoices ai
JOIN branches b ON b.id = ai.branch_id
WHERE ai.business_id = :business_id
  AND ai.status IN ('posted', 'closed')
  AND ai.invoice_date BETWEEN :from_date AND :to_date
GROUP BY b.name
ORDER BY total_sales DESC;

status IN ('posted', 'closed') is deliberate, not <> 'cancelled' - a 'draft' invoice hasn’t happened yet and shouldn’t count as a sale either.

Stock on hand by item and warehouse

SELECT i.code, i.name, w.name AS warehouse_name,
       SUM(sm.quantity) AS quantity_on_hand
FROM stock_movements sm
JOIN items i ON i.id = sm.item_id
JOIN warehouses w ON w.id = sm.warehouse_id
WHERE sm.business_id = :business_id
GROUP BY i.code, i.name, w.name
HAVING SUM(sm.quantity) <> 0
ORDER BY i.code;

There’s no denormalized on-hand column - stock_movements.quantity is signed, so a plain SUM() is the correct on-hand figure. The HAVING clause drops items with zero net movement (never stocked, or fully depleted) to keep the result relevant.

A/R aging (unpaid/partial invoices by how overdue they are)

SELECT bp.name AS customer_name,
       ai.document_no,
       ai.invoice_date,
       ai.due_date,
       ai.grand_total,
       ai.paid_amount,
       (ai.grand_total - ai.paid_amount) AS balance_due,
       GREATEST(0, CURRENT_DATE - ai.due_date) AS days_overdue
FROM ar_invoices ai
JOIN business_partners bp ON bp.id = ai.customer_id
WHERE ai.business_id = :business_id
  AND ai.status IN ('posted', 'closed')
  AND ai.payment_status IN ('unpaid', 'partial')
ORDER BY days_overdue DESC;

Bucket it in your reporting layer (CASE WHEN days_overdue > 90 THEN '90+' ...) rather than in SQL if you need standard aging buckets - keeping the raw days_overdue here makes the query reusable for other groupings too.

POS voids/cancels by cashier, last 30 days

SELECT COALESCE(TRIM(u.first_name || ' ' || u.last_name), 'Unknown') AS cashier_name,
       pve.event_type,
       COUNT(*) AS event_count,
       SUM(pve.amount) AS total_amount
FROM pos_void_events pve
LEFT JOIN users u ON u.id = pve.cashier_user_id
WHERE pve.business_id = :business_id
  AND pve.created_at >= now() - interval '30 days'
GROUP BY cashier_name, pve.event_type
ORDER BY total_amount DESC;

To find only the loss-prevention-flagged events (cash was already entered before the void/cancel), add AND pve.tendered_amount IS NOT NULL - see POS and INFI AI Assistant for what that column means.

A/P Invoice 3-way match exceptions

Compares an A/P Invoice’s lines against the GRN (and, through that, the PO) they were copied from, flagging any quantity or price mismatch - exactly the check INFI’s check_ap_invoice_3way_match tool runs.

SELECT ai.document_no,
       i.name AS item_name,
       ail.quantity AS invoiced_qty,
       ail.unit_cost AS invoiced_cost,
       grnl.quantity AS received_qty,
       grnl.unit_cost AS received_cost,
       pol.quantity AS ordered_qty,
       pol.unit_price AS ordered_price
FROM ap_invoices ai
JOIN ap_invoice_lines ail ON ail.ap_invoice_id = ai.id
JOIN items i ON i.id = ail.item_id
LEFT JOIN goods_receipt_note_lines grnl ON grnl.id = ail.source_grn_line_id
LEFT JOIN purchase_order_lines pol ON pol.id = grnl.source_po_line_id
WHERE ai.business_id = :business_id
  AND ai.id = :ap_invoice_id
  AND (
    ail.quantity <> COALESCE(grnl.quantity, ail.quantity)
    OR ABS(ail.unit_cost - COALESCE(grnl.unit_cost, ail.unit_cost)) > 0.01
    OR (pol.unit_price IS NOT NULL AND ABS(ail.unit_cost - pol.unit_price) > 0.01)
  );

An A/P invoice line with no source_grn_line_id is an expense line with nothing to match - the LEFT JOIN means it simply won’t trigger any of the mismatch conditions, rather than needing to be filtered out explicitly.