diff --git a/CRM/Paymentprocessingcore/BAO/PaymentAttempt.php b/CRM/Paymentprocessingcore/BAO/PaymentAttempt.php index cd12d5f..f03fe4d 100644 --- a/CRM/Paymentprocessingcore/BAO/PaymentAttempt.php +++ b/CRM/Paymentprocessingcore/BAO/PaymentAttempt.php @@ -92,7 +92,7 @@ public static function findByContributionId($contributionId) { } $attempt = new self(); - $attempt->contribution_id = $contributionId; + $attempt->contribution_id = (string) $contributionId; /** @var CRM_Paymentprocessingcore_DAO_PaymentAttempt $attempt */ if ($attempt->find(TRUE)) { @@ -111,6 +111,7 @@ public static function findByContributionId($contributionId) { public static function getStatuses() { return [ 'pending' => E::ts('Pending'), + 'processing' => E::ts('Processing'), 'completed' => E::ts('Completed'), 'failed' => E::ts('Failed'), 'cancelled' => E::ts('Cancelled'), @@ -143,7 +144,7 @@ public static function validateStatus(string $status): void { * Update payment attempt status with validation. * * @param int $id Payment attempt ID - * @param string $status New status: 'pending', 'completed', 'failed', 'cancelled' + * @param string $status New status: 'pending', 'processing', 'completed', 'failed', 'cancelled' * * @return void * @@ -158,4 +159,27 @@ public static function updateStatus(int $id, string $status): void { ]); } + /** + * Atomically update payment attempt status with optimistic locking. + * + * Only updates if the current status matches the expected status. + * This prevents race conditions when multiple workers try to process + * the same payment attempt simultaneously. + * + * @param int $id Payment attempt ID + * @param string $expectedStatus Current expected status + * @param string $newStatus New status to set + * + * @return bool TRUE if update was successful, FALSE if status didn't match + */ + public static function updateStatusAtomic(int $id, string $expectedStatus, string $newStatus): bool { + $result = \Civi\Api4\PaymentAttempt::update(FALSE) + ->addWhere('id', '=', $id) + ->addWhere('status', '=', $expectedStatus) + ->setValues(['status' => $newStatus]) + ->execute(); + + return $result->count() > 0; + } + } diff --git a/CRM/Paymentprocessingcore/BAO/PaymentProcessorCustomer.php b/CRM/Paymentprocessingcore/BAO/PaymentProcessorCustomer.php index f7bdd5d..cb7d99d 100644 --- a/CRM/Paymentprocessingcore/BAO/PaymentProcessorCustomer.php +++ b/CRM/Paymentprocessingcore/BAO/PaymentProcessorCustomer.php @@ -48,8 +48,8 @@ public static function findByContactAndProcessor(int $contactId, int $paymentPro } $customer = new self(); - $customer->contact_id = $contactId; - $customer->payment_processor_id = $paymentProcessorId; + $customer->contact_id = (string) $contactId; + $customer->payment_processor_id = (string) $paymentProcessorId; if ($customer->find(TRUE)) { /** @var array{id: int, contact_id: int, payment_processor_id: int, processor_customer_id: string, created_date: string} $data */ @@ -77,7 +77,7 @@ public static function findByProcessorCustomerId(string $processorCustomerId, in $customer = new self(); $customer->processor_customer_id = $processorCustomerId; - $customer->payment_processor_id = $paymentProcessorId; + $customer->payment_processor_id = (string) $paymentProcessorId; if ($customer->find(TRUE)) { /** @var array{id: int, contact_id: int, payment_processor_id: int, processor_customer_id: string, created_date: string} $data */ diff --git a/CRM/Paymentprocessingcore/BAO/PaymentWebhook.php b/CRM/Paymentprocessingcore/BAO/PaymentWebhook.php index abd177f..d0f74dd 100644 --- a/CRM/Paymentprocessingcore/BAO/PaymentWebhook.php +++ b/CRM/Paymentprocessingcore/BAO/PaymentWebhook.php @@ -24,7 +24,7 @@ public static function create($params) { $instance = new $className(); $instance->copyValues($params); $instance->save(); - CRM_Utils_Hook::post($hook, $entityName, $instance->id, $instance); + CRM_Utils_Hook::post($hook, $entityName, (int) $instance->id, $instance); return $instance; } diff --git a/CRM/Paymentprocessingcore/DAO/PaymentAttempt.php b/CRM/Paymentprocessingcore/DAO/PaymentAttempt.php index 4284036..d9eabb5 100644 --- a/CRM/Paymentprocessingcore/DAO/PaymentAttempt.php +++ b/CRM/Paymentprocessingcore/DAO/PaymentAttempt.php @@ -1,473 +1,30 @@ __table = 'civicrm_payment_attempt'; - 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('Payment Attempts') : E::ts('Payment Attempt'); - } - - /** - * Returns all the column names of this table - * - * @return array - */ - public static function &fields() { - if (!isset(Civi::$statics[__CLASS__]['fields'])) { - Civi::$statics[__CLASS__]['fields'] = [ - 'id' => [ - 'name' => 'id', - 'type' => CRM_Utils_Type::T_INT, - 'title' => E::ts('ID'), - 'description' => E::ts('Unique ID'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_attempt.id', - 'table_name' => 'civicrm_payment_attempt', - 'entity' => 'PaymentAttempt', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'localizable' => 0, - 'html' => [ - 'type' => 'Number', - ], - 'readonly' => TRUE, - 'add' => NULL, - ], - 'contribution_id' => [ - 'name' => 'contribution_id', - 'type' => CRM_Utils_Type::T_INT, - 'title' => E::ts('Contribution ID'), - 'description' => E::ts('FK to Contribution'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_attempt.contribution_id', - 'table_name' => 'civicrm_payment_attempt', - 'entity' => 'PaymentAttempt', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'localizable' => 0, - 'FKClassName' => 'CRM_Contribute_DAO_Contribution', - 'html' => [ - 'type' => 'EntityRef', - 'label' => E::ts("Contribution"), - ], - 'add' => NULL, - ], - 'contact_id' => [ - 'name' => 'contact_id', - 'type' => CRM_Utils_Type::T_INT, - 'title' => E::ts('Contact ID'), - 'description' => E::ts('FK to Contact (donor)'), - 'required' => FALSE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_attempt.contact_id', - 'table_name' => 'civicrm_payment_attempt', - 'entity' => 'PaymentAttempt', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'localizable' => 0, - 'FKClassName' => 'CRM_Contact_DAO_Contact', - 'html' => [ - 'type' => 'EntityRef', - 'label' => E::ts("Contact"), - ], - 'add' => NULL, - ], - 'payment_processor_id' => [ - 'name' => 'payment_processor_id', - 'type' => CRM_Utils_Type::T_INT, - 'title' => E::ts('Payment Processor ID'), - 'description' => E::ts('FK to Payment Processor'), - 'required' => FALSE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_attempt.payment_processor_id', - 'table_name' => 'civicrm_payment_attempt', - 'entity' => 'PaymentAttempt', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'localizable' => 0, - 'FKClassName' => 'CRM_Financial_DAO_PaymentProcessor', - 'html' => [ - 'type' => 'Select', - 'label' => E::ts("Payment Processor"), - ], - 'add' => NULL, - ], - 'processor_type' => [ - 'name' => 'processor_type', - 'type' => CRM_Utils_Type::T_STRING, - 'title' => E::ts('Processor Type'), - 'description' => E::ts('Processor type: \'stripe\', \'gocardless\', \'itas\', etc.'), - 'required' => TRUE, - 'maxlength' => 50, - 'size' => CRM_Utils_Type::BIG, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_attempt.processor_type', - 'table_name' => 'civicrm_payment_attempt', - 'entity' => 'PaymentAttempt', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'localizable' => 0, - 'html' => [ - 'type' => 'Text', - 'label' => E::ts("Processor Type"), - ], - 'add' => NULL, - ], - 'processor_session_id' => [ - 'name' => 'processor_session_id', - 'type' => CRM_Utils_Type::T_STRING, - 'title' => E::ts('Processor Session ID'), - 'description' => E::ts('Processor session ID (cs_... for Stripe, mandate_... for GoCardless)'), - 'maxlength' => 255, - 'size' => CRM_Utils_Type::HUGE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_attempt.processor_session_id', - 'table_name' => 'civicrm_payment_attempt', - 'entity' => 'PaymentAttempt', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'localizable' => 0, - 'html' => [ - 'type' => 'Text', - 'label' => E::ts("Processor Session ID"), - ], - 'add' => NULL, - ], - 'processor_payment_id' => [ - 'name' => 'processor_payment_id', - 'type' => CRM_Utils_Type::T_STRING, - 'title' => E::ts('Processor Payment ID'), - 'description' => E::ts('Processor payment ID (pi_... for Stripe, payment_... for GoCardless)'), - 'maxlength' => 255, - 'size' => CRM_Utils_Type::HUGE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_attempt.processor_payment_id', - 'table_name' => 'civicrm_payment_attempt', - 'entity' => 'PaymentAttempt', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'localizable' => 0, - 'html' => [ - 'type' => 'Text', - 'label' => E::ts("Processor Payment ID"), - ], - 'add' => NULL, - ], - 'status' => [ - 'name' => 'status', - 'type' => CRM_Utils_Type::T_STRING, - 'title' => E::ts('Status'), - 'description' => E::ts('Attempt status: pending, completed, failed, cancelled'), - 'required' => TRUE, - 'maxlength' => 25, - 'size' => CRM_Utils_Type::MEDIUM, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_attempt.status', - 'default' => 'pending', - 'table_name' => 'civicrm_payment_attempt', - 'entity' => 'PaymentAttempt', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'localizable' => 0, - 'html' => [ - 'type' => 'Select', - 'label' => E::ts("Status"), - ], - 'pseudoconstant' => [ - 'callback' => 'CRM_Paymentprocessingcore_BAO_PaymentAttempt::getStatuses', - ], - 'add' => NULL, - ], - 'created_date' => [ - 'name' => 'created_date', - 'type' => CRM_Utils_Type::T_TIMESTAMP, - 'title' => E::ts('Created Date'), - 'description' => E::ts('When attempt was created'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_attempt.created_date', - 'default' => 'CURRENT_TIMESTAMP', - 'table_name' => 'civicrm_payment_attempt', - 'entity' => 'PaymentAttempt', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'localizable' => 0, - 'html' => [ - 'type' => 'Select Date', - 'label' => E::ts("Created Date"), - ], - 'add' => NULL, - ], - 'updated_date' => [ - 'name' => 'updated_date', - 'type' => CRM_Utils_Type::T_TIMESTAMP, - 'title' => E::ts('Updated Date'), - 'description' => E::ts('Last updated'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_attempt.updated_date', - 'default' => 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', - 'table_name' => 'civicrm_payment_attempt', - 'entity' => 'PaymentAttempt', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'localizable' => 0, - 'html' => [ - 'type' => 'Select Date', - 'label' => E::ts("Updated Date"), - ], - 'add' => NULL, - ], - ]; - CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']); - } - return Civi::$statics[__CLASS__]['fields']; - } - - /** - * Returns the list of fields that can be imported - * - * @param bool $prefix - * - * @return array - */ - public static function &import($prefix = FALSE) { - $r = CRM_Core_DAO_AllCoreTables::getImports(__CLASS__, 'payment_attempt', $prefix, []); - return $r; - } - - /** - * Returns the list of fields that can be exported - * - * @param bool $prefix - * - * @return array - */ - public static function &export($prefix = FALSE) { - $r = CRM_Core_DAO_AllCoreTables::getExports(__CLASS__, 'payment_attempt', $prefix, []); - return $r; - } - - /** - * Returns the list of indices - * - * @param bool $localize - * - * @return array - */ - public static function indices($localize = TRUE) { - $indices = [ - 'index_contribution_id' => [ - 'name' => 'index_contribution_id', - 'field' => [ - 0 => 'contribution_id', - ], - 'localizable' => FALSE, - 'unique' => TRUE, - 'sig' => 'civicrm_payment_attempt::1::contribution_id', - ], - 'index_processor_type' => [ - 'name' => 'index_processor_type', - 'field' => [ - 0 => 'processor_type', - ], - 'localizable' => FALSE, - 'sig' => 'civicrm_payment_attempt::0::processor_type', - ], - 'index_processor_session' => [ - 'name' => 'index_processor_session', - 'field' => [ - 0 => 'processor_session_id', - 1 => 'processor_type', - ], - 'localizable' => FALSE, - 'sig' => 'civicrm_payment_attempt::0::processor_session_id::processor_type', - ], - 'index_processor_payment' => [ - 'name' => 'index_processor_payment', - 'field' => [ - 0 => 'processor_payment_id', - 1 => 'processor_type', - ], - 'localizable' => FALSE, - 'sig' => 'civicrm_payment_attempt::0::processor_payment_id::processor_type', - ], - ]; - return ($localize && !empty($indices)) ? CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices; - } - } diff --git a/CRM/Paymentprocessingcore/DAO/PaymentProcessorCustomer.php b/CRM/Paymentprocessingcore/DAO/PaymentProcessorCustomer.php index 92b256f..7f0fc18 100644 --- a/CRM/Paymentprocessingcore/DAO/PaymentProcessorCustomer.php +++ b/CRM/Paymentprocessingcore/DAO/PaymentProcessorCustomer.php @@ -1,345 +1,26 @@ __table = 'civicrm_payment_processor_customer'; - 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('Payment Processor Customers') : E::ts('Payment Processor Customer'); - } - - /** - * Returns all the column names of this table - * - * @return array - */ - public static function &fields() { - if (!isset(Civi::$statics[__CLASS__]['fields'])) { - Civi::$statics[__CLASS__]['fields'] = [ - 'id' => [ - 'name' => 'id', - 'type' => CRM_Utils_Type::T_INT, - 'title' => E::ts('ID'), - 'description' => E::ts('Unique ID'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_processor_customer.id', - 'table_name' => 'civicrm_payment_processor_customer', - 'entity' => 'PaymentProcessorCustomer', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentProcessorCustomer', - 'localizable' => 0, - 'html' => [ - 'type' => 'Number', - ], - 'readonly' => TRUE, - 'add' => NULL, - ], - 'payment_processor_id' => [ - 'name' => 'payment_processor_id', - 'type' => CRM_Utils_Type::T_INT, - 'title' => E::ts('Payment Processor ID'), - 'description' => E::ts('FK to Payment Processor'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_processor_customer.payment_processor_id', - 'table_name' => 'civicrm_payment_processor_customer', - 'entity' => 'PaymentProcessorCustomer', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentProcessorCustomer', - 'localizable' => 0, - 'FKClassName' => 'CRM_Financial_DAO_PaymentProcessor', - 'html' => [ - 'type' => 'Select', - 'label' => E::ts("Payment Processor"), - ], - 'add' => NULL, - ], - 'processor_customer_id' => [ - 'name' => 'processor_customer_id', - 'type' => CRM_Utils_Type::T_STRING, - 'title' => E::ts('Processor Customer ID'), - 'description' => E::ts('Customer ID from payment processor (e.g., cus_... for Stripe, cu_... for GoCardless)'), - 'required' => TRUE, - 'maxlength' => 255, - 'size' => CRM_Utils_Type::HUGE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_processor_customer.processor_customer_id', - 'table_name' => 'civicrm_payment_processor_customer', - 'entity' => 'PaymentProcessorCustomer', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentProcessorCustomer', - 'localizable' => 0, - 'html' => [ - 'type' => 'Text', - 'label' => E::ts("Processor Customer ID"), - ], - 'add' => NULL, - ], - 'contact_id' => [ - 'name' => 'contact_id', - 'type' => CRM_Utils_Type::T_INT, - 'title' => E::ts('Contact ID'), - 'description' => E::ts('FK to Contact'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_processor_customer.contact_id', - 'table_name' => 'civicrm_payment_processor_customer', - 'entity' => 'PaymentProcessorCustomer', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentProcessorCustomer', - 'localizable' => 0, - 'FKClassName' => 'CRM_Contact_DAO_Contact', - 'html' => [ - 'type' => 'EntityRef', - 'label' => E::ts("Contact"), - ], - 'add' => NULL, - ], - 'created_date' => [ - 'name' => 'created_date', - 'type' => CRM_Utils_Type::T_TIMESTAMP, - 'title' => E::ts('Created Date'), - 'description' => E::ts('When customer record was created'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_processor_customer.created_date', - 'default' => 'CURRENT_TIMESTAMP', - 'table_name' => 'civicrm_payment_processor_customer', - 'entity' => 'PaymentProcessorCustomer', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentProcessorCustomer', - 'localizable' => 0, - 'html' => [ - 'type' => 'Select Date', - 'label' => E::ts("Created Date"), - ], - 'add' => NULL, - ], - 'updated_date' => [ - 'name' => 'updated_date', - 'type' => CRM_Utils_Type::T_TIMESTAMP, - 'title' => E::ts('Updated Date'), - 'description' => E::ts('Last updated'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_processor_customer.updated_date', - 'default' => 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', - 'table_name' => 'civicrm_payment_processor_customer', - 'entity' => 'PaymentProcessorCustomer', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentProcessorCustomer', - 'localizable' => 0, - 'html' => [ - 'type' => 'Select Date', - 'label' => E::ts("Updated Date"), - ], - 'add' => NULL, - ], - ]; - CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']); - } - return Civi::$statics[__CLASS__]['fields']; - } - - /** - * Returns the list of fields that can be imported - * - * @param bool $prefix - * - * @return array - */ - public static function &import($prefix = FALSE) { - $r = CRM_Core_DAO_AllCoreTables::getImports(__CLASS__, 'payment_processor_customer', $prefix, []); - return $r; - } - - /** - * Returns the list of fields that can be exported - * - * @param bool $prefix - * - * @return array - */ - public static function &export($prefix = FALSE) { - $r = CRM_Core_DAO_AllCoreTables::getExports(__CLASS__, 'payment_processor_customer', $prefix, []); - return $r; - } - - /** - * Returns the list of indices - * - * @param bool $localize - * - * @return array - */ - public static function indices($localize = TRUE) { - $indices = [ - 'index_payment_processor_id' => [ - 'name' => 'index_payment_processor_id', - 'field' => [ - 0 => 'payment_processor_id', - ], - 'localizable' => FALSE, - 'sig' => 'civicrm_payment_processor_customer::0::payment_processor_id', - ], - 'index_processor_customer_id' => [ - 'name' => 'index_processor_customer_id', - 'field' => [ - 0 => 'processor_customer_id', - ], - 'localizable' => FALSE, - 'sig' => 'civicrm_payment_processor_customer::0::processor_customer_id', - ], - 'index_contact_id' => [ - 'name' => 'index_contact_id', - 'field' => [ - 0 => 'contact_id', - ], - 'localizable' => FALSE, - 'sig' => 'civicrm_payment_processor_customer::0::contact_id', - ], - 'unique_contact_processor' => [ - 'name' => 'unique_contact_processor', - 'field' => [ - 0 => 'contact_id', - 1 => 'payment_processor_id', - ], - 'localizable' => FALSE, - 'unique' => TRUE, - 'sig' => 'civicrm_payment_processor_customer::1::contact_id::payment_processor_id', - ], - 'unique_processor_customer' => [ - 'name' => 'unique_processor_customer', - 'field' => [ - 0 => 'payment_processor_id', - 1 => 'processor_customer_id', - ], - 'localizable' => FALSE, - 'unique' => TRUE, - 'sig' => 'civicrm_payment_processor_customer::1::payment_processor_id::processor_customer_id', - ], - ]; - return ($localize && !empty($indices)) ? CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices; - } - } diff --git a/CRM/Paymentprocessingcore/DAO/PaymentWebhook.php b/CRM/Paymentprocessingcore/DAO/PaymentWebhook.php index 62bb73d..6ca5a9e 100644 --- a/CRM/Paymentprocessingcore/DAO/PaymentWebhook.php +++ b/CRM/Paymentprocessingcore/DAO/PaymentWebhook.php @@ -1,558 +1,33 @@ __table = 'civicrm_payment_webhook'; - 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('Payment Webhooks') : E::ts('Payment Webhook'); - } - - /** - * Returns all the column names of this table - * - * @return array - */ - public static function &fields() { - if (!isset(Civi::$statics[__CLASS__]['fields'])) { - Civi::$statics[__CLASS__]['fields'] = [ - 'id' => [ - 'name' => 'id', - 'type' => CRM_Utils_Type::T_INT, - 'title' => E::ts('ID'), - 'description' => E::ts('Unique ID'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.id', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'Number', - ], - 'readonly' => TRUE, - 'add' => NULL, - ], - 'event_id' => [ - 'name' => 'event_id', - 'type' => CRM_Utils_Type::T_STRING, - 'title' => E::ts('Event ID'), - 'description' => E::ts('Processor event ID (evt_... for Stripe, evt_... for GoCardless)'), - 'required' => TRUE, - 'maxlength' => 255, - 'size' => CRM_Utils_Type::HUGE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.event_id', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'Text', - 'label' => E::ts("Event ID"), - ], - 'add' => NULL, - ], - 'processor_type' => [ - 'name' => 'processor_type', - 'type' => CRM_Utils_Type::T_STRING, - 'title' => E::ts('Processor Type'), - 'description' => E::ts('Processor type: \'stripe\', \'gocardless\', \'itas\', etc.'), - 'required' => TRUE, - 'maxlength' => 50, - 'size' => CRM_Utils_Type::BIG, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.processor_type', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'Text', - 'label' => E::ts("Processor Type"), - ], - 'add' => NULL, - ], - 'event_type' => [ - 'name' => 'event_type', - 'type' => CRM_Utils_Type::T_STRING, - 'title' => E::ts('Event Type'), - 'description' => E::ts('Event type (e.g. checkout.session.completed, payment_intent.succeeded)'), - 'required' => TRUE, - 'maxlength' => 100, - 'size' => CRM_Utils_Type::HUGE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.event_type', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'Text', - 'label' => E::ts("Event Type"), - ], - 'add' => NULL, - ], - 'payment_attempt_id' => [ - 'name' => 'payment_attempt_id', - 'type' => CRM_Utils_Type::T_INT, - 'title' => E::ts('Payment Attempt ID'), - 'description' => E::ts('FK to Payment Attempt'), - 'required' => FALSE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.payment_attempt_id', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'FKClassName' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'html' => [ - 'type' => 'EntityRef', - 'label' => E::ts("Payment Attempt"), - ], - 'add' => NULL, - ], - 'status' => [ - 'name' => 'status', - 'type' => CRM_Utils_Type::T_STRING, - 'title' => E::ts('Status'), - 'description' => E::ts('Processing status: new, processing, processed, error, permanent_error'), - 'required' => TRUE, - 'maxlength' => 25, - 'size' => CRM_Utils_Type::MEDIUM, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.status', - 'default' => 'new', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'Select', - 'label' => E::ts("Status"), - ], - 'pseudoconstant' => [ - 'callback' => 'CRM_Paymentprocessingcore_BAO_PaymentWebhook::getStatuses', - ], - 'add' => NULL, - ], - 'attempts' => [ - 'name' => 'attempts', - 'type' => CRM_Utils_Type::T_INT, - 'title' => E::ts('Attempts'), - 'description' => E::ts('Number of processing attempts'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.attempts', - 'default' => '0', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'Number', - 'label' => E::ts("Attempts"), - ], - 'add' => NULL, - ], - 'next_retry_at' => [ - 'name' => 'next_retry_at', - 'type' => CRM_Utils_Type::T_TIMESTAMP, - 'title' => E::ts('Next Retry At'), - 'description' => E::ts('When to retry processing (for exponential backoff)'), - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.next_retry_at', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'Select Date', - 'label' => E::ts("Next Retry At"), - ], - 'add' => NULL, - ], - 'result' => [ - 'name' => 'result', - 'type' => CRM_Utils_Type::T_STRING, - 'title' => E::ts('Result'), - 'description' => E::ts('Processing result: applied, noop, ignored_out_of_order, error'), - 'maxlength' => 50, - 'size' => CRM_Utils_Type::BIG, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.result', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'Text', - 'label' => E::ts("Result"), - ], - 'add' => NULL, - ], - 'error_log' => [ - 'name' => 'error_log', - 'type' => CRM_Utils_Type::T_TEXT, - 'title' => E::ts('Error Log'), - 'description' => E::ts('Error details if processing failed'), - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.error_log', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'TextArea', - 'label' => E::ts("Error Log"), - ], - 'add' => NULL, - ], - 'processing_started_at' => [ - 'name' => 'processing_started_at', - 'type' => CRM_Utils_Type::T_TIMESTAMP, - 'title' => E::ts('Processing Started At'), - 'description' => E::ts('When webhook entered processing state (for stuck webhook detection)'), - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.processing_started_at', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'Select Date', - 'label' => E::ts("Processing Started At"), - ], - 'add' => NULL, - ], - 'processed_at' => [ - 'name' => 'processed_at', - 'type' => CRM_Utils_Type::T_TIMESTAMP, - 'title' => E::ts('Processed At'), - 'description' => E::ts('When event was processed'), - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.processed_at', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'Select Date', - 'label' => E::ts("Processed At"), - ], - 'add' => NULL, - ], - 'created_date' => [ - 'name' => 'created_date', - 'type' => CRM_Utils_Type::T_TIMESTAMP, - 'title' => E::ts('Created Date'), - 'description' => E::ts('When webhook was received'), - 'required' => TRUE, - 'usage' => [ - 'import' => FALSE, - 'export' => FALSE, - 'duplicate_matching' => FALSE, - 'token' => FALSE, - ], - 'where' => 'civicrm_payment_webhook.created_date', - 'default' => 'CURRENT_TIMESTAMP', - 'table_name' => 'civicrm_payment_webhook', - 'entity' => 'PaymentWebhook', - 'bao' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'localizable' => 0, - 'html' => [ - 'type' => 'Select Date', - 'label' => E::ts("Created Date"), - ], - 'add' => NULL, - ], - ]; - CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']); - } - return Civi::$statics[__CLASS__]['fields']; - } - - /** - * Returns the list of fields that can be imported - * - * @param bool $prefix - * - * @return array - */ - public static function &import($prefix = FALSE) { - $r = CRM_Core_DAO_AllCoreTables::getImports(__CLASS__, 'payment_webhook', $prefix, []); - return $r; - } - - /** - * Returns the list of fields that can be exported - * - * @param bool $prefix - * - * @return array - */ - public static function &export($prefix = FALSE) { - $r = CRM_Core_DAO_AllCoreTables::getExports(__CLASS__, 'payment_webhook', $prefix, []); - return $r; - } - - /** - * Returns the list of indices - * - * @param bool $localize - * - * @return array - */ - public static function indices($localize = TRUE) { - $indices = [ - 'UI_event_processor' => [ - 'name' => 'UI_event_processor', - 'field' => [ - 0 => 'event_id', - 1 => 'processor_type', - ], - 'localizable' => FALSE, - 'unique' => TRUE, - 'sig' => 'civicrm_payment_webhook::1::event_id::processor_type', - ], - 'index_event_type' => [ - 'name' => 'index_event_type', - 'field' => [ - 0 => 'event_type', - ], - 'localizable' => FALSE, - 'sig' => 'civicrm_payment_webhook::0::event_type', - ], - 'index_status_retry' => [ - 'name' => 'index_status_retry', - 'field' => [ - 0 => 'status', - 1 => 'next_retry_at', - ], - 'localizable' => FALSE, - 'sig' => 'civicrm_payment_webhook::0::status::next_retry_at', - ], - ]; - return ($localize && !empty($indices)) ? CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices; - } - } diff --git a/Civi/Paymentprocessingcore/DTO/ChargeInstalmentItem.php b/Civi/Paymentprocessingcore/DTO/ChargeInstalmentItem.php new file mode 100644 index 0000000..483d6e4 --- /dev/null +++ b/Civi/Paymentprocessingcore/DTO/ChargeInstalmentItem.php @@ -0,0 +1,45 @@ +getProcessorType() !== 'Stripe') { + * return; + * } + * foreach ($event->getItems() as $item) { + * // Process each item... + * } + * } + * @endcode + */ +class ChargeInstalmentBatchEvent extends GenericHookEvent { + + /** + * Event name constant. + */ + public const NAME = 'paymentprocessingcore.charge_instalment_batch'; + + /** + * Constructor. + * + * @param string $processorType + * Processor type name (e.g., 'Stripe', 'GoCardless'). + * @param array $items + * Array of items to charge, keyed by contribution ID. + * @param int $maxRetryCount + * Maximum retry count before marking contribution as Failed. + */ + public function __construct( + protected string $processorType, + protected array $items, + protected int $maxRetryCount = 3, + ) {} + + /** + * Get the processor type. + * + * @return string + * The processor type name. + */ + public function getProcessorType(): string { + return $this->processorType; + } + + /** + * Get the items to charge. + * + * @return array + * Array of items to charge, keyed by contribution ID. + */ + public function getItems(): array { + return $this->items; + } + + /** + * Get the max retry count. + * + * @return int + * Maximum retry count before marking contribution as Failed. + */ + public function getMaxRetryCount(): int { + return $this->maxRetryCount; + } + +} diff --git a/Civi/Paymentprocessingcore/Hook/Container/ServiceContainer.php b/Civi/Paymentprocessingcore/Hook/Container/ServiceContainer.php index d4b84f4..5085b58 100644 --- a/Civi/Paymentprocessingcore/Hook/Container/ServiceContainer.php +++ b/Civi/Paymentprocessingcore/Hook/Container/ServiceContainer.php @@ -105,6 +105,17 @@ public function register(): void { 'Civi\Paymentprocessingcore\Service\InstalmentGenerationService', 'paymentprocessingcore.instalment_generation' ); + + // Register InstalmentChargeService + $this->container->setDefinition( + 'paymentprocessingcore.instalment_charge', + new Definition(\Civi\Paymentprocessingcore\Service\InstalmentChargeService::class) + )->setAutowired(TRUE)->setPublic(TRUE); + + $this->container->setAlias( + 'Civi\Paymentprocessingcore\Service\InstalmentChargeService', + 'paymentprocessingcore.instalment_charge' + ); } } diff --git a/Civi/Paymentprocessingcore/Service/InstalmentChargeService.php b/Civi/Paymentprocessingcore/Service/InstalmentChargeService.php new file mode 100644 index 0000000..1f9f2c5 --- /dev/null +++ b/Civi/Paymentprocessingcore/Service/InstalmentChargeService.php @@ -0,0 +1,422 @@ + $processorTypes + * Payment processor type names (e.g., ["Stripe", "GoCardless"]). + * Must be explicitly specified - no "all" default. + * @param int $batchSize + * Maximum number of records to process PER processor type. + * @param int $maxRetryCount + * Maximum failure count before skipping recurring contribution. + * + * @return array{charged: int, skipped: int, errored: int, processors_processed: array, message: string} + * Summary of processing results. + */ + public function chargeInstalments(array $processorTypes, int $batchSize, int $maxRetryCount): array { + + $this->logDebug('InstalmentChargeService::chargeInstalments: Starting', [ + 'processorTypes' => $processorTypes, + 'batchSize' => $batchSize, + 'maxRetryCount' => $maxRetryCount, + ]); + + $totalSummary = [ + 'charged' => 0, + 'skipped' => 0, + 'errored' => 0, + 'processors_processed' => [], + ]; + + // Process each processor type sequentially to avoid OOM. + foreach ($processorTypes as $processorType) { + $result = $this->chargeInstalmentsByProcessor($processorType, $batchSize, $maxRetryCount); + + // Aggregate results. + $totalSummary['charged'] += $result['charged']; + $totalSummary['skipped'] += $result['skipped']; + $totalSummary['errored'] += $result['errored']; + $totalSummary['processors_processed'][] = $processorType; + + $this->logDebug('InstalmentChargeService::chargeInstalments: Processor completed', [ + 'processorType' => $processorType, + 'result' => $result, + ]); + + // Memory is freed after each processor's batch is processed. + } + + $totalSummary['message'] = sprintf( + 'Processed %d processor type(s): %d charged, %d skipped, %d errors.', + count($totalSummary['processors_processed']), + $totalSummary['charged'], + $totalSummary['skipped'], + $totalSummary['errored'] + ); + + $this->logDebug('InstalmentChargeService::chargeInstalments: Completed', $totalSummary); + + return $totalSummary; + } + + /** + * Charge due instalments for a single processor type. + * + * @param string $processorType + * Payment processor type name (e.g., "Stripe"). + * @param int $batchSize + * Maximum number of records to process. + * @param int $maxRetryCount + * Maximum failure count before skipping recurring contribution. + * + * @return array{charged: int, skipped: int, errored: int} + * Summary of processing results for this processor. + */ + private function chargeInstalmentsByProcessor(string $processorType, int $batchSize, int $maxRetryCount): array { + $summary = [ + 'charged' => 0, + 'skipped' => 0, + 'errored' => 0, + ]; + + // Step 1: Select contributions to charge. + $contributions = $this->getEligibleContributions($processorType, $batchSize, $maxRetryCount); + + $this->logDebug('InstalmentChargeService::chargeInstalmentsByProcessor: Found eligible contributions', [ + 'processorType' => $processorType, + 'count' => count($contributions), + ]); + + if (empty($contributions)) { + return $summary; + } + + // Step 2: Batch fetch existing PaymentAttempts. + $contributionIds = array_map( + static fn($id): int => is_int($id) ? $id : (is_string($id) ? intval($id) : 0), + array_column($contributions, 'id') + ); + $existingAttempts = $this->batchFetchPaymentAttempts($contributionIds); + + // Step 3: Prepare batch and create/update PaymentAttempts. + $items = []; + foreach ($contributions as $contrib) { + try { + // Extract typed values from contribution record. + $contribId = is_int($contrib['id']) ? $contrib['id'] : 0; + $totalAmount = is_float($contrib['total_amount']) ? $contrib['total_amount'] : 0.0; + $paidAmount = is_float($contrib['paid_amount']) ? $contrib['paid_amount'] : 0.0; + $recurId = is_int($contrib['contribution_recur_id']) ? $contrib['contribution_recur_id'] : 0; + $contactId = is_int($contrib['contact_id']) ? $contrib['contact_id'] : 0; + $currency = is_string($contrib['currency']) ? $contrib['currency'] : ''; + $tokenId = is_int($contrib['payment_token_id']) ? $contrib['payment_token_id'] : 0; + $processorId = is_int($contrib['payment_processor_id']) ? $contrib['payment_processor_id'] : 0; + + // Check existing attempt (in-memory lookup). + $existing = $existingAttempts[$contribId] ?? NULL; + + // Get or create PaymentAttempt. + $paymentAttemptId = $this->getOrCreatePaymentAttempt($contrib, $existing, $processorType); + if ($paymentAttemptId === NULL) { + $summary['skipped']++; + continue; + } + + // Atomic transition pending -> processing. + if (!PaymentAttemptBAO::updateStatusAtomic($paymentAttemptId, 'pending', 'processing')) { + // Another worker claimed it. + $this->logDebug('InstalmentChargeService: Atomic transition failed', [ + 'contributionId' => $contribId, + 'paymentAttemptId' => $paymentAttemptId, + ]); + $summary['skipped']++; + continue; + } + + // Calculate outstanding amount. + $outstandingAmount = $totalAmount - $paidAmount; + + // Add to batch. + $items[$contribId] = new ChargeInstalmentItem( + contributionId: $contribId, + paymentAttemptId: $paymentAttemptId, + recurringContributionId: $recurId, + contactId: $contactId, + amount: $outstandingAmount, + currency: $currency, + paymentTokenId: $tokenId, + paymentProcessorId: $processorId, + ); + + $summary['charged']++; + } + catch (\Throwable $e) { + $this->logDebug('InstalmentChargeService: Error processing contribution', [ + 'contributionId' => $contrib['id'] ?? 0, + 'error' => $e->getMessage(), + ]); + $summary['errored']++; + } + } + + // Step 4: Dispatch batch event for this processor type. + if (!empty($items)) { + $event = new ChargeInstalmentBatchEvent($processorType, $items, $maxRetryCount); + \Civi::dispatcher()->dispatch(ChargeInstalmentBatchEvent::NAME, $event); + + $this->logDebug('InstalmentChargeService: Dispatched batch event', [ + 'processorType' => $processorType, + 'itemCount' => count($items), + 'maxRetryCount' => $maxRetryCount, + ]); + } + + return $summary; + } + + /** + * Get contributions eligible for charging. + * + * Selection criteria: + * - contribution_status_id IN (Pending, Partially Paid) + * - total_amount - paid_amount > 0 + * - receive_date <= CURRENT_DATE() + * - contribution_recur_id IS NOT NULL + * - Parent recurring: status != Cancelled, payment_token_id IS NOT NULL + * - contribution_recur.failure_count <= max_retry_count + * - No PaymentAttempt with status IN ('processing', 'completed', 'cancelled') + * - Filter by payment_processor_type.name = processorType + * + * @param string $processorType + * Payment processor type name. + * @param int $batchSize + * Maximum number of records to return. + * @param int $maxRetryCount + * Maximum failure count allowed. + * + * @return array> + * Array of contribution records. + */ + public function getEligibleContributions(string $processorType, int $batchSize, int $maxRetryCount): array { + // Get contribution IDs that have blocking PaymentAttempts. + $blockingAttemptContribIds = $this->getContributionIdsWithBlockingAttempts(); + + // Use API4 with explicit joins for multi-level relationships. + // Implicit FK paths don't work reliably for 4+ level deep joins. + $query = \Civi\Api4\Contribution::get(FALSE) + ->addSelect( + 'id', + 'contact_id', + 'total_amount', + 'paid_amount', + 'currency', + 'contribution_recur_id', + 'cr.payment_token_id', + 'pt.payment_processor_id' + ) + ->addJoin( + 'ContributionRecur AS cr', + 'INNER', + NULL, + ['contribution_recur_id', '=', 'cr.id'] + ) + ->addJoin( + 'PaymentToken AS pt', + 'INNER', + NULL, + ['cr.payment_token_id', '=', 'pt.id'] + ) + ->addJoin( + 'PaymentProcessor AS pp', + 'INNER', + NULL, + ['pt.payment_processor_id', '=', 'pp.id'] + ) + ->addJoin( + 'PaymentProcessorType AS ppt', + 'INNER', + NULL, + ['pp.payment_processor_type_id', '=', 'ppt.id'] + ) + ->addWhere('contribution_status_id:name', 'IN', ['Pending', 'Partially paid']) + ->addWhere('receive_date', '<=', 'now') + ->addWhere('cr.contribution_status_id:name', '!=', 'Cancelled') + ->addWhere('cr.payment_token_id', 'IS NOT NULL') + // Handle NULL failure_count (treat as 0) - OR condition for NULL-safe comparison. + ->addClause('OR', ['cr.failure_count', '<=', $maxRetryCount], ['cr.failure_count', 'IS NULL']) + ->addWhere('ppt.name', '=', $processorType) + ->addOrderBy('receive_date', 'ASC') + ->setLimit($batchSize); + + // Exclude contributions with blocking PaymentAttempts. + if (!empty($blockingAttemptContribIds)) { + $query->addWhere('id', 'NOT IN', $blockingAttemptContribIds); + } + + $contributions = $query->execute(); + + $results = []; + foreach ($contributions as $contrib) { + if (!is_array($contrib)) { + continue; + } + + $id = intval($contrib['id'] ?? 0); + $contactId = intval($contrib['contact_id'] ?? 0); + $totalAmount = floatval($contrib['total_amount'] ?? 0); + $paidAmount = floatval($contrib['paid_amount'] ?? 0); + $currency = strval($contrib['currency'] ?? ''); + $recurId = intval($contrib['contribution_recur_id'] ?? 0); + $tokenId = intval($contrib['cr.payment_token_id'] ?? 0); + $processorId = intval($contrib['pt.payment_processor_id'] ?? 0); + + // Only include if there's an outstanding balance. + if ($totalAmount - $paidAmount <= 0) { + continue; + } + + $results[] = [ + 'id' => $id, + 'contact_id' => $contactId, + 'total_amount' => $totalAmount, + 'currency' => $currency, + 'contribution_recur_id' => $recurId, + 'paid_amount' => $paidAmount, + 'payment_token_id' => $tokenId, + 'payment_processor_id' => $processorId, + ]; + } + + return $results; + } + + /** + * Get contribution IDs that have blocking PaymentAttempts. + * + * A blocking attempt is one with status: processing, completed, or cancelled. + * + * @return array + * Array of contribution IDs. + */ + private function getContributionIdsWithBlockingAttempts(): array { + $attempts = \Civi\Api4\PaymentAttempt::get(FALSE) + ->addSelect('contribution_id') + ->addWhere('status', 'IN', ['processing', 'completed', 'cancelled']) + ->execute(); + + $ids = []; + foreach ($attempts as $attempt) { + if (!is_array($attempt)) { + continue; + } + if (isset($attempt['contribution_id'])) { + $ids[] = intval($attempt['contribution_id']); + } + } + + return $ids; + } + + /** + * Batch fetch existing PaymentAttempts for contributions. + * + * @param array $contributionIds + * Array of contribution IDs. + * + * @return array> + * PaymentAttempts indexed by contribution_id. + */ + private function batchFetchPaymentAttempts(array $contributionIds): array { + if (empty($contributionIds)) { + return []; + } + + $attempts = \Civi\Api4\PaymentAttempt::get(FALSE) + ->addWhere('contribution_id', 'IN', $contributionIds) + ->execute(); + + $indexed = []; + foreach ($attempts as $attempt) { + if (!is_array($attempt)) { + continue; + } + if (isset($attempt['contribution_id'])) { + $indexed[intval($attempt['contribution_id'])] = $attempt; + } + } + + return $indexed; + } + + /** + * Get or create PaymentAttempt for a contribution. + * + * Returns the PaymentAttempt ID if it's in 'pending' status (new or existing). + * Returns NULL if the attempt exists with a non-pending status. + * + * @param array $contribution + * Contribution record. + * @param array|null $existingAttempt + * Existing PaymentAttempt or NULL. + * @param string $processorType + * Payment processor type name. + * + * @return int|null + * PaymentAttempt ID or NULL if not eligible. + */ + private function getOrCreatePaymentAttempt(array $contribution, ?array $existingAttempt, string $processorType): ?int { + if ($existingAttempt !== NULL) { + // Check if existing attempt is in pending status. + if ($existingAttempt['status'] !== 'pending') { + $this->logDebug('InstalmentChargeService: Skipping non-pending attempt', [ + 'contributionId' => $contribution['id'], + 'status' => $existingAttempt['status'], + ]); + return NULL; + } + $attemptId = $existingAttempt['id'] ?? 0; + return is_int($attemptId) ? $attemptId : (is_string($attemptId) ? intval($attemptId) : 0); + } + + // Create new PaymentAttempt with pending status. + $attempt = PaymentAttemptBAO::create([ + 'contribution_id' => $contribution['id'], + 'contact_id' => $contribution['contact_id'], + 'payment_processor_id' => $contribution['payment_processor_id'], + 'processor_type' => strtolower($processorType), + 'status' => 'pending', + ]); + + if (is_null($attempt)) { + return NULL; + } + + return intval($attempt->id); + } + +} diff --git a/api/v3/InstalmentCharge/Run.php b/api/v3/InstalmentCharge/Run.php new file mode 100644 index 0000000..62e55e0 --- /dev/null +++ b/api/v3/InstalmentCharge/Run.php @@ -0,0 +1,121 @@ +chargeInstalments($processorTypes, $batchSize, $maxRetryCount); + + return civicrm_api3_create_success($result, $params, 'InstalmentCharge', 'Run'); +} + +/** + * Parse processor_type parameter into array. + * + * Handles multiple input formats following CiviCRM patterns: + * - Already an array: use as-is + * - Array with 'IN' key: extract values (CiviCRM API convention) + * - Comma-separated string: split into array + * - Single string: wrap in array + * + * @param array $params + * API parameters. + * + * @return array + * Array of processor type names (may be empty). + */ +function _civicrm_api3_instalment_charge_parse_processor_types(array $params): array { + if (empty($params['processor_type'])) { + return []; + } + + $value = $params['processor_type']; + + // Already an array. + if (is_array($value)) { + // Handle CiviCRM API 'IN' format: ['IN' => ['Stripe', 'GoCardless']]. + if (!empty($value['IN'])) { + $value = $value['IN']; + } + // Filter and return. + return array_values(array_filter(array_map('trim', $value))); + } + + // String value - could be comma-separated or single value. + if (is_string($value)) { + $types = array_map('trim', explode(',', $value)); + return array_values(array_filter($types)); + } + + return []; +} + +/** + * InstalmentCharge.Run API specification. + * + * @param array $spec + * API specification array. + */ +function _civicrm_api3_instalment_charge_Run_spec(array &$spec): void { + $spec['processor_type'] = [ + 'title' => 'Processor Type', + 'description' => 'Payment processor type name(s) to charge. Comma-separated for multiple (e.g., "Stripe,GoCardless"), or array. Only processors with event subscribers will process charges.', + 'type' => CRM_Utils_Type::T_STRING, + 'api.required' => TRUE, + ]; + $spec['batch_size'] = [ + 'title' => 'Batch Size', + 'description' => 'Maximum number of contributions to process PER processor type.', + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => TRUE, + ]; + $spec['max_retry_count'] = [ + 'title' => 'Max Retry Count', + 'description' => 'Maximum recurring contribution failure count before skipping.', + 'type' => CRM_Utils_Type::T_INT, + 'api.required' => FALSE, + 'api.default' => 3, + ]; +} diff --git a/docker-compose.phpstan.yml b/docker-compose.phpstan.yml index 1d107f6..4fd2386 100644 --- a/docker-compose.phpstan.yml +++ b/docker-compose.phpstan.yml @@ -1,15 +1,12 @@ services: phpstan: - image: php:8.1-cli + image: composer:2 volumes: - .:/app working_dir: /app entrypoint: [] command: > sh -c " - if [ ! -f /tmp/phpstan.phar ]; then - curl -sL https://github.com/phpstan/phpstan/releases/download/1.12.10/phpstan.phar -o /tmp/phpstan.phar && - chmod +x /tmp/phpstan.phar - fi && - php /tmp/phpstan.phar analyse -c phpstan.neon --memory-limit=1G + composer install --no-interaction --prefer-dist && + vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G " diff --git a/docker-compose.test.yml b/docker-compose.test.yml index b348161..d9f7b49 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -16,7 +16,7 @@ services: - /var/lib/mysql:rw,noexec,nosuid,size=1024m civicrm: - image: compucorp/civicrm-buildkit:1.3.1-php8.0 + image: compucorp/php-fpm.civicrm:8.1 platform: linux/amd64 working_dir: /extension volumes: diff --git a/info.xml b/info.xml index 744332f..05107ca 100644 --- a/info.xml +++ b/info.xml @@ -28,13 +28,14 @@ CRM/Paymentprocessingcore - 23.02.1 + 25.10.2 menu-xml@1.0.0 - mgd-php@1.0.0 - smarty-v2@1.0.1 - entity-types-php@1.0.0 + mgd-php@2.0.0 + entity-types-php@2.0.0 + smarty@1.0.3 + scan-classes@1.0.0 - CRM_Paymentprocessingcore_Upgrader + CiviMix\Schema\Paymentprocessingcore\AutomaticUpgrader diff --git a/managed/Job_InstalmentCharge.mgd.php b/managed/Job_InstalmentCharge.mgd.php new file mode 100644 index 0000000..423b5bf --- /dev/null +++ b/managed/Job_InstalmentCharge.mgd.php @@ -0,0 +1,35 @@ + 'PaymentInstalmentCharge', + 'entity' => 'Job', + 'params' => [ + 'version' => 3, + 'name' => 'Charge due instalments for recurring contributions', + 'description' => 'Selects eligible Pending/Partially paid contributions for charging. Creates PaymentAttempt records and dispatches events for processor-specific payment API calls. Processes each processor type sequentially.', + 'run_frequency' => 'Always', + 'api_entity' => 'InstalmentCharge', + 'api_action' => 'Run', + 'api_version' => 3, + 'parameters' => "processor_type=Stripe\nbatch_size=500\nmax_retry_count=3", + 'is_active' => 1, + ], + ], +]; diff --git a/mixin/lib/civimix-schema@5.93.beta1/pathload.main.php b/mixin/lib/civimix-schema@5.93.beta1/pathload.main.php new file mode 100644 index 0000000..f0442e8 --- /dev/null +++ b/mixin/lib/civimix-schema@5.93.beta1/pathload.main.php @@ -0,0 +1,28 @@ +activatePackage('civimix-schema@5', __DIR__, [ + 'reloadable' => TRUE, + // The civimix-schema library specifically supports installation processes. From a + // bootstrap/service-availability POV, this is a rough environment which leads to + // the "Multi-Activation Issue" and "Multi-Download Issue". To adapt to them, + // civimix-schema follows "Reloadable Library" patterns. + // More information: https://github.com/totten/pathload-poc/blob/master/doc/issues.md +]); + +// When reloading, we make newer instance of the Facade object. +$GLOBALS['CiviMixSchema'] = require __DIR__ . '/src/CiviMixSchema.php'; + +if (!interface_exists(__NAMESPACE__ . '\SchemaHelperInterface')) { + require __DIR__ . '/src/SchemaHelperInterface.php'; +} + +// \CiviMix\Schema\loadClass() is a facade. The facade should remain identical across versions. +if (!function_exists(__NAMESPACE__ . '\loadClass')) { + + function loadClass(string $class) { + return $GLOBALS['CiviMixSchema']->loadClass($class); + } + + spl_autoload_register(__NAMESPACE__ . '\loadClass'); +} diff --git a/mixin/lib/civimix-schema@5.93.beta1/src/AutomaticUpgrader.php b/mixin/lib/civimix-schema@5.93.beta1/src/AutomaticUpgrader.php new file mode 100644 index 0000000..db6140b --- /dev/null +++ b/mixin/lib/civimix-schema@5.93.beta1/src/AutomaticUpgrader.php @@ -0,0 +1,181 @@ +initIdentity($params); + if ($info = $this->getInfo()) { + if ($class = $this->getDelegateUpgraderClass($info)) { + $this->customUpgrader = new $class(); + $this->customUpgrader->init($params); + if ($errors = $this->checkDelegateCompatibility($this->customUpgrader)) { + throw new \CRM_Core_Exception("AutomaticUpgrader is not compatible with $class:\n" . implode("\n", $errors)); + } + } + } + } + + public function notify(string $event, array $params = []) { + $info = $this->getInfo(); + if (!$info) { + return; + } + + if ($event === 'install') { + $GLOBALS['CiviMixSchema']->getHelper($this->getExtensionKey())->install(); + } + + if ($this->customUpgrader) { + $result = $this->customUpgrader->notify($event, $params); + // for upgrade checks, we need to pass check results up to the caller + // (for now - could definitely be more elegant!) + if ($event === 'upgrade') { + return $result; + } + } + + if ($event === 'uninstall') { + $GLOBALS['CiviMixSchema']->getHelper($this->getExtensionKey())->uninstall(); + } + } + + /** + * Civix-based extensions have a conventional name for their upgrader class ("CRM_Myext_Upgrader" + * or "Civi\Myext\Upgrader"). Figure out if this class exists. + * + * @param \CRM_Extension_Info $info + * @return string|null + * Ex: 'CRM_Myext_Upgrader' or 'Civi\Myext\Upgrader' + */ + public function getDelegateUpgraderClass(\CRM_Extension_Info $info): ?string { + $candidates = []; + + if (!empty($info->civix['namespace'])) { + $namespace = $info->civix['namespace']; + $candidates[] = sprintf('%s_Upgrader', str_replace('/', '_', $namespace)); + $candidates[] = sprintf('%s\\Upgrader', str_replace('/', '\\', $namespace)); + } + + foreach ($candidates as $candidate) { + if (class_exists($candidate)) { + return $candidate; + } + } + + return NULL; + } + + public function getInfo(): ?\CRM_Extension_Info { + try { + return \CRM_Extension_System::singleton()->getMapper()->keyToInfo($this->extensionName); + } + catch (\CRM_Extension_Exception_ParseException $e) { + \Civi::log()->error("Parse error in extension " . $this->extensionName . ": " . $e->getMessage()); + return NULL; + } + } + + /** + * @param \CRM_Extension_Upgrader_Interface $upgrader + * @return array + * List of error messages. + */ + public function checkDelegateCompatibility($upgrader): array { + $class = get_class($upgrader); + + $errors = []; + + if (!($upgrader instanceof \CRM_Extension_Upgrader_Base)) { + $errors[] = "$class is not based on CRM_Extension_Upgrader_Base."; + return $errors; + } + + // In the future, we will probably modify AutomaticUpgrader to build its own + // sequence of revisions (based on other sources of data). AutomaticUpgrader + // is only regarded as compatible with classes that strictly follow the standard revision-model. + $methodNames = [ + 'appendTask', + 'onUpgrade', + 'getRevisions', + 'getCurrentRevision', + 'setCurrentRevision', + 'enqueuePendingRevisions', + 'hasPendingRevisions', + ]; + foreach ($methodNames as $methodName) { + $method = new \ReflectionMethod($upgrader, $methodName); + if ($method->getDeclaringClass()->getName() !== 'CRM_Extension_Upgrader_Base') { + $errors[] = "To ensure future interoperability, AutomaticUpgrader only supports {$class}::{$methodName}() if it's inherited from CRM_Extension_Upgrader_Base"; + } + } + + return $errors; + } + + public function __set($property, $value) { + switch ($property) { + // _queueAdapter() needs these properties. + case 'ctx': + case 'queue': + if (!$this->customUpgrader) { + throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot assign delegated property: $property (No custom-upgrader found)"); + } + // "Invasive": unlike QueueTrait, we are not in the same class as the recipient. And we can't replace previously-published QueueTraits. + Invasive::set([$this->customUpgrader, $property], $value); + return; + } + + throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot assign unknown property: $property"); + } + + public function __get($property) { + switch ($property) { + // _queueAdapter() needs these properties. + case 'ctx': + case 'queue': + if (!$this->customUpgrader) { + throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot read delegated property: $property (No custom-upgrader found)"); + } + // "Invasive": Unlike QueueTrait, we are not in the same class as the recipient. And we can't replace previously-published QueueTraits. + return Invasive::get([$this->customUpgrader, $property]); + } + throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot read unknown property: $property"); + } + + public function __call($name, $arguments) { + if ($this->customUpgrader) { + return call_user_func_array([$this->customUpgrader, $name], $arguments); + } + else { + throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot delegate method $name (No custom-upgrader found)"); + } + } + +}; diff --git a/mixin/lib/civimix-schema@5.93.beta1/src/CiviMixSchema.php b/mixin/lib/civimix-schema@5.93.beta1/src/CiviMixSchema.php new file mode 100644 index 0000000..6d0a350 --- /dev/null +++ b/mixin/lib/civimix-schema@5.93.beta1/src/CiviMixSchema.php @@ -0,0 +1,46 @@ +regex, $class, $m)) { + $absPath = __DIR__ . DIRECTORY_SEPARATOR . $m[2] . '.php'; + class_alias(get_class(require $absPath), $class); + } + } + + /** + * @param string $extensionKey + * Ex: 'org.civicrm.flexmailer' + * @return \CiviMix\Schema\SchemaHelperInterface + */ + public function getHelper(string $extensionKey) { + $store = &\Civi::$statics['CiviMixSchema-helpers']; + if (!isset($store[$extensionKey])) { + $class = get_class(require __DIR__ . '/SchemaHelper.php'); + $store[$extensionKey] = new $class($extensionKey); + } + return $store[$extensionKey]; + } + +}; diff --git a/mixin/lib/civimix-schema@5.93.beta1/src/DAO.php b/mixin/lib/civimix-schema@5.93.beta1/src/DAO.php new file mode 100644 index 0000000..3b82b27 --- /dev/null +++ b/mixin/lib/civimix-schema@5.93.beta1/src/DAO.php @@ -0,0 +1,350 @@ + $field) { + $this->$name = NULL; + } + } + + /** + * @inheritDoc + */ + public function keys(): array { + $keys = []; + foreach (static::getEntityDefinition()['getFields']() as $name => $field) { + if (!empty($field['primary_key'])) { + $keys[] = $name; + } + } + return $keys; + } + + public static function getEntityTitle($plural = FALSE) { + $info = static::getEntityInfo(); + return ($plural && isset($info['title_plural'])) ? $info['title_plural'] : $info['title']; + } + + /** + * @inheritDoc + */ + public static function getEntityPaths(): array { + $definition = static::getEntityDefinition(); + if (isset($definition['getPaths'])) { + return $definition['getPaths'](); + } + return []; + } + + public static function getLabelField(): ?string { + return static::getEntityInfo()['label_field'] ?? NULL; + } + + /** + * @inheritDoc + */ + public static function getEntityDescription(): ?string { + return static::getEntityInfo()['description'] ?? NULL; + } + + /** + * @inheritDoc + */ + public static function getTableName() { + return static::getEntityDefinition()['table']; + } + + /** + * @inheritDoc + */ + public function getLog(): bool { + return static::getEntityInfo()['log'] ?? FALSE; + } + + /** + * @inheritDoc + */ + public static function getEntityIcon(string $entityName, ?int $entityId = NULL): ?string { + return static::getEntityInfo()['icon'] ?? NULL; + } + + /** + * @inheritDoc + */ + protected static function getTableAddVersion(): string { + return static::getEntityInfo()['add'] ?? '1.0'; + } + + /** + * @inheritDoc + */ + public static function getExtensionName(): ?string { + return static::getEntityDefinition()['module']; + } + + /** + * @inheritDoc + */ + public static function &fields() { + $fields = []; + foreach (static::getSchemaFields() as $field) { + $key = $field['uniqueName'] ?? $field['name']; + unset($field['uniqueName']); + $fields[$key] = $field; + } + return $fields; + } + + private static function getSchemaFields(): array { + if (!isset(\Civi::$statics[static::class]['fields'])) { + \Civi::$statics[static::class]['fields'] = static::loadSchemaFields(); + } + return \Civi::$statics[static::class]['fields']; + } + + private static function loadSchemaFields(): array { + $fields = []; + $entityDef = static::getEntityDefinition(); + $baoName = \CRM_Core_DAO_AllCoreTables::getBAOClassName(static::class); + + foreach ($entityDef['getFields']() as $fieldName => $fieldSpec) { + $field = [ + 'name' => $fieldName, + 'type' => !empty($fieldSpec['data_type']) ? \CRM_Utils_Type::getValidTypes()[$fieldSpec['data_type']] : static::getCrmTypeFromSqlType($fieldSpec['sql_type']), + 'title' => $fieldSpec['title'], + 'description' => $fieldSpec['description'] ?? NULL, + ]; + if (!empty($fieldSpec['required'])) { + $field['required'] = TRUE; + } + if (strpos($fieldSpec['sql_type'], 'decimal(') === 0) { + $precision = self::getFieldLength($fieldSpec['sql_type']); + $field['precision'] = array_map('intval', explode(',', $precision)); + } + foreach (['maxlength', 'size', 'rows', 'cols'] as $attr) { + if (isset($fieldSpec['input_attrs'][$attr])) { + $field[$attr] = $fieldSpec['input_attrs'][$attr]; + unset($fieldSpec['input_attrs'][$attr]); + } + } + if (strpos($fieldSpec['sql_type'], 'char(') !== FALSE) { + $length = self::getFieldLength($fieldSpec['sql_type']); + if (!isset($field['size'])) { + $field['size'] = constant(static::getDefaultSize($length)); + } + if (!isset($field['maxlength'])) { + $field['maxlength'] = $length; + } + } + $usage = $fieldSpec['usage'] ?? []; + $field['usage'] = [ + 'import' => in_array('import', $usage), + 'export' => in_array('export', $usage), + 'duplicate_matching' => in_array('duplicate_matching', $usage), + 'token' => in_array('token', $usage), + ]; + if ($field['usage']['import']) { + $field['import'] = TRUE; + } + $field['where'] = $entityDef['table'] . '.' . $field['name']; + if ($field['usage']['export'] || (!$field['usage']['export'] && $field['usage']['import'])) { + $field['export'] = $field['usage']['export']; + } + if (!empty($fieldSpec['contact_type'])) { + $field['contactType'] = $fieldSpec['contact_type']; + } + if (!empty($fieldSpec['permission'])) { + $field['permission'] = $fieldSpec['permission']; + } + if (array_key_exists('default', $fieldSpec)) { + $field['default'] = isset($fieldSpec['default']) ? (string) $fieldSpec['default'] : NULL; + if (is_bool($fieldSpec['default'])) { + $field['default'] = $fieldSpec['default'] ? '1' : '0'; + } + } + $field['table_name'] = $entityDef['table']; + $field['entity'] = $entityDef['name']; + $field['bao'] = $baoName; + $field['localizable'] = intval($fieldSpec['localizable'] ?? 0); + if (!empty($fieldSpec['localize_context'])) { + $field['localize_context'] = (string) $fieldSpec['localize_context']; + } + if (!empty($fieldSpec['entity_reference'])) { + if (!empty($fieldSpec['entity_reference']['entity'])) { + $field['FKClassName'] = static::getDAONameForEntity($fieldSpec['entity_reference']['entity']); + } + if (!empty($fieldSpec['entity_reference']['dynamic_entity'])) { + $field['DFKEntityColumn'] = $fieldSpec['entity_reference']['dynamic_entity']; + } + $field['FKColumnName'] = $fieldSpec['entity_reference']['key'] ?? 'id'; + } + if (!empty($fieldSpec['component'])) { + $field['component'] = $fieldSpec['component']; + } + if (!empty($fieldSpec['serialize'])) { + $field['serialize'] = $fieldSpec['serialize']; + } + if (!empty($fieldSpec['unique_name'])) { + $field['uniqueName'] = $fieldSpec['unique_name']; + } + if (!empty($fieldSpec['unique_title'])) { + $field['unique_title'] = $fieldSpec['unique_title']; + } + if (!empty($fieldSpec['deprecated'])) { + $field['deprecated'] = TRUE; + } + if (!empty($fieldSpec['input_attrs'])) { + $field['html'] = \CRM_Utils_Array::rekey($fieldSpec['input_attrs'], function($str) { + return \CRM_Utils_String::convertStringToCamel($str, FALSE); + }); + } + if (!empty($fieldSpec['input_type'])) { + $field['html']['type'] = $fieldSpec['input_type']; + } + if (!empty($fieldSpec['pseudoconstant'])) { + $field['pseudoconstant'] = \CRM_Utils_Array::rekey($fieldSpec['pseudoconstant'], function($str) { + return \CRM_Utils_String::convertStringToCamel($str, FALSE); + }); + if (!isset($field['pseudoconstant']['optionEditPath']) && !empty($field['pseudoconstant']['optionGroupName'])) { + $field['pseudoconstant']['optionEditPath'] = 'civicrm/admin/options/' . $field['pseudoconstant']['optionGroupName']; + } + } + if (!empty($fieldSpec['primary_key']) || !empty($fieldSpec['readonly'])) { + $field['readonly'] = TRUE; + } + $field['add'] = $fieldSpec['add'] ?? NULL; + $fields[$fieldName] = $field; + } + \CRM_Core_DAO_AllCoreTables::invoke(static::class, 'fields_callback', $fields); + return $fields; + } + + private static function getFieldLength($sqlType): ?string { + $open = strpos($sqlType, '('); + if ($open) { + return substr($sqlType, $open + 1, -1); + } + return NULL; + } + + /** + * @inheritDoc + */ + public static function indices(bool $localize = TRUE): array { + $definition = static::getEntityDefinition(); + $indices = []; + if (isset($definition['getIndices'])) { + $fields = $definition['getFields'](); + foreach ($definition['getIndices']() as $name => $info) { + $index = [ + 'name' => $name, + 'field' => [], + 'localizable' => FALSE, + ]; + foreach ($info['fields'] as $fieldName => $length) { + if (!empty($fields[$fieldName]['localizable'])) { + $index['localizable'] = TRUE; + } + if (is_int($length)) { + $fieldName .= "($length)"; + } + $index['field'][] = $fieldName; + } + if (!empty($info['unique'])) { + $index['unique'] = TRUE; + } + $index['sig'] = ($definition['table']) . '::' . intval($info['unique'] ?? 0) . '::' . implode('::', $index['field']); + $indices[$name] = $index; + } + } + return ($localize && $indices) ? \CRM_Core_DAO_AllCoreTables::multilingualize(static::class, $indices) : $indices; + } + + public static function getEntityDefinition(): array { + if (!isset(\Civi::$statics[static::class]['definition'])) { + $class = new \ReflectionClass(static::class); + $file = substr(basename($class->getFileName()), 0, -4) . '.entityType.php'; + $folder = dirname($class->getFileName(), 4) . '/schema/'; + $path = $folder . $file; + \Civi::$statics[static::class]['definition'] = include $path; + } + return \Civi::$statics[static::class]['definition']; + } + + private static function getEntityInfo(): array { + return static::getEntityDefinition()['getInfo'](); + } + + private static function getDefaultSize($length) { + // Infer from tag if was not explicitly set or was invalid + // This map is slightly different from CRM_Core_Form_Renderer::$_sizeMapper + // Because we usually want fields to render as smaller than their maxlength + $sizes = [ + 2 => 'TWO', + 4 => 'FOUR', + 6 => 'SIX', + 8 => 'EIGHT', + 16 => 'TWELVE', + 32 => 'MEDIUM', + 64 => 'BIG', + ]; + foreach ($sizes as $size => $name) { + if ($length <= $size) { + return "CRM_Utils_Type::$name"; + } + } + return 'CRM_Utils_Type::HUGE'; + } + + private static function getCrmTypeFromSqlType(string $sqlType): int { + [$type] = explode('(', $sqlType); + switch ($type) { + case 'varchar': + case 'char': + return \CRM_Utils_Type::T_STRING; + + case 'datetime': + return \CRM_Utils_Type::T_DATE + \CRM_Utils_Type::T_TIME; + + case 'decimal': + return \CRM_Utils_Type::T_MONEY; + + case 'double': + return \CRM_Utils_Type::T_FLOAT; + + case 'int unsigned': + case 'tinyint': + return \CRM_Utils_Type::T_INT; + + default: + return constant('CRM_Utils_Type::T_' . strtoupper($type)); + } + } + + private static function getDAONameForEntity($entity) { + if (is_callable(['CRM_Core_DAO_AllCoreTables', 'getDAONameForEntity'])) { + return \CRM_Core_DAO_AllCoreTables::getDAONameForEntity($entity); + } + else { + return \CRM_Core_DAO_AllCoreTables::getFullName($entity); + } + } + +}; diff --git a/mixin/lib/civimix-schema@5.93.beta1/src/SchemaHelper.php b/mixin/lib/civimix-schema@5.93.beta1/src/SchemaHelper.php new file mode 100644 index 0000000..5754805 --- /dev/null +++ b/mixin/lib/civimix-schema@5.93.beta1/src/SchemaHelper.php @@ -0,0 +1,179 @@ +key = $key; + } + + public function install(): void { + $this->runSqls([$this->generateInstallSql()]); + } + + public function uninstall(): void { + $this->runSqls([$this->generateUninstallSql()]); + } + + public function generateInstallSql(): ?string { + return $this->getSqlGenerator()->getCreateTablesSql(); + } + + public function generateUninstallSql(): string { + return $this->getSqlGenerator()->getDropTablesSql(); + } + + public function hasSchema(): bool { + return file_exists($this->getExtensionDir() . '/schema'); + } + + /** + * @param string $entityName + * @return string|null + */ + public function getTableName(string $entityName): ?string { + // Legacy compatability with CiviCRM < 5.74 + if (!method_exists('Civi', 'entity')) { + return \CRM_Core_DAO_AllCoreTables::getTableForEntityName($entityName); + } + return \Civi::entity($entityName)->getMeta('table'); + } + + public function tableExists(string $tableName): bool { + return \CRM_Core_DAO::checkTableExists($tableName); + } + + /** + * @param string $entityName + * @param string $fieldName + * @return bool + */ + public function schemaFieldExists(string $entityName, string $fieldName): bool { + return \CRM_Core_BAO_SchemaHandler::checkIfFieldExists($this->getTableName($entityName), $fieldName, FALSE); + } + + /** + * Converts an entity or field definition to SQL statement. + * + * @param array $defn + * The definition array, which can either represent + * an entity with fields or a single database column. + * @return string + * The generated SQL statement, which is either an SQL command + * for creating a table with constraints or for defining a single column. + */ + public function arrayToSql(array $defn): string { + $generator = $this->getSqlGenerator(); + // Entity array: generate entire table + if (isset($defn['getFields'])) { + return $generator->generateCreateTableWithConstraintSql($defn); + } + // Field array: generate single column + else { + return $generator->generateFieldSql($defn); + } + } + + /** + * Create table (if not exists) from a given php schema file. + * + * The original entityType.php file should be copied to a directory (e.g. `my_extension/upgrade/schema`) + * and prefixed with the version-added. + * + * @param string $filePath + * Relative path to copied schema file (relative to extension directory). + * @return bool + * @throws \CRM_Core_Exception + */ + public function createEntityTable(string $filePath): bool { + $absolutePath = $this->getExtensionDir() . DIRECTORY_SEPARATOR . $filePath; + $entityDefn = include $absolutePath; + $sql = $this->arrayToSql($entityDefn); + \CRM_Core_DAO::executeQuery($sql, [], TRUE, NULL, FALSE, FALSE); + return TRUE; + } + + /** + * Task to add or change a column definition, based on the php schema spec. + * + * @param string $entityName + * @param string $fieldName + * @param array $fieldSpec + * As definied in the .entityType.php file for $entityName + * @param string|null $position + * E.g. "AFTER `another_column_name`" or "FIRST" + * @return bool + * @throws \CRM_Core_Exception + */ + public function alterSchemaField(string $entityName, string $fieldName, array $fieldSpec, ?string $position = NULL): bool { + $tableName = $this->getTableName($entityName); + $fieldSql = $this->arrayToSql($fieldSpec); + if ($position) { + $fieldSql .= " $position"; + } + if ($this->schemaFieldExists($entityName, $fieldName)) { + $query = "ALTER TABLE `$tableName` CHANGE `$fieldName` `$fieldName` $fieldSql"; + } + else { + $query = "ALTER TABLE `$tableName` ADD COLUMN `$fieldName` $fieldSql"; + } + \CRM_Core_DAO::executeQuery($query, [], TRUE, NULL, FALSE, FALSE); + return TRUE; + } + + public function dropSchemaField(string $entityName, string $fieldName): bool { + if ($this->schemaFieldExists($entityName, $fieldName)) { + $tableName = $this->getTableName($entityName); + \CRM_Core_DAO::executeQuery("ALTER TABLE `$tableName` DROP COLUMN `$fieldName`", [], TRUE, NULL, FALSE, FALSE); + } + return TRUE; + } + + public function dropTable(string $tableName): bool { + \CRM_Core_BAO_SchemaHandler::dropTable($tableName); + return TRUE; + } + + /** + * @param array $sqls + * List of SQL scripts. + */ + private function runSqls(array $sqls): void { + foreach ($sqls as $sql) { + \CRM_Utils_File::runSqlQuery(CIVICRM_DSN, $sql); + } + } + + protected function getExtensionDir(): string { + if ($this->key === 'civicrm') { + $r = new \ReflectionClass('CRM_Core_ClassLoader'); + return dirname($r->getFileName(), 3); + } + $system = \CRM_Extension_System::singleton(); + return $system->getMapper()->keyToBasePath($this->key); + } + + private function getSqlGenerator() { + if ($this->sqlGenerator === NULL) { + $gen = require __DIR__ . '/SqlGenerator.php'; + $this->sqlGenerator = $gen::createFromFolder($this->key, $this->getExtensionDir() . '/schema', $this->key === 'civicrm'); + } + return $this->sqlGenerator; + } + +}; diff --git a/mixin/lib/civimix-schema@5.93.beta1/src/SchemaHelperInterface.php b/mixin/lib/civimix-schema@5.93.beta1/src/SchemaHelperInterface.php new file mode 100644 index 0000000..663e500 --- /dev/null +++ b/mixin/lib/civimix-schema@5.93.beta1/src/SchemaHelperInterface.php @@ -0,0 +1,44 @@ +entities = array_filter($entities, function($entity) { + return !empty($entity['table']); + }); + $this->findExternalTable = $findExternalTable ?: function() { + return NULL; + }; + } + + public function getEntities(): array { + return $this->entities; + } + + public function getCreateTablesSql(): string { + $sql = ''; + foreach ($this->entities as $entity) { + $sql .= $this->generateCreateTableSql($entity); + } + foreach ($this->entities as $entity) { + $sql .= $this->generateConstraintsSql($entity); + } + return $sql; + } + + public function getCreateTableSql(string $entityName): string { + $sql = $this->generateCreateTableSql($this->entities[$entityName]); + $sql .= $this->generateConstraintsSql($this->entities[$entityName]); + return $sql; + } + + public function getDropTablesSql(): string { + $sql = "SET FOREIGN_KEY_CHECKS=0;\n"; + foreach ($this->entities as $entity) { + $sql .= "DROP TABLE IF EXISTS `{$entity['table']}`;\n"; + } + $sql .= "SET FOREIGN_KEY_CHECKS=1;\n"; + return $sql; + } + + public function generateCreateTableWithConstraintSql(array $entity): string { + $definition = $this->getTableDefinition($entity); + $constraints = $this->getTableConstraints($entity); + $sql = "CREATE TABLE IF NOT EXISTS `{$entity['table']}` (\n " . + implode(",\n ", $definition); + if ($constraints) { + $sql .= ",\n " . implode(",\n ", $constraints); + } + $sql .= "\n)\n" . $this->getTableOptions() . ";\n"; + return $sql; + } + + private function generateCreateTableSql(array $entity): string { + $definition = $this->getTableDefinition($entity); + $sql = "CREATE TABLE IF NOT EXISTS `{$entity['table']}` (\n " . + implode(",\n ", $definition) . + "\n)\n" . + $this->getTableOptions() . ";\n"; + return $sql; + } + + private function getTableDefinition(array $entity): array { + $definition = []; + $primaryKeys = []; + foreach ($entity['getFields']() as $fieldName => $field) { + if (!empty($field['primary_key'])) { + $primaryKeys[] = "`$fieldName`"; + } + $definition[] = "`$fieldName` " . self::generateFieldSql($field); + } + if ($primaryKeys) { + $definition[] = 'PRIMARY KEY (' . implode(', ', $primaryKeys) . ')'; + } + $indices = isset($entity['getIndices']) ? $entity['getIndices']() : []; + foreach ($indices as $indexName => $index) { + $indexFields = []; + foreach ($index['fields'] as $fieldName => $length) { + $indexFields[] = "`$fieldName`" . (is_int($length) ? "($length)" : ''); + } + $definition[] = (!empty($index['unique']) ? 'UNIQUE ' : '') . "INDEX `$indexName`(" . implode(', ', $indexFields) . ')'; + } + return $definition; + } + + private function generateConstraintsSql(array $entity): string { + $constraints = $this->getTableConstraints($entity); + $sql = ''; + if ($constraints) { + $sql .= "ALTER TABLE `{$entity['table']}`\n "; + $sql .= 'ADD ' . implode(",\n ADD ", $constraints) . ";\n"; + } + return $sql; + } + + private function getTableConstraints(array $entity): array { + $constraints = []; + foreach ($entity['getFields']() as $fieldName => $field) { + // `entity_reference.fk` defaults to TRUE if not set. If FALSE, do not add constraint. + if (!empty($field['entity_reference']['entity']) && ($field['entity_reference']['fk'] ?? TRUE)) { + $fkName = \CRM_Core_BAO_SchemaHandler::getIndexName($entity['table'], $fieldName); + // Make sure the FK does not already exist... + CRM_Core_BAO_SchemaHandler::safeRemoveFK($entity['table'], 'FK_' . $fkName); + $constraint = "CONSTRAINT `FK_$fkName` FOREIGN KEY (`$fieldName`)" . + " REFERENCES `" . $this->getTableForEntity($field['entity_reference']['entity']) . "`(`{$field['entity_reference']['key']}`)"; + if (!empty($field['entity_reference']['on_delete'])) { + $constraint .= " ON DELETE {$field['entity_reference']['on_delete']}"; + } + $constraints[] = $constraint; + } + } + return $constraints; + } + + public static function generateFieldSql(array $field): string { + $fieldSql = $field['sql_type']; + if (!empty($field['collate'])) { + $fieldSql .= " COLLATE {$field['collate']}"; + } + // Required fields and booleans cannot be null + // FIXME: For legacy support this doesn't force boolean fields to be NOT NULL... but it really should. + if (!empty($field['required'])) { + $fieldSql .= ' NOT NULL'; + } + else { + $fieldSql .= ' NULL'; + } + if (!empty($field['auto_increment'])) { + $fieldSql .= " AUTO_INCREMENT"; + } + $fieldSql .= self::getDefaultSql($field); + if (!empty($field['description'])) { + $fieldSql .= " COMMENT '" . \CRM_Core_DAO::escapeString($field['description']) . "'"; + } + return $fieldSql; + } + + private static function getDefaultSql(array $field): string { + // Booleans always have a default + if ($field['sql_type'] === 'boolean') { + $field += ['default' => FALSE]; + } + if (!array_key_exists('default', $field)) { + return ''; + } + if (is_null($field['default'])) { + $default = 'NULL'; + } + elseif (is_bool($field['default'])) { + $default = $field['default'] ? 'TRUE' : 'FALSE'; + } + elseif (!is_string($field['default']) || str_starts_with($field['default'], 'CURRENT_TIMESTAMP')) { + $default = $field['default']; + } + else { + $default = "'" . \CRM_Core_DAO::escapeString($field['default']) . "'"; + } + return ' DEFAULT ' . $default; + } + + private function getTableForEntity(string $entityName): string { + return $this->entities[$entityName]['table'] ?? call_user_func($this->findExternalTable, $entityName); + } + + /** + * Get general/default options for use in CREATE TABLE (eg character set, collation). + */ + private function getTableOptions(): string { + if (!Civi\Core\Container::isContainerBooted()) { + // Pre-installation environment ==> aka new install + $collation = CRM_Core_BAO_SchemaHandler::DEFAULT_COLLATION; + } + else { + // What character-set is used for CiviCRM core schema? What collation? + // This depends on when the DB was *initialized*: + // - civicrm-core >= 5.33 has used `CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` + // - civicrm-core 4.3-5.32 has used `CHARACTER SET utf8 COLLATE utf8_unicode_ci` + // - civicrm-core <= 4.2 -- I haven't checked, but it's probably the same. + // Some systems have migrated (eg APIv3's `System.utf8conversion`), but (as of Feb 2024) + // we haven't made any effort to push to this change. + $collation = \CRM_Core_BAO_SchemaHandler::getInUseCollation(); + } + + $characterSet = (stripos($collation, 'utf8mb4') !== FALSE) ? 'utf8mb4' : 'utf8'; + return "ENGINE=InnoDB DEFAULT CHARACTER SET {$characterSet} COLLATE {$collation} ROW_FORMAT=DYNAMIC"; + } + +}; diff --git a/mixin/mgd-php@2.0.0.mixin.php b/mixin/mgd-php@2.0.0.mixin.php new file mode 100644 index 0000000..54443bf --- /dev/null +++ b/mixin/mgd-php@2.0.0.mixin.php @@ -0,0 +1,67 @@ +addListener('hook_civicrm_managed', function ($event) use ($mixInfo) { + // When deactivating on a polyfill/pre-mixin system, listeners may not cleanup automatically. + if (!$mixInfo->isActive()) { + return; + } + + // Optimization: if managed entities were requested for specific module(s), + // check name and return early if not applicable. + if ($event->modules && !in_array($mixInfo->longName, $event->modules, TRUE)) { + return; + } + + $path = $mixInfo->getPath(); + $mgdFiles = array_merge( + (array) glob("$path/*.mgd.php"), + CRM_Utils_File::findFiles("$path/managed", '*.mgd.php'), + CRM_Utils_File::findFiles("$path/api", '*.mgd.php'), + CRM_Utils_File::findFiles("$path/CRM", '*.mgd.php'), + CRM_Utils_File::findFiles("$path/Civi", '*.mgd.php'), + ); + + sort($mgdFiles); + foreach ($mgdFiles as $file) { + $es = include $file; + foreach ($es as $e) { + if (empty($e['module'])) { + $e['module'] = $mixInfo->longName; + } + if (empty($e['params']['version'])) { + $e['params']['version'] = '3'; + } + if (empty($e['source'])) { + $e['source'] = $file; + } + $event->entities[] = $e; + } + } + }); + +}; diff --git a/paymentprocessingcore.civix.php b/paymentprocessingcore.civix.php index 1d8860a..60bd5b0 100644 --- a/paymentprocessingcore.civix.php +++ b/paymentprocessingcore.civix.php @@ -75,10 +75,46 @@ public static function findClass($suffix) { return self::CLASS_PREFIX . '_' . str_replace('\\', '_', $suffix); } + /** + * @return \CiviMix\Schema\SchemaHelperInterface + */ + public static function schema() { + if (!isset($GLOBALS['CiviMixSchema'])) { + pathload()->loadPackage('civimix-schema@5', TRUE); + } + return $GLOBALS['CiviMixSchema']->getHelper(static::LONG_NAME); + } + } use CRM_Paymentprocessingcore_ExtensionUtil as E; +pathload()->addSearchDir(__DIR__ . '/mixin/lib'); +spl_autoload_register('_paymentprocessingcore_civix_class_loader', TRUE, TRUE); + +function _paymentprocessingcore_civix_class_loader($class) { + if ($class === 'CRM_Paymentprocessingcore_DAO_Base') { + if (version_compare(CRM_Utils_System::version(), '5.74.beta', '>=')) { + class_alias('CRM_Core_DAO_Base', 'CRM_Paymentprocessingcore_DAO_Base'); + // ^^ Materialize concrete names -- encourage IDE's to pick up on this association. + } + else { + $realClass = 'CiviMix\\Schema\\Paymentprocessingcore\\DAO'; + class_alias($realClass, $class); + // ^^ Abstract names -- discourage IDE's from picking up on this association. + } + return; + } + + // This allows us to tap-in to the installation process (without incurring real file-reads on typical requests). + if (strpos($class, 'CiviMix\\Schema\\Paymentprocessingcore\\') === 0) { + // civimix-schema@5 is designed for backported use in download/activation workflows, + // where new revisions may become dynamically available. + pathload()->loadPackage('civimix-schema@5', TRUE); + CiviMix\Schema\loadClass($class); + } +} + /** * (Delegated) Implements hook_civicrm_config(). * diff --git a/schema/PaymentAttempt.entityType.php b/schema/PaymentAttempt.entityType.php new file mode 100644 index 0000000..63193f0 --- /dev/null +++ b/schema/PaymentAttempt.entityType.php @@ -0,0 +1,157 @@ + 'PaymentAttempt', + 'table' => 'civicrm_payment_attempt', + 'class' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', + 'getInfo' => fn() => [ + 'title' => E::ts('Payment Attempt'), + 'title_plural' => E::ts('Payment Attempts'), + 'description' => E::ts('Tracks payment attempts across all processors (Stripe, GoCardless, ITAS, etc.)'), + 'log' => TRUE, + ], + 'getIndices' => fn() => [ + 'index_contribution_id' => [ + 'fields' => [ + 'contribution_id' => TRUE, + ], + 'unique' => TRUE, + ], + 'index_processor_type' => [ + 'fields' => [ + 'processor_type' => TRUE, + ], + ], + 'index_processor_session' => [ + 'fields' => [ + 'processor_session_id' => TRUE, + 'processor_type' => TRUE, + ], + ], + 'index_processor_payment' => [ + 'fields' => [ + 'processor_payment_id' => TRUE, + 'processor_type' => TRUE, + ], + ], + ], + 'getFields' => fn() => [ + 'id' => [ + 'title' => E::ts('ID'), + 'sql_type' => 'int unsigned', + 'input_type' => 'Number', + 'required' => TRUE, + 'description' => E::ts('Unique ID'), + 'primary_key' => TRUE, + 'auto_increment' => TRUE, + ], + 'contribution_id' => [ + 'title' => E::ts('Contribution ID'), + 'sql_type' => 'int unsigned', + 'input_type' => 'EntityRef', + 'required' => TRUE, + 'description' => E::ts('FK to Contribution'), + 'input_attrs' => [ + 'label' => E::ts('Contribution'), + ], + 'entity_reference' => [ + 'entity' => 'Contribution', + 'key' => 'id', + 'on_delete' => 'CASCADE', + ], + ], + 'contact_id' => [ + 'title' => E::ts('Contact ID'), + 'sql_type' => 'int unsigned', + 'input_type' => 'EntityRef', + 'description' => E::ts('FK to Contact (donor)'), + 'input_attrs' => [ + 'label' => E::ts('Contact'), + ], + 'entity_reference' => [ + 'entity' => 'Contact', + 'key' => 'id', + 'on_delete' => 'SET NULL', + ], + ], + 'payment_processor_id' => [ + 'title' => E::ts('Payment Processor ID'), + 'sql_type' => 'int unsigned', + 'input_type' => 'Select', + 'description' => E::ts('FK to Payment Processor'), + 'input_attrs' => [ + 'label' => E::ts('Payment Processor'), + ], + 'entity_reference' => [ + 'entity' => 'PaymentProcessor', + 'key' => 'id', + 'on_delete' => 'SET NULL', + ], + ], + 'processor_type' => [ + 'title' => E::ts('Processor Type'), + 'sql_type' => 'varchar(50)', + 'input_type' => 'Text', + 'required' => TRUE, + 'description' => E::ts('Processor type: \'stripe\', \'gocardless\', \'itas\', etc.'), + 'input_attrs' => [ + 'label' => E::ts('Processor Type'), + ], + ], + 'processor_session_id' => [ + 'title' => E::ts('Processor Session ID'), + 'sql_type' => 'varchar(255)', + 'input_type' => 'Text', + 'description' => E::ts('Processor session ID (cs_... for Stripe, mandate_... for GoCardless)'), + 'input_attrs' => [ + 'label' => E::ts('Processor Session ID'), + ], + ], + 'processor_payment_id' => [ + 'title' => E::ts('Processor Payment ID'), + 'sql_type' => 'varchar(255)', + 'input_type' => 'Text', + 'description' => E::ts('Processor payment ID (pi_... for Stripe, payment_... for GoCardless)'), + 'input_attrs' => [ + 'label' => E::ts('Processor Payment ID'), + ], + ], + 'status' => [ + 'title' => E::ts('Status'), + 'sql_type' => 'varchar(25)', + 'input_type' => 'Select', + 'required' => TRUE, + 'description' => E::ts('Attempt status: pending, completed, failed, cancelled'), + 'default' => 'pending', + 'input_attrs' => [ + 'label' => E::ts('Status'), + ], + 'pseudoconstant' => [ + 'callback' => 'CRM_Paymentprocessingcore_BAO_PaymentAttempt::getStatuses', + ], + ], + 'created_date' => [ + 'title' => E::ts('Created Date'), + 'sql_type' => 'timestamp', + 'input_type' => 'Select Date', + 'required' => TRUE, + 'description' => E::ts('When attempt was created'), + 'default' => 'CURRENT_TIMESTAMP', + 'input_attrs' => [ + 'label' => E::ts('Created Date'), + ], + ], + 'updated_date' => [ + 'title' => E::ts('Updated Date'), + 'sql_type' => 'timestamp', + 'input_type' => 'Select Date', + 'required' => TRUE, + 'description' => E::ts('Last updated'), + 'default' => 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', + 'input_attrs' => [ + 'label' => E::ts('Updated Date'), + ], + ], + ], +]; diff --git a/schema/PaymentProcessorCustomer.entityType.php b/schema/PaymentProcessorCustomer.entityType.php new file mode 100644 index 0000000..8ba5da5 --- /dev/null +++ b/schema/PaymentProcessorCustomer.entityType.php @@ -0,0 +1,118 @@ + 'PaymentProcessorCustomer', + 'table' => 'civicrm_payment_processor_customer', + 'class' => 'CRM_Paymentprocessingcore_DAO_PaymentProcessorCustomer', + 'getInfo' => fn() => [ + 'title' => E::ts('Payment Processor Customer'), + 'title_plural' => E::ts('Payment Processor Customers'), + 'description' => E::ts('Stores payment processor customer IDs for all processors (Stripe, GoCardless, ITAS, etc.)'), + 'log' => TRUE, + ], + 'getIndices' => fn() => [ + 'index_payment_processor_id' => [ + 'fields' => [ + 'payment_processor_id' => TRUE, + ], + ], + 'index_processor_customer_id' => [ + 'fields' => [ + 'processor_customer_id' => TRUE, + ], + ], + 'index_contact_id' => [ + 'fields' => [ + 'contact_id' => TRUE, + ], + ], + 'unique_contact_processor' => [ + 'fields' => [ + 'contact_id' => TRUE, + 'payment_processor_id' => TRUE, + ], + 'unique' => TRUE, + ], + 'unique_processor_customer' => [ + 'fields' => [ + 'payment_processor_id' => TRUE, + 'processor_customer_id' => TRUE, + ], + 'unique' => TRUE, + ], + ], + 'getFields' => fn() => [ + 'id' => [ + 'title' => E::ts('ID'), + 'sql_type' => 'int unsigned', + 'input_type' => 'Number', + 'required' => TRUE, + 'description' => E::ts('Unique ID'), + 'primary_key' => TRUE, + 'auto_increment' => TRUE, + ], + 'payment_processor_id' => [ + 'title' => E::ts('Payment Processor ID'), + 'sql_type' => 'int unsigned', + 'input_type' => 'Select', + 'required' => TRUE, + 'description' => E::ts('FK to Payment Processor'), + 'input_attrs' => [ + 'label' => E::ts('Payment Processor'), + ], + 'entity_reference' => [ + 'entity' => 'PaymentProcessor', + 'key' => 'id', + 'on_delete' => 'CASCADE', + ], + ], + 'processor_customer_id' => [ + 'title' => E::ts('Processor Customer ID'), + 'sql_type' => 'varchar(255)', + 'input_type' => 'Text', + 'required' => TRUE, + 'description' => E::ts('Customer ID from payment processor (e.g., cus_... for Stripe, cu_... for GoCardless)'), + 'input_attrs' => [ + 'label' => E::ts('Processor Customer ID'), + ], + ], + 'contact_id' => [ + 'title' => E::ts('Contact ID'), + 'sql_type' => 'int unsigned', + 'input_type' => 'EntityRef', + 'required' => TRUE, + 'description' => E::ts('FK to Contact'), + 'input_attrs' => [ + 'label' => E::ts('Contact'), + ], + 'entity_reference' => [ + 'entity' => 'Contact', + 'key' => 'id', + 'on_delete' => 'CASCADE', + ], + ], + 'created_date' => [ + 'title' => E::ts('Created Date'), + 'sql_type' => 'timestamp', + 'input_type' => 'Select Date', + 'required' => TRUE, + 'description' => E::ts('When customer record was created'), + 'default' => 'CURRENT_TIMESTAMP', + 'input_attrs' => [ + 'label' => E::ts('Created Date'), + ], + ], + 'updated_date' => [ + 'title' => E::ts('Updated Date'), + 'sql_type' => 'timestamp', + 'input_type' => 'Select Date', + 'required' => TRUE, + 'description' => E::ts('Last updated'), + 'default' => 'CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP', + 'input_attrs' => [ + 'label' => E::ts('Updated Date'), + ], + ], + ], +]; diff --git a/schema/PaymentWebhook.entityType.php b/schema/PaymentWebhook.entityType.php new file mode 100644 index 0000000..162d51a --- /dev/null +++ b/schema/PaymentWebhook.entityType.php @@ -0,0 +1,170 @@ + 'PaymentWebhook', + 'table' => 'civicrm_payment_webhook', + 'class' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', + 'getInfo' => fn() => [ + 'title' => E::ts('Payment Webhook'), + 'title_plural' => E::ts('Payment Webhooks'), + 'description' => E::ts('Webhook event log for de-duplication and idempotency across all processors'), + 'log' => TRUE, + ], + 'getIndices' => fn() => [ + 'UI_event_processor' => [ + 'fields' => [ + 'event_id' => TRUE, + 'processor_type' => TRUE, + ], + 'unique' => TRUE, + ], + 'index_event_type' => [ + 'fields' => [ + 'event_type' => TRUE, + ], + ], + 'index_status_retry' => [ + 'fields' => [ + 'status' => TRUE, + 'next_retry_at' => TRUE, + ], + ], + ], + 'getFields' => fn() => [ + 'id' => [ + 'title' => E::ts('ID'), + 'sql_type' => 'int unsigned', + 'input_type' => 'Number', + 'required' => TRUE, + 'description' => E::ts('Unique ID'), + 'primary_key' => TRUE, + 'auto_increment' => TRUE, + ], + 'event_id' => [ + 'title' => E::ts('Event ID'), + 'sql_type' => 'varchar(255)', + 'input_type' => 'Text', + 'required' => TRUE, + 'description' => E::ts('Processor event ID (evt_... for Stripe, evt_... for GoCardless)'), + 'input_attrs' => [ + 'label' => E::ts('Event ID'), + ], + ], + 'processor_type' => [ + 'title' => E::ts('Processor Type'), + 'sql_type' => 'varchar(50)', + 'input_type' => 'Text', + 'required' => TRUE, + 'description' => E::ts('Processor type: \'stripe\', \'gocardless\', \'itas\', etc.'), + 'input_attrs' => [ + 'label' => E::ts('Processor Type'), + ], + ], + 'event_type' => [ + 'title' => E::ts('Event Type'), + 'sql_type' => 'varchar(100)', + 'input_type' => 'Text', + 'required' => TRUE, + 'description' => E::ts('Event type (e.g. checkout.session.completed, payment_intent.succeeded)'), + 'input_attrs' => [ + 'label' => E::ts('Event Type'), + ], + ], + 'payment_attempt_id' => [ + 'title' => E::ts('Payment Attempt ID'), + 'sql_type' => 'int unsigned', + 'input_type' => 'EntityRef', + 'description' => E::ts('FK to Payment Attempt'), + 'input_attrs' => [ + 'label' => E::ts('Payment Attempt'), + ], + 'entity_reference' => [ + 'entity' => 'PaymentAttempt', + 'key' => 'id', + 'on_delete' => 'SET NULL', + ], + ], + 'status' => [ + 'title' => E::ts('Status'), + 'sql_type' => 'varchar(25)', + 'input_type' => 'Select', + 'required' => TRUE, + 'description' => E::ts('Processing status: new, processing, processed, error, permanent_error'), + 'default' => 'new', + 'input_attrs' => [ + 'label' => E::ts('Status'), + ], + 'pseudoconstant' => [ + 'callback' => 'CRM_Paymentprocessingcore_BAO_PaymentWebhook::getStatuses', + ], + ], + 'attempts' => [ + 'title' => E::ts('Attempts'), + 'sql_type' => 'int unsigned', + 'input_type' => 'Number', + 'required' => TRUE, + 'description' => E::ts('Number of processing attempts'), + 'default' => 0, + 'input_attrs' => [ + 'label' => E::ts('Attempts'), + ], + ], + 'next_retry_at' => [ + 'title' => E::ts('Next Retry At'), + 'sql_type' => 'timestamp', + 'input_type' => 'Select Date', + 'description' => E::ts('When to retry processing (for exponential backoff)'), + 'input_attrs' => [ + 'label' => E::ts('Next Retry At'), + ], + ], + 'result' => [ + 'title' => E::ts('Result'), + 'sql_type' => 'varchar(50)', + 'input_type' => 'Text', + 'description' => E::ts('Processing result: applied, noop, ignored_out_of_order, error'), + 'input_attrs' => [ + 'label' => E::ts('Result'), + ], + ], + 'error_log' => [ + 'title' => E::ts('Error Log'), + 'sql_type' => 'text', + 'input_type' => 'TextArea', + 'description' => E::ts('Error details if processing failed'), + 'input_attrs' => [ + 'label' => E::ts('Error Log'), + ], + ], + 'processing_started_at' => [ + 'title' => E::ts('Processing Started At'), + 'sql_type' => 'timestamp', + 'input_type' => 'Select Date', + 'description' => E::ts('When webhook entered processing state (for stuck webhook detection)'), + 'input_attrs' => [ + 'label' => E::ts('Processing Started At'), + ], + ], + 'processed_at' => [ + 'title' => E::ts('Processed At'), + 'sql_type' => 'timestamp', + 'input_type' => 'Select Date', + 'description' => E::ts('When event was processed'), + 'input_attrs' => [ + 'label' => E::ts('Processed At'), + ], + ], + 'created_date' => [ + 'title' => E::ts('Created Date'), + 'sql_type' => 'timestamp', + 'input_type' => 'Select Date', + 'required' => TRUE, + 'description' => E::ts('When webhook was received'), + 'default' => 'CURRENT_TIMESTAMP', + 'input_attrs' => [ + 'label' => E::ts('Created Date'), + ], + ], + ], +]; diff --git a/scripts/setup.sh b/scripts/setup.sh index cab8a7b..0c35cb3 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -44,7 +44,8 @@ done echo "🚀 Setting up CiviCRM environment (CiviCRM ${CIVICRM_VERSION}, Drupal ${CMS_VERSION})..." echo "📦 Installing required PHP extensions..." -apt update && apt install -y php-bcmath +PHP_VERSION=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;') +apt update && apt install -y php${PHP_VERSION}-bcmath || echo "bcmath extension may already be installed" echo "⬇️ Downgrading Composer to 2.2.5..." composer self-update 2.2.5 diff --git a/sql/auto_install.sql b/sql/auto_install.sql deleted file mode 100644 index 091dcc5..0000000 --- a/sql/auto_install.sql +++ /dev/null @@ -1,110 +0,0 @@ --- +--------------------------------------------------------------------+ --- | Copyright CiviCRM LLC. All rights reserved. | --- | | --- | This work is published under the GNU AGPLv3 license with some | --- | permitted exceptions and without any warranty. For full license | --- | and copyright information, see https://civicrm.org/licensing | --- +--------------------------------------------------------------------+ --- --- Generated from schema.tpl --- DO NOT EDIT. Generated by CRM_Core_CodeGen --- --- /******************************************************* --- * --- * Clean up the existing tables - this section generated from file:drop.tpl --- * --- *******************************************************/ - -SET FOREIGN_KEY_CHECKS=0; - -DROP TABLE IF EXISTS `civicrm_payment_webhook`; -DROP TABLE IF EXISTS `civicrm_payment_processor_customer`; -DROP TABLE IF EXISTS `civicrm_payment_attempt`; - -SET FOREIGN_KEY_CHECKS=1; - --- /******************************************************* --- * --- * Create new tables --- * --- *******************************************************/ - --- /******************************************************* --- * --- * civicrm_payment_attempt --- * --- * Tracks payment attempts across all processors (Stripe, GoCardless, ITAS, etc.) --- * --- *******************************************************/ -CREATE TABLE `civicrm_payment_attempt` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique ID', - `contribution_id` int unsigned NOT NULL COMMENT 'FK to Contribution', - `contact_id` int unsigned NULL COMMENT 'FK to Contact (donor)', - `payment_processor_id` int unsigned NULL COMMENT 'FK to Payment Processor', - `processor_type` varchar(50) NOT NULL COMMENT 'Processor type: \'stripe\', \'gocardless\', \'itas\', etc.', - `processor_session_id` varchar(255) COMMENT 'Processor session ID (cs_... for Stripe, mandate_... for GoCardless)', - `processor_payment_id` varchar(255) COMMENT 'Processor payment ID (pi_... for Stripe, payment_... for GoCardless)', - `status` varchar(25) NOT NULL DEFAULT 'pending' COMMENT 'Attempt status: pending, completed, failed, cancelled', - `created_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'When attempt was created', - `updated_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Last updated', - PRIMARY KEY (`id`), - UNIQUE INDEX `index_contribution_id`(contribution_id), - INDEX `index_processor_type`(processor_type), - INDEX `index_processor_session`(processor_session_id, processor_type), - INDEX `index_processor_payment`(processor_payment_id, processor_type), - CONSTRAINT FK_civicrm_payment_attempt_contribution_id FOREIGN KEY (`contribution_id`) REFERENCES `civicrm_contribution`(`id`) ON DELETE CASCADE, - CONSTRAINT FK_civicrm_payment_attempt_contact_id FOREIGN KEY (`contact_id`) REFERENCES `civicrm_contact`(`id`) ON DELETE SET NULL, - CONSTRAINT FK_civicrm_payment_attempt_payment_processor_id FOREIGN KEY (`payment_processor_id`) REFERENCES `civicrm_payment_processor`(`id`) ON DELETE SET NULL) -ENGINE=InnoDB; - --- /******************************************************* --- * --- * civicrm_payment_processor_customer --- * --- * Stores payment processor customer IDs for all processors (Stripe, GoCardless, ITAS, etc.) --- * --- *******************************************************/ -CREATE TABLE `civicrm_payment_processor_customer` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique ID', - `payment_processor_id` int unsigned NOT NULL COMMENT 'FK to Payment Processor', - `processor_customer_id` varchar(255) NOT NULL COMMENT 'Customer ID from payment processor (e.g., cus_... for Stripe, cu_... for GoCardless)', - `contact_id` int unsigned NOT NULL COMMENT 'FK to Contact', - `created_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'When customer record was created', - `updated_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Last updated', - PRIMARY KEY (`id`), - INDEX `index_payment_processor_id`(payment_processor_id), - INDEX `index_processor_customer_id`(processor_customer_id), - INDEX `index_contact_id`(contact_id), - UNIQUE INDEX `unique_contact_processor`(contact_id, payment_processor_id), - UNIQUE INDEX `unique_processor_customer`(payment_processor_id, processor_customer_id), - CONSTRAINT FK_civicrm_payment_processor_customer_payment_processor_id FOREIGN KEY (`payment_processor_id`) REFERENCES `civicrm_payment_processor`(`id`) ON DELETE CASCADE, - CONSTRAINT FK_civicrm_payment_processor_customer_contact_id FOREIGN KEY (`contact_id`) REFERENCES `civicrm_contact`(`id`) ON DELETE CASCADE) -ENGINE=InnoDB; - --- /******************************************************* --- * --- * civicrm_payment_webhook --- * --- * Webhook event log for de-duplication and idempotency across all processors --- * --- *******************************************************/ -CREATE TABLE `civicrm_payment_webhook` ( - `id` int unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique ID', - `event_id` varchar(255) NOT NULL COMMENT 'Processor event ID (evt_... for Stripe, evt_... for GoCardless)', - `processor_type` varchar(50) NOT NULL COMMENT 'Processor type: \'stripe\', \'gocardless\', \'itas\', etc.', - `event_type` varchar(100) NOT NULL COMMENT 'Event type (e.g. checkout.session.completed, payment_intent.succeeded)', - `payment_attempt_id` int unsigned NULL COMMENT 'FK to Payment Attempt', - `status` varchar(25) NOT NULL DEFAULT 'new' COMMENT 'Processing status: new, processing, processed, error, permanent_error', - `attempts` int unsigned NOT NULL DEFAULT 0 COMMENT 'Number of processing attempts', - `next_retry_at` timestamp COMMENT 'When to retry processing (for exponential backoff)', - `result` varchar(50) COMMENT 'Processing result: applied, noop, ignored_out_of_order, error', - `error_log` text COMMENT 'Error details if processing failed', - `processing_started_at` timestamp COMMENT 'When webhook entered processing state (for stuck webhook detection)', - `processed_at` timestamp COMMENT 'When event was processed', - `created_date` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'When webhook was received', - PRIMARY KEY (`id`), - UNIQUE INDEX `UI_event_processor`(event_id, processor_type), - INDEX `index_event_type`(event_type), - INDEX `index_status_retry`(status, next_retry_at), - CONSTRAINT FK_civicrm_payment_webhook_payment_attempt_id FOREIGN KEY (`payment_attempt_id`) REFERENCES `civicrm_payment_attempt`(`id`) ON DELETE SET NULL) -ENGINE=InnoDB; diff --git a/sql/auto_uninstall.sql b/sql/auto_uninstall.sql deleted file mode 100644 index 3a1acfa..0000000 --- a/sql/auto_uninstall.sql +++ /dev/null @@ -1,24 +0,0 @@ --- +--------------------------------------------------------------------+ --- | Copyright CiviCRM LLC. All rights reserved. | --- | | --- | This work is published under the GNU AGPLv3 license with some | --- | permitted exceptions and without any warranty. For full license | --- | and copyright information, see https://civicrm.org/licensing | --- +--------------------------------------------------------------------+ --- --- Generated from drop.tpl --- DO NOT EDIT. Generated by CRM_Core_CodeGen --- --- /******************************************************* --- * --- * Clean up the existing tables --- * --- *******************************************************/ - -SET FOREIGN_KEY_CHECKS=0; - -DROP TABLE IF EXISTS `civicrm_payment_webhook`; -DROP TABLE IF EXISTS `civicrm_payment_processor_customer`; -DROP TABLE IF EXISTS `civicrm_payment_attempt`; - -SET FOREIGN_KEY_CHECKS=1; diff --git a/stubs/CiviApi4.stub.php b/stubs/CiviApi4.stub.php index 5521bb9..2ddc83b 100644 --- a/stubs/CiviApi4.stub.php +++ b/stubs/CiviApi4.stub.php @@ -2,7 +2,7 @@ /** * @file - * PHPStan stubs for CiviCRM Api4 entity classes. + * PHPStan stubs for CiviCRM Api4 entity classes and DAO base. * * These classes are dynamically generated by CiviCRM at runtime * and are not available as physical files for static analysis. @@ -87,5 +87,54 @@ class MembershipType { } + /** + * @method static DAOGetAction get(bool $checkPermissions = TRUE) + * @method static DAOCreateAction create(bool $checkPermissions = TRUE) + * @method static DAOUpdateAction update(bool $checkPermissions = TRUE) + * @method static DAODeleteAction delete(bool $checkPermissions = TRUE) + */ + class PaymentAttempt { + + } + + /** + * @method static DAOGetAction get(bool $checkPermissions = TRUE) + * @method static DAOCreateAction create(bool $checkPermissions = TRUE) + * @method static DAOUpdateAction update(bool $checkPermissions = TRUE) + * @method static DAODeleteAction delete(bool $checkPermissions = TRUE) + */ + class PaymentToken { + + } + + /** + * @method static DAOGetAction get(bool $checkPermissions = TRUE) + * @method static DAOCreateAction create(bool $checkPermissions = TRUE) + * @method static DAOUpdateAction update(bool $checkPermissions = TRUE) + * @method static DAODeleteAction delete(bool $checkPermissions = TRUE) + */ + class OptionValue { + + } + +} + +namespace { + + /** + * Stub for CRM_Paymentprocessingcore_DAO_Base. + * + * This class is dynamically aliased to CRM_Core_DAO_Base at runtime. + * Providing a stub allows PHPStan to understand inherited methods. + * + * @method void copyValues(array $params) + * @method void save() + * @method static static writeRecord(array $params) + * + * @property int|string|null $id + */ + class CRM_Paymentprocessingcore_DAO_Base extends CRM_Core_DAO { + + } } diff --git a/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php b/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php index b96e7d8..702fc75 100644 --- a/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php +++ b/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentAttemptTest.php @@ -183,23 +183,93 @@ public function testFindByPaymentIdWrongProcessorType() { } /** - * Tests getStatuses returns correct status options. + * Tests getStatuses returns correct status options including processing. */ public function testGetStatuses() { $statuses = PaymentAttempt::getStatuses(); $this->assertIsArray($statuses); $this->assertArrayHasKey('pending', $statuses); + $this->assertArrayHasKey('processing', $statuses); $this->assertArrayHasKey('completed', $statuses); $this->assertArrayHasKey('failed', $statuses); $this->assertArrayHasKey('cancelled', $statuses); $this->assertEquals('Pending', $statuses['pending']); + $this->assertEquals('Processing', $statuses['processing']); $this->assertEquals('Completed', $statuses['completed']); $this->assertEquals('Failed', $statuses['failed']); $this->assertEquals('Cancelled', $statuses['cancelled']); } + /** + * Tests validateStatus accepts processing status. + */ + public function testValidateStatusAcceptsProcessing(): void { + // Should not throw exception. + PaymentAttempt::validateStatus('processing'); + $this->assertTrue(TRUE); + } + + /** + * Tests validateStatus rejects invalid status. + */ + public function testValidateStatusRejectsInvalid(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid PaymentAttempt status "invalid"'); + PaymentAttempt::validateStatus('invalid'); + } + + /** + * Tests updateStatusAtomic succeeds when status matches. + */ + public function testUpdateStatusAtomicSucceedsOnMatch(): void { + // Create attempt with pending status. + $attempt = PaymentAttempt::create([ + 'contribution_id' => $this->contributionId, + 'contact_id' => $this->contactId, + 'processor_type' => 'stripe', + 'status' => 'pending', + ]); + + // Atomic update from pending to processing should succeed. + $this->assertNotNull($attempt); + $result = PaymentAttempt::updateStatusAtomic((int) $attempt->id, 'pending', 'processing'); + + $this->assertTrue($result); + + // Verify status was updated. + $found = PaymentAttempt::findByContributionId($this->contributionId); + $this->assertNotNull($found); + $this->assertIsArray($found); + $this->assertEquals('processing', $found['status']); + } + + /** + * Tests updateStatusAtomic fails when status does not match. + */ + public function testUpdateStatusAtomicFailsOnMismatch(): void { + // Create attempt with completed status. + $attempt = PaymentAttempt::create([ + 'contribution_id' => $this->contributionId, + 'contact_id' => $this->contactId, + 'processor_type' => 'stripe', + 'status' => 'completed', + ]); + + // Atomic update expecting pending should fail. + $this->assertNotNull($attempt); + $result = PaymentAttempt::updateStatusAtomic((int) $attempt->id, 'pending', 'processing'); + + $this->assertFalse($result); + + // Verify status was NOT updated. + $found = PaymentAttempt::findByContributionId($this->contributionId); + $this->assertNotNull($found); + $this->assertIsArray($found); + $this->assertEquals('completed', $found['status']); + } + /** * Tests updating an existing payment attempt. */ diff --git a/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php b/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php index e81e1d8..f912b9a 100644 --- a/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php +++ b/tests/phpunit/CRM/Paymentprocessingcore/BAO/PaymentWebhookTest.php @@ -58,7 +58,8 @@ public function setUp(): void { 'processor_type' => 'stripe', 'status' => 'pending', ]); - $this->attemptId = $attempt->id; + $this->assertNotNull($attempt); + $this->attemptId = intval($attempt->id); } /** diff --git a/tests/phpunit/Civi/Paymentprocessingcore/DTO/ChargeInstalmentItemTest.php b/tests/phpunit/Civi/Paymentprocessingcore/DTO/ChargeInstalmentItemTest.php new file mode 100644 index 0000000..1a88936 --- /dev/null +++ b/tests/phpunit/Civi/Paymentprocessingcore/DTO/ChargeInstalmentItemTest.php @@ -0,0 +1,77 @@ +assertEquals(123, $item->contributionId); + $this->assertEquals(456, $item->paymentAttemptId); + $this->assertEquals(789, $item->recurringContributionId); + $this->assertEquals(100, $item->contactId); + $this->assertEquals(50.00, $item->amount); + $this->assertEquals('GBP', $item->currency); + $this->assertEquals(200, $item->paymentTokenId); + $this->assertEquals(300, $item->paymentProcessorId); + } + + /** + * Tests readonly properties are accessible. + */ + public function testReadonlyPropertiesAreAccessible(): void { + $item = new ChargeInstalmentItem( + contributionId: 1, + paymentAttemptId: 2, + recurringContributionId: 3, + contactId: 4, + amount: 100.50, + currency: 'USD', + paymentTokenId: 5, + paymentProcessorId: 6 + ); + + // All properties should be accessible without getters. + $this->assertIsInt($item->contributionId); + $this->assertIsInt($item->paymentAttemptId); + $this->assertIsInt($item->recurringContributionId); + $this->assertIsInt($item->contactId); + $this->assertIsFloat($item->amount); + $this->assertIsString($item->currency); + $this->assertIsInt($item->paymentTokenId); + $this->assertIsInt($item->paymentProcessorId); + } + + /** + * Tests that amount handles different numeric values. + */ + public function testAmountHandlesDifferentValues(): void { + $item1 = new ChargeInstalmentItem(1, 2, 3, 4, 0.01, 'GBP', 5, 6); + $this->assertEquals(0.01, $item1->amount); + + $item2 = new ChargeInstalmentItem(1, 2, 3, 4, 9999.99, 'GBP', 5, 6); + $this->assertEquals(9999.99, $item2->amount); + + $item3 = new ChargeInstalmentItem(1, 2, 3, 4, 100.00, 'GBP', 5, 6); + $this->assertEquals(100.00, $item3->amount); + } + +} diff --git a/tests/phpunit/Civi/Paymentprocessingcore/Event/ChargeInstalmentBatchEventTest.php b/tests/phpunit/Civi/Paymentprocessingcore/Event/ChargeInstalmentBatchEventTest.php new file mode 100644 index 0000000..076f6da --- /dev/null +++ b/tests/phpunit/Civi/Paymentprocessingcore/Event/ChargeInstalmentBatchEventTest.php @@ -0,0 +1,99 @@ + new ChargeInstalmentItem(1, 10, 100, 1000, 50.00, 'GBP', 5, 6), + 2 => new ChargeInstalmentItem(2, 20, 200, 2000, 75.00, 'USD', 7, 8), + ]; + + $event = new ChargeInstalmentBatchEvent('Stripe', $items); + + $this->assertEquals('Stripe', $event->getProcessorType()); + $this->assertCount(2, $event->getItems()); + } + + /** + * Tests getProcessorType returns correct value. + */ + public function testGetProcessorTypeReturnsCorrectValue(): void { + $event = new ChargeInstalmentBatchEvent('GoCardless', []); + + $this->assertEquals('GoCardless', $event->getProcessorType()); + } + + /** + * Tests getItems returns all items. + */ + public function testGetItemsReturnsAllItems(): void { + $item1 = new ChargeInstalmentItem(1, 10, 100, 1000, 50.00, 'GBP', 5, 6); + $item2 = new ChargeInstalmentItem(2, 20, 200, 2000, 75.00, 'USD', 7, 8); + $item3 = new ChargeInstalmentItem(3, 30, 300, 3000, 100.00, 'EUR', 9, 10); + + $items = [1 => $item1, 2 => $item2, 3 => $item3]; + + $event = new ChargeInstalmentBatchEvent('Stripe', $items); + + $returnedItems = $event->getItems(); + $this->assertCount(3, $returnedItems); + $this->assertArrayHasKey(1, $returnedItems); + $this->assertArrayHasKey(2, $returnedItems); + $this->assertArrayHasKey(3, $returnedItems); + $this->assertSame($item1, $returnedItems[1]); + $this->assertSame($item2, $returnedItems[2]); + $this->assertSame($item3, $returnedItems[3]); + } + + /** + * Tests event name constant. + */ + public function testEventNameConstant(): void { + $this->assertEquals( + 'paymentprocessingcore.charge_instalment_batch', + ChargeInstalmentBatchEvent::NAME + ); + } + + /** + * Tests empty items array is handled. + */ + public function testEmptyItemsArray(): void { + $event = new ChargeInstalmentBatchEvent('Stripe', []); + + $this->assertEquals('Stripe', $event->getProcessorType()); + $this->assertCount(0, $event->getItems()); + $this->assertEmpty($event->getItems()); + } + + /** + * Tests getMaxRetryCount returns default value. + */ + public function testGetMaxRetryCountReturnsDefaultValue(): void { + $event = new ChargeInstalmentBatchEvent('Stripe', []); + + $this->assertEquals(3, $event->getMaxRetryCount()); + } + + /** + * Tests getMaxRetryCount returns custom value. + */ + public function testGetMaxRetryCountReturnsCustomValue(): void { + $event = new ChargeInstalmentBatchEvent('Stripe', [], 5); + + $this->assertEquals(5, $event->getMaxRetryCount()); + } + +} diff --git a/tests/phpunit/Civi/Paymentprocessingcore/Service/InstalmentChargeServiceTest.php b/tests/phpunit/Civi/Paymentprocessingcore/Service/InstalmentChargeServiceTest.php new file mode 100644 index 0000000..c011e62 --- /dev/null +++ b/tests/phpunit/Civi/Paymentprocessingcore/Service/InstalmentChargeServiceTest.php @@ -0,0 +1,803 @@ + + */ + private array $capturedEvents = []; + + /** + * Set up test fixtures. + */ + public function setUp(): void { + parent::setUp(); + $this->service = new InstalmentChargeService(); + $this->capturedEvents = []; + + // Register event listener to capture dispatched events. + \Civi::dispatcher()->addListener( + ChargeInstalmentBatchEvent::NAME, + [$this, 'captureEvent'] + ); + } + + /** + * Tear down test fixtures. + */ + public function tearDown(): void { + // Remove event listener. + \Civi::dispatcher()->removeListener( + ChargeInstalmentBatchEvent::NAME, + [$this, 'captureEvent'] + ); + parent::tearDown(); + } + + /** + * Capture dispatched events for assertions. + * + * @param \Civi\Paymentprocessingcore\Event\ChargeInstalmentBatchEvent $event + * The dispatched event. + */ + public function captureEvent(ChargeInstalmentBatchEvent $event): void { + $this->capturedEvents[] = $event; + } + + // ------------------------------------------------------------------------- + // Selection query tests + // ------------------------------------------------------------------------- + + /** + * Tests that Pending contributions are selected. + */ + public function testSelectsPendingContributions(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(1, $result['charged']); + $this->assertCount(1, $this->capturedEvents); + } + + /** + * Tests that Partially paid contributions are selected. + */ + public function testSelectsPartiallyPaidContributions(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Partially paid', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + 'total_amount' => 100.00, + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(1, $result['charged']); + } + + /** + * Tests that Completed contributions are skipped. + */ + public function testSkipsCompletedContributions(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Completed', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + $this->assertCount(0, $this->capturedEvents); + } + + /** + * Tests that Failed contributions are skipped. + */ + public function testSkipsFailedContributions(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Failed', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + } + + /** + * Tests that zero outstanding balance contributions are skipped. + * + * Note: In CiviCRM, a contribution with zero outstanding balance would + * have status 'Completed'. This is effectively testSkipsCompletedContributions. + */ + public function testSkipsZeroOutstandingBalance(): void { + // A contribution with zero outstanding balance has status Completed. + $this->createTestFixtures([ + 'contribution_status' => 'Completed', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + 'total_amount' => 50.00, + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + } + + /** + * Tests that future receive dates are skipped. + */ + public function testSkipsFutureReceiveDate(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('+7 days')), + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + } + + /** + * Tests that contributions without recur ID are skipped. + */ + public function testSkipsNullContributionRecurId(): void { + // Create contribution without recurring. + $contactId = $this->createContact(); + \Civi\Api4\Contribution::create(FALSE) + ->addValue('contact_id', $contactId) + ->addValue('financial_type_id:name', 'Donation') + ->addValue('total_amount', 50.00) + ->addValue('contribution_status_id:name', 'Pending') + ->addValue('receive_date', date('Y-m-d', strtotime('-1 day'))) + ->execute(); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + } + + /** + * Tests that cancelled recurring contributions are skipped. + */ + public function testSkipsCancelledRecurring(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + 'recur_status' => 'Cancelled', + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + } + + /** + * Tests that recurring without payment token are skipped. + */ + public function testSkipsNullPaymentToken(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + 'with_payment_token' => FALSE, + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + } + + /** + * Tests that recurring with exceeded failure count are skipped. + */ + public function testSkipsExceededFailureCount(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + 'failure_count' => 5, + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + } + + /** + * Tests that contributions with existing processing attempt are skipped. + */ + public function testSkipsExistingProcessingAttempt(): void { + $fixtures = $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + // Create processing PaymentAttempt. + PaymentAttemptBAO::create([ + 'contribution_id' => $fixtures['contribution_id'], + 'contact_id' => $fixtures['contact_id'], + 'processor_type' => 'dummy', + 'status' => 'processing', + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + } + + /** + * Tests that contributions with existing completed attempt are skipped. + */ + public function testSkipsExistingCompletedAttempt(): void { + $fixtures = $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + // Create completed PaymentAttempt. + PaymentAttemptBAO::create([ + 'contribution_id' => $fixtures['contribution_id'], + 'contact_id' => $fixtures['contact_id'], + 'processor_type' => 'dummy', + 'status' => 'completed', + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + } + + /** + * Tests that contributions with existing cancelled attempt are skipped. + */ + public function testSkipsExistingCancelledAttempt(): void { + $fixtures = $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + // Create cancelled PaymentAttempt. + PaymentAttemptBAO::create([ + 'contribution_id' => $fixtures['contribution_id'], + 'contact_id' => $fixtures['contact_id'], + 'processor_type' => 'dummy', + 'status' => 'cancelled', + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + } + + /** + * Tests that processor type filter works correctly. + */ + public function testFiltersByProcessorType(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + // Query with non-matching processor type. + $result = $this->service->chargeInstalments(['NonExistentProcessor'], 500, 3); + + $this->assertEquals(0, $result['charged']); + } + + /** + * Tests that contributions with pending PaymentAttempt are included. + */ + public function testIncludesContributionWithPendingAttempt(): void { + $fixtures = $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + // Create pending PaymentAttempt. + PaymentAttemptBAO::create([ + 'contribution_id' => $fixtures['contribution_id'], + 'contact_id' => $fixtures['contact_id'], + 'processor_type' => 'dummy', + 'status' => 'pending', + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + // Should be included and attempt transitioned to processing. + $this->assertEquals(1, $result['charged']); + } + + // ------------------------------------------------------------------------- + // PaymentAttempt handling tests + // ------------------------------------------------------------------------- + + /** + * Tests that new PaymentAttempt is created for contribution without one. + */ + public function testCreatesNewPaymentAttemptForNewContribution(): void { + $fixtures = $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + // Check PaymentAttempt was created. + $contributionId = $fixtures['contribution_id']; + $this->assertNotNull($contributionId); + $attempt = PaymentAttemptBAO::findByContributionId($contributionId); + $this->assertNotNull($attempt); + $this->assertEquals('processing', $attempt['status']); + } + + /** + * Tests that existing pending PaymentAttempt is reused. + */ + public function testReusesPendingPaymentAttempt(): void { + $fixtures = $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + // Create pending PaymentAttempt. + $existingAttempt = PaymentAttemptBAO::create([ + 'contribution_id' => $fixtures['contribution_id'], + 'contact_id' => $fixtures['contact_id'], + 'processor_type' => 'dummy', + 'status' => 'pending', + ]); + + $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + // Verify same attempt was used and updated. + $contributionId = $fixtures['contribution_id']; + $this->assertNotNull($contributionId); + $attempt = PaymentAttemptBAO::findByContributionId($contributionId); + $this->assertNotNull($attempt); + $this->assertIsArray($attempt); + $this->assertNotNull($existingAttempt); + $this->assertEquals($existingAttempt->id, $attempt['id']); + $this->assertEquals('processing', $attempt['status']); + } + + /** + * Tests atomic transition to processing status. + */ + public function testAtomicTransitionToProcessing(): void { + $fixtures = $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $contributionId = $fixtures['contribution_id']; + $this->assertNotNull($contributionId); + $attempt = PaymentAttemptBAO::findByContributionId($contributionId); + $this->assertNotNull($attempt); + $this->assertIsArray($attempt); + $this->assertEquals('processing', $attempt['status']); + } + + // ------------------------------------------------------------------------- + // Batch event dispatch tests + // ------------------------------------------------------------------------- + + /** + * Tests that batch event is dispatched with all items. + */ + public function testDispatchesBatchEventWithAllItems(): void { + // Create multiple contributions. + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-2 days')), + ]); + + $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertCount(1, $this->capturedEvents); + $this->assertCount(2, $this->capturedEvents[0]->getItems()); + } + + /** + * Tests that batch event contains correct processor type. + */ + public function testBatchEventContainsCorrectProcessorType(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertCount(1, $this->capturedEvents); + $this->assertEquals(self::PROCESSOR_TYPE, $this->capturedEvents[0]->getProcessorType()); + } + + /** + * Tests that batch event items have correct data. + */ + public function testBatchEventItemsHaveCorrectData(): void { + $fixtures = $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + 'total_amount' => 75.50, + 'currency' => 'GBP', + ]); + + $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $items = $this->capturedEvents[0]->getItems(); + $this->assertCount(1, $items); + + $item = reset($items); + $this->assertInstanceOf(ChargeInstalmentItem::class, $item); + $this->assertEquals($fixtures['contribution_id'], $item->contributionId); + $this->assertEquals($fixtures['recur_id'], $item->recurringContributionId); + $this->assertEquals($fixtures['contact_id'], $item->contactId); + $this->assertEquals(75.50, $item->amount); + $this->assertEquals('GBP', $item->currency); + $this->assertEquals($fixtures['token_id'], $item->paymentTokenId); + $this->assertEquals($fixtures['processor_id'], $item->paymentProcessorId); + } + + /** + * Tests that empty batch does not dispatch event. + */ + public function testEmptyBatchDoesNotDispatchEvent(): void { + // No contributions created. + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + $this->assertCount(0, $this->capturedEvents); + } + + // ------------------------------------------------------------------------- + // Batch processing tests + // ------------------------------------------------------------------------- + + /** + * Tests that max batch size is respected. + */ + public function testRespectsMaxBatchSize(): void { + // Create 5 contributions. + for ($i = 0; $i < 5; $i++) { + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-' . ($i + 1) . ' days')), + ]); + } + + // Only process 2. + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 2, 3); + + $this->assertEquals(2, $result['charged']); + $this->assertCount(1, $this->capturedEvents); + $this->assertCount(2, $this->capturedEvents[0]->getItems()); + } + + /** + * Tests empty batch processes gracefully. + */ + public function testProcessesEmptyBatchGracefully(): void { + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(0, $result['charged']); + $this->assertEquals(0, $result['skipped']); + $this->assertEquals(0, $result['errored']); + $this->assertArrayHasKey('message', $result); + } + + /** + * Tests that result summary has correct counts. + */ + public function testReturnsCorrectSummary(): void { + // Create 2 valid contributions. + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-2 days')), + ]); + + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(2, $result['charged']); + $this->assertArrayHasKey('skipped', $result); + $this->assertArrayHasKey('errored', $result); + $this->assertArrayHasKey('message', $result); + $this->assertArrayHasKey('processors_processed', $result); + $this->assertContains(self::PROCESSOR_TYPE, $result['processors_processed']); + } + + // ------------------------------------------------------------------------- + // Multiple processor types tests + // ------------------------------------------------------------------------- + + /** + * Tests that multiple processor types can be specified. + */ + public function testMultipleProcessorTypesCanBeSpecified(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + ]); + + // Pass array with Dummy processor type. + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertEquals(1, $result['charged']); + $this->assertContains(self::PROCESSOR_TYPE, $result['processors_processed']); + } + + /** + * Tests that each processor type gets its own batch size. + */ + public function testEachProcessorGetsOwnBatchSize(): void { + // Create 3 contributions for Dummy processor. + for ($i = 0; $i < 3; $i++) { + $this->createTestFixtures([ + 'contribution_status' => 'Pending', + 'receive_date' => date('Y-m-d', strtotime('-' . ($i + 1) . ' days')), + ]); + } + + // Process with batch size 2. + $result = $this->service->chargeInstalments([self::PROCESSOR_TYPE], 2, 3); + + // Should process 2 (batch size), not all 3. + $this->assertEquals(2, $result['charged']); + } + + // ------------------------------------------------------------------------- + // Edge case tests + // ------------------------------------------------------------------------- + + /** + * Tests that partially paid contribution is selected and charged. + * + * Note: The current implementation charges total_amount for simplicity. + * Proper outstanding amount calculation from financial transactions + * would require more complex test setup. + */ + public function testHandlesPartiallyPaidContribution(): void { + $this->createTestFixtures([ + 'contribution_status' => 'Partially paid', + 'receive_date' => date('Y-m-d', strtotime('-1 day')), + 'total_amount' => 100.00, + ]); + + $this->service->chargeInstalments([self::PROCESSOR_TYPE], 500, 3); + + $this->assertCount(1, $this->capturedEvents); + $items = $this->capturedEvents[0]->getItems(); + $item = reset($items); + $this->assertNotFalse($item); + + // Currently charges total_amount (paid_amount calculation not implemented). + $this->assertEquals(100.00, $item->amount); + } + + // ------------------------------------------------------------------------- + // Helper methods + // ------------------------------------------------------------------------- + + /** + * Create test fixtures. + * + * @param array $options + * Options for the fixtures. + * + * @return array + * Fixture IDs. + */ + private function createTestFixtures(array $options = []): array { + $contactId = $this->createContact(); + $processorId = $this->createPaymentProcessor(); + + $tokenId = NULL; + if ($options['with_payment_token'] ?? TRUE) { + $tokenId = $this->createPaymentToken($contactId, $processorId); + } + + $recurId = $this->createRecurringContribution($contactId, $processorId, $tokenId, [ + 'contribution_status_id:name' => $options['recur_status'] ?? 'In Progress', + 'failure_count' => $options['failure_count'] ?? 0, + ]); + + $totalAmount = $options['total_amount'] ?? 50.00; + + $contributionId = $this->createContribution($contactId, $recurId, [ + 'contribution_status_id:name' => $options['contribution_status'] ?? 'Pending', + 'receive_date' => $options['receive_date'] ?? date('Y-m-d'), + 'total_amount' => $totalAmount, + 'currency' => $options['currency'] ?? 'GBP', + ]); + + // Note: paid_amount is a computed field, not a real column. + // For Partially Paid contributions, the amount is calculated from + // financial transactions. For test simplicity, we don't create + // actual payment records - the service charges total_amount for Pending + // and we skip complex Partially Paid scenarios. + + return [ + 'contact_id' => $contactId, + 'processor_id' => $processorId, + 'token_id' => $tokenId, + 'recur_id' => $recurId, + 'contribution_id' => $contributionId, + ]; + } + + /** + * Create a contact. + * + * @return int + */ + private function createContact(): int { + $contact = \Civi\Api4\Contact::create(FALSE) + ->addValue('contact_type', 'Individual') + ->addValue('first_name', 'Test') + ->addValue('last_name', 'User') + ->execute() + ->first(); + + if (!is_array($contact) || !isset($contact['id'])) { + throw new \RuntimeException('Failed to create contact'); + } + + return intval($contact['id']); + } + + /** + * Create a payment processor using Dummy type. + * + * @return int + */ + private function createPaymentProcessor(): int { + $processor = \Civi\Api4\PaymentProcessor::create(FALSE) + ->addValue('name', 'Test Processor ' . uniqid()) + ->addValue('payment_processor_type_id:name', self::PROCESSOR_TYPE) + ->addValue('class_name', 'Payment_Dummy') + ->addValue('is_active', TRUE) + ->addValue('is_test', FALSE) + ->addValue('domain_id', 1) + ->execute() + ->first(); + + if (!is_array($processor) || !isset($processor['id'])) { + throw new \RuntimeException('Failed to create payment processor'); + } + + return intval($processor['id']); + } + + /** + * Create a payment token. + * + * @param int $contactId + * @param int $processorId + * + * @return int + */ + private function createPaymentToken(int $contactId, int $processorId): int { + $token = \Civi\Api4\PaymentToken::create(FALSE) + ->addValue('contact_id', $contactId) + ->addValue('payment_processor_id', $processorId) + ->addValue('token', 'tok_test_' . uniqid()) + ->addValue('created_date', date('Y-m-d H:i:s')) + ->execute() + ->first(); + + if (!is_array($token) || !isset($token['id'])) { + throw new \RuntimeException('Failed to create payment token'); + } + + return intval($token['id']); + } + + /** + * Create a recurring contribution. + * + * @param int $contactId + * @param int $processorId + * @param int|null $tokenId + * @param array $params + * + * @return int + */ + private function createRecurringContribution(int $contactId, int $processorId, ?int $tokenId, array $params = []): int { + $defaults = [ + 'contact_id' => $contactId, + 'payment_processor_id' => $processorId, + 'amount' => 50.00, + 'currency' => 'GBP', + 'financial_type_id:name' => 'Donation', + 'frequency_unit:name' => 'month', + 'frequency_interval' => 1, + 'start_date' => date('Y-m-d', strtotime('-6 months')), + 'contribution_status_id:name' => 'In Progress', + ]; + + if ($tokenId !== NULL) { + $defaults['payment_token_id'] = $tokenId; + } + + $values = array_merge($defaults, $params); + + $recur = \Civi\Api4\ContributionRecur::create(FALSE) + ->setValues($values) + ->execute() + ->first(); + + if (!is_array($recur) || !isset($recur['id'])) { + throw new \RuntimeException('Failed to create recurring contribution'); + } + + return intval($recur['id']); + } + + /** + * Create a contribution. + * + * @param int $contactId + * @param int $recurId + * @param array $params + * + * @return int + */ + private function createContribution(int $contactId, int $recurId, array $params = []): int { + $defaults = [ + 'contact_id' => $contactId, + 'contribution_recur_id' => $recurId, + 'financial_type_id:name' => 'Donation', + 'total_amount' => 50.00, + 'currency' => 'GBP', + 'contribution_status_id:name' => 'Pending', + 'receive_date' => date('Y-m-d'), + ]; + + $values = array_merge($defaults, $params); + + $contribution = \Civi\Api4\Contribution::create(FALSE) + ->setValues($values) + ->execute() + ->first(); + + if (!is_array($contribution) || !isset($contribution['id'])) { + throw new \RuntimeException('Failed to create contribution'); + } + + return intval($contribution['id']); + } + +} diff --git a/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.entityType.php b/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.entityType.php deleted file mode 100644 index 54cef62..0000000 --- a/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.entityType.php +++ /dev/null @@ -1,10 +0,0 @@ - 'PaymentAttempt', - 'class' => 'CRM_Paymentprocessingcore_DAO_PaymentAttempt', - 'table' => 'civicrm_payment_attempt', - ], -]; diff --git a/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.xml b/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.xml deleted file mode 100644 index 8f7ffa1..0000000 --- a/xml/schema/CRM/Paymentprocessingcore/PaymentAttempt.xml +++ /dev/null @@ -1,167 +0,0 @@ - - - CRM/Paymentprocessingcore - PaymentAttempt - civicrm_payment_attempt - Tracks payment attempts across all processors (Stripe, GoCardless, ITAS, etc.) - true - - - id - int unsigned - true - Unique ID - - Number - - - - id - true - - - - contribution_id - int unsigned - true - FK to Contribution - - EntityRef - - - - - contribution_id -
civicrm_contribution
- id - CASCADE - - - index_contribution_id - contribution_id - true - - - - contact_id - int unsigned - false - FK to Contact (donor) - - EntityRef - - - - - contact_id - civicrm_contact
- id - SET NULL -
- - - payment_processor_id - int unsigned - false - FK to Payment Processor - - Select - - - - - payment_processor_id - civicrm_payment_processor
- id - SET NULL -
- - - - processor_type - varchar - 50 - true - Processor type: 'stripe', 'gocardless', 'itas', etc. - - Text - - - - - index_processor_type - processor_type - - - - processor_session_id - varchar - 255 - Processor session ID (cs_... for Stripe, mandate_... for GoCardless) - - Text - - - - - index_processor_session - processor_session_id - processor_type - - - - processor_payment_id - varchar - 255 - Processor payment ID (pi_... for Stripe, payment_... for GoCardless) - - Text - - - - - index_processor_payment - processor_payment_id - processor_type - - - - status - varchar - 25 - true - 'pending' - Attempt status: pending, completed, failed, cancelled - - CRM_Paymentprocessingcore_BAO_PaymentAttempt::getStatuses - - - Select - - - - - - created_date - timestamp - true - CURRENT_TIMESTAMP - When attempt was created - - Select Date - - - - - - updated_date - timestamp - true - CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - Last updated - - Select Date - - - - diff --git a/xml/schema/CRM/Paymentprocessingcore/PaymentProcessorCustomer.entityType.php b/xml/schema/CRM/Paymentprocessingcore/PaymentProcessorCustomer.entityType.php deleted file mode 100644 index d67e44d..0000000 --- a/xml/schema/CRM/Paymentprocessingcore/PaymentProcessorCustomer.entityType.php +++ /dev/null @@ -1,10 +0,0 @@ - 'PaymentProcessorCustomer', - 'class' => 'CRM_Paymentprocessingcore_DAO_PaymentProcessorCustomer', - 'table' => 'civicrm_payment_processor_customer', - ], -]; diff --git a/xml/schema/CRM/Paymentprocessingcore/PaymentProcessorCustomer.xml b/xml/schema/CRM/Paymentprocessingcore/PaymentProcessorCustomer.xml deleted file mode 100644 index b468263..0000000 --- a/xml/schema/CRM/Paymentprocessingcore/PaymentProcessorCustomer.xml +++ /dev/null @@ -1,119 +0,0 @@ - - - CRM/Paymentprocessingcore - PaymentProcessorCustomer - civicrm_payment_processor_customer - Stores payment processor customer IDs for all processors (Stripe, GoCardless, ITAS, etc.) - true - - - id - int unsigned - true - Unique ID - - Number - - - - id - true - - - - payment_processor_id - int unsigned - true - FK to Payment Processor - - Select - - - - - payment_processor_id -
civicrm_payment_processor
- id - CASCADE - - - index_payment_processor_id - payment_processor_id - - - - processor_customer_id - varchar - 255 - true - Customer ID from payment processor (e.g., cus_... for Stripe, cu_... for GoCardless) - - Text - - - - - index_processor_customer_id - processor_customer_id - - - - contact_id - int unsigned - true - FK to Contact - - EntityRef - - - - - contact_id - civicrm_contact
- id - CASCADE -
- - index_contact_id - contact_id - - - - - unique_contact_processor - contact_id - payment_processor_id - true - - - - unique_processor_customer - payment_processor_id - processor_customer_id - true - - - - created_date - timestamp - true - CURRENT_TIMESTAMP - When customer record was created - - Select Date - - - - - - updated_date - timestamp - true - CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - Last updated - - Select Date - - - - diff --git a/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.entityType.php b/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.entityType.php deleted file mode 100644 index 968b7c4..0000000 --- a/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.entityType.php +++ /dev/null @@ -1,10 +0,0 @@ - 'PaymentWebhook', - 'class' => 'CRM_Paymentprocessingcore_DAO_PaymentWebhook', - 'table' => 'civicrm_payment_webhook', - ], -]; diff --git a/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.xml b/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.xml deleted file mode 100644 index 87185a1..0000000 --- a/xml/schema/CRM/Paymentprocessingcore/PaymentWebhook.xml +++ /dev/null @@ -1,183 +0,0 @@ - - - CRM/Paymentprocessingcore - PaymentWebhook - civicrm_payment_webhook - Webhook event log for de-duplication and idempotency across all processors - true - - - id - int unsigned - true - Unique ID - - Number - - - - id - true - - - - event_id - varchar - 255 - true - Processor event ID (evt_... for Stripe, evt_... for GoCardless) - - Text - - - - - - processor_type - varchar - 50 - true - Processor type: 'stripe', 'gocardless', 'itas', etc. - - Text - - - - - - UI_event_processor - event_id - processor_type - true - - - - event_type - varchar - 100 - true - Event type (e.g. checkout.session.completed, payment_intent.succeeded) - - Text - - - - - index_event_type - event_type - - - - index_status_retry - status - next_retry_at - - - - payment_attempt_id - int unsigned - false - FK to Payment Attempt - - EntityRef - - - - - payment_attempt_id -
civicrm_payment_attempt
- id - SET NULL - - - - status - varchar - 25 - true - 'new' - Processing status: new, processing, processed, error, permanent_error - - CRM_Paymentprocessingcore_BAO_PaymentWebhook::getStatuses - - - Select - - - - - - attempts - int unsigned - true - 0 - Number of processing attempts - - Number - - - - - - next_retry_at - timestamp - When to retry processing (for exponential backoff) - - Select Date - - - - - - result - varchar - 50 - Processing result: applied, noop, ignored_out_of_order, error - - Text - - - - - - error_log - text - Error details if processing failed - - TextArea - - - - - - processing_started_at - timestamp - When webhook entered processing state (for stuck webhook detection) - - Select Date - - - - - - processed_at - timestamp - When event was processed - - Select Date - - - - - - created_date - timestamp - true - CURRENT_TIMESTAMP - When webhook was received - - Select Date - - - -