@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.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # FreshBooks Sync
2
+
3
+ > Synchronize Timesheet tasks with FreshBooks time entries.
4
+
5
+ Bidirectional synchronization between Timesheet tasks and FreshBooks `time_entries`. Timesheet projects map to FreshBooks projects and Timesheet users map to FreshBooks team members, so logged time lands on the right project and teammate in FreshBooks for billing.
6
+
7
+ - Package: `@timesheet/plugin-freshbooks`
8
+ - Manifest id: `freshbooks-sync`
9
+ - Category: accounting
10
+
11
+ ## Authentication
12
+
13
+ OAuth 2.0 against FreshBooks. The access token is not scoped to a single business, so the plugin resolves the target business from `GET /auth/api/v1/users/me`:
14
+
15
+ - `business.id` keys the Time Tracking, Projects, Services and Team Members endpoints.
16
+ - `business.account_id` keys the Events (webhook callback) endpoints.
17
+
18
+ When the connected identity belongs to more than one business, set the `businessId` config option to pick one; otherwise the first membership is used.
19
+
20
+ Requested scopes: `user:profile:read`, `user:time_entries:read`, `user:time_entries:write`, `user:projects:read`, `user:clients:read`, `user:billable_items:read`, `user:teams:read`.
21
+
22
+ ## Configuration
23
+
24
+ | Option | Required | Default | Description |
25
+ | --- | --- | --- | --- |
26
+ | `syncDirection` | yes | `bidirectional` | One of `bidirectional`, `timesheet-to-freshbooks`, `freshbooks-to-timesheet`. |
27
+ | `businessId` | no | (first) | FreshBooks `business.id` to sync when the account has multiple businesses. |
28
+
29
+ ## Mappings
30
+
31
+ - **Project mapping** (required): Timesheet project to FreshBooks project. The FreshBooks project supplies the client the time entry is billed to.
32
+ - **User mapping** (required): Timesheet user to FreshBooks team member (`identity_id`).
33
+ - **Rate mapping** (optional): Timesheet rate to FreshBooks service (`service_id`).
34
+
35
+ ## Triggers
36
+
37
+ - **Task Changed** (event): pushes Timesheet task create, update, and delete events to FreshBooks.
38
+ - **FreshBooks Webhook** (webhook): receives inbound `time_entry` changes.
39
+ - **Scheduled Full Sync** (daily at 02:00 UTC): reconciles task changes to FreshBooks.
40
+ - **Manual Sync** (user action): pulls FreshBooks time entries updated since the last run.
41
+ - **Register Webhooks** (user action): registers the FreshBooks callback for real-time inbound sync.
42
+
43
+ ### Webhook
44
+
45
+ FreshBooks callbacks are registered per account. **Register Webhooks** creates a single `time_entry` callback pointing at the plugin's webhook endpoint. FreshBooks then delivers a verification `verifier` to that endpoint; the handler stores it (it becomes the HMAC secret) and confirms the callback. Subsequent deliveries are authenticated with the `X-FreshBooks-Hmac-SHA256` signature over the raw body. The Scheduled Full Sync and Manual Sync remain the reliable inbound path if callbacks are not registered.
46
+
47
+ ### Echo suppression
48
+
49
+ FreshBooks time entries expose no `updated_at`, only a `created_at` and an `updated_since` list filter. Outbound echoes are skipped with the SDK's `isAlreadySyncedLocalChange` guard; inbound echoes are detected by comparing the incoming entry's content to the local task (`taskDiffersFromDesired`) and re-stamping `timesheetUpdatedAt` after each inbound write.
50
+
51
+ ## Development
52
+
53
+ ```bash
54
+ npm install # or: npm ci
55
+ npm run build # compile TypeScript into dist/
56
+ npm run typecheck
57
+ ```
58
+
59
+ The package ships `dist/` and `manifest.json`; the sandboxed plugin runtime loads it by package name, version, and integrity (`sha512-...`). Shared tests live in `integrations/tests/` and run from the `integrations/` root with `npm test`.
@@ -0,0 +1,4 @@
1
+ import { SyncModeInput } from '@timesheet/integration-sdk';
2
+ import { FreshBooksConfig } from '../lib/types';
3
+ import { FreshBooksSyncResult } from '../lib/taskSync';
4
+ export declare const handleSyncBatch: import("@timesheet/integration-sdk").IntegrationHandler<SyncModeInput, FreshBooksSyncResult, FreshBooksConfig>;
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleSyncBatch = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ const SYSTEM = 'freshbooks';
7
+ const PROJECT_ENTITY = 'project';
8
+ const USER_ENTITY = 'user';
9
+ const TASK_ENTITY = 'task';
10
+ const RATE_ENTITY = 'rate';
11
+ exports.handleSyncBatch = (0, integration_sdk_1.defineHandler)(async (input, context) => {
12
+ context.logger.info('Processing sync batch', {
13
+ sinceVersion: input.sinceVersion,
14
+ headVersion: input.headVersion,
15
+ changeCount: input.changes.length,
16
+ hasMore: input.hasMore
17
+ });
18
+ const [projectMappings, userMappings, taskMappings, rateMappings] = await Promise.all([
19
+ context.mappings.list({ system: SYSTEM, entity: PROJECT_ENTITY }),
20
+ context.mappings.list({ system: SYSTEM, entity: USER_ENTITY }),
21
+ context.mappings.list({ system: SYSTEM, entity: TASK_ENTITY }),
22
+ context.mappings.list({ system: SYSTEM, entity: RATE_ENTITY })
23
+ ]);
24
+ const projectMappingByLocalId = new Map();
25
+ for (const mapping of projectMappings) {
26
+ projectMappingByLocalId.set(mapping.localId, mapping);
27
+ }
28
+ const userMappingByLocalId = new Map();
29
+ for (const mapping of userMappings) {
30
+ userMappingByLocalId.set(mapping.localId, mapping);
31
+ }
32
+ const taskMappingByLocalId = new Map();
33
+ for (const mapping of taskMappings) {
34
+ taskMappingByLocalId.set(mapping.localId, mapping);
35
+ }
36
+ const rateMappingByLocalId = new Map();
37
+ for (const mapping of rateMappings) {
38
+ rateMappingByLocalId.set(mapping.localId, mapping);
39
+ }
40
+ let syncedCount = 0;
41
+ const errors = [];
42
+ for (const change of input.changes) {
43
+ if (change.entityType !== 'task') {
44
+ continue;
45
+ }
46
+ try {
47
+ const event = change.op === 'DELETE' ? 'task.delete' : 'task.update';
48
+ const item = change.item;
49
+ const result = await (0, taskSync_1.syncTaskToFreshBooks)({
50
+ event,
51
+ taskId: change.entityId,
52
+ item
53
+ }, context, { projectMappingByLocalId, userMappingByLocalId, taskMappingByLocalId, rateMappingByLocalId });
54
+ if (result.status === 'synced' || result.status === 'deleted') {
55
+ syncedCount++;
56
+ }
57
+ else if (result.status === 'skipped') {
58
+ context.logger.debug('Change skipped', { entityId: change.entityId, reason: result.details?.reason });
59
+ }
60
+ }
61
+ catch (err) {
62
+ context.logger.error('Failed to sync change', {
63
+ entityId: change.entityId,
64
+ op: change.op,
65
+ error: String(err)
66
+ });
67
+ errors.push({ entityId: change.entityId, error: String(err) });
68
+ }
69
+ }
70
+ return {
71
+ system: SYSTEM,
72
+ status: errors.length > 0 ? 'partial' : 'completed',
73
+ syncedCount,
74
+ details: {
75
+ sinceVersion: input.sinceVersion,
76
+ headVersion: input.headVersion,
77
+ totalChanges: input.changes.length,
78
+ hasMore: input.hasMore,
79
+ errors: errors.length > 0 ? errors : undefined
80
+ }
81
+ };
82
+ });
@@ -0,0 +1,3 @@
1
+ import { FreshBooksConfig, SyncInput } from '../lib/types';
2
+ import { FreshBooksSyncResult } from '../lib/taskSync';
3
+ export declare const handleWebhook: import("@timesheet/integration-sdk").IntegrationHandler<SyncInput, FreshBooksSyncResult, FreshBooksConfig>;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleWebhook = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.handleWebhook = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
+ context.logger.info('Handling FreshBooks webhook', {
8
+ installationId: context.installationId,
9
+ hasBody: !!input?.body
10
+ });
11
+ return await (0, taskSync_1.handleFreshBooksWebhook)(input, context);
12
+ });
@@ -0,0 +1,3 @@
1
+ import { ExternalEntity } from '@timesheet/integration-sdk';
2
+ import { FreshBooksConfig } from '../lib/types';
3
+ export declare const listExternalProjects: import("@timesheet/integration-sdk").IntegrationHandler<void, ExternalEntity[], FreshBooksConfig>;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listExternalProjects = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ const SYSTEM = 'freshbooks';
7
+ exports.listExternalProjects = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
8
+ context.logger.info('Listing FreshBooks projects', {
9
+ system: SYSTEM,
10
+ installationId: context.installationId
11
+ });
12
+ const client = (0, taskSync_1.createFreshBooksClient)(context);
13
+ return await client.listProjects();
14
+ });
@@ -0,0 +1,3 @@
1
+ import { ExternalEntity } from '@timesheet/integration-sdk';
2
+ import { FreshBooksConfig } from '../lib/types';
3
+ export declare const listExternalServices: import("@timesheet/integration-sdk").IntegrationHandler<void, ExternalEntity[], FreshBooksConfig>;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listExternalServices = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ const SYSTEM = 'freshbooks';
7
+ exports.listExternalServices = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
8
+ context.logger.info('Listing FreshBooks services', {
9
+ system: SYSTEM,
10
+ installationId: context.installationId
11
+ });
12
+ const client = (0, taskSync_1.createFreshBooksClient)(context);
13
+ return await client.listServices();
14
+ });
@@ -0,0 +1,3 @@
1
+ import { ExternalEntity } from '@timesheet/integration-sdk';
2
+ import { FreshBooksConfig } from '../lib/types';
3
+ export declare const listExternalUsers: import("@timesheet/integration-sdk").IntegrationHandler<void, ExternalEntity[], FreshBooksConfig>;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.listExternalUsers = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ const SYSTEM = 'freshbooks';
7
+ exports.listExternalUsers = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
8
+ context.logger.info('Listing FreshBooks team members', {
9
+ system: SYSTEM,
10
+ installationId: context.installationId
11
+ });
12
+ const client = (0, taskSync_1.createFreshBooksClient)(context);
13
+ return await client.listTeamMembers();
14
+ });
@@ -0,0 +1,3 @@
1
+ import { FreshBooksConfig } from '../lib/types';
2
+ import { FreshBooksSyncResult } from '../lib/taskSync';
3
+ export declare const registerWebhooks: import("@timesheet/integration-sdk").IntegrationHandler<void, FreshBooksSyncResult, FreshBooksConfig>;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerWebhooks = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.registerWebhooks = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
7
+ context.logger.info('Registering FreshBooks webhooks', {
8
+ installationId: context.installationId
9
+ });
10
+ return await (0, taskSync_1.registerFreshBooksWebhooks)(context);
11
+ });
@@ -0,0 +1,3 @@
1
+ import { FreshBooksConfig } from '../lib/types';
2
+ import { FreshBooksSyncResult } from '../lib/taskSync';
3
+ export declare const runFullSync: import("@timesheet/integration-sdk").IntegrationHandler<void, FreshBooksSyncResult, FreshBooksConfig>;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runFullSync = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.runFullSync = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
7
+ context.logger.info('Running FreshBooks full sync', {
8
+ installationId: context.installationId,
9
+ syncDirection: context.config?.syncDirection ?? 'bidirectional'
10
+ });
11
+ return await (0, taskSync_1.runFreshBooksFullSync)(context);
12
+ });
@@ -0,0 +1,3 @@
1
+ import { FreshBooksConfig, SyncInput } from '../lib/types';
2
+ import { FreshBooksSyncResult } from '../lib/taskSync';
3
+ export declare const syncTaskFromExternal: import("@timesheet/integration-sdk").IntegrationHandler<SyncInput, FreshBooksSyncResult, FreshBooksConfig>;
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.syncTaskFromExternal = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.syncTaskFromExternal = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
+ context.logger.info('Syncing task from FreshBooks payload', {
8
+ installationId: context.installationId,
9
+ externalTaskId: input?.externalTaskId
10
+ });
11
+ return await (0, taskSync_1.syncTaskFromFreshBooks)(input, context);
12
+ });
@@ -0,0 +1,3 @@
1
+ import { FreshBooksConfig, SyncInput } from '../lib/types';
2
+ import { FreshBooksSyncResult } from '../lib/taskSync';
3
+ export declare const syncTaskToExternal: import("@timesheet/integration-sdk").IntegrationHandler<SyncInput, FreshBooksSyncResult, FreshBooksConfig>;
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.syncTaskToExternal = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ exports.syncTaskToExternal = (0, integration_sdk_1.defineHandler)(async (input, context) => {
7
+ context.logger.info('Syncing task to FreshBooks', {
8
+ installationId: context.installationId,
9
+ event: input?.event,
10
+ taskId: input?.taskId ?? input?.item?.id
11
+ });
12
+ return await (0, taskSync_1.syncTaskToFreshBooks)(input, context);
13
+ });
@@ -0,0 +1,7 @@
1
+ import { FreshBooksConfig } from '../lib/types';
2
+ export declare const testConnection: import("@timesheet/integration-sdk").IntegrationHandler<void, {
3
+ system: string;
4
+ ok: boolean;
5
+ installationId: string;
6
+ businessId: string;
7
+ }, FreshBooksConfig>;
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.testConnection = void 0;
4
+ const integration_sdk_1 = require("@timesheet/integration-sdk");
5
+ const taskSync_1 = require("../lib/taskSync");
6
+ const SYSTEM = 'freshbooks';
7
+ exports.testConnection = (0, integration_sdk_1.defineHandler)(async (_input, context) => {
8
+ const client = (0, taskSync_1.createFreshBooksClient)(context);
9
+ try {
10
+ const business = await client.resolveBusiness();
11
+ return {
12
+ system: SYSTEM,
13
+ ok: !!business?.id,
14
+ installationId: context.installationId,
15
+ businessId: business?.id ? String(business.id) : ''
16
+ };
17
+ }
18
+ catch (err) {
19
+ context.logger.warn('FreshBooks test connection failed', { error: String(err) });
20
+ return {
21
+ system: SYSTEM,
22
+ ok: false,
23
+ installationId: context.installationId,
24
+ businessId: ''
25
+ };
26
+ }
27
+ });
@@ -0,0 +1,12 @@
1
+ export { syncTaskToExternal } from './handlers/syncTaskToExternal';
2
+ export { syncTaskFromExternal } from './handlers/syncTaskFromExternal';
3
+ export { handleSyncBatch } from './handlers/handleSyncBatch';
4
+ export { handleWebhook } from './handlers/handleWebhook';
5
+ export { runFullSync } from './handlers/runFullSync';
6
+ export { registerWebhooks } from './handlers/registerWebhooks';
7
+ export { testConnection } from './handlers/testConnection';
8
+ export { listExternalProjects } from './handlers/listExternalProjects';
9
+ export { listExternalUsers } from './handlers/listExternalUsers';
10
+ export { listExternalServices } from './handlers/listExternalServices';
11
+ export declare const PLUGIN_SYSTEM = "freshbooks";
12
+ export declare const PLUGIN_NAME = "FreshBooks Sync";
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLUGIN_NAME = exports.PLUGIN_SYSTEM = exports.listExternalServices = exports.listExternalUsers = exports.listExternalProjects = exports.testConnection = exports.registerWebhooks = exports.runFullSync = exports.handleWebhook = exports.handleSyncBatch = exports.syncTaskFromExternal = exports.syncTaskToExternal = void 0;
4
+ var syncTaskToExternal_1 = require("./handlers/syncTaskToExternal");
5
+ Object.defineProperty(exports, "syncTaskToExternal", { enumerable: true, get: function () { return syncTaskToExternal_1.syncTaskToExternal; } });
6
+ var syncTaskFromExternal_1 = require("./handlers/syncTaskFromExternal");
7
+ Object.defineProperty(exports, "syncTaskFromExternal", { enumerable: true, get: function () { return syncTaskFromExternal_1.syncTaskFromExternal; } });
8
+ var handleSyncBatch_1 = require("./handlers/handleSyncBatch");
9
+ Object.defineProperty(exports, "handleSyncBatch", { enumerable: true, get: function () { return handleSyncBatch_1.handleSyncBatch; } });
10
+ var handleWebhook_1 = require("./handlers/handleWebhook");
11
+ Object.defineProperty(exports, "handleWebhook", { enumerable: true, get: function () { return handleWebhook_1.handleWebhook; } });
12
+ var runFullSync_1 = require("./handlers/runFullSync");
13
+ Object.defineProperty(exports, "runFullSync", { enumerable: true, get: function () { return runFullSync_1.runFullSync; } });
14
+ var registerWebhooks_1 = require("./handlers/registerWebhooks");
15
+ Object.defineProperty(exports, "registerWebhooks", { enumerable: true, get: function () { return registerWebhooks_1.registerWebhooks; } });
16
+ var testConnection_1 = require("./handlers/testConnection");
17
+ Object.defineProperty(exports, "testConnection", { enumerable: true, get: function () { return testConnection_1.testConnection; } });
18
+ var listExternalProjects_1 = require("./handlers/listExternalProjects");
19
+ Object.defineProperty(exports, "listExternalProjects", { enumerable: true, get: function () { return listExternalProjects_1.listExternalProjects; } });
20
+ var listExternalUsers_1 = require("./handlers/listExternalUsers");
21
+ Object.defineProperty(exports, "listExternalUsers", { enumerable: true, get: function () { return listExternalUsers_1.listExternalUsers; } });
22
+ var listExternalServices_1 = require("./handlers/listExternalServices");
23
+ Object.defineProperty(exports, "listExternalServices", { enumerable: true, get: function () { return listExternalServices_1.listExternalServices; } });
24
+ exports.PLUGIN_SYSTEM = 'freshbooks';
25
+ exports.PLUGIN_NAME = 'FreshBooks Sync';
@@ -0,0 +1,47 @@
1
+ import { ExternalEntity } from '@timesheet/integration-sdk';
2
+ import { FreshBooksBusiness, FreshBooksCallback, FreshBooksProject, FreshBooksTimeEntry } from './types';
3
+ interface FreshBooksClientOptions {
4
+ getAccessToken: () => Promise<string>;
5
+ refreshAccessToken: () => Promise<string>;
6
+ /** Optional `business.id` override for multi-business identities. */
7
+ businessId?: string;
8
+ }
9
+ export declare class FreshBooksClient {
10
+ private static readonly BASE_URL;
11
+ private static readonly REQUEST_TIMEOUT_MS;
12
+ private static readonly PAGE_SIZE;
13
+ private readonly fetchAccessToken;
14
+ private readonly fetchRefreshedToken;
15
+ private readonly businessIdOverride;
16
+ private cachedToken;
17
+ private resolvedBusiness;
18
+ private readonly projectClientCache;
19
+ constructor(options: FreshBooksClientOptions);
20
+ resolveBusiness(): Promise<FreshBooksBusiness>;
21
+ businessId(): Promise<string>;
22
+ accountId(): Promise<string>;
23
+ testConnection(): Promise<boolean>;
24
+ listProjects(): Promise<ExternalEntity[]>;
25
+ getProject(projectId: string): Promise<FreshBooksProject | null>;
26
+ resolveClientId(projectId: string): Promise<number | null>;
27
+ listTeamMembers(): Promise<ExternalEntity[]>;
28
+ listServices(): Promise<ExternalEntity[]>;
29
+ listTimeEntries(options?: {
30
+ updatedSinceIso?: string;
31
+ }): Promise<FreshBooksTimeEntry[]>;
32
+ getTimeEntry(id: string): Promise<FreshBooksTimeEntry | null>;
33
+ createTimeEntry(payload: Record<string, unknown>): Promise<FreshBooksTimeEntry>;
34
+ updateTimeEntry(id: string, payload: Record<string, unknown>): Promise<FreshBooksTimeEntry>;
35
+ deleteTimeEntry(id: string): Promise<void>;
36
+ listCallbacks(): Promise<FreshBooksCallback[]>;
37
+ createCallback(event: string, uri: string): Promise<FreshBooksCallback | null>;
38
+ verifyCallback(callbackId: string, verifier: string): Promise<void>;
39
+ deleteCallback(callbackId: string): Promise<void>;
40
+ private paginate;
41
+ private paginateResponseArray;
42
+ private getAccessToken;
43
+ private refreshAccessToken;
44
+ private request;
45
+ private buildUrl;
46
+ }
47
+ export {};