Skip to content

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:

  1. Pull every tipped order from yesterday. Any order where tip > 0 and status is completed, confirmed, or ready.
  2. Pull yesterday's schedule. From employee_shifts — the confirmed schedule. Only roles ADMIN, MANAGER, STAFF, BARISTA are eligible (ARTIST and KIOSK are excluded).
  3. 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_timeend_time window.
  4. 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.
  5. 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.
  6. Sum each person's shares across the day. Each staff member gets one row in tip_distributions with their total for that day.
  7. Record the daily pool. One row in tip_pool_daily with the day's grand total, order count, staff count, and status calculated.

The cron is idempotenttip_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:

OrderTimeTipWho was scheduled
#10111:05a$4.00Alice (11–4)
#1021:30p$6.00Alice (11–4), Bob (12–8)
#1037:45p$5.00Bob (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:

  1. Filter to a date range and a staff member.
  2. Select unpaid rows.
  3. 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

FileWhat it does
muse-customer/functions/api/webhooks/stripe-orders.jsCaptures session.metadata.tip_amount from Stripe and writes it to orders.tip when payment succeeds.
muse-customer/functions/api/pos/complete-order.jsPOS orders already have tip set when the order row is created; complete-order just flips status.

Tips live on the orders table:

sql
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:

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:

js
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:

  1. OWNER_ID is configurable via the TIP_OWNER_STAFF_ID Pages env var, with staff-pinghui-001 as the fallback. Update there if ownership ever changes — no code edit needed.
  2. The Owner-on-shift check is a single time_entries.clock_in lookup for the date. Cheap, indexed by staff_id, runs once per distribution (not per order).
  3. 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:

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:

toml
[triggers]
crons = ["0 14 * * *"]   # 14:00 UTC = 10:00 AM EDT / 9:00 AM EST

The 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: ADMIN or MANAGER only
  • 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
  • MarkPaidModal for batch-marking distributions as paid

Eligibility Rules at a Glance

RuleWhere it's enforced
Order status must be completed, confirmed, or readydistribute.js order query
Tip must be > 0distribute.js order query
Staff role must be ADMIN / MANAGER / STAFF / BARISTAdistribute.js shift query (excludes ARTIST, KIOSK, SHIFT_LEADER)
Staff is_active = 1distribute.js shift query
Order time falls inside shift windoworderMinute >= start_minute AND orderMinute < end_minute
Owner included only if clocked in that datetime_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:

  1. DELETE FROM tip_distributions WHERE date = 'YYYY-MM-DD'
  2. DELETE FROM tip_pool_daily WHERE date = 'YYYY-MM-DD'
  3. Call POST /api/admin/tips/distribute with { 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.

Internal documentation for Muse & Co staff only