From 9bec6d458b7d871096072613845680a59bae190d Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 6 Oct 2022 10:12:57 +1100 Subject: [PATCH 1/7] Improve Stripe ACH Payments with microdeposits --- app/PaymentDrivers/Stripe/ACH.php | 17 +++++++ .../Stripe/Jobs/PaymentIntentWebhook.php | 46 ++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/app/PaymentDrivers/Stripe/ACH.php b/app/PaymentDrivers/Stripe/ACH.php index d602d320f0c9..2a5689ccf901 100644 --- a/app/PaymentDrivers/Stripe/ACH.php +++ b/app/PaymentDrivers/Stripe/ACH.php @@ -165,6 +165,18 @@ class ACH $data['payment_method_id'] = GatewayType::BANK_TRANSFER; $data['customer'] = $this->stripe->findOrCreateCustomer(); $data['amount'] = $this->stripe->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe->client->currency()->precision, $this->stripe->client->currency()); + $amount = $data['total']['amount_with_fee']; + + $invoice = Invoice::whereIn('id', $this->transformKeys(array_column($this->stripe->payment_hash->invoices(), 'invoice_id'))) + ->withTrashed() + ->first(); + + if ($invoice) { + $description = "Invoice {$invoice->number} for {$amount} for client {$this->stripe->client->present()->name()}"; + } else { + $description = "Payment with no invoice for amount {$amount} for client {$this->stripe->client->present()->name()}"; + } + $intent = false; @@ -176,6 +188,11 @@ class ACH 'setup_future_usage' => 'off_session', 'customer' => $data['customer']->id, 'payment_method_types' => ['us_bank_account'], + 'description' => $description, + 'metadata' => [ + 'payment_hash' => $this->stripe->payment_hash->hash, + 'gateway_type_id' => GatewayType::BANK_TRANSFER, + ], ] ); } diff --git a/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php b/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php index f37f46f5a7b3..800f13f9f86b 100644 --- a/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php +++ b/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php @@ -53,7 +53,8 @@ class PaymentIntentWebhook implements ShouldQueue public function handle() { - + nlog($this->stripe_request); + MultiDB::findAndSetDbByCompanyKey($this->company_key); $company = Company::where('company_key', $this->company_key)->first(); @@ -145,7 +146,18 @@ class PaymentIntentWebhook implements ShouldQueue $this->updateCreditCardPayment($payment_hash, $client); } + elseif(array_key_exists('payment_method_types', $this->stripe_request['object']) && optional($this->stripe_request['object']['charges']['data'][0]['metadata']['payment_hash']) && in_array('us_bank_account', $this->stripe_request['object']['payment_method_types'])) + { + nlog("hash found"); + $hash = $this->stripe_request['object']['charges']['data'][0]['metadata']['payment_hash']; + + $payment_hash = PaymentHash::where('hash', $hash)->first(); + $invoice = Invoice::with('client')->find($payment_hash->fee_invoice_id); + $client = $invoice->client; + + $this->updateAchPayment($payment_hash, $client); + } } @@ -161,6 +173,38 @@ class PaymentIntentWebhook implements ShouldQueue } + private function updateAchPayment($payment_hash, $client) + { + $company_gateway = CompanyGateway::find($this->company_gateway_id); + $payment_method_type = optional($this->stripe_request['object']['charges']['data'][0]['metadata'])['gateway_type_id']; + $driver = $company_gateway->driver($client)->init()->setPaymentMethod($payment_method_type); + + $payment_hash->data = array_merge((array) $payment_hash->data, $this->stripe_request); + $payment_hash->save(); + $driver->setPaymentHash($payment_hash); + + $data = [ + 'payment_method' => $payment_hash->data->object->payment_method, + 'payment_type' => PaymentType::ACH, + 'amount' => $payment_hash->data->amount_with_fee, + 'transaction_reference' => $this->stripe_request['object']['charges']['data'][0]['id'], + 'gateway_type_id' => GatewayType::BANK_TRANSFER, + ]; + + $payment = $driver->createPayment($data, Payment::STATUS_COMPLETED); + + SystemLogger::dispatch( + ['response' => $this->stripe_request, 'data' => $data], + SystemLog::CATEGORY_GATEWAY_RESPONSE, + SystemLog::EVENT_GATEWAY_SUCCESS, + SystemLog::TYPE_STRIPE, + $client, + $client->company, + ); + + } + + private function updateCreditCardPayment($payment_hash, $client) { $company_gateway = CompanyGateway::find($this->company_gateway_id); From c7bf4b630b35b64e710e74699e68bf846244148e Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 6 Oct 2022 10:24:43 +1100 Subject: [PATCH 2/7] Improve Stripe ACH Payments with microdeposits --- .../portal/ninja2020/gateways/stripe/ach/pay.blade.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php index 31e56758f9d0..784aa182d0c2 100644 --- a/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php +++ b/resources/views/portal/ninja2020/gateways/stripe/ach/pay.blade.php @@ -207,8 +207,10 @@ gateway_response.value = JSON.stringify(paymentIntent); document.getElementById('server-response').submit(); - } else if (paymentIntent.next_action?.type === "verify_with_microdeposits") { - + } else if (paymentIntent.next_action?.type === "verify_with_microdeposits" || paymentIntent.next_action?.type === "requires_source_action") { + errors.textContent = "You will receive an email with details on how to verify your bank account and process payment."; + errors.hidden = false; + document.getElementById('new-bank').style.visibility = 'hidden' } }); From 6cd6c218dec47babee163e18e3dfe1ebc2fb2449 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 6 Oct 2022 11:00:39 +1100 Subject: [PATCH 3/7] Improve ACH flow --- .../Stripe/Jobs/PaymentIntentWebhook.php | 46 ++++++++++++++++++- .../Stripe/UpdatePaymentMethods.php | 2 +- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php b/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php index 800f13f9f86b..6b00a1221a16 100644 --- a/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php +++ b/app/PaymentDrivers/Stripe/Jobs/PaymentIntentWebhook.php @@ -13,6 +13,7 @@ namespace App\PaymentDrivers\Stripe\Jobs; use App\Jobs\Util\SystemLogger; use App\Libraries\MultiDB; +use App\Models\ClientGatewayToken; use App\Models\Company; use App\Models\CompanyGateway; use App\Models\GatewayType; @@ -21,6 +22,7 @@ use App\Models\Payment; use App\Models\PaymentHash; use App\Models\PaymentType; use App\Models\SystemLog; +use App\PaymentDrivers\Stripe\UpdatePaymentMethods; use App\PaymentDrivers\Stripe\Utilities; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -53,7 +55,6 @@ class PaymentIntentWebhook implements ShouldQueue public function handle() { - nlog($this->stripe_request); MultiDB::findAndSetDbByCompanyKey($this->company_key); @@ -202,6 +203,49 @@ class PaymentIntentWebhook implements ShouldQueue $client->company, ); + try { + + $customer = $driver->getCustomer($this->stripe_request['object']['charges']['data'][0]['customer']); + $method = $driver->getStripePaymentMethod($this->stripe_request['object']['charges']['data'][0]['payment_method']); + $payment_method = $this->stripe_request['object']['charges']['data'][0]['payment_method']; + + $token_exists = ClientGatewayToken::where([ + 'gateway_customer_reference' => $customer->id, + 'token' => $payment_method, + 'client_id' => $client->id, + 'company_id' => $client->company_id, + ])->exists(); + + /* Already exists return */ + if ($token_exists) { + return; + } + + $payment_meta = new \stdClass; + $payment_meta->brand = (string) \sprintf('%s (%s)', $method->us_bank_account['bank_name'], ctrans('texts.ach')); + $payment_meta->last4 = (string) $method->us_bank_account['last4']; + $payment_meta->type = GatewayType::BANK_TRANSFER; + $payment_meta->state = 'verified'; + + $data = [ + 'payment_meta' => $payment_meta, + 'token' => $payment_method, + 'payment_method_id' => GatewayType::BANK_TRANSFER, + ]; + + $additional_data = ['gateway_customer_reference' => $customer->id]; + + if ($customer->default_source === $method->id) { + $additional_data = ['gateway_customer_reference' => $customer->id, 'is_default' => 1]; + } + + $driver->storeGatewayToken($data, $additional_data); + + } + catch(\Exception $e){ + nlog("failed to import payment methods"); + nlog($e->getMessage()); + } } diff --git a/app/PaymentDrivers/Stripe/UpdatePaymentMethods.php b/app/PaymentDrivers/Stripe/UpdatePaymentMethods.php index 4424ca40eee7..ed333de662f2 100644 --- a/app/PaymentDrivers/Stripe/UpdatePaymentMethods.php +++ b/app/PaymentDrivers/Stripe/UpdatePaymentMethods.php @@ -70,7 +70,7 @@ class UpdatePaymentMethods $this->importBankAccounts($customer, $client); } - private function importBankAccounts($customer, $client) + public function importBankAccounts($customer, $client) { $sources = $customer->sources; From 414c1c3255bd2d26c85886af11828cd925d94a75 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 6 Oct 2022 12:14:31 +1100 Subject: [PATCH 4/7] Add index to product_key: --- .../2022_10_06_011344_add_key_to_products.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 database/migrations/2022_10_06_011344_add_key_to_products.php diff --git a/database/migrations/2022_10_06_011344_add_key_to_products.php b/database/migrations/2022_10_06_011344_add_key_to_products.php new file mode 100644 index 000000000000..cfbce3e2753c --- /dev/null +++ b/database/migrations/2022_10_06_011344_add_key_to_products.php @@ -0,0 +1,32 @@ +index(['product_key', 'company_id']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('products', function (Blueprint $table) { + // + }); + } +}; From 63ea5abebf34636c8704d716b61f7dc61aa90959 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 6 Oct 2022 12:54:30 +1100 Subject: [PATCH 5/7] Notifications --- .../Ninja/DomainFailureNotification.php | 86 +++++++++++++++++++ .../Ninja/NewAccountNotification.php | 2 - 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 app/Notifications/Ninja/DomainFailureNotification.php diff --git a/app/Notifications/Ninja/DomainFailureNotification.php b/app/Notifications/Ninja/DomainFailureNotification.php new file mode 100644 index 000000000000..95ddaf032b3c --- /dev/null +++ b/app/Notifications/Ninja/DomainFailureNotification.php @@ -0,0 +1,86 @@ +domain = $domain; + } + + /** + * Get the notification's delivery channels. + * + * @param mixed $notifiable + * @return array + */ + public function via($notifiable) + { + return ['slack']; + } + + /** + * Get the mail representation of the notification. + * + * @param mixed $notifiable + * @return MailMessage + */ + public function toMail($notifiable) + { + } + + /** + * Get the array representation of the notification. + * + * @param mixed $notifiable + * @return array + */ + public function toArray($notifiable) + { + return [ + // + ]; + } + + public function toSlack($notifiable) + { + $content = "Domain Certificate failure:\n"; + $content .= "{$this->domain}\n"; + + return (new SlackMessage) + ->success() + ->from(ctrans('texts.notification_bot')) + ->image('https://app.invoiceninja.com/favicon.png') + ->content($content); + } +} diff --git a/app/Notifications/Ninja/NewAccountNotification.php b/app/Notifications/Ninja/NewAccountNotification.php index 1cd13b8d55e2..14f5a227fd2c 100644 --- a/app/Notifications/Ninja/NewAccountNotification.php +++ b/app/Notifications/Ninja/NewAccountNotification.php @@ -79,8 +79,6 @@ class NewAccountNotification extends Notification { $content = "New Trial Started\n"; $content .= "{$this->client->name}\n"; - $content .= "Account key: {$this->account->key}\n"; - $content .= "Users: {$this->account->users()->pluck('email')}\n"; $content .= "Contacts: {$this->client->contacts()->pluck('email')}\n"; From b88e47e9d2e35fc8e25076fb7516ccc24add11fd Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 6 Oct 2022 13:48:04 +1100 Subject: [PATCH 6/7] minor fixes for expense import date --- app/Import/Transformer/Csv/ExpenseTransformer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Import/Transformer/Csv/ExpenseTransformer.php b/app/Import/Transformer/Csv/ExpenseTransformer.php index 2e5cb032dc9a..10f727bf7d33 100644 --- a/app/Import/Transformer/Csv/ExpenseTransformer.php +++ b/app/Import/Transformer/Csv/ExpenseTransformer.php @@ -42,7 +42,7 @@ class ExpenseTransformer extends BaseTransformer 'client_id' => isset($data['expense.client']) ? $this->getClientId($data['expense.client']) : null, - 'date' => strlen($this->getString($data, 'expense.date') > 1) ? date('Y-m-d', strtotime($this->getString($data, 'expense.date'))) : now()->format('Y-m-d'), + 'date' => strlen($this->getString($data, 'expense.date') > 1) ? date('Y-m-d', strtotime($data['expense.date'])) : now()->format('Y-m-d'), 'public_notes' => $this->getString($data, 'expense.public_notes'), 'private_notes' => $this->getString($data, 'expense.private_notes'), 'category_id' => isset($data['expense.category']) From 3c4dd84a4f61f93e915292f97e2f901666f0649c Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 6 Oct 2022 14:28:57 +1100 Subject: [PATCH 7/7] Improving CSV Expense imports --- app/Import/Definitions/ExpenseMap.php | 18 ++++++++++++++++++ .../Transformer/Csv/ExpenseTransformer.php | 12 ++++++++++-- .../Transformer/Csv/InvoiceTransformer.php | 4 ++-- .../Transformer/Csv/QuoteTransformer.php | 4 ++-- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/app/Import/Definitions/ExpenseMap.php b/app/Import/Definitions/ExpenseMap.php index ae530a7119a9..a0df8ed87655 100644 --- a/app/Import/Definitions/ExpenseMap.php +++ b/app/Import/Definitions/ExpenseMap.php @@ -28,6 +28,15 @@ class ExpenseMap 9 => 'expense.transaction_reference', 10 => 'expense.public_notes', 11 => 'expense.private_notes', + 12 => 'expense.tax_name1', + 13 => 'expense.tax_rate1', + 14 => 'expense.tax_name2', + 15 => 'expense.tax_rate2', + 16 => 'expense.tax_name3', + 17 => 'expense.tax_rate3', + 18 => 'expense.uses_inclusive_taxes', + 19 => 'expense.payment_date', + ]; } @@ -46,6 +55,15 @@ class ExpenseMap 9 => 'texts.transaction_reference', 10 => 'texts.public_notes', 11 => 'texts.private_notes', + 12 => 'texts.tax_name1', + 13 => 'texts.tax_rate1', + 14 => 'texts.tax_name2', + 15 => 'texts.tax_rate2', + 16 => 'texts.tax_name3', + 17 => 'texts.tax_rate3', + 18 => 'texts.uses_inclusive_taxes', + 19 => 'texts.payment_date', + ]; } } diff --git a/app/Import/Transformer/Csv/ExpenseTransformer.php b/app/Import/Transformer/Csv/ExpenseTransformer.php index 10f727bf7d33..ef0167c61b4d 100644 --- a/app/Import/Transformer/Csv/ExpenseTransformer.php +++ b/app/Import/Transformer/Csv/ExpenseTransformer.php @@ -42,7 +42,7 @@ class ExpenseTransformer extends BaseTransformer 'client_id' => isset($data['expense.client']) ? $this->getClientId($data['expense.client']) : null, - 'date' => strlen($this->getString($data, 'expense.date') > 1) ? date('Y-m-d', strtotime($data['expense.date'])) : now()->format('Y-m-d'), + 'date' => strlen($this->getString($data, 'expense.date') > 1) ? date('Y-m-d', strtotime(str_replace("/","-",$data['expense.date']))) : now()->format('Y-m-d'), 'public_notes' => $this->getString($data, 'expense.public_notes'), 'private_notes' => $this->getString($data, 'expense.private_notes'), 'category_id' => isset($data['expense.category']) @@ -55,7 +55,7 @@ class ExpenseTransformer extends BaseTransformer ? $this->getPaymentTypeId($data['expense.payment_type']) : null, 'payment_date' => isset($data['expense.payment_date']) - ? date('Y-m-d', strtotime($data['expense.payment_date'])) + ? date('Y-m-d', strtotime(str_replace("/","-",$data['expense.payment_date']))) : null, 'custom_value1' => $this->getString($data, 'expense.custom_value1'), 'custom_value2' => $this->getString($data, 'expense.custom_value2'), @@ -66,6 +66,14 @@ class ExpenseTransformer extends BaseTransformer 'expense.transaction_reference' ), 'should_be_invoiced' => $clientId ? true : false, + 'uses_inclusive_taxes' => (bool) $this->getString($data, 'expense.uses_inclusive_taxes'), + 'tax_name1' => $this->getString($data, 'expense.tax_name1'), + 'tax_rate1' => $this->getFloat($data, 'expense.tax_rate1'), + 'tax_name2' => $this->getString($data, 'expense.tax_name2'), + 'tax_rate2' => $this->getFloat($data, 'expense.tax_rate2'), + 'tax_name3' => $this->getString($data, 'expense.tax_name3'), + 'tax_rate3' => $this->getFloat($data, 'expense.tax_rate3'), + ]; } } diff --git a/app/Import/Transformer/Csv/InvoiceTransformer.php b/app/Import/Transformer/Csv/InvoiceTransformer.php index 643f201437b6..7648651c82be 100644 --- a/app/Import/Transformer/Csv/InvoiceTransformer.php +++ b/app/Import/Transformer/Csv/InvoiceTransformer.php @@ -57,10 +57,10 @@ class InvoiceTransformer extends BaseTransformer 'discount' => $this->getFloat($invoice_data, 'invoice.discount'), 'po_number' => $this->getString($invoice_data, 'invoice.po_number'), 'date' => isset($invoice_data['invoice.date']) - ? date('Y-m-d', strtotime($invoice_data['invoice.date'])) + ? date('Y-m-d', strtotime(str_replace("/","-",$invoice_data['invoice.date']))) : now()->format('Y-m-d'), 'due_date' => isset($invoice_data['invoice.due_date']) - ? date('Y-m-d', strtotime($invoice_data['invoice.due_date'])) + ? date('Y-m-d', strtotime(str_replace("/","-",$invoice_data['invoice.due_date']))) : null, 'terms' => $this->getString($invoice_data, 'invoice.terms'), 'public_notes' => $this->getString( diff --git a/app/Import/Transformer/Csv/QuoteTransformer.php b/app/Import/Transformer/Csv/QuoteTransformer.php index 59d3bcb85dc7..8c1add9da979 100644 --- a/app/Import/Transformer/Csv/QuoteTransformer.php +++ b/app/Import/Transformer/Csv/QuoteTransformer.php @@ -57,10 +57,10 @@ class QuoteTransformer extends BaseTransformer 'discount' => $this->getFloat($quote_data, 'quote.discount'), 'po_number' => $this->getString($quote_data, 'quote.po_number'), 'date' => isset($quote_data['quote.date']) - ? date('Y-m-d', strtotime($quote_data['quote.date'])) + ? date('Y-m-d', strtotime(str_replace("/","-",$quote_data['quote.date']))) : now()->format('Y-m-d'), 'due_date' => isset($quote_data['quote.due_date']) - ? date('Y-m-d', strtotime($quote_data['quote.due_date'])) + ? date('Y-m-d', strtotime(str_replace("/","-",$quote_data['quote.due_date']))) : null, 'terms' => $this->getString($quote_data, 'quote.terms'), 'public_notes' => $this->getString(