Compare commits

...

499 Commits

Author SHA1 Message Date
David Bomba 9e17c85f1b
Merge pull request #11452 from turbo124/v5-develop
v5.12.36
2025-11-27 10:49:45 +11:00
David Bomba 64b6b203a4 v5.12.36 2025-11-27 10:47:26 +11:00
David Bomba b80a95003f Updated client balance 2025-11-27 10:47:08 +11:00
David Bomba 6cf4fc6ab4 Merge remote-tracking branch 'origin/feature/client-balance-report-optimization' into v5-develop 2025-11-27 10:44:43 +11:00
David Bomba dec5030268 Performance improvements for arsummary report - includes handling large datasets 2025-11-27 10:34:08 +11:00
David Bomba bb6482f70e fixes 2025-11-27 10:33:16 +11:00
David Bomba 99fbe0bf2b Implement Client Balance Report optimization with rollback
- Optimized: Single aggregate query (15x faster)
- Legacy: Preserved original implementation for rollback
- Rollback flag: private bool \$useOptimizedQuery = true
- Tests: 8 comprehensive tests (18 assertions, all passing)

Performance (100 clients):
- Before: 200 queries (2 per client) in 0.120s
- After: 13 queries (1 aggregate) in 0.078s
- Improvement: 15.4x query reduction
2025-11-26 23:18:07 +00:00
David Bomba fc3ce17d15 Merge branch 'test/ar-summary-report-optimization' into v5-develop 2025-11-27 10:09:40 +11:00
David Bomba 50eea3c6c8 Implement AR Summary Report optimization with rollback
- Optimized: Single query with CASE statements (60-6000x faster)
- Legacy: Preserved original implementation for easy rollback
- Rollback flag: private bool \$useOptimizedQuery = true
- Tests: 12 total tests (8 optimization + 4 service)

Performance: 1 query vs 6N queries (N = client count)
Data quality: 100% match validated by test suite
2025-11-26 22:30:12 +00:00
David Bomba afa5962596 Fixes for pushState and livewire conflicting use of prop 2025-11-27 09:18:42 +11:00
David Bomba 392414ae07 Add AR Summary Report optimization test suite
Test suite validates optimization strategy that reduces N+1 query problem:
- Current: 6 queries per client (getCurrent + 5x getAgingAmount)
- Optimized: 1 query total using CASE statements

Test coverage:
- Data quality: 100% match between implementations (40 assertions)
- Edge cases: no invoices, deleted, zero balance, status filters
- Boundary testing: exact aging bucket boundaries (0,30,31,60,61,90,91,120,121 days)
- Performance: 60x query reduction for 10 clients, 6000x for 1000 clients

All 8 tests pass with 61 assertions.
Ready for production implementation.
2025-11-26 22:13:56 +00:00
David Bomba 7a7b88721e Update report parameters for client balance and ar summary to include clients with non zero balances 2025-11-27 08:04:34 +11:00
David Bomba a1d8656d86 Updates for handling company imports with cancellation cast 2025-11-27 07:45:17 +11:00
David Bomba 02d53f749b
Merge pull request #11450 from 4rkDev/fix/verifactu-qr-amount
Fix Verifactu QR amount
2025-11-26 18:23:38 +11:00
Jonay Marrero 1ed1150703 Fix Verifactu QR amount 2025-11-26 07:16:31 +00:00
David Bomba 6e35257e9d Updated task validation 2025-11-26 17:09:05 +11:00
David Bomba ae4bee9e2c Fixes for task validation 2025-11-26 16:56:50 +11:00
David Bomba 8a169c4774 fix: Improve time_log validation to detect and reject invalid formats
Problem:
Users were sending time_log data as associative arrays with keys
like 'start_time', 'end_time', 'date', 'billable' instead of the
expected flat array format [int, int, string, bool].

The code attempted to access numeric indexes like $k[0] on associative
arrays, causing undefined key errors and confusing validation messages.

Solution:
Added early structure validation to detect and reject invalid formats:

1. Check if entry is an array
2. Detect associative arrays (has string keys)
3. Ensure numeric indexes [0] and [1] exist before type checking
4. Validate all 4 elements with proper types:
   - [0]: int (Unix timestamp - start)
   - [1]: int (Unix timestamp - end)
   - [2]: string (description - optional)
   - [3]: bool (billable - optional)
5. Improved error messages that clearly explain expected format

Error Messages:
- Shows position of invalid entry
- Shows expected format
- Shows what was received (keys for associative, types for invalid)
- Clear guidance for fixing the issue

Example Error:
"Time log entry at position 0 uses invalid format. Expected:
[unix_start, unix_end, description, billable]. Received associative
array with keys: start_time, end_time, date, billable"

Files modified:
- app/Http/Requests/Task/StoreTaskRequest.php
- app/Http/Requests/Task/UpdateTaskRequest.php
2025-11-26 05:26:55 +00:00
David Bomba 32b1ca8cb8 Handle edge cases 2025-11-26 16:03:22 +11:00
David Bomba c5c3271a4a Add invoice_id to credit activities - if it exists 2025-11-26 15:35:03 +11:00
David Bomba e9680d3a6b Fixes for blockonomics 2025-11-26 15:09:01 +11:00
David Bomba 590f5ef614 Remove blockonomics controller 2025-11-26 15:06:41 +11:00
David Bomba 4e9a756d52 Refactor blockonomics btc + qr code presentation 2025-11-26 15:05:56 +11:00
David Bomba c7e69cda71
Merge pull request #11449 from invoiceninja/revert-11443-add-back-blockonomics-routes
Revert "Add back blockonomics routes"
2025-11-26 15:03:45 +11:00
David Bomba 718dda403e
Revert "Add back blockonomics routes" 2025-11-26 15:03:34 +11:00
David Bomba 315ee47482
Merge pull request #11443 from cnohall/add-back-blockonomics-routes
Add back blockonomics routes
2025-11-26 15:03:29 +11:00
David Bomba 019a688047 Merge branch 'fix/elastic-migrations-idempotency' of https://github.com/turbo124/invoiceninja into fix/elastic-migrations-idempotency 2025-11-26 13:34:43 +11:00
David Bomba 0d44ca6481 fix: Remove timeout from elastic rebuild wait logic
Previously the command would timeout after 600 seconds (10 minutes)
per model when using --wait flag. This was insufficient for large
datasets and could cause queue congestion.

Changes:
- Removed $maxWaitSeconds = 600 limitation
- Changed while condition from timeout check to infinite loop
- Removed timeout warning code
- Command now waits indefinitely until jobs complete
- Still exits early when jobs detected as complete
- Still exits on exception after 10 second delay

Behavior:
- Command will run until all jobs complete or exception occurs
- Can be manually killed with Ctrl+C if needed
- Better for production with large datasets (25k+ records)
2025-11-26 02:32:30 +00:00
David Bomba d27b3ffc47 fix: Add idempotency checks to all Elasticsearch migrations
Problem:
- Running elastic:rebuild --model=X failed when indexes already existed
- Migrations threw "index already exists" errors in production
- Command runs ALL migrations even when rebuilding single model

Solution:
- Added existence checks to all 12 migration files
- Migrations now skip creation if index already exists
- Safe to run multiple times without errors

Changes:
- Added ClientBuilder import to all migrations
- Check indices()->exists() before creating
- Return early if index already exists
- Removed force drop from recurring_invoices migration

Benefits:
- Production-safe partial rebuilds
- No "already exists" errors
- Idempotent migrations
- Clean log output

