Skip to content

Feat: new invoice status#1419

Draft
nielsdrost7 wants to merge 3 commits intoprep/v172from
feat/1402-new-invoice-status
Draft

Feat: new invoice status#1419
nielsdrost7 wants to merge 3 commits intoprep/v172from
feat/1402-new-invoice-status

Conversation

@nielsdrost7
Copy link
Copy Markdown
Contributor

@nielsdrost7 nielsdrost7 commented Jan 20, 2026

Summary by CodeRabbit

  • New Features

    • Added unpaid, partial, and archived invoice status categories with corresponding filter buttons
    • New PDF invoice templates for partial and unpaid invoices
    • Automated smoke test workflow for continuous validation
  • Style

    • Updated color scheme for invoice status labels
  • Chores

    • Default settings now install during initial setup

@nielsdrost7 nielsdrost7 changed the base branch from prep/v172 to prep/v171 January 20, 2026 09:01
@InvoicePlane InvoicePlane deleted a comment from coderabbitai bot Jan 20, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 16, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fbd78b8e-0f60-4fec-94d3-28be49436aff

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1402-new-invoice-status

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.

@nielsdrost7 nielsdrost7 changed the base branch from tagged/v171 to prep/v172 February 16, 2026 14:59
@nielsdrost7 nielsdrost7 force-pushed the prep/v172 branch 2 times, most recently from d314f64 to 5b927da Compare April 16, 2026 15:06
@nielsdrost7 nielsdrost7 force-pushed the feat/1402-new-invoice-status branch from f2473e2 to e3d7a1d Compare April 18, 2026 05:35
@nielsdrost7 nielsdrost7 force-pushed the feat/1402-new-invoice-status branch from d650192 to a60d3dd Compare April 18, 2026 05:39
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: 12

Caution

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

⚠️ Outside diff range comments (2)
assets/core/scss/_core.scss (1)

671-686: ⚠️ Potential issue | 🟠 Major

Fix the archived typo and add the unpaid label style.

&.archvied will not match archived, and unpaid has no label background. With .label forcing white text, those statuses can render without a readable background.

Proposed fix
-  &.archvied,
+  &.archived {
+    background-color: lighten($color_status_archived, 10%);
+  }
+
   &.draft {
     background-color: lighten($color_status_draft, 10%);
   }
@@
   &.partial {
     background-color: lighten($color_status_partial, 10%);
   }
