diff --git a/CRM/Financeextras/BAO/CreditNoteImporter.php b/CRM/Financeextras/BAO/CreditNoteImporter.php new file mode 100644 index 00000000..834a7144 --- /dev/null +++ b/CRM/Financeextras/BAO/CreditNoteImporter.php @@ -0,0 +1,8 @@ +rowData = $rowData; + } + + /** + * Imports the row. + * + * @return int + * + * @throws CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException + * @throws Throwable + */ + public function import(): int { + $this->validateRequiredFields(); + + $this->contactId = $this->resolveContactId(); + $this->ownerOrganisationId = $this->resolveOwnerOrganisationId(); + $this->financialTypeId = $this->resolveFinancialTypeId($this->rowData['line_financial_type']); + $externalId = (string) $this->rowData['credit_note_external_id']; + $existingCreditNoteId = $this->findCreditNoteIdByExternalId($externalId); + $lineItem = $this->buildLineItemPayload(); + $transaction = CRM_Core_Transaction::create(); + try { + if ($existingCreditNoteId === NULL) { + $creditNoteId = $this->createCreditNoteWithFirstLine($lineItem, $externalId); + } + else { + $creditNoteId = $existingCreditNoteId; + $this->appendLineToExistingCreditNote($creditNoteId, $lineItem); + } + } + catch (\Throwable $e) { + $transaction->rollback(); + throw $e; + } + + $transaction->commit(); + + return $creditNoteId; + } + + /** + * Validates that the minimum set of CSV columns is present. + * + * @throws CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException + */ + private function validateRequiredFields(): void { + $required = [ + 'credit_note_external_id', + 'currency', + 'line_unit_price', + 'line_financial_type', + ]; + foreach ($required as $field) { + if (!isset($this->rowData[$field]) || empty($this->rowData[$field])) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + sprintf(ts('Missing required field "%s".'), $field) + ); + } + } + + if (empty($this->rowData['contact_id']) && empty($this->rowData['contact_external_id'])) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + 'Either "contact_id" or "contact_external_id" is required.' + ); + } + + if (empty($this->rowData['owner_organization_id']) && empty($this->rowData['owner_organization_external_id'])) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + 'Either "owner_organization_id" or "owner_organization_external_id" is required.' + ); + } + } + + /** + * Resolves the customer contact ID. + * + * @throws CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException + */ + private function resolveContactId(): int { + if (!empty($this->rowData['contact_id'])) { + $contact = \Civi\Api4\Contact::get(FALSE) + ->addSelect('id') + ->addWhere('id', '=', $this->rowData['contact_id']) + ->addWhere('is_deleted', '=', FALSE) + ->execute() + ->first(); + if (empty($contact)) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + sprintf('Cannot find contact with id "%s".', $this->rowData['contact_id']) + ); + } + return (int) $contact['id']; + } + + $contact = \Civi\Api4\Contact::get(FALSE) + ->addSelect('id') + ->addWhere('external_identifier', '=', $this->rowData['contact_external_id']) + ->addWhere('is_deleted', '=', FALSE) + ->execute() + ->first(); + if (empty($contact)) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + sprintf('Cannot find contact with external identifier "%s".', $this->rowData['contact_external_id']) + ); + } + return (int) $contact['id']; + } + + /** + * Resolves the owning organisation contact ID. + * + * @throws CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException + */ + private function resolveOwnerOrganisationId(): int { + if (!empty($this->rowData['owner_organization_id'])) { + $org = \Civi\Api4\Contact::get(FALSE) + ->addSelect('id') + ->addWhere('id', '=', $this->rowData['owner_organization_id']) + ->addWhere('contact_type', '=', 'Organization') + ->addWhere('is_deleted', '=', FALSE) + ->execute() + ->first(); + if (empty($org)) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + sprintf('Cannot find organisation with id "%s".', $this->rowData['owner_organization_id']) + ); + } + return (int) $org['id']; + } + + $org = \Civi\Api4\Contact::get(FALSE) + ->addSelect('id') + ->addWhere('external_identifier', '=', $this->rowData['owner_organization_external_id']) + ->addWhere('contact_type', '=', 'Organization') + ->addWhere('is_deleted', '=', FALSE) + ->execute() + ->first(); + if (empty($org)) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + sprintf('Cannot find organisation with external identifier "%s".', $this->rowData['owner_organization_external_id']) + ); + } + return (int) $org['id']; + } + + /** + * Resolves a financial type by name. + * + * @param string $financialTypeName + * + * @throws CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException + */ + private function resolveFinancialTypeId(string $financialTypeName): int { + if (!isset($this->cachedValues['financial_types'])) { + $this->cachedValues['financial_types'] = []; + $financialTypes = \Civi\Api4\FinancialType::get(FALSE) + ->addSelect('id', 'name') + ->addWhere('is_active', '=', TRUE) + ->execute(); + foreach ($financialTypes as $type) { + $this->cachedValues['financial_types'][$type['name']] = (int) $type['id']; + } + } + + if (empty($this->cachedValues['financial_types'][$financialTypeName])) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + sprintf('Invalid line financial type "%s".', $financialTypeName) + ); + } + + return $this->cachedValues['financial_types'][$financialTypeName]; + } + + /** + * Returns the credit note date, defaulting to today's date. + * + * @param mixed $value + * + * @throws CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException + */ + private function resolveCreditNoteDate($value): string { + if (empty($value)) { + return date('Y-m-d'); + } + + $timestamp = strtotime((string) $value); + if ($timestamp === FALSE) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + sprintf('Could not parse date "%s". Use a format like YYYY-MM-DD.', $value) + ); + } + + if ($timestamp <= 0) { + return date('Y-m-d'); + } + + return date('Y-m-d', $timestamp); + } + + /** + * Builds a line-item payload. + * + * @return array + */ + private function buildLineItemPayload(): array { + $quantity = !empty($this->rowData['line_quantity']) ? (float) $this->rowData['line_quantity'] : 1; + $unitPrice = (float) $this->rowData['line_unit_price']; + + return [ + 'description' => $this->rowData['line_description'] ?? '', + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'financial_type_id' => $this->financialTypeId, + 'tax_rate' => $this->getTaxRateForFinancialType($this->financialTypeId), + ]; + } + + /** + * Returns the tax rate (percentage) configured for the given financial type. + */ + private function getTaxRateForFinancialType(int $financialTypeId): float { + if (!isset($this->cachedValues['tax_rates'])) { + $this->cachedValues['tax_rates'] = \CRM_Core_PseudoConstant::getTaxRates(); + } + + return (float) ($this->cachedValues['tax_rates'][$financialTypeId] ?? 0); + } + + /** + * Looks up an existing credit note by external id. + * + * @throws CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException + */ + private function findCreditNoteIdByExternalId(string $externalId): ?int { + if (!\CRM_Core_DAO::checkTableExists('civicrm_value_credit_note_ext_id')) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + 'One required custom group is missing. Run the financeextras upgrade before importing credit notes.' + ); + } + + $id = \CRM_Core_DAO::singleValueQuery( + 'SELECT entity_id FROM civicrm_value_credit_note_ext_id WHERE external_id = %1', + [1 => [$externalId, 'String']] + ); + return $id !== NULL ? (int) $id : NULL; + } + + /** + * Persists the external-id <-> credit-note mapping. + * + * See the comment on findCreditNoteIdByExternalId() for why we use + * raw SQL here instead of APIv4 update with the custom field. + */ + private function recordExternalIdForCreditNote(int $creditNoteId, string $externalId): void { + \CRM_Core_DAO::executeQuery( + 'INSERT INTO civicrm_value_credit_note_ext_id (entity_id, external_id) VALUES (%1, %2)', + [ + 1 => [$creditNoteId, 'Integer'], + 2 => [$externalId, 'String'], + ] + ); + } + + /** + * Creates a brand-new credit note with its first line item. + * + * @param array $lineItem + * Line-item payload built by buildLineItemPayload(). + * @param string $externalId + * The external identifier supplied for the credit note. + */ + private function createCreditNoteWithFirstLine(array $lineItem, string $externalId): int { + $totals = \CRM_Financeextras_BAO_CreditNote::computeTotalAmount([$lineItem]); + + $optionValue = \Civi\Api4\OptionValue::get(FALSE) + ->addSelect('value') + ->addWhere('option_group_id:name', '=', 'financeextras_credit_note_status') + ->addWhere('name', '=', 'open') + ->execute() + ->first(); + + $creditNoteData = [ + 'contact_id' => $this->contactId, + 'owner_organization' => $this->ownerOrganisationId, + 'cn_number' => !empty($this->rowData['cn_number']) ? $this->rowData['cn_number'] : NULL, + 'date' => $this->resolveCreditNoteDate($this->rowData['date'] ?? NULL), + 'status_id' => ($optionValue && $optionValue['value']) ? $optionValue['value'] : NULL, + 'reference' => $this->rowData['reference'] ?? NULL, + 'currency' => $this->rowData['currency'], + 'description' => $this->rowData['description'] ?? '', + 'comment' => $this->rowData['comment'] ?? '', + 'subtotal' => $totals['totalBeforeTax'], + 'sales_tax' => array_sum(array_column($totals['taxRates'], 'value')), + 'total_credit' => $totals['totalAfterTax'], + ]; + + $created = \CRM_Financeextras_BAO_CreditNote::createWithAccountingEntries($creditNoteData, $this->financialTypeId); + $creditNote = $created['creditNote']; + $financialTrxn = $created['financialTrxn']; + + $this->recordExternalIdForCreditNote((int) $creditNote['id'], $externalId); + + \CRM_Financeextras_BAO_CreditNoteLine::createWithAcountingEntries([$lineItem], $creditNote, $financialTrxn); + + return (int) $creditNote['id']; + } + + /** + * Adds an additional line and its accounting entries to a credit note. + * + * @param int $creditNoteId + * @param array $lineItem + */ + private function appendLineToExistingCreditNote(int $creditNoteId, array $lineItem): void { + $creditNote = \Civi\Api4\CreditNote::get(FALSE) + ->addWhere('id', '=', $creditNoteId) + ->execute() + ->first(); + + if (empty($creditNote)) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + sprintf('Credit note with id "%d" referenced by mapping table no longer exists.', $creditNoteId) + ); + } + + $this->assertRowIsConsistentWithCreditNote($creditNote); + + $financialTrxn = $this->getCreditNoteFinancialTrxn($creditNoteId); + if (empty($financialTrxn)) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException( + sprintf('Credit note "%d" is missing its financial transaction; cannot append line.', $creditNoteId) + ); + } + + \CRM_Financeextras_BAO_CreditNoteLine::createWithAcountingEntries([$lineItem], $creditNote, $financialTrxn); + + [$newSubtotal, $newSalesTax, $newTotalCredit] = $this->aggregateLineTotals($creditNoteId); + + $this->setCreditNoteTotals($creditNoteId, $newSubtotal, $newSalesTax, $newTotalCredit); + $this->setFinancialTrxnTotals($financialTrxn, $creditNoteId, $newTotalCredit); + } + + /** + * Guards against appending a row to a credit note with wrong customer or owning organisation. + * + * @param array $creditNote + * APIv4 CreditNote::get result for the existing credit note. + * + * @throws CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException + */ + private function assertRowIsConsistentWithCreditNote(array $creditNote): void { + if ((int) $creditNote['contact_id'] !== $this->contactId) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException(sprintf( + 'Row references an existing credit note (id %d, external id "%s") but its contact id (%d) does not match the credit note\'s contact (%d). All rows that share a credit_note_external_id must use the same contact.', + $creditNote['id'], + $this->rowData['credit_note_external_id'], + $this->contactId, + $creditNote['contact_id'] + )); + } + + if ((int) $creditNote['owner_organization'] !== $this->ownerOrganisationId) { + throw new CRM_Financeextras_CreditNoteImporter_Exception_InvalidRowException(sprintf( + 'Row references an existing credit note (id %d, external id "%s") but its owner organisation (%d) does not match the credit note\'s owner (%d). All rows that share a credit_note_external_id must use the same owner organisation.', + $creditNote['id'], + $this->rowData['credit_note_external_id'], + $this->ownerOrganisationId, + $creditNote['owner_organization'] + )); + } + } + + /** + * Aggregates the line totals already persisted for the credit note. + * + * Returns [subtotal, sales_tax, total_credit]. + */ + private function aggregateLineTotals(int $creditNoteId): array { + $row = \Civi\Api4\CreditNoteLine::get(FALSE) + ->addSelect('SUM(line_total) AS subtotal', 'SUM(tax_amount) AS sales_tax') + ->addWhere('credit_note_id', '=', $creditNoteId) + ->execute() + ->first(); + + if (!is_array($row)) { + return [0.0, 0.0, 0.0]; + } + + $subtotal = (float) ($row['subtotal'] ?? 0); + $salesTax = (float) ($row['sales_tax'] ?? 0); + + return [ + $subtotal, + $salesTax, + $subtotal + $salesTax, + ]; + } + + /** + * Returns the single financial transaction associated with a credit note. + */ + private function getCreditNoteFinancialTrxn(int $creditNoteId): array { + $entityTrxn = \Civi\Api4\EntityFinancialTrxn::get(FALSE) + ->addSelect('financial_trxn_id') + ->addWhere('entity_table', '=', \CRM_Financeextras_DAO_CreditNote::$_tableName) + ->addWhere('entity_id', '=', $creditNoteId) + ->addOrderBy('id') + ->setLimit(1) + ->execute() + ->first(); + + if (empty($entityTrxn) || empty($entityTrxn['financial_trxn_id'])) { + return []; + } + + return \Civi\Api4\FinancialTrxn::get(FALSE) + ->addWhere('id', '=', $entityTrxn['financial_trxn_id']) + ->execute() + ->first() ?? []; + } + + /** + * Sets stored totals on the credit note record to the supplied values. + */ + private function setCreditNoteTotals(int $creditNoteId, float $subtotal, float $salesTax, float $totalCredit): void { + \Civi\Api4\CreditNote::update(FALSE) + ->addWhere('id', '=', $creditNoteId) + ->addValue('subtotal', $subtotal) + ->addValue('sales_tax', $salesTax) + ->addValue('total_credit', $totalCredit) + ->execute(); + } + + /** + * Sets the totals on the financial transaction backing the credit note. + * + * @param array $financialTrxn + * @param int $creditNoteId + * @param float $totalCredit + */ + private function setFinancialTrxnTotals(array $financialTrxn, int $creditNoteId, float $totalCredit): void { + $negatedTotal = -1 * $totalCredit; + $financialTrxnId = (int) $financialTrxn['id']; + + $trxnUpdate = \Civi\Api4\FinancialTrxn::update(FALSE) + ->addWhere('id', '=', $financialTrxnId) + ->addValue('total_amount', $negatedTotal); + if (isset($financialTrxn['net_amount']) && $financialTrxn['net_amount'] !== NULL) { + $trxnUpdate->addValue('net_amount', $negatedTotal); + } + $trxnUpdate->execute(); + + \Civi\Api4\EntityFinancialTrxn::update(FALSE) + ->addWhere('entity_table', '=', \CRM_Financeextras_DAO_CreditNote::$_tableName) + ->addWhere('entity_id', '=', $creditNoteId) + ->addWhere('financial_trxn_id', '=', $financialTrxnId) + ->addValue('amount', $negatedTotal) + ->execute(); + } + +} diff --git a/CRM/Financeextras/CreditNoteImporter/Exception/InvalidRowException.php b/CRM/Financeextras/CreditNoteImporter/Exception/InvalidRowException.php new file mode 100644 index 00000000..8206b80a --- /dev/null +++ b/CRM/Financeextras/CreditNoteImporter/Exception/InvalidRowException.php @@ -0,0 +1,8 @@ +__table = self::$_tableName; + parent::__construct(); + } + + /** + * Returns localized title of this entity. + * + * @param bool $plural + * Whether to return the plural version of the title. + */ + public static function getEntityTitle($plural = FALSE) { + return $plural ? E::ts('Credit Note Importers') : E::ts('Credit Note Importer'); + } + + /** + * Returns all the column names of this fake table. + * + * The fields listed here drive the CSV import column-mapping UI provided + * by nz.co.fuzion.csvimport. + * + * @return array + */ + public static function &fields() { + if (!isset(Civi::$statics[__CLASS__]['fields'])) { + Civi::$statics[__CLASS__]['fields'] = [ + 'id' => self::buildField('id', E::ts('Id'), CRM_Utils_Type::T_INT, [ + 'description' => E::ts('Synthetic primary key (unused).'), + 'required' => TRUE, + 'readonly' => TRUE, + ]), + + // Credit Note fields. + 'credit_note_external_id' => self::buildField('credit_note_external_id', E::ts('Credit Note External Id'), CRM_Utils_Type::T_STRING, [ + 'required' => TRUE, + 'description' => E::ts('External identifier used to group CSV rows into a single credit note. Rows sharing the same value are added as additional lines on the same credit note.'), + ]), + 'contact_id' => self::buildField('contact_id', E::ts('Contact Id'), CRM_Utils_Type::T_INT, [ + 'description' => E::ts('Internal CiviCRM contact ID. Either contact_id or contact_external_id is required.'), + ]), + 'contact_external_id' => self::buildField('contact_external_id', E::ts('Contact External Id'), CRM_Utils_Type::T_STRING, [ + 'description' => E::ts('External identifier of the contact. Used to look up contact when contact_id is empty.'), + ]), + 'owner_organization_id' => self::buildField('owner_organization_id', E::ts('Owner Organization Id'), CRM_Utils_Type::T_INT, [ + 'description' => E::ts('Internal CiviCRM contact ID of the owning organisation. Either owner_organization_id or owner_organization_external_id is required.'), + ]), + 'owner_organization_external_id' => self::buildField('owner_organization_external_id', E::ts('Owner Organization External Id'), CRM_Utils_Type::T_STRING, [ + 'description' => E::ts('External identifier of the owning organisation. Used when owner_organization_id is empty.'), + ]), + 'cn_number' => self::buildField('cn_number', E::ts('Credit Note Number'), CRM_Utils_Type::T_STRING, [ + 'description' => E::ts('Optional pre-set credit note number. If empty, the system generates one using the owner organisation prefix.'), + 'maxlength' => 11, + ]), + 'date' => self::buildField('date', E::ts('Credit Note Date'), CRM_Utils_Type::T_DATE, [ + 'description' => E::ts('Defaults to today if empty.'), + ]), + 'reference' => self::buildField('reference', E::ts('Credit Note Reference'), CRM_Utils_Type::T_STRING, [ + 'maxlength' => 11, + ]), + 'currency' => self::buildField('currency', E::ts('Currency'), CRM_Utils_Type::T_STRING, [ + 'required' => TRUE, + 'description' => E::ts('3-letter currency code, e.g. USD or GBP.'), + 'maxlength' => 3, + ]), + 'description' => self::buildField('description', E::ts('Credit Note Description'), CRM_Utils_Type::T_TEXT), + 'comment' => self::buildField('comment', E::ts('Credit Note Comment'), CRM_Utils_Type::T_TEXT), + + // Line fields. + 'line_description' => self::buildField('line_description', E::ts('Line Description'), CRM_Utils_Type::T_TEXT, [ + 'description' => E::ts('Description of this line item.'), + ]), + 'line_quantity' => self::buildField('line_quantity', E::ts('Line Quantity'), CRM_Utils_Type::T_FLOAT, [ + 'description' => E::ts('Defaults to 1 if empty.'), + ]), + 'line_unit_price' => self::buildField('line_unit_price', E::ts('Line Unit Price'), CRM_Utils_Type::T_MONEY, [ + 'required' => TRUE, + 'description' => E::ts('Unit price for this line. Tax is added on top automatically when the financial type has a Sales Tax account configured.'), + ]), + 'line_financial_type' => self::buildField('line_financial_type', E::ts('Line Financial Type'), CRM_Utils_Type::T_STRING, [ + 'required' => TRUE, + 'description' => E::ts('Name of the financial type for this line. The first line of a credit note also determines the credit note level financial type for accounting entries. Tax is derived from this financial type\'s Sales Tax Account relationship.'), + ]), + ]; + CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']); + } + return Civi::$statics[__CLASS__]['fields']; + } + + /** + * Helper to keep the fields() definition compact. + * + * @param string $name + * @param string $title + * @param int $type + * @param array $extras + * + * @return array + */ + private static function buildField($name, $title, $type, array $extras = []): array { + $base = [ + 'name' => $name, + 'type' => $type, + 'title' => $title, + 'where' => self::$_tableName . '.' . $name, + 'table_name' => self::$_tableName, + 'entity' => 'CreditNoteImporter', + 'bao' => 'CRM_Financeextras_DAO_CreditNoteImporter', + 'localizable' => 0, + 'html' => [ + 'label' => $title, + ], + 'add' => NULL, + ]; + + if (in_array($type, [CRM_Utils_Type::T_STRING], TRUE)) { + $base['maxlength'] = $extras['maxlength'] ?? 255; + $base['size'] = CRM_Utils_Type::HUGE; + } + if ($type === CRM_Utils_Type::T_MONEY) { + $base['precision'] = [20, 2]; + } + + unset($extras['maxlength']); + + return array_merge($base, $extras); + } + + /** + * @return array + */ + public static function &fieldKeys() { + if (!isset(Civi::$statics[__CLASS__]['fieldKeys'])) { + Civi::$statics[__CLASS__]['fieldKeys'] = array_flip(CRM_Utils_Array::collect('name', self::fields())); + } + return Civi::$statics[__CLASS__]['fieldKeys']; + } + + /** + * @return string + */ + public static function getTableName() { + return self::$_tableName; + } + + /** + * @return bool + */ + public function getLog() { + return self::$_log; + } + + /** + * @param bool $prefix + * + * @return array + */ + public static function &import($prefix = FALSE) { + $r = CRM_Core_DAO_AllCoreTables::getImports(__CLASS__, 'credit_note_importer_fake_entity', $prefix, []); + return $r; + } + + /** + * @param bool $prefix + * + * @return array + */ + public static function &export($prefix = FALSE) { + $r = CRM_Core_DAO_AllCoreTables::getExports(__CLASS__, 'credit_note_importer_fake_entity', $prefix, []); + return $r; + } + + /** + * @param bool $localize + * + * @return array + */ + public static function indices($localize = TRUE) { + return []; + } + +} diff --git a/CRM/Financeextras/Upgrader.php b/CRM/Financeextras/Upgrader.php index 7ffc5a5e..99bae571 100644 --- a/CRM/Financeextras/Upgrader.php +++ b/CRM/Financeextras/Upgrader.php @@ -7,6 +7,7 @@ use Civi\Financeextras\Setup\Manage\CreditNoteAllocationTypeManager; use Civi\Financeextras\Setup\Manage\CreditNoteInvoiceTemplateManager; use Civi\Financeextras\Setup\Manage\CreditNotePaymentInstrumentManager; +use Civi\Financeextras\Setup\Manage\CreditNoteCustomGroupExtensionManager; use Civi\Financeextras\Setup\Manage\ContributionOwnerOrganizationManager; use Civi\Financeextras\Setup\Manage\AccountsReceivablePaymentMethod; @@ -27,11 +28,15 @@ public function postInstall() { new ContributionOwnerOrganizationManager(), new CreditNotePaymentInstrumentManager(), new AccountsReceivablePaymentMethod(), + new CreditNoteCustomGroupExtensionManager(), ]; foreach ($manageSteps as $manageStep) { $manageStep->create(); } + $this->executeCustomDataFile('xml/credit_note_external_id_customGroup.xml'); + $this->executeSqlFile('sql/upgrade_1007.sql'); + $configurationSteps = [ new SetDefaultCompany(), ]; @@ -53,6 +58,7 @@ public function uninstall() { new ContributionOwnerOrganizationManager(), new CreditNotePaymentInstrumentManager(), new AccountsReceivablePaymentMethod(), + new CreditNoteCustomGroupExtensionManager(), ]; foreach ($steps as $step) { @@ -73,6 +79,7 @@ public function enable() { new ContributionOwnerOrganizationManager(), new CreditNotePaymentInstrumentManager(), new AccountsReceivablePaymentMethod(), + new CreditNoteCustomGroupExtensionManager(), ]; foreach ($steps as $step) { @@ -93,6 +100,7 @@ public function disable() { new ContributionOwnerOrganizationManager(), new CreditNotePaymentInstrumentManager(), new AccountsReceivablePaymentMethod(), + new CreditNoteCustomGroupExtensionManager(), ]; foreach ($steps as $step) { @@ -246,4 +254,17 @@ public function upgrade_1006(): bool { return TRUE; } + /** + * Installs the CreditNoteImporter prerequisites. + */ + public function upgrade_1007(): bool { + $this->ctx->log->info('Applying update 1007 - Installing CreditNoteImporter custom field'); + + (new CreditNoteCustomGroupExtensionManager())->create(); + $this->executeCustomDataFile('xml/credit_note_external_id_customGroup.xml'); + $this->executeSqlFile('sql/upgrade_1007.sql'); + + return TRUE; + } + } diff --git a/Civi/Api4/CreditNoteImporter.php b/Civi/Api4/CreditNoteImporter.php new file mode 100644 index 00000000..de6368d3 --- /dev/null +++ b/Civi/Api4/CreditNoteImporter.php @@ -0,0 +1,12 @@ + [self::STALE_MAPPING_AGE_DAYS, 'Integer']] + ); + } + + /** + * Runs only when the user is on csvimport's Preview form and entity is CreditNoteImporter. + * + * @param \CRM_Core_Form $form + * @param string $formName + * + * @return bool + */ + public static function shouldHandle($form, $formName): bool { + if ($formName !== 'CRM_Csvimport_Import_Form_Preview') { + return FALSE; + } + + $entity = NULL; + if (method_exists($form, 'getSubmittedValue')) { + $entity = $form->getSubmittedValue('entity'); + } + + return $entity === 'CreditNoteImporter'; + } + +} diff --git a/Civi/Financeextras/Setup/Manage/CreditNoteCustomGroupExtensionManager.php b/Civi/Financeextras/Setup/Manage/CreditNoteCustomGroupExtensionManager.php new file mode 100644 index 00000000..5ba8647b --- /dev/null +++ b/Civi/Financeextras/Setup/Manage/CreditNoteCustomGroupExtensionManager.php @@ -0,0 +1,58 @@ + 'cg_extend_objects', + 'value' => self::VALUE, + 'name' => self::TABLE_NAME, + 'label' => ts('Credit Notes'), + 'grouping' => NULL, + 'filter' => 0, + 'is_active' => TRUE, + 'is_reserved' => TRUE, + ]); + } + + /** + * {@inheritDoc} + */ + public function remove(): void { + \Civi\Api4\OptionValue::delete(FALSE) + ->addWhere('option_group_id:name', '=', 'cg_extend_objects') + ->addWhere('value', '=', self::VALUE) + ->execute(); + } + + /** + * {@inheritDoc} + */ + protected function toggle($status): void { + \Civi\Api4\OptionValue::update(FALSE) + ->addWhere('option_group_id:name', '=', 'cg_extend_objects') + ->addWhere('value', '=', self::VALUE) + ->addValue('is_active', $status) + ->execute(); + } + +} diff --git a/api/v3/CreditNoteAllocation.php b/api/v3/CreditNoteAllocation.php new file mode 100644 index 00000000..f78ff28f --- /dev/null +++ b/api/v3/CreditNoteAllocation.php @@ -0,0 +1,331 @@ + $params['credit_note_id'], + 'contribution_id' => $params['contribution_id'], + 'type_id' => $params['type_id'], + 'currency' => $params['currency'], + 'reference' => $params['reference'] ?? NULL, + 'amount' => $params['amount'], + 'date' => _civicrm_api3_credit_note_allocation_resolve_date($params['date'] ?? NULL), + ]; + + $allocation = CRM_Financeextras_BAO_CreditNoteAllocation::createWithAccountingEntries($data); + + return civicrm_api3_create_success([$allocation['id'] => $allocation], $params, 'CreditNoteAllocation', 'create'); +} + +/** + * CreditNoteAllocation.create API specification. + * + * @param array $params + */ +function _civicrm_api3_credit_note_allocation_create_spec(&$params) { + $params['credit_note_id'] = [ + 'title' => ts('Credit Note ID'), + 'description' => ts('ID of the Credit Note credit is being allocated from.'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 1, + 'FKClassName' => 'CRM_Financeextras_DAO_CreditNote', + 'FKApiName' => 'CreditNote', + ]; + + $params['contribution_id'] = [ + 'title' => ts('Contribution ID'), + 'description' => ts('ID of the Contribution to which credit is being allocated.'), + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => 1, + 'FKClassName' => 'CRM_Contribute_DAO_Contribution', + 'FKApiName' => 'Contribution', + ]; + + $params['type_id'] = [ + 'title' => ts('Allocation Type Id'), + 'description' => ts('One of the values of the financeextras_credit_note_allocation_type option group. Provide this OR type_name.'), + 'type' => CRM_Utils_Type::T_INT, + 'pseudoconstant' => [ + 'optionGroupName' => 'financeextras_credit_note_allocation_type', + ], + ]; + + $params['type_name'] = [ + 'title' => ts('Allocation Type Name'), + 'description' => ts('The option value name from financeextras_credit_note_allocation_type (e.g. "invoice", "manual_refund_payment"). Provide this OR type_id.'), + 'type' => CRM_Utils_Type::T_STRING, + ]; + + $params['currency'] = [ + 'title' => ts('Currency'), + 'description' => ts('3-letter currency code (e.g. USD, GBP).'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 1, + 'maxlength' => 3, + ]; + + $params['amount'] = [ + 'title' => ts('Amount'), + 'description' => ts('The amount of credit being allocated.'), + 'type' => CRM_Utils_Type::T_MONEY, + 'api.required' => 1, + ]; + + $params['date'] = [ + 'title' => ts('Allocation Date'), + 'description' => ts('Date the allocation was made. Defaults to today if omitted.'), + 'type' => CRM_Utils_Type::T_DATE, + ]; + + $params['reference'] = [ + 'title' => ts('Reference'), + 'description' => ts('Optional reference for the allocation.'), + 'type' => CRM_Utils_Type::T_STRING, + ]; +} + +/** + * CreditNoteAllocation.get API. + * + * @param array $params + * @return array API result descriptor + * @throws CiviCRM_API3_Exception + */ +function civicrm_api3_credit_note_allocation_get($params) { + return _civicrm_api3_basic_get('CRM_Financeextras_BAO_CreditNoteAllocation', $params); +} + +/** + * CreditNoteAllocation.delete API. + * + * @param array $params + * @return array API result descriptor + * @throws CiviCRM_API3_Exception + */ +function civicrm_api3_credit_note_allocation_delete($params) { + return _civicrm_api3_basic_delete('CRM_Financeextras_BAO_CreditNoteAllocation', $params); +} + +/** + * Enforces the rule that one of `type_id` or `type_name` must be supplied. + * + * @param array $params + * @throws CiviCRM_API3_Exception + */ +function _civicrm_api3_credit_note_allocation_validate_type(array $params) { + if (empty($params['type_id']) && empty($params['type_name'])) { + throw new CiviCRM_API3_Exception('Either type_id or type_name is required.'); + } +} + +/** + * Returns the allocation date, defaulting to today when the row didn't supply one. + * + * @param mixed $value + * + * @throws CiviCRM_API3_Exception + */ +function _civicrm_api3_credit_note_allocation_resolve_date($value): string { + if (empty($value)) { + return date('Y-m-d'); + } + + $timestamp = strtotime((string) $value); + if ($timestamp === FALSE) { + throw new CiviCRM_API3_Exception( + sprintf('Could not parse date "%s". Use a format like YYYY-MM-DD.', $value) + ); + } + + if ($timestamp <= 0) { + return date('Y-m-d'); + } + + return date('Y-m-d', $timestamp); +} + +/** + * Resolves pseudo-constant values that may have been supplied by name. + * + * @param array $params + * @throws CiviCRM_API3_Exception + */ +function _civicrm_api3_credit_note_allocation_resolve_pseudo_constants(array &$params) { + if (empty($params['type_id']) && !empty($params['type_name'])) { + $params['type_id'] = OptionValueUtils::getValueForOptionValue( + 'financeextras_credit_note_allocation_type', + $params['type_name'] + ); + } +} + +/** + * Loads the credit note record once with every field the downstream validators need. + * + * @param int|string $creditNoteId + * + * @return array + * @throws CiviCRM_API3_Exception + */ +function _civicrm_api3_credit_note_allocation_fetch_credit_note($creditNoteId): array { + $creditNote = \Civi\Api4\CreditNote::get(FALSE) + ->addSelect('id', 'cn_number', 'contact_id', 'currency', 'total_credit', 'remaining_credit') + ->addWhere('id', '=', $creditNoteId) + ->execute() + ->first(); + + if (empty($creditNote)) { + throw new CiviCRM_API3_Exception( + sprintf('Credit note with id "%s" does not exist.', $creditNoteId) + ); + } + + return $creditNote; +} + +/** + * Loads the contribution record once with the fields the validators need. + * + * @param int|string $contributionId + * + * @return array + * @throws CiviCRM_API3_Exception + */ +function _civicrm_api3_credit_note_allocation_fetch_contribution($contributionId): array { + $contribution = \Civi\Api4\Contribution::get(FALSE) + ->addSelect('id', 'contact_id', 'currency') + ->addWhere('id', '=', $contributionId) + ->execute() + ->first(); + + if (empty($contribution)) { + throw new CiviCRM_API3_Exception( + sprintf('Contribution with id "%s" does not exist.', $contributionId) + ); + } + + return $contribution; +} + +/** + * Validates that the credit note and the target contribution belong to the same contact. + * + * @param array $creditNote + * Loaded credit note (must include `id`, `cn_number`, `contact_id`). + * @param array $contribution + * Loaded contribution (must include `id`, `contact_id`). + * + * @throws CiviCRM_API3_Exception + */ +function _civicrm_api3_credit_note_allocation_validate_contact_match(array $creditNote, array $contribution) { + $reference = !empty($creditNote['cn_number']) ? $creditNote['cn_number'] : ('id ' . $creditNote['id']); + + if (empty($creditNote['contact_id'])) { + throw new CiviCRM_API3_Exception(sprintf( + 'Cannot allocate credit note %s because it has no contact set; allocation requires the credit note and the contribution to belong to the same contact.', + $reference + )); + } + + if ((int) $contribution['contact_id'] !== (int) $creditNote['contact_id']) { + throw new CiviCRM_API3_Exception(sprintf( + 'Cannot allocate credit note %s (contact %d) to contribution %d (contact %d). The credit note and the contribution must belong to the same contact.', + $reference, + $creditNote['contact_id'], + $contribution['id'], + $contribution['contact_id'] + )); + } +} + +/** + * Validates that the supplied currency is valid. + * + * @param array $params + * @param array $creditNote + * Loaded credit note (must include `id`, `cn_number`, `currency`). + * @param array $contribution + * Loaded contribution (must include `id`, `currency`). + * + * @throws CiviCRM_API3_Exception + */ +function _civicrm_api3_credit_note_allocation_validate_currency_match(array $params, array $creditNote, array $contribution) { + $reference = !empty($creditNote['cn_number']) ? $creditNote['cn_number'] : ('id ' . $creditNote['id']); + $rowCurrency = (string) ($params['currency'] ?? ''); + + if (!empty($creditNote['currency']) && strcasecmp($rowCurrency, (string) $creditNote['currency']) !== 0) { + throw new CiviCRM_API3_Exception(sprintf( + 'Currency mismatch for credit note %s: row supplied "%s" but the credit note is denominated in "%s". The allocation, credit note and contribution must all use the same currency.', + $reference, + $rowCurrency, + $creditNote['currency'] + )); + } + + if (!empty($contribution['currency']) && strcasecmp($rowCurrency, (string) $contribution['currency']) !== 0) { + throw new CiviCRM_API3_Exception(sprintf( + 'Currency mismatch for contribution %d: row supplied "%s" but the contribution is denominated in "%s". The allocation, credit note and contribution must all use the same currency.', + $contribution['id'], + $rowCurrency, + $contribution['currency'] + )); + } +} + +/** + * Validates that the requested allocation amount is valid. + * + * @param array $params + * @param array $creditNote + * + * @throws CiviCRM_API3_Exception + */ +function _civicrm_api3_credit_note_allocation_validate_amount(array $params, array $creditNote) { + $amount = (float) $params['amount']; + + if ($amount <= 0) { + throw new CiviCRM_API3_Exception('Allocation amount must be greater than zero.'); + } + + $totalCredit = (float) ($creditNote['total_credit'] ?? 0); + $remainingCredit = (float) ($creditNote['remaining_credit'] ?? 0); + $alreadyAllocated = $totalCredit - $remainingCredit; + + if ($amount > $remainingCredit) { + $reference = !empty($creditNote['cn_number']) ? $creditNote['cn_number'] : ('id ' . $creditNote['id']); + throw new CiviCRM_API3_Exception(sprintf( + 'Allocation amount (%s) exceeds the remaining credit (%s) for credit note %s. Total credit is %s and %s has already been allocated.', + number_format($amount, 2, '.', ''), + number_format($remainingCredit, 2, '.', ''), + $reference, + number_format($totalCredit, 2, '.', ''), + number_format($alreadyAllocated, 2, '.', '') + )); + } +} diff --git a/api/v3/CreditNoteImporter.php b/api/v3/CreditNoteImporter.php new file mode 100644 index 00000000..ccae74b2 --- /dev/null +++ b/api/v3/CreditNoteImporter.php @@ -0,0 +1,123 @@ +import(); + } + catch (Exception $exception) { + return civicrm_api3_create_error($exception->getMessage()); + } + + return civicrm_api3_create_success( + [ + $creditNoteId => [ + 'id' => $creditNoteId, + 'credit_note_id' => $creditNoteId, + ], + ], + $params, + 'CreditNoteImporter', + 'create' + ); +} + +/** + * CreditNoteImporter.create API specification. + * + * @param array $params + */ +function _civicrm_api3_credit_note_importer_create_spec(&$params) { + $params['credit_note_external_id'] = [ + 'title' => ts('Credit Note External Id'), + 'description' => ts('External identifier for the credit note.'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 1, + ]; + $params['contact_id'] = [ + 'title' => ts('Contact Id'), + 'description' => ts('Internal CiviCRM contact ID. Either contact_id or contact_external_id is required.'), + 'type' => CRM_Utils_Type::T_INT, + ]; + $params['contact_external_id'] = [ + 'title' => ts('Contact External Id'), + 'description' => ts('External identifier of the contact (civicrm_contact.external_identifier). Used when contact_id is empty.'), + 'type' => CRM_Utils_Type::T_STRING, + ]; + $params['owner_organization_id'] = [ + 'title' => ts('Owner Organization Id'), + 'description' => ts('Internal CiviCRM contact ID of the owning organisation. Either owner_organization_id or owner_organization_external_id is required.'), + 'type' => CRM_Utils_Type::T_INT, + ]; + $params['owner_organization_external_id'] = [ + 'title' => ts('Owner Organization External Id'), + 'description' => ts('External identifier of the owning organisation (civicrm_contact.external_identifier).'), + 'type' => CRM_Utils_Type::T_STRING, + ]; + $params['cn_number'] = [ + 'title' => ts('Credit Note Number'), + 'description' => ts('Optional pre-set credit note number. If empty, the system generates one using the owner organisation prefix.'), + 'type' => CRM_Utils_Type::T_STRING, + 'maxlength' => 11, + ]; + $params['date'] = [ + 'title' => ts('Credit Note Date'), + 'description' => ts('Defaults to today if empty.'), + 'type' => CRM_Utils_Type::T_DATE, + ]; + $params['reference'] = [ + 'title' => ts('Credit Note Reference'), + 'type' => CRM_Utils_Type::T_STRING, + 'maxlength' => 11, + ]; + $params['currency'] = [ + 'title' => ts('Currency'), + 'description' => ts('3-letter currency code (e.g. USD, GBP).'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 1, + 'maxlength' => 3, + ]; + $params['description'] = [ + 'title' => ts('Credit Note Description'), + 'type' => CRM_Utils_Type::T_TEXT, + ]; + $params['comment'] = [ + 'title' => ts('Credit Note Comment'), + 'type' => CRM_Utils_Type::T_TEXT, + ]; + $params['line_description'] = [ + 'title' => ts('Line Description'), + 'description' => ts('Description of this line item.'), + 'type' => CRM_Utils_Type::T_TEXT, + ]; + $params['line_quantity'] = [ + 'title' => ts('Line Quantity'), + 'description' => ts('Defaults to 1 if empty.'), + 'type' => CRM_Utils_Type::T_FLOAT, + ]; + $params['line_unit_price'] = [ + 'title' => ts('Line Unit Price'), + 'description' => ts('Unit price for this line. Tax is added on top automatically when the financial type has a Sales Tax account configured - it is not a separate CSV column.'), + 'type' => CRM_Utils_Type::T_MONEY, + 'api.required' => 1, + ]; + $params['line_financial_type'] = [ + 'title' => ts('Line Financial Type'), + 'description' => ts('Name of the financial type for this line. The first line of a credit note also determines the credit-note-level financial type used for the Accounts Receivable accounting entry. Tax is derived from this financial type\'s Sales Tax Account relationship.'), + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => 1, + ]; +} diff --git a/financeextras.php b/financeextras.php index 3e4364d6..3839c1bf 100644 --- a/financeextras.php +++ b/financeextras.php @@ -209,6 +209,7 @@ function financeextras_civicrm_buildForm($formName, &$form) { \Civi\Financeextras\Hook\BuildForm\AdditionalPaymentButton::class, \Civi\Financeextras\Hook\BuildForm\PaymentCreate::class, \Civi\Financeextras\Hook\BuildForm\RefundCreditNotePaymentInformation::class, + \Civi\Financeextras\Hook\BuildForm\CreditNoteImporterPreImportCleanup::class, ]; foreach ($hooks as $hook) { diff --git a/sql/upgrade_1007.sql b/sql/upgrade_1007.sql new file mode 100644 index 00000000..0c2aa19e --- /dev/null +++ b/sql/upgrade_1007.sql @@ -0,0 +1,16 @@ +-- Idempotent: only adds the column if it isn't already present. +SET @col_exists := ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'civicrm_value_credit_note_ext_id' + AND COLUMN_NAME = 'created_at' +); +SET @ddl := IF( + @col_exists = 0, + 'ALTER TABLE `civicrm_value_credit_note_ext_id` ADD COLUMN `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/tests/phpunit/api/v3/CreditNoteAllocationTest.php b/tests/phpunit/api/v3/CreditNoteAllocationTest.php new file mode 100644 index 00000000..c8cd739f --- /dev/null +++ b/tests/phpunit/api/v3/CreditNoteAllocationTest.php @@ -0,0 +1,313 @@ +buildCreditNoteWithTotal(self::CREDIT_TOTAL); + $contribution = $this->createContribution($creditNote['contact_id'], 200); + + $result = civicrm_api3('CreditNoteAllocation', 'create', [ + 'credit_note_id' => $creditNote['id'], + 'contribution_id' => $contribution['id'], + 'type_id' => $this->getAllocationTypeValue('invoice'), + 'currency' => 'GBP', + 'amount' => 100, + 'reference' => 'IMPORTER-TEST', + ]); + + $this->assertEquals(0, $result['is_error']); + $this->assertNotEmpty($result['id']); + + $allocation = \Civi\Api4\CreditNoteAllocation::get(FALSE) + ->addWhere('id', '=', $result['id']) + ->execute() + ->first(); + $this->assertNotEmpty($allocation); + $this->assertEquals($creditNote['id'], $allocation['credit_note_id']); + $this->assertEquals(100, $allocation['amount']); + + $entityTrxn = \Civi\Api4\EntityFinancialTrxn::get(FALSE) + ->addWhere('entity_table', '=', CRM_Financeextras_BAO_CreditNoteAllocation::$_tableName) + ->addWhere('entity_id', '=', $allocation['id']) + ->execute(); + $this->assertGreaterThan(0, $entityTrxn->count()); + } + + /** + * The pseudo-constant resolver should allow to pass the option value name (e.g. "invoice"). + */ + public function testTypeIdCanBeSuppliedByName() { + $creditNote = $this->buildCreditNoteWithTotal(self::CREDIT_TOTAL); + $contribution = $this->createContribution($creditNote['contact_id'], 200); + + $result = civicrm_api3('CreditNoteAllocation', 'create', [ + 'credit_note_id' => $creditNote['id'], + 'contribution_id' => $contribution['id'], + 'type_name' => 'invoice', + 'currency' => 'GBP', + 'amount' => 50, + ]); + + $this->assertEquals(0, $result['is_error']); + + $allocation = \Civi\Api4\CreditNoteAllocation::get(FALSE) + ->addWhere('id', '=', $result['id']) + ->addSelect('type_id:name') + ->execute() + ->first(); + $this->assertEquals('invoice', $allocation['type_id:name']); + } + + /** + * A negative or zero amount must be rejected before any DB write. + */ + public function testCreateRejectsNonPositiveAmount() { + $creditNote = $this->buildCreditNoteWithTotal(self::CREDIT_TOTAL); + $contribution = $this->createContribution($creditNote['contact_id'], 200); + + $this->expectException(CiviCRM_API3_Exception::class); + $this->expectExceptionMessageRegExp('/greater than zero/i'); + + civicrm_api3('CreditNoteAllocation', 'create', [ + 'credit_note_id' => $creditNote['id'], + 'contribution_id' => $contribution['id'], + 'type_id' => $this->getAllocationTypeValue('invoice'), + 'currency' => 'GBP', + 'amount' => 0, + ]); + } + + /** + * A single allocation that asks for more than the total credit must be rejected. + */ + public function testCreateRejectsAmountExceedingTotalCredit() { + $creditNote = $this->buildCreditNoteWithTotal(self::CREDIT_TOTAL); + $contribution = $this->createContribution($creditNote['contact_id'], 1000); + + $this->expectException(CiviCRM_API3_Exception::class); + $this->expectExceptionMessageRegExp('/exceeds the remaining credit/'); + + civicrm_api3('CreditNoteAllocation', 'create', [ + 'credit_note_id' => $creditNote['id'], + 'contribution_id' => $contribution['id'], + 'type_id' => $this->getAllocationTypeValue('invoice'), + 'currency' => 'GBP', + 'amount' => self::CREDIT_TOTAL + 1, + ]); + } + + /** + * Total allocations must not exceed the credit note's total. + */ + public function testCreateRejectsCumulativeAmountExceedingTotalCredit() { + $creditNote = $this->buildCreditNoteWithTotal(self::CREDIT_TOTAL); + $contribution = $this->createContribution($creditNote['contact_id'], 1000); + + // First allocation (150 of 200) succeeds. + civicrm_api3('CreditNoteAllocation', 'create', [ + 'credit_note_id' => $creditNote['id'], + 'contribution_id' => $contribution['id'], + 'type_id' => $this->getAllocationTypeValue('invoice'), + 'currency' => 'GBP', + 'amount' => 150, + ]); + + // Second allocation would push us to 250 - over the 200 limit. + $this->expectException(CiviCRM_API3_Exception::class); + $this->expectExceptionMessageRegExp('/exceeds the remaining credit/'); + + civicrm_api3('CreditNoteAllocation', 'create', [ + 'credit_note_id' => $creditNote['id'], + 'contribution_id' => $contribution['id'], + 'type_id' => $this->getAllocationTypeValue('invoice'), + 'currency' => 'GBP', + 'amount' => 100, + ]); + } + + /** + * A non-existent credit_note_id must produce an actionable error. + * + * A valid contribution_id is supplied so the spec-level api.required + * check on contribution_id passes; we want this test to exercise the + * credit-note-existence check that runs after spec validation, not + * the missing-required-field path. + */ + public function testCreateRejectsUnknownCreditNoteId() { + $contact = ContactFabricator::fabricate([ + 'first_name' => 'Unknown CN', + 'last_name' => 'Tester', + ]); + $contribution = $this->createContribution($contact['id'], 100); + + $this->expectException(CiviCRM_API3_Exception::class); + $this->expectExceptionMessageRegExp('/Credit note with id .* does not exist/'); + + civicrm_api3('CreditNoteAllocation', 'create', [ + 'credit_note_id' => 999999999, + 'contribution_id' => $contribution['id'], + 'type_id' => $this->getAllocationTypeValue('invoice'), + 'currency' => 'GBP', + 'amount' => 10, + ]); + } + + /** + * The credit note and the contribution must belong to the same + * contact. Allocating across contacts is almost certainly a CSV + * mistake and is refused with an actionable message. + */ + public function testCreateRejectsCrossContactAllocation() { + $creditNote = $this->buildCreditNoteWithTotal(self::CREDIT_TOTAL); + $otherContact = ContactFabricator::fabricate([ + 'first_name' => 'Other', + 'last_name' => 'Customer', + ]); + $contribution = $this->createContribution($otherContact['id'], 200); + + $this->expectException(CiviCRM_API3_Exception::class); + $this->expectExceptionMessageRegExp('/must belong to the same contact/'); + + civicrm_api3('CreditNoteAllocation', 'create', [ + 'credit_note_id' => $creditNote['id'], + 'contribution_id' => $contribution['id'], + 'type_id' => $this->getAllocationTypeValue('invoice'), + 'currency' => 'GBP', + 'amount' => 50, + ]); + } + + /** + * A credit note with no contact bound has no anchor against which + * the contribution's contact can be checked, so allocation is + * rejected outright. + */ + public function testCreateRejectsAllocationWhenCreditNoteHasNoContact() { + $creditNote = $this->buildCreditNoteWithTotal(self::CREDIT_TOTAL); + $contribution = $this->createContribution($creditNote['contact_id'], 200); + + // Strip the contact off the credit note to simulate a credit note + // that was created without one (the schema permits NULL). + \CRM_Core_DAO::executeQuery( + 'UPDATE financeextras_credit_note SET contact_id = NULL WHERE id = %1', + [1 => [$creditNote['id'], 'Integer']] + ); + + $this->expectException(CiviCRM_API3_Exception::class); + $this->expectExceptionMessageRegExp('/has no contact set/'); + + civicrm_api3('CreditNoteAllocation', 'create', [ + 'credit_note_id' => $creditNote['id'], + 'contribution_id' => $contribution['id'], + 'type_id' => $this->getAllocationTypeValue('invoice'), + 'currency' => 'GBP', + 'amount' => 50, + ]); + } + + /** + * The allocation row's currency must equal the credit note's + * currency. Otherwise we'd silently misrepresent amounts in the + * accounting entries. + */ + public function testCreateRejectsCurrencyMismatchAgainstCreditNote() { + // Credit note is GBP (default in getCreditNoteData), contribution + // is also GBP, but the allocation row supplies USD. + $creditNote = $this->buildCreditNoteWithTotal(self::CREDIT_TOTAL); + $contribution = $this->createContribution($creditNote['contact_id'], 200); + + $this->expectException(CiviCRM_API3_Exception::class); + $this->expectExceptionMessageRegExp('/Currency mismatch for credit note/'); + + civicrm_api3('CreditNoteAllocation', 'create', [ + 'credit_note_id' => $creditNote['id'], + 'contribution_id' => $contribution['id'], + 'type_id' => $this->getAllocationTypeValue('invoice'), + 'currency' => 'USD', + 'amount' => 50, + ]); + } + + /** + * The allocation row's currency must equal the target contribution's + * currency too - the credit note and contribution can theoretically + * disagree, but the allocation can only point at one of them, so we + * insist on a consistent triple. + */ + public function testCreateRejectsCurrencyMismatchAgainstContribution() { + // Credit note is GBP, allocation row is GBP, but the contribution + // is in USD. + $creditNote = $this->buildCreditNoteWithTotal(self::CREDIT_TOTAL); + $contribution = $this->createContribution($creditNote['contact_id'], 200, 'USD'); + + $this->expectException(CiviCRM_API3_Exception::class); + $this->expectExceptionMessageRegExp('/Currency mismatch for contribution/'); + + civicrm_api3('CreditNoteAllocation', 'create', [ + 'credit_note_id' => $creditNote['id'], + 'contribution_id' => $contribution['id'], + 'type_id' => $this->getAllocationTypeValue('invoice'), + 'currency' => 'GBP', + 'amount' => 50, + ]); + } + + /** + * Builds a credit note with a single line. + */ + private function buildCreditNoteWithTotal(int $creditTotal): array { + $creditNoteData = $this->getCreditNoteData(); + $creditNoteData['items'][] = $this->getCreditNoteLineData([ + 'quantity' => 1, + 'unit_price' => $creditTotal, + 'tax_rate' => 0, + ]); + + return CreditNote::save() + ->addRecord($creditNoteData) + ->execute() + ->first(); + } + + private function createContribution(int $contactId, $amount, string $currency = 'GBP'): array { + return \Civi\Api4\Contribution::create() + ->addValue('contact_id', $contactId) + ->addValue('total_amount', $amount) + ->addValue('currency', $currency) + // 2 = Pending - matches what AllocateActionTest uses for invoice + // allocations. + ->addValue('contribution_status_id', 2) + ->addValue('financial_type_id', 1) + ->execute() + ->first(); + } + + private function getAllocationTypeValue(string $name) { + return OptionValueUtils::getValueForOptionValue( + 'financeextras_credit_note_allocation_type', + $name + ); + } + +} diff --git a/tests/phpunit/api/v3/CreditNoteImporterTest.php b/tests/phpunit/api/v3/CreditNoteImporterTest.php new file mode 100644 index 00000000..ea2759e2 --- /dev/null +++ b/tests/phpunit/api/v3/CreditNoteImporterTest.php @@ -0,0 +1,443 @@ +customer = ContactFabricator::fabricate([ + 'first_name' => 'Importer', + 'last_name' => 'Customer', + 'external_identifier' => 'CUST-EXT-1', + ]); + + $this->ownerOrg = \Civi\Api4\Company::get(FALSE) + ->setLimit(1) + ->execute() + ->first(); + } + + /** + * A single CSV row should produce a one-line credit note with accounting entries. + */ + public function testSingleRowCreatesCreditNoteAndLine() { + $row = $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-A', + 'line_unit_price' => 100, + 'line_quantity' => 1, + ]); + + $result = civicrm_api3('CreditNoteImporter', 'create', $row); + $this->assertEquals(0, $result['is_error']); + $creditNoteId = $result['id']; + $this->assertNotEmpty($creditNoteId); + + $creditNote = CreditNote::get(FALSE) + ->addWhere('id', '=', $creditNoteId) + ->execute() + ->first(); + $this->assertNotEmpty($creditNote); + $this->assertEquals(100, $creditNote['subtotal']); + $this->assertEquals(0, $creditNote['sales_tax']); + $this->assertEquals(100, $creditNote['total_credit']); + $this->assertEquals('GBP', $creditNote['currency']); + + $lines = CreditNoteLine::get(FALSE) + ->addWhere('credit_note_id', '=', $creditNoteId) + ->execute(); + $this->assertCount(1, $lines); + $this->assertEquals(100, $lines->first()['line_total']); + } + + /** + * Rows with same credit_note_external_id must end up as two lines on one credit note. + */ + public function testRowsWithSameExternalIdAreGroupedIntoOneCreditNote() { + $importerCalls = [ + $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-B', + 'line_unit_price' => 60, + 'line_quantity' => 1, + 'line_description' => 'first line', + ]), + $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-B', + 'line_unit_price' => 40, + 'line_quantity' => 1, + 'line_description' => 'second line', + ]), + ]; + + $createdIds = []; + foreach ($importerCalls as $row) { + $createdIds[] = civicrm_api3('CreditNoteImporter', 'create', $row)['id']; + } + + $this->assertSame($createdIds[0], $createdIds[1], 'Both rows should target the same credit note id'); + + $creditNoteId = $createdIds[0]; + $this->assertCreditNoteCount('CN-EXT-B', 1); + + $lines = CreditNoteLine::get(FALSE) + ->addWhere('credit_note_id', '=', $creditNoteId) + ->addOrderBy('id', 'ASC') + ->execute() + ->getArrayCopy(); + $this->assertCount(2, $lines); + $this->assertEquals('first line', $lines[0]['description']); + $this->assertEquals('second line', $lines[1]['description']); + + $creditNote = CreditNote::get(FALSE) + ->addWhere('id', '=', $creditNoteId) + ->execute() + ->first(); + $this->assertEquals(100, $creditNote['subtotal']); + $this->assertEquals(100, $creditNote['total_credit']); + } + + /** + * Different external ids in the same import must produce separate credit notes. + */ + public function testDifferentExternalIdsProduceSeparateCreditNotes() { + $rowA = $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-C1', + 'line_unit_price' => 50, + ]); + $rowB = $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-C2', + 'line_unit_price' => 75, + ]); + + $idA = civicrm_api3('CreditNoteImporter', 'create', $rowA)['id']; + $idB = civicrm_api3('CreditNoteImporter', 'create', $rowB)['id']; + + $this->assertNotEquals($idA, $idB); + $this->assertCreditNoteCount('CN-EXT-C1', 1); + $this->assertCreditNoteCount('CN-EXT-C2', 1); + } + + /** + * The financial transaction should remain a single record per credit + * note, and its total_amount must equal the negated sum of all the + * lines that have been appended. + * + * Tax is now derived from the financial type's "Sales Tax Account is" + * relationship rather than supplied by the CSV, so this test uses + * the default Donation type (no tax) and asserts plain line totals. + */ + public function testFinancialTrxnTotalsStayConsistentAfterAppending() { + $creditNoteId = civicrm_api3('CreditNoteImporter', 'create', $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-D', + 'line_unit_price' => 100, + 'line_quantity' => 1, + ]))['id']; + + civicrm_api3('CreditNoteImporter', 'create', $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-D', + 'line_unit_price' => 50, + 'line_quantity' => 1, + ])); + + $entityTrxn = EntityFinancialTrxn::get(FALSE) + ->addWhere('entity_table', '=', \CRM_Financeextras_DAO_CreditNote::$_tableName) + ->addWhere('entity_id', '=', $creditNoteId) + ->execute(); + + $this->assertCount(1, $entityTrxn); + + $financialTrxn = FinancialTrxn::get(FALSE) + ->addWhere('id', '=', $entityTrxn->first()['financial_trxn_id']) + ->execute() + ->first(); + + $this->assertEquals(-150, (float) $financialTrxn['total_amount']); + + $creditNote = CreditNote::get(FALSE) + ->addWhere('id', '=', $creditNoteId) + ->execute() + ->first(); + $this->assertEquals(150, $creditNote['subtotal']); + $this->assertEquals(0, $creditNote['sales_tax']); + $this->assertEquals(150, $creditNote['total_credit']); + + $this->assertEquals(-150, (float) $entityTrxn->first()['amount']); + } + + /** + * Every line - whether the first or appended - must produce a + * financial_item linked to the credit note's financial transaction. + * + * The financial type used here (Donation) has no Sales Tax account, + * so the importer derives a 0% tax rate and only the income + * financial_item is created per line. Tax-bearing financial types + * are exercised by the upstream APIv4 tests. + */ + public function testEachLineProducesFinancialItemEntries() { + $creditNoteId = civicrm_api3('CreditNoteImporter', 'create', $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-E', + 'line_unit_price' => 100, + ]))['id']; + + civicrm_api3('CreditNoteImporter', 'create', $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-E', + 'line_unit_price' => 50, + ])); + + $lines = CreditNoteLine::get(FALSE) + ->addWhere('credit_note_id', '=', $creditNoteId) + ->addOrderBy('id', 'ASC') + ->execute() + ->getArrayCopy(); + $this->assertCount(2, $lines); + + $line1Items = FinancialItem::get(FALSE) + ->addWhere('entity_table', '=', \CRM_Financeextras_DAO_CreditNoteLine::$_tableName) + ->addWhere('entity_id', '=', $lines[0]['id']) + ->execute() + ->getArrayCopy(); + $this->assertCount(1, $line1Items); + + $line2Items = FinancialItem::get(FALSE) + ->addWhere('entity_table', '=', \CRM_Financeextras_DAO_CreditNoteLine::$_tableName) + ->addWhere('entity_id', '=', $lines[1]['id']) + ->execute() + ->getArrayCopy(); + $this->assertCount(1, $line2Items); + + foreach (array_merge($line1Items, $line2Items) as $item) { + $itemTrxn = EntityFinancialTrxn::get(FALSE) + ->addWhere('entity_table', '=', \CRM_Financial_BAO_FinancialItem::getTableName()) + ->addWhere('entity_id', '=', $item['id']) + ->execute(); + $this->assertCount(1, $itemTrxn); + } + } + + /** + * The credit note's accounting entries should be correct. + */ + public function testCreditNoteAccountingEntriesMatchApiV4Path() { + $creditNoteId = civicrm_api3('CreditNoteImporter', 'create', $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-F', + 'line_unit_price' => 80, + ]))['id']; + + $entityTrxn = EntityFinancialTrxn::get(FALSE) + ->addWhere('entity_table', '=', \CRM_Financeextras_DAO_CreditNote::$_tableName) + ->addWhere('entity_id', '=', $creditNoteId) + ->execute() + ->first(); + $this->assertNotEmpty($entityTrxn); + + $financialTrxn = FinancialTrxn::get(FALSE) + ->addWhere('id', '=', $entityTrxn['financial_trxn_id']) + ->addWhere('status_id:name', '=', 'Pending') + ->addWhere('payment_instrument_id:name', '=', AccountsReceivablePaymentMethod::NAME) + ->addWhere('is_payment', '=', FALSE) + ->execute() + ->first(); + $this->assertNotEmpty($financialTrxn); + } + + /** + * A row that uses contact_external_id rather than contact_id must resolve the customer the same way. + */ + public function testContactCanBeResolvedByExternalIdentifier() { + $row = $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-G', + 'line_unit_price' => 25, + ]); + unset($row['contact_id']); + $row['contact_external_id'] = 'CUST-EXT-1'; + + $result = civicrm_api3('CreditNoteImporter', 'create', $row); + $this->assertEquals(0, $result['is_error']); + + $creditNote = CreditNote::get(FALSE) + ->addWhere('id', '=', $result['id']) + ->execute() + ->first(); + $this->assertEquals($this->customer['id'], $creditNote['contact_id']); + } + + /** + * A missing required field surfaces as an error. + */ + public function testMissingRequiredFieldReturnsError() { + $row = $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-H', + 'line_unit_price' => 10, + ]); + unset($row['line_financial_type']); + + $this->expectException(CiviCRM_API3_Exception::class); + $this->expectExceptionMessageRegExp('/line_financial_type/'); + + civicrm_api3('CreditNoteImporter', 'create', $row); + } + + /** + * Unknown contact references must produce an actionable error. + */ + public function testUnknownContactReturnsError() { + $row = $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-I', + 'line_unit_price' => 10, + ]); + unset($row['contact_id']); + $row['contact_external_id'] = 'DOES-NOT-EXIST'; + + $this->expectException(CiviCRM_API3_Exception::class); + $this->expectExceptionMessageRegExp('/Cannot find contact/'); + + civicrm_api3('CreditNoteImporter', 'create', $row); + } + + /** + * An invalid financial type name must produce an actionable error. + */ + public function testUnknownFinancialTypeReturnsError() { + $row = $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-J', + 'line_unit_price' => 10, + 'line_financial_type' => 'NotAFinancialType', + ]); + + $this->expectException(CiviCRM_API3_Exception::class); + $this->expectExceptionMessageRegExp('/Invalid line financial type/'); + + civicrm_api3('CreditNoteImporter', 'create', $row); + } + + /** + * Two CSV rows that share a credit_note_external_id but reference a + * different contact must NOT be merged into the same credit note; + * the second row should fail with a clear error and leave the + * existing credit note unchanged. + */ + public function testRowWithMismatchedContactIsRejected() { + $otherContact = ContactFabricator::fabricate([ + 'first_name' => 'Other', + 'last_name' => 'Customer', + 'external_identifier' => 'CUST-EXT-2', + ]); + + // First row creates the credit note for CN-EXT-MISMATCH-1 with the + // default customer. + $creditNoteId = civicrm_api3('CreditNoteImporter', 'create', $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-MISMATCH-1', + 'line_unit_price' => 100, + ]))['id']; + + // Second row claims the same external id but supplies a different + // contact - that's a data error, not an append. + $rejected = $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-MISMATCH-1', + 'line_unit_price' => 50, + 'contact_id' => $otherContact['id'], + ]); + + try { + civicrm_api3('CreditNoteImporter', 'create', $rejected); + $this->fail('Expected the mismatched contact row to be rejected.'); + } + catch (CiviCRM_API3_Exception $e) { + $this->assertRegExp('/contact id .* does not match/', $e->getMessage()); + } + + // The original credit note must still have only one line. + $lines = CreditNoteLine::get(FALSE) + ->addWhere('credit_note_id', '=', $creditNoteId) + ->execute(); + $this->assertCount(1, $lines); + } + + /** + * Same as above for owner_organization_id - mismatched owner means + * the row must be rejected. + */ + public function testRowWithMismatchedOwnerOrgIsRejected() { + $otherOrg = ContactFabricator::fabricateOrganization([ + 'organization_name' => 'Other Owner Co', + ]); + + $creditNoteId = civicrm_api3('CreditNoteImporter', 'create', $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-MISMATCH-2', + 'line_unit_price' => 100, + ]))['id']; + + $rejected = $this->buildRow([ + 'credit_note_external_id' => 'CN-EXT-MISMATCH-2', + 'line_unit_price' => 50, + 'owner_organization_id' => $otherOrg['id'], + ]); + + try { + civicrm_api3('CreditNoteImporter', 'create', $rejected); + $this->fail('Expected the mismatched owner organisation row to be rejected.'); + } + catch (CiviCRM_API3_Exception $e) { + $this->assertRegExp('/owner organisation .* does not match/', $e->getMessage()); + } + + $lines = CreditNoteLine::get(FALSE) + ->addWhere('credit_note_id', '=', $creditNoteId) + ->execute(); + $this->assertCount(1, $lines); + } + + /** + * Asserts the number of credit notes whose external_id custom field + * matches the supplied value. Queries the custom-field shadow table + * directly to mirror what the production importer does (APIv4 + * custom-field reads/writes do not reliably persist for + * extension-defined DAO entities). + */ + private function assertCreditNoteCount(string $externalId, int $expected): void { + $count = (int) \CRM_Core_DAO::singleValueQuery( + 'SELECT COUNT(*) FROM civicrm_value_credit_note_ext_id WHERE external_id = %1', + [1 => [$externalId, 'String']] + ); + $this->assertEquals($expected, $count, sprintf('Expected %d credit note(s) for external id %s, got %d', $expected, $externalId, $count)); + } + + private function buildRow(array $overrides = []): array { + return array_merge([ + 'credit_note_external_id' => 'CN-EXT-DEFAULT', + 'contact_id' => $this->customer['id'], + 'owner_organization_id' => $this->ownerOrg['contact_id'], + 'currency' => 'GBP', + 'line_unit_price' => 100, + 'line_quantity' => 1, + 'line_financial_type' => 'Donation', + ], $overrides); + } + +} diff --git a/xml/credit_note_external_id_customGroup.xml b/xml/credit_note_external_id_customGroup.xml new file mode 100644 index 00000000..752dbb70 --- /dev/null +++ b/xml/credit_note_external_id_customGroup.xml @@ -0,0 +1,52 @@ + + + + + + credit_note_external_id + Credit Note External ID + CreditNote + + 0 + 1 + civicrm_value_credit_note_ext_id + 0 + 0 + 1 + + + + + external_id + + String + Text + 0 + 1 + 0 + 1 + 1 + 255 + 60 + 4 + external_id + credit_note_external_id + + + diff --git a/xml/schema/CRM/Financeextras/CreditNoteImporter.entityType.php b/xml/schema/CRM/Financeextras/CreditNoteImporter.entityType.php new file mode 100644 index 00000000..f85bd52e --- /dev/null +++ b/xml/schema/CRM/Financeextras/CreditNoteImporter.entityType.php @@ -0,0 +1,16 @@ + 'CreditNoteImporter', + 'class' => 'CRM_Financeextras_DAO_CreditNoteImporter', + 'table' => 'financeextras_credit_note_importer_fake_entity', + ], +];