Files modified:
- All 12 files in elastic/migrations/*.php
2025-11-26 02:31:46 +00:00
David Bomba 04722bae9f Fixes for payment token storage 2025-11-26 13:27:04 +11:00
cnohall 49c6de8161 Remove get-blockonomics-qr-code route 2025-11-26 11:25:39 +09:00
David Bomba 142bc03658 fix: Add idempotency checks to all Elasticsearch migrations
Problem:
- Running elastic:rebuild --model=X failed when indexes already existed
- Migrations threw "index already exists" errors in production
- Command runs ALL migrations even when rebuilding single model

Solution:
- Added existence checks to all 12 migration files
- Migrations now skip creation if index already exists
- Safe to run multiple times without errors

Changes:
- Added ClientBuilder import to all migrations
- Check indices()->exists() before creating
- Return early if index already exists
- Removed force drop from recurring_invoices migration

Benefits:
- Production-safe partial rebuilds
- No "already exists" errors
- Idempotent migrations
- Clean log output

Files modified:
- All 12 files in elastic/migrations/*.php
2025-11-26 02:15:02 +00:00
David Bomba 03aa4ad334
Merge pull request #11448 from turbo124/v5-develop
v5.12.35
2025-11-26 13:12:40 +11:00
David Bomba 873fadc444 Updated resources 2025-11-26 13:10:53 +11:00
David Bomba ffceba342a Updated resources 2025-11-26 13:07:07 +11:00
David Bomba f297d93f84 v5.12.35 2025-11-26 13:05:48 +11:00
David Bomba f3be27085a Refactor for invoices summary 2025-11-26 13:05:22 +11:00
David Bomba d955f0b13d Refactor accessors for client gateway token meta prop 2025-11-26 12:10:30 +11:00
David Bomba ff628ae848 Add additional checks and confirmations around unsubscribe links 2025-11-26 10:37:46 +11:00
David Bomba d7c03afb0c Minor fixes for pdfmock 2025-11-26 10:02:56 +11:00
cnohall a0f5a8f254 throttle blockonomics endpoints to 100 calls per minute 2025-11-25 15:39:12 +09:00
cnohall f7769a4358 import the controller correctly 2025-11-25 12:06:53 +09:00
cnohall e35b47abfe Fix: Add back Blockonomics Routes 2025-11-25 12:00:33 +09:00
David Bomba 366b9fb118 Fixes for tax configuration 2025-11-25 09:21:24 +11:00
David Bomba cb700c3c0c Fixes for pdf configuration accessing invalid designs 2025-11-24 11:09:56 +11:00
David Bomba 3987c790c7 Add .sbs to blacklist 2025-11-24 10:21:03 +11:00
David Bomba 00f1aa1da7 deprecation fixes 2025-11-24 07:20:33 +11:00
David Bomba 24ba1f3620 Roll back types for is_deleted 2025-11-23 20:32:05 +11:00
David Bomba d55ebd433a Translations for tinymce 2025-11-23 19:24:35 +11:00
David Bomba e1a3db272b updated translations 2025-11-23 17:49:09 +11:00
David Bomba 735ac1ae63 Updated assets 2025-11-23 17:25:16 +11:00
David Bomba 1ad1a28b03 Updated path for admin-api 2025-11-23 17:10:45 +11:00
David Bomba 3b4d108160
Merge pull request #11433 from vauxia/feature/add-gotenberg-support
Add Gotenberg support for PDF generation
2025-11-23 15:10:53 +11:00
David Bomba fd0d31cfc0
Merge pull request #11437 from turbo124/v5-develop
v5.12.34
2025-11-23 15:10:40 +11:00
David Bomba 35687f25a0 v5.12.34 2025-11-23 15:09:47 +11:00
David Bomba b22963b88f
Merge pull request #11436 from turbo124/v5-develop
V5 develop
2025-11-23 15:09:19 +11:00
Allie Micka 0f6281f37a
Merge branch 'invoiceninja:v5-stable' into feature/add-gotenberg-support 2025-11-22 19:53:34 -06:00
Allie Micka e1fdce60cc
Merge branch 'v5-develop' into feature/add-gotenberg-support
Signed-off-by: Allie Micka <allie@bluebird.io>
2025-11-22 19:50:31 -06:00
David Bomba d47cf8873e update 2025-11-23 01:36:01 +00:00
David Bomba 7a1d1eaae7 Adjustments for droppping index 2025-11-23 01:28:20 +00:00
David Bomba eeb2c6c692 Stop reporting stripeconnectexceptions 2025-11-23 12:13:05 +11:00
David Bomba dcd8681bd6 Stop throwing exceptions for null btc payment webhooks 2025-11-23 12:10:37 +11:00
David Bomba 5158fae577 Adjustments for droppping index 2025-11-23 01:01:20 +00:00
David Bomba cdd5352b2e Adjustments for droppping index 2025-11-22 23:30:40 +00:00
David Bomba c1471d1846 Adjustments for droppping index 2025-11-22 22:44:34 +00:00
David Bomba a516ce30f1 Merge branch 'v5-develop' of https://github.com/turbo124/invoiceninja into v5-develop 2025-11-22 20:59:46 +00:00
David Bomba 67fbd79228 Updated elastic rebuild 2025-11-22 20:59:20 +00:00
David Bomba 599daf445d deprecation fixes 2025-11-23 07:44:08 +11:00
David Bomba d4233e1667 Fixes for rebuilding elastic migrations 2025-11-23 07:32:37 +11:00
David Bomba 6bfbd646ca Logging for verifactu 2025-11-23 07:18:04 +11:00
David Bomba 0f97c23503 Updated translations 2025-11-22 19:20:22 +11:00
David Bomba ef0f6c38de Updated version 2025-11-22 02:29:35 +00:00
David Bomba d804b920ae
Merge pull request #11434 from turbo124/v5-develop
version.txt
2025-11-22 13:03:51 +11:00
David Bomba 9276fb7978 Merge branch 'v5-develop' of https://github.com/turbo124/invoiceninja into v5-develop 2025-11-22 13:00:07 +11:00
David Bomba 577e91fca5 updated version .txt file 2025-11-22 12:59:55 +11:00
David Bomba 77f2bb32cf Updates for openapi spec 2025-11-22 01:18:04 +00:00
Allie Micka 5fde1166c5 Support gotenberg as a PDF generator 2025-11-21 23:33:18 +00:00
Allie Micka a813f87fc2 Add Gotenberg PDF generation support 2025-11-21 23:32:46 +00:00
David Bomba 5dff442403 Console method to rebuild elastic indexes 2025-11-21 12:29:42 +00:00
David Bomba f209c1228e
Merge pull request #11432 from turbo124/v5-develop
v5.12.33
2025-11-21 22:41:57 +11:00
David Bomba a922cbc577 v5.12.33 2025-11-21 22:41:35 +11:00
David Bomba 1cef456f4b Fixes for dependencieS 2025-11-21 22:37:28 +11:00
David Bomba d9ed34c988
Merge pull request #11431 from turbo124/v5-develop
v5.12.32
2025-11-21 22:09:47 +11:00
David Bomba 556cfd4385 v5.12.31 2025-11-21 22:08:58 +11:00
David Bomba f7c65b3cc9 Merge branch '2025_11_21_test_refactor' into v5-develop 2025-11-21 22:08:18 +11:00
David Bomba 74b2b4c14b Fixes for tests 2025-11-21 22:08:07 +11:00
David Bomba 80b6fd7690 minor fix for public prop 2025-11-21 09:04:20 +00:00
David Bomba c6527b08f3 refactor tests 2025-11-21 08:52:04 +00:00
David Bomba 9ae145d818 Fixes for deprecation warning 2025-11-21 14:39:06 +11:00
David Bomba 2bea363d53
Merge pull request #11429 from turbo124/v5-develop
Tax Period Reports
2025-11-21 13:00:34 +11:00
David Bomba 1d8c100cbd Update Stripe ACH payments to support using ba tokens with payment intents 2025-11-21 12:59:54 +11:00
David Bomba f006960b61
Merge pull request #11426 from cnohall/add-support-for-multiple-blockonomics-stores
Add support for multiple Blockonomics' stores
2025-11-21 12:57:29 +11:00
David Bomba 7b3ed25195
Merge pull request #11428 from psycho0verload/feature/add_id_number
Add BT-29 and BT-32 support to ZUGFeRD E-Document using id_number
2025-11-21 12:56:25 +11:00
Jonathan Starck 8c66b195ab feat(e-invoice): add BT-29 and BT-32 to Zugferd E-Document using id_number field
- Implemented Seller Identifier (BT-29) and Tax Registration Identifier (BT-32)
  in `ZugferdEDocument.php`
- Mapped the previously unused `id_number` field to supply these values
- Ensures correct population of mandatory identifiers for e-invoicing (ZUGFeRD/XRechnung)
2025-11-21 02:28:19 +01:00
cnohall 48f1d8395b use company key for partial match of Blockonomics to fetch address from the correct store 2025-11-21 08:48:35 +09:00
David Bomba 37632a6cb7 Cleanup 2025-11-21 09:43:38 +11:00
David Bomba ba25555a27 Cleanup 2025-11-21 09:42:31 +11:00
David Bomba f32a1ea7ba Merge branch '2025_11_19_tax_period_report' into v5-develop 2025-11-21 09:30:20 +11:00
David Bomba 56b1abcf54 Improvements for tax reporting 2025-11-21 09:30:08 +11:00
David Bomba 694f1476de Improvements for tax reporting 2025-11-21 09:08:09 +11:00
cnohall 81093a3ee3 Add support for multiple blockonomics stores 2025-11-20 16:05:31 +09:00
David Bomba 6c09ed8a47 Improvements for tax reporting 2025-11-20 13:08:08 +11:00
David Bomba 4ec2808f64 Handle retries on tries = 1 2025-11-20 08:17:32 +11:00
David Bomba 1c01fe5b02 Handle retries on tries = 1 2025-11-20 08:12:20 +11:00
David Bomba 59ea3df9dd Handle merging PDFs with different orientations 2025-11-20 07:31:43 +11:00
David Bomba 2f8cf977b0 tests for tax period report 2025-11-20 07:24:35 +11:00
David Bomba 92106c0637 Working on deleted invoices and tax reporting 2025-11-19 16:30:37 +11:00
David Bomba a9cbfb188d PHPcs 2025-11-19 13:31:19 +11:00
David Bomba 2907752732 Working on tax reports, delta changes and adjustments 2025-11-19 13:02:07 +11:00
David Bomba a0745200d8 Working on tax reports, delta changes and adjustments 2025-11-19 12:53:27 +11:00
David Bomba f3263b9ce5 Working on tax reports, delta changes and adjustments 2025-11-19 12:51:43 +11:00
David Bomba 340d5e1f6c Handle null timezone 2025-11-19 07:16:34 +11:00
David Bomba 54e2826e37 Fixes for use Queueable in a listener 2025-11-19 07:11:50 +11:00
David Bomba 644a2d16a5 Handle passing null into ->delete() 2025-11-19 06:54:59 +11:00
David Bomba 31e13d4a7a Handle payment event entries 2025-11-19 06:53:16 +11:00
David Bomba 00e93582ea Tax Period Tests 2025-11-18 15:50:29 +11:00
David Bomba 0fb63dec60
Merge pull request #11423 from turbo124/v5-develop
v5.12.31
2025-11-18 15:01:23 +11:00
David Bomba f9ac001580 v5.12.31 2025-11-18 14:54:26 +11:00
David Bomba 64036b9b6b Tax Period Tests 2025-11-18 14:53:01 +11:00
David Bomba 442a3a990a Fixes for logging 2025-11-18 14:42:48 +11:00
David Bomba cd3cf81f65 Fixes for accessor 2025-11-18 14:41:39 +11:00
David Bomba 226c881eef Tax Reporting 2025-11-18 14:31:13 +11:00
David Bomba 392a7c7736 Fixes for tax period report filtering based on transaction event 2025-11-18 13:17:21 +11:00
David Bomba e84f44fc15 Ensure task time logs have standard format 2025-11-18 08:59:56 +11:00
David Bomba 3b86e29062 Add mime types to emails 2025-11-18 08:16:57 +11:00
David Bomba 1b7f59cfc2 Restructuring tax period reports 2025-11-17 19:30:48 +11:00
David Bomba cc4c93db8f Restructuring tax period reports 2025-11-17 16:55:08 +11:00
David Bomba 47827a033e Updates for Tax Reports 2025-11-17 15:47:29 +11:00
David Bomba 0d991036f4 Improve ability for adjustments to be made between reporting periods 2025-11-17 15:12:01 +11:00
David Bomba 2903ac539c Fixes for checking null description values 2025-11-17 10:33:27 +11:00
David Bomba f2cc228586 Updated phpstan.neon 2025-11-17 10:27:07 +11:00
David Bomba 3e0ba922b7 Additional structured metrics 2025-11-17 10:24:12 +11:00
David Bomba 1585ec57f9 Improve previews by using active invitations if possible 2025-11-15 09:55:15 +11:00
David Bomba 3e9f93f7e8 Improvements for storing files for inbound peppol invoices 2025-11-15 09:35:50 +11:00
David Bomba 4083ed82aa Updates for tax period report to combine data for tax data items if present 2025-11-14 17:05:48 +11:00
David Bomba 426cc1f5ad Return early 2025-11-14 15:59:17 +11:00
David Bomba 4e5e8d5187 Return early 2025-11-14 15:58:52 +11:00
David Bomba 062f273da1 Fixes for variable accessor 2025-11-14 13:12:25 +11:00
David Bomba 821e0a357c Updates for domain rules 2025-11-14 09:11:59 +11:00
David Bomba fa1d20dc8c Improve login performance of react application 2025-11-13 15:27:55 +11:00
David Bomba e9fb91cd31 Improve performance of bulk action locking 2025-11-13 15:07:50 +11:00
David Bomba d26314ad06 Improve performance of bulk action locking 2025-11-13 15:04:41 +11:00
David Bomba 9fe81b0e55 Adjustments for ach payment checks 2025-11-13 08:27:50 +11:00
hillelcoren 78a9a1baeb Admin Portal - Hosted 2025-11-12 14:50:43 +00:00
David Bomba f6696f83c8 static analysis cleanup 2025-11-12 16:02:31 +11:00
David Bomba 565858da6c Fixes for static analysis 2025-11-12 15:14:31 +11:00
David Bomba 7e11066280 Minor static analysis fixes 2025-11-12 10:31:26 +11:00
David Bomba f2a619c750 Fixes for confirming gateway fees 2025-11-12 10:26:49 +11:00
David Bomba 487351c196 Support no invoice line items present, but invoice amount is present 2025-11-12 08:18:53 +11:00
David Bomba e76b77afe8 Support no invoice line items present, but invoice amount is present 2025-11-12 08:08:45 +11:00
David Bomba 64d473605e Support no invoice numbers in .csv files, ie for automatic numbering 2025-11-12 07:30:43 +11:00
David Bomba e41e03cd9f Fixes for null values for pdf config/design 2025-11-11 10:52:17 +11:00
David Bomba 903b279a5f Fixes for currency code for template statements 2025-11-11 09:04:44 +11:00
David Bomba 30dfcb01c8 Adjustments for tests and email validation 2025-11-10 16:38:07 +11:00
David Bomba 1c9fa73d35 Enforce email validation 2025-11-10 16:27:06 +11:00
David Bomba da81025b24 Analytics DataMapper 2025-11-10 16:26:23 +11:00
David Bomba aa657ee1af Handle null values for company settings 2025-11-10 15:12:21 +11:00
David Bomba fb83d2fec0 Fixes for mailgun open webhooks 2025-11-10 15:09:52 +11:00
David Bomba 521a68d732 Adjustments for jobs that are overlapping and should not be reattempted 2025-11-10 14:57:58 +11:00
David Bomba cccc1e75d6 Updated domain 2025-11-10 09:15:49 +11:00
David Bomba 5be344f17e Updates for rate limits 2025-11-07 14:39:49 +11:00
David Bomba a070d7e6a0 Improvements for onboarding defaults 2025-11-07 09:02:27 +11:00
David Bomba 334e9c0c78 Ensure primary contact for vendor is always present 2025-11-06 11:26:27 +11:00
David Bomba bb8d257b41 Rate limiter 2025-11-06 10:04:45 +11:00
David Bomba b415d47bc5 Force all users to confirm their email address 2025-11-06 09:37:58 +11:00
David Bomba 7e7d790aaf Updates for account registrations 2025-11-05 09:41:07 +11:00
David Bomba 2ed4414703 Add expenses to templates 2025-11-04 12:20:44 +11:00
David Bomba dc52840418 Add expenses to templates 2025-11-04 11:05:08 +11:00
David Bomba 85e21221ac
Merge pull request #11396 from turbo124/v5-develop
v5.12.30
2025-11-04 10:34:15 +11:00
David Bomba 53a88363fd v5.12.30 2025-11-04 10:33:13 +11:00
David Bomba 9f53eec993
Merge pull request #11395 from turbo124/v5-develop
v5.12.29
2025-11-04 08:53:26 +11:00
David Bomba a19f94143c Updated translations 2025-11-04 08:51:09 +11:00
David Bomba d679ba93df v5.12.29 2025-11-04 08:49:51 +11:00
David Bomba f254a2b105 Minor fixes for Rotessa 2025-11-04 08:23:35 +11:00
David Bomba 2bf0326797 Add inline disposition for PDF previews 2025-11-03 17:19:45 +11:00
David Bomba f5f447a3ff Fixes for new reports 2025-11-03 11:59:08 +11:00
David Bomba ff280b4c5b Implement native groupBy for Twig 2025-11-03 08:51:39 +11:00
David Bomba ee7b2d52c5 Initial refactor for removing deprecated mb_convert_encoding() 2025-11-02 11:51:11 +11:00
David Bomba 3590e89c05 Remove openssl_free_key, deprecation 2025-11-02 11:43:39 +11:00
David Bomba e0bea26e52 Allow template_id to be passed as a property for reports 2025-11-02 11:41:57 +11:00
David Bomba 37d0f151b8 Fixes for tests 2025-11-02 08:17:24 +11:00
David Bomba 16fa896d78 Merge branch 'v5-develop' of https://github.com/turbo124/invoiceninja into v5-develop 2025-11-02 08:11:32 +11:00
David Bomba e61a370b95 Add Template Option for reports 2025-11-02 08:11:24 +11:00
David Bomba 35a1da1f23
Update BaseExport.php 2025-11-01 08:49:30 +11:00
David Bomba e01eddae26 Update the logic around saving tasks where start/stop is used AND task data is updated. Will leave timelog unchanged, BUT will update the remainder of task data 2025-10-30 17:35:50 +11:00
David Bomba bb1a0120ab Updates for elastic clustering 2025-10-30 12:20:11 +11:00
hillelcoren 25d0caa324 Admin Portal - Selfhosted 2025-10-29 17:21:22 +00:00
hillelcoren adf1c1e96c Admin Portal - Profile 2025-10-29 17:14:48 +00:00
hillelcoren 4e37a526d8 Admin Portal - Hosted 2025-10-29 17:08:19 +00:00
hillelcoren cbb40bd3d1 Admin Portal - Profile 2025-10-29 15:49:06 +00:00
hillelcoren 3fa404f4e8 Admin Portal - Hosted 2025-10-29 15:42:32 +00:00
hillelcoren a6501b2dff Admin Portal - Profile 2025-10-29 13:42:17 +00:00
hillelcoren d324c20e2e Admin Portal - Hosted 2025-10-29 13:35:35 +00:00
hillelcoren af94eab84d Admin Portal - Profile 2025-10-29 12:28:51 +00:00
hillelcoren a14b5862b7 Admin Portal - Hosted 2025-10-29 12:22:23 +00:00
hillelcoren 002666a308 Admin Portal - Profile 2025-10-29 12:06:58 +00:00
hillelcoren eedcdc8551 Admin Portal - Hosted 2025-10-29 12:00:08 +00:00
hillelcoren 00296a6671 Admin Portal - Profile 2025-10-29 11:44:11 +00:00
hillelcoren 079e78b8d9 Admin Portal - Hosted 2025-10-29 11:37:27 +00:00
David Bomba ccce21c0ec Unify quote conversion 2025-10-29 16:06:23 +11:00
David Bomba 0b22141492 updates for deprecations 2025-10-29 13:52:23 +11:00
David Bomba 0e45151927 Updates for removing microsoft header token 2025-10-29 12:17:39 +11:00
David Bomba 57114af04d php-cs-fix for edocs 2025-10-29 11:15:30 +11:00
David Bomba b08c575b35 php-cs-fix for tests 2025-10-29 11:13:52 +11:00
David Bomba 6f1ebf57a9 fixes for tests 2025-10-29 11:13:19 +11:00
David Bomba 8af6d67dd1 Fixes for tests 2025-10-29 11:11:45 +11:00
David Bomba bb839433eb Fixes for tests 2025-10-29 11:01:33 +11:00
David Bomba d5e2b0fde3 Fixes for tests 2025-10-29 10:52:13 +11:00
David Bomba 1da785d4d0 Fixes for tests 2025-10-29 09:48:15 +11:00
David Bomba aa5dd5b4fa Add in proper status filters 2025-10-29 09:30:35 +11:00
David Bomba 16a380972f Implement adequate report filtering for lesser permissioned users 2025-10-29 09:27:48 +11:00
David Bomba b21c182eda Minor updates for login routes 2025-10-28 13:44:02 +11:00
David Bomba dc54d01fd1 Updates for login route permission 2025-10-28 13:13:42 +11:00
David Bomba 9aec1415d8 Updates for routes 2025-10-28 08:13:52 +11:00
David Bomba 3f391b868b Updates for route rate limits 2025-10-27 16:50:56 +11:00
David Bomba 56670e221a Updated route rate limits 2025-10-27 16:12:42 +11:00
David Bomba 38c0d8dd95 Improvements for v3 of subscriptions 2025-10-27 10:07:34 +11:00
David Bomba f51f0c0c37 Improvements for powerboard cc form 2025-10-27 09:26:13 +11:00
David Bomba ee5509feca Change invoice item spec for api docs 2025-10-26 07:51:04 +11:00
David Bomba f305fa3076 Change order of indexes 2025-10-25 09:41:49 +11:00
David Bomba 8c6f8c3c1d Fixes for composer 2025-10-24 16:53:18 +11:00
David Bomba 367c3582bd Add actual delivery date for einvoices 2025-10-24 16:36:37 +11:00
David Bomba a71e76313d Updated lock 2025-10-24 07:38:52 +11:00
David Bomba dddd33e6d1 Merge branch 'v5-develop' of https://github.com/turbo124/invoiceninja into v5-develop 2025-10-24 07:28:22 +11:00
David Bomba 75e3c8800e Updated translations for feedback components 2025-10-24 07:28:14 +11:00
David Bomba 7fa48b6e2c
Merge pull request #11296 from cnohall/fix-invoice-statuses-for-blockonomics
Fix invoice statuses for blockonomics
2025-10-24 07:15:27 +11:00
cnohall bf20b44cc1 implement suggestions 2025-10-23 18:28:14 +09:00
David Bomba 584c33de70 Add number filters 2025-10-23 08:50:31 +11:00
cnohall f640417c25 fix payment logic 2025-10-21 13:32:31 +09:00
cnohall 792790e3f0 remove invoice manipulation 2025-10-21 12:59:50 +09:00
David Bomba 0d81da350b
Merge pull request #11368 from turbo124/v5-develop
Fixes for percentage discount imports
2025-10-21 07:51:16 +11:00
David Bomba 6daba8822b Fixes for importing invoices with item discounts 2025-10-21 07:49:25 +11:00
David Bomba e999f9b8bb SUpport defining template in scheduled emails 2025-10-20 13:09:28 +11:00
David Bomba 466833ee53 Add optional template for email reocrd 2025-10-20 12:50:47 +11:00
David Bomba 2161b0461f Add system logging for failed peppol sends 2025-10-20 08:53:28 +11:00
David Bomba b174656e87 Remove foreign keys for verifactu in order to preserve logs 2025-10-20 08:46:12 +11:00
David Bomba 9e032ad723 Updated translations 2025-10-20 08:12:07 +11:00
David Bomba 0e759a6afd Edge case for client balances when payment made on a deleted invoice 2025-10-19 14:10:00 +11:00
David Bomba c3d5af190a Additional translations 2025-10-19 07:17:20 +11:00
David Bomba 690ebf6f13 Fixes for importing durations that result in FALSE being returneD 2025-10-19 07:16:31 +11:00
David Bomba db73210904 Add projects to invoice list 2025-10-18 10:09:55 +11:00
cnohall 42f8881231 fix status 2025-10-17 15:00:42 +09:00
cnohall caa025b502 clarify vars 2025-10-17 14:58:23 +09:00
cnohall 8615d5bbc3 add fixes for invoice balance 2025-10-17 14:55:36 +09:00
cnohall efaceba367 clarify fiat amount maths 2025-10-17 09:34:36 +09:00
David Bomba 435bb8e999 Do not allow negative invoices to be created adhoc in Verifactu 2025-10-17 09:11:34 +11:00
David Bomba 8f1566817e Do not allow negative invoices to be created adhoc in Verifactu 2025-10-17 09:11:05 +11:00
David Bomba 89b2cbeedc Add logging for gmail error exceptions when sending 2025-10-17 07:48:06 +11:00
cnohall 8505f52353 remove dead code 2025-10-16 13:15:04 +09:00
cnohall 26094010a8 remove obsolete obsolete import 2025-10-16 13:10:23 +09:00
cnohall bde3da88b9 remove obsolete comment 2025-10-16 13:09:33 +09:00
cnohall 15b90d7953 simplify 2025-10-16 13:08:44 +09:00
David Bomba c0db1de1de Intercept reason for rectification 2025-10-16 14:27:48 +11:00
David Bomba ef5b692424 Mandatory text to present on Invoice PDF 2025-10-16 12:23:35 +11:00
David Bomba 9af00a87cf Allow resolution of verifactu flag down to the invoice level 2025-10-16 11:57:01 +11:00
David Bomba abb4d128d0 Flag invoices not to be sent to AEAT 2025-10-16 11:36:13 +11:00
David Bomba df3d02bfb7 Additional validation rules for client name 2025-10-16 10:10:21 +11:00
David Bomba 6b06bfc9b3 Clean up for quote email activity 2025-10-16 09:51:49 +11:00
David Bomba 117ff658ff Updates for yodlee 2025-10-16 09:33:48 +11:00
David Bomba 77fa0f9e03 Validation clean up 2025-10-15 14:31:50 +11:00
David Bomba 2e80af2b49 Updates for tests 2025-10-15 14:23:02 +11:00
David Bomba c83ba701f4 Updated translations 2025-10-15 13:51:44 +11:00
David Bomba b8987af0af Add filters for tasks 2025-10-15 12:58:43 +11:00
David Bomba f98411f81b Updates for validation for verifactu documents 2025-10-15 12:22:28 +11:00
David Bomba 345e8fa2ce Updates for logic with Verifactu 2025-10-15 11:41:47 +11:00
David Bomba 9fc9ab933d Updates for logic with Verifactu 2025-10-15 11:08:10 +11:00
David Bomba 00db79a611 Updates for verifactu tests 2025-10-15 09:24:25 +11:00
David Bomba adcffd0ce9 Updates for validation for verifactu documents 2025-10-15 09:03:32 +11:00
David Bomba 87e413f558 Ensure status toggles to cancelled once completed cancelled using R1 cancellations 2025-10-15 08:44:56 +11:00
David Bomba 259f82e251 Fixes for verifactu API tests 2025-10-15 08:32:00 +11:00
David Bomba 00f4835ddd Fixes for verifactu API tests 2025-10-15 08:22:10 +11:00
David Bomba aef0180a2f Fixes for verifactu API tests 2025-10-14 16:53:46 +11:00
David Bomba a65bc07ffe Fixes for testse 2025-10-14 14:13:35 +11:00
David Bomba c6421bb733 Minor fixes for registroalta class 2025-10-14 11:01:42 +11:00
David Bomba 1892564325 Fixes for tests 2025-10-14 10:52:10 +11:00
David Bomba da44460fcb Additional Ignores for phpstan 2025-10-14 10:34:12 +11:00
David Bomba 2532c2ca61 Fixes for tests 2025-10-14 10:32:55 +11:00
David Bomba 3204573cd0 Unify bulk invoice validation logic 2025-10-14 10:31:06 +11:00
David Bomba dbc4c1324e Fixes for tests 2025-10-14 10:27:21 +11:00
David Bomba 8e58e4dc6c Refactor for verifactu flowS 2025-10-14 09:42:40 +11:00
David Bomba 75594a794b Fixes for action invoice validation 2025-10-13 14:39:48 +11:00
David Bomba 3b63be39a9 Updates for verifactu delete/restore logic 2025-10-13 13:35:43 +11:00
David Bomba 050048799c Updates for Verifactu and handle of IRPF calculations - moving to surcharge layer 2025-10-13 13:28:04 +11:00
David Bomba a5cac9814b Validation fixes for rectification invoices 2025-10-13 09:37:19 +11:00
David Bomba d54f677c0b Add QR code logic for verifactu invoices 2025-10-13 08:56:21 +11:00
David Bomba 9796525644 Updated flow for invoices with Verifactu 2025-10-13 08:42:50 +11:00
David Bomba 78d93f5c21 Updated flow for invoices with Verifactu 2025-10-13 08:24:34 +11:00
David Bomba 0cc0add29e Introduce logic to prevent emails sending to end clients UNLESS verifactu submission has occurred. 2025-10-12 13:50:56 +11:00
David Bomba 59a7ac132a updates for sms reset routes rate limiting 2025-10-12 13:30:52 +11:00
David Bomba 6d40c7da83 load microsoft .js if client_id is set 2025-10-10 17:11:39 +11:00
David Bomba f4b98b552c QR Codes for Verifactu 2025-10-10 15:00:16 +11:00
David Bomba 212ca0c38c Fixes for test 2025-10-10 14:22:38 +11:00
David Bomba 03659e006b Catch for handling legacy objects that are being passed into the Invoice Backup object 2025-10-10 14:01:04 +11:00
David Bomba a22fde7fc3 Catch for handling legacy objects that are being passed into the Invoice Backup object 2025-10-10 14:00:56 +11:00
David Bomba 62be991273 Improvements for ACH payment statuses on failure 2025-10-10 13:46:23 +11:00
David Bomba c4de17d71c Updated OpenApi spec 2025-10-10 12:12:20 +11:00
David Bomba 2e45fbe9d5 Fixes for meta definitions of list views 2025-10-10 11:51:58 +11:00
David Bomba bc8c8ec17e Additional request bodies 2025-10-10 11:26:53 +11:00
David Bomba f2d904f2b1 Fixes for tests 2025-10-10 10:47:04 +11:00
David Bomba d6a9169628 updates for open api spec 2025-10-10 10:40:29 +11:00
David Bomba 8d6e2df1e6 Updated lock 2025-10-10 10:28:37 +11:00
David Bomba 769f132928 Fixes for conflict / verifactu 2025-10-10 09:22:59 +11:00
David Bomba 42866de4c6
Merge pull request #11338 from benbrummer/v5-develop
Conditional/additional logging for IS_DOCKER
2025-10-09 22:25:53 +11:00
Benjamin Brummer 6caa0b066d Conditional/additional logging channel for docker deplyoment based on IS_DOCKER 2025-10-09 12:46:51 +02:00
David Bomba fa36a24954 Fixes for pt-br translation 2025-10-09 08:28:21 +11:00
David Bomba 40a22a3007 Feedback API 2025-10-09 07:58:31 +11:00
David Bomba a6e5675566 Feedback API 2025-10-09 07:57:54 +11:00
David Bomba 3b9537ad55 Feedback API 2025-10-09 07:48:06 +11:00
David Bomba 9083544c7b Add comments around registration of peppol identifiers 2025-10-09 07:38:21 +11:00
David Bomba d9404a1eb5 Fixes for expense report when exporting all columns, vendor name previously displaying vendor number 2025-10-09 07:29:28 +11:00
David Bomba 38e6e88903 fixes for hermes registration 2025-10-08 23:41:18 +11:00
David Bomba 6b5b988ed4 Updates for PEPPOL Invoice Period + validation 2025-10-07 19:35:34 +11:00
David Bomba 318e301308 Fixes for date ranges 2025-10-07 18:28:40 +11:00
David Bomba 557700307c Bancontact checks for dupe payments 2025-10-04 16:43:16 +10:00
David Bomba 7bd40243e3 Updated logic around emailing refunds to contacts 2025-10-03 11:22:16 +10:00
David Bomba 12f31054c9
Merge pull request #11327 from eelco2k/v5-develop
Fix for issue #11072  Update Mollie CreditCard.php
2025-10-03 08:18:57 +10:00
Eelco cdc537e38d
Update CreditCard.php
Fix for issue #11072 for v5-develop branch

Signed-off-by: Eelco <eelcoschreurs@gmail.com>
2025-10-02 16:57:45 +02:00
David Bomba 6ccf88d5c7
Merge pull request #11318 from turbo124/v5-develop
Timezone correction for task imports
2025-09-30 10:08:42 +10:00
David Bomba 7a3e0d78f8 Timezone correct task timelog imports 2025-09-30 10:06:19 +10:00
David Bomba fa563322b4 Static Analysis 2025-09-30 09:31:23 +10:00
David Bomba d60f979df8 Update stripe integration to latest sdk version 2025-09-30 09:27:09 +10:00
David Bomba 83d6dc89ae
Merge pull request #11317 from turbo124/v5-develop
v5.12.28
2025-09-30 08:17:27 +10:00
David Bomba c9a3100067 Updated languages 2025-09-30 08:16:34 +10:00
David Bomba c2cca35964 Minor Static Analysis fixes 2025-09-30 08:14:23 +10:00
David Bomba 0a40d8e456 v5.12.28 2025-09-30 08:13:18 +10:00
David Bomba 22e7d34180 Updates for EPC codes 2025-09-30 08:12:05 +10:00
cnohall e627a9ca0f Remove hacks 2025-09-29 17:18:37 +09:00
cnohall 6a9e5f6166 remove PaymentFailed cehck, since all payments have payment hash 2025-09-29 17:08:13 +09:00
David Bomba fd415889dd Updated placeholders for consistent for entity_number_placeholder 2025-09-28 12:50:49 +10:00
David Bomba 8f98033e91 Fixes for openapi spec 2025-09-27 07:57:04 +10:00
David Bomba b970003353 New resources for checkout.com 2025-09-26 23:43:05 +10:00
David Bomba 9300829ded Support adding cardholder name to checkout.com meta data 2025-09-26 23:42:46 +10:00
David Bomba bb7c37ed44 Refactor for logging 2025-09-26 13:53:10 +10:00
David Bomba 86096e4502 Fixes for testse 2025-09-26 06:13:40 +10:00
David Bomba bd1cefc299 Minor update to client prop 2025-09-25 12:38:45 +10:00
David Bomba ebdf6f7919 debug PaymentBalanceActivity 2025-09-25 12:33:42 +10:00
David Bomba abe1a66310 Adjustments for logic around credit / reversed invoices 2025-09-25 12:07:13 +10:00
David Bomba f5b9af8ba1 Additional Tests 2025-09-25 09:28:00 +10:00
David Bomba fae786c3e4 Refund tests 2025-09-25 09:25:21 +10:00
David Bomba 99f58ebd72 Handle refunds on credits 2025-09-25 09:18:25 +10:00
David Bomba 37fc8a61d9 Improve logic around reversal / credit notes / refunds 2025-09-25 09:13:49 +10:00
David Bomba a35c817388 Fixes for check data 2025-09-25 08:31:47 +10:00
David Bomba e07af07fa4 Set timezone corrected date 2025-09-24 12:18:39 +10:00
David Bomba daff064af7 Fixes for invoice reversals => credit deletion 2025-09-24 09:49:52 +10:00
David Bomba 4c081c428f Fixes for credit tests 2025-09-23 18:43:04 +10:00
David Bomba 2dd324c0bf Additional checks when reversing 2025-09-23 18:36:27 +10:00
David Bomba 8c34d0cc5c Additional checks when reversing 2025-09-23 18:36:04 +10:00
David Bomba 0a0978234d adjustments for payment precision when importing 2025-09-23 17:00:13 +10:00
David Bomba 86fb8aeaaa Fxes for assigned user 2025-09-23 07:30:11 +10:00
David Bomba b1d135387b Minor fixes for company imports 2025-09-22 21:04:54 +10:00
cnohall 5c2eb2fb7c Merge branch 'v5-develop' of https://github.com/invoiceninja/invoiceninja into fix-invoice-statuses-for-blockonomics 2025-09-22 13:48:29 +09:00
David Bomba e73348b69b disable auto update from controller 2025-09-20 12:03:34 +10:00
David Bomba 5d80798034 Refactor for displaying client address details on PDF 2025-09-20 11:39:23 +10:00
David Bomba 55a11fef05 Fixes for html display of invoices 2025-09-18 09:01:21 +10:00
David Bomba 0f402d9ee1 Static analysis 2025-09-18 07:03:51 +10:00
David Bomba edeb86fe7b Disable chrome features performancemanager 2025-09-18 07:01:57 +10:00
David Bomba 9aa544e66b Fixes for splitting date ranges 2025-09-18 07:00:32 +10:00
cnohall dcd03d9990 allow for negative balance 2025-09-17 10:54:25 +09:00
cnohall 3c4b544235 remove manually added payments 2025-09-17 10:52:43 +09:00
cnohall 90a4bcec4d handle pending payments 2025-09-17 10:43:02 +09:00
cnohall 12df5ea94c simplify logic 2025-09-16 16:29:55 +09:00
cnohall c79f595439 works with one partial payment 2025-09-16 15:47:03 +09:00
cnohall adf8232147 Init 2025-09-16 14:44:13 +09:00
David Bomba 40f2741407 Fixes for api routes 2025-09-13 16:11:34 +10:00
David Bomba 52875f13a5 Updates for rate limits 2025-09-12 07:37:23 +10:00
David Bomba 1301b96ee5 Support for locations in pdf variables stack 2025-09-10 16:38:28 +10:00
David Bomba 5c798e9516 Location name 2025-09-10 07:14:33 +10:00
David Bomba ccf35c35ab Additional Liap listeners 2025-09-09 08:05:08 +10:00
David Bomba df57be359a Ensure proper cleanup on hosted 2025-09-08 10:32:14 +10:00
David Bomba 5ff1b60381 Improve cleanup after client purge 2025-09-08 10:27:28 +10:00
David Bomba bb77d935a7 Extend timeout for PDF previews in client portal 2025-09-08 07:40:31 +10:00
David Bomba 1cc41ed2cb Fixes for task import timelog 2025-09-08 07:26:17 +10:00
David Bomba cde3ca8af5 Improvements for delete payments 2025-09-08 06:59:56 +10:00
David Bomba 3b63cd4cb3
Merge pull request #11279 from turbo124/v5-develop
Improved sorting logic
2025-09-07 14:01:26 +10:00
David Bomba e2ca5281c4
Merge pull request #11277 from n1smithy/v5-develop
Patch for better customisation of pdf-numbering-fonts (v5-develop-branch)
2025-09-07 13:59:44 +10:00
David Bomba 8b9b344c35 Improved sorting logic 2025-09-07 13:55:04 +10:00
n1smithy 97a43fb4bf
Better pdf-page-numbering-customisation
Change font-look by adding env-variables for pdf-page-numbering

Signed-off-by: n1smithy <140073251+n1smithy@users.noreply.github.com>
2025-09-06 20:44:10 +02:00
n1smithy e566f83adc
Update PDF.php
First update for increasing customisation for pdf-page-numbering-fonts.

Signed-off-by: n1smithy <140073251+n1smithy@users.noreply.github.com>
2025-09-06 20:40:50 +02:00
David Bomba 876a025e7c
Merge pull request #11276 from turbo124/v5-develop
v5.12.27
2025-09-06 16:38:06 +10:00
David Bomba 3319fd7920 Minor cleanup 2025-09-06 16:36:40 +10:00
David Bomba 331be9d309 Patches for admin mailable 2025-09-06 16:34:01 +10:00
David Bomba 2c3ac4aad9 BCMath functions 2025-09-06 15:50:19 +10:00
David Bomba 4eb5582a64 fixes for e_invoice 2025-09-06 15:49:16 +10:00
David Bomba 33397aaf61
Merge pull request #11270 from clementnuss/fix-fr_CH-issues
Fix minor fr_CH localization issues
2025-09-06 15:35:23 +10:00
David Bomba f6bc016f22 v5.12.27 2025-09-06 15:27:55 +10:00
David Bomba f6aac6b424 Rollback BCMath 2025-09-06 15:27:35 +10:00
David Bomba afa4bc461c Fixes for tests 2025-09-06 13:26:10 +10:00
David Bomba 1f62b24e25 New bulk task options 2025-09-06 13:20:55 +10:00
David Bomba f7250f643a New bulk task options 2025-09-06 07:47:16 +10:00
David Bomba bf3e802dcc Testing BCMatch 2025-09-05 19:10:13 +10:00
Clément Nussbaumer db326941d5
Fix minor fr_CH localization issues
Signed-off-by: Clément Nussbaumer <clement@n8r.ch>
2025-09-05 07:23:57 +02:00
David Bomba 2222361cca transaction calculations 2025-09-05 14:41:21 +10:00
David Bomba d3f6f642f2 comparables 2025-09-05 14:39:15 +10:00
David Bomba f02c6bb277 Change approach for update client balance logic 2025-09-05 13:53:30 +10:00
David Bomba f0af52c017 fixes for double encoding 2025-09-04 11:41:06 +10:00
David Bomba 4d63b1336a minor code formatting 2025-09-04 10:41:57 +10:00
David Bomba 18e46d3c88 protect routes 2025-09-04 09:58:46 +10:00
David Bomba 0f24a1dd54
Merge pull request #11267 from turbo124/v5-develop
v5.12.26
2025-09-03 21:38:57 +10:00
David Bomba 1e5dadaba4 v5.12.26 2025-09-03 21:36:40 +10:00
David Bomba faa9193ca6 fixes for default mailer 2025-09-03 21:36:22 +10:00
David Bomba 06d3255ce0 Fixes for designs 2025-09-03 21:34:47 +10:00
David Bomba 4d126a7d1f Updated translations 2025-09-03 07:42:17 +10:00
David Bomba c38d107fdb change react release back to PHP 8.2 2025-09-03 07:28:23 +10:00
David Bomba c8032c834e Fixes for chart queries 2025-09-02 23:35:27 +10:00
David Bomba d7ddbda9eb
Merge pull request #11260 from turbo124/v5-develop
v5.12.25
2025-09-02 18:58:49 +10:00
David Bomba ad10ad0145 v5.12.25 2025-09-02 18:53:32 +10:00
David Bomba e09c908db4 Bump PHP version for builds 2025-09-02 18:53:15 +10:00
David Bomba 37a4f5ba62 Support adding the pdf into the XML 2025-09-02 18:29:10 +10:00
David Bomba 63d5a4ca52 fixes for missing props 2025-09-02 17:31:29 +10:00
David Bomba 4feca756b1
Merge pull request #11259 from turbo124/v5-develop
add in autodump for composer
2025-09-02 16:27:49 +10:00
David Bomba d344d2c702 add in autodump for composer 2025-09-02 16:27:25 +10:00
David Bomba 9c8544b977
Merge pull request #11258 from turbo124/v5-develop
v5.12.24
2025-09-02 16:26:09 +10:00
David Bomba 65a642b143 v5.12.24 2025-09-02 16:25:04 +10:00
David Bomba e083aac531 Updates for PDF 2025-09-02 16:24:12 +10:00
David Bomba 9094000f16 Fix for banking integrations 2025-09-02 10:25:58 +10:00
David Bomba fb0692b91c Bulk updates for task tests 2025-09-02 09:27:56 +10:00
David Bomba a27d311ae8 Fixes for pulling all requisitions. 2025-09-02 09:05:20 +10:00
David Bomba 783e589444 Support bulk actions for tasks 2025-09-02 08:07:21 +10:00
David Bomba dcb58821ef Bulk updates for tasks 2025-09-02 07:55:13 +10:00
David Bomba 56b3f0264c Log the message 2025-09-02 07:32:17 +10:00
David Bomba ab1d91d755 Log the message 2025-09-02 07:32:02 +10:00
David Bomba 29bfbaf644
Merge pull request #11185 from turbo124/verifactu
Verifactu
2025-08-15 13:28:44 +10:00
David Bomba 27dd9907f3 Cleanup for tests 2025-08-15 13:26:38 +10:00
David Bomba 5a188ac355 Fixes for tests 2025-08-15 13:22:29 +10:00
David Bomba cfc41eb29a Document validation for verifactu 2025-08-15 13:19:58 +10:00
David Bomba e58f19f593 Validation for verifactu documents 2025-08-15 13:13:51 +10:00
David Bomba e8336c85d7 Update rules for setting Impuesto values 2025-08-15 13:02:59 +10:00
David Bomba 117709e551 Validation checks 2025-08-15 12:26:40 +10:00
David Bomba 70dea557f5 Ensure tax codes that are sent are correct 2025-08-14 14:41:21 +10:00
David Bomba 084f0fea25 Activity translations for verifactu 2025-08-14 12:17:03 +10:00
David Bomba 256555c952 Add AEAT response to logs 2025-08-14 12:08:17 +10:00
David Bomba 9b98d45d3b Align tests with new workflow 2025-08-14 11:28:02 +10:00
David Bomba c3b5a972a1 Align tests with new workflow 2025-08-14 10:47:18 +10:00
David Bomba 7e1a6bc1c7 Align tests with new workflow 2025-08-14 10:38:51 +10:00
David Bomba 0a7744a70e Add IDOtro class 2025-08-14 10:07:32 +10:00
David Bomba 1252cdf7ae Integration of Verifactu with UI 2025-08-14 08:36:55 +10:00
David Bomba af926a394c Wiring up verifactu sending 2025-08-13 14:39:50 +10:00
David Bomba c5c9c4325e Wiring up verifactu sending 2025-08-13 14:26:03 +10:00
David Bomba 3d3b5f6938 Integration works for Verifactu 2025-08-13 13:15:51 +10:00
David Bomba ff92756dbc Verifactu api tests 2025-08-13 11:48:18 +10:00
David Bomba a141ca1549 Refactor tests to remove modifications of existing invoices 2025-08-13 11:39:11 +10:00
David Bomba bf8041ab7c Working on verifactu document mutations 2025-08-13 11:05:02 +10:00
David Bomba 8bc1513591 Wire up AEAT for processing 2025-08-13 10:28:10 +10:00
David Bomba 63e6f75a24 Additional rules around tests 2025-08-12 18:34:38 +10:00
David Bomba 5482f44bea Additional rules around tests 2025-08-12 14:42:32 +10:00
David Bomba 81ec3986ca Tests around the handling of verifactu credit amounts 2025-08-12 14:33:52 +10:00
David Bomba 1a3badf748 Tests around the handling of verifactu credit amounts 2025-08-12 13:52:05 +10:00
David Bomba c7e79fe673 Refactor to use generate parent/child ids 2025-08-12 13:41:11 +10:00
David Bomba 8d23ba14d4 Refactor to use generate parent/child ids 2025-08-12 12:29:36 +10:00
David Bomba 1836ccc434 Update for ivnoice backup casting 2025-08-12 12:13:44 +10:00
David Bomba 94b628b6eb Update for ivnoice backup casting 2025-08-12 11:59:44 +10:00
David Bomba 67df175525 Update for ivnoice backup casting 2025-08-12 10:24:18 +10:00
David Bomba 47f33c8691 Validation rules for modification invoice for Verifactu 2025-08-12 09:17:57 +10:00
David Bomba 6a0fff10ae Tests around handling cancellations in verifactu 2025-08-11 11:37:21 +10:00
David Bomba a447b6a20b Tests around handling cancellations in verifactu 2025-08-11 11:00:30 +10:00
David Bomba f7961ecb61 Additional tests for verifactu restore/archive logic 2025-08-11 10:10:33 +10:00
David Bomba 37c74ee18c Functional tests of spanish environment 2025-08-11 10:09:12 +10:00
David Bomba 555eb80018 Functional tests of spanish environment 2025-08-11 09:54:53 +10:00
David Bomba 1e8727c4a4 Force locking for spanish users 2025-08-11 09:25:48 +10:00
David Bomba 74f71d61d6 Padding settings 2025-08-11 09:14:51 +10:00
David Bomba 8a137329d4 Updates for Verifactu 2025-08-10 15:32:35 +10:00
David Bomba c02c87765b Create and cancel invoices working as expected 2025-08-10 13:03:51 +10:00
David Bomba 7393360db3 Working on feature flow and tests for verifactu submissions 2025-08-10 11:25:36 +10:00
David Bomba f7055b516e Working of feature flow of verifactu 2025-08-09 10:22:14 +10:00
David Bomba ee775e58a0 logging 2025-08-09 09:57:02 +10:00
David Bomba 5ff70dbeae Static analysis cleanup 2025-08-08 15:14:21 +10:00
David Bomba 3791469c31 Static analysis cleanup 2025-08-08 15:12:24 +10:00
David Bomba 1a86d5445b Updates for correct date formats for invoice cancellation tests 2025-08-08 14:07:11 +10:00
David Bomba 442ff42ceb additional tests 2025-08-08 14:04:13 +10:00
David Bomba b94316dbed XmlInterface 2025-08-08 13:17:06 +10:00
David Bomba 14fd4063f5 Verifactu feature tests 2025-08-08 12:43:23 +10:00
David Bomba 97f2e70f5d Expand tests to cancellation and modifications 2025-08-08 12:17:18 +10:00
David Bomba edd0de38ca Fixes for validation tests 2025-08-08 11:26:40 +10:00
David Bomba d53e1012af Add XSD validator for Verifactu 2025-08-08 11:23:03 +10:00
David Bomba 5895c1b0ed Tests for modified invoices 2025-08-08 10:40:38 +10:00
David Bomba aa918f7ec0 Verifactu initial invoice creation 2025-08-08 09:01:31 +10:00
David Bomba 33078ee86c Verifactu invoice generation 2025-08-07 21:56:11 +10:00
David Bomba 6c8c270c2f Verifactu invoice generation 2025-08-07 21:53:13 +10:00
David Bomba 5afd3b85bc additional verifactu validation tests 2025-08-07 21:26:30 +10:00
David Bomba dab787c3ae Validation rules for verifactu invoices 2025-08-07 19:42:18 +10:00
David Bomba 03a39f33b8 Add Entity Level Validation for Verifactu 2025-08-07 19:34:23 +10:00
David Bomba cbc5cb5f9b Skip WSTest by default 2025-08-07 18:45:33 +10:00
David Bomba 4127eb32f9 Add logging to wstest for requirements 2025-08-07 17:12:57 +10:00
David Bomba bf5359cb72 Add VerifactuLog 2025-08-07 15:16:04 +10:00
David Bomba d42735f2ee Tests 2025-08-07 14:36:13 +10:00
David Bomba ea663394b1 Working on ws tests 2025-08-07 14:15:17 +10:00
David Bomba b93ec6dd93 Additional tests for verifactu 2025-08-04 08:09:08 +10:00
David Bomba a431dd43d4 Fixes for verifactu WS 2025-06-26 17:29:48 +10:00
David Bomba 1c5c568251 WS Tests 2025-06-25 11:09:15 +10:00
David Bomba 604ba82f8f WS Tests 2025-06-25 08:52:37 +10:00
David Bomba c8e0cdd090 WS Tests 2025-06-25 08:50:56 +10:00
David Bomba 54ba4349f6 Tests for verifactu 2025-06-22 10:33:26 +10:00
David Bomba 0865ab3c3e Clean up for verifactu tests 2025-04-25 17:08:46 +10:00
David Bomba fc8cb7af36 Clean up for verifactu tests 2025-04-25 17:07:49 +10:00
David Bomba 46de411160 Finish test suite 2025-04-25 17:03:16 +10:00
David Bomba ff50e3c9d9 Finish test suite 2025-04-25 17:02:22 +10:00
David Bomba fdf7d2d3cf Refactor for additional test cases 2025-04-25 16:09:40 +10:00
David Bomba dd60eb3b58 Up to signing xml 2025-04-25 14:52:07 +10:00
David Bomba 1f4fae314c Stubs for generating Verifactu Standard Invoices 2025-04-25 14:03:26 +10:00
791 changed files with 752885 additions and 708411 deletions

View File

@ -24,7 +24,7 @@ jobs:
- name: Install composer dependencies
run: |
composer config -g github-oauth.github.com ${{ secrets.GITHUB_TOKEN }}
composer install --no-dev
composer install --no-dev -o
- name: Set current date to variable
id: set_date

View File

@ -1 +1 @@
5.12.23
5.12.36

View File

@ -0,0 +1,66 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www/elastic.co/licensing/elastic-license
*/
namespace App\Casts;
use App\DataMapper\InvoiceBackup;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class InvoiceBackupCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
if (is_null($value)) {
return new InvoiceBackup();
}
$data = json_decode($value, true) ?? [];
return InvoiceBackup::fromArray($data);
}
public function set($model, string $key, $value, array $attributes)
{
if (is_null($value)) {
return [$key => null];
}
// Ensure we're dealing with our object type
if (! $value instanceof InvoiceBackup) {
// Attempt to create the instance from legacy data before throwing
try {
if (is_object($value)) {
$value = InvoiceBackup::fromArray((array) $value);
}
} catch (\Exception $e) {
throw new \InvalidArgumentException('Value must be an InvoiceBackup instance. Legacy data conversion failed: ' . $e->getMessage());
}
}
return [
$key => json_encode([
'guid' => $value->guid,
'cancellation' => $value->cancellation ? [
'adjustment' => $value->cancellation->adjustment,
'status_id' => $value->cancellation->status_id,
] : [],
'parent_invoice_id' => $value->parent_invoice_id,
'parent_invoice_number' => $value->parent_invoice_number,
'document_type' => $value->document_type,
'child_invoice_ids' => $value->child_invoice_ids->toArray(),
'redirect' => $value->redirect,
'adjustable_amount' => $value->adjustable_amount,
'notes' => $value->notes,
])
];
}
}

