1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-10 21:22:58 +01:00

Merge pull request #6771 from LarsK1/v5-develop

Stripe: SEPA-payment
This commit is contained in:
David Bomba 2021-10-09 08:50:03 +11:00 committed by GitHub
commit 61adddc51c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 356 additions and 75 deletions

View File

@ -103,7 +103,8 @@ class Gateway extends StaticModel
GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable','charge.succeeded']], GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable','charge.succeeded']],
GatewayType::ALIPAY => ['refund' => false, 'token_billing' => false], GatewayType::ALIPAY => ['refund' => false, 'token_billing' => false],
GatewayType::APPLE_PAY => ['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 GatewayType::SOFORT => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']], //Stripe
GatewayType::SEPA => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']]];
case 39: case 39:
return [GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true]]; //Checkout return [GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true]]; //Checkout
break; break;

View File

@ -12,98 +12,129 @@
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\PaymentDrivers\StripePaymentDriver; use App\Exceptions\PaymentFailed;
use App\PaymentDrivers\Stripe\CreditCard;
use App\Utils\Ninja;
class SEPA class SEPA
{ {
/** @var StripePaymentDriver */ /** @var StripePaymentDriver */
public $stripe_driver; public StripePaymentDriver $stripe;
public function __construct(StripePaymentDriver $stripe_driver) public function __construct(StripePaymentDriver $stripe)
{ {
$this->stripe_driver = $stripe_driver; $this->stripe = $stripe;
} }
public function authorizeView(array $data) public function authorizeView($data)
{ {
$customer = $this->stripe_driver->findOrCreateCustomer(); return render('gateways.stripe.sepa.authorize', $data);
}
$setup_intent = \Stripe\SetupIntent::create([
'payment_method_types' => ['sepa_debit'],
'customer' => $customer->id,
], $this->stripe_driver->stripe_connect_auth);
$client_secret = $setup_intent->client_secret;
// Pass the client secret to the client
public function paymentView(array $data) {
$data['gateway'] = $this->stripe; $data['gateway'] = $this->stripe;
$data['return_url'] = $this->buildReturnUrl();
$data['stripe_amount'] = $this->stripe->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe->client->currency()->precision, $this->stripe->client->currency());
$data['client'] = $this->stripe->client;
$data['customer'] = $this->stripe->findOrCreateCustomer()->id;
$data['country'] = $this->stripe->client->country->iso_3166_2;
return render('gateways.stripe.sepa.authorize', array_merge($data)); $intent = \Stripe\PaymentIntent::create([
'amount' => $data['stripe_amount'],
'currency' => 'eur',
'payment_method_types' => ['sepa_debit'],
'setup_future_usage' => 'off_session',
'customer' => $this->stripe->findOrCreateCustomer(),
'description' => $this->stripe->decodeUnicodeString(ctrans('texts.invoices') . ': ' . collect($data['invoices'])->pluck('invoice_number')),
]);
$data['pi_client_secret'] = $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->save();
return render('gateways.stripe.sepa.pay', $data);
} }
private function buildReturnUrl(): string
{
return route('client.payments.response', [
'company_gateway_id' => $this->stripe->company_gateway->id,
'payment_hash' => $this->stripe->payment_hash->hash,
'payment_method_id' => GatewayType::SEPA,
]);
}
public function paymentResponse(PaymentResponseRequest $request) public function paymentResponse(PaymentResponseRequest $request)
{ {
$this->stripe->payment_hash->data = array_merge((array) $this->stripe->payment_hash->data, $request->all());
$this->stripe->payment_hash->save();
// $this->stripe_driver->init(); if ($request->redirect_status == 'succeeded') {
return $this->processSuccessfulPayment($request->payment_intent);
// $state = [
// 'server_response' => json_decode($request->gateway_response),
// 'payment_hash' => $request->payment_hash,
// ];
// $state['payment_intent'] = \Stripe\PaymentIntent::retrieve($state['server_response']->id, $this->stripe_driver->stripe_connect_auth);
// $state['customer'] = $state['payment_intent']->customer;
// $this->stripe_driver->payment_hash->data = array_merge((array) $this->stripe_driver->payment_hash->data, $state);
// $this->stripe_driver->payment_hash->save();
// $server_response = $this->stripe_driver->payment_hash->data->server_response;
// $response_handler = new CreditCard($this->stripe_driver);
// if ($server_response->status == 'succeeded') {
// $this->stripe_driver->logSuccessfulGatewayResponse(['response' => json_decode($request->gateway_response), 'data' => $this->stripe_driver->payment_hash], SystemLog::TYPE_STRIPE);
// return $response_handler->processSuccessfulPayment();
// }
// return $response_handler->processUnsuccessfulPayment($server_response);
} }
/* Searches for a stripe customer by email return $this->processUnsuccessfulPayment();
otherwise searches by gateway tokens in StripePaymentdriver }
finally creates a new customer if none found
*/ public function processSuccessfulPayment(string $payment_intent)
private function getCustomer()
{ {
$searchResults = \Stripe\Customer::all([ $this->stripe->init();
"email" => $this->stripe_driver->client->present()->email(),
"limit" => 1,
"starting_after" => null
], $this->stripe_driver->stripe_connect_auth);
$data = [
'payment_method' => $payment_intent,
'payment_type' => PaymentType::SEPA,
'amount' => $this->stripe->convertFromStripeAmount($this->stripe->payment_hash->data->stripe_amount, $this->stripe->client->currency()->precision, $this->stripe->client->currency()),
'transaction_reference' => $payment_intent,
'gateway_type_id' => GatewayType::SEPA,
];
if(count($searchResults) >= 1) $this->stripe->createPayment($data, Payment::STATUS_PENDING);
return $searchResults[0];
return $this->stripe_driver->findOrCreateCustomer(); SystemLogger::dispatch(
['response' => $this->stripe->payment_hash->data, 'data' => $data],
SystemLog::CATEGORY_GATEWAY_RESPONSE,
SystemLog::EVENT_GATEWAY_SUCCESS,
SystemLog::TYPE_STRIPE,
$this->stripe->client,
$this->stripe->client->company,
);
} return redirect()->route('client.payments.index');
} }
public function processUnsuccessfulPayment()
{
$server_response = $this->stripe->payment_hash->data;
PaymentFailureMailer::dispatch(
$this->stripe->client,
$server_response,
$this->stripe->client->company,
$this->stripe->convertFromStripeAmount($this->stripe->payment_hash->data->stripe_amount, $this->stripe->client->currency()->precision, $this->stripe->client->currency())
);
$message = [
'server_response' => $server_response,
'data' => $this->stripe->payment_hash->data,
];
SystemLogger::dispatch(
$message,
SystemLog::CATEGORY_GATEWAY_RESPONSE,
SystemLog::EVENT_GATEWAY_FAILURE,
SystemLog::TYPE_STRIPE,
$this->stripe->client,
$this->stripe->client->company,
);
throw new PaymentFailed('Failed to process the payment.', 500);
}
}

