Commit 934c7e0f authored by Mathieu Lutfy's avatar Mathieu Lutfy Committed by Aegir user

infrastructure/ops#856 Add civixero extension

parent da59a4c6
<?php
/**
* Class CRM_Civixero_BankTransaction.
*
* This class is intended to be used as an alternative to invoice push.
*
* It largely inherits the invoice class but creates Bank transaction
* (payment receipt) records instead of CiviCRM.
*
* To choose to push transactions as bank receipts rather than invoices
* you need to configure the Banktransaction.Push api as a scheduled job
* rather than an invoice push.
*
* This is envisaged as a one way job and a 'pull' is not anticipated.
*
* The two actions differ in which Xero entity they map to and the field
* mappings but are otherwise the same.
*/
class CRM_Civixero_BankTransaction extends CRM_Civixero_Invoice {
/**
* Name in Xero of entity being pushed.
*
* @var string
*/
protected $xero_entity = 'BankTransaction';
/**
* Push record to Xero.
*
* @param array $accountsInvoice
*
* @param int $connector_id
* ID of the connector (0 if nz.co.fuzion.connectors not installed.
*
* @return array
*/
protected function pushToXero($accountsInvoice, $connector_id) {
if ($accountsInvoice === FALSE) {
return FALSE;
}
$result = $this->getSingleton($connector_id)->BankTransactions($accountsInvoice);
return $result;
}
/**
* Map civicrm Array to Accounts package field names.
*
* @param array $invoiceData - require
* contribution fields
* - line items
* - receive date
* - source
* - contact_id
* @param int $accountsID
*
* @return array|bool
* BankTransaction Object/ array as expected by accounts package.
*/
protected function mapToAccounts($invoiceData, $accountsID) {
$lineItems = array();
foreach ($invoiceData['line_items'] as $lineItem) {
if ($this->connector_id != 0
&& $this->getAccountsContact()
&& $lineItem['accounts_contact_id'] != $this->getAccountsContact()) {
// We have configured the connect to be account specific and we are
// dealing with an account not related to this connector.
// This can result (intentionally) in some line items being pushed
// to one connector and some to another. To avoid this don't put a
// contact_id on the connector account.
continue;
}
$lineItems[] = array(
"Description" => $lineItem['display_name'] . ' ' . str_replace(array('&nbsp;'), ' ', $lineItem['label']),
"Quantity" => $lineItem['qty'],
"UnitAmount" => $lineItem['unit_price'],
"AccountCode" => !empty($lineItem['accounting_code']) ? $lineItem['accounting_code'] : $this->getDefaultAccountCode(),
);
}
$new_invoice = array(
"Type" => "RECEIVE",
"Contact" => array(
"ContactNumber" => $invoiceData['contact_id'],
),
"Date" => substr($invoiceData['receive_date'], 0, 10),
"Status" => "AUTHORISED",
"CurrencyCode" => CRM_Core_Config::singleton()->defaultCurrency,
"Reference" => $invoiceData['display_name'] . ' ' . $invoiceData['contribution_source'],
"LineAmountTypes" => "Inclusive",
'LineItems' => array('LineItem' => $lineItems),
'BankAccount' => array(
'Code' => $invoiceData['payment_instrument_accounting_code'],
),
'Url' => CRM_Utils_System::url(
'civicrm/contact/view/contribution',
array('reset' => 1, 'id' => $invoiceData['id'], 'action' => 'view'),
TRUE
),
);
if ($accountsID) {
$new_invoice['BankTransactionID'] = $accountsID;
}
$proceed = TRUE;
CRM_Accountsync_Hook::accountPushAlterMapped('bank_transaction', $invoiceData, $proceed, $new_invoice);
if (!$proceed) {
return FALSE;
}
$this->validatePrerequisites($new_invoice);
$new_invoice = array(
$new_invoice,
);
return $new_invoice;
}
/**
* Should transactions be split to go to different accounts based on the line items.
*
* Currently we just say 'yes' for bank transactions and 'no' for invoices but
* in future we may do a setting for this. Although we don't particularly envisage
* invoices ever being split.
*
* Splitting only works if the nz.co.fuzion.connectors extension is installed.
*
* @return bool
*/
protected function isSplitTransactions() {
return TRUE;
}
/**
* Get a list of responses indicating the transaction cannot be updated.
*
* @return array
*/
protected function getNotUpdateCandidateResponses() {
return array(
'This Bank Transaction cannot be edited as it has been reconciled with a Bank Statement.',
);
}
}
<?php
/**
* Class CRM_Civixero_Base
*
* Base class for classes that interact with Xero using push and pull methods.
*/
class CRM_Civixero_Base {
private static $singleton;
private $_xero_key;
private $_xero_secret;
private $_xero_public_certificate;
private $_xero_private_key;
protected $_plugin = 'xero';
protected $accounts_contact;
/**
* Connector ID.
*
* This will be 0 if nz.co.fuzion.connectors is not being used.
*
* @var int
*/
protected $connector_id;
/**
* Class constructor.
*
* @param array $parameters
*
* @throws \CRM_Core_Exception
*/
public function __construct($parameters = array()) {
$force = FALSE;
$this->connector_id = CRM_Utils_Array::value('connector_id', $parameters, 0);
$variables = array(
'xero_key',
'xero_secret',
'xero_public_certificate',
'xero_private_key',
);
foreach ($variables as $var) {
$value = CRM_Utils_Array::value($var, $parameters);
if (empty($value)) {
$value = $this->getSetting($var);
}
if ($value != $this->{'_' . $var}) {
$force = TRUE;
$this->{'_' . $var} = $value;
}
if (empty($value)) {
throw new CRM_Core_Exception($var . ts(' has not been set'));
}
}
$this->singleton($this->_xero_key, $this->_xero_secret, $this->_xero_public_certificate, $this->_xero_private_key, $this->connector_id, $force);
}
/**
* Get the contact that the connector account is associated with.
*
* This is the domain contact by default.
*
* The nz.co.fuzion.connectors extension is required to use more than one account.
*
* @return array
* @throws \CiviCRM_API3_Exception
*/
protected function getAccountsContact() {
if (empty($this->accounts_contact)) {
if (empty($this->connector_id)) {
$this->accounts_contact = civicrm_api3('domain', 'getvalue', array(
'current_domain' => TRUE,
'return' => 'contact_id',
));
}
else {
$this->accounts_contact = civicrm_api3('connector', 'getvalue', array(
'id' => $this->connector_id,
'return' => 'contact_id',
));
}
}
return $this->accounts_contact;
}
/**
* Set the accounts contact.
*
* The nz.co.fuzion.connectors extension is required to use more than one account.
*
* @param int $contact_id
* Accounts contact ID. This is recorded in the civicrm_financial_type table
* and in the civicrm_connector table.
*/
protected function setAccountsContact($contact_id) {
$this->accounts_contact = $contact_id;
}
/**
* Singleton function.
*
* @param string $civixero_key
* @param string $civixero_secret
* @param string $publicCertificate
* @param string $privateKey
* @param int $connector_id
* @param bool $force
*
* @return \CRM_Extension_System
*/
protected function singleton($civixero_key, $civixero_secret, $publicCertificate, $privateKey, $connector_id, $force = FALSE) {
if (!self::$singleton[$connector_id] || $force) {
require_once 'packages/Xero/Xero.php';
self::$singleton[$connector_id] = new Xero($civixero_key, $civixero_secret, $publicCertificate, $privateKey);
}
return self::$singleton[$connector_id];
}
/**
* Get instance of Xero object for connecting with Xero.
*
* @param int $connector_id
* The connector ID that is being synced. Unless nz.co.fuzion.connectors is
* in play this will be 0.
*
* @return Xero
*/
protected function getSingleton($connector_id) {
$this->connector_id = $connector_id;
return self::$singleton[$connector_id];
}
/**
* Get Xero Setting.
*
* @param string $var
*
* @return mixed
*/
protected function getSetting($var) {
if ($this->connector_id > 0) {
static $connectors = array();
if (empty($connectors[$this->connector_id])) {
$connector = civicrm_api3('connector', 'getsingle', array('id' => $this->connector_id));
$connectors[$this->connector_id] = array(
'xero_key' => $connector['field1'],
'xero_secret' => $connector['field2'],
'xero_public_certificate' => $connector['field3'],
'xero_private_key' => $connector['field4'],
// @todo not yet configurable per selector.
'xero_default_invoice_status' => 'SUBMITTED',
);
}
return $connectors[$this->connector_id][$var];
}
else {
return civicrm_api3('setting', 'getvalue', array(
'name' => $var,
'group' => 'Xero Settings',
));
}
}
/**
* Convert date to form expected by Xero.
*
* @param string $date date in mysql format (since it is coming through the api)
*
* @return string
* Formatted date
*/
protected function formatDateForXero($date) {
return date("Y-m-d H:m:s", strtotime(CRM_Utils_Date::mysqlToIso($date)));
}
/**
* Validate Response from Xero.
*
* Unfortunately our Xero class doesn't pass summariseErrors so we don't have that to use :-(
*
* http://developer.xero.com/documentation/getting-started/http-requests-and-responses/#post-put-creating-many
*
* @param array $response Response From Xero
*
* @return array|bool
* @throws \CRM_Civixero_Exception_XeroThrottle
* @throws \Exception
*/
protected function validateResponse($response) {
$message = '';
$errors = array();
// Comes back as a string for oauth errors.
if (is_string($response)) {
$responseParts = explode('&', urldecode($response));
if (CRM_Utils_Array::value(0, $responseParts) == 'oauth_problem=token_rejected') {
throw new Exception('Invalid credentials');
}
throw new CRM_Civixero_Exception_XeroThrottle($responseParts['oauth_problem']);
}
if (!empty($response['ErrorNumber'])) {
$errors[] = $response['Message'];
}
if (!empty($response['Elements']) && is_array($response['Elements']['DataContractBase']['ValidationErrors'])) {
foreach ($response['Elements']['DataContractBase']['ValidationErrors'] as $key => $value) {
// we have a situation where the validation errors are an array of errors
// original code expected a string - not sure if / when that might happen
// this is all a bit of a hackathon @ the moment
if (is_array($value[0])) {
foreach ($value as $errorMessage) {
if (trim($errorMessage['Message']) == 'Account code must be specified') {
return array(
'You need to set up the account code',
);
}
$message .= " " . $errorMessage['Message'];
}
}
else {
// Single message - string
$message = $value['Message'];
}
switch (trim($message)) {
case "The Contact Name already exists. Please enter a different Contact Name.":
$contact = $response['Elements']['DataContractBase']['Contact'];
$message .= "<br>contact ID is " . $contact['ContactNumber'];
$message .= "<br>contact name is " . $contact['Name'];
$message .= "<br>contact email is " . $contact['EmailAddress'];
break;
case "The TaxType field is mandatory Account code must be specified":
$message = "Account code needs setting up";
}
$errors[] = $message;
}
}
return is_array($errors) ? $errors : FALSE;
}
}
<?php
class CRM_Civixero_Contact extends CRM_Civixero_Base {
/**
* Pull contacts from Xero and store them into civicrm_account_contact.
*
* We call the civicrm_accountPullPreSave hook so other modules can alter if required
*
* @param array $params
*
* @throws API_Exception
* @throws CRM_Core_Exception
*/
public function pull($params) {
try {
$result = $this->getSingleton($params['connector_id'])
->Contacts(FALSE, $this->formatDateForXero($params['start_date']));
if (!is_array($result)) {
throw new API_Exception('Sync Failed', 'xero_retrieve_failure', (array) $result);
}
if (!empty($result['Contacts'])) {
$contacts = $result['Contacts']['Contact'];
if (isset($contacts['ContactID'])) {
// the return syntax puts the contact only level higher up when only one contact is involved
$contacts = array($contacts);
}
foreach ($contacts as $contact) {
$save = TRUE;
$params = array(
'accounts_display_name' => $contact['Name'],
'contact_id' => CRM_Utils_Array::value('ContactNumber', $contact),
'accounts_modified_date' => $contact['UpdatedDateUTC'],
'plugin' => 'xero',
'accounts_contact_id' => $contact['ContactID'],
'accounts_data' => json_encode($contact),
);
CRM_Accountsync_Hook::accountPullPreSave('contact', $contact, $save, $params);
if (!$save) {
continue;
}
try {
$params['id'] = civicrm_api3('account_contact', 'getvalue', array(
'return' => 'id',
'accounts_contact_id' => $contact['ContactID'],
'plugin' => $this->_plugin,
));
}
catch (CiviCRM_API3_Exception $e) {
// this is an update - but lets just check the contact id doesn't exist in the account_contact table first
// e.g if a list has been generated but not yet pushed
try {
$existing = civicrm_api3('account_contact', 'getsingle', array(
'return' => 'id',
'contact_id' => $contact['ContactNumber'],
'plugin' => $this->_plugin,
));
if (!empty($existing['accounts_contact_id']) && $existing['accounts_contact_id'] != $contact['ContactID']) {
// no idea how this happened or what it means - calling function can catch & deal with it
throw new CRM_Core_Exception(ts('Cannot update contact'), 'data_error', $contact);
}
}
catch (CiviCRM_API3_Exception $e) {
// ok - it IS an update
}
}
try {
civicrm_api3('account_contact', 'create', $params);
}
catch (CiviCRM_API3_Exception $e) {
CRM_Core_Session::setStatus(ts('Failed to store ') . $params['accounts_display_name']
. ts(' with error ') . $e->getMessage(),
ts('Contact Pull failed'));
}
}
}
}
catch (CRM_Civixero_Exception_XeroThrottle $e) {
throw new CRM_Core_Exception('Contact Pull aborted due to throttling by Xero');
}
}
/**
* Push contacts to Xero from the civicrm_account_contact with 'needs_update' = 1.
*
* We call the civicrm_accountPullPreSave hook so other modules can alter if required
*
* @param array $params
* - start_date
*
* @return bool
* @throws CRM_Core_Exception
* @throws CiviCRM_API3_Exception
*/
public function push($params) {
try {
$records = civicrm_api3('account_contact', 'get', array(
'accounts_needs_update' => 1,
'api.contact.get' => 1,
'plugin' => $this->_plugin,
'connector_id' => $params['connector_id'],
)
);
$errors = array();
//@todo pass limit through from params to get call
foreach ($records['values'] as $record) {
try {
$accountsContactID = !empty($record['accounts_contact_id']) ? $record['accounts_contact_id'] : NULL;
$accountsContact = $this->mapToAccounts($record['api.contact.get']['values'][0], $accountsContactID);
if ($accountsContact === FALSE) {
$result = FALSE;
$responseErrors = array();
}
else {
$result = $this->getSingleton($params['connector_id'])->Contacts($accountsContact);
$responseErrors = $this->validateResponse($result);
}
if ($result === FALSE) {
unset($record['accounts_modified_date']);
}
elseif ($responseErrors) {
$record['error_data'] = json_encode($responseErrors);
}
else {
/* When Xero returns an ID that matches an existing account_contact, update it instead. */
$matching = civicrm_api('account_contact', 'getsingle', array(
'accounts_contact_id' => $result['Contacts']['Contact']['ContactID'],
'contact_id' => array('!=' => $record['contact_id']),
'plugin' => $this->_plugin,
'version' => 3,
)
);
if(!$matching['is_error']) {
if(empty($matching['contact_id']) ||
civicrm_api3('contact', 'getvalue', array('id' => $matching['contact_id'], 'return' => 'contact_is_deleted'))) {
CRM_Core_Error::debug_log_message(ts('Updating existing contact for %1', array(1 => $record['contact_id'])));
civicrm_api3('account_contact', 'delete', array('id' => $record['id']));
$record['do_not_sync'] = 0;
$record['id'] = $matching['id'];
}
else {
throw new CiviCRM_API3_Exception(ts('Attempt to sync Contact %1 to Xero entry for existing Contact %2. ', array(1 => $record['contact_id'], 2 => $matching['contact_id']), NULL, $record));
}
}
$record['error_data'] = 'null';
if (empty($record['accounts_contact_id'])) {
$record['accounts_contact_id'] = $result['Contacts']['Contact']['ContactID'];
}
$record['accounts_modified_date'] = $result['Contacts']['Contact']['UpdatedDateUTC'];
$record['accounts_data'] = json_encode($result['Contacts']['Contact']);
$record['accounts_display_name'] = $result['Contacts']['Contact']['Name'];
}
// This will update the last sync date.
$record['accounts_needs_update'] = 0;
unset($record['last_sync_date']);
civicrm_api3('account_contact', 'create', $record);
} catch (CiviCRM_API3_Exception $e) {
$errors[] = ts('Failed to push ') . $record['contact_id'] . ' (' . $record['accounts_contact_id'] . ' )'
. ts(' with error ') . $e->getMessage() . print_r($responseErrors, TRUE)
. ts('Contact Push failed');
}
}
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 contacts were saved') . print_r($errors, TRUE), 'incomplete', $errors);
}
return TRUE;
}
catch (CRM_Civixero_Exception_XeroThrottle $e) {
throw new CRM_Core_Exception('Contact Push aborted due to throttling by Xero');
}
}
/**
* Map civicrm Array to Accounts package field names.
*
* @param array $contact
* Contact Array as returned from API
* @param $accountsID
*
* @return array|bool
* Contact Object/ array as expected by accounts package
*/
protected function mapToAccounts($contact, $accountsID) {
$new_contact = array(
"Name" => $contact['display_name'] . " - " . $contact['contact_id'],
"FirstName" => $contact['first_name'],
"LastName" => $contact['last_name'],
"EmailAddress" => CRM_Utils_Rule::email($contact['email']) ? $contact['email'] : '',
"ContactNumber" => $contact['contact_id'],
"Addresses" => array(
"Address" => array(
array(
"AddressType" => 'POBOX', // described in documentation as the default mailing address for invoices http://blog.xero.com/developer/api/types/#Addresses
"AddressLine1" => $contact['street_address'],
"City" => $contact['city'],
"PostalCode" => $contact['postal_code'],
"AddressLine2" => CRM_Utils_Array::value('supplemental_address_1', $contact, ''),
"AddressLine3" => CRM_Utils_Array::value('supplemental_address_2', $contact, ''),
"AddressLine4" => CRM_Utils_Array::value('supplemental_address_3', $contact, ''),
"Country" => CRM_Utils_Array::value('country', $contact, ''),
"Region" => CRM_Utils_Array::value('state_province_name', $contact, ''),
),
),
),
"Phones" => array(
"Phone" => array(
"PhoneType" => 'DEFAULT',
"PhoneNumber" => $contact['phone']
)
)
);
if (!empty($accountsID)) {
$new_contact['ContactID'] = $accountsID;
}
$proceed = TRUE;
CRM_Accountsync_Hook::accountPushAlterMapped('contact', $contact, $proceed, $new_contact);
$new_contact = array(
$new_contact
);
if (!$proceed) {
return FALSE;
}
return $new_contact;
}
}
<?php
/**
* Created by PhpStorm.
* User: eileen
* Date: 8/12/2014
* Time: 10:33 AM
*/
class CRM_Civixero_Exception_XeroThrottle extends Exception {
private $id;
/**
* Class constructor.
*
* @param string $message
*/
public function __construct($message) {
parent::__construct(ts($message));
CRM_Core_Error::debug_log_message('Oath rate exceeded');
}
}
<?php
/**
* Form controller class
*
* @see http://wiki.civicrm.org/confluence/display/CRMDOC43/QuickForm+Reference
*/
class CRM_Civixero_Form_XeroSettings extends CRM_Core_Form {
private $_settingFilter = array('group' => 'xero');
//everything from this line down is generic & can be re-used for a setting form in another extension
//actually - I lied - I added a specific call in getFormSettings
private $_submittedValues = array();
private $_settings = array();
function buildQuickForm() {
$settings = $this->getFormSettings();
foreach ($settings as $name => $setting) {
if (isset($setting['quick_form_type'])) {
$add = 'add' . $setting['quick_form_type'];
if ($add == 'addElement') {
$this->$add($setting['html_type'], $name, ts($setting['title']), CRM_Utils_Array::value('html_attributes', $setting, array ()));
}
elseif ($setting['html_type'] == 'Select') {
$optionValues = array();
if (!empty($setting['pseudoconstant'])) {
if(!empty($setting['pseudoconstant']['optionGroupName'])) {
$optionValues = CRM_Core_OptionGroup::values($setting['pseudoconstant']['optionGroupName'], FALSE, FALSE, FALSE, NULL, 'name');
}
elseif (!empty($setting['pseudoconstant']['callback'])) {
$cb = Civi\Core\Resolver::singleton()->get($setting['pseudoconstant']['callback']);
$optionValues = call_user_func_array($cb, $optionValues);
}
}
$this->add('select', $setting['name'], $setting['title'], $optionValues, FALSE, $setting['html_attributes']);
}
else {
$this->$add($name, ts($setting['title']));
}
$this->assign("{$setting['description']}_description", ts('description'));
}
}
$this->addButtons(array(
array (
'type' => 'submit',
'name' => ts('Submit'),
'isDefault' => TRUE,
)
));
// export form elements
$this->assign('elementNames', $this->getRenderableElementNames());