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
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
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
])
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")
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)
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%
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)