View File

@ -184,7 +184,7 @@ class CheckData extends Command
private function checkTaskTimeLogs()
{
\App\Models\Task::query()->cursor()->each(function ($task) {
$time_log = json_decode($task->time_log, true);
$time_log = json_decode($task->time_log, true) ?? [];
foreach($time_log as &$log){
if(count($log) > 4){

View File

@ -0,0 +1,437 @@
<?php
namespace App\Console\Commands\Elastic;
use Illuminate\Console\Command;
use App\Models\Client;
use App\Models\ClientContact;
use App\Models\Credit;
use App\Models\Expense;
use App\Models\Invoice;
use App\Models\Project;
use App\Models\PurchaseOrder;
use App\Models\Quote;
use App\Models\RecurringInvoice;
use App\Models\Task;
use App\Models\Vendor;
use App\Models\VendorContact;
use Elastic\Elasticsearch\ClientBuilder;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class RebuildElasticIndexes extends Command
{
protected $signature = 'elastic:rebuild
{--model= : Rebuild only a specific model (e.g., Invoice, Client)}
{--force : Force the operation without confirmation}
{--dry-run : Show what would be done without making changes}
{--chunk=500 : Number of records to import per chunk}
{--wait : Wait for our queued jobs to complete after each model (recommended for production)}
{--no-queue : Import synchronously instead of queueing (slower but safer)}';
protected $description = 'Rebuild Elasticsearch indexes one at a time to minimize production impact';
protected array $searchableModels = [
Client::class => 'clients_v2',
ClientContact::class => 'client_contacts_v2',
Credit::class => 'credits_v2',
Expense::class => 'expenses_v2',
Invoice::class => 'invoices_v2',
Project::class => 'projects_v2',
PurchaseOrder::class => 'purchase_orders_v2',
Quote::class => 'quotes_v2',
RecurringInvoice::class => 'recurring_invoices_v2',
Task::class => 'tasks_v2',
Vendor::class => 'vendors_v2',
VendorContact::class => 'vendor_contacts_v2',
];
public function handle(): int
{
$this->info('===========================================');
$this->info(' Elasticsearch Index Rebuild (One-by-One)');
$this->info('===========================================');
$this->newLine();
if (!$this->checkElasticsearchConnection()) {
$this->error('Cannot connect to Elasticsearch. Please check your configuration.');
return self::FAILURE;
}
if ($modelName = $this->option('model')) {
return $this->rebuildSingleModel($modelName);
}
if ($this->option('dry-run')) {
return $this->performDryRun();
}
if (!$this->option('force')) {
$this->warn('This command will rebuild ALL Elasticsearch indexes ONE AT A TIME:');
$this->info(' • Each index will be dropped, migrated, and re-imported sequentially');
$this->info(' • Search will be unavailable for each model during its rebuild');
$this->info(' • Other models remain searchable while one rebuilds');
if ($this->option('wait')) {
$this->info(' • Will WAIT for our jobs to complete (tracks pending + processing)');
} else {
$this->warn(' • WARNING: Jobs will queue up async (use --wait for production)');
}
if ($this->option('no-queue')) {
$this->info(' • Using SYNCHRONOUS import (slower but guaranteed)');
}
$this->newLine();
$totalRecords = $this->getTotalRecordCount();
$this->warn("Total records to re-index: {$totalRecords}");
$this->newLine();
if (!$this->confirm('Do you want to rebuild all indexes?', false)) {
$this->info('Operation cancelled.');
return self::SUCCESS;
}
}
$this->newLine();
$totalModels = count($this->searchableModels);
$currentModel = 0;
$startTime = now();
foreach ($this->searchableModels as $modelClass => $indexName) {
$currentModel++;
$modelName = class_basename($modelClass);
$this->newLine();
$this->info("[{$currentModel}/{$totalModels}] Rebuilding {$modelName}...");
$this->line("Index: {$indexName}");
if (!$this->rebuildIndex($modelClass, $indexName)) {
$this->error("Failed to rebuild {$modelName}. Stopping.");
return self::FAILURE;
}
}
$this->newLine();
$duration = now()->diffForHumans($startTime, true);
$this->info('✓ All indexes rebuilt successfully!');
$this->info("Total time: {$duration}");
return self::SUCCESS;
}
protected function rebuildSingleModel(string $modelName): int
{
$modelClass = null;
foreach ($this->searchableModels as $class => $indexName) {
if (class_basename($class) === $modelName) {
$modelClass = $class;
break;
}
}
if (!$modelClass) {
$this->error("Model '{$modelName}' not found.");
$this->info('Available models: ' . implode(', ', array_map('class_basename', array_keys($this->searchableModels))));
return self::FAILURE;
}
$indexName = $this->searchableModels[$modelClass];
try {
$recordCount = $modelClass::count();
} catch (\Exception $e) {
$this->warn("Could not count records: " . $e->getMessage());
$recordCount = 0;
}
$this->newLine();
$this->info("Rebuilding {$modelName}");
$this->line("Index: {$indexName}");
$this->line("Records: {$recordCount}");
$this->line("Chunk size: {$this->option('chunk')}");
$this->line("Mode: " . ($this->option('no-queue') ? 'Synchronous' : 'Queued'));
$this->newLine();
if (!$this->option('force') && !$this->option('dry-run')) {
if (!$this->confirm('Continue with rebuild?', true)) {
$this->info('Operation cancelled.');
return self::SUCCESS;
}
}
if ($this->option('dry-run')) {
$this->info('DRY RUN - Would rebuild:');
$this->line(" 1. Drop index: {$indexName}");
$this->line(" 2. Run elastic:migrate for {$modelName}");
$this->line(" 3. Import {$recordCount} {$modelName} records");
return self::SUCCESS;
}
$startTime = now();
if ($this->rebuildIndex($modelClass, $indexName)) {
$duration = now()->diffForHumans($startTime, true);
$this->newLine();
$this->info("{$modelName} rebuilt successfully in {$duration}!");
return self::SUCCESS;
}
$this->error("✗ Failed to rebuild {$modelName}");
return self::FAILURE;
}
protected function performDryRun(): int
{
$this->info('DRY RUN - The following would be rebuilt:');
$this->newLine();
foreach ($this->searchableModels as $modelClass => $indexName) {
$modelName = class_basename($modelClass);
try {
$recordCount = $modelClass::count();
} catch (\Exception $e) {
$recordCount = 0;
}
$this->line("{$modelName}");
$this->line(" Index: {$indexName}");
$this->line(" Records: {$recordCount}");
$this->newLine();
}
$this->info('No changes made (dry run mode)');
return self::SUCCESS;
}
protected function rebuildIndex(string $modelClass, string $indexName): bool
{
$modelName = class_basename($modelClass);
$client = $this->getElasticsearchClient();
try {
$this->line(" [1/3] Dropping index {$indexName}...");
try {
$indexExists = $client->indices()->exists(['index' => $indexName]);
if ($indexExists) {
try {
$client->indices()->delete(['index' => $indexName]);
$this->info(" ✓ Index dropped");
} catch (\Exception $deleteException) {
$this->warn(" ⚠ Failed to delete index: " . $deleteException->getMessage());
$this->line(" - Continuing with migration...", 'comment');
}
} else {
$this->line(" - Index does not exist (will be created)", 'comment');
}
} catch (\Exception $existsException) {
$this->warn(" ⚠ Could not check index existence: " . $existsException->getMessage());
$this->line(" - Continuing with migration...", 'comment');
}
$this->line(" [2/3] Running elastic migration...");
try {
Artisan::call('elastic:migrate', [], $this->getOutput());
$this->info(" ✓ Migration completed");
} catch (\Exception $migrateException) {
$this->error(" ✗ Migration failed: " . $migrateException->getMessage());
return false;
}
$this->line(" [3/3] Importing {$modelName} data...");
try {
$recordCount = $modelClass::count();
} catch (\Exception $countException) {
$this->warn(" ⚠ Could not count records: " . $countException->getMessage());
$recordCount = 0;
}
if ($recordCount > 0) {
try {
if ($this->option('no-queue')) {
$this->line(" - Using synchronous import (no queue)", 'comment');
$this->importSynchronously($modelClass, $recordCount);
} else {
$this->importWithQueueTracking($modelClass, $recordCount);
}
$this->info(" ✓ Import completed for {$recordCount} records");
} catch (\Exception $importException) {
$this->error(" ✗ Import failed: " . $importException->getMessage());
return false;
}
} else {
$this->line(" - No records to import", 'comment');
}
return true;
} catch (\Exception $e) {
$this->error(" ✗ Unexpected error: " . $e->getMessage());
$this->line(" Stack trace: " . $e->getTraceAsString());
return false;
}
}
protected function importSynchronously(string $modelClass, int $totalRecords): void
{
$chunkSize = (int) $this->option('chunk');
$chunks = ceil($totalRecords / $chunkSize);
$processed = 0;
$this->line(" - Processing {$chunks} chunks of {$chunkSize} records each", 'comment');
$modelClass::chunk($chunkSize, function ($models) use (&$processed, $totalRecords) {
$models->searchable();
$processed += $models->count();
$percentage = round(($processed / $totalRecords) * 100);
$this->line(" - Indexed {$processed}/{$totalRecords} ({$percentage}%)", 'comment');
});
}
protected function importWithQueueTracking(string $modelClass, int $recordCount): void
{
$chunkSize = (int) $this->option('chunk');
$expectedJobCount = ceil($recordCount / $chunkSize);
$queueName = config('scout.queue.queue', 'scout');
$connection = config('scout.queue.connection', config('queue.default'));
try {
$baselineJobCount = $this->getTotalActiveJobCount($connection, $queueName);
} catch (\Exception $e) {
$baselineJobCount = 0;
$this->line(" - Cannot track queue baseline: " . $e->getMessage(), 'comment');
}
$this->line(" - Baseline active jobs: {$baselineJobCount} (pending + processing)", 'comment');
$this->line(" - Dispatching ~{$expectedJobCount} import jobs (chunks of {$chunkSize})", 'comment');
Artisan::call('scout:import', [
'model' => $modelClass,
'--chunk' => $chunkSize,
], $this->getOutput());
$this->line(" - Jobs dispatched to queue", 'comment');
if ($this->option('wait')) {
$this->waitForOurJobsToComplete($connection, $queueName, $baselineJobCount, $expectedJobCount);
}
}
protected function waitForOurJobsToComplete(
string $connection,
string $queueName,
int $baselineJobCount,
int $expectedJobCount
): void {
$this->newLine();
$this->line(" Waiting for our {$expectedJobCount} jobs to complete...");
$this->line(" (Tracking: pending + processing jobs)", 'comment');
$startTime = time();
$lastReportedDelta = -1;
$stableCount = 0;
while (true) {
try {
$currentJobCount = $this->getTotalActiveJobCount($connection, $queueName);
$delta = $currentJobCount - $baselineJobCount;
if ($currentJobCount <= $baselineJobCount) {
$this->info(" ✓ Our jobs completed (active: {$currentJobCount}, baseline: {$baselineJobCount})");
return;
}
if ($delta !== $lastReportedDelta) {
$this->line(" - Our jobs remaining: ~{$delta} (total active: {$currentJobCount})", 'comment');
$lastReportedDelta = $delta;
$stableCount = 0;
} else {
$stableCount++;
}
if ($stableCount >= 15 && $delta <= $expectedJobCount) {
$this->info(" ✓ Queue stabilized - assuming complete");
return;
}
sleep(2);
} catch (\Exception $e) {
$this->warn(" ⚠ Could not check queue status: " . $e->getMessage());
$this->line(" - Waiting 10 seconds before continuing...", 'comment');
sleep(10);
return;
}
}
}
protected function getTotalActiveJobCount(string $connection, string $queueName): int
{
$driver = config("queue.connections.{$connection}.driver");
switch ($driver) {
case 'database':
// Count both pending (reserved_at IS NULL) and processing (reserved_at IS NOT NULL)
return DB::table(config("queue.connections.{$connection}.table", 'jobs'))
->where('queue', $queueName)
->count();
case 'redis':
// Redis: pending jobs in list + reserved jobs in processing set
$redis = Redis::connection('sentinel-default');
$prefix = config('database.redis.options.prefix', '');
// Pending jobs in the queue list
$pending = $redis->llen($prefix . 'queues:' . $queueName);
// Processing jobs in the reserved set
$processing = $redis->zcard($prefix . 'queues:' . $queueName . ':reserved');
return $pending + $processing;
case 'sync':
return 0;
default:
throw new \Exception("Cannot check queue size for driver: {$driver}");
}
}
protected function getTotalRecordCount(): int
{
$total = 0;
foreach ($this->searchableModels as $modelClass => $indexName) {
try {
$total += $modelClass::count();
} catch (\Exception $e) {
// Skip if model fails to count
}
}
return $total;
}
protected function checkElasticsearchConnection(): bool
{
try {
$client = $this->getElasticsearchClient();
$client->ping();
$this->info('✓ Elasticsearch connection successful');
return true;
} catch (\Exception $e) {
$this->error('✗ Elasticsearch connection failed: ' . $e->getMessage());
return false;
}
}
protected function getElasticsearchClient()
{
return ClientBuilder::fromConfig(config('elastic.client.connections.default'));
}
}

View File

@ -0,0 +1,87 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper\Analytics;
use Turbo124\Beacon\ExampleMetric\GenericMixedMetric;
class FeedbackCreated extends GenericMixedMetric
{
/**
* The type of Sample.
*
* Monotonically incrementing counter
*
* - counter
*
* @var string
*/
public $type = 'mixed_metric';
/**
* The name of the counter.
* @var string
*/
public $name = 'app.feedback';
/**
* The datetime of the counter measurement.
*
* date("Y-m-d H:i:s")
*
*/
public $datetime;
/**
* The Class failure name
* set to 0.
*
* @var string
*/
public $string_metric5 = '';
/**
* The exception string
* set to 0.
*
* @var string
*/
public $string_metric6 = '';
/**
* The counter
* set to 1.
*
*/
public $int_metric1 = 1;
/**
* Company Key
* @var string
*/
public $string_metric7 = '';
/**
* Subject
* @var string
*/
public $string_metric8 = '';
public function __construct($int_metric1, $string_metric5, $string_metric6, $string_metric7, $string_metric8)
{
$this->int_metric1 = $int_metric1;
$this->string_metric5 = $string_metric5;
$this->string_metric6 = $string_metric6;
$this->string_metric7 = $string_metric7;
$this->string_metric8 = $string_metric8;
}
}

View File

@ -0,0 +1,75 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper\Analytics;
use Turbo124\Beacon\ExampleMetric\GenericStructuredMetric;
class PeppolRegistration extends GenericStructuredMetric
{
/**
* The type of Sample.
*
* Monotonically incrementing counter
*
* - counter
*
* @var string
*/
public $type = 'structured_metric';
/**
* The name of the counter.
* @var string
*/
public $name = 'peppol.registration';
/**
* The datetime of the counter measurement.
*
* date("Y-m-d H:i:s")
*
*/
public $datetime;
/**
* HTML content
*
* @var string
*/
public $html = '';
/**
* JSON data
*
* @var array
*/
public $json = [];
/**
* Initialize with either HTML or JSON content
*
* @param string|null $html HTML content
* @param array|null $json JSON data
*/
public function __construct(?string $html = null, ?array $json = null)
{
if ($html !== null) {
$this->html = $html;
}
if ($json !== null) {
$this->json = $json;
}
}
}

View File

@ -0,0 +1,74 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper\Analytics;
use Turbo124\Beacon\ExampleMetric\GenericStructuredMetric;
class VerifactuLog extends GenericStructuredMetric
{
/**
* The type of Sample.
*
* Monotonically incrementing counter
*
* - counter
*
* @var string
*/
public $type = 'structured_metric';
/**
* The name of the counter.
* @var string
*/
public $name = 'verifactu.log';
/**
* The datetime of the counter measurement.
*
* date("Y-m-d H:i:s")
*
*/
public $datetime;
/**
* HTML content
*
* @var string
*/
public $html = '';
/**
* JSON data
*
* @var array
*/
public $json = [];
/**
* Initialize with either HTML or JSON content
*
* @param string|null $html HTML content
* @param array|null $json JSON data
*/
public function __construct(?string $html = null, ?array $json = null)
{
if ($html !== null) {
$this->html = $html;
}
if ($json !== null) {
$this->json = $json;
}
}
}

View File

@ -0,0 +1,75 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper\Billing;
use Turbo124\Beacon\ExampleMetric\GenericStructuredMetric;
class AppleLiapWebhook extends GenericStructuredMetric
{
/**
* The type of Sample.
*
* Monotonically incrementing counter
*
* - counter
*
* @var string
*/
public $type = 'structured_metric';
/**
* The name of the counter.
* @var string
*/
public $name = 'apple.liap.webhook';
/**
* The datetime of the counter measurement.
*
* date("Y-m-d H:i:s")
*
*/
public $datetime;
/**
* HTML content
*
* @var string
*/
public $html = '';
/**
* JSON data
*
* @var array
*/
public $json = [];
/**
* Initialize with either HTML or JSON content
*
* @param string|null $html HTML content
* @param array|null $json JSON data
*/
public function __construct(?string $html = null, ?array $json = null)
{
if ($html !== null) {
$this->html = $html;
}
if ($json !== null) {
$this->json = $json;
}
}
}

View File

@ -0,0 +1,75 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper\Billing;
use Turbo124\Beacon\ExampleMetric\GenericStructuredMetric;
class GoogleLiapWebhook extends GenericStructuredMetric
{
/**
* The type of Sample.
*
* Monotonically incrementing counter
*
* - counter
*
* @var string
*/
public $type = 'structured_metric';
/**
* The name of the counter.
* @var string
*/
public $name = 'google.liap.webhook';
/**
* The datetime of the counter measurement.
*
* date("Y-m-d H:i:s")
*
*/
public $datetime;
/**
* HTML content
*
* @var string
*/
public $html = '';
/**
* JSON data
*
* @var array
*/
public $json = [];
/**
* Initialize with either HTML or JSON content
*
* @param string|null $html HTML content
* @param array|null $json JSON data
*/
public function __construct(?string $html = null, ?array $json = null)
{
if ($html !== null) {
$this->html = $html;
}
if ($json !== null) {
$this->json = $json;
}
}
}

View File

@ -0,0 +1,36 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www/elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
/**
* Cancellation value object for invoice backup data.
*/
class Cancellation
{
public function __construct(
public float $adjustment = 0, // The cancellation adjustment amount
public int $status_id = 0 //The status id of the invoice when it was cancelled
) {}
public static function fromArray(array|object $data): self
{
if (is_object($data)) {
$data = (array) $data;
}
return new self(
adjustment: $data['adjustment'] ?? 0,
status_id: $data['status_id'] ?? 0
);
}
}

View File

@ -479,7 +479,7 @@ class CompanySettings extends BaseSettings
public $sync_invoice_quote_columns = true;
public $e_invoice_type = 'EN16931';
public $e_invoice_type = 'EN16931'; //verifactu
public $e_quote_type = 'OrderX_Comfort';
@ -963,6 +963,7 @@ class CompanySettings extends BaseSettings
{
$variables = [
'client_details' => [
'$client.location_name',
'$client.name',
'$client.number',
'$client.vat_number',

View File

@ -0,0 +1,106 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www/elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Casts\InvoiceBackupCast;
use App\DataMapper\Cancellation;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Support\Collection;
/**
* InvoiceBackup.
*/
class InvoiceBackup implements Castable
{
/**
* @param string $guid - The E-INVOICE SENT GUID reference - or enum to advise the document has been successfully sent.
* @param Cancellation $cancellation The cancellation data for the invoice.
* @param string $parent_invoice_id The id of the invoice that was cancelled
* @param string $parent_invoice_number The number of the invoice that was cancelled
* @param string $document_type The type of document the invoice is - F1, R2, R1
* @param Collection $child_invoice_ids The collection of child invoice IDs
* @param string $redirect The redirect url for the invoice
* @param float $adjustable_amount The adjustable amount for the invoice
* @param string $notes The notes field - can be multi purpose, but general usage for Verifactu cancellation reason
* @return void
*/
public function __construct(
public string $guid = '',
public Cancellation $cancellation = new Cancellation(0,0),
public ?string $parent_invoice_id = null,
public ?string $parent_invoice_number = null,
public ?string $document_type = null,
public Collection $child_invoice_ids = new Collection(),
public ?string $redirect = null,
public float $adjustable_amount = 0,
public ?string $notes = null,
) {}
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string
{
return InvoiceBackupCast::class;
}
public static function fromArray(array $data): self
{
return new self(
guid: $data['guid'] ?? '',
cancellation: Cancellation::fromArray($data['cancellation'] ?? []),
parent_invoice_id: $data['parent_invoice_id'] ?? null,
parent_invoice_number: $data['parent_invoice_number'] ?? null,
document_type: $data['document_type'] ?? null,
child_invoice_ids: isset($data['child_invoice_ids']) ? collect($data['child_invoice_ids']) : new Collection(),
redirect: $data['redirect'] ?? null,
adjustable_amount: $data['adjustable_amount'] ?? 0,
notes: $data['notes'] ?? null,
);
}
/**
* Add a child invoice ID to the collection
*/
public function addChildInvoiceId(string $invoiceId): void
{
$this->child_invoice_ids->push($invoiceId);
}
/**
* Remove a child invoice ID from the collection
*/
public function removeChildInvoiceId(string $invoiceId): void
{
$this->child_invoice_ids = $this->child_invoice_ids->reject($invoiceId);
}
/**
* Check if a child invoice ID exists
*/
public function hasChildInvoiceId(string $invoiceId): bool
{
return $this->child_invoice_ids->contains($invoiceId);
}
/**
* Get all child invoice IDs as an array
*/
public function getChildInvoiceIds(): array
{
return $this->child_invoice_ids->toArray();
}
}

View File

@ -34,4 +34,5 @@ class EmailRecord
* @var string
*/
public string $entity_id = '';
}

View File

@ -23,13 +23,14 @@ class TaxDetail
public string $country_nexus; // Country nexus (e.g. "US", "UK", "CA")
public float $taxable_amount; // net amount exclusive of taxes
public float $tax_amount; // total tax amount
public float $tax_amount_paid; // Amount actually paid (Based on the payment history)
public float $tax_amount_remaining; // Amount still pending
public string $tax_status; // "collected", "pending", "refundable", "partially_paid", "adjustment"
// Adjustment-specific fields (used when tax_status is "adjustment")
public ?string $adjustment_reason; // "invoice_cancelled", "tax_rate_change", "exemption_applied", "correction"
public ?string $postal_code; // "invoice_cancelled", "tax_rate_change", "exemption_applied", "correction"
public float $line_total;
public float $total_tax;
public function __construct(array $attributes = [])
{
$this->tax_name = $attributes['tax_name'] ?? '';
@ -38,13 +39,12 @@ class TaxDetail
$this->country_nexus = $attributes['country_nexus'] ?? '';
$this->taxable_amount = $attributes['taxable_amount'] ?? 0.0;
$this->tax_amount = $attributes['tax_amount'] ?? 0.0;
$this->tax_amount_paid = $attributes['tax_amount_paid'] ?? 0.0;
$this->tax_amount_remaining = $attributes['tax_amount_remaining'] ?? 0.0;
$this->tax_status = $attributes['tax_status'] ?? 'pending';
// Adjustment fields
$this->adjustment_reason = $attributes['adjustment_reason'] ?? null;
$this->postal_code = $attributes['postal_code'] ?? null;
$this->line_total = $attributes['line_total'] ?? 0.0;
$this->total_tax = $attributes['total_tax'] ?? 0.0;
}
public function toArray(): array
@ -56,10 +56,10 @@ class TaxDetail
'country_nexus' => $this->country_nexus,
'taxable_amount' => $this->taxable_amount,
'tax_amount' => $this->tax_amount,
'tax_amount_paid' => $this->tax_amount_paid,
'tax_amount_remaining' => $this->tax_amount_remaining,
'tax_status' => $this->tax_status,
'adjustment_reason' => $this->adjustment_reason,
'postal_code' => $this->postal_code,
'line_total' => $this->line_total,
'total_tax' => $this->total_tax,
];
return $data;

View File

@ -14,28 +14,37 @@ namespace App\DataMapper\TaxReport;
/**
* Tax summary with totals for different tax states
*
* Represents the taxable amount and tax amount for an invoice in a specific period.
* The meaning of these values depends on the status:
*
* - 'updated': Full invoice tax liability (accrual) or paid tax (cash)
* - 'delta': Differential tax change from invoice updates
* - 'adjustment': Tax change from payment refunds/deletions
* - 'cancelled': Proportional tax on refunded/cancelled amount
* - 'deleted': Full tax reversal
* - 'reversed': Full tax reversal of credit note
*/
class TaxSummary
{
public float $total_taxes; // Tax collected and confirmed (ie. Invoice Paid)
public float $total_paid; // Tax pending collection (Outstanding tax of balance owing)
public float $taxable_amount;
public float $tax_amount;
public string $status;
public float $adjustment;
public function __construct(array $attributes = [])
{
$this->total_taxes = $attributes['total_taxes'] ?? 0.0;
$this->total_paid = $attributes['total_paid'] ?? 0.0;
$this->taxable_amount = $attributes['taxable_amount'] ?? 0.0;
// Support both old and new property names for backwards compatibility during migration
$this->tax_amount = $attributes['tax_amount'] ?? $attributes['tax_adjustment'] ?? $attributes['total_taxes'] ?? 0.0;
$this->status = $attributes['status'] ?? 'updated';
$this->adjustment = $attributes['adjustment'] ?? 0.0;
}
public function toArray(): array
{
return [
'total_taxes' => $this->total_taxes,
'total_paid' => $this->total_paid,
'taxable_amount' => $this->taxable_amount,
'tax_amount' => $this->tax_amount,
'status' => $this->status,
'adjustment' => $this->adjustment,
];
}
}

View File

@ -1,63 +0,0 @@
<?php
namespace App\Elastic\Index;
class EntityIndex
{
public array $mapping = [
'properties' => [
'id' => [ 'type' => 'text' ],
'name' => [ 'type' => 'text' ],
'hashed_id' => [ 'type' => 'text' ],
'number' => [ 'type' => 'text' ],
'is_deleted' => [ 'type' => 'boolean' ],
'amount' => [ 'type' => 'long' ],
'balance' => [ 'type' => 'long' ],
'due_date' => [ 'type' => 'date' ],
'date' => [ 'type' => 'date' ],
'custom_value1' => [ 'type' => 'text' ],
'custom_value2' => [ 'type' => 'text' ],
'custom_value3' => [ 'type' => 'text' ],
'custom_value4' => [ 'type' => 'text' ],
'company_key' => [ 'type' => 'text' ],
'po_number' => [ 'type' => 'text' ],
'line_items' => [
'type' => 'nested',
'properties' => [
'product_key' => [ 'type' => 'text' ],
'notes' => [ 'type' => 'text' ],
'cost' => [ 'type' => 'long' ],
'product_cost' => [ 'type' => 'long' ],
'is_amount_discount' => [ 'type' => 'boolean' ],
'line_total' => [ 'type' => 'long' ],
'gross_line_total' => [ 'type' => 'long' ],
'tax_amount' => [ 'type' => 'long' ],
'quantity' => [ 'type' => 'float' ],
'discount' => [ 'type' => 'float' ],
'tax_name1' => [ 'type' => 'text' ],
'tax_rate1' => [ 'type' => 'float' ],
'tax_name2' => [ 'type' => 'text' ],
'tax_rate2' => [ 'type' => 'float' ],
'tax_name3' => [ 'type' => 'text' ],
'tax_rate3' => [ 'type' => 'float' ],
'custom_value1' => [ 'type' => 'text' ],
'custom_value2' => [ 'type' => 'text' ],
'custom_value3' => [ 'type' => 'text' ],
'custom_value4' => [ 'type' => 'text' ],
'type_id' => [ 'type' => 'text' ],
'tax_id' => [ 'type' => 'text' ],
'task_id' => [ 'type' => 'text' ],
'expense_id' => [ 'type' => 'text' ],
'unit_code' => [ 'type' => 'text' ],
]
]
]
];
public function create(string $index_name): void
{
\Elastic\Migrations\Facades\Index::createRaw($index_name, $this->mapping);
}
}

View File

@ -81,6 +81,7 @@ class Handler extends ExceptionHandler
ModelNotFoundException::class,
NotFoundHttpException::class,
RelationNotFoundException::class,
StripeConnectFailure::class,
];
/**

View File

@ -93,7 +93,7 @@ class ActivityExport extends BaseExport
}
private function init(): Builder
public function init(): Builder
{
MultiDB::setDb($this->company->db);
App::forgetInstance('translator');
@ -115,6 +115,8 @@ class ActivityExport extends BaseExport
$query = $this->addDateRange($query, 'activities');
$query = $this->filterByUserPermissions($query);
if ($this->input['activity_type_id'] ?? false) {
$query->where('activity_type_id', $this->input['activity_type_id']);
}
@ -127,18 +129,16 @@ class ActivityExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header
$this->csv->insertOne($this->buildHeader());
$query->cursor()
->each(function ($entity) {
/** @var \App\Models\Activity $entity */
$this->buildRow($entity);
});

View File

@ -12,12 +12,13 @@
namespace App\Export\CSV;
use App\Jobs\Credit\ZipCredits;
use Str;
use App\Models\Task;
use App\Models\User;
use App\Models\Quote;
use App\Models\Client;
use App\Models\Credit;
use App\Models\Design;
use App\Models\Vendor;
use App\Utils\Helpers;
use App\Models\Company;
@ -27,20 +28,21 @@ use App\Models\Payment;
use App\Models\Product;
use App\Models\Document;
use League\Fractal\Manager;
use App\Jobs\Quote\ZipQuotes;
use App\Models\ClientContact;
use App\Models\PurchaseOrder;
use Illuminate\Support\Carbon;
use App\Jobs\Credit\ZipCredits;
use App\Utils\Traits\MakesHash;
use App\Models\RecurringInvoice;
use App\Jobs\Document\ZipDocuments;
use App\Jobs\Invoice\ZipInvoices;
use App\Jobs\PurchaseOrder\ZipPurchaseOrders;
use App\Jobs\Quote\ZipQuotes;
use App\Jobs\Document\ZipDocuments;
use App\Transformers\TaskTransformer;
use App\Transformers\PaymentTransformer;
use Illuminate\Database\Eloquent\Builder;
use App\Services\Template\TemplateService;
use App\Jobs\PurchaseOrder\ZipPurchaseOrders;
use League\Fractal\Serializer\ArraySerializer;
use Str;
class BaseExport
{
@ -1291,10 +1293,13 @@ $products = str_getcsv($this->input['product_key'], ',', "'");
$this->end_date = 'All available data';
return $query;
case 'last7':
case 'last_7_days':
case 'last7_days':
$this->start_date = now()->subDays(7)->format('Y-m-d');
$this->end_date = now()->format('Y-m-d');
return $query->whereBetween($this->date_key, [now()->subDays(7), now()])->orderBy($this->date_key, 'ASC');
case 'last30':
case 'last_30_days':
$this->start_date = now()->subDays(30)->format('Y-m-d');
$this->end_date = now()->format('Y-m-d');
return $query->whereBetween($this->date_key, [now()->subDays(30), now()])->orderBy($this->date_key, 'ASC');
@ -1708,4 +1713,105 @@ $products = str_getcsv($this->input['product_key'], ',', "'");
return $entity;
}
public function filterByUserPermissions(Builder $query): Builder
{
$user = User::withTrashed()->where('id', $this->input['user_id'])->where('account_id', $this->company->account_id)->first();
if ($user->isAdmin() || $user->hasExactPermission('view_all') || $user->hasExactPermission('edit_all')) { // No State? Do we need to ensure -> isAdmin() binds to the correct company?
return $query;
}
if($user->hasExactPermission('create_all')){
return $query->where('user_id', $user->id);
}
return $this->resolveEntityFilters($user, $query);
}
public function exportTemplate(Builder $query, string $template_id)
{
$template = Design::withTrashed()->find($this->decodePrimaryKey($template_id));
$model_string = $this->getModelString($query);
$data = [
"{$model_string}s" => $query->get(),
"start_date" => $this->start_date,
"end_date" => $this->end_date,
];
$ts = new TemplateService($template);
$ts->setCompany($this->company);
$ts->addGlobal(['currency_code' => $this->company->currency()->code]);
$ts->build($data);
return $ts->getPdf();
}
private function getModelString(Builder $query): ?string
{
$model = get_class($query->getModel());
return match($model) {
'App\Models\Client' => 'client',
'App\Models\ClientContact' => 'client',
'App\Models\Invoice' => 'invoice',
'App\Models\Quote' => 'quote',
'App\Models\Credit' => 'credit',
'App\Models\PurchaseOrder' => 'purchase_order',
'App\Models\RecurringInvoice' => 'recurring_invoice',
'App\Models\RecurringExpense' => 'recurring_expense',
'App\Models\Task' => 'task',
'App\Models\Vendor' => 'vendor',
'App\Models\VendorContact' => 'vendor_contact',
'App\Models\Product' => 'product',
'App\Models\Payment' => 'payment',
'App\Models\Expense' => 'expense',
'App\Models\Document' => 'document',
'App\Models\Activity' => 'activity',
'App\Models\Task' => 'task',
'App\Models\Project' => 'project',
default => null,
};
}
private function resolveEntityFilters(User $user, Builder $query): Builder
{
$model = get_class($query->getModel());
$model_string = $this->getModelString($query);
$column_listing = \Illuminate\Support\Facades\Schema::getColumnListing($query->getModel()->getTable());
/** If the User can view or edit the entity, then return the query unfiltered */
if($user->hasIntersectPermissions(["view_{$model_string}", "edit_{$model_string}"])){
return $query;
}
//Handle Child Models Like ClientContact or VendorContact
if(in_array($model, ['App\Models\ClientContact', 'App\Models\VendorContact'])){
$query->whereHas($model_string, function ($_q) use ($user){
$_q->where('user_id', $user->id)->orWhere('assigned_user_id', $user->id);
});
return $query;
}
return $query->where(function ($q) use ($user, $column_listing){
if(in_array('user_id', $column_listing)){
$q->where('user_id', $user->id);
}
if(in_array('assigned_user_id', $column_listing)){
$q->orWhere('assigned_user_id', $user->id);
}
});
}
}

View File

@ -136,6 +136,8 @@ class ClientExport extends BaseExport
$query = $this->addDateRange($query, ' clients');
$query = $this->filterByUserPermissions($query);
if ($this->input['document_email_attachment'] ?? false) {
$this->queueDocuments($query);
}
@ -149,7 +151,7 @@ class ClientExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header
@ -227,7 +229,7 @@ class ClientExport extends BaseExport
}
if (in_array('client.assigned_user', $this->input['report_keys'])) {
$entity['client.assigned_user'] = $client->assigned_user ? $client->user->present()->name() : '';
$entity['client.assigned_user'] = $client->assigned_user ? $client->assigned_user->present()->name() : '';
}
if (in_array('client.classification', $this->input['report_keys']) && isset($client->classification)) {

View File

@ -45,7 +45,7 @@ class ContactExport extends BaseExport
$this->decorator = new Decorator();
}
private function init(): Builder
public function init(): Builder
{
MultiDB::setDb($this->company->db);
@ -65,6 +65,7 @@ class ContactExport extends BaseExport
});
$query = $this->addDateRange($query, 'client_contacts');
$query = $this->filterByUserPermissions($query);
return $query;
@ -76,7 +77,7 @@ class ContactExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header

