mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2025-06-23 20:00:33 -04:00
Better Braintree support
This commit is contained in:
parent
d27822e17b
commit
cf98c37f40
22
app/Events/PaymentWasVoided.php
Normal file
22
app/Events/PaymentWasVoided.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php namespace App\Events;
|
||||
|
||||
use App\Events\Event;
|
||||
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PaymentWasVoided extends Event {
|
||||
|
||||
use SerializesModels;
|
||||
public $payment;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($payment)
|
||||
{
|
||||
$this->payment = $payment;
|
||||
}
|
||||
|
||||
}
|
@ -409,8 +409,9 @@ if (!defined('CONTACT_EMAIL')) {
|
||||
//define('ACTIVITY_TYPE_UPDATE_PAYMENT', 11);
|
||||
define('ACTIVITY_TYPE_ARCHIVE_PAYMENT', 12);
|
||||
define('ACTIVITY_TYPE_DELETE_PAYMENT', 13);
|
||||
define('ACTIVITY_TYPE_REFUNDED_PAYMENT', 39);
|
||||
define('ACTIVITY_TYPE_FAILED_PAYMENT', 40);
|
||||
define('ACTIVITY_TYPE_VOIDED_PAYMENT', 39);
|
||||
define('ACTIVITY_TYPE_REFUNDED_PAYMENT', 40);
|
||||
define('ACTIVITY_TYPE_FAILED_PAYMENT', 41);
|
||||
|
||||
define('ACTIVITY_TYPE_CREATE_CREDIT', 14);
|
||||
//define('ACTIVITY_TYPE_UPDATE_CREDIT', 15);
|
||||
@ -489,10 +490,11 @@ if (!defined('CONTACT_EMAIL')) {
|
||||
define('INVOICE_STATUS_PAID', 6);
|
||||
|
||||
define('PAYMENT_STATUS_PENDING', 1);
|
||||
define('PAYMENT_STATUS_FAILED', 2);
|
||||
define('PAYMENT_STATUS_COMPLETED', 3);
|
||||
define('PAYMENT_STATUS_PARTIALLY_REFUNDED', 4);
|
||||
define('PAYMENT_STATUS_REFUNDED', 5);
|
||||
define('PAYMENT_STATUS_VOIDED', 2);
|
||||
define('PAYMENT_STATUS_FAILED', 3);
|
||||
define('PAYMENT_STATUS_COMPLETED', 4);
|
||||
define('PAYMENT_STATUS_PARTIALLY_REFUNDED', 5);
|
||||
define('PAYMENT_STATUS_REFUNDED', 6);
|
||||
|
||||
define('CUSTOM_DESIGN', 11);
|
||||
|
||||
|
@ -23,6 +23,7 @@ use App\Events\QuoteInvitationWasApproved;
|
||||
use App\Events\PaymentWasCreated;
|
||||
use App\Events\PaymentWasDeleted;
|
||||
use App\Events\PaymentWasRefunded;
|
||||
use App\Events\PaymentWasVoided;
|
||||
use App\Events\PaymentWasArchived;
|
||||
use App\Events\PaymentWasRestored;
|
||||
use App\Events\PaymentFailed;
|
||||
@ -323,6 +324,18 @@ class ActivityListener
|
||||
);
|
||||
}
|
||||
|
||||
public function voidedPayment(PaymentWasVoided $event)
|
||||
{
|
||||
$payment = $event->payment;
|
||||
|
||||
$this->activityRepo->create(
|
||||
$payment,
|
||||
ACTIVITY_TYPE_VOIDED_PAYMENT,
|
||||
$payment->amount,
|
||||
$payment->amount * -1
|
||||
);
|
||||
}
|
||||
|
||||
public function failedPayment(PaymentFailed $event)
|
||||
{
|
||||
$payment = $event->payment;
|
||||
|
@ -9,6 +9,7 @@ use App\Events\PaymentWasCreated;
|
||||
use App\Events\PaymentWasDeleted;
|
||||
use App\Events\PaymentWasRefunded;
|
||||
use App\Events\PaymentWasRestored;
|
||||
use App\Events\PaymentWasVoided;
|
||||
use App\Events\PaymentFailed;
|
||||
use App\Events\InvoiceInvitationWasViewed;
|
||||
|
||||
@ -76,6 +77,16 @@ class InvoiceListener
|
||||
$invoice->updatePaidStatus();
|
||||
}
|
||||
|
||||
public function voidedPayment(PaymentWasVoided $event)
|
||||
{
|
||||
$payment = $event->payment;
|
||||
$invoice = $payment->invoice;
|
||||
$adjustment = $payment->amount;
|
||||
|
||||
$invoice->updateBalances($adjustment);
|
||||
$invoice->updatePaidStatus();
|
||||
}
|
||||
|
||||
public function failedPayment(PaymentFailed $event)
|
||||
{
|
||||
$payment = $event->payment;
|
||||
|
@ -4,7 +4,9 @@ use Event;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use App\Events\PaymentWasCreated;
|
||||
use App\Events\PaymentWasRefunded;
|
||||
use App\Events\PaymentWasVoided;
|
||||
use App\Events\PaymentCompleted;
|
||||
use App\Events\PaymentVoided;
|
||||
use App\Events\PaymentFailed;
|
||||
use Laracasts\Presenter\PresentableTrait;
|
||||
|
||||
@ -90,7 +92,7 @@ class Payment extends EntityModel
|
||||
|
||||
public function isCompleted()
|
||||
{
|
||||
return $this->payment_status_id >= PAYMENT_STATUS_COMPLETED;
|
||||
return $this->payment_status_id == PAYMENT_STATUS_COMPLETED;
|
||||
}
|
||||
|
||||
public function isPartiallyRefunded()
|
||||
@ -103,9 +105,14 @@ class Payment extends EntityModel
|
||||
return $this->payment_status_id == PAYMENT_STATUS_REFUNDED;
|
||||
}
|
||||
|
||||
public function isVoided()
|
||||
{
|
||||
return $this->payment_status_id == PAYMENT_STATUS_VOIDED;
|
||||
}
|
||||
|
||||
public function recordRefund($amount = null)
|
||||
{
|
||||
if (!$this->isRefunded()) {
|
||||
if (!$this->isRefunded() && !$this->isVoided()) {
|
||||
if (!$amount) {
|
||||
$amount = $this->amount;
|
||||
}
|
||||
@ -123,6 +130,17 @@ class Payment extends EntityModel
|
||||
}
|
||||
}
|
||||
|
||||
public function markVoided()
|
||||
{
|
||||
if (!$this->isVoided() && !$this->isPartiallyRefunded() && !$this->isRefunded()) {
|
||||
$this->refunded = $this->amount;
|
||||
$this->payment_status_id = PAYMENT_STATUS_VOIDED;
|
||||
$this->save();
|
||||
|
||||
Event::fire(new PaymentWasVoided($this));
|
||||
}
|
||||
}
|
||||
|
||||
public function markComplete()
|
||||
{
|
||||
$this->payment_status_id = PAYMENT_STATUS_COMPLETED;
|
||||
|
@ -112,6 +112,10 @@ class EventServiceProvider extends ServiceProvider {
|
||||
'App\Listeners\InvoiceListener@refundedPayment',
|
||||
'App\Listeners\CreditListener@refundedPayment',
|
||||
],
|
||||
'App\Events\PaymentWasVoided' => [
|
||||
'App\Listeners\ActivityListener@voidedPayment',
|
||||
'App\Listeners\InvoiceListener@voidedPayment',
|
||||
],
|
||||
'App\Events\PaymentFailed' => [
|
||||
'App\Listeners\ActivityListener@failedPayment',
|
||||
'App\Listeners\InvoiceListener@failedPayment',
|
||||
|
@ -14,6 +14,7 @@ use App\Models\Account;
|
||||
use App\Models\Country;
|
||||
use App\Models\Client;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\AccountGateway;
|
||||
use App\Http\Controllers\PaymentController;
|
||||
use App\Models\AccountGatewayToken;
|
||||
use App\Ninja\Repositories\PaymentRepository;
|
||||
@ -27,7 +28,8 @@ class PaymentService extends BaseService
|
||||
protected $datatableService;
|
||||
|
||||
protected static $refundableGateways = array(
|
||||
GATEWAY_STRIPE
|
||||
GATEWAY_STRIPE,
|
||||
GATEWAY_BRAINTREE
|
||||
);
|
||||
|
||||
public function __construct(PaymentRepository $paymentRepo, AccountRepository $accountRepo, DatatableService $datatableService)
|
||||
@ -212,7 +214,28 @@ class PaymentService extends BaseService
|
||||
}
|
||||
}
|
||||
} elseif ($accountGateway->gateway_id == GATEWAY_BRAINTREE) {
|
||||
$response = $gateway->findCustomer($token)->send();
|
||||
$data = $response->getData();
|
||||
|
||||
if (!($data instanceof \Braintree\Customer)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sources = $data->paymentMethods;
|
||||
|
||||
$paymentTypes = Cache::get('paymentTypes');
|
||||
$currencies = Cache::get('currencies');
|
||||
foreach ($sources as $source) {
|
||||
if ($source instanceof \Braintree\CreditCard) {
|
||||
$paymentMethods[] = array(
|
||||
'id' => $source->token,
|
||||
'default' => $source->isDefault(),
|
||||
'type' => $paymentTypes->find($this->parseCardType($source->cardType)),
|
||||
'last4' => $source->last4,
|
||||
'expiration' => $source->expirationYear . '-' . $source->expirationMonth . '-00',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $paymentMethods;
|
||||
@ -249,7 +272,24 @@ class PaymentService extends BaseService
|
||||
return $response->getMessage();
|
||||
}
|
||||
} elseif ($accountGateway->gateway_id == GATEWAY_BRAINTREE) {
|
||||
// Make sure the source is owned by this client
|
||||
$sources = $this->getClientPaymentMethods($client);
|
||||
$ownsSource = false;
|
||||
foreach ($sources as $source) {
|
||||
if ($source['id'] == $sourceId) {
|
||||
$ownsSource = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$ownsSource) {
|
||||
return 'Unknown source';
|
||||
}
|
||||
|
||||
$response = $gateway->deletePaymentMethod(array('token'=>$sourceId))->send();
|
||||
|
||||
if (!$response->isSuccessful()) {
|
||||
return $response->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -272,7 +312,27 @@ class PaymentService extends BaseService
|
||||
'default_card='.$sourceId
|
||||
);
|
||||
} elseif ($accountGateway->gateway_id == GATEWAY_BRAINTREE) {
|
||||
// Make sure the source is owned by this client
|
||||
$sources = $this->getClientPaymentMethods($client);
|
||||
$ownsSource = false;
|
||||
foreach ($sources as $source) {
|
||||
if ($source['id'] == $sourceId) {
|
||||
$ownsSource = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$ownsSource) {
|
||||
return 'Unknown source';
|
||||
}
|
||||
|
||||
$response = $gateway->updatePaymentMethod(array(
|
||||
'token' => $sourceId,
|
||||
'makeDefault' => true,
|
||||
))->send();
|
||||
|
||||
if (!$response->isSuccessful()) {
|
||||
return $response->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -285,10 +345,18 @@ class PaymentService extends BaseService
|
||||
if ($customerReference) {
|
||||
$details['customerReference'] = $customerReference;
|
||||
|
||||
$customerResponse = $gateway->fetchCustomer(array('customerReference'=>$customerReference))->send();
|
||||
if ($accountGateway->gateway->id == GATEWAY_STRIPE) {
|
||||
$customerResponse = $gateway->fetchCustomer(array('customerReference'=>$customerReference))->send();
|
||||
|
||||
if (!$customerResponse->isSuccessful()){
|
||||
$customerReference = null; // The customer might not exist anymore
|
||||
if (!$customerResponse->isSuccessful()){
|
||||
$customerReference = null; // The customer might not exist anymore
|
||||
}
|
||||
} elseif ($accountGateway->gateway->id == GATEWAY_BRAINTREE) {
|
||||
$customer = $gateway->findCustomer($customerReference)->send()->getData();
|
||||
|
||||
if (!($customer instanceof \Braintree\Customer)){
|
||||
$customerReference = null; // The customer might not exist anymore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -599,8 +667,9 @@ class PaymentService extends BaseService
|
||||
|
||||
} elseif ($accountGateway->gateway_id == GATEWAY_BRAINTREE) {
|
||||
$details['customerId'] = $token;
|
||||
$customer = $gateway->findCustomer($token)->send();
|
||||
$details['paymentMethodToken'] = $customer->getData()->paymentMethods[0]->token;
|
||||
$customer = $gateway->findCustomer($token)->send()->getData();
|
||||
$defaultPaymentMethod = $customer->defaultPaymentMethod();
|
||||
$details['paymentMethodToken'] = $defaultPaymentMethod->token;
|
||||
}
|
||||
|
||||
// submit purchase/get response
|
||||
@ -723,7 +792,7 @@ class PaymentService extends BaseService
|
||||
return "javascript:showRefundModal({$model->public_id}, '{$max_refund}', '{$formatted}', '{$symbol}')";
|
||||
},
|
||||
function ($model) {
|
||||
return Auth::user()->can('editByOwner', [ENTITY_PAYMENT, $model->user_id]) && $model->payment_status_id != PAYMENT_STATUS_FAILED &&
|
||||
return Auth::user()->can('editByOwner', [ENTITY_PAYMENT, $model->user_id]) && $model->payment_status_id >= PAYMENT_STATUS_COMPLETED &&
|
||||
$model->refunded < $model->amount &&
|
||||
(
|
||||
($model->transaction_reference && in_array($model->gateway_id , static::$refundableGateways))
|
||||
@ -742,18 +811,18 @@ class PaymentService extends BaseService
|
||||
}
|
||||
|
||||
$payments = $this->getRepo()->findByPublicIdsWithTrashed($ids);
|
||||
$successful = 0;
|
||||
|
||||
foreach ($payments as $payment) {
|
||||
if(Auth::user()->can('edit', $payment)){
|
||||
if(!empty($params['amount'])) {
|
||||
$this->refund($payment, floatval($params['amount']));
|
||||
} else {
|
||||
$this->refund($payment);
|
||||
$amount = !empty($params['amount']) ? floatval($params['amount']) : null;
|
||||
if ($this->refund($payment, $amount)) {
|
||||
$successful++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count($payments);
|
||||
return $successful;
|
||||
} else {
|
||||
return parent::bulk($ids, $action);
|
||||
}
|
||||
@ -779,6 +848,7 @@ class PaymentService extends BaseService
|
||||
]);
|
||||
$class = 'primary';
|
||||
break;
|
||||
case PAYMENT_STATUS_VOIDED:
|
||||
case PAYMENT_STATUS_REFUNDED:
|
||||
$class = 'default';
|
||||
break;
|
||||
@ -792,14 +862,20 @@ class PaymentService extends BaseService
|
||||
}
|
||||
|
||||
$amount = min($amount, $payment->amount - $payment->refunded);
|
||||
|
||||
$accountGateway = $payment->account_gateway;
|
||||
|
||||
if (!$amount) {
|
||||
if (!$accountGateway) {
|
||||
$accountGateway = AccountGateway::withTrashed()->find($payment->account_gateway_id);
|
||||
}
|
||||
|
||||
if (!$amount || !$accountGateway) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($payment->payment_type_id != PAYMENT_TYPE_CREDIT) {
|
||||
$accountGateway = $this->createGateway($payment->account_gateway);
|
||||
$refund = $accountGateway->refund(array(
|
||||
$gateway = $this->createGateway($accountGateway);
|
||||
$refund = $gateway->refund(array(
|
||||
'transactionReference' => $payment->transaction_reference,
|
||||
'amount' => $amount,
|
||||
));
|
||||
@ -808,11 +884,49 @@ class PaymentService extends BaseService
|
||||
if ($response->isSuccessful()) {
|
||||
$payment->recordRefund($amount);
|
||||
} else {
|
||||
$this->error('Unknown', $response->getMessage(), $accountGateway);
|
||||
$data = $response->getData();
|
||||
|
||||
if ($data instanceof \Braintree\Result\Error) {
|
||||
$error = $data->errors->deepAll()[0];
|
||||
if ($error && $error->code == 91506) {
|
||||
if ($amount == $payment->amount) {
|
||||
// This is an unsettled transaction; try to void it
|
||||
$void = $gateway->void(array(
|
||||
'transactionReference' => $payment->transaction_reference,
|
||||
));
|
||||
$response = $void->send();
|
||||
|
||||
if ($response->isSuccessful()) {
|
||||
$payment->markVoided();
|
||||
}
|
||||
} else {
|
||||
$this->error('Unknown', 'Partial refund not allowed for unsettled transactions.', $accountGateway);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$response->isSuccessful()) {
|
||||
$this->error('Unknown', $response->getMessage(), $accountGateway);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$payment->recordRefund($amount);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function error($type, $error, $accountGateway = false, $exception = false)
|
||||
{
|
||||
$message = '';
|
||||
if ($accountGateway && $accountGateway->gateway) {
|
||||
$message = $accountGateway->gateway->name . ': ';
|
||||
}
|
||||
$message .= $error ?: trans('texts.payment_error');
|
||||
|
||||
Session::flash('error', $message);
|
||||
Utils::logError("Payment Error [{$type}]: " . ($exception ? Utils::getErrorString($exception) : $message), 'PHP', true);
|
||||
}
|
||||
|
||||
public function makeStripeCall($accountGateway, $method, $url, $body = null) {
|
||||
|
@ -25,7 +25,7 @@ class PaymentsChanges extends Migration
|
||||
Schema::table('payments', function($table)
|
||||
{
|
||||
$table->decimal('refunded', 13, 2);
|
||||
$table->unsignedInteger('payment_status_id')->default(3);
|
||||
$table->unsignedInteger('payment_status_id')->default(PAYMENT_STATUS_COMPLETED);
|
||||
$table->foreign('payment_status_id')->references('id')->on('payment_statuses');
|
||||
|
||||
$table->unsignedInteger('routing_number')->nullable();
|
||||
|
@ -17,10 +17,11 @@ class PaymentStatusSeeder extends Seeder
|
||||
{
|
||||
$statuses = [
|
||||
['id' => '1', 'name' => 'Pending'],
|
||||
['id' => '2', 'name' => 'Failed'],
|
||||
['id' => '3', 'name' => 'Completed'],
|
||||
['id' => '4', 'name' => 'Partially Refunded'],
|
||||
['id' => '5', 'name' => 'Refunded'],
|
||||
['id' => '2', 'name' => 'Voided'],
|
||||
['id' => '3', 'name' => 'Failed'],
|
||||
['id' => '4', 'name' => 'Completed'],
|
||||
['id' => '5', 'name' => 'Partially Refunded'],
|
||||
['id' => '6', 'name' => 'Refunded'],
|
||||
];
|
||||
|
||||
foreach ($statuses as $status) {
|
||||
|
@ -1188,8 +1188,10 @@ $LANG = array(
|
||||
'status_partially_refunded' => 'Partially Refunded',
|
||||
'status_partially_refunded_amount' => ':amount Refunded',
|
||||
'status_refunded' => 'Refunded',
|
||||
'status_voided' => 'Cancelled',
|
||||
'refunded_payment' => 'Refunded Payment',
|
||||
'activity_39' => ':user refunded :adjustment of a :payment_amount payment (:payment)',
|
||||
'activity_39' => ':user cancelled a :payment_amount payment (:payment)',
|
||||
'activity_40' => ':user refunded :adjustment of a :payment_amount payment (:payment)',
|
||||
'card_expiration' => 'Exp: :expires',
|
||||
|
||||
'card_creditcardother' => 'Unknown',
|
||||
@ -1252,7 +1254,7 @@ $LANG = array(
|
||||
'use_for_auto_bill' => 'Use For Autobill',
|
||||
'used_for_auto_bill' => 'Autobill Payment Method',
|
||||
'payment_method_set_as_default' => 'Set Autobill payment method.',
|
||||
'activity_40' => ':payment_amount payment (:payment) failed',
|
||||
'activity_41' => ':payment_amount payment (:payment) failed',
|
||||
'webhook_url' => 'Webhook URL',
|
||||
'stripe_webhook_help' => 'You must :link for ACH payment status to be updated.',
|
||||
'stripe_webhook_help_link_text' => 'add this URL as an endpoint at Stripe',
|
||||
@ -1274,7 +1276,7 @@ $LANG = array(
|
||||
'disabled_by_client' => 'Disabled by client',
|
||||
'manage_auto_bill' => 'Manage Auto-bill',
|
||||
'enabled' => 'Enabled',
|
||||
'disabled' => 'Disabled',
|
||||
''
|
||||
);
|
||||
|
||||
return $LANG;
|
||||
|
@ -161,7 +161,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (!empty($paymentMethods))
|
||||
@if (!empty($account->getTokenGatewayId()))
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<h3>{{ trans('texts.payment_methods') }}</h3>
|
||||
|
@ -34,6 +34,8 @@
|
||||
}
|
||||
},
|
||||
onError: function(e) {
|
||||
var $form = $('.payment-form');
|
||||
$form.find('button').prop('disabled', false);
|
||||
// Show the errors on the form
|
||||
if (e.details && e.details.invalidFieldKeys.length) {
|
||||
var invalidField = e.details.invalidFieldKeys[0];
|
||||
@ -54,6 +56,10 @@
|
||||
}
|
||||
});
|
||||
$('.payment-form').submit(function(event) {
|
||||
var $form = $(this);
|
||||
|
||||
// Disable the submit button to prevent repeated clicks
|
||||
$form.find('button').prop('disabled', true);
|
||||
$('#js-error-message').hide();
|
||||
});
|
||||
});
|
||||
|
@ -16,6 +16,7 @@
|
||||
display:inline-block;
|
||||
}
|
||||
</style>
|
||||
@if(!empty($paymentMethods))
|
||||
@foreach ($paymentMethods as $paymentMethod)
|
||||
<div class="payment_method">
|
||||
<span class="payment_method_img_container">
|
||||
@ -41,6 +42,7 @@
|
||||
<a href="javasript::void" class="payment_method_remove" onclick="removePaymentMethod('{{$paymentMethod['id']}}')">×</a>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
<center>
|
||||
{!! Button::success(strtoupper(trans('texts.add_credit_card')))
|
||||
|
Loading…
x
Reference in New Issue
Block a user