View File

@ -28,6 +28,7 @@ use App\PaymentDrivers\Stripe\Alipay;
use App\PaymentDrivers\Stripe\Charge; use App\PaymentDrivers\Stripe\Charge;
use App\PaymentDrivers\Stripe\CreditCard; use App\PaymentDrivers\Stripe\CreditCard;
use App\PaymentDrivers\Stripe\SOFORT; use App\PaymentDrivers\Stripe\SOFORT;
use App\PaymentDrivers\Stripe\SEPA;
use App\PaymentDrivers\Stripe\Utilities; use App\PaymentDrivers\Stripe\Utilities;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
use Exception; use Exception;

View File

@ -32,6 +32,7 @@ use App\PaymentDrivers\Stripe\Connect\Verify;
use App\PaymentDrivers\Stripe\CreditCard; use App\PaymentDrivers\Stripe\CreditCard;
use App\PaymentDrivers\Stripe\ImportCustomers; use App\PaymentDrivers\Stripe\ImportCustomers;
use App\PaymentDrivers\Stripe\SOFORT; use App\PaymentDrivers\Stripe\SOFORT;
use App\PaymentDrivers\Stripe\SEPA;
use App\PaymentDrivers\Stripe\UpdatePaymentMethods; use App\PaymentDrivers\Stripe\UpdatePaymentMethods;
use App\PaymentDrivers\Stripe\Utilities; use App\PaymentDrivers\Stripe\Utilities;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
@ -75,7 +76,7 @@ class StripePaymentDriver extends BaseDriver
GatewayType::ALIPAY => Alipay::class, GatewayType::ALIPAY => Alipay::class,
GatewayType::SOFORT => SOFORT::class, GatewayType::SOFORT => SOFORT::class,
GatewayType::APPLE_PAY => ApplePay::class, GatewayType::APPLE_PAY => ApplePay::class,
GatewayType::SEPA => 1, // TODO GatewayType::SEPA => SEPA::class,
]; ];
const SYSTEM_LOG_TYPE = SystemLog::TYPE_STRIPE; const SYSTEM_LOG_TYPE = SystemLog::TYPE_STRIPE;
@ -146,6 +147,12 @@ class StripePaymentDriver extends BaseDriver
$types[] = GatewayType::ALIPAY; $types[] = GatewayType::ALIPAY;
} }
if ($this->client
&& isset($this->client->country)
&& in_array($this->client->country->iso_3166_3, ['AUS', 'DNK', 'DEU', 'ITA', 'LUX', 'NOR', 'SVN', 'GBR', 'EST', 'GRC', 'JPN', 'PRT', 'ESP', 'USA', 'BEL', 'FIN'])) { // TODO: More has to be added https://stripe.com/docs/payments/sepa-debit
$types[] = GatewayType::SEPA;
}
return $types; return $types;
} }

