Files
fourge-portal/supabase/functions/stripe-webhook/index.ts
T
Krao Hasanee 8eacd86b04 fix: invoice integrity — atomic numbering, safe delete, single create path
- Invoice numbers now come from DB function next_invoice_number()
  (max+1 per year under advisory lock) with a unique index; the old
  row-count method reused numbers after deletes and raced concurrent
  creates, which broke public pay links
- Remove dead standalone TeamCreateInvoice page; the TeamInvoices
  modal is the single create path (page had already drifted)
- Invoice delete now asks for confirmation and only un-bills tasks/
  submissions not billed on another invoice
- Created invoice shows correct "sent" status in list without reload
- Invoice/due dates computed at save time, not module load
- Reopening a paid invoice clears stale stripe_fee
- Stripe webhook markPaid is idempotent (no double receipts)
- subcontractor_invoice_items.version_number column stores billing
  version explicitly; description parsing kept as legacy fallback
- Drop unused buildInvoiceStatusByKey/deriveVersionStatus

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:02:28 -04:00

107 lines
4.1 KiB
TypeScript

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
import Stripe from 'https://esm.sh/stripe@14?target=deno';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16' });
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!;
const internalWebhookSecret = Deno.env.get('SUPABASE_WEBHOOK_SECRET')!;
serve(async (req) => {
const body = await req.text();
const sig = req.headers.get('stripe-signature');
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(body, sig!, webhookSecret);
} catch (err) {
console.error('Webhook signature failed:', err.message);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
// Helper: retrieve fee from a payment intent and mark invoice paid
async function markPaid(paymentIntentId: string, invoice_id: string) {
// Idempotency: Stripe retries events, and card payments fire both
// checkout.session.completed and payment_intent.succeeded. Never
// overwrite paid_at or re-send the receipt for an already-paid invoice.
const { data: existing } = await supabase
.from('invoices')
.select('status')
.eq('id', invoice_id)
.single();
if (existing?.status === 'paid') return;
let stripe_fee: number | null = null;
try {
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ['latest_charge.balance_transaction'],
});
const charge = pi.latest_charge as Stripe.Charge | null;
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
if (balanceTx?.fee != null) stripe_fee = balanceTx.fee / 100;
} catch (err) {
console.error('Failed to retrieve Stripe fee:', err.message);
}
const updateData: Record<string, unknown> = {
status: 'paid',
paid_at: new Date().toISOString(),
};
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
await supabase.from('invoices').update(updateData).eq('id', invoice_id);
const { data: invoice } = await supabase
.from('invoices')
.select('invoice_number, invoice_email, bill_to, total, paid_at, company_id')
.eq('id', invoice_id)
.single();
if (!invoice?.invoice_email) return;
await fetch(`${Deno.env.get('SUPABASE_URL')}/functions/v1/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-webhook-secret': internalWebhookSecret,
},
body: JSON.stringify({
type: 'receipt_sent',
to: [invoice.invoice_email],
data: {
invoiceNumber: invoice.invoice_number,
billTo: invoice.bill_to || invoice.invoice_email,
total: `$${Number(invoice.total || 0).toFixed(2)}`,
paidDate: new Date(invoice.paid_at || new Date().toISOString()).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }),
},
}),
});
}
// Card payments: session completes with payment_status = 'paid' immediately
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const invoice_id = session.metadata?.invoice_id;
const paymentIntentId = session.payment_intent as string | null;
// Only mark paid here for instant payment methods (card).
// ACH sessions complete with payment_status 'unpaid' — let payment_intent.succeeded handle those.
if (invoice_id && paymentIntentId && session.payment_status === 'paid') {
await markPaid(paymentIntentId, invoice_id);
}
}
// ACH / async payment methods: payment_intent.succeeded fires when money actually settles
if (event.type === 'payment_intent.succeeded') {
const pi = event.data.object as Stripe.PaymentIntent;
const invoice_id = pi.metadata?.invoice_id;
if (invoice_id) {
await markPaid(pi.id, invoice_id);
}
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
});