DEV Community

Cover image for A restaurant month as double-entry data: prime cost, tip liabilities and delivery gross-up
Damin Mutti
Damin Mutti

Posted on Originally published at zerabooks.com

A restaurant month as double-entry data: prime cost, tip liabilities and delivery gross-up

If you model a restaurant's books as data, a handful of rules carry most of the weight. Prime cost is food cost plus beverage cost plus all labor, divided by sales. Card tips and sales tax are credits to liability accounts that should drain back toward zero. A delivery app payout is a net figure you expand back into sales and expense lines. Everything else is ordinary double entry.

I run ZeraBooks, an AI-native general ledger, and restaurant books are a good test of whether a ledger's data model is honest. Below is a small Python sketch of the parts that matter. Every figure is invented.

The accounts

Number ranges keep the chart sortable and make report sections easy to group. ZeraBooks uses 10000s for assets, 20000s for liabilities, 40000s for revenue, 50000s for cost of revenue and 60000s for operating expenses. A few restaurant accounts inside those ranges:

10100 Operating checking       21000 Tips payable
13000 Food inventory           22000 Sales tax payable
13100 Beverage inventory       23000 Gift cards outstanding
41000 Food sales               51000 Food cost
42000 Beverage sales           52000 Beverage cost
43000 Delivery sales           61000 Wages and payroll taxes
                               64000 Delivery app commissions
Enter fullscreen mode Exit fullscreen mode

Labor sits in the 60000s, which matters later: prime cost crosses two report sections.

Every entry must balance

from dataclasses import dataclass
from decimal import Decimal as D

@dataclass
class Line:
    account: str
    debit: D = D("0")
    credit: D = D("0")

def post(entry: list[Line]) -> list[Line]:
    dr = sum((l.debit for l in entry), D("0"))
    cr = sum((l.credit for l in entry), D("0"))
    if dr != cr:
        raise ValueError(f"unbalanced: {dr} debits vs {cr} credits")
    return entry
Enter fullscreen mode Exit fullscreen mode

Decimal everywhere. Floats will eventually leave you a cent out on a tax liability, and that cent will be the hardest bug of the year.

One card deposit, three owners

A $5,467.00 card deposit holds food and drink sales, 8% sales tax on them, and tips:

deposit = post([
    Line("10100 Operating checking", debit=D("5467.00")),
    Line("41000 Food sales",         credit=D("3600.00")),
    Line("42000 Beverage sales",     credit=D("925.00")),
    Line("22000 Sales tax payable",  credit=D("362.00")),   # 8% of 4,525.00
    Line("21000 Tips payable",       credit=D("580.00")),   # the remainder
])
Enter fullscreen mode Exit fullscreen mode

Revenue is $4,525.00. The other $942.00 belongs to the state and the staff. When payroll pays the tips, the reverse entry debits 21000 and credits checking, or the clearing account your payroll provider uses.

An invariant: tips payable drains each period

def tips_residual(collected: D, paid_out: D) -> D:
    return collected - paid_out

r = tips_residual(D("9450.00"), D("8810.00"))
if abs(r) > D("25.00"):
    print(f"Tips payable left {r}: tips unpaid, or a payout recorded twice")
Enter fullscreen mode Exit fullscreen mode

Here $640.00 is sitting in the liability. One period like that might be timing. A residual that grows period after period is a data bug in the books, and it's much cheaper to catch in a check than in a conversation with a server about a short paycheck. Sales tax payable has the same shape over its filing period, and its balance before filing should match the tax line on the return.

Delivery payouts: expand the net

Apps deposit the order total minus their deductions. Recording only the deposit understates sales and hides the commission:

def gross_up(p: dict) -> list[Line]:
    deductions = p["commission"] + p["promo"] + p["refunds"]
    if p["subtotal"] - deductions != p["net"]:
        raise ValueError("payout report doesn't foot")
    return post([
        Line("64000 Delivery app commissions", debit=p["commission"]),
        Line("65000 Marketing",                debit=p["promo"]),
        Line("43100 Delivery refunds",         debit=p["refunds"]),
        Line("43000 Delivery sales",           credit=deductions),
    ])

week = {"subtotal": D("3180.00"), "commission": D("636.00"), "promo": D("120.00"),
        "refunds": D("42.50"), "net": D("2381.50")}
gross_up(week)
Enter fullscreen mode Exit fullscreen mode

The $2,381.50 deposit was already booked to delivery sales. This entry adds $798.50, so sales show the full $3,180.00 and each deduction lands on its own line. In this example the app collected and remitted the sales tax, so it isn't in the payout at all. That varies by state and by app, so check the report before you assume it.

Prime cost

def cost_used(opening: D, purchases: D, closing: D) -> D:
    return opening + purchases - closing

food  = cost_used(D("6900"), D("19480"), D("7350"))   # 19,030
bev   = cost_used(D("3100"), D("4260"),  D("2980"))   # 4,380
labor = D("26700")                                    # wages, salaries, payroll taxes, benefits
sales = D("81200")

prime = (food + bev + labor) / sales
print(f"{prime:.1%}")   # 61.7%
Enter fullscreen mode Exit fullscreen mode

Food and beverage cost come from purchases adjusted by the inventory count, never purchases alone. If the closing count is missing, the function still returns a number. It's just the wrong one, and nothing in the data tells you.

A full worked month in the same pattern, with a different restaurant, the chart of accounts and every entry in plain English, is in the ZeraBooks guide to bookkeeping for restaurants. ZeraBooks is an AI-native general ledger, bank statement converter and done-for-you bookkeeping service for small businesses and accounting firms. If you'd rather hand the monthly work to someone, ZeraBooks bookkeeping services start from $150 a month (USD), priced exactly after a free call. It works from uploaded statements and reports. There's no POS or delivery app connection in the model above, and none in ours either.

The inventory count is the one input no statement can give you. Make your code refuse to compute prime cost without it.

Top comments (0)