Skip to content
23 changes: 14 additions & 9 deletions src/hooks/useAutoSaveTipSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,20 @@ export function useAutoSaveTipSettings({
onSave,
}: Params) {
useEffect(() => {
if (!settings) return;

const hasChanges =
tipSource !== settings.tip_source ||
shareMethod !== settings.share_method ||
splitCadence !== settings.split_cadence ||
JSON.stringify(roleWeights) !== JSON.stringify(settings.role_weights) ||
JSON.stringify(Array.from(selectedEmployees).sort((a, b) => a.localeCompare(b))) !==
JSON.stringify((settings.enabled_employee_ids || []).sort((a, b) => a.localeCompare(b)));
// If no settings exist, this is first-time setup - save after user makes selections
const hasChanges = settings
? // Compare with existing settings
tipSource !== settings.tip_source ||
shareMethod !== settings.share_method ||
splitCadence !== settings.split_cadence ||
JSON.stringify(roleWeights) !== JSON.stringify(settings.role_weights) ||
JSON.stringify(Array.from(selectedEmployees).sort((a, b) => a.localeCompare(b))) !==
JSON.stringify((settings.enabled_employee_ids || []).sort((a, b) => a.localeCompare(b)))
: // No settings exist - trigger save if any field differs from defaults
selectedEmployees.size > 0 ||
tipSource !== 'manual' ||
shareMethod !== 'hours' ||
splitCadence !== 'daily';

if (!hasChanges) return;

Expand Down
20 changes: 19 additions & 1 deletion supabase/functions/_shared/toastOrderProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,21 @@ interface OrderDateTime {
orderTime: string | null;
}

/** Convert Toast YYYYMMDD integer (e.g. 20260210) to ISO date string (2026-02-10) */
function parseBusinessDate(bizDate: number | undefined): string | null {
if (!bizDate) return null;
const s = String(bizDate);
if (s.length !== 8) return null;
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
}

function parseOrderDateTime(order: any): OrderDateTime {
// Prefer businessDate (restaurant's business day, YYYYMMDD integer) over UTC closedDate
const bizDate = parseBusinessDate(order.businessDate);
if (bizDate) {
return { orderDate: bizDate, orderTime: null };
}

let closedDate = order.closedDate ? new Date(order.closedDate) : null;

if (!closedDate && order.checks?.[0]?.closedDate) {
Expand Down Expand Up @@ -164,14 +178,18 @@ async function upsertPayment(
restaurantId: string,
orderDate: string
): Promise<void> {
// Use paidBusinessDate (restaurant's business day) when available,
// falling back to orderDate (UTC-derived) for older data
const paymentDate = parseBusinessDate(payment.paidBusinessDate) || orderDate;

const { error: paymentError } = await supabase.from('toast_payments').upsert({
restaurant_id: restaurantId,
toast_payment_guid: payment.guid,
toast_order_guid: orderGuid,
payment_type: payment.type || null,
amount: payment.amount ?? 0,
tip_amount: payment.tipAmount ?? null,
payment_date: orderDate,
payment_date: paymentDate,
payment_status: payment.paymentStatus || payment.status || null,
raw_json: payment,
synced_at: new Date().toISOString(),
Expand Down
98 changes: 98 additions & 0 deletions supabase/migrations/20260210234900_fix_get_pos_tips_by_date.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
-- Fix get_pos_tips_by_date to include uncategorized tips from unified_sales
--
-- ISSUE: The function only looked at unified_sales_splits (already categorized items),
-- but POS systems like Toast sync tips directly to unified_sales with item_type='tip'
-- BEFORE they are categorized. This caused "No POS tips found" message even when tips existed.
--
-- SOLUTION: Query both:
-- 1. Categorized tips (unified_sales_splits with "tip" in account name/subtype)
-- 2. Uncategorized tips (unified_sales where item_type='tip' OR adjustment_type='tip')

CREATE OR REPLACE FUNCTION get_pos_tips_by_date(
p_restaurant_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS TABLE (
tip_date DATE,
total_amount_cents INTEGER,
transaction_count INTEGER,
pos_source TEXT
)
LANGUAGE plpgsql
SECURITY INVOKER
AS $$
BEGIN
-- Authorization check
IF NOT EXISTS (
SELECT 1 FROM user_restaurants
WHERE restaurant_id = p_restaurant_id
AND user_id = auth.uid()
) THEN
RAISE EXCEPTION 'Access denied: User does not have access to restaurant %', p_restaurant_id;
END IF;

RETURN QUERY
WITH categorized_tips AS (
SELECT
us.sale_date AS t_date,
SUM(uss.amount * 100)::INTEGER AS t_cents,
COUNT(DISTINCT us.external_order_id)::INTEGER AS t_count,
us.pos_system AS t_source
FROM unified_sales us
INNER JOIN unified_sales_splits uss ON us.id = uss.sale_id
INNER JOIN chart_of_accounts coa ON uss.category_id = coa.id
WHERE us.restaurant_id = p_restaurant_id
AND us.sale_date >= p_start_date
AND us.sale_date <= p_end_date
AND (
LOWER(COALESCE(coa.account_name, '')) LIKE '%tip%'
OR LOWER(COALESCE(coa.account_subtype::TEXT, '')) LIKE '%tip%'
)
GROUP BY us.sale_date, us.pos_system
),
uncategorized_tips AS (
-- item_type='tip' or adjustment_type='tip' without a tip-account split
SELECT
us.sale_date AS t_date,
SUM(COALESCE(us.total_price, us.unit_price * us.quantity, 0) * 100)::INTEGER AS t_cents,
COUNT(DISTINCT us.external_order_id)::INTEGER AS t_count,
us.pos_system AS t_source
FROM unified_sales us
WHERE us.restaurant_id = p_restaurant_id
AND us.sale_date >= p_start_date
AND us.sale_date <= p_end_date
AND (us.item_type = 'tip' OR us.adjustment_type = 'tip')
AND NOT EXISTS (
SELECT 1 FROM unified_sales_splits uss
INNER JOIN chart_of_accounts coa ON uss.category_id = coa.id
WHERE uss.sale_id = us.id
AND (
LOWER(COALESCE(coa.account_name, '')) LIKE '%tip%'
OR LOWER(COALESCE(coa.account_subtype::TEXT, '')) LIKE '%tip%'
)
)
Comment on lines +66 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential gap: tip row with a non-tip split is excluded from both CTEs.

The NOT EXISTS check excludes any unified_sales tip row that has any split, regardless of whether that split maps to a tip account. If someone miscategorizes a tip record (e.g., assigns it to a "food" account), it will have a split → excluded from uncategorized_tips, but the split's account won't match '%tip%' → excluded from categorized_tips too. The tip silently disappears.

Consider narrowing the exclusion to tip-related splits only:

Proposed fix
       AND NOT EXISTS (
         SELECT 1 FROM unified_sales_splits uss
+        INNER JOIN chart_of_accounts coa ON uss.category_id = coa.id
         WHERE uss.sale_id = us.id
+        AND (
+          LOWER(COALESCE(coa.account_name, '')) LIKE '%tip%'
+          OR LOWER(COALESCE(coa.account_subtype::TEXT, '')) LIKE '%tip%'
+        )
       )

This keeps the tip in the uncategorized CTE until it's specifically categorized as a tip, preventing silent data loss from miscategorization.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
AND NOT EXISTS (
SELECT 1 FROM unified_sales_splits uss
WHERE uss.sale_id = us.id
)
AND NOT EXISTS (
SELECT 1 FROM unified_sales_splits uss
INNER JOIN chart_of_accounts coa ON uss.category_id = coa.id
WHERE uss.sale_id = us.id
AND (
LOWER(COALESCE(coa.account_name, '')) LIKE '%tip%'
OR LOWER(COALESCE(coa.account_subtype::TEXT, '')) LIKE '%tip%'
)
)
🤖 Prompt for AI Agents
In `@supabase/migrations/20260210234900_fix_get_pos_tips_by_date.sql` around lines
69 - 72, The NOT EXISTS filter on unified_sales_splits (alias uss) currently
removes any unified_sales row (alias us) that has any split, causing tip rows
with non-tip splits to be excluded from both CTEs; change the condition in both
CTEs (uncategorized_tips and categorized_tips) so the NOT EXISTS only excludes
rows when there is an existing split that maps to a tip account (e.g., add the
split account check such as uss.account_name ILIKE '%tip%' or the equivalent
account_id -> tip-account mapping) — i.e., make the WHERE clause in the
EXISTS/NOT EXISTS include the tip-account predicate so only tip-related splits
cause exclusion.

GROUP BY us.sale_date, us.pos_system
),
combined_tips AS (
SELECT t_date, t_cents, t_count, t_source FROM categorized_tips
UNION ALL
SELECT t_date, t_cents, t_count, t_source FROM uncategorized_tips
)
SELECT
ct.t_date,
SUM(ct.t_cents)::INTEGER,
SUM(ct.t_count)::INTEGER,
ct.t_source
FROM combined_tips ct
GROUP BY ct.t_date, ct.t_source
ORDER BY ct.t_date DESC;
END;
$$;
Comment on lines +11 to +91

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check for existing pgTAP tests related to this function
rg "get_pos_tips_by_date" --type=sql

# List test files in supabase/tests
fd . supabase/tests/ --type f --extension sql 2>/dev/null | head -20

# Check if there's a test file for this migration
ls -la supabase/tests/ 2>/dev/null | head -20

Repository: toyiyo/nimble-pnl

Length of output: 4509


🏁 Script executed:

cat supabase/tests/26_get_pos_tips_by_date.sql

Repository: toyiyo/nimble-pnl

Length of output: 6668


🏁 Script executed:

# Check unified_sales table definition for item_type and adjustment_type
rg "CREATE TABLE.*unified_sales" -A 20 supabase/migrations/

# Also check if test data includes uncategorized items
grep -n "item_type\|adjustment_type" supabase/tests/26_get_pos_tips_by_date.sql

Repository: toyiyo/nimble-pnl

Length of output: 4704


🏁 Script executed:

# Search for item_type and adjustment_type in all migrations
rg "item_type|adjustment_type" supabase/migrations/ -A 2 -B 2

# Also check the specific migration file being reviewed
grep -n "item_type\|adjustment_type" supabase/migrations/20260210234900_fix_get_pos_tips_by_date.sql

Repository: toyiyo/nimble-pnl

Length of output: 43264


🏁 Script executed:

# Check the test data more carefully - look for items without splits
grep -A 5 "INSERT INTO unified_sales" supabase/tests/26_get_pos_tips_by_date.sql

# Also check if any test items are inserted with item_type or adjustment_type='tip'
grep -E "item_type|adjustment_type" supabase/tests/26_get_pos_tips_by_date.sql

# Check the comment at the start of the migration to understand what's being fixed
head -15 supabase/migrations/20260210234900_fix_get_pos_tips_by_date.sql

Repository: toyiyo/nimble-pnl

Length of output: 2034


Test coverage for uncategorized tips scenario is missing.

While pgTAP tests exist for this function, they don't cover the core new functionality added by this migration. The uncategorized_tips CTE (lines 56-70) handles items with item_type='tip' or adjustment_type='tip' that haven't been categorized yet—which is the entire purpose of this fix. Current test data only exercises the categorized_tips path; all test items have splits and thus never trigger the uncategorized path.

Add test fixture data with at least one uncategorized tip item (no splits, with item_type='tip' or adjustment_type='tip') and verify it's correctly aggregated alongside categorized tips.

🤖 Prompt for AI Agents
In `@supabase/migrations/20260210234900_fix_get_pos_tips_by_date.sql` around lines
11 - 93, Add pgTAP test coverage for the uncategorized_tips path in
get_pos_tips_by_date by inserting at least one unified_sales row for the target
restaurant with item_type='tip' or adjustment_type='tip' that has no
corresponding unified_sales_splits row (so it is uncategorized), then call
get_pos_tips_by_date and assert that the returned rows include that tip's date,
pos_system, and amount in the aggregated total_amount_cents and
transaction_count alongside existing categorized tips; reference the function
get_pos_tips_by_date and the uncategorized_tips CTE to locate where this
behavior should be validated.


COMMENT ON FUNCTION get_pos_tips_by_date IS
'Aggregates POS tips from both categorized (unified_sales_splits) and uncategorized (unified_sales) sources.
Used by tip pooling system to display POS-imported tips.
Returns daily totals for:
1. Categorized tips (splits with account name/subtype containing ''tip'')
2. Uncategorized tips (item_type=''tip'' or adjustment_type=''tip'')';
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
-- Fix Toast payment dates: use paidBusinessDate (restaurant business day) instead of UTC date
--
-- Toast provides paidBusinessDate as a YYYYMMDD integer representing the restaurant's
-- actual business day. The sync was using the UTC date from paidDate/closedDate, causing
-- late-night payments to appear on the next calendar day.

-- Step 1: Fix toast_payments.payment_date from raw_json->>'paidBusinessDate'
UPDATE toast_payments
SET payment_date = CONCAT(
SUBSTRING(CAST((raw_json->>'paidBusinessDate')::BIGINT AS TEXT), 1, 4), '-',
SUBSTRING(CAST((raw_json->>'paidBusinessDate')::BIGINT AS TEXT), 5, 2), '-',
SUBSTRING(CAST((raw_json->>'paidBusinessDate')::BIGINT AS TEXT), 7, 2)
)::DATE
WHERE raw_json->>'paidBusinessDate' IS NOT NULL
AND LENGTH(CAST((raw_json->>'paidBusinessDate')::BIGINT AS TEXT)) = 8
AND payment_date != CONCAT(
SUBSTRING(CAST((raw_json->>'paidBusinessDate')::BIGINT AS TEXT), 1, 4), '-',
SUBSTRING(CAST((raw_json->>'paidBusinessDate')::BIGINT AS TEXT), 5, 2), '-',
SUBSTRING(CAST((raw_json->>'paidBusinessDate')::BIGINT AS TEXT), 7, 2)
)::DATE;

-- Step 2: Fix unified_sales.sale_date for Toast tip entries
-- These have external_item_id ending in '_tip' and reference toast_payments via external_order_id
UPDATE unified_sales us
SET sale_date = tp.payment_date
FROM toast_payments tp
WHERE us.pos_system = 'toast'
AND us.external_order_id = tp.toast_order_guid
AND us.external_item_id = tp.toast_payment_guid || '_tip'
AND us.sale_date != tp.payment_date;

-- Step 3: Fix toast_orders.order_date from raw_json->>'businessDate'
-- (order_date was also derived from UTC closedDate)
UPDATE toast_orders
SET order_date = CONCAT(
SUBSTRING(CAST((raw_json->>'businessDate')::BIGINT AS TEXT), 1, 4), '-',
SUBSTRING(CAST((raw_json->>'businessDate')::BIGINT AS TEXT), 5, 2), '-',
SUBSTRING(CAST((raw_json->>'businessDate')::BIGINT AS TEXT), 7, 2)
)::DATE
WHERE raw_json->>'businessDate' IS NOT NULL
AND LENGTH(CAST((raw_json->>'businessDate')::BIGINT AS TEXT)) = 8
AND order_date != CONCAT(
SUBSTRING(CAST((raw_json->>'businessDate')::BIGINT AS TEXT), 1, 4), '-',
SUBSTRING(CAST((raw_json->>'businessDate')::BIGINT AS TEXT), 5, 2), '-',
SUBSTRING(CAST((raw_json->>'businessDate')::BIGINT AS TEXT), 7, 2)
)::DATE;

-- Step 4: Fix unified_sales.sale_date for regular Toast items using (now-corrected) toast_orders
UPDATE unified_sales us
SET sale_date = to_ord.order_date
FROM toast_orders to_ord
WHERE us.pos_system = 'toast'
AND us.external_order_id = to_ord.toast_order_guid
AND us.restaurant_id = to_ord.restaurant_id
AND us.item_type IS DISTINCT FROM 'tip'
AND us.adjustment_type IS DISTINCT FROM 'tip'
AND to_ord.raw_json->>'businessDate' IS NOT NULL
AND LENGTH(CAST((to_ord.raw_json->>'businessDate')::BIGINT AS TEXT)) = 8
AND us.sale_date != to_ord.order_date;
66 changes: 65 additions & 1 deletion supabase/tests/26_get_pos_tips_by_date.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
-- Tests for get_pos_tips_by_date function

BEGIN;
SELECT plan(12);
SELECT plan(16);

-- Disable RLS for test setup
SET LOCAL role TO postgres;
Expand Down Expand Up @@ -157,5 +157,69 @@ SELECT ok(
'First row should be most recent date (DESC order)'
);

-- Test 10: Uncategorized tips (item_type='tip', no splits) are included

INSERT INTO unified_sales (id, restaurant_id, pos_system, external_order_id, item_name, quantity, total_price, sale_date, item_type) VALUES
('00000000-0000-0000-0000-000000000030'::uuid, '00000000-0000-0000-0000-000000000001'::uuid, 'toast', 'order-005', 'POS Tip', 1, 25.00, '2024-01-18', 'tip')
ON CONFLICT (id) DO UPDATE SET total_price = EXCLUDED.total_price;

SELECT is(
(SELECT total_amount_cents FROM get_pos_tips_by_date(
'00000000-0000-0000-0000-000000000001'::uuid, '2024-01-18'::DATE, '2024-01-18'::DATE
) WHERE tip_date = '2024-01-18'),
2500::INTEGER,
'Uncategorized tip (item_type=tip, no splits) should return 2500 cents'
);

-- Test 11: Uncategorized tip with adjustment_type='tip'

INSERT INTO unified_sales (id, restaurant_id, pos_system, external_order_id, item_name, quantity, total_price, sale_date, adjustment_type) VALUES
('00000000-0000-0000-0000-000000000031'::uuid, '00000000-0000-0000-0000-000000000001'::uuid, 'square', 'order-006', 'Adjustment Tip', 1, 15.00, '2024-01-18', 'tip')
ON CONFLICT (id) DO UPDATE SET total_price = EXCLUDED.total_price;

SELECT is(
(SELECT COUNT(*) FROM get_pos_tips_by_date(
'00000000-0000-0000-0000-000000000001'::uuid, '2024-01-18'::DATE, '2024-01-18'::DATE
))::INTEGER,
2::INTEGER,
'Should return 2 rows for Jan 18: toast uncategorized + square uncategorized'
);

-- Test 12: Tip miscategorized to non-tip account still appears (not silently dropped)

INSERT INTO unified_sales (id, restaurant_id, pos_system, external_order_id, item_name, quantity, total_price, sale_date, item_type) VALUES
('00000000-0000-0000-0000-000000000032'::uuid, '00000000-0000-0000-0000-000000000001'::uuid, 'clover', 'order-007', 'Miscategorized Tip', 1, 40.00, '2024-01-19', 'tip')
ON CONFLICT (id) DO UPDATE SET total_price = EXCLUDED.total_price;

-- Categorize this tip to a non-tip account (Food Sales)
INSERT INTO unified_sales_splits (sale_id, category_id, amount) VALUES
('00000000-0000-0000-0000-000000000032'::uuid, '00000000-0000-0000-0000-000000000012'::uuid, 40.00);

SELECT is(
(SELECT total_amount_cents FROM get_pos_tips_by_date(
'00000000-0000-0000-0000-000000000001'::uuid, '2024-01-19'::DATE, '2024-01-19'::DATE
) WHERE tip_date = '2024-01-19'),
4000::INTEGER,
'Tip miscategorized to non-tip account should still appear via uncategorized path'
);

-- Test 13: Tip correctly categorized to tip account is NOT double-counted

INSERT INTO unified_sales (id, restaurant_id, pos_system, external_order_id, item_name, quantity, total_price, sale_date, item_type) VALUES
('00000000-0000-0000-0000-000000000033'::uuid, '00000000-0000-0000-0000-000000000001'::uuid, 'square', 'order-008', 'Properly Categorized Tip', 1, 60.00, '2024-01-20', 'tip')
ON CONFLICT (id) DO UPDATE SET total_price = EXCLUDED.total_price;

-- Categorize to a tip account
INSERT INTO unified_sales_splits (sale_id, category_id, amount) VALUES
('00000000-0000-0000-0000-000000000033'::uuid, '00000000-0000-0000-0000-000000000010'::uuid, 60.00);

SELECT is(
(SELECT total_amount_cents FROM get_pos_tips_by_date(
'00000000-0000-0000-0000-000000000001'::uuid, '2024-01-20'::DATE, '2024-01-20'::DATE
) WHERE tip_date = '2024-01-20'),
6000::INTEGER,
'Tip categorized to tip account should appear once (via categorized path, not double-counted)'
);

SELECT * FROM finish();
ROLLBACK;
23 changes: 20 additions & 3 deletions tests/unit/useAutoSaveTipSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ describe('useAutoSaveTipSettings', () => {
updated_at: '2026-01-01',
};

it('does not trigger save when settings is null', () => {
it('does not trigger save when settings is null and all values are defaults', () => {
renderHook(() =>
useAutoSaveTipSettings({
settings: null,
tipSource: 'manual',
shareMethod: 'hours',
splitCadence: 'daily',
roleWeights: { Server: 1 },
selectedEmployees: new Set(['emp1']),
roleWeights: {},
selectedEmployees: new Set(),
onSave,
})
);
Expand All @@ -44,6 +44,23 @@ describe('useAutoSaveTipSettings', () => {
expect(onSave).not.toHaveBeenCalled();
});

it('triggers save when settings is null but user has configured values', () => {
renderHook(() =>
useAutoSaveTipSettings({
settings: null,
tipSource: 'pos',
shareMethod: 'hours',
splitCadence: 'daily',
roleWeights: {},
selectedEmployees: new Set(),
onSave,
})
);

vi.advanceTimersByTime(1500);
expect(onSave).toHaveBeenCalledOnce();
});

it('does not trigger save when no changes detected', () => {
renderHook(() =>
useAutoSaveTipSettings({
Expand Down
Loading