diff --git a/app/Http/Livewire/PaymentMethodsTable.php b/app/Http/Livewire/PaymentMethodsTable.php index d50a6f35372f..b4906176fd05 100644 --- a/app/Http/Livewire/PaymentMethodsTable.php +++ b/app/Http/Livewire/PaymentMethodsTable.php @@ -37,7 +37,6 @@ class PaymentMethodsTable extends Component ->where('company_id', $this->company->id) ->where('client_id', $this->client->id) ->orderBy($this->sort_field, $this->sort_asc ? 'asc' : 'desc') - ->withTrashed() ->paginate($this->per_page); return render('components.livewire.payment-methods-table', [ diff --git a/app/Models/Gateway.php b/app/Models/Gateway.php index 7b65f9fb93b1..e8808b943e67 100644 --- a/app/Models/Gateway.php +++ b/app/Models/Gateway.php @@ -131,6 +131,10 @@ class Gateway extends StaticModel GatewayType::CREDIT_CARD => ['refund' => false, 'token_billing' => true], //Square ]; break; + case 52: + return [ + GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true] // GoCardless + ]; break; default: return []; diff --git a/app/Models/SystemLog.php b/app/Models/SystemLog.php index 47794de1a79c..316ada10063b 100644 --- a/app/Models/SystemLog.php +++ b/app/Models/SystemLog.php @@ -73,6 +73,7 @@ class SystemLog extends Model const TYPE_MOLLIE = 312; const TYPE_EWAY = 313; const TYPE_SQUARE = 320; + const TYPE_GOCARDLESS = 321; const TYPE_QUOTA_EXCEEDED = 400; const TYPE_UPSTREAM_FAILURE = 401; diff --git a/app/PaymentDrivers/GoCardless/ACH.php b/app/PaymentDrivers/GoCardless/ACH.php new file mode 100644 index 000000000000..2dd94a453b25 --- /dev/null +++ b/app/PaymentDrivers/GoCardless/ACH.php @@ -0,0 +1,256 @@ +go_cardless = $go_cardless; + + $this->go_cardless->init(); + } + + /** + * Authorization page for ACH. + * + * @param array $data + * @return Redirector|RedirectResponse + */ + public function authorizeView(array $data) + { + $session_token = \Illuminate\Support\Str::uuid()->toString(); + + try { + $redirect = $this->go_cardless->gateway->redirectFlows()->create([ + "params" => [ + "session_token" => $session_token, + "success_redirect_url" => route('client.payment_methods.confirm', [ + 'method' => GatewayType::BANK_TRANSFER, + 'session_token' => $session_token, + ]), + "prefilled_customer" => [ + "given_name" => auth('contact')->user()->first_name, + "family_name" => auth('contact')->user()->last_name, + "email" => auth('contact')->user()->email, + "address_line1" => auth('contact')->user()->client->address1, + "city" => auth('contact')->user()->client->city, + "postal_code" => auth('contact')->user()->client->postal_code, + ], + ], + ]); + + return redirect( + $redirect->redirect_url + ); + } catch (\Exception $exception) { + return $this->processUnsuccessfulAuthorization($exception); + } + } + + /** + * Handle unsuccessful authorization. + * + * @param Exception $exception + * @throws PaymentFailed + * @return void + */ + public function processUnsuccessfulAuthorization(\Exception $exception): void + { + SystemLogger::dispatch( + $exception->getMessage(), + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_FAILURE, + SystemLog::TYPE_GOCARDLESS, + $this->go_cardless->client, + $this->go_cardless->client->company, + ); + + throw new PaymentFailed($exception->getMessage(), $exception->getCode()); + } + + /** + * Handle ACH post-redirect authorization. + * + * @param Request $request + * @return RedirectResponse|void + */ + public function authorizeResponse(Request $request) + { + try { + $redirect_flow = $this->go_cardless->gateway->redirectFlows()->complete( + $request->redirect_flow_id, + ['params' => [ + 'session_token' => $request->session_token + ]], + ); + + $payment_meta = new \stdClass; + $payment_meta->brand = ctrans('texts.ach'); + $payment_meta->type = GatewayType::BANK_TRANSFER; + $payment_meta->state = 'authorized'; + + $data = [ + 'payment_meta' => $payment_meta, + 'token' => $redirect_flow->links->mandate, + 'payment_method_id' => GatewayType::BANK_TRANSFER, + ]; + + $payment_method = $this->go_cardless->storeGatewayToken($data, ['gateway_customer_reference' => $redirect_flow->links->customer]); + + return redirect()->route('client.payment_methods.show', $payment_method->hashed_id); + } catch (\Exception $exception) { + return $this->processUnsuccessfulAuthorization($exception); + } + } + + /** + * Show the payment page for ACH. + * + * @param array $data + * @return View + */ + public function paymentView(array $data): View + { + $data['gateway'] = $this->go_cardless; + $data['amount'] = $this->go_cardless->convertToGoCardlessAmount($data['total']['amount_with_fee'], $this->go_cardless->client->currency()->precision); + $data['currency'] = $this->go_cardless->client->getCurrencyCode(); + + return render('gateways.gocardless.ach.pay', $data); + } + + /** + * Process payments for ACH. + * + * @param PaymentResponseRequest $request + * @return RedirectResponse|void + */ + public function paymentResponse(PaymentResponseRequest $request) + { + $token = ClientGatewayToken::find( + $this->decodePrimaryKey($request->source) + )->firstOrFail(); + + try { + $payment = $this->go_cardless->gateway->payments()->create([ + 'params' => [ + 'amount' => $request->amount, + 'currency' => $request->currency, + 'metadata' => [ + 'payment_hash' => $this->go_cardless->payment_hash->hash, + ], + 'links' => [ + 'mandate' => $token->token, + ], + ], + ]); + + + if ($payment->status === 'pending_submission') { + return $this->processPendingPayment($payment, ['token' => $token->hashed_id]); + } + + return $this->processUnsuccessfulPayment($payment); + } catch (\Exception $exception) { + throw new PaymentFailed($exception->getMessage(), $exception->getCode()); + } + } + + /** + * Handle pending payments for ACH. + * + * @param ResourcesPayment $payment + * @param array $data + * @return RedirectResponse + */ + public function processPendingPayment(\GoCardlessPro\Resources\Payment $payment, array $data = []) + { + $data = [ + 'payment_method' => $data['token'], + 'payment_type' => PaymentType::ACH, + 'amount' => $this->go_cardless->payment_hash->data->amount_with_fee, + 'transaction_reference' => $payment->id, + 'gateway_type_id' => GatewayType::BANK_TRANSFER, + ]; + + $payment = $this->go_cardless->createPayment($data, Payment::STATUS_PENDING); + + SystemLogger::dispatch( + ['response' => $payment, 'data' => $data], + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_SUCCESS, + SystemLog::TYPE_GOCARDLESS, + $this->go_cardless->client, + $this->go_cardless->client->company, + ); + + return redirect()->route('client.payments.show', ['payment' => $this->go_cardless->encodePrimaryKey($payment->id)]); + } + + /** + * Process unsuccessful payments for ACH. + * + * @param ResourcesPayment $payment + * @return never + */ + public function processUnsuccessfulPayment(\GoCardlessPro\Resources\Payment $payment) + { + PaymentFailureMailer::dispatch($this->go_cardless->client, $payment->status, $this->go_cardless->client->company, $this->go_cardless->payment_hash->data->amount_with_fee); + + PaymentFailureMailer::dispatch( + $this->go_cardless->client, + $payment, + $this->go_cardless->client->company, + $payment->amount + ); + + $message = [ + 'server_response' => $payment, + 'data' => $this->go_cardless->payment_hash->data, + ]; + + SystemLogger::dispatch( + $message, + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_FAILURE, + SystemLog::TYPE_GOCARDLESS, + $this->go_cardless->client, + $this->go_cardless->client->company, + ); + + throw new PaymentFailed('Failed to process the payment.', 500); + } +} diff --git a/app/PaymentDrivers/GoCardlessPaymentDriver.php b/app/PaymentDrivers/GoCardlessPaymentDriver.php new file mode 100644 index 000000000000..770a272c7d37 --- /dev/null +++ b/app/PaymentDrivers/GoCardlessPaymentDriver.php @@ -0,0 +1,253 @@ + \App\PaymentDrivers\GoCardless\ACH::class, + ]; + + const SYSTEM_LOG_TYPE = SystemLog::TYPE_GOCARDLESS; + + public function setPaymentMethod($payment_method_id) + { + $class = self::$methods[$payment_method_id]; + + $this->payment_method = new $class($this); + + return $this; + } + + public function gatewayTypes(): array + { + $types = []; + + if ( + $this->client + && isset($this->client->country) + && in_array($this->client->country->iso_3166_3, ['USA']) + ) { + $types[] = GatewayType::BANK_TRANSFER; + } + + return $types; + } + + public function init(): self + { + $this->gateway = new \GoCardlessPro\Client([ + 'access_token' => $this->company_gateway->getConfigField('accessToken'), + 'environment' => $this->company_gateway->getConfigField('testMode') ? \GoCardlessPro\Environment::SANDBOX : \GoCardlessPro\Environment::LIVE, + ]); + + return $this; + } + + public function authorizeView(array $data) + { + return $this->payment_method->authorizeView($data); + } + + public function authorizeResponse($request) + { + return $this->payment_method->authorizeResponse($request); + } + + public function processPaymentView(array $data) + { + return $this->payment_method->paymentView($data); + } + + public function processPaymentResponse($request) + { + return $this->payment_method->paymentResponse($request); + } + + public function refund(Payment $payment, $amount, $return_client_response = false) + { + // .. + } + + public function tokenBilling(ClientGatewayToken $cgt, PaymentHash $payment_hash) + { + $amount = array_sum(array_column($payment_hash->invoices(), 'amount')) + $payment_hash->fee_total; + $converted_amount = $this->convertToGoCardlessAmount($amount, $this->client->currency()->precision); + + $this->init(); + + try { + $payment = $this->gateway->payments()->create([ + 'params' => [ + 'amount' => $converted_amount, + 'currency' => $this->client->getCurrencyCode(), + 'metadata' => [ + 'payment_hash' => $this->payment_hash->hash, + ], + 'links' => [ + 'mandate' => $cgt->token, + ], + ], + ]); + + + if ($payment->status === 'pending_submission') { + $this->confirmGatewayFee(); + + $data = [ + 'payment_method' => $cgt->hashed_id, + 'payment_type' => PaymentType::ACH, + 'amount' => $amount, + 'transaction_reference' => $payment->id, + 'gateway_type_id' => GatewayType::BANK_TRANSFER, + ]; + + $payment = $this->createPayment($data, Payment::STATUS_COMPLETED); + + SystemLogger::dispatch( + ['response' => $payment, 'data' => $data], + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_SUCCESS, + SystemLog::TYPE_GOCARDLESS, + $this->client, + $this->client->company + ); + + return $payment; + } + + PaymentFailureMailer::dispatch( + $this->client, + $payment->status, + $this->client->company, + $amount + ); + + $message = [ + 'server_response' => $payment, + 'data' => $payment_hash->data, + ]; + + SystemLogger::dispatch( + $message, + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_FAILURE, + SystemLog::TYPE_GOCARDLESS, + $this->client, + $this->client->company + ); + + return false; + } catch (\Exception $exception) { + $this->unWindGatewayFees($this->payment_hash); + + $data = [ + 'status' => '', + 'error_type' => '', + 'error_code' => $exception->getCode(), + 'param' => '', + 'message' => $exception->getMessage(), + ]; + + SystemLogger::dispatch($data, SystemLog::CATEGORY_GATEWAY_RESPONSE, SystemLog::EVENT_GATEWAY_FAILURE, SystemLog::TYPE_GOCARDLESS, $this->client, $this->client->company); + } + } + + public function convertToGoCardlessAmount($amount, $precision) + { + return \round(($amount * pow(10, $precision)), 0); + } + + public function detach(ClientGatewayToken $token) + { + $this->init(); + + try { + $this->gateway->mandates()->cancel($token->token); + } catch (\Exception $e) { + nlog($e->getMessage()); + + SystemLogger::dispatch( + [ + 'server_response' => $e->getMessage(), + 'data' => request()->all(), + ], + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_FAILURE, + SystemLog::TYPE_GOCARDLESS, + $this->client, + $this->client->company + ); + } + } + + public function processWebhookRequest(PaymentWebhookRequest $request) + { + // Allow app to catch up with webhook request. + sleep(2); + + $this->init(); + + foreach ($request->events as $event) { + if ($event['action'] === 'confirmed') { + $payment = Payment::query() + ->where('transaction_reference', $event['links']['payment']) + ->where('company_id', $request->getCompany()->id) + ->first(); + + if ($payment) { + $payment->status_id = Payment::STATUS_COMPLETED; + $payment->save(); + } + } + + if ($event['action'] === 'failed') { + // Update invoices, etc? + + $payment = Payment::query() + ->where('transaction_reference', $event['links']['payment']) + ->where('company_id', $request->getCompany()->id) + ->first(); + + if ($payment) { + $payment->status_id = Payment::STATUS_FAILED; + $payment->save(); + } + } + } + + return response()->json([], 200); + } +} diff --git a/database/migrations/2021_09_28_154647_activate_gocardless_payment_driver.php b/database/migrations/2021_09_28_154647_activate_gocardless_payment_driver.php new file mode 100644 index 000000000000..ace55614bdb8 --- /dev/null +++ b/database/migrations/2021_09_28_154647_activate_gocardless_payment_driver.php @@ -0,0 +1,33 @@ +provider = 'GoCardless'; + $gateway->visible = true; + $gateway->save(); + } + } +} diff --git a/database/seeders/PaymentLibrariesSeeder.php b/database/seeders/PaymentLibrariesSeeder.php index 818431f7de96..d512c31b7350 100644 --- a/database/seeders/PaymentLibrariesSeeder.php +++ b/database/seeders/PaymentLibrariesSeeder.php @@ -76,7 +76,7 @@ class PaymentLibrariesSeeder extends Seeder ['id' => 49, 'name' => 'WePay', 'provider' => 'WePay', 'is_offsite' => false, 'sort_order' => 3, 'key' => '8fdeed552015b3c7b44ed6c8ebd9e992', 'fields' => '{"accountId":"","accessToken":"","type":"goods","testMode":false,"feePayer":"payee"}'], ['id' => 50, 'name' => 'Braintree', 'provider' => 'Braintree', 'sort_order' => 3, 'key' => 'f7ec488676d310683fb51802d076d713', 'fields' => '{"merchantId":"","merchantAccountId":"","publicKey":"","privateKey":"","testMode":false}'], ['id' => 51, 'name' => 'FirstData Payeezy', 'provider' => 'FirstData_Payeezy', 'key' => '30334a52fb698046572c627ca10412e8', 'fields' => '{"gatewayId":"","password":"","keyId":"","hmac":"","testMode":false}'], - ['id' => 52, 'name' => 'GoCardless', 'provider' => 'GoCardlessV2\Redirect', 'sort_order' => 9, 'is_offsite' => true, 'key' => 'b9886f9257f0c6ee7c302f1c74475f6c', 'fields' => '{"accessToken":"","webhookSecret":"","testMode":true}'], + ['id' => 52, 'name' => 'GoCardless', 'provider' => 'GoCardless', 'sort_order' => 9, 'is_offsite' => true, 'key' => 'b9886f9257f0c6ee7c302f1c74475f6c', 'fields' => '{"accessToken":"","webhookSecret":"","testMode":true}'], ['id' => 53, 'name' => 'PagSeguro', 'provider' => 'PagSeguro', 'key' => 'ef498756b54db63c143af0ec433da803', 'fields' => '{"email":"","token":"","sandbox":false}'], ['id' => 54, 'name' => 'PAYMILL', 'provider' => 'Paymill', 'key' => 'ca52f618a39367a4c944098ebf977e1c', 'fields' => '{"apiKey":""}'], ['id' => 55, 'name' => 'Custom', 'provider' => 'Custom', 'is_offsite' => true, 'sort_order' => 21, 'key' => '54faab2ab6e3223dbe848b1686490baa', 'fields' => '{"name":"","text":""}'], @@ -97,7 +97,7 @@ class PaymentLibrariesSeeder extends Seeder Gateway::query()->update(['visible' => 0]); - Gateway::whereIn('id', [1,7,11,15,20,39,46,55,50,57])->update(['visible' => 1]); + Gateway::whereIn('id', [1,7,11,15,20,39,46,55,50,57,52])->update(['visible' => 1]); if (Ninja::isHosted()) { Gateway::whereIn('id', [20])->update(['visible' => 0]); diff --git a/resources/views/portal/ninja2020/gateways/gocardless/ach/pay.blade.php b/resources/views/portal/ninja2020/gateways/gocardless/ach/pay.blade.php new file mode 100644 index 000000000000..98dd992b5b5a --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/gocardless/ach/pay.blade.php @@ -0,0 +1,55 @@ +@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/routes/client.php b/routes/client.php index f2e0cd8485a3..c824bbe303c9 100644 --- a/routes/client.php +++ b/routes/client.php @@ -56,6 +56,8 @@ Route::group(['middleware' => ['auth:contact', 'locale', 'check_client_existence Route::get('payment_methods/{payment_method}/verification', 'ClientPortal\PaymentMethodController@verify')->name('payment_methods.verification'); Route::post('payment_methods/{payment_method}/verification', 'ClientPortal\PaymentMethodController@processVerification'); + Route::get('payment_methods/confirm', 'ClientPortal\PaymentMethodController@store')->name('payment_methods.confirm'); + Route::resource('payment_methods', 'ClientPortal\PaymentMethodController')->except(['edit', 'update']); Route::match(['GET', 'POST'], 'quotes/approve', 'ClientPortal\QuoteController@bulk')->name('quotes.bulk'); diff --git a/tests/Browser/ClientPortal/Gateways/GoCardless/ACHTest.php b/tests/Browser/ClientPortal/Gateways/GoCardless/ACHTest.php new file mode 100644 index 000000000000..5867f8329f35 --- /dev/null +++ b/tests/Browser/ClientPortal/Gateways/GoCardless/ACHTest.php @@ -0,0 +1,42 @@ +driver->manage()->deleteAllCookies(); + } + + $this->disableCompanyGateways(); + + CompanyGateway::where('gateway_key', 'b9886f9257f0c6ee7c302f1c74475f6c')->restore(); + + $this->browse(function (Browser $browser) { + $browser + ->visit(new Login()) + ->auth(); + }); + } + + public function testPayingWithNoPreauthorizedIsntPossible() + { + $this->browse(function (Browser $browser) { + $browser + ->visitRoute('client.invoices.index') + ->click('@pay-now') + ->press('Pay Now') + ->clickLink('Bank Transfer') + ->assertSee('To pay with a bank account, first you have to add it as payment method.'); + }); + } +}