@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,289 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FreshBooksClient = void 0;
4
+ class FreshBooksClient {
5
+ constructor(options) {
6
+ this.cachedToken = null;
7
+ this.resolvedBusiness = null;
8
+ // FreshBooks time entries reference a client, but only the project carries
9
+ // the client relationship — cache project → client so outbound pushes don't
10
+ // refetch the project on every task.
11
+ this.projectClientCache = new Map();
12
+ this.fetchAccessToken = options.getAccessToken;
13
+ this.fetchRefreshedToken = options.refreshAccessToken;
14
+ this.businessIdOverride = options.businessId;
15
+ }
16
+ // The access token is not scoped to a business; `/users/me` lists every
17
+ // business the identity belongs to. Time Tracking, Projects, Services and
18
+ // Team Members are keyed on `business.id`; Events (webhooks) on `account_id`.
19
+ async resolveBusiness() {
20
+ if (this.resolvedBusiness) {
21
+ return this.resolvedBusiness;
22
+ }
23
+ const me = await this.request('GET', '/auth/api/v1/users/me');
24
+ const businesses = (me?.response?.business_memberships ?? [])
25
+ .map((membership) => membership.business)
26
+ .filter((business) => !!business?.id && !!business.account_id);
27
+ if (businesses.length === 0) {
28
+ throw new Error('FreshBooks: the connected identity has no business membership.');
29
+ }
30
+ let chosen = businesses[0];
31
+ if (this.businessIdOverride) {
32
+ const match = businesses.find((business) => String(business.id) === this.businessIdOverride);
33
+ if (match) {
34
+ chosen = match;
35
+ }
36
+ }
37
+ this.resolvedBusiness = chosen;
38
+ return chosen;
39
+ }
40
+ async businessId() {
41
+ return String((await this.resolveBusiness()).id);
42
+ }
43
+ async accountId() {
44
+ return (await this.resolveBusiness()).account_id;
45
+ }
46
+ async testConnection() {
47
+ const business = await this.resolveBusiness();
48
+ return !!business?.id;
49
+ }
50
+ async listProjects() {
51
+ const bid = await this.businessId();
52
+ const projects = await this.paginate(`/projects/business/${encodeURIComponent(bid)}/projects`, 'projects', { active: 'true' });
53
+ return projects.map((project) => {
54
+ if (project.id && typeof project.client_id === 'number') {
55
+ this.projectClientCache.set(project.id, project.client_id);
56
+ }
57
+ return {
58
+ id: String(project.id),
59
+ name: project.title ?? String(project.id),
60
+ clientId: typeof project.client_id === 'number' ? project.client_id : null,
61
+ active: project.active ?? true
62
+ };
63
+ });
64
+ }
65
+ async getProject(projectId) {
66
+ const bid = await this.businessId();
67
+ try {
68
+ const response = await this.request('GET', `/projects/business/${encodeURIComponent(bid)}/projects/${encodeURIComponent(projectId)}`);
69
+ return response?.project ?? null;
70
+ }
71
+ catch (err) {
72
+ if (String(err).includes('(404)')) {
73
+ return null;
74
+ }
75
+ throw err;
76
+ }
77
+ }
78
+ // The FreshBooks time entry needs the client id; only the project knows it.
79
+ async resolveClientId(projectId) {
80
+ const numericId = Number(projectId);
81
+ if (Number.isFinite(numericId) && this.projectClientCache.has(numericId)) {
82
+ return this.projectClientCache.get(numericId) ?? null;
83
+ }
84
+ const project = await this.getProject(projectId);
85
+ if (project?.id && typeof project.client_id === 'number') {
86
+ this.projectClientCache.set(project.id, project.client_id);
87
+ return project.client_id;
88
+ }
89
+ return null;
90
+ }
91
+ async listTeamMembers() {
92
+ const bid = await this.businessId();
93
+ const members = await this.paginateResponseArray(`/auth/api/v1/businesses/${encodeURIComponent(bid)}/team_members`);
94
+ return members
95
+ .filter((member) => member.identity_id != null)
96
+ .map((member) => ({
97
+ id: String(member.identity_id),
98
+ name: [member.first_name, member.last_name].filter(Boolean).join(' ').trim() ||
99
+ member.email ||
100
+ String(member.identity_id),
101
+ active: member.active ?? true
102
+ }));
103
+ }
104
+ async listServices() {
105
+ const bid = await this.businessId();
106
+ const services = await this.paginate(`/comments/business/${encodeURIComponent(bid)}/services`, 'services', {});
107
+ return services
108
+ .filter((service) => (service.vis_state ?? 0) === 0)
109
+ .map((service) => ({
110
+ id: String(service.id),
111
+ name: service.name ?? String(service.id),
112
+ billable: service.billable ?? true
113
+ }));
114
+ }
115
+ async listTimeEntries(options) {
116
+ const bid = await this.businessId();
117
+ const query = {};
118
+ if (options?.updatedSinceIso) {
119
+ query.updated_since = options.updatedSinceIso;
120
+ }
121
+ return this.paginate(`/timetracking/business/${encodeURIComponent(bid)}/time_entries`, 'time_entries', query);
122
+ }
123
+ async getTimeEntry(id) {
124
+ const bid = await this.businessId();
125
+ try {
126
+ const response = await this.request('GET', `/timetracking/business/${encodeURIComponent(bid)}/time_entries/${encodeURIComponent(id)}`);
127
+ return response?.time_entry ?? null;
128
+ }
129
+ catch (err) {
130
+ if (String(err).includes('(404)')) {
131
+ return null;
132
+ }
133
+ throw err;
134
+ }
135
+ }
136
+ async createTimeEntry(payload) {
137
+ const bid = await this.businessId();
138
+ const response = await this.request('POST', `/timetracking/business/${encodeURIComponent(bid)}/time_entries`, undefined, { time_entry: payload });
139
+ if (!response?.time_entry?.id) {
140
+ throw new Error('FreshBooks createTimeEntry did not return a time entry id.');
141
+ }
142
+ return response.time_entry;
143
+ }
144
+ async updateTimeEntry(id, payload) {
145
+ const bid = await this.businessId();
146
+ const response = await this.request('PUT', `/timetracking/business/${encodeURIComponent(bid)}/time_entries/${encodeURIComponent(id)}`, undefined, { time_entry: payload });
147
+ if (!response?.time_entry?.id) {
148
+ throw new Error('FreshBooks updateTimeEntry did not return a time entry id.');
149
+ }
150
+ return response.time_entry;
151
+ }
152
+ async deleteTimeEntry(id) {
153
+ const bid = await this.businessId();
154
+ await this.request('DELETE', `/timetracking/business/${encodeURIComponent(bid)}/time_entries/${encodeURIComponent(id)}`);
155
+ }
156
+ // ---- Webhook callbacks (Events API, keyed on account_id) ----
157
+ async listCallbacks() {
158
+ const aid = await this.accountId();
159
+ const response = await this.request('GET', `/events/account/${encodeURIComponent(aid)}/events/callbacks`);
160
+ return response?.response?.result?.callbacks ?? [];
161
+ }
162
+ async createCallback(event, uri) {
163
+ const aid = await this.accountId();
164
+ const response = await this.request('POST', `/events/account/${encodeURIComponent(aid)}/events/callbacks`, undefined, { callback: { event, uri } });
165
+ return response?.response?.result?.callback ?? null;
166
+ }
167
+ // Confirm ownership of a freshly created callback by echoing back the
168
+ // verifier FreshBooks delivered to the callback URL.
169
+ async verifyCallback(callbackId, verifier) {
170
+ const aid = await this.accountId();
171
+ await this.request('PUT', `/events/account/${encodeURIComponent(aid)}/events/callbacks/${encodeURIComponent(callbackId)}`, undefined, { callback: { verifier } });
172
+ }
173
+ async deleteCallback(callbackId) {
174
+ const aid = await this.accountId();
175
+ await this.request('DELETE', `/events/account/${encodeURIComponent(aid)}/events/callbacks/${encodeURIComponent(callbackId)}`);
176
+ }
177
+ // ---- Internals ----
178
+ async paginate(path, key, baseQuery) {
179
+ const out = [];
180
+ let page = 1;
181
+ while (true) {
182
+ const response = await this.request('GET', path, {
183
+ ...baseQuery,
184
+ page: String(page),
185
+ per_page: String(FreshBooksClient.PAGE_SIZE)
186
+ });
187
+ const items = Array.isArray(response?.[key]) ? response[key] : [];
188
+ out.push(...items);
189
+ const meta = response?.meta;
190
+ const pages = meta?.pages ?? 1;
191
+ if (page >= pages || items.length === 0) {
192
+ break;
193
+ }
194
+ page += 1;
195
+ }
196
+ return out;
197
+ }
198
+ // The Identity/Auth API returns list payloads under `response` (an array)
199
+ // rather than a named resource key.
200
+ async paginateResponseArray(path) {
201
+ const out = [];
202
+ let page = 1;
203
+ while (true) {
204
+ const response = await this.request('GET', path, {
205
+ page: String(page),
206
+ per_page: String(FreshBooksClient.PAGE_SIZE)
207
+ });
208
+ const items = Array.isArray(response?.response) ? response.response : [];
209
+ out.push(...items);
210
+ const pages = response?.meta?.pages ?? 1;
211
+ if (page >= pages || items.length === 0) {
212
+ break;
213
+ }
214
+ page += 1;
215
+ }
216
+ return out;
217
+ }
218
+ async getAccessToken() {
219
+ if (this.cachedToken) {
220
+ return this.cachedToken;
221
+ }
222
+ this.cachedToken = await this.fetchAccessToken();
223
+ return this.cachedToken;
224
+ }
225
+ async refreshAccessToken() {
226
+ this.cachedToken = null;
227
+ this.cachedToken = await this.fetchRefreshedToken();
228
+ return this.cachedToken;
229
+ }
230
+ async request(method, path, query, body, retried = false) {
231
+ const token = await this.getAccessToken();
232
+ const url = this.buildUrl(path, query);
233
+ const controller = new AbortController();
234
+ const timeoutId = setTimeout(() => controller.abort(), FreshBooksClient.REQUEST_TIMEOUT_MS);
235
+ let response;
236
+ try {
237
+ response = await fetch(url, {
238
+ method,
239
+ headers: {
240
+ Authorization: `Bearer ${token}`,
241
+ Accept: 'application/json',
242
+ 'Content-Type': 'application/json',
243
+ // Required by the Projects and Time Tracking (alpha) endpoints.
244
+ 'Api-Version': 'alpha'
245
+ },
246
+ body: body !== undefined ? JSON.stringify(body) : undefined,
247
+ signal: controller.signal
248
+ });
249
+ }
250
+ catch (error) {
251
+ clearTimeout(timeoutId);
252
+ if (error instanceof DOMException && error.name === 'AbortError') {
253
+ throw new Error(`FreshBooks API ${method} ${path} timed out after ${FreshBooksClient.REQUEST_TIMEOUT_MS}ms`);
254
+ }
255
+ throw error;
256
+ }
257
+ clearTimeout(timeoutId);
258
+ if (response.status === 401 && !retried) {
259
+ const refreshed = await this.refreshAccessToken();
260
+ if (refreshed) {
261
+ return this.request(method, path, query, body, true);
262
+ }
263
+ }
264
+ if (!response.ok) {
265
+ const errorBody = await response.text();
266
+ throw new Error(`FreshBooks API ${method} ${path} failed (${response.status}): ${errorBody}`);
267
+ }
268
+ if (response.status === 204) {
269
+ return undefined;
270
+ }
271
+ return (await response.json());
272
+ }
273
+ buildUrl(path, query) {
274
+ const url = new URL(`${FreshBooksClient.BASE_URL}${path}`);
275
+ if (query) {
276
+ for (const [key, value] of Object.entries(query)) {
277
+ if (value === undefined || value === null || value === '') {
278
+ continue;
279
+ }
280
+ url.searchParams.append(key, String(value));
281
+ }
282
+ }
283
+ return url.toString();
284
+ }
285
+ }
286
+ exports.FreshBooksClient = FreshBooksClient;
287
+ FreshBooksClient.BASE_URL = 'https://api.freshbooks.com';
288
+ FreshBooksClient.REQUEST_TIMEOUT_MS = 30000;
289
+ FreshBooksClient.PAGE_SIZE = 100;
@@ -0,0 +1,22 @@
1
+ import { IntegrationContext, MappingRecord } from '@timesheet/integration-sdk';
2
+ import { FreshBooksClient } from './freshbooksClient';
3
+ import { FreshBooksConfig, SyncInput } from './types';
4
+ export interface FreshBooksSyncResult {
5
+ system: string;
6
+ status: string;
7
+ syncedCount: number;
8
+ details?: Record<string, unknown>;
9
+ }
10
+ export interface SyncBatchCaches {
11
+ projectMappingByLocalId?: Map<string, MappingRecord>;
12
+ userMappingByLocalId?: Map<string, MappingRecord>;
13
+ taskMappingByLocalId?: Map<string, MappingRecord>;
14
+ rateMappingByLocalId?: Map<string, MappingRecord>;
15
+ }
16
+ export declare function resetSharedClient(): void;
17
+ export declare function createFreshBooksClient(context: IntegrationContext<FreshBooksConfig>): FreshBooksClient;
18
+ export declare function syncTaskToFreshBooks(input: SyncInput, context: IntegrationContext<FreshBooksConfig>, caches?: SyncBatchCaches): Promise<FreshBooksSyncResult>;
19
+ export declare function runFreshBooksFullSync(context: IntegrationContext<FreshBooksConfig>): Promise<FreshBooksSyncResult>;
20
+ export declare function handleFreshBooksWebhook(input: SyncInput, context: IntegrationContext<FreshBooksConfig>): Promise<FreshBooksSyncResult>;
21
+ export declare function syncTaskFromFreshBooks(input: SyncInput, context: IntegrationContext<FreshBooksConfig>): Promise<FreshBooksSyncResult>;
22
+ export declare function registerFreshBooksWebhooks(context: IntegrationContext<FreshBooksConfig>): Promise<FreshBooksSyncResult>;