Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • extensions/stripe
  • madhavi/stripe
  • wmortada/stripe
  • jamie/stripe
  • michaelmcandrew/stripe
  • bgm/stripe
  • noah/stripe
  • laryn/stripe
  • phani/stripe
  • JonGold/stripe
  • scardinius/stripe
  • varshith/stripe
  • naomi/stripe
  • jhoskins98/stripe
  • artfulrobot/stripe
  • jitendra/stripe
  • justinfreeman/stripe
  • revati_gawas/stripe
  • capo/stripe
  • pradeep/stripe
  • partners/ixiam/stripe
  • homotechsual/stripe
  • Edselopez/stripe
  • goron/stripe
  • tapash/stripe
  • petednz/stripe
  • kartik1000/stripe
  • ananelson/stripe
  • Samuele.Masetto/stripe
  • sluc23/stripe
  • aaron/stripe
  • DaveD/stripe
  • konadave/stripe
  • partners/coopsymbiotic/stripe
  • kurund/stripe
  • AllenShaw/stripe
  • awestbha/stripe
  • mathavan/stripe
  • BjoernE/stripe
  • alietz/stripe
  • seamuslee/stripe
  • damo-civi/stripe
  • dmunio/stripe
  • ufundo/stripe
44 results
Show changes
Commits on Source (142)
Showing
with 1021 additions and 1055 deletions
......@@ -10,7 +10,10 @@
*/
use Brick\Money\Money;
use Brick\Math\RoundingMode;
use Civi\Api4\ContributionRecur;
use Civi\Api4\PaymentprocessorWebhook;
use Civi\Api4\StripeCustomer;
use CRM_Stripe_ExtensionUtil as E;
use Civi\Payment\PropertyBag;
use Stripe\Stripe;
......@@ -30,6 +33,11 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
*/
public $stripeClient;
/**
* @var \Civi\Stripe\Api;
*/
public \Civi\Stripe\Api $api;
/**
* Custom properties used by this payment processor
*
......@@ -46,6 +54,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
*/
public function __construct($mode, $paymentProcessor) {
$this->_paymentProcessor = $paymentProcessor;
$this->api = new \Civi\Stripe\Api($this);
if (defined('STRIPE_PHPUNIT_TEST') && isset($GLOBALS['mockStripeClient'])) {
// When under test, prefer the mock.
......@@ -102,7 +111,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
]);
$key = self::getPublicKey($paymentProcessor);
}
catch (CiviCRM_API3_Exception $e) {
catch (CRM_Core_Exception $e) {
return '';
}
return $key;
......@@ -122,7 +131,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
]);
$key = self::getSecretKey($paymentProcessor);
}
catch (CiviCRM_API3_Exception $e) {
catch (CRM_Core_Exception $e) {
return '';
}
return $key;
......@@ -171,16 +180,48 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
return TRUE;
}
public function supportsRecurring() {
return TRUE;
}
/**
* We can edit stripe recurring contributions
* @return bool
*/
public function supportsEditRecurringContribution() {
return FALSE;
return TRUE;
}
public function supportsRecurring() {
return TRUE;
/**
* Get an array of the fields that can be edited on the recurring contribution.
*
* Some payment processors support editing the amount and other scheduling details of recurring payments, especially
* those which use tokens. Others are fixed. This function allows the processor to return an array of the fields that
* can be updated from the contribution recur edit screen.
*
* The fields are likely to be a subset of these
* - 'amount',
* - 'installments',
* - 'frequency_interval',
* - 'frequency_unit',
* - 'cycle_day',
* - 'next_sched_contribution_date',
* - 'end_date',
* - 'failure_retry_day',
*
* The form does not restrict which fields from the contribution_recur table can be added (although if the html_type
* metadata is not defined in the xml for the field it will cause an error.
*
* Open question - would it make sense to return membership_id in this - which is sometimes editable and is on that
* form (UpdateSubscription).
*
* @return array
*/
public function getEditableRecurringScheduleFields() {
if ($this->supports('changeSubscriptionAmount')) {
return ['amount'];
}
return [];
}
/**
......@@ -254,7 +295,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* @throws \Brick\Money\Exception\UnknownCurrencyException
*/
public function getAmountFormattedForStripeAPI(PropertyBag $propertyBag): string {
return Money::of($propertyBag->getAmount(), $propertyBag->getCurrency())->getMinorAmount()->getIntegralPart();
return Money::of($propertyBag->getAmount(), $propertyBag->getCurrency(), NULL, RoundingMode::HALF_UP)->getMinorAmount()->getIntegralPart();
}
/**
......@@ -268,7 +309,8 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
// Set plugin info and API credentials.
Stripe::setAppInfo('CiviCRM', CRM_Utils_System::version(), CRM_Utils_System::baseURL());
Stripe::setApiKey(self::getSecretKey($this->_paymentProcessor));
Stripe::setApiVersion(CRM_Stripe_Check::API_VERSION);
// With Stripe-php 12 we pin to latest Stripe API
// Stripe::setApiVersion(CRM_Stripe_Check::API_VERSION);
}
/**
......@@ -287,17 +329,18 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
switch (get_class($e)) {
case 'Stripe\Exception\CardException':
/** @var \Stripe\Exception\CardException $e */
// Since it's a decline, \Stripe\Exception\CardException will be caught
\Civi::log('stripe')->error($this->getLogPrefix() . $op . ': ' . get_class($e) . ': ' . $e->getMessage() . print_r($e->getJsonBody(),TRUE));
$error['code'] = $e->getError()->code;
$error['message'] = $e->getError()->message;
$error['code'] = $e->getStripeCode();
$error['message'] = $e->getMessage();
return $error;
case 'Stripe\Exception\RateLimitException':
// Too many requests made to the API too quickly
case 'Stripe\Exception\InvalidRequestException':
// Invalid parameters were supplied to Stripe's API
switch ($e->getError()->code) {
switch ($e->getStripeCode()) {
case 'payment_intent_unexpected_state':
$genericError['message'] = E::ts('An error occurred while processing the payment');
break;
......@@ -320,8 +363,18 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
\Civi::log('stripe')->error($this->getLogPrefix() . $op . ': ' . get_class($e) . ': ' . $e->getMessage() . print_r($e->getJsonBody(),TRUE));
return $e->getJsonBody()['error'] ?? $genericError;
case 'Stripe\Exception\PermissionException':
// The client is probably setup with a restricted API key and does not have permission to do the requested action.
// We should not display the specific error to the end customer but we *do* want the details in the log.
// For example, if we have a readonly API key we won't be able to update Stripe customer metadata, but we may choose to continue!
\Civi::log('stripe')->warning($this->getLogPrefix() . $op . ': ' . get_class($e) . ': ' . $e->getMessage());
$genericError['code'] = $e->getStripeCode();
$genericError['message'] = $e->getMessage();
return $genericError;
default:
// Something else happened, completely unrelated to Stripe
\Civi::log('stripe')->error($this->getLogPrefix() . $op . ' (unknown error): ' . get_class($e) . ': ' . $e->getMessage());
return $genericError;
}
}
......@@ -329,52 +382,44 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
/**
* Create or update a Stripe Plan
*
* @param array $params
* @param \Civi\Payment\PropertyBag $propertyBag
* @param integer $amount
*
* @return \Stripe\Plan
*/
public function createPlan(array $params, int $amount): \Stripe\Plan {
$currency = $this->getCurrency($params);
$planId = "every-{$params['recurFrequencyInterval']}-{$params['recurFrequencyUnit']}-{$amount}-" . strtolower($currency);
if ($this->_paymentProcessor['is_test']) {
$planId .= '-test';
}
public function createPlan(\Civi\Payment\PropertyBag $propertyBag, int $amount): \Stripe\Plan {
$planID = "every-{$propertyBag->getRecurFrequencyInterval()}-{$propertyBag->getRecurFrequencyUnit()}-{$amount}-" . strtolower($propertyBag->getCurrency());
// Try and retrieve existing plan from Stripe
// If this fails, we'll create a new one
try {
$plan = $this->stripeClient->plans->retrieve($planId);
$plan = $this->stripeClient->plans->retrieve($planID);
}
catch (\Stripe\Exception\InvalidRequestException $e) {
// The following call is just for logging's sake.
$this->parseStripeException('plan_retrieve', $e);
if ($e->getError()->code === 'resource_missing') {
$formatted_amount = CRM_Utils_Money::formatLocaleNumericRoundedByCurrency(($amount / 100), $currency);
$productName = "CiviCRM " . (isset($params['membership_name']) ? $params['membership_name'] . ' ' : '') . "every {$params['recurFrequencyInterval']} {$params['recurFrequencyUnit']}(s) {$currency}{$formatted_amount}";
if ($this->_paymentProcessor['is_test']) {
$productName .= '-test';
}
if ($e->getStripeCode() === 'resource_missing') {
$formattedAmount = CRM_Utils_Money::formatLocaleNumericRoundedByCurrency(($amount / 100), $propertyBag->getCurrency());
$productName = "{$propertyBag->getCurrency()}{$formattedAmount} "
. ($propertyBag->has('membership_name') ? $propertyBag->getCustomProperty('membership_name') . ' ' : '')
. "every {$propertyBag->getRecurFrequencyInterval()} {$propertyBag->getRecurFrequencyUnit()}(s)";
$product = $this->stripeClient->products->create([
"name" => $productName,
"type" => "service"
'name' => $productName,
'type' => 'service'
]);
// Create a new Plan.
$stripePlan = [
'amount' => $amount,
'interval' => $params['recurFrequencyUnit'],
'interval' => $propertyBag->getRecurFrequencyUnit(),
'product' => $product->id,
'currency' => $currency,
'id' => $planId,
'interval_count' => $params['recurFrequencyInterval'],
'currency' => $propertyBag->getCurrency(),
'id' => $planID,
'interval_count' => $propertyBag->getRecurFrequencyInterval(),
];
$plan = $this->stripeClient->plans->create($stripePlan);
}
}
return $plan;
}
/**
* Override CRM_Core_Payment function
*
......@@ -463,6 +508,11 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
$context = \Civi\Formprotection\Forms::getContextFromQuickform($form);
}
$motoEnabled = CRM_Core_Permission::check('allow stripe moto payments')
&& (
(in_array('backend', \Civi::settings()->get('stripe_moto')) && $form->isBackOffice)
|| (in_array('frontend', \Civi::settings()->get('stripe_moto')) && !$form->isBackOffice)
);
$jsVars = [
'id' => $form->_paymentProcessor['id'],
'currency' => $this->getDefaultCurrencyForForm($form),
......@@ -473,7 +523,8 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
'apiVersion' => CRM_Stripe_Check::API_VERSION,
'csrfToken' => NULL,
'country' => \Civi::settings()->get('stripe_country'),
'moto' => \Civi::settings()->get('stripe_moto') && ($form->isBackOffice ?? FALSE) && CRM_Core_Permission::check('allow stripe moto payments'),
'moto' => $motoEnabled,
'disablelink' => \Civi::settings()->get('stripe_cardelement_disablelink'),
];
if (class_exists('\Civi\Firewall\Firewall')) {
$firewall = new \Civi\Firewall\Firewall();
......@@ -563,7 +614,6 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* Result array
*
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function doPayment(&$paymentParams, $component = 'contribute') {
......@@ -603,10 +653,6 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
$amountFormattedForStripe = $this->getAmountFormattedForStripeAPI($propertyBag);
// @fixme: Check if we still need to call the getBillingEmail function - eg. how does it handle "email-Primary".
$email = $this->getBillingEmail($propertyBag, $propertyBag->getContactID());
$propertyBag->setEmail($email);
$stripeCustomer = $this->getStripeCustomer($propertyBag);
$customerParams = [
......@@ -629,6 +675,10 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
// This is where we save the customer card
// @todo For a recurring payment we have to save the card. For a single payment we'd like to develop the
// save card functionality but should not save by default as the customer has not agreed.
if (empty($paymentMethodID)) {
\Civi::log('stripe')->error($this->getLogPrefix() . 'recur payment but missing paymentmethod. Check form config');
throw new PaymentProcessorException('Payment form is not configured correctly!');
}
return $this->doRecurPayment($propertyBag, $amountFormattedForStripe, $stripeCustomer);
}
......@@ -676,7 +726,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
catch (Exception $e) {
$parsedError = $this->parseStripeException('doPayment', $e);
$this->handleError($parsedError['code'], $parsedError['message'], ($propertyBag->has('error_url') ? $propertyBag->getCustomProperty('error_url') : ''), FALSE);
$this->handleError($parsedError['code'], $parsedError['message'], $this->getErrorUrl($propertyBag), FALSE);
}
// @fixme FROM HERE we are using $params ONLY - SET things if required ($propertyBag is not used beyond here)
......@@ -697,44 +747,78 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* @param \Civi\Payment\PropertyBag $propertyBag
*
* @return \Stripe\Customer|PropertySpy
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
protected function getStripeCustomer(\Civi\Payment\PropertyBag $propertyBag) {
if (!$propertyBag->has('email')) {
// @fixme: Check if we still need to call the getBillingEmail function - eg. how does it handle "email-Primary".
$email = $this->getBillingEmail($propertyBag);
$propertyBag->setEmail($email);
}
// See if we already have a stripe customer
$customerParams = [
'contact_id' => $propertyBag->getContactID(),
'processor_id' => $this->getPaymentProcessor()['id'],
'email' => $propertyBag->getEmail(),
'currency' => mb_strtolower($propertyBag->getCurrency()),
// Include this to allow redirect within session on payment failure
'error_url' => $propertyBag->getCustomProperty('error_url'),
'error_url' => $this->getErrorUrl($propertyBag),
];
// Get the Stripe Customer:
// 1. Look for an existing customer.
// 2. If no customer (or a deleted customer found), create a new one.
// 3. If existing customer found, update the metadata that Stripe holds for this customer.
$stripeCustomerID = CRM_Stripe_Customer::find($customerParams);
// Customers can only be billed for subscriptions in a single currency. currency field was added in 6.10
// So we look for a customer with matching currency and if not check for an empty currency (if customer was created before 6.10)
$stripeCustomer = StripeCustomer::get(FALSE)
->addWhere('contact_id', '=', $customerParams['contact_id'])
->addWhere('processor_id', '=', $customerParams['processor_id'])
->addClause('OR', ['currency', 'IS EMPTY'], ['currency', '=', $customerParams['currency']])
->addOrderBy('currency', 'DESC')
->execute()->first();
// Customer not in civicrm database. Create a new Customer in Stripe.
if (!isset($stripeCustomerID)) {
$stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
if (empty($stripeCustomer)) {
$stripeCustomerObject = CRM_Stripe_Customer::create($customerParams, $this);
}
else {
$shouldDeleteStripeCustomer = $shouldCreateNewStripeCustomer = FALSE;
// Customer was found in civicrm database, fetch from Stripe.
try {
$stripeCustomer = $this->stripeClient->customers->retrieve($stripeCustomerID);
$shouldDeleteStripeCustomer = $stripeCustomer->isDeleted();
$stripeCustomerObject = $this->stripeClient->customers->retrieve($stripeCustomer['customer_id']);
$shouldDeleteStripeCustomer = $stripeCustomerObject->isDeleted();
} catch (Exception $e) {
$err = $this->parseStripeException('retrieve_customer', $e);
\Civi::log()->error($this->getLogPrefix() . 'Failed to retrieve Stripe Customer: ' . $err['code']);
\Civi::log('stripe')->error($this->getLogPrefix() . 'Failed to retrieve Stripe Customer: ' . $err['code']);
$shouldDeleteStripeCustomer = TRUE;
}
if (empty($stripeCustomer['currency'])) {
// We have no currency set for the customer in the CiviCRM database
if ($stripeCustomerObject->currency === $customerParams['currency']) {
// We can use this customer but we need to update the currency in the civicrm database
StripeCustomer::update(FALSE)
->addValue('currency', $stripeCustomerObject->currency)
->addWhere('id', '=', $stripeCustomer['id'])
->execute();
}
else {
// We need to create a new customer
$shouldCreateNewStripeCustomer = TRUE;
}
}
if ($shouldDeleteStripeCustomer) {
// Customer doesn't exist or was deleted, create a new one
// Customer was deleted, delete it.
CRM_Stripe_Customer::delete($customerParams);
}
if ($shouldDeleteStripeCustomer || $shouldCreateNewStripeCustomer) {
try {
$stripeCustomer = CRM_Stripe_Customer::create($customerParams, $this);
$stripeCustomerObject = CRM_Stripe_Customer::create($customerParams, $this);
} catch (Exception $e) {
// We still failed to create a customer
$err = $this->parseStripeException('create_customer', $e);
......@@ -742,7 +826,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
}
}
return $stripeCustomer;
return $stripeCustomerObject;
}
/**
......@@ -771,14 +855,19 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* @return array
* The result in a nice formatted array (or an error object).
*
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
*/
public function doRecurPayment(\Civi\Payment\PropertyBag $propertyBag, int $amountFormattedForStripe, $stripeCustomer): array {
// Make sure recurFrequencyInterval is set (default to 1 if not)
if (!$propertyBag->has('recurFrequencyInterval') || $propertyBag->getRecurFrequencyInterval() === 0) {
$propertyBag->setRecurFrequencyInterval(1);
}
$params = $this->getPropertyBagAsArray($propertyBag);
// @fixme FROM HERE we are using $params array (but some things are READING from $propertyBag)
// @fixme: Split this out into "$returnParams"
// We set payment status as pending because the IPN will set it as completed / failed
$params = $this->setStatusPaymentPending($params);
......@@ -786,7 +875,8 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
if (empty($this->getRecurringContributionId($propertyBag))) {
$required = 'contributionRecurID';
}
if (!isset($params['recurFrequencyUnit'])) {
if (!$propertyBag->has('recurFrequencyUnit')) {
$required = 'recurFrequencyUnit';
}
if ($required) {
......@@ -794,17 +884,14 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
throw new CRM_Core_Exception($this->getLogPrefix() . 'doRecurPayment: Missing mandatory parameter: ' . $required);
}
// Make sure recurFrequencyInterval is set (default to 1 if not)
empty($params['recurFrequencyInterval']) ? $params['recurFrequencyInterval'] = 1 : NULL;
// Create the stripe plan
$planId = self::createPlan($params, $amountFormattedForStripe);
$plan = self::createPlan($propertyBag, $amountFormattedForStripe);
// Attach the Subscription to the Stripe Customer.
$subscriptionParams = [
'proration_behavior' => 'none',
'plan' => $planId,
'metadata' => ['Description' => $params['description']],
'plan' => $plan->id,
'metadata' => ['Description' => $propertyBag->getDescription()],
'expand' => ['latest_invoice.payment_intent'],
'customer' => $stripeCustomer->id,
'off_session' => TRUE,
......@@ -821,23 +908,21 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
$this->setPaymentProcessorSubscriptionID($stripeSubscription->id);
$nextScheduledContributionDate = $this->calculateNextScheduledDate($params);
$contributionRecur = \Civi\Api4\ContributionRecur::update(FALSE)
$contributionRecur = ContributionRecur::update(FALSE)
->addWhere('id', '=', $this->getRecurringContributionId($propertyBag))
->addValue('processor_id', $this->getPaymentProcessorSubscriptionID())
->addValue('auto_renew', 1)
->addValue('next_sched_contribution_date', $nextScheduledContributionDate)
->addValue('cycle_day', date('d', strtotime($nextScheduledContributionDate)));
if (!empty($params['installments'])) {
if ($propertyBag->has('recurInstallments') && ($propertyBag->getRecurInstallments() > 0)) {
// We set an end date if installments > 0
if (empty($params['receive_date'])) {
$params['receive_date'] = date('YmdHis');
}
if ($params['installments']) {
$contributionRecur
->addValue('end_date', $this->calculateEndDate($params))
->addValue('installments', $params['installments']);
}
$contributionRecur
->addValue('end_date', $this->calculateEndDate($params))
->addValue('installments', $propertyBag->getRecurInstallments());
}
if ($stripeSubscription->status === 'incomplete') {
......@@ -848,7 +933,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
if ($stripeSubscription->status === 'incomplete') {
// For example with test card 4000000000000341 (Attaching this card to a Customer object succeeds, but attempts to charge the customer fail)
\Civi::log()->warning($this->getLogPrefix() . 'subscription status=incomplete. ID:' . $stripeSubscription->id);
\Civi::log('stripe')->warning($this->getLogPrefix() . 'subscription status=incomplete. ID:' . $stripeSubscription->id);
throw new PaymentProcessorException('Payment failed');
}
......@@ -973,7 +1058,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
}
catch (Exception $e) {
$this->handleError($e->getCode(), $e->getMessage(), $params['error_url']);
$this->handleError($e->getCode(), $e->getMessage(), $params['error_url'] ?? '');
}
finally {
// Always update the paymentIntent in the CiviCRM database for later tracking
......@@ -1004,6 +1089,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
*
* @return array
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @throws \Brick\Money\Exception\UnknownCurrencyException
*/
public function doRefund(&$params) {
$requiredParams = ['trxn_id', 'amount'];
......@@ -1137,7 +1223,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
* @throws \CRM_Core_Exception
*/
public function calculateEndDate($params) {
$requiredParams = ['receive_date', 'installments', 'recurFrequencyInterval', 'recurFrequencyUnit'];
$requiredParams = ['receive_date', 'recurInstallments', 'recurFrequencyInterval', 'recurFrequencyUnit'];
foreach ($requiredParams as $required) {
if (!isset($params[$required])) {
$message = $this->getLogPrefix() . 'calculateEndDate: Missing mandatory parameter: ' . $required;
......@@ -1164,7 +1250,7 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
break;
}
$numberOfUnits = $params['installments'] * $params['recurFrequencyInterval'];
$numberOfUnits = $params['recurInstallments'] * $params['recurFrequencyInterval'];
$endDate = new DateTime($params['receive_date']);
$endDate->add(new DateInterval("P{$numberOfUnits}{$frequencyUnit}"));
return $endDate->format('Ymd') . '235959';
......@@ -1258,8 +1344,8 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
if (!$propertyBag->has('recurProcessorID')) {
$errorMessage = E::ts('The recurring contribution cannot be cancelled (No reference (trxn_id) found).');
\Civi::log()->error($errorMessage);
$errorMessage = E::ts('The recurring contribution cannot be cancelled (No reference (processor_id) found).');
\Civi::log('stripe')->error($errorMessage);
throw new PaymentProcessorException($errorMessage);
}
......@@ -1270,19 +1356,106 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
}
}
catch (Exception $e) {
$errorMessage = E::ts('Could not delete Stripe subscription: %1', [1 => $e->getMessage()]);
\Civi::log()->error($errorMessage);
$errorMessage = E::ts('Could not cancel Stripe subscription: %1', [1 => $e->getMessage()]);
\Civi::log('stripe')->error($errorMessage);
throw new PaymentProcessorException($errorMessage);
}
return ['message' => E::ts('Successfully cancelled the subscription at Stripe.')];
}
/**
* Change the amount of the recurring payment.
*
* @param string $message
* @param array $params
*
* @return bool|object
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function changeSubscriptionAmount(&$message = '', $params = []) {
// We only support the following params: amount
try {
$propertyBag = $this->beginChangeSubscriptionAmount($params);
// Get the Stripe subscription
$subscription = $this->stripeClient->subscriptions->retrieve($propertyBag->getRecurProcessorID());
$calculatedItems = $this->api->calculateItemsForSubscription($propertyBag->getRecurProcessorID(), $subscription->items->data);
$contributionRecurKey = mb_strtolower($propertyBag->getCurrency()) . "_{$propertyBag->getRecurFrequencyUnit()}_{$propertyBag->getRecurFrequencyInterval()}";
if (isset($calculatedItems[$contributionRecurKey])) {
$calculatedItem = $calculatedItems[$contributionRecurKey];
if (Money::of($calculatedItem['amount'], mb_strtoupper($calculatedItem['currency']))
->isAmountAndCurrencyEqualTo(Money::of($propertyBag->getAmount(), $propertyBag->getCurrency()))) {
throw new PaymentProcessorException('Amount is the same as before!');
}
}
else {
throw new PaymentProcessorException('Cannot find existing price/plan for this subscription with matching frequency!');
}
// Get the existing Price
$existingPrice = $subscription->items->data[0]->price;
// Check if the Stripe Product already has a Price configured for the new amount
$priceToMatch = [
'active' => TRUE,
'currency' => $subscription->currency,
'product' => $existingPrice->product,
'type' => 'recurring',
'recurring' => [
'interval' => $existingPrice->recurring['interval'],
],
];
$existingPrices = $this->stripeClient->prices->all($priceToMatch);
foreach ($existingPrices as $price) {
if ($price->unit_amount === (int) $this->getAmountFormattedForStripeAPI($propertyBag)) {
// Yes, we already have a matching price option - use it!
$newPriceID = $price->id;
break;
}
}
if (empty($newPriceID)) {
// We didn't find an existing price that matched for the product. Create a new one.
$newPriceParameters = [
'currency' => $subscription->currency,
'unit_amount' => $this->getAmountFormattedForStripeAPI($propertyBag),
'product' => $existingPrice->product,
'metadata' => $existingPrice->metadata->toArray(),
'recurring' => [
'interval' => $existingPrice->recurring['interval'],
'interval_count' => $existingPrice->recurring['interval_count'],
],
];
$newPriceID = $this->stripeClient->prices->create($newPriceParameters)->id;
}
// Update the Stripe subscription, replacing the existing price with the new one.
$this->stripeClient->subscriptions->update($propertyBag->getRecurProcessorID(), [
'items' => [
[
'id' => $subscription->items->data[0]->id,
'price' => $newPriceID,
],
],
// See https://stripe.com/docs/billing/subscriptions/prorations - we disable this to keep it simple for now.
'proration_behavior' => 'none',
]);
}
catch (Exception $e) {
// On ANY failure, throw an exception which will be reported back to the user.
\Civi::log()->error('Update Subscription failed for RecurID: ' . $propertyBag->getContributionRecurID() . ' Error: ' . $e->getMessage());
throw new PaymentProcessorException('Update Subscription Failed: ' . $e->getMessage(), $e->getCode(), $params);
}
return TRUE;
}
/**
* Process incoming payment notification (IPN).
*
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \Stripe\Exception\UnknownApiErrorException
*/
public function handlePaymentNotification() {
......@@ -1307,12 +1480,12 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
$ipnClass->setData($data);
} catch (\UnexpectedValueException $e) {
// Invalid payload
\Civi::log()->error($this->getLogPrefix() . 'webhook signature validation error: ' . $e->getMessage());
\Civi::log('stripe')->error($this->getLogPrefix() . 'webhook signature validation error: ' . $e->getMessage());
http_response_code(400);
exit();
} catch (\Stripe\Exception\SignatureVerificationException $e) {
// Invalid signature
\Civi::log()->error($this->getLogPrefix() . 'webhook signature validation error: ' . $e->getMessage());
\Civi::log('stripe')->error($this->getLogPrefix() . 'webhook signature validation error: ' . $e->getMessage());
http_response_code(400);
exit();
}
......@@ -1450,6 +1623,17 @@ class CRM_Core_Payment_Stripe extends CRM_Core_Payment {
return $text;
}
/**
* Get the help text to present on the recurring update page.
*
* This should reflect what can or cannot be edited.
*
* @return string
*/
public function getRecurringScheduleUpdateHelpText() {
return E::ts('Use this form to change the amount for this recurring contribution. The Stripe subscription will be updated with the new amount.');
}
/*
* Sets a mock stripe client object for this object and all future created
* instances. This should only be called by phpunit tests.
......
......@@ -9,13 +9,9 @@
+--------------------------------------------------------------------+
*/
use Civi\Api4\PaymentprocessorWebhook;
use CRM_Stripe_ExtensionUtil as E;
use Civi\Payment\PropertyBag;
use Stripe\Stripe;
use Civi\Payment\Exception\PaymentProcessorException;
use Stripe\StripeObject;
use Stripe\Webhook;
/**
* Class CRM_Core_Payment_Stripe
......@@ -132,11 +128,7 @@ class CRM_Core_Payment_StripeCheckout extends CRM_Core_Payment_Stripe {
* Assoc array of input parameters for this transaction.
* @param string $component
*
* @return array
* Result array
*
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public function doPayment(&$paymentParams, $component = 'contribute') {
......@@ -151,23 +143,24 @@ class CRM_Core_Payment_StripeCheckout extends CRM_Core_Payment_Stripe {
// Not sure what the point of this next line is.
$this->_component = $component;
$successUrl = $this->getReturnSuccessUrl($paymentParams['qfKey']);
$failUrl = $this->getCancelUrl($paymentParams['qfKey'], NULL);
$lineItems = $this->calculateLineItems($paymentParams);
$checkoutSession = $this->createCheckoutSession($successUrl, $failUrl, $propertyBag, $lineItems);
// Get existing/saved Stripe customer or create a new one
$existingStripeCustomer = \Civi\Api4\StripeCustomer::get(FALSE)
->addWhere('contact_id', '=', $propertyBag->getContactID())
->addWhere('processor_id', '=', $this->getPaymentProcessor()['id'])
->execute()
->first();
if (empty($existingStripeCustomer)) {
$stripeCustomer = $this->getStripeCustomer($propertyBag);
$stripeCustomerID = $stripeCustomer->id;
}
else {
$stripeCustomerID = $existingStripeCustomer['customer_id'];
}
// Allow each CMS to do a pre-flight check before redirecting to Stripe.
CRM_Core_Config::singleton()->userSystem->prePostRedirect();
CRM_Utils_System::setHttpHeader("HTTP/1.1 303 See Other", '');
CRM_Utils_System::redirect($checkoutSession->url);
}
/**
* This gathers the line items which are then used in buildCheckoutLineItems()
*
* @param array|PropertyBag $paymentParams
*/
public function calculateLineItems($paymentParams): array {
$lineItems = [];
if (!empty($paymentParams['skipLineItem']) || empty($paymentParams['line_item'])) {
if (!empty($paymentParams['participants_info'])) {
......@@ -186,20 +179,45 @@ class CRM_Core_Payment_StripeCheckout extends CRM_Core_Payment_Stripe {
'field_title' => $paymentParams['source'] ?? $paymentParams['description'],
'label' => $paymentParams['source'] ?? $paymentParams['description'],
'qty' => 1,
]
]
],
],
];
}
}
else {
$lineItems = $paymentParams['line_item'];
$lineItems = $paymentParams['line_item'] ?? [];
}
return $lineItems;
}
/**
* Create a Stripe Checkout Session
*
* @return \Stripe\Checkout\Session
*/
public function createCheckoutSession(string $successUrl, string $failUrl, PropertyBag $propertyBag, array $lineItems) {
// Get existing/saved Stripe customer or create a new one
$existingStripeCustomer = \Civi\Api4\StripeCustomer::get(FALSE)
->addWhere('contact_id', '=', $propertyBag->getContactID())
->addWhere('processor_id', '=', $this->getPaymentProcessor()['id'])
->execute()
->first();
if (empty($existingStripeCustomer)) {
$stripeCustomer = $this->getStripeCustomer($propertyBag);
$stripeCustomerID = $stripeCustomer->id;
}
else {
$stripeCustomerID = $existingStripeCustomer['customer_id'];
}
// Build the checkout session parameters
$checkoutSessionParams = [
'line_items' => $this->buildCheckoutLineItems($lineItems, $propertyBag),
'mode' => $propertyBag->getIsRecur() ? 'subscription' : 'payment',
'success_url' => $successUrl,
'cancel_url' => $failUrl,
// Nb. We can only specify customer_email|customer, not both.
// 'customer_email' => $propertyBag->getEmail(),
'customer' => $stripeCustomerID,
// 'submit_type' => one of 'auto', pay, book, donate
......@@ -213,7 +231,8 @@ class CRM_Core_Payment_StripeCheckout extends CRM_Core_Payment_Stripe {
$checkoutSessionParams['subscription_data'] = [
'description' => $this->getDescription($propertyBag, 'description'),
];
}else {
}
else {
$checkoutSessionParams['payment_intent_data'] = [
'description' => $this->getDescription($propertyBag, 'description'),
];
......@@ -232,10 +251,7 @@ class CRM_Core_Payment_StripeCheckout extends CRM_Core_Payment_Stripe {
CRM_Stripe_BAO_StripeCustomer::updateMetadata(['contact_id' => $propertyBag->getContactID()], $this, $checkoutSession['customer']);
// Allow each CMS to do a pre-flight check before redirecting to PayPal.
CRM_Core_Config::singleton()->userSystem->prePostRedirect();
CRM_Utils_System::setHttpHeader("HTTP/1.1 303 See Other", '');
CRM_Utils_System::redirect($checkoutSession->url);
return $checkoutSession;
}
/**
......@@ -246,20 +262,27 @@ class CRM_Core_Payment_StripeCheckout extends CRM_Core_Payment_Stripe {
private function getSupportedPaymentMethods(\Civi\Payment\PropertyBag $propertyBag): array {
$paymentMethods = \Civi::settings()->get('stripe_checkout_supported_payment_methods');
$result = [];
foreach ($paymentMethods as $index => $paymentMethod) {
foreach ($paymentMethods as $paymentMethod) {
switch ($paymentMethod) {
case 'sepa_debit':
case 'bancontact':
if ($propertyBag->getCurrency() === 'EUR') {
$result[] = $paymentMethod;
}
break;
case 'ach_debit':
case 'us_bank_account':
if ($propertyBag->getCurrency() === 'USD') {
$result[] = $paymentMethod;
}
break;
case 'bacs_debit':
if ($propertyBag->getCurrency() === 'GBP') {
$result[] = $paymentMethod;
}
break;
default:
$result[] = $paymentMethod;
}
......@@ -270,7 +293,6 @@ class CRM_Core_Payment_StripeCheckout extends CRM_Core_Payment_Stripe {
return $result;
}
/**
* Takes the lineitems passed into doPayment and converts them into an array suitable for passing to Stripe Checkout
*
......
......@@ -221,6 +221,7 @@ class CRM_Core_Payment_StripeIPN {
$this->charge_id = $this->retrieve('charge_id', 'String', FALSE);
$this->payment_intent_id = $this->retrieve('payment_intent_id', 'String', FALSE);
$this->customer_id = $this->retrieve('customer_id', 'String', FALSE);
$this->setInputParametersHasRun = TRUE;
}
......@@ -259,22 +260,24 @@ class CRM_Core_Payment_StripeIPN {
* When CiviCRM receives a Stripe webhook call this method (via handlePaymentNotification()).
* This checks the webhook and either queues or triggers processing (depending on existing webhooks in queue)
*
* Set default to "process immediately". This will get changed to FALSE if we already
* have a pending webhook in the queue or the webhook is flagged for delayed processing.
* @param bool $processWebhook
*
* @return bool
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \Stripe\Exception\UnknownApiErrorException
*/
public function onReceiveWebhook(): bool {
public function onReceiveWebhook($processWebhook = TRUE): bool {
if (!in_array($this->eventType, CRM_Stripe_Webhook::getDefaultEnabledEvents())) {
// We don't handle this event, return 200 OK so Stripe does not retry.
return TRUE;
}
// Now re-retrieve the data from Stripe to ensure it's legit.
$test = $this->getPaymentProcessor()->getPaymentProcessor()['is_test'] ? '(Test)' : '(Live)';
$name = $this->getPaymentProcessor()->getPaymentProcessor()['name'];
// Special case if this is the test webhook
if (substr($this->getEventID(), -15, 15) === '_00000000000000') {
$test = (boolean) $this->getPaymentProcessor()->getPaymentProcessor()['is_test'] ? '(Test)' : '(Live)';
$name = $this->getPaymentProcessor()->getPaymentProcessor()['name'];
echo "Test webhook from Stripe ({$this->getEventID()}) received successfully by CiviCRM: {$name} {$test}.";
exit();
}
......@@ -282,6 +285,29 @@ class CRM_Core_Payment_StripeIPN {
// Check, decode, validate webhook data and extract some parameters to the class
$this->setInputParameters();
// If we have both Stripe (elements) and Stripe Checkout setup it is quite likely that
// we have two payment processors with the same private/public key and we'll receive "duplicate" webhooks.
// So if we have a Stripe Customer ID with the event check that it matches our payment processor ID as recorded
// in the civicrm_stripe_customers table.
if (!empty($this->getStripeCustomerID())) {
$stripeCustomers = \Civi\Api4\StripeCustomer::get(FALSE)
->addWhere('customer_id', '=', $this->getStripeCustomerID())
->execute();
$eventForThisPaymentProcessor = FALSE;
foreach ($stripeCustomers as $stripeCustomer) {
if ($stripeCustomer['processor_id'] === $this->getPaymentProcessor()->getID()) {
// We have a customer in the database for this processor - continue processing
$eventForThisPaymentProcessor = TRUE;
break;
}
}
if (!$eventForThisPaymentProcessor) {
echo "Event ({$this->getEventID()}) is not for this payment processor - ignoring. CiviCRM: {$name} {$test}.";
exit();
}
}
// Get a "unique" identifier for this webhook that allows us to match "duplicate/similar" webhooks.
$uniqueIdentifier = $this->getWebhookUniqueIdentifier();
// Get all received webhooks with matching identifier which have not been processed
......@@ -294,10 +320,6 @@ class CRM_Core_Payment_StripeIPN {
->addWhere('processed_date', 'IS NULL')
->execute();
// Set default to "process immediately". This will get changed to FALSE if we already
// have a pending webhook in the queue or the webhook is flagged for delayed processing.
$processWebhook = TRUE;
if (empty($paymentProcessorWebhooks->rowCount)) {
// We have not received this webhook before.
// Some webhooks we always add to the queue and do not process immediately (eg. invoice.finalized)
......@@ -364,14 +386,10 @@ class CRM_Core_Payment_StripeIPN {
if (!$this->setEventType($webhookEvent['trigger'])) {
// We don't handle this event
return FALSE;
};
// @todo consider storing webhook data when received.
$this->setVerifyData(TRUE);
$this->setExceptionMode(FALSE);
if (isset($emailReceipt)) {
$this->setSendEmailReceipt($emailReceipt);
}
$this->setExceptionMode(FALSE);
$processingResult = $this->processWebhookEvent();
// Update the stored webhook event.
PaymentprocessorWebhook::update(FALSE)
......@@ -466,7 +484,7 @@ class CRM_Core_Payment_StripeIPN {
$return->message = $e->getMessage() . "\n" . $e->getTraceAsString();
}
$return->exception = $e;
\Civi::log()->error("StripeIPN: processWebhookEvent failed. EventID: {$this->eventID} : " . $return->message);
\Civi::log('stripe')->error("StripeIPN: processWebhookEvent failed. EventID: {$this->eventID} : " . $return->message);
}
}
......
......@@ -24,6 +24,7 @@ class CRM_Stripe_Api {
// object is a string containing the Stripe object name
switch ($stripeObject->object) {
case 'charge':
/** @var \Stripe\Charge $stripeObject */
switch ($name) {
case 'charge_id':
return (string) $stripeObject->id;
......@@ -50,6 +51,7 @@ class CRM_Stripe_Api {
return (string) $stripeObject->balance_transaction;
case 'receive_date':
case 'created_date':
return self::formatDate($stripeObject->created);
case 'invoice_id':
......@@ -73,10 +75,18 @@ class CRM_Stripe_Api {
case 'payment_intent_id':
return (string) $stripeObject->payment_intent;
case 'description':
return (string) $stripeObject->description;
case 'status':
// This might be "succeeded", "pending", "failed" (https://stripe.com/docs/api/charges/object#charge_object-status)
return (string) $stripeObject->status;
}
break;
case 'invoice':
/** @var \Stripe\Invoice $stripeObject */
switch ($name) {
case 'charge_id':
return (string) $stripeObject->charge;
......@@ -85,31 +95,45 @@ class CRM_Stripe_Api {
return (string) $stripeObject->id;
case 'receive_date':
/*
* The "created" date of the invoice does not equal the paid date but it *might* be the same.
* We should use the paid_at below or lookup via the charge or paymentintent.
* "status_transitions": {
* "finalized_at": 1676295806,
* "marked_uncollectible_at": null,
* "paid_at": 1677591861,
* "voided_at": null
* },
*/
if (!empty($stripeObject->status_transitions->paid_at)) {
return self::formatDate($stripeObject->status_transitions->paid_at);
}
// Intentionally falls through to invoice_date
case 'invoice_date':
if (!empty($stripeObject->status_transitions->finalized_at)) {
return self::formatDate($stripeObject->status_transitions->finalized_at);
}
// Intentionally falls through to created_date
case 'created_date':
return self::formatDate($stripeObject->created);
case 'subscription_id':
return (string) $stripeObject->subscription;
case 'amount':
return (string) $stripeObject->amount_due / 100;
return (float) $stripeObject->amount_due / 100;
case 'amount_paid':
return (string) $stripeObject->amount_paid / 100;
return (float) $stripeObject->amount_paid / 100;
case 'amount_remaining':
return (string) $stripeObject->amount_remaining / 100;
return (float) $stripeObject->amount_remaining / 100;
case 'currency':
return self::formatCurrency($stripeObject->currency);
case 'status_id':
if ((bool) $stripeObject->paid) {
return 'Completed';
}
else {
return 'Pending';
}
case 'description':
return (string) $stripeObject->description;
......@@ -121,22 +145,45 @@ class CRM_Stripe_Api {
Civi::log()->error("Coding error: CRM_Stripe_Api::getObjectParam failure_message is not a property on a Stripe Invoice object. Please alter your code to fetch the Charge and obtain the failure_message from that.");
return '';
case 'status':
return self::mapInvoiceStatusToContributionStatus($stripeObject);
}
break;
case 'subscription':
/** @var \Stripe\Subscription $stripeObject */
switch ($name) {
case 'frequency_interval':
return (string) $stripeObject->plan->interval_count;
case 'frequency_unit':
return (string) $stripeObject->plan->interval;
case 'amount':
$plan = [
'amount' => 0,
'interval' => '',
'interval_count' => 0,
];
foreach ($stripeObject->items as $item) {
if ($item->price->active && ($item->quantity > 0)) {
$plan['amount'] += $item->price->unit_amount * $item->quantity;
$plan['interval'] = $item->plan->interval;
$plan['interval_count'] = $item->plan->interval_count;
}
}
case 'plan_amount':
return (string) $stripeObject->plan->amount / 100;
switch($name) {
case 'frequency_interval':
return (int) $plan['interval_count'];
case 'frequency_unit':
return (string) $plan['interval'];
case 'amount':
return (float) $plan['amount'] / 100;
}
break;
case 'currency':
return self::formatCurrency($stripeObject->plan->currency);
return self::formatCurrency($stripeObject->currency);
case 'plan_start':
return self::formatDate($stripeObject->start_date);
......@@ -144,6 +191,12 @@ class CRM_Stripe_Api {
case 'cancel_date':
return self::formatDate($stripeObject->canceled_at);
case 'next_sched_contribution_date':
return self::formatDate($stripeObject->current_period_end);
case 'current_period_start':
return self::formatDate($stripeObject->current_period_start);
case 'cycle_day':
return date("d", $stripeObject->billing_cycle_anchor);
......@@ -167,30 +220,73 @@ class CRM_Stripe_Api {
case \Stripe\Subscription::STATUS_INCOMPLETE_EXPIRED:
default:
return CRM_Core_PseudoConstant::getKey('CRM_Contribute_BAO_Contribution', 'contribution_status_id', 'Cancelled');
}
case 'status':
return self::mapSubscriptionStatusToRecurStatus($stripeObject->status);
case 'customer_id':
return (string) $stripeObject->customer;
}
break;
case 'checkout.session':
/** @var \Stripe\Checkout\Session $stripeObject */
switch ($name) {
case 'payment_intent_id':
return (string) $stripeObject->payment_intent;
case 'checkout_session_id':
return (string) $stripeObject->id;
case 'client_reference_id':
return (string) $stripeObject->client_reference_id;
case 'subscription_id':
return (string) $stripeObject->subscription;
case 'customer_id':
return (string) $stripeObject->customer;
case 'invoice_id':
return (string) $stripeObject->invoice;
case 'payment_intent_id':
return (string) $stripeObject->payment_intent;
case 'subscription_id':
return (string) $stripeObject->subscription;
}
break;
case 'subscription_item':
/** @var \Stripe\SubscriptionItem $stripeObject */
switch ($name) {
default:
if (isset($stripeObject->$name)) {
return $stripeObject->$name;
}
\Civi::log('stripe')->error('getObjectParam: Tried to get param "' . $name . '" from "' . $stripeObject->object . '" but it is not set');
return NULL;
// unit_amount
}
break;
case 'price':
/** @var \Stripe\Price $stripeObject */
switch ($name) {
case 'unit_amount':
return (float) $stripeObject->unit_amount / 100;
case 'recurring_interval':
// eg. "year"
return (string) $stripeObject->recurring->interval ?? '';
case 'recurring_interval_count':
// eg 1
return (int) $stripeObject->recurring->interval_count ?? 0;
default:
if (isset($stripeObject->$name)) {
return $stripeObject->$name;
}
\Civi::log('stripe')->error('getObjectParam: Tried to get param "' . $name . '" from "' . $stripeObject->object . '" but it is not set');
return NULL;
// unit_amount
}
break;
......@@ -278,9 +374,9 @@ class CRM_Stripe_Api {
// 'affirm',
// 'afterpay_clearpay',
// 'alipay',
// 'au_becs_debit',
'au_becs_debit' => E::ts('BECS Direct Debit payments in Australia'),
'bacs_debit' => E::ts('BACS Direct Debit'),
// 'bancontact',
'bancontact' => E::ts('Bancontact'),
// 'blik',
// 'boleto',
// 'cashapp',
......@@ -304,4 +400,49 @@ class CRM_Stripe_Api {
];
}
/**
* Map the Stripe Subscription Status to the CiviCRM ContributionRecur status.
*
* @param string $subscriptionStatus
*
* @return string
*/
public static function mapSubscriptionStatusToRecurStatus(string $subscriptionStatus): string {
$statusMap = [
'incomplete' => 'Failed',
'incomplete_expired' => 'Failed',
'trialing' => 'In Progress',
'active' => 'In Progress',
'past_due' => 'Overdue',
'canceled' => 'Cancelled',
'unpaid' => 'Failed',
'paused' => 'Pending',
];
return $statusMap[$subscriptionStatus] ?? '';
}
/**
* Map the Stripe Invoice Status to the CiviCRM Contribution status.
* https://stripe.com/docs/invoicing/overview#invoice-statuses
*
* @param \Stripe\Invoice $invoice
*
* @return string
*/
public static function mapInvoiceStatusToContributionStatus(\Stripe\Invoice $invoice): string {
$statusMap = [
'draft' => 'Pending',
'open' => 'Pending',
'paid' => 'Completed',
'void' => 'Cancelled',
'uncollectible' => 'Failed',
];
if ($invoice->status === 'open' && $invoice->attempted && empty($invoice->next_payment_attempt)) {
// An invoice will automatically be retried. If that fails the status will remain "open" but it has effectively failed.
// We use attempted + next_payment_attempt to check if it will NOT be retried and then record it as Failed in CiviCRM.
return 'Failed';
}
return $statusMap[$invoice->status] ?? '';
}
}
......@@ -4,6 +4,7 @@ use Civi\Api4\Contact;
use Civi\Api4\Email;
use Civi\Api4\Extension;
use Civi\Api4\StripeCustomer;
use Civi\Payment\Exception\PaymentProcessorException;
use CRM_Stripe_ExtensionUtil as E;
class CRM_Stripe_BAO_StripeCustomer extends CRM_Stripe_DAO_StripeCustomer {
......@@ -72,15 +73,15 @@ class CRM_Stripe_BAO_StripeCustomer extends CRM_Stripe_DAO_StripeCustomer {
* @param \CRM_Core_Payment_Stripe $stripe
* @param string $stripeCustomerID
*
* @return \Stripe\Customer
* @throws \CiviCRM_API3_Exception
* @return string
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function updateMetadata(array $params, \CRM_Core_Payment_Stripe $stripe, string $stripeCustomerID) {
public static function updateMetadata(array $params, \CRM_Core_Payment_Stripe $stripe, string $stripeCustomerID): string {
$requiredParams = ['contact_id'];
foreach ($requiredParams as $required) {
if (empty($params[$required])) {
throw new \Civi\Payment\Exception\PaymentProcessorException('Stripe Customer (updateMetadata): Missing required parameter: ' . $required);
throw new PaymentProcessorException('Stripe Customer (updateMetadata): Missing required parameter: ' . $required);
}
}
......@@ -91,14 +92,19 @@ class CRM_Stripe_BAO_StripeCustomer extends CRM_Stripe_DAO_StripeCustomer {
}
catch (Exception $e) {
$err = $stripe->parseStripeException('create_customer', $e);
\Civi::log('stripe')->error('Failed to create Stripe Customer: ' . $err['message'] . '; ' . print_r($err, TRUE));
throw new \Civi\Payment\Exception\PaymentProcessorException('Failed to update Stripe Customer: ' . $err['code']);
if ($e instanceof \Stripe\Exception\PermissionException) {
\Civi::log('stripe')->warning($stripe->getLogPrefix() . 'Could not update Stripe Customer metadata for StripeCustomerID: ' . $stripeCustomerID . '; contactID: ' . $params['contact_id']);
}
else {
\Civi::log('stripe')->error($stripe->getLogPrefix() . 'Failed to create Stripe Customer: ' . $err['message'] . '; ' . print_r($err, TRUE));
throw new PaymentProcessorException('Failed to update Stripe Customer: ' . $err['code']);
}
}
return $stripeCustomer;
return $stripeCustomer ?? '';
}
/**
* Update the metadata at Stripe for a given contactid
* Update the metadata at Stripe for a given contactID
*
* @param int $contactID
*
......@@ -112,18 +118,12 @@ class CRM_Stripe_BAO_StripeCustomer extends CRM_Stripe_DAO_StripeCustomer {
// Could be multiple customer_id's and/or stripe processors
foreach ($customers as $customer) {
/** @var CRM_Core_Payment_Stripe $stripe */
\Civi\Api4\StripeCustomer::updateStripe(FALSE)
StripeCustomer::updateStripe(FALSE)
->setPaymentProcessorID($customer['processor_id'])
->setContactID($contactID)
->setCustomerID($customer['customer_id'])
->execute()
->first();
$stripe = \Civi\Payment\System::singleton()->getById($customer['processor_id']);
CRM_Stripe_BAO_StripeCustomer::updateMetadata(
['contact_id' => $contactID, 'processor_id' => $customer['processor_id']],
$stripe,
$customer['customer_id']
);
}
}
......
......@@ -19,19 +19,19 @@ class CRM_Stripe_Check {
/**
* @var string
*/
const API_VERSION = '2022-11-15';
const API_MIN_VERSION = '2020-08-27';
const API_VERSION = \Stripe\Util\ApiVersion::CURRENT;
const API_MIN_VERSION = \Stripe\Util\ApiVersion::CURRENT;
/**
* @var string
*/
const MIN_VERSION_MJWSHARED = '1.2.13';
const MIN_VERSION_FIREWALL = '1.5.8';
const MIN_VERSION_MJWSHARED = '1.3';
const MIN_VERSION_FIREWALL = '1.5.9';
/**
* @var array
*/
private $messages;
private array $messages;
/**
* constructor.
......@@ -44,7 +44,7 @@ class CRM_Stripe_Check {
/**
* @return array
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
*/
public function checkRequirements() {
$this->checkExtensionMjwshared();
......@@ -59,23 +59,32 @@ class CRM_Stripe_Check {
* @param string $minVersion
* @param string $actualVersion
*/
private function requireExtensionMinVersion($extensionName, $minVersion, $actualVersion) {
private function requireExtensionMinVersion(string $extensionName, string $minVersion, string $actualVersion) {
$actualVersionModified = $actualVersion;
if (substr($actualVersion, -4) === '-dev') {
$message = new CRM_Utils_Check_Message(
__FUNCTION__ . $extensionName . E::SHORT_NAME . '_requirements_dev',
E::ts('You are using a development version of %1 extension.',
[1 => $extensionName]),
E::ts('%1: Development version', [1 => $extensionName]),
\Psr\Log\LogLevel::WARNING,
'fa-code'
);
$this->messages[] = $message;
$actualVersionModified = substr($actualVersion, 0, -4);
$devMessageAlreadyDefined = FALSE;
foreach ($this->messages as $message) {
if ($message->getName() === __FUNCTION__ . $extensionName . '_requirements_dev') {
// Another extension already generated the "Development version" message for this extension
$devMessageAlreadyDefined = TRUE;
}
}
if (!$devMessageAlreadyDefined) {
$message = new \CRM_Utils_Check_Message(
__FUNCTION__ . $extensionName . '_requirements_dev',
E::ts('You are using a development version of %1 extension.',
[1 => $extensionName]),
E::ts('%1: Development version', [1 => $extensionName]),
\Psr\Log\LogLevel::WARNING,
'fa-code'
);
$this->messages[] = $message;
}
}
if (version_compare($actualVersionModified, $minVersion) === -1) {
$message = new CRM_Utils_Check_Message(
$message = new \CRM_Utils_Check_Message(
__FUNCTION__ . $extensionName . E::SHORT_NAME . '_requirements',
E::ts('The %1 extension requires the %2 extension version %3 or greater but your system has version %4.',
[
......@@ -99,7 +108,7 @@ class CRM_Stripe_Check {
}
/**
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
*/
private function checkExtensionMjwshared() {
// mjwshared: required. Requires min version
......@@ -136,7 +145,7 @@ class CRM_Stripe_Check {
}
/**
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
*/
private function checkExtensionFirewall() {
$extensionName = 'firewall';
......
......@@ -41,6 +41,7 @@ class CRM_Stripe_Customer {
$result = StripeCustomer::get(FALSE)
->addWhere('contact_id', '=', $params['contact_id'])
->addWhere('processor_id', '=', $params['processor_id'])
->addClause('OR', ['currency', 'IS EMPTY'], ['currency', '=', $params['currency']])
->addSelect('customer_id')
->execute();
......@@ -80,23 +81,12 @@ class CRM_Stripe_Customer {
] + $options, ['customer_id']);
}
/**
* Add a new Stripe customer to the CiviCRM database
*
* @param array $params
*
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function add(array $params) {
return civicrm_api4('StripeCustomer', 'create', ['checkPermissions' => FALSE, 'values' => $params]);
}
/**
* @param array $params
* @param \CRM_Core_Payment_Stripe $stripe
*
* @return \Stripe\Customer|\PropertySpy
* @throws \CiviCRM_API3_Exception
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
*/
public static function create(array $params, \CRM_Core_Payment_Stripe $stripe) {
......@@ -110,7 +100,7 @@ class CRM_Stripe_Customer {
$stripeCustomerParams = CRM_Stripe_BAO_StripeCustomer::getStripeCustomerMetadata($params['contact_id'], $params['invoice_settings'] ?? []);
try {
$stripeCustomer = $stripe->stripeClient->customers->create($stripeCustomerParams);
$stripeCustomerObject = $stripe->stripeClient->customers->create($stripeCustomerParams);
}
catch (Exception $e) {
$err = $stripe->parseStripeException('create_customer', $e);
......@@ -119,14 +109,14 @@ class CRM_Stripe_Customer {
}
// Store the relationship between CiviCRM's email address for the Contact & Stripe's Customer ID.
$params = [
'contact_id' => $params['contact_id'],
'customer_id' => $stripeCustomer->id,
'processor_id' => $params['processor_id'],
];
self::add($params);
return $stripeCustomer;
StripeCustomer::create(FALSE)
->addValue('contact_id', $params['contact_id'])
->addValue('customer_id', $stripeCustomerObject->id)
->addValue('processor_id', $params['processor_id'])
->addValue('currency', $params['currency'])
->execute();
return $stripeCustomerObject;
}
/**
......@@ -147,7 +137,7 @@ class CRM_Stripe_Customer {
throw new PaymentProcessorException('Stripe Customer (delete): Missing required parameter: contact_id or customer_id');
}
$delete = StripeCustomer::delete()
$delete = StripeCustomer::delete(FALSE)
->addWhere('processor_id', '=', $params['processor_id']);
if (!empty($params['customer_id'])) {
......
<?php
/**
* @package CRM
* @copyright CiviCRM LLC https://civicrm.org/licensing
* DAOs provide an OOP-style facade for reading and writing database records.
*
* Generated from com.drastikbydesign.stripe/xml/schema/CRM/Stripe/StripeCustomer.xml
* DO NOT EDIT. Generated by CRM_Core_CodeGen
* (GenCodeChecksum:6fd6993b0c77bb447ecfb04a5fc2ef34)
*/
use CRM_Stripe_ExtensionUtil as E;
/**
* Database access object for the StripeCustomer entity.
* DAOs are a primary source for metadata in older versions of CiviCRM (<5.74)
* and are required for some subsystems (such as APIv3).
*
* This stub provides compatibility. It is not intended to be modified in a
* substantive way. Property annotations may be added, but are not required.
* @property string $id
* @property string $customer_id
* @property string $contact_id
* @property string $processor_id
* @property string $currency
*/
class CRM_Stripe_DAO_StripeCustomer extends CRM_Core_DAO {
const EXT = E::LONG_NAME;
const TABLE_ADDED = '';
class CRM_Stripe_DAO_StripeCustomer extends CRM_Stripe_DAO_Base {
/**
* Static instance to hold the table name.
*
* Required by older versions of CiviCRM (<5.74).
* @var string
*/
public static $_tableName = 'civicrm_stripe_customers';
/**
* Should CiviCRM log any modifications to this table in the civicrm_log table.
*
* @var bool
*/
public static $_log = TRUE;
/**
* Unique ID
*
* @var int|string|null
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $id;
/**
* Stripe Customer ID
*
* @var string|null
* (SQL type: varchar(255))
* Note that values will be retrieved from the database as a string.
*/
public $customer_id;
/**
* FK to Contact
*
* @var int|string|null
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $contact_id;
/**
* ID from civicrm_payment_processor
*
* @var int|string|null
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $processor_id;
/**
* Class constructor.
*/
public function __construct() {
$this->__table = 'civicrm_stripe_customers';
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('Stripe Customers') : E::ts('Stripe Customer');
}
/**
* Returns foreign keys and entity references.
*
* @return array
* [CRM_Core_Reference_Interface]
*/
public static function getReferenceColumns() {
if (!isset(Civi::$statics[__CLASS__]['links'])) {
Civi::$statics[__CLASS__]['links'] = static::createReferenceColumns(__CLASS__);
Civi::$statics[__CLASS__]['links'][] = new CRM_Core_Reference_Basic(self::getTableName(), 'contact_id', 'civicrm_contact', 'id');
CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'links_callback', Civi::$statics[__CLASS__]['links']);
}
return Civi::$statics[__CLASS__]['links'];
}
/**
* 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,
'description' => E::ts('Unique ID'),
'required' => TRUE,
'where' => 'civicrm_stripe_customers.id',
'table_name' => 'civicrm_stripe_customers',
'entity' => 'StripeCustomer',
'bao' => 'CRM_Stripe_DAO_StripeCustomer',
'localizable' => 0,
'readonly' => TRUE,
'add' => NULL,
],
'customer_id' => [
'name' => 'customer_id',
'type' => CRM_Utils_Type::T_STRING,
'title' => E::ts('Stripe Customer ID'),
'description' => E::ts('Stripe Customer ID'),
'maxlength' => 255,
'size' => CRM_Utils_Type::HUGE,
'where' => 'civicrm_stripe_customers.customer_id',
'table_name' => 'civicrm_stripe_customers',
'entity' => 'StripeCustomer',
'bao' => 'CRM_Stripe_DAO_StripeCustomer',
'localizable' => 0,
'add' => NULL,
],
'contact_id' => [
'name' => 'contact_id',
'type' => CRM_Utils_Type::T_INT,
'description' => E::ts('FK to Contact'),
'where' => 'civicrm_stripe_customers.contact_id',
'table_name' => 'civicrm_stripe_customers',
'entity' => 'StripeCustomer',
'bao' => 'CRM_Stripe_DAO_StripeCustomer',
'localizable' => 0,
'FKClassName' => 'CRM_Contact_DAO_Contact',
'add' => NULL,
],
'processor_id' => [
'name' => 'processor_id',
'type' => CRM_Utils_Type::T_INT,
'title' => E::ts('Payment Processor ID'),
'description' => E::ts('ID from civicrm_payment_processor'),
'where' => 'civicrm_stripe_customers.processor_id',
'table_name' => 'civicrm_stripe_customers',
'entity' => 'StripeCustomer',
'bao' => 'CRM_Stripe_DAO_StripeCustomer',
'localizable' => 0,
'pseudoconstant' => [
'table' => 'civicrm_payment_processor',
'keyColumn' => 'id',
'labelColumn' => 'name',
],
'add' => NULL,
],
];
CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']);
}
return Civi::$statics[__CLASS__]['fields'];
}
/**
* Return a mapping from field-name to the corresponding key (as used in fields()).
*
* @return array
* Array(string $name => string $uniqueName).
*/
public static function &fieldKeys() {
if (!isset(Civi::$statics[__CLASS__]['fieldKeys'])) {
Civi::$statics[__CLASS__]['fieldKeys'] = array_flip(CRM_Utils_Array::collect('name', self::fields()));
}
return Civi::$statics[__CLASS__]['fieldKeys'];
}
/**
* Returns the names of this table
*
* @return string
*/
public static function getTableName() {
return self::$_tableName;
}
/**
* Returns if this table needs to be logged
*
* @return bool
*/
public function getLog() {
return self::$_log;
}
/**
* 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__, 'stripe_customers', $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__, 'stripe_customers', $prefix, []);
return $r;
}
/**
* Returns the list of indices
*
* @param bool $localize
*
* @return array
*/
public static function indices($localize = TRUE) {
$indices = [
'customer_id' => [
'name' => 'customer_id',
'field' => [
0 => 'customer_id',
],
'localizable' => FALSE,
'unique' => TRUE,
'sig' => 'civicrm_stripe_customers::1::customer_id',
],
];
return ($localize && !empty($indices)) ? CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices;
}
}
<?php
/**
* @package CRM
* @copyright CiviCRM LLC https://civicrm.org/licensing
* DAOs provide an OOP-style facade for reading and writing database records.
*
* Generated from com.drastikbydesign.stripe/xml/schema/CRM/Stripe/StripePaymentintent.xml
* DO NOT EDIT. Generated by CRM_Core_CodeGen
* (GenCodeChecksum:50c4ef9341699c0242005eede56e04d8)
*/
use CRM_Stripe_ExtensionUtil as E;
/**
* Database access object for the StripePaymentintent entity.
* DAOs are a primary source for metadata in older versions of CiviCRM (<5.74)
* and are required for some subsystems (such as APIv3).
*
* This stub provides compatibility. It is not intended to be modified in a
* substantive way. Property annotations may be added, but are not required.
* @property string $id
* @property string $stripe_intent_id
* @property string $contribution_id
* @property string $payment_processor_id
* @property string $description
* @property string $status
* @property string $identifier
* @property string $contact_id
* @property string $created_date
* @property string $flags
* @property string $referrer
* @property string $extra_data
*/
class CRM_Stripe_DAO_StripePaymentintent extends CRM_Core_DAO {
const EXT = E::LONG_NAME;
const TABLE_ADDED = '';
class CRM_Stripe_DAO_StripePaymentintent extends CRM_Stripe_DAO_Base {
/**
* Static instance to hold the table name.
*
* Required by older versions of CiviCRM (<5.74).
* @var string
*/
public static $_tableName = 'civicrm_stripe_paymentintent';
/**
* Should CiviCRM log any modifications to this table in the civicrm_log table.
*
* @var bool
*/
public static $_log = TRUE;
/**
* Unique ID
*
* @var int|string|null
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $id;
/**
* The Stripe PaymentIntent/SetupIntent/PaymentMethod ID
*
* @var string|null
* (SQL type: varchar(255))
* Note that values will be retrieved from the database as a string.
*/
public $stripe_intent_id;
/**
* FK ID from civicrm_contribution
*
* @var int|string|null
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $contribution_id;
/**
* Foreign key to civicrm_payment_processor.id
*
* @var int|string|null
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $payment_processor_id;
/**
* Description of this paymentIntent
*
* @var string
* (SQL type: varchar(255))
* Note that values will be retrieved from the database as a string.
*/
public $description;
/**
* The status of the paymentIntent
*
* @var string
* (SQL type: varchar(25))
* Note that values will be retrieved from the database as a string.
*/
public $status;
/**
* An identifier that we can use in CiviCRM to find the paymentIntent if we do not have the ID (eg. session key)
*
* @var string
* (SQL type: varchar(255))
* Note that values will be retrieved from the database as a string.
*/
public $identifier;
/**
* FK to Contact
*
* @var int|string|null
* (SQL type: int unsigned)
* Note that values will be retrieved from the database as a string.
*/
public $contact_id;
/**
* When was paymentIntent created
*
* @var string|null
* (SQL type: timestamp)
* Note that values will be retrieved from the database as a string.
*/
public $created_date;
/**
* Flags associated with this PaymentIntent (NC=no contributionID when doPayment called)
*
* @var string
* (SQL type: varchar(100))
* Note that values will be retrieved from the database as a string.
*/
public $flags;
/**
* HTTP referrer of this paymentIntent
*
* @var string
* (SQL type: varchar(255))
* Note that values will be retrieved from the database as a string.
*/
public $referrer;
/**
* Extra data collected to help with diagnostics (such as email, name)
*
* @var string
* (SQL type: varchar(255))
* Note that values will be retrieved from the database as a string.
*/
public $extra_data;
/**
* Class constructor.
*/
public function __construct() {
$this->__table = 'civicrm_stripe_paymentintent';
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('Stripe Paymentintents') : E::ts('Stripe Paymentintent');
}
/**
* Returns foreign keys and entity references.
*
* @return array
* [CRM_Core_Reference_Interface]
*/
public static function getReferenceColumns() {
if (!isset(Civi::$statics[__CLASS__]['links'])) {
Civi::$statics[__CLASS__]['links'] = static::createReferenceColumns(__CLASS__);
Civi::$statics[__CLASS__]['links'][] = new CRM_Core_Reference_Basic(self::getTableName(), 'payment_processor_id', 'civicrm_payment_processor', 'id');
Civi::$statics[__CLASS__]['links'][] = new CRM_Core_Reference_Basic(self::getTableName(), 'contact_id', 'civicrm_contact', 'id');
CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'links_callback', Civi::$statics[__CLASS__]['links']);
}
return Civi::$statics[__CLASS__]['links'];
}
/**
* 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,
'description' => E::ts('Unique ID'),
'required' => TRUE,
'where' => 'civicrm_stripe_paymentintent.id',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'readonly' => TRUE,
'add' => NULL,
],
'stripe_intent_id' => [
'name' => 'stripe_intent_id',
'type' => CRM_Utils_Type::T_STRING,
'title' => E::ts('Stripe Intent ID'),
'description' => E::ts('The Stripe PaymentIntent/SetupIntent/PaymentMethod ID'),
'maxlength' => 255,
'size' => CRM_Utils_Type::HUGE,
'where' => 'civicrm_stripe_paymentintent.stripe_intent_id',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'add' => NULL,
],
'contribution_id' => [
'name' => 'contribution_id',
'type' => CRM_Utils_Type::T_INT,
'title' => E::ts('Contribution ID'),
'description' => E::ts('FK ID from civicrm_contribution'),
'where' => 'civicrm_stripe_paymentintent.contribution_id',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'add' => NULL,
],
'payment_processor_id' => [
'name' => 'payment_processor_id',
'type' => CRM_Utils_Type::T_INT,
'title' => E::ts('Payment Processor'),
'description' => E::ts('Foreign key to civicrm_payment_processor.id'),
'where' => 'civicrm_stripe_paymentintent.payment_processor_id',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'FKClassName' => 'CRM_Financial_DAO_PaymentProcessor',
'pseudoconstant' => [
'table' => 'civicrm_payment_processor',
'keyColumn' => 'id',
'labelColumn' => 'name',
],
'add' => NULL,
],
'description' => [
'name' => 'description',
'type' => CRM_Utils_Type::T_STRING,
'title' => E::ts('Description'),
'description' => E::ts('Description of this paymentIntent'),
'required' => FALSE,
'maxlength' => 255,
'size' => CRM_Utils_Type::HUGE,
'where' => 'civicrm_stripe_paymentintent.description',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'add' => NULL,
],
'status' => [
'name' => 'status',
'type' => CRM_Utils_Type::T_STRING,
'title' => E::ts('Status'),
'description' => E::ts('The status of the paymentIntent'),
'required' => FALSE,
'maxlength' => 25,
'size' => CRM_Utils_Type::MEDIUM,
'where' => 'civicrm_stripe_paymentintent.status',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'add' => NULL,
],
'identifier' => [
'name' => 'identifier',
'type' => CRM_Utils_Type::T_STRING,
'title' => E::ts('Identifier'),
'description' => E::ts('An identifier that we can use in CiviCRM to find the paymentIntent if we do not have the ID (eg. session key)'),
'required' => FALSE,
'maxlength' => 255,
'size' => CRM_Utils_Type::HUGE,
'where' => 'civicrm_stripe_paymentintent.identifier',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'add' => NULL,
],
'contact_id' => [
'name' => 'contact_id',
'type' => CRM_Utils_Type::T_INT,
'description' => E::ts('FK to Contact'),
'where' => 'civicrm_stripe_paymentintent.contact_id',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'FKClassName' => 'CRM_Contact_DAO_Contact',
'add' => NULL,
],
'created_date' => [
'name' => 'created_date',
'type' => CRM_Utils_Type::T_TIMESTAMP,
'title' => E::ts('Created Date'),
'description' => E::ts('When was paymentIntent created'),
'where' => 'civicrm_stripe_paymentintent.created_date',
'default' => 'CURRENT_TIMESTAMP',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'add' => NULL,
],
'flags' => [
'name' => 'flags',
'type' => CRM_Utils_Type::T_STRING,
'title' => E::ts('Flags'),
'description' => E::ts('Flags associated with this PaymentIntent (NC=no contributionID when doPayment called)'),
'required' => FALSE,
'maxlength' => 100,
'size' => CRM_Utils_Type::HUGE,
'where' => 'civicrm_stripe_paymentintent.flags',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'add' => NULL,
],
'referrer' => [
'name' => 'referrer',
'type' => CRM_Utils_Type::T_STRING,
'title' => E::ts('Referrer'),
'description' => E::ts('HTTP referrer of this paymentIntent'),
'required' => FALSE,
'maxlength' => 255,
'size' => CRM_Utils_Type::HUGE,
'where' => 'civicrm_stripe_paymentintent.referrer',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'add' => NULL,
],
'extra_data' => [
'name' => 'extra_data',
'type' => CRM_Utils_Type::T_STRING,
'title' => E::ts('Extra Data'),
'description' => E::ts('Extra data collected to help with diagnostics (such as email, name)'),
'required' => FALSE,
'maxlength' => 255,
'size' => CRM_Utils_Type::HUGE,
'where' => 'civicrm_stripe_paymentintent.extra_data',
'table_name' => 'civicrm_stripe_paymentintent',
'entity' => 'StripePaymentintent',
'bao' => 'CRM_Stripe_DAO_StripePaymentintent',
'localizable' => 0,
'add' => NULL,
],
];
CRM_Core_DAO_AllCoreTables::invoke(__CLASS__, 'fields_callback', Civi::$statics[__CLASS__]['fields']);
}
return Civi::$statics[__CLASS__]['fields'];
}
/**
* Return a mapping from field-name to the corresponding key (as used in fields()).
*
* @return array
* Array(string $name => string $uniqueName).
*/
public static function &fieldKeys() {
if (!isset(Civi::$statics[__CLASS__]['fieldKeys'])) {
Civi::$statics[__CLASS__]['fieldKeys'] = array_flip(CRM_Utils_Array::collect('name', self::fields()));
}
return Civi::$statics[__CLASS__]['fieldKeys'];
}
/**
* Returns the names of this table
*
* @return string
*/
public static function getTableName() {
return self::$_tableName;
}
/**
* Returns if this table needs to be logged
*
* @return bool
*/
public function getLog() {
return self::$_log;
}
/**
* 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__, 'stripe_paymentintent', $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__, 'stripe_paymentintent', $prefix, []);
return $r;
}
/**
* Returns the list of indices
*
* @param bool $localize
*
* @return array
*/
public static function indices($localize = TRUE) {
$indices = [
'UI_stripe_intent_id' => [
'name' => 'UI_stripe_intent_id',
'field' => [
0 => 'stripe_intent_id',
],
'localizable' => FALSE,
'unique' => TRUE,
'sig' => 'civicrm_stripe_paymentintent::1::stripe_intent_id',
],
];
return ($localize && !empty($indices)) ? CRM_Core_DAO_AllCoreTables::multilingualize(__CLASS__, $indices) : $indices;
}
}
......@@ -41,18 +41,7 @@ class CRM_Stripe_PaymentIntent {
/**
* @param \CRM_Core_Payment_Stripe $paymentProcessor
*/
public function __construct($paymentProcessor = NULL) {
if ($paymentProcessor) {
$this->setPaymentProcessor($paymentProcessor);
}
}
/**
* @param \CRM_Core_Payment_Stripe $paymentProcessor
*
* @return void
*/
public function setPaymentProcessor(\CRM_Core_Payment_Stripe $paymentProcessor) {
public function __construct(\CRM_Core_Payment_Stripe $paymentProcessor) {
$this->paymentProcessor = $paymentProcessor;
}
......@@ -346,17 +335,12 @@ class CRM_Stripe_PaymentIntent {
*/
public function processIntent(array $params) {
// Params that may or may not be set by calling code:
// 'capture' was used by civicrmStripeConfirm.js and was removed when we added setupIntents.
$params['capture'] = $params['capture'] ?? FALSE;
// 'currency' should really be set but we'll default if not set.
$currency = \CRM_Utils_Type::validate($params['currency'], 'String', \CRM_Core_Config::singleton()->defaultCurrency);
// If a payment using MOTO (mail order telephone order) was requested.
// This parameter has security implications and great care should be taken when setting it to TRUE.
$params['moto'] = $params['moto'] ?? FALSE;
/** @var \CRM_Core_Payment_Stripe $paymentProcessor */
$paymentProcessor = \Civi\Payment\System::singleton()->getById($params['paymentProcessorID']);
$this->setPaymentProcessor($paymentProcessor);
if ($this->paymentProcessor->getPaymentProcessor()['class_name'] !== 'Payment_Stripe') {
\Civi::log('stripe')->error(__CLASS__ . " payment processor {$params['paymentProcessorID']} is not Stripe");
return (object) ['ok' => FALSE, 'message' => 'Payment processor is not Stripe', 'data' => []];
......@@ -373,7 +357,7 @@ class CRM_Stripe_PaymentIntent {
$processPaymentIntentParams = [
'paymentIntentID' => $params['intentID'],
'paymentMethodID' => $params['paymentMethodID'],
'capture' => $params['capture'],
'capture' => FALSE,
'amount' => $params['amount'],
'currency' => $currency,
'payment_method_options' => $params['payment_method_options'] ?? [],
......@@ -430,10 +414,7 @@ class CRM_Stripe_PaymentIntent {
$intentParams = [];
$intentParams['confirm'] = TRUE;
$intentParams['confirmation_method'] = 'manual';
if (empty($params['paymentIntentID']) && empty($params['paymentMethodID'])) {
$intentParams['confirm'] = FALSE;
$intentParams['confirmation_method'] = 'automatic';
}
$intentParams['payment_method_types'] = ['card'];
if (!empty($params['paymentIntentID'])) {
try {
......@@ -442,12 +423,9 @@ class CRM_Stripe_PaymentIntent {
if ($intent->status === 'requires_confirmation') {
$intent->confirm();
}
if ($params['capture'] && $intent->status === 'requires_capture') {
$intent->capture();
}
}
catch (Exception $e) {
\Civi::log()->debug(get_class($e) . $e->getMessage());
\Civi::log('stripe')->debug($this->paymentProcessor->getLogPrefix() . get_class($e) . $e->getMessage());
}
}
else {
......@@ -469,6 +447,7 @@ class CRM_Stripe_PaymentIntent {
if (isset($params['customer'])) {
$intentParams['customer'] = $params['customer'];
}
$intent = $this->paymentProcessor->stripeClient->paymentIntents->create($intentParams);
}
catch (Exception $e) {
......
......@@ -112,7 +112,7 @@ class CRM_Stripe_Upgrader extends CRM_Extension_Upgrader_Base {
CRM_Core_DAO::executeQuery('UPDATE `civicrm_stripe_subscriptions` SET processor_id = %2 where processor_id IS NULL and is_live = 0', $p);
}
}
} catch (CiviCRM_API3_Exception $e) {
} catch (CRM_Core_Exception $e) {
Civi::log()->debug("Cannot find a PaymentProcessorType named Stripe.");
return;
}
......@@ -242,7 +242,7 @@ class CRM_Stripe_Upgrader extends CRM_Extension_Upgrader_Base {
'invoice_id' => $subscriptions->invoice_id,
'contribution_test' => $test_mode,
]);
} catch (CiviCRM_API3_Exception $e) {
} catch (CRM_Core_Exception $e) {
// Don't quit here. If we can't find the recurring ID for a single customer, make a note in the error log and carry on.
Civi::log()->debug('Recurring contribution search: ' . $e->getMessage());
}
......@@ -420,7 +420,7 @@ class CRM_Stripe_Upgrader extends CRM_Extension_Upgrader_Base {
}
public function upgrade_6803() {
$this->ctx->log->info('Applying Stripe update 5028. In civicrm_stripe_customers database table, rename id to customer_id, add new id column');
$this->ctx->log->info('In civicrm_stripe_customers database table, rename id to customer_id, add new id column');
if (!CRM_Core_BAO_SchemaHandler::checkIfFieldExists('civicrm_stripe_customers', 'customer_id')) {
// ALTER TABLE ... RENAME COLUMN only in MySQL8+
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_stripe_customers CHANGE COLUMN id customer_id varchar(255) COMMENT 'Stripe Customer ID'");
......@@ -433,4 +433,23 @@ class CRM_Stripe_Upgrader extends CRM_Extension_Upgrader_Base {
return TRUE;
}
public function upgrade_6900() {
$this->ctx->log->info('Add currency to civicrm_stripe_customers because the customer can only have one currency for subscriptions');
if (!CRM_Core_BAO_SchemaHandler::checkIfFieldExists('civicrm_stripe_customers', 'currency')) {
CRM_Core_DAO::executeQuery("ALTER TABLE civicrm_stripe_customers ADD COLUMN currency varchar(3) DEFAULT NULL COMMENT '3 character string, value from Stripe customer.'");
}
return TRUE;
}
public function upgrade_6902() {
$this->ctx->log->info('Convert MOTO setting to array');
if (\Civi::settings()->get('stripe_moto') === TRUE) {
\Civi::settings()->set('stripe_moto', ['backend']);
}
else {
\Civi::settings()->set('stripe_moto', []);
}
return TRUE;
}
}
......@@ -47,15 +47,14 @@ class GetBalanceTransactionDetails extends \Civi\Api4\Generic\AbstractAction {
throw new \CRM_Core_Exception('Missing paymentProcessorID');
}
$stripeApi = new \Civi\Stripe\Api();
$stripeApi->setPaymentProcessor($this->paymentProcessorID);
$stripeApi = new \Civi\Stripe\Api(\Civi\Payment\System::singleton()->getById($this->paymentProcessorID));
$charge = $stripeApi->getPaymentProcessor()->stripeClient->charges->retrieve($this->chargeID);
$stripeEvent = new \Stripe\Event();
$stripeEvent->object = $charge;
$stripeApi->setData($stripeEvent);
$balanceTransactionDetails = $stripeApi->getDetailsFromBalanceTransaction($this->chargeID, $stripeEvent->object);
$balanceTransactionDetails = $stripeApi->getDetailsFromBalanceTransactionByChargeObject($stripeEvent->object);
$result->exchangeArray($balanceTransactionDetails);
}
......
......@@ -63,7 +63,6 @@ class UpdateStripe extends \Civi\Api4\Generic\AbstractAction {
*
* @return void
* @throws \CRM_Core_Exception
* @throws \Stripe\Exception\ApiErrorException
*/
public function _run(\Civi\Api4\Generic\Result $result) {
if (empty($this->customerID) && empty($this->contactID)) {
......@@ -94,9 +93,10 @@ class UpdateStripe extends \Civi\Api4\Generic\AbstractAction {
/** @var \CRM_Core_Payment_Stripe $paymentProcessor */
$paymentProcessor = \Civi\Payment\System::singleton()->getById($this->paymentProcessorID);
$stripeCustomer = \CRM_Stripe_BAO_StripeCustomer::updateMetadata(['contact_id' => $this->contactID, 'description' => $this->description], $paymentProcessor, $this->customerID);
$stripeCustomerID = \CRM_Stripe_BAO_StripeCustomer::updateMetadata(['contact_id' => $this->contactID, 'description' => $this->description], $paymentProcessor, $this->customerID);
$result->exchangeArray($stripeCustomer->toArray());
// Return values may change!
$result->exchangeArray(['stripeCustomerID' => $stripeCustomerID]);
}
}
......@@ -74,20 +74,11 @@ class ProcessMOTO extends \Civi\Api4\Generic\AbstractAction {
*/
protected $extraData = '';
/**
* @param \Civi\Api4\Generic\Result $result
*
* @return array
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
*/
/**
* @param \Civi\Api4\Generic\Result $result
*
* @return void
* @throws \API_Exception
* @throws \CRM_Core_Exception
* @throws \CiviCRM_API3_Exception
* @throws \Stripe\Exception\ApiErrorException
*/
public function _run(\Civi\Api4\Generic\Result $result) {
......@@ -98,15 +89,16 @@ class ProcessMOTO extends \Civi\Api4\Generic\AbstractAction {
}
if (empty($this->amount) && !$this->setup) {
\Civi::log('stripe')->error(__CLASS__ . 'missing amount and not capture or setup');
\Civi::log('stripe')->error(__CLASS__ . 'missing amount and not setup');
throw new \API_Exception('Bad request');
}
if (empty($this->paymentProcessorID)) {
\Civi::log('stripe')->error(__CLASS__ . ' missing paymentProcessorID');
throw new \API_Exception('Bad request');
}
$intentProcessor = new \CRM_Stripe_PaymentIntent();
/** @var \CRM_Core_Payment_Stripe $paymentProcessor */
$paymentProcessor = \Civi\Payment\System::singleton()->getById($this->paymentProcessorID);
$intentProcessor = new \CRM_Stripe_PaymentIntent($paymentProcessor);
$intentProcessor->setDescription($this->description);
$intentProcessor->setReferrer($_SERVER['HTTP_REFERER'] ?? '');
$intentProcessor->setExtraData($this->extraData ?? '');
......
......@@ -107,7 +107,7 @@ class ProcessPublic extends \Civi\Api4\Generic\AbstractAction {
}
if (empty($this->amount) && !$this->setup) {
\Civi::log('stripe')->error(__CLASS__ . 'missing amount and not capture or setup');
\Civi::log('stripe')->error(__CLASS__ . 'missing amount and not setup');
throw new \CRM_Core_Exception('Bad request');
}
......@@ -127,8 +127,9 @@ class ProcessPublic extends \Civi\Api4\Generic\AbstractAction {
\Civi::log('stripe')->error(__CLASS__ . ' missing paymentProcessorID');
throw new \CRM_Core_Exception('Bad request');
}
$intentProcessor = new \CRM_Stripe_PaymentIntent();
/** @var \CRM_Core_Payment_Stripe $paymentProcessor */
$paymentProcessor = \Civi\Payment\System::singleton()->getById($this->paymentProcessorID);
$intentProcessor = new \CRM_Stripe_PaymentIntent($paymentProcessor);
$intentProcessor->setDescription($this->description);
$intentProcessor->setReferrer($_SERVER['HTTP_REFERER'] ?? '');
$intentProcessor->setExtraData($this->extraData ?? '');
......@@ -149,7 +150,7 @@ class ProcessPublic extends \Civi\Api4\Generic\AbstractAction {
$result->exchangeArray($processIntentResult->data);
}
else {
throw new \CRM_Core_Exception($processIntentResult->message);
throw new \CRM_Core_Exception($processIntentResult->message, 0, ['show_detailed_error' => TRUE]);
}
}
......
......@@ -11,20 +11,18 @@
namespace Civi\Api4;
/**
* CiviCRM settings api.
* CiviCRM StripeCharge API
*
* Used to read/write persistent setting data from CiviCRM.
* Used to get info about Stripe Charges
*
* @see \Civi\Core\SettingsBag
* @searchable none
* @since 5.19
* @package Civi\Api4
*/
class StripeCharge extends Generic\AbstractEntity {
/**
* @param bool $checkPermissions
* @return Action\Setting\Get
* @return Action\StripeCharge\GetBalanceTransactionDetails
*/
public static function getBalanceTransactionDetails($checkPermissions = TRUE) {
return (new Action\StripeCharge\GetBalanceTransactionDetails(__CLASS__, __FUNCTION__))
......
......@@ -6,6 +6,7 @@ namespace Civi\Api4;
*
* Provided by the Stripe Payment Processor extension.
*
* @searchable secondary
* @package Civi\Api4
*/
class StripeCustomer extends Generic\DAOEntity {
......
......@@ -6,6 +6,7 @@ namespace Civi\Api4;
*
* Provided by the Stripe extension
*
* @searchable secondary
* @package Civi\Api4
*/
class StripePaymentintent extends Generic\DAOEntity {
......
......@@ -11,12 +11,17 @@
*/
namespace Civi\Stripe;
use Civi\Payment\Exception\PaymentProcessorException;
use CRM_Stripe_ExtensionUtil as E;
class Api {
use \CRM_Core_Payment_MJWIPNTrait;
public function __construct($paymentProcessor) {
$this->_paymentProcessor = $paymentProcessor;
}
/**
* @param string $name The key of the required value
* @param string $dataType The datatype of the required value (eg. String)
......@@ -27,7 +32,34 @@ class Api {
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @throws \Stripe\Exception\ApiErrorException
*/
public function getValueFromStripeObject(string $name, string $dataType, $stripeObject) {
public function getValueFromStripeObject(string $name, string $dataType, $stripeObject, $allowOverride = TRUE) {
if (\Civi::settings()->get('stripe_record_payoutcurrency') && $allowOverride) {
// Intercept amount/currency as we need to use the values from the balancetransaction
if (in_array($name, ['amount', 'currency'])) {
try {
$balanceTransactionDetails = $this->getDetailsFromBalanceTransactionByChargeObject($stripeObject);
switch ($name) {
case 'amount':
if (isset($balanceTransactionDetails['payout_amount'])) {
return $balanceTransactionDetails['payout_amount'];
}
break;
case 'currency':
if (isset($balanceTransactionDetails['payout_currency'])) {
return $balanceTransactionDetails['payout_currency'];
}
break;
}
}
catch (PaymentProcessorException $e) {
\Civi::log('stripe')->warning($this->getPaymentProcessor()->getLogPrefix() . "getValueFromStripeObject($name, $dataType, $stripeObject->object) getDetailsFromBalanceTransaction failed: " . $e->getMessage());
// We allow this to continue with "normal" processing as this feature is experimental and we don't want to break normal workflow
// It means we'll end up with values for amount/currency in the amount charged per normal behaviour.
}
}
}
$value = \CRM_Stripe_Api::getObjectParam($name, $stripeObject);
$value = \CRM_Utils_Type::validate($value, $dataType, FALSE);
return $value;
......@@ -35,45 +67,122 @@ class Api {
/**
* @param string $chargeID
* @param \Stripe\StripeObject $stripeObject
*
* @return array
* @return float[]
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @throws \Stripe\Exception\ApiErrorException
*/
public function getDetailsFromBalanceTransactionByChargeID(string $chargeID): array {
$chargeObject = $this->getPaymentProcessor()->stripeClient->charges->retrieve($chargeID);
$balanceTransactionID = $this->getValueFromStripeObject('balance_transaction', 'String', $chargeObject);
return $this->getDetailsFromBalanceTransaction($balanceTransactionID, $chargeObject);
}
/**
* @param \Stripe\StripeObject $chargeObject
*
* @return float[]
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @throws \Stripe\Exception\ApiErrorException
*/
public function getDetailsFromBalanceTransaction(string $chargeID, $stripeObject = NULL): array {
if ($stripeObject && ($stripeObject['object'] !== 'charge') && (!empty($chargeID))) {
$charge = $this->getPaymentProcessor()->stripeClient->charges->retrieve($chargeID);
$balanceTransactionID = $this->getValueFromStripeObject('balance_transaction', 'String', $charge);
public function getDetailsFromBalanceTransactionByChargeObject($chargeObject): array {
if ($chargeObject && ($chargeObject->object === 'charge')) {
$balanceTransactionID = $this->getValueFromStripeObject('balance_transaction', 'String', $chargeObject);
return $this->getDetailsFromBalanceTransaction($balanceTransactionID, $chargeObject);
}
else {
$balanceTransactionID = $this->getValueFromStripeObject('balance_transaction', 'String', $stripeObject);
// We don't have any way of getting the balance_transaction ID.
throw new \Civi\Payment\Exception\PaymentProcessorException('Cannot call getDetailsFromBalanceTransaction when stripeObject is not of type "charge"');
}
}
/**
* @param string $balanceTransactionID
* @param \Stripe\StripeObject $chargeObject
*
* @return float[]
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @throws \Stripe\Exception\ApiErrorException
*/
public function getDetailsFromBalanceTransaction(string $balanceTransactionID, $chargeObject): array {
if (empty($balanceTransactionID)) {
// This shouldn't be able to happen, but catch it in case it does so we can debug
throw new \Civi\Payment\Exception\PaymentProcessorException('getDetailsFromBalanceTransaction: empty balanceTransactionID!');
}
// We may need to get balance transaction details multiple times when processing.
// The first time we retrieve from stripe but then we use the cached version.
if (isset(\Civi::$statics[__CLASS__][__FUNCTION__]['balanceTransactions'][$balanceTransactionID])) {
return \Civi::$statics[__CLASS__][__FUNCTION__]['balanceTransactions'][$balanceTransactionID];
}
try {
$balanceTransaction = $this->getPaymentProcessor()->stripeClient->balanceTransactions->retrieve($balanceTransactionID);
}
catch (\Exception $e) {
throw new \Civi\Payment\Exception\PaymentProcessorException("Error retrieving balanceTransaction {$balanceTransactionID}. " . $e->getMessage());
}
if (!empty($balanceTransactionID)) {
$fee = $this->getPaymentProcessor()
->getFeeFromBalanceTransaction($balanceTransaction, $this->getValueFromStripeObject('currency', 'String', $stripeObject));
return [
'fee_amount' => $fee,
'available_on' => \CRM_Stripe_Api::formatDate($balanceTransaction->available_on),
'exchange_rate' => $balanceTransaction->exchange_rate,
'charge_amount' => $this->getValueFromStripeObject('amount', 'Float', $stripeObject),
'charge_currency' => $this->getValueFromStripeObject('currency', 'String', $stripeObject),
'payout_amount' => $balanceTransaction->amount / 100,
'payout_currency' => \CRM_Stripe_Api::formatCurrency($balanceTransaction->currency),
];
}
else {
return [
'fee_amount' => 0.0
];
$chargeCurrency = $this->getValueFromStripeObject('currency', 'String', $chargeObject, FALSE);
$chargeFee = $this->getPaymentProcessor()->getFeeFromBalanceTransaction($balanceTransaction, $chargeCurrency);
\Civi::$statics[__CLASS__][__FUNCTION__]['balanceTransactions'][$balanceTransactionID] = [
'fee_amount' => \Civi::settings()->get('stripe_record_payoutcurrency') ? $balanceTransaction->fee / 100 : $chargeFee,
'available_on' => \CRM_Stripe_Api::formatDate($balanceTransaction->available_on),
'exchange_rate' => $balanceTransaction->exchange_rate,
'charge_amount' => $this->getValueFromStripeObject('amount', 'Float', $chargeObject, FALSE),
'charge_currency' => $chargeCurrency,
'charge_fee' => $chargeFee,
'payout_amount' => $balanceTransaction->amount / 100,
'payout_currency' => \CRM_Stripe_Api::formatCurrency($balanceTransaction->currency),
'payout_fee' => $balanceTransaction->fee / 100,
];
return \Civi::$statics[__CLASS__][__FUNCTION__]['balanceTransactions'][$balanceTransactionID];
}
/**
* @param string $subscriptionID
* @param array $itemsData
* Array of \Stripe\SubscriptionItem
*
* @return array
* @throws \CRM_Core_Exception
* @throws \Civi\Payment\Exception\PaymentProcessorException
* @throws \Stripe\Exception\ApiErrorException
*/
public function calculateItemsForSubscription(string $subscriptionID, array $itemsData) {
$calculatedItems = [];
// Recalculate amount and update
foreach ($itemsData as $item) {
$subscriptionItem['subscriptionItemID'] = $this->getValueFromStripeObject('id', 'String', $item);
$subscriptionItem['quantity'] = $this->getValueFromStripeObject('quantity', 'Int', $item);
$subscriptionItem['unit_amount'] = $this->getValueFromStripeObject('unit_amount', 'Float', $item->price);
$calculatedItem['currency'] = $this->getValueFromStripeObject('currency', 'String', $item->price);
$calculatedItem['amount'] = $subscriptionItem['unit_amount'] * $subscriptionItem['quantity'];
if ($this->getValueFromStripeObject('type', 'String', $item->price) === 'recurring') {
$calculatedItem['frequency_unit'] = $this->getValueFromStripeObject('recurring_interval', 'String', $item->price);
$calculatedItem['frequency_interval'] = $this->getValueFromStripeObject('recurring_interval_count', 'Int', $item->price);
}
if (empty($calculatedItem['frequency_unit'])) {
\Civi::log('stripe')->warning("StripeIPN: {$subscriptionID} customer.subscription.updated:
Non recurring subscription items are not supported");
}
else {
$intervalKey = $calculatedItem['currency'] . '_' . $calculatedItem['frequency_unit'] . '_' . $calculatedItem['frequency_interval'];
if (isset($calculatedItems[$intervalKey])) {
// If we have more than one subscription item with the same currency and frequency add up the amounts and combine.
$calculatedItem['amount'] += ($calculatedItems[$intervalKey]['amount'] ?? 0);
$calculatedItem['subscriptionItem'] = $calculatedItems[$intervalKey]['subscriptionItem'];
}
$calculatedItem['subscriptionItem'][] = $subscriptionItem;
$calculatedItems[$intervalKey] = $calculatedItem;
}
}
return $calculatedItems;
}
}
This diff is collapsed.