View File

@ -0,0 +1,91 @@
/**
* 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
*/
class ProcessSEPA {
constructor(key, stripeConnect) {
this.key = key;
this.errors = document.getElementById('errors');
this.stripeConnect = stripeConnect;
}
setupStripe = () => {
this.stripe = Stripe(this.key);
if(this.stripeConnect)
this.stripe.stripeAccount = stripeConnect;
const elements = this.stripe.elements();
var style = {
base: {
color: "#32325d",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
fontSmoothing: "antialiased",
fontSize: "16px",
"::placeholder": {
color: "#aab7c4"
},
":-webkit-autofill": {
color: "#32325d"
}
},
invalid: {
color: "#fa755a",
iconColor: "#fa755a",
":-webkit-autofill": {
color: "#fa755a"
}
}
};
var options = {
style: style,
supportedCountries: ["SEPA"],
// 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
// as placeholder reflects the IBAN format of that country.
placeholderCountry: "DE"
};
this.iban = elements.create("iban", options);
this.iban.mount("#sepa-iban");
return this;
};
handle = () => {
document.getElementById('pay-now').addEventListener('click', (e) => {
document.getElementById('pay-now').disabled = true;
document.querySelector('#pay-now > svg').classList.remove('hidden');
document.querySelector('#pay-now > span').classList.add('hidden');
this.stripe.confirmSepaDebitPayment(
document.querySelector('meta[name=pi-client-secret').content,
{
payment_method: {
sepa_debit: this.iban,
billing_details: {
name: document.getElementById("sepa-name").value,
email: document.getElementById("sepa-email-address").value,
},
},
return_url: document.querySelector(
'meta[name="return-url"]'
).content,
}
);
});
};
}
const publishableKey = document.querySelector(
'meta[name="stripe-publishable-key"]'
)?.content ?? '';
const stripeConnect =
document.querySelector('meta[name="stripe-account-id"]')?.content ?? '';
new ProcessSEPA(publishableKey, stripeConnect).setupStripe().handle();

View File

@ -0,0 +1,9 @@
/**
* 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
*/

View File

@ -0,0 +1,91 @@
/**
* 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
*/
class ProcessSEPA {
constructor(key, stripeConnect) {
this.key = key;
this.errors = document.getElementById('errors');
this.stripeConnect = stripeConnect;
}
setupStripe = () => {
this.stripe = Stripe(this.key);
if(this.stripeConnect)
this.stripe.stripeAccount = stripeConnect;
const elements = this.stripe.elements();
var style = {
base: {
color: "#32325d",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
fontSmoothing: "antialiased",
fontSize: "16px",
"::placeholder": {
color: "#aab7c4"
},
":-webkit-autofill": {
color: "#32325d"
}
},
invalid: {
color: "#fa755a",
iconColor: "#fa755a",
":-webkit-autofill": {
color: "#fa755a"
}
}
};
var options = {
style: style,
supportedCountries: ["SEPA"],
// 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
// as placeholder reflects the IBAN format of that country.
placeholderCountry: "DE"
};
this.iban = elements.create("iban", options);
this.iban.mount("#sepa-iban");
return this;
};
handle = () => {
document.getElementById('pay-now').addEventListener('click', (e) => {
document.getElementById('pay-now').disabled = true;
document.querySelector('#pay-now > svg').classList.remove('hidden');
document.querySelector('#pay-now > span').classList.add('hidden');
this.stripe.confirmSepaDebitPayment(
document.querySelector('meta[name=pi-client-secret').content,
{
payment_method: {
sepa_debit: this.iban,
billing_details: {
name: document.getElementById("sepa-name").value,
email: document.getElementById("sepa-email-address").value,
},
},
return_url: document.querySelector(
'meta[name="return-url"]'
).content,
}
);
});
};
}
const publishableKey = document.querySelector(
'meta[name="stripe-publishable-key"]'
)?.content ?? '';
const stripeConnect =
document.querySelector('meta[name="stripe-account-id"]')?.content ?? '';
new ProcessSEPA(publishableKey, stripeConnect).setupStripe().handle();

