Skip to content

Conversation

@lohanidamodar
Copy link
Contributor

@lohanidamodar lohanidamodar commented Jun 3, 2025

  • Invoice object
  • Credit object
  • Discount object

Summary by CodeRabbit

  • New Features

    • Added comprehensive invoicing with lifecycle statuses, finalization flow, tax/VAT rounding, and full import/export.
    • Introduced discounts (fixed and percentage) with validation, ordering, calculation, and array import/export.
    • Added credits support with tracking, consumption logic, automatic status transitions when exhausted, and array import/export.
    • Totals now reflect discounts, credits, tax, and VAT.
  • Tests

    • Added extensive unit tests covering invoices, discounts, credits, calculations, status flows, validation, and serialization.

- Invoice object
- Credit object
- Discount object
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jun 3, 2025

Walkthrough

Adds three domain classes: Credit (value object) with status constants, credit consumption logic, and (de)serialization; Discount (value object) with fixed/percentage types, non‑negative validation, calculation, and (de)serialization; and Invoice (aggregate) that manages amounts, discounts, credits, lifecycle methods (applyDiscounts, applyCredits, finalize), normalization from arrays, and (de)serialization. Also adds PHPUnit test suites for Credit, Discount, and Invoice covering construction, getters/setters, calculations, application flows, status transitions, edge cases, and (de)serialization.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Areas to focus review on:

  • Invoice.applyDiscounts / Invoice.applyCredits / Invoice.finalize: arithmetic order, rounding, tax/VAT handling, and status branching.
  • Normalization and reconstruction: Invoice::setDiscounts, Invoice::setCredits, and fromArray behavior for arrays vs objects.
  • Credit::useCredits and isFullyUsed: negative input handling, float precision, and automatic status transition.
  • Discount::calculateDiscount and validation: fixed vs percentage logic and exceptions on negative/null values.
  • Tests: correctness of assertions (float deltas), coverage of edge cases, and consistency between object and array (de)serialization.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "Feat: invoice basic implementation" is clearly related to the changeset and specifically references a primary component being introduced. The title refers to the Invoice class, which is a substantial and central part of the implementation. While the PR also introduces Credit and Discount classes (mentioned equally in the PR objectives), the title captures the main conceptual feature being added: a basic invoice system. The title is concrete and specific rather than vague, and the instructions acknowledge that titles don't need to cover every detail of the changeset.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat-invoice

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.

@lohanidamodar lohanidamodar marked this pull request as ready for review September 18, 2025 01:52
Copy link
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: 6

🧹 Nitpick comments (15)
src/Pay/Discount/Discount.php (2)

98-107: Add return type declaration for consistency

The toArray method lacks a return type declaration while other methods have them.

-public function toArray()
+public function toArray(): array
 {
     return [
         'id' => $this->id,
         'amount' => $this->amount,
         'value' => $this->value,
         'description' => $this->description,
         'type' => $this->type,
     ];
 }

109-120: Add parameter and return type declarations

The fromArray method lacks type declarations for both parameter and return type.

-public static function fromArray($data)
+public static function fromArray(array $data): self
 {
     $discount = new self(
         $data['id'] ?? $data['$id'] ?? '',
         $data['value'] ?? 0,
         $data['amount'] ?? 0,
         $data['description'] ?? '',
         $data['type'] ?? self::TYPE_FIXED,
     );
 
     return $discount;
 }
src/Pay/Credit/Credit.php (4)

16-17: PHPDoc type hint inconsistency

The PHPDoc comment indicates @param int $creditsUsed but the constructor accepts float $creditsUsed.

 /**
  * @param  string  $id
  * @param  float  $credits
- * @param  int  $creditsUsed
+ * @param  float  $creditsUsed
  * @param  string  $status
  */

86-88: Potential floating-point comparison issue

Using strict equality (===) with floating-point numbers can be unreliable due to precision issues.

Consider using a small epsilon for comparison:

-if ($this->credits === 0) {
+if ($this->credits < 0.0001) {
     $this->status = self::STATUS_APPLIED;
 }

93-98: Missing parameter type declaration

The setStatus method parameter lacks a type declaration while other setters have them.

-public function setStatus($status): static
+public function setStatus(string $status): static
 {
     $this->status = $status;
 
     return $this;
 }

100-103: Potential floating-point comparison issue

Similar to the previous issue, using strict equality with floats can be problematic.

 public function isFullyUsed(): bool
 {
-    return $this->credits === 0 || $this->status === self::STATUS_APPLIED;
+    return $this->credits < 0.0001 || $this->status === self::STATUS_APPLIED;
 }
tests/Pay/Credit/CreditTest.php (2)

82-87: Remove unnecessary conditional logic in test

The test includes defensive code that shouldn't be necessary if the implementation is correct. Tests should assert expected behavior, not work around potential bugs.

 // Check if status is applied when credits are zero
-// If the implementation doesn't change the status, we need to manually call markAsApplied
-if ($this->credit->getStatus() !== Credit::STATUS_APPLIED) {
-    $this->credit->markAsApplied();
-}
 $this->assertEquals(Credit::STATUS_APPLIED, $this->credit->getStatus());

98-104: Remove unnecessary conditional logic in test

Same issue as above - the test should assert the expected behavior directly.

 // Check if status is applied when credits are zero
-// If the implementation doesn't change the status, we need to manually call markAsApplied
-if ($this->credit->getStatus() !== Credit::STATUS_APPLIED) {
-    $this->credit->markAsApplied();
-}
 $this->assertEquals(Credit::STATUS_APPLIED, $this->credit->getStatus());
tests/Pay/Invoice/InvoiceTest.php (1)

333-339: Remove unnecessary conditional logic in test

Similar to the Credit tests, this defensive code shouldn't be needed if the implementation is correct.

-// The implementation may use different logic for setting status
-// Adjust based on the actual implementation
-if ($this->invoice->getStatus() !== Invoice::STATUS_SUCCEEDED) {
-    $this->invoice->markAsSucceeded();
-}
-
 $this->assertEquals(Invoice::STATUS_SUCCEEDED, $this->invoice->getStatus());
tests/Pay/Discount/DiscountTest.php (1)

109-120: Incorrect handling of negative invoice amounts

The test wraps the discount calculation with max(0, ...) which hides the actual behavior. The implementation should handle negative amounts properly, and the test should verify that behavior directly.

 public function testCalculateDiscountWithNegativeInvoiceAmount(): void
 {
     $invoiceAmount = -50.0;
 
-    // Assuming the implementation should handle negative amounts safely
-    // Adjust based on the expected behavior in your application
-    $fixedDiscountAmount = max(0, $this->fixedDiscount->calculateDiscount($invoiceAmount));
-    $percentageDiscountAmount = max(0, $this->percentageDiscount->calculateDiscount($invoiceAmount));
+    $fixedDiscountAmount = $this->fixedDiscount->calculateDiscount($invoiceAmount);
+    $percentageDiscountAmount = $this->percentageDiscount->calculateDiscount($invoiceAmount);
 
     $this->assertEquals(0, $fixedDiscountAmount);
-    $this->assertEquals(0, $percentageDiscountAmount);
+    // Percentage of negative amount would be negative
+    $this->assertEquals(-5.0, $percentageDiscountAmount);
 }

Note: The implementation should probably return 0 for negative amounts, but the test should verify the actual behavior.

src/Pay/Invoice/Invoice.php (5)

141-141: Unnecessary array check

The parameter is already type-hinted as array, so checking is_array($discounts) is redundant.

 public function setDiscounts(array $discounts): static
 {
     // Handle both arrays of Discount objects and arrays of arrays
-    if (is_array($discounts)) {
         $discountObjects = [];
         foreach ($discounts as $discount) {
             if ($discount instanceof Discount) {
                 $discountObjects[] = $discount;
             } elseif (is_array($discount)) {
                 // Convert array to Discount object for backward compatibility
                 $discountObjects[] = new Discount(
                     $discount['id'] ?? uniqid('discount_'),
                     $discount['value'] ?? 0,
                     $discount['amount'] ?? 0,
                     $discount['description'] ?? '',
                     $discount['type'] ?? Discount::TYPE_FIXED
                 );
             } else {
                 throw new \InvalidArgumentException('Discount must be either a Discount object or an array');
             }
         }
         $this->discounts = $discountObjects;
-    } else {
-        throw new \InvalidArgumentException('Discounts must be an array');
-    }
 
     return $this;
 }

234-237: Add parameter type declaration and return type

The isBelowMinimumAmount method lacks type declarations.

-public function isBelowMinimumAmount($minimumAmount = 0.50)
+public function isBelowMinimumAmount(float $minimumAmount = 0.50): bool
 {
     return $this->grossAmount < $minimumAmount;
 }

239-242: Potential floating-point comparison issue

Using == for float comparison can be unreliable.

 public function isZeroAmount(): bool
 {
-    return $this->grossAmount == 0;
+    return abs($this->grossAmount) < 0.0001;
 }

311-312: Unreachable/unnecessary code in applyCredits

The condition at Line 311 checks if $amount == 0 and breaks, but Line 319-321 has an unnecessary check for isZeroAmount() which would never be true at that point, and the continue statement in a loop that's about to check the same condition is redundant.

 foreach ($this->credits as $credit) {
     if ($amount == 0) {
         break;
     }
 
     $creditToUse = $credit->useCredits($amount);
     $amount = $amount - $creditToUse;
     $totalCreditsUsed += $creditToUse;
     $creditsIds[] = $credit->getId();
-    if ($this->isZeroAmount()) {
-        continue;
-    }
 }

Also applies to: 319-321, 338-340


429-431: Array keys may not be preserved after filtering

Using array_filter without array_values may result in non-sequential array keys, which could cause issues if the array is expected to have sequential numeric keys.

 public function removeDiscountById(string $id): static
 {
-    $this->discounts = array_filter($this->discounts, function ($discount) use ($id) {
+    $this->discounts = array_values(array_filter($this->discounts, function ($discount) use ($id) {
         return $discount->getId() !== $id;
-    });
+    }));
 
     return $this;
 }
 public function removeCreditById(string $id): static
 {
-    $this->credits = array_filter($this->credits, function ($credit) use ($id) {
+    $this->credits = array_values(array_filter($this->credits, function ($credit) use ($id) {
         return $credit->getId() !== $id;
-    });
+    }));
 
     return $this;
 }

Also applies to: 438-440

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 00bf2b7 and 5b1deed.

📒 Files selected for processing (6)
  • src/Pay/Credit/Credit.php (1 hunks)
  • src/Pay/Discount/Discount.php (1 hunks)
  • src/Pay/Invoice/Invoice.php (1 hunks)
  • tests/Pay/Credit/CreditTest.php (1 hunks)
  • tests/Pay/Discount/DiscountTest.php (1 hunks)
  • tests/Pay/Invoice/InvoiceTest.php (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (6)
tests/Pay/Credit/CreditTest.php (2)
src/Pay/Credit/Credit.php (3)
  • Credit (5-123)
  • markAsApplied (28-33)
  • useCredits (76-91)
src/Pay/Invoice/Invoice.php (9)
  • getId (57-60)
  • getCredits (264-267)
  • getCreditsUsed (174-177)
  • getStatus (72-75)
  • setCredits (269-285)
  • setCreditsUsed (179-184)
  • setStatus (198-203)
  • toArray (445-461)
  • fromArray (463-492)
tests/Pay/Discount/DiscountTest.php (2)
src/Pay/Discount/Discount.php (14)
  • Discount (5-121)
  • getId (22-25)
  • getValue (70-73)
  • getAmount (34-37)
  • getDescription (46-49)
  • getType (58-61)
  • setId (27-32)
  • setValue (75-80)
  • setAmount (39-44)
  • setDescription (51-56)
  • setType (63-68)
  • calculateDiscount (82-91)
  • toArray (98-107)
  • fromArray (109-120)
src/Pay/Invoice/Invoice.php (4)
  • getId (57-60)
  • getAmount (62-65)
  • toArray (445-461)
  • fromArray (463-492)
src/Pay/Discount/Discount.php (2)
src/Pay/Invoice/Invoice.php (3)
  • __construct (38-55)
  • toArray (445-461)
  • fromArray (463-492)
src/Pay/Credit/Credit.php (3)
  • __construct (19-21)
  • toArray (114-122)
  • fromArray (105-112)
tests/Pay/Invoice/InvoiceTest.php (3)
src/Pay/Credit/Credit.php (1)
  • Credit (5-123)
src/Pay/Discount/Discount.php (3)
  • Discount (5-121)
  • toArray (98-107)
  • fromArray (109-120)
src/Pay/Invoice/Invoice.php (28)
  • Invoice (8-493)
  • getGrossAmount (85-88)
  • getTaxAmount (97-100)
  • getVatAmount (109-112)
  • setGrossAmount (90-95)
  • setTaxAmount (102-107)
  • setVatAmount (114-119)
  • setStatus (198-203)
  • markAsPaid (80-83)
  • markAsDue (205-210)
  • markAsSucceeded (215-220)
  • markAsCancelled (222-227)
  • setDiscounts (138-165)
  • setCredits (269-285)
  • applyDiscounts (332-353)
  • applyCredits (304-330)
  • finalize (355-383)
  • toArray (445-461)
  • fromArray (463-492)
  • hasDiscounts (385-388)
  • hasCredits (390-393)
  • findDiscountById (405-414)
  • findCreditById (416-425)
  • removeDiscountById (427-434)
  • removeCreditById (436-443)
  • isNegativeAmount (229-232)
  • isZeroAmount (239-242)
  • isBelowMinimumAmount (234-237)
src/Pay/Credit/Credit.php (2)
src/Pay/Invoice/Invoice.php (10)
  • __construct (38-55)
  • getStatus (72-75)
  • getId (57-60)
  • getCredits (264-267)
  • setCredits (269-285)
  • getCreditsUsed (174-177)
  • setCreditsUsed (179-184)
  • setStatus (198-203)
  • fromArray (463-492)
  • toArray (445-461)
src/Pay/Discount/Discount.php (4)
  • __construct (18-20)
  • getId (22-25)
  • fromArray (109-120)
  • toArray (98-107)
src/Pay/Invoice/Invoice.php (2)
src/Pay/Credit/Credit.php (12)
  • Credit (5-123)
  • __construct (19-21)
  • setCredits (52-57)
  • getId (35-38)
  • getStatus (23-26)
  • getCreditsUsed (59-62)
  • setCreditsUsed (64-69)
  • setStatus (93-98)
  • toArray (114-122)
  • getCredits (47-50)
  • fromArray (105-112)
  • useCredits (76-91)
src/Pay/Discount/Discount.php (7)
  • Discount (5-121)
  • __construct (18-20)
  • getId (22-25)
  • getAmount (34-37)
  • toArray (98-107)
  • fromArray (109-120)
  • calculateDiscount (82-91)
🔇 Additional comments (1)
src/Pay/Discount/Discount.php (1)

93-96: Operator precedence issue in validation logic

The condition has incorrect operator precedence. The || operator will make this return true whenever $this->type === self::TYPE_PERCENTAGE regardless of the value check.

Add parentheses to fix the logical grouping:

 public function isValid(): bool
 {
-    return $this->amount > 0 && $this->type === self::TYPE_FIXED || $this->value > 0 && $this->type === self::TYPE_PERCENTAGE;
+    return ($this->amount > 0 && $this->type === self::TYPE_FIXED) || ($this->value > 0 && $this->type === self::TYPE_PERCENTAGE);
 }

Likely an incorrect or invalid review comment.

Copy link
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: 2

🧹 Nitpick comments (7)
src/Pay/Discount/Discount.php (2)

109-117: Add return type declaration for consistency.

The method is missing an explicit return type declaration.

Apply this diff:

-    public function toArray()
+    public function toArray(): array
     {

119-138: Add type declarations for consistency.

The method is missing explicit type declarations for the parameter and return value.

Apply this diff:

-    public static function fromArray($data)
+    public static function fromArray(array $data): self
     {
src/Pay/Invoice/Invoice.php (5)

228-231: Add type hint and use strict comparison.

The parameter lacks a type declaration, and the comparison should be strict for consistency.

Apply this diff:

-    public function isBelowMinimumAmount($minimumAmount = 0.50)
+    public function isBelowMinimumAmount(float $minimumAmount = 0.50): bool
     {
-        return $this->grossAmount < $minimumAmount;
+        return $this->grossAmount < $minimumAmount;
     }

Note: The comparison operator < is already strict; consider also adding the explicit return type.


233-236: Use strict comparison for zero check.

Loose comparison (==) with floats can lead to unexpected behavior. Since amounts are rounded to 2 decimals, use strict comparison.

Apply this diff:

 public function isZeroAmount(): bool
 {
-    return $this->grossAmount == 0;
+    return $this->grossAmount === 0.0;
 }

305-331: Use strict comparison and remove redundant check.

Two issues:

  1. Line 312 uses loose comparison (==) which should be strict (===)
  2. Lines 320-322: The isZeroAmount() check is redundant since Line 312-314 already breaks when amount reaches 0

Apply this diff:

     foreach ($this->credits as $credit) {
-        if ($amount == 0) {
+        if ($amount === 0.0) {
             break;
         }

         $creditToUse = $credit->useCredits($amount);
         $amount = $amount - $creditToUse;
         $totalCreditsUsed += $creditToUse;
         $creditsIds[] = $credit->getId();
-        if ($this->isZeroAmount()) {
-            continue;
-        }
     }

333-354: Use strict comparison for consistency.

Line 339 uses loose comparison which should be strict.

Apply this diff:

     foreach ($discounts as $discount) {
-        if ($amount == 0) {
+        if ($amount === 0.0) {
             break;
         }

428-444: Consider re-indexing arrays after filtering.

Both methods use array_filter which preserves keys, potentially leaving gaps in the numeric indices. While this works with foreach iteration, it may cause issues if code assumes sequential indices.

If sequential indices are needed, apply this diff:

 public function removeDiscountById(string $id): static
 {
-    $this->discounts = array_filter($this->discounts, function ($discount) use ($id) {
+    $this->discounts = array_values(array_filter($this->discounts, function ($discount) use ($id) {
         return $discount->getId() !== $id;
-    });
+    }));

     return $this;
 }

 public function removeCreditById(string $id): static
 {
-    $this->credits = array_filter($this->credits, function ($credit) use ($id) {
+    $this->credits = array_values(array_filter($this->credits, function ($credit) use ($id) {
         return $credit->getId() !== $id;
-    });
+    }));

     return $this;
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5b1deed and 3c14458.

📒 Files selected for processing (4)
  • src/Pay/Discount/Discount.php (1 hunks)
  • src/Pay/Invoice/Invoice.php (1 hunks)
  • tests/Pay/Discount/DiscountTest.php (1 hunks)
  • tests/Pay/Invoice/InvoiceTest.php (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/Pay/Invoice/InvoiceTest.php
🧰 Additional context used
🧬 Code graph analysis (3)
tests/Pay/Discount/DiscountTest.php (1)
src/Pay/Discount/Discount.php (12)
  • Discount (5-139)
  • getId (28-31)
  • getValue (43-46)
  • getDescription (64-67)
  • getType (76-79)
  • setId (33-38)
  • setValue (53-62)
  • setDescription (69-74)
  • setType (81-86)
  • calculateDiscount (94-107)
  • toArray (109-117)
  • fromArray (119-138)
src/Pay/Invoice/Invoice.php (2)
src/Pay/Credit/Credit.php (12)
  • Credit (5-123)
  • __construct (19-21)
  • setCredits (52-57)
  • getId (35-38)
  • getStatus (23-26)
  • fromArray (105-112)
  • getCreditsUsed (59-62)
  • setCreditsUsed (64-69)
  • setStatus (93-98)
  • toArray (114-122)
  • getCredits (47-50)
  • useCredits (76-91)
src/Pay/Discount/Discount.php (6)
  • Discount (5-139)
  • __construct (17-26)
  • getId (28-31)
  • fromArray (119-138)
  • calculateDiscount (94-107)
  • toArray (109-117)
src/Pay/Discount/Discount.php (2)
src/Pay/Invoice/Invoice.php (4)
  • __construct (38-55)
  • getId (57-60)
  • toArray (446-462)
  • fromArray (464-493)
src/Pay/Credit/Credit.php (5)
  • __construct (19-21)
  • getId (35-38)
  • setId (40-45)
  • toArray (114-122)
  • fromArray (105-112)
🔇 Additional comments (13)
src/Pay/Discount/Discount.php (2)

17-26: LGTM: Constructor with proper validation.

The constructor is well-documented and includes appropriate validation to prevent negative discount values.


94-107: LGTM: Discount calculation logic is correct.

The method properly handles both discount types, caps fixed discounts at the amount, and guards against non-positive amounts.

tests/Pay/Discount/DiscountTest.php (4)

22-63: LGTM: Well-structured test setup and basic validation.

The setUp method and basic tests appropriately cover constructor and getter/setter behavior.


65-90: LGTM: Comprehensive discount calculation tests.

The tests effectively validate both fixed and percentage discount calculations, including the capping behavior for fixed discounts.


116-162: LGTM: Thorough serialization tests.

The tests comprehensively cover array conversion in both directions, including minimal data scenarios with proper defaults.


164-222: LGTM: Comprehensive validation tests.

The tests thoroughly validate error handling for negative and null values across constructor, setter, and deserialization methods.

src/Pay/Invoice/Invoice.php (7)

38-55: LGTM: Well-structured constructor.

The constructor properly uses promoted properties and ensures discounts/credits are converted to objects via the setters.


57-75: LGTM: Standard getters.

The getters are straightforward and correctly return the property values.


77-131: LGTM: Fluent setters with proper return types.

The setter methods follow a consistent fluent interface pattern.


138-159: LGTM: Robust discount setter with type handling.

The method properly handles both Discount objects and arrays, with appropriate validation and error messages.


192-221: LGTM: Clean status transition methods.

The status management methods are straightforward and follow a consistent pattern.


238-253: LGTM: Correct sequential discount calculation.

The method properly applies discounts in sequence, reducing the base amount after each discount.


270-303: LGTM: Consistent credit handling.

The credit management methods follow the same pattern as discounts with proper validation and conversion.

Copy link
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: 1

♻️ Duplicate comments (1)
src/Pay/Discount/Discount.php (1)

135-140: Validate discount type in setter to prevent silent failures.

The setType method accepts any string without validation. If an invalid type is provided, calculateDiscount() will silently return 0, making it difficult to detect configuration errors during development or runtime.

Apply this diff to add validation:

 public function setType(string $type): static
 {
+    if ($type !== self::TYPE_FIXED && $type !== self::TYPE_PERCENTAGE) {
+        throw new \InvalidArgumentException(
+            'Discount type must be either TYPE_FIXED or TYPE_PERCENTAGE'
+        );
+    }
+
     $this->type = $type;

     return $this;
 }

Based on past review comments that flagged this issue.

🧹 Nitpick comments (1)
src/Pay/Invoice/Invoice.php (1)

249-270: Remove redundant array type check.

Line 252 checks is_array($discounts), but since the parameter is already typed as array, this condition will always be true. The else block at lines 265-267 is unreachable dead code.

Apply this diff to simplify:

 public function setDiscounts(array $discounts): static
 {
-    // Handle both arrays of Discount objects and arrays of arrays
-    if (is_array($discounts)) {
-        $discountObjects = [];
-        foreach ($discounts as $discount) {
-            if ($discount instanceof Discount) {
-                $discountObjects[] = $discount;
-            } elseif (is_array($discount)) {
-                // Convert array to Discount object using fromArray for backward compatibility
-                $discountObjects[] = Discount::fromArray($discount);
-            } else {
-                throw new \InvalidArgumentException('Discount must be either a Discount object or an array');
-            }
+    $discountObjects = [];
+    foreach ($discounts as $discount) {
+        if ($discount instanceof Discount) {
+            $discountObjects[] = $discount;
+        } elseif (is_array($discount)) {
+            // Convert array to Discount object using fromArray for backward compatibility
+            $discountObjects[] = Discount::fromArray($discount);
+        } else {
+            throw new \InvalidArgumentException('Discount must be either a Discount object or an array');
         }
-        $this->discounts = $discountObjects;
-    } else {
-        throw new \InvalidArgumentException('Discounts must be an array');
     }
+    $this->discounts = $discountObjects;

     return $this;
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3c14458 and 99c6e03.

📒 Files selected for processing (3)
  • src/Pay/Credit/Credit.php (1 hunks)
  • src/Pay/Discount/Discount.php (1 hunks)
  • src/Pay/Invoice/Invoice.php (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/Pay/Credit/Credit.php
🧰 Additional context used
🧬 Code graph analysis (2)
src/Pay/Discount/Discount.php (2)
src/Pay/Credit/Credit.php (3)
  • __construct (36-38)
  • toArray (215-223)
  • fromArray (200-208)
src/Pay/Invoice/Invoice.php (3)
  • __construct (68-85)
  • toArray (749-766)
  • fromArray (774-805)
src/Pay/Invoice/Invoice.php (2)
src/Pay/Credit/Credit.php (12)
  • Credit (11-224)
  • __construct (36-38)
  • setCredits (101-106)
  • getId (67-70)
  • getStatus (45-48)
  • fromArray (200-208)
  • getCreditsUsed (113-116)
  • setCreditsUsed (124-129)
  • setStatus (177-182)
  • toArray (215-223)
  • getCredits (90-93)
  • useCredits (150-169)
src/Pay/Discount/Discount.php (7)
  • Discount (11-209)
  • __construct (33-42)
  • getId (49-52)
  • fromArray (189-208)
  • toArray (171-179)
  • getType (124-127)
  • calculateDiscount (151-164)
🔇 Additional comments (3)
src/Pay/Discount/Discount.php (1)

151-164: Consider validating against negative amounts.

While the method returns 0 for $amount <= 0, it doesn't explicitly prevent or warn about negative amounts being passed. Negative amounts could indicate a logic error upstream, and silently returning 0 may mask bugs.

Consider either:

  1. Adding explicit validation to throw an exception for negative amounts
  2. Adding a comment explaining why negative amounts are treated as zero

This would help catch potential bugs where negative amounts are passed unintentionally. Based on past review comments.

src/Pay/Invoice/Invoice.php (2)

565-603: Discount application logic looks solid.

The method correctly:

  • Sorts fixed discounts before percentage discounts
  • Applies discounts sequentially to the running amount
  • Stops when amount reaches zero
  • Rounds final amounts appropriately

616-643: Finalization workflow is well-structured.

The method properly sequences:

  1. Initialize grossAmount from base amount
  2. Apply discounts
  3. Round and add tax/VAT
  4. Apply credits
  5. Set appropriate status based on final amount

The status transitions (zero → succeeded, below minimum → cancelled, otherwise → due) provide clear business logic.

lohanidamodar and others added 2 commits October 29, 2025 07:05
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
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: 0

🧹 Nitpick comments (3)
tests/Pay/Invoice/InvoiceTest.php (1)

322-340: Remove conditional logic from test—assert implementation behavior directly.

Lines 335-337 contain conditional logic that checks whether the status is SUCCEEDED and manually marks it if not. Tests should assert expected behavior, not conditionally adjust state. This pattern suggests either:

  1. Uncertainty about the implementation's behavior
  2. The implementation is non-deterministic
  3. The test is working around an inconsistency

Based on the finalize() implementation in Invoice.php (lines 631-632), when isZeroAmount() returns true, the status should be set to SUCCEEDED.

Remove the conditional and assert the expected behavior directly:

     $this->invoice->finalize();

     $this->assertEquals(0.0, $this->invoice->getGrossAmount());
-
-    // The implementation may use different logic for setting status
-    // Adjust based on the actual implementation
-    if ($this->invoice->getStatus() !== Invoice::STATUS_SUCCEEDED) {
-        $this->invoice->markAsSucceeded();
-    }
-
     $this->assertEquals(Invoice::STATUS_SUCCEEDED, $this->invoice->getStatus());
src/Pay/Invoice/Invoice.php (2)

249-270: Minor: Redundant type check on already-typed parameter.

Line 252 checks is_array($discounts), but the parameter is already type-hinted as array in the method signature (line 249). This check will always be true and the else block (lines 265-267) is unreachable.

Consider removing the redundant check and the unreachable else block:

 public function setDiscounts(array $discounts): static
 {
-    // Handle both arrays of Discount objects and arrays of arrays
-    if (is_array($discounts)) {
-        $discountObjects = [];
-        foreach ($discounts as $discount) {
-            if ($discount instanceof Discount) {
-                $discountObjects[] = $discount;
-            } elseif (is_array($discount)) {
-                // Convert array to Discount object using fromArray for backward compatibility
-                $discountObjects[] = Discount::fromArray($discount);
-            } else {
-                throw new \InvalidArgumentException('Discount must be either a Discount object or an array');
-            }
+    $discountObjects = [];
+    foreach ($discounts as $discount) {
+        if ($discount instanceof Discount) {
+            $discountObjects[] = $discount;
+        } elseif (is_array($discount)) {
+            $discountObjects[] = Discount::fromArray($discount);
+        } else {
+            throw new \InvalidArgumentException('Discount must be either a Discount object or an array');
         }
-        $this->discounts = $discountObjects;
-    } else {
-        throw new \InvalidArgumentException('Discounts must be an array');
     }
+    $this->discounts = $discountObjects;

     return $this;
 }

472-488: Similar redundant check and slightly misleading comment.

Like setDiscounts, this method has a redundant type check since $credits is already typed as array. Additionally, the comment on line 474 says "Validate that all items are Credit objects" but the code actually normalizes arrays to Credit objects rather than just validating.

Consider simplifying the structure and clarifying the comment:

 public function setCredits(array $credits): static
 {
-    // Validate that all items are Credit objects
+    // Normalize to Credit objects
     $creditObjects = [];
     foreach ($credits as $credit) {
         if ($credit instanceof Credit) {
             $creditObjects[] = $credit;
         } elseif (is_array($credit)) {
             $creditObjects[] = Credit::fromArray($credit);
         } else {
-            throw new \InvalidArgumentException('All items in credits array must be Credit objects or arrays with id and credits keys');
+            throw new \InvalidArgumentException('Credit must be either a Credit object or an array');
         }
     }
     $this->credits = $creditObjects;

     return $this;
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 99c6e03 and 8486e22.

📒 Files selected for processing (4)
  • src/Pay/Discount/Discount.php (1 hunks)
  • src/Pay/Invoice/Invoice.php (1 hunks)
  • tests/Pay/Discount/DiscountTest.php (1 hunks)
  • tests/Pay/Invoice/InvoiceTest.php (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/Pay/Discount/Discount.php
🧰 Additional context used
🧬 Code graph analysis (3)
tests/Pay/Invoice/InvoiceTest.php (3)
src/Pay/Credit/Credit.php (1)
  • Credit (11-224)
src/Pay/Discount/Discount.php (4)
  • Discount (11-213)
  • getId (49-52)
  • toArray (175-183)
  • fromArray (193-212)
src/Pay/Invoice/Invoice.php (43)
  • Invoice (14-803)
  • getId (92-95)
  • getAmount (102-105)
  • getCurrency (112-115)
  • getStatus (122-125)
  • getGrossAmount (142-145)
  • getTaxAmount (165-168)
  • getVatAmount (188-191)
  • getCreditsUsed (290-293)
  • getAddress (211-214)
  • getDiscounts (234-237)
  • getCredits (457-460)
  • setGrossAmount (153-158)
  • setTaxAmount (176-181)
  • setVatAmount (199-204)
  • setAddress (222-227)
  • setCreditsUsed (301-306)
  • setStatus (337-342)
  • markAsPaid (132-135)
  • markAsDue (349-354)
  • markAsSucceeded (361-366)
  • markAsCancelled (373-378)
  • addDiscount (278-283)
  • addCredit (496-501)
  • getTotalAvailableCredits (508-516)
  • setDiscounts (249-270)
  • setCredits (472-488)
  • applyDiscounts (562-600)
  • getDiscountTotal (419-422)
  • applyCredits (526-549)
  • finalize (613-640)
  • setDiscountTotal (430-435)
  • toArray (746-763)
  • fromArray (771-802)
  • hasDiscounts (647-650)
  • hasCredits (657-660)
  • findDiscountById (683-692)
  • findCreditById (700-709)
  • removeDiscountById (717-724)
  • removeCreditById (732-739)
  • isNegativeAmount (385-388)
  • isZeroAmount (406-409)
  • isBelowMinimumAmount (396-399)
src/Pay/Invoice/Invoice.php (2)
src/Pay/Credit/Credit.php (12)
  • Credit (11-224)
  • __construct (36-38)
  • setCredits (101-106)
  • getId (67-70)
  • getStatus (45-48)
  • fromArray (200-208)
  • getCreditsUsed (113-116)
  • setCreditsUsed (124-129)
  • setStatus (177-182)
  • toArray (215-223)
  • getCredits (90-93)
  • useCredits (150-169)
src/Pay/Discount/Discount.php (7)
  • Discount (11-213)
  • __construct (33-42)
  • getId (49-52)
  • fromArray (193-212)
  • toArray (175-183)
  • getType (124-127)
  • calculateDiscount (155-168)
tests/Pay/Discount/DiscountTest.php (2)
src/Pay/Discount/Discount.php (12)
  • Discount (11-213)
  • getId (49-52)
  • getValue (72-75)
  • getDescription (101-104)
  • getType (124-127)
  • setId (60-65)
  • setValue (85-94)
  • setDescription (112-117)
  • setType (135-144)
  • calculateDiscount (155-168)
  • toArray (175-183)
  • fromArray (193-212)
src/Pay/Invoice/Invoice.php (3)
  • getId (92-95)
  • toArray (746-763)
  • fromArray (771-802)
🔇 Additional comments (5)
tests/Pay/Invoice/InvoiceTest.php (1)

1-72: Comprehensive test coverage with clear assertions.

The test suite provides excellent coverage of the Invoice class functionality, including:

  • Constructor variations with and without discounts/credits
  • Getter/setter validation
  • Status transitions
  • Discount and credit application with proper ordering
  • Finalization workflow with edge cases
  • Serialization/deserialization round-trips
  • Utility methods for finding and removing items

The tests are well-organized, use clear naming conventions, and properly exercise both the happy path and edge cases.

Also applies to: 93-320, 342-477

tests/Pay/Discount/DiscountTest.php (1)

1-223: Excellent test coverage for the Discount value object.

The test suite thoroughly validates all aspects of the Discount class:

  • Constructor validation and initialization
  • Getter/setter behavior with type enforcement
  • Discount calculation logic for both TYPE_FIXED and TYPE_PERCENTAGE
  • Edge cases including zero amounts, negative amounts, and capping behavior
  • Serialization/deserialization with full and minimal data sets
  • Validation of negative and null values with proper exception messages

The tests are clear, focused, and properly assert expected behavior without masking implementation details.

src/Pay/Invoice/Invoice.php (3)

1-227: Well-structured foundation with clear status constants and fluent API.

The class header, constants, constructor, and basic accessors are well-designed:

  • Comprehensive status constants with clear documentation
  • Constructor properly normalizes discounts and credits via setters
  • Consistent fluent interface pattern throughout
  • Appropriate use of promoted properties for concise initialization

526-600: Credit and discount application logic is correct and well-implemented.

Both applyCredits and applyDiscounts implement proper financial calculation flows:

applyCredits (526-549):

  • Correctly iterates through credits sequentially
  • Uses Credit's useCredits method for proper state management
  • Tracks used credits and IDs for audit trail
  • Breaks early when amount reaches zero
  • Properly rounds to 2 decimal places

applyDiscounts (562-600):

  • Correctly sorts discounts (fixed first, then percentage) to ensure accurate calculations
  • Sequential application prevents incorrect compounding
  • Skips zero-value discounts
  • Properly rounds both amount and discount total

613-802: Finalization flow and serialization are well-orchestrated.

The remaining implementation is solid:

finalize (613-640):

  • Proper sequence: base amount → discounts → tax/VAT → credits
  • Appropriate status determination based on final amount
  • Consistent rounding throughout

Utility methods (647-739):

  • Simple, readable implementations for has/find/remove operations
  • Linear search is appropriate for typical invoice sizes

Serialization (746-802):

  • toArray properly delegates to nested objects
  • fromArray correctly reconstructs object graph using Discount::fromArray and Credit::fromArray
  • Sensible defaults for optional fields

@lohanidamodar lohanidamodar merged commit fb39b22 into main Nov 6, 2025
7 checks passed
@lohanidamodar lohanidamodar deleted the feat-invoice branch November 6, 2025 01:06
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.

3 participants