Merge pull request #6827 from beganovich/v5-726

Stripe: SEPA improvements
This commit is contained in:
David Bomba 2021-10-14 05:41:07 +11:00 committed by GitHub
commit f038073b4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 360 additions and 109 deletions

View File

@ -11,15 +11,15 @@
namespace App\PaymentDrivers\Stripe; namespace App\PaymentDrivers\Stripe;
use App\Exceptions\PaymentFailed;
use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest; use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest;
use App\PaymentDrivers\StripePaymentDriver;
use App\Jobs\Mail\PaymentFailureMailer; use App\Jobs\Mail\PaymentFailureMailer;
use App\Jobs\Util\SystemLogger; use App\Jobs\Util\SystemLogger;
use App\Models\GatewayType; use App\Models\GatewayType;
use App\Models\Payment; use App\Models\Payment;
use App\Models\PaymentType; use App\Models\PaymentType;
use App\Models\SystemLog; use App\Models\SystemLog;
use App\Exceptions\PaymentFailed; use App\PaymentDrivers\StripePaymentDriver;
class SEPA class SEPA
{ {
@ -29,6 +29,8 @@ class SEPA
public function __construct(StripePaymentDriver $stripe) public function __construct(StripePaymentDriver $stripe)
{ {
$this->stripe = $stripe; $this->stripe = $stripe;
$this->stripe->init();
} }
public function authorizeView($data) public function authorizeView($data)
@ -36,7 +38,8 @@ class SEPA
return render('gateways.stripe.sepa.authorize', $data); return render('gateways.stripe.sepa.authorize', $data);
} }
public function paymentView(array $data) { public function paymentView(array $data)
{
$data['gateway'] = $this->stripe; $data['gateway'] = $this->stripe;
$data['payment_method_id'] = GatewayType::SEPA; $data['payment_method_id'] = GatewayType::SEPA;
$data['stripe_amount'] = $this->stripe->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe->client->currency()->precision, $this->stripe->client->currency()); $data['stripe_amount'] = $this->stripe->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe->client->currency()->precision, $this->stripe->client->currency());
@ -52,11 +55,19 @@ class SEPA
'setup_future_usage' => 'off_session', 'setup_future_usage' => 'off_session',
'customer' => $this->stripe->findOrCreateCustomer(), 'customer' => $this->stripe->findOrCreateCustomer(),
'description' => $this->stripe->decodeUnicodeString(ctrans('texts.invoices') . ': ' . collect($data['invoices'])->pluck('invoice_number')), 'description' => $this->stripe->decodeUnicodeString(ctrans('texts.invoices') . ': ' . collect($data['invoices'])->pluck('invoice_number')),
]); ]);
$data['pi_client_secret'] = $intent->client_secret; $data['pi_client_secret'] = $intent->client_secret;
if (count($data['tokens']) > 0) {
$setup_intent = $this->stripe->stripe->setupIntents->create([
'payment_method_types' => ['sepa_debit'],
'customer' => $this->stripe->findOrCreateCustomer()->id,
]);
$data['si_client_secret'] = $setup_intent->client_secret;
}
$this->stripe->payment_hash->data = array_merge((array) $this->stripe->payment_hash->data, ['stripe_amount' => $data['stripe_amount']]); $this->stripe->payment_hash->data = array_merge((array) $this->stripe->payment_hash->data, ['stripe_amount' => $data['stripe_amount']]);
$this->stripe->payment_hash->save(); $this->stripe->payment_hash->save();
@ -65,28 +76,24 @@ class SEPA
public function paymentResponse(PaymentResponseRequest $request) public function paymentResponse(PaymentResponseRequest $request)
{ {
$gateway_response = json_decode($request->gateway_response); $gateway_response = json_decode($request->gateway_response);
$this->stripe->payment_hash->data = array_merge((array) $this->stripe->payment_hash->data, $request->all()); $this->stripe->payment_hash->data = array_merge((array) $this->stripe->payment_hash->data, $request->all());
$this->stripe->payment_hash->save(); $this->stripe->payment_hash->save();
if (property_exists($gateway_response, 'status') && $gateway_response->status == 'processing') { if (property_exists($gateway_response, 'status') && ($gateway_response->status == 'processing' || $gateway_response->status === 'succeeded')) {
if ($request->store_card) {
$this->stripe->init();
$this->storePaymentMethod($gateway_response); $this->storePaymentMethod($gateway_response);
}
return $this->processSuccessfulPayment($gateway_response->id); return $this->processSuccessfulPayment($gateway_response->id);
} }
return $this->processUnsuccessfulPayment(); return $this->processUnsuccessfulPayment();
} }
public function processSuccessfulPayment(string $payment_intent) public function processSuccessfulPayment(string $payment_intent)
{ {
$this->stripe->init();
$data = [ $data = [
'payment_method' => $payment_intent, 'payment_method' => $payment_intent,
'payment_type' => PaymentType::SEPA, 'payment_type' => PaymentType::SEPA,
@ -95,7 +102,7 @@ class SEPA
'gateway_type_id' => GatewayType::SEPA, 'gateway_type_id' => GatewayType::SEPA,
]; ];
$this->stripe->createPayment($data, Payment::STATUS_PENDING); $payment = $this->stripe->createPayment($data, Payment::STATUS_PENDING);
SystemLogger::dispatch( SystemLogger::dispatch(
['response' => $this->stripe->payment_hash->data, 'data' => $data], ['response' => $this->stripe->payment_hash->data, 'data' => $data],
@ -106,7 +113,7 @@ class SEPA
$this->stripe->client->company, $this->stripe->client->company,
); );
return redirect()->route('client.payments.index'); return redirect()->route('client.payments.show', $payment->hashed_id);
} }
public function processUnsuccessfulPayment() public function processUnsuccessfulPayment()
@ -141,7 +148,6 @@ class SEPA
private function storePaymentMethod($intent) private function storePaymentMethod($intent)
{ {
try { try {
$method = $this->stripe->getStripePaymentMethod($intent->payment_method); $method = $this->stripe->getStripePaymentMethod($intent->payment_method);
$payment_meta = new \stdClass; $payment_meta = new \stdClass;

File diff suppressed because one or more lines are too long

View File

@ -20,7 +20,7 @@
"/js/clients/payments/stripe-ach.js": "/js/clients/payments/stripe-ach.js?id=81c2623fc1e5769b51c7", "/js/clients/payments/stripe-ach.js": "/js/clients/payments/stripe-ach.js?id=81c2623fc1e5769b51c7",
"/js/clients/payments/stripe-alipay.js": "/js/clients/payments/stripe-alipay.js?id=665ddf663500767f1a17", "/js/clients/payments/stripe-alipay.js": "/js/clients/payments/stripe-alipay.js?id=665ddf663500767f1a17",
"/js/clients/payments/stripe-credit-card.js": "/js/clients/payments/stripe-credit-card.js?id=a30464874dee84678344", "/js/clients/payments/stripe-credit-card.js": "/js/clients/payments/stripe-credit-card.js?id=a30464874dee84678344",
"/js/clients/payments/stripe-sepa.js": "/js/clients/payments/stripe-sepa.js?id=e7dc964c85085314b12c", "/js/clients/payments/stripe-sepa.js": "/js/clients/payments/stripe-sepa.js?id=3f2fa0857dc804a85dcb",
"/js/clients/payments/stripe-sofort.js": "/js/clients/payments/stripe-sofort.js?id=231571942310348aa616", "/js/clients/payments/stripe-sofort.js": "/js/clients/payments/stripe-sofort.js?id=231571942310348aa616",
"/js/clients/payments/wepay-credit-card.js": "/js/clients/payments/wepay-credit-card.js?id=f51400e03c5fdb6cdabe", "/js/clients/payments/wepay-credit-card.js": "/js/clients/payments/wepay-credit-card.js?id=f51400e03c5fdb6cdabe",
"/js/clients/quotes/action-selectors.js": "/js/clients/quotes/action-selectors.js?id=1b8f9325aa6e8595e7fa", "/js/clients/quotes/action-selectors.js": "/js/clients/quotes/action-selectors.js?id=1b8f9325aa6e8595e7fa",

View File

@ -18,87 +18,168 @@ class ProcessSEPA {
setupStripe = () => { setupStripe = () => {
this.stripe = Stripe(this.key); this.stripe = Stripe(this.key);
if(this.stripeConnect) if (this.stripeConnect) this.stripe.stripeAccount = stripeConnect;
this.stripe.stripeAccount = stripeConnect;
const elements = this.stripe.elements(); const elements = this.stripe.elements();
var style = { var style = {
base: { base: {
color: "#32325d", color: '#32325d',
fontFamily: fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
fontSmoothing: "antialiased", fontSmoothing: 'antialiased',
fontSize: "16px", fontSize: '16px',
"::placeholder": { '::placeholder': {
color: "#aab7c4" color: '#aab7c4',
},
':-webkit-autofill': {
color: '#32325d',
}, },
":-webkit-autofill": {
color: "#32325d"
}
}, },
invalid: { invalid: {
color: "#fa755a", color: '#fa755a',
iconColor: "#fa755a", iconColor: '#fa755a',
":-webkit-autofill": { ':-webkit-autofill': {
color: "#fa755a" color: '#fa755a',
} },
} },
}; };
var options = { var options = {
style: style, style: style,
supportedCountries: ["SEPA"], supportedCountries: ['SEPA'],
// If you know the country of the customer, you can optionally pass it to // If you know the country of the customer, you can optionally pass it to
// the Element as placeholderCountry. The example IBAN that is being used // the Element as placeholderCountry. The example IBAN that is being used
// as placeholder reflects the IBAN format of that country. // as placeholder reflects the IBAN format of that country.
placeholderCountry: document.querySelector('meta[name="country"]').content placeholderCountry: document.querySelector('meta[name="country"]')
.content,
}; };
this.iban = elements.create("iban", options); this.iban = elements.create('iban', options);
this.iban.mount("#sepa-iban"); this.iban.mount('#sepa-iban');
return this; return this;
}; };
handle = () => { handle = () => {
document.getElementById('pay-now').addEventListener('click', (e) => {
let errors = document.getElementById('errors'); let errors = document.getElementById('errors');
if (document.getElementById('sepa-name').value === "") { Array.from(
document.getElementsByClassName('toggle-payment-with-token')
).forEach((element) =>
element.addEventListener('click', (element) => {
document
.getElementById('stripe--payment-container')
.classList.add('hidden');
document.getElementById('save-card--container').style.display =
'none';
document.querySelector('input[name=token]').value =
element.target.dataset.token;
})
);
document
.getElementById('toggle-payment-with-new-bank-account')
.addEventListener('click', (element) => {
document
.getElementById('stripe--payment-container')
.classList.remove('hidden');
document.getElementById('save-card--container').style.display =
'grid';
document.querySelector('input[name=token]').value = '';
});
document.getElementById('pay-now').addEventListener('click', (e) => {
if (
document.querySelector('input[name=token]').value.length !== 0
) {
document.querySelector('#errors').hidden = true;
document.getElementById('pay-now').disabled = true;
document
.querySelector('#pay-now > svg')
.classList.remove('hidden');
document
.querySelector('#pay-now > span')
.classList.add('hidden');
this.stripe
.confirmSepaDebitSetup(
document.querySelector('meta[name=si-client-secret')
.content,
{
payment_method: document.querySelector(
'input[name=token]'
).value,
}
)
.then((result) => {
if (result.error) {
console.error(error);
return;
}
document.querySelector(
'input[name="gateway_response"]'
).value = JSON.stringify(result.setupIntent);
return document
.querySelector('#server-response')
.submit();
})
.catch((error) => {
errors.textContent = error;
errors.hidden = false;
});
return;
}
if (document.getElementById('sepa-name').value === '') {
document.getElementById('sepa-name').focus(); document.getElementById('sepa-name').focus();
errors.textContent = "Name required."; errors.textContent = document.querySelector(
'meta[name=translation-name-required]'
).content;
errors.hidden = false; errors.hidden = false;
return; return;
} }
if (document.getElementById('sepa-email-address').value === "") { if (document.getElementById('sepa-email-address').value === '') {
document.getElementById('sepa-email-address').focus(); document.getElementById('sepa-email-address').focus();
errors.textContent = "Email required."; errors.textContent = document.querySelector(
'meta[name=translation-email-required]'
).content;
errors.hidden = false; errors.hidden = false;
return ; return;
} }
if (!document.getElementById('sepa-mandate-acceptance').checked) { if (!document.getElementById('sepa-mandate-acceptance').checked) {
errors.textContent = "Accept Terms"; errors.textContent = document.querySelector(
'meta[name=translation-terms-required]'
).content;
errors.hidden = false; errors.hidden = false;
console.log("Terms"); console.log('Terms');
return ; return;
} }
document.getElementById('pay-now').disabled = true; document.getElementById('pay-now').disabled = true;
document.querySelector('#pay-now > svg').classList.remove('hidden'); document.querySelector('#pay-now > svg').classList.remove('hidden');
document.querySelector('#pay-now > span').classList.add('hidden'); document.querySelector('#pay-now > span').classList.add('hidden');
this.stripe.confirmSepaDebitPayment( this.stripe
document.querySelector('meta[name=pi-client-secret').content, .confirmSepaDebitPayment(
document.querySelector('meta[name=pi-client-secret')
.content,
{ {
payment_method: { payment_method: {
sepa_debit: this.iban, sepa_debit: this.iban,
billing_details: { billing_details: {
name: document.getElementById("sepa-name").value, name: document.getElementById('sepa-name')
email: document.getElementById("sepa-email-address").value, .value,
email: document.getElementById(
'sepa-email-address'
).value,
}, },
}, },
} }
).then((result) => { )
.then((result) => {
if (result.error) { if (result.error) {
return this.handleFailure(result.error.message); return this.handleFailure(result.error.message);
} }
@ -113,6 +194,15 @@ class ProcessSEPA {
'input[name="gateway_response"]' 'input[name="gateway_response"]'
).value = JSON.stringify(result.paymentIntent); ).value = JSON.stringify(result.paymentIntent);
let tokenBillingCheckbox = document.querySelector(
'input[name="token-billing-checkbox"]:checked'
);
if (tokenBillingCheckbox) {
document.querySelector('input[name="store_card"]').value =
tokenBillingCheckbox.value;
}
document.getElementById('server-response').submit(); document.getElementById('server-response').submit();
} }
@ -129,9 +219,9 @@ class ProcessSEPA {
} }
} }
const publishableKey = document.querySelector( const publishableKey =
'meta[name="stripe-publishable-key"]' document.querySelector('meta[name="stripe-publishable-key"]')?.content ??
)?.content ?? ''; '';
const stripeConnect = const stripeConnect =
document.querySelector('meta[name="stripe-account-id"]')?.content ?? ''; document.querySelector('meta[name="stripe-account-id"]')?.content ?? '';

View File

@ -4327,6 +4327,7 @@ $LANG = array(
'giropay' => 'GiroPay', 'giropay' => 'GiroPay',
'giropay_law' => 'By entering your Customer information (such as name, sort code and account number) you (the Customer) agree that this information is given voluntarily.', 'giropay_law' => 'By entering your Customer information (such as name, sort code and account number) you (the Customer) agree that this information is given voluntarily.',
'eps' => 'EPS', 'eps' => 'EPS',
'you_need_to_accept_the_terms_before_proceeding' => 'You need to accept the terms before proceeding.',
); );
return $LANG; return $LANG;

View File

@ -7,9 +7,24 @@
<meta name="country" content="{{ $country }}"> <meta name="country" content="{{ $country }}">
<meta name="customer" content="{{ $customer }}"> <meta name="customer" content="{{ $customer }}">
<meta name="pi-client-secret" content="{{ $pi_client_secret }}"> <meta name="pi-client-secret" content="{{ $pi_client_secret }}">
<meta name="si-client-secret" content="{{ $si_client_secret ?? '' }}">
<meta name="translation-name-required" content="{{ ctrans('texts.missing_account_holder_name') }}">
<meta name="translation-email-required" content="{{ ctrans('texts.provide_email') }}">
<meta name="translation-terms-required" content="{{ ctrans('texts.you_need_to_accept_the_terms_before_proceeding') }}">
@endsection @endsection
@section('gateway_content') @section('gateway_content')
<form action="{{ route('client.payments.response') }}" method="post" id="server-response">
@csrf
<input type="hidden" name="gateway_response">
<input type="hidden" name="company_gateway_id" value="{{ $gateway->getCompanyGatewayId() }}">
<input type="hidden" name="payment_method_id" value="{{ $payment_method_id }}">
<input type="hidden" name="payment_hash" value="{{ $payment_hash }}">
<input type="hidden" name="store_card">
<input type="hidden" name="token">
</form>
<div class="alert alert-failure mb-4" hidden id="errors"></div> <div class="alert alert-failure mb-4" hidden id="errors"></div>
@include('portal.ninja2020.gateways.includes.payment_details') @include('portal.ninja2020.gateways.includes.payment_details')
@ -18,7 +33,48 @@
{{ ctrans('texts.sepa') }} ({{ ctrans('texts.bank_transfer') }}) {{ ctrans('texts.sepa') }} ({{ ctrans('texts.bank_transfer') }})
@endcomponent @endcomponent
@include('portal.ninja2020.gateways.stripe.sepa.sepa_debit') @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.pay_with')])
@if (count($tokens) > 0)
@foreach ($tokens as $token)
<label class="mr-4">
<input type="radio" data-token="{{ $token->token }}" name="payment-type"
class="form-radio cursor-pointer toggle-payment-with-token" />
<span class="ml-1 cursor-pointer">**** {{ optional($token->meta)->last4 }}</span>
</label>
@endforeach
@endisset
<label>
<input type="radio" id="toggle-payment-with-new-bank-account" class="form-radio cursor-pointer" name="payment-type"
checked />
<span class="ml-1 cursor-pointer">{{ __('texts.new_bank_account') }}</span>
</label>
@endcomponent
@component('portal.ninja2020.components.general.card-element-single')
<div id="stripe--payment-container">
<label for="sepa-name">
<input class="input w-full" id="sepa-name" type="text"
placeholder="{{ ctrans('texts.bank_account_holder') }}">
</label>
<label for="sepa-email" class="mt-4">
<input class="input w-full" id="sepa-email-address" type="email"
placeholder="{{ ctrans('texts.email') }}">
</label>
<label>
<div class="border p-3 rounded mt-2">
<div id="sepa-iban"></div>
</div>
</label>
<div id="mandate-acceptance" class="mt-4">
<input type="checkbox" id="sepa-mandate-acceptance" class="input mr-4">
<label for="sepa-mandate-acceptance" class="cursor-pointer">
{{ ctrans('texts.sepa_mandat', ['company' => $contact->company->present()->name()]) }}
</label>
</div>
</div>
@endcomponent
@include('portal.ninja2020.gateways.includes.save_card') @include('portal.ninja2020.gateways.includes.save_card')
@include('portal.ninja2020.gateways.includes.pay_now') @include('portal.ninja2020.gateways.includes.pay_now')
@endsection @endsection

View File

@ -1,29 +0,0 @@
<div id="stripe--payment-container">
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.name')])
<form action="{{ route('client.payments.response') }}" method="post" id="server-response">
@csrf
<input type="hidden" name="gateway_response">
<input type="hidden" name="company_gateway_id" value="{{ $gateway->getCompanyGatewayId() }}">
<input type="hidden" name="payment_method_id" value="{{ $payment_method_id }}">
<input type="hidden" name="payment_hash" value="{{ $payment_hash }}">
<input type="hidden" name="store_card">
<label for="sepa-name">
<input class="input w-full" id="sepa-name" type="text" placeholder="{{ ctrans('texts.bank_account_holder') }}">
</label>
<label for="sepa-email" >
<input class="input w-full" id="sepa-email-address" type="email" placeholder="{{ ctrans('texts.email') }}">
</label>
<label>
<div class="border p-4 rounded">
<div id="sepa-iban"></div>
</div>
</label>
<div id="mandate-acceptance">
<input type="checkbox" id="sepa-mandate-acceptance" class="input mr-4">
<label for="sepa-mandate-acceptance">{{ctrans('texts.sepa_mandat', ['company' => auth('contact')->user()->company->present()->name()])}}</label>
</div>
</form>
@endcomponent
</div>

View File

@ -0,0 +1,127 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace Tests\Browser\ClientPortal\Gateways\Stripe;
use App\DataMapper\FeesAndLimits;
use App\Models\Client;
use App\Models\CompanyGateway;
use App\Models\GatewayType;
use Laravel\Dusk\Browser;
use Tests\Browser\Pages\ClientPortal\Login;
use Tests\DuskTestCase;
class SEPATest extends DuskTestCase
{
protected function setUp(): void
{
parent::setUp();
foreach (static::$browsers as $browser) {
$browser->driver->manage()->deleteAllCookies();
}
$this->browse(function (Browser $browser) {
$browser
->visit(new Login())
->auth();
});
$this->disableCompanyGateways();
// Enable Stripe.
CompanyGateway::where('gateway_key', 'd14dd26a37cecc30fdd65700bfb55b23')->restore();
// Enable SEPA.
$cg = CompanyGateway::where('gateway_key', 'd14dd26a37cecc30fdd65700bfb55b23')->firstOrFail();
$fees_and_limits = $cg->fees_and_limits;
$fees_and_limits->{GatewayType::SEPA} = new FeesAndLimits();
$cg->fees_and_limits = $fees_and_limits;
$cg->save();
// SEPA required DE to be billing country.
$client = Client::first();
$client->country_id = 276;
$settings = $client->settings;
$settings->currency_id = "3";
$client->settings = $settings;
$client->save();
}
public function testPayingWithNewSEPABankAccount(): void
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.invoices.index')
->click('@pay-now')
->click('@pay-now-dropdown')
->clickLink('SEPA Direct Debit')
->type('#sepa-name', 'John Doe')
->type('#sepa-email-address', 'test@invoiceninja.com')
->withinFrame('iframe', function (Browser $browser) {
$browser->type('iban', 'DE89370400440532013000');
})
->check('#sepa-mandate-acceptance', true)
->click('#pay-now')
->waitForText('Details of the payment', 60);
});
}
public function testPayingWithNewSEPABankAccountAndSaveForFuture(): void
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.invoices.index')
->click('@pay-now')
->click('@pay-now-dropdown')
->clickLink('SEPA Direct Debit')
->type('#sepa-name', 'John Doe')
->type('#sepa-email-address', 'test@invoiceninja.com')
->withinFrame('iframe', function (Browser $browser) {
$browser->type('iban', 'DE89370400440532013000');
})
->check('#sepa-mandate-acceptance', true)
->radio('#proxy_is_default', true)
->click('#pay-now')
->waitForText('Details of the payment', 60);
});
}
public function testPayWithSavedBankAccount()
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.invoices.index')
->click('@pay-now')
->click('@pay-now-dropdown')
->clickLink('SEPA Direct Debit')
->click('.toggle-payment-with-token')
->click('#pay-now')
->waitForText('Details of the payment', 60);
});
}
public function testRemoveBankAccount()
{
$this->browse(function (Browser $browser) {
$browser
->visitRoute('client.payment_methods.index')
->clickLink('View')
->press('Remove Payment Method')
->waitForText('Confirmation')
->click('@confirm-payment-removal')
->assertSee('Payment method has been successfully removed.');
});
}
}