@timesheet/plugin-freshbooks 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,739 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resetSharedClient = resetSharedClient;
4
+ exports.createFreshBooksClient = createFreshBooksClient;
5
+ exports.syncTaskToFreshBooks = syncTaskToFreshBooks;
6
+ exports.runFreshBooksFullSync = runFreshBooksFullSync;
7
+ exports.handleFreshBooksWebhook = handleFreshBooksWebhook;
8
+ exports.syncTaskFromFreshBooks = syncTaskFromFreshBooks;
9
+ exports.registerFreshBooksWebhooks = registerFreshBooksWebhooks;
10
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
11
+ const freshbooksClient_1 = require("./freshbooksClient");
12
+ const SYSTEM = 'freshbooks';
13
+ const PROJECT_ENTITY = 'project';
14
+ const USER_ENTITY = 'user';
15
+ const TASK_ENTITY = 'task';
16
+ const RATE_ENTITY = 'rate';
17
+ const SYNC_STATE_KEY = 'freshbooks:last-sync-time';
18
+ const WEBHOOK_STATE_KEY = 'freshbooks:webhook';
19
+ // Import locks close the webhook-vs-full-sync race on first import; held for
20
+ // the TTL (not released on success) so duplicate deliveries stay suppressed
21
+ // until the new mapping is visible everywhere.
22
+ const IMPORT_LOCK_TTL_SECONDS = 60 * 60;
23
+ let sharedClient = null;
24
+ function resetSharedClient() {
25
+ sharedClient = null;
26
+ }
27
+ function createFreshBooksClient(context) {
28
+ return new freshbooksClient_1.FreshBooksClient({
29
+ getAccessToken: () => context.credentials.getAccessToken(SYSTEM),
30
+ refreshAccessToken: () => context.credentials.refreshToken(SYSTEM),
31
+ businessId: context.config?.businessId
32
+ });
33
+ }
34
+ function getOrCreateClient(context) {
35
+ if (!sharedClient) {
36
+ sharedClient = createFreshBooksClient(context);
37
+ }
38
+ return sharedClient;
39
+ }
40
+ // ============================================================================
41
+ // Outbound: Timesheet task → FreshBooks time entry
42
+ // ============================================================================
43
+ async function syncTaskToFreshBooks(input, context, caches) {
44
+ const syncDirection = context.config?.syncDirection ?? 'bidirectional';
45
+ if (syncDirection === 'freshbooks-to-timesheet' || syncDirection === 'external-to-timesheet') {
46
+ return skip({ reason: 'sync-direction-mismatch' });
47
+ }
48
+ const taskId = resolveTaskId(input);
49
+ if (!taskId) {
50
+ return skip({ reason: 'missing-task-id' });
51
+ }
52
+ const task = await loadTask(taskId, input, context);
53
+ if (!task) {
54
+ return skip({ reason: 'task-not-found', taskId });
55
+ }
56
+ if (task.running) {
57
+ return skip({ reason: 'task-running', taskId });
58
+ }
59
+ const client = getOrCreateClient(context);
60
+ const taskMapping = await getMapping(context, caches?.taskMappingByLocalId, TASK_ENTITY, task.id);
61
+ // A delete only needs the task mapping — external project/user mappings may
62
+ // not resolve from a minimal delete payload, and aren't needed to remove the
63
+ // external entry.
64
+ if (task.deleted) {
65
+ if (taskMapping?.externalId) {
66
+ try {
67
+ await client.deleteTimeEntry(taskMapping.externalId);
68
+ }
69
+ catch (err) {
70
+ // A 404 means it's already gone in FreshBooks — treat as deleted.
71
+ if (!String(err).includes('(404)')) {
72
+ throw err;
73
+ }
74
+ }
75
+ await context.mappings.delete({ system: SYSTEM, entity: TASK_ENTITY, localId: task.id });
76
+ caches?.taskMappingByLocalId?.delete(task.id);
77
+ return { system: SYSTEM, status: 'deleted', syncedCount: 1 };
78
+ }
79
+ return skip({ reason: 'already-deleted' });
80
+ }
81
+ // Echo guard: a change not newer than our own last write for this task is
82
+ // the event fired by that write — syncing it out would ping-pong forever.
83
+ if (taskMapping?.externalId
84
+ && (0, integration_sdk_1.isAlreadySyncedLocalChange)(taskMapping.metadata, (0, integration_sdk_1.getLastUpdateMillis)(task))) {
85
+ return skip({ reason: 'already-synced-task-change', taskId: task.id });
86
+ }
87
+ const projectId = task.project?.id;
88
+ if (!projectId) {
89
+ return skip({ reason: 'missing-project', taskId });
90
+ }
91
+ const projectMapping = await getMapping(context, caches?.projectMappingByLocalId, PROJECT_ENTITY, projectId);
92
+ if (!projectMapping?.externalId) {
93
+ return skip({ reason: 'missing-project-mapping', projectId });
94
+ }
95
+ const localUserId = task.user ?? task.member?.uid;
96
+ if (!localUserId) {
97
+ return skip({ reason: 'missing-user', taskId });
98
+ }
99
+ const userMapping = await getMapping(context, caches?.userMappingByLocalId, USER_ENTITY, localUserId);
100
+ if (!userMapping?.externalId) {
101
+ return skip({ reason: 'missing-user-mapping', userId: localUserId });
102
+ }
103
+ const durationSeconds = computeDurationSeconds(task);
104
+ if (durationSeconds == null) {
105
+ return skip({ reason: 'invalid-task-duration', taskId: task.id });
106
+ }
107
+ if (durationSeconds <= 0) {
108
+ return skip({ reason: 'zero-duration', taskId: task.id });
109
+ }
110
+ const startedAt = toStartedAt(task.startDateTime);
111
+ if (!startedAt) {
112
+ return skip({ reason: 'invalid-start-date', taskId: task.id });
113
+ }
114
+ const rateId = task.rate?.id;
115
+ const rateMapping = rateId
116
+ ? await getMapping(context, caches?.rateMappingByLocalId, RATE_ENTITY, rateId)
117
+ : null;
118
+ // The time entry needs the client id; the mapping caches it after the first
119
+ // resolve, otherwise ask FreshBooks for the project's client.
120
+ const clientId = await resolveClientId(client, projectMapping, task.id, context);
121
+ const payload = buildTimeEntryPayload({
122
+ projectExternalId: projectMapping.externalId,
123
+ userExternalId: userMapping.externalId,
124
+ clientId,
125
+ serviceExternalId: rateMapping?.externalId,
126
+ durationSeconds,
127
+ startedAt,
128
+ note: task.description ?? '',
129
+ billable: task.billable ?? false
130
+ });
131
+ let external;
132
+ if (taskMapping?.externalId) {
133
+ const existing = await client.getTimeEntry(taskMapping.externalId);
134
+ if (existing?.id) {
135
+ external = await client.updateTimeEntry(String(existing.id), payload);
136
+ }
137
+ else {
138
+ external = await client.createTimeEntry(payload);
139
+ }
140
+ }
141
+ else {
142
+ external = await client.createTimeEntry(payload);
143
+ }
144
+ const upserted = {
145
+ localId: task.id,
146
+ externalId: String(external.id),
147
+ externalLabel: task.description ?? task.id,
148
+ metadata: buildTaskMappingMetadata({
149
+ projectId: projectMapping.externalId,
150
+ identityId: userMapping.externalId,
151
+ serviceId: rateMapping?.externalId,
152
+ clientId: clientId != null ? String(clientId) : undefined,
153
+ localLastUpdateMillis: (0, integration_sdk_1.getLastUpdateMillis)(task)
154
+ }),
155
+ syncStatus: 'SYNCED'
156
+ };
157
+ await context.mappings.upsert({ system: SYSTEM, entity: TASK_ENTITY, ...upserted });
158
+ caches?.taskMappingByLocalId?.set(task.id, upserted);
159
+ return {
160
+ system: SYSTEM,
161
+ status: 'synced',
162
+ syncedCount: 1,
163
+ details: { taskId: task.id, externalTaskId: String(external.id) }
164
+ };
165
+ }
166
+ // ============================================================================
167
+ // Inbound: FreshBooks → Timesheet (full sync + webhook handler)
168
+ // ============================================================================
169
+ async function runFreshBooksFullSync(context) {
170
+ const syncDirection = context.config?.syncDirection ?? 'bidirectional';
171
+ const allowInbound = syncDirection !== 'timesheet-to-freshbooks' && syncDirection !== 'timesheet-to-external';
172
+ const projectMappings = await context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY });
173
+ const userMappings = await context.mappings.list({ system: SYSTEM, entity: USER_ENTITY });
174
+ if (projectMappings.length === 0 || userMappings.length === 0) {
175
+ return skip({
176
+ reason: projectMappings.length === 0 ? 'missing-project-mappings' : 'missing-user-mappings'
177
+ });
178
+ }
179
+ if (!allowInbound) {
180
+ return {
181
+ system: SYSTEM,
182
+ status: 'completed',
183
+ syncedCount: 0,
184
+ details: { syncDirection, reason: 'outbound-only' }
185
+ };
186
+ }
187
+ const rateMappings = await context.mappings.list({ system: SYSTEM, entity: RATE_ENTITY });
188
+ const projectByExternalId = new Map(projectMappings.map((mapping) => [mapping.externalId, mapping.localId]));
189
+ const userByExternalId = new Map(userMappings.map((mapping) => [mapping.externalId, mapping.localId]));
190
+ const rateByExternalId = new Map(rateMappings.map((mapping) => [mapping.externalId, mapping.localId]));
191
+ const lastSyncTime = (await context.state.get(SYNC_STATE_KEY)) ?? undefined;
192
+ const startedAt = new Date().toISOString();
193
+ const client = getOrCreateClient(context);
194
+ const entries = await client.listTimeEntries({ updatedSinceIso: lastSyncTime });
195
+ let syncedCount = 0;
196
+ for (const entry of entries) {
197
+ const synced = await syncSingleExternalEntry(entry, context, projectByExternalId, userByExternalId, rateByExternalId);
198
+ if (synced) {
199
+ syncedCount += 1;
200
+ }
201
+ }
202
+ await context.state.set(SYNC_STATE_KEY, startedAt);
203
+ return {
204
+ system: SYSTEM,
205
+ status: 'completed',
206
+ syncedCount,
207
+ details: { syncDirection, sinceIso: lastSyncTime ?? null, entryCount: entries.length }
208
+ };
209
+ }
210
+ async function handleFreshBooksWebhook(input, context) {
211
+ const payload = parseWebhookPayload(input.body, getRawBody(input));
212
+ // Verification handshake: on callback creation FreshBooks POSTs a `verifier`
213
+ // to the callback URL. Persist it (it becomes the HMAC secret) and confirm
214
+ // ownership by echoing it back.
215
+ const verifier = readString(payload?.verifier);
216
+ const callbackId = payload?.object_id != null ? String(payload.object_id) : undefined;
217
+ if (verifier && callbackId) {
218
+ await context.state.set(WEBHOOK_STATE_KEY, { callbackId, verifier });
219
+ try {
220
+ await getOrCreateClient(context).verifyCallback(callbackId, verifier);
221
+ }
222
+ catch (err) {
223
+ context.logger.warn('FreshBooks callback verification failed', { callbackId, error: String(err) });
224
+ }
225
+ return { system: SYSTEM, status: 'handshake', syncedCount: 0, details: { callbackId } };
226
+ }
227
+ const syncDirection = context.config?.syncDirection ?? 'bidirectional';
228
+ if (syncDirection === 'timesheet-to-freshbooks' || syncDirection === 'timesheet-to-external') {
229
+ return skip({ reason: 'sync-direction-mismatch' });
230
+ }
231
+ const stored = (await context.state.get(WEBHOOK_STATE_KEY)) ?? undefined;
232
+ const secret = stored?.verifier ?? context.config?.webhookSecret;
233
+ const signature = getHeader(input, 'x-freshbooks-hmac-sha256');
234
+ const rawBody = getRawBody(input);
235
+ // The backend may have already verified and routed the delivery; otherwise
236
+ // fail closed unless we hold a verifier to authenticate the signature.
237
+ if (input?.verified !== true) {
238
+ if (!secret) {
239
+ return { system: SYSTEM, status: 'rejected', syncedCount: 0, details: { reason: 'not-verified' } };
240
+ }
241
+ if (!signature || !rawBody) {
242
+ return { system: SYSTEM, status: 'rejected', syncedCount: 0, details: { reason: 'missing-signature-or-body' } };
243
+ }
244
+ if (!(await verifyFreshBooksSignature(rawBody, signature, secret))) {
245
+ context.logger.warn('FreshBooks webhook rejected: signature mismatch');
246
+ return { system: SYSTEM, status: 'rejected', syncedCount: 0, details: { reason: 'invalid-signature' } };
247
+ }
248
+ }
249
+ const eventName = readString(payload?.name);
250
+ const objectId = payload?.object_id != null ? String(payload.object_id) : undefined;
251
+ if (!eventName || !objectId) {
252
+ return { system: SYSTEM, status: 'ignored', syncedCount: 0, details: { reason: 'no-event' } };
253
+ }
254
+ if (!eventName.startsWith('time_entry')) {
255
+ return { system: SYSTEM, status: 'ignored', syncedCount: 0, details: { reason: 'non-time-entry-event', eventName } };
256
+ }
257
+ if (eventName.endsWith('.delete')) {
258
+ const removed = await deleteLocalTaskByExternalId(context, objectId);
259
+ return {
260
+ system: SYSTEM,
261
+ status: 'completed',
262
+ syncedCount: removed ? 1 : 0,
263
+ details: { eventName, objectId }
264
+ };
265
+ }
266
+ return syncTaskFromFreshBooks({ externalTaskId: objectId }, context);
267
+ }
268
+ async function syncTaskFromFreshBooks(input, context) {
269
+ const syncDirection = context.config?.syncDirection ?? 'bidirectional';
270
+ if (syncDirection === 'timesheet-to-freshbooks' || syncDirection === 'timesheet-to-external') {
271
+ return skip({ reason: 'sync-direction-mismatch' });
272
+ }
273
+ const externalTaskId = input?.externalTaskId;
274
+ if (!externalTaskId) {
275
+ return skip({ reason: 'missing-external-task-id' });
276
+ }
277
+ const projectMappings = await context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY });
278
+ const userMappings = await context.mappings.list({ system: SYSTEM, entity: USER_ENTITY });
279
+ const rateMappings = await context.mappings.list({ system: SYSTEM, entity: RATE_ENTITY });
280
+ const projectByExternalId = new Map(projectMappings.map((mapping) => [mapping.externalId, mapping.localId]));
281
+ const userByExternalId = new Map(userMappings.map((mapping) => [mapping.externalId, mapping.localId]));
282
+ const rateByExternalId = new Map(rateMappings.map((mapping) => [mapping.externalId, mapping.localId]));
283
+ const client = getOrCreateClient(context);
284
+ const entry = await client.getTimeEntry(externalTaskId);
285
+ if (!entry) {
286
+ const removed = await deleteLocalTaskByExternalId(context, externalTaskId);
287
+ return {
288
+ system: SYSTEM,
289
+ status: 'completed',
290
+ syncedCount: removed ? 1 : 0,
291
+ details: { externalTaskId, reason: removed ? 'deleted-missing-entry' : 'entry-not-found' }
292
+ };
293
+ }
294
+ const synced = await syncSingleExternalEntry(entry, context, projectByExternalId, userByExternalId, rateByExternalId);
295
+ return {
296
+ system: SYSTEM,
297
+ status: 'completed',
298
+ syncedCount: synced ? 1 : 0,
299
+ details: { externalTaskId }
300
+ };
301
+ }
302
+ async function syncSingleExternalEntry(entry, context, projectByExternalId, userByExternalId, rateByExternalId) {
303
+ if (!entry?.id) {
304
+ return false;
305
+ }
306
+ const externalProjectId = entry.project_id != null ? String(entry.project_id) : undefined;
307
+ const externalUserId = entry.identity_id != null ? String(entry.identity_id) : undefined;
308
+ if (!externalProjectId || !externalUserId) {
309
+ return false;
310
+ }
311
+ const localProjectId = projectByExternalId.get(externalProjectId);
312
+ const localUserId = userByExternalId.get(externalUserId);
313
+ if (!localProjectId || !localUserId) {
314
+ return false;
315
+ }
316
+ // Only set the rate when the service is mapped — an absent or unmapped
317
+ // service must not clear a locally assigned rate.
318
+ const externalServiceId = entry.service_id != null ? String(entry.service_id) : undefined;
319
+ const localRateId = externalServiceId ? rateByExternalId.get(externalServiceId) : undefined;
320
+ const dateRange = entryToDateRange(entry);
321
+ if (!dateRange) {
322
+ return false;
323
+ }
324
+ const desired = {
325
+ projectId: localProjectId,
326
+ userId: localUserId,
327
+ startDateTime: dateRange.startDateTime,
328
+ endDateTime: dateRange.endDateTime,
329
+ description: entry.note ?? '',
330
+ billable: entry.billable ?? false,
331
+ ...(localRateId ? { rateId: localRateId } : {})
332
+ };
333
+ const taskMapping = await context.mappings.findByExternal({
334
+ system: SYSTEM,
335
+ entity: TASK_ENTITY,
336
+ externalId: String(entry.id)
337
+ });
338
+ if (!taskMapping?.localId) {
339
+ const importLockKey = getEntryImportLockStateKey(entry);
340
+ if (!(await (0, integration_sdk_1.tryAcquireStateLock)(context.state, importLockKey, IMPORT_LOCK_TTL_SECONDS))) {
341
+ context.logger.info('FreshBooks time entry import already in progress, skipping duplicate create', {
342
+ timeEntryId: entry.id
343
+ });
344
+ return false;
345
+ }
346
+ let created;
347
+ try {
348
+ created = await context.data.createTask({
349
+ projectId: desired.projectId,
350
+ userId: desired.userId,
351
+ startDateTime: desired.startDateTime,
352
+ endDateTime: desired.endDateTime,
353
+ description: desired.description,
354
+ billable: desired.billable,
355
+ billed: entry.billed ?? false,
356
+ ...(desired.rateId ? { rateId: desired.rateId } : {})
357
+ });
358
+ }
359
+ catch (err) {
360
+ // No task was created — release the lock so the retry can import the
361
+ // entry instead of skipping it as a duplicate and losing it.
362
+ await (0, integration_sdk_1.releaseStateLock)(context.state, importLockKey);
363
+ throw err;
364
+ }
365
+ await upsertTaskMapping(context, created.id, entry, desired, (0, integration_sdk_1.getLastUpdateMillis)(created) || Date.now());
366
+ return true;
367
+ }
368
+ const existing = await context.data.getTask(taskMapping.localId);
369
+ // FreshBooks time entries carry no external `updated_at`, so the echo of our
370
+ // own outbound write is detected by comparing content: if the entry already
371
+ // matches the local task there is nothing to apply, and re-writing it would
372
+ // bump the task's lastUpdate and restart the loop.
373
+ if (!taskDiffersFromDesired(existing, desired)) {
374
+ return false;
375
+ }
376
+ const updated = await context.data.updateTask(taskMapping.localId, {
377
+ projectId: desired.projectId,
378
+ startDateTime: desired.startDateTime,
379
+ endDateTime: desired.endDateTime,
380
+ description: desired.description,
381
+ billable: desired.billable,
382
+ billed: entry.billed ?? false,
383
+ ...(desired.rateId ? { rateId: desired.rateId } : {})
384
+ });
385
+ // Re-stamp with the post-write lastUpdate so the resulting task.update event
386
+ // is recognised as our own echo and skipped outbound.
387
+ await upsertTaskMapping(context, taskMapping.localId, entry, desired, (0, integration_sdk_1.getLastUpdateMillis)(updated) || Date.now());
388
+ return true;
389
+ }
390
+ // ============================================================================
391
+ // Webhook registration
392
+ // ============================================================================
393
+ async function registerFreshBooksWebhooks(context) {
394
+ const webhookUrl = context.metadata?.webhooks?.['integration-webhook'];
395
+ if (!webhookUrl) {
396
+ return skip({ reason: 'no-webhook-url' });
397
+ }
398
+ const client = getOrCreateClient(context);
399
+ const existing = await client.listCallbacks();
400
+ const alreadyRegistered = existing.some((callback) => callback.uri === webhookUrl && (callback.event ?? '').startsWith('time_entry'));
401
+ if (alreadyRegistered) {
402
+ return { system: SYSTEM, status: 'completed', syncedCount: 0, details: { reason: 'already-registered' } };
403
+ }
404
+ // A single `time_entry` subscription catches create/update/delete under one
405
+ // verifier, so there is exactly one HMAC secret to store.
406
+ const callback = await client.createCallback('time_entry', webhookUrl);
407
+ return {
408
+ system: SYSTEM,
409
+ status: 'completed',
410
+ syncedCount: callback?.callbackid ? 1 : 0,
411
+ details: { callbackId: callback?.callbackid ?? null, uri: webhookUrl }
412
+ };
413
+ }
414
+ // ============================================================================
415
+ // Helpers
416
+ // ============================================================================
417
+ async function upsertTaskMapping(context, localId, entry, desired, localLastUpdateMillis) {
418
+ await context.mappings.upsert({
419
+ system: SYSTEM,
420
+ entity: TASK_ENTITY,
421
+ localId,
422
+ externalId: String(entry.id),
423
+ externalLabel: entry.note ?? String(entry.id),
424
+ metadata: buildTaskMappingMetadata({
425
+ projectId: entry.project_id != null ? String(entry.project_id) : '',
426
+ identityId: entry.identity_id != null ? String(entry.identity_id) : '',
427
+ serviceId: entry.service_id != null ? String(entry.service_id) : undefined,
428
+ clientId: entry.client_id != null ? String(entry.client_id) : undefined,
429
+ localLastUpdateMillis
430
+ }),
431
+ syncStatus: 'SYNCED'
432
+ });
433
+ }
434
+ async function deleteLocalTaskByExternalId(context, externalId) {
435
+ const mapping = await context.mappings.findByExternal({
436
+ system: SYSTEM,
437
+ entity: TASK_ENTITY,
438
+ externalId
439
+ });
440
+ if (!mapping?.localId) {
441
+ return false;
442
+ }
443
+ try {
444
+ await context.data.deleteTask(mapping.localId);
445
+ }
446
+ catch (err) {
447
+ context.logger.warn('Failed to delete local task for FreshBooks delete event', {
448
+ localId: mapping.localId,
449
+ externalId,
450
+ error: String(err)
451
+ });
452
+ }
453
+ await context.mappings.delete({ system: SYSTEM, entity: TASK_ENTITY, localId: mapping.localId });
454
+ return true;
455
+ }
456
+ async function resolveClientId(client, projectMapping, taskId, context) {
457
+ const cached = readMetadataString(projectMapping.metadata ?? {}, 'clientId')
458
+ ?? readMetadataString(projectMapping.metadata ?? {}, 'client_id');
459
+ if (cached) {
460
+ const numeric = Number(cached);
461
+ if (Number.isFinite(numeric)) {
462
+ return numeric;
463
+ }
464
+ }
465
+ try {
466
+ return await client.resolveClientId(projectMapping.externalId);
467
+ }
468
+ catch (err) {
469
+ context.logger.warn('Failed to resolve FreshBooks client for project', {
470
+ taskId,
471
+ projectExternalId: projectMapping.externalId,
472
+ error: String(err)
473
+ });
474
+ return null;
475
+ }
476
+ }
477
+ async function getMapping(context, cache, entity, localId) {
478
+ if (cache) {
479
+ return cache.get(localId) ?? null;
480
+ }
481
+ return context.mappings.get({ system: SYSTEM, entity, localId });
482
+ }
483
+ async function loadTask(taskId, input, context) {
484
+ // Prefer the inline sync payload — normalize its flat fields to the nested
485
+ // API shape (project: { id }) used downstream.
486
+ if (input?.item && typeof input.item === 'object' && (input.item.id || input.item.taskId)) {
487
+ const raw = input.item;
488
+ const projectId = raw.projectId;
489
+ if (!raw.project && projectId) {
490
+ raw.project = { id: projectId };
491
+ }
492
+ const userId = raw.userId;
493
+ if (!raw.user && userId) {
494
+ raw.user = userId;
495
+ }
496
+ const rateId = raw.rateId;
497
+ if (!raw.rate && rateId) {
498
+ raw.rate = { id: rateId };
499
+ }
500
+ if (!raw.id && raw.taskId) {
501
+ raw.id = raw.taskId;
502
+ }
503
+ return raw;
504
+ }
505
+ try {
506
+ return await context.data.getTask(taskId);
507
+ }
508
+ catch {
509
+ return null;
510
+ }
511
+ }
512
+ function resolveTaskId(input) {
513
+ return input?.taskId || input?.entityId || input?.item?.taskId || input?.item?.id;
514
+ }
515
+ function buildTimeEntryPayload(input) {
516
+ const payload = {
517
+ is_logged: true,
518
+ duration: input.durationSeconds,
519
+ started_at: input.startedAt,
520
+ note: input.note,
521
+ project_id: toNumberOrString(input.projectExternalId),
522
+ identity_id: toNumberOrString(input.userExternalId),
523
+ billable: input.billable
524
+ };
525
+ if (input.clientId != null) {
526
+ payload.client_id = input.clientId;
527
+ }
528
+ if (input.serviceExternalId) {
529
+ payload.service_id = toNumberOrString(input.serviceExternalId);
530
+ }
531
+ return payload;
532
+ }
533
+ // Net worked seconds: FreshBooks bills the tracked time excluding breaks, which
534
+ // is how `duration - durationBreak` is reported elsewhere in the platform.
535
+ function computeDurationSeconds(task) {
536
+ const gross = Number(task.duration);
537
+ const breakSeconds = Number(task.durationBreak ?? 0);
538
+ const safeBreak = Number.isFinite(breakSeconds) && breakSeconds > 0 ? breakSeconds : 0;
539
+ if (Number.isFinite(gross) && gross > 0) {
540
+ return Math.max(0, Math.round(gross - safeBreak));
541
+ }
542
+ const start = parseDate(task.startDateTime);
543
+ const end = parseDate(task.endDateTime);
544
+ if (start && end) {
545
+ const seconds = Math.round((end.getTime() - start.getTime()) / 1000) - safeBreak;
546
+ return Math.max(0, seconds);
547
+ }
548
+ return null;
549
+ }
550
+ // FreshBooks `started_at` is UTC ISO 8601 (e.g. 2010-10-17T05:45:53Z).
551
+ function toStartedAt(value) {
552
+ const date = parseDate(value);
553
+ if (!date) {
554
+ return null;
555
+ }
556
+ return `${date.toISOString().slice(0, 19)}Z`;
557
+ }
558
+ // The Timesheet API datetime format is `yyyy-MM-dd'T'HH:mm:ssxxx`; emit an
559
+ // explicit +00:00 offset rather than a trailing 'Z' (which the API rejects).
560
+ function toApiDateTime(date) {
561
+ return `${date.toISOString().slice(0, 19)}+00:00`;
562
+ }
563
+ function entryToDateRange(entry) {
564
+ const start = parseDate(entry.started_at);
565
+ if (!start) {
566
+ return null;
567
+ }
568
+ const durationSeconds = Number(entry.duration ?? 0);
569
+ const safeDuration = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 0;
570
+ const end = new Date(start.getTime() + safeDuration * 1000);
571
+ return {
572
+ startDateTime: toApiDateTime(start),
573
+ endDateTime: toApiDateTime(end)
574
+ };
575
+ }
576
+ // True when the incoming entry would change the local task. Used as the inbound
577
+ // echo guard in place of an external update timestamp.
578
+ function taskDiffersFromDesired(task, desired) {
579
+ if (!task) {
580
+ return true;
581
+ }
582
+ const startEqual = sameInstant(task.startDateTime, desired.startDateTime);
583
+ const endEqual = sameInstant(task.endDateTime, desired.endDateTime);
584
+ const descEqual = (task.description ?? '') === desired.description;
585
+ const billableEqual = (task.billable ?? false) === desired.billable;
586
+ const projectEqual = (task.project?.id ?? '') === desired.projectId;
587
+ const rateEqual = (task.rate?.id ?? undefined) === desired.rateId;
588
+ return !(startEqual && endEqual && descEqual && billableEqual && projectEqual && rateEqual);
589
+ }
590
+ function sameInstant(a, b) {
591
+ const da = parseDate(a);
592
+ const db = parseDate(b);
593
+ if (!da || !db) {
594
+ return da === db;
595
+ }
596
+ return da.getTime() === db.getTime();
597
+ }
598
+ function parseDate(value) {
599
+ if (!value) {
600
+ return null;
601
+ }
602
+ const date = new Date(value);
603
+ return Number.isNaN(date.getTime()) ? null : date;
604
+ }
605
+ function toNumberOrString(value) {
606
+ const numeric = Number(value);
607
+ return Number.isFinite(numeric) && String(numeric) === value ? numeric : value;
608
+ }
609
+ function buildTaskMappingMetadata(input) {
610
+ return {
611
+ projectId: input.projectId,
612
+ identityId: input.identityId,
613
+ ...(input.serviceId ? { serviceId: input.serviceId } : {}),
614
+ ...(input.clientId ? { clientId: input.clientId } : {}),
615
+ // FreshBooks exposes no external update timestamp, so only the local stamp
616
+ // is recorded (echo suppression is content-based on the inbound side).
617
+ ...(0, integration_sdk_1.syncMetadataStamp)({ localLastUpdateMillis: input.localLastUpdateMillis })
618
+ };
619
+ }
620
+ function getEntryImportLockStateKey(entry) {
621
+ const version = entry.created_at || String(entry.duration ?? '') || 'unknown';
622
+ return `freshbooks:time-entry-import:${stableHash(`${entry.id}:${version}`)}`;
623
+ }
624
+ function stableHash(value) {
625
+ let hash = 2166136261;
626
+ for (let i = 0; i < value.length; i++) {
627
+ hash ^= value.charCodeAt(i);
628
+ hash = Math.imul(hash, 16777619);
629
+ }
630
+ return (hash >>> 0).toString(36);
631
+ }
632
+ function readString(value) {
633
+ return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
634
+ }
635
+ function readMetadataString(metadata, key) {
636
+ const value = metadata[key];
637
+ return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
638
+ }
639
+ function getHeader(input, name) {
640
+ const headers = { ...(input?.headers ?? {}) };
641
+ if (input?.body && typeof input.body === 'object') {
642
+ const nested = input.body.headers;
643
+ if (nested && typeof nested === 'object') {
644
+ Object.assign(headers, nested);
645
+ }
646
+ }
647
+ const target = name.toLowerCase();
648
+ const key = Object.keys(headers).find((header) => header.toLowerCase() === target);
649
+ if (!key) {
650
+ return undefined;
651
+ }
652
+ const value = headers[key];
653
+ return value === undefined || value === null ? undefined : String(value);
654
+ }
655
+ function getRawBody(input) {
656
+ if (typeof input?.rawBody === 'string' && input.rawBody.length > 0) {
657
+ return input.rawBody;
658
+ }
659
+ if (typeof input?.body === 'string' && input.body.length > 0) {
660
+ return input.body;
661
+ }
662
+ // Reconstructing JSON here only matches the HMAC if the runtime serialized
663
+ // the parsed body identically to the request bytes — it typically doesn't,
664
+ // so verification fails closed downstream.
665
+ if (input?.body && typeof input.body === 'object') {
666
+ try {
667
+ return JSON.stringify(input.body);
668
+ }
669
+ catch {
670
+ return undefined;
671
+ }
672
+ }
673
+ return undefined;
674
+ }
675
+ function parseWebhookPayload(body, rawBody) {
676
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
677
+ return body;
678
+ }
679
+ const raw = rawBody ?? (typeof body === 'string' ? body : undefined);
680
+ if (!raw) {
681
+ return null;
682
+ }
683
+ try {
684
+ return JSON.parse(raw);
685
+ }
686
+ catch {
687
+ // FreshBooks posts application/x-www-form-urlencoded bodies.
688
+ return parseFormEncoded(raw);
689
+ }
690
+ }
691
+ function parseFormEncoded(raw) {
692
+ try {
693
+ const params = new URLSearchParams(raw);
694
+ const out = {};
695
+ params.forEach((value, key) => {
696
+ out[key] = value;
697
+ });
698
+ return Object.keys(out).length > 0 ? out : null;
699
+ }
700
+ catch {
701
+ return null;
702
+ }
703
+ }
704
+ // Web Crypto is used so this plugin works in sandboxed runtimes (esbuild bundler
705
+ // without Node built-ins). Both Node 18+ and the plugin runtime expose
706
+ // `globalThis.crypto.subtle`.
707
+ async function verifyFreshBooksSignature(rawBody, signatureHeader, secret) {
708
+ // FreshBooks signs the raw request body with HMAC-SHA256 keyed on the
709
+ // callback verifier, and sends the digest as base64 in
710
+ // `X-FreshBooks-Hmac-SHA256`.
711
+ const encoder = new TextEncoder();
712
+ const key = await globalThis.crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
713
+ const signatureBuffer = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(rawBody));
714
+ const expected = bufferToBase64(signatureBuffer);
715
+ return constantTimeEquals(expected, signatureHeader);
716
+ }
717
+ function bufferToBase64(buffer) {
718
+ const bytes = new Uint8Array(buffer);
719
+ let binary = '';
720
+ for (let i = 0; i < bytes.length; i++) {
721
+ binary += String.fromCharCode(bytes[i]);
722
+ }
723
+ return typeof btoa === 'function'
724
+ ? btoa(binary)
725
+ : Buffer.from(binary, 'binary').toString('base64');
726
+ }
727
+ function constantTimeEquals(a, b) {
728
+ if (a.length !== b.length) {
729
+ return false;
730
+ }
731
+ let result = 0;
732
+ for (let i = 0; i < a.length; i++) {
733
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
734
+ }
735
+ return result === 0;
736
+ }
737
+ function skip(details) {
738
+ return { system: SYSTEM, status: 'skipped', syncedCount: 0, details };
739
+ }