diff --git a/app/Models/Gateway.php b/app/Models/Gateway.php index 1fc248c0b61e..47f35f9c9863 100644 --- a/app/Models/Gateway.php +++ b/app/Models/Gateway.php @@ -134,13 +134,14 @@ class Gateway extends StaticModel GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => [' ']], ]; break; - case 56: + case 56: //Stripe return [ GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true, 'webhooks' => ['payment_intent.succeeded']], 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', 'payment_intent.succeeded']], //Stripe + GatewayType::SOFORT => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']], + GatewayType::KLARNA => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']], GatewayType::SEPA => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']], GatewayType::PRZELEWY24 => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']], GatewayType::GIROPAY => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']], diff --git a/app/Models/GatewayType.php b/app/Models/GatewayType.php index a3e1bc4fd2e6..9802edae51fa 100644 --- a/app/Models/GatewayType.php +++ b/app/Models/GatewayType.php @@ -61,6 +61,8 @@ class GatewayType extends StaticModel const FPX = 22; + const KLARNA = 23; + public function gateway() { return $this->belongsTo(Gateway::class); @@ -116,6 +118,8 @@ class GatewayType extends StaticModel return ctrans('texts.payment_type_instant_bank_pay'); case self::FPX: return ctrans('texts.fpx'); + case self::KLARNA: + return ctrans('texts.klarna'); default: return ' '; break; diff --git a/app/Models/PaymentType.php b/app/Models/PaymentType.php index 22eb885b787d..21b165014c65 100644 --- a/app/Models/PaymentType.php +++ b/app/Models/PaymentType.php @@ -55,6 +55,7 @@ class PaymentType extends StaticModel const ACSS = 44; const INSTANT_BANK_PAY = 45; const FPX = 46; + const KLARNA = 47; public static function parseCardType($cardName) { diff --git a/app/PaymentDrivers/Stripe/Klarna.php b/app/PaymentDrivers/Stripe/Klarna.php new file mode 100644 index 000000000000..dfafdef7a2a2 --- /dev/null +++ b/app/PaymentDrivers/Stripe/Klarna.php @@ -0,0 +1,154 @@ +stripe = $stripe; + } + + public function authorizeView($data) + { + return render('gateways.stripe.klarna.authorize', $data); + } + + public function paymentView(array $data) + { + $this->stripe->init(); + + $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; + + $amount = $data['total']['amount_with_fee']; + + $invoice_numbers = collect($data['invoices'])->pluck('invoice_number'); + + if ($invoice_numbers.length > 0) { + $description = ctrans('texts.payment_provider_paymenttext', ['invoicenumber' => $invoice_numbers->implode(', '), 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + } else { + $description = ctrans('texts.payment_prvoder_paymenttext_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + } + + $intent = \Stripe\PaymentIntent::create([ + 'amount' => $data['stripe_amount'], + 'currency' => 'eur', + 'payment_method_types' => ['klarna'], + 'customer' => $this->stripe->findOrCreateCustomer(), + 'description' => $description, + 'metadata' => [ + 'payment_hash' => $this->stripe->payment_hash->hash, + 'gateway_type_id' => GatewayType::GIROPAY, + ], + ], $this->stripe->stripe_connect_auth); + + $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.klarna.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::KLARNA, + ]); + } + + public function paymentResponse($request) + { + $this->stripe->payment_hash->data = array_merge((array) $this->stripe->payment_hash->data, $request->all()); + $this->stripe->payment_hash->save(); + + if (in_array($request->redirect_status, ['succeeded','pending'])) { + return $this->processSuccessfulPayment($request->payment_intent); + } + + return $this->processUnsuccessfulPayment(); + } + + public function processSuccessfulPayment(string $payment_intent) + { + + $this->stripe->init(); + + //catch duplicate submissions. + if (Payment::where('transaction_reference', $payment_intent)->exists()) { + return redirect()->route('client.payments.index'); + } + + $data = [ + 'payment_method' => $payment_intent, + 'payment_type' => PaymentType::GIROPAY, + '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::KLARNA, + ]; + + $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; + + $this->stripe->sendFailureMail($server_response); + + $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(ctrans('texts.payment_provider_failed_process_payment'), 500); + } +} diff --git a/app/PaymentDrivers/StripePaymentDriver.php b/app/PaymentDrivers/StripePaymentDriver.php index 73500a5a70e8..09926793567d 100644 --- a/app/PaymentDrivers/StripePaymentDriver.php +++ b/app/PaymentDrivers/StripePaymentDriver.php @@ -410,7 +410,7 @@ class StripePaymentDriver extends BaseDriver { $this->init(); - $params = []; + $params = ['usage' => 'off_session']; $meta = $this->stripe_connect_auth; return SetupIntent::create($params, $meta); @@ -669,14 +669,22 @@ class StripePaymentDriver extends BaseDriver ], $this->stripe_connect_auth); if ($charge->captured) { - $payment = Payment::query() - ->where('transaction_reference', $transaction['payment_intent']) - ->where('company_id', $request->getCompany()->id) - ->where(function ($query) use ($transaction) { - $query->where('transaction_reference', $transaction['payment_intent']) - ->orWhere('transaction_reference', $transaction['id']); - }) - ->first(); + + $payment = false; + + if(isset($transaction['payment_intent'])) + { + $payment = Payment::query() + ->where('transaction_reference', $transaction['payment_intent']) + ->where('company_id', $request->getCompany()->id) + ->first(); + } + elseif(isset($transaction['id'])) { + $payment = Payment::query() + ->where('transaction_reference', $transaction['id']) + ->where('company_id', $request->getCompany()->id) + ->first(); + } if ($payment) { $payment->status_id = Payment::STATUS_COMPLETED; diff --git a/database/migrations/2022_05_12_56879_add_stripe_klarna.php b/database/migrations/2022_05_12_56879_add_stripe_klarna.php new file mode 100644 index 000000000000..969553634d52 --- /dev/null +++ b/database/migrations/2022_05_12_56879_add_stripe_klarna.php @@ -0,0 +1,34 @@ +id = 47; + $type->name = 'Klarna'; + $type->gateway_type_id = GatewayType::KLARNA; + + $type->save(); + }); + $type = new GatewayType(); + + $type->id = 23; + $type->alias = 'klarna'; + $type->name = 'Klarna'; + + $type->save(); + } +}; diff --git a/lang/en/texts.php b/lang/en/texts.php index f829dee616e0..fe90fc06bef5 100644 --- a/lang/en/texts.php +++ b/lang/en/texts.php @@ -2822,11 +2822,11 @@ $LANG = array( 'invalid_url' => 'Invalid URL', 'workflow_settings' => 'Workflow Settings', 'auto_email_invoice' => 'Auto Email', - 'auto_email_invoice_help' => 'Automatically email recurring invoices when they are created.', + 'auto_email_invoice_help' => 'Automatically email recurring invoices when created.', 'auto_archive_invoice' => 'Auto Archive', - 'auto_archive_invoice_help' => 'Automatically archive invoices when they are paid.', + 'auto_archive_invoice_help' => 'Automatically archive invoices when paid.', 'auto_archive_quote' => 'Auto Archive', - 'auto_archive_quote_help' => 'Automatically archive quotes when they are converted.', + 'auto_archive_quote_help' => 'Automatically archive quotes when converted to invoice.', 'require_approve_quote' => 'Require approve quote', 'require_approve_quote_help' => 'Require clients to approve quotes.', 'allow_approve_expired_quote' => 'Allow approve expired quote', @@ -3414,7 +3414,7 @@ $LANG = array( 'credit_number_counter' => 'Credit Number Counter', 'reset_counter_date' => 'Reset Counter Date', 'counter_padding' => 'Counter Padding', - 'shared_invoice_quote_counter' => 'Shared Invoice Quote Counter', + 'shared_invoice_quote_counter' => 'Share Invoice Quote Counter', 'default_tax_name_1' => 'Default Tax Name 1', 'default_tax_rate_1' => 'Default Tax Rate 1', 'default_tax_name_2' => 'Default Tax Name 2', @@ -3688,7 +3688,7 @@ $LANG = array( 'force_update_help' => 'You are running the latest version but there may be pending fixes available.', 'mark_paid_help' => 'Track the expense has been paid', 'mark_invoiceable_help' => 'Enable the expense to be invoiced', - 'add_documents_to_invoice_help' => 'Make the documents visible', + 'add_documents_to_invoice_help' => 'Make the documents visible to client', 'convert_currency_help' => 'Set an exchange rate', 'expense_settings' => 'Expense Settings', 'clone_to_recurring' => 'Clone to Recurring', @@ -4061,7 +4061,7 @@ $LANG = array( 'save_payment_method_details' => 'Save payment method details', 'new_card' => 'New card', 'new_bank_account' => 'New bank account', - 'company_limit_reached' => 'Limit of 10 companies per account.', + 'company_limit_reached' => 'Limit of :limit companies per account.', 'credits_applied_validation' => 'Total credits applied cannot be MORE than total of invoices', 'credit_number_taken' => 'Credit number already taken', 'credit_not_found' => 'Credit not found', @@ -4199,7 +4199,7 @@ $LANG = array( 'client_id_number' => 'Client ID Number', 'count_minutes' => ':count Minutes', 'password_timeout' => 'Password Timeout', - 'shared_invoice_credit_counter' => 'Shared Invoice/Credit Counter', + 'shared_invoice_credit_counter' => 'Share Invoice/Credit Counter', 'activity_80' => ':user created subscription :subscription', 'activity_81' => ':user updated subscription :subscription', @@ -4219,7 +4219,7 @@ $LANG = array( 'max_companies_desc' => 'You have reached your maximum number of companies. Delete existing companies to migrate new ones.', 'migration_already_completed' => 'Company already migrated', 'migration_already_completed_desc' => 'Looks like you already migrated :company_name to the V5 version of the Invoice Ninja. In case you want to start over, you can force migrate to wipe existing data.', - 'payment_method_cannot_be_authorized_first' => 'This payment method can be can saved for future use, once you complete your first transaction. Don\'t forget to check "Store credit card details" during payment process.', + 'payment_method_cannot_be_authorized_first' => 'This payment method can be can saved for future use, once you complete your first transaction. Don\'t forget to check "Store details" during payment process.', 'new_account' => 'New account', 'activity_100' => ':user created recurring invoice :recurring_invoice', 'activity_101' => ':user updated recurring invoice :recurring_invoice', @@ -4254,7 +4254,7 @@ $LANG = array( 'auto_bill_disabled' => 'Auto Bill Disabled', 'select_payment_method' => 'Select a payment method:', 'login_without_password' => 'Log in without password', - 'email_sent' => 'E-mail sent, please check your inbox.', + 'email_sent' => 'Email me when an invoice is sent', 'one_time_purchases' => 'One time purchases', 'recurring_purchases' => 'Recurring purchases', 'you_might_be_interested_in_following' => 'You might be interested in the following', @@ -4540,7 +4540,7 @@ $LANG = array( 'reminder_message' => 'Reminder for invoice :number for :balance', 'gmail_credentials_invalid_subject' => 'Send with GMail invalid credentials', 'gmail_credentials_invalid_body' => 'Your GMail credentials are not correct, please log into the administrator portal and navigate to Settings > User Details and disconnect and reconnect your GMail account. We will send you this notification daily until this issue is resolved', - 'notification_invoice_sent' => 'Invoice Sent', + 'notification_invoice_sent' => 'The following client :client was emailed Invoice :invoice for :amount.', 'total_columns' => 'Total Fields', 'view_task' => 'View Task', 'cancel_invoice' => 'Cancel', @@ -4573,13 +4573,13 @@ $LANG = array( 'tax_amount3' => 'Tax Amount 3', 'update_project' => 'Update Project', 'auto_archive_invoice_cancelled' => 'Auto Archive Cancelled Invoice', - 'auto_archive_invoice_cancelled_help' => 'Automatically archive invoices when they are cancelled', + 'auto_archive_invoice_cancelled_help' => 'Automatically archive invoices when cancelled', 'no_invoices_found' => 'No invoices found', 'created_record' => 'Successfully created record', 'auto_archive_paid_invoices' => 'Auto Archive Paid', 'auto_archive_paid_invoices_help' => 'Automatically archive invoices when they are paid.', 'auto_archive_cancelled_invoices' => 'Auto Archive Cancelled', - 'auto_archive_cancelled_invoices_help' => 'Automatically archive invoices when they are cancelled.', + 'auto_archive_cancelled_invoices_help' => 'Automatically archive invoices when cancelled.', 'alternate_pdf_viewer' => 'Alternate PDF Viewer', 'alternate_pdf_viewer_help' => 'Improve scrolling over the PDF preview [BETA]', 'currency_cayman_island_dollar' => 'Cayman Island Dollar', @@ -4777,6 +4777,80 @@ $LANG = array( 'invoice_task_project' => 'Invoice Task Project', 'invoice_task_project_help' => 'Add the project to the invoice line items', + 'bulk_action' => 'Bulk Action', + 'phone_validation_error' => 'This mobile/cell phone number is not valid, please enter in E.164 format', + 'transaction' => 'Transaction', + 'disable_2fa' => 'Disable 2FA', + 'change_number' => 'Change Number', + 'resend_code' => 'Resend Code', + 'base_type' => 'Base Type', + 'category_type' => 'Category Type', + 'bank_transaction' => 'Transaction', + 'bulk_print' => 'Print PDF', + 'vendor_postal_code' => 'Vendor Postal Code', + 'preview_location' => 'Preview Location', + 'bottom' => 'Bottom', + 'side' => 'Side', + 'pdf_preview' => 'PDF Preview', + 'long_press_to_select' => 'Long Press to Select', + 'purchase_order_item' => 'Purchase Order Item', + 'would_you_rate_the_app' => 'Would you like to rate the app?', + 'include_deleted' => 'Include Deleted', + 'include_deleted_help' => 'Include deleted records in reports', + 'due_on' => 'Due On', + 'browser_pdf_viewer' => 'Use Browser PDF Viewer', + 'browser_pdf_viewer_help' => 'Warning: Prevents interacting with app over the PDF', + 'converted_transactions' => 'Successfully converted transactions', + 'default_category' => 'Default Category', + 'connect_accounts' => 'Connect Accounts', + 'manage_rules' => 'Manage Rules', + 'search_category' => 'Search 1 Category', + 'search_categories' => 'Search :count Categories', + 'min_amount' => 'Min Amount', + 'max_amount' => 'Max Amount', + 'converted_transaction' => 'Successfully converted transaction', + 'convert_to_payment' => 'Convert to Payment', + 'deposit' => 'Deposit', + 'withdrawal' => 'Withdrawal', + 'deposits' => 'Deposits', + 'withdrawals' => 'Withdrawals', + 'matched' => 'Matched', + 'unmatched' => 'Unmatched', + 'create_credit' => 'Create Credit', + 'transactions' => 'Transactions', + 'new_transaction' => 'New Transaction', + 'edit_transaction' => 'Edit Transaction', + 'created_transaction' => 'Successfully created transaction', + 'updated_transaction' => 'Successfully updated transaction', + 'archived_transaction' => 'Successfully archived transaction', + 'deleted_transaction' => 'Successfully deleted transaction', + 'removed_transaction' => 'Successfully removed transaction', + 'restored_transaction' => 'Successfully restored transaction', + 'search_transaction' => 'Search Transaction', + 'search_transactions' => 'Search :count Transactions', + 'deleted_bank_account' => 'Successfully deleted bank account', + 'removed_bank_account' => 'Successfully removed bank account', + 'restored_bank_account' => 'Successfully restored bank account', + 'search_bank_account' => 'Search Bank Account', + 'search_bank_accounts' => 'Search :count Bank Accounts', + 'code_was_sent_to' => 'A code has been sent via SMS to :number', + 'verify_phone_number_2fa_help' => 'Please verify your phone number for 2FA backup', + 'enable_applying_payments_later' => 'Enable Applying Payments Later', + 'line_item_tax_rates' => 'Line Item Tax Rates', + 'show_tasks_in_client_portal' => 'Show Tasks in Client Portal', + 'notification_quote_expired_subject' => 'Quote :invoice has expired for :client', + 'notification_quote_expired' => 'The following Quote :invoice for client :client and :amount has now expired.', + 'auto_sync' => 'Auto Sync', + 'refresh_accounts' => 'Refresh Accounts', + 'upgrade_to_connect_bank_account' => 'Upgrade to Enterprise to connect your bank account', + 'click_here_to_connect_bank_account' => 'Click here to connect your bank account', + 'include_tax' => 'Include tax', + 'email_template_change' => 'E-mail template body can be changed on', + 'task_update_authorization_error' => 'Insufficient permissions, or task may be locked', + 'cash_vs_accrual' => 'Accrual accounting', + 'cash_vs_accrual_help' => 'Turn on for accrual reporting, turn off for cash basis reporting.', + 'expense_paid_report' => 'Expensed reporting', + 'expense_paid_report_help' => 'Turn on for reporting all expenses, turn off for reporting only paid expenses', ); return $LANG; diff --git a/resources/js/clients/payments/stripe-klarna.js b/resources/js/clients/payments/stripe-klarna.js new file mode 100644 index 000000000000..dfaa63092919 --- /dev/null +++ b/resources/js/clients/payments/stripe-klarna.js @@ -0,0 +1,68 @@ +/** + * 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 ProcessKlarna { + constructor(key, stripeConnect) { + this.key = key; + this.errors = document.getElementById('errors'); + this.stripeConnect = stripeConnect; + } + + setupStripe = () => { + + if (this.stripeConnect){ + // this.stripe.stripeAccount = this.stripeConnect; + + this.stripe = Stripe(this.key, { + stripeAccount: this.stripeConnect, + }); + + } + else { + this.stripe = Stripe(this.key); + } + + + return this; + }; + + handle = () => { + document.getElementById('pay-now').addEventListener('click', (e) => { + let errors = document.getElementById('errors'); + + document.getElementById('pay-now').disabled = true; + document.querySelector('#pay-now > svg').classList.remove('hidden'); + document.querySelector('#pay-now > span').classList.add('hidden'); + + this.stripe.confirmKlarnaPayment( + document.querySelector('meta[name=pi-client-secret').content, + { + payment_method: { + billing_details: { + name: document.getElementById("giropay-name").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 ProcessKlarna(publishableKey, stripeConnect).setupStripe().handle(); diff --git a/resources/views/portal/ninja2020/gateways/stripe/klarna/authorize.blade.php b/resources/views/portal/ninja2020/gateways/stripe/klarna/authorize.blade.php new file mode 100644 index 000000000000..ceb2d28000d5 --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/stripe/klarna/authorize.blade.php @@ -0,0 +1,7 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => ctrans('texts.bank_account'), 'card_title' => ctrans('texts.bank_account')]) + +@section('gateway_content') + @component('portal.ninja2020.components.general.card-element-single', ['title' => ctrans('texts.bank_account'), 'show_title' => false]) + {{ __('texts.sofort_authorize_label') }} + @endcomponent +@endsection diff --git a/resources/views/portal/ninja2020/gateways/stripe/klarna/pay.blade.php b/resources/views/portal/ninja2020/gateways/stripe/klarna/pay.blade.php new file mode 100644 index 000000000000..54989216165f --- /dev/null +++ b/resources/views/portal/ninja2020/gateways/stripe/klarna/pay.blade.php @@ -0,0 +1,31 @@ +@extends('portal.ninja2020.layout.payments', ['gateway_title' => 'Klarna', 'card_title' => 'Klarna']) + +@section('gateway_head') + @if($gateway->company_gateway->getConfigField('account_id')) + + + @else + + @endif + + + + + +@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.klarna') }} ({{ ctrans('texts.bank_transfer') }}) + @endcomponent + @include('portal.ninja2020.gateways.includes.pay_now') +@endsection + +@push('footer') + + +@endpush diff --git a/webpack.mix.js b/webpack.mix.js index 2ed78e49227c..df0604426709 100644 --- a/webpack.mix.js +++ b/webpack.mix.js @@ -22,6 +22,10 @@ mix.js("resources/js/app.js", "public/js") "resources/js/clients/payments/stripe-ach.js", "public/js/clients/payments/stripe-ach.js" ) + .js( + "resources/js/clients/payments/stripe-klarna.js", + "public/js/clients/payments/stripe-klarna.js" + ) .js( "resources/js/clients/invoices/action-selectors.js", "public/js/clients/invoices/action-selectors.js"