diff --git a/backend/Actions/Xero/RecordApiHelper.php b/backend/Actions/Xero/RecordApiHelper.php new file mode 100644 index 000000000..ebbcca742 --- /dev/null +++ b/backend/Actions/Xero/RecordApiHelper.php @@ -0,0 +1,492 @@ +_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}; + } + } + + 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 || strcasecmp((string) ($connection->getConnection()->app_slug ?? ''), self::$authConfig['slug']) !== 0) { + 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 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'); + } + + $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. + * + * @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; + $authEventId = \is_array($claims) ? (string) ($claims['authentication_event_id'] ?? '') : ''; + + $client = new ApiClient($connection); + $client->setBaseURL(self::CONNECTIONS_URL); + $client->setHeaders(['Accept' => 'application/json']); + + $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 ($body as $organisation) { + if (($organisation['tenantType'] ?? '') === 'ORGANISATION' && !empty($organisation['tenantId'])) { + $tenantIds[] = (string) $organisation['tenantId']; + } + } + + return $tenantIds; + } + + 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. 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/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/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..70879a427 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Xero/Xero.jsx @@ -0,0 +1,99 @@ +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' + }) + + 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..985bc604e --- /dev/null +++ b/frontend/src/components/AllIntegrations/Xero/XeroAuthorization.jsx @@ -0,0 +1,64 @@ +import { AUTH_TYPES } from '../../../Utils/connectionAuth' +import { __, sprintf } 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')}

+
    +
  • ${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')}
  • +
  • ${__('Click Authorize and select one organisation. Make one connection for each organisation.', '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..5157fbdd0 --- /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..a00ec1bd2 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Xero/XeroIntegLayout.jsx @@ -0,0 +1,187 @@ +import { useEffect, useMemo } 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) + 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 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] + ) + + const selectOptions = select => select.options || sourceOptions[select.source] || [] + + 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..14dbca7d8 --- /dev/null +++ b/frontend/src/components/AllIntegrations/Xero/staticData.js @@ -0,0 +1,1277 @@ +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 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, + ...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: 'DAYSAFTERBILLDATE' }, + { + label: __('Days After the End of the Invoice Month', 'bit-integrations'), + value: 'DAYSAFTERINVOICEMONTH' + }, + { 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 Xero action or a form field. Xero shows most 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 Code', '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: __( + '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')}`, + update_quote: lineUpdateNote, + create_purchase_order: lineNote, + update_purchase_order: lineUpdateNote, + create_repeating_invoice: `${lineNote} ${__( + '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.', + '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. Particulars and Code apply only to New Zealand banks. Narrative applies only to UK banks.', + 'bit-integrations' + ), + allocate_overpayment: paymentAllocationNote, + delete_overpayment_allocation: paymentRemoveAllocationNote, + allocate_prepayment: paymentAllocationNote, + delete_prepayment_allocation: paymentRemoveAllocationNote, + 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, + 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} ${__( + 'Map the debit fields to replace all lines with one debit line and one credit line.', + '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.', + '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' + ) +} 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 000000000..33e480b27 Binary files /dev/null and b/frontend/src/resource/img/integ/xero.webp differ