diff --git a/packages/pieces/community/invoiceninja/src/index.ts b/packages/pieces/community/invoiceninja/src/index.ts index 8d7df6f2ed..337885b46c 100644 --- a/packages/pieces/community/invoiceninja/src/index.ts +++ b/packages/pieces/community/invoiceninja/src/index.ts @@ -12,6 +12,8 @@ import { getReport } from './lib/actions/get-report'; import { existsTask } from './lib/actions/task-exists'; import { createInvoice } from './lib/actions/create-invoice'; import { createClient } from './lib/actions/create-client'; +import { createRecurringInvoice } from './lib/actions/create-recurring'; +import { actionRecurringInvoice } from './lib/actions/action-recurring'; export const invoiceninjaAuth = PieceAuth.CustomAuth({ props: { @@ -48,6 +50,8 @@ export const invoiceninja = createPiece({ getReport, createInvoice, createClient, + createRecurringInvoice, + actionRecurringInvoice, createCustomApiCallAction({ baseUrl: (auth) => `${(auth as { base_url: string }).base_url.replace(/\/$/, '')}/api/v1`, diff --git a/packages/pieces/community/invoiceninja/src/lib/actions/action-recurring.ts b/packages/pieces/community/invoiceninja/src/lib/actions/action-recurring.ts new file mode 100644 index 0000000000..ea1efef942 --- /dev/null +++ b/packages/pieces/community/invoiceninja/src/lib/actions/action-recurring.ts @@ -0,0 +1,86 @@ +import { + createAction, + Property, + Validators, + } from '@activepieces/pieces-framework'; + + import { invoiceninjaAuth } from '../..'; + + export const actionRecurringInvoice = createAction({ + auth: invoiceninjaAuth, + name: 'action_recurring_invoice', + displayName: 'Perform Action on Recurring Invoice', + description: 'Actions include: start, stop, send_now, restore, archive, delete.', + props: { + recurring_id: Property.LongText({ + displayName: 'Recurring Invoice ID (alphanumeric)', + description: 'Recurring Invoice ID from Invoice Ninja', + required: true, + }), + actionRecurring: Property.StaticDropdown({ + displayName: 'Action to perform', + description: 'Choose one', + defaultValue: 1, + required: true, + options: { + options: [ + { + label: 'Start', + value:'start', + }, + { + label: 'Stop', + value: 'stop', + }, + { + label: 'Send Now', + value: 'send_now', + }, + { + label: 'Restore', + value: 'restore', + }, + { + label: 'Archive', + value: 'archive', + }, + { + label: 'Delete', + value: 'delete', + } + ] + }, + }), + }, + + async run(context) { + const INapiToken = context.auth.access_token; + const headers = { + 'X-Api-Token': INapiToken, + 'Content-Type': 'application/json', + }; + + const baseUrl = context.auth.base_url.replace(/\/$/, ''); + let errorMessages = ''; + let i: string[] = [ context.propsValue.recurring_id]; + + const createRequestBody = { + action: context.propsValue.actionRecurring, + ids: i, + } + const createRequestResponse = await fetch(`${baseUrl}/api/v1/recurring_invoices/bulk`, { + method: 'POST', + headers, + body: JSON.stringify(createRequestBody), + }); + + if (!createRequestResponse.ok) { + throw new Error(`Failed to perform action on recurring invoice. Status: ${createRequestResponse.status}`); + } + + const createResponseBody = await createRequestResponse.json(); + + return createResponseBody; + } +}); + \ No newline at end of file diff --git a/packages/pieces/community/invoiceninja/src/lib/actions/create-recurring.ts b/packages/pieces/community/invoiceninja/src/lib/actions/create-recurring.ts new file mode 100644 index 0000000000..5fc4795ae2 --- /dev/null +++ b/packages/pieces/community/invoiceninja/src/lib/actions/create-recurring.ts @@ -0,0 +1,287 @@ +import { + createAction, + Property, + Validators, + } from '@activepieces/pieces-framework'; + + import { invoiceninjaAuth } from '../..'; + + export const createRecurringInvoice = createAction({ + auth: invoiceninjaAuth, + name: 'create_recurring_invoice', + displayName: 'Create Recurring Invoice', + description: 'Creates a recurring invoice in Invoice Ninja for billing purposes.', + + props: { + client_id: Property.LongText({ + displayName: 'Client ID (alphanumeric)', + description: 'Client ID from Invoice Ninja', + required: true, + }), + purchase_order_no: Property.LongText({ + displayName: 'Purchase Order Number (alphanumeric)', + description: 'Descriptive text or arbitrary number (optional)', + required: false, + }), + discount: Property.LongText({ + displayName: 'Apply discount', + description: 'Enter a number for the whole invoice discount', + defaultValue: '0', + required: true, + }), + discount_type: Property.StaticDropdown({ + displayName: 'Type of discount', + description: 'Select either amount or percentage for invoice discount. Applies to line items and invoice.', + defaultValue: true, + required: true, + options: { + options: [ + { + label: 'Amount', + value: true + }, + { + label: 'Percentage', + value: false + } + ] + } + }), + public_notes: Property.LongText({ + displayName: 'Public notes for invoice', + description: 'Text that may be visible in the client portal (optional)', + required: false, + }), + private_notes: Property.LongText({ + displayName: 'Private notes for invoice', + description: 'Text not visible for clients (optional)', + required: false, + }), + order_items_json: Property.LongText({ + displayName: 'Order Items JSON string', + description: 'e.g., [{ "quantity":1,"product_key":"product key", "discount": "0" }]', + required: true, + }), + frequency: Property.StaticDropdown({ + displayName: 'Frequency of billing', + description: 'Choose one', + defaultValue: 5, + required: true, + options: { + options: [ + { + label: 'Use override below', + value:0, + }, + { + label: 'Daily', + value: 1 + }, + { + label: 'Weekly', + value: 2 + }, + { + label: '2 Weeks', + value: 3 + }, + { + label: '4 Weeks', + value: 4 + }, + { + label: 'Monthly', + value: 5 + }, + { + label: 'Two Months', + value: 6 + }, + { + label: 'Quarterly', + value: 7 + }, + { + label: 'Four Months', + value: 8 + }, + { + label: 'Semi Annually', + value: 9 + }, + { + label: 'Annually', + value: 10 + }, + { + label: 'Two Years', + value: 11 + }, + { + label: 'Three Years', + value: 12 + } + ] + }, + }), + nocycles: Property.Number({ + displayName: 'No of billing cycles', + description: 'Enter a number. How many times should this bill be generated', + required: false, + processors: [], + validators: [Validators.inRange(0, 999)], + }), + auto_frequency: Property.Number({ + displayName: 'Overide Frequency using Frequency ID (optional)', + description: 'Enter a number. 1-12 - corresponds to dropdown above [Daily being 1, Weekly 2 etc..]!', + required: false, + processors: [], + validators: [Validators.inRange(1, 12)], + }), + due_date: Property.DateTime({ + displayName: 'Invoice next send date', + description: 'e.g., 2024-01-20', + required: true, + processors: [], + validators: [Validators.datetimeIso], + }), + last_date: Property.DateTime({ + displayName: 'Invoice last sent date', + description: 'e.g., 2024-01-20', + required: false, + processors: [], + validators: [Validators.datetimeIso], + }), + }, + + async run(context) { + const INapiToken = context.auth.access_token; + const headers = { + 'X-Api-Token': INapiToken, + 'Content-Type': 'application/json', + }; + + const lineItemsArray = JSON.parse(context.propsValue.order_items_json); + + if (!Array.isArray(lineItemsArray)) { + throw new Error('Invalid format for order_items_json. It should be an array of objects.'); + } + + if (lineItemsArray.length === 0) { + throw new Error('The line_items array must not be empty.'); + } + + const isValidLineItem = lineItemsArray.every(item => ( + typeof item === 'object' && + 'quantity' in item && typeof item.quantity === 'number' && + 'product_key' in item && typeof item.product_key === 'string' && + 'discount' in item && typeof item.discount === 'string' + )); + + if (!isValidLineItem) { + throw new Error('Each item in the line_items array must be an object with "quantity" (number), "product_key" (string), and "discount" (string).'); + } + + const baseUrl = context.auth.base_url.replace(/\/$/, ''); + let errorMessages = ''; + + try { + const lineItemsWithDetailsPromises = lineItemsArray.map(async item => { + try { + const getProductDetailsResponse = await fetch(`${baseUrl}/api/v1/products/?product_key=${item.product_key}`, { + method: 'GET', + headers, + }); + + if (!getProductDetailsResponse.ok) { + console.error(`Failed to get product details for ${item.product_key}. Status: ${getProductDetailsResponse.status}`); + errorMessages += `Failed to get product details for ${item.product_key}\n`; + return null; + } + + const productDetailsResponseBody = await getProductDetailsResponse.json(); + const productCount = productDetailsResponseBody.meta.pagination.count; + + if (productCount < 1) { + console.error(`No product details found for ${item.product_key}.`); + errorMessages += `No product details found for ${item.product_key}\n`; + return null; + } + + const productDetails = productDetailsResponseBody.data[0]; + + return { + quantity: item.quantity, + product_key: item.product_key, + product_cost: productDetails.price, + cost: productDetails.price, + notes: productDetails.notes, + discount: item.discount || '0', + is_amount_discount: context.propsValue.discount_type, + tax_name1: productDetails.tax_name1, + tax_rate1: productDetails.tax_rate1, + tax_id: productDetails.tax_id, + }; + } catch (error) { + console.error(`Error getting product details for ${item.product_key}:`, error); + errorMessages += `Error getting product details for ${item.product_key}: ${error}\n`; + return null; + } + }); + + const lineItemsWithDetails = await Promise.all(lineItemsWithDetailsPromises); + + if (errorMessages) { + // If there are error messages, throw an error with the accumulated messages + throw new Error(errorMessages.trim()); + } + + const today = new Date(); + const year = today.getFullYear(); + const month = today.getMonth() + 1; + const day = today.getDate(); + const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; + + const createInvoiceRequestBody = { + //status_id: 2, // seems cant set the status id when creating need another api call either using the recurring invoice ID and updating the status that way + // or possibly calling /bulk with action=start there's also an action send_now + next_send_date: context.propsValue.due_date, + date: formattedDate, + client_id: context.propsValue.client_id || '', + po_number: context.propsValue.purchase_order_no || '', + public_notes: context.propsValue.public_notes || '', + private_notes: context.propsValue.private_notes || '', + line_items: lineItemsWithDetails, + discount: context.propsValue.discount, + is_amount_discount: context.propsValue.discount_type, + frequency_id: context.propsValue.auto_frequency || context.propsValue.frequency, + remaining_cycles:context.propsValue.nocycles || -1, + }; + // if remaining cycles is set to 0 it will automatically go completed -1 is endless! + // status_id 2 is pending ie start scheduling + const createInvoiceResponse = await fetch(`${baseUrl}/api/v1/recurring_invoices`, { + method: 'POST', + headers, + body: JSON.stringify(createInvoiceRequestBody), + }); + + if (!createInvoiceResponse.ok) { + throw new Error(`Failed to create recurring invoice. Status: ${createInvoiceResponse.status}`); + } + + const createInvoiceResponseBody = await createInvoiceResponse.json(); + + return createInvoiceResponseBody; + } catch (error) { + console.error('Error creating recurring invoice or getting product details:', error); + if (errorMessages) { + // If there are error messages, throw an error with the accumulated messages + throw new Error(errorMessages.trim()); + } else { + // If there are no error messages, throw the original error + throw error; + } + } + }, + }); + \ No newline at end of file