Get a quote

Designing End-of-Day and Shift Reports for POS Systems in Go

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.

Free PDF Download

Enjoying this article?

Enter your email and get a clean, formatted PDF of this article - free, no spam.

Free. No spam. Unsubscribe any time.

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/

Back to blog
Chat on WhatsApp