Skip to content

v164 to master#1386

Merged
nielsdrost7 merged 227 commits intomasterfrom
development
Jan 19, 2026
Merged

v164 to master#1386
nielsdrost7 merged 227 commits intomasterfrom
development

Conversation

@nielsdrost7
Copy link
Copy Markdown
Contributor

@nielsdrost7 nielsdrost7 commented Jan 19, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • Added PayPal Advanced Credit Cards and Venmo payment options
    • Enhanced e-invoicing with improved version validation and configuration
    • Added item-level discount display in invoices and quotes
    • Implemented password reset rate limiting and bot detection
  • Bug Fixes

    • Fixed Open Invoices panel to display correct invoice data
    • Corrected attachment download link paths
    • Improved email validation handling
  • Security

    • Enhanced file access validation and path traversal protection
    • Improved safe redirect handling to prevent open redirect vulnerabilities
    • Added email sanitization and safer file headers
  • Chores

    • Updated Docker configuration permissions
    • Added password reset configuration options
    • Removed outdated dependencies

✏️ Tip: You can customize this high-level summary in your review settings.

nielsdrost7 and others added 30 commits December 26, 2023 12:48
invoice_date_created should not be updated and always stay the same
* remove unnecessary settings tmp_invoice_date and tmp_due_date settings
* remove unnecessary helper function reset_invoice_due_dates
naui95 and others added 5 commits December 29, 2025 10:32
Bumps [qs](https://github.com/ljharb/qs) from 6.14.0 to 6.14.1.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](ljharb/qs@v6.14.0...v6.14.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…cing fields to handle unmigrated databases (#1381)

* Initial plan

* Add defensive checks for client_einvoicing fields to handle unmigrated databases

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Add version checking and extensive logging for einvoicing field access

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Add null checks for version retrieval to prevent null concatenation in logs

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update application/modules/clients/controllers/Clients.php

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Simplify redundant null coalescing in view.php by extracting to variable

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Add comment explaining defensive null coalescing pattern in view.php

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Fix potential null dereference in Mdl_versions::get_current_version()

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Fix incorrect property access in Mdl_versions::get_current_version()

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
… reusable helper (#1383)

* Initial plan

* Fix critical security vulnerabilities in guest/Get.php file access

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Fix log injection vulnerabilities in security logging

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Optimize hash calculation and strengthen path validation

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Fix HTTP header injection vulnerability in Content-Disposition

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Strengthen header injection protection with comprehensive control character filtering

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Create reusable file security helper and apply to all file access points

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Address code review feedback - improve string formatting and null handling

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Address code review feedback - improve path traversal detection and header sanitization

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

* Fix code review issues - remove redundant checks and security bypass

Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: nielsdrost7 <47660417+nielsdrost7@users.noreply.github.com>
@github-advanced-security
Copy link
Copy Markdown
Contributor

This pull request sets up GitHub code scanning for this repository. Once the scans have completed and the checks have passed, the analysis results for this pull request branch will appear on this overview. Once you merge this pull request, the 'Security' tab will show more code scanning analysis results (for example, for the default branch). Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results. For more information about GitHub code scanning, check out the documentation.

Comment thread .github/workflows/docker-mariadb.yml Fixed
Comment thread .github/workflows/docker-nginx.yml Fixed
Comment thread .github/workflows/docker-php-fpm.yml Fixed
Comment thread .github/workflows/docker-phpmyadmin.yml Fixed
nielsdrost7 and others added 4 commits January 19, 2026 12:00
…n permissions

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
…n permissions

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
…n permissions

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
…n permissions

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
@InvoicePlane InvoicePlane deleted a comment from coderabbitai bot Jan 19, 2026
@InvoicePlane InvoicePlane deleted a comment from coderabbitai bot Jan 19, 2026
@nielsdrost7
Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@nielsdrost7 nielsdrost7 requested a review from Copilot January 19, 2026 11:11
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Jan 19, 2026

✅ Actions performed

Full review triggered.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Jan 19, 2026

📝 Walkthrough

Walkthrough

This release introduces security enhancements including file validation helpers, password reset rate limiting, and path-traversal protections. Additionally, it expands PayPal gateway capabilities with advanced credit card fields and Venmo support, improves e-invoicing with database migration handling, adds null-safety checks across views, and updates GitHub Actions workflows with explicit permissions.

Changes

Cohort / File(s) Summary
GitHub Actions & CI/CD Permissions
.github/workflows/docker-*.yml
Added explicit permissions: contents: read (and packages: write for phpmyadmin) blocks to workflows for least-privilege container builds.
PayPal Payment Gateway
application/config/payment_gateways.php, application/libraries/gateways/PaypalLib.php, application/modules/guest/controllers/Payment_information.php, application/modules/guest/views/gateways/paypal.php, assets/core/css/paypal.css, assets/core/js/paypal.js
Added advancedCreditCards and venmo configuration options. Introduced centralized buildHeaders() and generateRequestId() methods to PayPal library. Redesigned card payment UI with separate card form, error handling, and spinner. Updated capture logic to derive actual status from PayPal response and handle PENDING/COMPLETED states.
File Security & Validation
application/helpers/file_security_helper.php, application/modules/guest/controllers/Get.php, application/modules/invoices/controllers/Invoices.php, application/modules/upload/controllers/Upload.php
Added comprehensive file_security_helper.php with path-traversal protections, filename sanitization, and directory confinement validation. Integrated validate_file_access() across file download/streaming operations. Added extension whitelist (jpg, jpeg, png, pdf, gif, webp) to upload controller.
E-invoicing System
application/config/payment_gateways.php, application/helpers/e-invoice_helper.php, application/modules/clients/controllers/Clients.php, application/modules/clients/models/Mdl_clients.php, application/modules/clients/views/partial_client_table.php, application/modules/clients/views/view.php, application/modules/mailer/views/invoice.php
Added XML config ID validation (is_valid_xml_config_id) with path-traversal protection. Implemented migration-aware field detection for client_einvoicing_active and client_einvoicing_version with fallback defaults and warning logs. Added callback validation for einvoicing version. Updated views with null-coalescing operators for null-safety.
Password Reset Security
application/modules/sessions/controllers/Sessions.php
Added rate limiting per IP and email, bot detection, session-based attempt tracking. Introduced safe referer validation to prevent open redirects. Added configurable thresholds (IP, email, time windows). Avoids email enumeration by consistent messaging regardless of user existence.
Email & Mailer Handling
application/helpers/mailer_helper.php, application/modules/mailer/helpers/phpmailer_helper.php
Added validate_email_address() for single/comma-semicolon-separated email validation. Replaced mb_strpos with str_contains for reliable comma detection. Enhanced redirect safety in mail error handling.
View Templates & Discount Display
application/views/invoice_templates/public/InvoicePlane_Web.php, application/views/quote_templates/public/InvoicePlane_Web.php, application/modules/guest/controllers/View.php, application/modules/guest/views/index.php
Added dynamic item-discount column rendering based on $show_item_discounts flag. Implemented dynamic colspan calculations for layout alignment. Updated guest index panel to use $open_invoices instead of $overdue_invoices. Changed attachment download URL from guest/get/attachment/ to guest/get/get_file/.
PDF & Invoice Processing
application/helpers/mpdf_helper.php, application/helpers/pdf_helper.php, application/views/invoice_templates/pdf/InvoicePlane.php, application/views/quote_templates/pdf/InvoicePlane.php
Refactored mPDF footer handling to use DefHTMLFooterByName() and SetHTMLFooterByName(). Added try-catch error handling for WriteHTML. Integrated XML config ID validation in PDF helper. Updated quote template footer with duplicate discount cell fix.
Numeric & Data Processing
application/helpers/number_helper.php, application/modules/reports/models/Mdl_reports.php
Ensured format_amount() returns float via explicit cast. Fixed standardize_amount() to conditionally handle thousands separator. Refactored reports filtering: integer casting for quantity bounds, parameterized SQL queries, date-handling logic.
Database & Models
application/modules/invoices/models/Mdl_invoices_recurring.php, application/modules/settings/models/Mdl_versions.php, application/modules/upload/models/Mdl_uploads.php
Added default_order_by() method to recurring invoices model. Fixed get_current_version() to safely return null on empty result. Parameterized SQL queries in upload model.
Configuration & Assets
.gitignore, Gruntfile.js, ipconfig.php.example, composer.json, package.json, assets/core/js/.gitignore, assets/invoiceplane/sass/_custom_styles.scss
Added .temp to gitignore. Excluded PayPal assets from Grunt purge. Added password reset rate limit configuration keys. Updated version metadata. Whitelisted paypal.js. Minor SCSS formatting. Removed composer.json version field.
Infrastructure
application/modules/setup/sql/040_1.6.4.sql, resources/docker/php-fpm/Dockerfile, index.php
Added migration script placeholder for 1.6.4. Removed obsolete PHP dependencies (libmcrypt-dev, libcurl4-nss-dev, libc-client-dev, wkhtmltopdf). Refactored temp file cleanup to use explicit glob merge.
Language & Localization
application/language/english/ip_lang.php
Added translation keys for e-invoicing, password reset rate limiting, security logging (XML validation, email validation, IP rate limit checks, bot detection).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

Feature Request, Security, Enhancement

Suggested reviewers

  • naui95

Poem

🐰 Tis the season of safeguards and guarding our gates,
With PayPal cards dancing and limits on rates,
Files locked behind cryptographic bright lights,
Invoices sprouting their discounts delights,
A 1.6.4 hopping toward secure, swift heights!

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'v164 to master' is vague and generic, lacking specificity about the primary changes in this large, multi-faceted pull request. Provide a more descriptive title that highlights the main purpose of this merge, such as 'Merge v1.6.4 development: security hardening, PayPal enhancements, and e-invoicing improvements' or similar.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR merges version 1.6.4-beta-1 into master, introducing security enhancements, PayPal payment improvements, and various bug fixes. The changes include comprehensive file security hardening, password reset rate limiting, SQL injection prevention, and enhanced PayPal integration with advanced credit card support.

Changes:

  • Major security improvements including file upload/download validation, SQL injection fixes, and path traversal prevention
  • Enhanced PayPal integration with card fields and improved error handling
  • UI improvements for invoice/quote templates with conditional discount display
  • Dependency updates (yarn, composer) and Docker configuration refinements

Reviewed changes

Copilot reviewed 46 out of 51 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
application/helpers/file_security_helper.php New comprehensive file security validation helper
application/modules/upload/controllers/Upload.php Enhanced file upload security with validation
application/modules/sessions/controllers/Sessions.php Password reset rate limiting implementation
application/modules/guest/controllers/gateways/Paypal.php Improved PayPal capture with pending status handling
application/libraries/gateways/PaypalLib.php Added partner attribution and request ID generation
assets/core/js/paypal.js New PayPal SDK integration with card fields
application/modules/settings/models/Mdl_versions.php Fixed null reference bug in version retrieval
application/modules/reports/models/Mdl_reports.php SQL injection fix with type casting
application/helpers/mpdf_helper.php PDF footer handling improvements
yarn.lock Dependency updates for Parcel watcher and other packages

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

AND ' . $this->db->escape($from_date) . ' <= inv.invoice_date_created
AND ' . $this->db->escape($to_date) . ' >= inv.invoice_date_created
AND ' . $minQuantity . ' <=
AND ' . $this->db->escape($minQuantity) . ' <=
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The escaping approach is inconsistent with the pattern used on line 564. Line 687 should use explicit (int) casting like line 564, rather than using db->escape(). This maintains consistency and makes the integer type constraint explicit.

Copilot uses AI. Check for mistakes.

public function default_order_by()
{
$this->db->order_by( 'recur_status ASC, recur_next_date ASC' );
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default_order_by method has inconsistent formatting. The method body should use either 2 or 4 spaces for indentation (not mixed), and there should be a blank line after the method opening brace. The closing brace on line 67 also appears to have extra spacing.

Suggested change
$this->db->order_by( 'recur_status ASC, recur_next_date ASC' );
$this->db->order_by( 'recur_status ASC, recur_next_date ASC' );

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +78
// Set the status of the actual transaction (not just the API call result.)
$capture_status = mb_strtoupper($paypal_object->purchase_units[0]->payments->captures[0]->status) ?? null;

// If either Completed or Pending, we're treating it as completed from the buyer's perspective.
if ($capture_status === 'COMPLETED' || $capture_status === 'PENDING') {
$invoice_id = $paypal_object->purchase_units[0]->payments->captures[0]->invoice_id;
$amount = $paypal_object->purchase_units[0]->payments->captures[0]->amount->value;
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property access $paypal_object->purchase_units[0]->payments->captures[0]->status on line 73 should use null-safe operators or explicit null checks. The ?? null at the end doesn't prevent potential errors from accessing array indices or properties that may not exist earlier in the chain.

Suggested change
// Set the status of the actual transaction (not just the API call result.)
$capture_status = mb_strtoupper($paypal_object->purchase_units[0]->payments->captures[0]->status) ?? null;
// If either Completed or Pending, we're treating it as completed from the buyer's perspective.
if ($capture_status === 'COMPLETED' || $capture_status === 'PENDING') {
$invoice_id = $paypal_object->purchase_units[0]->payments->captures[0]->invoice_id;
$amount = $paypal_object->purchase_units[0]->payments->captures[0]->amount->value;
// Safely extract the first capture object from the PayPal response.
$capture = null;
if (
isset($paypal_object->purchase_units) &&
is_array($paypal_object->purchase_units) &&
isset($paypal_object->purchase_units[0]) &&
isset($paypal_object->purchase_units[0]->payments) &&
isset($paypal_object->purchase_units[0]->payments->captures) &&
is_array($paypal_object->purchase_units[0]->payments->captures) &&
isset($paypal_object->purchase_units[0]->payments->captures[0])
) {
$capture = $paypal_object->purchase_units[0]->payments->captures[0];
}
// Set the status of the actual transaction (not just the API call result.)
$capture_status = null;
if (is_object($capture) && isset($capture->status)) {
$capture_status = mb_strtoupper($capture->status);
}
// If either Completed or Pending, we're treating it as completed from the buyer's perspective.
if ($capture_status === 'COMPLETED' || $capture_status === 'PENDING') {
$invoice_id = $capture->invoice_id;
$amount = $capture->amount->value;

Copilot uses AI. Check for mistakes.
Comment on lines +342 to +343
if (!$attempts) {
$attempts = [];
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent spacing in array initialization. Line 342 has no spaces inside the array braces, while most other array initializations in the codebase use spaces. Should be $attempts = [ ]; or consistently use no spaces throughout.

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +47
$result = $this->limit(1)->get();

// Check if any rows were returned to avoid null dereference
if ($result->query->num_rows() === 0) {
return null;
}

$current_version = $result->query->row()->version_file;
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code accesses $result->query->num_rows() and $result->query->row(), but $result is already the query result object from $this->limit(1)->get(). It should be $result->num_rows() and $result->row() without the intermediate query property access.

Copilot uses AI. Check for mistakes.
Comment on lines +96 to +100
if ($thousands_separator) {
$amount = strtr($amount, [$thousands_separator => '', $decimal_point => '.']);
} else {
$amount = strtr($amount, [$decimal_point => '.']);
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition if ($thousands_separator) on line 96 will evaluate to false when $thousands_separator is '0' or an empty string, which may not be the intended behavior. Consider using if ($thousands_separator !== null && $thousands_separator !== '') for explicit checking, or using isset() if appropriate.

Copilot uses AI. Check for mistakes.
Comment on lines +353 to +359
private function sanitize_for_log($value): string
{
$sanitized = (string) $value;
$sanitized = str_replace(array("\r", "\n"), ' ', $sanitized);

return $sanitized;
}
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing return type declaration. The method should declare : void as its return type to match modern PHP standards and improve code clarity. The method signature should be private function sanitize_for_log($value): string based on the return statement.

Copilot uses AI. Check for mistakes.
* @param array $items
* @return boolean
*/
private function has_discounts(array $items) : bool {
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type hint uses boolean but should use bool which is the standard PHP type hint. The comment on line 277 correctly uses @return boolean for PHPDoc (which is acceptable), but the actual type hint should be : bool.

Suggested change
private function has_discounts(array $items) : bool {
private function has_discounts(array $items): bool
{

Copilot uses AI. Check for mistakes.
AND ' . $this->db->escape($to_date) . ' >= inv2.invoice_date_created
)
) AND ' . $maxQuantity . ' >=
) AND ' . $this->db->escape($maxQuantity) . ' >=
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent use of escaping for SQL query parameters. Line 564 uses casting with (int), but line 574 uses db->escape(). For integer values, both locations should use consistent type casting to (int) instead of mixing approaches. This ensures predictable behavior and better security.

Copilot uses AI. Check for mistakes.
$CI = &get_instance();
$CI->load->model('settings/mdl_versions');
$current_version = $CI->mdl_versions->get_current_version();
$current_version = $current_version ?: 'unknown';
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent property access syntax. Line 314 uses $current_version = $current_version ?: 'unknown'; but could be more explicit with $current_version = $current_version ?? 'unknown'; to match the null coalescing operator pattern used elsewhere in the codebase.

Suggested change
$current_version = $current_version ?: 'unknown';
$current_version = $current_version ?? 'unknown';

Copilot uses AI. Check for mistakes.
@nielsdrost7 nielsdrost7 marked this pull request as ready for review January 19, 2026 11:21
@nielsdrost7 nielsdrost7 merged commit add8bb7 into master Jan 19, 2026
13 checks passed
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
application/views/quote_templates/public/InvoicePlane_Web.php (1)

210-218: Use $colspan for discount rows

Both discount rows still hardcode colspan="4", which misaligns the table when the item‑discount column is hidden.

🔧 Proposed fix
-                                    <td class="no-bottom-border" colspan="4"></td>
+                                    <td class="no-bottom-border" colspan="<?php echo $colspan; ?>"></td>
...
-                                    <td class="no-bottom-border" colspan="4"></td>
+                                    <td class="no-bottom-border" colspan="<?php echo $colspan; ?>"></td>

Also applies to: 264-270

application/views/invoice_templates/public/InvoicePlane_Web.php (1)

275-281: Legacy discount row should use dynamic colspan

colspan="4" will misalign when item discounts are hidden; use $colspan for consistency.

🔧 Proposed fix
-                                    <td class="no-bottom-border" colspan="4"></td>
+                                    <td class="no-bottom-border" colspan="<?php echo $colspan; ?>"></td>
application/views/quote_templates/pdf/InvoicePlane.php (1)

169-181: Remove the duplicate discount cell and extra closing brace.

The unconditional discount <td> duplicates output and introduces an extra } that breaks the loop/HTML structure.

🛠️ Proposed fix
 <?php
     if ($show_item_discounts) {
 ?>
                 <td class="text-right">
                     <?php echo format_currency($item->item_discount); ?>
                 </td>
 <?php
     }
 ?>
-                <td class="text-right">
-                    <?php echo format_currency($item->item_discount); ?>
-                </td>
-<?php
-    }
-?>
application/modules/guest/controllers/gateways/Paypal.php (1)

150-162: Bug: Same showOrderDetails return value issue in error branch.

This has the same bug as line 124 - showOrderDetails returns an array with a Guzzle response, not a JSON string.

Proposed fix
             //get the order details to have the invoice id from paypal
-            $order_details = json_decode($this->lib_paypal->showOrderDetails($order_id));
+            $order_result = $this->lib_paypal->showOrderDetails($order_id);
+            $order_details = null;
+            $invoice_id = null;
+            
+            if ($order_result['status']) {
+                $order_details = json_decode($order_result['response']->getBody());
+                $invoice_id = $order_details->purchase_units[0]->payments->captures[0]->invoice_id ?? null;
+            }

             //record the failed transaction in the logs
             $this->db->insert('ip_merchant_responses', [
-                'invoice_id'                   => $order_details->purchase_units[0]->payments->captures[0]->invoice_id,
+                'invoice_id'                   => $invoice_id,
🤖 Fix all issues with AI agents
In `@application/helpers/mailer_helper.php`:
- Around line 248-257: The current prefix-based referer check in the block that
sets $redirect (using $referer = $_SERVER['HTTP_REFERER'] ?? '' and
strpos($referer, $base_url) === 0) can be abused for open-redirects; replace the
prefix check with a parsed-origin comparison: use parse_url() to extract scheme
and host for both $referer and base_url(), ensure both scheme and host exactly
match before assigning $redirect = $referer, and otherwise fall back to
base_url(); keep the same variables ($referer, $redirect, $base_url) and logic
flow but validate origin via parse_url host/scheme equality to prevent malicious
domains like example.com.evil.com from passing.
- Around line 212-231: The validate_email_address function mishandles spaces and
mixed delimiters and uses PHP 8-only str_contains; change it to split on both
commas and semicolons (e.g. use preg_split or explode with a regex like /[;,]/),
trim each resulting $emailItem, ignore empty items, validate each trimmed
address with filter_var(FILTER_VALIDATE_EMAIL), and replace str_contains usage
with a PHP 7-compatible check (e.g. strpos) or avoid it entirely by always using
the delimiter-agnostic split in validate_email_address to ensure robust
validation.

In `@application/helpers/mpdf_helper.php`:
- Around line 97-99: The 'footerWithPageNumbers' footer defined via
mpdf->DefHTMLFooterByName currently only injects the quote/invoice footer HTML
and omits the page-number block, so the quote page-number toggle won't display;
update the mpdf->DefHTMLFooterByName('footerWithPageNumbers', ...) call to
include the same page-number HTML used for invoices (i.e., the page number
container/placeholder used elsewhere) alongside
CI->mdl_settings->settings['pdf_quote_footer'] (and invoice footer if intended)
so its behavior matches the invoice footer; ensure the other footer definitions
(footer, defaultFooter) remain unchanged.

In `@application/modules/guest/controllers/gateways/Paypal.php`:
- Around line 122-126: The code incorrectly json_decodes the return of
$this->lib_paypal->showOrderDetails($order_id) because showOrderDetails returns
an array with ['status'=>..., 'response'=><GuzzleResponse>]; update the logic to
call showOrderDetails into a variable (e.g. $result), verify $result['status']
is true, extract the Guzzle response via $result['response'], read its body
(->getBody()->getContents() or cast to string), json_decode that body to
populate $order_details, then pull invoice_id from
$order_details->purchase_units[0]->payments->captures[0]->invoice_id (with null
checks); replace the existing direct
json_decode($this->lib_paypal->showOrderDetails(...)) usage accordingly and
handle failure cases when status is false or body decoding fails.

In `@application/modules/guest/views/gateways/paypal.php`:
- Around line 52-64: The PayPal JS config is interpolating PHP variables
directly into window.PayPalConfig (advEnabled, venmoEnabled, clientId, currency,
invoiceUrlKey, createOrderUrl, capturePaymentUrl, successUrl, errorUrl), which
risks breaking JS or enabling XSS; update the template to emit these values
using PHP's json_encode (or json_encode the entire associative array and echo
it) so booleans, strings and URLs are safely escaped for JavaScript (ensure
calls that build URLs like site_url(...) are json_encoded before embedding into
the JS object).
- Around line 50-51: The PHP conditional closing tag "<?php endif; ?>" is
currently placed inside the inner "col-xs-12" div causing mismatched HTML when
$adv_enabled is false; move the "<?php endif; ?>" so the conditional wraps the
entire card block (or relocate it to after the outer card container's closing
</div>) ensuring the outer div that began at the "col-xs-12" start is always
closed, and verify that the conditional opening for $adv_enabled and its
corresponding "<?php endif; ?>" enclose complete HTML elements rather than
splitting container tags.

In `@application/modules/reports/models/Mdl_reports.php`:
- Around line 214-215: Remove the (int) casts on $minQuantity and $maxQuantity
so monetary thresholds keep decimal precision (these are compared against
SUM(invoice_item_subtotal)/SUM(invoice_total)), and instead cast to (float) or
validate with is_numeric() to preserve cents; also change the truthy check from
if ($maxQuantity) to an explicit numeric/null check (e.g., if
(is_numeric($maxQuantity)) or if ($maxQuantity !== null && $maxQuantity !== '')
) so a legitimate zero maximum is honored; update any related variable usage or
SQL parameter binding in the same function to use the new float/validated values
(or rename if these are actually quantities vs monetary thresholds).
- Around line 217-218: The current logic in Mdl_reports.php sets $from_date and
$to_date to today's date when empty, causing mandatory filtering to today;
change this to match the pattern used by sales_by_client and payment_history by
leaving $from_date and $to_date as NULL/empty when the inputs are empty and only
applying the date WHERE clauses if those variables are provided. Update the
handling around date_to_mysql(...) and any subsequent query-building in the same
function (referencing $from_date, $to_date, and date_to_mysql) so date filters
are skipped when inputs are not supplied rather than defaulting to
date('Y-m-d').

In `@application/modules/sessions/controllers/Sessions.php`:
- Around line 331-359: The function _is_ip_rate_limited_password_reset filters
out old timestamps but never persists the pruned array back to session, causing
permanent lockout; update the session after pruning by calling the session
setter with the same session key (session_key built from
'password_reset_attempts_' . md5($ip_address)) e.g. persist the filtered
$attempts via $this->session->set_userdata($session_key, $attempts) before
returning, and apply the same fix to the other similar block referenced (lines
392-419) so both pruning operations write the cleaned list back to session.

In `@assets/core/js/paypal.js`:
- Around line 217-218: The fetch calls that capture payments currently use GET
and should be changed to POST: update the onApprove handler's fetch invocation
(referenced by onApprove and config.capturePaymentUrl) to use method: 'POST'
instead of 'GET' and likewise update the second fetch call at the other
occurrence around line 250; also ensure the server-side payment capture
controller is modified to accept POST requests and read the order ID from the
request body (or POST parameters) rather than the URL/query string.

In `@index.php`:
- Around line 306-311: The cleanup code assumes glob() returns arrays but glob()
can return false, causing warnings when passed to array_merge; update the $files
assembly to normalize glob results to arrays (e.g., coerce each
glob(UPLOADS_TEMP_FOLDER . '*.pdf') and glob(UPLOADS_TEMP_FOLDER . '*.xml') to
[] when false) before calling array_merge, then proceed with array_map('unlink',
$files) so unlink only receives a proper array; reference the existing symbols
$files, array_merge, glob, UPLOADS_TEMP_FOLDER, array_map and unlink when making
the change.
🧹 Nitpick comments (11)
application/modules/mailer/helpers/phpmailer_helper.php (1)

100-123: Harden recipient parsing and confirm PHP version for str_contains.

Common inputs like "a@b.com, b@c.com" or mixed delimiters can produce invalid addresses due to whitespace/semicolon leftovers. Also, str_contains requires PHP 8—please verify the minimum PHP version or use a delimiter-agnostic split.

💡 Suggested parsing (handles commas/semicolons + trims + skips empties)
-    $to = (str_contains($to, ',')) ? explode(',', $to) : explode(';', $to);
+    $to = preg_split('/\s*[;,]\s*/', $to, -1, PREG_SPLIT_NO_EMPTY);

...
-        $cc = (str_contains($cc, ',')) ? explode(',', $cc) : explode(';', $cc);
+        $cc = preg_split('/\s*[;,]\s*/', $cc, -1, PREG_SPLIT_NO_EMPTY);

...
-        $bcc = (str_contains($bcc, ',')) ? explode(',', $bcc) : explode(';', $bcc);
+        $bcc = preg_split('/\s*[;,]\s*/', $bcc, -1, PREG_SPLIT_NO_EMPTY);
resources/docker/php-fpm/Dockerfile (1)

28-28: Remove libonig-dev — it's not needed for the pre-built mbstring in php:8.1-fpm.

The base image php:8.1-fpm includes the mbstring extension pre-compiled and enabled by default. The libonig-dev package is only required when manually compiling mbstring from source, which is not the case here. Removing it will reduce the image size without impact.

application/helpers/pdf_helper.php (1)

129-137: Reduce duplicate XML ID validation calls.
is_valid_xml_config_id() is invoked twice and file_exists() is redundant. Caching the result improves readability and avoids duplicate checks.

♻️ Refactor suggestion
-        $path      = APPPATH . 'helpers/XMLconfigs/';
-        
-        // Security: Validate XML config ID to prevent path traversal
-        if ($xml_id && is_valid_xml_config_id($xml_id) && file_exists($path . $xml_id . '.php') && include $path . $xml_id . '.php') {
+        $path        = APPPATH . 'helpers/XMLconfigs/';
+        $xml_id_valid = $xml_id && is_valid_xml_config_id($xml_id);
+
+        // Security: Validate XML config ID to prevent path traversal
+        if ($xml_id_valid && include $path . $xml_id . '.php') {
             $embed_xml = $xml_setting['embedXML'];
             $XMLname   = $xml_setting['XMLname'];
             $options   = (empty($xml_setting['options']) ? $options : $xml_setting['options']); // Optional
             $generator = (empty($xml_setting['generator']) ? $generator : $xml_setting['generator']); // Optional
-        } elseif ($xml_id && !is_valid_xml_config_id($xml_id)) {
+        } elseif ($xml_id && ! $xml_id_valid) {
             log_message('error', trans('log_invalid_xml_config_id_pdf_helper') . ': ' . $xml_id);
         }
assets/core/css/paypal.css (1)

49-53: Deduplicate #card-spinner styles

There are two #card-spinner blocks; keeping just one avoids confusion and accidental overrides.

♻️ Suggested cleanup
-/* Spinner styling */
-#card-spinner {
-    display: none;
-    margin-left: 10px;
-}

Also applies to: 84-88

application/libraries/gateways/PaypalLib.php (1)

153-165: Avoid mb_str_split dependency unless guaranteed

mb_str_split requires ext‑mbstring. If that extension isn’t guaranteed in your deployment, this can fatal. Consider str_split or explicitly require mbstring.

♻️ Suggested change
-        $uuid = vsprintf('%s%s-%s-%s-%s-%s%s%s', mb_str_split(bin2hex($data), 4));
+        $uuid = vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
application/modules/clients/controllers/Clients.php (1)

363-399: Avoid per-client version lookups & log spam when schema is unmigrated.

Line 363–399: When fields are missing, this runs for every client in list views. Consider caching the version and logging once per request to reduce DB calls and log noise.

♻️ Possible refactor (log/cache once per request)
-        if (!property_exists($client, 'client_einvoicing_active') || !property_exists($client, 'client_einvoicing_version')) {
-            // Fields don't exist - database hasn't been migrated to 1.6.3+
-            $this->load->model('settings/mdl_versions');
-            $current_version = $this->mdl_versions->get_current_version();
-            $current_version = $current_version ?: 'unknown';
-            $currentVersionForLog = $this->sanitize_for_log($current_version);
-
-            log_message('warning', '[eInvoicing] Database version mismatch detected in check_client_einvoice_active: Running source code 1.6.3+ with database version ' . $currentVersionForLog);
-            log_message('warning', '[eInvoicing] Missing fields: client_einvoicing_active and client_einvoicing_version not found in client object (client_id=' . $clientIdForLog . ')');
-            log_message('warning', '[eInvoicing] Please run database migration 039_1.6.3.sql to add these fields');
+        static $einvoiceSchemaChecked = false;
+        static $cachedVersion = null;
+        if (!property_exists($client, 'client_einvoicing_active') || !property_exists($client, 'client_einvoicing_version')) {
+            if (!$einvoiceSchemaChecked) {
+                $this->load->model('settings/mdl_versions');
+                $cachedVersion = $this->mdl_versions->get_current_version() ?: 'unknown';
+                $currentVersionForLog = $this->sanitize_for_log($cachedVersion);
+                log_message('warning', '[eInvoicing] Database version mismatch detected in check_client_einvoice_active: Running source code 1.6.3+ with database version ' . $currentVersionForLog);
+                log_message('warning', '[eInvoicing] Missing fields: client_einvoicing_active and client_einvoicing_version not found in client object (client_id=' . $clientIdForLog . ')');
+                log_message('warning', '[eInvoicing] Please run database migration 039_1.6.3.sql to add these fields');
+                $einvoiceSchemaChecked = true;
+            }
application/modules/clients/views/view.php (1)

309-313: Consider a fallback if get_xml_full_name() returns null.

Line 309–313: If the XML config is invalid/missing, get_xml_full_name() returns null and the UI shows a blank value. Prefer a clear fallback (e.g., “none”) for consistency.

♻️ Possible tweak
-        echo (($client->client_einvoicing_active ?? 0) && $einvoicing_version) ? get_xml_full_name($einvoicing_version) : trans('none'); 
+        echo (($client->client_einvoicing_active ?? 0) && $einvoicing_version)
+            ? (get_xml_full_name($einvoicing_version) ?: trans('none'))
+            : trans('none');
application/modules/guest/controllers/Payment_information.php (1)

124-128: Normalize venmo flag to boolean for consistency.

Line 124–128: advanced_credit_cards is a boolean but venmo is passed as raw setting value. Casting avoids strict-check surprises in the view/JS.

♻️ Suggested change
-            'venmo'                 => get_setting('gateway_paypal_venmo'),
+            'venmo'                 => (int) get_setting('gateway_paypal_venmo') === 1,
assets/core/js/paypal.js (1)

213-226: Consider adding response validation before parsing JSON.

The fetch calls assume the response is always valid JSON. If the server returns an error page or non-JSON response, response.json() will throw, but the error won't be user-friendly.

Proposed improvement
 createOrder() {
-    return fetch(config.createOrderUrl, { method: "GET" })
-        .then(response => response.json())
-        .then(order => order.id);
+    return fetch(config.createOrderUrl, { method: "GET" })
+        .then(response => {
+            if (!response.ok) {
+                throw new Error('Failed to create order: HTTP ' + response.status);
+            }
+            return response.json();
+        })
+        .then(order => {
+            if (!order || !order.id) {
+                throw new Error('Invalid order response');
+            }
+            return order.id;
+        });
 },
application/modules/upload/controllers/Upload.php (2)

178-184: Redundant checks after basename() and potential log injection.

After basename() on line 175, the filename cannot contain / or \\ by definition. More importantly, line 182 logs the raw $filename after basename(), which could still contain malicious content for log injection (e.g., newlines aren't stripped by basename).

Proposed fix using hash_for_logging
         // Security: Check for path traversal attempts before sanitization
         if (str_contains($filename, '..') || 
             str_contains($filename, '/') || 
             str_contains($filename, '\\') ||
             str_contains($filename, "\0")) {
-            log_message('error', 'Path traversal attempt detected in filename: ' . $filename);
+            log_message('error', 'Path traversal attempt detected in filename (hash: ' . hash_for_logging($filename) . ')');
             return '';
         }

54-60: MIME validation silently skipped without fileinfo extension.

When the fileinfo extension isn't loaded, MIME type validation is silently skipped. This could allow bypassing content-type restrictions. Consider logging a warning or requiring the extension.

Proposed improvement
-        if (extension_loaded('fileinfo')) {
-            $this->validate_mime_type(mime_content_type($tempFile));
-        }
+        if (extension_loaded('fileinfo')) {
+            $this->validate_mime_type(mime_content_type($tempFile));
+        } else {
+            log_message('warning', 'fileinfo extension not loaded - MIME validation skipped');
+        }

Comment on lines +212 to +231
/**
* Validate email address syntax
* $email string can be a single email or a list of emails.
* The emails can either be comma (,) or semicolon (;) separated.
*
* @param string $email
*
* @return bool returs true if all emails are valid otherwise false
*/
function validate_email_address(string $email): bool
{
$emails = (str_contains($email, ',')) ? explode(',', $email) : explode(';', $email);

foreach ($emails as $emailItem) {
if ( ! filter_var($emailItem, FILTER_VALIDATE_EMAIL)) {
return false;
}
}

return true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Email validation rejects common inputs and depends on PHP 8 str_contains.

Addresses like "a@b.com, b@c.com" (with spaces) or mixed delimiters will currently fail. Also, str_contains requires PHP 8. Consider a delimiter-agnostic split and trim.

✅ Robust validation (trims + handles commas/semicolons + avoids PHP 8 dependency)
-function validate_email_address(string $email): bool
-{
-    $emails = (str_contains($email, ',')) ? explode(',', $email) : explode(';', $email);
-
-    foreach ($emails as $emailItem) {
-        if ( ! filter_var($emailItem, FILTER_VALIDATE_EMAIL)) {
-            return false;
-        }
-    }
-
-    return true;
-}
+function validate_email_address(string $email): bool
+{
+    $emails = preg_split('/\s*[;,]\s*/', $email, -1, PREG_SPLIT_NO_EMPTY);
+    if (empty($emails)) {
+        return false;
+    }
+    foreach ($emails as $emailItem) {
+        if ( ! filter_var(trim($emailItem), FILTER_VALIDATE_EMAIL)) {
+            return false;
+        }
+    }
+    return true;
+}
🤖 Prompt for AI Agents
In `@application/helpers/mailer_helper.php` around lines 212 - 231, The
validate_email_address function mishandles spaces and mixed delimiters and uses
PHP 8-only str_contains; change it to split on both commas and semicolons (e.g.
use preg_split or explode with a regex like /[;,]/), trim each resulting
$emailItem, ignore empty items, validate each trimmed address with
filter_var(FILTER_VALIDATE_EMAIL), and replace str_contains usage with a PHP
7-compatible check (e.g. strpos) or avoid it entirely by always using the
delimiter-agnostic split in validate_email_address to ensure robust validation.

Comment on lines +248 to +257
// Use provided redirect, or validate HTTP_REFERER against base_url
if (empty($redirect)) {
$referer = $_SERVER['HTTP_REFERER'] ?? '';
$base_url = base_url();
// Only use referer if it's from same domain
if (!empty($referer) && strpos($referer, $base_url) === 0) {
$redirect = $referer;
} else {
$redirect = base_url(); // Safe default
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prefix-based referer check allows open-redirects.

A referer like https://example.com.evil.com (or https://example.com@evil.com) can pass the prefix check. Use parse_url host/scheme comparison instead.

🔒 Safer same-origin validation
-        if (empty($redirect)) {
-            $referer = $_SERVER['HTTP_REFERER'] ?? '';
-            $base_url = base_url();
-            // Only use referer if it's from same domain
-            if (!empty($referer) && strpos($referer, $base_url) === 0) {
-                $redirect = $referer;
-            } else {
-                $redirect = base_url(); // Safe default
-            }
-        }
+        if (empty($redirect)) {
+            $referer  = $_SERVER['HTTP_REFERER'] ?? '';
+            $base_url = base_url();
+            $refParts  = $referer ? parse_url($referer) : null;
+            $baseParts = parse_url($base_url);
+
+            if (
+                $refParts && $baseParts &&
+                isset($refParts['host'], $baseParts['host']) &&
+                strcasecmp($refParts['host'], $baseParts['host']) === 0 &&
+                ($refParts['scheme'] ?? '') === ($baseParts['scheme'] ?? '') &&
+                ($refParts['port'] ?? null) === ($baseParts['port'] ?? null)
+            ) {
+                $redirect = $referer;
+            } else {
+                $redirect = $base_url; // Safe default
+            }
+        }
🤖 Prompt for AI Agents
In `@application/helpers/mailer_helper.php` around lines 248 - 257, The current
prefix-based referer check in the block that sets $redirect (using $referer =
$_SERVER['HTTP_REFERER'] ?? '' and strpos($referer, $base_url) === 0) can be
abused for open-redirects; replace the prefix check with a parsed-origin
comparison: use parse_url() to extract scheme and host for both $referer and
base_url(), ensure both scheme and host exactly match before assigning $redirect
= $referer, and otherwise fall back to base_url(); keep the same variables
($referer, $redirect, $base_url) and logic flow but validate origin via
parse_url host/scheme equality to prevent malicious domains like
example.com.evil.com from passing.

Comment on lines +97 to +99
$mpdf->DefHTMLFooterByName('footerWithPageNumbers', '<div id="footer">' . $CI->mdl_settings->settings['pdf_invoice_footer'] . '</div><div id="footer">' . $CI->mdl_settings->settings['pdf_quote_footer'] . '</div>');
$mpdf->DefHTMLFooterByName('footer', '<div id="footer">' . $CI->mdl_settings->settings['pdf_quote_footer'] . '</div>');
$mpdf->DefHTMLFooterByName('defaultFooter', '<div id="footer">' . $CI->mdl_settings->settings['pdf_quote_footer'] . '</div>');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Quote footerWithPageNumbers doesn’t actually include page numbers.

The template’s optional page-number toggle won’t work for quotes as-is. Add the page number block to match invoice behavior.

🛠️ Proposed fix
-        $mpdf->DefHTMLFooterByName('footerWithPageNumbers', '<div id="footer">' . $CI->mdl_settings->settings['pdf_invoice_footer'] . '</div><div id="footer">' . $CI->mdl_settings->settings['pdf_quote_footer'] . '</div>');
+        $mpdf->DefHTMLFooterByName(
+            'footerWithPageNumbers',
+            '<div id="footer">' . $CI->mdl_settings->settings['pdf_invoice_footer'] . '</div>'
+            . '<div id="footer">' . $CI->mdl_settings->settings['pdf_quote_footer'] . '</div>'
+            . '<div><p align="center">' . str_replace('_', ' ', $filename) . ' - ' . trans('page') . ' {PAGENO} / {nbpg}</p></div>'
+        );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$mpdf->DefHTMLFooterByName('footerWithPageNumbers', '<div id="footer">' . $CI->mdl_settings->settings['pdf_invoice_footer'] . '</div><div id="footer">' . $CI->mdl_settings->settings['pdf_quote_footer'] . '</div>');
$mpdf->DefHTMLFooterByName('footer', '<div id="footer">' . $CI->mdl_settings->settings['pdf_quote_footer'] . '</div>');
$mpdf->DefHTMLFooterByName('defaultFooter', '<div id="footer">' . $CI->mdl_settings->settings['pdf_quote_footer'] . '</div>');
$mpdf->DefHTMLFooterByName(
'footerWithPageNumbers',
'<div id="footer">' . $CI->mdl_settings->settings['pdf_invoice_footer'] . '</div>'
. '<div id="footer">' . $CI->mdl_settings->settings['pdf_quote_footer'] . '</div>'
. '<div><p align="center">' . str_replace('_', ' ', $filename) . ' - ' . trans('page') . ' {PAGENO} / {nbpg}</p></div>'
);
$mpdf->DefHTMLFooterByName('footer', '<div id="footer">' . $CI->mdl_settings->settings['pdf_quote_footer'] . '</div>');
$mpdf->DefHTMLFooterByName('defaultFooter', '<div id="footer">' . $CI->mdl_settings->settings['pdf_quote_footer'] . '</div>');
🤖 Prompt for AI Agents
In `@application/helpers/mpdf_helper.php` around lines 97 - 99, The
'footerWithPageNumbers' footer defined via mpdf->DefHTMLFooterByName currently
only injects the quote/invoice footer HTML and omits the page-number block, so
the quote page-number toggle won't display; update the
mpdf->DefHTMLFooterByName('footerWithPageNumbers', ...) call to include the same
page-number HTML used for invoices (i.e., the page number container/placeholder
used elsewhere) alongside CI->mdl_settings->settings['pdf_quote_footer'] (and
invoice footer if intended) so its behavior matches the invoice footer; ensure
the other footer definitions (footer, defaultFooter) remain unchanged.

Comment on lines +122 to +126
// If we can't get invoice_id from captures, try to get it from order details
if ( ! $invoice_id) {
$order_details = json_decode($this->lib_paypal->showOrderDetails($order_id));
$invoice_id = $order_details->purchase_units[0]->payments->captures[0]->invoice_id ?? null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Bug: showOrderDetails returns array, not JSON string.

Based on the PaypalLib.php snippet, showOrderDetails returns ['status' => bool, 'response' => $response] where $response is a Guzzle response object. The current code attempts to json_decode the array directly, which will return null.

Proposed fix
                 // If we can't get invoice_id from captures, try to get it from order details
                 if ( ! $invoice_id) {
-                    $order_details = json_decode($this->lib_paypal->showOrderDetails($order_id));
-                    $invoice_id    = $order_details->purchase_units[0]->payments->captures[0]->invoice_id ?? null;
+                    $order_result = $this->lib_paypal->showOrderDetails($order_id);
+                    if ($order_result['status']) {
+                        $order_details = json_decode($order_result['response']->getBody());
+                        $invoice_id    = $order_details->purchase_units[0]->payments->captures[0]->invoice_id ?? null;
+                    }
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// If we can't get invoice_id from captures, try to get it from order details
if ( ! $invoice_id) {
$order_details = json_decode($this->lib_paypal->showOrderDetails($order_id));
$invoice_id = $order_details->purchase_units[0]->payments->captures[0]->invoice_id ?? null;
}
// If we can't get invoice_id from captures, try to get it from order details
if ( ! $invoice_id) {
$order_result = $this->lib_paypal->showOrderDetails($order_id);
if ($order_result['status']) {
$order_details = json_decode($order_result['response']->getBody());
$invoice_id = $order_details->purchase_units[0]->payments->captures[0]->invoice_id ?? null;
}
}
🤖 Prompt for AI Agents
In `@application/modules/guest/controllers/gateways/Paypal.php` around lines 122 -
126, The code incorrectly json_decodes the return of
$this->lib_paypal->showOrderDetails($order_id) because showOrderDetails returns
an array with ['status'=>..., 'response'=><GuzzleResponse>]; update the logic to
call showOrderDetails into a variable (e.g. $result), verify $result['status']
is true, extract the Guzzle response via $result['response'], read its body
(->getBody()->getContents() or cast to string), json_decode that body to
populate $order_details, then pull invoice_id from
$order_details->purchase_units[0]->payments->captures[0]->invoice_id (with null
checks); replace the existing direct
json_decode($this->lib_paypal->showOrderDetails(...)) usage accordingly and
handle failure cases when status is false or body decoding fails.

Comment on lines +50 to +51
<?php endif; ?>
</div>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

HTML structure issue: endif closes inside wrong container.

The <?php endif; ?> on line 50 is inside the col-xs-12 div that started on line 21, but the div on line 51 closes the card container. This creates mismatched nesting when $adv_enabled is false - the outer div from line 21 won't be closed.

Suggested fix
         </div>
-    <?php endif; ?>
 </div>
+<?php endif; ?>

Or restructure so the conditional wraps complete HTML blocks.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<?php endif; ?>
</div>
</div>
<?php endif; ?>
🤖 Prompt for AI Agents
In `@application/modules/guest/views/gateways/paypal.php` around lines 50 - 51,
The PHP conditional closing tag "<?php endif; ?>" is currently placed inside the
inner "col-xs-12" div causing mismatched HTML when $adv_enabled is false; move
the "<?php endif; ?>" so the conditional wraps the entire card block (or
relocate it to after the outer card container's closing </div>) ensuring the
outer div that began at the "col-xs-12" start is always closed, and verify that
the conditional opening for $adv_enabled and its corresponding "<?php endif; ?>"
enclose complete HTML elements rather than splitting container tags.

Comment on lines +214 to +215
$minQuantity = (int) $minQuantity;
$maxQuantity = (int) $maxQuantity;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check if the file exists and get an overview
file="application/modules/reports/models/Mdl_reports.php"
if [ -f "$file" ]; then
  wc -l "$file"
else
  echo "File not found at expected path"
  find . -name "Mdl_reports.php" -type f 2>/dev/null
fi

Repository: InvoicePlane/InvoicePlane

Length of output: 123


🏁 Script executed:

# Read the context around lines 214-215 (casting section)
sed -n '200,230p' application/modules/reports/models/Mdl_reports.php | cat -n

Repository: InvoicePlane/InvoicePlane

Length of output: 1342


🏁 Script executed:

# Read the context around lines 564-575 (mentioned in scratchpad)
sed -n '550,590p' application/modules/reports/models/Mdl_reports.php | cat -n

Repository: InvoicePlane/InvoicePlane

Length of output: 3010


🏁 Script executed:

# Read the context around lines 687-688 (mentioned in scratchpad)
sed -n '675,700p' application/modules/reports/models/Mdl_reports.php | cat -n

Repository: InvoicePlane/InvoicePlane

Length of output: 1837


🏁 Script executed:

# Search for where minQuantity and maxQuantity are defined/used
rg -n "minQuantity|maxQuantity" application/modules/reports/models/Mdl_reports.php -B 2 -A 2

Repository: InvoicePlane/InvoicePlane

Length of output: 3502


Remove integer casting; thresholds should preserve decimal precision when compared against monetary totals.

The (int) casts at lines 214-215 truncate cents from monetary thresholds. Since the SQL compares these against SUM(invoice_item_subtotal) and SUM(invoice_total) (decimal values), integer casting changes report filtering accuracy. Additionally, if ($maxQuantity) at line 228 would fail for a legitimate zero maximum. Either preserve decimal precision using (float) or null checks, or clarify whether these are truly intended as quantity filters (in which case the variable names and SQL logic need correction).

🤖 Prompt for AI Agents
In `@application/modules/reports/models/Mdl_reports.php` around lines 214 - 215,
Remove the (int) casts on $minQuantity and $maxQuantity so monetary thresholds
keep decimal precision (these are compared against
SUM(invoice_item_subtotal)/SUM(invoice_total)), and instead cast to (float) or
validate with is_numeric() to preserve cents; also change the truthy check from
if ($maxQuantity) to an explicit numeric/null check (e.g., if
(is_numeric($maxQuantity)) or if ($maxQuantity !== null && $maxQuantity !== '')
) so a legitimate zero maximum is honored; update any related variable usage or
SQL parameter binding in the same function to use the new float/validated values
(or rename if these are actually quantities vs monetary thresholds).

Comment on lines +217 to +218
$from_date = $from_date == '' ? date('Y-m-d') : date_to_mysql($from_date);
$to_date = $to_date == '' ? date('Y-m-d') : date_to_mysql($to_date);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the file structure and locate the file
find . -name "Mdl_reports.php" -type f

Repository: InvoicePlane/InvoicePlane

Length of output: 194


🏁 Script executed:

# Read the relevant section with context around lines 217-218
sed -n '200,235p' application/modules/reports/models/Mdl_reports.php | cat -n

Repository: InvoicePlane/InvoicePlane

Length of output: 1603


🏁 Script executed:

# Get the full function to understand the context
# First, let's see the size of the file
wc -l application/modules/reports/models/Mdl_reports.php

Repository: InvoicePlane/InvoicePlane

Length of output: 123


🏁 Script executed:

# Search for function definition around line 217
sed -n '180,220p' application/modules/reports/models/Mdl_reports.php | cat -n

Repository: InvoicePlane/InvoicePlane

Length of output: 1774


🏁 Script executed:

# Search for how sales_by_year is called in controllers
rg "sales_by_year" --type php -B 2 -A 2

Repository: InvoicePlane/InvoicePlane

Length of output: 2880


🏁 Script executed:

# Look for tests related to date handling or sales_by_year
find . -name "*test*" -o -name "*Test*" | grep -i report

Repository: InvoicePlane/InvoicePlane

Length of output: 51


🏁 Script executed:

# Check if there's a controller that calls this method
rg "sales_by_year" --type php -l

Repository: InvoicePlane/InvoicePlane

Length of output: 224


🏁 Script executed:

# Search for date_to_mysql function to understand what it does
rg "function date_to_mysql" --type php -A 5

Repository: InvoicePlane/InvoicePlane

Length of output: 474


🏁 Script executed:

# Check how null vs empty string is handled elsewhere in date processing
rg "== ''" --type php | grep -i date | head -20

Repository: InvoicePlane/InvoicePlane

Length of output: 596


🏁 Script executed:

# Now let's see how from_date and to_date are used in the sales_by_year method
sed -n '200,350p' application/modules/reports/models/Mdl_reports.php | cat -n

Repository: InvoicePlane/InvoicePlane

Length of output: 9753


🏁 Script executed:

# Check if there are WHERE clauses that use these dates
rg "from_date|to_date" application/modules/reports/models/Mdl_reports.php -A 2 -B 2

Repository: InvoicePlane/InvoicePlane

Length of output: 18046


🏁 Script executed:

# Look at the view that displays the form to understand if dates are optional
find . -path "*reports*" -name "*sales_by_year*" -type f

Repository: InvoicePlane/InvoicePlane

Length of output: 174


🏁 Script executed:

# Check if dates can be null/empty in the form
rg "from_date|to_date" application/modules/reports/views/ --type php | head -30

Repository: InvoicePlane/InvoicePlane

Length of output: 3199


🏁 Script executed:

# Look at what date_to_mysql does with null input
cat application/helpers/date_helper.php | head -50

Repository: InvoicePlane/InvoicePlane

Length of output: 1415


🏁 Script executed:

# Look at the controller method that calls sales_by_year
sed -n '1,100p' application/modules/reports/controllers/Reports.php | grep -A 20 "public function sales_by_year"

Repository: InvoicePlane/InvoicePlane

Length of output: 51


🏁 Script executed:

# Compare the handling in the controller vs the model for different methods
rg "sales_by_client|payment_history|sales_by_year" application/modules/reports/controllers/Reports.php -B 2 -A 5

Repository: InvoicePlane/InvoicePlane

Length of output: 2405


Reconsider defaulting empty dates to "today"—inconsistent with other report methods.

This defaults empty/NULL date inputs to the current day, which differs from sales_by_client and payment_history methods that allow optional date filtering. Users leaving date fields blank expecting "all time" results will instead get only today's data. If intentional, confirm; otherwise, follow the pattern of skipping date filters when dates are not provided.

🤖 Prompt for AI Agents
In `@application/modules/reports/models/Mdl_reports.php` around lines 217 - 218,
The current logic in Mdl_reports.php sets $from_date and $to_date to today's
date when empty, causing mandatory filtering to today; change this to match the
pattern used by sales_by_client and payment_history by leaving $from_date and
$to_date as NULL/empty when the inputs are empty and only applying the date
WHERE clauses if those variables are provided. Update the handling around
date_to_mysql(...) and any subsequent query-building in the same function
(referencing $from_date, $to_date, and date_to_mysql) so date filters are
skipped when inputs are not supplied rather than defaulting to date('Y-m-d').

Comment on lines +331 to +359
private function _is_ip_rate_limited_password_reset()
{
$max_attempts = env('PASSWORD_RESET_IP_MAX_ATTEMPTS', 5);
$window_minutes = env('PASSWORD_RESET_IP_WINDOW_MINUTES', 60);

$ip_address = $this->input->ip_address();
$session_key = 'password_reset_attempts_' . md5($ip_address);

// Get current attempts from session
$attempts = $this->session->userdata($session_key);

if (!$attempts) {
$attempts = [];
}

// Clean up old attempts outside the time window
$cutoff_time = time() - ($window_minutes * 60);
$attempts = array_filter($attempts, function($timestamp) use ($cutoff_time) {
return $timestamp > $cutoff_time;
});

// Check if rate limited
if (count($attempts) >= $max_attempts) {
log_message('info', trans('log_ip_rate_limit_check') . ': ' . count($attempts) . ' attempts from IP: ' . $ip_address);
return true;
}

return false;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Rate‑limit pruning isn’t persisted (leads to permanent lockout)

You filter out old attempts but never write the filtered list back to the session. Old timestamps accumulate and eventually always exceed the limit, even after the window should expire.

🔧 Proposed fix
-        $attempts = array_filter($attempts, function($timestamp) use ($cutoff_time) {
+        $attempts = array_filter($attempts, function($timestamp) use ($cutoff_time) {
             return $timestamp > $cutoff_time;
         });
+        $this->session->set_userdata($session_key, $attempts);
...
-        $attempts = array_filter($attempts, function($timestamp) use ($cutoff_time) {
+        $attempts = array_filter($attempts, function($timestamp) use ($cutoff_time) {
             return $timestamp > $cutoff_time;
         });
+        $this->session->set_userdata($session_key, $attempts);

Also applies to: 392-419

🤖 Prompt for AI Agents
In `@application/modules/sessions/controllers/Sessions.php` around lines 331 -
359, The function _is_ip_rate_limited_password_reset filters out old timestamps
but never persists the pruned array back to session, causing permanent lockout;
update the session after pruning by calling the session setter with the same
session key (session_key built from 'password_reset_attempts_' .
md5($ip_address)) e.g. persist the filtered $attempts via
$this->session->set_userdata($session_key, $attempts) before returning, and
apply the same fix to the other similar block referenced (lines 392-419) so both
pruning operations write the cleaned list back to session.

Comment thread assets/core/js/paypal.js
Comment on lines +217 to +218
onApprove: function(data) {
return fetch(config.capturePaymentUrl + data.orderID, { method: 'GET' })
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for paypal capture payment endpoint implementation
rg -n 'paypal_capture_payment|capturePaymentUrl|capture.*payment' --type=php -A 5 -B 2

Repository: InvoicePlane/InvoicePlane

Length of output: 2887


🏁 Script executed:

#!/bin/bash
# Also check the context in the JavaScript file
rg -n 'capturePaymentUrl|onApprove' assets/core/js/paypal.js -B 5 -A 10

Repository: InvoicePlane/InvoicePlane

Length of output: 1957


🏁 Script executed:

#!/bin/bash
# Look for any configuration or comments about GET vs POST
rg -n 'GET|POST|method.*payment|capture' assets/core/js/paypal.js -B 2 -A 2

Repository: InvoicePlane/InvoicePlane

Length of output: 2141


🏁 Script executed:

#!/bin/bash
# Check for overall PayPal integration structure
git ls-files | grep -i paypal

Repository: InvoicePlane/InvoicePlane

Length of output: 275


Change payment capture to use POST method instead of GET.

Using GET for capturePaymentUrl violates HTTP best practices for state-changing operations. Payment capture modifies server state and should use POST, not GET. The current implementation exposes the sensitive order ID in browser history, server logs, and referrer headers, creating unnecessary security and privacy risks. Update the fetch call at line 218 (and line 250) to use method: 'POST' and adjust the server-side controller to accept POST requests instead.

🤖 Prompt for AI Agents
In `@assets/core/js/paypal.js` around lines 217 - 218, The fetch calls that
capture payments currently use GET and should be changed to POST: update the
onApprove handler's fetch invocation (referenced by onApprove and
config.capturePaymentUrl) to use method: 'POST' instead of 'GET' and likewise
update the second fetch call at the other occurrence around line 250; also
ensure the server-side payment capture controller is modified to accept POST
requests and read the order ID from the request body (or POST parameters) rather
than the URL/query string.

Comment thread index.php
Comment on lines +306 to +311
$files = array_merge(
glob(UPLOADS_TEMP_FOLDER . '*.pdf'),
glob(UPLOADS_TEMP_FOLDER . '*.xml')
);

array_map('unlink', $files);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

PHP glob() function return value false error empty array documentation

💡 Result:

Short answer: glob() returns an array of matched pathnames; it returns an empty array when no files match, and FALSE on error. Some platforms may make it impossible to distinguish an empty match from an error. See PHP manual. [1][2]

References

  • PHP manual: glob() — Return values and flags. [1]
  • Note about platform differences / indistinguishable empty match vs error. [2]

Sources
[1] https://www.php.net/manual/en/function.glob.php
[2] https://www.php-resource.de/en/php-manual/function.glob.html


🌐 Web query:

PHP array_merge() warning false non-array argument type error

💡 Result:

Cause: array_merge() accepts only arrays — passing false, null, a scalar, or an empty/non-array value triggers warnings or unexpected results. PHP 7.4+ allows calling array_merge() with no args but still requires each argument to be an array. [1][3]

Fixes (pick one that fits your code):

  • Ensure the value is an array before merging:

    • if (is_array($x)) $res = array_merge($res, $x);. [2]
  • Cast to array (quickest):

    • $res = array_merge($res, (array)$maybeArray); — turns null/false/scalar into an array. [1][2]
  • Use a safe default with null coalescing or ternary:

    • $res = array_merge($res, $maybeArray ?? []); or $res = array_merge($res, is_array($maybeArray) ? $maybeArray : []);. [1]
  • If using PHP 7.4+ and spread operator, ensure the source is a non-empty array of arrays (avoid spreading empty or non-array elements):

    • $res = array_merge(...$arrays); // only when $arrays contains only arrays. [1][3]

Example:

  • Bad: array_merge($a, $b) where $b can be false/null.
  • Safe: array_merge($a, (array)$b) or array_merge($a, $b ?? []). [1][2]

References:

  • PHP manual: array_merge (behavior, casting example). [1]
  • Common fixes on StackOverflow (cast to array / is_array check). [2]
  • Static-analysis guidance / defaults: use [[]] or cast when collecting arrays. [3]

Guard against glob() returning false before array_merge.
If the temp folder is unreadable or missing, glob() can return false, which triggers warnings in array_merge. Normalizing to empty arrays keeps cleanup silent and resilient.

🔧 Suggested hardening
-$files = array_merge(
-    glob(UPLOADS_TEMP_FOLDER . '*.pdf'),
-    glob(UPLOADS_TEMP_FOLDER . '*.xml')
-);
+$files = array_merge(
+    glob(UPLOADS_TEMP_FOLDER . '*.pdf') ?: [],
+    glob(UPLOADS_TEMP_FOLDER . '*.xml') ?: []
+);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$files = array_merge(
glob(UPLOADS_TEMP_FOLDER . '*.pdf'),
glob(UPLOADS_TEMP_FOLDER . '*.xml')
);
array_map('unlink', $files);
$files = array_merge(
glob(UPLOADS_TEMP_FOLDER . '*.pdf') ?: [],
glob(UPLOADS_TEMP_FOLDER . '*.xml') ?: []
);
array_map('unlink', $files);
🤖 Prompt for AI Agents
In `@index.php` around lines 306 - 311, The cleanup code assumes glob() returns
arrays but glob() can return false, causing warnings when passed to array_merge;
update the $files assembly to normalize glob results to arrays (e.g., coerce
each glob(UPLOADS_TEMP_FOLDER . '*.pdf') and glob(UPLOADS_TEMP_FOLDER . '*.xml')
to [] when false) before calling array_merge, then proceed with
array_map('unlink', $files) so unlink only receives a proper array; reference
the existing symbols $files, array_merge, glob, UPLOADS_TEMP_FOLDER, array_map
and unlink when making the change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.