From 482caa852cf506feb05cc58f111a640e5e032577 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Thu, 29 Oct 2026 17:09:42 +0600 Subject: [PATCH 1/6] feat(xero): add Xero action backend XeroController uses the OAuth2 connection, finds the organisation from the token's auth event and caches its tenant ID on the connection, and serves the contact, account, item, tax rate, currency and branding theme lists. RecordApiHelper sends each of the 71 actions to its Pro hook. --- backend/Actions/Xero/RecordApiHelper.php | 498 +++++++++++++++++++++++ backend/Actions/Xero/Routes.php | 16 + backend/Actions/Xero/XeroController.php | 201 +++++++++ 3 files changed, 715 insertions(+) create mode 100644 backend/Actions/Xero/RecordApiHelper.php create mode 100644 backend/Actions/Xero/Routes.php create mode 100644 backend/Actions/Xero/XeroController.php diff --git a/backend/Actions/Xero/RecordApiHelper.php b/backend/Actions/Xero/RecordApiHelper.php new file mode 100644 index 000000000..638f73c26 --- /dev/null +++ b/backend/Actions/Xero/RecordApiHelper.php @@ -0,0 +1,498 @@ +_integrationDetails = $integrationDetails; + $this->_integrationID = $integId; + $this->apiClient = $apiClient; + } + + public function execute($fieldValues, $fieldMap) + { + $fieldData = static::generateReqDataFromFieldMap($fieldValues, $fieldMap); + $mainAction = $this->_integrationDetails->mainAction ?? ''; + $settings = $this->settings(); + $default = [ + 'success' => false, + // translators: %s is the plugin name. + 'message' => wp_sprintf(__('%s plugin is not installed or activate', 'bit-integrations'), 'Bit Integrations Pro'), + 'code' => 400, + ]; + + switch ($mainAction) { + case 'create_contact': + $response = Hooks::apply(Config::withPrefix('xero_create_contact'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_contact': + $response = Hooks::apply(Config::withPrefix('xero_update_contact'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_or_update_contact': + $response = Hooks::apply(Config::withPrefix('xero_create_or_update_contact'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'archive_contact': + $response = Hooks::apply(Config::withPrefix('xero_archive_contact'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_contact_group': + $response = Hooks::apply(Config::withPrefix('xero_create_contact_group'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'rename_contact_group': + $response = Hooks::apply(Config::withPrefix('xero_rename_contact_group'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_contact_group': + $response = Hooks::apply(Config::withPrefix('xero_delete_contact_group'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'add_contacts_to_group': + $response = Hooks::apply(Config::withPrefix('xero_add_contacts_to_group'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'remove_contact_from_group': + $response = Hooks::apply(Config::withPrefix('xero_remove_contact_from_group'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'remove_all_contacts_from_group': + $response = Hooks::apply(Config::withPrefix('xero_remove_all_contacts_from_group'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_invoice': + $response = Hooks::apply(Config::withPrefix('xero_create_invoice'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_bill': + $response = Hooks::apply(Config::withPrefix('xero_create_bill'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_invoice': + $response = Hooks::apply(Config::withPrefix('xero_update_invoice'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'approve_invoice': + $response = Hooks::apply(Config::withPrefix('xero_approve_invoice'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'void_invoice': + $response = Hooks::apply(Config::withPrefix('xero_void_invoice'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_invoice': + $response = Hooks::apply(Config::withPrefix('xero_delete_invoice'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'email_invoice': + $response = Hooks::apply(Config::withPrefix('xero_email_invoice'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_credit_note': + $response = Hooks::apply(Config::withPrefix('xero_create_credit_note'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_credit_note': + $response = Hooks::apply(Config::withPrefix('xero_update_credit_note'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'approve_credit_note': + $response = Hooks::apply(Config::withPrefix('xero_approve_credit_note'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'void_credit_note': + $response = Hooks::apply(Config::withPrefix('xero_void_credit_note'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_credit_note': + $response = Hooks::apply(Config::withPrefix('xero_delete_credit_note'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'allocate_credit_note': + $response = Hooks::apply(Config::withPrefix('xero_allocate_credit_note'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_credit_note_allocation': + $response = Hooks::apply(Config::withPrefix('xero_delete_credit_note_allocation'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_quote': + $response = Hooks::apply(Config::withPrefix('xero_create_quote'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_quote': + $response = Hooks::apply(Config::withPrefix('xero_update_quote'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_quote_status': + $response = Hooks::apply(Config::withPrefix('xero_update_quote_status'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_purchase_order': + $response = Hooks::apply(Config::withPrefix('xero_create_purchase_order'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_purchase_order': + $response = Hooks::apply(Config::withPrefix('xero_update_purchase_order'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_purchase_order_status': + $response = Hooks::apply(Config::withPrefix('xero_update_purchase_order_status'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_repeating_invoice': + $response = Hooks::apply(Config::withPrefix('xero_create_repeating_invoice'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_repeating_invoice': + $response = Hooks::apply(Config::withPrefix('xero_delete_repeating_invoice'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_item': + $response = Hooks::apply(Config::withPrefix('xero_create_item'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_item': + $response = Hooks::apply(Config::withPrefix('xero_update_item'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_item': + $response = Hooks::apply(Config::withPrefix('xero_delete_item'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_linked_transaction': + $response = Hooks::apply(Config::withPrefix('xero_create_linked_transaction'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_linked_transaction': + $response = Hooks::apply(Config::withPrefix('xero_update_linked_transaction'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_linked_transaction': + $response = Hooks::apply(Config::withPrefix('xero_delete_linked_transaction'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_payment': + $response = Hooks::apply(Config::withPrefix('xero_create_payment'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_payment': + $response = Hooks::apply(Config::withPrefix('xero_delete_payment'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_batch_payment': + $response = Hooks::apply(Config::withPrefix('xero_create_batch_payment'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_batch_payment': + $response = Hooks::apply(Config::withPrefix('xero_delete_batch_payment'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'allocate_overpayment': + $response = Hooks::apply(Config::withPrefix('xero_allocate_overpayment'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_overpayment_allocation': + $response = Hooks::apply(Config::withPrefix('xero_delete_overpayment_allocation'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'allocate_prepayment': + $response = Hooks::apply(Config::withPrefix('xero_allocate_prepayment'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_prepayment_allocation': + $response = Hooks::apply(Config::withPrefix('xero_delete_prepayment_allocation'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_spend_money': + $response = Hooks::apply(Config::withPrefix('xero_create_spend_money'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_receive_money': + $response = Hooks::apply(Config::withPrefix('xero_create_receive_money'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_bank_transaction': + $response = Hooks::apply(Config::withPrefix('xero_update_bank_transaction'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_bank_transaction': + $response = Hooks::apply(Config::withPrefix('xero_delete_bank_transaction'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_bank_transfer': + $response = Hooks::apply(Config::withPrefix('xero_create_bank_transfer'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_bank_transfer': + $response = Hooks::apply(Config::withPrefix('xero_delete_bank_transfer'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_manual_journal': + $response = Hooks::apply(Config::withPrefix('xero_create_manual_journal'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_manual_journal': + $response = Hooks::apply(Config::withPrefix('xero_update_manual_journal'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_manual_journal_status': + $response = Hooks::apply(Config::withPrefix('xero_update_manual_journal_status'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_account': + $response = Hooks::apply(Config::withPrefix('xero_create_account'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_account': + $response = Hooks::apply(Config::withPrefix('xero_update_account'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'archive_account': + $response = Hooks::apply(Config::withPrefix('xero_archive_account'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_account': + $response = Hooks::apply(Config::withPrefix('xero_delete_account'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_tracking_category': + $response = Hooks::apply(Config::withPrefix('xero_create_tracking_category'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_tracking_category': + $response = Hooks::apply(Config::withPrefix('xero_update_tracking_category'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_tracking_category': + $response = Hooks::apply(Config::withPrefix('xero_delete_tracking_category'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_tracking_option': + $response = Hooks::apply(Config::withPrefix('xero_create_tracking_option'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_tracking_option': + $response = Hooks::apply(Config::withPrefix('xero_update_tracking_option'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'delete_tracking_option': + $response = Hooks::apply(Config::withPrefix('xero_delete_tracking_option'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'create_tax_rate': + $response = Hooks::apply(Config::withPrefix('xero_create_tax_rate'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'update_tax_rate': + $response = Hooks::apply(Config::withPrefix('xero_update_tax_rate'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'add_currency': + $response = Hooks::apply(Config::withPrefix('xero_add_currency'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'add_note': + $response = Hooks::apply(Config::withPrefix('xero_add_note'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'upload_attachment': + $response = Hooks::apply(Config::withPrefix('xero_upload_attachment'), $default, $fieldData, $this->apiClient, $settings); + + break; + + case 'download_pdf': + $response = Hooks::apply(Config::withPrefix('xero_download_pdf'), $default, $fieldData, $this->apiClient, $settings); + + break; + + default: + $response = ['success' => false, 'message' => __('The action is not valid. Select a Xero action.', 'bit-integrations'), 'code' => 400]; + + break; + } + + $responseType = isset($response['success']) && $response['success'] ? 'success' : 'error'; + LogHandler::save($this->_integrationID, ['type' => 'Xero', 'type_name' => $mainAction], $responseType, wp_json_encode($response)); + + return $response; + } + + protected static function generateReqDataFromFieldMap($fieldValues, $fieldMap) + { + $data = []; + + foreach ((array) $fieldMap as $map) { + $triggerField = $map->formField ?? ''; + $xeroField = $map->xeroField ?? ''; + + if (empty($xeroField)) { + continue; + } + + if ($triggerField === 'custom') { + $data[$xeroField] = Common::replaceFieldWithValue($map->customValue ?? '', $fieldValues); + } elseif (isset($fieldValues[$triggerField])) { + $data[$xeroField] = $fieldValues[$triggerField]; + } + } + + return $data; + } + + private function settings() + { + $details = $this->_integrationDetails; + $settings = []; + + foreach (self::SETTING_KEYS as $key) { + if (isset($details->{$key}) && $details->{$key} !== '' && $details->{$key} !== []) { + $settings[$key] = $details->{$key}; + } + } + + foreach ((array) ($details->utilities ?? []) as $key => $value) { + if ($value !== '' && $value !== null && $value !== []) { + $settings[preg_replace('/^selected_/', '', (string) $key)] = $value; + } + } + + return $settings; + } +} diff --git a/backend/Actions/Xero/Routes.php b/backend/Actions/Xero/Routes.php new file mode 100644 index 000000000..707d3462b --- /dev/null +++ b/backend/Actions/Xero/Routes.php @@ -0,0 +1,16 @@ + AuthorizationType::OAUTH2, + 'slug' => 'Xero', + 'fields' => [], + ]; + + public function getContacts($queryParams) + { + self::sendOptions( + $queryParams, + '/Contacts', + ['order' => 'Name ASC', 'summaryOnly' => 'true', 'where' => 'ContactStatus=="ACTIVE"'], + 'Contacts', + static function ($record) { + return ['id' => $record['ContactID'], 'name' => trim(($record['Name'] ?? '') . (empty($record['EmailAddress']) ? '' : ' (' . $record['EmailAddress'] . ')'))]; + } + ); + } + + public function getAccountCodes($queryParams) + { + self::sendOptions( + $queryParams, + '/Accounts', + ['order' => 'Code ASC', 'where' => 'Status=="ACTIVE"'], + 'Accounts', + static function ($record) { + return empty($record['Code']) ? null : ['id' => $record['Code'], 'name' => $record['Code'] . ' - ' . ($record['Name'] ?? '') . ' (' . ($record['Type'] ?? '') . ')']; + } + ); + } + + public function getBankAccounts($queryParams) + { + self::sendOptions($queryParams, '/Accounts', ['order' => 'Name ASC', 'where' => 'Status=="ACTIVE" AND Type=="BANK"'], 'Accounts', [self::class, 'accountIdOption']); + } + + public function getItems($queryParams) + { + self::sendOptions( + $queryParams, + '/Items', + ['order' => 'Code ASC'], + 'Items', + static function ($record) { + return ['id' => $record['Code'], 'name' => trim($record['Code'] . ' - ' . ($record['Name'] ?? ''), ' -')]; + } + ); + } + + public function getTaxRates($queryParams) + { + self::sendOptions( + $queryParams, + '/TaxRates', + ['where' => 'Status=="ACTIVE"'], + 'TaxRates', + static function ($record) { + return ['id' => $record['TaxType'], 'name' => ($record['Name'] ?? $record['TaxType']) . ' (' . $record['TaxType'] . ')']; + } + ); + } + + public function getCurrencies($queryParams) + { + self::sendOptions( + $queryParams, + '/Currencies', + [], + 'Currencies', + static function ($record) { + return ['id' => $record['Code'], 'name' => trim($record['Code'] . ' - ' . ($record['Description'] ?? ''), ' -')]; + } + ); + } + + public function getBrandingThemes($queryParams) + { + self::sendOptions( + $queryParams, + '/BrandingThemes', + [], + 'BrandingThemes', + static function ($record) { + return ['id' => $record['BrandingThemeID'], 'name' => $record['Name'] ?? $record['BrandingThemeID']]; + } + ); + } + + public function execute($integrationData, $fieldValues) + { + $integrationDetails = $integrationData->flow_details; + $integId = $integrationData->id; + $client = self::client($integrationDetails->connection_id ?? 0); + + if (\is_string($client)) { + $error = new WP_Error('REQ_FIELD_EMPTY', $client); + LogHandler::save($integId, 'record', 'validation', $error); + + return $error; + } + + return (new RecordApiHelper($integrationDetails, $integId, $client))->execute($fieldValues, $integrationDetails->field_map ?? []); + } + + private static function accountIdOption($record) + { + return ['id' => $record['AccountID'], 'name' => trim(($record['Code'] ?? '') . ' - ' . ($record['Name'] ?? ''), ' -') . ' (' . ($record['Type'] ?? '') . ')']; + } + + /** + * @param mixed $connectionId + * + * @return ApiClient|string + */ + private static function client($connectionId) + { + $connection = AuthorizationFactory::getConnectionHandler($connectionId); + + if ($connection === null) { + return __('A Xero connection is required. Select a connection.', 'bit-integrations'); + } + + $authDetails = (array) ($connection->getAuthDetails() ?? []); + $tenantId = (string) ($authDetails['tenant_id'] ?? ''); + + if ($tenantId === '') { + $tenantId = self::findOrganisation($connection, (string) ($authDetails['access_token'] ?? '')); + + if ($tenantId === '') { + return __('Xero has no organisation for this connection. Connect again and select an organisation.', 'bit-integrations'); + } + + $connection->updateAuthDetails(['tenant_id' => $tenantId] + (array) ($connection->getAuthDetails() ?? [])); + } + + $apiClient = new ApiClient($connection); + $apiClient->setBaseURL(self::API_URL); + $apiClient->setHeaders(['Accept' => 'application/json', 'xero-tenant-id' => $tenantId]); + + return $apiClient; + } + + // The consent screen can add several organisations; the token's auth event names the ones picked for this connection. + private static function findOrganisation($connection, string $accessToken): string + { + $parts = explode('.', $accessToken); + $claims = \count($parts) === 3 ? json_decode((string) base64_decode(strtr($parts[1], '-_', '+/')), true) : null; + $authEventId = \is_array($claims) ? (string) ($claims['authentication_event_id'] ?? '') : ''; + + $client = new ApiClient($connection); + $client->setBaseURL(self::CONNECTIONS_URL); + $client->setHeaders(['Accept' => 'application/json']); + + foreach ($authEventId === '' ? [''] : ['?authEventId=' . rawurlencode($authEventId), ''] as $query) { + $organisations = json_decode((string) wp_json_encode($client->get($query)->getBody()), true); + + foreach (\is_array($organisations) ? $organisations : [] as $organisation) { + if (($organisation['tenantType'] ?? '') === 'ORGANISATION' && !empty($organisation['tenantId'])) { + return (string) $organisation['tenantId']; + } + } + } + + return ''; + } + + private static function sendOptions($queryParams, string $path, array $query, string $collection, callable $toOption) + { + $client = self::client($queryParams->connection_id ?? 0); + + if (\is_string($client)) { + wp_send_json_error($client, 400); + } + + $response = $client->get($path . (empty($query) ? '' : '?' . http_build_query($query))); + $body = json_decode((string) wp_json_encode($response->getBody()), true); + + if (!$response->success() || !isset($body[$collection])) { + wp_send_json_error($body['Detail'] ?? $body['Message'] ?? $response->getError() ?? __('Xero did not return the list. Try again.', 'bit-integrations'), 400); + } + + wp_send_json_success(array_values(array_filter(array_map($toOption, $body[$collection]))), 200); + } +} From 650d78ee20f2b958c4e19967ccc5b12c3b837d1b Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Thu, 29 Oct 2026 17:09:42 +0600 Subject: [PATCH 2/6] feat(xero): add Xero action UI OAuth connection, 71 Pro actions with one line per document, a contact dropdown, and selects for fixed options and fetched lists. --- .../AllIntegrations/Xero/EditXero.jsx | 81 ++ .../components/AllIntegrations/Xero/Xero.jsx | 100 ++ .../Xero/XeroAuthorization.jsx | 63 + .../AllIntegrations/Xero/XeroCommonFunc.js | 115 ++ .../AllIntegrations/Xero/XeroFieldMap.jsx | 107 ++ .../AllIntegrations/Xero/XeroIntegLayout.jsx | 178 +++ .../AllIntegrations/Xero/staticData.js | 1250 +++++++++++++++++ 7 files changed, 1894 insertions(+) create mode 100644 frontend/src/components/AllIntegrations/Xero/EditXero.jsx create mode 100644 frontend/src/components/AllIntegrations/Xero/Xero.jsx create mode 100644 frontend/src/components/AllIntegrations/Xero/XeroAuthorization.jsx create mode 100644 frontend/src/components/AllIntegrations/Xero/XeroCommonFunc.js create mode 100644 frontend/src/components/AllIntegrations/Xero/XeroFieldMap.jsx create mode 100644 frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx create mode 100644 frontend/src/components/AllIntegrations/Xero/staticData.js diff --git a/frontend/src/components/AllIntegrations/Xero/EditXero.jsx b/frontend/src/components/AllIntegrations/Xero/EditXero.jsx new file mode 100644 index 000000000..c26887482 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Xero/EditXero.jsx @@ -0,0 +1,81 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router' +import { useRecoilState, useRecoilValue } from 'recoil' +import { $actionConf, $formFields, $newFlow } from '../../../GlobalStates' +import { __ } from '../../../Utils/i18nwrap' +import SnackMsg from '../../Utilities/SnackMsg' +import { saveActionConf } from '../IntegrationHelpers/IntegrationHelpers' +import IntegrationStepThree from '../IntegrationHelpers/IntegrationStepThree' +import SetEditIntegComponents from '../IntegrationHelpers/SetEditIntegComponents' +import { checkMappedFields, handleInput } from './XeroCommonFunc' +import XeroIntegLayout from './XeroIntegLayout' + +function EditXero({ allIntegURL }) { + const navigate = useNavigate() + const [flow] = useRecoilState($newFlow) + const [xeroConf, setXeroConf] = useRecoilState($actionConf) + const [isLoading, setIsLoading] = useState(false) + const [snack, setSnackbar] = useState({ show: false }) + const formField = useRecoilValue($formFields) + + const saveConfig = () => { + if (!checkMappedFields(xeroConf)) { + setSnackbar({ + show: true, + msg: __('Fill in the required options and fields.', 'bit-integrations') + }) + + return + } + saveActionConf({ + allIntegURL, + conf: xeroConf, + edit: 1, + flow, + navigate, + setIsLoading, + setSnackbar + }) + } + + return ( +
+ + +
+ {__('Integration Name:', 'bit-integrations')} + handleInput(e, xeroConf, setXeroConf)} + name="name" + value={xeroConf.name} + type="text" + placeholder={__('Integration Name...', 'bit-integrations')} + /> +
+
+ + + + + + +
+
+ ) +} + +export default EditXero diff --git a/frontend/src/components/AllIntegrations/Xero/Xero.jsx b/frontend/src/components/AllIntegrations/Xero/Xero.jsx new file mode 100644 index 000000000..fd16fa198 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Xero/Xero.jsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import toast from 'react-hot-toast' +import { useNavigate } from 'react-router' +import { __ } from '../../../Utils/i18nwrap' +import SnackMsg from '../../Utilities/SnackMsg' +import Steps from '../../Utilities/Steps' +import { saveIntegConfig } from '../IntegrationHelpers/IntegrationHelpers' +import IntegrationStepThree from '../IntegrationHelpers/IntegrationStepThree' +import XeroAuthorization from './XeroAuthorization' +import { checkMappedFields } from './XeroCommonFunc' +import XeroIntegLayout from './XeroIntegLayout' +import 'react-multiple-select-dropdown-lite/dist/index.css' + +function Xero({ formFields, setFlow, flow, allIntegURL }) { + const navigate = useNavigate() + const [isLoading, setIsLoading] = useState(false) + const [step, setstep] = useState(1) + const [snack, setSnackbar] = useState({ show: false }) + + const [xeroConf, setXeroConf] = useState({ + field_map: [], + name: 'Xero', + type: 'Xero', + utilities: {} + }) + + const saveConfig = () => { + setIsLoading(true) + const resp = saveIntegConfig(flow, setFlow, allIntegURL, xeroConf, navigate, '', '', setIsLoading) + resp.then(res => { + if (res.success) { + toast.success(res.data?.msg) + navigate(allIntegURL) + } else { + toast.error(res.data || res) + } + }) + } + + const nextPage = pageNo => { + setTimeout(() => { + document.getElementById('btcd-settings-wrp').scrollTop = 0 + }, 300) + + if (!checkMappedFields(xeroConf)) { + toast.error( + __('Select an action and fill in the required options and fields.', 'bit-integrations') + ) + + return + } + setstep(pageNo) + } + + return ( +
+ +
+ +
+ + + +
+ {step === 2 && ( + + )} + + +
+ + saveConfig()} + isLoading={isLoading} + dataConf={xeroConf} + setDataConf={setXeroConf} + formFields={formFields} + /> +
+ ) +} + +export default Xero diff --git a/frontend/src/components/AllIntegrations/Xero/XeroAuthorization.jsx b/frontend/src/components/AllIntegrations/Xero/XeroAuthorization.jsx new file mode 100644 index 000000000..f81425a48 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Xero/XeroAuthorization.jsx @@ -0,0 +1,63 @@ +import { AUTH_TYPES } from '../../../Utils/connectionAuth' +import { __ } from '../../../Utils/i18nwrap' +import tutorialLinks from '../../../Utils/StaticData/tutorialLinks' +import Authorization from '../../Connections/Authorization' + +const developerPortalUrl = 'https://developer.xero.com/app/manage' + +const scope = [ + 'openid', + 'profile', + 'email', + 'offline_access', + 'accounting.contacts', + 'accounting.settings', + 'accounting.attachments', + 'accounting.invoices', + 'accounting.payments', + 'accounting.banktransactions', + 'accounting.manualjournals' +].join(' ') + +const note = ` +

${__('Steps to connect Xero:', 'bit-integrations')}

+ + ` + +export default function XeroAuthorization({ xeroConf, setXeroConf, step, setstep, isInfo }) { + return ( + + ) +} diff --git a/frontend/src/components/AllIntegrations/Xero/XeroCommonFunc.js b/frontend/src/components/AllIntegrations/Xero/XeroCommonFunc.js new file mode 100644 index 000000000..6e4b44480 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Xero/XeroCommonFunc.js @@ -0,0 +1,115 @@ +import { create } from 'mutative' +import toast from 'react-hot-toast' +import bitsFetch from '../../../Utils/bitsFetch' +import { __ } from '../../../Utils/i18nwrap' +import { fieldsByAction, selectsByAction } from './staticData' + +const sources = { + accountCodes: 'xero_get_account_codes', + bankAccounts: 'xero_get_bank_accounts', + brandingThemes: 'xero_get_branding_themes', + contacts: 'xero_get_contacts', + currencies: 'xero_get_currencies', + items: 'xero_get_items', + taxRates: 'xero_get_tax_rates' +} + +let pendingFetches = 0 + +export const handleInput = (e, xeroConf, setXeroConf) => { + const { name, value } = e.target + + setXeroConf( + create(xeroConf, draftConf => { + if (value !== '') { + draftConf[name] = value + } else { + delete draftConf[name] + } + }) + ) +} + +export const fetchSource = (source, conf, setConf, setIsLoading, silent = false) => { + const route = sources[source] + + if (!route || !conf?.connection_id) return + + pendingFetches += 1 + setIsLoading(true) + + const request = bitsFetch({ connection_id: conf.connection_id }, route) + .then(result => { + if (result?.success) { + setConf(prevConf => + create(prevConf, draftConf => { + if (!draftConf.default) { + draftConf.default = {} + } + + draftConf.default[source] = result.data || [] + }) + ) + return __('Options refreshed.', 'bit-integrations') + } + + throw new Error( + typeof result?.data === 'string' + ? result.data + : __('Refresh failed. Try again.', 'bit-integrations') + ) + }) + .finally(() => { + pendingFetches -= 1 + setIsLoading(pendingFetches > 0) + }) + + if (silent) { + request.catch(error => toast.error(error.message, { id: 'xero-fetch-error' })) + return + } + + toast.promise(request, { + error: error => error.message, + loading: __('Loading options...', 'bit-integrations'), + success: message => message + }) +} + +export const fetchActionSources = (conf, setConf, setIsLoading) => { + const selectSources = (selectsByAction[conf?.mainAction] || []) + .map(select => select.source) + .filter(Boolean) + + ;[...new Set(selectSources)].forEach(source => fetchSource(source, conf, setConf, setIsLoading, true)) +} + +export const generateMappedField = action => { + const actionFields = fieldsByAction[action] || [] + const requiredFlds = actionFields.filter(fld => fld.required === true) + + if (requiredFlds.length > 0) { + return requiredFlds.map(field => ({ formField: '', xeroField: field.key })) + } + + return actionFields.length > 0 ? [{ formField: '', xeroField: '' }] : [] +} + +export const checkMappedFields = xeroConf => { + if (!xeroConf?.mainAction) return false + + const missingSelect = (selectsByAction[xeroConf.mainAction] || []).some( + select => select.required && !xeroConf[select.key]?.length + ) + + if (missingSelect) return false + + const incompleteRows = (xeroConf.field_map || []).filter( + mappedField => + !mappedField.formField || + !mappedField.xeroField || + (mappedField.formField === 'custom' && !mappedField.customValue) + ) + + return incompleteRows.length === 0 +} diff --git a/frontend/src/components/AllIntegrations/Xero/XeroFieldMap.jsx b/frontend/src/components/AllIntegrations/Xero/XeroFieldMap.jsx new file mode 100644 index 000000000..129da767f --- /dev/null +++ b/frontend/src/components/AllIntegrations/Xero/XeroFieldMap.jsx @@ -0,0 +1,107 @@ +import { useRecoilValue } from 'recoil' +import { $appConfigState } from '../../../GlobalStates' +import { __, sprintf } from '../../../Utils/i18nwrap' +import { SmartTagField } from '../../../Utils/StaticData/SmartTagField' +import TagifyInput from '../../Utilities/TagifyInput' +import { + addFieldMap, + delFieldMap, + handleCustomValue, + handleFieldMapping +} from '../GlobalIntegrationHelper' +import { fieldsByAction } from './staticData' + +export default function XeroFieldMap({ i, formFields, field, xeroConf, setXeroConf }) { + const btcbi = useRecoilValue($appConfigState) + const { isPro } = btcbi + + const actionFields = fieldsByAction[xeroConf?.mainAction] || [] + const requiredFlds = actionFields.filter(fld => fld.required === true) + const nonRequiredFlds = actionFields.filter(fld => fld.required === false) + + return ( +
+
+
+ + + {field.formField === 'custom' && ( + handleCustomValue(e, i, xeroConf, setXeroConf)} + label={__('Custom Value', 'bit-integrations')} + className="mr-2" + type="text" + value={field.customValue} + placeholder={__('Custom Value', 'bit-integrations')} + formFields={formFields} + /> + )} + + +
+ + {i >= requiredFlds.length && ( + <> + + + + )} +
+
+ ) +} diff --git a/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx b/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx new file mode 100644 index 000000000..1aa9354e9 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx @@ -0,0 +1,178 @@ +import { useEffect } from 'react' +import { create } from 'mutative' +import MultiSelect from 'react-multiple-select-dropdown-lite' +import { useRecoilValue } from 'recoil' +import { $appConfigState } from '../../../GlobalStates' +import { __ } from '../../../Utils/i18nwrap' +import Loader from '../../Loaders/Loader' +import Note from '../../Utilities/Note' +import { checkIsPro, getProLabel } from '../../Utilities/ProUtilHelpers' +import { addFieldMap } from '../GlobalIntegrationHelper' +import { fetchActionSources, fetchSource, generateMappedField } from './XeroCommonFunc' +import XeroFieldMap from './XeroFieldMap' +import { fieldsByAction, modules, notesByAction, selectKeys, selectsByAction } from './staticData' +import 'react-multiple-select-dropdown-lite/dist/index.css' + +export default function XeroIntegLayout({ formFields, xeroConf, setXeroConf, isLoading, setIsLoading }) { + const btcbi = useRecoilValue($appConfigState) + const { isPro } = btcbi + + const action = xeroConf?.mainAction + const selects = selectsByAction[action] || [] + + useEffect(() => { + if (action && xeroConf?.connection_id) { + fetchActionSources(xeroConf, setXeroConf, setIsLoading) + } + }, []) + + const handleMainAction = value => { + const nextConf = create(xeroConf, draftConf => { + draftConf.mainAction = value + draftConf.field_map = generateMappedField(value) + draftConf.utilities = {} + selectKeys.forEach(key => { + delete draftConf[key] + }) + }) + + setXeroConf(nextConf) + fetchActionSources(nextConf, setXeroConf, setIsLoading) + } + + const handleSelect = (select, value) => { + setXeroConf(prevConf => + create(prevConf, draftConf => { + if (value) { + draftConf[select.key] = value + } else { + delete draftConf[select.key] + } + }) + ) + } + + const selectOptions = select => { + if (select.options) return select.options + + const items = xeroConf?.default?.[select.source] || [] + + return items.map(({ id, name }) => ({ + label: items.filter(item => item.name === name).length > 1 ? `${name} (#${id})` : name, + value: String(id) + })) + } + + return ( + <> +
+
+ {__('Action:', 'bit-integrations')} + handleMainAction(value)} + options={modules?.map(module => ({ + disabled: !checkIsPro(isPro, module.is_pro), + label: checkIsPro(isPro, module.is_pro) ? module.label : getProLabel(module.label), + value: module.name + }))} + singleSelect + closeOnSelect + /> +
+ + {selects.map(select => { + const options = selectOptions(select) + + return ( +
0 ? 'loaded' : 'empty'}`}> +
+
+ + {select.label} + {select.required && ' *'} + + handleSelect(select, value)} + singleSelect + closeOnSelect + /> + {select.source && ( + + )} +
+
+ ) + })} + + {isLoading && ( + + )} + + {fieldsByAction[action]?.length > 0 && ( +
+ {__('Map Fields', 'bit-integrations')} +
+
+
+ {__('Form Fields', 'bit-integrations')} +
+
+ {__('Xero Fields', 'bit-integrations')} +
+
+ + {xeroConf?.field_map?.map((itm, i) => ( + + ))} + + {fieldsByAction[action].some(actionField => !actionField.required) && ( +
+ +
+ )} +
+ )} + + {notesByAction[action] && ( + <> +
+ + + )} + + ) +} diff --git a/frontend/src/components/AllIntegrations/Xero/staticData.js b/frontend/src/components/AllIntegrations/Xero/staticData.js new file mode 100644 index 000000000..cac940c65 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Xero/staticData.js @@ -0,0 +1,1250 @@ +import { __ } from '../../../Utils/i18nwrap' + +export const modules = [ + { name: 'create_contact', label: __('Create Contact', 'bit-integrations'), is_pro: true }, + { name: 'update_contact', label: __('Update Contact', 'bit-integrations'), is_pro: true }, + { + name: 'create_or_update_contact', + label: __('Create or Update Contact', 'bit-integrations'), + is_pro: true + }, + { name: 'archive_contact', label: __('Archive Contact', 'bit-integrations'), is_pro: true }, + { name: 'create_contact_group', label: __('Create Contact Group', 'bit-integrations'), is_pro: true }, + { name: 'rename_contact_group', label: __('Rename Contact Group', 'bit-integrations'), is_pro: true }, + { name: 'delete_contact_group', label: __('Delete Contact Group', 'bit-integrations'), is_pro: true }, + { + name: 'add_contacts_to_group', + label: __('Add Contacts to Group', 'bit-integrations'), + is_pro: true + }, + { + name: 'remove_contact_from_group', + label: __('Remove Contact from Group', 'bit-integrations'), + is_pro: true + }, + { + name: 'remove_all_contacts_from_group', + label: __('Remove All Contacts from Group', 'bit-integrations'), + is_pro: true + }, + { name: 'create_invoice', label: __('Create Invoice', 'bit-integrations'), is_pro: true }, + { name: 'create_bill', label: __('Create Bill', 'bit-integrations'), is_pro: true }, + { name: 'update_invoice', label: __('Update Invoice or Bill', 'bit-integrations'), is_pro: true }, + { name: 'approve_invoice', label: __('Approve Invoice or Bill', 'bit-integrations'), is_pro: true }, + { name: 'void_invoice', label: __('Void Invoice or Bill', 'bit-integrations'), is_pro: true }, + { + name: 'delete_invoice', + label: __('Delete Draft Invoice or Bill', 'bit-integrations'), + is_pro: true + }, + { name: 'email_invoice', label: __('Email Invoice', 'bit-integrations'), is_pro: true }, + { name: 'create_credit_note', label: __('Create Credit Note', 'bit-integrations'), is_pro: true }, + { name: 'update_credit_note', label: __('Update Credit Note', 'bit-integrations'), is_pro: true }, + { name: 'approve_credit_note', label: __('Approve Credit Note', 'bit-integrations'), is_pro: true }, + { name: 'void_credit_note', label: __('Void Credit Note', 'bit-integrations'), is_pro: true }, + { + name: 'delete_credit_note', + label: __('Delete Draft Credit Note', 'bit-integrations'), + is_pro: true + }, + { + name: 'allocate_credit_note', + label: __('Allocate Credit Note to Invoice', 'bit-integrations'), + is_pro: true + }, + { + name: 'delete_credit_note_allocation', + label: __('Remove Credit Note Allocation', 'bit-integrations'), + is_pro: true + }, + { name: 'create_quote', label: __('Create Quote', 'bit-integrations'), is_pro: true }, + { name: 'update_quote', label: __('Update Quote', 'bit-integrations'), is_pro: true }, + { name: 'update_quote_status', label: __('Update Quote Status', 'bit-integrations'), is_pro: true }, + { + name: 'create_purchase_order', + label: __('Create Purchase Order', 'bit-integrations'), + is_pro: true + }, + { + name: 'update_purchase_order', + label: __('Update Purchase Order', 'bit-integrations'), + is_pro: true + }, + { + name: 'update_purchase_order_status', + label: __('Update Purchase Order Status', 'bit-integrations'), + is_pro: true + }, + { + name: 'create_repeating_invoice', + label: __('Create Repeating Invoice', 'bit-integrations'), + is_pro: true + }, + { + name: 'delete_repeating_invoice', + label: __('Delete Repeating Invoice', 'bit-integrations'), + is_pro: true + }, + { name: 'create_item', label: __('Create Item', 'bit-integrations'), is_pro: true }, + { name: 'update_item', label: __('Update Item', 'bit-integrations'), is_pro: true }, + { name: 'delete_item', label: __('Delete Item', 'bit-integrations'), is_pro: true }, + { + name: 'create_linked_transaction', + label: __('Create Linked Transaction', 'bit-integrations'), + is_pro: true + }, + { + name: 'update_linked_transaction', + label: __('Update Linked Transaction', 'bit-integrations'), + is_pro: true + }, + { + name: 'delete_linked_transaction', + label: __('Delete Linked Transaction', 'bit-integrations'), + is_pro: true + }, + { name: 'create_payment', label: __('Create Payment', 'bit-integrations'), is_pro: true }, + { name: 'delete_payment', label: __('Delete Payment', 'bit-integrations'), is_pro: true }, + { name: 'create_batch_payment', label: __('Create Batch Payment', 'bit-integrations'), is_pro: true }, + { name: 'delete_batch_payment', label: __('Delete Batch Payment', 'bit-integrations'), is_pro: true }, + { + name: 'allocate_overpayment', + label: __('Allocate Overpayment to Invoice', 'bit-integrations'), + is_pro: true + }, + { + name: 'delete_overpayment_allocation', + label: __('Remove Overpayment Allocation', 'bit-integrations'), + is_pro: true + }, + { + name: 'allocate_prepayment', + label: __('Allocate Prepayment to Invoice', 'bit-integrations'), + is_pro: true + }, + { + name: 'delete_prepayment_allocation', + label: __('Remove Prepayment Allocation', 'bit-integrations'), + is_pro: true + }, + { name: 'create_spend_money', label: __('Create Spend Money', 'bit-integrations'), is_pro: true }, + { name: 'create_receive_money', label: __('Create Receive Money', 'bit-integrations'), is_pro: true }, + { + name: 'update_bank_transaction', + label: __('Update Bank Transaction', 'bit-integrations'), + is_pro: true + }, + { + name: 'delete_bank_transaction', + label: __('Delete Bank Transaction', 'bit-integrations'), + is_pro: true + }, + { name: 'create_bank_transfer', label: __('Create Bank Transfer', 'bit-integrations'), is_pro: true }, + { name: 'delete_bank_transfer', label: __('Delete Bank Transfer', 'bit-integrations'), is_pro: true }, + { + name: 'create_manual_journal', + label: __('Create Manual Journal', 'bit-integrations'), + is_pro: true + }, + { + name: 'update_manual_journal', + label: __('Update Manual Journal', 'bit-integrations'), + is_pro: true + }, + { + name: 'update_manual_journal_status', + label: __('Update Manual Journal Status', 'bit-integrations'), + is_pro: true + }, + { name: 'create_account', label: __('Create Account', 'bit-integrations'), is_pro: true }, + { name: 'update_account', label: __('Update Account', 'bit-integrations'), is_pro: true }, + { name: 'archive_account', label: __('Archive Account', 'bit-integrations'), is_pro: true }, + { name: 'delete_account', label: __('Delete Account', 'bit-integrations'), is_pro: true }, + { + name: 'create_tracking_category', + label: __('Create Tracking Category', 'bit-integrations'), + is_pro: true + }, + { + name: 'update_tracking_category', + label: __('Update Tracking Category', 'bit-integrations'), + is_pro: true + }, + { + name: 'delete_tracking_category', + label: __('Delete Tracking Category', 'bit-integrations'), + is_pro: true + }, + { + name: 'create_tracking_option', + label: __('Create Tracking Option', 'bit-integrations'), + is_pro: true + }, + { + name: 'update_tracking_option', + label: __('Update Tracking Option', 'bit-integrations'), + is_pro: true + }, + { + name: 'delete_tracking_option', + label: __('Delete Tracking Option', 'bit-integrations'), + is_pro: true + }, + { name: 'create_tax_rate', label: __('Create Tax Rate', 'bit-integrations'), is_pro: true }, + { name: 'update_tax_rate', label: __('Update Tax Rate', 'bit-integrations'), is_pro: true }, + { name: 'add_currency', label: __('Add Currency', 'bit-integrations'), is_pro: true }, + { name: 'add_note', label: __('Add Note to Record', 'bit-integrations'), is_pro: true }, + { name: 'upload_attachment', label: __('Upload Attachment', 'bit-integrations'), is_pro: true }, + { name: 'download_pdf', label: __('Download PDF to Media Library', 'bit-integrations'), is_pro: true } +] + +const field = (key, label, required = false) => ({ key, label, required }) + +const idField = (key, label) => field(key, label, true) + +const select = (key, label, source, required = false) => ({ key, label, required, source }) + +const enumSelect = (key, label, selectOptions, required = false) => ({ + key, + label, + options: selectOptions, + required +}) + +const yesNoOptions = [ + { label: __('Yes', 'bit-integrations'), value: 'true' }, + { label: __('No', 'bit-integrations'), value: 'false' } +] + +const lineAmountTypeOptions = [ + { label: __('Tax Exclusive', 'bit-integrations'), value: 'Exclusive' }, + { label: __('Tax Inclusive', 'bit-integrations'), value: 'Inclusive' }, + { label: __('No Tax', 'bit-integrations'), value: 'NoTax' } +] + +const contactSelect = (required = true) => + select('contact_id', __('Contact', 'bit-integrations'), 'contacts', required) + +const bankAccountSelect = (key, label, required = true) => select(key, label, 'bankAccounts', required) + +const currencySelect = select('currency_code', __('Currency', 'bit-integrations'), 'currencies') + +const brandingThemeSelect = select( + 'branding_theme_id', + __('Branding Theme', 'bit-integrations'), + 'brandingThemes' +) + +const lineAmountTypeSelect = enumSelect( + 'line_amount_types', + __('Amounts Are', 'bit-integrations'), + lineAmountTypeOptions +) + +const lineSelects = [ + select('line_item_code', __('Line Item', 'bit-integrations'), 'items'), + select('line_account_code', __('Line Account', 'bit-integrations'), 'accountCodes'), + select('line_tax_type', __('Line Tax Rate', 'bit-integrations'), 'taxRates') +] + +const lineFields = [ + field('line_description', __('Line Description', 'bit-integrations')), + field('line_quantity', __('Line Quantity', 'bit-integrations')), + field('line_unit_amount', __('Line Unit Price', 'bit-integrations')), + field('line_discount_rate', __('Line Discount (%)', 'bit-integrations')), + field('line_amount', __('Line Amount', 'bit-integrations')), + field('line_tracking_name', __('Line Tracking Category Name', 'bit-integrations')), + field('line_tracking_option', __('Line Tracking Option Name', 'bit-integrations')) +] + +const lineNote = __( + 'Select a Line Item or map a Line Description. Leave the account and the tax rate empty to use the item details.', + 'bit-integrations' +) + +const updateNote = __( + 'Only the mapped fields change. Xero keeps the current value of every other field.', + 'bit-integrations' +) + +const invoiceIdField = idField('invoice_id', __('Invoice or Bill ID', 'bit-integrations')) +const creditNoteIdField = idField('credit_note_id', __('Credit Note ID', 'bit-integrations')) +const quoteIdField = idField('quote_id', __('Quote ID', 'bit-integrations')) +const purchaseOrderIdField = idField('purchase_order_id', __('Purchase Order ID', 'bit-integrations')) +const repeatingInvoiceIdField = idField( + 'repeating_invoice_id', + __('Repeating Invoice ID', 'bit-integrations') +) + +const documentDateField = field('date', __('Date (YYYY-MM-DD)', 'bit-integrations')) +const documentReferenceField = field('reference', __('Reference', 'bit-integrations')) +const currencyRateField = field('currency_rate', __('Currency Rate', 'bit-integrations')) + +const invoiceStatusOptions = [ + { label: __('Draft', 'bit-integrations'), value: 'DRAFT' }, + { label: __('Submitted for Approval', 'bit-integrations'), value: 'SUBMITTED' }, + { label: __('Approved', 'bit-integrations'), value: 'AUTHORISED' } +] + +const invoiceStatusSelect = enumSelect('status', __('Status', 'bit-integrations'), invoiceStatusOptions) + +const sentToContactSelect = enumSelect( + 'sent_to_contact', + __('Mark as Sent', 'bit-integrations'), + yesNoOptions +) + +const documentSelects = contactRequired => [ + contactSelect(contactRequired), + ...lineSelects, + lineAmountTypeSelect, + currencySelect, + brandingThemeSelect +] + +const invoiceFields = [ + documentDateField, + field('due_date', __('Due Date (YYYY-MM-DD)', 'bit-integrations')), + field('invoice_number', __('Invoice Number', 'bit-integrations')), + documentReferenceField, + currencyRateField, + field('expected_payment_date', __('Expected Payment Date (YYYY-MM-DD)', 'bit-integrations')), + field('url', __('Source URL', 'bit-integrations')), + ...lineFields +] + +const billFields = [ + documentDateField, + field('due_date', __('Due Date (YYYY-MM-DD)', 'bit-integrations')), + field('invoice_number', __('Bill Reference', 'bit-integrations')), + currencyRateField, + field('planned_payment_date', __('Planned Payment Date (YYYY-MM-DD)', 'bit-integrations')), + field('url', __('Source URL', 'bit-integrations')), + ...lineFields +] + +const updateInvoiceFields = [ + invoiceIdField, + documentDateField, + field('due_date', __('Due Date (YYYY-MM-DD)', 'bit-integrations')), + field('invoice_number', __('Invoice Number or Bill Reference', 'bit-integrations')), + documentReferenceField, + currencyRateField, + field('expected_payment_date', __('Expected Payment Date (YYYY-MM-DD)', 'bit-integrations')), + field('planned_payment_date', __('Planned Payment Date (YYYY-MM-DD)', 'bit-integrations')), + field('url', __('Source URL', 'bit-integrations')), + ...lineFields +] + +const creditNoteTypeSelect = enumSelect( + 'credit_note_type', + __('Credit Note Type', 'bit-integrations'), + [ + { label: __('Customer Credit Note', 'bit-integrations'), value: 'ACCRECCREDIT' }, + { label: __('Supplier Credit Note', 'bit-integrations'), value: 'ACCPAYCREDIT' } + ], + true +) + +const creditNoteFields = [ + documentDateField, + field('credit_note_number', __('Credit Note Number', 'bit-integrations')), + documentReferenceField, + currencyRateField, + ...lineFields +] + +const quoteStatusSelect = enumSelect('status', __('Status', 'bit-integrations'), [ + { label: __('Draft', 'bit-integrations'), value: 'DRAFT' }, + { label: __('Sent', 'bit-integrations'), value: 'SENT' } +]) + +const quoteStatusChangeSelect = enumSelect( + 'status', + __('Status', 'bit-integrations'), + [ + { label: __('Draft', 'bit-integrations'), value: 'DRAFT' }, + { label: __('Sent', 'bit-integrations'), value: 'SENT' }, + { label: __('Accepted', 'bit-integrations'), value: 'ACCEPTED' }, + { label: __('Declined', 'bit-integrations'), value: 'DECLINED' }, + { label: __('Invoiced', 'bit-integrations'), value: 'INVOICED' }, + { label: __('Deleted', 'bit-integrations'), value: 'DELETED' } + ], + true +) + +const quoteFields = [ + field('date', __('Quote Date (YYYY-MM-DD)', 'bit-integrations')), + field('expiry_date', __('Expiry Date (YYYY-MM-DD)', 'bit-integrations')), + field('quote_number', __('Quote Number', 'bit-integrations')), + documentReferenceField, + field('title', __('Title', 'bit-integrations')), + field('summary', __('Summary', 'bit-integrations')), + field('terms', __('Terms', 'bit-integrations')), + ...lineFields +] + +const purchaseOrderStatusSelect = enumSelect( + 'status', + __('Status', 'bit-integrations'), + invoiceStatusOptions +) + +const purchaseOrderStatusChangeSelect = enumSelect( + 'status', + __('Status', 'bit-integrations'), + [ + ...invoiceStatusOptions, + { label: __('Billed', 'bit-integrations'), value: 'BILLED' }, + { label: __('Deleted', 'bit-integrations'), value: 'DELETED' } + ], + true +) + +const purchaseOrderFields = [ + field('date', __('Order Date (YYYY-MM-DD)', 'bit-integrations')), + field('delivery_date', __('Delivery Date (YYYY-MM-DD)', 'bit-integrations')), + field('purchase_order_number', __('Purchase Order Number', 'bit-integrations')), + documentReferenceField, + field('delivery_address', __('Delivery Address', 'bit-integrations')), + field('attention_to', __('Attention To', 'bit-integrations')), + field('telephone', __('Telephone', 'bit-integrations')), + field('delivery_instructions', __('Delivery Instructions', 'bit-integrations')), + ...lineFields +] + +const repeatingInvoiceFields = [ + field('period', __('Repeat Every (Number)', 'bit-integrations'), true), + field('due_date', __('Due Date Number', 'bit-integrations'), true), + field('start_date', __('Start Date (YYYY-MM-DD)', 'bit-integrations'), true), + field('end_date', __('End Date (YYYY-MM-DD)', 'bit-integrations')), + documentReferenceField, + ...lineFields +] + +const repeatingInvoiceSelects = [ + enumSelect( + 'invoice_type', + __('Type', 'bit-integrations'), + [ + { label: __('Invoice', 'bit-integrations'), value: 'ACCREC' }, + { label: __('Bill', 'bit-integrations'), value: 'ACCPAY' } + ], + true + ), + contactSelect(), + enumSelect( + 'unit', + __('Repeat Unit', 'bit-integrations'), + [ + { label: __('Weeks', 'bit-integrations'), value: 'WEEKLY' }, + { label: __('Months', 'bit-integrations'), value: 'MONTHLY' } + ], + true + ), + enumSelect( + 'due_date_type', + __('Due Date Type', 'bit-integrations'), + [ + { label: __('Days After the Invoice Date', 'bit-integrations'), value: 'DAYSAFTERINVOICEDATE' }, + { + label: __('Days After the End of the Invoice Month', 'bit-integrations'), + value: 'DAYSAFTERINVOICEMONTH' + }, + { label: __('Days After the Bill Date', 'bit-integrations'), value: 'DAYSAFTERBILLDATE' }, + { + label: __('Days After the End of the Bill Month', 'bit-integrations'), + value: 'DAYSAFTERBILLMONTH' + }, + { label: __('Day of the Current Month', 'bit-integrations'), value: 'OFCURRENTMONTH' }, + { label: __('Day of the Following Month', 'bit-integrations'), value: 'OFFOLLOWINGMONTH' } + ], + true + ), + ...lineSelects, + enumSelect('status', __('Status', 'bit-integrations'), [ + { label: __('Draft', 'bit-integrations'), value: 'DRAFT' }, + { label: __('Approved', 'bit-integrations'), value: 'AUTHORISED' } + ]), + lineAmountTypeSelect, + currencySelect, + brandingThemeSelect, + enumSelect('approved_for_sending', __('Approve for Sending', 'bit-integrations'), yesNoOptions), + enumSelect('send_copy', __('Send Me a Copy', 'bit-integrations'), yesNoOptions), + enumSelect('mark_as_sent', __('Mark as Sent', 'bit-integrations'), yesNoOptions), + enumSelect('include_pdf', __('Attach PDF', 'bit-integrations'), yesNoOptions) +] + +const lineUpdateNote = `${updateNote} ${__( + 'Select a Line Item or map a Line Description to replace all lines with one new line. Leave both empty to keep the current lines.', + 'bit-integrations' +)}` + +const invoiceIdNote = __( + 'Map the InvoiceID of the invoice or the bill, for example from a previous Xero action.', + 'bit-integrations' +) + +const contactIdField = idField('contact_id', __('Contact ID', 'bit-integrations')) + +const contactGroupIdField = idField('contact_group_id', __('Contact Group ID', 'bit-integrations')) + +const contactAddressFields = (prefix, title) => + [ + ['address_line1', __('Address Line 1', 'bit-integrations')], + ['address_line2', __('Address Line 2', 'bit-integrations')], + ['city', __('City', 'bit-integrations')], + ['region', __('Region', 'bit-integrations')], + ['postal_code', __('Postal Code', 'bit-integrations')], + ['country', __('Country', 'bit-integrations')] + ].map(([key, label]) => field(`${prefix}${key}`, `${title} ${label}`)) + +const contactDetailFields = (nameRequired, emailRequired) => [ + field('name', __('Name', 'bit-integrations'), nameRequired), + field('email', __('Email', 'bit-integrations'), emailRequired), + field('first_name', __('First Name', 'bit-integrations')), + field('last_name', __('Last Name', 'bit-integrations')), + field('phone', __('Phone', 'bit-integrations')), + field('mobile', __('Mobile', 'bit-integrations')), + field('website', __('Website', 'bit-integrations')), + field('contact_number', __('Contact Number', 'bit-integrations')), + field('account_number', __('Account Number', 'bit-integrations')), + field('company_number', __('Company Registration Number', 'bit-integrations')), + field('tax_number', __('Tax Number', 'bit-integrations')), + field('bank_account_details', __('Bank Account Details', 'bit-integrations')), + ...contactAddressFields('postal_', __('Postal', 'bit-integrations')), + ...contactAddressFields('street_', __('Street', 'bit-integrations')) +] + +const contactSelects = [ + select('currency_code', __('Default Currency', 'bit-integrations'), 'currencies'), + select('sales_tax_type', __('Default Sales Tax Rate', 'bit-integrations'), 'taxRates'), + select('purchases_tax_type', __('Default Purchases Tax Rate', 'bit-integrations'), 'taxRates') +] + +const itemIdField = idField('item_id', __('Item ID', 'bit-integrations')) + +const itemDetailFields = codeRequired => [ + field('code', __('Item Code', 'bit-integrations'), codeRequired), + field('name', __('Item Name', 'bit-integrations')), + field('description', __('Sales Description', 'bit-integrations')), + field('purchase_description', __('Purchase Description', 'bit-integrations')), + field('sales_unit_price', __('Sales Unit Price', 'bit-integrations')), + field('purchase_unit_price', __('Purchase Unit Price', 'bit-integrations')) +] + +const itemSelects = [ + enumSelect('is_sold', __('Sell This Item', 'bit-integrations'), yesNoOptions), + select('sales_account_code', __('Sales Account', 'bit-integrations'), 'accountCodes'), + select('sales_tax_type', __('Sales Tax Rate', 'bit-integrations'), 'taxRates'), + enumSelect('is_purchased', __('Purchase This Item', 'bit-integrations'), yesNoOptions), + select('purchase_account_code', __('Purchase Account', 'bit-integrations'), 'accountCodes'), + select('purchase_tax_type', __('Purchase Tax Rate', 'bit-integrations'), 'taxRates'), + select('cogs_account_code', __('Cost of Goods Sold Account', 'bit-integrations'), 'accountCodes'), + select( + 'inventory_asset_account_code', + __('Inventory Asset Account', 'bit-integrations'), + 'accountCodes' + ) +] + +const linkedTransactionIdField = idField( + 'linked_transaction_id', + __('Linked Transaction ID', 'bit-integrations') +) + +const linkedTransactionFields = sourceRequired => [ + field('source_transaction_id', __('Source Bill ID', 'bit-integrations'), sourceRequired), + field('source_line_item_id', __('Source Line Item ID', 'bit-integrations'), sourceRequired), + field('target_transaction_id', __('Target Invoice ID', 'bit-integrations')), + field('target_line_item_id', __('Target Line Item ID', 'bit-integrations')) +] + +const linkedTransactionNote = __( + 'A Target Invoice ID needs a Target Line Item ID. Get the line item IDs from the bill and the invoice in Xero.', + 'bit-integrations' +) + +const paymentTargetOptions = [ + { label: __('Invoice or Bill', 'bit-integrations'), value: 'invoice' }, + { label: __('Credit Note', 'bit-integrations'), value: 'credit_note' }, + { label: __('Prepayment', 'bit-integrations'), value: 'prepayment' }, + { label: __('Overpayment', 'bit-integrations'), value: 'overpayment' } +] + +const paymentDateField = field('date', __('Date (YYYY-MM-DD)', 'bit-integrations')) + +const paymentAmountField = field('amount', __('Amount', 'bit-integrations'), true) + +const paymentAllocationFields = (recordKey, recordLabel) => [ + idField(recordKey, recordLabel), + idField('invoice_id', __('Invoice or Bill ID', 'bit-integrations')), + paymentAmountField, + paymentDateField +] + +const paymentRemoveAllocationFields = (recordKey, recordLabel) => [ + idField(recordKey, recordLabel), + idField('allocation_id', __('Allocation ID', 'bit-integrations')) +] + +const paymentAllocationNote = __( + 'Leave the Date empty to use today. The invoice or bill must have the same contact.', + 'bit-integrations' +) + +const paymentRemoveAllocationNote = __( + 'Xero shows the Allocation ID in the Allocations list of the record.', + 'bit-integrations' +) + +const spendMoneyTypeOptions = [ + { label: __('Spend Money', 'bit-integrations'), value: 'SPEND' }, + { label: __('Prepayment', 'bit-integrations'), value: 'SPEND-PREPAYMENT' }, + { label: __('Overpayment', 'bit-integrations'), value: 'SPEND-OVERPAYMENT' } +] + +const receiveMoneyTypeOptions = [ + { label: __('Receive Money', 'bit-integrations'), value: 'RECEIVE' }, + { label: __('Prepayment', 'bit-integrations'), value: 'RECEIVE-PREPAYMENT' }, + { label: __('Overpayment', 'bit-integrations'), value: 'RECEIVE-OVERPAYMENT' } +] + +const manualJournalCreateStatusOptions = [ + { label: __('Draft', 'bit-integrations'), value: 'DRAFT' }, + { label: __('Posted', 'bit-integrations'), value: 'POSTED' } +] + +const manualJournalStatusOptions = [ + { label: __('Draft', 'bit-integrations'), value: 'DRAFT' }, + { label: __('Posted', 'bit-integrations'), value: 'POSTED' }, + { label: __('Deleted', 'bit-integrations'), value: 'DELETED' }, + { label: __('Voided', 'bit-integrations'), value: 'VOIDED' } +] + +const accountTypeOptions = [ + { label: __('Bank', 'bit-integrations'), value: 'BANK' }, + { label: __('Current Asset', 'bit-integrations'), value: 'CURRENT' }, + { label: __('Current Liability', 'bit-integrations'), value: 'CURRLIAB' }, + { label: __('Depreciation', 'bit-integrations'), value: 'DEPRECIATN' }, + { label: __('Direct Costs', 'bit-integrations'), value: 'DIRECTCOSTS' }, + { label: __('Equity', 'bit-integrations'), value: 'EQUITY' }, + { label: __('Expense', 'bit-integrations'), value: 'EXPENSE' }, + { label: __('Fixed Asset', 'bit-integrations'), value: 'FIXED' }, + { label: __('Inventory', 'bit-integrations'), value: 'INVENTORY' }, + { label: __('Liability', 'bit-integrations'), value: 'LIABILITY' }, + { label: __('Non-current Asset', 'bit-integrations'), value: 'NONCURRENT' }, + { label: __('Other Income', 'bit-integrations'), value: 'OTHERINCOME' }, + { label: __('Overhead', 'bit-integrations'), value: 'OVERHEADS' }, + { label: __('Prepayment', 'bit-integrations'), value: 'PREPAYMENT' }, + { label: __('Revenue', 'bit-integrations'), value: 'REVENUE' }, + { label: __('Sales', 'bit-integrations'), value: 'SALES' }, + { label: __('Non-current Liability', 'bit-integrations'), value: 'TERMLIAB' } +] + +const bankAccountTypeOptions = [ + { label: __('Bank', 'bit-integrations'), value: 'BANK' }, + { label: __('Credit Card', 'bit-integrations'), value: 'CREDITCARD' }, + { label: __('PayPal', 'bit-integrations'), value: 'PAYPAL' } +] + +const trackingStatusOptions = [ + { label: __('Active', 'bit-integrations'), value: 'ACTIVE' }, + { label: __('Archived', 'bit-integrations'), value: 'ARCHIVED' } +] + +const taxRateStatusOptions = [ + { label: __('Active', 'bit-integrations'), value: 'ACTIVE' }, + { label: __('Archived', 'bit-integrations'), value: 'ARCHIVED' }, + { label: __('Deleted', 'bit-integrations'), value: 'DELETED' } +] + +const reportTaxTypeOptions = [ + 'AVALARA', + 'BASEXCLUDED', + 'CAPITALSALESOUTPUT', + 'CAPITALEXPENSESINPUT', + 'ECOUTPUT', + 'ECOUTPUTSERVICES', + 'ECINPUT', + 'ECACQUISITIONS', + 'EXEMPTEXPENSES', + 'EXEMPTINPUT', + 'EXEMPTOUTPUT', + 'GSTONIMPORTS', + 'INPUT', + 'INPUTTAXED', + 'MOSSSALES', + 'NONE', + 'NONEOUTPUT', + 'OUTPUT', + 'PURCHASESINPUT', + 'SALESOUTPUT', + 'EXEMPTCAPITAL', + 'EXEMPTEXPORT', + 'CAPITALEXINPUT', + 'GSTONCAPIMPORTS', + 'GSTONCAPITALIMPORTS', + 'REVERSECHARGES', + 'PAYMENTS', + 'INVOICE', + 'CASH', + 'ACCRUAL', + 'FLATRATECASH', + 'FLATRATEACCRUAL', + 'ACCRUALS', + 'TXCA', + 'SRCAS', + 'DSOUTPUT', + 'BLINPUT2', + 'EPINPUT', + 'IMINPUT2', + 'MEINPUT', + 'IGDSINPUT2', + 'ESN33OUTPUT', + 'OPINPUT', + 'OSOUTPUT', + 'TXN33INPUT', + 'TXESSINPUT', + 'TXREINPUT', + 'TXPETINPUT', + 'NRINPUT', + 'ES33OUTPUT', + 'ZERORATEDINPUT', + 'ZERORATEDOUTPUT', + 'DRCHARGESUPPLY', + 'DRCHARGE', + 'CAPINPUT', + 'CAPIMPORTS', + 'IMINPUT', + 'INPUT2', + 'CIUINPUT', + 'SRINPUT', + 'OUTPUT2', + 'SROUTPUT', + 'CAPOUTPUT', + 'SROUTPUT2', + 'CIUOUTPUT', + 'ZROUTPUT', + 'ZREXPORT', + 'ACC28PLUS', + 'ACCUPTO28', + 'OTHEROUTPUT', + 'SHOUTPUT', + 'ZRINPUT', + 'BADDEBT', + 'OTHERINPUT', + 'BADDEBTRELIEF', + 'IGDSINPUT3', + 'SROVR', + 'TOURISTREFUND', + 'TXRCN33', + 'TXRCRE', + 'TXRCESS', + 'TXRCTS', + 'CAPEXINPUT', + 'UNDEFINED', + 'CAPEXOUTPUT', + 'ZEROEXPOUTPUT', + 'GOODSIMPORT', + 'NONEINPUT', + 'NOTREPORTED', + 'SROVRRS', + 'SROVRLVG', + 'SRLVG', + 'IM', + 'IMESS', + 'IMN33', + 'IMRE', + 'BADDEBTRECOVERY', + 'USSALESTAX', + 'BLINPUT3' +].map(code => ({ + label: code, + value: code +})) + +const historyRecordTypeOptions = [ + { label: __('Bank Transaction', 'bit-integrations'), value: 'BankTransactions' }, + { label: __('Bank Transfer', 'bit-integrations'), value: 'BankTransfers' }, + { label: __('Batch Payment', 'bit-integrations'), value: 'BatchPayments' }, + { label: __('Contact', 'bit-integrations'), value: 'Contacts' }, + { label: __('Credit Note', 'bit-integrations'), value: 'CreditNotes' }, + { label: __('Invoice or Bill', 'bit-integrations'), value: 'Invoices' }, + { label: __('Item', 'bit-integrations'), value: 'Items' }, + { label: __('Manual Journal', 'bit-integrations'), value: 'ManualJournals' }, + { label: __('Overpayment', 'bit-integrations'), value: 'Overpayments' }, + { label: __('Payment', 'bit-integrations'), value: 'Payments' }, + { label: __('Prepayment', 'bit-integrations'), value: 'Prepayments' }, + { label: __('Purchase Order', 'bit-integrations'), value: 'PurchaseOrders' }, + { label: __('Quote', 'bit-integrations'), value: 'Quotes' }, + { label: __('Repeating Invoice', 'bit-integrations'), value: 'RepeatingInvoices' } +] + +const attachmentRecordTypeOptions = [ + { label: __('Account', 'bit-integrations'), value: 'Accounts' }, + { label: __('Bank Transaction', 'bit-integrations'), value: 'BankTransactions' }, + { label: __('Bank Transfer', 'bit-integrations'), value: 'BankTransfers' }, + { label: __('Contact', 'bit-integrations'), value: 'Contacts' }, + { label: __('Credit Note', 'bit-integrations'), value: 'CreditNotes' }, + { label: __('Invoice or Bill', 'bit-integrations'), value: 'Invoices' }, + { label: __('Manual Journal', 'bit-integrations'), value: 'ManualJournals' }, + { label: __('Purchase Order', 'bit-integrations'), value: 'PurchaseOrders' }, + { label: __('Quote', 'bit-integrations'), value: 'Quotes' }, + { label: __('Repeating Invoice', 'bit-integrations'), value: 'RepeatingInvoices' } +] + +const pdfRecordTypeOptions = [ + { label: __('Invoice or Bill', 'bit-integrations'), value: 'Invoices' }, + { label: __('Credit Note', 'bit-integrations'), value: 'CreditNotes' }, + { label: __('Quote', 'bit-integrations'), value: 'Quotes' }, + { label: __('Purchase Order', 'bit-integrations'), value: 'PurchaseOrders' } +] + +const bankTransactionIdField = idField( + 'bank_transaction_id', + __('Bank Transaction ID', 'bit-integrations') +) +const bankTransferIdField = idField('bank_transfer_id', __('Bank Transfer ID', 'bit-integrations')) +const manualJournalIdField = idField('manual_journal_id', __('Manual Journal ID', 'bit-integrations')) +const accountIdField = idField('account_id', __('Account ID', 'bit-integrations')) +const trackingCategoryIdField = idField( + 'tracking_category_id', + __('Tracking Category ID', 'bit-integrations') +) +const trackingOptionIdField = idField('tracking_option_id', __('Tracking Option ID', 'bit-integrations')) +const recordIdField = idField('record_id', __('Record ID', 'bit-integrations')) +const yesNoSelect = (key, label) => enumSelect(key, label, yesNoOptions) + +const bankTransactionFields = [ + ...lineFields, + field('date', __('Date (YYYY-MM-DD)', 'bit-integrations')), + field('reference', __('Reference', 'bit-integrations')), + field('currency_rate', __('Currency Rate', 'bit-integrations')), + field('url', __('Source URL', 'bit-integrations')) +] + +const bankTransactionSelects = (typeOptions, required) => [ + ...(typeOptions + ? [enumSelect('transaction_type', __('Transaction Type', 'bit-integrations'), typeOptions)] + : []), + contactSelect(required), + bankAccountSelect('bank_account_id', __('Bank Account', 'bit-integrations'), required), + ...lineSelects, + lineAmountTypeSelect, + currencySelect, + yesNoSelect('is_reconciled', __('Mark as Reconciled', 'bit-integrations')) +] + +const manualJournalFields = required => [ + field('narration', __('Narration', 'bit-integrations'), required), + field('debit_amount', __('Debit Amount', 'bit-integrations'), required), + field('credit_amount', __('Credit Amount', 'bit-integrations')), + field('line_description', __('Line Description', 'bit-integrations')), + field('date', __('Date (YYYY-MM-DD)', 'bit-integrations')), + field('url', __('Source URL', 'bit-integrations')) +] + +const manualJournalSelects = required => [ + select('debit_account_code', __('Debit Account', 'bit-integrations'), 'accountCodes', required), + select('credit_account_code', __('Credit Account', 'bit-integrations'), 'accountCodes', required), + select('line_tax_type', __('Tax Rate', 'bit-integrations'), 'taxRates'), + enumSelect('status', __('Status', 'bit-integrations'), manualJournalCreateStatusOptions), + lineAmountTypeSelect, + yesNoSelect('show_on_cash_basis_reports', __('Show on Cash Basis Reports', 'bit-integrations')) +] + +const accountFields = [ + field('code', __('Account Code', 'bit-integrations')), + field('description', __('Description', 'bit-integrations')), + field('bank_account_number', __('Bank Account Number', 'bit-integrations')) +] + +const accountSelects = required => [ + enumSelect('account_type', __('Account Type', 'bit-integrations'), accountTypeOptions, required), + select('tax_type', __('Tax Rate', 'bit-integrations'), 'taxRates'), + enumSelect('bank_account_type', __('Bank Account Type', 'bit-integrations'), bankAccountTypeOptions), + currencySelect, + yesNoSelect('enable_payments', __('Enable Payments to This Account', 'bit-integrations')), + yesNoSelect('show_in_expense_claims', __('Show in Expense Claims', 'bit-integrations')), + yesNoSelect('add_to_watchlist', __('Show on the Dashboard Watchlist', 'bit-integrations')) +] + +const taxComponentSelects = [ + enumSelect('report_tax_type', __('Report Tax Type', 'bit-integrations'), reportTaxTypeOptions), + yesNoSelect('is_compound', __('Compound', 'bit-integrations')), + yesNoSelect('is_non_recoverable', __('Non-recoverable', 'bit-integrations')) +] + +const manualJournalNote = __( + 'Xero posts the debit amount to the debit account and the same amount as a credit to the credit account. Leave the credit amount empty to use the debit amount.', + 'bit-integrations' +) + +const updateLinesNote = __( + 'Map a line field to replace all lines with one new line. Leave the line fields empty to keep the current lines.', + 'bit-integrations' +) + +const idNote = __( + 'Map the ID from a previous step or a form field. Xero shows IDs in the record URL.', + 'bit-integrations' +) + +export const fieldsByAction = { + create_invoice: invoiceFields, + create_bill: billFields, + update_invoice: updateInvoiceFields, + approve_invoice: [invoiceIdField], + void_invoice: [invoiceIdField], + delete_invoice: [invoiceIdField], + email_invoice: [idField('invoice_id', __('Invoice ID', 'bit-integrations'))], + create_credit_note: creditNoteFields, + update_credit_note: [creditNoteIdField, ...creditNoteFields], + approve_credit_note: [creditNoteIdField], + void_credit_note: [creditNoteIdField], + delete_credit_note: [creditNoteIdField], + allocate_credit_note: [ + creditNoteIdField, + idField('invoice_id', __('Invoice ID', 'bit-integrations')), + field('amount', __('Amount', 'bit-integrations'), true), + field('date', __('Allocation Date (YYYY-MM-DD)', 'bit-integrations')) + ], + delete_credit_note_allocation: [ + creditNoteIdField, + idField('allocation_id', __('Allocation ID', 'bit-integrations')) + ], + create_quote: quoteFields, + update_quote: [quoteIdField, ...quoteFields], + update_quote_status: [quoteIdField], + create_purchase_order: purchaseOrderFields, + update_purchase_order: [purchaseOrderIdField, ...purchaseOrderFields], + update_purchase_order_status: [purchaseOrderIdField], + create_repeating_invoice: repeatingInvoiceFields, + delete_repeating_invoice: [repeatingInvoiceIdField], + create_contact: contactDetailFields(true, false), + update_contact: [contactIdField, ...contactDetailFields(false, false)], + create_or_update_contact: contactDetailFields(false, true), + archive_contact: [contactIdField], + create_contact_group: [field('name', __('Group Name', 'bit-integrations'), true)], + rename_contact_group: [ + contactGroupIdField, + field('name', __('New Group Name', 'bit-integrations'), true) + ], + delete_contact_group: [contactGroupIdField], + add_contacts_to_group: [ + contactGroupIdField, + field('contact_ids', __('Contact IDs (Comma Separated)', 'bit-integrations')) + ], + remove_contact_from_group: [contactGroupIdField, contactIdField], + remove_all_contacts_from_group: [contactGroupIdField], + create_item: itemDetailFields(true), + update_item: [itemIdField, ...itemDetailFields(false)], + delete_item: [itemIdField], + create_linked_transaction: linkedTransactionFields(true), + update_linked_transaction: [linkedTransactionIdField, ...linkedTransactionFields(false)], + delete_linked_transaction: [linkedTransactionIdField], + create_payment: [ + idField( + 'target_id', + __('Invoice, Bill, Credit Note, Prepayment or Overpayment ID', 'bit-integrations') + ), + paymentAmountField, + paymentDateField, + field('reference', __('Reference', 'bit-integrations')), + field('bank_amount', __('Bank Amount', 'bit-integrations')), + field('currency_rate', __('Currency Rate', 'bit-integrations')) + ], + delete_payment: [idField('payment_id', __('Payment ID', 'bit-integrations'))], + create_batch_payment: [ + idField('invoice_id', __('Invoice or Bill ID', 'bit-integrations')), + paymentAmountField, + paymentDateField, + field('reference', __('Reference', 'bit-integrations'), true), + field('payment_details', __('Payment Details', 'bit-integrations')), + field('particulars', __('Particulars', 'bit-integrations')), + field('code', __('Code', 'bit-integrations')), + field('details', __('Batch Details', 'bit-integrations')), + field('narrative', __('Narrative', 'bit-integrations')) + ], + delete_batch_payment: [idField('batch_payment_id', __('Batch Payment ID', 'bit-integrations'))], + allocate_overpayment: paymentAllocationFields( + 'overpayment_id', + __('Overpayment ID', 'bit-integrations') + ), + delete_overpayment_allocation: paymentRemoveAllocationFields( + 'overpayment_id', + __('Overpayment ID', 'bit-integrations') + ), + allocate_prepayment: paymentAllocationFields('prepayment_id', __('Prepayment ID', 'bit-integrations')), + delete_prepayment_allocation: paymentRemoveAllocationFields( + 'prepayment_id', + __('Prepayment ID', 'bit-integrations') + ), + create_spend_money: bankTransactionFields, + create_receive_money: bankTransactionFields, + update_bank_transaction: [bankTransactionIdField, ...bankTransactionFields], + delete_bank_transaction: [bankTransactionIdField], + create_bank_transfer: [ + field('amount', __('Amount', 'bit-integrations'), true), + field('date', __('Transfer Date (YYYY-MM-DD)', 'bit-integrations')), + field('reference', __('Reference', 'bit-integrations')) + ], + delete_bank_transfer: [bankTransferIdField], + create_manual_journal: manualJournalFields(true), + update_manual_journal: [manualJournalIdField, ...manualJournalFields(false)], + update_manual_journal_status: [manualJournalIdField], + create_account: [field('name', __('Account Name', 'bit-integrations'), true), ...accountFields], + update_account: [ + accountIdField, + field('name', __('Account Name', 'bit-integrations')), + ...accountFields + ], + archive_account: [accountIdField], + delete_account: [accountIdField], + create_tracking_category: [field('name', __('Name', 'bit-integrations'), true)], + update_tracking_category: [trackingCategoryIdField, field('name', __('New Name', 'bit-integrations'))], + delete_tracking_category: [trackingCategoryIdField], + create_tracking_option: [trackingCategoryIdField, field('name', __('Name', 'bit-integrations'), true)], + update_tracking_option: [ + trackingCategoryIdField, + trackingOptionIdField, + field('name', __('New Name', 'bit-integrations')) + ], + delete_tracking_option: [trackingCategoryIdField, trackingOptionIdField], + create_tax_rate: [ + field('name', __('Name', 'bit-integrations'), true), + field('component_name', __('Component Name', 'bit-integrations'), true), + field('component_rate', __('Component Rate (%)', 'bit-integrations'), true) + ], + update_tax_rate: [ + idField('tax_type', __('Tax Type', 'bit-integrations')), + field('name', __('New Name', 'bit-integrations')), + field('component_name', __('Component Name', 'bit-integrations')), + field('component_rate', __('Component Rate (%)', 'bit-integrations')) + ], + add_currency: [field('currency_code', __('Currency Code', 'bit-integrations'), true)], + add_note: [recordIdField, field('note', __('Note', 'bit-integrations'), true)], + upload_attachment: [ + recordIdField, + field('file', __('File', 'bit-integrations'), true), + field('file_name', __('File Name', 'bit-integrations')) + ], + download_pdf: [recordIdField, field('file_name', __('File Name', 'bit-integrations'))] +} + +export const selectsByAction = { + create_invoice: [...documentSelects(true), invoiceStatusSelect, sentToContactSelect], + create_bill: [...documentSelects(true), invoiceStatusSelect], + update_invoice: [...documentSelects(false), invoiceStatusSelect, sentToContactSelect], + approve_invoice: [], + void_invoice: [], + delete_invoice: [], + email_invoice: [], + create_credit_note: [ + creditNoteTypeSelect, + ...documentSelects(true), + invoiceStatusSelect, + sentToContactSelect + ], + update_credit_note: [...documentSelects(false), invoiceStatusSelect, sentToContactSelect], + approve_credit_note: [], + void_credit_note: [], + delete_credit_note: [], + allocate_credit_note: [], + delete_credit_note_allocation: [], + create_quote: [...documentSelects(true), quoteStatusSelect], + update_quote: [...documentSelects(false), quoteStatusSelect], + update_quote_status: [quoteStatusChangeSelect], + create_purchase_order: [...documentSelects(true), purchaseOrderStatusSelect], + update_purchase_order: [...documentSelects(false), purchaseOrderStatusSelect], + update_purchase_order_status: [purchaseOrderStatusChangeSelect], + create_repeating_invoice: repeatingInvoiceSelects, + delete_repeating_invoice: [], + create_contact: contactSelects, + update_contact: contactSelects, + create_or_update_contact: contactSelects, + archive_contact: [], + create_contact_group: [], + rename_contact_group: [], + delete_contact_group: [], + add_contacts_to_group: [contactSelect(false)], + remove_contact_from_group: [], + remove_all_contacts_from_group: [], + create_item: itemSelects, + update_item: itemSelects, + delete_item: [], + create_linked_transaction: [contactSelect(false)], + update_linked_transaction: [contactSelect(false)], + delete_linked_transaction: [], + create_payment: [ + enumSelect('target_type', __('Pay For', 'bit-integrations'), paymentTargetOptions, true), + bankAccountSelect('bank_account_id', __('Bank Account', 'bit-integrations')), + enumSelect('is_reconciled', __('Mark as Reconciled', 'bit-integrations'), yesNoOptions) + ], + delete_payment: [], + create_batch_payment: [bankAccountSelect('bank_account_id', __('Bank Account', 'bit-integrations'))], + delete_batch_payment: [], + allocate_overpayment: [], + delete_overpayment_allocation: [], + allocate_prepayment: [], + delete_prepayment_allocation: [], + create_spend_money: bankTransactionSelects(spendMoneyTypeOptions, true), + create_receive_money: bankTransactionSelects(receiveMoneyTypeOptions, true), + update_bank_transaction: bankTransactionSelects(null, false), + delete_bank_transaction: [], + create_bank_transfer: [ + bankAccountSelect('from_bank_account_id', __('From Bank Account', 'bit-integrations')), + bankAccountSelect('to_bank_account_id', __('To Bank Account', 'bit-integrations')), + yesNoSelect('from_is_reconciled', __('Mark the Sending Side as Reconciled', 'bit-integrations')), + yesNoSelect('to_is_reconciled', __('Mark the Receiving Side as Reconciled', 'bit-integrations')) + ], + delete_bank_transfer: [], + create_manual_journal: manualJournalSelects(true), + update_manual_journal: manualJournalSelects(false), + update_manual_journal_status: [ + enumSelect('status', __('Status', 'bit-integrations'), manualJournalStatusOptions, true) + ], + create_account: accountSelects(true), + update_account: accountSelects(false), + archive_account: [], + delete_account: [], + create_tracking_category: [], + update_tracking_category: [ + enumSelect('status', __('Status', 'bit-integrations'), trackingStatusOptions) + ], + delete_tracking_category: [], + create_tracking_option: [], + update_tracking_option: [ + enumSelect('status', __('Status', 'bit-integrations'), trackingStatusOptions) + ], + delete_tracking_option: [], + create_tax_rate: taxComponentSelects, + update_tax_rate: [ + ...taxComponentSelects, + enumSelect('status', __('Status', 'bit-integrations'), taxRateStatusOptions) + ], + add_currency: [], + add_note: [ + enumSelect('record_type', __('Record Type', 'bit-integrations'), historyRecordTypeOptions, true) + ], + upload_attachment: [ + enumSelect('record_type', __('Record Type', 'bit-integrations'), attachmentRecordTypeOptions, true), + yesNoSelect('include_online', __('Show on Online Invoice or Credit Note', 'bit-integrations')) + ], + download_pdf: [ + enumSelect('record_type', __('Record Type', 'bit-integrations'), pdfRecordTypeOptions, true) + ] +} + +export const selectKeys = [ + ...new Set(Object.values(selectsByAction).flatMap(selects => selects.map(({ key }) => key))) +] + +export const notesByAction = { + create_invoice: `${lineNote} ${__( + 'An approved invoice needs a due date when the contact has no payment terms.', + 'bit-integrations' + )}`, + create_bill: lineNote, + update_invoice: lineUpdateNote, + approve_invoice: invoiceIdNote, + void_invoice: __( + 'Remove all payments and allocations before you void an invoice or a bill.', + 'bit-integrations' + ), + delete_invoice: __('Xero deletes only draft and submitted invoices and bills.', 'bit-integrations'), + email_invoice: __( + 'The invoice must be approved, and its contact needs an email address.', + 'bit-integrations' + ), + create_credit_note: lineNote, + update_credit_note: lineUpdateNote, + void_credit_note: __('Remove all allocations before you void a credit note.', 'bit-integrations'), + delete_credit_note: __('Xero deletes only draft and submitted credit notes.', 'bit-integrations'), + allocate_credit_note: __( + 'The credit note must be approved. Leave the date empty to use today.', + 'bit-integrations' + ), + delete_credit_note_allocation: __( + 'Get the AllocationID from the Allocations list of the credit note.', + 'bit-integrations' + ), + create_quote: `${lineNote} ${__('Leave the quote date empty to use today.', 'bit-integrations')}`, + update_quote: lineUpdateNote, + create_purchase_order: lineNote, + update_purchase_order: lineUpdateNote, + create_repeating_invoice: `${lineNote} ${__( + 'Xero sends the invoice only when the status is Approved, the type is Invoice and Approve for Sending is Yes.', + 'bit-integrations' + )}`, + update_contact: updateNote, + create_or_update_contact: __( + 'Xero updates the contact that has this email. If no contact has it, Xero creates one. Map a name to create a contact.', + 'bit-integrations' + ), + add_contacts_to_group: __( + 'Select a contact, or map contact IDs. Separate the IDs with commas.', + 'bit-integrations' + ), + create_item: __('The item code must be unique in Xero.', 'bit-integrations'), + update_item: updateNote, + create_linked_transaction: linkedTransactionNote, + update_linked_transaction: `${updateNote} ${linkedTransactionNote}`, + create_payment: __( + 'Select what the payment is for and map the ID of that record. Leave the Date empty to use today.', + 'bit-integrations' + ), + create_batch_payment: __( + 'The batch pays one invoice or bill. Leave the Date empty to use today.', + 'bit-integrations' + ), + allocate_overpayment: paymentAllocationNote, + delete_overpayment_allocation: paymentRemoveAllocationNote, + allocate_prepayment: paymentAllocationNote, + delete_prepayment_allocation: paymentRemoveAllocationNote, + create_spend_money: lineNote, + create_receive_money: lineNote, + update_bank_transaction: `${updateNote} ${updateLinesNote}`, + delete_bank_transaction: idNote, + delete_bank_transfer: idNote, + create_manual_journal: manualJournalNote, + update_manual_journal: `${updateNote} ${__( + 'Map the debit fields to replace all lines with one debit line and one credit line.', + 'bit-integrations' + )}`, + update_manual_journal_status: idNote, + update_account: updateNote, + delete_account: __( + 'Xero does not delete bank accounts or accounts with transactions. Use Archive Account for them.', + 'bit-integrations' + ), + update_tracking_category: __( + 'Map a new name or select a status. Xero keeps the other values.', + 'bit-integrations' + ), + update_tracking_option: __( + 'Map a new name or select a status. Xero keeps the other values.', + 'bit-integrations' + ), + update_tax_rate: __( + 'Map the Tax Type code of the tax rate, for example TAX001. Map a component to replace all components.', + 'bit-integrations' + ), + add_currency: __( + 'Map the three-letter ISO code, for example USD. The organisation must have multicurrency enabled.', + 'bit-integrations' + ), + add_note: __( + 'Select the record type. Then map the ID of the record that gets the note.', + 'bit-integrations' + ), + upload_attachment: __( + 'Map a file upload field or a public file URL. Files can be up to 25 MB.', + 'bit-integrations' + ), + download_pdf: __( + 'Bit Integrations saves the PDF in the WordPress media library. Leave the file name empty to use the record type and ID.', + 'bit-integrations' + ) +} From 64d14394bbaff13ac34d288954d332bc0d094416 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Thu, 29 Oct 2026 17:09:43 +0600 Subject: [PATCH 3/6] feat(xero): register the Xero integration --- frontend/src/Utils/StaticData/tutorialLinks.js | 4 ++++ .../src/components/AllIntegrations/EditInteg.jsx | 3 +++ .../src/components/AllIntegrations/IntegInfo.jsx | 3 +++ .../src/components/AllIntegrations/NewInteg.jsx | 10 ++++++++++ .../src/components/Flow/New/SelectAction.jsx | 3 ++- frontend/src/resource/img/integ/xero.webp | Bin 0 -> 2686 bytes 6 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 frontend/src/resource/img/integ/xero.webp diff --git a/frontend/src/Utils/StaticData/tutorialLinks.js b/frontend/src/Utils/StaticData/tutorialLinks.js index b263affda..d0ce0b315 100644 --- a/frontend/src/Utils/StaticData/tutorialLinks.js +++ b/frontend/src/Utils/StaticData/tutorialLinks.js @@ -810,6 +810,10 @@ const tutorialLinks = { quizMaker: { youTubeLink: '', docLink: 'https://bit-integrations.com/wp-docs/actions/quiz-maker-integration-as-an-action/' + }, + xero: { + youTubeLink: '', + docLink: '' } } export default tutorialLinks diff --git a/frontend/src/components/AllIntegrations/EditInteg.jsx b/frontend/src/components/AllIntegrations/EditInteg.jsx index 23de76679..20b9d9e10 100644 --- a/frontend/src/components/AllIntegrations/EditInteg.jsx +++ b/frontend/src/components/AllIntegrations/EditInteg.jsx @@ -239,6 +239,7 @@ const EditUserRegistrationMembership = lazy( () => import('./UserRegistrationMembership/EditUserRegistrationMembership') ) const EditQuizMaker = lazy(() => import('./QuizMaker/EditQuizMaker')) +const EditXero = lazy(() => import('./Xero/EditXero')) const loaderStyle = { display: 'flex', @@ -775,6 +776,8 @@ const IntegType = memo(({ allIntegURL, flow }) => { return case 'QuizMaker': return + case 'Xero': + return default: return (
diff --git a/frontend/src/components/AllIntegrations/IntegInfo.jsx b/frontend/src/components/AllIntegrations/IntegInfo.jsx index 48a6f5923..e1b28f31a 100644 --- a/frontend/src/components/AllIntegrations/IntegInfo.jsx +++ b/frontend/src/components/AllIntegrations/IntegInfo.jsx @@ -261,6 +261,7 @@ const RoxAppointmentBookingAuthorization = lazy(() => import('./RoxAppointmentBooking/RoxAppointmentBookingAuthorization') ) const QuizMakerAuthorization = lazy(() => import('./QuizMaker/QuizMakerAuthorization')) +const XeroAuthorization = lazy(() => import('./Xero/XeroAuthorization')) const IntegrationInfoFallback = ({ integrationConf, editUrl }) => (
@@ -848,6 +849,8 @@ const IntegrationInfo = memo(({ integrationConf, location, editUrl }) => { ) case 'QuizMaker': return + case 'Xero': + return default: // Actions with no authorization UI of their own (site-local ones like Mail // or Post Creation, and anything this build has no component for) used to diff --git a/frontend/src/components/AllIntegrations/NewInteg.jsx b/frontend/src/components/AllIntegrations/NewInteg.jsx index 99ce833ea..78610e93f 100644 --- a/frontend/src/components/AllIntegrations/NewInteg.jsx +++ b/frontend/src/components/AllIntegrations/NewInteg.jsx @@ -236,6 +236,7 @@ const RoxAppointmentBooking = lazy(() => import('./RoxAppointmentBooking/RoxAppointmentBooking') ) const QuizMaker = lazy(() => import('./QuizMaker/QuizMaker')) +const Xero = lazy(() => import('./Xero/Xero')) const NewIntegs = memo(({ integUrlName, allIntegURL, flow, setFlow }) => { switch (integUrlName) { @@ -2205,6 +2206,15 @@ const NewIntegs = memo(({ integUrlName, allIntegURL, flow, setFlow }) => { setFlow={setFlow} /> ) + case 'Xero': + return ( + + ) default: return (
diff --git a/frontend/src/components/Flow/New/SelectAction.jsx b/frontend/src/components/Flow/New/SelectAction.jsx index edb81c7a2..abde33d40 100644 --- a/frontend/src/components/Flow/New/SelectAction.jsx +++ b/frontend/src/components/Flow/New/SelectAction.jsx @@ -251,7 +251,8 @@ export default function SelectAction() { { type: 'WpSimpleBookingCalendar', is_pro: true }, { type: 'Sender', is_pro: true }, { type: 'MainWP', is_pro: true }, - { type: 'QuizMaker', name: 'Quiz Maker', is_pro: true } + { type: 'QuizMaker', name: 'Quiz Maker', is_pro: true }, + { type: 'Xero', is_pro: true } ] const [showProModal, setShowProModal] = useState(false) diff --git a/frontend/src/resource/img/integ/xero.webp b/frontend/src/resource/img/integ/xero.webp new file mode 100644 index 0000000000000000000000000000000000000000..33e480b27e8754273de34d50cbebef025d8439e7 GIT binary patch literal 2686 zcmV-^3W4=fNk&F?3IG6CMM6+kP&il$0000G0001g004gg06|PpNFoFP00E#xZQJok zdjD8u^|)=j>>8UZFWc_asBPP}k{Y`>Dp##-grzwDHwb=w?>-_X0K~{dPJM0j!ApkX zx)zhuWp1r6Fb!}&W^IZ z+0m5JF8-{1hzN4{&n?M1>d0rW{l!WIY%kd~Ur7^r%H@8R2%I(F%v8`qo_xtnL}|O3 zsi1*8`I4E4)^;O9AvR{gMKck#?Rv6ARFSJYiRk_uYh=-me!jf|I`2OsLxNkxXEURaQdTA-!5qx}6R}UDx4g0VSOC5(@inJT92A z2A0tIhsgq&QAVg;IbRUtN(sd)rwSm!K&bxX6TIopv2-IhV|Y^R@Dj@VkEnT(ta1?Q zy9;G}yk{i{4t>JIoF;-|=Tgpe_X(2U#c;LEgN1K1M+@2rn!Rb9L>dU9KgDse#*2yn zb0)lnodnhXjId-E2(ouo;iQLr)PS)gL>j=_Yf@pun>W}C^i!E3UX9xO>puIOhWI0|n@&8L7#LMyg|8@|> zj{$(@H_R%d0mimr);A3x$AOvW6@cXgbv?jY+-^xD%W$hsnAVP4|AN;>adW@FnZ=Fl zIY5xVa0$Cd2JC(-#;&#wyXF?`229vFoY=80?1&jVXDfE5dhA+Cu&Xm-S9}>e!y)W0 z6|%_fd((lN=c5^&xD70hs=%!-k-31|T^X#x?K6NpCuZK)0LFJ<);|M4TZ&nA3;^uK z>=Xb%_Ap+qm;V?2CteLn|AQ@f9fE)&0WLohVei1@eT*t?#2@Nu>*bKh_VJfa;W6*>H$-TH(@*r$R z@56-MDGd)|zsICB3Ajw|!o!pc+^+Xx!ETXo6#W$zza{|Ji`sGM%LMK}9>Tz}g5g1? zchS$gLjioM5BX`wp3?!p(Kl%KC;|9>{Cm2Ujs^aw{7tjQ*&xLERrG311z}G4kyfR% zL8y=4^wEfYG!}##bHqg-&$$E;vbu1HHlsUqAZ*zuU3BSRp#Y&XOTP2c!~a``1Vk9K zxtA8EE%6{uN$Sr&8u-g{q#)Y3&j-Y3*_i}lmZaYs6xXmZR|X34JC8K}Ga*=sXmBtU;@{DSxQTzvFfZS#PWB~DXI zU9sWPzK<3q%0P@(P&gni1^@u?9{`;JDu4ih06vjKoJu95qM@c4T9~jB32X>(41mo7 z=mFXR^a1<1Ao7e;OOU%#I z2kg(NKicoxe|LZI-?84}Kfii_e?E`<44)?Pt^h|Ndok@`8l>P#dn>9%d>E&k23cjN!;?olj6J^>&B59auav1sL2lCp0YGQEHR{?*M#?D!D>%gA;iGb_z4R)fbuBc}Y3kT1sg zKo`O%qAF=bks+YIi8b_a#Y^W4^(W+DKr~R+PSPED|6;dEUmxbJogrhCP3+Lvl*P$* zvUdpD?>VSnqL)>m+6dJJRiFG;LwFy5s$;-Y$zQ3YZKEG|}c z$ioeM%Gn2@=G7Vuiu{{k000T19Nyu&fNbXe5|&Z$J|k8(H`RxNd#*uf_2W}WV_lEN zxXop{H5*3g6_cRbCvy|WSaHz+004?xm1^px_OOu?dGh_|5miSqZ zdUH|k#Bxs{N)=RJHWR&H2T>yg!;#n;i z(yHzzd?Av65LM}RVNtV)%)}2qKNg~ z^Wyf0;Uq@klM%9Al{;__$d^j@Dlp^6m#NT{*Rk!M?B@xJJZ22f5pAp@iKOXke30D@ zfnB4kVX|*bU#FPCn36`NfuAJQ0til>a3%ZwSg)x_jv(}ajJ>EstrB9+VAA?n3_4tHlmWCtaZeayYbkCaogyOquo zH<-qV2Ipht5Mg-DJK=Q77&dYmn8*gTaZ>X}$bN}x8h0&h={rMDZt!X*|JaYhq{pou z3Y@YIkfJ8pC9Gm|->$=VnbWZmV*9vHi6GcxdWzBo+yf3W=tTUmehWotcN>_e1jKsK zv_!k3K+o%1bm+2=V+h|VBzUg3L8Q_Rv;$YAg~wn@iu&%CU2)r?dYYj?98h)Rqy7U* zmd%BH<_AON3h>Cxt3jK4r;!P_H|yNO5PTi;Ih4GTFCSy~{Q0S789E?HQ0>SnA3gfA zp383l99uxSS|0~vkX0@aWwuOtoAO@quHl%b8TDhCfBKOr z-liz-e{mZZ&JGA5h}su@_Y61%Bwc&_8y*Y6mI|V8-RqGa0Omyfrdoz@suw^ z+fl+xkC##rBAc}nh1WZX%1b$O;~$G4pWN0zI`ta6HF)95{!cH?v1DQ)fB*mh02Th( z+QjesP#m)6RG*HCYSJ`6Oo8RseD1&h%)nWg(<9DG2|h;0V3`DS?;yJ0Zm)!YFk6o4>M4g|fkGwZvjib92qj s9%F2t2TJ`383N6Umm2g?F798-jiRO;ZO&-5A)cFzWXg~2GByAJ0F1#lbpQYW literal 0 HcmV?d00001 From 39ac050a53f3ba1820cebadb6ce7020dba6b492e Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Sun, 1 Nov 2026 10:28:31 +0600 Subject: [PATCH 4/6] fix(xero): tidy the action config and add field notes Remove the unused utilities config, mark locked required rows, give the list routes a fallback error, and add notes for contact, account, bank transfer, batch payment and ID-only actions. --- backend/Actions/Xero/RecordApiHelper.php | 6 --- backend/Actions/Xero/XeroController.php | 2 +- .../components/AllIntegrations/Xero/Xero.jsx | 3 +- .../AllIntegrations/Xero/XeroFieldMap.jsx | 2 +- .../AllIntegrations/Xero/XeroIntegLayout.jsx | 1 - .../AllIntegrations/Xero/staticData.js | 38 ++++++++++++++++--- 6 files changed, 35 insertions(+), 17 deletions(-) diff --git a/backend/Actions/Xero/RecordApiHelper.php b/backend/Actions/Xero/RecordApiHelper.php index 638f73c26..ebbcca742 100644 --- a/backend/Actions/Xero/RecordApiHelper.php +++ b/backend/Actions/Xero/RecordApiHelper.php @@ -487,12 +487,6 @@ private function settings() } } - foreach ((array) ($details->utilities ?? []) as $key => $value) { - if ($value !== '' && $value !== null && $value !== []) { - $settings[preg_replace('/^selected_/', '', (string) $key)] = $value; - } - } - return $settings; } } diff --git a/backend/Actions/Xero/XeroController.php b/backend/Actions/Xero/XeroController.php index 0151e879d..a81833d38 100644 --- a/backend/Actions/Xero/XeroController.php +++ b/backend/Actions/Xero/XeroController.php @@ -193,7 +193,7 @@ private static function sendOptions($queryParams, string $path, array $query, st $body = json_decode((string) wp_json_encode($response->getBody()), true); if (!$response->success() || !isset($body[$collection])) { - wp_send_json_error($body['Detail'] ?? $body['Message'] ?? $response->getError() ?? __('Xero did not return the list. Try again.', 'bit-integrations'), 400); + wp_send_json_error($body['Detail'] ?? $body['Message'] ?? ($response->getError() ?: __('Xero did not return the list. Check the connection and try again.', 'bit-integrations')), 400); } wp_send_json_success(array_values(array_filter(array_map($toOption, $body[$collection]))), 200); diff --git a/frontend/src/components/AllIntegrations/Xero/Xero.jsx b/frontend/src/components/AllIntegrations/Xero/Xero.jsx index fd16fa198..70879a427 100644 --- a/frontend/src/components/AllIntegrations/Xero/Xero.jsx +++ b/frontend/src/components/AllIntegrations/Xero/Xero.jsx @@ -20,8 +20,7 @@ function Xero({ formFields, setFlow, flow, allIntegURL }) { const [xeroConf, setXeroConf] = useState({ field_map: [], name: 'Xero', - type: 'Xero', - utilities: {} + type: 'Xero' }) const saveConfig = () => { diff --git a/frontend/src/components/AllIntegrations/Xero/XeroFieldMap.jsx b/frontend/src/components/AllIntegrations/Xero/XeroFieldMap.jsx index 129da767f..5157fbdd0 100644 --- a/frontend/src/components/AllIntegrations/Xero/XeroFieldMap.jsx +++ b/frontend/src/components/AllIntegrations/Xero/XeroFieldMap.jsx @@ -72,7 +72,7 @@ export default function XeroFieldMap({ i, formFields, field, xeroConf, setXeroCo {i < requiredFlds.length ? ( ) : ( nonRequiredFlds.map(({ key, label }) => ( diff --git a/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx b/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx index 1aa9354e9..a812b32a8 100644 --- a/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx +++ b/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx @@ -30,7 +30,6 @@ export default function XeroIntegLayout({ formFields, xeroConf, setXeroConf, isL const nextConf = create(xeroConf, draftConf => { draftConf.mainAction = value draftConf.field_map = generateMappedField(value) - draftConf.utilities = {} selectKeys.forEach(key => { delete draftConf[key] }) diff --git a/frontend/src/components/AllIntegrations/Xero/staticData.js b/frontend/src/components/AllIntegrations/Xero/staticData.js index cac940c65..4090cff86 100644 --- a/frontend/src/components/AllIntegrations/Xero/staticData.js +++ b/frontend/src/components/AllIntegrations/Xero/staticData.js @@ -415,7 +415,7 @@ const purchaseOrderFields = [ const repeatingInvoiceFields = [ field('period', __('Repeat Every (Number)', 'bit-integrations'), true), - field('due_date', __('Due Date Number', 'bit-integrations'), true), + field('due_date', __('Due Day (Number)', 'bit-integrations'), true), field('start_date', __('Start Date (YYYY-MM-DD)', 'bit-integrations'), true), field('end_date', __('End Date (YYYY-MM-DD)', 'bit-integrations')), documentReferenceField, @@ -887,7 +887,7 @@ const updateLinesNote = __( ) const idNote = __( - 'Map the ID from a previous step or a form field. Xero shows IDs in the record URL.', + 'Map the ID from a previous Xero action or a form field. Xero shows most IDs in the record URL.', 'bit-integrations' ) @@ -1018,7 +1018,7 @@ export const fieldsByAction = { field('component_rate', __('Component Rate (%)', 'bit-integrations'), true) ], update_tax_rate: [ - idField('tax_type', __('Tax Type', 'bit-integrations')), + idField('tax_type', __('Tax Type Code', 'bit-integrations')), field('name', __('New Name', 'bit-integrations')), field('component_name', __('Component Name', 'bit-integrations')), field('component_rate', __('Component Rate (%)', 'bit-integrations')) @@ -1167,7 +1167,7 @@ export const notesByAction = { 'bit-integrations' ), delete_credit_note_allocation: __( - 'Get the AllocationID from the Allocations list of the credit note.', + 'Xero shows the Allocation ID in the Allocations list of the credit note.', 'bit-integrations' ), create_quote: `${lineNote} ${__('Leave the quote date empty to use today.', 'bit-integrations')}`, @@ -1175,9 +1175,10 @@ export const notesByAction = { create_purchase_order: lineNote, update_purchase_order: lineUpdateNote, create_repeating_invoice: `${lineNote} ${__( - 'Xero sends the invoice only when the status is Approved, the type is Invoice and Approve for Sending is Yes.', + 'Due Day is the number of days for a Days After type, or the day of the month for a Day of the Month type, for example 20. Xero sends the invoice only when the status is Approved, the type is Invoice and Approve for Sending is Yes.', 'bit-integrations' )}`, + create_contact: __('The contact name must be unique in Xero.', 'bit-integrations'), update_contact: updateNote, create_or_update_contact: __( 'Xero updates the contact that has this email. If no contact has it, Xero creates one. Map a name to create a contact.', @@ -1196,7 +1197,7 @@ export const notesByAction = { 'bit-integrations' ), create_batch_payment: __( - 'The batch pays one invoice or bill. Leave the Date empty to use today.', + 'The batch pays one invoice or bill. Leave the Date empty to use today. Particulars and Code apply only to New Zealand banks. Narrative applies only to UK banks.', 'bit-integrations' ), allocate_overpayment: paymentAllocationNote, @@ -1206,7 +1207,28 @@ export const notesByAction = { create_spend_money: lineNote, create_receive_money: lineNote, update_bank_transaction: `${updateNote} ${updateLinesNote}`, + archive_contact: idNote, + rename_contact_group: idNote, + delete_contact_group: idNote, + remove_contact_from_group: idNote, + remove_all_contacts_from_group: idNote, + approve_credit_note: idNote, + update_quote_status: idNote, + update_purchase_order_status: idNote, + delete_repeating_invoice: idNote, + delete_item: idNote, + delete_linked_transaction: idNote, + delete_payment: idNote, + delete_batch_payment: idNote, + archive_account: idNote, + create_tracking_option: idNote, + delete_tracking_category: idNote, + delete_tracking_option: idNote, delete_bank_transaction: idNote, + create_bank_transfer: __( + 'Select two different bank accounts. Leave the transfer date empty to use today.', + 'bit-integrations' + ), delete_bank_transfer: idNote, create_manual_journal: manualJournalNote, update_manual_journal: `${updateNote} ${__( @@ -1214,6 +1236,10 @@ export const notesByAction = { 'bit-integrations' )}`, update_manual_journal_status: idNote, + create_account: __( + 'Map an account code for every account type except Bank. A Bank account needs a bank account number.', + 'bit-integrations' + ), update_account: updateNote, delete_account: __( 'Xero does not delete bank accounts or accounts with transactions. Use Archive Account for them.', From 73fae3f23570bf999f9b02f378084f593ebe9ce9 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Sun, 1 Nov 2026 11:33:11 +0600 Subject: [PATCH 5/6] fix(xero): offer only the repeating invoice due date types Xero accepts Xero rejects DAYSAFTERINVOICEDATE and DAYSAFTERBILLMONTH for repeating invoices. Also tell users to pick the receivable or payable account for an overpayment. --- .../AllIntegrations/Xero/staticData.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/AllIntegrations/Xero/staticData.js b/frontend/src/components/AllIntegrations/Xero/staticData.js index 4090cff86..14dbca7d8 100644 --- a/frontend/src/components/AllIntegrations/Xero/staticData.js +++ b/frontend/src/components/AllIntegrations/Xero/staticData.js @@ -446,16 +446,11 @@ const repeatingInvoiceSelects = [ 'due_date_type', __('Due Date Type', 'bit-integrations'), [ - { label: __('Days After the Invoice Date', 'bit-integrations'), value: 'DAYSAFTERINVOICEDATE' }, + { label: __('Days After the Invoice Date', 'bit-integrations'), value: 'DAYSAFTERBILLDATE' }, { label: __('Days After the End of the Invoice Month', 'bit-integrations'), value: 'DAYSAFTERINVOICEMONTH' }, - { label: __('Days After the Bill Date', 'bit-integrations'), value: 'DAYSAFTERBILLDATE' }, - { - label: __('Days After the End of the Bill Month', 'bit-integrations'), - value: 'DAYSAFTERBILLMONTH' - }, { label: __('Day of the Current Month', 'bit-integrations'), value: 'OFCURRENTMONTH' }, { label: __('Day of the Following Month', 'bit-integrations'), value: 'OFFOLLOWINGMONTH' } ], @@ -1204,8 +1199,14 @@ export const notesByAction = { delete_overpayment_allocation: paymentRemoveAllocationNote, allocate_prepayment: paymentAllocationNote, delete_prepayment_allocation: paymentRemoveAllocationNote, - create_spend_money: lineNote, - create_receive_money: lineNote, + create_spend_money: `${lineNote} ${__( + 'For an Overpayment, select the Accounts Payable account as the Line Account.', + 'bit-integrations' + )}`, + create_receive_money: `${lineNote} ${__( + 'For an Overpayment, select the Accounts Receivable account as the Line Account.', + 'bit-integrations' + )}`, update_bank_transaction: `${updateNote} ${updateLinesNote}`, archive_contact: idNote, rename_contact_group: idNote, From f5cac84ec16a1bcf11f611de5d9310361927a944 Mon Sep 17 00:00:00 2001 From: Rishad Alam Date: Sun, 1 Nov 2026 11:54:53 +0600 Subject: [PATCH 6/6] fix(xero): address PR review findings Reject connections that belong to another app, report Xero's error when the organisation lookup fails, use the full organisation list only when it holds one organisation, build dropdown labels once per list, and keep the developer portal sentence in one translatable string. --- backend/Actions/Xero/XeroController.php | 61 ++++++++++++++++--- .../Xero/XeroAuthorization.jsx | 11 ++-- .../AllIntegrations/Xero/XeroIntegLayout.jsx | 30 ++++++--- 3 files changed, 77 insertions(+), 25 deletions(-) diff --git a/backend/Actions/Xero/XeroController.php b/backend/Actions/Xero/XeroController.php index a81833d38..1f31d3b70 100644 --- a/backend/Actions/Xero/XeroController.php +++ b/backend/Actions/Xero/XeroController.php @@ -133,7 +133,7 @@ private static function client($connectionId) { $connection = AuthorizationFactory::getConnectionHandler($connectionId); - if ($connection === null) { + if ($connection === null || strcasecmp((string) ($connection->getConnection()->app_slug ?? ''), self::$authConfig['slug']) !== 0) { return __('A Xero connection is required. Select a connection.', 'bit-integrations'); } @@ -143,6 +143,10 @@ private static function client($connectionId) if ($tenantId === '') { $tenantId = self::findOrganisation($connection, (string) ($authDetails['access_token'] ?? '')); + if ($tenantId instanceof WP_Error) { + return $tenantId->get_error_message(); + } + if ($tenantId === '') { return __('Xero has no organisation for this connection. Connect again and select an organisation.', 'bit-integrations'); } @@ -157,8 +161,14 @@ private static function client($connectionId) return $apiClient; } - // The consent screen can add several organisations; the token's auth event names the ones picked for this connection. - private static function findOrganisation($connection, string $accessToken): string + /** + * The consent screen can add several organisations; the token's auth event names the ones picked for this connection. + * + * @param mixed $connection + * + * @return string|WP_Error the tenant ID, an empty string when Xero lists no organisation + */ + private static function findOrganisation($connection, string $accessToken) { $parts = explode('.', $accessToken); $claims = \count($parts) === 3 ? json_decode((string) base64_decode(strtr($parts[1], '-_', '+/')), true) : null; @@ -168,17 +178,48 @@ private static function findOrganisation($connection, string $accessToken): stri $client->setBaseURL(self::CONNECTIONS_URL); $client->setHeaders(['Accept' => 'application/json']); - foreach ($authEventId === '' ? [''] : ['?authEventId=' . rawurlencode($authEventId), ''] as $query) { - $organisations = json_decode((string) wp_json_encode($client->get($query)->getBody()), true); + $organisations = self::listOrganisations($client, $authEventId === '' ? '' : '?authEventId=' . rawurlencode($authEventId)); + + // Without the auth event, the full list is safe to use only when it holds one organisation. + if (\is_array($organisations) && empty($organisations) && $authEventId !== '') { + $organisations = self::listOrganisations($client, ''); + $organisations = \is_array($organisations) && \count($organisations) > 1 ? [] : $organisations; + } + + if ($organisations instanceof WP_Error) { + return $organisations; + } + + return (string) ($organisations[0] ?? ''); + } + + /** + * @return string[]|WP_Error + */ + private static function listOrganisations(ApiClient $client, string $query) + { + $response = $client->get($query); + $body = json_decode((string) wp_json_encode($response->getBody()), true); + + if (!$response->success() || !\is_array($body)) { + $message = \is_array($body) ? ($body['Detail'] ?? $body['Message'] ?? $body['Title'] ?? '') : ''; + + return new WP_Error( + 'XERO_CONNECTIONS', + // translators: %s: error message from Xero. + \sprintf(__('Xero did not return the organisations (%s). Connect again and try again.', 'bit-integrations'), (string) ($message ?: $response->getError() ?: $response->getStatus())) + ); + } + + $tenantIds = []; - foreach (\is_array($organisations) ? $organisations : [] as $organisation) { - if (($organisation['tenantType'] ?? '') === 'ORGANISATION' && !empty($organisation['tenantId'])) { - return (string) $organisation['tenantId']; - } + foreach ($body as $organisation) { + if (($organisation['tenantType'] ?? '') === 'ORGANISATION' && !empty($organisation['tenantId'])) { + $tenantIds[] = (string) $organisation['tenantId']; } } - return ''; + return $tenantIds; } private static function sendOptions($queryParams, string $path, array $query, string $collection, callable $toOption) diff --git a/frontend/src/components/AllIntegrations/Xero/XeroAuthorization.jsx b/frontend/src/components/AllIntegrations/Xero/XeroAuthorization.jsx index f81425a48..985bc604e 100644 --- a/frontend/src/components/AllIntegrations/Xero/XeroAuthorization.jsx +++ b/frontend/src/components/AllIntegrations/Xero/XeroAuthorization.jsx @@ -1,5 +1,5 @@ import { AUTH_TYPES } from '../../../Utils/connectionAuth' -import { __ } from '../../../Utils/i18nwrap' +import { __, sprintf } from '../../../Utils/i18nwrap' import tutorialLinks from '../../../Utils/StaticData/tutorialLinks' import Authorization from '../../Connections/Authorization' @@ -22,10 +22,11 @@ const scope = [ const note = `

${__('Steps to connect Xero:', 'bit-integrations')}

    -
  • ${__('Open the', 'bit-integrations')} ${__( - 'Xero Developer portal', - 'bit-integrations' - )}.
  • +
  • ${sprintf( + // translators: %s: link to the Xero Developer portal. + __('Open the %s.', 'bit-integrations'), + `${__('Xero Developer portal', 'bit-integrations')}` + )}
  • ${__('Click New app and select Web app.', 'bit-integrations')}
  • ${__('Copy the Callback / Redirect URL from this form. Add it to the app as a redirect URI.', 'bit-integrations')}
  • ${__('Open Configuration. Copy the Client ID and generate a Client Secret. Paste both into this form.', 'bit-integrations')}
  • diff --git a/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx b/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx index a812b32a8..a00ec1bd2 100644 --- a/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx +++ b/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { create } from 'mutative' import MultiSelect from 'react-multiple-select-dropdown-lite' import { useRecoilValue } from 'recoil' @@ -51,16 +51,26 @@ export default function XeroIntegLayout({ formFields, xeroConf, setXeroConf, isL ) } - const selectOptions = select => { - if (select.options) return select.options - - const items = xeroConf?.default?.[select.source] || [] + const sourceOptions = useMemo( + () => + Object.fromEntries( + Object.entries(xeroConf?.default || {}).map(([source, items]) => { + const nameCounts = new Map() + items.forEach(({ name }) => nameCounts.set(name, (nameCounts.get(name) || 0) + 1)) + + return [ + source, + items.map(({ id, name }) => ({ + label: nameCounts.get(name) > 1 ? `${name} (#${id})` : name, + value: String(id) + })) + ] + }) + ), + [xeroConf?.default] + ) - return items.map(({ id, name }) => ({ - label: items.filter(item => item.name === name).length > 1 ? `${name} (#${id})` : name, - value: String(id) - })) - } + const selectOptions = select => select.options || sourceOptions[select.source] || [] return ( <>