@getpopapi/n8n-nodes-pop-zoho 0.1.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,697 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.PopZohoInvoice = void 0;
37
+ const crypto = __importStar(require("crypto"));
38
+ const n8n_workflow_1 = require("n8n-workflow");
39
+ const ZohoInvoiceOAuth2Api_credentials_1 = require("../../credentials/ZohoInvoiceOAuth2Api.credentials");
40
+ // ---------------------------------------------------------------------------
41
+ // Validation
42
+ // ---------------------------------------------------------------------------
43
+ function validatePopPayload(payload, itemIndex, context) {
44
+ var _a, _b;
45
+ const data = payload.data;
46
+ if (!data) {
47
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Missing field: data', { itemIndex });
48
+ }
49
+ const general = (_a = data.invoice_body) === null || _a === void 0 ? void 0 : _a.general_data;
50
+ const required = [
51
+ [general === null || general === void 0 ? void 0 : general.doc_type, 'data.invoice_body.general_data.doc_type'],
52
+ [general === null || general === void 0 ? void 0 : general.date, 'data.invoice_body.general_data.date'],
53
+ [general === null || general === void 0 ? void 0 : general.currency, 'data.invoice_body.general_data.currency'],
54
+ [(_b = data.transferee_client) === null || _b === void 0 ? void 0 : _b.personal_data, 'data.transferee_client.personal_data'],
55
+ [data.order_items, 'data.order_items'],
56
+ ];
57
+ for (const [value, path] of required) {
58
+ if (value === undefined || value === null || value === '') {
59
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Missing required field: ${path}`, { itemIndex });
60
+ }
61
+ }
62
+ if (!Array.isArray(data.order_items) || data.order_items.length === 0) {
63
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'data.order_items must be a non-empty array', { itemIndex });
64
+ }
65
+ const docType = general.doc_type;
66
+ if (!['TD01', 'TD04'].includes(docType)) {
67
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Unsupported doc_type: ${docType}. Accepted values: TD01 (invoice), TD04 (credit note).`, { itemIndex });
68
+ }
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // Zoho API helpers
72
+ // ---------------------------------------------------------------------------
73
+ async function zohoGet(context, apiBase, orgHeaderKey, orgId, path) {
74
+ const separator = path.includes('?') ? '&' : '?';
75
+ const options = {
76
+ method: 'GET',
77
+ url: `${apiBase}/${path}${separator}organization_id=${orgId}`,
78
+ headers: { [orgHeaderKey]: orgId },
79
+ json: true,
80
+ };
81
+ return context.helpers.requestWithAuthentication.call(context, 'zohoInvoiceOAuth2Api', options);
82
+ }
83
+ async function zohoPost(context, apiBase, orgHeaderKey, orgId, path, body) {
84
+ const separator = path.includes('?') ? '&' : '?';
85
+ const options = {
86
+ method: 'POST',
87
+ url: `${apiBase}/${path}${separator}organization_id=${orgId}`,
88
+ headers: { [orgHeaderKey]: orgId },
89
+ body,
90
+ json: true,
91
+ };
92
+ return context.helpers.requestWithAuthentication.call(context, 'zohoInvoiceOAuth2Api', options);
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // HMAC signature verification
96
+ // ---------------------------------------------------------------------------
97
+ function verifyPopSignature(context, rawBody, headers, secret, itemIndex) {
98
+ var _a, _b, _c, _d;
99
+ const signatureHeader = ((_b = (_a = headers['x-pop-signature']) !== null && _a !== void 0 ? _a : headers['X-POP-Signature']) !== null && _b !== void 0 ? _b : '');
100
+ const timestampHeader = ((_d = (_c = headers['x-pop-timestamp']) !== null && _c !== void 0 ? _c : headers['X-POP-Timestamp']) !== null && _d !== void 0 ? _d : '');
101
+ if (!signatureHeader || !timestampHeader) {
102
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Missing POP signature headers (X-POP-Signature, X-POP-Timestamp). Request rejected.', { itemIndex });
103
+ }
104
+ // Reject requests older than 5 minutes (replay protection)
105
+ const ts = parseInt(timestampHeader, 10);
106
+ const age = Math.abs(Date.now() / 1000 - ts);
107
+ if (isNaN(ts) || age > 300) {
108
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Request timestamp is invalid or too old (${Math.round(age)}s). Replay attack rejected.`, { itemIndex });
109
+ }
110
+ const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody + timestampHeader).digest('hex');
111
+ // Constant-time comparison to prevent timing attacks
112
+ const sigBuf = Buffer.from(signatureHeader);
113
+ const expectedBuf = Buffer.from(expected);
114
+ const match = sigBuf.length === expectedBuf.length &&
115
+ crypto.timingSafeEqual(sigBuf, expectedBuf);
116
+ if (!match) {
117
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Invalid POP signature. Request rejected.', { itemIndex });
118
+ }
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // RSA JWT verification (origin proof)
122
+ // ---------------------------------------------------------------------------
123
+ function verifyPopJwt(token, publicKeyPem, context, itemIndex) {
124
+ if (!token) {
125
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Missing _pop_jwt in payload. Request must originate from POP API.', { itemIndex });
126
+ }
127
+ const parts = token.split('.');
128
+ if (parts.length !== 3) {
129
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Malformed _pop_jwt.', { itemIndex });
130
+ }
131
+ const [headerB64, payloadB64, sigB64] = parts;
132
+ const signingInput = `${headerB64}.${payloadB64}`;
133
+ // base64url → Buffer (replace url-safe chars, then decode as base64)
134
+ const fromB64url = (s) => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
135
+ const verifier = crypto.createVerify('RSA-SHA256');
136
+ verifier.update(signingInput);
137
+ let valid;
138
+ try {
139
+ valid = verifier.verify(publicKeyPem, fromB64url(sigB64));
140
+ }
141
+ catch {
142
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'POP JWT verification error. Check that the RSA Public Key is correctly set.', { itemIndex });
143
+ }
144
+ if (!valid) {
145
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Invalid POP JWT signature. Request rejected.', { itemIndex });
146
+ }
147
+ let claims;
148
+ try {
149
+ claims = JSON.parse(fromB64url(payloadB64).toString('utf8'));
150
+ }
151
+ catch {
152
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Cannot parse _pop_jwt claims.', { itemIndex });
153
+ }
154
+ if (claims.iss !== 'popapi.io') {
155
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Invalid JWT issuer: "${claims.iss}". Expected "popapi.io".`, { itemIndex });
156
+ }
157
+ const nowSec = Math.floor(Date.now() / 1000);
158
+ if (!claims.exp || claims.exp < nowSec) {
159
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'POP JWT has expired. Request rejected.', { itemIndex });
160
+ }
161
+ }
162
+ // ---------------------------------------------------------------------------
163
+ // Tax resolution
164
+ // ---------------------------------------------------------------------------
165
+ async function buildTaxMap(context, apiBase, orgHeaderKey, orgId, product) {
166
+ var _a;
167
+ // Zoho Invoice uses /taxes; Zoho Books uses /settings/taxes
168
+ const taxPath = product === 'books' ? 'settings/taxes' : 'taxes';
169
+ const response = await zohoGet(context, apiBase, orgHeaderKey, orgId, taxPath);
170
+ const taxes = ((_a = response.taxes) !== null && _a !== void 0 ? _a : []);
171
+ const map = new Map();
172
+ for (const tax of taxes) {
173
+ const key = parseFloat(String(tax.tax_percentage)).toFixed(2);
174
+ map.set(key, tax.tax_id);
175
+ }
176
+ return map;
177
+ }
178
+ function resolveTaxId(context, taxMap, rateStr, itemIndex) {
179
+ const normalized = parseFloat(rateStr || '0').toFixed(2);
180
+ if (normalized === '0.00')
181
+ return null;
182
+ const taxId = taxMap.get(normalized);
183
+ if (!taxId) {
184
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Tax rate ${normalized}% not found in Zoho. Configure it in Settings → Taxes.`, { itemIndex });
185
+ }
186
+ return taxId;
187
+ }
188
+ // ---------------------------------------------------------------------------
189
+ // Contact resolution
190
+ // ---------------------------------------------------------------------------
191
+ const GCC_COUNTRIES = new Set(['SA', 'BH', 'KW', 'OM', 'QA']);
192
+ function deriveVatTreatment(countryId, vatCode) {
193
+ if (!countryId)
194
+ return null;
195
+ const country = countryId.toUpperCase();
196
+ if (country === 'AE') {
197
+ return vatCode ? 'vat_registered' : 'consumer';
198
+ }
199
+ if (GCC_COUNTRIES.has(country)) {
200
+ return vatCode ? 'gcc_vat_registered' : null;
201
+ }
202
+ return null;
203
+ }
204
+ async function searchContacts(context, apiBase, orgHeaderKey, orgId, searchText) {
205
+ var _a;
206
+ const response = await zohoGet(context, apiBase, orgHeaderKey, orgId, `contacts?search_text=${encodeURIComponent(searchText)}`);
207
+ return ((_a = response.contacts) !== null && _a !== void 0 ? _a : []);
208
+ }
209
+ async function resolveContact(context, apiBase, orgHeaderKey, orgId, payload, createIfMissing, matchStrategy, itemIndex) {
210
+ var _a, _b, _c, _d, _e, _f, _g, _h;
211
+ const personal = payload.data.transferee_client.personal_data;
212
+ const place = (_a = payload.data.transferee_client) === null || _a === void 0 ? void 0 : _a.place;
213
+ const vatCode = (_b = personal.tax_id_vat) === null || _b === void 0 ? void 0 : _b.id_code;
214
+ const vatCountry = (_c = personal.tax_id_vat) === null || _c === void 0 ? void 0 : _c.country_id;
215
+ const email = personal.email;
216
+ const isCompany = !!personal.company_name;
217
+ const contactName = isCompany
218
+ ? personal.company_name
219
+ : [personal.first_name, personal.last_name].filter(Boolean).join(' ');
220
+ // 1. Search by VAT / TRN (only when strategy includes VAT lookup)
221
+ if (matchStrategy === 'vat_email_name' && vatCode) {
222
+ const results = await searchContacts(context, apiBase, orgHeaderKey, orgId, vatCode);
223
+ if (results.length === 1)
224
+ return { contactId: results[0].contact_id, contactCreated: false };
225
+ if (results.length > 1) {
226
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Multiple Zoho contacts found for VAT/TRN ${vatCode}. Resolve the duplicate manually.`, { itemIndex });
227
+ }
228
+ }
229
+ // 2. Search by email
230
+ if (email) {
231
+ const results = await searchContacts(context, apiBase, orgHeaderKey, orgId, email);
232
+ if (results.length === 1)
233
+ return { contactId: results[0].contact_id, contactCreated: false };
234
+ if (results.length > 1) {
235
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Multiple Zoho contacts found for email ${email}. Resolve the duplicate manually.`, { itemIndex });
236
+ }
237
+ }
238
+ // 3. Search by name
239
+ if (contactName) {
240
+ const results = await searchContacts(context, apiBase, orgHeaderKey, orgId, contactName);
241
+ if (results.length === 1)
242
+ return { contactId: results[0].contact_id, contactCreated: false };
243
+ if (results.length > 1) {
244
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), `Multiple Zoho contacts found for name "${contactName}". Provide VAT or email to disambiguate.`, { itemIndex });
245
+ }
246
+ }
247
+ // 4. Not found
248
+ if (!createIfMissing) {
249
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Contact not found in Zoho. Set "Create contact if missing" to true to create it automatically.', { itemIndex });
250
+ }
251
+ // 5. Create contact
252
+ const contactBody = {
253
+ contact_name: contactName,
254
+ contact_type: 'customer',
255
+ };
256
+ if (isCompany) {
257
+ contactBody.company_name = personal.company_name;
258
+ }
259
+ if (email) {
260
+ contactBody.contact_persons = [{ email, is_primary_contact: true }];
261
+ }
262
+ if (place) {
263
+ contactBody.billing_address = {
264
+ address: (_d = place.address) !== null && _d !== void 0 ? _d : '',
265
+ city: (_e = place.city) !== null && _e !== void 0 ? _e : '',
266
+ zip: (_f = place.zip_code) !== null && _f !== void 0 ? _f : '',
267
+ state: (_g = place.province_id) !== null && _g !== void 0 ? _g : '',
268
+ country_code: (_h = place.country_id) !== null && _h !== void 0 ? _h : '',
269
+ };
270
+ }
271
+ const vatTreatment = deriveVatTreatment(vatCountry, vatCode);
272
+ if (vatTreatment) {
273
+ contactBody.vat_treatment = vatTreatment;
274
+ }
275
+ const created = await zohoPost(context, apiBase, orgHeaderKey, orgId, 'contacts', contactBody);
276
+ const newContact = created.contact;
277
+ if (!(newContact === null || newContact === void 0 ? void 0 : newContact.contact_id)) {
278
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Contact creation failed: Zoho did not return a contact_id.', { itemIndex });
279
+ }
280
+ return { contactId: newContact.contact_id, contactCreated: true };
281
+ }
282
+ // ---------------------------------------------------------------------------
283
+ // Payment terms
284
+ // ---------------------------------------------------------------------------
285
+ function derivePaymentTermsDays(termsPayment, defaultDays) {
286
+ if (!termsPayment)
287
+ return null;
288
+ if (termsPayment === 'TP01' || termsPayment === 'TP03')
289
+ return 0;
290
+ if (termsPayment === 'TP02')
291
+ return defaultDays;
292
+ return null;
293
+ }
294
+ // ---------------------------------------------------------------------------
295
+ // Payload mapping
296
+ // ---------------------------------------------------------------------------
297
+ function buildLineItems(items, taxMap, context, itemIndex) {
298
+ return items.map((item) => {
299
+ var _a;
300
+ const taxId = resolveTaxId(context, taxMap, item.rate, itemIndex);
301
+ const lineItem = {
302
+ name: item.description,
303
+ quantity: parseFloat(item.quantity),
304
+ rate: parseFloat(item.unit_price),
305
+ };
306
+ if (taxId)
307
+ lineItem.tax_id = taxId;
308
+ if ((_a = item.item_code) === null || _a === void 0 ? void 0 : _a.value)
309
+ lineItem.sku = item.item_code.value;
310
+ if (item.unit)
311
+ lineItem.unit = item.unit;
312
+ const discountPct = parseFloat(item.discount_percent || '0');
313
+ const discountAmt = parseFloat(item.discount_amount || '0');
314
+ const unitPrice = parseFloat(item.unit_price);
315
+ if (discountPct > 0) {
316
+ lineItem.discount = discountPct;
317
+ lineItem.discount_type = 'percentage';
318
+ }
319
+ else if (discountAmt > 0 && unitPrice > 0) {
320
+ // Zoho line items only accept percentage — derive it from the absolute amount
321
+ lineItem.discount = Math.round((discountAmt / unitPrice) * 10000) / 100;
322
+ lineItem.discount_type = 'percentage';
323
+ }
324
+ return lineItem;
325
+ });
326
+ }
327
+ function buildZohoInvoiceBody(payload, contactId, taxMap, sendEmail, invoiceStatus, defaultPlaceOfSupply, defaultPaymentTermsDays, context, itemIndex) {
328
+ var _a, _b, _c, _d;
329
+ const general = payload.data.invoice_body.general_data;
330
+ const lineItems = buildLineItems(payload.data.order_items, taxMap, context, itemIndex);
331
+ const body = {
332
+ customer_id: contactId,
333
+ date: general.date,
334
+ currency_code: general.currency,
335
+ line_items: lineItems,
336
+ status: invoiceStatus,
337
+ };
338
+ if ((_b = (_a = payload.data) === null || _a === void 0 ? void 0 : _a.purchase_order_data) === null || _b === void 0 ? void 0 : _b.id) {
339
+ body.reference_number = payload.data.purchase_order_data.id;
340
+ }
341
+ const paymentTermsDays = derivePaymentTermsDays((_d = (_c = payload.data) === null || _c === void 0 ? void 0 : _c.payment_data) === null || _d === void 0 ? void 0 : _d.terms_payment, defaultPaymentTermsDays);
342
+ if (paymentTermsDays !== null) {
343
+ body.payment_terms = paymentTermsDays;
344
+ }
345
+ if (defaultPlaceOfSupply) {
346
+ body.place_of_supply = defaultPlaceOfSupply;
347
+ }
348
+ if (sendEmail) {
349
+ body.send_from_org_email_id = true;
350
+ }
351
+ return body;
352
+ }
353
+ // ---------------------------------------------------------------------------
354
+ // Node definition
355
+ // ---------------------------------------------------------------------------
356
+ class PopZohoInvoice {
357
+ constructor() {
358
+ this.description = {
359
+ displayName: 'POP → Zoho',
360
+ name: 'popZohoInvoice',
361
+ icon: 'file:pop-zoho-invoice.svg',
362
+ group: ['transform'],
363
+ version: 1,
364
+ description: 'Connect POP Cloud API (v2) payloads to Zoho Invoice / Zoho Books',
365
+ defaults: { name: 'POP → Zoho' },
366
+ inputs: [n8n_workflow_1.NodeConnectionTypes.Main],
367
+ outputs: [n8n_workflow_1.NodeConnectionTypes.Main],
368
+ credentials: [
369
+ {
370
+ name: 'zohoInvoiceOAuth2Api',
371
+ required: false,
372
+ },
373
+ ],
374
+ properties: [
375
+ {
376
+ displayName: 'POP API URL',
377
+ name: 'popApiUrl',
378
+ type: 'string',
379
+ default: 'https://popapi.io',
380
+ description: 'Base URL of your POP API instance (no trailing slash)',
381
+ placeholder: 'https://popapi.io',
382
+ },
383
+ {
384
+ displayName: 'POP API License Key',
385
+ name: 'popConnectorSecret',
386
+ type: 'string',
387
+ typeOptions: { password: true },
388
+ default: '',
389
+ description: 'Your POP API license key. Used to verify the HMAC signature on every incoming request.',
390
+ placeholder: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
391
+ },
392
+ {
393
+ displayName: 'POP API RSA Public Key Name or ID',
394
+ name: 'popPublicKey',
395
+ type: 'options',
396
+ typeOptions: {
397
+ loadOptionsMethod: 'fetchPopPublicKey',
398
+ loadOptionsDependsOn: ['popApiUrl'],
399
+ },
400
+ default: '',
401
+ description: 'Click the refresh button to fetch the RSA public key automatically from your POP API instance. Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
402
+ },
403
+ {
404
+ displayName: 'Dry Run (No Zoho Calls)',
405
+ name: 'dryRun',
406
+ type: 'boolean',
407
+ default: false,
408
+ description: 'Whether to validate the POP payload and return the mapped Zoho body without making any API call. Use for testing mapping without Zoho credentials.',
409
+ },
410
+ {
411
+ displayName: 'Contact Match Strategy',
412
+ name: 'contactMatchStrategy',
413
+ type: 'options',
414
+ options: [
415
+ {
416
+ name: 'Email → Name',
417
+ value: 'email_name',
418
+ description: 'Skip VAT lookup — search by email, then name',
419
+ },
420
+ {
421
+ name: 'VAT → Email → Name',
422
+ value: 'vat_email_name',
423
+ description: 'Search by VAT/TRN first, then email, then company/person name',
424
+ },
425
+ ],
426
+ default: 'vat_email_name',
427
+ description: 'How to find an existing Zoho contact for the invoice customer',
428
+ },
429
+ {
430
+ displayName: 'Create Contact If Missing',
431
+ name: 'createContactIfMissing',
432
+ type: 'boolean',
433
+ default: true,
434
+ description: 'Whether to automatically create the contact in Zoho when no match is found',
435
+ },
436
+ {
437
+ displayName: 'Invoice Status',
438
+ name: 'invoiceStatus',
439
+ type: 'options',
440
+ options: [
441
+ { name: 'Draft', value: 'draft' },
442
+ { name: 'Sent', value: 'sent' },
443
+ ],
444
+ default: 'draft',
445
+ description: 'Status to set on the created invoice in Zoho',
446
+ },
447
+ {
448
+ displayName: 'Send Email to Customer',
449
+ name: 'sendEmail',
450
+ type: 'boolean',
451
+ default: false,
452
+ description: "Whether to trigger Zoho's built-in invoice email to the customer after creation",
453
+ },
454
+ {
455
+ displayName: 'Place of Supply',
456
+ name: 'placeOfSupply',
457
+ type: 'options',
458
+ options: [
459
+ { name: 'Abu Dhabi (AE-AZ)', value: 'AE-AZ' },
460
+ { name: 'Ajman (AE-AJ)', value: 'AE-AJ' },
461
+ { name: 'Dubai (AE-DU)', value: 'AE-DU' },
462
+ { name: 'Fujairah (AE-FU)', value: 'AE-FU' },
463
+ { name: 'Not Specified', value: '' },
464
+ { name: 'Other (Enter Code)', value: 'other' },
465
+ { name: 'Ras Al Khaimah (AE-RK)', value: 'AE-RK' },
466
+ { name: 'Sharjah (AE-SH)', value: 'AE-SH' },
467
+ { name: 'Umm Al Quwain (AE-UQ)', value: 'AE-UQ' },
468
+ ],
469
+ default: '',
470
+ description: 'Place of supply for e-invoicing (e.g. UAE emirates). Leave as "Not specified" if not required.',
471
+ },
472
+ {
473
+ displayName: 'Place of Supply Code',
474
+ name: 'placeOfSupplyCustom',
475
+ type: 'string',
476
+ default: '',
477
+ displayOptions: { show: { placeOfSupply: ['other'] } },
478
+ placeholder: 'e.g. IN-KA',
479
+ description: 'Enter the place of supply code manually',
480
+ },
481
+ {
482
+ displayName: 'Deferred Payment Terms (Days)',
483
+ name: 'defaultPaymentTermsDays',
484
+ type: 'number',
485
+ default: 30,
486
+ description: 'Number of days granted for payment when the payload specifies deferred payment (e.g. bank transfer net 30). Not applied for immediate payments.',
487
+ },
488
+ ],
489
+ usableAsTool: true,
490
+ };
491
+ this.methods = {
492
+ loadOptions: {
493
+ async fetchPopPublicKey() {
494
+ const apiUrl = this.getNodeParameter('popApiUrl').replace(/\/$/, '');
495
+ if (!apiUrl)
496
+ return [];
497
+ const response = await this.helpers.httpRequest({
498
+ method: 'GET',
499
+ url: `${apiUrl}/wp-json/api/v2/connector/pubkey`,
500
+ json: true,
501
+ });
502
+ const publicKey = response === null || response === void 0 ? void 0 : response.public_key;
503
+ if (!publicKey)
504
+ return [];
505
+ return [{ name: 'POP API Public Key (Auto-Fetched)', value: publicKey }];
506
+ },
507
+ },
508
+ };
509
+ }
510
+ async execute() {
511
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w;
512
+ const items = this.getInputData();
513
+ const returnData = [];
514
+ try {
515
+ const popSecret = this.getNodeParameter('popConnectorSecret', 0);
516
+ const popPublicKey = this.getNodeParameter('popPublicKey', 0);
517
+ const dryRunParam = this.getNodeParameter('dryRun', 0);
518
+ const matchStrategy = this.getNodeParameter('contactMatchStrategy', 0);
519
+ const createIfMissing = this.getNodeParameter('createContactIfMissing', 0);
520
+ const sendEmail = this.getNodeParameter('sendEmail', 0);
521
+ const invoiceStatus = this.getNodeParameter('invoiceStatus', 0);
522
+ const defaultPaymentTermsDays = this.getNodeParameter('defaultPaymentTermsDays', 0);
523
+ const placeOfSupplySelect = this.getNodeParameter('placeOfSupply', 0);
524
+ const defaultPlaceOfSupply = placeOfSupplySelect === 'other'
525
+ ? this.getNodeParameter('placeOfSupplyCustom', 0).trim()
526
+ : placeOfSupplySelect;
527
+ // 1. Security verification — always mandatory, cannot be disabled.
528
+ // Two independent checks:
529
+ // - HMAC: proves the payload belongs to the customer's license key
530
+ // - RSA JWT: proves the request originated from POP API servers
531
+ if (!popSecret) {
532
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'POP API License Key is required.');
533
+ }
534
+ if (!popPublicKey) {
535
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'POP API RSA Public Key is required.');
536
+ }
537
+ for (let i = 0; i < items.length; i++) {
538
+ const raw = items[i].json;
539
+ const body = ((_a = raw.body) !== null && _a !== void 0 ? _a : raw);
540
+ const headers = ((_b = raw.headers) !== null && _b !== void 0 ? _b : {});
541
+ const bodyForSig = JSON.stringify((_c = raw.body) !== null && _c !== void 0 ? _c : raw);
542
+ verifyPopSignature(this, bodyForSig, headers, popSecret, i);
543
+ verifyPopJwt(body['_pop_jwt'], popPublicKey, this, i);
544
+ }
545
+ // 2. Determine if any item needs live Zoho calls
546
+ // _pop_dry_run in payload (set server-side by POP API) overrides the node parameter.
547
+ // This allows POP API to enforce sandbox→dry_run without the client being able to fake it
548
+ // (the field is covered by the HMAC signature).
549
+ const itemModes = items.map((item) => {
550
+ var _a;
551
+ const raw = item.json;
552
+ const body = ((_a = raw.body) !== null && _a !== void 0 ? _a : raw);
553
+ const fromPayload = body['_pop_dry_run'];
554
+ return fromPayload !== undefined ? Boolean(fromPayload) : dryRunParam;
555
+ });
556
+ const needsLive = itemModes.some((isDry) => !isDry);
557
+ // 3. Init Zoho connection once if at least one item is live
558
+ let taxMap;
559
+ let apiBase;
560
+ let orgHeaderKey;
561
+ let orgId;
562
+ let product;
563
+ if (needsLive) {
564
+ const credentials = await this.getCredentials('zohoInvoiceOAuth2Api');
565
+ product = (_d = credentials.product) !== null && _d !== void 0 ? _d : 'invoice';
566
+ const region = (_e = credentials.region) !== null && _e !== void 0 ? _e : 'eu';
567
+ orgId = credentials.organizationId;
568
+ apiBase = (_g = ((_f = ZohoInvoiceOAuth2Api_credentials_1.ZOHO_URLS[product]) !== null && _f !== void 0 ? _f : ZohoInvoiceOAuth2Api_credentials_1.ZOHO_URLS.invoice)[region]) !== null && _g !== void 0 ? _g : ZohoInvoiceOAuth2Api_credentials_1.ZOHO_URLS.invoice.eu;
569
+ orgHeaderKey = (_h = ZohoInvoiceOAuth2Api_credentials_1.ZOHO_ORG_HEADER[product]) !== null && _h !== void 0 ? _h : ZohoInvoiceOAuth2Api_credentials_1.ZOHO_ORG_HEADER.invoice;
570
+ if (!orgId) {
571
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), 'Organization ID is missing in credentials. Set it in Zoho → Settings → Organization Profile.');
572
+ }
573
+ taxMap = await buildTaxMap(this, apiBase, orgHeaderKey, orgId, product);
574
+ }
575
+ // 4. Process each item
576
+ for (let i = 0; i < items.length; i++) {
577
+ try {
578
+ const raw = items[i].json;
579
+ const popPayload = (raw.body ? raw.body : raw);
580
+ const isDryRun = itemModes[i];
581
+ validatePopPayload(popPayload, i, this);
582
+ const docType = popPayload.data.invoice_body.general_data.doc_type;
583
+ const endpoint = docType === 'TD04' ? 'creditnotes' : 'invoices';
584
+ if (isDryRun) {
585
+ const dryTaxMap = new Map();
586
+ for (const item of (_k = (_j = popPayload.data) === null || _j === void 0 ? void 0 : _j.order_items) !== null && _k !== void 0 ? _k : []) {
587
+ const normalized = parseFloat(item.rate || '0').toFixed(2);
588
+ if (normalized !== '0.00') {
589
+ dryTaxMap.set(normalized, `DRY_RUN_TAX_ID_${normalized}pct`);
590
+ }
591
+ }
592
+ const zohoBody = buildZohoInvoiceBody(popPayload, 'DRY_RUN_CONTACT_ID', dryTaxMap, sendEmail, invoiceStatus, defaultPlaceOfSupply, defaultPaymentTermsDays, this, i);
593
+ if (docType === 'TD04') {
594
+ const connectedId = (_m = (_l = popPayload.data) === null || _l === void 0 ? void 0 : _l.connected_invoice_data) === null || _m === void 0 ? void 0 : _m.id;
595
+ const connectedDate = (_p = (_o = popPayload.data) === null || _o === void 0 ? void 0 : _o.connected_invoice_data) === null || _p === void 0 ? void 0 : _p.date;
596
+ if (connectedId)
597
+ zohoBody.reference_invoice_id = connectedId;
598
+ if (connectedDate)
599
+ zohoBody.reference_invoice_date = connectedDate;
600
+ }
601
+ returnData.push({
602
+ json: {
603
+ success: true,
604
+ status_code: 200,
605
+ message: 'Dry run: payload validated and mapped successfully. No Zoho API call was made.',
606
+ dry_run: true,
607
+ zoho_endpoint: `POST /${endpoint}`,
608
+ zoho_body: zohoBody,
609
+ },
610
+ pairedItem: { item: i },
611
+ });
612
+ }
613
+ else {
614
+ const { contactId, contactCreated } = await resolveContact(this, apiBase, orgHeaderKey, orgId, popPayload, createIfMissing, matchStrategy, i);
615
+ const zohoBody = buildZohoInvoiceBody(popPayload, contactId, taxMap, sendEmail, invoiceStatus, defaultPlaceOfSupply, defaultPaymentTermsDays, this, i);
616
+ if (docType === 'TD04') {
617
+ const connectedId = (_r = (_q = popPayload.data) === null || _q === void 0 ? void 0 : _q.connected_invoice_data) === null || _r === void 0 ? void 0 : _r.id;
618
+ const connectedDate = (_t = (_s = popPayload.data) === null || _s === void 0 ? void 0 : _s.connected_invoice_data) === null || _t === void 0 ? void 0 : _t.date;
619
+ if (connectedId)
620
+ zohoBody.reference_invoice_id = connectedId;
621
+ if (connectedDate)
622
+ zohoBody.reference_invoice_date = connectedDate;
623
+ }
624
+ const response = await zohoPost(this, apiBase, orgHeaderKey, orgId, endpoint, zohoBody);
625
+ const doc = ((_v = (_u = response.invoice) !== null && _u !== void 0 ? _u : response.creditnote) !== null && _v !== void 0 ? _v : {});
626
+ const docType2 = docType === 'TD04' ? 'creditnote' : 'invoice';
627
+ returnData.push({
628
+ json: {
629
+ success: true,
630
+ status_code: 200,
631
+ message: `${docType2 === 'creditnote' ? 'Credit note' : 'Invoice'} created successfully in Zoho.`,
632
+ zoho_product: product,
633
+ zoho_document_type: docType2,
634
+ zoho_invoice_id: doc.invoice_id || doc.creditnote_id || null,
635
+ zoho_invoice_number: doc.invoice_number || doc.creditnote_number || null,
636
+ zoho_status: (_w = doc.status) !== null && _w !== void 0 ? _w : null,
637
+ zoho_total: doc.total != null ? Math.round(doc.total * 100) / 100 : null,
638
+ contact_id: contactId,
639
+ contact_created: contactCreated,
640
+ },
641
+ pairedItem: { item: i },
642
+ });
643
+ }
644
+ }
645
+ catch (itemError) {
646
+ if (this.continueOnFail()) {
647
+ const message = itemError instanceof Error ? itemError.message : 'Unknown error.';
648
+ returnData.push({
649
+ json: { success: false, error_code: 'connector_error', message },
650
+ pairedItem: { item: i },
651
+ });
652
+ continue;
653
+ }
654
+ throw itemError;
655
+ }
656
+ }
657
+ return [returnData];
658
+ }
659
+ catch (error) {
660
+ const message = error instanceof Error ? error.message : 'Unknown connector error.';
661
+ let errorCode = 'connector_error';
662
+ if (/signature|timestamp|jwt|JWT|issuer|expired/i.test(message)) {
663
+ errorCode = 'auth_error';
664
+ }
665
+ else if (/Missing required field|Missing field|must be a non-empty/i.test(message)) {
666
+ errorCode = 'validation_error';
667
+ }
668
+ else if (/Unsupported doc_type/i.test(message)) {
669
+ errorCode = 'unsupported_doc_type';
670
+ }
671
+ else if (/Tax rate.*not found/i.test(message)) {
672
+ errorCode = 'tax_not_found';
673
+ }
674
+ else if (/Multiple Zoho contacts/i.test(message)) {
675
+ errorCode = 'contact_ambiguous';
676
+ }
677
+ else if (/Contact not found/i.test(message)) {
678
+ errorCode = 'contact_not_found';
679
+ }
680
+ else if (/Contact creation failed/i.test(message)) {
681
+ errorCode = 'contact_creation_failed';
682
+ }
683
+ else if (/Organization ID/i.test(message)) {
684
+ errorCode = 'config_error';
685
+ }
686
+ else if (/License Key|Public Key/i.test(message)) {
687
+ errorCode = 'config_error';
688
+ }
689
+ return [[{
690
+ json: { success: false, error_code: errorCode, message },
691
+ pairedItem: { item: 0 },
692
+ }]];
693
+ }
694
+ }
695
+ }
696
+ exports.PopZohoInvoice = PopZohoInvoice;
697
+ //# sourceMappingURL=PopZohoInvoice.node.js.map