From c4231f702dbfdf90b01a90bd0b0513cf7b3d606b Mon Sep 17 00:00:00 2001 From: David Bomba Date: Wed, 18 May 2022 09:39:54 +1000 Subject: [PATCH 1/6] refactor for ach payments --- app/PaymentDrivers/Stripe/ACH.php | 16 ++++- .../ach/pay_instant_verification.blade.php | 66 +++++++++++++++++++ .../gateways/stripe/plaid/auth.blade.php | 57 ++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 resources/views/portal/ninja2020/gateways/stripe/ach/pay_instant_verification.blade.php create mode 100644 resources/views/portal/ninja2020/gateways/stripe/plaid/auth.blade.php diff --git a/app/PaymentDrivers/Stripe/ACH.php b/app/PaymentDrivers/Stripe/ACH.php index d151ed0d8ae7..8dbee2d534ef 100644 --- a/app/PaymentDrivers/Stripe/ACH.php +++ b/app/PaymentDrivers/Stripe/ACH.php @@ -143,7 +143,21 @@ class ACH $data['customer'] = $this->stripe->findOrCreateCustomer(); $data['amount'] = $this->stripe->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe->client->currency()->precision, $this->stripe->client->currency()); - return render('gateways.stripe.ach.pay', $data); + $intent = + $this->stripe->createPaymentIntent([ + 'amount' => $data['amount'], + 'currency' => $data['currency'], + 'setup_future_usage' => 'off_session', + 'customer' => $data['customer']->id, + 'payment_method_types' => ['us_bank_account'], + ] + ); + + $data['client_secret'] = $intent->client_secret; + + + return render('gateways.stripe.ach.pay_instant_verification', $data); + // return render('gateways.stripe.ach.pay', $data); } public function tokenBilling(ClientGatewayToken $cgt, PaymentHash $payment_hash) diff --git a/resources/views/portal/ninja2020/gateways/stripe/ach/pay_instant_verification.blade.php b/resources/views/portal/ninja2020/gateways/stripe/ach/pay_instant_verification.blade.php new file mode 100644 index 000000000000..a5841604a1f9 --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/stripe/ach/pay_instant_verification.blade.php @@ -0,0 +1,66 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => 'ACH', 'card_title' => 'ACH']) + +@section('gateway_content') + @if(count($tokens) > 0) + + + @include('portal.ninja2020.gateways.includes.payment_details') + +
+ @csrf + + + + + + + + +
+ + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.pay_with')]) + @if(count($tokens) > 0) + @foreach($tokens as $token) + + @endforeach + @endisset + @endcomponent + + @else + @component('portal.ninja2020.components.general.card-element-single', ['title' => 'ACH', 'show_title' => false]) + {{ ctrans('texts.bank_account_not_linked') }} + {{ ctrans('texts.add_payment_method') }} + + @endcomponent + @endif + + @include('portal.ninja2020.gateways.includes.pay_now') +@endsection + +@push('footer') + +@endpush diff --git a/resources/views/portal/ninja2020/gateways/stripe/plaid/auth.blade.php b/resources/views/portal/ninja2020/gateways/stripe/plaid/auth.blade.php new file mode 100644 index 000000000000..da23ba1ab8d5 --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/stripe/plaid/auth.blade.php @@ -0,0 +1,57 @@ + + + + \ No newline at end of file From 7df60f5f2777c0954acf827fc87ff20b410ba654 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Wed, 18 May 2022 12:59:24 +1000 Subject: [PATCH 2/6] ACH updates --- .../Requests/Invoice/StoreInvoiceRequest.php | 4 +- .../Requests/Invoice/UpdateInvoiceRequest.php | 2 +- app/PaymentDrivers/Stripe/ACH.php | 37 ++- .../gateways/stripe/ach/pay.blade.php | 218 ++++++++++++++++-- .../ach/pay_instant_verification.blade.php | 20 +- 5 files changed, 241 insertions(+), 40 deletions(-) diff --git a/app/Http/Requests/Invoice/StoreInvoiceRequest.php b/app/Http/Requests/Invoice/StoreInvoiceRequest.php index 6b17e0e0b3d7..d8e05af55f3a 100644 --- a/app/Http/Requests/Invoice/StoreInvoiceRequest.php +++ b/app/Http/Requests/Invoice/StoreInvoiceRequest.php @@ -79,7 +79,9 @@ class StoreInvoiceRequest extends Request $input = $this->decodePrimaryKeys($input); - $input['line_items'] = isset($input['line_items']) ? $this->cleanItems($input['line_items']) : []; + if (isset($input['line_items']) && is_array($input['line_items'])) + $input['line_items'] = isset($input['line_items']) ? $this->cleanItems($input['line_items']) : []; + $input['amount'] = 0; $input['balance'] = 0; diff --git a/app/Http/Requests/Invoice/UpdateInvoiceRequest.php b/app/Http/Requests/Invoice/UpdateInvoiceRequest.php index e818842fe834..afbe0999a6f7 100644 --- a/app/Http/Requests/Invoice/UpdateInvoiceRequest.php +++ b/app/Http/Requests/Invoice/UpdateInvoiceRequest.php @@ -76,7 +76,7 @@ class UpdateInvoiceRequest extends Request $input['id'] = $this->invoice->id; - if (isset($input['line_items'])) { + if (isset($input['line_items']) && is_array($input['line_items'])) { $input['line_items'] = isset($input['line_items']) ? $this->cleanItems($input['line_items']) : []; } diff --git a/app/PaymentDrivers/Stripe/ACH.php b/app/PaymentDrivers/Stripe/ACH.php index 8dbee2d534ef..7e96cd3439be 100644 --- a/app/PaymentDrivers/Stripe/ACH.php +++ b/app/PaymentDrivers/Stripe/ACH.php @@ -143,21 +143,24 @@ class ACH $data['customer'] = $this->stripe->findOrCreateCustomer(); $data['amount'] = $this->stripe->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe->client->currency()->precision, $this->stripe->client->currency()); - $intent = - $this->stripe->createPaymentIntent([ - 'amount' => $data['amount'], - 'currency' => $data['currency'], - 'setup_future_usage' => 'off_session', - 'customer' => $data['customer']->id, - 'payment_method_types' => ['us_bank_account'], - ] - ); + $intent = false; - $data['client_secret'] = $intent->client_secret; + if(count($data['tokens']) == 0) + { + $intent = + $this->stripe->createPaymentIntent([ + 'amount' => $data['amount'], + 'currency' => $data['currency'], + 'setup_future_usage' => 'off_session', + 'customer' => $data['customer']->id, + 'payment_method_types' => ['us_bank_account'], + ] + ); + } + $data['client_secret'] = $intent ? $intent->client_secret : false; - return render('gateways.stripe.ach.pay_instant_verification', $data); - // return render('gateways.stripe.ach.pay', $data); + return render('gateways.stripe.ach.pay', $data); } public function tokenBilling(ClientGatewayToken $cgt, PaymentHash $payment_hash) @@ -217,11 +220,21 @@ class ACH } + public function handlePaymentIntentResponse($request) + { + nlog($request->all()); + dd($request->all()); + } + public function paymentResponse($request) { $this->stripe->init(); + //it may be a payment intent here. + if($request->input('client_secret')) + $this->handlePaymentIntentResponse($request); + $source = ClientGatewayToken::query() ->where('id', $this->decodePrimaryKey($request->source)) ->where('company_id', auth()->guard('contact')->user()->client->company->id) diff --git a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php index 684287124574..3c4dd4e332eb 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php @@ -1,8 +1,22 @@ @extends('portal.ninja2020.layout.payments', ['gateway_title' => 'ACH', 'card_title' => 'ACH']) +@section('gateway_head') + @if($gateway->company_gateway->getConfigField('account_id')) + + + @else + + @endif + + + + +@endsection + @section('gateway_content') + + @if(count($tokens) > 0) - @include('portal.ninja2020.gateways.includes.payment_details') @@ -15,6 +29,8 @@ + + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.pay_with')]) @@ -32,33 +48,201 @@ @endisset @endcomponent + @include('portal.ninja2020.gateways.includes.pay_now') + @else - @component('portal.ninja2020.components.general.card-element-single', ['title' => 'ACH', 'show_title' => false]) - {{ ctrans('texts.bank_account_not_linked') }} - {{ ctrans('texts.add_payment_method') }} + + @component('portal.ninja2020.components.general.card-element-single') + + @endcomponent + + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.account_holder_name')]) + + @endcomponent + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.email')]) + + @endcomponent +
+
+ Connect a bank account +
+
+ +
+
@endif - @include('portal.ninja2020.gateways.includes.pay_now') @endsection @push('footer') + + @endpush diff --git a/resources/views/portal/ninja2020/gateways/stripe/ach/pay_instant_verification.blade.php b/resources/views/portal/ninja2020/gateways/stripe/ach/pay_instant_verification.blade.php index a5841604a1f9..007b204e0ffc 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/ach/pay_instant_verification.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/ach/pay_instant_verification.blade.php @@ -33,16 +33,18 @@ @endisset @endcomponent - @else - @component('portal.ninja2020.components.general.card-element-single', ['title' => 'ACH', 'show_title' => false]) - {{ ctrans('texts.bank_account_not_linked') }} - {{ ctrans('texts.add_payment_method') }} - - @endcomponent - @endif - @include('portal.ninja2020.gateways.includes.pay_now') + + @else + + @component('portal.ninja2020.components.general.card-element-single', ['title' => 'ACH', 'show_title' => false]) + Pay with a new bank account. + + + @endcomponent + + @endif + @endsection @push('footer') From ad203ac2288d127840a7bf7389008d5a441e641e Mon Sep 17 00:00:00 2001 From: David Bomba Date: Wed, 18 May 2022 14:22:04 +1000 Subject: [PATCH 3/6] ACH refactor --- .../gateways/stripe/ach/pay.blade.php | 147 ++++++++++++------ 1 file changed, 103 insertions(+), 44 deletions(-) diff --git a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php index 3c4dd4e332eb..5a1fc11b44cb 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php @@ -52,11 +52,6 @@ @else - @component('portal.ninja2020.components.general.card-element-single') - - - @endcomponent - @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.account_holder_name')]) @endcomponent @@ -76,11 +71,74 @@ {{ $slot ?? ctrans('texts.new_bank_account') }} + +
+
+ + + +
+
+ @endif @endsection + + + @push('footer') @@ -88,6 +146,23 @@ let payNow = document.getElementById('pay-now'); + let stripePaymentIntent = ''; + + let stripe; + + let response; + + let publishableKey = document.querySelector('meta[name="stripe-publishable-key"]').content + let stripeConnect = document.querySelector('meta[name="stripe-account-id"]')?.content + + if(stripeConnect){ + stripe = Stripe(publishableKey, { stripeAccount: stripeConnect}); + } + else { + stripe = Stripe(publishableKey); + } + + if(payNow) { @@ -113,27 +188,8 @@ document.getElementById('new-bank').addEventListener('click', (ev) => { - if (!document.getElementById('accept-terms').checked) { - errors.textContent = "You must accept the mandate terms prior to making payment."; - errors.hidden = false; - - return; - } - errors.hidden = true; - let stripe; - - let publishableKey = document.querySelector('meta[name="stripe-publishable-key"]').content - let stripeConnect = document.querySelector('meta[name="stripe-account-id"]')?.content - - if(stripeConnect){ - stripe = Stripe(publishableKey, { stripeAccount: stripeConnect}); - } - else { - stripe = Stripe(publishableKey); - } - let newBankButton = document.getElementById('new-bank'); newBankButton.disabled = true; newBankButton.querySelector('svg').classList.remove('hidden'); @@ -174,16 +230,23 @@ // manually-entered. Display payment method details and mandate text // to the customer and confirm the intent once they accept // the mandate. + stripePaymentIntent = paymentIntent; + showModal(paymentIntent); - - confirmPayment(stripe, clientSecret); } }); }); - function confirmPayment(stripe, clientSecret){ + function showModal(paymentIntent) + { + document.getElementById('open-delete-popup').click(); + } + + function confirmPayment(){ + + const clientSecret = document.querySelector('meta[name="client_secret"]')?.content; stripe.confirmUsBankAccountPayment(clientSecret) .then(({paymentIntent, error}) => { @@ -204,35 +267,31 @@ // Confirmation succeeded! The account will be debited. // Display a message to customer. - // let gateway_response = document.querySelector('input[name="gateway_response"]'); - // gateway_response.value = JSON.stringify(paymentIntent.id); - - var wait = paymentIntent => new Promise(resolve => setTimeout(resolve, paymentIntent)); - - // document.getElementById('server-response').submit(); - - } else if (paymentIntent.next_action?.type === "verify_with_microdeposits") { // The account needs to be verified via microdeposits. // Display a message to consumer with next steps (consumer waits for // microdeposits, then enters a statement descriptor code on a page sent to them via email). } + }).finally((promise) => { + + console.log(promise); + console.log("and we are finished") + }); - + + resetButtons(); + finalize(); + } + function finalize() + { - function setTimeout(paymentIntent){ - - let gateway_response = document.getElementById('gateway_response'); - gateway_response.value = JSON.stringify( - paymentIntent - ); - document.getElementById('server-response').submit(); - + document.getElementById('server-response').submit(); + } function resetButtons() From b12abf910377d673fdc260d33bf89ed0939ce206 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Wed, 18 May 2022 20:10:39 +1000 Subject: [PATCH 4/6] Updates for ach payments --- app/Services/Chart/ChartQueries.php | 2 +- .../gateways/stripe/ach/pay.blade.php | 207 +++++------------- 2 files changed, 52 insertions(+), 157 deletions(-) diff --git a/app/Services/Chart/ChartQueries.php b/app/Services/Chart/ChartQueries.php index 8771bdc2afbe..6224f98d3c53 100644 --- a/app/Services/Chart/ChartQueries.php +++ b/app/Services/Chart/ChartQueries.php @@ -117,7 +117,7 @@ trait ChartQueries GROUP BY invoices.date HAVING currency_id = :currency_id "), [ - 'company_currency' => $this->company->settings->currency_id, + 'company_currency' => (int)$this->company->settings->currency_id, 'currency_id' => $currency_id, 'company_id' => $this->company->id, 'start_date' => $start_date, diff --git a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php index 5a1fc11b44cb..8e7dbd193a7f 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php @@ -15,24 +15,24 @@ @section('gateway_content') + +
+ @csrf + + + + + + + + + +
@if(count($tokens) > 0) @include('portal.ninja2020.gateways.includes.payment_details') -
- @csrf - - - - - - - - - -
- @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.pay_with')]) @if(count($tokens) > 0) @foreach($tokens as $token) @@ -52,6 +52,11 @@ @else + @component('portal.ninja2020.components.general.card-element-single') + + + @endcomponent + @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.account_holder_name')]) @endcomponent @@ -71,87 +76,40 @@ {{ $slot ?? ctrans('texts.new_bank_account') }} - -
-
- - - -
-
- @endif @endsection - - - @push('footer') -@endpush +@endpush \ No newline at end of file From d29a1626cb14f188bb83f4ce7b392c77664cf5a7 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 19 May 2022 15:48:31 +1000 Subject: [PATCH 5/6] Refactor for Stripe ACH --- app/Http/Livewire/PaymentsTable.php | 2 +- app/Jobs/Mail/PaymentFailedMailer.php | 1 - app/Models/Gateway.php | 2 +- app/PaymentDrivers/Stripe/ACH.php | 190 ++++++++++++- .../Jobs/PaymentIntentFailureWebhook.php | 128 +++++++++ .../Stripe/Jobs/PaymentIntentWebhook.php | 6 +- app/PaymentDrivers/StripePaymentDriver.php | 22 +- app/Services/Payment/DeletePayment.php | 8 - public/js/clients/payments/stripe-ach.js | 2 +- public/mix-manifest.json | 2 +- resources/js/clients/payments/stripe-ach.js | 7 + .../gateways/stripe/ach/authorize.blade.php | 16 +- .../gateways/stripe/ach/pay.blade.php | 262 ++++++++++-------- .../gateways/stripe/credit_card/pay.blade.php | 27 +- 14 files changed, 518 insertions(+), 157 deletions(-) create mode 100644 app/PaymentDrivers/Stripe/Jobs/PaymentIntentFailureWebhook.php diff --git a/app/Http/Livewire/PaymentsTable.php b/app/Http/Livewire/PaymentsTable.php index e9b2cd3dbbfc..7c2cb8970840 100644 --- a/app/Http/Livewire/PaymentsTable.php +++ b/app/Http/Livewire/PaymentsTable.php @@ -41,7 +41,7 @@ class PaymentsTable extends Component { $query = Payment::query() ->with('type', 'client') - ->whereIn('status_id', [Payment::STATUS_COMPLETED, Payment::STATUS_PENDING, Payment::STATUS_REFUNDED, Payment::STATUS_PARTIALLY_REFUNDED]) + ->whereIn('status_id', [Payment::STATUS_FAILED, Payment::STATUS_COMPLETED, Payment::STATUS_PENDING, Payment::STATUS_REFUNDED, Payment::STATUS_PARTIALLY_REFUNDED]) ->where('company_id', $this->company->id) ->where('client_id', auth()->guard('contact')->user()->client->id) ->orderBy($this->sort_field, $this->sort_asc ? 'asc' : 'desc') diff --git a/app/Jobs/Mail/PaymentFailedMailer.php b/app/Jobs/Mail/PaymentFailedMailer.php index 77f7585bdac1..4188a3d035f3 100644 --- a/app/Jobs/Mail/PaymentFailedMailer.php +++ b/app/Jobs/Mail/PaymentFailedMailer.php @@ -72,7 +72,6 @@ class PaymentFailedMailer implements ShouldQueue */ public function handle() { - //Set DB MultiDB::setDb($this->company->db); App::setLocale($this->client->locale()); diff --git a/app/Models/Gateway.php b/app/Models/Gateway.php index 6df3448565e2..feddf40625de 100644 --- a/app/Models/Gateway.php +++ b/app/Models/Gateway.php @@ -103,7 +103,7 @@ class Gateway extends StaticModel case 20: return [ GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true], - GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable','charge.succeeded','payment_intent.succeeded']], + GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable','charge.succeeded','payment_intent.succeeded','charge.failed','payment_intent.payment_failed']], GatewayType::ALIPAY => ['refund' => false, 'token_billing' => false], GatewayType::APPLE_PAY => ['refund' => false, 'token_billing' => false], GatewayType::SOFORT => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']], //Stripe diff --git a/app/PaymentDrivers/Stripe/ACH.php b/app/PaymentDrivers/Stripe/ACH.php index 7e96cd3439be..f3b57eb03978 100644 --- a/app/PaymentDrivers/Stripe/ACH.php +++ b/app/PaymentDrivers/Stripe/ACH.php @@ -30,8 +30,12 @@ use App\PaymentDrivers\StripePaymentDriver; use App\Utils\Traits\MakesHash; use Exception; use Stripe\Customer; +use Stripe\Exception\ApiErrorException; +use Stripe\Exception\AuthenticationException; use Stripe\Exception\CardException; use Stripe\Exception\InvalidRequestException; +use Stripe\Exception\RateLimitException; +use Stripe\PaymentIntent; class ACH { @@ -45,6 +49,9 @@ class ACH $this->stripe = $stripe; } + /** + * Authorize a bank account - requires microdeposit verification + */ public function authorizeView(array $data) { $data['gateway'] = $this->stripe; @@ -134,7 +141,11 @@ class ACH return back()->with('error', $e->getMessage()); } } - + + /** + * Make a payment WITH instant verification. + */ + public function paymentView(array $data) { $data['gateway'] = $this->stripe; @@ -177,6 +188,9 @@ class ACH $description = "Payment with no invoice for amount {$amount} for client {$this->stripe->client->present()->name()}"; } + if(substr($cgt->token, 0, 2) === "pm") + return $this->paymentIntentTokenBilling($amount, $invoice, $description, $cgt, false); + $this->stripe->init(); $response = null; @@ -220,20 +234,178 @@ class ACH } + public function paymentIntentTokenBilling($amount, $invoice, $description, $cgt, $client_present = true) + { + $this->stripe->init(); + + try { + $data = [ + 'amount' => $this->stripe->convertToStripeAmount($amount, $this->stripe->client->currency()->precision, $this->stripe->client->currency()), + 'currency' => $this->stripe->client->getCurrencyCode(), + 'payment_method' => $cgt->token, + 'customer' => $cgt->gateway_customer_reference, + 'confirm' => true, + 'description' => $description, + 'metadata' => [ + 'payment_hash' => $this->stripe->payment_hash->hash, + 'gateway_type_id' => $cgt->gateway_type_id, + ], + ]; + + if($cgt->gateway_type_id == GatewayType::BANK_TRANSFER) + $data['payment_method_types'] = ['us_bank_account']; + + $response = $this->stripe->createPaymentIntent($data, $this->stripe->stripe_connect_auth); + + SystemLogger::dispatch($response, SystemLog::CATEGORY_GATEWAY_RESPONSE, SystemLog::EVENT_GATEWAY_SUCCESS, SystemLog::TYPE_STRIPE, $this->stripe->client, $this->stripe->client->company); + + }catch(\Exception $e) { + + $data =[ + 'status' => '', + 'error_type' => '', + 'error_code' => '', + 'param' => '', + 'message' => '', + ]; + + switch ($e) { + case ($e instanceof CardException): + $data['status'] = $e->getHttpStatus(); + $data['error_type'] = $e->getError()->type; + $data['error_code'] = $e->getError()->code; + $data['param'] = $e->getError()->param; + $data['message'] = $e->getError()->message; + break; + case ($e instanceof RateLimitException): + $data['message'] = 'Too many requests made to the API too quickly'; + break; + case ($e instanceof InvalidRequestException): + $data['message'] = 'Invalid parameters were supplied to Stripe\'s API'; + break; + case ($e instanceof AuthenticationException): + $data['message'] = 'Authentication with Stripe\'s API failed'; + break; + case ($e instanceof ApiErrorException): + $data['message'] = 'Network communication with Stripe failed'; + break; + + default: + $data['message'] = $e->getMessage(); + break; + } + + $this->stripe->processInternallyFailedPayment($this->stripe, $e); + + SystemLogger::dispatch($data, SystemLog::CATEGORY_GATEWAY_RESPONSE, SystemLog::EVENT_GATEWAY_FAILURE, SystemLog::TYPE_STRIPE, $this->stripe->client, $this->stripe->client->company); + } + + if (! $response) { + return false; + } + + $payment_method_type = PaymentType::ACH; + + $data = [ + 'gateway_type_id' => $cgt->gateway_type_id, + 'payment_type' => PaymentType::ACH, + 'transaction_reference' => $response->charges->data[0]->id, + 'amount' => $amount, + ]; + + $payment = $this->stripe->createPayment($data, Payment::STATUS_PENDING); + $payment->meta = $cgt->meta; + $payment->save(); + + $this->stripe->payment_hash->payment_id = $payment->id; + $this->stripe->payment_hash->save(); + + if($client_present){ + return redirect()->route('client.payments.show', ['payment' => $this->stripe->encodePrimaryKey($payment->id)]); + } + + return $payment; + } + public function handlePaymentIntentResponse($request) { - nlog($request->all()); - dd($request->all()); + + $response = json_decode($request->gateway_response); + $bank_account_response = json_decode($request->bank_account_response); + + $method = $bank_account_response->payment_method->us_bank_account; + $method->id = $response->payment_method; + $method->state = 'authorized'; + + $this->stripe->payment_hash = PaymentHash::where("hash", $request->input("payment_hash"))->first(); + + if($response->id && $response->status === "processing") { + $payment_intent = PaymentIntent::retrieve($response->id, $this->stripe->stripe_connect_auth); + + $state = [ + 'gateway_type_id' => GatewayType::BANK_TRANSFER, + 'amount' => $response->amount, + 'currency' => $response->currency, + 'customer' => $request->customer, + 'source' => $response->payment_method, + 'charge' => $response + ]; + + $this->stripe->payment_hash->data = array_merge((array)$this->stripe->payment_hash->data, $state); + $this->stripe->payment_hash->save(); + + $customer = $this->stripe->getCustomer($request->customer); + + $this->storePaymentMethod($method, GatewayType::BANK_TRANSFER, $customer); + + return $this->processPendingPayment($state, true); + } + + if($response->next_action){ + + } + } + public function processPendingPaymentIntent($state, $client_present = true) + { + $this->stripe->init(); + + $data = [ + 'payment_method' => $state['source'], + 'payment_type' => PaymentType::ACH, + 'amount' => $state['amount'], + 'transaction_reference' => $state['charge'], + 'gateway_type_id' => GatewayType::BANK_TRANSFER, + ]; + + $payment = $this->stripe->createPayment($data, Payment::STATUS_PENDING); + + SystemLogger::dispatch( + ['response' => $state, 'data' => $data], + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_SUCCESS, + SystemLog::TYPE_STRIPE, + $this->stripe->client, + $this->stripe->client->company, + ); + + if(!$client_present) + return $payment; + + return redirect()->route('client.payments.show', ['payment' => $this->stripe->encodePrimaryKey($payment->id)]); + } + + + public function paymentResponse($request) { $this->stripe->init(); //it may be a payment intent here. - if($request->input('client_secret')) - $this->handlePaymentIntentResponse($request); + if($request->input('client_secret') != '') + return $this->handlePaymentIntentResponse($request); $source = ClientGatewayToken::query() ->where('id', $this->decodePrimaryKey($request->source)) @@ -269,6 +441,9 @@ class ACH $description = "Payment with no invoice for amount {$amount} for client {$this->stripe->client->present()->name()}"; } + if(substr($source->token, 0, 2) === "pm") + return $this->paymentIntentTokenBilling($amount, $invoice, $description, $source); + try { $state['charge'] = \Stripe\Charge::create([ 'amount' => $state['amount'], @@ -297,6 +472,7 @@ class ACH } } + public function processPendingPayment($state, $client_present = true) { $this->stripe->init(); @@ -348,12 +524,14 @@ class ACH private function storePaymentMethod($method, $payment_method_id, $customer) { + $state = property_exists($method, 'state') ? $method->state : 'unauthorized'; + try { $payment_meta = new \stdClass; $payment_meta->brand = (string) \sprintf('%s (%s)', $method->bank_name, ctrans('texts.ach')); $payment_meta->last4 = (string) $method->last4; $payment_meta->type = GatewayType::BANK_TRANSFER; - $payment_meta->state = 'unauthorized'; + $payment_meta->state = $state; $data = [ 'payment_meta' => $payment_meta, diff --git a/app/PaymentDrivers/Stripe/Jobs/PaymentIntentFailureWebhook.php b/app/PaymentDrivers/Stripe/Jobs/PaymentIntentFailureWebhook.php new file mode 100644 index 000000000000..dd2cd68c125f --- /dev/null +++ b/app/PaymentDrivers/Stripe/Jobs/PaymentIntentFailureWebhook.php @@ -0,0 +1,128 @@ +stripe_request = $stripe_request; + $this->company_key = $company_key; + $this->company_gateway_id = $company_gateway_id; + } + + public function handle() + { + + MultiDB::findAndSetDbByCompanyKey($this->company_key); + + $company = Company::where('company_key', $this->company_key)->first(); + + foreach ($this->stripe_request as $transaction) { + + if(array_key_exists('payment_intent', $transaction)) + { + + $payment = Payment::query() + ->where('company_id', $company->id) + ->where(function ($query) use ($transaction) { + $query->where('transaction_reference', $transaction['payment_intent']) + ->orWhere('transaction_reference', $transaction['id']); + }) + ->first(); + + } + else + { + + $payment = Payment::query() + ->where('company_id', $company->id) + ->where('transaction_reference', $transaction['id']) + ->first(); + + } + + if ($payment) { + + $client = $payment->client; + + if($payment->status_id == Payment::STATUS_PENDING) + $payment->service()->deletePayment(); + + $payment->status_id = Payment::STATUS_FAILED; + $payment->save(); + + $payment_hash = PaymentHash::where('payment_id', $payment->id)->first(); + + if($payment_hash) + { + + $error = ctrans('texts.client_payment_failure_body', [ + 'invoice' => implode(",", $payment->invoices->pluck('number')->toArray()), + 'amount' => array_sum(array_column($payment_hash->invoices(), 'amount')) + $payment_hash->fee_total]); + + } + else + $error = "Payment for " . $payment->client->present()->name(). " for {$payment->amount} failed"; + + if(array_key_exists('failure_message', $transaction)){ + + $error .= "\n\n" .$transaction['failure_message']; + } + + PaymentFailedMailer::dispatch( + $payment_hash, + $client->company, + $client, + $error + ); + + + } + + } + + } + +} \ No newline at end of file diff --git a/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php b/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php index a7b11bda0377..dfbe20efcf49 100644 --- a/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php +++ b/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php @@ -53,11 +53,7 @@ class PaymentIntentWebhook implements ShouldQueue public function handle() { - // nlog($this->stripe_request); - // nlog(optional($this->stripe_request['object']['charges']['data'][0]['metadata'])['gateway_type_id']); - // nlog(optional($this->stripe_request['object']['charges']['data'][0]['metadata'])['payment_hash']); - // nlog(optional($this->stripe_request['object']['charges']['data'][0]['payment_method_details']['card'])['brand']); - + MultiDB::findAndSetDbByCompanyKey($this->company_key); $company = Company::where('company_key', $this->company_key)->first(); diff --git a/app/PaymentDrivers/StripePaymentDriver.php b/app/PaymentDrivers/StripePaymentDriver.php index cf47f1bd8e0b..ddcc5e274eb3 100644 --- a/app/PaymentDrivers/StripePaymentDriver.php +++ b/app/PaymentDrivers/StripePaymentDriver.php @@ -38,6 +38,7 @@ use App\PaymentDrivers\Stripe\EPS; use App\PaymentDrivers\Stripe\FPX; use App\PaymentDrivers\Stripe\GIROPAY; use App\PaymentDrivers\Stripe\ImportCustomers; +use App\PaymentDrivers\Stripe\Jobs\PaymentIntentFailureWebhook; use App\PaymentDrivers\Stripe\Jobs\PaymentIntentWebhook; use App\PaymentDrivers\Stripe\PRZELEWY24; use App\PaymentDrivers\Stripe\SEPA; @@ -47,6 +48,7 @@ use App\PaymentDrivers\Stripe\Utilities; use App\PaymentDrivers\Stripe\iDeal; use App\Utils\Traits\MakesHash; use Exception; +use Google\Service\ServiceConsumerManagement\CustomError; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Carbon; use Laracasts\Presenter\Exceptions\PresenterException; @@ -409,6 +411,16 @@ class StripePaymentDriver extends BaseDriver return $this->company_gateway->getPublishableKey(); } + public function getCustomer($customer_id) :?Customer + { + $customer = Customer::retrieve($customer_id, $this->stripe_connect_auth); + + if($customer) + return $customer; + + return false; + } + /** * Finds or creates a Stripe Customer object. * @@ -568,15 +580,21 @@ class StripePaymentDriver extends BaseDriver //payment_intent.succeeded - this will confirm or cancel the payment if($request->type === 'payment_intent.succeeded'){ - PaymentIntentWebhook::dispatch($request->data, $request->company_key, $this->company_gateway->id)->delay(now()->addSeconds(10)); + PaymentIntentWebhook::dispatch($request->data, $request->company_key, $this->company_gateway->id)->delay(now()->addSeconds(rand(2,10))); return response()->json([], 200); } + if(in_array($request->type, ['payment_intent.payment_failed','charge.failed'])){ + PaymentIntentFailureWebhook::dispatch($request->data, $request->company_key, $this->company_gateway->id)->delay(now()->addSeconds(rand(2,10))); + return response()->json([], 200); + } + + if ($request->type === 'charge.succeeded') { foreach ($request->data as $transaction) { - if(array_key_exists('payment_intent', $transaction)) + if(array_key_exists('payment_intent', $transaction) && $transaction['payment_intent']) { $payment = Payment::query() // ->where('company_id', $request->getCompany()->id) diff --git a/app/Services/Payment/DeletePayment.php b/app/Services/Payment/DeletePayment.php index 896e8baead67..86e304182b6e 100644 --- a/app/Services/Payment/DeletePayment.php +++ b/app/Services/Payment/DeletePayment.php @@ -46,14 +46,6 @@ class DeletePayment ->save(); } - //reverse paymentables->invoices - - //reverse paymentables->credits - - //set refunded to amount - - //set applied amount to 0 - private function cleanupPayment() { $this->payment->is_deleted = true; diff --git a/public/js/clients/payments/stripe-ach.js b/public/js/clients/payments/stripe-ach.js index a0cd224a2119..4cad4ced3f04 100755 --- a/public/js/clients/payments/stripe-ach.js +++ b/public/js/clients/payments/stripe-ach.js @@ -1,2 +1,2 @@ /*! For license information please see stripe-ach.js.LICENSE.txt */ -(()=>{function e(e,t){for(var n=0;n svg").classList.add("hidden"),document.querySelector("#save-button > span").classList.remove("hidden"),r.errors.textContent="",r.errors.textContent=e,r.errors.hidden=!1})),t(this,"handleSuccess",(function(e){document.getElementById("gateway_response").value=JSON.stringify(e),document.getElementById("server_response").submit()})),t(this,"handleSubmit",(function(e){document.getElementById("save-button").disabled=!0,document.querySelector("#save-button > svg").classList.remove("hidden"),document.querySelector("#save-button > span").classList.add("hidden"),e.preventDefault(),r.errors.textContent="",r.errors.hidden=!0,r.stripe.createToken("bank_account",r.getFormData()).then((function(e){return e.hasOwnProperty("error")?r.handleError(e.error.message):r.handleSuccess(e)}))})),this.errors=document.getElementById("errors"),this.key=document.querySelector('meta[name="stripe-publishable-key"]').content,this.stripe_connect=null===(e=document.querySelector('meta[name="stripe-account-id"]'))||void 0===e?void 0:e.content}var r,o,u;return r=n,(o=[{key:"handle",value:function(){var e=this;document.getElementById("save-button").addEventListener("click",(function(t){return e.handleSubmit(t)}))}}])&&e(r.prototype,o),u&&e(r,u),n}())).setupStripe().handle()})(); \ No newline at end of file +(()=>{function e(e,t){for(var n=0;n svg").classList.add("hidden"),document.querySelector("#save-button > span").classList.remove("hidden"),r.errors.textContent="",r.errors.textContent=e,r.errors.hidden=!1})),t(this,"handleSuccess",(function(e){document.getElementById("gateway_response").value=JSON.stringify(e),document.getElementById("server_response").submit()})),t(this,"handleSubmit",(function(e){if(!document.getElementById("accept-terms").checked)return errors.textContent="You must accept the mandate terms prior to making payment.",void(errors.hidden=!1);document.getElementById("save-button").disabled=!0,document.querySelector("#save-button > svg").classList.remove("hidden"),document.querySelector("#save-button > span").classList.add("hidden"),e.preventDefault(),r.errors.textContent="",r.errors.hidden=!0,r.stripe.createToken("bank_account",r.getFormData()).then((function(e){return e.hasOwnProperty("error")?r.handleError(e.error.message):r.handleSuccess(e)}))})),this.errors=document.getElementById("errors"),this.key=document.querySelector('meta[name="stripe-publishable-key"]').content,this.stripe_connect=null===(e=document.querySelector('meta[name="stripe-account-id"]'))||void 0===e?void 0:e.content}var r,o,u;return r=n,(o=[{key:"handle",value:function(){var e=this;document.getElementById("save-button").addEventListener("click",(function(t){return e.handleSubmit(t)}))}}])&&e(r.prototype,o),u&&e(r,u),n}())).setupStripe().handle()})(); \ No newline at end of file diff --git a/public/mix-manifest.json b/public/mix-manifest.json index b38b596ecded..9068d75070a0 100755 --- a/public/mix-manifest.json +++ b/public/mix-manifest.json @@ -2,7 +2,7 @@ "/js/app.js": "/js/app.js?id=0e3959ab851d3350364d", "/js/clients/payment_methods/authorize-authorize-card.js": "/js/clients/payment_methods/authorize-authorize-card.js?id=de4468c682d6861847de", "/js/clients/payments/authorize-credit-card-payment.js": "/js/clients/payments/authorize-credit-card-payment.js?id=cfe5de1cf87a0b01568d", - "/js/clients/payments/stripe-ach.js": "/js/clients/payments/stripe-ach.js?id=a5f14c885c3aeef6c744", + "/js/clients/payments/stripe-ach.js": "/js/clients/payments/stripe-ach.js?id=50d964c4a3ffa7f2f99f", "/js/clients/invoices/action-selectors.js": "/js/clients/invoices/action-selectors.js?id=6b79265cbb8c963eef19", "/js/clients/invoices/payment.js": "/js/clients/invoices/payment.js?id=2cccf9e51b60a0ab17b8", "/js/clients/payments/stripe-sofort.js": "/js/clients/payments/stripe-sofort.js?id=22fc06e698dea2c3bdf3", diff --git a/resources/js/clients/payments/stripe-ach.js b/resources/js/clients/payments/stripe-ach.js index e8f84827f78b..7002c83fecdb 100644 --- a/resources/js/clients/payments/stripe-ach.js +++ b/resources/js/clients/payments/stripe-ach.js @@ -70,6 +70,13 @@ class AuthorizeACH { }; handleSubmit = (e) => { + + if (!document.getElementById('accept-terms').checked) { + errors.textContent = "You must accept the mandate terms prior to making payment."; + errors.hidden = false; + return; + } + document.getElementById('save-button').disabled = true; document.querySelector('#save-button > svg').classList.remove('hidden'); document.querySelector('#save-button > span').classList.add('hidden'); diff --git a/resources/views/portal/ninja2020/gateways/stripe/ach/authorize.blade.php b/resources/views/portal/ninja2020/gateways/stripe/ach/authorize.blade.php index c5e03cec9f8d..d88aeb847fa8 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/ach/authorize.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/ach/authorize.blade.php @@ -27,6 +27,10 @@ +
+

Adding a bank account here requires verification, which may take several days. In order to use Instant Verification please pay an invoice first, this process will automatically verify your bank account.

+
+ @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.account_holder_type')]) @@ -39,14 +43,18 @@ @endcomponent @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.account_holder_name')]) - + @endcomponent @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.country')]) @endcomponent @@ -55,7 +63,11 @@ @endcomponent diff --git a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php index 8e7dbd193a7f..fe497eb72385 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php @@ -27,6 +27,7 @@ + @if(count($tokens) > 0) @@ -35,17 +36,21 @@ @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.pay_with')]) @if(count($tokens) > 0) +
    @foreach($tokens as $token) - +
  • + +
  • @endforeach - @endisset +
