diff --git a/app/Models/Gateway.php b/app/Models/Gateway.php index 5d5a5578b..8bd0b64eb 100644 --- a/app/Models/Gateway.php +++ b/app/Models/Gateway.php @@ -103,7 +103,8 @@ class Gateway extends StaticModel GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable','charge.succeeded']], 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 + 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: return [GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true]]; //Checkout break; diff --git a/app/PaymentDrivers/Stripe/SEPA.php b/app/PaymentDrivers/Stripe/SEPA.php index 74b564f6e..cba8c6bdc 100644 --- a/app/PaymentDrivers/Stripe/SEPA.php +++ b/app/PaymentDrivers/Stripe/SEPA.php @@ -12,98 +12,129 @@ namespace App\PaymentDrivers\Stripe; -use App\Exceptions\PaymentFailed; use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest; +use App\PaymentDrivers\StripePaymentDriver; use App\Jobs\Mail\PaymentFailureMailer; use App\Jobs\Util\SystemLogger; use App\Models\GatewayType; use App\Models\Payment; use App\Models\PaymentType; use App\Models\SystemLog; -use App\PaymentDrivers\StripePaymentDriver; -use App\PaymentDrivers\Stripe\CreditCard; -use App\Utils\Ninja; +use App\Exceptions\PaymentFailed; class SEPA { /** @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(); - - $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 - + return render('gateways.stripe.sepa.authorize', $data); + } + public function paymentView(array $data) { $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) { + $this->stripe->payment_hash->data = array_merge((array) $this->stripe->payment_hash->data, $request->all()); + $this->stripe->payment_hash->save(); - // $this->stripe_driver->init(); - - // $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); - + if ($request->redirect_status == 'succeeded') { + return $this->processSuccessfulPayment($request->payment_intent); + } + return $this->processUnsuccessfulPayment(); } - /* Searches for a stripe customer by email - otherwise searches by gateway tokens in StripePaymentdriver - finally creates a new customer if none found - */ - private function getCustomer() + public function processSuccessfulPayment(string $payment_intent) { - $searchResults = \Stripe\Customer::all([ - "email" => $this->stripe_driver->client->present()->email(), - "limit" => 1, - "starting_after" => null - ], $this->stripe_driver->stripe_connect_auth); - + $this->stripe->init(); - if(count($searchResults) >= 1) - return $searchResults[0]; + $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, + ]; - return $this->stripe_driver->findOrCreateCustomer(); + $this->stripe->createPayment($data, Payment::STATUS_PENDING); - } + 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); + } } - diff --git a/app/PaymentDrivers/StripeConnectPaymentDriver.php b/app/PaymentDrivers/StripeConnectPaymentDriver.php index 2a6443827..919939118 100644 --- a/app/PaymentDrivers/StripeConnectPaymentDriver.php +++ b/app/PaymentDrivers/StripeConnectPaymentDriver.php @@ -28,6 +28,7 @@ use App\PaymentDrivers\Stripe\Alipay; use App\PaymentDrivers\Stripe\Charge; use App\PaymentDrivers\Stripe\CreditCard; use App\PaymentDrivers\Stripe\SOFORT; +use App\PaymentDrivers\Stripe\SEPA; use App\PaymentDrivers\Stripe\Utilities; use App\Utils\Traits\MakesHash; use Exception; diff --git a/app/PaymentDrivers/StripePaymentDriver.php b/app/PaymentDrivers/StripePaymentDriver.php index 62537689b..604a4a89c 100644 --- a/app/PaymentDrivers/StripePaymentDriver.php +++ b/app/PaymentDrivers/StripePaymentDriver.php @@ -32,6 +32,7 @@ use App\PaymentDrivers\Stripe\Connect\Verify; use App\PaymentDrivers\Stripe\CreditCard; use App\PaymentDrivers\Stripe\ImportCustomers; use App\PaymentDrivers\Stripe\SOFORT; +use App\PaymentDrivers\Stripe\SEPA; use App\PaymentDrivers\Stripe\UpdatePaymentMethods; use App\PaymentDrivers\Stripe\Utilities; use App\Utils\Traits\MakesHash; @@ -75,7 +76,7 @@ class StripePaymentDriver extends BaseDriver GatewayType::ALIPAY => Alipay::class, GatewayType::SOFORT => SOFORT::class, GatewayType::APPLE_PAY => ApplePay::class, - GatewayType::SEPA => 1, // TODO + GatewayType::SEPA => SEPA::class, ]; const SYSTEM_LOG_TYPE = SystemLog::TYPE_STRIPE; @@ -125,7 +126,7 @@ class StripePaymentDriver extends BaseDriver $types = [ // GatewayType::CRYPTO, GatewayType::CREDIT_CARD - ]; + ]; if ($this->client && isset($this->client->country) @@ -146,6 +147,12 @@ class StripePaymentDriver extends BaseDriver $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; } @@ -326,7 +333,7 @@ class StripePaymentDriver extends BaseDriver if($customer) return $customer; - } + } //Search by email $searchResults = \Stripe\Customer::all([ @@ -337,11 +344,11 @@ class StripePaymentDriver extends BaseDriver if(count($searchResults) == 1) return $searchResults->data[0]; - + //Else create a new record $data['name'] = $this->client->present()->name(); $data['phone'] = $this->client->present()->phone(); - + if (filter_var($this->client->present()->email(), FILTER_VALIDATE_EMAIL)) { $data['email'] = $this->client->present()->email(); } @@ -370,7 +377,7 @@ class StripePaymentDriver extends BaseDriver // ->create(['charge' => $payment->transaction_reference, 'amount' => $this->convertToStripeAmount($amount, $this->client->currency()->precision, $this->client->currency())], $meta); $response = \Stripe\Refund::create([ - 'charge' => $payment->transaction_reference, + 'charge' => $payment->transaction_reference, 'amount' => $this->convertToStripeAmount($amount, $this->client->currency()->precision, $this->client->currency()) ], $meta); diff --git a/public/js/clients/payments/stripe-sepa.js b/public/js/clients/payments/stripe-sepa.js new file mode 100644 index 000000000..1fdf03776 --- /dev/null +++ b/public/js/clients/payments/stripe-sepa.js @@ -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(); diff --git a/public/js/clients/payments/stripe-sepa.js.LICENSE.txt b/public/js/clients/payments/stripe-sepa.js.LICENSE.txt new file mode 100644 index 000000000..585c6ab0e --- /dev/null +++ b/public/js/clients/payments/stripe-sepa.js.LICENSE.txt @@ -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 + */ diff --git a/resources/js/clients/payments/stripe-sepa.js b/resources/js/clients/payments/stripe-sepa.js new file mode 100644 index 000000000..5a216d81c --- /dev/null +++ b/resources/js/clients/payments/stripe-sepa.js @@ -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(); diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index a79ec3208..761169379 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -1779,7 +1779,7 @@ $LANG = array( 'lang_Bulgarian' => 'Bulgarian', 'lang_Russian (Russia)' => 'Russian (Russia)', - + // Industries 'industry_Accounting & Legal' => 'Accounting & Legal', 'industry_Advertising' => 'Advertising', @@ -4316,7 +4316,9 @@ $LANG = array( 'payment_method_cannot_be_preauthorized' => 'This payment method cannot be preauthorized.', 'kbc_cbc' => 'KBC/CBC', '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', + 'bank_account_holder' => 'Bank Account Holder', 'aio_checkout' => 'All-in-one checkout', ); diff --git a/resources/views/portal/ninja2020/gateways/stripe/sepa/authorize.blade.php b/resources/views/portal/ninja2020/gateways/stripe/sepa/authorize.blade.php index def2150fb..539346205 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/sepa/authorize.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/sepa/authorize.blade.php @@ -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') @if($gateway->company_gateway->getConfigField('account_id')) @@ -10,9 +10,9 @@ @endsection @section('gateway_content') - @if(session()->has('ach_error')) + @if(session()->has('sepa_error'))
-