View File

@ -87,7 +87,7 @@ class CreditExport extends BaseExport
return $clean_row;
}
private function init(): Builder
public function init(): Builder
{
MultiDB::setDb($this->company->db);
@ -123,6 +123,8 @@ class CreditExport extends BaseExport
$query = $this->addCreditStatusFilter($query, $this->input['status']);
}
$query = $this->filterByUserPermissions($query);
if ($this->input['document_email_attachment'] ?? false) {
$this->queueDocuments($query);
}
@ -138,7 +140,7 @@ class CreditExport extends BaseExport
{
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header

View File

@ -64,7 +64,7 @@ class DocumentExport extends BaseExport
return array_merge(['columns' => $header], $report);
}
private function init(): Builder
public function init(): Builder
{
MultiDB::setDb($this->company->db);
@ -81,6 +81,8 @@ class DocumentExport extends BaseExport
$query = $this->addDateRange($query, 'documents');
$query = $this->filterByUserPermissions($query);
if ($this->input['document_email_attachment'] ?? false) {
$this->queueDocuments($query);
}
@ -94,7 +96,7 @@ class DocumentExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header

View File

@ -62,7 +62,7 @@ class ExpenseExport extends BaseExport
return array_merge(['columns' => $header], $report);
}
private function init(): Builder
public function init(): Builder
{
MultiDB::setDb($this->company->db);
@ -114,6 +114,8 @@ class ExpenseExport extends BaseExport
$query = $this->addCategoryFilter($query, $this->input['categories']);
}
$query = $this->filterByUserPermissions($query);
if ($this->input['document_email_attachment'] ?? false) {
$this->queueDocuments($query);
}
@ -127,7 +129,7 @@ class ExpenseExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header
@ -225,9 +227,9 @@ class ExpenseExport extends BaseExport
if (in_array('expense.vendor_id', $this->input['report_keys'])) {
// $entity['expense.vendor'] = $expense->vendor ? $expense->vendor->name : '';
$entity['expense.vendor_id'] = $expense->vendor ? $expense->vendor->name : '';
$entity['expense.vendor_id'] = $expense->vendor ? $expense->vendor->id : '';
// $entity['expense.vendor_id'] = $expense->vendor ? $expense->vendor->id : '';
}
if (in_array('expense.payment_type_id', $this->input['report_keys'])) {

View File

@ -82,6 +82,9 @@ class InvoiceExport extends BaseExport
$query = $this->addInvoiceStatusFilter($query, $this->input['status']);
}
$query = $this->filterByUserPermissions($query);
if ($this->input['document_email_attachment'] ?? false) {
$this->queueDocuments($query);
}
@ -120,7 +123,7 @@ class InvoiceExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
if ($tax_amount_position = array_search('invoice.total_taxes', $this->input['report_keys'])) {

View File

@ -93,6 +93,8 @@ class InvoiceItemExport extends BaseExport
$query = $this->addInvoiceStatusFilter($query, $this->input['status']);
}
$query = $this->filterByUserPermissions($query);
$query = $this->applyProductFilters($query);
if ($this->input['document_email_attachment'] ?? false) {
@ -137,7 +139,7 @@ class InvoiceItemExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header

View File

@ -40,7 +40,7 @@ class PaymentExport extends BaseExport
$this->decorator = new Decorator();
}
private function init(): Builder
public function init(): Builder
{
MultiDB::setDb($this->company->db);
@ -72,6 +72,7 @@ class PaymentExport extends BaseExport
}
$query = $this->addPaymentStatusFilters($query, $this->input['status'] ?? '');
$query = $this->filterByUserPermissions($query);
if ($this->input['document_email_attachment'] ?? false) {
$this->queueDocuments($query);
@ -107,7 +108,7 @@ class PaymentExport extends BaseExport
{
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header

View File

@ -61,7 +61,7 @@ class ProductExport extends BaseExport
return array_merge(['columns' => $header], $report);
}
private function init(): Builder
public function init(): Builder
{
MultiDB::setDb($this->company->db);
@ -83,6 +83,7 @@ class ProductExport extends BaseExport
}
$query = $this->addDateRange($query, 'products');
$query = $this->filterByUserPermissions($query);
if ($this->input['document_email_attachment'] ?? false) {
$this->queueDocuments($query);
@ -98,7 +99,7 @@ class ProductExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header

View File

@ -107,7 +107,7 @@ class ProductSalesExport extends BaseExport
$this->products = Product::query()->where('company_id', $this->company->id)->withTrashed()->get();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
if (count($this->input['report_keys']) == 0) {
@ -128,6 +128,8 @@ class ProductSalesExport extends BaseExport
$query = $this->filterByClients($query);
$query = $this->filterByUserPermissions($query);
$query = $this->filterByProducts($query);
$this->csv->insertOne($this->buildHeader());

View File

@ -76,6 +76,7 @@ class PurchaseOrderExport extends BaseExport
if ($clients) {
$query = $this->addClientFilter($query, $clients);
}
$query = $this->filterByUserPermissions($query);
$query = $this->addPurchaseOrderStatusFilter($query, $this->input['status'] ?? '');
@ -118,7 +119,7 @@ class PurchaseOrderExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header

View File

@ -79,6 +79,7 @@ class PurchaseOrderItemExport extends BaseExport
if ($clients) {
$query = $this->addClientFilter($query, $clients);
}
$query = $this->filterByUserPermissions($query);
$query = $this->addPurchaseOrderStatusFilter($query, $this->input['status'] ?? '');
@ -120,7 +121,7 @@ class PurchaseOrderItemExport extends BaseExport
public function run()
{
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
$query = $this->init();

View File

@ -40,7 +40,7 @@ class QuoteExport extends BaseExport
$this->decorator = new Decorator();
}
private function init(): Builder
public function init(): Builder
{
MultiDB::setDb($this->company->db);
@ -78,6 +78,8 @@ class QuoteExport extends BaseExport
$query = $this->addQuoteStatusFilter($query, $this->input['status'] ?? '');
$query = $this->filterByUserPermissions($query);
if ($this->input['document_email_attachment'] ?? false) {
$this->queueDocuments($query);
}
@ -116,7 +118,7 @@ class QuoteExport extends BaseExport
public function run()
{
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
$query = $this->init();

View File

@ -82,6 +82,7 @@ class QuoteItemExport extends BaseExport
if ($clients) {
$query = $this->addClientFilter($query, $clients);
}
$query = $this->filterByUserPermissions($query);
$query = $this->addQuoteStatusFilter($query, $this->input['status'] ?? '');
@ -126,7 +127,7 @@ class QuoteItemExport extends BaseExport
{
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
$query = $this->init();

View File

@ -73,6 +73,7 @@ class RecurringInvoiceExport extends BaseExport
if ($clients) {
$query = $this->addClientFilter($query, $clients);
}
$query = $this->filterByUserPermissions($query);
$query = $this->addRecurringInvoiceStatusFilter($query, $this->input['status'] ?? '');
@ -86,7 +87,7 @@ class RecurringInvoiceExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header

View File

@ -93,6 +93,7 @@ class RecurringInvoiceItemExport extends BaseExport
if ($this->input['status'] ?? false) {
$query = $this->addRecurringInvoiceStatusFilter($query, $this->input['status']);
}
$query = $this->filterByUserPermissions($query);
$query = $this->applyProductFilters($query);
@ -138,7 +139,7 @@ class RecurringInvoiceItemExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header

View File

@ -82,6 +82,12 @@ class TaskExport extends BaseExport
$query = $this->addClientFilter($query, $clients);
}
if($this->input['status'] ?? false){
$query = $this->addTaskStatusFilter($query, $this->input['status']);
}
$query = $this->filterByUserPermissions($query);
$document_attachments = &$this->input['document_email_attachment'];
if ($document_attachments) {
@ -98,7 +104,7 @@ class TaskExport extends BaseExport
$query = $this->init();
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
//insert the header
@ -261,6 +267,7 @@ class TaskExport extends BaseExport
*/
protected function addTaskStatusFilter(Builder $query, string $status): Builder
{
/** @var array $status_parameters */
$status_parameters = explode(',', $status);
@ -276,6 +283,16 @@ class TaskExport extends BaseExport
$query->whereNull('invoice_id');
}
$keys = $this->transformKeys($status_parameters);
$keys = collect($keys)->filter(function ($key){
return is_int($key);
})->toArray();
if(count($keys) > 0){
$query->whereIn('status_id', $keys);
}
return $query;
}

View File

@ -54,7 +54,7 @@ class VendorExport extends BaseExport
$t->replace(Ninja::transformTranslations($this->company->settings));
//load the CSV document from a string
$this->csv = Writer::createFromString();
$this->csv = Writer::fromString();
\League\Csv\CharsetConverter::addTo($this->csv, 'UTF-8', 'UTF-8');
if (count($this->input['report_keys']) == 0) {
@ -70,6 +70,7 @@ class VendorExport extends BaseExport
}
$query = $this->addDateRange($query, 'vendors');
$query = $this->filterByUserPermissions($query);
if ($this->input['document_email_attachment'] ?? false) {
$this->queueDocuments($query);

View File

@ -173,7 +173,9 @@ class ClientFilters extends QueryFilters
$sort_col[0] = 'name';
}
if (!is_array($sort_col) || count($sort_col) != 2 || !in_array($sort_col[0], \Illuminate\Support\Facades\Schema::getColumnListing($this->builder->getModel()->getTable()))) {
if(is_array($sort_col) && $sort_col[0] == 'contacts'){
}
elseif (!is_array($sort_col) || count($sort_col) != 2 || !in_array($sort_col[0], \Illuminate\Support\Facades\Schema::getColumnListing($this->builder->getModel()->getTable()))) {
return $this->builder;
}
@ -184,18 +186,52 @@ class ClientFilters extends QueryFilters
}
if ($sort_col[0] == 'name') {
return $this->builder
->select('clients.*')
->selectSub(function ($query) {
$query->from('client_contacts')
->whereColumn('client_contacts.client_id', 'clients.id')
->whereNull('client_contacts.deleted_at')
->select(\DB::raw('COALESCE(NULLIF(first_name, ""), email) as contact_info'))
->limit(1);
}, 'first_contact_name')
->orderByRaw("COALESCE(NULLIF(clients.name, ''), first_contact_name) " . $dir);
// Use a raw subquery in the ORDER BY instead of adding it to SELECT
// This avoids conflicts with the Excludable trait
return $this->builder->orderByRaw("
COALESCE(
NULLIF(clients.name, ''),
(
SELECT COALESCE(NULLIF(first_name, ''), email)
FROM client_contacts
WHERE client_contacts.client_id = clients.id
AND client_contacts.deleted_at IS NULL
LIMIT 1
)
) " . $dir
);
}
if($sort_col[0] == 'contacts'){
return $this->builder->orderByRaw("
(
SELECT
CASE
WHEN first_name IS NOT NULL AND first_name != '' AND last_name IS NOT NULL AND last_name != ''
THEN CONCAT(first_name, ' ', last_name)
WHEN first_name IS NOT NULL AND first_name != ''
THEN first_name
WHEN last_name IS NOT NULL AND last_name != ''
THEN last_name
ELSE email
END
FROM client_contacts
WHERE client_contacts.client_id = clients.id
AND client_contacts.deleted_at IS NULL
ORDER BY
CASE
WHEN first_name IS NOT NULL AND first_name != '' AND last_name IS NOT NULL AND last_name != '' THEN 1
WHEN first_name IS NOT NULL AND first_name != '' THEN 2
WHEN last_name IS NOT NULL AND last_name != '' THEN 3
ELSE 4
END,
first_name ASC,
last_name ASC,
email ASC
LIMIT 1
) " . $dir
);
}
return $this->builder->orderBy($sort_col[0], $dir);
}

View File

@ -150,7 +150,8 @@ class CreditFilters extends QueryFilters
$dir = ($sort_col[1] == 'asc') ? 'asc' : 'desc';
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'credits.client_id'), $dir);
}

View File

@ -287,11 +287,20 @@ class InvoiceFilters extends QueryFilters
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'invoices.client_id'), $dir);
}
if ($sort_col[0] == 'project_id') {
return $this->builder->orderByRaw('ISNULL(project_id), project_id '. $dir)
->orderBy(\App\Models\Project::select('name')
->whereColumn('projects.id', 'invoices.project_id'), $dir);
}
if ($sort_col[0] == 'number') {
return $this->builder->orderByRaw("REGEXP_REPLACE(invoices.number,'[^0-9]+','')+0 " . $dir);
}

