@slates-integrations/zoho 0.2.0-rc.5

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,90 @@
1
+ import { ServiceError, badRequestError } from '@lowerdeck/error';
2
+
3
+ type ErrorResponse = {
4
+ status?: number;
5
+ statusText?: string;
6
+ data?: unknown;
7
+ };
8
+
9
+ let isRecord = (value: unknown): value is Record<string, unknown> =>
10
+ typeof value === 'object' && value !== null;
11
+
12
+ let pushMessage = (messages: string[], value: unknown) => {
13
+ if (typeof value !== 'string') return;
14
+
15
+ let trimmed = value.trim();
16
+ if (trimmed && !messages.includes(trimmed)) {
17
+ messages.push(trimmed);
18
+ }
19
+ };
20
+
21
+ let collectZohoMessages = (value: unknown, messages: string[]) => {
22
+ if (!isRecord(value)) {
23
+ pushMessage(messages, value);
24
+ return;
25
+ }
26
+
27
+ for (let key of ['message', 'error', 'error_description', 'code']) {
28
+ pushMessage(messages, value[key]);
29
+ }
30
+
31
+ if (isRecord(value.details)) {
32
+ for (let [key, detailValue] of Object.entries(value.details)) {
33
+ if (typeof detailValue === 'string') {
34
+ pushMessage(messages, `${key}: ${detailValue}`);
35
+ }
36
+ }
37
+ }
38
+
39
+ if (Array.isArray(value.data)) {
40
+ for (let item of value.data) {
41
+ collectZohoMessages(item, messages);
42
+ }
43
+ }
44
+ };
45
+
46
+ let extractZohoMessage = (error: unknown) => {
47
+ let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined;
48
+ let messages: string[] = [];
49
+
50
+ collectZohoMessages(response?.data, messages);
51
+
52
+ if (messages.length > 0) {
53
+ return messages.join(' - ');
54
+ }
55
+
56
+ if (error instanceof Error && error.message) {
57
+ return error.message;
58
+ }
59
+
60
+ return 'Unknown error';
61
+ };
62
+
63
+ export let zohoServiceError = (message: string) =>
64
+ new ServiceError(badRequestError({ message }));
65
+
66
+ export let zohoApiError = (error: unknown, operation = 'request') => {
67
+ if (error instanceof ServiceError) {
68
+ return error;
69
+ }
70
+
71
+ let response = isRecord(error) ? (error.response as ErrorResponse | undefined) : undefined;
72
+ let status = response?.status;
73
+ let statusLabel =
74
+ status !== undefined
75
+ ? `HTTP ${status}${response?.statusText ? ` ${response.statusText}` : ''}: `
76
+ : '';
77
+
78
+ let serviceError = zohoServiceError(
79
+ `Zoho API ${operation} failed: ${statusLabel}${extractZohoMessage(error)}`
80
+ );
81
+
82
+ serviceError.data.reason = 'zoho_api_error';
83
+ serviceError.data.upstreamStatus = status;
84
+
85
+ if (error instanceof Error) {
86
+ serviceError.setParent(error);
87
+ }
88
+
89
+ return serviceError;
90
+ };
@@ -0,0 +1,77 @@
1
+ export type Datacenter = 'us' | 'eu' | 'in' | 'au' | 'jp' | 'ca';
2
+
3
+ let accountsUrls: Record<Datacenter, string> = {
4
+ us: 'https://accounts.zoho.com',
5
+ eu: 'https://accounts.zoho.eu',
6
+ in: 'https://accounts.zoho.in',
7
+ au: 'https://accounts.zoho.com.au',
8
+ jp: 'https://accounts.zoho.jp',
9
+ ca: 'https://accounts.zoho.ca'
10
+ };
11
+
12
+ let apiBaseUrls: Record<Datacenter, string> = {
13
+ us: 'https://www.zohoapis.com',
14
+ eu: 'https://www.zohoapis.eu',
15
+ in: 'https://www.zohoapis.in',
16
+ au: 'https://www.zohoapis.com.au',
17
+ jp: 'https://www.zohoapis.jp',
18
+ ca: 'https://www.zohoapis.ca'
19
+ };
20
+
21
+ let deskBaseUrls: Record<Datacenter, string> = {
22
+ us: 'https://desk.zoho.com',
23
+ eu: 'https://desk.zoho.eu',
24
+ in: 'https://desk.zoho.in',
25
+ au: 'https://desk.zoho.com.au',
26
+ jp: 'https://desk.zoho.jp',
27
+ ca: 'https://desk.zoho.ca'
28
+ };
29
+
30
+ let peopleBaseUrls: Record<Datacenter, string> = {
31
+ us: 'https://people.zoho.com',
32
+ eu: 'https://people.zoho.eu',
33
+ in: 'https://people.zoho.in',
34
+ au: 'https://people.zoho.com.au',
35
+ jp: 'https://people.zoho.jp',
36
+ ca: 'https://people.zoho.ca'
37
+ };
38
+
39
+ let projectsBaseUrls: Record<Datacenter, string> = {
40
+ us: 'https://projectsapi.zoho.com',
41
+ eu: 'https://projectsapi.zoho.eu',
42
+ in: 'https://projectsapi.zoho.in',
43
+ au: 'https://projectsapi.zoho.com.au',
44
+ jp: 'https://projectsapi.zoho.jp',
45
+ ca: 'https://projectsapi.zoho.ca'
46
+ };
47
+
48
+ let locationToDatacenter: Record<string, Datacenter> = {
49
+ us: 'us',
50
+ eu: 'eu',
51
+ in: 'in',
52
+ au: 'au',
53
+ jp: 'jp',
54
+ ca: 'ca'
55
+ };
56
+
57
+ export let getAccountsUrl = (dc: Datacenter): string => accountsUrls[dc];
58
+ export let getApiBaseUrl = (dc: Datacenter): string => apiBaseUrls[dc];
59
+ export let getDeskBaseUrl = (dc: Datacenter): string => deskBaseUrls[dc];
60
+ export let getPeopleBaseUrl = (dc: Datacenter): string => peopleBaseUrls[dc];
61
+ export let getProjectsBaseUrl = (dc: Datacenter): string => projectsBaseUrls[dc];
62
+
63
+ export let datacenterFromLocation = (location: string): Datacenter => {
64
+ return locationToDatacenter[location] ?? 'us';
65
+ };
66
+
67
+ export let datacenterFromApiDomain = (apiDomain?: string): Datacenter | undefined => {
68
+ if (!apiDomain) return undefined;
69
+
70
+ for (let [dc, baseUrl] of Object.entries(apiBaseUrls)) {
71
+ if (apiDomain.startsWith(baseUrl)) {
72
+ return dc as Datacenter;
73
+ }
74
+ }
75
+
76
+ return undefined;
77
+ };
package/src/spec.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { SlateSpecification } from 'slates';
2
+ import { auth } from './auth';
3
+ import { config } from './config';
4
+
5
+ export let spec = SlateSpecification.create({
6
+ key: 'zoho',
7
+ name: 'Zoho',
8
+ description: undefined,
9
+ metadata: {},
10
+ config,
11
+ auth
12
+ });
@@ -0,0 +1,114 @@
1
+ import { SlateTool } from 'slates';
2
+ import { z } from 'zod';
3
+ import { spec } from '../spec';
4
+ import { ZohoBooksClient } from '../lib/client';
5
+ import type { Datacenter } from '../lib/urls';
6
+ import { zohoServiceError } from '../lib/errors';
7
+
8
+ export let booksGetInvoices = SlateTool.create(spec, {
9
+ name: 'Books Get Invoices',
10
+ key: 'books_get_invoices',
11
+ description: `List or retrieve invoices from Zoho Books. Supports filtering by status, customer, and pagination. Can also list organizations to find the organization ID needed for all Books operations.`,
12
+ instructions: [
13
+ 'The organizationId is required. Use listOrganizations=true to discover available organizations.',
14
+ 'Provide invoiceId to fetch a single invoice with full details.',
15
+ 'Filter by status: "sent", "draft", "overdue", "paid", "void", "unpaid", "partially_paid".'
16
+ ],
17
+ tags: {
18
+ readOnly: true
19
+ }
20
+ })
21
+ .input(
22
+ z.object({
23
+ organizationId: z
24
+ .string()
25
+ .optional()
26
+ .describe('Zoho Books organization ID (required unless listing organizations)'),
27
+ invoiceId: z.string().optional().describe('Specific invoice ID to fetch'),
28
+ status: z
29
+ .string()
30
+ .optional()
31
+ .describe('Filter by status (e.g., "sent", "draft", "overdue", "paid", "void")'),
32
+ customerId: z.string().optional().describe('Filter invoices by customer ID'),
33
+ page: z.number().optional().describe('Page number'),
34
+ perPage: z.number().optional().describe('Records per page (max 200)'),
35
+ sortColumn: z
36
+ .string()
37
+ .optional()
38
+ .describe('Sort by column (e.g., "date", "invoice_number", "total")'),
39
+ sortOrder: z.enum(['ascending', 'descending']).optional().describe('Sort order'),
40
+ listOrganizations: z
41
+ .boolean()
42
+ .optional()
43
+ .describe('If true, lists available organizations instead of invoices')
44
+ })
45
+ )
46
+ .output(
47
+ z.object({
48
+ invoices: z.array(z.record(z.string(), z.any())).optional().describe('Invoice records'),
49
+ organizations: z
50
+ .array(
51
+ z.object({
52
+ organizationId: z.string(),
53
+ name: z.string(),
54
+ isDefault: z.boolean().optional(),
55
+ currencyCode: z.string().optional()
56
+ })
57
+ )
58
+ .optional()
59
+ .describe('Available organizations (if listOrganizations is true)'),
60
+ hasMorePages: z.boolean().optional()
61
+ })
62
+ )
63
+ .handleInvocation(async ctx => {
64
+ let dc = (ctx.auth.datacenter || ctx.config.datacenter || 'us') as Datacenter;
65
+
66
+ if (ctx.input.listOrganizations) {
67
+ let result = await ZohoBooksClient.listOrganizations(ctx.auth.token, dc);
68
+ let orgs = (result?.organizations || []).map((o: any) => ({
69
+ organizationId: o.organization_id,
70
+ name: o.name,
71
+ isDefault: o.is_default_org,
72
+ currencyCode: o.currency_code
73
+ }));
74
+ return {
75
+ output: { organizations: orgs },
76
+ message: `Found **${orgs.length}** Zoho Books organizations.`
77
+ };
78
+ }
79
+
80
+ if (!ctx.input.organizationId) throw zohoServiceError('organizationId is required');
81
+ let client = new ZohoBooksClient({
82
+ token: ctx.auth.token,
83
+ datacenter: dc,
84
+ organizationId: ctx.input.organizationId
85
+ });
86
+
87
+ if (ctx.input.invoiceId) {
88
+ let result = await client.getInvoice(ctx.input.invoiceId);
89
+ return {
90
+ output: { invoices: [result?.invoice || result] },
91
+ message: `Fetched invoice **${result?.invoice?.invoice_number || ctx.input.invoiceId}**.`
92
+ };
93
+ }
94
+
95
+ let result = await client.listInvoices({
96
+ page: ctx.input.page,
97
+ perPage: ctx.input.perPage,
98
+ status: ctx.input.status,
99
+ customerId: ctx.input.customerId,
100
+ sortColumn: ctx.input.sortColumn,
101
+ sortOrder: ctx.input.sortOrder
102
+ });
103
+
104
+ let invoices = result?.invoices || [];
105
+ let pageContext = result?.page_context;
106
+ return {
107
+ output: {
108
+ invoices,
109
+ hasMorePages: pageContext?.has_more_page ?? false
110
+ },
111
+ message: `Retrieved **${invoices.length}** invoices.`
112
+ };
113
+ })
114
+ .build();
@@ -0,0 +1,168 @@
1
+ import { SlateTool } from 'slates';
2
+ import { z } from 'zod';
3
+ import { spec } from '../spec';
4
+ import { ZohoBooksClient } from '../lib/client';
5
+ import type { Datacenter } from '../lib/urls';
6
+ import { zohoServiceError } from '../lib/errors';
7
+
8
+ let addressSchema = z.object({
9
+ attention: z.string().optional(),
10
+ address: z.string().optional().describe('Street 1 address'),
11
+ street: z.string().optional().describe('Deprecated alias for address'),
12
+ street2: z.string().optional().describe('Street 2 address'),
13
+ city: z.string().optional(),
14
+ state: z.string().optional(),
15
+ stateCode: z.string().optional(),
16
+ zip: z.string().optional(),
17
+ country: z.string().optional(),
18
+ fax: z.string().optional(),
19
+ phone: z.string().optional()
20
+ });
21
+
22
+ export let booksManageContact = SlateTool.create(spec, {
23
+ name: 'Books Manage Contact',
24
+ key: 'books_manage_contact',
25
+ description: `Create, update, or delete contacts (customers/vendors) in Zoho Books. Manage contact details, billing/shipping addresses, payment terms, and contact persons.`,
26
+ instructions: [
27
+ 'The organizationId is required for all Zoho Books operations.',
28
+ 'For create, contactName is required.',
29
+ 'Use contactType to specify "customer" or "vendor".'
30
+ ],
31
+ tags: {
32
+ destructive: true
33
+ }
34
+ })
35
+ .input(
36
+ z.object({
37
+ organizationId: z.string().describe('Zoho Books organization ID'),
38
+ action: z
39
+ .enum(['create', 'update', 'delete', 'get', 'list'])
40
+ .describe('Operation to perform'),
41
+ contactId: z
42
+ .string()
43
+ .optional()
44
+ .describe('Contact ID (required for get, update, delete)'),
45
+ contactName: z
46
+ .string()
47
+ .optional()
48
+ .describe('Contact/company name (required for create)'),
49
+ contactType: z.enum(['customer', 'vendor']).optional().describe('Type of contact'),
50
+ companyName: z.string().optional().describe('Company name'),
51
+ email: z.string().optional().describe('Contact email address'),
52
+ phone: z.string().optional().describe('Contact phone number'),
53
+ website: z.string().optional().describe('Contact website'),
54
+ billingAddress: addressSchema.optional().describe('Billing address'),
55
+ shippingAddress: addressSchema.optional().describe('Shipping address'),
56
+ paymentTerms: z.number().optional().describe('Net payment terms in days'),
57
+ notes: z.string().optional().describe('Notes about the contact'),
58
+ page: z.number().optional().describe('Page number (for list action)'),
59
+ perPage: z.number().optional().describe('Records per page (for list action)')
60
+ })
61
+ )
62
+ .output(
63
+ z.object({
64
+ contact: z.record(z.string(), z.any()).optional().describe('Contact record'),
65
+ contacts: z
66
+ .array(z.record(z.string(), z.any()))
67
+ .optional()
68
+ .describe('List of contacts (for list action)'),
69
+ deleted: z.boolean().optional(),
70
+ hasMorePages: z.boolean().optional()
71
+ })
72
+ )
73
+ .handleInvocation(async ctx => {
74
+ let dc = (ctx.auth.datacenter || ctx.config.datacenter || 'us') as Datacenter;
75
+ let client = new ZohoBooksClient({
76
+ token: ctx.auth.token,
77
+ datacenter: dc,
78
+ organizationId: ctx.input.organizationId
79
+ });
80
+
81
+ if (ctx.input.action === 'list') {
82
+ let result = await client.listContacts({
83
+ page: ctx.input.page,
84
+ perPage: ctx.input.perPage,
85
+ contactType: ctx.input.contactType
86
+ });
87
+ return {
88
+ output: {
89
+ contacts: result?.contacts || [],
90
+ hasMorePages: result?.page_context?.has_more_page ?? false
91
+ },
92
+ message: `Retrieved **${(result?.contacts || []).length}** contacts.`
93
+ };
94
+ }
95
+
96
+ if (ctx.input.action === 'get') {
97
+ if (!ctx.input.contactId) throw zohoServiceError('contactId is required for get');
98
+ let result = await client.getContact(ctx.input.contactId);
99
+ return {
100
+ output: { contact: result?.contact || result },
101
+ message: `Fetched contact **${result?.contact?.contact_name || ctx.input.contactId}**.`
102
+ };
103
+ }
104
+
105
+ let buildAddress = (address: z.infer<typeof addressSchema>) => ({
106
+ attention: address.attention,
107
+ address: address.address || address.street,
108
+ street2: address.street2,
109
+ city: address.city,
110
+ state: address.state,
111
+ state_code: address.stateCode,
112
+ zip: address.zip,
113
+ country: address.country,
114
+ fax: address.fax,
115
+ phone: address.phone
116
+ });
117
+
118
+ let buildData = () => {
119
+ let data: Record<string, any> = {};
120
+ if (ctx.input.contactName) data.contact_name = ctx.input.contactName;
121
+ if (ctx.input.contactType) data.contact_type = ctx.input.contactType;
122
+ if (ctx.input.companyName) data.company_name = ctx.input.companyName;
123
+ if (ctx.input.email) data.email = ctx.input.email;
124
+ if (ctx.input.phone) data.phone = ctx.input.phone;
125
+ if (ctx.input.website) data.website = ctx.input.website;
126
+ if (ctx.input.paymentTerms !== undefined) data.payment_terms = ctx.input.paymentTerms;
127
+ if (ctx.input.notes) data.notes = ctx.input.notes;
128
+ if (ctx.input.billingAddress) {
129
+ data.billing_address = buildAddress(ctx.input.billingAddress);
130
+ }
131
+ if (ctx.input.shippingAddress) {
132
+ data.shipping_address = buildAddress(ctx.input.shippingAddress);
133
+ }
134
+ return data;
135
+ };
136
+
137
+ if (ctx.input.action === 'create') {
138
+ if (!ctx.input.contactName) throw zohoServiceError('contactName is required for create');
139
+ let result = await client.createContact(buildData());
140
+ let contact = result?.contact;
141
+ return {
142
+ output: { contact },
143
+ message: `Created contact **${contact?.contact_name}**.`
144
+ };
145
+ }
146
+
147
+ if (ctx.input.action === 'update') {
148
+ if (!ctx.input.contactId) throw zohoServiceError('contactId is required for update');
149
+ let result = await client.updateContact(ctx.input.contactId, buildData());
150
+ let contact = result?.contact;
151
+ return {
152
+ output: { contact },
153
+ message: `Updated contact **${contact?.contact_name || ctx.input.contactId}**.`
154
+ };
155
+ }
156
+
157
+ if (ctx.input.action === 'delete') {
158
+ if (!ctx.input.contactId) throw zohoServiceError('contactId is required for delete');
159
+ await client.deleteContact(ctx.input.contactId);
160
+ return {
161
+ output: { contact: { contact_id: ctx.input.contactId }, deleted: true },
162
+ message: `Deleted contact **${ctx.input.contactId}**.`
163
+ };
164
+ }
165
+
166
+ throw zohoServiceError('Invalid Books contact action.');
167
+ })
168
+ .build();
@@ -0,0 +1,142 @@
1
+ import { SlateTool } from 'slates';
2
+ import { z } from 'zod';
3
+ import { spec } from '../spec';
4
+ import { ZohoBooksClient } from '../lib/client';
5
+ import type { Datacenter } from '../lib/urls';
6
+ import { zohoServiceError } from '../lib/errors';
7
+
8
+ export let booksManageExpense = SlateTool.create(spec, {
9
+ name: 'Books Manage Expense',
10
+ key: 'books_manage_expense',
11
+ description: `Create, update, delete, or list expenses in Zoho Books. Track business expenses with account categorization, amounts, dates, vendors, and custom fields.`,
12
+ instructions: [
13
+ 'The organizationId is required for all Zoho Books operations.',
14
+ 'For create, accountId, date, and amount are required.'
15
+ ],
16
+ tags: {
17
+ destructive: true
18
+ }
19
+ })
20
+ .input(
21
+ z.object({
22
+ organizationId: z.string().describe('Zoho Books organization ID'),
23
+ action: z
24
+ .enum(['create', 'update', 'delete', 'get', 'list'])
25
+ .describe('Operation to perform'),
26
+ expenseId: z
27
+ .string()
28
+ .optional()
29
+ .describe('Expense ID (required for update, delete, get)'),
30
+ accountId: z.string().optional().describe('Expense account ID (required for create)'),
31
+ date: z.string().optional().describe('Expense date (YYYY-MM-DD, required for create)'),
32
+ amount: z.number().optional().describe('Expense amount (required for create)'),
33
+ paidThroughAccountId: z
34
+ .string()
35
+ .optional()
36
+ .describe('Account ID through which payment was made'),
37
+ vendorId: z.string().optional().describe('Vendor ID'),
38
+ description: z.string().optional().describe('Expense description'),
39
+ referenceNumber: z.string().optional().describe('Reference number'),
40
+ taxId: z.string().optional().describe('Tax ID to apply'),
41
+ isBillable: z.boolean().optional().describe('Whether the expense is billable'),
42
+ customerId: z.string().optional().describe('Customer ID (for billable expenses)'),
43
+ projectId: z.string().optional().describe('Project ID'),
44
+ currencyId: z.string().optional().describe('Currency ID'),
45
+ page: z.number().optional().describe('Page number (for list action)'),
46
+ perPage: z.number().optional().describe('Records per page (for list action)'),
47
+ status: z.string().optional().describe('Filter by status (for list action)')
48
+ })
49
+ )
50
+ .output(
51
+ z.object({
52
+ expense: z.record(z.string(), z.any()).optional().describe('Expense record'),
53
+ expenses: z.array(z.record(z.string(), z.any())).optional().describe('List of expenses'),
54
+ deleted: z.boolean().optional(),
55
+ hasMorePages: z.boolean().optional(),
56
+ apiMessage: z.string().optional()
57
+ })
58
+ )
59
+ .handleInvocation(async ctx => {
60
+ let dc = (ctx.auth.datacenter || ctx.config.datacenter || 'us') as Datacenter;
61
+ let client = new ZohoBooksClient({
62
+ token: ctx.auth.token,
63
+ datacenter: dc,
64
+ organizationId: ctx.input.organizationId
65
+ });
66
+
67
+ if (ctx.input.action === 'list') {
68
+ let result = await client.listExpenses({
69
+ page: ctx.input.page,
70
+ perPage: ctx.input.perPage,
71
+ status: ctx.input.status
72
+ });
73
+ return {
74
+ output: {
75
+ expenses: result?.expenses || [],
76
+ hasMorePages: result?.page_context?.has_more_page ?? false
77
+ },
78
+ message: `Retrieved **${(result?.expenses || []).length}** expenses.`
79
+ };
80
+ }
81
+
82
+ if (ctx.input.action === 'get') {
83
+ if (!ctx.input.expenseId) throw zohoServiceError('expenseId is required for get');
84
+ let result = await client.getExpense(ctx.input.expenseId);
85
+ return {
86
+ output: { expense: result?.expense || result },
87
+ message: `Fetched expense **${ctx.input.expenseId}**.`
88
+ };
89
+ }
90
+
91
+ let buildData = () => {
92
+ let data: Record<string, any> = {};
93
+ if (ctx.input.accountId) data.account_id = ctx.input.accountId;
94
+ if (ctx.input.date) data.date = ctx.input.date;
95
+ if (ctx.input.amount !== undefined) data.amount = ctx.input.amount;
96
+ if (ctx.input.paidThroughAccountId)
97
+ data.paid_through_account_id = ctx.input.paidThroughAccountId;
98
+ if (ctx.input.vendorId) data.vendor_id = ctx.input.vendorId;
99
+ if (ctx.input.description) data.description = ctx.input.description;
100
+ if (ctx.input.referenceNumber) data.reference_number = ctx.input.referenceNumber;
101
+ if (ctx.input.taxId) data.tax_id = ctx.input.taxId;
102
+ if (ctx.input.isBillable !== undefined) data.is_billable = ctx.input.isBillable;
103
+ if (ctx.input.customerId) data.customer_id = ctx.input.customerId;
104
+ if (ctx.input.projectId) data.project_id = ctx.input.projectId;
105
+ if (ctx.input.currencyId) data.currency_id = ctx.input.currencyId;
106
+ return data;
107
+ };
108
+
109
+ if (ctx.input.action === 'create') {
110
+ if (!ctx.input.accountId) throw zohoServiceError('accountId is required for create');
111
+ if (!ctx.input.date) throw zohoServiceError('date is required for create');
112
+ if (ctx.input.amount === undefined)
113
+ throw zohoServiceError('amount is required for create');
114
+ let result = await client.createExpense(buildData());
115
+ let expense = result?.expense;
116
+ return {
117
+ output: { expense, apiMessage: result?.message },
118
+ message: `Created expense of **${expense?.total || ctx.input.amount}** on **${expense?.date || ctx.input.date}**.`
119
+ };
120
+ }
121
+
122
+ if (ctx.input.action === 'update') {
123
+ if (!ctx.input.expenseId) throw zohoServiceError('expenseId is required for update');
124
+ let result = await client.updateExpense(ctx.input.expenseId, buildData());
125
+ return {
126
+ output: { expense: result?.expense, apiMessage: result?.message },
127
+ message: `Updated expense **${ctx.input.expenseId}**.`
128
+ };
129
+ }
130
+
131
+ if (ctx.input.action === 'delete') {
132
+ if (!ctx.input.expenseId) throw zohoServiceError('expenseId is required for delete');
133
+ let result = await client.deleteExpense(ctx.input.expenseId);
134
+ return {
135
+ output: { deleted: true, apiMessage: result?.message },
136
+ message: `Deleted expense **${ctx.input.expenseId}**.`
137
+ };
138
+ }
139
+
140
+ throw zohoServiceError('Invalid Books expense action.');
141
+ })
142
+ .build();