Add Stripe fee tracking on paid invoices + backfill function
- Store stripe_fee on invoices when webhook receives checkout.session.completed - Display Stripe fee and net received in InvoiceDetail when paid via Stripe - Add backfill-stripe-fees edge function to populate fee on existing paid invoices - Migration: add stripe_fee column to invoices table - Includes all pending portal changes (brand book, sign survey, task/project/company updates, etc.) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
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 corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
);
|
||||
|
||||
// Find all paid invoices that don't have a stripe_fee yet
|
||||
const { data: invoices, error } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, total')
|
||||
.eq('status', 'paid')
|
||||
.is('stripe_fee', null);
|
||||
|
||||
if (error) {
|
||||
return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (!invoices?.length) {
|
||||
return new Response(JSON.stringify({ message: 'No invoices missing fees', updated: 0 }), { headers: corsHeaders });
|
||||
}
|
||||
|
||||
const results: { invoice_id: string; invoice_number: string; status: string; fee?: number }[] = [];
|
||||
|
||||
for (const invoice of invoices) {
|
||||
try {
|
||||
// Search Stripe checkout sessions by invoice_id metadata
|
||||
const sessions = await stripe.checkout.sessions.search({
|
||||
query: `metadata["invoice_id"]:"${invoice.id}"`,
|
||||
limit: 1,
|
||||
expand: ['data.payment_intent.latest_charge.balance_transaction'],
|
||||
});
|
||||
|
||||
const session = sessions.data[0];
|
||||
if (!session) {
|
||||
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: 'no_session' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const paymentIntent = session.payment_intent as Stripe.PaymentIntent | null;
|
||||
const charge = paymentIntent?.latest_charge as Stripe.Charge | null;
|
||||
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
|
||||
|
||||
if (balanceTx?.fee == null) {
|
||||
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: 'no_balance_tx' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const stripe_fee = balanceTx.fee / 100;
|
||||
await supabase.from('invoices').update({ stripe_fee }).eq('id', invoice.id);
|
||||
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: 'updated', fee: stripe_fee });
|
||||
} catch (err) {
|
||||
results.push({ invoice_id: invoice.id, invoice_number: invoice.invoice_number, status: `error: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = results.filter(r => r.status === 'updated').length;
|
||||
return new Response(
|
||||
JSON.stringify({ updated, total: invoices.length, results }),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
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')!;
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const invoice_id = session.metadata?.invoice_id;
|
||||
|
||||
if (invoice_id) {
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
);
|
||||
|
||||
// Retrieve the Stripe processing fee from the balance transaction
|
||||
let stripe_fee: number | null = null;
|
||||
try {
|
||||
const paymentIntentId = session.payment_intent as string;
|
||||
if (paymentIntentId) {
|
||||
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, {
|
||||
expand: ['latest_charge.balance_transaction'],
|
||||
});
|
||||
const charge = paymentIntent.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' };
|
||||
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
|
||||
|
||||
await supabase.from('invoices').update(updateData).eq('id', invoice_id);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ received: true }), { status: 200 });
|
||||
});
|
||||
Reference in New Issue
Block a user