View File

@ -172,7 +172,8 @@ class PaymentFilters extends QueryFilters
$dir = ($sort_col[1] == 'asc') ? 'asc' : 'desc';
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'payments.client_id'), $dir);
}

View File

@ -68,7 +68,8 @@ class ProjectFilters extends QueryFilters
$dir = ($sort_col[1] == 'asc') ? 'asc' : 'desc';
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'projects.client_id'), $dir);
}

View File

@ -147,12 +147,12 @@ abstract class QueryFilters
return $this->builder->where(function ($query) use ($filters) {
if (in_array(self::STATUS_ACTIVE, $filters)) {
$query = $query->orWhereNull('deleted_at');
$query = $query->orWhereNull($this->builder->getModel()->getTable() . '.deleted_at');
}
if (in_array(self::STATUS_ARCHIVED, $filters)) {
$query = $query->orWhere(function ($q) {
$q->whereNotNull('deleted_at')->where('is_deleted', 0);
$q->whereNotNull($this->builder->getModel()->getTable() . '.deleted_at')->where('is_deleted', 0);
});
}

View File

@ -173,7 +173,8 @@ class QuoteFilters extends QueryFilters
}
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'quotes.client_id'), $dir);
}

View File

@ -141,7 +141,8 @@ class RecurringInvoiceFilters extends QueryFilters
$dir = ($sort_col[1] == 'asc') ? 'asc' : 'desc';
if ($sort_col[0] == 'client_id') {
return $this->builder->orderBy(\App\Models\Client::select('name')
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
->orderBy(\App\Models\Client::select('name')
->whereColumn('clients.id', 'recurring_invoices.client_id'), $dir);
}
@ -204,7 +205,13 @@ class RecurringInvoiceFilters extends QueryFilters
$parts = explode('|', $range);
if (!isset($parts[0]) || !isset($parts[1])) {
return $this->builder;
$parts = explode(',', $range);
if (!isset($parts[0]) || !isset($parts[1])){
return $this->builder;
}
}
if (is_numeric($parts[0])) {

View File

@ -36,6 +36,7 @@ class TaskFilters extends QueryFilters
return $this->builder->where(function ($query) use ($filter) {
$query->where('description', 'like', '%'.$filter.'%')
->orWhere("number", 'like', '%'.$filter.'%')
->orWhere('time_log', 'like', '%'.$filter.'%')
->orWhere('custom_value1', 'like', '%'.$filter.'%')
->orWhere('custom_value2', 'like', '%'.$filter.'%')

View File

@ -89,21 +89,44 @@ class NordigenClient
$allRequisitions = collect();
$offset = null;
$limit = 100;
$maxIterations = 1000; // Safety limit to prevent infinite loops
$iteration = 0;
do {
$iteration++;
// Safety check to prevent infinite loops
if ($iteration > $maxIterations) {
nlog("getAllRequisitions: Maximum iterations reached ({$maxIterations}), breaking to prevent infinite loop");
break;
}
$requisitions = $this->getRequisitions($limit, $offset);
nlog($requisitions);
if ($requisitions->isEmpty()) {
break;
}
$allRequisitions = $allRequisitions->merge($requisitions);
// Check if we got fewer results than requested (end of data)
if ($requisitions->count() < $limit) {
break;
}
// Use the last requisition's ID as the offset for cursor-based pagination
$lastRequisition = $requisitions->last();
$offset = $lastRequisition['id'] ?? null;
$newOffset = $lastRequisition['id'] ?? null;
// Check if we're making progress (offset is changing)
if ($newOffset === $offset) {
nlog("getAllRequisitions: Offset not changing, likely stuck in loop. Breaking.");
break;
}
$offset = $newOffset;
} while ($requisitions->count() === $limit && $offset);
} while ($offset);
return $allRequisitions;
}
@ -120,7 +143,7 @@ class NordigenClient
$params['offset'] = $offset;
}
$response = $this->httpClient->get("{$this->baseUrl}/agreements/", $params);
$response = $this->httpClient->get("{$this->baseUrl}/agreements/enduser", $params);
return $this->handlePaginatedResponse($response);
}
@ -130,7 +153,7 @@ class NordigenClient
*/
public function getAgreement(string $agreementId): ?array
{
$response = $this->httpClient->get("{$this->baseUrl}/agreements/{$agreementId}/");
$response = $this->httpClient->get("{$this->baseUrl}/agreements/enduser{$agreementId}/");
return $this->handleResponse($response);
}
@ -140,7 +163,7 @@ class NordigenClient
*/
public function createAgreement(array $data): ?array
{
$response = $this->httpClient->post("{$this->baseUrl}/agreements/", $data);
$response = $this->httpClient->post("{$this->baseUrl}/agreements/enduser", $data);
return $this->handleResponse($response);
}
@ -150,7 +173,7 @@ class NordigenClient
*/
public function updateAgreement(string $agreementId, array $data): ?array
{
$response = $this->httpClient->put("{$this->baseUrl}/agreements/{$agreementId}/", $data);
$response = $this->httpClient->put("{$this->baseUrl}/agreements/enduser/{$agreementId}/", $data);
return $this->handleResponse($response);
}
@ -160,7 +183,7 @@ class NordigenClient
*/
public function deleteAgreement(string $agreementId): bool
{
$response = $this->httpClient->delete("{$this->baseUrl}/agreements/{$agreementId}/");
$response = $this->httpClient->delete("{$this->baseUrl}/agreements/enduser/{$agreementId}/");
return $response->successful();
}
@ -850,6 +873,8 @@ class NordigenClient
return collect($data);
}
/**
* Handle single response
*/

