End-of-day and shift summary reports are the most operationally critical feature of any POS system. If the numbers disagree with the payment terminal printout, operations stop. Getting shift reports wrong destroys trust faster than any other POS failure.
End-of-day and shift summary reports are the most operationally critical feature of any POS system. Restaurant managers close shifts twice a day. Cashiers hand over between shifts. Accountants reconcile daily totals against payment terminal receipts. If the POS shift report disagrees with the payment terminal printout, operations stop and someone spends an hour investigating. Getting shift reports wrong destroys trust in the system faster than any other category of POS failure.
What a shift report actually needs to contain
The managers of Lebanese restaurants and retail stores who use POS systems every day have a clear mental model of what a shift summary must show. It is not just a list of transactions. It is a reconciliation tool. The shift report must answer:
- How much cash should be in the drawer?
- How much was taken by card, and does that match the terminal total?
- How many voids were processed, and who authorized them?
- What discounts were applied, and to whom?
- How does today's shift compare to the same shift last week?
A useful shift report includes: gross sales by category, discounts and their types, voids and refunds with operator identification, net sales after discounts and voids, sales by payment method, expected cash in drawer, transaction count and average transaction value, and an hourly sales breakdown.
Designing the shift data model
A shift is a bounded time window owned by a cashier or manager, attached to a specific terminal and location:
CREATE TABLE shifts (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL,
location_id bigint NOT NULL,
terminal_id bigint NOT NULL,
cashier_id bigint NOT NULL,
opened_by bigint NOT NULL,
closed_by bigint,
opened_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz,
opening_float decimal(12, 2) NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'open'
);
CREATE TABLE shift_summaries (
id bigserial PRIMARY KEY,
shift_id bigint NOT NULL REFERENCES shifts(id),
tenant_id bigint NOT NULL,
gross_sales decimal(12, 2) NOT NULL,
total_discounts decimal(12, 2) NOT NULL,
total_voids decimal(12, 2) NOT NULL,
total_refunds decimal(12, 2) NOT NULL,
net_sales decimal(12, 2) NOT NULL,
cash_sales decimal(12, 2) NOT NULL,
card_sales decimal(12, 2) NOT NULL,
other_sales decimal(12, 2) NOT NULL,
transaction_count int NOT NULL,
void_count int NOT NULL,
refund_count int NOT NULL,
expected_cash_in_drawer decimal(12, 2) NOT NULL,
computed_at timestamptz NOT NULL DEFAULT now()
);
The shift_summaries table stores precomputed values at the time of closing. Never recompute shift totals from raw transactions after closing. The summary is the authoritative record of what happened during that shift.
Computing shift totals correctly
The most common mistake in shift report calculation is handling voids incorrectly. A void cancels a transaction entirely. A refund is a separate negative transaction. The math is different:
type ShiftTotals struct {
GrossSales decimal.Decimal
TotalDiscounts decimal.Decimal
TotalVoids decimal.Decimal
TotalRefunds decimal.Decimal
NetSales decimal.Decimal
CashSales decimal.Decimal
CardSales decimal.Decimal
OtherSales decimal.Decimal
TransactionCount int
VoidCount int
RefundCount int
}
for rows.Next() {
var order Order
rows.Scan(&order)
if order.Status == "voided" {
// Void: track separately but do not count as gross sales
totals.TotalVoids = totals.TotalVoids.Add(order.Total)
totals.VoidCount++
continue
}
if order.Status == "refunded" {
totals.TotalRefunds = totals.TotalRefunds.Add(order.Total)
totals.RefundCount++
}
totals.GrossSales = totals.GrossSales.Add(order.Subtotal)
totals.TotalDiscounts = totals.TotalDiscounts.Add(order.DiscountAmount)
totals.TransactionCount++
switch order.PaymentMethod {
case "cash":
totals.CashSales = totals.CashSales.Add(order.Total)
case "card":
totals.CardSales = totals.CardSales.Add(order.Total)
default:
totals.OtherSales = totals.OtherSales.Add(order.Total)
}
}
totals.NetSales = totals.GrossSales.
Sub(totals.TotalDiscounts).
Sub(totals.TotalVoids).
Sub(totals.TotalRefunds)
Closing a shift atomically
Closing a shift is a two-step operation: compute totals and record the closed state. These must happen in a single transaction. If the system crashes between computing and recording, the shift remains open and can be closed again with fresh totals:
func (s *ShiftService) CloseShift(ctx context.Context, shiftID int64, closedByID int64) (*ShiftSummary, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
// Lock the shift row to prevent concurrent close attempts
var shift Shift
err = tx.QueryRowContext(ctx, `
SELECT id, status, opened_at, opening_float, terminal_id
FROM shifts WHERE id = $1 AND tenant_id = $2
FOR UPDATE
`, shiftID, s.tenantID).Scan(&shift.ID, &shift.Status, &shift.OpenedAt,
&shift.OpeningFloat, &shift.TerminalID)
if err != nil {
return nil, err
}
if shift.Status == "closed" {
return nil, ErrShiftAlreadyClosed
}
totals, err := s.computeTotalsInTx(ctx, tx, shiftID)
if err != nil {
return nil, err
}
expectedCash := shift.OpeningFloat.Add(totals.CashSales)
_, err = tx.ExecContext(ctx, `
INSERT INTO shift_summaries
(shift_id, tenant_id, gross_sales, total_discounts, total_voids,
total_refunds, net_sales, cash_sales, card_sales, other_sales,
transaction_count, void_count, refund_count, expected_cash_in_drawer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
`, shiftID, s.tenantID, totals.GrossSales, totals.TotalDiscounts,
totals.TotalVoids, totals.TotalRefunds, totals.NetSales,
totals.CashSales, totals.CardSales, totals.OtherSales,
totals.TransactionCount, totals.VoidCount, totals.RefundCount, expectedCash)
if err != nil {
return nil, err
}
_, err = tx.ExecContext(ctx, `
UPDATE shifts SET status = 'closed', closed_at = now(), closed_by = $1 WHERE id = $2
`, closedByID, shiftID)
if err != nil {
return nil, err
}
return &ShiftSummary{Totals: totals, ExpectedCash: expectedCash}, tx.Commit()
}
Handling multiple payment methods and tender types
In Lebanese restaurants, the payment landscape is fragmented. A single shift may include cash in USD, cash in LBP, credit card through a local acquirer, mobile payment through a digital wallet app, and employee tab deductions. Each payment method has different operational implications for reconciliation.
Model payment tenders as a separate table with one row per payment method per order:
CREATE TABLE order_tenders (
id bigserial PRIMARY KEY,
order_id bigint NOT NULL,
shift_id bigint NOT NULL,
method text NOT NULL, -- 'cash_usd', 'cash_lbp', 'card_visa', 'wallet_app'
amount decimal(12, 2) NOT NULL,
currency char(3) NOT NULL DEFAULT 'USD',
exchange_rate decimal(10, 6),
reference text
);
The shift summary aggregates by tender type, giving the manager a per-method reconciliation line. This is the format accountants and POS auditors in Lebanon expect to see.
Operational lessons from Lebanese restaurant clients
Three patterns appear consistently across restaurant POS deployments in Lebanon.
Managers close shifts at irregular times. A shift may run for 6 hours or 14 hours depending on how busy the restaurant is. The shift data model must handle open shifts across midnight without corrupting daily totals. Index on opened_at and closed_at separately, and compute daily aggregates from shift summaries rather than from raw orders.
Void authorization is a constant source of operational friction. Voids must require a manager PIN or supervisor override. Every void must log who authorized it. During a busy Friday evening service, a cashier who discovers they rang up the wrong table will be tempted to void without authorization if the system lets them. It must not.
Connectivity drops during shift close. Lebanese networks are unreliable. The shift close operation must be idempotent: if the POS terminal loses connectivity mid-request and the manager presses close again, the second close attempt must detect the already-closed state and return the existing summary without recomputing.
Key lessons from production
Precompute and store shift summaries at close time. Never recompute from raw transactions. Handle voids and refunds as distinct ledger types. Use SELECT FOR UPDATE to prevent concurrent close attempts. Support multi-currency tender tracking from day one. Build void authorization into the system, not as a later addition.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
Voxire builds POS and operational software for restaurants and retailers in Lebanon and across MENA. If your team needs a reliable shift management and reconciliation system, reach out at https://voxire.com/get-a-quote/



