Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MJW branch #158

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions CRM/Civixero/Contact.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 ?? []];
}

/**
Expand All @@ -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) {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 ?? [];
}

/**
Expand Down
84 changes: 42 additions & 42 deletions CRM/Civixero/Invoice.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,44 +36,41 @@ 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 {
$xeroParams = ['Type' => 'ACCREC'];
$filter = $params['xero_invoice_id'] ?? $params['invoice_number'] ?? FALSE;
$count = 0;
public function pull(array $params): array {
$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);
Expand All @@ -91,7 +88,7 @@ public function pull(array $params): int {
$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 {
Expand Down Expand Up @@ -141,7 +138,7 @@ public function pull(array $params): int {
}
}
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();
}
}
Expand All @@ -150,7 +147,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 ?? []];
}

/**
Expand All @@ -164,17 +161,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);
Expand All @@ -190,7 +186,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');
Expand All @@ -212,13 +207,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 ?? [];
}

/**
Expand Down Expand Up @@ -682,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()
Expand All @@ -694,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);
Expand Down Expand Up @@ -729,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)
Expand Down
118 changes: 100 additions & 18 deletions CRM/Civixero/Item.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

}
Loading