Handle failed payments

This commit is contained in:
Joshua Dwire 2016-04-30 17:54:56 -04:00
parent c536bd8569
commit ed1f2b6044
19 changed files with 317 additions and 57 deletions

View File

@ -0,0 +1,23 @@
<?php namespace App\Events;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class PaymentCompleted extends Event {
use SerializesModels;
public $payment;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct($payment)
{
$this->payment = $payment;
}
}

View File

@ -0,0 +1,23 @@
<?php namespace App\Events;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class PaymentFailed extends Event {
use SerializesModels;
public $payment;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct($payment)
{
$this->payment = $payment;
}
}

View File

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

View File

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

View File

@ -22,6 +22,7 @@ class VerifyCsrfToken extends BaseVerifier {
'hook/email_opened',
'hook/email_bounced',
'reseller_stats',
'paymenthook/*',
];
/**

View File

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

View File

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

View File

@ -1,5 +1,6 @@
<?php namespace app\Listeners;
use App\Events\PaymentFailed;
use Carbon;
use App\Models\Credit;
use App\Events\PaymentWasDeleted;

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\PaymentFailed;
use App\Events\InvoiceInvitationWasViewed;
class InvoiceListener
@ -75,6 +76,16 @@ class InvoiceListener
$invoice->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) {

View File

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

View File

@ -57,6 +57,7 @@ class UserMailer extends Mailer
];
if ($payment) {
$data['payment'] = $payment;
$data['paymentAmount'] = $account->formatMoney($payment->amount, $client);
}

View File

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

View File

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

4
composer.lock generated
View File

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

View File

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

View File

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

View File

@ -115,7 +115,6 @@
->addGroupClass('gateway-option')
!!}
<div class="stripe-ach">
@if ($account->getGatewayByType(PAYMENT_TYPE_DIRECT_DEBIT))
{!! Former::checkbox('enable_ach')
->label(trans('texts.ach'))
@ -128,7 +127,16 @@
->label(trans('texts.ach'))
->text(trans('texts.enable_ach'))
->help(trans('texts.stripe_ach_help')) !!}
<div class="stripe-plaid">
<div class="stripe-ach-options">
<div class="form-group">
<label class="control-label col-lg-4 col-sm-4">{{ trans('texts.webhook_url') }}</label>
<div class="col-lg-8 col-sm-8 help-block">
<input type="text" class="form-control" onfocus="$(this).select()" readonly value="{{ URL::to('/paymenthook/'.$account->account_key.'/'.GATEWAY_STRIPE) }}">
<div class="help-block"><strong>{!! trans('texts.stripe_webhook_help', [
'link'=>'<a href="https://dashboard.stripe.com/account/webhooks" target="_blank">'.trans('texts.stripe_webhook_help_link_text').'</a>'
]) !!}</strong></div>
</div>
</div>
<div class="form-group">
<div class="col-sm-8 col-sm-offset-4">
<h4>{{trans('texts.plaid')}}</h4>
@ -211,7 +219,7 @@
function enablePlaidSettings() {
var visible = $('#enable_ach').is(':checked');
$('.stripe-plaid').toggle(visible);
$('.stripe-ach-options').toggle(visible);
}
$(function() {

View File

@ -0,0 +1,26 @@
@extends('emails.master_user')
@section('markup')
@if ($account->enable_email_markup)
@include('emails.partials.user_view_action')
@endif
@stop
@section('body')
<div>
{{ trans('texts.email_salutation', ['name' => $userName]) }}
</div>
&nbsp;
<div>
{{ trans("texts.notification_invoice_payment_failed", ['amount' => $paymentAmount, 'client' => $clientName, 'invoice' => $invoiceNumber]) }}
</div>
&nbsp;
<div>
{{ $payment->gateway_error }}
</div>
&nbsp;
<div>
{{ trans('texts.email_signature') }} <br/>
{{ trans('texts.email_from') }}
</div>
@stop

View File

@ -0,0 +1,8 @@
{!! trans('texts.email_salutation', ['name' => $userName]) !!}
{!! trans("texts.notification_invoice_payment_failed", ['amount' => $paymentAmount, 'client' => $clientName, 'invoice' => $invoiceNumber]) !!}
{!! $payment->gateway_error !!}
{!! trans('texts.email_signature') !!}
{!! trans('texts.email_from') !!}