+ @endif @endcomponent @include('portal.ninja2020.gateways.includes.pay_now') @@ -82,121 +87,140 @@ @endsection @push('footer') - + - + let newBankButton = document.getElementById('new-bank'); + newBankButton.disabled = true; + newBankButton.querySelector('svg').classList.remove('hidden'); + newBankButton.querySelector('span').classList.add('hidden'); + + ev.preventDefault(); + const accountHolderNameField = document.getElementById('account-holder-name-field'); + const emailField = document.getElementById('email-field'); + const clientSecret = document.querySelector('meta[name="client_secret"]')?.content; + // Calling this method will open the instant verification dialog. + stripe.collectBankAccountForPayment({ + clientSecret: clientSecret, + params: { + payment_method_type: 'us_bank_account', + payment_method_data: { + billing_details: { + name: accountHolderNameField.value, + email: emailField.value, + }, + }, + }, + expand: ['payment_method'], + }) + .then(({paymentIntent, error}) => { + if (error) { + + console.error(error.message); + errors.textContent = error.message; + errors.hidden = false; + resetButtons(); + + // PaymentMethod collection failed for some reason. + } else if (paymentIntent.status === 'requires_payment_method') { + // Customer canceled the hosted verification modal. Present them with other + // payment method type options. + + errors.textContent = "We were unable to process the payment with this account, please try another one."; + errors.hidden = false; + resetButtons(); + return; + + } else if (paymentIntent.status === 'requires_confirmation') { + + let bank_account_response = document.getElementById('bank_account_response'); + bank_account_response.value = JSON.stringify(paymentIntent); + + confirmPayment(stripe, clientSecret); + } + + }); + }); + + function confirmPayment(stripe, clientSecret){ + stripe.confirmUsBankAccountPayment(clientSecret) + .then(({paymentIntent, error}) => { + console.log(paymentIntent); + if (error) { + console.error(error.message); + // The payment failed for some reason. + } else if (paymentIntent.status === "requires_payment_method") { + // Confirmation failed. Attempt again with a different payment method. + + errors.textContent = "We were unable to process the payment with this account, please try another one."; + errors.hidden = false; + resetButtons(); + + } else if (paymentIntent.status === "processing") { + // Confirmation succeeded! The account will be debited. + + let gateway_response = document.getElementById('gateway_response'); + gateway_response.value = JSON.stringify(paymentIntent); + document.getElementById('server-response').submit(); + + } else if (paymentIntent.next_action?.type === "verify_with_microdeposits") { + + } + }); + + } + + function resetButtons() + { + + let newBankButton = document.getElementById('new-bank'); + newBankButton.disabled = false; + newBankButton.querySelector('svg').classList.add('hidden'); + newBankButton.querySelector('span').classList.remove('hidden'); + + } + @endpush \ No newline at end of file diff --git a/resources/views/portal/ninja2020/gateways/stripe/credit_card/pay.blade.php b/resources/views/portal/ninja2020/gateways/stripe/credit_card/pay.blade.php index 8a91de072bb8..dc35139ad13c 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/credit_card/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/credit_card/pay.blade.php @@ -48,28 +48,35 @@ @include('portal.ninja2020.gateways.includes.payment_details') @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.pay_with')]) +
    @if(count($tokens) > 0) @foreach($tokens as $token) +
  • +
  • @endforeach @endisset - +
  • + +
  • +
