Skip to content

Commit 8574a3b

Browse files
committed
feat: llm stubout webhook handler for accounting sync
1 parent 4511ab5 commit 8574a3b

File tree

6 files changed

+1177
-44
lines changed

6 files changed

+1177
-44
lines changed
Lines changed: 189 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
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";
323
import type { ActionFunctionArgs } from "@vercel/remix";
424
import { json } from "@vercel/remix";
525
import 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+
49110
export 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

Comments
 (0)