Version 4.7.13

This commit is contained in:
Neo committed 2025-12-19 16:14:56 -08:00
1 parent fd22d99e69
commit 7124a02564
954 files changed
+29277 -59308

No files matched your search

@@ -85,7 +85,7 @@ class _CreditCard extends \IPS\nexus\Customer\CreditCard
$cardType = 'amex';
break;
}
/* Check it doesn't already exist */
$otherCards = \IPS\Db::i()->select( 'card_data', 'nexus_customer_cards', array( 'card_member=? AND card_method=?', $this->member->member_id, $this->method->id ) );
if ( \count( $otherCards ) )
@@ -99,7 +99,7 @@ class _CreditCard extends \IPS\nexus\Customer\CreditCard
}
}
}
/* Save */
$response = $this->method->api( 'vault/credit-card', array(
'number' => $card->number,
@@ -94,7 +94,11 @@ class _Exception extends \DomainException
break;
default:
if ( $refund )
if( isset( $details ) AND $details[0]['issue'] === 'INSTRUMENT_DECLINED' )
{
$message = \IPS\Member::loggedIn()->language()->get( 'payment_refused' );
}
elseif ( $refund )
{
$message = \IPS\Member::loggedIn()->language()->get( 'refund_failed' );
}
@@ -40,13 +40,13 @@ class _PayPal extends \IPS\nexus\Gateway
public function checkValidity( \IPS\nexus\Money $amount, \IPS\GeoLocation $billingAddress = NULL, \IPS\nexus\Customer $customer = NULL, $recurrings = array() )
{
$settings = json_decode( $this->settings, TRUE );
/* Card payments require name and billing address */
if ( isset( $settings['type'] ) and $settings['type'] === 'card' and ( !$customer->cm_first_name or !$customer->cm_last_name or !$billingAddress ) )
{
return FALSE;
}
if ( isset( $settings['type'] ) and $settings['type'] === 'card' and ( !$customer->cm_first_name or !$customer->cm_last_name or !$billingAddress ) )
{
return FALSE;
}
/* Check transaction limit */
switch ( $amount->currency )
{
@@ -207,6 +207,22 @@ class _PayPal extends \IPS\nexus\Gateway
/* Pass to parent */
return parent::checkValidity( $amount, $billingAddress, $customer, $recurrings );
}
/**
* [Node] Get Node Description
*
* @return string|null
*/
protected function get__description()
{
$settings = json_decode( $this->settings, TRUE );
if( isset( $settings['type'] ) and $settings['type'] === 'card' )
{
return \IPS\Member::loggedIn()->language()->addToStack( 'gateway_deprecated', FALSE, array( 'sprintf' => 'PayPal Credit Card' ) );
}
return null;
}
/**
* Can store cards?
@@ -258,6 +274,7 @@ class _PayPal extends \IPS\nexus\Gateway
public function paymentScreen( \IPS\nexus\Invoice $invoice, \IPS\nexus\Money $amount, \IPS\nexus\Customer $member = NULL, $recurrings = array(), $type = 'checkout' )
{
$settings = json_decode( $this->settings, TRUE );
if ( isset( $settings['type'] ) and $settings['type'] === 'card' )
{
return array( 'card' => new \IPS\nexus\Form\CreditCard( $this->id . '_card', NULL, TRUE, array(
@@ -285,12 +302,13 @@ class _PayPal extends \IPS\nexus\Gateway
* @throws \LogicException Message will be displayed to user
*/
public function auth( \IPS\nexus\Transaction $transaction, $values, \IPS\nexus\Fraud\MaxMind\Request $maxMind = NULL, $recurrings = array(), $source = NULL )
{
{
/* We need a transaction ID */
$transaction->save();
/* Do it */
$settings = json_decode( $this->settings, TRUE );
if ( isset( $settings['type'] ) and $settings['type'] === 'card' )
{
return $this->_cardAuth( \is_array( $values ) ? $values[ $this->id . '_card' ] : $values, $transaction, $maxMind );
@@ -306,24 +324,25 @@ class _PayPal extends \IPS\nexus\Gateway
return $this->_billingAgreementAuth( $transaction, $maxMind, $recurrance['term'], $recurrance['items'] );
}
else
{
{
return $this->_paypalAuth( $transaction, $maxMind );
}
}
}
/**
* Authorize Card Payment
*
* @param \IPS\nexus\CreditCard|\IPS\nexus\Customer\CreditCard $card The card to charge
* @param \IPS\nexus\Transaction $transaction Transaction
* @param \IPS\nexus\Fraud\MaxMind\Request|NULL $maxMind *If* MaxMind is enabled, the request object will be passed here so gateway can additional data before request is made
* @param \IPS\nexus\Fraud\MaxMind\Request|NULL $maxMind *If* MaxMind is enabled, the request object will be passed here so gateway can additional data before request is made
* @param string|NULL $source 'checkout' if the customer is doing this at a normal checkout, 'renewal' is an automatically generated renewal invoice, 'manual' is admin manually charging. NULL is unknown
* @return \IPS\DateTime|NULL Auth is valid until or NULL to indicate auth is good forever
* @throws \LogicException Message will be displayed to user
*/
protected function _cardAuth( $card, \IPS\nexus\Transaction $transaction, \IPS\nexus\Fraud\MaxMind\Request $maxMind = NULL )
protected function _cardAuth( $card, \IPS\nexus\Transaction $transaction, \IPS\nexus\Fraud\MaxMind\Request $maxMind = NULL, ?string $source = NULL )
{
/* Stored Card */
/* Stored Card */
if ( $card instanceof \IPS\nexus\Customer\CreditCard )
{
$payer = array(
@@ -346,7 +365,7 @@ class _PayPal extends \IPS\nexus\Gateway
\IPS\Session::i()->setMember( $transaction->member );
\IPS\Member\Device::loadOrCreate( $transaction->member, FALSE )->updateAfterAuthentication( NULL );
}
if ( $maxMind )
{
$maxMind->setCard( $card );
@@ -380,23 +399,31 @@ class _PayPal extends \IPS\nexus\Gateway
'cvv2' => $card->ccv,
'first_name' => $this->_getFirstName( $transaction ),
'last_name' => $this->_getLastName( $transaction ),
'billing_address' => $this->_getAddress( $transaction->invoice->billaddress, $transaction->member )
'billing_address' => $this->_getAddress( $transaction->invoice->billaddress, $transaction->member, 'card' )
)
),
)
);
}
/* Send the request */
$response = $this->api( 'payments/payment', array(
'intent' => 'authorize',
'payer' => $payer,
'transactions' => array( $this->_getTransactions( $transaction ) ),
'redirect_urls' => array(
'return_url' => \IPS\Settings::i()->base_url . 'applications/nexus/interface/gateways/paypal.php?nexusTransactionId=' . $transaction->id,
'cancel_url' => (string) $transaction->invoice->checkoutUrl(),
)
) );
try
{
/* Send the request */
$response = $this->api( 'payments/payment', array(
'intent' => 'authorize',
'payer' => $payer,
'transactions' => array( $this->_getTransactions( $transaction ) ),
'redirect_urls' => array(
'return_url' => \IPS\Settings::i()->base_url . 'applications/nexus/interface/gateways/paypal.php?nexusTransactionId=' . $transaction->id,
'cancel_url' => (string) $transaction->invoice->checkoutUrl(),
)
) );
}
catch( \IPS\nexus\Gateway\PayPal\Exception $e )
{
$this->processException( $transaction, $e );
throw $e;
}
/* Set transaction data */
$transaction->gw_id = $response['transactions'][0]['related_resources'][0]['authorization']['id']; // The transaction ID for the authorization. At capture, it will be updated again to the capture transaction ID
@@ -430,15 +457,32 @@ class _PayPal extends \IPS\nexus\Gateway
protected function _paypalAuth( \IPS\nexus\Transaction $transaction, \IPS\nexus\Fraud\MaxMind\Request $maxMind = NULL )
{
/* Send the request */
$response = $this->api( 'payments/payment', array(
'intent' => 'authorize',
'payer' => array( 'payment_method' => 'paypal' ),
'transactions' => array( $this->_getTransactions( $transaction ) ),
'redirect_urls' => array(
'return_url' => \IPS\Settings::i()->base_url . 'applications/nexus/interface/gateways/paypal.php?nexusTransactionId=' . $transaction->id,
'cancel_url' => (string) $transaction->invoice->checkoutUrl(),
)
) );
try
{
$response = $this->api( 'checkout/orders', array(
'intent' => 'AUTHORIZE',
'purchase_units' => $this->_getPurchaseUnits( $transaction ),
'payment_source' => array(
'paypal' => array(
'experience_context' => array(
'return_url' => \IPS\Settings::i()->base_url . 'applications/nexus/interface/gateways/paypal.php?nexusTransactionId=' . $transaction->id,
'cancel_url' => (string) $transaction->invoice->checkoutUrl(),
),
'email_address' => $transaction->member->email,
'name' => array(
'given_name' => $this->_getFirstName( $transaction ),
'surname' => $this->_getLastName( $transaction )
),
'address' => ( $transaction->invoice->billaddress instanceof \IPS\GeoLocation ) ? $this->_getAddress( $transaction->invoice->billaddress, $transaction->member ) : NULL,
)
)
), 'POST', true, null, md5( (string) $transaction->invoice->checkoutUrl() . ';' . $transaction->id ), 2 );
}
catch( \IPS\nexus\Gateway\PayPal\Exception $e )
{
$this->processException( $transaction, $e );
throw $e;
}
/* Set transaction data */
$transaction->gw_id = $response['id']; // This is a payment ID ("PAY-XXX"). At this time we do not have a real transaction ID
@@ -447,11 +491,12 @@ class _PayPal extends \IPS\nexus\Gateway
/* Redirect */
foreach ( $response['links'] as $link )
{
if ( $link['rel'] === 'approval_url' )
if ( $link['rel'] === 'payer-action' )
{
\IPS\Output::i()->redirect( \IPS\Http\Url::external( $link['href'] ) );
}
}
throw new \RuntimeException;
}
@@ -471,10 +516,17 @@ class _PayPal extends \IPS\nexus\Gateway
/* Work out the name */
$titles = array();
$initialTerm = null;
foreach ( $items as $item )
{
$titles[] = ( $item->name . ( $item->quantity > 1 ? " x{$item->quantity}" : '' ) );
if( isset( $item->initialInterval ) AND $item->initialInterval instanceof \DateInterval )
{
$initialTerm = $item->initialInterval;
}
}
$title = implode( ', ', $titles );
if ( mb_strlen( $title ) > 127 )
{
@@ -488,49 +540,51 @@ class _PayPal extends \IPS\nexus\Gateway
'description' => $description,
'type' => 'DIGITAL',
) );
/* Create a plan */
/* Set up the billing cycles */
$billingCycles = array();
$sequence = 1;
$renewalAmountIncludingTax = $term->cost->amount->multiply( new \IPS\Math\Number( number_format( $term->tax ? ( ( 1 + $term->tax->rate( $transaction->invoice->billaddress ) ) ) : 1, 4, '.', '' ) ) );
$billingCycle = array(
/* If we have an initial term that is different from the renewal, start with that */
if( $initialTerm instanceof \DateInterval OR $transaction->amount->amount->compare( $renewalAmountIncludingTax ) !== 0 )
{
$intervalToUse = $initialTerm instanceof \DateInterval ? $initialTerm : $term->interval;
$billingCycles[] = array(
'pricing_scheme' => array(
'fixed_price' => array(
'currency_code' => $transaction->amount->currency,
'value' => $transaction->amount->amountAsString()
),
),
'frequency' => static::_getFrequencyFromInterval( $intervalToUse ),
'tenure_type' => 'TRIAL',
'sequence' => $sequence,
'total_cycles' => 1
);
$sequence++;
}
/* And now the regular renewals */
$billingCycles[] = array(
'pricing_scheme' => array(
'fixed_price' => array(
'currency_code' => $term->cost->currency,
'value' => ( new \IPS\nexus\Money( $renewalAmountIncludingTax, $term->cost->currency ) )->amountAsString()
),
),
'frequency' => array(),
'frequency' => static::_getFrequencyFromInterval( $term->interval ),
'tenure_type' => 'REGULAR',
'sequence' => 1,
'sequence' => $sequence,
'total_cycles' => 0
);
if ( $term->interval->y )
{
$billingCycle['frequency']['interval_unit'] = 'YEAR';
$billingCycle['frequency']['interval_count'] = $term->interval->y;
}
elseif ( $term->interval->m )
{
$billingCycle['frequency']['interval_unit'] = 'MONTH';
$billingCycle['frequency']['interval_count'] = $term->interval->m;
}
elseif ( $term->interval->d )
{
if ( $term->interval->d % 7 == 0 )
{
$billingCycle['frequency']['interval_unit'] = 'WEEK';
$billingCycle['frequency']['interval_count'] = $term->interval->d / 7;
}
else
{
$billingCycle['frequency']['interval_unit'] = 'DAY';
$billingCycle['frequency']['interval_count'] = $term->interval->d;
}
}
/* Create a plan */
$planDetails = array(
'product_id' => $product['id'],
'name' => $title,
'description' => $description,
'billing_cycles' => array(),
'billing_cycles' => $billingCycles,
'payment_preferences' => array(
'auto_bill_outstanding' => FALSE,
'payment_failure_threshold' => \intval( $settings['billing_agreement_allowed_fails'] )
@@ -541,28 +595,7 @@ class _PayPal extends \IPS\nexus\Gateway
),
'quantity_supported' => FALSE
);
if ( $transaction->amount->amount->compare( $renewalAmountIncludingTax ) === 0 )
{
$planDetails['billing_cycles'][] = $billingCycle;
}
else
{
$planDetails['billing_cycles'][] = array(
'pricing_scheme' => array(
'fixed_price' => array(
'currency_code' => $transaction->amount->currency,
'value' => $transaction->amount->amountAsString()
),
),
'frequency' => $billingCycle['frequency'],
'tenure_type' => 'TRIAL',
'sequence' => 1,
'total_cycles' => 1
);
$billingCycle['sequence'] = 2;
$planDetails['billing_cycles'][] = $billingCycle;
}
$plan = $this->api( 'billing/plans', $planDetails );
/* Create a subscription */
@@ -611,12 +644,18 @@ class _PayPal extends \IPS\nexus\Gateway
*/
public function void( \IPS\nexus\Transaction $transaction )
{
/* If this is is the intitial transaction for a billing agreement which hasn't
/* If this is the initial transaction for a billing agreement which hasn't
been processed yet, cancel the billing agreement. If it's a subscription it may still be the first payment */
if ( $transaction->status === $transaction::STATUS_GATEWAY_PENDING and $transaction->billing_agreement )
{
$transaction->billing_agreement->cancel();
}
/* If the transaction is not in a 'hold' state, it cannot be voided and must be refunded instead */
if( !\in_array( $transaction->status, array( $transaction::STATUS_HELD, $transaction::STATUS_REVIEW ) ) || !$transaction->auth )
{
return $this->refund( $transaction );
}
/* Try to find the authorization ID */
if ( mb_substr( $transaction->gw_id, 0, 4 ) === 'PAY-' )
@@ -646,33 +685,26 @@ class _PayPal extends \IPS\nexus\Gateway
}
else
{
$authId = $transaction->gw_id;
if( $transaction->billing_agreement )
{
try
{
$sale = $this->api( "payments/sale/{$transaction->gw_id}", NULL, 'get' );
return $this->refund( $transaction ); // "Sales" came from Billing Agreements and have already been captured
return $this->refund( $transaction );
}
catch ( \IPS\nexus\Gateway\PayPal\Exception $e )
{
$authId = $transaction->gw_id;
}
}
else
{
$authId = $transaction->gw_id;
catch ( \IPS\nexus\Gateway\PayPal\Exception $e ){}
}
}
/* Void it */
return $this->api( "payments/authorization/{$authId}/void" );
return $this->api( "payments/authorizations/{$authId}/void", null, 'POST', TRUE, NULL, NULL, 2 );
}
/**
* Capture
*
* @param \IPS\nexus\Transaction $transaction Transaction
* @return void
* @return bool
* @throws \LogicException
*/
public function capture( \IPS\nexus\Transaction $transaction )
@@ -757,45 +789,34 @@ class _PayPal extends \IPS\nexus\Gateway
}
else
{
$authId = $transaction->gw_id;
if( $transaction->billing_agreement )
{
try
{
$sale = $this->api( "payments/sale/{$transaction->gw_id}", NULL, 'get' );
$sale = $this->api( "payments/authorizations/{$authId}", NULL, 'get', TRUE, NULL, NULL, 2 );
return TRUE; // "Sales" came from Billing Agreements and have already been captured
}
catch ( \IPS\nexus\Gateway\PayPal\Exception $e )
{
$authId = $transaction->gw_id;
}
}
else
{
$authId = $transaction->gw_id;
catch ( \IPS\nexus\Gateway\PayPal\Exception $e ){}
}
}
/* Capture it */
try
{
$response = $this->api( "payments/authorization/{$authId}/capture", array(
'amount' => array(
'currency' => $transaction->amount->currency,
'total' => $transaction->amount->amountAsString(),
),
'is_final_capture' => TRUE,
) );
$response = $this->api( "payments/authorizations/{$authId}/capture", NULL, 'POST', TRUE, NULL, NULL, 2 );
$transaction->gw_id = $response['id']; // We now set the gateway ID to the capture ID
$transaction->save();
}
catch ( \IPS\nexus\Gateway\PayPal\Exception $e )
{
if ( $e->getName() == 'AUTHORIZATION_ALREADY_COMPLETED' )
if ( $e->getName() == 'ORDER_ALREADY_AUTHORIZED' )
{
return TRUE;
}
throw $e;
}
return TRUE;
}
@@ -816,7 +837,7 @@ class _PayPal extends \IPS\nexus\Gateway
$amount = $amount ? new \IPS\nexus\Money( $amount, $transaction->currency ) : $transaction->amount;
/* If it's a billing agreement and the gateway ID isn't prefixed with an I, it's likely to be a subscription */
if( $transaction->billing_agreement AND $transaction->gw_id AND mb_substr( $transaction->gw_id, 0, 2 ) !== 'I-' )
if( $transaction->billing_agreement AND $transaction->gw_id AND mb_substr( $transaction->billing_agreement->gw_id, 0, 2 ) !== 'I-' )
{
$response = $this->api( "payments/sale/{$transaction->gw_id}/refund", array( 'amount' => array(
'currency' => $amount->currency,
@@ -855,10 +876,13 @@ class _PayPal extends \IPS\nexus\Gateway
}
/* Process Refund */
$response = $this->api( "payments/capture/{$captureId}/refund", array( 'amount' => array(
'currency' => $amount->currency,
'total' => $amount->amountAsString()
) ) );
$response = $this->api( "payments/captures/{$captureId}/refund", array(
'amount' => array(
'currency_code' => $amount->currency,
'value' => $amount->amountAsString()
),
'invoice_id' => $transaction->invoice->id
), 'POST', TRUE, NULL, NULL, 2 );
return $response['id'];
}
@@ -884,9 +908,10 @@ class _PayPal extends \IPS\nexus\Gateway
public function settings( &$form )
{
$settings = json_decode( $this->settings, TRUE );
if ( isset( $settings['type'] ) and $settings['type'] === 'card' )
{
$form->addMessage( \IPS\Member::loggedIn()->language()->addToStack( 'gateway_deprecated', FALSE, array( 'sprintf' => 'PayPal Credit Card' ) ), 'ipsMessage ipsMessage_warning' );
$form->add( new \IPS\Helpers\Form\Radio( 'paypal_type', $settings['type'], TRUE, array( 'options' => array( 'paypal' => 'paypal_type_paypal', 'card' => 'paypal_type_card' ), 'toggles' => array( 'paypal' => array( 'paypal_billing_agreements' ), 'card' => array( 'paypal_vault' ) ) ) ) );
}
@@ -911,12 +936,12 @@ class _PayPal extends \IPS\nexus\Gateway
}, NULL, NULL, 'paypal_billing_agreements' ) );
$form->add( new \IPS\Helpers\Form\Number( 'paypal_billing_agreement_allowed_fails', ( $this->id AND isset( $settings['billing_agreement_allowed_fails'] ) ) ? $settings['billing_agreement_allowed_fails'] : 0, FALSE, array( 'unlimited' => 0, 'min' => 1 ), NULL, \IPS\Member::loggedIn()->language()->addToStack('paypal_billing_agreement_allowed_fails_prefix'), \IPS\Member::loggedIn()->language()->addToStack('paypal_billing_agreement_allowed_fails_suffix'), 'paypal_billing_agreement_allowed_fails' ) );
if ( isset( $settings['type'] ) and $settings['type'] === 'card' )
{
$form->add( new \IPS\Helpers\Form\YesNo( 'paypal_vault', ( $this->id and isset( $settings['vault'] ) ) ? $settings['vault'] : TRUE, FALSE, array(), NULL, NULL, NULL, 'paypal_vault' ) );
}
$form->add( new \IPS\Helpers\Form\Text( 'paypal_client_id', $settings['client_id'], TRUE ) );
$form->add( new \IPS\Helpers\Form\Text( 'paypal_secret', $settings['secret'], TRUE ) );
}
@@ -989,11 +1014,13 @@ class _PayPal extends \IPS\nexus\Gateway
* @param array $data The data to send
* @param string $method Method (get/post)
* @param array|NULL $settings Settings (NULL for saved setting)
* @param string|Null $requestId
* @param int|Null $version API Version
* @return array|null
* @throws \IPS\Http|Exception
* @throws \IPS\nexus\Gateway\PayPal\Exception
*/
public function api( $uri, $data=NULL, $method='post', $expectResponse=TRUE, $settings=NULL )
public function api( $uri, $data=NULL, $method='post', $expectResponse=TRUE, $settings=NULL, $requestId=NULL, $version=NULL )
{
if ( !$settings )
{
@@ -1007,14 +1034,18 @@ class _PayPal extends \IPS\nexus\Gateway
$this->save();
}
}
/* Some API calls (e.g. for billing agreements) still use v1 for the REST APIs */
$version = $version ?: 1;
$response = \IPS\Http\Url::external( 'https://' . ( \IPS\NEXUS_TEST_GATEWAYS ? 'api.sandbox.paypal.com' : 'api.paypal.com' ) . '/v1/' . $uri )
$response = \IPS\Http\Url::external( 'https://' . ( \IPS\NEXUS_TEST_GATEWAYS ? 'api-m.sandbox.paypal.com' : 'api-m.paypal.com' ) . '/v' . $version . '/' . $uri )
->request( \IPS\LONG_REQUEST_TIMEOUT )
->forceTls()
->setHeaders( array(
'Content-Type' => 'application/json',
'Authorization' => "Bearer {$settings['token']}",
'PayPal-Partner-Attribution-Id' => 'InvisionPower_SP'
'PayPal-Partner-Attribution-Id' => 'InvisionPower_SP',
'PayPal-Request-Id' => $requestId
) )
->$method( $data === NULL ? NULL : json_encode( $data ) );
@@ -1038,14 +1069,14 @@ class _PayPal extends \IPS\nexus\Gateway
*
* @param array|NULL $settings Settings (NULL for saved setting)
* @return array
* @throws \IPS\Http|Exception
* @throws \IPS\Http\Request\Exception
* @throws \UnexpectedValueException
*/
protected function getNewToken( $settings = NULL )
{
$settings = $settings ?: json_decode( $this->settings, TRUE );
$response = \IPS\Http\Url::external( 'https://' . ( \IPS\NEXUS_TEST_GATEWAYS ? 'api.sandbox.paypal.com' : 'api.paypal.com' ) . '/v1/oauth2/token' )
$response = \IPS\Http\Url::external( 'https://' . ( \IPS\NEXUS_TEST_GATEWAYS ? 'api-m.sandbox.paypal.com' : 'api-m.paypal.com' ) . '/v1/oauth2/token' )
->request()
->forceTls()
->setHeaders( array(
@@ -1063,14 +1094,63 @@ class _PayPal extends \IPS\nexus\Gateway
return $response;
}
/**
* Handle the PayPal exception and mark the transaction as failed
*
* @param \IPS\nexus\Transaction $transaction
* @param \Exception $e
* @return void
*/
public function processException( \IPS\nexus\Transaction $transaction, \Exception $e )
{
/* Make sure it's a PayPal exception only. The interface might send us a different exception type */
if( ! ( $e instanceof \IPS\nexus\Gateway\PayPal\Exception ) )
{
return;
}
/* Mark the transaction as failed */
$transaction->status = \IPS\nexus\Transaction::STATUS_REFUSED;
$details = json_decode( $e->extraLogData(), true );
/* Log the error message to the transaction history */
$extra = $transaction->extra;
$extra['history'][] = array( 's' => \IPS\nexus\Transaction::STATUS_REFUSED, 'on' => time(), 'noteRaw' => ( $details['details'][0]['description'] ?? $e->getMessage() ) );
if( isset( $details['processor_response'] ) )
{
$extra['processor_response'] = $details['processor_response'];
// log this failure to the customer history
$responseCode = $details['processor_response']['response_code'];
if( \is_numeric( $responseCode ) )
{
$responseCode = (int) $responseCode;
}
if( $transaction->member->language()->checkKeyExists( 'processor_response_code__' . $responseCode ) )
{
$failureReason = $transaction->member->language()->get( 'processor_response_code__' . $responseCode );
$transaction->member->logHistory( 'nexus', 'custom', array(
'message' => \sprintf( $transaction->member->language()->get( 'history_payment_rejected' ), $failureReason )
) );
}
}
$transaction->extra = $extra;
$transaction->save();
}
/**
* Get address for PayPal
*
* @param \IPS\nexus\Transaction $transaction Transaction
* @param \IPS\nexus\Customer $customer
* @param string $paymentType
* @return array
*/
protected function _getAddress( \IPS\GeoLocation $address, \IPS\nexus\Customer $customer )
protected function _getAddress( \IPS\GeoLocation $address, \IPS\nexus\Customer $customer, $paymentType='paypal' )
{
/* PayPal requires short codes for states */
$state = $address->region;
@@ -1085,21 +1165,41 @@ class _PayPal extends \IPS\nexus\Gateway
}
}
}
/* Construct */
$address = array(
'line1' => $address->addressLines[0],
'line2' => isset( $address->addressLines[1] ) ? $address->addressLines[1] : '',
'city' => $address->city,
'country_code' => $address->country,
'postal_code' => $address->postalCode,
'state' => $state,
);
/* Add phone number */
if ( $customer->cm_phone )
if( $paymentType == 'card' )
{
$address['phone'] = preg_replace( '/[^\+0-9\s]/', '', $customer->cm_phone );
/* Construct */
$address = array(
'line1' => $address->addressLines[0],
'line2' => isset( $address->addressLines[1] ) ? $address->addressLines[1] : '',
'city' => $address->city,
'country_code' => $address->country,
'postal_code' => $address->postalCode,
'state' => $state,
);
/* Add phone number */
if ( $customer->cm_phone )
{
$address['phone'] = preg_replace( '/[^\+0-9\s]/', '', $customer->cm_phone );
}
}
else
{
/* Construct */
$address = array(
'address_line_1' => $address->addressLines[0],
'address_line_2' => isset( $address->addressLines[1] ) ? $address->addressLines[1] : '',
'admin_area_2' => $address->city,
'country_code' => $address->country,
'postal_code' => $address->postalCode,
'admin_area_1' => $state,
);
if( empty( $address['address_line_2'] ) )
{
unset( $address['address_line_2'] );
}
}
/* Return */
@@ -1110,7 +1210,7 @@ class _PayPal extends \IPS\nexus\Gateway
* Get first name for PayPal
*
* @param \IPS\nexus\Transaction $transaction Transaction
* @return array
* @return string
*/
protected function _getFirstName( \IPS\nexus\Transaction $transaction )
{
@@ -1121,12 +1221,102 @@ class _PayPal extends \IPS\nexus\Gateway
* Get last name for PayPal
*
* @param \IPS\nexus\Transaction $transaction Transaction
* @return array
* @return string
*/
protected function _getLastName( \IPS\nexus\Transaction $transaction )
{
return $transaction->invoice->member->member_id ? $transaction->invoice->member->cm_last_name : $transaction->invoice->guest_data['member']['cm_last_name'];
}
/**
* Get transaction data for PayPal
*
* @param \IPS\nexus\Transaction $transaction Transaction
* @return array
*/
protected function _getPurchaseUnits( \IPS\nexus\Transaction $transaction )
{
/* Init */
$payPalTransactionData = array(
'amount' => array(
'currency_code' => $transaction->amount->currency,
'value' => $transaction->amount->amountAsString(),
),
'custom_id'=> \IPS\Settings::i()->site_secret_key . '-' . $transaction->id,
'invoice_id' => $transaction->invoice->id,
'items' => array()
);
/* If we're paying the whole invoice, we can add item data... */
if ( $transaction->amount->amount->compare( $transaction->invoice->total->amount ) === 0 )
{
$summary = $transaction->invoice->summary();
/* Shipping / Tax */
$payPalTransactionData['amount']['breakdown'] = array(
'shipping' => array( 'currency_code' => $summary['shippingTotal']->currency, 'value' => $summary['shippingTotal']->amountAsString() ),
'item_total' => array( 'currency_code' => $summary['subtotal']->currency, 'value' => $summary['subtotal']->amountAsString() ),
'tax_total' => array( 'currency_code' => $summary['taxTotal']->currency, 'value' => $summary['taxTotal']->amountAsString() ),
'discount' => array( 'currency_code' => $summary['discount']->currency, 'value' => $summary['discount']->amountAsString() )
);
/* Items */
$itemTotal = new \IPS\Math\Number( '0' );
foreach ( $summary['items'] as $item )
{
if( $item instanceof \IPS\nexus\extensions\nexus\Item\CouponDiscount )
{
continue;
}
$itemCategory = 'DIGITAL_GOODS';
if( $item->physical )
{
$itemCategory = 'PHYSICAL_GOODS';
}
elseif( $item instanceof \IPS\nexus\extensions\nexus\Item\Donation )
{
$itemCategory = 'DONATION';
}
$itemData = array(
'name' => mb_strlen( $item->name ) > 127 ? mb_substr( $item->name, 0, 124 ) . '...' : $item->name,
'quantity' => $item->quantity,
'unit_amount' => array(
'currency_code' => $item->price->currency,
'value' => $item->price->amountAsString()
),
'category' => $itemCategory
);
$itemTotal = $itemTotal->add( $item->price->amount->multiply( new \IPS\Math\Number("{$item->quantity}") ) );
$payPalTransactionData['items'][] = $itemData;
}
/* PayPal requires that the item total be equal to the pre-discounted amounts. The summary uses the invoice total, which is not accepted. */
$itemTotal = new \IPS\nexus\Money( $itemTotal, $transaction->amount->currency );
$payPalTransactionData['amount']['breakdown']['item_total']['value'] = $itemTotal->amountAsString();
/* Shipping Address */
if ( $transaction->invoice->shipaddress )
{
$payPalTransactionData['shipping'] = array(
'address' => $this->_getAddress( $transaction->invoice->shipaddress, $transaction->member ),
'name' => array(
'full_name' => $this->_getFirstName( $transaction ) . ' ' . $this->_getLastName( $transaction )
),
'type' => 'SHIPPING'
);
}
}
/* Otherwise just use a generic description */
else
{
$payPalTransactionData['description'] = sprintf( $transaction->member->language()->get('partial_payment_desc'), $transaction->invoice->id );
}
return array( $payPalTransactionData );
}
/**
* Get transaction data for PayPal
@@ -1174,7 +1364,7 @@ class _PayPal extends \IPS\nexus\Gateway
{
$payPalTransactionData['item_list']['shipping_address'] = array_merge(
array( 'recipient_name' => $this->_getFirstName( $transaction ) . ' ' . $this->_getLastName( $transaction ) ),
$this->_getAddress( $transaction->invoice->shipaddress, $transaction->invoice->member )
$this->_getAddress( $transaction->invoice->shipaddress, $transaction->invoice->member, 'card' )
);
}
}
@@ -1190,24 +1380,86 @@ class _PayPal extends \IPS\nexus\Gateway
/**
* Can we handle the renewal terms in a billing agreement?
*
* @param array $recurrings Details about recurring costs
* @return array
* @param array $recurrings Details about recurring costs
* @return bool
*/
protected static function _canProcessRecurringsAsBillingAgreement( array $recurrings )
{
if ( \count( $recurrings ) == 1 )
if( \count( $recurrings ) == 1 )
{
$recurrance = array_pop( $recurrings );
/* We need to allow for some minor deviation than checking for the exact second, this can happen if the request is at just the exact time where
the clock ticks over to the next second */
$difference = \IPS\DateTime::create()->add( $recurrance['term']->interval )->getTimestamp() - $recurrance['dueDate']->getTimestamp();
if ( $difference >= 0 AND $difference < 10 )
/* If we only have one item, we're fine */
if( \count( $recurrance['items'] ) == 1 )
{
return TRUE;
return true;
}
/* Make sure all the terms match */
$initialTermFrequency = null;
foreach( $recurrance['items'] as $item )
{
if( isset( $item->initialInterval ) AND $item->initialInterval instanceof \DateInterval )
{
$thisTermFrequency = static::_getFrequencyFromInterval( $item->initialInterval );
if( $initialTermFrequency !== null AND ( $thisTermFrequency['interval_unit'] != $initialTermFrequency['interval_unit'] OR $thisTermFrequency['interval_count'] != $initialTermFrequency['interval_count'] ) )
{
return false;
}
$initialTermFrequency = $thisTermFrequency;
}
elseif( $initialTermFrequency !== null )
{
return false;
}
}
return true;
}
return FALSE;
return false;
}
/**
* Get the billing cycle frequency based on the DateInterval
*
* @param \DateInterval $term
* @return array|null
*/
protected static function _getFrequencyFromInterval( \DateInterval $term )
{
if( $term->y )
{
return array(
'interval_unit' => 'YEAR',
'interval_count' => $term->y
);
}
if( $term->m )
{
return array(
'interval_unit' => 'MONTH',
'interval_count' => $term->m
);
}
if( $term->d )
{
if( $term->d % 7 == 0 )
{
return array(
'interval_unit' => 'WEEK',
'interval_count' => $term->d / 7
);
}
return array(
'interval_unit' => 'DAY',
'interval_count' => $term->d
);
}
return null;
}
}
@@ -12,6 +12,9 @@
namespace IPS\nexus\Gateway\PayPal;
/* To prevent PHP errors (extending class does not exist) revealing path */
use IPS\Settings;
if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
@@ -33,9 +36,8 @@ class _Payout extends \IPS\nexus\Payout
$settings = json_decode( \IPS\Settings::i()->nexus_payout, TRUE );
$return = array();
$return[] = new \IPS\Helpers\Form\Text( 'paypal_api_username', isset( $settings['PayPal'] ) ? $settings['PayPal']['api_username'] : '' );
$return[] = new \IPS\Helpers\Form\Text( 'paypal_api_password', isset( $settings['PayPal'] ) ? $settings['PayPal']['api_password'] : '' );
$return[] = new \IPS\Helpers\Form\Text( 'paypal_api_signature', isset( $settings['PayPal'] ) ? $settings['PayPal']['api_signature'] : '' );
$return[] = new \IPS\Helpers\Form\Text( 'paypal_client_id', ( isset( $settings['PayPal']['client_id'] ) AND isset( $settings['PayPal']['client_id'] ) AND $settings['PayPal']['client_id'] ) ? $settings['PayPal']['client_id'] : '', NULL );
$return[] = new \IPS\Helpers\Form\Text( 'paypal_secret', ( isset( $settings['PayPal']['secret'] ) AND $settings['PayPal']['secret'] ) ? $settings['PayPal']['secret'] : '', NULL );
return $return;
}
@@ -77,28 +79,60 @@ class _Payout extends \IPS\nexus\Payout
*/
public function process()
{
$settings = json_decode( \IPS\Settings::i()->nexus_payout, TRUE );
$response = \IPS\Http\Url::external( \IPS\NEXUS_TEST_GATEWAYS ? 'https://api-3t.sandbox.paypal.com/nvp' : 'https://api-3t.paypal.com/nvp' )->request()->post( array(
'METHOD' => 'MassPay',
'VERSION' => '90',
'USER' => $settings['PayPal']['api_username'],
'PWD' => $settings['PayPal']['api_password'],
'SIGNATURE' => $settings['PayPal']['api_signature'],
'RECEIVERTYPE' => 'EmailAddress',
'CURRENCYCODE' => $this->amount->currency,
'L_EMAIL0' => $this->data,
'L_AMT0' => $this->amount->amountAsString()
) )->decodeQueryString();
static::checkToken();
if ( $response['ACK'] != 'Success' )
$settings = json_decode( \IPS\Settings::i()->nexus_payout, true );
$data = array(
'items' => array(
array(
'amount' => array(
'currency' => $this->amount->currency,
'value' => $this->amount->amountAsString()
),
'receiver' => $this->data,
'recipient_type' => 'EMAIL'
)
),
'sender_batch_header' => array(
'recipient_type' => 'EMAIL',
'sender_batch_id' => "Payout " . \IPS\DateTime::create()->rfc3339()
)
);
$response = \IPS\Http\Url::external( 'https://' . ( \IPS\NEXUS_TEST_GATEWAYS ? 'api-m.sandbox.paypal.com' : 'api.paypal.com' ) . '/v1/payments/payouts' )
->request()
->forceTls()
->setHeaders( array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $settings['PayPal']['token']
) )
->post( json_encode( $data ) );
$json = $response->decodeJson();
if( $response->httpResponseCode != 201 )
{
throw new \DomainException( $response['L_LONGMESSAGE0'] );
throw new \IPS\Http\Request\Exception( $json['message'] );
}
else
{
$this->status = static::STATUS_COMPLETE;
$this->completed = new \IPS\DateTime;
if( isset( $json['batch_header'] ) AND $json['batch_header']['payout_batch_id'] )
{
$this->gw_id = $json['batch_header']['payout_batch_id'];
switch( $json['batch_header']['batch_status'] )
{
case 'DENIED':
case 'CANCELED':
$this->status = static::STATUS_CANCELED;
break;
case 'SUCCESS':
$this->status = static::STATUS_COMPLETE;
break;
default:
$this->status = static::STATUS_PROCESSING;
break;
}
$this->save();
}
}
@@ -112,57 +146,167 @@ class _Payout extends \IPS\nexus\Payout
*/
public static function massProcess( \IPS\Patterns\ActiveRecordIterator $payouts )
{
/* Make sure we check the token first so that we have proper authorization to API calls */
static::checkToken();
$settings = json_decode( \IPS\Settings::i()->nexus_payout, TRUE );
$byCurrency = array();
foreach ( $payouts as $payout )
/* Build a batch of payout items */
$payoutIds = $payoutData = [];
foreach( $payouts as $payout )
{
$byCurrency[ $payout->amount->currency ][] = array( 'email' => $payout->data, 'amount' => $payout->amount->amountAsString() );
$payoutIds[ $payout->amount->currency ] = $payout->id;
$payoutData[ $payout->amount->currency ][] = [
'amount' => [
'currency' => $payout->amount->currency,
'value' => $payout->amount->amountAsString()
],
'receiver' => $payout->data,
'recipient_type' => 'EMAIL'
];
}
$batches = array();
foreach ( $byCurrency as $currency => $recipients )
foreach( $payoutData as $currency => $batchData )
{
$baseData = array(
'METHOD' => 'MassPay',
'VERSION' => '90',
'USER' => $settings['PayPal']['api_username'],
'PWD' => $settings['PayPal']['api_password'],
'SIGNATURE' => $settings['PayPal']['api_signature'],
'RECEIVERTYPE' => 'EmailAddress',
'CURRENCYCODE' => $currency,
);
$data = $baseData;
foreach ( $recipients as $i => $recipient )
$requestData = [
'items' => $batchData,
'sender_batch_header' => [
'recipient_type' => 'EMAIL',
'sender_batch_id' => "Payout-{$currency}-" . \IPS\DateTime::create()->rfc3339()
]
];
$response = \IPS\Http\Url::external( 'https://' . ( \IPS\NEXUS_TEST_GATEWAYS ? 'api-m.sandbox.paypal.com' : 'api.paypal.com' ) . '/v1/payments/payouts' )
->request()
->forceTls()
->setHeaders( [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $settings['PayPal']['token']
] )
->post( json_encode( $requestData ) );
if( $response->httpResponseCode != 201 )
{
$data["L_EMAIL{$i}"] = $recipient['email'];
$data["L_AMT{$i}"] = $recipient['amount'];
if ( $i >= 249 )
throw new \RuntimeException( (string) $response, $response->httpResponseCode );
}
$response = $response->decodeJson();
if( isset( $response['batch_header'] ) AND $response['batch_header']['payout_batch_id'] )
{
$update = [
'po_gw_id' => $response['batch_header']['payout_batch_id']
];
switch( $response['batch_header']['batch_status'] )
{
$batches[] = $data;
$data = $baseData;
case 'DENIED':
case 'CANCELED':
$update['po_status'] = static::STATUS_CANCELED;
break;
case 'SUCCESS':
$update['po_status'] = static::STATUS_COMPLETE;
break;
default:
$update['po_status'] = static::STATUS_PROCESSING;
break;
}
\IPS\Db::i()->update( 'nexus_payouts', $update, \IPS\Db::i()->in( 'po_id', $payoutIds[ $currency ] ) );
}
$batches[] = $data;
}
$error = NULL;
foreach ( $batches as $batchData )
}
/**
* @brief cache batch results for further lookup
*/
protected static array $_batchCache = [];
/**
* Check the status of a payout batch and update the withdrawal requests accordingly
*
* @param string $batchId
* @return string|NULL
*/
public static function checkStatus( string $batchId ):? string
{
if( isset( static::$_batchCache[ $batchId ] ) )
{
$response = \IPS\Http\Url::external( \IPS\NEXUS_TEST_GATEWAYS ? 'https://api-3t.sandbox.paypal.com/nvp' : 'https://api-3t.paypal.com/nvp' )->request()->post( $batchData )->decodeQueryString();
if ( $response['ACK'] != 'Success' )
return static::$_batchCache[ $batchId ];
}
/* Make sure we check the token first so that we have proper authorization to API calls */
static::checkToken();
$settings = json_decode( \IPS\Settings::i()->nexus_payout, TRUE );
$response = \IPS\Http\Url::external( 'https://' . ( \IPS\NEXUS_TEST_GATEWAYS ? 'api-m.sandbox.paypal.com' : 'api.paypal.com' ) . '/v1/payments/payouts/' . $batchId )
->request()
->forceTls()
->setHeaders( array(
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $settings['PayPal']['token']
) )
->get()
->decodeJson();
if( isset( $response['batch_header'] ) AND $response['batch_header']['payout_batch_id'] )
{
switch( $response['batch_header']['batch_status'] )
{
$error = $response['L_LONGMESSAGE0'];
case 'DENIED':
case 'CANCELED':
return static::$_batchCache[ $batchId ] = static::STATUS_CANCELED;
break;
case 'SUCCESS':
return static::$_batchCache[ $batchId ] = static::STATUS_COMPLETE;
break;
default:
return static::$_batchCache[ $batchId ] = static::STATUS_PROCESSING;
break;
}
}
if ( $error )
return NULL;
}
/**
* Get Token
*
* @return void
* @throws \IPS\Http\Request\Exception
* @throws \UnexpectedValueException
*/
protected static function checkToken()
{
$payoutSettings = json_decode( \IPS\Settings::i()->nexus_payout, true );
$settings = $payoutSettings['PayPal'];
if ( !isset( $settings['token'] ) or $settings['token_expire'] < time() )
{
throw new \DomainException( $error );
$response = \IPS\Http\Url::external( 'https://' . ( \IPS\NEXUS_TEST_GATEWAYS ? 'api-m.sandbox.paypal.com' : 'api.paypal.com' ) . '/v1/oauth2/token' )
->request()
->forceTls()
->setHeaders( array(
'Accept' => 'application/json',
'Accept-Language' => 'en_US',
) )
->login( $settings['client_id'], $settings['secret'] )
->post( array( 'grant_type' => 'client_credentials' ) )
->decodeJson();
if ( !isset( $response['access_token'] ) )
{
throw new \UnexpectedValueException( isset( $response['error_description'] ) ? $response['error_description'] : $response );
}
$settings['token'] = $response['access_token'];
$settings['token_expire'] = ( time() + $response['expires_in'] );
$payoutSettings['PayPal'] = $settings;
\IPS\Settings::i()->changeValues( array(
'nexus_payout' => json_encode( $payoutSettings )
) );
}
}
}
@@ -781,8 +781,9 @@ class _Stripe extends \IPS\nexus\Gateway
if ( mb_substr( $transaction->gw_id, 0, 3 ) === 'pi_' )
{
$response = $this->api( "payment_intents/{$transaction->gw_id}", NULL, 'get' );
foreach ( $response['charges']['data'] as $charge )
$response = $this->api( "charges?payment_intent={$transaction->gw_id}", NULL, 'get' );
foreach ( $response['data'] as $charge )
{
if ( $charge['paid'] )
{
@@ -829,9 +830,9 @@ class _Stripe extends \IPS\nexus\Gateway
{
if ( mb_substr( $transaction->gw_id, 0, 3 ) === 'pi_' )
{
$response = $this->api( "payment_intents/{$transaction->gw_id}", NULL, 'get' );
foreach ( $response['charges']['data'] as $charge )
$response = $this->api( "charges?payment_intent={$transaction->gw_id}", NULL, 'get' );
foreach ( $response['data'] as $charge )
{
if ( $charge['paid'] )
{
@@ -911,8 +912,9 @@ class _Stripe extends \IPS\nexus\Gateway
{
if ( mb_substr( $transaction->gw_id, 0, 3 ) === 'pi_' )
{
$response = $this->api( "payment_intents/{$transaction->gw_id}", NULL, 'get' );
foreach ( $response['charges']['data'] as $charge )
$response = $this->api( "charges?payment_intent={$transaction->gw_id}", NULL, 'get' );
foreach ( $response['data'] as $charge )
{
if ( $charge['status'] === 'succeeded' )
{
@@ -1082,7 +1084,7 @@ class _Stripe extends \IPS\nexus\Gateway
$response = \IPS\Http\Url::external( 'https://api.stripe.com/v1/' . $uri )
->request( \IPS\LONG_REQUEST_TIMEOUT )
->setHeaders( array( 'Stripe-Version' => '2019-03-14' ) )
->setHeaders( array( 'Stripe-Version' => '2022-11-15' ) )
->forceTls()
->login( $settings['secret_key'], '' )
->$method( $data )