Updates for Stripe ACH

This commit is contained in:
David Bomba
2026-01-19 09:16:40 +11:00
parent 0800575e7e
commit 154b74f9cc
10 changed files with 274 additions and 117 deletions

View File

@@ -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.
- [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

View File

@@ -147,7 +147,7 @@ class Gateway extends StaticModel
case 56:
return [
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::ALIPAY => ['refund' => false, 'token_billing' => false],
GatewayType::APPLE_PAY => ['refund' => false, 'token_billing' => false],

View File

@@ -51,10 +51,46 @@ class ACH implements LivewireMethodInterface
/**
* 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)
{
$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));
}
@@ -62,36 +98,96 @@ class ACH implements LivewireMethodInterface
{
$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();
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) {
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)
{
$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)
{
@@ -379,12 +526,16 @@ class ACH implements LivewireMethodInterface
$response = json_decode($request->gateway_response);
$bank_account_response = json_decode($request->bank_account_response);
if ($response->status == 'requires_source_action' && $response->next_action->type == 'verify_with_microdeposits') {
$method = $bank_account_response->payment_method->us_bank_account;
$method = $bank_account_response->payment_method->us_bank_account;
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 ?? null;
if (!$method) {
throw new PaymentFailed('Unable to retrieve bank account details');
}
$method->id = $response->payment_method;
$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);
$cgt = $this->storePaymentMethod($method, GatewayType::BANK_TRANSFER, $customer);

View File

@@ -727,6 +727,12 @@ class StripePaymentDriver extends BaseDriver implements SupportsHeadlessInterfac
$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') {
PaymentIntentProcessingWebhook::dispatch($request->data, $request->company_key, $this->company_gateway->id)->delay(now()->addSeconds(5));
return response()->json([], 200);

File diff suppressed because one or more lines are too long

View 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();

View File

@@ -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();

View File

@@ -12,7 +12,7 @@
"file": "assets/wait-8f4ae121.js"
},
"resources/js/app.js": {
"file": "assets/app-8ade2cf8.js",
"file": "assets/app-03a29a9d.js",
"imports": [
"_index-08e160a7.js",
"__commonjsHelpers-725317a4.js"
@@ -203,7 +203,7 @@
"src": "resources/js/clients/payments/stripe-ach-pay.js"
},
"resources/js/clients/payments/stripe-ach.js": {
"file": "assets/stripe-ach-fe366ca7.js",
"file": "assets/stripe-ach-1f0bff4a.js",
"isEntry": true,
"src": "resources/js/clients/payments/stripe-ach.js"
},

View File

@@ -17,36 +17,30 @@ class AuthorizeACH {
this.stripe_connect = document.querySelector(
'meta[name="stripe-account-id"]'
)?.content;
this.clientSecret = document.querySelector(
'meta[name="stripe-client-secret"]'
)?.content;
}
setupStripe = () => {
if (this.stripeConnect){
this.stripe = Stripe(this.key, {
stripeAccount: this.stripeConnect,
});
}
else {
if (this.stripe_connect) {
this.stripe = Stripe(this.key, {
stripeAccount: this.stripe_connect,
});
} else {
this.stripe = Stripe(this.key);
}
return this;
};
getFormData = () => {
return {
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_name: document.getElementById('account-holder-name').value,
account_holder_type: document.querySelector(
'input[name="account-holder-type"]:checked'
).value,
email: document.querySelector('meta[name="contact-email"]')?.content || '',
};
};
@@ -60,40 +54,82 @@ class AuthorizeACH {
this.errors.hidden = false;
};
handleSuccess = (response) => {
document.getElementById('gateway_response').value = JSON.stringify(
response
);
handleSuccess = (setupIntent) => {
document.getElementById('gateway_response').value = JSON.stringify(setupIntent);
document.getElementById('server_response').submit();
};
handleSubmit = (e) => {
handleSubmit = async (e) => {
e.preventDefault();
if (!document.getElementById('accept-terms').checked) {
errors.textContent = "You must accept the mandate terms prior to making payment.";
errors.hidden = false;
return;
this.errors.textContent = "You must accept the mandate terms prior to adding this payment method.";
this.errors.hidden = false;
return;
}
document.getElementById('save-button').disabled = true;
document.querySelector('#save-button > svg').classList.remove('hidden');
document.querySelector('#save-button > span').classList.add('hidden');
e.preventDefault();
this.errors.textContent = '';
this.errors.hidden = true;
this.stripe
.createToken('bank_account', this.getFormData())
.then((result) => {
if (result.hasOwnProperty('error')) {
return this.handleError(result.error.message);
const formData = this.getFormData();
try {
// Step 1: Collect bank account using Financial Connections
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() {

View File

@@ -7,6 +7,8 @@
@else
<meta name="stripe-publishable-key" content="{{ $gateway->company_gateway->getPublishableKey() }}">
@endif
<meta name="stripe-client-secret" content="{{ $client_secret }}">
<meta name="contact-email" content="{{ auth()->guard('contact')->user()->email }}">
@endsection
@section('gateway_content')
@@ -27,10 +29,6 @@
<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')])
<span class="flex items-center mr-4">
<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() }}">
@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')
<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>