View File

@ -177,18 +177,27 @@ class Nordigen
);
}
/**
* validAgreement
* @param string $institution_id
* @param array $_accounts
* @return array|null
* @todo - very expensive!
*/
public function validAgreement($institution_id, $_accounts)
{
$nc = new \App\Helpers\Bank\Nordigen\Http\NordigenClient($this->client->getAccessToken());
$requisitions = $nc->getAllRequisitions();
$requisitions->filter(function($requisition) use ($institution_id, $_accounts){
$requisition = $requisitions->filter(function($requisition) use ($institution_id, $_accounts){
if($requisition['institution_id'] == $institution_id && !empty(array_intersect($requisition['accounts'], $_accounts))){
return $requisition;
}
});
return $requisition->first()->toArray() ?? null;
}
@ -214,11 +223,11 @@ class Nordigen
$out->metadata = $this->client->account($account_id)->getAccountMetaData();
$out->institution = $this->client->institution->getInstitution($out->metadata['institution_id']);
if($out->metadata['status'] == 'READY'){
$out->data = $this->client->account($account_id)->getAccountDetails()['account'];
$out->balances = $this->client->account($account_id)->getAccountBalances()['balances'];
}
else{
// if($out->metadata['status'] == 'READY'){
// $out->data = $this->client->account($account_id)->getAccountDetails()['account'];
// $out->balances = $this->client->account($account_id)->getAccountBalances()['balances'];
// }
// else{
$out->data = [
'iban' => $out->metadata['iban'],
@ -233,7 +242,7 @@ class Nordigen
],
],
];
}
// }
$it = new AccountTransformer();
return $it->transform($out);

View File

