1- import { getCarbonServiceRole , QUICKBOOKS_WEBHOOK_SECRET } from "@carbon/auth" ;
1+ /**
2+ * QuickBooks Webhook Handler
3+ *
4+ * This endpoint receives webhook notifications from QuickBooks when entities
5+ * (customers, vendors, etc.) are created, updated, or deleted in QuickBooks.
6+ *
7+ * The webhook handler:
8+ * 1. Validates the webhook payload structure
9+ * 2. Verifies the webhook signature for security
10+ * 3. Looks up the company integration by QuickBooks realm ID
11+ * 4. Triggers background sync jobs to process the entity changes
12+ *
13+ * Supported entity types:
14+ * - Customer: Synced to Carbon's customer table
15+ * - Vendor: Synced to Carbon's supplier table
16+ *
17+ * The actual sync logic is handled asynchronously by the accounting-sync
18+ * background job to prevent webhook timeouts and ensure reliability.
19+ */
220
21+ import { getCarbonServiceRole , QUICKBOOKS_WEBHOOK_SECRET } from "@carbon/auth" ;
22+ import { tasks } from "@trigger.dev/sdk/v3" ;
323import type { ActionFunctionArgs } from "@vercel/remix" ;
424import { json } from "@vercel/remix" ;
525import crypto from "crypto" ;
@@ -46,21 +66,78 @@ function verifyQuickBooksSignature(
4666 ) ;
4767}
4868
69+ async function triggerAccountingSync (
70+ companyId : string ,
71+ realmId : string ,
72+ entities : Array < {
73+ entityType : "customer" | "vendor" ;
74+ entityId : string ;
75+ operation : "Create" | "Update" | "Delete" ;
76+ } >
77+ ) {
78+ // Prepare the payload for the accounting sync job
79+ const payload = {
80+ companyId,
81+ provider : "quickbooks" as const ,
82+ syncType : "webhook" as const ,
83+ syncDirection : "from_accounting" as const ,
84+ entities : entities . map ( ( entity ) => ( {
85+ entityType : entity . entityType ,
86+ entityId : entity . entityId ,
87+ operation : entity . operation . toLowerCase ( ) as
88+ | "create"
89+ | "update"
90+ | "delete" ,
91+ externalId : entity . entityId , // In QuickBooks, the entity ID is the external ID
92+ } ) ) ,
93+ metadata : {
94+ tenantId : realmId ,
95+ webhookId : crypto . randomUUID ( ) ,
96+ timestamp : new Date ( ) . toISOString ( ) ,
97+ } ,
98+ } ;
99+
100+ // Trigger the background job using Trigger.dev
101+ const handle = await tasks . trigger ( "accounting-sync" , payload ) ;
102+
103+ console . log (
104+ `Triggered accounting sync job ${ handle . id } for ${ entities . length } entities`
105+ ) ;
106+
107+ return handle ;
108+ }
109+
49110export async function action ( { request, params } : ActionFunctionArgs ) {
50111 const serviceRole = await getCarbonServiceRole ( ) ;
51112
113+ // Parse and validate the webhook payload
52114 const payload = await request . clone ( ) . json ( ) ;
53-
54115 const parsedPayload = quickbooksEventValidator . safeParse ( payload ) ;
116+
55117 if ( ! parsedPayload . success ) {
56- return json ( { success : false } , { status : 400 } ) ;
118+ console . error ( "Invalid QuickBooks webhook payload:" , parsedPayload . error ) ;
119+ return json (
120+ {
121+ success : false ,
122+ error : "Invalid payload format" ,
123+ } ,
124+ { status : 400 }
125+ ) ;
57126 }
58127
128+ // Verify webhook signature for security
59129 const payloadText = await request . text ( ) ;
60130 const signatureHeader = request . headers . get ( "intuit-signature" ) ;
61131
62132 if ( ! signatureHeader ) {
63- return json ( { success : false } , { status : 401 } ) ;
133+ console . warn ( "QuickBooks webhook received without signature" ) ;
134+ return json (
135+ {
136+ success : false ,
137+ error : "Missing signature" ,
138+ } ,
139+ { status : 401 }
140+ ) ;
64141 }
65142
66143 const requestIsValid = verifyQuickBooksSignature (
@@ -69,51 +146,121 @@ export async function action({ request, params }: ActionFunctionArgs) {
69146 ) ;
70147
71148 if ( ! requestIsValid ) {
72- return json ( { success : false } , { status : 401 } ) ;
149+ console . error ( "QuickBooks webhook signature verification failed" ) ;
150+ return json (
151+ {
152+ success : false ,
153+ error : "Invalid signature" ,
154+ } ,
155+ { status : 401 }
156+ ) ;
73157 }
74158
159+ console . log (
160+ "Processing QuickBooks webhook with" ,
161+ parsedPayload . data . eventNotifications . length ,
162+ "events"
163+ ) ;
164+
75165 const events = parsedPayload . data . eventNotifications ;
76- for await ( const event of events ) {
77- const { realmId, dataChangeEvent } = event ;
78-
79- const companyIntegration = await serviceRole
80- . from ( "companyIntegration" )
81- . select ( "*" )
82- . eq ( "metadata->>tenantId" , realmId )
83- . eq ( "id" , "quickbooks" )
84- . single ( ) ;
85-
86- console . log ( { companyIntegration } ) ;
87-
88- const { entities } = dataChangeEvent ;
89- for await ( const entity of entities ) {
90- const { id, name, operation } = entity ;
91- console . log ( { realmId, id, name, operation } ) ;
92- }
93- }
166+ const syncJobs = [ ] ;
167+ const errors = [ ] ;
168+
169+ // Process each event notification
170+ for ( const event of events ) {
171+ try {
172+ const { realmId, dataChangeEvent } = event ;
94173
95- // const quickbooksIntegration = await serviceRole
96- // .from("companyIntegration")
97- // .select("*")
98- // .eq("metadata->>tenantId", realmId)
99- // .eq("id", "quickbooks")
100- // .single();
174+ // Find the company integration for this QuickBooks realm
175+ const companyIntegration = await serviceRole
176+ . from ( "companyIntegration" )
177+ . select ( "*" )
178+ . eq ( "metadata->>tenantId" , realmId )
179+ . eq ( "id" , "quickbooks" )
180+ . single ( ) ;
101181
102- // try {
103- // const { webhookToken } = integrationValidator.parse(
104- // quickbooksIntegration.data.metadata
105- // );
182+ if ( companyIntegration . error || ! companyIntegration . data . companyId ) {
183+ console . error ( `No QuickBooks integration found for realm ${ realmId } ` ) ;
184+ errors . push ( {
185+ realmId,
186+ error : "Integration not found" ,
187+ } ) ;
188+ continue ;
189+ }
106190
107- // const payloadText = await request.text() ;
108- //
191+ const companyId = companyIntegration . data . companyId ;
192+ const { entities } = dataChangeEvent ;
109193
110- // const payload = JSON.parse(payloadText);
111- // console.log("QuickBooks webhook payload", payload);
194+ // Group entities by type for efficient batch processing
195+ const entitiesToSync : Array < {
196+ entityType : "customer" | "vendor" ;
197+ entityId : string ;
198+ operation : "Create" | "Update" | "Delete" ;
199+ } > = [ ] ;
112200
113- // await tasks.trigger<typeof quickbooksTask>("quickbooks", {
114- // companyId,
115- // payload,
116- // });
201+ for ( const entity of entities ) {
202+ const { id, name, operation } = entity ;
203+
204+ // Log each entity change for debugging
205+ console . log (
206+ `QuickBooks ${ operation } : ${ name } ${ id } (realm: ${ realmId } )`
207+ ) ;
208+
209+ // Map QuickBooks entity types to our internal types
210+ if ( name === "Customer" ) {
211+ entitiesToSync . push ( {
212+ entityType : "customer" ,
213+ entityId : id ,
214+ operation,
215+ } ) ;
216+ } else if ( name === "Vendor" ) {
217+ entitiesToSync . push ( {
218+ entityType : "vendor" ,
219+ entityId : id ,
220+ operation,
221+ } ) ;
222+ } else {
223+ console . log ( `Skipping unsupported entity type: ${ name } ` ) ;
224+ }
225+ }
226+
227+ // Trigger background sync job if there are entities to process
228+ if ( entitiesToSync . length > 0 ) {
229+ try {
230+ const jobHandle = await triggerAccountingSync (
231+ companyId ,
232+ realmId ,
233+ entitiesToSync
234+ ) ;
235+ syncJobs . push ( {
236+ id : jobHandle . id ,
237+ companyId,
238+ realmId,
239+ entityCount : entitiesToSync . length ,
240+ } ) ;
241+ } catch ( error ) {
242+ console . error ( "Failed to trigger sync job:" , error ) ;
243+ errors . push ( {
244+ realmId,
245+ error :
246+ error instanceof Error ? error . message : "Failed to trigger job" ,
247+ } ) ;
248+ }
249+ }
250+ } catch ( error ) {
251+ console . error ( "Error processing event:" , error ) ;
252+ errors . push ( {
253+ error : error instanceof Error ? error . message : "Unknown error" ,
254+ } ) ;
255+ }
256+ }
117257
118- return json ( { success : true } ) ;
258+ // Return detailed response
259+ return json ( {
260+ success : errors . length === 0 ,
261+ jobsTriggered : syncJobs . length ,
262+ jobs : syncJobs ,
263+ errors : errors . length > 0 ? errors : undefined ,
264+ timestamp : new Date ( ) . toISOString ( ) ,
265+ } ) ;
119266}
0 commit comments