{{ session('ach_error') }}

+

{{ session('sepa_error') }}

@endif @@ -78,5 +78,5 @@ @section('gateway_footer') - + @endsection diff --git a/resources/views/portal/ninja2020/gateways/stripe/sepa/pay.blade.php b/resources/views/portal/ninja2020/gateways/stripe/sepa/pay.blade.php new file mode 100644 index 000000000..508ccf57a --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/stripe/sepa/pay.blade.php @@ -0,0 +1,30 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => 'SEPA', 'card_title' => 'SEPA']) + +@section('gateway_head') + + + + + + + +@endsection + +@section('gateway_content') + + + @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') + + +@endpush diff --git a/resources/views/portal/ninja2020/gateways/stripe/sepa/sepa_debit.blade.php b/resources/views/portal/ninja2020/gateways/stripe/sepa/sepa_debit.blade.php new file mode 100644 index 000000000..126c5b48b --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/stripe/sepa/sepa_debit.blade.php @@ -0,0 +1,19 @@ +
+ @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.name')]) + + + +
+ + +
+ @endcomponent +
diff --git a/resources/views/portal/ninja2020/gateways/stripe/sofort/pay.blade.php b/resources/views/portal/ninja2020/gateways/stripe/sofort/pay.blade.php index 9fcec3e1c..85c223516 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/sofort/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/sofort/pay.blade.php @@ -18,7 +18,6 @@ @component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.payment_type')]) {{ ctrans('texts.sofort') }} ({{ ctrans('texts.bank_transfer') }}) @endcomponent - @include('portal.ninja2020.gateways.includes.pay_now') @endsection