diff --git a/app/Events/PaymentWasVoided.php b/app/Events/PaymentWasVoided.php new file mode 100644 index 000000000000..962b8eee8262 --- /dev/null +++ b/app/Events/PaymentWasVoided.php @@ -0,0 +1,22 @@ +payment = $payment; + } + +} diff --git a/app/Http/routes.php b/app/Http/routes.php index fec67489b79d..82a5b00f2cd4 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -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); diff --git a/app/Listeners/ActivityListener.php b/app/Listeners/ActivityListener.php index 6db4f3db7779..9a0aa0f6cd41 100644 --- a/app/Listeners/ActivityListener.php +++ b/app/Listeners/ActivityListener.php @@ -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; diff --git a/app/Listeners/InvoiceListener.php b/app/Listeners/InvoiceListener.php index ea53a0eec539..003f9362bf6e 100644 --- a/app/Listeners/InvoiceListener.php +++ b/app/Listeners/InvoiceListener.php @@ -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; diff --git a/app/Models/Payment.php b/app/Models/Payment.php index 0498ffe55613..faa42e3c93f3 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -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; diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index ace2313ad7c5..03e1a4b36a36 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -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', diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php index 676d2dfd47d4..01d420eefde2 100644 --- a/app/Services/PaymentService.php +++ b/app/Services/PaymentService.php @@ -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) { diff --git a/database/migrations/2016_04_23_182223_payments_changes.php b/database/migrations/2016_04_23_182223_payments_changes.php index 5dd1bb6154d4..1f8c2037feef 100644 --- a/database/migrations/2016_04_23_182223_payments_changes.php +++ b/database/migrations/2016_04_23_182223_payments_changes.php @@ -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(); diff --git a/database/seeds/PaymentStatusSeeder.php b/database/seeds/PaymentStatusSeeder.php index 8e93d57798fd..4b3da3d085b8 100644 --- a/database/seeds/PaymentStatusSeeder.php +++ b/database/seeds/PaymentStatusSeeder.php @@ -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) { diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index bcca0ed6e7ff..5ff2f333d833 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -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; diff --git a/resources/views/invited/dashboard.blade.php b/resources/views/invited/dashboard.blade.php index c7c67b6489d9..7574d544c669 100644 --- a/resources/views/invited/dashboard.blade.php +++ b/resources/views/invited/dashboard.blade.php @@ -161,7 +161,7 @@ - @if (!empty($paymentMethods)) + @if (!empty($account->getTokenGatewayId()))