+ @endcomponent @include('portal.ninja2020.gateways.stripe.includes.card_widget') From 6fe0fe1a5c40fe8ebcf889dab3898279cfa79ebf Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 19 May 2022 16:00:06 +1000 Subject: [PATCH 6/6] Refactor for Stripe ACH --- .../views/portal/ninja2020/gateways/stripe/ach/pay.blade.php | 1 + .../portal/ninja2020/gateways/stripe/credit_card/pay.blade.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php index fe497eb72385..31e56758f9d0 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php @@ -108,6 +108,7 @@ payNowButton.querySelector('span').classList.add('hidden'); document.getElementById('server-response').submit(); }); + } document.getElementById('new-bank').addEventListener('click', (ev) => { diff --git a/resources/views/portal/ninja2020/gateways/stripe/credit_card/pay.blade.php b/resources/views/portal/ninja2020/gateways/stripe/credit_card/pay.blade.php index dc35139ad13c..8508977d1f48 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/credit_card/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/credit_card/pay.blade.php @@ -11,7 +11,6 @@ @endphp - @section('gateway_head') @if($gateway->company_gateway->getConfigField('account_id')) @@ -81,6 +80,7 @@ @include('portal.ninja2020.gateways.stripe.includes.card_widget') @include('portal.ninja2020.gateways.includes.pay_now') + @endsection @section('gateway_footer')