View File

@ -4316,7 +4316,9 @@ $LANG = array(
'payment_method_cannot_be_preauthorized' => 'This payment method cannot be preauthorized.', 'payment_method_cannot_be_preauthorized' => 'This payment method cannot be preauthorized.',
'kbc_cbc' => 'KBC/CBC', 'kbc_cbc' => 'KBC/CBC',
'bancontact' => 'Bancontact', 'bancontact' => 'Bancontact',
'sepa_mandat' => 'By providing your IBAN and confirming this payment, you are authorizing Rocketship Inc. and Stripe, our payment service provider, to send instructions to your bank to debit your account and your bank to debit your account in accordance with those instructions. You are entitled to a refund from your bank under the terms and conditions of your agreement with your bank. A refund must be claimed within 8 weeks starting from the date on which your account was debited.',
'ideal' => 'iDEAL', 'ideal' => 'iDEAL',
'bank_account_holder' => 'Bank Account Holder',
'aio_checkout' => 'All-in-one checkout', 'aio_checkout' => 'All-in-one checkout',
); );

View File

@ -1,4 +1,4 @@
@extends('portal.ninja2020.layout.payments', ['gateway_title' => 'SEPA', 'card_title' => 'SEPA']) @extends('portal.ninja2020.layout.payments', ['gateway_title' => 'SEPA', 'card_title' => 'SEPA-Lastschrift'])
@section('gateway_head') @section('gateway_head')
@if($gateway->company_gateway->getConfigField('account_id')) @if($gateway->company_gateway->getConfigField('account_id'))
@ -10,9 +10,9 @@
@endsection @endsection
@section('gateway_content') @section('gateway_content')
@if(session()->has('ach_error')) @if(session()->has('sepa_error'))
<div class="alert alert-failure mb-4"> <div class="alert alert-failure mb-4">
<p>{{ session('ach_error') }}</p> <p>{{ session('sepa_error') }}</p>
</div> </div>
@endif @endif
@ -78,5 +78,5 @@
@section('gateway_footer') @section('gateway_footer')
<script src="https://js.stripe.com/v3/"></script> <script src="https://js.stripe.com/v3/"></script>
<script src="{{ asset('js/clients/payments/stripe-ach.js') }}"></script> <script src="{{ asset('js/clients/payments/stripe-sepa.js') }}"></script>
@endsection @endsection

View File

@ -0,0 +1,30 @@
@extends('portal.ninja2020.layout.payments', ['gateway_title' => 'SEPA', 'card_title' => 'SEPA'])
@section('gateway_head')
<meta name="stripe-publishable-key" content="{{ $gateway->getPublishableKey() }}">
<meta name="stripe-account-id" content="{{ $gateway->company_gateway->getConfigField('account_id') }}">
<meta name="return-url" content="{{ $return_url }}">
<meta name="amount" content="{{ $stripe_amount }}">
<meta name="country" content="{{ $country }}">
<meta name="customer" content="{{ $customer }}">
<meta name="pi-client-secret" content="{{ $pi_client_secret }}">
@endsection
@section('gateway_content')
<div class="alert alert-failure mb-4" hidden id="errors"></div>
@include('portal.ninja2020.gateways.includes.payment_details')
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.payment_type')])
{{ ctrans('texts.sepa') }} ({{ ctrans('texts.bank_transfer') }})
@endcomponent
@include('portal.ninja2020.gateways.stripe.sepa.sepa_debit')
@include('portal.ninja2020.gateways.includes.pay_now')
@endsection
@push('footer')
<script src="https://js.stripe.com/v3/"></script>
<script src="{{ asset('js/clients/payments/stripe-sepa.js') }}"></script>
@endpush

View File

@ -0,0 +1,19 @@
<div id="stripe--payment-container">
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.name')])
<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>
@endcomponent
</div>

View File

@ -18,7 +18,6 @@
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.payment_type')]) @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.payment_type')])
{{ ctrans('texts.sofort') }} ({{ ctrans('texts.bank_transfer') }}) {{ ctrans('texts.sofort') }} ({{ ctrans('texts.bank_transfer') }})
@endcomponent @endcomponent
@include('portal.ninja2020.gateways.includes.pay_now') @include('portal.ninja2020.gateways.includes.pay_now')
@endsection @endsection