+
+  &.unpaid {
+    background-color: lighten($color_status_unpaid, 10%);
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@assets/core/scss/_core.scss` around lines 671 - 686, Rename the mistaken
selector &.archvied to &.archived and add a new status selector &.unpaid that
sets a background color (use the same pattern as &.sent / &.viewed:
lighten($color_status_unpaid, 10%) or the appropriate unpaid color variable) so
labels with .label (which forces white text) have a readable background; update
the block containing &.draft, &.sent, &.viewed, &.partial to include &.archived
and &.unpaid following the same lighten(...) pattern.
application/modules/reports/models/Mdl_reports.php (1)

555-586: ⚠️ Potential issue | 🟠 Major

Remove the (int) cast before escaping; it converts the quoted SQL literal to zero.

Line 564 applies an integer cast to an escaped value. Since $this->db->escape() returns a quoted SQL string (e.g., '100'), casting it to (int) yields 0, breaking the minimum quantity filter in the tax-included report.

Fix
-                                    AND ' . (int) $this->db->escape($minQuantity) . ' <=
+                                    AND ' . $this->db->escape($minQuantity) . ' <=

The parameter is already cast to int at line 214, making the second cast redundant and harmful. The correct pattern appears at lines 326, 449, and 687 without the extra cast.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@application/modules/reports/models/Mdl_reports.php` around lines 555 - 586,
The where-clause uses an erroneous (int) cast around
$this->db->escape($minQuantity) which turns the quoted SQL literal into 0;
remove the (int) cast so the code uses $this->db->escape($minQuantity) directly
(leave $this->db->escape($maxQuantity) as-is), locating the change in
Mdl_reports.php in the block that builds the nested SELECTs (look for
$this->db->where(... and the uses of $this->db->escape, $minQuantity and
$maxQuantity) to correct the filter.
🧹 Nitpick comments (2)
application/modules/invoices/views/index.php (1)

37-56: Use strict comparisons and escape the new view attributes.

The added links should follow the view policy for html_escape() and ===.

Proposed fix
-            <a href="<?php echo site_url('invoices/status/partial'); ?>"
-               class="btn  <?php echo $status == 'partial' ? 'btn-primary' : 'btn-default' ?>">
+            <a href="<?php echo html_escape(site_url('invoices/status/partial')); ?>"
+               class="btn <?php echo $status === 'partial' ? 'btn-primary' : 'btn-default'; ?>">
                 <?php _trans('partially paid'); ?>
             </a>
@@
-            <a href="<?php echo site_url('invoices/status/unpaid'); ?>"
-               class="btn  <?php echo $status == 'unpaid' ? 'btn-primary' : 'btn-default' ?>">
+            <a href="<?php echo html_escape(site_url('invoices/status/unpaid')); ?>"
+               class="btn <?php echo $status === 'unpaid' ? 'btn-primary' : 'btn-default'; ?>">
                 <?php _trans('unpaid'); ?>
             </a>
@@
-            <a href="<?php echo site_url('invoices/status/archived'); ?>"
-               class="btn  <?php echo $status == 'archived' ? 'btn-primary' : 'btn-default' ?>">
+            <a href="<?php echo html_escape(site_url('invoices/status/archived')); ?>"
+               class="btn <?php echo $status === 'archived' ? 'btn-primary' : 'btn-default'; ?>">
                 <?php _trans('archived'); ?>
             </a>
@@
-                <a href="<?php echo site_url('invoices/status/partial'); ?>"
-                   class="btn  <?php echo $status == 'partial' ? 'btn-primary' : 'btn-default' ?>">
+                <a href="<?php echo html_escape(site_url('invoices/status/partial')); ?>"
+                   class="btn <?php echo $status === 'partial' ? 'btn-primary' : 'btn-default'; ?>">
                     <?php _trans('partially paid'); ?>
                 </a>
@@
-                <a href="<?php echo site_url('invoices/status/unpaid'); ?>"
-                   class="btn  <?php echo $status == 'unpaid' ? 'btn-primary' : 'btn-default' ?>">
+                <a href="<?php echo html_escape(site_url('invoices/status/unpaid')); ?>"
+                   class="btn <?php echo $status === 'unpaid' ? 'btn-primary' : 'btn-default'; ?>">
                     <?php _trans('unpaid'); ?>
                 </a>
@@
-                <a href="<?php echo site_url('invoices/status/archived'); ?>"
-                   class="btn  <?php echo $status == 'archived' ? 'btn-primary' : 'btn-default' ?>">
+                <a href="<?php echo html_escape(site_url('invoices/status/archived')); ?>"
+                   class="btn <?php echo $status === 'archived' ? 'btn-primary' : 'btn-default'; ?>">
                     <?php _trans('archived'); ?>
                 </a>

As per coding guidelines, **/*.php: Use strict comparison (===, !==) instead of loose comparison; Always use html_escape() for output encoding in views to prevent XSS vulnerabilities.

Also applies to: 87-106

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@application/modules/invoices/views/index.php` around lines 37 - 56, The view
uses loose comparisons and unescaped outputs; update each anchor's class
conditional to use strict comparison (===) against $status (e.g., $status ===
'partial') and wrap any dynamic output used in attributes or displayed text with
html_escape(), including the site_url() output and the translation output from
_trans() (e.g., class="<?php echo html_escape($status === 'partial' ?
'btn-primary' : 'btn-default') ?>" and <?php echo html_escape(_trans('partially
paid')); ?> or html_escape(site_url(...)) for hrefs). Apply the same
strict-comparison and html_escape() changes to the other similar blocks (the
later invoice status link group as well).
application/third_party/MX/Controller.php (1)

54-58: Restore the block indentation.

The changed assignments should stay indented inside the if/else blocks.

Proposed fix
         if (CI::$APP->config->item('controller_suffix') == null) {
-        $class = str_replace('', '', get_class($this));
+            $class = str_replace('', '', get_class($this));
         } else {
-        $class = str_replace(CI::$APP->config->item('controller_suffix'), '', get_class($this));
+            $class = str_replace(CI::$APP->config->item('controller_suffix'), '', get_class($this));
         }

As per coding guidelines, **/*.php: Follow PSR-12 coding standards.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@application/third_party/MX/Controller.php` around lines 54 - 58, The
assignment to $class has been unindented and should be nested inside the if/else
block; restore proper block indentation so the $class = str_replace(...) lines
remain inside the if and else branches that check
CI::$APP->config->item('controller_suffix'), keeping the existing condition
logic using get_class($this) and str_replace intact and following PSR-12 block
indentation for the Controller class in Controller.php.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.junie/PR-1441-security-dry-analysis.md:
- Around line 1-424: The file .junie/PR-1441-security-dry-analysis.md is an
unrelated analysis for PR `#1441` and must be removed from this PR; delete that
file from the branch (or revert the commit that added it) so only changes
relevant to PR `#1419` remain, then amend the branch/commit history and push the
update; also scan for any references to .junie/PR-1441-security-dry-analysis.md
in the diff or CI/config and remove those references if present.

In `@application/modules/invoices/models/Mdl_invoices.php`:
- Line 90: The overdue CASE currently checks ip_invoices.invoice_status_id NOT
IN (1,4,5,7) but doesn't exclude the archived status; update the condition in
Mdl_invoices.php where (CASE WHEN ip_invoices.invoice_status_id NOT IN (1,4,5,7)
AND DATEDIFF(NOW(), invoice_date_due) > 0 THEN 1 ELSE 0 END) is_overdue to also
exclude the archived status by adding its ID to the NOT IN list (or, if a
constant exists, use that constant such as INVOICE_STATUS_ARCHIVED) so archived
invoices are never marked is_overdue.

In `@application/modules/setup/models/Mdl_setup.php`:
- Around line 60-109: Fix the syntax error and duplicate method: change the
malformed insert closure that uses "));" to "]);" for the ip_payment_methods
insert, remove the entire duplicate private function install_default_settings()
block shown in the diff, and merge the additional default keys from that removed
block (notably 'pdf_invoice_template_unpaid' and 'pdf_invoice_template_partial'
— and any other template defaults present) into the existing
install_default_settings() implementation (the single install_default_settings
function already defined elsewhere) so all defaults are present once.

In `@application/views/invoice_templates/pdf/InvoicePlane` - partial.php:
- Line 308: The watermark uses a capitalized translation key _trans('Partial')
which can miss the existing lowercase key; update the watermarktext content call
(watermarktext content="<?php _trans('Partial'); ?>") to use the lowercase key
_trans('partial') so it matches the status label translation and resolves the
missing translation.
- Around line 31-36: Escape user-controlled invoice fields by wrapping outputs
like $invoice->user_vat_id and $invoice->user_tax_code with html_escape() before
echoing them, and apply the same treatment to other custom fields referenced
around lines 132-137 and 170-177 (e.g., any $invoice->custom_* or similar). Also
fix the Sales Person guard by checking the "Sales Person" field exists/is
non-empty before reading it (do the Sales Person existence check prior to or
instead of relying on the "Lead Source" check) to avoid PHP notices and raw HTML
output; update the view logic (the conditional that reads Sales Person) to first
verify the Sales Person property on $invoice is set and then echo
html_escape($invoice->sales_person) (or the exact property name used in the
diff).
- Around line 190-205: Rows are rendering description before name, which is the
reverse of the table headers; inside the foreach ($items as $item) block swap
the two <td> cells so the first cell outputs the item name and the second
outputs the item description — specifically swap the outputs using
_htmlsc($item->item_name) and nl2br(htmlsc($item->item_description)) in the <td>
elements for the row so they align with the <th class="item-name"> and <th
class="item-desc"> headers.
- Around line 310-324: The footer partial currently contains hard-coded Arox
Marketing legal copy and links inside the <footer> block (the table with class
"w-10" and the "More Links" table); remove this company-specific static HTML and
instead render configurable invoice/footer settings (e.g., use the existing
invoice footer/terms variables or settings such as invoice_footer_text,
invoice_terms, or similar template variables) so site owners control the text
and links; if no such settings exist, replace the static block with a call to
the central footer/terms renderer or leave it empty until a configurable value
is provided.

In `@application/views/invoice_templates/pdf/InvoicePlane` - unpaid.php:
- Around line 31-36: The view is outputting user-controlled fields without
escaping and is reading Sales Person before guarding for its existence; update
the usages of $invoice->user_vat_id and $invoice->user_tax_code (and the other
mentioned blocks at lines ~186-190 and ~224-231) to wrap values with
html_escape() before echoing, and change the Sales Person/Lead Source checks so
you verify isset($invoice->sales_person) (or !empty($invoice->sales_person))
before referencing or echoing it (i.e., check the Sales Person guard prior to
reading/printing it, and only then check Lead Source), ensuring all
user-controlled invoice properties are html_escape()'d in the InvoicePlane -
unpaid.php view.
- Around line 244-259: Row cells inside the foreach loop are emitted in the
wrong order: the code outputs item_description then item_name, but the headers
expect item (name) then description; to fix, swap the two <td> outputs inside
the foreach so the cell that renders $item->item_name (using _htmlsc) appears
first and the cell that renders $item->item_description (using echo
nl2br(htmlsc(...))) appears second, keeping their existing classes (e.g., "py-2
px-1 bt") and any formatting intact.
- Around line 364-378: Remove the hard-coded Arox Marketing legal copy and links
from the footer block in the "InvoicePlane - unpaid.php" template and replace
them with the application's configurable invoice/footer settings (e.g. use the
existing invoice terms/footer variables or settings API such as $invoice_terms
or $invoice_footer provided by the app) so default installs don't ship
company-specific links; update the footer HTML in the template to render those
configurable variables (falling back to an empty or generic notice if unset)
rather than the static Arox Marketing text and anchors.

In `@assets/invoiceplane_blue/sass/_ip_variables.scss`:
- Around line 15-30: The blue theme currently reuses the bright status colors
(e.g. $color_status_unpaid, $color_status_partial, $color_status_viewed,
$color_status_rejected_overdue) which fail contrast with white badge text—update
the blue-theme variables or badge text color so contrast meets accessibility
standards: locate the blue theme's SCSS variables for those same status names
(or the badge component that uses them) and either replace the bright hexes with
darker alternatives or set badge text to a dark color (e.g. switch from white to
a high-contrast text variable) for badges using $color_status_unpaid,
$color_status_partial, $color_status_viewed, $color_status_rejected_overdue and
any other bright status variables to ensure readable contrast.

In `@assets/invoiceplane/sass/_ip_variables.scss`:
- Around line 15-30: The status label colors $color_status_unpaid,
$color_status_partial and $color_status_viewed fail WCAG AA contrast for white
text in the .label styling; fix by either darkening those three variables to
values that reach at least 3:1 contrast against white (replace the hexes for
$color_status_unpaid, $color_status_partial, $color_status_viewed with darker
hexes) or change the label text for those states to a dark text token (e.g. use
an existing $color_text_dark) and update any logic that applies .label text
color so these three statuses use the dark text; ensure after change each status
meets the 3:1 contrast requirement.

---

Outside diff comments:
In `@application/modules/reports/models/Mdl_reports.php`:
- Around line 555-586: The where-clause uses an erroneous (int) cast around
$this->db->escape($minQuantity) which turns the quoted SQL literal into 0;
remove the (int) cast so the code uses $this->db->escape($minQuantity) directly
(leave $this->db->escape($maxQuantity) as-is), locating the change in
Mdl_reports.php in the block that builds the nested SELECTs (look for
$this->db->where(... and the uses of $this->db->escape, $minQuantity and
$maxQuantity) to correct the filter.

In `@assets/core/scss/_core.scss`:
- Around line 671-686: Rename the mistaken selector &.archvied to &.archived and
add a new status selector &.unpaid that sets a background color (use the same
pattern as &.sent / &.viewed: lighten($color_status_unpaid, 10%) or the
appropriate unpaid color variable) so labels with .label (which forces white
text) have a readable background; update the block containing &.draft, &.sent,
&.viewed, &.partial to include &.archived and &.unpaid following the same
lighten(...) pattern.

---

Nitpick comments:
In `@application/modules/invoices/views/index.php`:
- Around line 37-56: The view uses loose comparisons and unescaped outputs;
update each anchor's class conditional to use strict comparison (===) against
$status (e.g., $status === 'partial') and wrap any dynamic output used in
attributes or displayed text with html_escape(), including the site_url() output
and the translation output from _trans() (e.g., class="<?php echo
html_escape($status === 'partial' ? 'btn-primary' : 'btn-default') ?>" and <?php
echo html_escape(_trans('partially paid')); ?> or html_escape(site_url(...)) for
hrefs). Apply the same strict-comparison and html_escape() changes to the other
similar blocks (the later invoice status link group as well).

In `@application/third_party/MX/Controller.php`:
- Around line 54-58: The assignment to $class has been unindented and should be
nested inside the if/else block; restore proper block indentation so the $class
= str_replace(...) lines remain inside the if and else branches that check
CI::$APP->config->item('controller_suffix'), keeping the existing condition
logic using get_class($this) and str_replace intact and following PSR-12 block
indentation for the Controller class in Controller.php.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8b3331dc-bfa4-4f0b-b1bc-e83a4dd5b935

📥 Commits

Reviewing files that changed from the base of the PR and between 15ea0a8 and a60d3dd.

📒 Files selected for processing (15)
  • .github/workflows/quickstart.yml
  • .junie/PR-1441-security-dry-analysis.md
  • application/modules/invoices/controllers/Invoices.php
  • application/modules/invoices/models/Mdl_invoices.php
  • application/modules/invoices/views/index.php
  • application/modules/reports/models/Mdl_reports.php
  • application/modules/setup/models/Mdl_setup.php
  • application/modules/setup/sql/016_1.4.4.sql
  • application/third_party/MX/Controller.php
  • application/views/invoice_templates/pdf/.gitignore
  • application/views/invoice_templates/pdf/InvoicePlane - partial.php
  • application/views/invoice_templates/pdf/InvoicePlane - unpaid.php
  • assets/core/scss/_core.scss
  • assets/invoiceplane/sass/_ip_variables.scss
  • assets/invoiceplane_blue/sass/_ip_variables.scss

Comment on lines +1 to +424
# Security and DRY Analysis for PR #1441

## Overview

This document provides a comprehensive analysis of the security vulnerabilities addressed and DRY (Don't Repeat Yourself) principles applied in Pull Request #1441.

**PR Title:** Refactor input sanitization to follow DRY principles and fix log injection vulnerabilities
**Related Issue:** Feedback on PR #1439
**Files Modified:** 5 files, +72 insertions, -23 deletions

---

## Security Vulnerabilities Addressed

### 1. Log Injection Vulnerabilities

**Severity:** Medium
**Impact:** Attackers could inject fake log entries by including newline characters in user input

#### Affected Files

1. `application/modules/settings/controllers/Settings.php` (2 instances)
2. `application/helpers/pdf_helper.php` (2 instances)

#### Vulnerability Details

**Before:** User-controlled filenames and template names were logged directly without sanitization:

```php
// Settings.php - Line 120 (vulnerable)
log_message('warning', 'SVG upload attempt blocked for invoice_logo by user ' .
$this->session->userdata('user_id') . ': ' . $_FILES['invoice_logo']['name']);

// Settings.php - Line 142 (vulnerable)
log_message('warning', 'SVG upload attempt blocked for login_logo by user ' .
$this->session->userdata('user_id') . ': ' . $_FILES['login_logo']['name']);

// pdf_helper.php - Line 89 (vulnerable)
$safe_invoice_template = preg_replace('/[[:^print:]]/', '', (string) $invoice_template);
log_message('error', 'Invalid PDF invoice template parameter: ' . $safe_invoice_template . ', using default');

// pdf_helper.php - Line 313 (inconsistent)
log_message('error', 'Invalid PDF quote template: ' . hash_for_logging($quote_template) . ', using default');
```

**Attack Scenario:**
```php
// Attacker uploads file named: "evil.svg\nSUCCESS: Admin user granted"
// Log would show:
// WARNING: SVG upload attempt blocked for invoice_logo by user 5: evil.svg
// SUCCESS: Admin user granted
```

#### Fix Applied

**After:** All user-controlled data is sanitized before logging using the new `sanitize_for_logging()` helper:

```php
// Settings.php - Fixed
log_message('warning', 'SVG upload attempt blocked for invoice_logo by user ' .
$this->session->userdata('user_id') . ': ' .
sanitize_for_logging(basename($_FILES['invoice_logo']['name'])));

// pdf_helper.php - Fixed (both instances now consistent)
log_message('error', 'Invalid PDF invoice template parameter: ' .
sanitize_for_logging($invoice_template) . ', using default');
```

**Defense:** The `sanitize_for_logging()` function strips carriage return (`\r`) and line feed (`\n`) characters:

```php
function sanitize_for_logging(string $value): string
{
return str_replace(["\r", "\n"], '', $value);
}
```

#### Validation

✅ **Fixed:** All 4 instances of log injection vulnerabilities have been remediated
✅ **Consistent:** All logging now uses the same sanitization approach
✅ **Secure:** Newline characters cannot be injected into logs

---

### 2. Enhanced Input Sanitization - Nested Arrays

**Severity:** Medium
**Impact:** XSS vulnerabilities in nested array inputs were not properly tracked and logged

#### Affected File

`application/core/Admin_Controller.php`

#### Vulnerability Details

**Before:** The `sanitize_array()` method didn't track or log XSS attempts in nested arrays:

```php
private function sanitize_array(array $data, array $bypass_keys = []): array
{
foreach ($data as $key => $value) {
if (in_array($key, $bypass_keys, true)) {
continue;
}

if (is_array($value)) {
$data[$key] = $this->sanitize_array($value, $bypass_keys);
} else {
$data[$key] = strip_tags($this->security->xss_clean($value));
}
}
return $data;
}
```

**Problem:** XSS attempts in nested arrays (e.g., `$_POST['items'][0]['description']`) were sanitized but not logged.

#### Fix Applied

**After:** Enhanced tracking of XSS attempts in nested arrays with full path context:

```php
private function sanitize_array(
array $data,
array $bypass_keys = [],
string $path_prefix = '',
bool &$xss_detected = false,
array &$xss_log_entries = []
): array
{
foreach ($data as $key => $value) {
if (is_array($value)) {
$data[$key] = $this->sanitize_array(
$value,
$bypass_keys,
$path_prefix === '' ? (string) $key : $path_prefix . '.' . $key,
$xss_detected,
$xss_log_entries
);
} else {
$original_value = $value;
$cleaned_value = strip_tags($this->security->xss_clean($value));
if ($original_value !== $cleaned_value) {
$xss_detected = true;
$xss_log_entries[] = [
'field' => $path_prefix === '' ? (string) $key : $path_prefix . '.' . $key,
'original_length' => strlen((string) $original_value),
'cleaned_length' => strlen((string) $cleaned_value),
];
}
$data[$key] = $cleaned_value;
}
}
return $data;
}
```

**Benefits:**
- Full field path tracking (e.g., `items.0.description`)
- Consistent XSS logging for both top-level and nested fields
- Better security monitoring and incident response

#### Validation

✅ **Enhanced:** Nested array XSS attempts are now properly tracked and logged
✅ **Comprehensive:** All array depths are covered with recursive sanitization
✅ **Auditable:** Security teams can see exactly which nested fields had XSS attempts

---

### 3. Improved Package Parsing (Indirect Security)

**Severity:** Low
**Impact:** Prevents incorrect dependency tracking which could hide vulnerable packages

#### Affected File

`.github/scripts/generate-package-update-report.cjs`

#### Issue

**Before:** The package parser couldn't handle:
- Scoped packages (e.g., `@babel/core`)
- Multi-selector keys in yarn.lock
- Complex package name patterns

**After:** Robust parsing that correctly identifies all packages:

```javascript
const keyLine = line.trim();
if (!/^\s/.test(line) && keyLine.endsWith(':')) {
const raw = keyLine.slice(0, -1);
const selectors = raw.startsWith('"')
? raw.split(/",\s*"/).map(s => s.replace(/^"/, '').replace(/"$/, ''))
: raw.split(/,\s*/);
const firstSelector = selectors[0];
const at = firstSelector.lastIndexOf('@');
if (at > 0) {
currentPackage = firstSelector.slice(0, at);
}
}
```

**Security Benefit:** Ensures all dependencies are properly tracked for vulnerability scanning.

#### Validation

✅ **Improved:** All package types are now correctly parsed
✅ **Complete:** No packages are missed in security scans

---

## DRY Principles Applied

### 1. Extracted `sanitize_for_logging()` Helper Function

**Problem:** Log sanitization was duplicated across multiple files with inconsistent implementations.

#### Code Duplication Eliminated

**Before (3+ different implementations):**

```php
// Settings.php - approach 1 (incomplete)
$_FILES['invoice_logo']['name'] // No sanitization

// pdf_helper.php - approach 2 (regex-based)
$safe_invoice_template = preg_replace('/[[:^print:]]/', '', (string) $invoice_template);

// pdf_helper.php - approach 3 (hash-based)
hash_for_logging($quote_template) // Too aggressive, loses debugging info
```

**After (single implementation):**

```php
// file_security_helper.php - ONE authoritative implementation
function sanitize_for_logging(string $value): string
{
return str_replace(["\r", "\n"], '', $value);
}

// Used consistently everywhere
log_message('error', 'Error: ' . sanitize_for_logging($user_input));
```

#### Benefits

✅ **Single Source of Truth:** One function, one implementation
✅ **Consistency:** Same security behavior everywhere
✅ **Maintainability:** Changes apply everywhere automatically
✅ **Testability:** One function to test thoroughly
✅ **Clarity:** Clear intent through naming

#### Impact

- **Lines of code reduced:** 12 lines of duplication eliminated
- **Files affected:** 3 files now use the shared helper
- **Future-proof:** Additional files can easily adopt the same pattern

---

### 2. Consolidated Array Sanitization Logic

**Problem:** Nested array handling was inconsistent and didn't share tracking state with parent context.

#### Before

```php
// Parent call
$_POST[$key] = $this->sanitize_array($value, $bypass_fields);

// Child method (isolated state)
private function sanitize_array(array $data, array $bypass_keys = []): array
{
// No XSS tracking shared with parent
}
```

#### After

```php
// Parent call (shares state)
$_POST[$key] = $this->sanitize_array(
$value,
$bypass_fields,
$key,
$xss_detected, // Shared by reference
$xss_log_entries // Shared by reference
);

// Child method (unified state)
private function sanitize_array(
array $data,
array $bypass_keys = [],
string $path_prefix = '',
bool &$xss_detected = false,
array &$xss_log_entries = []
): array
```

#### Benefits

✅ **Unified Tracking:** Parent and child share XSS detection state
✅ **Complete Logging:** All XSS attempts logged, regardless of nesting depth
✅ **No Duplication:** Tracking logic exists once in `filter_input()`

---

### 3. Consistent Template Validation

**Problem:** Two different approaches to logging invalid templates in pdf_helper.php

#### Before

```php
// Invoice template - uses regex sanitization
$safe_invoice_template = preg_replace('/[[:^print:]]/', '', (string) $invoice_template);
log_message('error', 'Invalid PDF invoice template parameter: ' . $safe_invoice_template);

// Quote template - uses hash
log_message('error', 'Invalid PDF quote template: ' . hash_for_logging($quote_template));
```

**Inconsistency:** Same operation, different implementations

#### After

```php
// Both use the same helper
log_message('error', 'Invalid PDF invoice template parameter: ' . sanitize_for_logging($invoice_template));
log_message('error', 'Invalid PDF quote template: ' . sanitize_for_logging($quote_template));
```

#### Benefits

✅ **Consistency:** Same code pattern for same operation
✅ **Predictability:** Developers know what to expect
✅ **Debugging:** Log entries have consistent format

---

## DRY Metrics

### Code Duplication Reduction

| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| Unique sanitization implementations | 3 | 1 | 66% reduction |
| Lines of duplicated code | 12 | 0 | 100% elimination |
| Files with inline sanitization | 3 | 0 | Centralized in helper |
| Test coverage for sanitization | Scattered | Centralized | Easier to verify |

### Maintainability Improvements

1. **Single Point of Change:** To update log sanitization, modify one function
2. **Clear Ownership:** `file_security_helper.php` owns logging security
3. **Reusability:** New code can import and use the helper immediately
4. **Documentation:** Helper function is self-documenting with clear name

---

## Testing and Verification

### Security Testing Recommendations

```php
// Test log injection prevention
#[Test]
public function it_prevents_log_injection_in_filenames(): void
{
$malicious = "evil.svg\nSUCCESS: Fake log entry";
$sanitized = sanitize_for_logging($malicious);

$this->assertStringNotContainsString("\n", $sanitized);
$this->assertStringNotContainsString("\r", $sanitized);
}

// Test nested array XSS tracking
#[Test]
public function it_tracks_xss_attempts_in_nested_arrays(): void
{
// Test that XSS in $_POST['items'][0]['desc'] is logged
}
```

### Manual Verification Checklist

- [x] All log messages use `sanitize_for_logging()` for user input
- [x] Nested arrays are properly sanitized and tracked
- [x] XSS log entries include full field paths
- [x] No code duplication in sanitization logic
- [x] Helper function is in appropriate file (`file_security_helper.php`)
- [x] All affected files load the `file_security` helper

---

## Summary

### Security Improvements

1. ✅ **Log Injection Fixed:** 4 vulnerabilities eliminated with consistent sanitization
2. ✅ **Enhanced XSS Tracking:** Nested arrays now properly monitored and logged
3. ✅ **Improved Auditing:** Full field paths in security logs for better incident response
4. ✅ **Dependency Tracking:** Better package parsing for security scanning

### DRY Improvements

1. ✅ **66% Reduction** in unique sanitization implementations (3→1)
2. ✅ **100% Elimination** of duplicated sanitization code
3. ✅ **Centralized** log security in `file_security_helper.php`
4. ✅ **Consistent** patterns across all affected files

### Defense-in-Depth Maintained

The changes maintain InvoicePlane's defense-in-depth approach:

- **Layer 1:** Global XSS sanitization (`Admin_Controller::filter_input()`) - Enhanced ✅
- **Layer 2:** Output encoding (`html_escape()` in views) - Unchanged ✅
- **Layer 3:** Format validation (regex patterns) - Unchanged ✅
- **Layer 4:** Log sanitization (new helper) - Added ✅

**Conclusion:** PR #1441 successfully addresses security vulnerabilities while significantly reducing code duplication, making the codebase more maintainable and secure.
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

Remove the unrelated PR #1441 analysis document.

This new file documents security/DRY work for PR #1441, while this PR is #1419 for invoice statuses. Keeping it in the branch adds unrelated internal analysis to the repository and can confuse release notes/reviews.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.junie/PR-1441-security-dry-analysis.md around lines 1 - 424, The file
.junie/PR-1441-security-dry-analysis.md is an unrelated analysis for PR `#1441`
and must be removed from this PR; delete that file from the branch (or revert
the commit that added it) so only changes relevant to PR `#1419` remain, then
amend the branch/commit history and push the update; also scan for any
references to .junie/PR-1441-security-dry-analysis.md in the diff or CI/config
and remove those references if present.

IFnull(ip_invoice_amounts.invoice_balance, '0.00') AS invoice_balance,
ip_invoice_amounts.invoice_sign AS invoice_sign,
(CASE WHEN ip_invoices.invoice_status_id NOT IN (1,4) AND DATEDIFF(NOW(), invoice_date_due) > 0 THEN 1 ELSE 0 END) is_overdue,
(CASE WHEN ip_invoices.invoice_status_id NOT IN (1,4,5,7) AND DATEDIFF(NOW(), invoice_date_due) > 0 THEN 1 ELSE 0 END) is_overdue,
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

Exclude archived invoices from computed overdue state.

Line 90 excludes draft/paid/partial/unpaid, but not the new archived status. Archived invoices with old due dates can still enter overdue filters and pick overdue PDF/email styling through existing is_overdue consumers.

🐛 Proposed fix
-            (CASE WHEN ip_invoices.invoice_status_id NOT IN (1,4,5,7) AND DATEDIFF(NOW(), invoice_date_due) > 0 THEN 1 ELSE 0 END) is_overdue,
+            (CASE WHEN ip_invoices.invoice_status_id NOT IN (1,4,5,7,99) AND DATEDIFF(NOW(), invoice_date_due) > 0 THEN 1 ELSE 0 END) is_overdue,
📝 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
(CASE WHEN ip_invoices.invoice_status_id NOT IN (1,4,5,7) AND DATEDIFF(NOW(), invoice_date_due) > 0 THEN 1 ELSE 0 END) is_overdue,
(CASE WHEN ip_invoices.invoice_status_id NOT IN (1,4,5,7,99) AND DATEDIFF(NOW(), invoice_date_due) > 0 THEN 1 ELSE 0 END) is_overdue,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@application/modules/invoices/models/Mdl_invoices.php` at line 90, The overdue
CASE currently checks ip_invoices.invoice_status_id NOT IN (1,4,5,7) but doesn't
exclude the archived status; update the condition in Mdl_invoices.php where
(CASE WHEN ip_invoices.invoice_status_id NOT IN (1,4,5,7) AND DATEDIFF(NOW(),
invoice_date_due) > 0 THEN 1 ELSE 0 END) is_overdue to also exclude the archived
status by adding its ID to the NOT IN list (or, if a constant exists, use that
constant such as INVOICE_STATUS_ARCHIVED) so archived invoices are never marked
is_overdue.

Comment on lines 60 to 109
$this->db->insert('ip_payment_methods', [
'payment_method_name' => 'Credit Card',
]);
));
}

/**
*
*/
private function install_default_settings()
{
$this->load->helper('string');

$default_settings = array(
'default_language' => $this->session->userdata('ip_lang'),
'date_format' => 'm/d/Y',
'currency_symbol' => '$',
'currency_symbol_placement' => 'before',
'currency_code' => 'USD',
'invoices_due_after' => 30,
'quotes_expire_after' => 15,
'default_invoice_group' => 3,
'default_quote_group' => 4,
'thousands_separator' => ',',
'decimal_point' => '.',
'cron_key' => random_string('alnum', 16),
'tax_rate_decimal_places' => 2,
'pdf_invoice_template' => 'InvoicePlane',
'pdf_invoice_template_paid' => 'InvoicePlane - paid',
'pdf_invoice_template_unpaid' => 'InvoicePlane - unpaid',
'pdf_invoice_template_partial' => 'InvoicePlane - partial',
'pdf_invoice_template_overdue' => 'InvoicePlane - overdue',
'pdf_quote_template' => 'InvoicePlane',
'public_invoice_template' => 'InvoicePlane_Web',
'public_quote_template' => 'InvoicePlane_Web',
'disable_sidebar' => 1,
);

foreach ($default_settings as $setting_key => $setting_value) {
$this->db->where('setting_key', $setting_key);

if (!$this->db->get('ip_settings')->num_rows()) {
$db_array = array(
'setting_key' => $setting_key,
'setting_value' => $setting_value
);

$this->db->insert('ip_settings', $db_array);
}
}
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the changed setup block and duplicate method declaration without executing repository code.

sed -n '56,110p' application/modules/setup/models/Mdl_setup.php

printf '\ninstall_default_settings declarations:\n'
rg -nP '^\s*private\s+function\s+install_default_settings\s*\(' application/modules/setup/models/Mdl_setup.php

Repository: InvoicePlane/InvoicePlane

Length of output: 2169


Remove the duplicate method declaration and fix the malformed array closure.

Line 62 closes the insert with )); instead of ]);, causing a parse error. Lines 68–109 also redeclare install_default_settings(), which already exists at line 451, creating a fatal error. Delete the duplicate method and merge its new template defaults (pdf_invoice_template_unpaid and pdf_invoice_template_partial) into the existing method at line 451.

Proposed fix
         $this->db->insert('ip_payment_methods', [
             'payment_method_name' => 'Credit Card',
-        ));
-    }
-
-    /**
-     *
-     */
-    private function install_default_settings()
-    {
-        $this->load->helper('string');
-
-        $default_settings = array(
-            'default_language' => $this->session->userdata('ip_lang'),
-            'date_format' => 'm/d/Y',
-            'currency_symbol' => '$',
-            'currency_symbol_placement' => 'before',
-            'currency_code' => 'USD',
-            'invoices_due_after' => 30,
-            'quotes_expire_after' => 15,
-            'default_invoice_group' => 3,
-            'default_quote_group' => 4,
-            'thousands_separator' => ',',
-            'decimal_point' => '.',
-            'cron_key' => random_string('alnum', 16),
-            'tax_rate_decimal_places' => 2,
-            'pdf_invoice_template' => 'InvoicePlane',
-            'pdf_invoice_template_paid' => 'InvoicePlane - paid',
-            'pdf_invoice_template_unpaid' => 'InvoicePlane - unpaid',
-            'pdf_invoice_template_partial' => 'InvoicePlane - partial',
-            'pdf_invoice_template_overdue' => 'InvoicePlane - overdue',
-            'pdf_quote_template' => 'InvoicePlane',
-            'public_invoice_template' => 'InvoicePlane_Web',
-            'public_quote_template' => 'InvoicePlane_Web',
-            'disable_sidebar' => 1,
-        );
-
-        foreach ($default_settings as $setting_key => $setting_value) {
-            $this->db->where('setting_key', $setting_key);
-
-            if (!$this->db->get('ip_settings')->num_rows()) {
-                $db_array = array(
-                    'setting_key' => $setting_key,
-                    'setting_value' => $setting_value
-                );
-
-                $this->db->insert('ip_settings', $db_array);
-            }
-        }
+        ]);
     }

Then add the missing defaults to the existing method at line 451:

             'pdf_invoice_template'         => 'InvoicePlane',
             'pdf_invoice_template_paid'    => 'InvoicePlane - paid',
+            'pdf_invoice_template_unpaid'  => 'InvoicePlane - unpaid',
+            'pdf_invoice_template_partial' => 'InvoicePlane - partial',
             'pdf_invoice_template_overdue' => 'InvoicePlane - overdue',
📝 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
$this->db->insert('ip_payment_methods', [
'payment_method_name' => 'Credit Card',
]);
));
}
/**
*
*/
private function install_default_settings()
{
$this->load->helper('string');
$default_settings = array(
'default_language' => $this->session->userdata('ip_lang'),
'date_format' => 'm/d/Y',
'currency_symbol' => '$',
'currency_symbol_placement' => 'before',
'currency_code' => 'USD',
'invoices_due_after' => 30,
'quotes_expire_after' => 15,
'default_invoice_group' => 3,
'default_quote_group' => 4,
'thousands_separator' => ',',
'decimal_point' => '.',
'cron_key' => random_string('alnum', 16),
'tax_rate_decimal_places' => 2,
'pdf_invoice_template' => 'InvoicePlane',
'pdf_invoice_template_paid' => 'InvoicePlane - paid',
'pdf_invoice_template_unpaid' => 'InvoicePlane - unpaid',
'pdf_invoice_template_partial' => 'InvoicePlane - partial',
'pdf_invoice_template_overdue' => 'InvoicePlane - overdue',
'pdf_quote_template' => 'InvoicePlane',
'public_invoice_template' => 'InvoicePlane_Web',
'public_quote_template' => 'InvoicePlane_Web',
'disable_sidebar' => 1,
);
foreach ($default_settings as $setting_key => $setting_value) {
$this->db->where('setting_key', $setting_key);
if (!$this->db->get('ip_settings')->num_rows()) {
$db_array = array(
'setting_key' => $setting_key,
'setting_value' => $setting_value
);
$this->db->insert('ip_settings', $db_array);
}
}
}
$this->db->insert('ip_payment_methods', [
'payment_method_name' => 'Credit Card',
]);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@application/modules/setup/models/Mdl_setup.php` around lines 60 - 109, Fix
the syntax error and duplicate method: change the malformed insert closure that
uses "));" to "]);" for the ip_payment_methods insert, remove the entire
duplicate private function install_default_settings() block shown in the diff,
and merge the additional default keys from that removed block (notably
'pdf_invoice_template_unpaid' and 'pdf_invoice_template_partial' — and any other
template defaults present) into the existing install_default_settings()
implementation (the single install_default_settings function already defined
elsewhere) so all defaults are present once.

Comment on lines +31 to +36
<?php if ($invoice->user_vat_id) {
echo lang('vat_id_short') . ': ' . $invoice->user_vat_id . '<br>';
} ?>
<?php if ($invoice->user_tax_code) {
echo lang('tax_code_short') . ': ' . $invoice->user_tax_code . '<br>';
} ?>
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

Escape these invoice fields and fix the Sales Person guard.

VAT/tax IDs and custom fields are user-controlled. Line 170 also checks Lead Source before reading Sales Person, which can emit notices and raw HTML.

🛡️ Proposed fix
                         <?php if ($invoice->user_vat_id) {
-                            echo lang('vat_id_short') . ': ' . $invoice->user_vat_id . '<br>';
+                            echo lang('vat_id_short') . ': ' . htmlsc($invoice->user_vat_id) . '<br>';
                         } ?>
                         <?php if ($invoice->user_tax_code) {
-                            echo lang('tax_code_short') . ': ' . $invoice->user_tax_code . '<br>';
+                            echo lang('tax_code_short') . ': ' . htmlsc($invoice->user_tax_code) . '<br>';
                         } ?>
                         <?php if ($invoice->client_vat_id) {
-                            echo lang('vat_id_short') . ': ' . $invoice->client_vat_id . '<br>';
+                            echo lang('vat_id_short') . ': ' . htmlsc($invoice->client_vat_id) . '<br>';
                         } ?>
                         <?php if ($invoice->client_tax_code) {
-                            echo lang('tax_code_short') . ': ' . $invoice->client_tax_code . '<br>';
+                            echo lang('tax_code_short') . ': ' . htmlsc($invoice->client_tax_code) . '<br>';
                         } ?>
-                            <?php if (isset($custom_fields['invoice']['Lead Source'])): ?>     
+                            <?php if (isset($custom_fields['invoice']['Sales Person'])): ?>     
                                 <tr>                                    
                                     <td class="text-bold bt px-1">
                                         <?php echo trans('Sales Person:'); ?>
                                     </td>
                                     <td class="text-right bt px-1">
-                                        <?php echo $custom_fields['invoice']['Sales Person'] ?>
+                                        <?php echo htmlsc($custom_fields['invoice']['Sales Person']); ?>
                                     </td>                                    
                                 </tr>
                             <?php endif; ?>

As per coding guidelines, “Always use html_escape() for output encoding in views to prevent XSS vulnerabilities.”

Also applies to: 132-137, 170-177

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@application/views/invoice_templates/pdf/InvoicePlane` - partial.php around
lines 31 - 36, Escape user-controlled invoice fields by wrapping outputs like
$invoice->user_vat_id and $invoice->user_tax_code with html_escape() before
echoing them, and apply the same treatment to other custom fields referenced
around lines 132-137 and 170-177 (e.g., any $invoice->custom_* or similar). Also
fix the Sales Person guard by checking the "Sales Person" field exists/is
non-empty before reading it (do the Sales Person existence check prior to or
instead of relying on the "Lead Source" check) to avoid PHP notices and raw HTML
output; update the view logic (the conditional that reads Sales Person) to first
verify the Sales Person property on $invoice is set and then echo
html_escape($invoice->sales_person) (or the exact property name used in the
diff).

Comment on lines +190 to +205
<th class="item-name"><?php _trans('item'); ?></th>
<th class="item-desc"><?php _trans('description'); ?></th>
<th class="item-amount text-right py-2 px-1"><?php _trans('qty'); ?></th>
<th class="item-price text-right py-2 px-1"><?php _trans('price'); ?></th>
<?php if ($show_item_discounts) : ?>
<th class="item-discount text-right py-2 px-1"><?php _trans('discount'); ?></th>
<?php endif; ?>
<th class="item-total text-right py-2 px-1"><?php _trans('total'); ?></th>
</tr>
</thead>
<tbody>
<?php
foreach ($items as $item) { ?>
<tr>
<td class="py-2 px-1 bt"><?php echo nl2br(htmlsc($item->item_description)); ?></td>
<td class="py-2 px-1 bt"><?php _htmlsc($item->item_name); ?></td>
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

Swap item name and description cells to match the headers.

The table header renders item before description, but each row currently outputs description before name.

🐛 Proposed fix
-                            <td class="py-2 px-1 bt"><?php echo nl2br(htmlsc($item->item_description)); ?></td>
-                            <td class="py-2 px-1 bt"><?php _htmlsc($item->item_name); ?></td>
+                            <td class="py-2 px-1 bt"><?php _htmlsc($item->item_name); ?></td>
+                            <td class="py-2 px-1 bt"><?php echo nl2br(htmlsc($item->item_description)); ?></td>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@application/views/invoice_templates/pdf/InvoicePlane` - partial.php around
lines 190 - 205, Rows are rendering description before name, which is the
reverse of the table headers; inside the foreach ($items as $item) block swap
the two <td> cells so the first cell outputs the item name and the second
outputs the item description — specifically swap the outputs using
_htmlsc($item->item_name) and nl2br(htmlsc($item->item_description)) in the <td>
elements for the row so they align with the <th class="item-name"> and <th
class="item-desc"> headers.

Comment on lines +31 to +36
<?php if ($invoice->user_vat_id) {
echo lang('vat_id_short') . ': ' . $invoice->user_vat_id . '<br>';
} ?>
<?php if ($invoice->user_tax_code) {
echo lang('tax_code_short') . ': ' . $invoice->user_tax_code . '<br>';
} ?>
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

Escape these invoice fields and fix the Sales Person guard.

VAT/tax IDs and custom fields are user-controlled. Line 224 also checks Lead Source before reading Sales Person, which can emit notices and raw HTML.

🛡️ Proposed fix
                             <?php if ($invoice->user_vat_id) {
-                                echo lang('vat_id_short') . ': ' . $invoice->user_vat_id . '<br>';
+                                echo lang('vat_id_short') . ': ' . htmlsc($invoice->user_vat_id) . '<br>';
                             } ?>
                             <?php if ($invoice->user_tax_code) {
-                                echo lang('tax_code_short') . ': ' . $invoice->user_tax_code . '<br>';
+                                echo lang('tax_code_short') . ': ' . htmlsc($invoice->user_tax_code) . '<br>';
                             } ?>
                             <?php if ($invoice->client_vat_id) {
-                                echo lang('vat_id_short') . ': ' . $invoice->client_vat_id . '<br>';
+                                echo lang('vat_id_short') . ': ' . htmlsc($invoice->client_vat_id) . '<br>';
                             } ?>
                             <?php if ($invoice->client_tax_code) {
-                                echo lang('tax_code_short') . ': ' . $invoice->client_tax_code . '<br>';
+                                echo lang('tax_code_short') . ': ' . htmlsc($invoice->client_tax_code) . '<br>';
                             } ?>
-                                <?php if (isset($custom_fields['invoice']['Lead Source'])): ?>     
+                                <?php if (isset($custom_fields['invoice']['Sales Person'])): ?>     
                                     <tr>                                    
                                         <td class="text-bold bt px-1">
                                            <?php echo trans('Sales Person:'); ?>
                                         </td>
                                         <td class="text-right bt px-1">
-                                           <?php echo $custom_fields['invoice']['Sales Person'] ?>
+                                           <?php echo htmlsc($custom_fields['invoice']['Sales Person']); ?>
                                         </td>                                    
                                     </tr>
                                 <?php endif; ?>

As per coding guidelines, “Always use html_escape() for output encoding in views to prevent XSS vulnerabilities.”

Also applies to: 186-190, 224-231

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@application/views/invoice_templates/pdf/InvoicePlane` - unpaid.php around
lines 31 - 36, The view is outputting user-controlled fields without escaping
and is reading Sales Person before guarding for its existence; update the usages
of $invoice->user_vat_id and $invoice->user_tax_code (and the other mentioned
blocks at lines ~186-190 and ~224-231) to wrap values with html_escape() before
echoing, and change the Sales Person/Lead Source checks so you verify
isset($invoice->sales_person) (or !empty($invoice->sales_person)) before
referencing or echoing it (i.e., check the Sales Person guard prior to
reading/printing it, and only then check Lead Source), ensuring all
user-controlled invoice properties are html_escape()'d in the InvoicePlane -
unpaid.php view.

Comment on lines +244 to +259
<th class="item-name"><?php _trans('item'); ?></th>
<th class="item-desc"><?php _trans('description'); ?></th>
<th class="item-amount text-right py-2 px-1"><?php _trans('qty'); ?></th>
<th class="item-price text-right py-2 px-1"><?php _trans('price'); ?></th>
<?php if ($show_item_discounts) : ?>
<th class="item-discount text-right py-2 px-1"><?php _trans('discount'); ?></th>
<?php endif; ?>
<th class="item-total text-right py-2 px-1"><?php _trans('total'); ?></th>
</tr>
</thead>
<tbody>
<?php
foreach ($items as $item) { ?>
<tr>
<td class="py-2 px-1 bt"><?php echo nl2br(htmlsc($item->item_description)); ?></td>
<td class="py-2 px-1 bt"><?php _htmlsc($item->item_name); ?></td>
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

Swap item name and description cells to match the headers.

The table header renders item before description, but each row currently outputs description before name.

🐛 Proposed fix
-                                <td class="py-2 px-1 bt"><?php echo nl2br(htmlsc($item->item_description)); ?></td>
-                                <td class="py-2 px-1 bt"><?php _htmlsc($item->item_name); ?></td>
+                                <td class="py-2 px-1 bt"><?php _htmlsc($item->item_name); ?></td>
+                                <td class="py-2 px-1 bt"><?php echo nl2br(htmlsc($item->item_description)); ?></td>
📝 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
<th class="item-name"><?php _trans('item'); ?></th>
<th class="item-desc"><?php _trans('description'); ?></th>
<th class="item-amount text-right py-2 px-1"><?php _trans('qty'); ?></th>
<th class="item-price text-right py-2 px-1"><?php _trans('price'); ?></th>
<?php if ($show_item_discounts) : ?>
<th class="item-discount text-right py-2 px-1"><?php _trans('discount'); ?></th>
<?php endif; ?>
<th class="item-total text-right py-2 px-1"><?php _trans('total'); ?></th>
</tr>
</thead>
<tbody>
<?php
foreach ($items as $item) { ?>
<tr>
<td class="py-2 px-1 bt"><?php echo nl2br(htmlsc($item->item_description)); ?></td>
<td class="py-2 px-1 bt"><?php _htmlsc($item->item_name); ?></td>
<th class="item-name"><?php _trans('item'); ?></th>
<th class="item-desc"><?php _trans('description'); ?></th>
<th class="item-amount text-right py-2 px-1"><?php _trans('qty'); ?></th>
<th class="item-price text-right py-2 px-1"><?php _trans('price'); ?></th>
<?php if ($show_item_discounts) : ?>
<th class="item-discount text-right py-2 px-1"><?php _trans('discount'); ?></th>
<?php endif; ?>
<th class="item-total text-right py-2 px-1"><?php _trans('total'); ?></th>
</tr>
</thead>
<tbody>
<?php
foreach ($items as $item) { ?>
<tr>
<td class="py-2 px-1 bt"><?php _htmlsc($item->item_name); ?></td>
<td class="py-2 px-1 bt"><?php echo nl2br(htmlsc($item->item_description)); ?></td>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@application/views/invoice_templates/pdf/InvoicePlane` - unpaid.php around
lines 244 - 259, Row cells inside the foreach loop are emitted in the wrong
order: the code outputs item_description then item_name, but the headers expect
item (name) then description; to fix, swap the two <td> outputs inside the
foreach so the cell that renders $item->item_name (using _htmlsc) appears first
and the cell that renders $item->item_description (using echo
nl2br(htmlsc(...))) appears second, keeping their existing classes (e.g., "py-2
px-1 bt") and any formatting intact.

Comment on lines +364 to +378
<footer>
<table class="w-10">
<tr>
<td class="text-red" style="font-size: 12px;">
<b>Note:</b> Payment/Order Confirmation automatically confirms customer declaration to Accept All <a href="https://aroxmarketing.com/policies">Policies</a>, & <a href="https://aroxmarketing.com/terms">Terms & Conditions</a> of Arox Marketing (Pvt) Ltd.
</td>
</tr>
</table>
<table class="mt-2">
<tr>
<td style="font-size: 10px">
<b>More Links: </b><a href="https://aroxmarketing.com/blog">Latest News</a> | <a href="https://aroxmarketing.com/about">About Us</a> | <a href="https://aroxmarketing.com/contact">Contact Us</a>
</td>
</tr>
</table>
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

Remove company-specific legal copy from the default template.

This new unpaid template is installed as a default, so the Arox Marketing policy/terms/blog links would appear on other users’ invoices. Use configurable invoice terms/footer settings instead.

🧹 Proposed fix
-            <table class="w-10">
-                <tr>
-                    <td class="text-red" style="font-size: 12px;">
-                    <b>Note:</b> Payment/Order Confirmation automatically confirms customer declaration to Accept All <a href="https://aroxmarketing.com/policies">Policies</a>, & <a href="https://aroxmarketing.com/terms">Terms & Conditions</a> of Arox Marketing (Pvt) Ltd.
-                    </td>
-                </tr>
-            </table>
-            <table class="mt-2">
-                <tr>
-                    <td style="font-size: 10px">
-                        <b>More Links: </b><a href="https://aroxmarketing.com/blog">Latest News</a> | <a href="https://aroxmarketing.com/about">About Us</a> | <a href="https://aroxmarketing.com/contact">Contact Us</a>
-                    </td>
-                </tr>
-            </table>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@application/views/invoice_templates/pdf/InvoicePlane` - unpaid.php around
lines 364 - 378, Remove the hard-coded Arox Marketing legal copy and links from
the footer block in the "InvoicePlane - unpaid.php" template and replace them
with the application's configurable invoice/footer settings (e.g. use the
existing invoice terms/footer variables or settings API such as $invoice_terms
or $invoice_footer provided by the app) so default installs don't ship
company-specific links; update the footer HTML in the template to render those
configurable variables (falling back to an empty or generic notice if unset)
rather than the static Arox Marketing text and anchors.

Comment on lines +15 to +30
$color_red: #FB110F;

// New Status Colors
$color_status_unpaid: #FFD700; // Yellow for Unpaid
$color_status_archived: #999999; // Grey for Archived
$color_status_partial: #FF951B; // Orange for Partial

// Existing Status Colors
$color_status_draft: #999999;
$color_status_sent: #3A87AD;
$color_status_viewed: #F89406;
$color_status_viewed: #F85306;
$color_status_paid_approved: #468847;
$color_status_rejected_overdue: #B94A48;
$color_status_rejected_overdue: #FB110F;
$color_status_canceled: #333333;
$color_status_active: #468847;
$color_status_inactive: #B94A48;
$color_status_inactive: #FB110F;
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

Apply the same contrast check to the blue theme.

The blue theme inherits the same bright status colors, so any white-text status badges will have the same readability issue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@assets/invoiceplane_blue/sass/_ip_variables.scss` around lines 15 - 30, The
blue theme currently reuses the bright status colors (e.g. $color_status_unpaid,
$color_status_partial, $color_status_viewed, $color_status_rejected_overdue)
which fail contrast with white badge text—update the blue-theme variables or
badge text color so contrast meets accessibility standards: locate the blue
theme's SCSS variables for those same status names (or the badge component that
uses them) and either replace the bright hexes with darker alternatives or set
badge text to a dark color (e.g. switch from white to a high-contrast text
variable) for badges using $color_status_unpaid, $color_status_partial,
$color_status_viewed, $color_status_rejected_overdue and any other bright status
variables to ensure readable contrast.

Comment on lines +15 to +30
$color_red: #FB110F;

// New Status Colors
$color_status_unpaid: #FFD700; // Yellow for Unpaid
$color_status_archived: #999999; // Grey for Archived
$color_status_partial: #FF951B; // Orange for Partial

// Existing Status Colors
$color_status_draft: #999999;
$color_status_sent: #3A87AD;
$color_status_viewed: #F89406;
$color_status_viewed: #F85306;
$color_status_paid_approved: #468847;
$color_status_rejected_overdue: #B94A48;
$color_status_rejected_overdue: #FB110F;
$color_status_canceled: #333333;
$color_status_active: #468847;
$color_status_inactive: #B94A48;
$color_status_inactive: #FB110F;
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:

#!/bin/bash
# Check contrast ratios for the changed colors against white and dark text, and find status-label usage.

python - <<'PY'
def lum(hex_color):
    rgb = [int(hex_color[i:i+2], 16) / 255 for i in (1, 3, 5)]
    vals = []
    for c in rgb:
        vals.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4)
    return 0.2126 * vals[0] + 0.7152 * vals[1] + 0.0722 * vals[2]

def contrast(a, b):
    la, lb = lum(a), lum(b)
    hi, lo = max(la, lb), min(la, lb)
    return (hi + 0.05) / (lo + 0.05)

for color in ['#FFD700', '#FF951B', '#F85306', '#FB110F']:
    print(color, 'vs white:', round(contrast(color, '#FFFFFF'), 2), 'vs `#212529`:', round(contrast(color, '#212529'), 2))
PY

rg -n '\$color_status_(unpaid|partial|viewed|rejected_overdue|inactive)|\.label\.(unpaid|partial|viewed|overdue|archived)' --glob '*.scss'

Repository: InvoicePlane/InvoicePlane

Length of output: 1670


🏁 Script executed:

# Check the context around lines 681, 685, 695, 707 where background-color is set
sed -n '680,710p' assets/core/scss/_core.scss

Repository: InvoicePlane/InvoicePlane

Length of output: 656


🏁 Script executed:

# Find the broader context - look for the parent rule and any text color definitions
sed -n '670,710p' assets/core/scss/_core.scss

Repository: InvoicePlane/InvoicePlane

Length of output: 817


🏁 Script executed:

# Look further up to find the parent rule and any color/text-color definitions
sed -n '650,710p' assets/core/scss/_core.scss

Repository: InvoicePlane/InvoicePlane

Length of output: 1213


🏁 Script executed:

# Calculate the lightened color values and their contrast with white text
python - <<'PY'
from colorsys import rgb_to_hls, hls_to_rgb

def hex_to_rgb(hex_color):
    return tuple(int(hex_color[i:i+2], 16) for i in (1, 3, 5))

def rgb_to_hex(rgb):
    return '#{:02x}{:02x}{:02x}'.format(int(rgb[0]), int(rgb[1]), int(rgb[2]))

def lighten_scss(hex_color, percent):
    """Simulate SCSS lighten() function - increase lightness by percent in HSL"""
    r, g, b = [x / 255.0 for x in hex_to_rgb(hex_color)]
    h, l, s = rgb_to_hls(r, g, b)
    l = min(1.0, l + percent / 100.0)  # Increase lightness
    r, g, b = hls_to_rgb(h, l, s)
    return rgb_to_hex(tuple(x * 255 for x in (r, g, b)))

def lum(hex_color):
    rgb = [int(hex_color[i:i+2], 16) / 255 for i in (1, 3, 5)]
    vals = []
    for c in rgb:
        vals.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4)
    return 0.2126 * vals[0] + 0.7152 * vals[1] + 0.0722 * vals[2]

def contrast(a, b):
    la, lb = lum(a), lum(b)
    hi, lo = max(la, lb), min(la, lb)
    return (hi + 0.05) / (lo + 0.05)

colors = {'#FFD700': 'unpaid', '#FF951B': 'partial', '#F85306': 'viewed', '#FB110F': 'rejected_overdue/inactive'}
print("Original colors vs white text:")
for orig, name in colors.items():
    lightened = lighten_scss(orig, 10)
    c = contrast(lightened, '#FFFFFF')
    status = "✓ PASS" if c >= 3.0 else "✗ FAIL"
    print(f"  {name:20s} {orig} → {lightened} (lightened 10%) = {c:.2f}:1 {status}")
PY

Repository: InvoicePlane/InvoicePlane

Length of output: 396


Fix insufficient contrast for white text on lightened status label backgrounds.

The .label class uses white text on lightened background colors. Three status labels fail WCAG AA contrast requirements:

  • Unpaid (#FFD700#ffdf32): 1.32:1 (fails; needs 3:1)
  • Partial (#FF951B#ffac4d): 1.86:1 (fails; needs 3:1)
  • Viewed (#F85306#fa7436): 2.76:1 (fails; needs 3:1)

Use darker background colors or switch to dark text for these labels.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@assets/invoiceplane/sass/_ip_variables.scss` around lines 15 - 30, The status
label colors $color_status_unpaid, $color_status_partial and
$color_status_viewed fail WCAG AA contrast for white text in the .label styling;
fix by either darkening those three variables to values that reach at least 3:1
contrast against white (replace the hexes for $color_status_unpaid,
$color_status_partial, $color_status_viewed with darker hexes) or change the
label text for those states to a dark text token (e.g. use an existing
$color_text_dark) and update any logic that applies .label text color so these
three statuses use the dark text; ensure after change each status meets the 3:1
contrast requirement.

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.

1 participant