diff --git a/app/Events/PaymentCompleted.php b/app/Events/PaymentCompleted.php new file mode 100644 index 000000000000..281dddfae0df --- /dev/null +++ b/app/Events/PaymentCompleted.php @@ -0,0 +1,23 @@ +payment = $payment; + } + +} diff --git a/app/Events/PaymentFailed.php b/app/Events/PaymentFailed.php new file mode 100644 index 000000000000..5ff1a1874534 --- /dev/null +++ b/app/Events/PaymentFailed.php @@ -0,0 +1,23 @@ +payment = $payment; + } + +} diff --git a/app/Http/Controllers/PaymentController.php b/app/Http/Controllers/PaymentController.php index fadcdd0394b4..8801ec56d07a 100644 --- a/app/Http/Controllers/PaymentController.php +++ b/app/Http/Controllers/PaymentController.php @@ -15,6 +15,7 @@ use Cache; use App\Models\Invoice; use App\Models\Invitation; use App\Models\Client; +use App\Models\Account; use App\Models\PaymentType; use App\Models\License; use App\Models\Payment; @@ -23,6 +24,7 @@ use App\Ninja\Repositories\PaymentRepository; use App\Ninja\Repositories\InvoiceRepository; use App\Ninja\Repositories\AccountRepository; use App\Ninja\Mailers\ContactMailer; +use App\Ninja\Mailers\UserMailer; use App\Services\PaymentService; use App\Http\Requests\CreatePaymentRequest; @@ -32,7 +34,7 @@ class PaymentController extends BaseController { protected $model = 'App\Models\Payment'; - public function __construct(PaymentRepository $paymentRepo, InvoiceRepository $invoiceRepo, AccountRepository $accountRepo, ContactMailer $contactMailer, PaymentService $paymentService) + public function __construct(PaymentRepository $paymentRepo, InvoiceRepository $invoiceRepo, AccountRepository $accountRepo, ContactMailer $contactMailer, PaymentService $paymentService, UserMailer $userMailer) { // parent::__construct(); @@ -41,6 +43,7 @@ class PaymentController extends BaseController $this->accountRepo = $accountRepo; $this->contactMailer = $contactMailer; $this->paymentService = $paymentService; + $this->userMailer = $userMailer; } public function index() @@ -784,4 +787,85 @@ class PaymentController extends BaseController return null; } } + + public function handlePaymentWebhook($accountKey, $gatewayId) + { + $gatewayId = intval($gatewayId); + + if ($gatewayId != GATEWAY_STRIPE) { + return response()->json([ + 'message' => 'Unsupported gateway', + ], 404); + } + + $account = Account::where('accounts.account_key', '=', $accountKey)->first(); + + if (!$account) { + return response()->json([ + 'message' => 'Unknown account', + ], 404); + } + + $eventId = Input::get('id'); + $eventType= Input::get('type'); + + if (!$eventId) { + return response()->json([ + 'message' => 'Missing event id', + ], 400); + } + + if (!$eventType) { + return response()->json([ + 'message' => 'Missing event type', + ], 400); + } + + if (!in_array($eventType, array('charge.failed', 'charge.succeeded'))) { + return array('message' => 'Ignoring event'); + } + + $accountGateway = $account->getGatewayConfig(intval($gatewayId)); + + // Fetch the event directly from Stripe for security + $eventDetails = $this->paymentService->makeStripeCall($accountGateway, 'GET', 'events/'.$eventId); + + if (is_string($eventDetails) || !$eventDetails) { + return response()->json([ + 'message' => $eventDetails ? $eventDetails : 'Could not get event details.', + ], 500); + } + + if ($eventType != $eventDetails['type']) { + return response()->json([ + 'message' => 'Event type mismatch', + ], 400); + } + + if (!$eventDetails['pending_webhooks'] && false) { + return response()->json([ + 'message' => 'This is not a pending event', + ], 400); + } + + $charge = $eventDetails['data']['object']; + $transactionRef = $charge['id']; + + $payment = Payment::where('transaction_reference', '=', $transactionRef)->first(); + + if (!$payment) { + return array('message' => 'Unknown payment'); + } + + if ($eventType == 'charge.failed') { + if (!$payment->isFailed() || true) { + $payment->markFailed($charge['failure_message']); + $this->userMailer->sendNotification($payment->user, $payment->invoice, 'payment_failed', $payment); + } + } elseif ($eventType == 'charge.succeeded') { + $payment->markComplete(); + } + + return array('message' => 'Processed successfully'); + } } diff --git a/app/Http/Controllers/PublicClientController.php b/app/Http/Controllers/PublicClientController.php index 868a116719e8..1c05920be6eb 100644 --- a/app/Http/Controllers/PublicClientController.php +++ b/app/Http/Controllers/PublicClientController.php @@ -12,6 +12,7 @@ use Session; use Datatable; use Validator; use Cache; +use Redirect; use App\Models\Gateway; use App\Models\Invitation; use App\Models\Document; @@ -783,6 +784,7 @@ class PublicClientController extends BaseController return $this->returnError(); } + $typeLink = $paymentType; $paymentType = 'PAYMENT_TYPE_' . strtoupper($paymentType); $client = $invitation->invoice->client; $account = $client->account; @@ -799,12 +801,12 @@ class PublicClientController extends BaseController } if(empty($sourceId)) { - $this->error('Token-No-Ref', $this->paymentService->lastError, $accountGateway); - return Redirect::to('payment/'.$invitationKey)->withInput(Request::except('cvv')); + $this->paymentMethodError('Token-No-Ref', $this->paymentService->lastError, $accountGateway); + return Redirect::to('client/paymentmethods/add/' . $typeLink)->withInput(Request::except('cvv')); } else if ($paymentType == PAYMENT_TYPE_STRIPE_ACH && empty($usingPlaid) ) { // The user needs to complete verification Session::flash('message', trans('texts.bank_account_verification_next_steps')); - return Redirect::to('client/paymentmethods/add/' . $paymentType); + return Redirect::to('client/paymentmethods/'); } else { Session::flash('message', trans('texts.payment_method_added')); return redirect()->to('/client/paymentmethods/'); @@ -832,4 +834,16 @@ class PublicClientController extends BaseController return redirect()->to('/client/paymentmethods/'); } + + private function paymentMethodError($type, $error, $accountGateway = false, $exception = false) + { + $message = ''; + if ($accountGateway && $accountGateway->gateway) { + $message = $accountGateway->gateway->name . ': '; + } + $message .= $error ?: trans('texts.payment_method_error'); + + Session::flash('error', $message); + Utils::logError("Payment Method Error [{$type}]: " . ($exception ? Utils::getErrorString($exception) : $message), 'PHP', true); + } } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 7766a0991337..fa485068fc05 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -22,6 +22,7 @@ class VerifyCsrfToken extends BaseVerifier { 'hook/email_opened', 'hook/email_bounced', 'reseller_stats', + 'paymenthook/*', ]; /** diff --git a/app/Http/routes.php b/app/Http/routes.php index d21883c566b9..8e6d430a6729 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -67,6 +67,7 @@ Route::group(['middleware' => 'auth:client'], function() { Route::get('bank/{routing_number}', 'PaymentController@getBankInfo'); +Route::post('paymenthook/{accountKey}/{gatewayId}', 'PaymentController@handlePaymentWebhook'); Route::get('license', 'PaymentController@show_license_payment'); Route::post('license', 'PaymentController@do_license_payment'); Route::get('claim_license', 'PaymentController@claim_license'); @@ -406,6 +407,7 @@ if (!defined('CONTACT_EMAIL')) { 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_CREATE_CREDIT', 14); //define('ACTIVITY_TYPE_UPDATE_CREDIT', 15); diff --git a/app/Listeners/ActivityListener.php b/app/Listeners/ActivityListener.php index 811d1050b9cb..6db4f3db7779 100644 --- a/app/Listeners/ActivityListener.php +++ b/app/Listeners/ActivityListener.php @@ -25,6 +25,7 @@ use App\Events\PaymentWasDeleted; use App\Events\PaymentWasRefunded; use App\Events\PaymentWasArchived; use App\Events\PaymentWasRestored; +use App\Events\PaymentFailed; use App\Events\CreditWasCreated; use App\Events\CreditWasDeleted; use App\Events\CreditWasArchived; @@ -322,6 +323,18 @@ class ActivityListener ); } + public function failedPayment(PaymentFailed $event) + { + $payment = $event->payment; + + $this->activityRepo->create( + $payment, + ACTIVITY_TYPE_FAILED_PAYMENT, + $payment->amount, + $payment->amount * -1 + ); + } + public function archivedPayment(PaymentWasArchived $event) { if ($event->payment->is_deleted) { diff --git a/app/Listeners/CreditListener.php b/app/Listeners/CreditListener.php index 5c2ce2539d80..fba2e2c7ff9a 100644 --- a/app/Listeners/CreditListener.php +++ b/app/Listeners/CreditListener.php @@ -1,5 +1,6 @@ updatePaidStatus(); } + public function failedPayment(PaymentFailed $event) + { + $payment = $event->payment; + $invoice = $payment->invoice; + $adjustment = $payment->amount - $payment->refunded; + + $invoice->updateBalances($adjustment); + $invoice->updatePaidStatus(); + } + public function restoredPayment(PaymentWasRestored $event) { if ( ! $event->fromDeleted) { diff --git a/app/Models/Payment.php b/app/Models/Payment.php index 22924cc61297..0498ffe55613 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -4,6 +4,8 @@ use Event; use Illuminate\Database\Eloquent\SoftDeletes; use App\Events\PaymentWasCreated; use App\Events\PaymentWasRefunded; +use App\Events\PaymentCompleted; +use App\Events\PaymentFailed; use Laracasts\Presenter\PresentableTrait; class Payment extends EntityModel @@ -121,6 +123,21 @@ class Payment extends EntityModel } } + public function markComplete() + { + $this->payment_status_id = PAYMENT_STATUS_COMPLETED; + $this->save(); + Event::fire(new PaymentCompleted($this)); + } + + public function markFailed($failureMessage) + { + $this->payment_status_id = PAYMENT_STATUS_FAILED; + $this->gateway_error = $failureMessage; + $this->save(); + Event::fire(new PaymentFailed($this)); + } + public function getEntityType() { return ENTITY_PAYMENT; diff --git a/app/Ninja/Mailers/UserMailer.php b/app/Ninja/Mailers/UserMailer.php index b08d50d7838f..afa42f9f5596 100644 --- a/app/Ninja/Mailers/UserMailer.php +++ b/app/Ninja/Mailers/UserMailer.php @@ -57,6 +57,7 @@ class UserMailer extends Mailer ]; if ($payment) { + $data['payment'] = $payment; $data['paymentAmount'] = $account->formatMoney($payment->amount, $client); } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 3ce5fcfa29e0..ace2313ad7c5 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\PaymentFailed' => [ + 'App\Listeners\ActivityListener@failedPayment', + 'App\Listeners\InvoiceListener@failedPayment', + ], 'App\Events\PaymentWasRestored' => [ 'App\Listeners\ActivityListener@restoredPayment', 'App\Listeners\InvoiceListener@restoredPayment', diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php index 8ad8a990eb34..462bc918b943 100644 --- a/app/Services/PaymentService.php +++ b/app/Services/PaymentService.php @@ -213,29 +213,15 @@ class PaymentService extends BaseService return 'Unsupported gateway'; } - try{ - // Omnipay doesn't support verifying payment methods - // Also, it doesn't want to urlencode without putting numbers inside the brackets - $response = (new \GuzzleHttp\Client(['base_uri'=>'https://api.stripe.com/v1/']))->request( - 'POST', - 'customers/'.$token.'/sources/'.$sourceId.'/verify', - [ - 'body' => 'amounts[]='.intval($amount1).'&amounts[]='.intval($amount2), - 'headers' => ['content-type' => 'application/x-www-form-urlencoded'], - 'auth' => [$accountGateway->getConfig()->apiKey,''], - ] - ); - return json_decode($response->getBody(), true); - } catch (\GuzzleHttp\Exception\BadResponseException $e) { - $response = $e->getResponse(); - $body = json_decode($response->getBody(), true); - if ($body && $body['error'] && $body['error']['type'] == 'invalid_request_error') { - return $body['error']['message']; - } - - return $e->getMessage(); - } + // Omnipay doesn't support verifying payment methods + // Also, it doesn't want to urlencode without putting numbers inside the brackets + return $this->makeStripeCall( + $accountGateway, + 'POST', + 'customers/'.$token.'/sources/'.$sourceId.'/verify', + 'amounts[]='.intval($amount1).'&amounts[]='.intval($amount2) + ); } public function removeClientPaymentMethod($client, $sourceId) { @@ -267,28 +253,13 @@ class PaymentService extends BaseService $gateway = $this->createGateway($accountGateway); if ($accountGateway->gateway_id == GATEWAY_STRIPE) { - try{ - // Omnipay doesn't support setting a default source - $response = (new \GuzzleHttp\Client(['base_uri'=>'https://api.stripe.com/v1/']))->request( - 'POST', - 'customers/'.$token, - [ - 'body' => 'default_card='.$sourceId, - 'headers' => ['content-type' => 'application/x-www-form-urlencoded'], - 'auth' => [$accountGateway->getConfig()->apiKey,''], - ] - ); - return true; - } catch (\GuzzleHttp\Exception\BadResponseException $e) { - $response = $e->getResponse(); - $body = json_decode($response->getBody(), true); - if ($body && $body['error'] && $body['error']['type'] == 'invalid_request_error') { - return $body['error']['message']; - } - - return $e->getMessage(); - } + return $this->makeStripeCall( + $accountGateway, + 'POST', + 'customers/'.$token, + 'default_card='.$sourceId + ); } elseif ($accountGateway->gateway_id == GATEWAY_BRAINTREE) { } @@ -312,14 +283,21 @@ class PaymentService extends BaseService if ($accountGateway->gateway->id == GATEWAY_STRIPE) { $tokenResponse = $gateway->createCard($details)->send(); - $cardReference = $tokenResponse->getCardReference(); + $sourceReference = $tokenResponse->getCardReference(); $customerReference = $tokenResponse->getCustomerReference(); - if ($customerReference == $cardReference) { + if (!$sourceReference) { + $responseData = $tokenResponse->getData(); + if ($responseData['object'] == 'bank_account' || $responseData['object'] == 'card') { + $sourceReference = $responseData['id']; + } + } + + if ($customerReference == $sourceReference) { // This customer was just created; find the card $data = $tokenResponse->getData(); if (!empty($data['default_source'])) { - $cardReference = $data['default_source']; + $sourceReferebce = $data['default_source']; } } } elseif ($accountGateway->gateway->id == GATEWAY_BRAINTREE) { @@ -356,7 +334,7 @@ class PaymentService extends BaseService $this->lastError = $tokenResponse->getMessage(); } - return $cardReference; + return $sourceReference; } public function getCheckoutComToken($invitation) @@ -425,6 +403,8 @@ class PaymentService extends BaseService $data = $purchaseResponse->getData(); $source = !empty($data['source'])?$data['source']:$data['card']; + $payment->payment_status_id = $data['status'] == 'succeeded' ? PAYMENT_STATUS_COMPLETED : PAYMENT_STATUS_PENDING; + if ($source) { $payment->last4 = $source['last4']; @@ -794,4 +774,39 @@ class PaymentService extends BaseService $payment->recordRefund($amount); } } + + public function makeStripeCall($accountGateway, $method, $url, $body = null) { + $apiKey = $accountGateway->getConfig()->apiKey; + + if (!$apiKey) { + return 'No API key set'; + } + + try{ + $options = [ + 'headers' => ['content-type' => 'application/x-www-form-urlencoded'], + 'auth' => [$accountGateway->getConfig()->apiKey,''], + ]; + + if ($body) { + $options['body'] = $body; + } + + $response = (new \GuzzleHttp\Client(['base_uri'=>'https://api.stripe.com/v1/']))->request( + $method, + $url, + $options + ); + return json_decode($response->getBody(), true); + } catch (\GuzzleHttp\Exception\BadResponseException $e) { + $response = $e->getResponse(); + $body = json_decode($response->getBody(), true); + + if ($body && $body['error'] && $body['error']['type'] == 'invalid_request_error') { + return $body['error']['message']; + } + + return $e->getMessage(); + } + } } diff --git a/composer.lock b/composer.lock index 23b08f2d7e9d..9519f3ad0523 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "039f9d8f2e342f6c05dadb3523883a47", - "content-hash": "fd558fd1e187969baf015eab8e288e5b", + "hash": "7139e4aedb2ac151079c50ee5c17f93c", + "content-hash": "a314d6c0a16785dd2395a7fd73cdc76d", "packages": [ { "name": "agmscode/omnipay-agms", diff --git a/database/migrations/2016_04_23_182223_payments_changes.php b/database/migrations/2016_04_23_182223_payments_changes.php index c99512cfabf9..839fe26c940c 100644 --- a/database/migrations/2016_04_23_182223_payments_changes.php +++ b/database/migrations/2016_04_23_182223_payments_changes.php @@ -31,6 +31,7 @@ class PaymentsChanges extends Migration $table->unsignedInteger('routing_number')->nullable(); $table->smallInteger('last4')->unsigned()->nullable(); $table->date('expiration')->nullable(); + $table->text('gateway_error')->nullable(); }); } @@ -50,6 +51,7 @@ class PaymentsChanges extends Migration $table->dropColumn('routing_number'); $table->dropColumn('last4'); $table->dropColumn('expiration'); + $table->dropColumn('gateway_error'); }); Schema::dropIfExists('payment_statuses'); diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index 83fdb5481daa..a6af30f5c03a 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -1251,7 +1251,14 @@ $LANG = array( 'payment_method_added' => 'Added payment method.', 'use_for_auto_bill' => 'Use For Autobill', 'used_for_auto_bill' => 'Autobill Payment Method', - 'payment_method_set_as_default' => 'Set Autobill payment method.' + 'payment_method_set_as_default' => 'Set Autobill payment method.', + 'activity_40' => ':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', + 'payment_method_error' => 'There was an error adding your payment methd. Please try again later.', + 'notification_invoice_payment_failed_subject' => 'Payment failed for Invoice :invoice', + 'notification_invoice_payment_failed' => 'A payment made by client :client towards Invoice :invoice failed. The payment has been marked as failed and :amount has been added to the client\'s balance.', ); return $LANG; diff --git a/resources/views/accounts/account_gateway.blade.php b/resources/views/accounts/account_gateway.blade.php index 469a11090374..4d4e79d7a157 100644 --- a/resources/views/accounts/account_gateway.blade.php +++ b/resources/views/accounts/account_gateway.blade.php @@ -115,7 +115,6 @@ ->addGroupClass('gateway-option') !!}