Tip Pool & Distribution
How Muse & Co fairly splits tips among the people who were actually working when each order came in.
TL;DR — The Fair-Split Rule
Every tipped order is split equally among the people on shift at the minute that order was placed.
The Owner is assumed to be on shift by default — we're a mom-and-pop shop, and the Owner is almost always working the floor, the back office, or both. The one exception: if the Owner didn't clock in that day, they're treated as off-shift and excluded from that day's pool.
This is different from a flat "split the day's pool by hours worked." We do it per-order, so:
- A morning rush tip is split only among morning staff.
- An evening rush tip is split only among evening staff.
- If you closed alone, you keep 100% of the late-night tips (plus the owner's share).
How It Works (Step-by-Step)
Every day at 10:00 AM ET, an automatic job runs for yesterday's tips:
- Pull every tipped order from yesterday. Any order where
tip > 0and status iscompleted,confirmed, orready. - Pull yesterday's schedule. From
employee_shifts— the confirmed schedule. Only rolesADMIN,MANAGER,STAFF,BARISTAare eligible (ARTIST and KIOSK are excluded). - For each order, find who was working at that minute. The order's timestamp is converted to Eastern Time, then to minutes-since-midnight, and matched against each shift's
start_time→end_timewindow. - Add the Owner if they were on shift. The system looks for an Owner clock-in (
time_entries) on that date.- If the Owner clocked in → added to every order's eligible list, whether or not they had a scheduled shift row. This is the mom-and-pop rule: the Owner is presumed on shift whenever they're here, regardless of schedule paperwork.
- If the Owner did not clock in → they're treated as off and get no share for that day.
- Divide the tip equally.
per_person = round(order.tip / split_count, 2 decimals). Any leftover pennies from rounding go to the Owner if they're in the pool; otherwise to the first eligible staff on the order. Totals always reconcile to the original tip exactly. - Sum each person's shares across the day. Each staff member gets one row in
tip_distributionswith their total for that day. - Record the daily pool. One row in
tip_pool_dailywith the day's grand total, order count, staff count, and statuscalculated.
The cron is idempotent — tip_pool_daily.date is unique, so re-running won't double-count.
A Worked Example
Yesterday had three tipped orders, and the Owner clocked in at 10:30a:
| Order | Time | Tip | Who was scheduled |
|---|---|---|---|
| #101 | 11:05a | $4.00 | Alice (11–4) |
| #102 | 1:30p | $6.00 | Alice (11–4), Bob (12–8) |
| #103 | 7:45p | $5.00 | Bob (12–8) |
Owner clocked in → added to every order. Splits:
- #101: Alice + Owner → $2.00 each
- #102: Alice + Bob + Owner → $2.00 each
- #103: Bob + Owner → $2.50 each
Day totals:
- Alice: $2.00 + $2.00 = $4.00
- Bob: $2.00 + $2.50 = $4.50
- Owner: $2.00 + $2.00 + $2.50 = $6.50
- Sum: $15.00 ✓ (matches $4 + $6 + $5)
Variant — Owner didn't clock in: Same three orders, but no Owner clock-in for the day.
- #101: Alice → $4.00
- #102: Alice + Bob → $3.00 each
- #103: Bob → $5.00
- Alice $7.00, Bob $8.00, Owner $0.00
If a tip is $5.01 split 3 ways, each person rounds to $1.67 and the extra penny goes to the Owner (if in the pool) or to the first eligible staff on the order otherwise.
Marking Tips as Paid
Distributions start as unpaid (paid_at IS NULL). Admins and Managers use the Payroll page in the staff portal:
- Filter to a date range and a staff member.
- Select unpaid rows.
- Open the Mark Paid modal, choose a payment method (cash / Venmo / Cashapp / payroll), and confirm.
The endpoint uses an atomic gate (WHERE paid_at IS NULL) so two managers can't accidentally pay the same row twice. Per-row cap: $10,000. Per-batch cap: $100,000.
What If the Schedule Is Wrong?
The job uses the scheduled shifts, not actual clock-in/out. If someone:
- Called out and isn't replaced in the schedule → they'll still get a share. Fix the schedule before 10 AM the next day.
- Picked up an extra shift that wasn't in the system → they get no share. Add the shift retroactively, then re-run distribution (or contact an admin to adjust).
Adjustments after the fact: update tip_distributions directly, or void the pool and re-distribute. The pool's status field can be flipped to adjusted to mark that a human touched it.
Why This Design?
- Per-order > per-day pool. Rewards being there at busy times. A barista who works the dead 2pm hour shouldn't get the same share as the one who closed a Saturday rush.
- Schedule, not clock punches. Simpler, predictable, and avoids fights over a forgotten clock-out. The trade-off is the schedule has to be accurate.
- Owner is presumed on shift, but not forced. The Owner is on-site nearly every day — bookkeeping, supply runs, customer service, covering breaks — so they're treated as on shift without needing a schedule row. The clock-in check is the explicit way to say "I wasn't here today, don't count me." No clock-in, no share.
For Engineers — Implementation Reference
Tip Capture
| File | What it does |
|---|---|
muse-customer/functions/api/webhooks/stripe-orders.js | Captures session.metadata.tip_amount from Stripe and writes it to orders.tip when payment succeeds. |
muse-customer/functions/api/pos/complete-order.js | POS orders already have tip set when the order row is created; complete-order just flips status. |
Tips live on the orders table:
orders (
id TEXT PRIMARY KEY,
tip REAL DEFAULT 0,
created_at DATETIME,
status TEXT, -- 'completed' | 'confirmed' | 'ready' counted
...
)Schedule (Source of Truth for Eligibility)
muse-admin/migrations/017_schedule_redesign.sql:
employee_shifts (
staff_id TEXT,
date TEXT, -- 'YYYY-MM-DD'
start_time TEXT, -- 'HH:MM'
end_time TEXT, -- 'HH:MM'
status TEXT, -- 'confirmed' | 'pending'
...
)Times are stored as HH:MM strings and converted to minutes-since-midnight at compute time. The order's created_at is converted from UTC to ET before matching. (See Workers timezone rule — never new Date('YYYY-MM-DD') in Workers.)
Distribution Algorithm
muse-customer/functions/api/admin/tips/distribute.js (lines 184–292) and muse-customer/functions/scheduled.ts (lines 113–208).
Pseudocode of the core loop:
const OWNER_ID = env.TIP_OWNER_STAFF_ID || 'staff-pinghui-001';
// "Logged in" signal: did the Owner clock in for this date?
const ownerOnShift = await db.prepare(
`SELECT 1 FROM time_entries WHERE staff_id = ? AND date(clock_in) = ? LIMIT 1`
).bind(OWNER_ID, date).first();
for (const order of tippedOrders) {
const orderMinute = etMinutesSinceMidnight(order.created_at);
const eligible = new Map(); // staff_id → info
for (const s of shifts) {
if (orderMinute >= s.startMin && orderMinute < s.endMin) eligible.set(s.staff_id, s);
}
// Mom-and-pop rule: Owner is presumed on shift, but only if they clocked in.
if (ownerOnShift && !eligible.has(OWNER_ID)) eligible.set(OWNER_ID, ownerRow);
if (eligible.size === 0) continue;
const perPerson = Math.round((order.tip / eligible.size) * 100) / 100;
const remainder = Math.round((order.tip - perPerson * eligible.size) * 100) / 100;
// Remainder goes to Owner if present, else the first eligible staff.
const remainderId = eligible.has(OWNER_ID) ? OWNER_ID : eligible.keys().next().value;
for (const staffId of eligible.keys()) {
const share = staffId === remainderId ? perPerson + remainder : perPerson;
staffTotals[staffId] = (staffTotals[staffId] || 0) + share;
}
}Three things to notice:
OWNER_IDis configurable via theTIP_OWNER_STAFF_IDPages env var, withstaff-pinghui-001as the fallback. Update there if ownership ever changes — no code edit needed.- The Owner-on-shift check is a single
time_entries.clock_inlookup for the date. Cheap, indexed bystaff_id, runs once per distribution (not per order). - The remainder routes to the Owner when present; otherwise to the first eligible staff. This keeps the sum of distributions exactly equal to the sum of order tips — no drift.
Storage
muse-customer/migrations/093_tip_distributions.sql and 0122_tip_payouts.sql:
tip_pool_daily (
id, date UNIQUE, -- prevents double-distribution
total_tips, order_count, staff_count, per_person_amount,
status, -- 'calculated' | 'finalized' | 'adjusted'
finalized_by, finalized_at, notes
)
tip_distributions (
id, pool_id, date, staff_id, staff_name,
share_amount, hours_worked,
source, -- 'auto' | 'manual'
paid_at, paid_by, payment_method, payment_reference, paid_amount, paid_notes
)paid_at IS NULL ⇒ unpaid. The mark-paid endpoint uses that gate atomically.
Cron Schedule
muse-cron/wrangler.toml:
[triggers]
crons = ["0 14 * * *"] # 14:00 UTC = 10:00 AM EDT / 9:00 AM ESTThe cron Worker calls https://ncmuse.co/scheduled with X-Cron-Secret. scheduled.ts orchestrates several daily tasks; tip distribution is one of them (lines 113–208). Idempotency is guaranteed by tip_pool_daily.date UNIQUE — re-running the job on the same date is a no-op.
Mark-Paid Endpoint
muse-customer/functions/api/admin/tips/mark-paid.js:
POST /api/admin/tips/mark-paid- Auth:
ADMINorMANAGERonly - Atomic:
UPDATE tip_distributions SET paid_at = ?, ... WHERE id IN (...) AND paid_at IS NULL - Returns
{ updated: [...], already_paid: [...], missing: [...] }for reconciliation
Admin UI
muse-admin/src/pages/PayrollPage.tsx:
- Two tabs: Tips (default) and Attendance
- Date presets: last-14, this-week, last-week, this-month, last-month, custom
- Staff filter dropdown
MarkPaidModalfor batch-marking distributions as paid
Eligibility Rules at a Glance
| Rule | Where it's enforced |
|---|---|
Order status must be completed, confirmed, or ready | distribute.js order query |
| Tip must be > 0 | distribute.js order query |
Staff role must be ADMIN / MANAGER / STAFF / BARISTA | distribute.js shift query (excludes ARTIST, KIOSK, SHIFT_LEADER) |
Staff is_active = 1 | distribute.js shift query |
| Order time falls inside shift window | orderMinute >= start_minute AND orderMinute < end_minute |
| Owner included only if clocked in that date | time_entries.clock_in lookup; OWNER_ID from env TIP_OWNER_STAFF_ID (default staff-pinghui-001) |
Edits & Re-runs
To re-distribute a day after fixing the schedule:
DELETE FROM tip_distributions WHERE date = 'YYYY-MM-DD'DELETE FROM tip_pool_daily WHERE date = 'YYYY-MM-DD'- Call
POST /api/admin/tips/distributewith{ date: 'YYYY-MM-DD' }(admin-only).
If any distributions were already marked paid, do not delete — adjust manually and set status = 'adjusted' on the pool row with a note explaining what changed.
Last reviewed: 2026-05-15. Owner ID is configured via the TIP_OWNER_STAFF_ID Pages environment variable — update there if ownership ever changes.
