mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
71cc71f89f
parse-invoice: guard oversized PDFs (>18MB → clear "too large, saved for manual entry" message) and surface the AI gateway's actual error instead of a bare status code. inbound-bill-email: route to an association by the recipient alias (<alias>@bills.avriamail.com, via associations.inbound_alias) in addition to the sender's vendor mapping; fix extractEmail (bare addresses were mis-split, e.g. invoices@x → s@x); surface parse-invoice's real error in the inbox. Deployed via MCP; migration associations_inbound_alias adds + populates the aliases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
// SendGrid Inbound Parse webhook handler.
|
|
// Receives multipart/form-data POST from SendGrid when an email arrives at
|
|
// the configured inbound hostname (e.g. bills.stagelaw.com).
|
|
// Looks up the sender in vendor_email_mappings; if matched, parses the PDF
|
|
// attachment via parse-invoice and creates a draft bill. Otherwise quarantines
|
|
// the email in inbound_bill_emails for manual review.
|
|
|
|
import { createClient } from 'npm:@supabase/supabase-js@2'
|
|
|
|
const corsHeaders = {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
}
|
|
|
|
function extractEmail(raw: string | null): { email: string | null; name: string | null } {
|
|
if (!raw) return { email: null, name: null }
|
|
// Prefer the address inside <...>; otherwise take the first bare email token.
|
|
// (A greedy "Name" group mis-splits bare addresses like invoices@x.com -> s@x.com.)
|
|
const angle = raw.match(/<([^<>\s]+@[^<>\s]+)>/)
|
|
const email = angle?.[1] ?? raw.match(/[^\s<>,;]+@[^\s<>,;]+/)?.[0] ?? null
|
|
const nameM = raw.match(/^\s*"?([^"<]+?)"?\s*</)
|
|
return {
|
|
email: email ? email.trim().toLowerCase() : null,
|
|
name: nameM ? (nameM[1].trim() || null) : null,
|
|
}
|
|
}
|
|
|
|
async function arrayBufferToBase64(buf: ArrayBuffer): Promise<string> {
|
|
const bytes = new Uint8Array(buf)
|
|
let binary = ''
|
|
const chunk = 0x8000
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
|
|
}
|
|
return btoa(binary)
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders })
|
|
if (req.method !== 'POST') {
|
|
return new Response('Method not allowed', { status: 405, headers: corsHeaders })
|
|
}
|
|
|
|
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
|
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
|
const supabase = createClient(supabaseUrl, serviceKey)
|
|
|
|
let formData: FormData
|
|
try {
|
|
formData = await req.formData()
|
|
} catch (err) {
|
|
console.error('Failed to parse form data', err)
|
|
return new Response('Bad Request', { status: 400, headers: corsHeaders })
|
|
}
|
|
|
|
const fromRaw = formData.get('from')?.toString() ?? null
|
|
const toRaw = formData.get('to')?.toString() ?? null
|
|
const subject = formData.get('subject')?.toString() ?? null
|
|
const text = formData.get('text')?.toString() ?? null
|
|
const attachmentCount = parseInt(formData.get('attachments')?.toString() ?? '0', 10)
|
|
|
|
const { email: fromEmail, name: fromName } = extractEmail(fromRaw)
|
|
const { email: toEmail } = extractEmail(toRaw)
|
|
|
|
console.log('Inbound bill email', { fromEmail, toEmail, subject, attachmentCount })
|
|
|
|
// ===== Deduplication =====
|
|
// External mail loops (auto-forwarders, retry policies) can re-deliver the exact
|
|
// same email repeatedly. If we already received a message with the same sender +
|
|
// subject in the last 30 days, treat this delivery as a duplicate and short-circuit.
|
|
if (fromEmail && subject) {
|
|
const sinceIso = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
|
const { data: existing, error: dupErr } = await supabase
|
|
.from('inbound_bill_emails')
|
|
.select('id, status, bill_id, created_at')
|
|
.eq('from_email', fromEmail)
|
|
.eq('subject', subject)
|
|
.gte('created_at', sinceIso)
|
|
.order('created_at', { ascending: false })
|
|
.limit(1)
|
|
if (dupErr) {
|
|
console.error('Dedup lookup failed', dupErr)
|
|
} else if (existing && existing.length > 0) {
|
|
console.log('Duplicate inbound email ignored', {
|
|
fromEmail, subject, existingId: existing[0].id, existingStatus: existing[0].status,
|
|
})
|
|
return new Response(
|
|
JSON.stringify({ ok: true, deduplicated: true, existingId: existing[0].id }),
|
|
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } },
|
|
)
|
|
}
|
|
}
|
|
|
|
// Find first PDF attachment
|
|
let pdfFile: File | null = null
|
|
for (let i = 1; i <= attachmentCount; i++) {
|
|
const file = formData.get(`attachment${i}`) as File | null
|
|
if (file && (file.type === 'application/pdf' || file.name?.toLowerCase().endsWith('.pdf'))) {
|
|
pdfFile = file
|
|
break
|
|
}
|
|
}
|
|
|
|
// Build raw payload snapshot (small fields only)
|
|
const rawPayload: Record<string, unknown> = {
|
|
from: fromRaw, to: toRaw, subject,
|
|
attachments: attachmentCount,
|
|
envelope: formData.get('envelope')?.toString() ?? null,
|
|
spam_score: formData.get('spam_score')?.toString() ?? null,
|
|
}
|
|
|
|
// Upload attachment to storage if present
|
|
let storagePath: string | null = null
|
|
let attachmentUrl: string | null = null
|
|
let pdfBase64: string | null = null
|
|
if (pdfFile) {
|
|
const buf = await pdfFile.arrayBuffer()
|
|
pdfBase64 = await arrayBufferToBase64(buf)
|
|
const safeName = (pdfFile.name || 'invoice.pdf').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
storagePath = `${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}-${safeName}`
|
|
const { error: uploadErr } = await supabase.storage
|
|
.from('inbound-bill-attachments')
|
|
.upload(storagePath, buf, { contentType: 'application/pdf', upsert: false })
|
|
if (uploadErr) {
|
|
console.error('Failed to upload attachment', uploadErr)
|
|
storagePath = null
|
|
} else {
|
|
const { data: signed } = await supabase.storage
|
|
.from('inbound-bill-attachments')
|
|
.createSignedUrl(storagePath, 60 * 60 * 24 * 30) // 30 days
|
|
attachmentUrl = signed?.signedUrl ?? null
|
|
}
|
|
}
|
|
|
|
// Determine the target association: first by the RECIPIENT alias
|
|
// (<alias>@bills.avriamail.com), then by the SENDER's vendor mapping.
|
|
let mappedAssociationId: string | null = null
|
|
let mappedVendorId: string | null = null
|
|
|
|
// Recipient local-part from the SMTP envelope (most reliable), falling back to
|
|
// the To header. e.g. "ashleymanor@bills.avriamail.com" -> alias "ashleymanor".
|
|
let recipientLocal: string | null = null
|
|
try {
|
|
const env = JSON.parse(formData.get('envelope')?.toString() ?? '{}')
|
|
const envTo = Array.isArray(env?.to) ? env.to[0] : null
|
|
recipientLocal = (envTo ?? toRaw ?? '').toString().match(/([^\s<>@]+)@/)?.[1]?.toLowerCase() ?? null
|
|
if (recipientLocal) recipientLocal = recipientLocal.split('+')[0]
|
|
} catch (_) { /* ignore malformed envelope */ }
|
|
if (recipientLocal) {
|
|
const { data: assoc } = await supabase
|
|
.from('associations')
|
|
.select('id')
|
|
.ilike('inbound_alias', recipientLocal)
|
|
.limit(1)
|
|
if (assoc && assoc.length > 0) mappedAssociationId = assoc[0].id as string
|
|
}
|
|
|
|
// Sender → vendor/association mapping (sets the vendor, and the association too
|
|
// if the recipient alias didn't resolve one).
|
|
if (fromEmail) {
|
|
const { data: mappings } = await supabase
|
|
.from('vendor_email_mappings')
|
|
.select('association_id, vendor_id')
|
|
.eq('email', fromEmail)
|
|
.limit(1)
|
|
if (mappings && mappings.length > 0) {
|
|
mappedVendorId = (mappings[0].vendor_id as string) ?? null
|
|
if (!mappedAssociationId) mappedAssociationId = mappings[0].association_id as string
|
|
}
|
|
}
|
|
|
|
// Always create the inbox row first so nothing is lost
|
|
const { data: inboxRow, error: inboxErr } = await supabase
|
|
.from('inbound_bill_emails')
|
|
.insert({
|
|
from_email: fromEmail,
|
|
from_name: fromName,
|
|
to_email: toEmail,
|
|
subject,
|
|
body_text: text?.slice(0, 10000) ?? null,
|
|
attachment_url: attachmentUrl,
|
|
attachment_filename: pdfFile?.name ?? null,
|
|
attachment_storage_path: storagePath,
|
|
association_id: mappedAssociationId,
|
|
raw_payload: rawPayload,
|
|
status: 'unassigned',
|
|
})
|
|
.select('id')
|
|
.single()
|
|
|
|
if (inboxErr || !inboxRow) {
|
|
console.error('Failed to create inbox row', inboxErr)
|
|
return new Response('Internal error', { status: 500, headers: corsHeaders })
|
|
}
|
|
|
|
const inboxId = inboxRow.id as string
|
|
|
|
// No PDF? Leave as unassigned with note
|
|
if (!pdfBase64) {
|
|
await supabase
|
|
.from('inbound_bill_emails')
|
|
.update({ status: 'unassigned', error_message: 'No PDF attachment found' })
|
|
.eq('id', inboxId)
|
|
return new Response(JSON.stringify({ ok: true, inboxId, reason: 'no_pdf' }), {
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
})
|
|
}
|
|
|
|
// Unknown sender → leave for manual review (with the PDF saved + parsed for convenience)
|
|
// We still try to parse so staff can preview the extracted data
|
|
let parsed: Record<string, unknown> | null = null
|
|
let parseError: string | null = null
|
|
try {
|
|
const { data, error } = await supabase.functions.invoke('parse-invoice', {
|
|
body: { pdf_base64: pdfBase64, filename: pdfFile?.name ?? 'invoice.pdf' },
|
|
})
|
|
if (error) {
|
|
// Surface the function's actual error body (e.g. "PDF too large…") instead
|
|
// of the generic "non-2xx status code" wrapper.
|
|
let detail = error.message
|
|
try {
|
|
const body = await (error as { context?: Response }).context?.json()
|
|
if (body?.error) detail = body.error
|
|
} catch (_) { /* keep generic message */ }
|
|
throw new Error(detail)
|
|
}
|
|
parsed = (data as { data?: Record<string, unknown> })?.data ?? null
|
|
} catch (err) {
|
|
parseError = err instanceof Error ? err.message : String(err)
|
|
console.error('parse-invoice failed', parseError)
|
|
}
|
|
|
|
await supabase
|
|
.from('inbound_bill_emails')
|
|
.update({ parsed_data: parsed, error_message: parseError })
|
|
.eq('id', inboxId)
|
|
|
|
// If we have a mapping AND we parsed successfully, auto-create a draft bill
|
|
if (mappedAssociationId && parsed) {
|
|
const totalAmount = Number(parsed.total_amount) || 0
|
|
const invoiceNumber = (parsed.invoice_number as string) || null
|
|
const invoiceDate = (parsed.invoice_date as string) || new Date().toISOString().slice(0, 10)
|
|
const dueDate = (parsed.due_date as string) || null
|
|
const vendorName = (parsed.vendor_name as string) || fromName || fromEmail || 'Unknown Vendor'
|
|
const lineItems = Array.isArray((parsed as any).line_items) ? (parsed as any).line_items : []
|
|
|
|
// Upload the PDF to the public `invoices` bucket so the URL is permanent
|
|
// (the inbound-bill-attachments bucket is private and signed URLs expire).
|
|
let publicPdfUrl: string | null = null
|
|
try {
|
|
const safeName = (pdfFile?.name || 'invoice.pdf').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
const invPath = `inbound/${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}-${safeName}`
|
|
const buf = Uint8Array.from(atob(pdfBase64), (c) => c.charCodeAt(0))
|
|
const { error: invUploadErr } = await supabase.storage
|
|
.from('invoices')
|
|
.upload(invPath, buf, { contentType: 'application/pdf', upsert: false })
|
|
if (invUploadErr) {
|
|
console.error('Failed to upload PDF to invoices bucket', invUploadErr)
|
|
} else {
|
|
const { data: pub } = supabase.storage.from('invoices').getPublicUrl(invPath)
|
|
publicPdfUrl = pub?.publicUrl ?? null
|
|
}
|
|
} catch (err) {
|
|
console.error('Public PDF upload error', err)
|
|
}
|
|
|
|
const billAttachmentUrl = publicPdfUrl ?? attachmentUrl
|
|
|
|
// Create a linked invoices row (mirrors AI Bill Parser flow) so Bill Approvals
|
|
// can read raw_pdf_url and display the attachment.
|
|
let sourceInvoiceId: string | null = null
|
|
try {
|
|
const { data: invRow, error: invErr } = await supabase
|
|
.from('invoices')
|
|
.insert({
|
|
association_id: mappedAssociationId,
|
|
vendor_name: vendorName,
|
|
invoice_number: invoiceNumber,
|
|
issue_date: invoiceDate,
|
|
due_date: dueDate,
|
|
amount: totalAmount,
|
|
description: `Inbound from ${vendorName} (${subject ?? 'no subject'})`,
|
|
status: 'pending',
|
|
raw_pdf_url: billAttachmentUrl,
|
|
line_items: lineItems,
|
|
})
|
|
.select('id')
|
|
.single()
|
|
if (invErr) {
|
|
console.error('Failed to create invoice row', invErr)
|
|
} else {
|
|
sourceInvoiceId = invRow?.id ?? null
|
|
}
|
|
} catch (err) {
|
|
console.error('Invoice insert error', err)
|
|
}
|
|
|
|
const { data: bill, error: billErr } = await supabase
|
|
.from('bills')
|
|
.insert({
|
|
association_id: mappedAssociationId,
|
|
vendor_id: mappedVendorId,
|
|
amount: totalAmount,
|
|
invoice_number: invoiceNumber,
|
|
bill_date: invoiceDate,
|
|
due_date: dueDate,
|
|
description: `Inbound from ${vendorName} (${subject ?? 'no subject'})`,
|
|
attachment_url: billAttachmentUrl,
|
|
source_invoice_id: sourceInvoiceId,
|
|
status: 'pending',
|
|
})
|
|
.select('id')
|
|
.single()
|
|
|
|
if (billErr) {
|
|
console.error('Failed to create bill', billErr)
|
|
await supabase
|
|
.from('inbound_bill_emails')
|
|
.update({ status: 'error', error_message: `Bill creation failed: ${billErr.message}` })
|
|
.eq('id', inboxId)
|
|
} else {
|
|
await supabase
|
|
.from('inbound_bill_emails')
|
|
.update({
|
|
status: 'processed',
|
|
bill_id: bill.id,
|
|
processed_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', inboxId)
|
|
console.log('Created bill from inbound email', { billId: bill.id, inboxId })
|
|
}
|
|
}
|
|
|
|
// Notify staff (admins + managers) of the inbound bill
|
|
try {
|
|
const { data: staffRoles } = await supabase
|
|
.from('user_roles')
|
|
.select('user_id')
|
|
.in('role', ['admin', 'manager'])
|
|
|
|
const uniqueIds = Array.from(
|
|
new Set((staffRoles ?? []).map((r: any) => r.user_id).filter(Boolean))
|
|
)
|
|
|
|
if (uniqueIds.length > 0) {
|
|
const senderLabel = fromName || fromEmail || 'Unknown sender'
|
|
const subjectLabel = subject ? `: ${subject}` : ''
|
|
const title = mappedAssociationId
|
|
? 'New inbound bill received'
|
|
: 'Inbound bill needs review'
|
|
const message = mappedAssociationId
|
|
? `Bill from ${senderLabel}${subjectLabel} was received and processed.`
|
|
: `Email from ${senderLabel}${subjectLabel} could not be matched to a vendor and needs manual review.`
|
|
const link = '/dashboard/inbound-bills'
|
|
|
|
const notifications = uniqueIds.map((uid) => ({
|
|
user_id: uid,
|
|
type: 'inbound_bill_received',
|
|
title,
|
|
message,
|
|
related_item_id: inboxId,
|
|
related_item_type: 'inbound_bill_email',
|
|
link,
|
|
}))
|
|
|
|
const { error: notifErr } = await supabase
|
|
.from('in_app_notifications')
|
|
.insert(notifications)
|
|
if (notifErr) console.error('Failed to insert notifications', notifErr)
|
|
}
|
|
} catch (err) {
|
|
console.error('Notification dispatch failed', err)
|
|
}
|
|
|
|
return new Response(JSON.stringify({ ok: true, inboxId }), {
|
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
})
|
|
})
|