mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2026-03-03 03:07:01 +00:00
Updates for Stripe ACH
This commit is contained in:
@@ -15,7 +15,7 @@ Invoice Ninja Version 5 is here! We've taken the best parts of version 4 and add
|
|||||||
- [Hosted](https://www.invoiceninja.com): Our hosted version is a Software as a Service (SaaS) solution. You're up and running in under 5 minutes, with no need to worry about hosting or server infrastructure.
|
- [Hosted](https://www.invoiceninja.com): Our hosted version is a Software as a Service (SaaS) solution. You're up and running in under 5 minutes, with no need to worry about hosting or server infrastructure.
|
||||||
- [Self-Hosted](https://www.invoiceninja.org): For those who prefer to manage their own hosting and server infrastructure. This version gives you full control and flexibility.
|
- [Self-Hosted](https://www.invoiceninja.org): For those who prefer to manage their own hosting and server infrastructure. This version gives you full control and flexibility.
|
||||||
|
|
||||||
All Pro and Enterprise features from the hosted app are included in the source-available code. We offer a $30 per year white-label license to remove the Invoice Ninja branding from client-facing parts of the app.
|
All Pro and Enterprise features from the hosted app are included in the source-available code. We offer a $40 per year white-label license to remove the Invoice Ninja branding from client-facing parts of the app.
|
||||||
|
|
||||||
#### Get social with us
|
#### Get social with us
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ class Gateway extends StaticModel
|
|||||||
case 56:
|
case 56:
|
||||||
return [
|
return [
|
||||||
GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true, 'webhooks' => ['payment_intent.succeeded', 'charge.refunded', 'payment_intent.payment_failed']],
|
GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true, 'webhooks' => ['payment_intent.succeeded', 'charge.refunded', 'payment_intent.payment_failed']],
|
||||||
GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.refunded','charge.succeeded', 'customer.source.updated', 'payment_intent.processing', 'payment_intent.payment_failed', 'charge.failed']],
|
GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.refunded', 'charge.succeeded', 'customer.source.updated', 'setup_intent.succeeded', 'payment_intent.processing', 'payment_intent.payment_failed', 'charge.failed']],
|
||||||
GatewayType::DIRECT_DEBIT => ['refund' => false, 'token_billing' => false, 'webhooks' => ['payment_intent.processing', 'charge.refunded', 'payment_intent.succeeded', 'payment_intent.partially_funded', 'payment_intent.payment_failed']],
|
GatewayType::DIRECT_DEBIT => ['refund' => false, 'token_billing' => false, 'webhooks' => ['payment_intent.processing', 'charge.refunded', 'payment_intent.succeeded', 'payment_intent.partially_funded', 'payment_intent.payment_failed']],
|
||||||
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],
|
||||||
|
|||||||
@@ -51,10 +51,46 @@ class ACH implements LivewireMethodInterface
|
|||||||
/**
|
/**
|
||||||
* Authorize a bank account - requires microdeposit verification
|
* Authorize a bank account - requires microdeposit verification
|
||||||
*/
|
*/
|
||||||
|
// public function authorizeView(array $data)
|
||||||
|
// {
|
||||||
|
// $data['gateway'] = $this->stripe;
|
||||||
|
|
||||||
|
// return render('gateways.stripe.ach.authorize', array_merge($data));
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instant Verification methods with fall back to microdeposits.
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||||
|
*/
|
||||||
public function authorizeView(array $data)
|
public function authorizeView(array $data)
|
||||||
{
|
{
|
||||||
$data['gateway'] = $this->stripe;
|
$data['gateway'] = $this->stripe;
|
||||||
|
|
||||||
|
$customer = $this->stripe->findOrCreateCustomer();
|
||||||
|
|
||||||
|
// Create SetupIntent with Financial Connections for instant verification
|
||||||
|
$intent = \Stripe\SetupIntent::create([
|
||||||
|
'customer' => $customer->id,
|
||||||
|
'usage' => 'off_session',
|
||||||
|
'payment_method_types' => ['us_bank_account'],
|
||||||
|
'payment_method_options' => [
|
||||||
|
'us_bank_account' => [
|
||||||
|
'financial_connections' => [
|
||||||
|
'permissions' => ['payment_method'],
|
||||||
|
// Optional: add 'balances', 'ownership' for additional data
|
||||||
|
],
|
||||||
|
'verification_method' => 'automatic', // instant with microdeposit fallback
|
||||||
|
// Or use 'instant' to require instant only (no fallback)
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], $this->stripe->stripe_connect_auth);
|
||||||
|
|
||||||
|
$data['client_secret'] = $intent->client_secret;
|
||||||
|
$data['customer'] = $customer;
|
||||||
|
|
||||||
return render('gateways.stripe.ach.authorize', array_merge($data));
|
return render('gateways.stripe.ach.authorize', array_merge($data));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,36 +98,96 @@ class ACH implements LivewireMethodInterface
|
|||||||
{
|
{
|
||||||
$this->stripe->init();
|
$this->stripe->init();
|
||||||
|
|
||||||
$stripe_response = json_decode($request->input('gateway_response'));
|
$setup_intent = json_decode($request->input('gateway_response'));
|
||||||
|
|
||||||
|
if (!$setup_intent || !isset($setup_intent->payment_method)) {
|
||||||
|
throw new PaymentFailed('Invalid response from payment gateway.');
|
||||||
|
}
|
||||||
|
|
||||||
$customer = $this->stripe->findOrCreateCustomer();
|
$customer = $this->stripe->findOrCreateCustomer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$source = Customer::createSource($customer->id, ['source' => $stripe_response->token->id], array_merge($this->stripe->stripe_connect_auth, ['idempotency_key' => uniqid("st", true)]));
|
// Retrieve the payment method to get bank account details
|
||||||
|
$payment_method = $this->stripe->getStripePaymentMethod($setup_intent->payment_method);
|
||||||
|
|
||||||
|
if (!$payment_method || !isset($payment_method->us_bank_account)) {
|
||||||
|
throw new PaymentFailed('Unable to retrieve bank account details.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bank_account = $payment_method->us_bank_account;
|
||||||
|
|
||||||
|
// Determine verification state based on SetupIntent status
|
||||||
|
/** @var string $status */
|
||||||
|
$status = $setup_intent->status ?? 'unauthorized'; //@phpstan-ignore-line
|
||||||
|
$state = match ($status) {
|
||||||
|
'succeeded' => 'authorized',
|
||||||
|
'requires_action' => 'unauthorized', // Microdeposit verification pending
|
||||||
|
default => 'unauthorized',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a new stdClass object for storage (Stripe objects are immutable)
|
||||||
|
$method = new \stdClass();
|
||||||
|
$method->id = $setup_intent->payment_method; //@phpstan-ignore-line
|
||||||
|
$method->bank_name = $bank_account->bank_name;
|
||||||
|
$method->last4 = $bank_account->last4;
|
||||||
|
$method->state = $state;
|
||||||
|
|
||||||
|
// If microdeposit verification is required, store the verification URL
|
||||||
|
if ($status === 'requires_action' &&
|
||||||
|
isset($setup_intent->next_action) &&
|
||||||
|
($setup_intent->next_action->type ?? null) === 'verify_with_microdeposits') { //@phpstan-ignore-line
|
||||||
|
$method->next_action = $setup_intent->next_action->verify_with_microdeposits->hosted_verification_url ?? null; //@phpstan-ignore-line
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: We don't attach the payment method here - it's already linked to the
|
||||||
|
// customer via the SetupIntent. For us_bank_account, the payment method must be
|
||||||
|
// verified before it can be used. Verification happens via:
|
||||||
|
// - Instant verification (Financial Connections) - already verified
|
||||||
|
// - Microdeposits - verified via webhook (setup_intent.succeeded)
|
||||||
|
|
||||||
|
$client_gateway_token = $this->storePaymentMethod($method, GatewayType::BANK_TRANSFER, $customer);
|
||||||
|
|
||||||
|
// If instant verification succeeded, redirect to payment methods
|
||||||
|
if ($state === 'authorized') {
|
||||||
|
return redirect()->route('client.payment_methods.show', ['payment_method' => $client_gateway_token->hashed_id])
|
||||||
|
->with('message', ctrans('texts.payment_method_added'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If microdeposit verification required, send notification and redirect
|
||||||
|
$verification = route('client.payment_methods.verification', [
|
||||||
|
'payment_method' => $client_gateway_token->hashed_id,
|
||||||
|
'method' => GatewayType::BANK_TRANSFER
|
||||||
|
], false);
|
||||||
|
|
||||||
|
$mailer = new NinjaMailerObject();
|
||||||
|
|
||||||
|
$mailer->mailable = new ACHVerificationNotification(
|
||||||
|
auth()->guard('contact')->user()->client->company,
|
||||||
|
route('client.contact_login', [
|
||||||
|
'contact_key' => auth()->guard('contact')->user()->contact_key,
|
||||||
|
'next' => $verification
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$mailer->company = auth()->guard('contact')->user()->client->company;
|
||||||
|
$mailer->settings = auth()->guard('contact')->user()->client->company->settings;
|
||||||
|
$mailer->to_user = auth()->guard('contact')->user();
|
||||||
|
|
||||||
|
NinjaMailerJob::dispatch($mailer);
|
||||||
|
|
||||||
|
return redirect()->route('client.payment_methods.verification', [
|
||||||
|
'payment_method' => $client_gateway_token->hashed_id,
|
||||||
|
'method' => GatewayType::BANK_TRANSFER
|
||||||
|
]);
|
||||||
|
|
||||||
} catch (InvalidRequestException $e) {
|
} catch (InvalidRequestException $e) {
|
||||||
throw new PaymentFailed($e->getMessage(), $e->getCode());
|
throw new PaymentFailed($e->getMessage(), $e->getCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
$client_gateway_token = $this->storePaymentMethod($source, $request->input('method'), $customer);
|
|
||||||
|
|
||||||
$verification = route('client.payment_methods.verification', ['payment_method' => $client_gateway_token->hashed_id, 'method' => GatewayType::BANK_TRANSFER], false);
|
|
||||||
|
|
||||||
$mailer = new NinjaMailerObject();
|
|
||||||
|
|
||||||
$mailer->mailable = new ACHVerificationNotification(
|
|
||||||
auth()->guard('contact')->user()->client->company,
|
|
||||||
route('client.contact_login', ['contact_key' => auth()->guard('contact')->user()->contact_key, 'next' => $verification])
|
|
||||||
);
|
|
||||||
|
|
||||||
$mailer->company = auth()->guard('contact')->user()->client->company;
|
|
||||||
$mailer->settings = auth()->guard('contact')->user()->client->company->settings;
|
|
||||||
$mailer->to_user = auth()->guard('contact')->user();
|
|
||||||
|
|
||||||
NinjaMailerJob::dispatch($mailer);
|
|
||||||
|
|
||||||
return redirect()->route('client.payment_methods.verification', ['payment_method' => $client_gateway_token->hashed_id, 'method' => GatewayType::BANK_TRANSFER]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle customer.source.updated webhook (legacy Sources API)
|
||||||
|
*/
|
||||||
public function updateBankAccount(array $event)
|
public function updateBankAccount(array $event)
|
||||||
{
|
{
|
||||||
$stripe_event = $event['data']['object'];
|
$stripe_event = $event['data']['object'];
|
||||||
@@ -108,6 +204,57 @@ class ACH implements LivewireMethodInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle setup_intent.succeeded webhook (new SetupIntent/Financial Connections flow)
|
||||||
|
*
|
||||||
|
* This is called when microdeposit verification is completed for us_bank_account payment methods.
|
||||||
|
*/
|
||||||
|
public function handleSetupIntentSucceeded(array $event): void
|
||||||
|
{
|
||||||
|
$setup_intent = $event['data']['object'];
|
||||||
|
|
||||||
|
// Only handle us_bank_account payment methods
|
||||||
|
if (!isset($setup_intent['payment_method']) || !isset($setup_intent['payment_method_types'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array('us_bank_account', $setup_intent['payment_method_types'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payment_method_id = $setup_intent['payment_method'];
|
||||||
|
$customer_id = $setup_intent['customer'] ?? null;
|
||||||
|
|
||||||
|
if (!$payment_method_id || !$customer_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the token by payment method ID
|
||||||
|
$token = ClientGatewayToken::query()
|
||||||
|
->where('token', $payment_method_id)
|
||||||
|
->where('gateway_customer_reference', $customer_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$token) {
|
||||||
|
nlog("ACH SetupIntent succeeded but no matching token found for payment_method: {$payment_method_id}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the token state to authorized
|
||||||
|
$meta = $token->meta;
|
||||||
|
$meta->state = 'authorized';
|
||||||
|
|
||||||
|
// Clear the next_action since verification is complete
|
||||||
|
if (isset($meta->next_action)) {
|
||||||
|
unset($meta->next_action);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token->meta = $meta;
|
||||||
|
$token->save();
|
||||||
|
|
||||||
|
nlog("ACH bank account verified via SetupIntent webhook: {$payment_method_id}");
|
||||||
|
}
|
||||||
|
|
||||||
public function verificationView(ClientGatewayToken $token)
|
public function verificationView(ClientGatewayToken $token)
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -379,12 +526,16 @@ class ACH implements LivewireMethodInterface
|
|||||||
$response = json_decode($request->gateway_response);
|
$response = json_decode($request->gateway_response);
|
||||||
$bank_account_response = json_decode($request->bank_account_response);
|
$bank_account_response = json_decode($request->bank_account_response);
|
||||||
|
|
||||||
if ($response->status == 'requires_source_action' && $response->next_action->type == 'verify_with_microdeposits') {
|
if (in_array($response->status,['requires_action','requires_source_action']) && ($response->next_action->type ?? null) == 'verify_with_microdeposits') {
|
||||||
$method = $bank_account_response->payment_method->us_bank_account;
|
$method = $bank_account_response->payment_method->us_bank_account ?? null;
|
||||||
$method = $bank_account_response->payment_method->us_bank_account;
|
|
||||||
|
if (!$method) {
|
||||||
|
throw new PaymentFailed('Unable to retrieve bank account details');
|
||||||
|
}
|
||||||
|
|
||||||
$method->id = $response->payment_method;
|
$method->id = $response->payment_method;
|
||||||
$method->state = 'unauthorized';
|
$method->state = 'unauthorized';
|
||||||
$method->next_action = $response->next_action->verify_with_microdeposits->hosted_verification_url;
|
$method->next_action = $response->next_action->verify_with_microdeposits->hosted_verification_url ?? null;
|
||||||
|
|
||||||
$customer = $this->stripe->getCustomer($request->customer);
|
$customer = $this->stripe->getCustomer($request->customer);
|
||||||
$cgt = $this->storePaymentMethod($method, GatewayType::BANK_TRANSFER, $customer);
|
$cgt = $this->storePaymentMethod($method, GatewayType::BANK_TRANSFER, $customer);
|
||||||
|
|||||||
@@ -727,6 +727,12 @@ class StripePaymentDriver extends BaseDriver implements SupportsHeadlessInterfac
|
|||||||
$ach->updateBankAccount($request->all());
|
$ach->updateBankAccount($request->all());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle SetupIntent succeeded for ACH microdeposit verification
|
||||||
|
if ($request->type === 'setup_intent.succeeded') {
|
||||||
|
$ach = new ACH($this);
|
||||||
|
$ach->handleSetupIntentSucceeded($request->all());
|
||||||
|
}
|
||||||
|
|
||||||
if ($request->type === 'payment_intent.processing') {
|
if ($request->type === 'payment_intent.processing') {
|
||||||
PaymentIntentProcessingWebhook::dispatch($request->data, $request->company_key, $this->company_gateway->id)->delay(now()->addSeconds(5));
|
PaymentIntentProcessingWebhook::dispatch($request->data, $request->company_key, $this->company_gateway->id)->delay(now()->addSeconds(5));
|
||||||
return response()->json([], 200);
|
return response()->json([], 200);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
9
public/build/assets/stripe-ach-1f0bff4a.js
vendored
Normal file
9
public/build/assets/stripe-ach-1f0bff4a.js
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
var i=Object.defineProperty;var u=(n,e,t)=>e in n?i(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t;var s=(n,e,t)=>(u(n,typeof e!="symbol"?e+"":e,t),t);/**
|
||||||
|
* 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://www.elastic.co/licensing/elastic-license
|
||||||
|
*/class d{constructor(){s(this,"setupStripe",()=>(this.stripe_connect?this.stripe=Stripe(this.key,{stripeAccount:this.stripe_connect}):this.stripe=Stripe(this.key),this));s(this,"getFormData",()=>{var e;return{account_holder_name:document.getElementById("account-holder-name").value,account_holder_type:document.querySelector('input[name="account-holder-type"]:checked').value,email:((e=document.querySelector('meta[name="contact-email"]'))==null?void 0:e.content)||""}});s(this,"handleError",e=>{document.getElementById("save-button").disabled=!1,document.querySelector("#save-button > svg").classList.add("hidden"),document.querySelector("#save-button > span").classList.remove("hidden"),this.errors.textContent="",this.errors.textContent=e,this.errors.hidden=!1});s(this,"handleSuccess",e=>{document.getElementById("gateway_response").value=JSON.stringify(e),document.getElementById("server_response").submit()});s(this,"handleSubmit",async e=>{if(e.preventDefault(),!document.getElementById("accept-terms").checked){this.errors.textContent="You must accept the mandate terms prior to adding this payment method.",this.errors.hidden=!1;return}document.getElementById("save-button").disabled=!0,document.querySelector("#save-button > svg").classList.remove("hidden"),document.querySelector("#save-button > span").classList.add("hidden"),this.errors.textContent="",this.errors.hidden=!0;const t=this.getFormData();try{const{setupIntent:r,error:c}=await this.stripe.collectBankAccountForSetup({clientSecret:this.clientSecret,params:{payment_method_type:"us_bank_account",payment_method_data:{billing_details:{name:t.account_holder_name,email:t.email}}}});if(c)return this.handleError(c.message);if(r.status==="requires_payment_method")return this.handleError("Please complete the bank account verification process.");if(r.status==="requires_confirmation"){const{setupIntent:a,error:o}=await this.stripe.confirmUsBankAccountSetup(this.clientSecret);return o?this.handleError(o.message):this.handleSuccess(a)}return r.status==="requires_action"?this.handleSuccess(r):r.status==="succeeded"?this.handleSuccess(r):this.handleSuccess(r)}catch(r){return this.handleError(r.message||"An unexpected error occurred.")}});var e,t;this.errors=document.getElementById("errors"),this.key=document.querySelector('meta[name="stripe-publishable-key"]').content,this.stripe_connect=(e=document.querySelector('meta[name="stripe-account-id"]'))==null?void 0:e.content,this.clientSecret=(t=document.querySelector('meta[name="stripe-client-secret"]'))==null?void 0:t.content}handle(){document.getElementById("save-button").addEventListener("click",e=>this.handleSubmit(e))}}new d().setupStripe().handle();
|
||||||
9
public/build/assets/stripe-ach-fe366ca7.js
vendored
9
public/build/assets/stripe-ach-fe366ca7.js
vendored
@@ -1,9 +0,0 @@
|
|||||||
var s=Object.defineProperty;var o=(n,e,t)=>e in n?s(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t;var r=(n,e,t)=>(o(n,typeof e!="symbol"?e+"":e,t),t);/**
|
|
||||||
* 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://www.elastic.co/licensing/elastic-license
|
|
||||||
*/class c{constructor(){r(this,"setupStripe",()=>(this.stripeConnect?this.stripe=Stripe(this.key,{stripeAccount:this.stripeConnect}):this.stripe=Stripe(this.key),this));r(this,"getFormData",()=>({country:document.getElementById("country").value,currency:document.getElementById("currency").value,routing_number:document.getElementById("routing-number").value,account_number:document.getElementById("account-number").value,account_holder_name:document.getElementById("account-holder-name").value,account_holder_type:document.querySelector('input[name="account-holder-type"]:checked').value}));r(this,"handleError",e=>{document.getElementById("save-button").disabled=!1,document.querySelector("#save-button > svg").classList.add("hidden"),document.querySelector("#save-button > span").classList.remove("hidden"),this.errors.textContent="",this.errors.textContent=e,this.errors.hidden=!1});r(this,"handleSuccess",e=>{document.getElementById("gateway_response").value=JSON.stringify(e),document.getElementById("server_response").submit()});r(this,"handleSubmit",e=>{if(!document.getElementById("accept-terms").checked){errors.textContent="You must accept the mandate terms prior to making payment.",errors.hidden=!1;return}document.getElementById("save-button").disabled=!0,document.querySelector("#save-button > svg").classList.remove("hidden"),document.querySelector("#save-button > span").classList.add("hidden"),e.preventDefault(),this.errors.textContent="",this.errors.hidden=!0,this.stripe.createToken("bank_account",this.getFormData()).then(t=>t.hasOwnProperty("error")?this.handleError(t.error.message):this.handleSuccess(t))});var e;this.errors=document.getElementById("errors"),this.key=document.querySelector('meta[name="stripe-publishable-key"]').content,this.stripe_connect=(e=document.querySelector('meta[name="stripe-account-id"]'))==null?void 0:e.content}handle(){document.getElementById("save-button").addEventListener("click",e=>this.handleSubmit(e))}}new c().setupStripe().handle();
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"file": "assets/wait-8f4ae121.js"
|
"file": "assets/wait-8f4ae121.js"
|
||||||
},
|
},
|
||||||
"resources/js/app.js": {
|
"resources/js/app.js": {
|
||||||
"file": "assets/app-8ade2cf8.js",
|
"file": "assets/app-03a29a9d.js",
|
||||||
"imports": [
|
"imports": [
|
||||||
"_index-08e160a7.js",
|
"_index-08e160a7.js",
|
||||||
"__commonjsHelpers-725317a4.js"
|
"__commonjsHelpers-725317a4.js"
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
"src": "resources/js/clients/payments/stripe-ach-pay.js"
|
"src": "resources/js/clients/payments/stripe-ach-pay.js"
|
||||||
},
|
},
|
||||||
"resources/js/clients/payments/stripe-ach.js": {
|
"resources/js/clients/payments/stripe-ach.js": {
|
||||||
"file": "assets/stripe-ach-fe366ca7.js",
|
"file": "assets/stripe-ach-1f0bff4a.js",
|
||||||
"isEntry": true,
|
"isEntry": true,
|
||||||
"src": "resources/js/clients/payments/stripe-ach.js"
|
"src": "resources/js/clients/payments/stripe-ach.js"
|
||||||
},
|
},
|
||||||
|
|||||||
102
resources/js/clients/payments/stripe-ach.js
vendored
102
resources/js/clients/payments/stripe-ach.js
vendored
@@ -17,36 +17,30 @@ class AuthorizeACH {
|
|||||||
this.stripe_connect = document.querySelector(
|
this.stripe_connect = document.querySelector(
|
||||||
'meta[name="stripe-account-id"]'
|
'meta[name="stripe-account-id"]'
|
||||||
)?.content;
|
)?.content;
|
||||||
|
this.clientSecret = document.querySelector(
|
||||||
|
'meta[name="stripe-client-secret"]'
|
||||||
|
)?.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
setupStripe = () => {
|
setupStripe = () => {
|
||||||
|
if (this.stripe_connect) {
|
||||||
if (this.stripeConnect){
|
this.stripe = Stripe(this.key, {
|
||||||
|
stripeAccount: this.stripe_connect,
|
||||||
this.stripe = Stripe(this.key, {
|
|
||||||
stripeAccount: this.stripeConnect,
|
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.stripe = Stripe(this.key);
|
this.stripe = Stripe(this.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
getFormData = () => {
|
getFormData = () => {
|
||||||
return {
|
return {
|
||||||
country: document.getElementById('country').value,
|
account_holder_name: document.getElementById('account-holder-name').value,
|
||||||
currency: document.getElementById('currency').value,
|
|
||||||
routing_number: document.getElementById('routing-number').value,
|
|
||||||
account_number: document.getElementById('account-number').value,
|
|
||||||
account_holder_name: document.getElementById('account-holder-name')
|
|
||||||
.value,
|
|
||||||
account_holder_type: document.querySelector(
|
account_holder_type: document.querySelector(
|
||||||
'input[name="account-holder-type"]:checked'
|
'input[name="account-holder-type"]:checked'
|
||||||
).value,
|
).value,
|
||||||
|
email: document.querySelector('meta[name="contact-email"]')?.content || '',
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,40 +54,82 @@ class AuthorizeACH {
|
|||||||
this.errors.hidden = false;
|
this.errors.hidden = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSuccess = (response) => {
|
handleSuccess = (setupIntent) => {
|
||||||
document.getElementById('gateway_response').value = JSON.stringify(
|
document.getElementById('gateway_response').value = JSON.stringify(setupIntent);
|
||||||
response
|
|
||||||
);
|
|
||||||
|
|
||||||
document.getElementById('server_response').submit();
|
document.getElementById('server_response').submit();
|
||||||
};
|
};
|
||||||
|
|
||||||
handleSubmit = (e) => {
|
handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
if (!document.getElementById('accept-terms').checked) {
|
if (!document.getElementById('accept-terms').checked) {
|
||||||
errors.textContent = "You must accept the mandate terms prior to making payment.";
|
this.errors.textContent = "You must accept the mandate terms prior to adding this payment method.";
|
||||||
errors.hidden = false;
|
this.errors.hidden = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('save-button').disabled = true;
|
document.getElementById('save-button').disabled = true;
|
||||||
document.querySelector('#save-button > svg').classList.remove('hidden');
|
document.querySelector('#save-button > svg').classList.remove('hidden');
|
||||||
document.querySelector('#save-button > span').classList.add('hidden');
|
document.querySelector('#save-button > span').classList.add('hidden');
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
this.errors.textContent = '';
|
this.errors.textContent = '';
|
||||||
this.errors.hidden = true;
|
this.errors.hidden = true;
|
||||||
|
|
||||||
this.stripe
|
const formData = this.getFormData();
|
||||||
.createToken('bank_account', this.getFormData())
|
|
||||||
.then((result) => {
|
try {
|
||||||
if (result.hasOwnProperty('error')) {
|
// Step 1: Collect bank account using Financial Connections
|
||||||
return this.handleError(result.error.message);
|
const { setupIntent, error } = await this.stripe.collectBankAccountForSetup({
|
||||||
|
clientSecret: this.clientSecret,
|
||||||
|
params: {
|
||||||
|
payment_method_type: 'us_bank_account',
|
||||||
|
payment_method_data: {
|
||||||
|
billing_details: {
|
||||||
|
name: formData.account_holder_name,
|
||||||
|
email: formData.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return this.handleError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the SetupIntent status
|
||||||
|
if (setupIntent.status === 'requires_payment_method') {
|
||||||
|
// Customer closed the modal without completing - show error
|
||||||
|
return this.handleError('Please complete the bank account verification process.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setupIntent.status === 'requires_confirmation') {
|
||||||
|
// User completed Financial Connections, now confirm the SetupIntent
|
||||||
|
const { setupIntent: confirmedSetupIntent, error: confirmError } =
|
||||||
|
await this.stripe.confirmUsBankAccountSetup(this.clientSecret);
|
||||||
|
|
||||||
|
if (confirmError) {
|
||||||
|
return this.handleError(confirmError.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.handleSuccess(result);
|
return this.handleSuccess(confirmedSetupIntent);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (setupIntent.status === 'requires_action') {
|
||||||
|
// Microdeposit verification required - redirect to verification
|
||||||
|
return this.handleSuccess(setupIntent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setupIntent.status === 'succeeded') {
|
||||||
|
// Instant verification succeeded
|
||||||
|
return this.handleSuccess(setupIntent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle any other status
|
||||||
|
return this.handleSuccess(setupIntent);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
return this.handleError(err.message || 'An unexpected error occurred.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handle() {
|
handle() {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
@else
|
@else
|
||||||
<meta name="stripe-publishable-key" content="{{ $gateway->company_gateway->getPublishableKey() }}">
|
<meta name="stripe-publishable-key" content="{{ $gateway->company_gateway->getPublishableKey() }}">
|
||||||
@endif
|
@endif
|
||||||
|
<meta name="stripe-client-secret" content="{{ $client_secret }}">
|
||||||
|
<meta name="contact-email" content="{{ auth()->guard('contact')->user()->email }}">
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@section('gateway_content')
|
@section('gateway_content')
|
||||||
@@ -27,10 +29,6 @@
|
|||||||
|
|
||||||
<div class="alert alert-failure mb-4" hidden id="errors"></div>
|
<div class="alert alert-failure mb-4" hidden id="errors"></div>
|
||||||
|
|
||||||
<div class="alert alert-warning mb-4">
|
|
||||||
<h2>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.</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.account_holder_type')])
|
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.account_holder_type')])
|
||||||
<span class="flex items-center mr-4">
|
<span class="flex items-center mr-4">
|
||||||
<input class="form-radio mr-2" type="radio" value="individual" name="account-holder-type" checked>
|
<input class="form-radio mr-2" type="radio" value="individual" name="account-holder-type" checked>
|
||||||
@@ -46,40 +44,6 @@
|
|||||||
<input class="input w-full" id="account-holder-name" type="text" placeholder="{{ ctrans('texts.name') }}" required value="{{ auth()->guard('contact')->user()->client->present()->name() }}">
|
<input class="input w-full" id="account-holder-name" type="text" placeholder="{{ ctrans('texts.name') }}" required value="{{ auth()->guard('contact')->user()->client->present()->name() }}">
|
||||||
@endcomponent
|
@endcomponent
|
||||||
|
|
||||||
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.country')])
|
|
||||||
<select name="countries" id="country" class="form-select input w-full bg-white">
|
|
||||||
<option disabled selected></option>
|
|
||||||
@foreach($countries as $country)
|
|
||||||
@if($country->iso_3166_2 == 'US')
|
|
||||||
<option value="{{ $country->iso_3166_2 }}" selected>{{ $country->iso_3166_2 }} ({{ $country->getName() }})</option>
|
|
||||||
@else
|
|
||||||
<option value="{{ $country->iso_3166_2 }}">{{ $country->iso_3166_2 }} ({{ $country->getName() }})</option>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
@endcomponent
|
|
||||||
|
|
||||||
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.currency')])
|
|
||||||
<select name="currencies" id="currency" class="form-select input w-full">
|
|
||||||
<option disabled selected></option>
|
|
||||||
@foreach($currencies as $currency)
|
|
||||||
@if($currency->code == 'USD')
|
|
||||||
<option value="{{ $currency->code }}" selected>{{ $currency->code }} ({{ $currency->getName() }})</option>
|
|
||||||
@else
|
|
||||||
<option value="{{ $currency->code }}">{{ $currency->code }} ({{ $currency->name }})</option>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
@endcomponent
|
|
||||||
|
|
||||||
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.routing_number')])
|
|
||||||
<input class="input w-full" id="routing-number" type="text" required>
|
|
||||||
@endcomponent
|
|
||||||
|
|
||||||
@component('portal.ninja2020.components.general.card-element', ['title' => ctrans('texts.account_number')])
|
|
||||||
<input class="input w-full" id="account-number" type="text" required>
|
|
||||||
@endcomponent
|
|
||||||
|
|
||||||
@component('portal.ninja2020.components.general.card-element-single')
|
@component('portal.ninja2020.components.general.card-element-single')
|
||||||
<input type="checkbox" class="form-checkbox mr-1" id="accept-terms" required>
|
<input type="checkbox" class="form-checkbox mr-1" id="accept-terms" required>
|
||||||
<label for="accept-terms" class="cursor-pointer">{{ ctrans('texts.ach_authorization', ['company' => auth()->guard('contact')->user()->company->present()->name, 'email' => auth()->guard('contact')->user()->client->company->settings->email]) }}</label>
|
<label for="accept-terms" class="cursor-pointer">{{ ctrans('texts.ach_authorization', ['company' => auth()->guard('contact')->user()->company->present()->name, 'email' => auth()->guard('contact')->user()->client->company->settings->email]) }}</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user