From 2dc0af5178779197763a1918047e7faaeaee59f0 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 22:37:15 -0700 Subject: [PATCH] fix(paypal): tenant-scoped, idempotent sync (no fragile ON CONFLICT) payments has no unique index on external_id, so the onConflict:'external_id' upsert was unreliable and fell back to an insert that stripped external_id -- creating duplicate, non-idempotent rows on every re-sync, and not tenant-scoped. Now: look up which PayPal transaction ids already exist in THIS space and insert only the new rows (external_id preserved). No schema change. --- app/api/paypal/sync/route.ts | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/app/api/paypal/sync/route.ts b/app/api/paypal/sync/route.ts index 0a6a7db..b37ad16 100644 --- a/app/api/paypal/sync/route.ts +++ b/app/api/paypal/sync/route.ts @@ -120,23 +120,31 @@ export async function POST(req: NextRequest) { return NextResponse.json({ imported: 0, message: 'No incoming payments found' }) } - // Upsert — skip rows that already exist by external_id - const { data: imported, error: insertError } = await supabase + // Idempotent + tenant-scoped: there is no unique constraint on + // payments.external_id, so an `ON CONFLICT (external_id)` upsert is not + // reliable. Instead, look up which PayPal transaction ids already exist + // *in this space* and insert only the genuinely new ones (keeping + // external_id so a later re-sync stays idempotent). + const externalIds = rows.map(r => r.external_id).filter((x): x is string => !!x) + const { data: existing } = await supabase .from('payments') - .upsert(rows, { onConflict: 'external_id', ignoreDuplicates: true }) - .select() + .select('external_id') + .eq('space_id', member.space_id) + .in('external_id', externalIds.length > 0 ? externalIds : ['__none__']) + const seen = new Set((existing ?? []).map(e => e.external_id as string)) + const newRows = rows.filter(r => !r.external_id || !seen.has(r.external_id)) - if (insertError) { - // If external_id column doesn't exist yet, fall back to plain insert - const { data: fallback, error: fallbackError } = await supabase - .from('payments') - .insert(rows.map(({ external_id, ...rest }) => rest)) - .select() - if (fallbackError) throw new Error(fallbackError.message) - return NextResponse.json({ imported: fallback?.length ?? 0 }) + if (newRows.length === 0) { + return NextResponse.json({ imported: 0, message: 'Already up to date' }) } - return NextResponse.json({ imported: imported?.length ?? rows.length }) + const { data: imported, error: insertError } = await supabase + .from('payments') + .insert(newRows) + .select() + if (insertError) throw new Error(insertError.message) + + return NextResponse.json({ imported: imported?.length ?? newRows.length }) } catch (err: any) { console.error('[PayPal sync error]', err) return NextResponse.json({ error: err.message ?? 'Sync failed' }, { status: 500 })