From 61b1bddb2346599ca7c6dc52be14a630328af45f Mon Sep 17 00:00:00 2001 From: Matthew Wire Date: Tue, 6 Aug 2024 11:16:11 +0100 Subject: [PATCH 1/5] Return array of IDs for Contact Push/Pull --- CRM/Civixero/Contact.php | 13 ++++++++----- api/v3/Civixero/Contactpull.php | 4 ++-- api/v3/Civixero/Contactpush.php | 7 ++++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CRM/Civixero/Contact.php b/CRM/Civixero/Contact.php index 8a6145ad..d5cba60f 100644 --- a/CRM/Civixero/Contact.php +++ b/CRM/Civixero/Contact.php @@ -19,7 +19,7 @@ class CRM_Civixero_Contact extends CRM_Civixero_Base { * * @throws CRM_Core_Exception */ - public function pull(array $params): void { + public function pull(array $params): array { // If we specify a xero contact id (UUID) then we try to load ONLY that contact. $params['xero_contact_id'] = $params['xero_contact_id'] ?? FALSE; @@ -149,6 +149,7 @@ public function pull(array $params): void { // Since we expect this to wind up in the job log we'll print the errors throw new CRM_Core_Exception(E::ts('Not all records were saved') . ': ' . print_r($errors, TRUE), 'incomplete', $errors); } + return ['AccountContactIDs' => $ids ?? []]; } /** @@ -159,14 +160,15 @@ public function pull(array $params): void { * @param array $params * - start_date * - * @return bool + * @return array * @throws CRM_Core_Exception */ - public function push(array $params, int $limit = 10): bool { + public function push(array $params, int $limit = 10): array { $records = $this->getContactsRequiringPushUpdate($params, $limit); if (empty($records)) { - return TRUE; + return []; } + $errors = []; foreach ($records as $record) { @@ -260,6 +262,7 @@ public function push(array $params, int $limit = 10): bool { ->setValues($record) ->addValue('accounts_needs_update', FALSE) ->execute(); + $contactIDsPushed[] = $record['contact_id']; } catch (CRM_Civixero_Exception_XeroThrottle $e) { throw new CRM_Core_Exception('Contact Push aborted due to throttling by Xero' . print_r($errors, TRUE)); @@ -287,7 +290,7 @@ public function push(array $params, int $limit = 10): bool { // since we expect this to wind up in the job log we'll print the errors throw new CRM_Core_Exception(E::ts('Not all contacts were saved') . print_r($errors, TRUE), 'incomplete', $errors); } - return TRUE; + return $contactIDsPushed ?? []; } /** diff --git a/api/v3/Civixero/Contactpull.php b/api/v3/Civixero/Contactpull.php index fc64081e..61db111e 100644 --- a/api/v3/Civixero/Contactpull.php +++ b/api/v3/Civixero/Contactpull.php @@ -47,6 +47,6 @@ function _civicrm_api3_civixero_contactpull_spec(&$spec) { */ function civicrm_api3_civixero_contactpull($params) { $xero = new CRM_Civixero_Contact($params); - $xero->pull($params); - return civicrm_api3_create_success(1, $params, 'Civixero', 'contactpull'); + $result = $xero->pull($params); + return civicrm_api3_create_success($result, $params, 'Civixero', 'contactpull'); } diff --git a/api/v3/Civixero/Contactpush.php b/api/v3/Civixero/Contactpush.php index f20e6aed..5d17e12a 100644 --- a/api/v3/Civixero/Contactpush.php +++ b/api/v3/Civixero/Contactpush.php @@ -46,8 +46,9 @@ function _civicrm_api3_civixero_contactpush_spec(&$spec) { * @see civicrm_api3_create_success */ function civicrm_api3_civixero_contactpush(array $params): array { - $xero = new CRM_Civixero_Contact($params); - $xero->push($params); - return civicrm_api3_create_success($params, $params); + $options = _civicrm_api3_get_options_from_params($params); + $xero = new CRM_Civixero_Contact(); + $result = $xero->push($params, $options['limit']); + return civicrm_api3_create_success(['contactIDspushed' => $result], $params); } From 6e27061ca47993a32c8aeb3c225b0696f9124f60 Mon Sep 17 00:00:00 2001 From: Matthew Wire Date: Tue, 6 Aug 2024 11:21:47 +0100 Subject: [PATCH 2/5] Return array of IDs for Invoice Push/Pull --- CRM/Civixero/Invoice.php | 22 ++++++++++------------ api/v3/Civixero/Invoicepush.php | 2 +- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/CRM/Civixero/Invoice.php b/CRM/Civixero/Invoice.php index 0f96a614..afd16867 100644 --- a/CRM/Civixero/Invoice.php +++ b/CRM/Civixero/Invoice.php @@ -36,13 +36,13 @@ class CRM_Civixero_Invoice extends CRM_Civixero_Base { * * @param array $params * - * @return int + * @return array * @throws CRM_Core_Exception */ - public function pull(array $params): int { + public function pull(array $params): array { $xeroParams = ['Type' => 'ACCREC']; $filter = $params['xero_invoice_id'] ?? $params['invoice_number'] ?? FALSE; - $count = 0; + $errors = []; /** @noinspection PhpUndefinedMethodInspection */ $result = $this @@ -150,7 +150,7 @@ public function pull(array $params): int { // Since we expect this to wind up in the job log we'll print the errors throw new CRM_Core_Exception(E::ts('Not all records were saved') . ': ' . print_r($errors, TRUE), 'incomplete', $errors); } - return $count; + return ['AccountInvoiceIDs' => $ids ?? []]; } /** @@ -164,17 +164,16 @@ public function pull(array $params): int { * @param int $limit * Number of invoices to process * - * @return int + * @return array * @throws \CRM_Core_Exception */ - public function push($params, $limit = 10) { + public function push(array $params, int $limit = 10) { $records = $this->getContributionsRequiringPushUpdate($params, $limit); if (empty($records)) { - return 0; + return []; } $errors = []; - - $count = 0; + $responseErrors = []; foreach ($records as $record) { try { $accountsInvoice = $this->getAccountsInvoice($record); @@ -190,7 +189,6 @@ public function push($params, $limit = 10) { } $result = $this->pushToXero($accountsInvoice, $params['connector_id']); $responseErrors = $this->savePushResponse($result, $record); - $count++; } catch (CRM_Civixero_Exception_XeroThrottle $e) { $errors[] = ($this->xero_entity . ' Push aborted due to throttling by Xero'); @@ -212,13 +210,13 @@ public function push($params, $limit = 10) { ->execute(); $errors[] = $errorMessage; } + $contributionIDsPushed[] = $record['contribution_id']; } if ($errors) { // since we expect this to wind up in the job log we'll print the errors throw new CRM_Core_Exception(ts('Not all records were saved') . print_r($errors, TRUE), 'incomplete', $errors); } - return $count; - + return $contributionIDsPushed ?? []; } /** diff --git a/api/v3/Civixero/Invoicepush.php b/api/v3/Civixero/Invoicepush.php index df4406b8..8a76b230 100644 --- a/api/v3/Civixero/Invoicepush.php +++ b/api/v3/Civixero/Invoicepush.php @@ -39,6 +39,6 @@ function civicrm_api3_civixero_invoicepush(array $params): array { $xero = new CRM_Civixero_Invoice($params); $result = $xero->push($params, $options['limit']); - return civicrm_api3_create_success($result, $params); + return civicrm_api3_create_success(['contributionIDsPushed' => $result], $params); } From 33211c2b075c63c6f8f36d744d644b86ee493d25 Mon Sep 17 00:00:00 2001 From: Matthew Wire Date: Tue, 6 Aug 2024 15:54:54 +0100 Subject: [PATCH 3/5] Add API to pull,create and delete items --- CRM/Civixero/Item.php | 118 +++++++++++++++++++++++---- Civi/Api4/Action/Xero/ItemCreate.php | 100 +++++++++++++++++++++++ Civi/Api4/Action/Xero/ItemDelete.php | 45 ++++++++++ Civi/Api4/Action/Xero/ItemPull.php | 51 ++++++++++++ 4 files changed, 296 insertions(+), 18 deletions(-) create mode 100644 Civi/Api4/Action/Xero/ItemCreate.php create mode 100644 Civi/Api4/Action/Xero/ItemDelete.php create mode 100644 Civi/Api4/Action/Xero/ItemPull.php diff --git a/CRM/Civixero/Item.php b/CRM/Civixero/Item.php index 30ded143..7c3cae9a 100644 --- a/CRM/Civixero/Item.php +++ b/CRM/Civixero/Item.php @@ -3,29 +3,111 @@ class CRM_Civixero_Item extends CRM_Civixero_Base { /** - * Pull Item Codes from Xero and temporarily stash them on static. - * - * We don't want to keep stale ones in our DB - we'll check each time - * We call the civicrm_accountPullPreSave hook so other modules can alter - * if required. - * - * - I can't think of a reason why they would but it seems consistent - * - * @param array $params - * - * @return array - * - * @throws CRM_Core_Exception + * Pull Items from Xero */ - public function pull($params) { + public function pull(array $filters) { static $items = []; if (empty($items)) { - $retrieved = $this->getSingleton($this->connector_id)->Items(); - if ($this->validateResponse($retrieved)) { - throw new CRM_Core_Exception('Failed to get items'); + $order = "Name ASC"; + $where = $filters['where'] ?? NULL; + $modifiedSince = NULL; + // $modifiedSince = date('Y-m-dTH:i:s', strtotime('20240101000000')); + + try { + $xeroItems = $this->getAccountingApiInstance()->getItems($this->getTenantID(), $modifiedSince, $where, $order); + foreach ($xeroItems as $xeroItem) { + /** + * @var \XeroAPI\XeroPHP\Models\Accounting\Item $xeroItem + */ + foreach ($xeroItem::attributeMap() as $localName => $originalName) { + $getter = 'get' . $originalName; + switch ($localName) { + case 'purchase_details': + case 'sales_details': + foreach ($xeroItem->$getter()::attributeMap() as $localSubName => $originalSubName) { + $subGetter = 'get' . $originalSubName; + $item[$localName][$localSubName] = $xeroItem->$getter()->$subGetter(); + } + break; + + default: + $item[$localName] = $xeroItem->$getter(); + } + } + $items[$item['item_id']] = $item; + } + } catch (\Exception $e) { + \Civi::log('civixero')->error('Exception when calling AccountingApi->getItems: ' . $e->getMessage()); + throw $e; } - $items = $retrieved['Items']['Item']; } return $items; } + + /** + * @throws \Exception + */ + public function createItem(array $itemParameters): array { + $apiInstance = $this->getAccountingApiInstance(); + + $item = new XeroAPI\XeroPHP\Models\Accounting\Item; + foreach ($item::attributeMap() as $localName => $originalName) { + $setter = 'set' . $originalName; + if (in_array($setter, ['setSalesDetails', 'setPurchaseDetails'])) { + $purchaseDetails = new \XeroAPI\XeroPHP\Models\Accounting\Purchase; + foreach ($purchaseDetails::attributeMap() as $purchaseLocalName => $purchaseOriginalName) { + $purchaseSetter = 'set' . $purchaseOriginalName; + if (isset($itemParameters[$localName][$purchaseLocalName])) { + $purchaseDetails->$purchaseSetter($itemParameters[$localName][$purchaseLocalName]); + } + } + $item->$setter($purchaseDetails); + } + elseif (isset($itemParameters[$localName])) { + $item->$setter($itemParameters[$localName]); + } + } + $idempotencyKey = md5(rand() . microtime()); + $items = new XeroAPI\XeroPHP\Models\Accounting\Items(); + $items->setItems([$item]); + + try { + $result = $apiInstance->updateOrCreateItems($this->getTenantID(), $items, FALSE, NULL, $idempotencyKey); + foreach ($result->getItems() as $item) { + return [ + 'itemID' => $item->getItemId(), + 'itemCode' => $item->getCode(), + ]; + } + } catch (\Exception $e) { + \Civi::log('civixero')->error('Exception when calling AccountingApi->updateOrCreateItems: ' . $e->getMessage()); + throw $e; + } + } + + /** + * @throws \Exception + */ + public function deleteItem(string $itemID): array { + $apiInstance = $this->getAccountingApiInstance(); + + try { + $result = $apiInstance->deleteItem($this->getTenantID(), $itemID); + return [ + 'itemID' => $itemID, + 'deleted' => TRUE, + ]; + } catch (\Exception $e) { + if ($e instanceof XeroAPI\XeroPHP\ApiException && $e->getCode() == 404) { + // Already deleted. + return [ + 'itemID' => $itemID, + 'notFound' => TRUE, + ]; + } + \Civi::log('civixero')->error('Exception when calling AccountingApi->deleteItem: ' . $e->getMessage()); + throw $e; + } + } + } diff --git a/Civi/Api4/Action/Xero/ItemCreate.php b/Civi/Api4/Action/Xero/ItemCreate.php new file mode 100644 index 00000000..581c3b65 --- /dev/null +++ b/Civi/Api4/Action/Xero/ItemCreate.php @@ -0,0 +1,100 @@ + $this->connectorID]; + $xero = new \CRM_Civixero_Item($params); + + $itemParameters = [ + 'code' => $this->code, + 'name' => substr($this->name, 0, 50), + 'description' => substr($this->description, 0, 4000), + 'purchase_description' => substr($this->purchase_description, 0, 4000), + 'is_sold' => $this->is_sold, + 'is_purchased' => $this->is_purchased, + ]; + if (!empty($this->purchase_details)) { + $itemParameters['purchase_details'] = $this->purchase_details; + } + if (!empty($this->sales_details)) { + $itemParameters['sales_details'] = $this->sales_details; + } + + $item = $xero->createItem($itemParameters); + $result->exchangeArray($item); + return $result; + } + +} diff --git a/Civi/Api4/Action/Xero/ItemDelete.php b/Civi/Api4/Action/Xero/ItemDelete.php new file mode 100644 index 00000000..778fda7f --- /dev/null +++ b/Civi/Api4/Action/Xero/ItemDelete.php @@ -0,0 +1,45 @@ +itemID)) { + throw new \CRM_Core_Exception('itemID is required.'); + } + $params = ['connector_id' => $this->connectorID]; + $xero = new \CRM_Civixero_Item($params); + $item = $xero->deleteItem($this->itemID); + $result->exchangeArray($item); + return $result; + } + +} diff --git a/Civi/Api4/Action/Xero/ItemPull.php b/Civi/Api4/Action/Xero/ItemPull.php new file mode 100644 index 00000000..baf9ba8f --- /dev/null +++ b/Civi/Api4/Action/Xero/ItemPull.php @@ -0,0 +1,51 @@ + $this->connectorID]; + $xero = new \CRM_Civixero_Item($params); + if (!empty($this->name)) { + $filters['where'][] = 'Name=="' . $this->name . '"'; + } + if (!empty($this->code)) { + $filters['where'][] = 'Code=="' . $this->code . '"'; + } + $items = $xero->pull($filters ?? []); + $result->exchangeArray($items ?? []); + return $result; + } + +} From 958a52637a4a358b8d3437e4369e10b33a5ab9bd Mon Sep 17 00:00:00 2001 From: Matthew Wire Date: Wed, 21 Aug 2024 09:19:44 +0100 Subject: [PATCH 4/5] Convert pull to use Invoice API via SDK and ignore DELETED invoices --- CRM/Civixero/Invoice.php | 64 +++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/CRM/Civixero/Invoice.php b/CRM/Civixero/Invoice.php index afd16867..84567a2e 100644 --- a/CRM/Civixero/Invoice.php +++ b/CRM/Civixero/Invoice.php @@ -40,40 +40,37 @@ class CRM_Civixero_Invoice extends CRM_Civixero_Base { * @throws CRM_Core_Exception */ public function pull(array $params): array { - $xeroParams = ['Type' => 'ACCREC']; - $filter = $params['xero_invoice_id'] ?? $params['invoice_number'] ?? FALSE; - $errors = []; - /** @noinspection PhpUndefinedMethodInspection */ - $result = $this - ->getSingleton($params['connector_id']) - ->Invoices($filter, $this->formatDateForXero($params['start_date']), $xeroParams); - if (!is_array($result)) { - throw new CRM_Core_Exception('Sync Failed', 'xero_retrieve_failure', (array) $result); - } - if (!empty($result['Invoices'])) { - $invoices = $result['Invoices']['Invoice']; - if (isset($invoices['InvoiceID'])) { - // The return syntax puts the contact only level higher up when only one contact is involved. - $invoices = [$invoices]; - } - foreach ($invoices as $invoice) { + $modifiedSince = NULL; + $where = 'Status!="' . \XeroAPI\XeroPHP\Models\Accounting\Invoice::STATUS_DELETED . '"' + . ' AND TYPE="' . \XeroAPI\XeroPHP\Models\Accounting\Invoice::TYPE_ACCREC . '"'; + if (!empty(\Civi::settings()->get('account_sync_contribution_day_zero'))) { + $dateFrom = new \DateTime(\Civi::settings()->get('account_sync_contribution_day_zero')); + $where .= ' AND Date>DateTime(' . $dateFrom->format('Y,m,d') . ')'; + } + $order = "Date ASC"; + $ids[] = $params['xero_invoice_id'] ?? NULL; + $invoiceNumbers = $params['invoice_number'] ?? NULL; + $invoices = $this->getAccountingApiInstance()->getInvoices($this->getTenantID(), $modifiedSince, $where, $order, $ids, $invoiceNumbers); + + if (!empty($invoices->getInvoices())) { + foreach ($invoices->getInvoices() as $invoice) { $accountInvoiceParams = [ 'plugin' => $this->_plugin, 'connector_id' => $params['connector_id'], - 'accounts_modified_date' => date('Y-m-d H:i:s', strtotime($invoice['UpdatedDateUTC'])), - 'accounts_invoice_id' => $invoice['InvoiceID'], - 'accounts_data' => json_encode($invoice), - 'accounts_status_id' => $this->mapStatus($invoice['Status']), + 'accounts_modified_date' => $invoice->getUpdatedDateUtcAsDate()->format('Y-m-d H:i:s'), + 'accounts_invoice_id' => $invoice->getInvoiceId(), + 'accounts_data' => json_encode((array) $invoice), + 'accounts_status_id' => $this->mapStatus($invoice->getStatus()), 'accounts_needs_update' => FALSE, ]; $prefix = $this->getSetting('xero_invoice_number_prefix') ?: ''; // If we have no prefix we don't know if the InvoiceNumber was generated by Xero or CiviCRM, so we can't use it. - if (!empty($prefix) && !empty($invoice['InvoiceNumber']) && (substr($invoice['InvoiceNumber'], 0, strlen($prefix)) === $prefix)) { + if (!empty($prefix) && !empty($invoice->getInvoiceNumber()) && (substr($invoice->getInvoiceNumber(), 0, strlen($prefix)) === $prefix)) { // Strip out the invoice number prefix if present. - $contributionID = preg_replace("/^\Q{$prefix}\E/", '', $invoice['InvoiceNumber'] ?? NULL); + $contributionID = preg_replace("/^\Q{$prefix}\E/", '', $invoice->getInvoiceNumber() ?? NULL); // Xero sets InvoiceNumber = InvoiceID (accounts_invoice_id) if not set by CiviCRM. // We can only use it if it is an integer (map it to CiviCRM contribution_id). $contributionID = CRM_Utils_Type::validate($contributionID, 'Integer', FALSE); @@ -91,7 +88,7 @@ public function pull(array $params): array { $accountInvoice = AccountInvoice::get(FALSE) ->addWhere('plugin', '=', $this->_plugin) ->addWhere('connector_id', '=', $params['connector_id']) - ->addWhere('accounts_invoice_id', '=', $invoice['InvoiceID']) + ->addWhere('accounts_invoice_id', '=', $invoice->getInvoiceId()) ->execute() ->first(); try { @@ -141,7 +138,7 @@ public function pull(array $params): array { } } catch (CRM_Core_Exception $e) { - $errors[] = E::ts('Failed to store %1 (%2)', [1 => $invoice['InvoiceNumber'], 2 => $invoice['InvoiceID']]) + $errors[] = E::ts('Failed to store %1 (%2)', [1 => $invoice->getInvoiceNumber(), 2 => $invoice->getInvoiceId()]) . E::ts(' with error ') . $e->getMessage(); } } @@ -680,9 +677,14 @@ public static function getTaxModes(): array { } /** + * @param \XeroAPI\XeroPHP\Models\Accounting\Invoice $invoice + * @param array $accountInvoiceParams + * * @return bool + * @throws \CRM_Core_Exception + * @throws \Civi\API\Exception\UnauthorizedException */ - private function createContributionFromAccountsInvoice(array $invoice, array $accountInvoiceParams): bool { + private function createContributionFromAccountsInvoice(\XeroAPI\XeroPHP\Models\Accounting\Invoice $invoice, array $accountInvoiceParams): bool { $accountInvoiceParams = AccountInvoice::get(FALSE) ->addWhere('accounts_invoice_id', '=', $accountInvoiceParams['accounts_invoice_id']) ->execute() @@ -692,7 +694,7 @@ private function createContributionFromAccountsInvoice(array $invoice, array $ac return FALSE; } - $accountsContactID = $invoice['Contact']['ContactID'] ?? NULL; + $accountsContactID = $invoice->getContact()->getContactId() ?? NULL; if (empty($accountsContactID)) { $errorMessage = __FUNCTION__ . ': missing ContactID in AccountsInvoice'; $this->recordAccountInvoiceError($accountInvoiceParams['id'], $errorMessage); @@ -727,10 +729,10 @@ private function createContributionFromAccountsInvoice(array $invoice, array $ac ->addValue('contribution_status_id:name', 'Pending') ->addValue('contact_id', $accountContact['contact_id']) ->addValue('financial_type_id.name', 'Donation') - ->addValue('receive_date', date('YmdHis', strtotime($invoice['Date']))) - ->addValue('total_amount', $invoice['Total']) - ->addValue('currency', $invoice['CurrencyCode']) - ->addValue('source', 'Xero: ' . $invoice['InvoiceNumber'] . ' ' . $invoice['Reference']) + ->addValue('receive_date', $invoice->getDateAsDate()->format('YmdHis')) + ->addValue('total_amount', $invoice->getTotal()) + ->addValue('currency', $invoice->getCurrencyCode()) + ->addValue('source', 'Xero: ' . $invoice->getInvoiceNumber() . ' ' . $invoice->getReference()) ->execute() ->first(); AccountInvoice::update(FALSE) From dd144c5b9c1651bd3cce3ac2fe8aa7dbb98714cb Mon Sep 17 00:00:00 2001 From: Matthew Wire Date: Thu, 22 Dec 2022 19:56:11 +0000 Subject: [PATCH 5/5] MJW version --- info.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/info.xml b/info.xml index 0bca00fd..fea3d264 100644 --- a/info.xml +++ b/info.xml @@ -16,8 +16,8 @@ eileen eileen@fuzion.co.nz - 2024-08-22 - 3.0.1 + 2024-11-05 + 99.MJW.3.0.1 stable 5.74