Better Braintree support

This commit is contained in:
Joshua Dwire 2016-05-06 17:05:42 -04:00
parent d27822e17b
commit cf98c37f40
13 changed files with 228 additions and 33 deletions

View 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;
}
}

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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',

View File

@ -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) {

View File

@ -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();

View File

@ -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) {

View File

@ -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:&nbsp: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;

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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']}}')">&times;</a>
</div>
@endforeach
@endif
<center>
{!! Button::success(strtoupper(trans('texts.add_credit_card')))