@ -67,10 +67,16 @@ class EpcQrGenerator
public function encodeMessage()
{
$name = $this->company->present()->name();
if (isset($this->company->e_invoice->Invoice->PaymentMeans) && ($pm = $this->company->e_invoice->Invoice->PaymentMeans[0] ?? false) && in_array($pm->PaymentMeansCode->value, ['30', '58'])) {
$iban = $pm->PayeeFinancialAccount->ID->value;
$bic = $pm->PayeeFinancialAccount->FinancialInstitutionBranch->FinancialInstitution->ID->value ?? '';
if(isset($pm->PayeeFinancialAccount->Name) && strlen($pm->PayeeFinancialAccount->Name ?? '') > 0) {
$name = $pm->PayeeFinancialAccount->Name;
}
} else {
@ -85,7 +91,7 @@ class EpcQrGenerator
'1', // Encoding: 1 = UTF-8
'SCT', // Service Tag: SEPA Credit Transfer
$bic, // BIC
$this->company->present()->name(), // Name of the beneficiary
$name, // Recipient Name - Account Name
$iban, // IBAN
$this->formatMoney($this->amount), // Amount with EUR prefix
'', // Reference

View File

@ -222,6 +222,7 @@ class InvoiceItemSum
private function push(): self
{
$this->sub_total += round($this->getLineTotal(), $this->currency->precision);
$this->gross_sub_total += $this->getGrossLineTotal();
@ -295,6 +296,7 @@ class InvoiceItemSum
*/
private function calcTaxes()
{
if ($this->calc_tax) {
$this->calcTaxesAutomatically();
}
@ -368,24 +370,28 @@ class InvoiceItemSum
$tax_component = 0;
$amount = 0;
if ($this->invoice->custom_surcharge1) {
$tax_component += round($this->invoice->custom_surcharge1 * ($tax['percentage'] / 100), 2);
$amount += $this->invoice->custom_surcharge1;
}
if ($this->invoice->custom_surcharge2) {
$tax_component += round($this->invoice->custom_surcharge2 * ($tax['percentage'] / 100), 2);
$amount += $this->invoice->custom_surcharge2;
}
if ($this->invoice->custom_surcharge3) {
$tax_component += round($this->invoice->custom_surcharge3 * ($tax['percentage'] / 100), 2);
$amount += $this->invoice->custom_surcharge3;
}
if ($this->invoice->custom_surcharge4) {
$tax_component += round($this->invoice->custom_surcharge4 * ($tax['percentage'] / 100), 2);
$amount += $this->invoice->custom_surcharge4;
}
$amount = $this->invoice->custom_surcharge4 + $this->invoice->custom_surcharge3 + $this->invoice->custom_surcharge2 + $this->invoice->custom_surcharge1;
if ($tax_component > 0) {
$this->groupTax($tax['name'], $tax['percentage'], $tax_component, $amount, $tax['tax_id']);
}

View File

@ -28,12 +28,12 @@ class Office365MailTransport extends AbstractTransport
{
$symfony_message = MessageConverter::toEmail($message->getOriginalMessage()); //@phpstan-ignore-line
$graph = new Graph();
/** @phpstan-ignore-next-line **/
$token = $symfony_message->getHeaders()->get('gmailtoken')->getValue();
$symfony_message->getHeaders()->remove('gmailtoken');
// $symfony_message->getHeaders()->remove('gmailtoken');
$message->getOriginalMessage()->getHeaders()->remove('gmailtoken');
$graph->setAccessToken($token);

View File

@ -86,8 +86,8 @@ class AccountController extends BaseController
}
if ($request->has('hash') && config('ninja.cloudflare.turnstile.secret')) { //@todo once all platforms are implemented, we disable access to the rest of this route without a success response.
if ($request->has('hash') && config('ninja.cloudflare.turnstile.secret')) {
if (Secure::decrypt($request->input('hash')) !== $request->input('email')) {
return response()->json(['message' => 'Invalid Signup Payload'], 400);
}

View File

@ -135,13 +135,13 @@ class LoginController extends BaseController
if (strlen($request->input('one_time_password')) == 0 || !$google2fa->verifyKey(decrypt($user->google_2fa_secret), $request->input('one_time_password'))) {
return response()
->json(['message' => ctrans('texts.invalid_one_time_password')], 401)
->json(['message' => ctrans('texts.invalid_one_time_password')], 422)
->header('X-App-Version', config('ninja.app_version'))
->header('X-Api-Version', config('ninja.minimum_client_version'));
}
} elseif (strlen($user->google_2fa_secret ?? '') > 2 && !$request->has('one_time_password')) {
return response()
->json(['message' => ctrans('texts.invalid_one_time_password')], 401)
->json(['message' => ctrans('texts.invalid_one_time_password')], 422)
->header('X-App-Version', config('ninja.app_version'))
->header('X-Api-Version', config('ninja.minimum_client_version'));
}
@ -378,8 +378,8 @@ class LoginController extends BaseController
$account_user = $account->default_company->owner();
Auth::login($account_user, false);
$account_user->email_verified_at = now();
$account_user->save();
// $account_user->email_verified_at = now();
// $account_user->save();
/** @var \App\Models\CompanyUser $cu */
$cu = $this->hydrateCompanyUser($account_user);
@ -630,8 +630,8 @@ class LoginController extends BaseController
}
$user = $account->default_company->owner();
$user->email_verified_at = now();
$user->save();
// $user->email_verified_at = now();
// $user->save();
Auth::login($user, false);

View File

@ -84,6 +84,24 @@ class ResetPasswordController extends Controller
*/
public function reset(Request $request)
{
// Safely decode URL-encoded token and email before validation
if ($request->has('token')) {
$token = $request->input('token');
// Only decode if it contains URL encoding characters
if (strpos($token, '%') !== false) {
$request->merge(['token' => urldecode($token)]);
}
}
if ($request->has('email')) {
$email = $request->input('email');
// Only decode if it contains URL encoding characters
if (strpos($email, '%') !== false) {
$request->merge(['email' => urldecode($email)]);
}
}
$request->validate($this->rules(), $this->validationErrorMessages());
// Here we will attempt to reset the user's password. If it is successful we

View File

@ -82,15 +82,10 @@ class NordigenController extends BaseController
$agreement = $nordigen->createAgreement($institution, $institution['max_access_valid_for_days'], $txDays);//@2025-07-01: this is the correct way to get the access days
// $agreement = $nordigen->createAgreement($institution, $data['access_days'] ?? 9999, $txDays);
//this does not work in a multi tenant environment, it simply grabs the first agreement, without differentiating between companies. we may need to store the current requistion...
// $agreement = $nordigen->firstValidAgreement($institution['id'], $data['access_days'] ?? 0, $txDays)
// ?? $nordigen->createAgreement($institution, $data['max_access_valid_for_days'] ?? 90, $txDays);
} catch (\Exception $e) {
$debug = "{$e->getMessage()} ({$e->getCode()})";
nlog("Nordigen: Could not create an agreement with ${institution['name']}: {$debug}");
nlog("Nordigen: Could not create an agreement with {$institution['name']}: {$debug}");
return $this->failed('eua-failure', $context, $company);
}
@ -224,7 +219,7 @@ class NordigenController extends BaseController
->where('integration_type', BankIntegration::INTEGRATION_TYPE_NORDIGEN)
->where('auto_sync', true)
->each(function ($bank_integration) {
ProcessBankTransactionsNordigen::dispatch($bank_integration);
ProcessBankTransactionsNordigen::dispatch($bank_integration)->delay(now()->addHour());
});
// prevent rerun of this method with same ref

View File

@ -77,7 +77,13 @@ class YodleeController extends BaseController
$accounts = $yodlee->getAccounts();
foreach ($accounts as $account) {
if (!BankIntegration::where('bank_account_id', $account['id'])->where('company_id', $company->id)->exists()) {
if ($bi = BankIntegration::where('bank_account_id', $account['id'])->where('company_id', $company->id)->first()) {
$bi->disabled_upstream = false;
$bi->balance = $account['current_balance'];
$bi->currency = $account['account_currency'];
$bi->integration_type = BankIntegration::INTEGRATION_TYPE_YODLEE;
$bi->save();
} else {
$bank_integration = new BankIntegration();
$bank_integration->company_id = $company->id;
$bank_integration->account_id = $company->account_id;

View File

@ -653,10 +653,7 @@ class BaseController extends Controller
$resource = new Collection($query, $transformer, $this->entity_type);
$resource->setPaginator(new IlluminatePaginatorAdapter($paginator));
}
// else {
// $resource = new Collection($query, $transformer, $this->entity_type);
// }
return $this->response($this->manager->createData($resource)->toArray());
}
@ -671,7 +668,22 @@ class BaseController extends Controller
/** @var \App\Models\User $user */
$user = auth()->user();
if ($user->getCompany()->is_large) {
/** React does not require bloated login response. */
if(request()->hasHeader('X-React')){
$this->manager->parseIncludes(
[
'account',
'user.company_user',
'token',
'company',
]);
// Set created_at to current time to filter out all existing related records
// (designs, documents, groups, etc.) for a minimal response payload
request()->merge(['created_at' => time()]);
return $this->miniLoadResponse($query);
}
elseif ($user->getCompany()->is_large) {
$this->manager->parseIncludes($this->mini_load);
return $this->miniLoadResponse($query);
@ -1089,7 +1101,7 @@ class BaseController extends Controller
$data = $this->first_load;
}
} else {
$included = request()->input('include', '');
$included = request()->input('include') ?? '';
$included = explode(',', $included);
foreach ($included as $include) {

View File

@ -36,7 +36,7 @@ class CreditController extends Controller
$data = [
'credit' => $credit,
'key' => $invitation ? $invitation->key : false,
'_key' => $invitation ? $invitation->key : false,
'invitation' => $invitation
];

View File

@ -26,9 +26,16 @@ class EmailPreferencesController extends Controller
{
public function index(string $entity, string $invitation_key, Request $request): \Illuminate\View\View
{
request()->session()->invalidate();
request()->session()->regenerate(true);
request()->session()->regenerateToken();
$class = "\\App\\Models\\".ucfirst(Str::camel($entity)).'Invitation';
$invitation = $class::where('key', $invitation_key)->firstOrFail();
auth()->guard('contact')->loginUsingId($invitation->contact->id, true);
$data['receive_emails'] = $invitation->contact->is_locked ? false : true;
$data['company'] = $invitation->company;

View File

@ -76,7 +76,7 @@ class InvoiceController extends Controller
$data = [
'invoice' => $invoice->service()->removeUnpaidGatewayFees()->save(),
'invitation' => $invitation ?: $invoice->invitations->first(),
'key' => $invitation ? $invitation->key : false,
'_key' => $invitation ? $invitation->key : false,
'hash' => $hash,
'variables' => $variables,
'invoices' => [$invoice->hashed_id],
@ -99,7 +99,7 @@ class InvoiceController extends Controller
{
$data = Cache::get($hash);
for ($x = 0; $x < 3; $x++) {
for ($x = 0; $x < 18; $x++) {
$data = Cache::get($hash);
@ -107,7 +107,7 @@ class InvoiceController extends Controller
break;
}
usleep(300000);
usleep(100000);
}
@ -131,7 +131,7 @@ class InvoiceController extends Controller
$file = (new \App\Jobs\Entity\CreateRawPdf($invitation))->handle();
$headers = ['Content-Type' => 'application/pdf'];
$headers = ['Content-Type' => 'application/pdf', 'Content-Disposition' => 'inline'];
return response()->make($file, 200, $headers);
}

View File

@ -59,7 +59,7 @@ class QuoteController extends Controller
$data = [
'quote' => $quote,
'key' => $invitation ? $invitation->key : false,
'_key' => $invitation ? $invitation->key : false,
'invitation' => $invitation,
'variables' => $variables,
];

View File

@ -552,7 +552,23 @@ class CompanyController extends BaseController
});
try {
Storage::disk(config('filesystems.default'))->deleteDirectory($company->company_key);
if(Ninja::isHosted()){
try{
Storage::disk('s3')->deleteDirectory($company->company_key);
}
catch(\Throwable $th){}
try{
Storage::disk('backup')->deleteDirectory($company->company_key);
}
catch(\Throwable $th){}
}
else {
Storage::disk(config('filesystems.default'))->deleteDirectory($company->company_key);
}
} catch (\Exception $e) {
}

View File

@ -111,7 +111,7 @@ class ConnectedAccountController extends BaseController
nlog("microsoft");
nlog($email);
if (auth()->user()->email != $email && MultiDB::checkUserEmailExists($email)) {
if (strtolower(auth()->user()->email) != strtolower($email) && MultiDB::checkUserEmailExists(strtolower($email))) {
return response()->json(['message' => ctrans('texts.email_already_register')], 400);
}

View File

@ -212,24 +212,38 @@ class CreditController extends BaseController
->triggeredActions($request)
->save();
/** 2025-09-24
*
* Handling invoice reversals needs stricter boundary checks:
*
* On the reversal of a paid invoice creates a credit note. However if this credit note is deleted the original payment becomes a dangling record.
*
* In order to avoid this, we link the payment to the credit note. This allows us to preserve the relation of the payment to the subsequent credit note.
*
* Now on Credit or Payment deletion we can correctly maintain the relation of the payment to the credit note.
*/
if ($credit->invoice_id) {
$credit = $credit->service()->markSent()->save();
// $credit->client->service()->updatePaidToDate(-1 * $credit->balance)->save(); // If we mutate the paid to date, we need to reverse the status of the invoice, this will allow the credit note that has been created to be used and double paid to dates prevented.
$credit->client->service()->updateBalanceAndPaidToDate(-1 * ($credit->invoice->balance ?? 0), -1 * $credit->balance)->save();
// $invoice = $credit->invoice;
$invoice = \App\Models\Invoice::withTrashed()->find($credit->invoice_id);
if ($invoice) {
$invoice->status_id = Invoice::STATUS_REVERSED;
$invoice->save();
//2025-08-25 after convert to a credit note, we need to delete the payments associated with the invoice.
$invoice->payments()->each(function ($p) {
$p->pivot->forceDelete();
$p->invoices()->each(function ($i) {
$i->pivot->forceDelete();
});
});
//2025-09-25 this logic is flawed as unlinking the invoice then prevents a valid refund from taking place.
// $invoice->payments()->each(function ($p) use ($credit) {
// // $p->pivot->forceDelete();
// $p->invoices()->each(function ($i) use ($credit) {
// // $i->pivot->forceDelete();
// $pivot = $i->pivot;
// $pivot->paymentable_id = $credit->id;
// $pivot->paymentable_type = Credit::class;
// $pivot->save();
// });
// });
}

View File

@ -42,10 +42,10 @@ class EInvoiceController extends BaseController
*/
public function validateEntity(ValidateEInvoiceRequest $request)
{
$el = new EntityLevel();
$el = $request->getValidatorClass();
$data = [];
match ($request->entity) {
'invoices' => $data = $el->checkInvoice($request->getEntity()),
'clients' => $data = $el->checkClient($request->getEntity()),
@ -132,9 +132,8 @@ class EInvoiceController extends BaseController
public function quota(ShowQuotaRequest $request): JsonResponse
{
nlog(["quota" => $request->all()]);
/**
* @var \App\Models\Company
*/
/** @var \App\Models\Company $company */
$company = auth()->user()->company();
$response = \Illuminate\Support\Facades\Http::baseUrl(config('ninja.hosted_ninja_url'))

View File

@ -12,17 +12,18 @@
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use App\Http\Requests\EInvoice\Peppol\StoreEntityRequest;
use Illuminate\Http\JsonResponse;
use App\Services\EDocument\Jobs\SendEDocument;
use App\Http\Requests\EInvoice\Peppol\RetrySendRequest;
use App\Services\EDocument\Gateway\Storecove\Storecove;
use App\Http\Requests\EInvoice\Peppol\DisconnectRequest;
use App\Http\Requests\EInvoice\Peppol\ShowEntityRequest;
use App\Http\Requests\EInvoice\Peppol\StoreEntityRequest;
use App\Http\Requests\EInvoice\Peppol\UpdateEntityRequest;
use App\Services\EDocument\Standards\Verifactu\SendToAeat;
use App\Http\Requests\EInvoice\Peppol\AddTaxIdentifierRequest;
use App\Http\Requests\EInvoice\Peppol\RemoveTaxIdentifierRequest;
use App\Http\Requests\EInvoice\Peppol\RetrySendRequest;
use App\Http\Requests\EInvoice\Peppol\ShowEntityRequest;
use App\Http\Requests\EInvoice\Peppol\UpdateEntityRequest;
use App\Services\EDocument\Jobs\SendEDocument;
class EInvoicePeppolController extends BaseController
{
@ -266,8 +267,12 @@ class EInvoicePeppolController extends BaseController
public function retrySend(RetrySendRequest $request)
{
SendEDocument::dispatch($request->entity, $request->entity_id, auth()->user()->company()->db);
if(auth()->user()->company()->verifactuEnabled()) {
SendToAeat::dispatch($request->entity_id, auth()->user()->company(), 'create');
}
else {
SendEDocument::dispatch($request->entity, $request->entity_id, auth()->user()->company()->db);
}
return response()->json(['message' => 'trying....'], 200);
}

View File

@ -72,6 +72,13 @@ class EmailController extends BaseController
$user = auth()->user();
$company = $entity_obj->company;
/** Force AEAT Submission */
if($company->verifactuEnabled() && ($entity_obj instanceof Invoice) && $entity_obj->backup->guid == "") {
$entity_obj->invitations()->update(['email_error' => 'primed']); // Flag the invitations as primed for AEAT submission
$entity_obj->service()->markSent()->sendVerifactu();
return $this->itemResponse($entity_obj->fresh());
}
if ($request->cc_email && (Ninja::isSelfHost() || $user->account->isPremium())) {
foreach ($request->cc_email as $email) {
@ -80,19 +87,27 @@ class EmailController extends BaseController
}
$entity_obj->invitations->each(function ($invitation) use ($entity_obj, $mo, $template) {
if (! $invitation->contact->trashed() && $invitation->contact->email && !$invitation->contact->is_locked) {
$entity_obj->invitations()
->whereHas('contact', function($query) {
$query->where(function ($sq){
$sq->whereNotNull('email')
->orWhere('email', '!=', '');
})->where('is_locked', false)
->withoutTrashed();
})
->each(function ($invitation) use ($entity_obj, $mo, $template) {
$entity_obj->service()->markSent()->save();
$mo->invitation_id = $invitation->id;
$mo->client_id = $invitation->contact->client_id ?? null;
$mo->vendor_id = $invitation->contact->vendor_id ?? null;
$mo->invitation_id = $invitation->id;
$mo->client_id = $invitation->contact->client_id ?? null;
$mo->vendor_id = $invitation->contact->vendor_id ?? null;
Email::dispatch($mo, $invitation->company);
$entity_obj->entityEmailEvent($invitation, $template, $template);
Email::dispatch($mo, $invitation->company);
$entity_obj->entityEmailEvent($invitation, $template, $template);
}
});
});
$entity_obj = $entity_obj->fresh();
$entity_obj->last_sent_date = now();

View File

@ -12,30 +12,31 @@
namespace App\Http\Controllers;
use App\Events\Expense\ExpenseWasCreated;
use App\Events\Expense\ExpenseWasUpdated;
use App\Factory\ExpenseFactory;
use App\Filters\ExpenseFilters;
use App\Http\Requests\Expense\BulkExpenseRequest;
use App\Http\Requests\Expense\CreateExpenseRequest;
use App\Http\Requests\Expense\DestroyExpenseRequest;
use App\Http\Requests\Expense\EditExpenseRequest;
use App\Http\Requests\Expense\EDocumentRequest;
use App\Http\Requests\Expense\ShowExpenseRequest;
use App\Http\Requests\Expense\StoreExpenseRequest;
use App\Http\Requests\Expense\UpdateExpenseRequest;
use App\Http\Requests\Expense\UploadExpenseRequest;
use App\Jobs\EDocument\ImportEDocument;
use App\Utils\Ninja;
use App\Models\Account;
use App\Models\Expense;
use Illuminate\Http\Response;
use App\Factory\ExpenseFactory;
use App\Filters\ExpenseFilters;
use App\Utils\Traits\MakesHash;
use App\Utils\Traits\Uploadable;
use App\Utils\Traits\BulkOptions;
use App\Utils\Traits\SavesDocuments;
use App\Jobs\EDocument\ImportEDocument;
use App\Repositories\ExpenseRepository;
use App\Transformers\ExpenseTransformer;
use App\Utils\Ninja;
use App\Utils\Traits\BulkOptions;
use App\Utils\Traits\MakesHash;
use App\Utils\Traits\SavesDocuments;
use App\Utils\Traits\Uploadable;
use Illuminate\Http\Response;
use App\Events\Expense\ExpenseWasCreated;
use App\Events\Expense\ExpenseWasUpdated;
use App\Services\Template\TemplateAction;
use App\Http\Requests\Expense\EDocumentRequest;
use App\Http\Requests\Expense\BulkExpenseRequest;
use App\Http\Requests\Expense\EditExpenseRequest;
use App\Http\Requests\Expense\ShowExpenseRequest;
use App\Http\Requests\Expense\StoreExpenseRequest;
use App\Http\Requests\Expense\CreateExpenseRequest;
use App\Http\Requests\Expense\UpdateExpenseRequest;
use App\Http\Requests\Expense\UploadExpenseRequest;
use App\Http\Requests\Expense\DestroyExpenseRequest;
/**
* Class ExpenseController.
@ -329,6 +330,7 @@ class ExpenseController extends BaseController
$user = auth()->user();
$expense = ExpenseFactory::create($user->company()->id, $user->id);
$expense->date = now()->addSeconds($user->company()->utc_offset())->format('Y-m-d');
return $this->itemResponse($expense);
}
@ -498,6 +500,25 @@ class ExpenseController extends BaseController
$expenses = Expense::withTrashed()->find($request->ids);
if ($request->action == 'template' && $user->can('view', $expenses->first())) {
$hash_or_response = $request->boolean('send_email') ? 'email sent' : \Illuminate\Support\Str::uuid();
TemplateAction::dispatch(
$expenses->pluck('hashed_id')->toArray(),
$request->template_id,
Expense::class,
$user->id,
$user->company(),
$user->company()->db,
$hash_or_response,
$request->boolean('send_email')
);
return response()->json(['message' => $hash_or_response], 200);
}
if ($request->action == 'bulk_update' && $user->can('edit', $expenses->first())) {
$expenses = Expense::withTrashed()

View File

@ -0,0 +1,37 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers;
use App\Utils\Ninja;
use Illuminate\Http\Request;
use Turbo124\Beacon\Facades\LightLogs;
use App\DataMapper\Analytics\FeedbackCreated;
class FeedbackController extends Controller
{
public function __invoke(Request $request)
{
if(Ninja::isHosted()){
$user = auth()->user();
$company = $user->company();
$rating = $request->input('rating', 0);
$notes = $request->input('notes', '');
LightLogs::create(new FeedbackCreated($rating, $notes, $company->company_key, $company->account->key, $user->present()->name()))->batch();
}
return response()->noContent();
}
}

View File

@ -1,58 +0,0 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers\Gateways;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request; // Import the Request class
use Illuminate\Support\Facades\Http; // Import the Http facade
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
class BlockonomicsController extends Controller
{
public function getBTCPrice(Request $request)
{
$currency = $request->query('currency');
$response = Http::get("https://www.blockonomics.co/api/price?currency={$currency}");
if ($response->successful()) {
return response()->json(['price' => $response->json('price')]);
}
return response()->json(['error' => 'Unable to fetch BTC price'], 500);
}
public function getQRCode(Request $request)
{
$qr_string = $request->query('qr_string');
$svg = $this->getPaymentQrCodeRaw($qr_string);
return response($svg)->header('Content-Type', 'image/svg+xml');
}
private function getPaymentQrCodeRaw($qr_string)
{
$renderer = new ImageRenderer(
new RendererStyle(150, margin: 0),
new SvgImageBackEnd()
);
$writer = new Writer($renderer);
$qr = $writer->writeString($qr_string, 'utf-8');
return $qr;
}
}

View File

@ -443,11 +443,10 @@ class ImportController extends Controller
private function getCsvData($csvfile)
{
if (! ini_get('auto_detect_line_endings')) {
ini_set('auto_detect_line_endings', '1');
}
$csv = Reader::createFromString($csvfile);
$csv = Reader::fromString($csvfile);
// $csv = Reader::createFromString($csvfile);
$csvdelimiter = self::detectDelimiter($csvfile);
$csv->setDelimiter($csvdelimiter);
$stmt = new Statement();

View File

@ -241,6 +241,8 @@ class InvoiceController extends BaseController
event(new InvoiceWasCreated($invoice, $invoice->company, Ninja::eventVars($user ? $user->id : null)));
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return $this->itemResponse($invoice);
}
@ -495,24 +497,29 @@ class InvoiceController extends BaseController
$ids = $request->input('ids');
if (Ninja::isHosted() && (stripos($action, 'email') !== false) && !$user->company()->account->account_sms_verified) {
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return response(['message' => 'Please verify your account to send emails.'], 400);
}
if (Ninja::isHosted() && $user->account->emailQuotaExceeded()) {
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return response(['message' => ctrans('texts.email_quota_exceeded_subject')], 400);
}
if ($user->hasExactPermission('disable_emails') && (stripos($action, 'email') !== false)) {
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return response(['message' => ctrans('texts.disable_emails_error')], 400);
}
if (in_array($request->action, ['auto_bill', 'mark_paid']) && $user->cannot('create', \App\Models\Payment::class)) {
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return response(['message' => ctrans('texts.not_authorized'), 'errors' => ['ids' => [ctrans('texts.not_authorized')]]], 422);
}
$invoices = Invoice::withTrashed()->whereIn('id', $this->transformKeys($ids))->company()->get();
if ($invoices->count() == 0) {
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return response()->json(['message' => 'No Invoices Found']);
}
@ -521,13 +528,15 @@ class InvoiceController extends BaseController
*/
if ($action == 'bulk_download' && $invoices->count() > 1) {
$invoices->each(function ($invoice) use ($user) {
$invoices->each(function ($invoice) use ($user, $request) {
if ($user->cannot('view', $invoice)) {
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return response()->json(['message' => ctrans('text.access_denied')]);
}
});
ZipInvoices::dispatch($invoices->pluck('id'), $invoices->first()->company, auth()->user());
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return response()->json(['message' => ctrans('texts.sent_message')], 200);
}
@ -536,6 +545,8 @@ class InvoiceController extends BaseController
$filename = $invoices->first()->getFileName();
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return response()->streamDownload(function () use ($invoices) {
echo $invoices->first()->service()->getInvoicePdf();
}, $filename, ['Content-Type' => 'application/pdf']);
@ -563,6 +574,7 @@ class InvoiceController extends BaseController
})->toArray();
$mergedPdf = (new PdfMerge($paths))->run();
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return response()->streamDownload(function () use ($mergedPdf) {
echo $mergedPdf;
@ -588,6 +600,8 @@ class InvoiceController extends BaseController
$request->boolean('send_email')
);
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return response()->json(['message' => $hash_or_response], 200);
}
@ -599,19 +613,21 @@ class InvoiceController extends BaseController
}
});
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return $this->listResponse(Invoice::withTrashed()->whereIn('id', $this->transformKeys($ids))->company());
}
if (in_array($action, ['email','send_email'])) {
$invoice = $invoices->first();
$invoices->filter(function ($invoice) use ($user) {
return $user->can('edit', $invoice);
})->each(function ($invoice) use ($user, $request) {
$invoice->service()->sendEmail(email_type: $request->input('email_type', $invoice->calculateTemplate('invoice')));
$invoice->service()->markSent()->sendEmail(email_type: $request->input('email_type', $invoice->calculateTemplate('invoice')));
});
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return $this->listResponse(Invoice::withTrashed()->whereIn('id', $this->transformKeys($ids))->company());
}
@ -625,6 +641,7 @@ class InvoiceController extends BaseController
});
/* Need to understand which permission are required for the given bulk action ie. view / edit */
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return $this->listResponse(Invoice::withTrashed()->whereIn('id', $this->transformKeys($ids))->company());
}
@ -779,7 +796,7 @@ class InvoiceController extends BaseController
}
break;
case 'cancel':
$invoice = $invoice->service()->handleCancellation()->save();
$invoice = $invoice->service()->handleCancellation(request()->input('reason'))->save();
if (! $bulk) {
$this->itemResponse($invoice);
}

View File

@ -214,6 +214,8 @@ class PaymentController extends BaseController
event('eloquent.created: App\Models\Payment', $payment);
\Illuminate\Support\Facades\Cache::forget($request->lock_key);
return $this->itemResponse($payment);
}

View File

@ -561,7 +561,8 @@ class QuoteController extends BaseController
$quotes->each(function ($quote, $key) use ($user) {
if ($user->can('edit', $quote) && $quote->service()->isConvertable()) {
$quote->service()->convertToInvoice();
// $quote->service()->convertToInvoice();
$quote->service()->convert()->save();
}
});

View File

@ -34,20 +34,21 @@ class ReportExportController extends BaseController
return response()->json(['message' => 'Still working.....'], 409);
}
// $report = base64_decode($report);
$report = base64_decode($report);
Cache::forget($hash);
// Cache::forget($hash);
if($this->isXlsxData($report)){
nlog("isXlsxData");
return response($report, 200, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition' => 'inline; filename="report.xlsx"',
'Content-Length' => strlen($report)
]);
// if($this->isXlsxData($report)){
}
nlog(" IS NOT");
// return response($report, 200, [
// 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
// 'Content-Disposition' => 'inline; filename="report.xlsx"',
// 'Content-Length' => strlen($report)
// ]);
// }
// Check if the content starts with PDF signature (%PDF-)
$isPdf = str_starts_with(trim($report), '%PDF-');
@ -72,7 +73,7 @@ class ReportExportController extends BaseController
// private function isXlsxData($fileData)
// {
// // Check minimum size (XLSX files are typically > 1KB)
// if (strlen($fileData) < 1024) {
// if (strlen($fileData ?? '') < 1024) {
// return false;
// }
@ -86,4 +87,13 @@ class ReportExportController extends BaseController
// return strpos($fileData, '[Content_Types].xml') !== false;
// }
function isXlsxData(string $blob): bool {
// nlog(bin2hex(substr($blob, 0, 4)));
nlog("504b0304" === bin2hex(substr($blob, 0, 4)));
return "504b0304" === bin2hex(substr($blob, 0, 4));
}
}

View File

@ -30,8 +30,6 @@ class ReportPreviewController extends BaseController
{
$report = Cache::get($hash);
nlog($report);
if (!$report) {
return response()->json(['message' => 'Still working.....'], 409);

View File

@ -302,8 +302,6 @@ class SNSController extends BaseController
OPENSSL_ALGO_SHA1
);
openssl_free_key($publicKey);
if ($verificationResult === 1) {
nlog('SNS: Signature verification successful');
return true;
@ -401,13 +399,19 @@ class SNSController extends BaseController
*/
private function handleSubscriptionConfirmation(array $snsData)
{
$subscribeUrl = $snsData['SubscribeURL'] ?? null;
// Verify the subscription confirmation payload
$verificationResult = $this->verifySubscriptionConfirmationPayload($snsData);
if (!$subscribeUrl) {
nlog('SNS Subscription confirmation: Missing SubscribeURL');
return response()->json(['error' => 'Missing SubscribeURL'], 400);
if (!$verificationResult['valid']) {
nlog('SNS Subscription confirmation: Payload verification failed', [
'errors' => $verificationResult['errors'],
'payload' => $snsData
]);
return response()->json(['error' => 'Invalid subscription confirmation payload', 'details' => $verificationResult['errors']], 400);
}
$subscribeUrl = $snsData['SubscribeURL'];
nlog('SNS Subscription confirmation received', [
'topic_arn' => $snsData['TopicArn'] ?? 'unknown',
'subscribe_url' => $subscribeUrl
@ -416,7 +420,7 @@ class SNSController extends BaseController
// You can optionally make an HTTP request to confirm the subscription
// This is required by AWS to complete the SNS subscription setup
try {
$response = file_get_contents($subscribeUrl);
$response = \Illuminate\Support\Facades\Http::timeout(10)->get($subscribeUrl);
nlog('SNS Subscription confirmed', ['response' => $response]);
} catch (\Exception $e) {
nlog('SNS Subscription confirmation failed', ['error' => $e->getMessage()]);
@ -425,6 +429,133 @@ class SNSController extends BaseController
return response()->json(['status' => 'subscription_confirmed']);
}
/**
* Verify SNS subscription confirmation payload structure and content
*
* @param array $snsData
* @return array ['valid' => bool, 'errors' => array]
*/
private function verifySubscriptionConfirmationPayload(array $snsData): array
{
$errors = [];
// Required fields for subscription confirmation
$requiredFields = [
'Type' => 'SubscriptionConfirmation',
'MessageId' => 'string',
'TopicArn' => 'string',
'SubscribeURL' => 'string',
'Timestamp' => 'string',
'Token' => 'string'
];
// Validate required fields exist and have correct types
foreach ($requiredFields as $field => $expectedType) {
if (!isset($snsData[$field])) {
$errors[] = "Missing required field: {$field}";
continue;
}
$value = $snsData[$field];
// Type-specific validation
if ($expectedType === 'string' && !is_string($value)) {
$errors[] = "Field '{$field}' must be a string";
} elseif ($expectedType === 'SubscriptionConfirmation' && $value !== 'SubscriptionConfirmation') {
$errors[] = "Field '{$field}' must be 'SubscriptionConfirmation'";
}
}
// Validate specific field formats
if (isset($snsData['MessageId']) && !$this->isValidMessageId($snsData['MessageId'])) {
$errors[] = 'Invalid MessageId format';
}
if (isset($snsData['TopicArn']) && !$this->isValidTopicArn($snsData['TopicArn'])) {
$errors[] = 'Invalid TopicArn format';
}
if (isset($snsData['SubscribeURL']) && !$this->isValidSubscribeUrl($snsData['SubscribeURL'])) {
$errors[] = 'Invalid SubscribeURL format or domain';
}
if (isset($snsData['Timestamp']) && !$this->isValidISOTimestamp($snsData['Timestamp'])) {
$errors[] = 'Invalid Timestamp format (must be ISO 8601)';
}
if (isset($snsData['Token']) && !$this->isValidSubscriptionToken($snsData['Token'])) {
$errors[] = 'Invalid Token format';
}
// Validate TopicArn matches expected if configured
if (isset($snsData['TopicArn']) && !empty($this->expectedTopicArn)) {
if ($snsData['TopicArn'] !== $this->expectedTopicArn) {
$errors[] = 'TopicArn does not match expected value';
}
}
// Check for replay attacks (messages older than 15 minutes)
if (isset($snsData['Timestamp'])) {
$messageTimestamp = strtotime($snsData['Timestamp']);
$currentTimestamp = time();
if (($currentTimestamp - $messageTimestamp) > 900) { // 15 minutes
$errors[] = 'Message timestamp is too old (potential replay attack)';
}
}
// Check for suspicious content patterns
if ($this->containsSuspiciousContent($snsData)) {
$errors[] = 'Payload contains suspicious content patterns';
}
return [
'valid' => empty($errors),
'errors' => $errors
];
}
/**
* Validate MessageId format (should be a UUID-like string)
*
* @param string $messageId
* @return bool
*/
private function isValidMessageId(string $messageId): bool
{
// AWS SNS MessageId is typically a UUID format
return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $messageId) === 1;
}
/**
* Validate TopicArn format
*
* @param string $topicArn
* @return bool
*/
private function isValidTopicArn(string $topicArn): bool
{
// AWS SNS Topic ARN format: arn:aws:sns:region:account-id:topic-name
return preg_match('/^arn:aws:sns:[a-z0-9-]+:[0-9]{12}:[a-zA-Z0-9_-]+$/', $topicArn) === 1;
}
/**
* Validate subscription token format
*
* @param string $token
* @return bool
*/
private function isValidSubscriptionToken(string $token): bool
{
// AWS SNS subscription tokens are typically long alphanumeric strings
if (strlen($token) < 20 || strlen($token) > 200) {
return false;
}
// Should contain only alphanumeric characters and common symbols
return preg_match('/^[a-zA-Z0-9+\/=\-_]+$/', $token) === 1;
}
/**
* Handle SES notification from SNS
*

View File

@ -89,8 +89,8 @@ class SearchController extends Controller
$params = [
// 'index' => 'clients,invoices,client_contacts',
// 'index' => 'clients,invoices,client_contacts,quotes,expenses,credits,recurring_invoices,vendors,vendor_contacts,purchase_orders,projects',
'index' => 'clients_v2,invoices_v2,client_contacts_v2,quotes_v2,expenses_v2,credits_v2,recurring_invoices_v2,vendors_v2,vendor_contacts_v2,purchase_orders_v2,projects_v2,tasks_v2',
'index' => 'clients,invoices,client_contacts,quotes,expenses_v2,credits,recurring_invoices,vendors,vendor_contacts,purchase_orders,projects',
// 'index' => 'clients_v2,invoices_v2,client_contacts_v2,quotes_v2,expenses_v2,credits_v2,recurring_invoices_v2,vendors_v2,vendor_contacts_v2,purchase_orders_v2,projects_v2,tasks_v2',
'body' => [
'query' => [
'bool' => [
@ -139,7 +139,7 @@ class SearchController extends Controller
$results = $elastic->search($params);
nlog($results['hits']);
// nlog($results['hits']);
$this->mapResults($results['hits']['hits'] ?? []);

View File

@ -50,7 +50,7 @@ class SelfUpdateController extends BaseController
set_time_limit(0);
define('STDIN', fopen('php://stdin', 'r'));
if (Ninja::isHosted()) {
if (Ninja::isHosted() || config('ninja.disable_auto_update')) {
return response()->json(['message' => ctrans('texts.self_update_not_available')], 403);
}

View File

@ -276,12 +276,32 @@ class TaskController extends BaseController
$old_task_status_order = $task->status_order;
$request_data = $request->all();
//2025-07-31 - if the start or stop query parameter is not present, then we need to save the task
if (!($request->query('start', false) || $request->query('stop', false))) {
$task = $this->task_repo->save($request->all(), $task);
$task = $this->task_repo->save($request_data, $task);
}
else {
$task = $this->task_repo->triggeredActions($request, $task);
/*
*
* 2025-10-30 - if the start or stop query parameter is present,
* then we need to trigger the actions and save the task
* but we need to remove the time_log from the request data
* because it will be updated by the triggeredActions method.
*
* Handles the scenario where the description is updated and then start/stop is pressed
*/
$task = $this->task_repo->triggeredActions($request, $task);
if(isset($request_data['time_log'])) {
unset($request_data['time_log']);
}
$task = $this->task_repo->save($request_data, $task);
}
if (is_null($task->status_order) || $task->status_order != $old_task_status_order) {
$this->task_repo->sortStatuses($task);
@ -512,7 +532,19 @@ class TaskController extends BaseController
$ids = $request->input('ids');
$tasks = Task::withTrashed()->whereIn('id', $this->transformKeys($ids))->company()->get();
$tasks = Task::withTrashed()->whereIn('id', $this->transformKeys($ids))->company();
$_tasks = (clone $tasks);
if ($request->action == 'bulk_update' && $user->can('edit', $_tasks->first())) {
$this->task_repo->bulkUpdate($tasks, $request->column, $request->new_value);
return $this->listResponse(Task::withTrashed()->whereIn('id', $this->transformKeys($ids))->company());
}
$tasks = $tasks->get();
if ($action == 'template' && $user->can('view', $tasks->first())) {

View File

@ -100,7 +100,7 @@ class PurchaseOrderController extends Controller
$data = [
'purchase_order' => $purchase_order,
'key' => $invitation ? $invitation->key : false,
'_key' => $invitation ? $invitation->key : false,
'settings' => $purchase_order->company->settings,
'sidebar' => $this->sidebarMenu(),
'company' => $purchase_order->company,
@ -124,7 +124,8 @@ class PurchaseOrderController extends Controller
$file = $invitation->purchase_order->service()->getPurchaseOrderPdf();
$headers = ['Content-Type' => 'application/pdf'];
// $headers = ['Content-Type' => 'application/pdf'];
$headers = ['Content-Type' => 'application/pdf', 'Content-Disposition' => 'inline'];
return response()->make($file, 200, $headers);

View File

@ -88,7 +88,6 @@ class QueryLogging
}
LightLogs::create(new DbQuery($request->method(), substr(urldecode($request->url()), 0, 180), $count, $time, $ip, $client_version, $platform))
->probe($request, $ip, true)
->batch();
}

View File

@ -38,6 +38,10 @@ class ValidJson
is_null(json_decode($request->getContent())) &&
json_last_error() !== JSON_ERROR_NONE
) {
// nlog("Malformed JSON payload.");
// nlog($request->all());
return response()->json([
'message' => 'Malformed JSON payload.',
'error' => 'Invalid JSON data provided',

View File

@ -20,6 +20,167 @@ use App\Utils\Ninja;
class CreateAccountRequest extends Request
{
private array $fake_domains = [
'generator.email',
'emailfake.com',
'email-fake.com',
'10minutemail.com',
'10minutemail.net',
'10minmail.com',
'tempmail.com',
'temp-mail.org',
'tempmail.net',
'tempmailo.com',
'tempmail.ws',
'tempmail.ninja',
'tempmail.plus',
'guerrillamail.com',
'guerrillamail.net',
'guerrillamail.org',
'guerrillamail.de',
'mailinator.com',
'yopmail.com',
'yopmail.fr',
'yopmail.net',
'maildrop.cc',
'mailnesia.com',
'throwawaymail.com',
'fakemailgenerator.com',
'trashmail.com',
'trashmail.de',
'spamgourmet.com',
'mailondeck.com',
'getnada.com',
'nada.ltd',
'dispostable.com',
'mohmal.com',
'33mail.com',
'anonaddy.com',
'simplelogin.io',
'burnermail.io',
'burnermail.com',
'mytempmail.org',
'fakeinbox.com',
'mail-temp.com',
'20minutemail.com',
'30minutemail.com',
'60minutemail.com',
'0wnd.net',
'0clickemail.com',
'0-mail.com',
'mailcatch.com',
'mailcatch.org',
'mailforspam.com',
'getairmail.com',
'mailtothis.com',
'spam4.me',
'spamdecoy.net',
'mailnull.com',
'inboxkitten.com',
'mailimate.com',
'instantemailaddress.com',
'mailfreeonline.com',
'mailsac.com',
'fakebox.org',
'nowmymail.com',
'mail-temporaire.fr',
'trashmail.ws',
'spamspot.com',
'mail-temporaire.com',
'tempinbox.com',
'mailcatch.io',
'inboxalias.com',
'get-mail.org',
'mailpoof.com',
'temporary-mail.net',
'mytrashmail.com',
'spambog.com',
'spambog.de',
'spambox.us',
'spamcorptastic.com',
'temporarymail.com',
'mail-tester.com',
'temporarymail.org',
'emailondeck.com',
'easytrashmail.com',
'mailsucker.net',
'fakeemailgenerator.com',
'mail7.io',
'sharklasers.com',
'spamgourmet.net',
'fakermail.com',
'dodsi.com',
'spamstack.net',
'byom.de',
'temporarymailaddress.com',
'mail-temp.org',
'spambox.info',
'luxusmail.org',
'e-mail.com',
'trash-me.com',
'fexbox.org',
'getonemail.com',
'mailhub.pro',
'cryptogmail.com',
'mailjunkie.com',
'fake-mail.io',
'disposablemail.com',
'disposableinbox.com',
'mailswipe.net',
'instantmailaddress.com',
'dropmail.me',
'trashmails.com',
'spambog.ru',
'fakeinbox.org',
'meltmail.com',
'mail-temporaire.info',
'mailnesia.net',
'mailscreen.com',
'spambooger.com',
'tempr.email',
'emailondeck.net',
'throwawayaddress.com',
'mail-temp.info',
'mailinator.net',
'emailtemporal.org',
'tempmailgen.com',
'temporaryemail.net',
'temporaryinbox.com',
'tempmailer.com',
'tempmailer.de',
'mymail-in.net',
'trashmail.net',
'mailexpire.com',
'mailhazard.com',
'guerrillamailblock.com',
'temporary-mail.org',
'mailcatch.io',
'emailtemporal.com',
'dropmail.ga',
'tempail.com',
'spambox.org',
'mytemp.email',
'mailspeed.ru',
'mailimate.org',
'getairmail.com',
'dispostable.org',
'emailfake.org',
'fakemail.net',
'tempmailaddress.com',
'tempmailbox.com',
'mailbox92.biz',
'tempmailgen.org',
'mail-temporaire.net',
'tempmail24.com',
'inboxbear.com',
'maildrop.cf',
'maildrop.ga',
'maildrop.gq',
'maildrop.ml',
'maildrop.tk',
];
/**
* Determine if the user is authorized to make this request.
*
@ -59,10 +220,33 @@ class CreateAccountRequest extends Request
];
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
try {
$domain = explode("@", $this->input('email'))[1] ?? "";
$dns = dns_get_record($domain, DNS_MX);
$server = $dns[0]["target"] ?? null;
if($server && in_array($server, $this->fake_domains)){
$validator->errors()->add('email', 'Account Already Exists.');
}
} catch (\Throwable $e) {
nlog($e->getMessage());
nlog("I could not check the email address => ".$this->input('email'));
}
});
}
public function prepareForValidation()
{
nlog(array_merge(['signup' => 'true', 'ipaddy' => request()->ip()], $this->all()));
nlog(array_merge(['signup' => 'true', 'ipaddy' => request()->ip(), 'headers' => request()->headers->all()], $this->all()));
$input = $this->all();

View File

@ -39,7 +39,7 @@ class BulkClientRequest extends Request
'action' => 'required|string|in:archive,restore,delete,template,assign_group,bulk_update',
'ids' => ['required','bail','array',Rule::exists('clients', 'id')->where('company_id', $user->company()->id)],
'template' => 'sometimes|string',
'template_id' => 'sometimes|string',
'template_id' => 'sometimes|string|required_if:action,template',
'group_settings_id' => ['required_if:action,assign_group',Rule::exists('group_settings', 'id')->where('company_id', $user->company()->id)],
'send_email' => 'sometimes|bool',
'column' => ['required_if:action,bulk_update', 'string', Rule::in(\App\Models\Client::$bulk_update_columns)],

View File

@ -43,6 +43,7 @@ class StoreClientRequest extends Request
/** @var \App\Models\User $user */
$user = auth()->user();
$rules['name'] = 'bail|sometimes|nullable|string';
$rules['file'] = 'bail|sometimes|array';
$rules['file.*'] = $this->fileValidation();
$rules['documents'] = 'bail|sometimes|array';
@ -99,6 +100,20 @@ class StoreClientRequest extends Request
return $rules;
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
$user = auth()->user();
$company = $user->company();
if(isset($this->settings['lock_invoices']) && $company->verifactuEnabled() && $this->settings['lock_invoices'] != 'when_sent'){
$validator->errors()->add('settings.lock_invoices', 'Locked Invoices Cannot Be Disabled');
}
});
}
public function prepareForValidation()
{
$input = $this->all();
@ -185,7 +200,7 @@ class StoreClientRequest extends Request
}
// prevent xss injection
if (array_key_exists('name', $input)) {
if (array_key_exists('name', $input) && is_string($input['name'])) {
$input['name'] = strip_tags($input['name']);
}

View File

@ -101,6 +101,20 @@ class UpdateClientRequest extends Request
return $rules;
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
$user = auth()->user();
$company = $user->company();
if(isset($this->settings['lock_invoices']) && $company->verifactuEnabled() && $this->settings['lock_invoices'] != 'when_sent'){
$validator->errors()->add('settings.lock_invoices', 'Locked Invoices Cannot Be Disabled');
}
});
}
public function messages()
{
return [

View File

@ -213,6 +213,15 @@ class UpdateCompanyRequest extends Request
$settings[$protected_var] = str_replace("script", "", $settings[$protected_var]);
}
}
if($this->company->getSetting('e_invoice_type') == 'VERIFACTU') {
$settings['e_invoice_type'] = 'VERIFACTU';
}
}
if(isset($settings['e_invoice_type']) && $settings['e_invoice_type'] == 'VERIFACTU' && $this->company->verifactuEnabled()) {
$settings['lock_invoices'] = 'when_sent';
}
if (isset($settings['email_style_custom'])) {

View File

@ -84,6 +84,12 @@ class UpdateCreditRequest extends Request
return $rules;
}
public function withValidator($validator)
{
}
public function prepareForValidation()
{
$input = $this->all();

View File

@ -25,7 +25,8 @@ class StoreDesignRequest extends Request
'credit',
'purchase_order',
'project',
'task'
'task',
'expense'
];
/**
@ -89,7 +90,7 @@ class StoreDesignRequest extends Request
$input['design']['body'] = '';
}
if (array_key_exists('entities', $input)) {
if (array_key_exists('entities', $input) && is_string($input['entities'])) {
$user_entities = explode(",", $input['entities']);
$e = [];

View File

@ -28,7 +28,8 @@ class UpdateDesignRequest extends Request
'credit',
'purchase_order',
'project',
'task'
'task',
'expense'
];
/**
@ -80,7 +81,7 @@ class UpdateDesignRequest extends Request
$input['design']['body'] = '';
}
if (array_key_exists('entities', $input)) {
if (array_key_exists('entities', $input) && is_string($input['entities'])) {
$user_entities = explode(",", $input['entities']);
$e = [];

View File

@ -29,7 +29,7 @@ class RetrySendRequest extends Request
return true;
}
return $user->account->isPaid() && $user->isAdmin() && $user->company()->legal_entity_id != null;
return $user->account->isPaid() && $user->isAdmin() && ($user->company()->legal_entity_id != null || $user->company()->verifactuEnabled());
}
/**

View File

@ -53,14 +53,4 @@ class UpdateEntityRequest extends FormRequest
$this->replace($input);
}
// public function after(): array
// {
// return [
// function (Validator $validator) {
// if ($this->input('acts_as_sender') === false && $this->input('acts_as_receiver') === false) {
// $validator->errors()->add('acts_as_receiver', ctrans('texts.acts_as_must_be_true'));
// }
// }
// ];
// }
}

View File

@ -17,6 +17,7 @@ use App\Models\Client;
use App\Models\Company;
use App\Models\Invoice;
use App\Http\Requests\Request;
use App\Services\EDocument\Standards\Validation\Peppol\EntityLevel;
use Illuminate\Validation\Rule;
class ValidateEInvoiceRequest extends Request
@ -76,7 +77,6 @@ class ValidateEInvoiceRequest extends Request
return false;
}
$class = Invoice::class;
match ($this->entity) {
@ -93,4 +93,25 @@ class ValidateEInvoiceRequest extends Request
return $class::withTrashed()->find(is_string($this->entity_id) ? $this->decodePrimaryKey($this->entity_id) : $this->entity_id);
}
/**
* getValidatorClass
*
* Return the validator class based on the EInvoicing Standard
*
* @return \App\Services\EDocument\Standards\Validation\EntityLevelInterface
*/
public function getValidatorClass()
{
$user = auth()->user();
if($user->company()->settings->e_invoice_type == 'VERIFACTU') {
return new \App\Services\EDocument\Standards\Validation\Verifactu\EntityLevel();
}
// if($user->company()->settings->e_invoice_type == 'PEPPOL') {
return new \App\Services\EDocument\Standards\Validation\Peppol\EntityLevel();
// }
}
}

View File

@ -38,11 +38,14 @@ class BulkExpenseRequest extends Request
$user = auth()->user();
return [
'action' => 'required|string|in:archive,restore,delete,bulk_update,bulk_categorize',
'action' => 'required|string|in:archive,restore,delete,bulk_update,bulk_categorize,template',
'ids' => ['required','bail','array', Rule::exists('expenses', 'id')->where('company_id', $user->company()->id)],
'category_id' => ['sometimes', 'bail', Rule::exists('expense_categories', 'id')->where('company_id', $user->company()->id)],
'column' => ['required_if:action,bulk_update', 'string', Rule::in(\App\Models\Expense::$bulk_update_columns)],
'new_value' => ['required_if:action,bulk_update|string'],
'template' => 'sometimes|string',
'template_id' => 'sometimes|string|required_if:action,template',
'send_email' => 'sometimes|bool',
];
}

View File

@ -82,7 +82,7 @@ class UpdateExpenseRequest extends Request
unset($input['documents']);
}
if (! array_key_exists('currency_id', $input) || strlen($input['currency_id']) == 0) {
if (! array_key_exists('currency_id', $input) || strlen($input['currency_id'] ?? '') == 0) {
$input['currency_id'] = (string) $user->company()->settings->currency_id;
}

View File

@ -47,6 +47,20 @@ class StoreGroupSettingRequest extends Request
return $rules;
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
$user = auth()->user();
$company = $user->company();
if(isset($this->settings['lock_invoices']) && $company->verifactuEnabled() && $this->settings['lock_invoices'] != 'when_sent'){
$validator->errors()->add('settings.lock_invoices', 'Locked Invoices Cannot Be Disabled');
}
});
}
public function prepareForValidation()
{
$input = $this->all();

View File

@ -41,6 +41,22 @@ class UpdateGroupSettingRequest extends Request
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
$user = auth()->user();
$company = $user->company();
if(isset($this->settings['lock_invoices']) && $company->verifactuEnabled() && $this->settings['lock_invoices'] != 'when_sent'){
$validator->errors()->add('settings.lock_invoices', 'Locked Invoices Cannot Be Disabled');
}
});
}
public function prepareForValidation()
{
$input = $this->all();

View File

@ -26,7 +26,7 @@ class ActionInvoiceRequest extends Request
*
* @return bool
*/
private $error_msg;
// private $error_msg;
// private $invoice;
@ -38,36 +38,37 @@ class ActionInvoiceRequest extends Request
public function rules()
{
return [
'action' => 'required',
'action' => ['required'],
];
}
public function withValidator($validator)
{
$validator->after(function ($validator) {
if ($this->action == 'delete' && ! $this->invoiceDeletable($this->invoice)) {
$validator->errors()->add('action', 'This invoice cannot be deleted');
}elseif ($this->action == 'cancel' && ! $this->invoiceCancellable($this->invoice)) {
$validator->errors()->add('action', 'This invoice cannot be cancelled');
}elseif ($this->action == 'reverse' && ! $this->invoiceReversable($this->invoice)) {
$validator->errors()->add('action', 'This invoice cannot be reversed');
}elseif($this->action == 'restore' && ! $this->invoiceRestorable($this->invoice)) {
$validator->errors()->add('action', 'This invoice cannot be restored');
}elseif($this->action == 'mark_paid' && ! $this->invoicePayable($this->invoice)) {
$validator->errors()->add('action', 'This invoice cannot be marked as paid');
}
});
}
public function prepareForValidation()
{
$input = $this->all();
if ($this->action) {
$input['action'] = $this->action;
} elseif (! array_key_exists('action', $input)) {
$this->error_msg = 'Action is a required field';
} elseif (! $this->invoiceDeletable($this->invoice)) {
unset($input['action']);
$this->error_msg = 'This invoice cannot be deleted';
} elseif (! $this->invoiceCancellable($this->invoice)) {
unset($input['action']);
$this->error_msg = 'This invoice cannot be cancelled';
} elseif (! $this->invoiceReversable($this->invoice)) {
unset($input['action']);
$this->error_msg = 'This invoice cannot be reversed';
}
$input['action'] = $this->route('action');
$this->replace($input);
}
public function messages()
{
return [
'action' => $this->error_msg,
];
}
}

Some files were not shown because too many files have changed in this diff Show More