Browse Source

Merge pull request #177 from andrewdwallo/refactor/multicurrency-handling

Refactor/multicurrency handling
3.x
Andrew Wallo 4 months ago
parent
commit
cec76901fd
No account linked to committer's email address
48 changed files with 1228 additions and 1170 deletions
  1. 0
    44
      app/Casts/DocumentMoneyCast.php
  2. 0
    39
      app/Casts/JournalEntryCast.php
  3. 3
    19
      app/Casts/MoneyCast.php
  4. 4
    4
      app/Casts/RateCast.php
  5. 0
    91
      app/Casts/TransactionAmountCast.php
  6. 0
    14
      app/Casts/TrimLeadingZeroCast.php
  7. 2
    3
      app/Collections/Accounting/JournalEntryCollection.php
  8. 1
    24
      app/Concerns/HasTransactionAction.php
  9. 4
    4
      app/Concerns/ManagesLineItems.php
  10. 2
    2
      app/DTO/DocumentDTO.php
  11. 6
    6
      app/DTO/DocumentPreviewDTO.php
  12. 5
    1
      app/DTO/LineItemDTO.php
  13. 12
    12
      app/DTO/LineItemPreviewDTO.php
  14. 2
    0
      app/Filament/Company/Resources/Accounting/TransactionResource.php
  15. 2
    3
      app/Filament/Company/Resources/Common/OfferingResource.php
  16. 18
    18
      app/Filament/Company/Resources/Purchases/BillResource.php
  17. 1
    5
      app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php
  18. 9
    12
      app/Filament/Company/Resources/Purchases/BillResource/RelationManagers/PaymentsRelationManager.php
  19. 5
    4
      app/Filament/Company/Resources/Sales/EstimateResource.php
  20. 17
    16
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  21. 7
    7
      app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php
  22. 5
    4
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php
  23. 96
    71
      app/Models/Accounting/Bill.php
  24. 0
    9
      app/Models/Accounting/DocumentLineItem.php
  25. 3
    10
      app/Models/Accounting/Estimate.php
  26. 93
    65
      app/Models/Accounting/Invoice.php
  27. 0
    2
      app/Models/Accounting/JournalEntry.php
  28. 1
    6
      app/Models/Accounting/RecurringInvoice.php
  29. 1
    3
      app/Models/Accounting/Transaction.php
  30. 0
    2
      app/Models/Common/Offering.php
  31. 7
    1
      app/Observers/BillObserver.php
  32. 2
    2
      app/Observers/EstimateObserver.php
  33. 11
    1
      app/Observers/InvoiceObserver.php
  34. 7
    7
      app/Observers/TransactionObserver.php
  35. 28
    7
      app/Providers/MacroServiceProvider.php
  36. 1
    1
      app/Services/ReportService.php
  37. 4
    8
      app/Services/TransactionService.php
  38. 12
    8
      app/View/Models/DocumentTotalViewModel.php
  39. 295
    290
      composer.lock
  40. 85
    90
      database/factories/Accounting/BillFactory.php
  41. 87
    54
      database/factories/Accounting/EstimateFactory.php
  42. 108
    100
      database/factories/Accounting/InvoiceFactory.php
  43. 163
    64
      database/factories/Accounting/RecurringInvoiceFactory.php
  44. 1
    1
      database/factories/Common/OfferingFactory.php
  45. 1
    0
      database/factories/CompanyFactory.php
  46. 63
    0
      tests/Feature/Accounting/InvoiceTest.php
  47. 14
    0
      tests/Feature/Accounting/RecurringInvoiceTest.php
  48. 40
    36
      tests/Feature/Accounting/TransactionTest.php

+ 0
- 44
app/Casts/DocumentMoneyCast.php View File

1
-<?php
2
-
3
-namespace App\Casts;
4
-
5
-use App\Utilities\Currency\CurrencyAccessor;
6
-use App\Utilities\Currency\CurrencyConverter;
7
-use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
8
-use Illuminate\Database\Eloquent\Model;
9
-use UnexpectedValueException;
10
-
11
-class DocumentMoneyCast implements CastsAttributes
12
-{
13
-    /**
14
-     * Cast the given value.
15
-     *
16
-     * @param  array<string, mixed>  $attributes
17
-     */
18
-    public function get(Model $model, string $key, mixed $value, array $attributes): mixed
19
-    {
20
-        $currency_code = $attributes['currency_code'] ?? CurrencyAccessor::getDefaultCurrency();
21
-
22
-        if ($value !== null) {
23
-            return CurrencyConverter::convertCentsToFloat($value, $currency_code);
24
-        }
25
-
26
-        return 0.0;
27
-    }
28
-
29
-    /**
30
-     * Prepare the given value for storage.
31
-     *
32
-     * @param  array<string, mixed>  $attributes
33
-     */
34
-    public function set(Model $model, string $key, mixed $value, array $attributes): mixed
35
-    {
36
-        if (is_numeric($value)) {
37
-            $value = (string) $value;
38
-        } elseif (! is_string($value)) {
39
-            throw new UnexpectedValueException('Expected string or numeric value for money cast');
40
-        }
41
-
42
-        return CurrencyConverter::prepareForAccessor($value, 'USD');
43
-    }
44
-}

+ 0
- 39
app/Casts/JournalEntryCast.php View File

1
-<?php
2
-
3
-namespace App\Casts;
4
-
5
-use App\Utilities\Currency\CurrencyAccessor;
6
-use App\Utilities\Currency\CurrencyConverter;
7
-use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
8
-use Illuminate\Database\Eloquent\Model;
9
-use UnexpectedValueException;
10
-
11
-class JournalEntryCast implements CastsAttributes
12
-{
13
-    public function get(Model $model, string $key, mixed $value, array $attributes): string
14
-    {
15
-        $currency_code = CurrencyAccessor::getDefaultCurrency();
16
-
17
-        if ($value !== null) {
18
-            return CurrencyConverter::prepareForMutator($value, $currency_code);
19
-        }
20
-
21
-        return '';
22
-    }
23
-
24
-    /**
25
-     * @throws UnexpectedValueException
26
-     */
27
-    public function set(Model $model, string $key, mixed $value, array $attributes): int
28
-    {
29
-        $currency_code = CurrencyAccessor::getDefaultCurrency();
30
-
31
-        if (is_numeric($value)) {
32
-            $value = (string) $value;
33
-        } elseif (! is_string($value)) {
34
-            throw new UnexpectedValueException('Expected string or numeric value for money cast');
35
-        }
36
-
37
-        return CurrencyConverter::prepareForAccessor($value, $currency_code);
38
-    }
39
-}

+ 3
- 19
app/Casts/MoneyCast.php View File

2
 
2
 
3
 namespace App\Casts;
3
 namespace App\Casts;
4
 
4
 
5
-use App\Utilities\Currency\CurrencyAccessor;
6
-use App\Utilities\Currency\CurrencyConverter;
7
 use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
5
 use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
8
 use Illuminate\Database\Eloquent\Model;
6
 use Illuminate\Database\Eloquent\Model;
9
 use UnexpectedValueException;
7
 use UnexpectedValueException;
10
 
8
 
11
 class MoneyCast implements CastsAttributes
9
 class MoneyCast implements CastsAttributes
12
 {
10
 {
13
-    public function get(Model $model, string $key, mixed $value, array $attributes): string
11
+    public function get(Model $model, string $key, mixed $value, array $attributes): int
14
     {
12
     {
15
-        $currency_code = $attributes['currency_code'] ?? CurrencyAccessor::getDefaultCurrency();
16
-
17
-        if ($value !== null) {
18
-            return CurrencyConverter::prepareForMutator($value, $currency_code);
19
-        }
20
-
21
-        return '';
13
+        return (int) $value;
22
     }
14
     }
23
 
15
 
24
     /**
16
     /**
26
      */
18
      */
27
     public function set(Model $model, string $key, mixed $value, array $attributes): int
19
     public function set(Model $model, string $key, mixed $value, array $attributes): int
28
     {
20
     {
29
-        $currency_code = $attributes['currency_code'] ?? CurrencyAccessor::getDefaultCurrency();
30
-
31
-        if (is_numeric($value)) {
32
-            $value = (string) $value;
33
-        } elseif (! is_string($value)) {
34
-            throw new UnexpectedValueException('Expected string or numeric value for money cast');
35
-        }
36
-
37
-        return CurrencyConverter::prepareForAccessor($value, $currency_code);
21
+        return (int) $value;
38
     }
22
     }
39
 }
23
 }

+ 4
- 4
app/Casts/RateCast.php View File

16
             return '0';
16
             return '0';
17
         }
17
         }
18
 
18
 
19
-        $currency_code = $this->getDefaultCurrencyCode();
19
+        $currencyCode = $attributes['currency_code'] ?? $this->getDefaultCurrencyCode();
20
         $computation = AdjustmentComputation::parse($attributes['computation'] ?? $attributes['discount_computation'] ?? null);
20
         $computation = AdjustmentComputation::parse($attributes['computation'] ?? $attributes['discount_computation'] ?? null);
21
 
21
 
22
         if ($computation?->isFixed()) {
22
         if ($computation?->isFixed()) {
23
-            return money($value, $currency_code)->formatSimple();
23
+            return money($value, $currencyCode)->formatSimple();
24
         }
24
         }
25
 
25
 
26
         return RateCalculator::formatScaledRate($value);
26
         return RateCalculator::formatScaledRate($value);
38
 
38
 
39
         $computation = AdjustmentComputation::parse($attributes['computation'] ?? $attributes['discount_computation'] ?? null);
39
         $computation = AdjustmentComputation::parse($attributes['computation'] ?? $attributes['discount_computation'] ?? null);
40
 
40
 
41
-        $currency_code = $this->getDefaultCurrencyCode();
41
+        $currencyCode = $attributes['currency_code'] ?? $this->getDefaultCurrencyCode();
42
 
42
 
43
         if ($computation?->isFixed()) {
43
         if ($computation?->isFixed()) {
44
-            return money($value, $currency_code, true)->getAmount();
44
+            return money($value, $currencyCode, true)->getAmount();
45
         }
45
         }
46
 
46
 
47
         return RateCalculator::parseLocalizedRate($value);
47
         return RateCalculator::parseLocalizedRate($value);

+ 0
- 91
app/Casts/TransactionAmountCast.php View File

1
-<?php
2
-
3
-namespace App\Casts;
4
-
5
-use App\Models\Banking\BankAccount;
6
-use App\Utilities\Currency\CurrencyAccessor;
7
-use App\Utilities\Currency\CurrencyConverter;
8
-use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
9
-use Illuminate\Database\Eloquent\Model;
10
-use UnexpectedValueException;
11
-
12
-class TransactionAmountCast implements CastsAttributes
13
-{
14
-    /**
15
-     * Static cache to persist across instances
16
-     */
17
-    private static array $currencyCache = [];
18
-
19
-    /**
20
-     * Eagerly load all required bank accounts at once if needed
21
-     */
22
-    private function loadMissingBankAccounts(array $ids): void
23
-    {
24
-        $missingIds = array_filter($ids, static fn ($id) => ! isset(self::$currencyCache[$id]) && $id !== null);
25
-
26
-        if (empty($missingIds)) {
27
-            return;
28
-        }
29
-
30
-        /** @var BankAccount[] $accounts */
31
-        $accounts = BankAccount::with('account')
32
-            ->whereIn('id', $missingIds)
33
-            ->get();
34
-
35
-        foreach ($accounts as $account) {
36
-            self::$currencyCache[$account->id] = $account->account->currency_code ?? CurrencyAccessor::getDefaultCurrency();
37
-        }
38
-    }
39
-
40
-    public function get(Model $model, string $key, mixed $value, array $attributes): string
41
-    {
42
-        // Attempt to retrieve the currency code from the related bankAccount->account model
43
-        $bankAccountId = $attributes['bank_account_id'] ?? null;
44
-
45
-        if ($bankAccountId !== null && ! isset(self::$currencyCache[$bankAccountId])) {
46
-            $this->loadMissingBankAccounts([$bankAccountId]);
47
-        }
48
-
49
-        $currencyCode = $this->getCurrencyCodeFromBankAccountId($bankAccountId);
50
-
51
-        if ($value !== null) {
52
-            return CurrencyConverter::prepareForMutator($value, $currencyCode);
53
-        }
54
-
55
-        return '';
56
-    }
57
-
58
-    /**
59
-     * @throws UnexpectedValueException
60
-     */
61
-    public function set(Model $model, string $key, mixed $value, array $attributes): int
62
-    {
63
-        $bankAccountId = $attributes['bank_account_id'] ?? null;
64
-
65
-        if ($bankAccountId !== null && ! isset(self::$currencyCache[$bankAccountId])) {
66
-            $this->loadMissingBankAccounts([$bankAccountId]);
67
-        }
68
-
69
-        $currencyCode = $this->getCurrencyCodeFromBankAccountId($bankAccountId);
70
-
71
-        if (is_numeric($value)) {
72
-            $value = (string) $value;
73
-        } elseif (! is_string($value)) {
74
-            throw new UnexpectedValueException('Expected string or numeric value for money cast');
75
-        }
76
-
77
-        return CurrencyConverter::prepareForAccessor($value, $currencyCode);
78
-    }
79
-
80
-    /**
81
-     * Get currency code from the cache or use default
82
-     */
83
-    private function getCurrencyCodeFromBankAccountId(?int $bankAccountId): string
84
-    {
85
-        if ($bankAccountId === null) {
86
-            return CurrencyAccessor::getDefaultCurrency();
87
-        }
88
-
89
-        return self::$currencyCache[$bankAccountId] ?? CurrencyAccessor::getDefaultCurrency();
90
-    }
91
-}

+ 0
- 14
app/Casts/TrimLeadingZeroCast.php View File

1
-<?php
2
-
3
-namespace App\Casts;
4
-
5
-use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
6
-use Illuminate\Database\Eloquent\Model;
7
-
8
-class TrimLeadingZeroCast implements CastsInboundAttributes
9
-{
10
-    public function set(Model $model, string $key, mixed $value, array $attributes): int
11
-    {
12
-        return (int) ltrim($value, '0');
13
-    }
14
-}

+ 2
- 3
app/Collections/Accounting/JournalEntryCollection.php View File

4
 
4
 
5
 use App\Models\Accounting\JournalEntry;
5
 use App\Models\Accounting\JournalEntry;
6
 use App\Utilities\Currency\CurrencyAccessor;
6
 use App\Utilities\Currency\CurrencyAccessor;
7
-use App\Utilities\Currency\CurrencyConverter;
8
 use App\ValueObjects\Money;
7
 use App\ValueObjects\Money;
9
 use Illuminate\Database\Eloquent\Collection;
8
 use Illuminate\Database\Eloquent\Collection;
10
 
9
 
14
     {
13
     {
15
         $total = $this->reduce(static function ($carry, JournalEntry $item) {
14
         $total = $this->reduce(static function ($carry, JournalEntry $item) {
16
             if ($item->type->isDebit()) {
15
             if ($item->type->isDebit()) {
17
-                $amountAsInteger = CurrencyConverter::convertToCents($item->amount);
16
+                $amountAsInteger = $item->amount;
18
 
17
 
19
                 return bcadd($carry, $amountAsInteger, 0);
18
                 return bcadd($carry, $amountAsInteger, 0);
20
             }
19
             }
29
     {
28
     {
30
         $total = $this->reduce(static function ($carry, JournalEntry $item) {
29
         $total = $this->reduce(static function ($carry, JournalEntry $item) {
31
             if ($item->type->isCredit()) {
30
             if ($item->type->isCredit()) {
32
-                $amountAsInteger = CurrencyConverter::convertToCents($item->amount);
31
+                $amountAsInteger = $item->amount;
33
 
32
 
34
                 return bcadd($carry, $amountAsInteger, 0);
33
                 return bcadd($carry, $amountAsInteger, 0);
35
             }
34
             }

+ 1
- 24
app/Concerns/HasTransactionAction.php View File

9
 use App\Models\Accounting\Transaction;
9
 use App\Models\Accounting\Transaction;
10
 use App\Models\Banking\BankAccount;
10
 use App\Models\Banking\BankAccount;
11
 use App\Utilities\Currency\CurrencyAccessor;
11
 use App\Utilities\Currency\CurrencyAccessor;
12
-use App\Utilities\Currency\CurrencyConverter;
13
 use Awcodes\TableRepeater\Header;
12
 use Awcodes\TableRepeater\Header;
14
 use Closure;
13
 use Closure;
15
 use Filament\Forms;
14
 use Filament\Forms;
91
                     ->options(fn (?Transaction $transaction) => Transaction::getBankAccountOptions(currentBankAccountId: $transaction?->bank_account_id))
90
                     ->options(fn (?Transaction $transaction) => Transaction::getBankAccountOptions(currentBankAccountId: $transaction?->bank_account_id))
92
                     ->live()
91
                     ->live()
93
                     ->searchable()
92
                     ->searchable()
94
-                    ->afterStateUpdated(function (Forms\Set $set, $state, $old, Forms\Get $get) {
95
-                        $amount = CurrencyConverter::convertAndSet(
96
-                            BankAccount::find($state)->account->currency_code,
97
-                            BankAccount::find($old)->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(),
98
-                            $get('amount')
99
-                        );
100
-
101
-                        if ($amount !== null) {
102
-                            $set('amount', $amount);
103
-                        }
104
-                    })
105
                     ->required(),
93
                     ->required(),
106
                 Forms\Components\Select::make('type')
94
                 Forms\Components\Select::make('type')
107
                     ->label('Type')
95
                     ->label('Type')
144
                     ->options(fn (Forms\Get $get, ?Transaction $transaction) => Transaction::getBankAccountOptions(excludedAccountId: $get('account_id'), currentBankAccountId: $transaction?->bank_account_id))
132
                     ->options(fn (Forms\Get $get, ?Transaction $transaction) => Transaction::getBankAccountOptions(excludedAccountId: $get('account_id'), currentBankAccountId: $transaction?->bank_account_id))
145
                     ->live()
133
                     ->live()
146
                     ->searchable()
134
                     ->searchable()
147
-                    ->afterStateUpdated(function (Forms\Set $set, $state, $old, Forms\Get $get) {
148
-                        $amount = CurrencyConverter::convertAndSet(
149
-                            BankAccount::find($state)->account->currency_code,
150
-                            BankAccount::find($old)->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(),
151
-                            $get('amount')
152
-                        );
153
-
154
-                        if ($amount !== null) {
155
-                            $set('amount', $amount);
156
-                        }
157
-                    })
158
                     ->required(),
135
                     ->required(),
159
                 Forms\Components\Select::make('type')
136
                 Forms\Components\Select::make('type')
160
                     ->label('Type')
137
                     ->label('Type')
350
             Forms\Components\TextInput::make('amount')
327
             Forms\Components\TextInput::make('amount')
351
                 ->label('Amount')
328
                 ->label('Amount')
352
                 ->live()
329
                 ->live()
353
-                ->mask(moneyMask(CurrencyAccessor::getDefaultCurrency()))
330
+                ->money()
354
                 ->afterStateUpdated(function (Forms\Get $get, Forms\Set $set, ?string $state, ?string $old) {
331
                 ->afterStateUpdated(function (Forms\Get $get, Forms\Set $set, ?string $state, ?string $old) {
355
                     $this->updateJournalEntryAmount(JournalEntryType::parse($get('type')), $state, $old);
332
                     $this->updateJournalEntryAmount(JournalEntryType::parse($get('type')), $state, $old);
356
                 })
333
                 })

+ 4
- 4
app/Concerns/ManagesLineItems.php View File

97
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
97
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
98
 
98
 
99
         return [
99
         return [
100
-            'subtotal' => CurrencyConverter::convertCentsToFormatSimple($subtotalCents, $currencyCode),
101
-            'tax_total' => CurrencyConverter::convertCentsToFormatSimple($taxTotalCents, $currencyCode),
102
-            'discount_total' => CurrencyConverter::convertCentsToFormatSimple($discountTotalCents, $currencyCode),
103
-            'total' => CurrencyConverter::convertCentsToFormatSimple($grandTotalCents, $currencyCode),
100
+            'subtotal' => $subtotalCents,
101
+            'tax_total' => $taxTotalCents,
102
+            'discount_total' => $discountTotalCents,
103
+            'total' => $grandTotalCents,
104
         ];
104
         ];
105
     }
105
     }
106
 
106
 

+ 2
- 2
app/DTO/DocumentDTO.php View File

94
         );
94
         );
95
     }
95
     }
96
 
96
 
97
-    protected static function formatToMoney(float | string $value, ?string $currencyCode): string
97
+    protected static function formatToMoney(int $value, ?string $currencyCode): string
98
     {
98
     {
99
-        return CurrencyConverter::formatToMoney($value, $currencyCode);
99
+        return CurrencyConverter::formatCentsToMoney($value, $currencyCode);
100
     }
100
     }
101
 
101
 
102
     public function getFontHtml(): Htmlable
102
     public function getFontHtml(): Htmlable

+ 6
- 6
app/DTO/DocumentPreviewDTO.php View File

17
         $paymentTerms = PaymentTerms::parse($data['payment_terms']) ?? $settings->payment_terms;
17
         $paymentTerms = PaymentTerms::parse($data['payment_terms']) ?? $settings->payment_terms;
18
 
18
 
19
         $amountDue = $settings->type !== DocumentType::Estimate ?
19
         $amountDue = $settings->type !== DocumentType::Estimate ?
20
-            self::formatToMoney('950', null) :
20
+            self::formatToMoney(95000, null) :
21
             null;
21
             null;
22
 
22
 
23
         return new self(
23
         return new self(
31
             date: $company->locale->date_format->getLabel(),
31
             date: $company->locale->date_format->getLabel(),
32
             dueDate: $paymentTerms->getDueDate($company->locale->date_format->value),
32
             dueDate: $paymentTerms->getDueDate($company->locale->date_format->value),
33
             currencyCode: CurrencyAccessor::getDefaultCurrency(),
33
             currencyCode: CurrencyAccessor::getDefaultCurrency(),
34
-            subtotal: self::formatToMoney('1000', null),
35
-            discount: self::formatToMoney('100', null),
36
-            tax: self::formatToMoney('50', null),
37
-            total: self::formatToMoney('950', null),
38
-            amountDue: $amountDue,
34
+            subtotal: self::formatToMoney(100000, null), // $1000.00
35
+            discount: self::formatToMoney(10000, null), // $100.00
36
+            tax: self::formatToMoney(5000, null), // $50.00
37
+            total: self::formatToMoney(95000, null), // $950.00
38
+            amountDue: $amountDue, // $950.00 or null for estimates
39
             company: CompanyDTO::fromModel($company),
39
             company: CompanyDTO::fromModel($company),
40
             client: ClientPreviewDTO::fake(),
40
             client: ClientPreviewDTO::fake(),
41
             lineItems: LineItemPreviewDTO::fakeItems(),
41
             lineItems: LineItemPreviewDTO::fakeItems(),

+ 5
- 1
app/DTO/LineItemDTO.php View File

26
         );
26
         );
27
     }
27
     }
28
 
28
 
29
-    protected static function formatToMoney(float | string $value, ?string $currencyCode): string
29
+    protected static function formatToMoney(float | string | int $value, ?string $currencyCode): string
30
     {
30
     {
31
+        if (is_int($value)) {
32
+            return CurrencyConverter::formatCentsToMoney($value, $currencyCode);
33
+        }
34
+
31
         return CurrencyConverter::formatToMoney($value, $currencyCode);
35
         return CurrencyConverter::formatToMoney($value, $currencyCode);
32
     }
36
     }
33
 }
37
 }

+ 12
- 12
app/DTO/LineItemPreviewDTO.php View File

8
     {
8
     {
9
         return [
9
         return [
10
             new self(
10
             new self(
11
-                name: 'Item 1',
12
-                description: 'Sample item description',
11
+                name: 'Professional Services',
12
+                description: 'Consulting and strategic planning',
13
                 quantity: 2,
13
                 quantity: 2,
14
-                unitPrice: self::formatToMoney(150.00, null),
15
-                subtotal: self::formatToMoney(300.00, null),
14
+                unitPrice: self::formatToMoney(15000, null), // $150.00
15
+                subtotal: self::formatToMoney(30000, null),  // $300.00
16
             ),
16
             ),
17
             new self(
17
             new self(
18
-                name: 'Item 2',
19
-                description: 'Another sample item description',
18
+                name: 'Software License',
19
+                description: 'Annual subscription and support',
20
                 quantity: 3,
20
                 quantity: 3,
21
-                unitPrice: self::formatToMoney(200.00, null),
22
-                subtotal: self::formatToMoney(600.00, null),
21
+                unitPrice: self::formatToMoney(20000, null), // $200.00
22
+                subtotal: self::formatToMoney(60000, null),  // $600.00
23
             ),
23
             ),
24
             new self(
24
             new self(
25
-                name: 'Item 3',
26
-                description: 'Yet another sample item description',
25
+                name: 'Training Session',
26
+                description: 'Team onboarding and documentation',
27
                 quantity: 1,
27
                 quantity: 1,
28
-                unitPrice: self::formatToMoney(180.00, null),
29
-                subtotal: self::formatToMoney(180.00, null),
28
+                unitPrice: self::formatToMoney(10000, null), // $100.00
29
+                subtotal: self::formatToMoney(10000, null),  // $100.00
30
             ),
30
             ),
31
         ];
31
         ];
32
     }
32
     }

+ 2
- 0
app/Filament/Company/Resources/Accounting/TransactionResource.php View File

7
 use App\Filament\Forms\Components\DateRangeSelect;
7
 use App\Filament\Forms\Components\DateRangeSelect;
8
 use App\Filament\Tables\Actions\EditTransactionAction;
8
 use App\Filament\Tables\Actions\EditTransactionAction;
9
 use App\Filament\Tables\Actions\ReplicateBulkAction;
9
 use App\Filament\Tables\Actions\ReplicateBulkAction;
10
+use App\Filament\Tables\Columns;
10
 use App\Models\Accounting\JournalEntry;
11
 use App\Models\Accounting\JournalEntry;
11
 use App\Models\Accounting\Transaction;
12
 use App\Models\Accounting\Transaction;
12
 use App\Models\Common\Client;
13
 use App\Models\Common\Client;
55
                     });
56
                     });
56
             })
57
             })
57
             ->columns([
58
             ->columns([
59
+                Columns::id(),
58
                 Tables\Columns\TextColumn::make('posted_at')
60
                 Tables\Columns\TextColumn::make('posted_at')
59
                     ->label('Date')
61
                     ->label('Date')
60
                     ->sortable()
62
                     ->sortable()

+ 2
- 3
app/Filament/Company/Resources/Common/OfferingResource.php View File

12
 use App\Filament\Forms\Components\CreateAccountSelect;
12
 use App\Filament\Forms\Components\CreateAccountSelect;
13
 use App\Filament\Forms\Components\CreateAdjustmentSelect;
13
 use App\Filament\Forms\Components\CreateAdjustmentSelect;
14
 use App\Models\Common\Offering;
14
 use App\Models\Common\Offering;
15
-use App\Utilities\Currency\CurrencyAccessor;
16
 use Filament\Forms;
15
 use Filament\Forms;
17
 use Filament\Forms\Form;
16
 use Filament\Forms\Form;
18
 use Filament\Resources\Resource;
17
 use Filament\Resources\Resource;
103
             ])->columns();
102
             ])->columns();
104
     }
103
     }
105
 
104
 
106
-    public static function getSellableSection(bool $showByDefault = false): Forms\Components\Section
105
+    public static function getSellableSection(): Forms\Components\Section
107
     {
106
     {
108
         return Forms\Components\Section::make('Sale Information')
107
         return Forms\Components\Section::make('Sale Information')
109
             ->schema([
108
             ->schema([
178
                 Tables\Columns\TextColumn::make('type')
177
                 Tables\Columns\TextColumn::make('type')
179
                     ->searchable(),
178
                     ->searchable(),
180
                 Tables\Columns\TextColumn::make('price')
179
                 Tables\Columns\TextColumn::make('price')
181
-                    ->currency(CurrencyAccessor::getDefaultCurrency(), true)
180
+                    ->currency()
182
                     ->sortable()
181
                     ->sortable()
183
                     ->description(function (Offering $record) {
182
                     ->description(function (Offering $record) {
184
                         $adjustments = $record->adjustments()
183
                         $adjustments = $record->adjustments()

+ 18
- 18
app/Filament/Company/Resources/Purchases/BillResource.php View File

152
                                         $date = $get('date');
152
                                         $date = $get('date');
153
                                         $paymentTerms = $get('payment_terms');
153
                                         $paymentTerms = $get('payment_terms');
154
 
154
 
155
-                                        if (! $date || $paymentTerms === 'custom') {
155
+                                        if (! $date || $paymentTerms === 'custom' || ! $paymentTerms) {
156
                                             return;
156
                                             return;
157
                                         }
157
                                         }
158
 
158
 
261
                                                 return;
261
                                                 return;
262
                                             }
262
                                             }
263
 
263
 
264
-                                            $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
264
+                                            $unitPrice = CurrencyConverter::convertCentsToFormatSimple($offeringRecord->price, 'USD');
265
 
265
 
266
                                             $set('description', $offeringRecord->description);
266
                                             $set('description', $offeringRecord->description);
267
                                             $set('unit_price', $unitPrice);
267
                                             $set('unit_price', $unitPrice);
282
                                     ->maxValue(9999999999.99)
282
                                     ->maxValue(9999999999.99)
283
                                     ->default(1),
283
                                     ->default(1),
284
                                 Forms\Components\TextInput::make('unit_price')
284
                                 Forms\Components\TextInput::make('unit_price')
285
-                                    ->label('Price')
286
                                     ->hiddenLabel()
285
                                     ->hiddenLabel()
287
-                                    ->numeric()
286
+                                    ->money(useAffix: false)
288
                                     ->live()
287
                                     ->live()
289
-                                    ->maxValue(9999999999.99)
290
                                     ->default(0),
288
                                     ->default(0),
291
                                 Forms\Components\Group::make([
289
                                 Forms\Components\Group::make([
292
                                     CreateAdjustmentSelect::make('purchaseTaxes')
290
                                     CreateAdjustmentSelect::make('purchaseTaxes')
327
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
325
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
328
                                     ->content(function (Forms\Get $get) {
326
                                     ->content(function (Forms\Get $get) {
329
                                         $quantity = max((float) ($get('quantity') ?? 0), 0);
327
                                         $quantity = max((float) ($get('quantity') ?? 0), 0);
330
-                                        $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
328
+                                        $unitPrice = CurrencyConverter::isValidAmount($get('unit_price'), 'USD')
329
+                                            ? CurrencyConverter::convertToFloat($get('unit_price'), 'USD')
330
+                                            : 0;
331
                                         $purchaseTaxes = $get('purchaseTaxes') ?? [];
331
                                         $purchaseTaxes = $get('purchaseTaxes') ?? [];
332
                                         $purchaseDiscounts = $get('purchaseDiscounts') ?? [];
332
                                         $purchaseDiscounts = $get('purchaseDiscounts') ?? [];
333
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
333
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
466
                                     ->live(onBlur: true)
466
                                     ->live(onBlur: true)
467
                                     ->helperText(function (Bill $record, $state) {
467
                                     ->helperText(function (Bill $record, $state) {
468
                                         $billCurrency = $record->currency_code;
468
                                         $billCurrency = $record->currency_code;
469
-                                        if (! CurrencyConverter::isValidAmount($state, $billCurrency)) {
469
+                                        if (! CurrencyConverter::isValidAmount($state, 'USD')) {
470
                                             return null;
470
                                             return null;
471
                                         }
471
                                         }
472
 
472
 
473
-                                        $amountDue = $record->getRawOriginal('amount_due');
474
-                                        $amount = CurrencyConverter::convertToCents($state, $billCurrency);
473
+                                        $amountDue = $record->amount_due;
474
+                                        $amount = CurrencyConverter::convertToCents($state, 'USD');
475
 
475
 
476
                                         if ($amount <= 0) {
476
                                         if ($amount <= 0) {
477
                                             return 'Please enter a valid positive amount';
477
                                             return 'Please enter a valid positive amount';
486
                                         };
486
                                         };
487
                                     })
487
                                     })
488
                                     ->rules([
488
                                     ->rules([
489
-                                        static fn (Bill $record): Closure => static function (string $attribute, $value, Closure $fail) use ($record) {
490
-                                            if (! CurrencyConverter::isValidAmount($value, $record->currency_code)) {
489
+                                        static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
490
+                                            if (! CurrencyConverter::isValidAmount($value, 'USD')) {
491
                                                 $fail('Please enter a valid amount');
491
                                                 $fail('Please enter a valid amount');
492
                                             }
492
                                             }
493
                                         },
493
                                         },
584
                             }
584
                             }
585
                         })
585
                         })
586
                         ->mountUsing(function (Collection $records, Form $form) {
586
                         ->mountUsing(function (Collection $records, Form $form) {
587
-                            $totalAmountDue = $records->sum(fn (Bill $bill) => $bill->getRawOriginal('amount_due'));
587
+                            $totalAmountDue = $records->sum('amount_due');
588
 
588
 
589
                             $form->fill([
589
                             $form->fill([
590
                                 'posted_at' => now(),
590
                                 'posted_at' => now(),
591
-                                'amount' => CurrencyConverter::convertCentsToFormatSimple($totalAmountDue),
591
+                                'amount' => $totalAmountDue,
592
                             ]);
592
                             ]);
593
                         })
593
                         })
594
                         ->form([
594
                         ->form([
624
                                 ->label('Notes'),
624
                                 ->label('Notes'),
625
                         ])
625
                         ])
626
                         ->before(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
626
                         ->before(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
627
-                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
628
-                            $totalAmountDue = $records->sum(fn (Bill $bill) => $bill->getRawOriginal('amount_due'));
627
+                            $totalPaymentAmount = $data['amount'] ?? 0;
628
+                            $totalAmountDue = $records->sum('amount_due');
629
 
629
 
630
                             if ($totalPaymentAmount > $totalAmountDue) {
630
                             if ($totalPaymentAmount > $totalAmountDue) {
631
                                 $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
631
                                 $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
641
                             }
641
                             }
642
                         })
642
                         })
643
                         ->action(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
643
                         ->action(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
644
-                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
644
+                            $totalPaymentAmount = $data['amount'] ?? 0;
645
                             $remainingAmount = $totalPaymentAmount;
645
                             $remainingAmount = $totalPaymentAmount;
646
 
646
 
647
                             $records->each(function (Bill $record) use (&$remainingAmount, $data) {
647
                             $records->each(function (Bill $record) use (&$remainingAmount, $data) {
648
-                                $amountDue = $record->getRawOriginal('amount_due');
648
+                                $amountDue = $record->amount_due;
649
 
649
 
650
                                 if ($amountDue <= 0 || $remainingAmount <= 0) {
650
                                 if ($amountDue <= 0 || $remainingAmount <= 0) {
651
                                     return;
651
                                     return;
652
                                 }
652
                                 }
653
 
653
 
654
                                 $paymentAmount = min($amountDue, $remainingAmount);
654
                                 $paymentAmount = min($amountDue, $remainingAmount);
655
-                                $data['amount'] = CurrencyConverter::convertCentsToFormatSimple($paymentAmount);
655
+                                $data['amount'] = $paymentAmount;
656
 
656
 
657
                                 $record->recordPayment($data);
657
                                 $record->recordPayment($data);
658
                                 $remainingAmount -= $paymentAmount;
658
                                 $remainingAmount -= $paymentAmount;

+ 1
- 5
app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php View File

45
 
45
 
46
         $record = parent::handleRecordUpdate($record, $data);
46
         $record = parent::handleRecordUpdate($record, $data);
47
 
47
 
48
-        if (! $record->initialTransaction) {
49
-            $record->createInitialTransaction();
50
-        } else {
51
-            $record->updateInitialTransaction();
52
-        }
48
+        $record->updateInitialTransaction();
53
 
49
 
54
         return $record;
50
         return $record;
55
     }
51
     }

+ 9
- 12
app/Filament/Company/Resources/Purchases/BillResource/RelationManagers/PaymentsRelationManager.php View File

79
 
79
 
80
                                 $billCurrency = $ownerRecord->currency_code;
80
                                 $billCurrency = $ownerRecord->currency_code;
81
 
81
 
82
-                                if (! CurrencyConverter::isValidAmount($state, $billCurrency)) {
82
+                                if (! CurrencyConverter::isValidAmount($state, 'USD')) {
83
                                     return null;
83
                                     return null;
84
                                 }
84
                                 }
85
 
85
 
86
-                                $amountDue = $ownerRecord->getRawOriginal('amount_due');
86
+                                $amountDue = $ownerRecord->amount_due;
87
 
87
 
88
-                                $amount = CurrencyConverter::convertToCents($state, $billCurrency);
88
+                                $amount = CurrencyConverter::convertToCents($state, 'USD');
89
 
89
 
90
                                 if ($amount <= 0) {
90
                                 if ($amount <= 0) {
91
                                     return 'Please enter a valid positive amount';
91
                                     return 'Please enter a valid positive amount';
92
                                 }
92
                                 }
93
 
93
 
94
-                                $currentPaymentAmount = $record?->getRawOriginal('amount') ?? 0;
94
+                                $currentPaymentAmount = $record?->amount ?? 0;
95
 
95
 
96
                                 $newAmountDue = $amountDue - $amount + $currentPaymentAmount;
96
                                 $newAmountDue = $amountDue - $amount + $currentPaymentAmount;
97
 
97
 
102
                                 };
102
                                 };
103
                             })
103
                             })
104
                             ->rules([
104
                             ->rules([
105
-                                static fn (RelationManager $livewire): Closure => static function (string $attribute, $value, Closure $fail) use ($livewire) {
106
-                                    /** @var Bill $bill */
107
-                                    $bill = $livewire->getOwnerRecord();
108
-
109
-                                    if (! CurrencyConverter::isValidAmount($value, $bill->currency_code)) {
105
+                                static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
106
+                                    if (! CurrencyConverter::isValidAmount($value, 'USD')) {
110
                                         $fail('Please enter a valid amount');
107
                                         $fail('Please enter a valid amount');
111
                                     }
108
                                     }
112
                                 },
109
                                 },
122
                         $bill = $livewire->getOwnerRecord();
119
                         $bill = $livewire->getOwnerRecord();
123
                         $billCurrency = $bill->currency_code;
120
                         $billCurrency = $bill->currency_code;
124
 
121
 
125
-                        if (empty($amount) || empty($bankAccountId) || ! CurrencyConverter::isValidAmount($amount, $billCurrency)) {
122
+                        if (empty($amount) || empty($bankAccountId) || ! CurrencyConverter::isValidAmount($amount, 'USD')) {
126
                             return null;
123
                             return null;
127
                         }
124
                         }
128
 
125
 
139
                         }
136
                         }
140
 
137
 
141
                         // Convert amount from bill currency to bank currency
138
                         // Convert amount from bill currency to bank currency
142
-                        $amountInBillCurrencyCents = CurrencyConverter::convertToCents($amount, $billCurrency);
139
+                        $amountInBillCurrencyCents = CurrencyConverter::convertToCents($amount, 'USD');
143
                         $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
140
                         $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
144
                             $amountInBillCurrencyCents,
141
                             $amountInBillCurrencyCents,
145
                             $billCurrency,
142
                             $billCurrency,
210
                         }
207
                         }
211
                     )
208
                     )
212
                     ->sortable()
209
                     ->sortable()
213
-                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
210
+                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code ?? CurrencyAccessor::getDefaultCurrency()),
214
             ])
211
             ])
215
             ->filters([
212
             ->filters([
216
                 //
213
                 //

+ 5
- 4
app/Filament/Company/Resources/Sales/EstimateResource.php View File

259
                                                 return;
259
                                                 return;
260
                                             }
260
                                             }
261
 
261
 
262
-                                            $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
262
+                                            $unitPrice = CurrencyConverter::convertCentsToFormatSimple($offeringRecord->price, 'USD');
263
 
263
 
264
                                             $set('description', $offeringRecord->description);
264
                                             $set('description', $offeringRecord->description);
265
                                             $set('unit_price', $unitPrice);
265
                                             $set('unit_price', $unitPrice);
281
                                     ->default(1),
281
                                     ->default(1),
282
                                 Forms\Components\TextInput::make('unit_price')
282
                                 Forms\Components\TextInput::make('unit_price')
283
                                     ->hiddenLabel()
283
                                     ->hiddenLabel()
284
-                                    ->numeric()
284
+                                    ->money(useAffix: false)
285
                                     ->live()
285
                                     ->live()
286
-                                    ->maxValue(9999999999.99)
287
                                     ->default(0),
286
                                     ->default(0),
288
                                 Forms\Components\Group::make([
287
                                 Forms\Components\Group::make([
289
                                     CreateAdjustmentSelect::make('salesTaxes')
288
                                     CreateAdjustmentSelect::make('salesTaxes')
324
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
323
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
325
                                     ->content(function (Forms\Get $get) {
324
                                     ->content(function (Forms\Get $get) {
326
                                         $quantity = max((float) ($get('quantity') ?? 0), 0);
325
                                         $quantity = max((float) ($get('quantity') ?? 0), 0);
327
-                                        $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
326
+                                        $unitPrice = CurrencyConverter::isValidAmount($get('unit_price'), 'USD')
327
+                                            ? CurrencyConverter::convertToFloat($get('unit_price'), 'USD')
328
+                                            : 0;
328
                                         $salesTaxes = $get('salesTaxes') ?? [];
329
                                         $salesTaxes = $get('salesTaxes') ?? [];
329
                                         $salesDiscounts = $get('salesDiscounts') ?? [];
330
                                         $salesDiscounts = $get('salesDiscounts') ?? [];
330
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
331
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();

+ 17
- 16
app/Filament/Company/Resources/Sales/InvoiceResource.php View File

163
                                         $invoiceDate = $get('date');
163
                                         $invoiceDate = $get('date');
164
                                         $paymentTerms = $get('payment_terms');
164
                                         $paymentTerms = $get('payment_terms');
165
 
165
 
166
-                                        if (! $invoiceDate || $paymentTerms === 'custom') {
166
+                                        if (! $invoiceDate || $paymentTerms === 'custom' || ! $paymentTerms) {
167
                                             return;
167
                                             return;
168
                                         }
168
                                         }
169
 
169
 
272
                                                 return;
272
                                                 return;
273
                                             }
273
                                             }
274
 
274
 
275
-                                            $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
275
+                                            $unitPrice = CurrencyConverter::convertCentsToFormatSimple($offeringRecord->price, 'USD');
276
 
276
 
277
                                             $set('description', $offeringRecord->description);
277
                                             $set('description', $offeringRecord->description);
278
                                             $set('unit_price', $unitPrice);
278
                                             $set('unit_price', $unitPrice);
294
                                     ->default(1),
294
                                     ->default(1),
295
                                 Forms\Components\TextInput::make('unit_price')
295
                                 Forms\Components\TextInput::make('unit_price')
296
                                     ->hiddenLabel()
296
                                     ->hiddenLabel()
297
-                                    ->numeric()
297
+                                    ->money(useAffix: false)
298
                                     ->live()
298
                                     ->live()
299
-                                    ->maxValue(9999999999.99)
300
                                     ->default(0),
299
                                     ->default(0),
301
                                 Forms\Components\Group::make([
300
                                 Forms\Components\Group::make([
302
                                     CreateAdjustmentSelect::make('salesTaxes')
301
                                     CreateAdjustmentSelect::make('salesTaxes')
337
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
336
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
338
                                     ->content(function (Forms\Get $get) {
337
                                     ->content(function (Forms\Get $get) {
339
                                         $quantity = max((float) ($get('quantity') ?? 0), 0);
338
                                         $quantity = max((float) ($get('quantity') ?? 0), 0);
340
-                                        $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
339
+                                        $unitPrice = CurrencyConverter::isValidAmount($get('unit_price'), 'USD')
340
+                                            ? CurrencyConverter::convertToFloat($get('unit_price'), 'USD')
341
+                                            : 0;
341
                                         $salesTaxes = $get('salesTaxes') ?? [];
342
                                         $salesTaxes = $get('salesTaxes') ?? [];
342
                                         $salesDiscounts = $get('salesDiscounts') ?? [];
343
                                         $salesDiscounts = $get('salesDiscounts') ?? [];
343
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
344
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
518
                                     ->live(onBlur: true)
519
                                     ->live(onBlur: true)
519
                                     ->helperText(function (Invoice $record, $state) {
520
                                     ->helperText(function (Invoice $record, $state) {
520
                                         $invoiceCurrency = $record->currency_code;
521
                                         $invoiceCurrency = $record->currency_code;
521
-                                        if (! CurrencyConverter::isValidAmount($state, $invoiceCurrency)) {
522
+                                        if (! CurrencyConverter::isValidAmount($state, 'USD')) {
522
                                             return null;
523
                                             return null;
523
                                         }
524
                                         }
524
 
525
 
525
-                                        $amountDue = $record->getRawOriginal('amount_due');
526
+                                        $amountDue = $record->amount_due;
526
 
527
 
527
-                                        $amount = CurrencyConverter::convertToCents($state, $invoiceCurrency);
528
+                                        $amount = CurrencyConverter::convertToCents($state, 'USD');
528
 
529
 
529
                                         if ($amount <= 0) {
530
                                         if ($amount <= 0) {
530
                                             return 'Please enter a valid positive amount';
531
                                             return 'Please enter a valid positive amount';
543
                                         };
544
                                         };
544
                                     })
545
                                     })
545
                                     ->rules([
546
                                     ->rules([
546
-                                        static fn (Invoice $record): Closure => static function (string $attribute, $value, Closure $fail) use ($record) {
547
-                                            if (! CurrencyConverter::isValidAmount($value, $record->currency_code)) {
547
+                                        static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
548
+                                            if (! CurrencyConverter::isValidAmount($value, 'USD')) {
548
                                                 $fail('Please enter a valid amount');
549
                                                 $fail('Please enter a valid amount');
549
                                             }
550
                                             }
550
                                         },
551
                                         },
697
                             }
698
                             }
698
                         })
699
                         })
699
                         ->mountUsing(function (DocumentCollection $records, Form $form) {
700
                         ->mountUsing(function (DocumentCollection $records, Form $form) {
700
-                            $totalAmountDue = $records->sumMoneyFormattedSimple('amount_due');
701
+                            $totalAmountDue = $records->sum('amount_due');
701
 
702
 
702
                             $form->fill([
703
                             $form->fill([
703
                                 'posted_at' => now(),
704
                                 'posted_at' => now(),
737
                                 ->label('Notes'),
738
                                 ->label('Notes'),
738
                         ])
739
                         ])
739
                         ->before(function (DocumentCollection $records, Tables\Actions\BulkAction $action, array $data) {
740
                         ->before(function (DocumentCollection $records, Tables\Actions\BulkAction $action, array $data) {
740
-                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
741
-                            $totalAmountDue = $records->sumMoneyInCents('amount_due');
741
+                            $totalPaymentAmount = $data['amount'] ?? 0;
742
+                            $totalAmountDue = $records->sum('amount_due');
742
 
743
 
743
                             if ($totalPaymentAmount > $totalAmountDue) {
744
                             if ($totalPaymentAmount > $totalAmountDue) {
744
                                 $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
745
                                 $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
754
                             }
755
                             }
755
                         })
756
                         })
756
                         ->action(function (DocumentCollection $records, Tables\Actions\BulkAction $action, array $data) {
757
                         ->action(function (DocumentCollection $records, Tables\Actions\BulkAction $action, array $data) {
757
-                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
758
+                            $totalPaymentAmount = $data['amount'] ?? 0;
758
 
759
 
759
                             $remainingAmount = $totalPaymentAmount;
760
                             $remainingAmount = $totalPaymentAmount;
760
 
761
 
761
                             $records->each(function (Invoice $record) use (&$remainingAmount, $data) {
762
                             $records->each(function (Invoice $record) use (&$remainingAmount, $data) {
762
-                                $amountDue = $record->getRawOriginal('amount_due');
763
+                                $amountDue = $record->amount_due;
763
 
764
 
764
                                 if ($amountDue <= 0 || $remainingAmount <= 0) {
765
                                 if ($amountDue <= 0 || $remainingAmount <= 0) {
765
                                     return;
766
                                     return;
767
 
768
 
768
                                 $paymentAmount = min($amountDue, $remainingAmount);
769
                                 $paymentAmount = min($amountDue, $remainingAmount);
769
 
770
 
770
-                                $data['amount'] = CurrencyConverter::convertCentsToFormatSimple($paymentAmount);
771
+                                $data['amount'] = $paymentAmount;
771
 
772
 
772
                                 $record->recordPayment($data);
773
                                 $record->recordPayment($data);
773
 
774
 

+ 7
- 7
app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php View File

88
 
88
 
89
                                 $invoiceCurrency = $ownerRecord->currency_code;
89
                                 $invoiceCurrency = $ownerRecord->currency_code;
90
 
90
 
91
-                                if (! CurrencyConverter::isValidAmount($state, $invoiceCurrency)) {
91
+                                if (! CurrencyConverter::isValidAmount($state, 'USD')) {
92
                                     return null;
92
                                     return null;
93
                                 }
93
                                 }
94
 
94
 
95
-                                $amountDue = $ownerRecord->getRawOriginal('amount_due');
95
+                                $amountDue = $ownerRecord->amount_due;
96
 
96
 
97
-                                $amount = CurrencyConverter::convertToCents($state, $invoiceCurrency);
97
+                                $amount = CurrencyConverter::convertToCents($state, 'USD');
98
 
98
 
99
                                 if ($amount <= 0) {
99
                                 if ($amount <= 0) {
100
                                     return 'Please enter a valid positive amount';
100
                                     return 'Please enter a valid positive amount';
101
                                 }
101
                                 }
102
 
102
 
103
-                                $currentPaymentAmount = $record?->getRawOriginal('amount') ?? 0;
103
+                                $currentPaymentAmount = $record?->amount ?? 0;
104
 
104
 
105
                                 if ($ownerRecord->status === InvoiceStatus::Overpaid) {
105
                                 if ($ownerRecord->status === InvoiceStatus::Overpaid) {
106
                                     $newAmountDue = $amountDue + $amount - $currentPaymentAmount;
106
                                     $newAmountDue = $amountDue + $amount - $currentPaymentAmount;
135
                         $invoice = $livewire->getOwnerRecord();
135
                         $invoice = $livewire->getOwnerRecord();
136
                         $invoiceCurrency = $invoice->currency_code;
136
                         $invoiceCurrency = $invoice->currency_code;
137
 
137
 
138
-                        if (empty($amount) || empty($bankAccountId) || ! CurrencyConverter::isValidAmount($amount, $invoiceCurrency)) {
138
+                        if (empty($amount) || empty($bankAccountId) || ! CurrencyConverter::isValidAmount($amount, 'USD')) {
139
                             return null;
139
                             return null;
140
                         }
140
                         }
141
 
141
 
152
                         }
152
                         }
153
 
153
 
154
                         // Convert amount from invoice currency to bank currency
154
                         // Convert amount from invoice currency to bank currency
155
-                        $amountInInvoiceCurrencyCents = CurrencyConverter::convertToCents($amount, $invoiceCurrency);
155
+                        $amountInInvoiceCurrencyCents = CurrencyConverter::convertToCents($amount, 'USD');
156
                         $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
156
                         $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
157
                             $amountInInvoiceCurrencyCents,
157
                             $amountInInvoiceCurrencyCents,
158
                             $invoiceCurrency,
158
                             $invoiceCurrency,
223
                         }
223
                         }
224
                     )
224
                     )
225
                     ->sortable()
225
                     ->sortable()
226
-                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
226
+                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code ?? CurrencyAccessor::getDefaultCurrency()),
227
             ])
227
             ])
228
             ->filters([
228
             ->filters([
229
                 //
229
                 //

+ 5
- 4
app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php View File

185
                                                 return;
185
                                                 return;
186
                                             }
186
                                             }
187
 
187
 
188
-                                            $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
188
+                                            $unitPrice = CurrencyConverter::convertCentsToFormatSimple($offeringRecord->price, 'USD');
189
 
189
 
190
                                             $set('description', $offeringRecord->description);
190
                                             $set('description', $offeringRecord->description);
191
                                             $set('unit_price', $unitPrice);
191
                                             $set('unit_price', $unitPrice);
207
                                     ->default(1),
207
                                     ->default(1),
208
                                 Forms\Components\TextInput::make('unit_price')
208
                                 Forms\Components\TextInput::make('unit_price')
209
                                     ->hiddenLabel()
209
                                     ->hiddenLabel()
210
-                                    ->numeric()
210
+                                    ->money(useAffix: false)
211
                                     ->live()
211
                                     ->live()
212
-                                    ->maxValue(9999999999.99)
213
                                     ->default(0),
212
                                     ->default(0),
214
                                 Forms\Components\Group::make([
213
                                 Forms\Components\Group::make([
215
                                     CreateAdjustmentSelect::make('salesTaxes')
214
                                     CreateAdjustmentSelect::make('salesTaxes')
250
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
249
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
251
                                     ->content(function (Forms\Get $get) {
250
                                     ->content(function (Forms\Get $get) {
252
                                         $quantity = max((float) ($get('quantity') ?? 0), 0);
251
                                         $quantity = max((float) ($get('quantity') ?? 0), 0);
253
-                                        $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
252
+                                        $unitPrice = CurrencyConverter::isValidAmount($get('unit_price'), 'USD')
253
+                                            ? CurrencyConverter::convertToFloat($get('unit_price'), 'USD')
254
+                                            : 0;
254
                                         $salesTaxes = $get('salesTaxes') ?? [];
255
                                         $salesTaxes = $get('salesTaxes') ?? [];
255
                                         $salesDiscounts = $get('salesDiscounts') ?? [];
256
                                         $salesDiscounts = $get('salesDiscounts') ?? [];
256
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
257
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();

+ 96
- 71
app/Models/Accounting/Bill.php View File

2
 
2
 
3
 namespace App\Models\Accounting;
3
 namespace App\Models\Accounting;
4
 
4
 
5
-use App\Casts\MoneyCast;
6
 use App\Casts\RateCast;
5
 use App\Casts\RateCast;
7
 use App\Collections\Accounting\DocumentCollection;
6
 use App\Collections\Accounting\DocumentCollection;
8
 use App\Enums\Accounting\AdjustmentComputation;
7
 use App\Enums\Accounting\AdjustmentComputation;
24
 use Illuminate\Database\Eloquent\Attributes\CollectedBy;
23
 use Illuminate\Database\Eloquent\Attributes\CollectedBy;
25
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
24
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
26
 use Illuminate\Database\Eloquent\Builder;
25
 use Illuminate\Database\Eloquent\Builder;
27
-use Illuminate\Database\Eloquent\Casts\Attribute;
28
 use Illuminate\Database\Eloquent\Model;
26
 use Illuminate\Database\Eloquent\Model;
29
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
27
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
30
 use Illuminate\Database\Eloquent\Relations\MorphMany;
28
 use Illuminate\Database\Eloquent\Relations\MorphMany;
68
         'discount_method' => DocumentDiscountMethod::class,
66
         'discount_method' => DocumentDiscountMethod::class,
69
         'discount_computation' => AdjustmentComputation::class,
67
         'discount_computation' => AdjustmentComputation::class,
70
         'discount_rate' => RateCast::class,
68
         'discount_rate' => RateCast::class,
71
-        'subtotal' => MoneyCast::class,
72
-        'tax_total' => MoneyCast::class,
73
-        'discount_total' => MoneyCast::class,
74
-        'total' => MoneyCast::class,
75
-        'amount_paid' => MoneyCast::class,
76
-        'amount_due' => MoneyCast::class,
77
     ];
69
     ];
78
 
70
 
79
     public function vendor(): BelongsTo
71
     public function vendor(): BelongsTo
137
         return $this->amount_due;
129
         return $this->amount_due;
138
     }
130
     }
139
 
131
 
140
-    protected function isCurrentlyOverdue(): Attribute
132
+    public function shouldBeOverdue(): bool
141
     {
133
     {
142
-        return Attribute::get(function () {
143
-            return $this->due_date->isBefore(today()) && $this->canBeOverdue();
144
-        });
134
+        return $this->due_date->isBefore(today()) && $this->canBeOverdue();
145
     }
135
     }
146
 
136
 
147
     public function wasInitialized(): bool
137
     public function wasInitialized(): bool
228
         $requiresConversion = $billCurrency !== $bankAccountCurrency;
218
         $requiresConversion = $billCurrency !== $bankAccountCurrency;
229
 
219
 
230
         // Store the original payment amount in bill currency before any conversion
220
         // Store the original payment amount in bill currency before any conversion
231
-        $amountInBillCurrencyCents = CurrencyConverter::convertToCents($data['amount'], $billCurrency);
221
+        $amountInBillCurrencyCents = $data['amount'];
232
 
222
 
233
         if ($requiresConversion) {
223
         if ($requiresConversion) {
234
             $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
224
             $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
236
                 $billCurrency,
226
                 $billCurrency,
237
                 $bankAccountCurrency
227
                 $bankAccountCurrency
238
             );
228
             );
239
-            $formattedAmountForBankCurrency = CurrencyConverter::convertCentsToFormatSimple(
240
-                $amountInBankCurrencyCents,
241
-                $bankAccountCurrency
242
-            );
229
+            $formattedAmountForBankCurrency = $amountInBankCurrencyCents;
243
         } else {
230
         } else {
244
-            $formattedAmountForBankCurrency = $data['amount']; // Already in simple format
231
+            $formattedAmountForBankCurrency = $amountInBillCurrencyCents;
245
         }
232
         }
246
 
233
 
247
         // Create transaction with converted amount
234
         // Create transaction with converted amount
266
     public function createInitialTransaction(?Carbon $postedAt = null): void
253
     public function createInitialTransaction(?Carbon $postedAt = null): void
267
     {
254
     {
268
         $postedAt ??= $this->date;
255
         $postedAt ??= $this->date;
269
-
270
-        $total = $this->formatAmountToDefaultCurrency($this->getRawOriginal('total'));
271
-
272
-        $transaction = $this->transactions()->create([
273
-            'company_id' => $this->company_id,
274
-            'type' => TransactionType::Journal,
275
-            'posted_at' => $postedAt,
276
-            'amount' => $total,
277
-            'description' => 'Bill Creation for Bill #' . $this->bill_number,
278
-        ]);
279
-
280
         $baseDescription = "{$this->vendor->name}: Bill #{$this->bill_number}";
256
         $baseDescription = "{$this->vendor->name}: Bill #{$this->bill_number}";
281
 
257
 
282
-        $transaction->journalEntries()->create([
283
-            'company_id' => $this->company_id,
258
+        $journalEntryData = [];
259
+
260
+        $totalInBillCurrency = $this->total;
261
+        $journalEntryData[] = [
284
             'type' => JournalEntryType::Credit,
262
             'type' => JournalEntryType::Credit,
285
             'account_id' => Account::getAccountsPayableAccount($this->company_id)->id,
263
             'account_id' => Account::getAccountsPayableAccount($this->company_id)->id,
286
-            'amount' => $total,
264
+            'amount_in_bill_currency' => $totalInBillCurrency,
287
             'description' => $baseDescription,
265
             'description' => $baseDescription,
288
-        ]);
266
+        ];
289
 
267
 
290
-        $totalLineItemSubtotalCents = $this->convertAmountToDefaultCurrency((int) $this->lineItems()->sum('subtotal'));
291
-        $billDiscountTotalCents = $this->convertAmountToDefaultCurrency((int) $this->getRawOriginal('discount_total'));
292
-        $remainingDiscountCents = $billDiscountTotalCents;
268
+        $totalLineItemSubtotalInBillCurrency = (int) $this->lineItems()->sum('subtotal');
269
+        $billDiscountTotalInBillCurrency = (int) $this->discount_total;
270
+        $remainingDiscountInBillCurrency = $billDiscountTotalInBillCurrency;
293
 
271
 
294
         foreach ($this->lineItems as $index => $lineItem) {
272
         foreach ($this->lineItems as $index => $lineItem) {
295
             $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
273
             $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
274
+            $lineItemSubtotalInBillCurrency = $lineItem->getRawOriginal('subtotal');
296
 
275
 
297
-            $lineItemSubtotal = $this->formatAmountToDefaultCurrency($lineItem->getRawOriginal('subtotal'));
298
-
299
-            $transaction->journalEntries()->create([
300
-                'company_id' => $this->company_id,
276
+            $journalEntryData[] = [
301
                 'type' => JournalEntryType::Debit,
277
                 'type' => JournalEntryType::Debit,
302
                 'account_id' => $lineItem->offering->expense_account_id,
278
                 'account_id' => $lineItem->offering->expense_account_id,
303
-                'amount' => $lineItemSubtotal,
279
+                'amount_in_bill_currency' => $lineItemSubtotalInBillCurrency,
304
                 'description' => $lineItemDescription,
280
                 'description' => $lineItemDescription,
305
-            ]);
281
+            ];
306
 
282
 
307
             foreach ($lineItem->adjustments as $adjustment) {
283
             foreach ($lineItem->adjustments as $adjustment) {
308
-                $adjustmentAmount = $this->formatAmountToDefaultCurrency($lineItem->calculateAdjustmentTotalAmount($adjustment));
284
+                $adjustmentAmountInBillCurrency = $lineItem->calculateAdjustmentTotalAmount($adjustment);
309
 
285
 
310
                 if ($adjustment->isNonRecoverablePurchaseTax()) {
286
                 if ($adjustment->isNonRecoverablePurchaseTax()) {
311
-                    $transaction->journalEntries()->create([
312
-                        'company_id' => $this->company_id,
287
+                    $journalEntryData[] = [
313
                         'type' => JournalEntryType::Debit,
288
                         'type' => JournalEntryType::Debit,
314
                         'account_id' => $lineItem->offering->expense_account_id,
289
                         'account_id' => $lineItem->offering->expense_account_id,
315
-                        'amount' => $adjustmentAmount,
290
+                        'amount_in_bill_currency' => $adjustmentAmountInBillCurrency,
316
                         'description' => "{$lineItemDescription} ({$adjustment->name})",
291
                         'description' => "{$lineItemDescription} ({$adjustment->name})",
317
-                    ]);
292
+                    ];
318
                 } elseif ($adjustment->account_id) {
293
                 } elseif ($adjustment->account_id) {
319
-                    $transaction->journalEntries()->create([
320
-                        'company_id' => $this->company_id,
294
+                    $journalEntryData[] = [
321
                         'type' => $adjustment->category->isDiscount() ? JournalEntryType::Credit : JournalEntryType::Debit,
295
                         'type' => $adjustment->category->isDiscount() ? JournalEntryType::Credit : JournalEntryType::Debit,
322
                         'account_id' => $adjustment->account_id,
296
                         'account_id' => $adjustment->account_id,
323
-                        'amount' => $adjustmentAmount,
297
+                        'amount_in_bill_currency' => $adjustmentAmountInBillCurrency,
324
                         'description' => $lineItemDescription,
298
                         'description' => $lineItemDescription,
325
-                    ]);
299
+                    ];
326
                 }
300
                 }
327
             }
301
             }
328
 
302
 
329
-            if ($this->discount_method->isPerDocument() && $totalLineItemSubtotalCents > 0) {
330
-                $lineItemSubtotalCents = $this->convertAmountToDefaultCurrency((int) $lineItem->getRawOriginal('subtotal'));
303
+            // Handle per-document discount allocation
304
+            if ($this->discount_method->isPerDocument() && $totalLineItemSubtotalInBillCurrency > 0) {
305
+                $lineItemSubtotalInBillCurrency = (int) $lineItem->getRawOriginal('subtotal');
331
 
306
 
332
                 if ($index === $this->lineItems->count() - 1) {
307
                 if ($index === $this->lineItems->count() - 1) {
333
-                    $lineItemDiscount = $remainingDiscountCents;
308
+                    $lineItemDiscountInBillCurrency = $remainingDiscountInBillCurrency;
334
                 } else {
309
                 } else {
335
-                    $lineItemDiscount = (int) round(
336
-                        ($lineItemSubtotalCents / $totalLineItemSubtotalCents) * $billDiscountTotalCents
310
+                    $lineItemDiscountInBillCurrency = (int) round(
311
+                        ($lineItemSubtotalInBillCurrency / $totalLineItemSubtotalInBillCurrency) * $billDiscountTotalInBillCurrency
337
                     );
312
                     );
338
-                    $remainingDiscountCents -= $lineItemDiscount;
313
+                    $remainingDiscountInBillCurrency -= $lineItemDiscountInBillCurrency;
339
                 }
314
                 }
340
 
315
 
341
-                if ($lineItemDiscount > 0) {
342
-                    $transaction->journalEntries()->create([
343
-                        'company_id' => $this->company_id,
316
+                if ($lineItemDiscountInBillCurrency > 0) {
317
+                    $journalEntryData[] = [
344
                         'type' => JournalEntryType::Credit,
318
                         'type' => JournalEntryType::Credit,
345
                         'account_id' => Account::getPurchaseDiscountAccount($this->company_id)->id,
319
                         'account_id' => Account::getPurchaseDiscountAccount($this->company_id)->id,
346
-                        'amount' => CurrencyConverter::convertCentsToFormatSimple($lineItemDiscount),
320
+                        'amount_in_bill_currency' => $lineItemDiscountInBillCurrency,
347
                         'description' => "{$lineItemDescription} (Proportional Discount)",
321
                         'description' => "{$lineItemDescription} (Proportional Discount)",
348
-                    ]);
322
+                    ];
349
                 }
323
                 }
350
             }
324
             }
351
         }
325
         }
326
+
327
+        // Convert amounts to default currency
328
+        $totalDebitsInDefaultCurrency = 0;
329
+        $totalCreditsInDefaultCurrency = 0;
330
+
331
+        foreach ($journalEntryData as &$entry) {
332
+            $entry['amount_in_default_currency'] = $this->formatAmountToDefaultCurrency($entry['amount_in_bill_currency']);
333
+
334
+            if ($entry['type'] === JournalEntryType::Debit) {
335
+                $totalDebitsInDefaultCurrency += $entry['amount_in_default_currency'];
336
+            } else {
337
+                $totalCreditsInDefaultCurrency += $entry['amount_in_default_currency'];
338
+            }
339
+        }
340
+
341
+        unset($entry);
342
+
343
+        // Handle currency conversion imbalance
344
+        $imbalance = $totalDebitsInDefaultCurrency - $totalCreditsInDefaultCurrency;
345
+        if ($imbalance !== 0) {
346
+            $targetType = $imbalance > 0 ? JournalEntryType::Credit : JournalEntryType::Debit;
347
+            $adjustmentAmount = abs($imbalance);
348
+
349
+            // Find last entry of target type and adjust it
350
+            $lastKey = array_key_last(array_filter($journalEntryData, fn ($entry) => $entry['type'] === $targetType, ARRAY_FILTER_USE_BOTH));
351
+            $journalEntryData[$lastKey]['amount_in_default_currency'] += $adjustmentAmount;
352
+
353
+            if ($targetType === JournalEntryType::Debit) {
354
+                $totalDebitsInDefaultCurrency += $adjustmentAmount;
355
+            } else {
356
+                $totalCreditsInDefaultCurrency += $adjustmentAmount;
357
+            }
358
+        }
359
+
360
+        if ($totalDebitsInDefaultCurrency !== $totalCreditsInDefaultCurrency) {
361
+            throw new \Exception('Journal entries do not balance for Bill #' . $this->bill_number . '. Debits: ' . $totalDebitsInDefaultCurrency . ', Credits: ' . $totalCreditsInDefaultCurrency);
362
+        }
363
+
364
+        // Create the transaction using the sum of debits
365
+        $transaction = $this->transactions()->create([
366
+            'company_id' => $this->company_id,
367
+            'type' => TransactionType::Journal,
368
+            'posted_at' => $postedAt,
369
+            'amount' => $totalDebitsInDefaultCurrency,
370
+            'description' => 'Bill Creation for Bill #' . $this->bill_number,
371
+        ]);
372
+
373
+        // Create all journal entries
374
+        foreach ($journalEntryData as $entry) {
375
+            $transaction->journalEntries()->create([
376
+                'company_id' => $this->company_id,
377
+                'type' => $entry['type'],
378
+                'account_id' => $entry['account_id'],
379
+                'amount' => $entry['amount_in_default_currency'],
380
+                'description' => $entry['description'],
381
+            ]);
382
+        }
352
     }
383
     }
353
 
384
 
354
     public function updateInitialTransaction(): void
385
     public function updateInitialTransaction(): void
355
     {
386
     {
356
-        $transaction = $this->initialTransaction;
357
-
358
-        if ($transaction) {
359
-            $transaction->delete();
360
-        }
387
+        $this->initialTransaction?->delete();
361
 
388
 
362
         $this->createInitialTransaction();
389
         $this->createInitialTransaction();
363
     }
390
     }
374
         return $amountCents;
401
         return $amountCents;
375
     }
402
     }
376
 
403
 
377
-    public function formatAmountToDefaultCurrency(int $amountCents): string
404
+    public function formatAmountToDefaultCurrency(int $amountCents): int
378
     {
405
     {
379
-        $convertedCents = $this->convertAmountToDefaultCurrency($amountCents);
380
-
381
-        return CurrencyConverter::convertCentsToFormatSimple($convertedCents);
406
+        return $this->convertAmountToDefaultCurrency($amountCents);
382
     }
407
     }
383
 
408
 
384
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
409
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction

+ 0
- 9
app/Models/Accounting/DocumentLineItem.php View File

3
 namespace App\Models\Accounting;
3
 namespace App\Models\Accounting;
4
 
4
 
5
 use Akaunting\Money\Money;
5
 use Akaunting\Money\Money;
6
-use App\Casts\DocumentMoneyCast;
7
 use App\Concerns\Blamable;
6
 use App\Concerns\Blamable;
8
 use App\Concerns\CompanyOwned;
7
 use App\Concerns\CompanyOwned;
9
 use App\Enums\Accounting\AdjustmentCategory;
8
 use App\Enums\Accounting\AdjustmentCategory;
41
         'updated_by',
40
         'updated_by',
42
     ];
41
     ];
43
 
42
 
44
-    protected $casts = [
45
-        'unit_price' => DocumentMoneyCast::class,
46
-        'subtotal' => DocumentMoneyCast::class,
47
-        'tax_total' => DocumentMoneyCast::class,
48
-        'discount_total' => DocumentMoneyCast::class,
49
-        'total' => DocumentMoneyCast::class,
50
-    ];
51
-
52
     public function documentable(): MorphTo
43
     public function documentable(): MorphTo
53
     {
44
     {
54
         return $this->morphTo();
45
         return $this->morphTo();

+ 3
- 10
app/Models/Accounting/Estimate.php View File

2
 
2
 
3
 namespace App\Models\Accounting;
3
 namespace App\Models\Accounting;
4
 
4
 
5
-use App\Casts\MoneyCast;
6
 use App\Casts\RateCast;
5
 use App\Casts\RateCast;
7
 use App\Collections\Accounting\DocumentCollection;
6
 use App\Collections\Accounting\DocumentCollection;
8
 use App\Enums\Accounting\AdjustmentComputation;
7
 use App\Enums\Accounting\AdjustmentComputation;
78
         'discount_method' => DocumentDiscountMethod::class,
77
         'discount_method' => DocumentDiscountMethod::class,
79
         'discount_computation' => AdjustmentComputation::class,
78
         'discount_computation' => AdjustmentComputation::class,
80
         'discount_rate' => RateCast::class,
79
         'discount_rate' => RateCast::class,
81
-        'subtotal' => MoneyCast::class,
82
-        'tax_total' => MoneyCast::class,
83
-        'discount_total' => MoneyCast::class,
84
-        'total' => MoneyCast::class,
85
     ];
80
     ];
86
 
81
 
87
     protected $appends = [
82
     protected $appends = [
135
         return $this->total;
130
         return $this->total;
136
     }
131
     }
137
 
132
 
138
-    protected function isCurrentlyExpired(): Attribute
133
+    public function shouldBeExpired(): bool
139
     {
134
     {
140
-        return Attribute::get(function () {
141
-            return $this->expiration_date?->isBefore(today());
142
-        });
135
+        return $this->expiration_date?->isBefore(today()) && $this->canBeExpired();
143
     }
136
     }
144
 
137
 
145
     public function isDraft(): bool
138
     public function isDraft(): bool
471
             'currency_code' => $this->currency_code,
464
             'currency_code' => $this->currency_code,
472
             'discount_method' => $this->discount_method,
465
             'discount_method' => $this->discount_method,
473
             'discount_computation' => $this->discount_computation,
466
             'discount_computation' => $this->discount_computation,
474
-            'discount_rate' => $this->discount_rate,
467
+            'discount_rate' => $this->getRawOriginal('discount_rate'),
475
             'subtotal' => $this->subtotal,
468
             'subtotal' => $this->subtotal,
476
             'tax_total' => $this->tax_total,
469
             'tax_total' => $this->tax_total,
477
             'discount_total' => $this->discount_total,
470
             'discount_total' => $this->discount_total,

+ 93
- 65
app/Models/Accounting/Invoice.php View File

2
 
2
 
3
 namespace App\Models\Accounting;
3
 namespace App\Models\Accounting;
4
 
4
 
5
-use App\Casts\MoneyCast;
6
 use App\Casts\RateCast;
5
 use App\Casts\RateCast;
7
 use App\Collections\Accounting\DocumentCollection;
6
 use App\Collections\Accounting\DocumentCollection;
8
 use App\Enums\Accounting\AdjustmentComputation;
7
 use App\Enums\Accounting\AdjustmentComputation;
87
         'discount_method' => DocumentDiscountMethod::class,
86
         'discount_method' => DocumentDiscountMethod::class,
88
         'discount_computation' => AdjustmentComputation::class,
87
         'discount_computation' => AdjustmentComputation::class,
89
         'discount_rate' => RateCast::class,
88
         'discount_rate' => RateCast::class,
90
-        'subtotal' => MoneyCast::class,
91
-        'tax_total' => MoneyCast::class,
92
-        'discount_total' => MoneyCast::class,
93
-        'total' => MoneyCast::class,
94
-        'amount_paid' => MoneyCast::class,
95
-        'amount_due' => MoneyCast::class,
96
     ];
89
     ];
97
 
90
 
98
     protected $appends = [
91
     protected $appends = [
205
             ->where('status', InvoiceStatus::Overdue);
198
             ->where('status', InvoiceStatus::Overdue);
206
     }
199
     }
207
 
200
 
208
-    protected function isCurrentlyOverdue(): Attribute
201
+    public function shouldBeOverdue(): bool
209
     {
202
     {
210
-        return Attribute::get(function () {
211
-            return $this->due_date->isBefore(today()) && $this->canBeOverdue();
212
-        });
203
+        return $this->due_date->isBefore(today()) && $this->canBeOverdue();
213
     }
204
     }
214
 
205
 
215
     public function isDraft(): bool
206
     public function isDraft(): bool
332
         $requiresConversion = $invoiceCurrency !== $bankAccountCurrency;
323
         $requiresConversion = $invoiceCurrency !== $bankAccountCurrency;
333
 
324
 
334
         // Store the original payment amount in invoice currency before any conversion
325
         // Store the original payment amount in invoice currency before any conversion
335
-        $amountInInvoiceCurrencyCents = CurrencyConverter::convertToCents($data['amount'], $invoiceCurrency);
326
+        $amountInInvoiceCurrencyCents = $data['amount'];
336
 
327
 
337
         if ($requiresConversion) {
328
         if ($requiresConversion) {
338
             $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
329
             $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
340
                 $invoiceCurrency,
331
                 $invoiceCurrency,
341
                 $bankAccountCurrency
332
                 $bankAccountCurrency
342
             );
333
             );
343
-            $formattedAmountForBankCurrency = CurrencyConverter::convertCentsToFormatSimple(
344
-                $amountInBankCurrencyCents,
345
-                $bankAccountCurrency
346
-            );
334
+            $formattedAmountForBankCurrency = $amountInBankCurrencyCents;
347
         } else {
335
         } else {
348
-            $formattedAmountForBankCurrency = $data['amount']; // Already in simple format
336
+            $formattedAmountForBankCurrency = $amountInInvoiceCurrencyCents;
349
         }
337
         }
350
 
338
 
351
         // Create transaction
339
         // Create transaction
385
 
373
 
386
     public function createApprovalTransaction(): void
374
     public function createApprovalTransaction(): void
387
     {
375
     {
388
-        $total = $this->formatAmountToDefaultCurrency($this->getRawOriginal('total'));
389
-
390
-        $transaction = $this->transactions()->create([
391
-            'company_id' => $this->company_id,
392
-            'type' => TransactionType::Journal,
393
-            'posted_at' => $this->date,
394
-            'amount' => $total,
395
-            'description' => 'Invoice Approval for Invoice #' . $this->invoice_number,
396
-        ]);
397
-
398
         $baseDescription = "{$this->client->name}: Invoice #{$this->invoice_number}";
376
         $baseDescription = "{$this->client->name}: Invoice #{$this->invoice_number}";
399
 
377
 
400
-        $transaction->journalEntries()->create([
401
-            'company_id' => $this->company_id,
378
+        $journalEntryData = [];
379
+
380
+        $totalInInvoiceCurrency = $this->total;
381
+        $journalEntryData[] = [
402
             'type' => JournalEntryType::Debit,
382
             'type' => JournalEntryType::Debit,
403
             'account_id' => Account::getAccountsReceivableAccount($this->company_id)->id,
383
             'account_id' => Account::getAccountsReceivableAccount($this->company_id)->id,
404
-            'amount' => $total,
384
+            'amount_in_invoice_currency' => $totalInInvoiceCurrency,
405
             'description' => $baseDescription,
385
             'description' => $baseDescription,
406
-        ]);
386
+        ];
407
 
387
 
408
-        $totalLineItemSubtotalCents = $this->convertAmountToDefaultCurrency((int) $this->lineItems()->sum('subtotal'));
409
-        $invoiceDiscountTotalCents = $this->convertAmountToDefaultCurrency((int) $this->getRawOriginal('discount_total'));
410
-        $remainingDiscountCents = $invoiceDiscountTotalCents;
388
+        $totalLineItemSubtotalInInvoiceCurrency = (int) $this->lineItems()->sum('subtotal');
389
+        $invoiceDiscountTotalInInvoiceCurrency = (int) $this->discount_total;
390
+        $remainingDiscountInInvoiceCurrency = $invoiceDiscountTotalInInvoiceCurrency;
411
 
391
 
412
         foreach ($this->lineItems as $index => $lineItem) {
392
         foreach ($this->lineItems as $index => $lineItem) {
413
             $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
393
             $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
394
+            $lineItemSubtotalInInvoiceCurrency = $lineItem->subtotal;
414
 
395
 
415
-            $lineItemSubtotal = $this->formatAmountToDefaultCurrency($lineItem->getRawOriginal('subtotal'));
416
-
417
-            $transaction->journalEntries()->create([
418
-                'company_id' => $this->company_id,
396
+            $journalEntryData[] = [
419
                 'type' => JournalEntryType::Credit,
397
                 'type' => JournalEntryType::Credit,
420
                 'account_id' => $lineItem->offering->income_account_id,
398
                 'account_id' => $lineItem->offering->income_account_id,
421
-                'amount' => $lineItemSubtotal,
399
+                'amount_in_invoice_currency' => $lineItemSubtotalInInvoiceCurrency,
422
                 'description' => $lineItemDescription,
400
                 'description' => $lineItemDescription,
423
-            ]);
401
+            ];
424
 
402
 
425
             foreach ($lineItem->adjustments as $adjustment) {
403
             foreach ($lineItem->adjustments as $adjustment) {
426
-                $adjustmentAmount = $this->formatAmountToDefaultCurrency($lineItem->calculateAdjustmentTotalAmount($adjustment));
404
+                $adjustmentAmountInInvoiceCurrency = $lineItem->calculateAdjustmentTotalAmount($adjustment);
427
 
405
 
428
-                $transaction->journalEntries()->create([
429
-                    'company_id' => $this->company_id,
406
+                $journalEntryData[] = [
430
                     'type' => $adjustment->category->isDiscount() ? JournalEntryType::Debit : JournalEntryType::Credit,
407
                     'type' => $adjustment->category->isDiscount() ? JournalEntryType::Debit : JournalEntryType::Credit,
431
                     'account_id' => $adjustment->account_id,
408
                     'account_id' => $adjustment->account_id,
432
-                    'amount' => $adjustmentAmount,
409
+                    'amount_in_invoice_currency' => $adjustmentAmountInInvoiceCurrency,
433
                     'description' => $lineItemDescription,
410
                     'description' => $lineItemDescription,
434
-                ]);
411
+                ];
435
             }
412
             }
436
 
413
 
437
-            if ($this->discount_method->isPerDocument() && $totalLineItemSubtotalCents > 0) {
438
-                $lineItemSubtotalCents = $this->convertAmountToDefaultCurrency((int) $lineItem->getRawOriginal('subtotal'));
414
+            // Handle per-document discount allocation
415
+            if ($this->discount_method->isPerDocument() && $totalLineItemSubtotalInInvoiceCurrency > 0) {
416
+                $lineItemSubtotalInInvoiceCurrency = $lineItem->subtotal;
439
 
417
 
440
                 if ($index === $this->lineItems->count() - 1) {
418
                 if ($index === $this->lineItems->count() - 1) {
441
-                    $lineItemDiscount = $remainingDiscountCents;
419
+                    $lineItemDiscountInInvoiceCurrency = $remainingDiscountInInvoiceCurrency;
442
                 } else {
420
                 } else {
443
-                    $lineItemDiscount = (int) round(
444
-                        ($lineItemSubtotalCents / $totalLineItemSubtotalCents) * $invoiceDiscountTotalCents
421
+                    $lineItemDiscountInInvoiceCurrency = (int) round(
422
+                        ($lineItemSubtotalInInvoiceCurrency / $totalLineItemSubtotalInInvoiceCurrency) * $invoiceDiscountTotalInInvoiceCurrency
445
                     );
423
                     );
446
-                    $remainingDiscountCents -= $lineItemDiscount;
424
+                    $remainingDiscountInInvoiceCurrency -= $lineItemDiscountInInvoiceCurrency;
447
                 }
425
                 }
448
 
426
 
449
-                if ($lineItemDiscount > 0) {
450
-                    $transaction->journalEntries()->create([
451
-                        'company_id' => $this->company_id,
427
+                if ($lineItemDiscountInInvoiceCurrency > 0) {
428
+                    $journalEntryData[] = [
452
                         'type' => JournalEntryType::Debit,
429
                         'type' => JournalEntryType::Debit,
453
                         'account_id' => Account::getSalesDiscountAccount($this->company_id)->id,
430
                         'account_id' => Account::getSalesDiscountAccount($this->company_id)->id,
454
-                        'amount' => CurrencyConverter::convertCentsToFormatSimple($lineItemDiscount),
431
+                        'amount_in_invoice_currency' => $lineItemDiscountInInvoiceCurrency,
455
                         'description' => "{$lineItemDescription} (Proportional Discount)",
432
                         'description' => "{$lineItemDescription} (Proportional Discount)",
456
-                    ]);
433
+                    ];
457
                 }
434
                 }
458
             }
435
             }
459
         }
436
         }
437
+
438
+        // Convert amounts to default currency
439
+        $totalDebitsInDefaultCurrency = 0;
440
+        $totalCreditsInDefaultCurrency = 0;
441
+
442
+        foreach ($journalEntryData as &$entry) {
443
+            $entry['amount_in_default_currency'] = $this->formatAmountToDefaultCurrency($entry['amount_in_invoice_currency']);
444
+
445
+            if ($entry['type'] === JournalEntryType::Debit) {
446
+                $totalDebitsInDefaultCurrency += $entry['amount_in_default_currency'];
447
+            } else {
448
+                $totalCreditsInDefaultCurrency += $entry['amount_in_default_currency'];
449
+            }
450
+        }
451
+
452
+        unset($entry);
453
+
454
+        // Handle currency conversion imbalance
455
+        $imbalance = $totalDebitsInDefaultCurrency - $totalCreditsInDefaultCurrency;
456
+        if ($imbalance !== 0) {
457
+            $targetType = $imbalance > 0 ? JournalEntryType::Credit : JournalEntryType::Debit;
458
+            $adjustmentAmount = abs($imbalance);
459
+
460
+            // Find last entry of target type and adjust it
461
+            $lastKey = array_key_last(array_filter($journalEntryData, fn ($entry) => $entry['type'] === $targetType, ARRAY_FILTER_USE_BOTH));
462
+            $journalEntryData[$lastKey]['amount_in_default_currency'] += $adjustmentAmount;
463
+
464
+            if ($targetType === JournalEntryType::Debit) {
465
+                $totalDebitsInDefaultCurrency += $adjustmentAmount;
466
+            } else {
467
+                $totalCreditsInDefaultCurrency += $adjustmentAmount;
468
+            }
469
+        }
470
+
471
+        if ($totalDebitsInDefaultCurrency !== $totalCreditsInDefaultCurrency) {
472
+            throw new \Exception('Journal entries do not balance for Invoice #' . $this->invoice_number . '. Debits: ' . $totalDebitsInDefaultCurrency . ', Credits: ' . $totalCreditsInDefaultCurrency);
473
+        }
474
+
475
+        // Create the transaction using the sum of debits
476
+        $transaction = $this->transactions()->create([
477
+            'company_id' => $this->company_id,
478
+            'type' => TransactionType::Journal,
479
+            'posted_at' => $this->date,
480
+            'amount' => $totalDebitsInDefaultCurrency,
481
+            'description' => 'Invoice Approval for Invoice #' . $this->invoice_number,
482
+        ]);
483
+
484
+        // Create all journal entries
485
+        foreach ($journalEntryData as $entry) {
486
+            $transaction->journalEntries()->create([
487
+                'company_id' => $this->company_id,
488
+                'type' => $entry['type'],
489
+                'account_id' => $entry['account_id'],
490
+                'amount' => $entry['amount_in_default_currency'],
491
+                'description' => $entry['description'],
492
+            ]);
493
+        }
460
     }
494
     }
461
 
495
 
462
     public function updateApprovalTransaction(): void
496
     public function updateApprovalTransaction(): void
463
     {
497
     {
464
-        $transaction = $this->approvalTransaction;
465
-
466
-        if ($transaction) {
467
-            $transaction->delete();
468
-        }
498
+        $this->approvalTransaction?->delete();
469
 
499
 
470
         $this->createApprovalTransaction();
500
         $this->createApprovalTransaction();
471
     }
501
     }
482
         return $amountCents;
512
         return $amountCents;
483
     }
513
     }
484
 
514
 
485
-    public function formatAmountToDefaultCurrency(int $amountCents): string
515
+    public function formatAmountToDefaultCurrency(int $amountCents): int
486
     {
516
     {
487
-        $convertedCents = $this->convertAmountToDefaultCurrency($amountCents);
488
-
489
-        return CurrencyConverter::convertCentsToFormatSimple($convertedCents);
517
+        return $this->convertAmountToDefaultCurrency($amountCents);
490
     }
518
     }
491
 
519
 
492
     // TODO: Potentially handle this another way
520
     // TODO: Potentially handle this another way

+ 0
- 2
app/Models/Accounting/JournalEntry.php View File

2
 
2
 
3
 namespace App\Models\Accounting;
3
 namespace App\Models\Accounting;
4
 
4
 
5
-use App\Casts\JournalEntryCast;
6
 use App\Collections\Accounting\JournalEntryCollection;
5
 use App\Collections\Accounting\JournalEntryCollection;
7
 use App\Concerns\Blamable;
6
 use App\Concerns\Blamable;
8
 use App\Concerns\CompanyOwned;
7
 use App\Concerns\CompanyOwned;
32
 
31
 
33
     protected $casts = [
32
     protected $casts = [
34
         'type' => JournalEntryType::class,
33
         'type' => JournalEntryType::class,
35
-        'amount' => JournalEntryCast::class,
36
     ];
34
     ];
37
 
35
 
38
     public function account(): BelongsTo
36
     public function account(): BelongsTo

+ 1
- 6
app/Models/Accounting/RecurringInvoice.php View File

2
 
2
 
3
 namespace App\Models\Accounting;
3
 namespace App\Models\Accounting;
4
 
4
 
5
-use App\Casts\MoneyCast;
6
 use App\Casts\RateCast;
5
 use App\Casts\RateCast;
7
 use App\Collections\Accounting\DocumentCollection;
6
 use App\Collections\Accounting\DocumentCollection;
8
 use App\Enums\Accounting\AdjustmentComputation;
7
 use App\Enums\Accounting\AdjustmentComputation;
109
         'discount_method' => DocumentDiscountMethod::class,
108
         'discount_method' => DocumentDiscountMethod::class,
110
         'discount_computation' => AdjustmentComputation::class,
109
         'discount_computation' => AdjustmentComputation::class,
111
         'discount_rate' => RateCast::class,
110
         'discount_rate' => RateCast::class,
112
-        'subtotal' => MoneyCast::class,
113
-        'tax_total' => MoneyCast::class,
114
-        'discount_total' => MoneyCast::class,
115
-        'total' => MoneyCast::class,
116
     ];
111
     ];
117
 
112
 
118
     protected $appends = [
113
     protected $appends = [
643
             'currency_code' => $this->currency_code,
638
             'currency_code' => $this->currency_code,
644
             'discount_method' => $this->discount_method,
639
             'discount_method' => $this->discount_method,
645
             'discount_computation' => $this->discount_computation,
640
             'discount_computation' => $this->discount_computation,
646
-            'discount_rate' => $this->discount_rate,
641
+            'discount_rate' => $this->getRawOriginal('discount_rate'),
647
             'subtotal' => $this->subtotal,
642
             'subtotal' => $this->subtotal,
648
             'tax_total' => $this->tax_total,
643
             'tax_total' => $this->tax_total,
649
             'discount_total' => $this->discount_total,
644
             'discount_total' => $this->discount_total,

+ 1
- 3
app/Models/Accounting/Transaction.php View File

2
 
2
 
3
 namespace App\Models\Accounting;
3
 namespace App\Models\Accounting;
4
 
4
 
5
-use App\Casts\TransactionAmountCast;
6
 use App\Concerns\Blamable;
5
 use App\Concerns\Blamable;
7
 use App\Concerns\CompanyOwned;
6
 use App\Concerns\CompanyOwned;
8
 use App\Enums\Accounting\AccountCategory;
7
 use App\Enums\Accounting\AccountCategory;
60
     protected $casts = [
59
     protected $casts = [
61
         'type' => TransactionType::class,
60
         'type' => TransactionType::class,
62
         'payment_method' => PaymentMethod::class,
61
         'payment_method' => PaymentMethod::class,
63
-        'amount' => TransactionAmountCast::class,
64
         'pending' => 'boolean',
62
         'pending' => 'boolean',
65
         'reviewed' => 'boolean',
63
         'reviewed' => 'boolean',
66
         'posted_at' => 'date',
64
         'posted_at' => 'date',
110
     public function updateAmountIfBalanced(): void
108
     public function updateAmountIfBalanced(): void
111
     {
109
     {
112
         if ($this->journalEntries->areBalanced() && $this->journalEntries->sumDebits()->formatSimple() !== $this->getAttributeValue('amount')) {
110
         if ($this->journalEntries->areBalanced() && $this->journalEntries->sumDebits()->formatSimple() !== $this->getAttributeValue('amount')) {
113
-            $this->setAttribute('amount', $this->journalEntries->sumDebits()->formatSimple());
111
+            $this->setAttribute('amount', $this->journalEntries->sumDebits()->getAmount());
114
             $this->save();
112
             $this->save();
115
         }
113
         }
116
     }
114
     }

+ 0
- 2
app/Models/Common/Offering.php View File

2
 
2
 
3
 namespace App\Models\Common;
3
 namespace App\Models\Common;
4
 
4
 
5
-use App\Casts\MoneyCast;
6
 use App\Concerns\Blamable;
5
 use App\Concerns\Blamable;
7
 use App\Concerns\CompanyOwned;
6
 use App\Concerns\CompanyOwned;
8
 use App\Enums\Accounting\AdjustmentCategory;
7
 use App\Enums\Accounting\AdjustmentCategory;
40
 
39
 
41
     protected $casts = [
40
     protected $casts = [
42
         'type' => OfferingType::class,
41
         'type' => OfferingType::class,
43
-        'price' => MoneyCast::class,
44
         'sellable' => 'boolean',
42
         'sellable' => 'boolean',
45
         'purchasable' => 'boolean',
43
         'purchasable' => 'boolean',
46
     ];
44
     ];

+ 7
- 1
app/Observers/BillObserver.php View File

17
 
17
 
18
     public function saving(Bill $bill): void
18
     public function saving(Bill $bill): void
19
     {
19
     {
20
-        if ($bill->is_currently_overdue) {
20
+        if ($bill->isDirty('due_date') && $bill->status === BillStatus::Overdue && ! $bill->shouldBeOverdue() && ! $bill->hasPayments()) {
21
+            $bill->status = BillStatus::Open;
22
+
23
+            return;
24
+        }
25
+
26
+        if ($bill->shouldBeOverdue()) {
21
             $bill->status = BillStatus::Overdue;
27
             $bill->status = BillStatus::Overdue;
22
         }
28
         }
23
     }
29
     }

+ 2
- 2
app/Observers/EstimateObserver.php View File

15
             return;
15
             return;
16
         }
16
         }
17
 
17
 
18
-        if ($estimate->isDirty('expiration_date') && $estimate->status === EstimateStatus::Expired && ! $estimate->is_currently_expired) {
18
+        if ($estimate->isDirty('expiration_date') && $estimate->status === EstimateStatus::Expired && ! $estimate->shouldBeExpired()) {
19
             $estimate->status = $estimate->hasBeenSent() ? EstimateStatus::Sent : EstimateStatus::Unsent;
19
             $estimate->status = $estimate->hasBeenSent() ? EstimateStatus::Sent : EstimateStatus::Unsent;
20
 
20
 
21
             return;
21
             return;
22
         }
22
         }
23
 
23
 
24
-        if ($estimate->is_currently_expired && $estimate->canBeExpired()) {
24
+        if ($estimate->shouldBeExpired()) {
25
             $estimate->status = EstimateStatus::Expired;
25
             $estimate->status = EstimateStatus::Expired;
26
         }
26
         }
27
     }
27
     }

+ 11
- 1
app/Observers/InvoiceObserver.php View File

12
 {
12
 {
13
     public function saving(Invoice $invoice): void
13
     public function saving(Invoice $invoice): void
14
     {
14
     {
15
-        if ($invoice->approved_at && $invoice->is_currently_overdue) {
15
+        if (! $invoice->wasApproved()) {
16
+            return;
17
+        }
18
+
19
+        if ($invoice->isDirty('due_date') && $invoice->status === InvoiceStatus::Overdue && ! $invoice->shouldBeOverdue() && ! $invoice->hasPayments()) {
20
+            $invoice->status = $invoice->hasBeenSent() ? InvoiceStatus::Sent : InvoiceStatus::Unsent;
21
+
22
+            return;
23
+        }
24
+
25
+        if ($invoice->shouldBeOverdue()) {
16
             $invoice->status = InvoiceStatus::Overdue;
26
             $invoice->status = InvoiceStatus::Overdue;
17
         }
27
         }
18
     }
28
     }

+ 7
- 7
app/Observers/TransactionObserver.php View File

138
 
138
 
139
                 // Fall back to conversion if metadata is not available
139
                 // Fall back to conversion if metadata is not available
140
                 $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
140
                 $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
141
-                $amountCents = (int) $transaction->getRawOriginal('amount');
141
+                $amountCents = (int) $transaction->amount;
142
 
142
 
143
                 return CurrencyConverter::convertBalance($amountCents, $bankAccountCurrency, $invoiceCurrency);
143
                 return CurrencyConverter::convertBalance($amountCents, $bankAccountCurrency, $invoiceCurrency);
144
             });
144
             });
158
 
158
 
159
                 // Fall back to conversion if metadata is not available
159
                 // Fall back to conversion if metadata is not available
160
                 $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
160
                 $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
161
-                $amountCents = (int) $transaction->getRawOriginal('amount');
161
+                $amountCents = (int) $transaction->amount;
162
 
162
 
163
                 return CurrencyConverter::convertBalance($amountCents, $bankAccountCurrency, $invoiceCurrency);
163
                 return CurrencyConverter::convertBalance($amountCents, $bankAccountCurrency, $invoiceCurrency);
164
             });
164
             });
165
 
165
 
166
         $totalPaidInInvoiceCurrencyCents = $depositTotalInInvoiceCurrencyCents - $withdrawalTotalInInvoiceCurrencyCents;
166
         $totalPaidInInvoiceCurrencyCents = $depositTotalInInvoiceCurrencyCents - $withdrawalTotalInInvoiceCurrencyCents;
167
 
167
 
168
-        $invoiceTotalInInvoiceCurrencyCents = (int) $invoice->getRawOriginal('total');
168
+        $invoiceTotalInInvoiceCurrencyCents = (int) $invoice->total;
169
 
169
 
170
         $newStatus = match (true) {
170
         $newStatus = match (true) {
171
             $totalPaidInInvoiceCurrencyCents > $invoiceTotalInInvoiceCurrencyCents => InvoiceStatus::Overpaid,
171
             $totalPaidInInvoiceCurrencyCents > $invoiceTotalInInvoiceCurrencyCents => InvoiceStatus::Overpaid,
183
         }
183
         }
184
 
184
 
185
         $invoice->update([
185
         $invoice->update([
186
-            'amount_paid' => CurrencyConverter::convertCentsToFormatSimple($totalPaidInInvoiceCurrencyCents, $invoiceCurrency),
186
+            'amount_paid' => $totalPaidInInvoiceCurrencyCents,
187
             'status' => $newStatus,
187
             'status' => $newStatus,
188
             'paid_at' => $paidAt,
188
             'paid_at' => $paidAt,
189
         ]);
189
         ]);
212
 
212
 
213
                 // Fall back to conversion if metadata is not available
213
                 // Fall back to conversion if metadata is not available
214
                 $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
214
                 $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
215
-                $amountCents = (int) $transaction->getRawOriginal('amount');
215
+                $amountCents = (int) $transaction->amount;
216
 
216
 
217
                 return CurrencyConverter::convertBalance($amountCents, $bankAccountCurrency, $billCurrency);
217
                 return CurrencyConverter::convertBalance($amountCents, $bankAccountCurrency, $billCurrency);
218
             });
218
             });
219
 
219
 
220
         $totalPaidInBillCurrencyCents = $withdrawalTotalInBillCurrencyCents;
220
         $totalPaidInBillCurrencyCents = $withdrawalTotalInBillCurrencyCents;
221
 
221
 
222
-        $billTotalInBillCurrencyCents = (int) $bill->getRawOriginal('total');
222
+        $billTotalInBillCurrencyCents = (int) $bill->total;
223
 
223
 
224
         $newStatus = match (true) {
224
         $newStatus = match (true) {
225
             $totalPaidInBillCurrencyCents >= $billTotalInBillCurrencyCents => BillStatus::Paid,
225
             $totalPaidInBillCurrencyCents >= $billTotalInBillCurrencyCents => BillStatus::Paid,
236
         }
236
         }
237
 
237
 
238
         $bill->update([
238
         $bill->update([
239
-            'amount_paid' => CurrencyConverter::convertCentsToFormatSimple($totalPaidInBillCurrencyCents, $billCurrency),
239
+            'amount_paid' => $totalPaidInBillCurrencyCents,
240
             'status' => $newStatus,
240
             'status' => $newStatus,
241
             'paid_at' => $paidAt,
241
             'paid_at' => $paidAt,
242
         ]);
242
         ]);

+ 28
- 7
app/Providers/MacroServiceProvider.php View File

20
 use Filament\Forms\Components\TextInput;
20
 use Filament\Forms\Components\TextInput;
21
 use Filament\Infolists\Components\TextEntry;
21
 use Filament\Infolists\Components\TextEntry;
22
 use Filament\Support\Enums\IconPosition;
22
 use Filament\Support\Enums\IconPosition;
23
+use Filament\Support\RawJs;
23
 use Filament\Tables\Columns\TextColumn;
24
 use Filament\Tables\Columns\TextColumn;
24
 use Filament\Tables\Contracts\HasTable;
25
 use Filament\Tables\Contracts\HasTable;
25
 use Illuminate\Contracts\Support\Htmlable;
26
 use Illuminate\Contracts\Support\Htmlable;
64
                     });
65
                     });
65
             }
66
             }
66
 
67
 
67
-            $this->mask(static function (TextInput $component) use ($currency) {
68
-                $currency = $component->evaluate($currency);
68
+            $this->mask(RawJs::make('$money($input)'))
69
+                ->afterStateHydrated(function (TextInput $component, ?int $state) {
70
+                    if (blank($state)) {
71
+                        return;
72
+                    }
69
 
73
 
70
-                return moneyMask($currency);
71
-            });
74
+                    $formatted = CurrencyConverter::convertCentsToFormatSimple($state, 'USD');
75
+                    $component->state($formatted);
76
+                })
77
+                ->dehydrateStateUsing(function (?string $state): ?int {
78
+                    if (blank($state)) {
79
+                        return null;
80
+                    }
81
+
82
+                    // Remove thousand separators
83
+                    $cleaned = str_replace(',', '', $state);
84
+
85
+                    // If no decimal point, assume it's whole dollars (add .00)
86
+                    if (! str_contains($cleaned, '.')) {
87
+                        $cleaned .= '.00';
88
+                    }
89
+
90
+                    // Convert to float then to cents (integer)
91
+                    return (int) round((float) $cleaned * 100);
92
+                });
72
 
93
 
73
             return $this;
94
             return $this;
74
         });
95
         });
174
 
195
 
175
         TextColumn::macro('currency', function (string | Closure | null $currency = null, ?bool $convert = null): static {
196
         TextColumn::macro('currency', function (string | Closure | null $currency = null, ?bool $convert = null): static {
176
             $currency ??= CurrencyAccessor::getDefaultCurrency();
197
             $currency ??= CurrencyAccessor::getDefaultCurrency();
177
-            $convert ??= true;
198
+            $convert ??= false;
178
 
199
 
179
             $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency, $convert): ?string {
200
             $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency, $convert): ?string {
180
                 if (blank($state)) {
201
                 if (blank($state)) {
192
 
213
 
193
         TextEntry::macro('currency', function (string | Closure | null $currency = null, ?bool $convert = null): static {
214
         TextEntry::macro('currency', function (string | Closure | null $currency = null, ?bool $convert = null): static {
194
             $currency ??= CurrencyAccessor::getDefaultCurrency();
215
             $currency ??= CurrencyAccessor::getDefaultCurrency();
195
-            $convert ??= true;
216
+            $convert ??= false;
196
 
217
 
197
             $this->formatStateUsing(static function (TextEntry $entry, $state) use ($currency, $convert): ?string {
218
             $this->formatStateUsing(static function (TextEntry $entry, $state) use ($currency, $convert): ?string {
198
                 if (blank($state)) {
219
                 if (blank($state)) {
210
 
231
 
211
         TextColumn::macro('currencyWithConversion', function (string | Closure | null $currency = null, ?bool $convertFromCents = null): static {
232
         TextColumn::macro('currencyWithConversion', function (string | Closure | null $currency = null, ?bool $convertFromCents = null): static {
212
             $currency ??= CurrencyAccessor::getDefaultCurrency();
233
             $currency ??= CurrencyAccessor::getDefaultCurrency();
213
-            $convertFromCents ??= false;
234
+            $convertFromCents ??= true;
214
 
235
 
215
             $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency, $convertFromCents): ?string {
236
             $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency, $convertFromCents): ?string {
216
                 if (blank($state)) {
237
                 if (blank($state)) {

+ 1
- 1
app/Services/ReportService.php View File

209
             foreach ($account->journalEntries as $journalEntry) {
209
             foreach ($account->journalEntries as $journalEntry) {
210
                 $transaction = $journalEntry->transaction;
210
                 $transaction = $journalEntry->transaction;
211
                 $signedAmount = $journalEntry->signed_amount;
211
                 $signedAmount = $journalEntry->signed_amount;
212
-                $amount = $journalEntry->getRawOriginal('amount');
212
+                $amount = $journalEntry->amount;
213
 
213
 
214
                 if ($journalEntry->type->isDebit()) {
214
                 if ($journalEntry->type->isDebit()) {
215
                     $periodDebitTotal += $amount;
215
                     $periodDebitTotal += $amount;

+ 4
- 8
app/Services/TransactionService.php View File

250
         });
250
         });
251
     }
251
     }
252
 
252
 
253
-    private function getConvertedTransactionAmount(Transaction $transaction): string
253
+    private function getConvertedTransactionAmount(Transaction $transaction): int
254
     {
254
     {
255
         $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
255
         $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
256
         $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
256
         $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
262
         return $transaction->amount;
262
         return $transaction->amount;
263
     }
263
     }
264
 
264
 
265
-    private function convertToDefaultCurrency(string $amount, string $fromCurrency, string $toCurrency): string
265
+    private function convertToDefaultCurrency(int $amount, string $fromCurrency, string $toCurrency): int
266
     {
266
     {
267
-        $amountInCents = CurrencyConverter::prepareForAccessor($amount, $fromCurrency);
268
-
269
-        $convertedAmountInCents = CurrencyConverter::convertBalance($amountInCents, $fromCurrency, $toCurrency);
270
-
271
-        return CurrencyConverter::prepareForMutator($convertedAmountInCents, $toCurrency);
267
+        return CurrencyConverter::convertBalance($amount, $fromCurrency, $toCurrency);
272
     }
268
     }
273
 
269
 
274
     private function hasRelevantChanges(Transaction $transaction): bool
270
     private function hasRelevantChanges(Transaction $transaction): bool
276
         return $transaction->wasChanged(['amount', 'account_id', 'bank_account_id', 'type']);
272
         return $transaction->wasChanged(['amount', 'account_id', 'bank_account_id', 'type']);
277
     }
273
     }
278
 
274
 
279
-    private function updateJournalEntryForTransaction(JournalEntry $journalEntry, Account $account, string $convertedTransactionAmount): void
275
+    private function updateJournalEntryForTransaction(JournalEntry $journalEntry, Account $account, int $convertedTransactionAmount): void
280
     {
276
     {
281
         DB::transaction(static function () use ($journalEntry, $account, $convertedTransactionAmount) {
277
         DB::transaction(static function () use ($journalEntry, $account, $convertedTransactionAmount) {
282
             $journalEntry->update([
278
             $journalEntry->update([

+ 12
- 8
app/View/Models/DocumentTotalViewModel.php View File

72
     private function calculateLineSubtotalInCents(array $item, string $currencyCode): int
72
     private function calculateLineSubtotalInCents(array $item, string $currencyCode): int
73
     {
73
     {
74
         $quantity = max((float) ($item['quantity'] ?? 0), 0);
74
         $quantity = max((float) ($item['quantity'] ?? 0), 0);
75
-        $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
75
+        $unitPrice = CurrencyConverter::isValidAmount($item['unit_price'], 'USD')
76
+            ? CurrencyConverter::convertToFloat($item['unit_price'], 'USD')
77
+            : 0;
76
 
78
 
77
         $subtotal = $quantity * $unitPrice;
79
         $subtotal = $quantity * $unitPrice;
78
 
80
 
79
-        return CurrencyConverter::convertToCents($subtotal, $currencyCode);
81
+        return CurrencyConverter::convertToCents($subtotal, 'USD');
80
     }
82
     }
81
 
83
 
82
     private function calculateAdjustmentsTotalInCents($lineItems, string $key, string $currencyCode): int
84
     private function calculateAdjustmentsTotalInCents($lineItems, string $key, string $currencyCode): int
83
     {
85
     {
84
-        return $lineItems->reduce(function ($carry, $item) use ($key, $currencyCode) {
86
+        return $lineItems->reduce(function ($carry, $item) use ($key) {
85
             $quantity = max((float) ($item['quantity'] ?? 0), 0);
87
             $quantity = max((float) ($item['quantity'] ?? 0), 0);
86
-            $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
88
+            $unitPrice = CurrencyConverter::isValidAmount($item['unit_price'], 'USD')
89
+                ? CurrencyConverter::convertToFloat($item['unit_price'], 'USD')
90
+                : 0;
91
+
87
             $adjustmentIds = $item[$key] ?? [];
92
             $adjustmentIds = $item[$key] ?? [];
88
             $lineTotal = $quantity * $unitPrice;
93
             $lineTotal = $quantity * $unitPrice;
89
 
94
 
90
-            $lineTotalInCents = CurrencyConverter::convertToCents($lineTotal, $currencyCode);
95
+            $lineTotalInCents = CurrencyConverter::convertToCents($lineTotal, 'USD');
91
 
96
 
92
             $adjustmentTotal = Adjustment::whereIn('id', $adjustmentIds)
97
             $adjustmentTotal = Adjustment::whereIn('id', $adjustmentIds)
93
                 ->get()
98
                 ->get()
120
             return RateCalculator::calculatePercentage($subtotalInCents, $scaledDiscountRate);
125
             return RateCalculator::calculatePercentage($subtotalInCents, $scaledDiscountRate);
121
         }
126
         }
122
 
127
 
123
-        if (! CurrencyConverter::isValidAmount($discountRate)) {
128
+        if (! CurrencyConverter::isValidAmount($discountRate, $currencyCode)) {
124
             $discountRate = '0';
129
             $discountRate = '0';
125
         }
130
         }
126
 
131
 
152
 
157
 
153
     private function calculateAmountDueInCents(int $grandTotalInCents, string $currencyCode): int
158
     private function calculateAmountDueInCents(int $grandTotalInCents, string $currencyCode): int
154
     {
159
     {
155
-        $amountPaid = $this->data['amount_paid'] ?? '0.00';
156
-        $amountPaidInCents = CurrencyConverter::convertToCents($amountPaid, $currencyCode);
160
+        $amountPaidInCents = $this->data['amount_paid'] ?? 0;
157
 
161
 
158
         return $grandTotalInCents - $amountPaidInCents;
162
         return $grandTotalInCents - $amountPaidInCents;
159
     }
163
     }

+ 295
- 290
composer.lock
File diff suppressed because it is too large
View File


+ 85
- 90
database/factories/Accounting/BillFactory.php View File

12
 use App\Models\Common\Vendor;
12
 use App\Models\Common\Vendor;
13
 use App\Models\Company;
13
 use App\Models\Company;
14
 use App\Models\Setting\DocumentDefault;
14
 use App\Models\Setting\DocumentDefault;
15
-use App\Utilities\Currency\CurrencyConverter;
16
 use App\Utilities\RateCalculator;
15
 use App\Utilities\RateCalculator;
17
 use Illuminate\Database\Eloquent\Factories\Factory;
16
 use Illuminate\Database\Eloquent\Factories\Factory;
18
 use Illuminate\Support\Carbon;
17
 use Illuminate\Support\Carbon;
34
      */
33
      */
35
     public function definition(): array
34
     public function definition(): array
36
     {
35
     {
37
-        $isFutureBill = $this->faker->boolean();
38
-
39
-        if ($isFutureBill) {
40
-            $billDate = $this->faker->dateTimeBetween('-10 days', '+10 days');
41
-        } else {
42
-            $billDate = $this->faker->dateTimeBetween('-1 year', '-30 days');
43
-        }
44
-
45
-        $dueDays = $this->faker->numberBetween(14, 60);
36
+        $billDate = $this->faker->dateTimeBetween('-1 year', '-1 day');
46
 
37
 
47
         return [
38
         return [
48
             'company_id' => 1,
39
             'company_id' => 1,
49
-            'vendor_id' => fn (array $attributes) => Vendor::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id'),
40
+            'vendor_id' => function (array $attributes) {
41
+                return Vendor::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id')
42
+                    ?? Vendor::factory()->state([
43
+                        'company_id' => $attributes['company_id'],
44
+                    ]);
45
+            },
50
             'bill_number' => $this->faker->unique()->numerify('BILL-####'),
46
             'bill_number' => $this->faker->unique()->numerify('BILL-####'),
51
             'order_number' => $this->faker->unique()->numerify('PO-####'),
47
             'order_number' => $this->faker->unique()->numerify('PO-####'),
52
             'date' => $billDate,
48
             'date' => $billDate,
53
-            'due_date' => Carbon::parse($billDate)->addDays($dueDays),
49
+            'due_date' => $this->faker->dateTimeInInterval($billDate, '+6 months'),
54
             'status' => BillStatus::Open,
50
             'status' => BillStatus::Open,
55
             'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
51
             'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
56
             'discount_computation' => AdjustmentComputation::Percentage,
52
             'discount_computation' => AdjustmentComputation::Percentage,
79
     public function withLineItems(int $count = 3): static
75
     public function withLineItems(int $count = 3): static
80
     {
76
     {
81
         return $this->afterCreating(function (Bill $bill) use ($count) {
77
         return $this->afterCreating(function (Bill $bill) use ($count) {
78
+            // Clear existing line items first
79
+            $bill->lineItems()->delete();
80
+
82
             DocumentLineItem::factory()
81
             DocumentLineItem::factory()
83
                 ->count($count)
82
                 ->count($count)
84
                 ->forBill($bill)
83
                 ->forBill($bill)
91
     public function initialized(): static
90
     public function initialized(): static
92
     {
91
     {
93
         return $this->afterCreating(function (Bill $bill) {
92
         return $this->afterCreating(function (Bill $bill) {
94
-            $this->ensureLineItems($bill);
95
-
96
-            if ($bill->wasInitialized()) {
97
-                return;
98
-            }
99
-
100
-            $postedAt = Carbon::parse($bill->date)
101
-                ->addHours($this->faker->numberBetween(1, 24));
102
-
103
-            $bill->createInitialTransaction($postedAt);
93
+            $this->performInitialization($bill);
104
         });
94
         });
105
     }
95
     }
106
 
96
 
107
     public function partial(int $maxPayments = 4): static
97
     public function partial(int $maxPayments = 4): static
108
     {
98
     {
109
         return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
99
         return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
110
-            $this->ensureInitialized($bill);
111
-
112
-            $this->withPayments(max: $maxPayments, billStatus: BillStatus::Partial)
113
-                ->callAfterCreating(collect([$bill]));
100
+            $this->performInitialization($bill);
101
+            $this->performPayments($bill, $maxPayments, BillStatus::Partial);
114
         });
102
         });
115
     }
103
     }
116
 
104
 
117
     public function paid(int $maxPayments = 4): static
105
     public function paid(int $maxPayments = 4): static
118
     {
106
     {
119
         return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
107
         return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
120
-            $this->ensureInitialized($bill);
121
-
122
-            $this->withPayments(max: $maxPayments)
123
-                ->callAfterCreating(collect([$bill]));
108
+            $this->performInitialization($bill);
109
+            $this->performPayments($bill, $maxPayments, BillStatus::Paid);
124
         });
110
         });
125
     }
111
     }
126
 
112
 
131
                 'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
117
                 'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
132
             ])
118
             ])
133
             ->afterCreating(function (Bill $bill) {
119
             ->afterCreating(function (Bill $bill) {
134
-                $this->ensureInitialized($bill);
120
+                $this->performInitialization($bill);
135
             });
121
             });
136
     }
122
     }
137
 
123
 
138
-    public function withPayments(?int $min = null, ?int $max = null, BillStatus $billStatus = BillStatus::Paid): static
124
+    protected function performInitialization(Bill $bill): void
139
     {
125
     {
140
-        $min ??= 1;
126
+        if ($bill->wasInitialized()) {
127
+            return;
128
+        }
141
 
129
 
142
-        return $this->afterCreating(function (Bill $bill) use ($billStatus, $max, $min) {
143
-            $this->ensureInitialized($bill);
130
+        $postedAt = Carbon::parse($bill->date)
131
+            ->addHours($this->faker->numberBetween(1, 24));
144
 
132
 
145
-            $bill->refresh();
133
+        if ($postedAt->isAfter(now())) {
134
+            $postedAt = Carbon::parse($this->faker->dateTimeBetween($bill->date, now()));
135
+        }
146
 
136
 
147
-            $amountDue = $bill->getRawOriginal('amount_due');
137
+        $bill->createInitialTransaction($postedAt);
138
+    }
148
 
139
 
149
-            $totalAmountDue = match ($billStatus) {
150
-                BillStatus::Partial => (int) floor($amountDue * 0.5),
151
-                default => $amountDue,
152
-            };
140
+    protected function performPayments(Bill $bill, int $maxPayments, BillStatus $billStatus): void
141
+    {
142
+        $bill->refresh();
153
 
143
 
154
-            if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
155
-                return;
156
-            }
144
+        $amountDue = $bill->getRawOriginal('amount_due');
157
 
145
 
158
-            $paymentCount = $max && $min ? $this->faker->numberBetween($min, $max) : $min;
159
-            $paymentAmount = (int) floor($totalAmountDue / $paymentCount);
160
-            $remainingAmount = $totalAmountDue;
146
+        $totalAmountDue = match ($billStatus) {
147
+            BillStatus::Partial => (int) floor($amountDue * 0.5),
148
+            default => $amountDue,
149
+        };
161
 
150
 
162
-            $paymentDate = Carbon::parse($bill->initialTransaction->posted_at);
163
-            $paymentDates = [];
151
+        if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
152
+            return;
153
+        }
164
 
154
 
165
-            for ($i = 0; $i < $paymentCount; $i++) {
166
-                $amount = $i === $paymentCount - 1 ? $remainingAmount : $paymentAmount;
155
+        $paymentCount = $this->faker->numberBetween(1, $maxPayments);
156
+        $paymentAmount = (int) floor($totalAmountDue / $paymentCount);
157
+        $remainingAmount = $totalAmountDue;
167
 
158
 
168
-                if ($amount <= 0) {
169
-                    break;
170
-                }
159
+        $initialPaymentDate = Carbon::parse($bill->initialTransaction->posted_at);
160
+        $maxPaymentDate = now();
161
+
162
+        $paymentDates = [];
171
 
163
 
172
-                $postedAt = $paymentDate->copy()->addDays($this->faker->numberBetween(1, 30));
173
-                $paymentDates[] = $postedAt;
164
+        for ($i = 0; $i < $paymentCount; $i++) {
165
+            $amount = $i === $paymentCount - 1 ? $remainingAmount : $paymentAmount;
174
 
166
 
175
-                $data = [
176
-                    'posted_at' => $postedAt,
177
-                    'amount' => CurrencyConverter::convertCentsToFormatSimple($amount, $bill->currency_code),
178
-                    'payment_method' => $this->faker->randomElement(PaymentMethod::class),
179
-                    'bank_account_id' => BankAccount::where('company_id', $bill->company_id)->inRandomOrder()->value('id'),
180
-                    'notes' => $this->faker->sentence,
181
-                ];
167
+            if ($amount <= 0) {
168
+                break;
169
+            }
182
 
170
 
183
-                $bill->recordPayment($data);
184
-                $remainingAmount -= $amount;
171
+            if ($i === 0) {
172
+                $postedAt = $initialPaymentDate->copy()->addDays($this->faker->numberBetween(1, 15));
173
+            } else {
174
+                $postedAt = $paymentDates[$i - 1]->copy()->addDays($this->faker->numberBetween(1, 10));
185
             }
175
             }
186
 
176
 
187
-            if ($billStatus !== BillStatus::Paid) {
188
-                return;
177
+            if ($postedAt->isAfter($maxPaymentDate)) {
178
+                $postedAt = Carbon::parse($this->faker->dateTimeBetween($initialPaymentDate, $maxPaymentDate));
189
             }
179
             }
190
 
180
 
181
+            $paymentDates[] = $postedAt;
182
+
183
+            $data = [
184
+                'posted_at' => $postedAt,
185
+                'amount' => $amount,
186
+                'payment_method' => $this->faker->randomElement(PaymentMethod::class),
187
+                'bank_account_id' => BankAccount::where('company_id', $bill->company_id)->inRandomOrder()->value('id'),
188
+                'notes' => $this->faker->sentence,
189
+            ];
190
+
191
+            $bill->recordPayment($data);
192
+            $remainingAmount -= $amount;
193
+        }
194
+
195
+        if ($billStatus === BillStatus::Paid && ! empty($paymentDates)) {
191
             $latestPaymentDate = max($paymentDates);
196
             $latestPaymentDate = max($paymentDates);
192
             $bill->updateQuietly([
197
             $bill->updateQuietly([
193
                 'status' => $billStatus,
198
                 'status' => $billStatus,
194
                 'paid_at' => $latestPaymentDate,
199
                 'paid_at' => $latestPaymentDate,
195
             ]);
200
             ]);
196
-        });
201
+        }
197
     }
202
     }
198
 
203
 
199
     public function configure(): static
204
     public function configure(): static
200
     {
205
     {
201
         return $this->afterCreating(function (Bill $bill) {
206
         return $this->afterCreating(function (Bill $bill) {
202
-            $this->ensureInitialized($bill);
207
+            DocumentLineItem::factory()
208
+                ->count(3)
209
+                ->forBill($bill)
210
+                ->create();
211
+
212
+            $this->recalculateTotals($bill);
203
 
213
 
204
             $number = DocumentDefault::getBaseNumber() + $bill->id;
214
             $number = DocumentDefault::getBaseNumber() + $bill->id;
205
 
215
 
208
                 'order_number' => "PO-{$number}",
218
                 'order_number' => "PO-{$number}",
209
             ]);
219
             ]);
210
 
220
 
211
-            if ($bill->wasInitialized() && $bill->is_currently_overdue) {
221
+            if ($bill->wasInitialized() && $bill->shouldBeOverdue()) {
212
                 $bill->updateQuietly([
222
                 $bill->updateQuietly([
213
                     'status' => BillStatus::Overdue,
223
                     'status' => BillStatus::Overdue,
214
                 ]);
224
                 ]);
216
         });
226
         });
217
     }
227
     }
218
 
228
 
219
-    protected function ensureLineItems(Bill $bill): void
220
-    {
221
-        if (! $bill->hasLineItems()) {
222
-            $this->withLineItems()->callAfterCreating(collect([$bill]));
223
-        }
224
-    }
225
-
226
-    protected function ensureInitialized(Bill $bill): void
227
-    {
228
-        if (! $bill->wasInitialized()) {
229
-            $this->initialized()->callAfterCreating(collect([$bill]));
230
-        }
231
-    }
232
-
233
     protected function recalculateTotals(Bill $bill): void
229
     protected function recalculateTotals(Bill $bill): void
234
     {
230
     {
235
         $bill->refresh();
231
         $bill->refresh();
250
                 $scaledRate = RateCalculator::parseLocalizedRate($bill->discount_rate);
246
                 $scaledRate = RateCalculator::parseLocalizedRate($bill->discount_rate);
251
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
247
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
252
             } else {
248
             } else {
253
-                $discountTotalCents = CurrencyConverter::convertToCents($bill->discount_rate, $bill->currency_code);
249
+                $discountTotalCents = $bill->getRawOriginal('discount_rate');
254
             }
250
             }
255
         }
251
         }
256
 
252
 
257
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
253
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
258
-        $currencyCode = $bill->currency_code;
259
 
254
 
260
         $bill->update([
255
         $bill->update([
261
-            'subtotal' => CurrencyConverter::convertCentsToFormatSimple($subtotalCents, $currencyCode),
262
-            'tax_total' => CurrencyConverter::convertCentsToFormatSimple($taxTotalCents, $currencyCode),
263
-            'discount_total' => CurrencyConverter::convertCentsToFormatSimple($discountTotalCents, $currencyCode),
264
-            'total' => CurrencyConverter::convertCentsToFormatSimple($grandTotalCents, $currencyCode),
256
+            'subtotal' => $subtotalCents,
257
+            'tax_total' => $taxTotalCents,
258
+            'discount_total' => $discountTotalCents,
259
+            'total' => $grandTotalCents,
265
         ]);
260
         ]);
266
     }
261
     }
267
 }
262
 }

+ 87
- 54
database/factories/Accounting/EstimateFactory.php View File

10
 use App\Models\Common\Client;
10
 use App\Models\Common\Client;
11
 use App\Models\Company;
11
 use App\Models\Company;
12
 use App\Models\Setting\DocumentDefault;
12
 use App\Models\Setting\DocumentDefault;
13
-use App\Utilities\Currency\CurrencyConverter;
14
 use App\Utilities\RateCalculator;
13
 use App\Utilities\RateCalculator;
15
 use Illuminate\Database\Eloquent\Factories\Factory;
14
 use Illuminate\Database\Eloquent\Factories\Factory;
16
 use Illuminate\Support\Carbon;
15
 use Illuminate\Support\Carbon;
32
      */
31
      */
33
     public function definition(): array
32
     public function definition(): array
34
     {
33
     {
35
-        $estimateDate = $this->faker->dateTimeBetween('-1 year');
34
+        $estimateDate = $this->faker->dateTimeBetween('-2 months', '-1 day');
36
 
35
 
37
         return [
36
         return [
38
             'company_id' => 1,
37
             'company_id' => 1,
39
-            'client_id' => fn (array $attributes) => Client::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id'),
38
+            'client_id' => function (array $attributes) {
39
+                return Client::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id')
40
+                    ?? Client::factory()->state([
41
+                        'company_id' => $attributes['company_id'],
42
+                    ]);
43
+            },
40
             'header' => 'Estimate',
44
             'header' => 'Estimate',
41
             'subheader' => 'Estimate',
45
             'subheader' => 'Estimate',
42
             'estimate_number' => $this->faker->unique()->numerify('EST-####'),
46
             'estimate_number' => $this->faker->unique()->numerify('EST-####'),
43
             'reference_number' => $this->faker->unique()->numerify('REF-####'),
47
             'reference_number' => $this->faker->unique()->numerify('REF-####'),
44
             'date' => $estimateDate,
48
             'date' => $estimateDate,
45
-            'expiration_date' => Carbon::parse($estimateDate)->addDays($this->faker->numberBetween(14, 30)),
49
+            'expiration_date' => $this->faker->dateTimeInInterval($estimateDate, '+3 months'),
46
             'status' => EstimateStatus::Draft,
50
             'status' => EstimateStatus::Draft,
47
             'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
51
             'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
48
             'discount_computation' => AdjustmentComputation::Percentage,
52
             'discount_computation' => AdjustmentComputation::Percentage,
72
     public function withLineItems(int $count = 3): static
76
     public function withLineItems(int $count = 3): static
73
     {
77
     {
74
         return $this->afterCreating(function (Estimate $estimate) use ($count) {
78
         return $this->afterCreating(function (Estimate $estimate) use ($count) {
79
+            // Clear existing line items first
80
+            $estimate->lineItems()->delete();
81
+
75
             DocumentLineItem::factory()
82
             DocumentLineItem::factory()
76
                 ->count($count)
83
                 ->count($count)
77
                 ->forEstimate($estimate)
84
                 ->forEstimate($estimate)
84
     public function approved(): static
91
     public function approved(): static
85
     {
92
     {
86
         return $this->afterCreating(function (Estimate $estimate) {
93
         return $this->afterCreating(function (Estimate $estimate) {
87
-            $this->ensureLineItems($estimate);
88
-
89
-            if (! $estimate->canBeApproved()) {
90
-                return;
91
-            }
92
-
93
-            $approvedAt = Carbon::parse($estimate->date)
94
-                ->addHours($this->faker->numberBetween(1, 24));
95
-
96
-            $estimate->approveDraft($approvedAt);
94
+            $this->performApproval($estimate);
97
         });
95
         });
98
     }
96
     }
99
 
97
 
100
     public function accepted(): static
98
     public function accepted(): static
101
     {
99
     {
102
         return $this->afterCreating(function (Estimate $estimate) {
100
         return $this->afterCreating(function (Estimate $estimate) {
103
-            $this->ensureSent($estimate);
101
+            $this->performSent($estimate);
104
 
102
 
105
             $acceptedAt = Carbon::parse($estimate->last_sent_at)
103
             $acceptedAt = Carbon::parse($estimate->last_sent_at)
106
                 ->addDays($this->faker->numberBetween(1, 7));
104
                 ->addDays($this->faker->numberBetween(1, 7));
107
 
105
 
106
+            if ($acceptedAt->isAfter(now())) {
107
+                $acceptedAt = Carbon::parse($this->faker->dateTimeBetween($estimate->last_sent_at, now()));
108
+            }
109
+
108
             $estimate->markAsAccepted($acceptedAt);
110
             $estimate->markAsAccepted($acceptedAt);
109
         });
111
         });
110
     }
112
     }
113
     {
115
     {
114
         return $this->afterCreating(function (Estimate $estimate) {
116
         return $this->afterCreating(function (Estimate $estimate) {
115
             if (! $estimate->wasAccepted()) {
117
             if (! $estimate->wasAccepted()) {
116
-                $this->accepted()->callAfterCreating(collect([$estimate]));
118
+                $this->performSent($estimate);
119
+
120
+                $acceptedAt = Carbon::parse($estimate->last_sent_at)
121
+                    ->addDays($this->faker->numberBetween(1, 7));
122
+
123
+                if ($acceptedAt->isAfter(now())) {
124
+                    $acceptedAt = Carbon::parse($this->faker->dateTimeBetween($estimate->last_sent_at, now()));
125
+                }
126
+
127
+                $estimate->markAsAccepted($acceptedAt);
117
             }
128
             }
118
 
129
 
119
             $convertedAt = Carbon::parse($estimate->accepted_at)
130
             $convertedAt = Carbon::parse($estimate->accepted_at)
120
                 ->addDays($this->faker->numberBetween(1, 7));
131
                 ->addDays($this->faker->numberBetween(1, 7));
121
 
132
 
133
+            if ($convertedAt->isAfter(now())) {
134
+                $convertedAt = Carbon::parse($this->faker->dateTimeBetween($estimate->accepted_at, now()));
135
+            }
136
+
122
             $estimate->convertToInvoice($convertedAt);
137
             $estimate->convertToInvoice($convertedAt);
123
         });
138
         });
124
     }
139
     }
126
     public function declined(): static
141
     public function declined(): static
127
     {
142
     {
128
         return $this->afterCreating(function (Estimate $estimate) {
143
         return $this->afterCreating(function (Estimate $estimate) {
129
-            $this->ensureSent($estimate);
144
+            $this->performSent($estimate);
130
 
145
 
131
             $declinedAt = Carbon::parse($estimate->last_sent_at)
146
             $declinedAt = Carbon::parse($estimate->last_sent_at)
132
                 ->addDays($this->faker->numberBetween(1, 7));
147
                 ->addDays($this->faker->numberBetween(1, 7));
133
 
148
 
149
+            if ($declinedAt->isAfter(now())) {
150
+                $declinedAt = Carbon::parse($this->faker->dateTimeBetween($estimate->last_sent_at, now()));
151
+            }
152
+
134
             $estimate->markAsDeclined($declinedAt);
153
             $estimate->markAsDeclined($declinedAt);
135
         });
154
         });
136
     }
155
     }
138
     public function sent(): static
157
     public function sent(): static
139
     {
158
     {
140
         return $this->afterCreating(function (Estimate $estimate) {
159
         return $this->afterCreating(function (Estimate $estimate) {
141
-            $this->ensureApproved($estimate);
142
-
143
-            $sentAt = Carbon::parse($estimate->approved_at)
144
-                ->addHours($this->faker->numberBetween(1, 24));
145
-
146
-            $estimate->markAsSent($sentAt);
160
+            $this->performSent($estimate);
147
         });
161
         });
148
     }
162
     }
149
 
163
 
150
     public function viewed(): static
164
     public function viewed(): static
151
     {
165
     {
152
         return $this->afterCreating(function (Estimate $estimate) {
166
         return $this->afterCreating(function (Estimate $estimate) {
153
-            $this->ensureSent($estimate);
167
+            $this->performSent($estimate);
154
 
168
 
155
             $viewedAt = Carbon::parse($estimate->last_sent_at)
169
             $viewedAt = Carbon::parse($estimate->last_sent_at)
156
                 ->addHours($this->faker->numberBetween(1, 24));
170
                 ->addHours($this->faker->numberBetween(1, 24));
157
 
171
 
172
+            if ($viewedAt->isAfter(now())) {
173
+                $viewedAt = Carbon::parse($this->faker->dateTimeBetween($estimate->last_sent_at, now()));
174
+            }
175
+
158
             $estimate->markAsViewed($viewedAt);
176
             $estimate->markAsViewed($viewedAt);
159
         });
177
         });
160
     }
178
     }
166
                 'expiration_date' => now()->subDays($this->faker->numberBetween(1, 30)),
184
                 'expiration_date' => now()->subDays($this->faker->numberBetween(1, 30)),
167
             ])
185
             ])
168
             ->afterCreating(function (Estimate $estimate) {
186
             ->afterCreating(function (Estimate $estimate) {
169
-                $this->ensureApproved($estimate);
187
+                $this->performApproval($estimate);
170
             });
188
             });
171
     }
189
     }
172
 
190
 
191
+    protected function performApproval(Estimate $estimate): void
192
+    {
193
+        if (! $estimate->canBeApproved()) {
194
+            throw new \InvalidArgumentException('Estimate cannot be approved. Current status: ' . $estimate->status->value);
195
+        }
196
+
197
+        $approvedAt = Carbon::parse($estimate->date)
198
+            ->addHours($this->faker->numberBetween(1, 24));
199
+
200
+        if ($approvedAt->isAfter(now())) {
201
+            $approvedAt = Carbon::parse($this->faker->dateTimeBetween($estimate->date, now()));
202
+        }
203
+
204
+        $estimate->approveDraft($approvedAt);
205
+    }
206
+
207
+    protected function performSent(Estimate $estimate): void
208
+    {
209
+        if (! $estimate->wasApproved()) {
210
+            $this->performApproval($estimate);
211
+        }
212
+
213
+        $sentAt = Carbon::parse($estimate->approved_at)
214
+            ->addHours($this->faker->numberBetween(1, 24));
215
+
216
+        if ($sentAt->isAfter(now())) {
217
+            $sentAt = Carbon::parse($this->faker->dateTimeBetween($estimate->approved_at, now()));
218
+        }
219
+
220
+        $estimate->markAsSent($sentAt);
221
+    }
222
+
173
     public function configure(): static
223
     public function configure(): static
174
     {
224
     {
175
         return $this->afterCreating(function (Estimate $estimate) {
225
         return $this->afterCreating(function (Estimate $estimate) {
176
-            $this->ensureLineItems($estimate);
226
+            DocumentLineItem::factory()
227
+                ->count(3)
228
+                ->forEstimate($estimate)
229
+                ->create();
230
+
231
+            $this->recalculateTotals($estimate);
177
 
232
 
178
             $number = DocumentDefault::getBaseNumber() + $estimate->id;
233
             $number = DocumentDefault::getBaseNumber() + $estimate->id;
179
 
234
 
182
                 'reference_number' => "REF-{$number}",
237
                 'reference_number' => "REF-{$number}",
183
             ]);
238
             ]);
184
 
239
 
185
-            if ($estimate->wasApproved() && $estimate->is_currently_expired) {
240
+            if ($estimate->wasApproved() && $estimate->shouldBeExpired()) {
186
                 $estimate->updateQuietly([
241
                 $estimate->updateQuietly([
187
                     'status' => EstimateStatus::Expired,
242
                     'status' => EstimateStatus::Expired,
188
                 ]);
243
                 ]);
190
         });
245
         });
191
     }
246
     }
192
 
247
 
193
-    protected function ensureLineItems(Estimate $estimate): void
194
-    {
195
-        if (! $estimate->hasLineItems()) {
196
-            $this->withLineItems()->callAfterCreating(collect([$estimate]));
197
-        }
198
-    }
199
-
200
-    protected function ensureApproved(Estimate $estimate): void
201
-    {
202
-        if (! $estimate->wasApproved()) {
203
-            $this->approved()->callAfterCreating(collect([$estimate]));
204
-        }
205
-    }
206
-
207
-    protected function ensureSent(Estimate $estimate): void
208
-    {
209
-        if (! $estimate->hasBeenSent()) {
210
-            $this->sent()->callAfterCreating(collect([$estimate]));
211
-        }
212
-    }
213
-
214
     protected function recalculateTotals(Estimate $estimate): void
248
     protected function recalculateTotals(Estimate $estimate): void
215
     {
249
     {
216
         $estimate->refresh();
250
         $estimate->refresh();
231
                 $scaledRate = RateCalculator::parseLocalizedRate($estimate->discount_rate);
265
                 $scaledRate = RateCalculator::parseLocalizedRate($estimate->discount_rate);
232
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
266
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
233
             } else {
267
             } else {
234
-                $discountTotalCents = CurrencyConverter::convertToCents($estimate->discount_rate, $estimate->currency_code);
268
+                $discountTotalCents = $estimate->getRawOriginal('discount_rate');
235
             }
269
             }
236
         }
270
         }
237
 
271
 
238
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
272
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
239
-        $currencyCode = $estimate->currency_code;
240
 
273
 
241
         $estimate->update([
274
         $estimate->update([
242
-            'subtotal' => CurrencyConverter::convertCentsToFormatSimple($subtotalCents, $currencyCode),
243
-            'tax_total' => CurrencyConverter::convertCentsToFormatSimple($taxTotalCents, $currencyCode),
244
-            'discount_total' => CurrencyConverter::convertCentsToFormatSimple($discountTotalCents, $currencyCode),
245
-            'total' => CurrencyConverter::convertCentsToFormatSimple($grandTotalCents, $currencyCode),
275
+            'subtotal' => $subtotalCents,
276
+            'tax_total' => $taxTotalCents,
277
+            'discount_total' => $discountTotalCents,
278
+            'total' => $grandTotalCents,
246
         ]);
279
         ]);
247
     }
280
     }
248
 }
281
 }

+ 108
- 100
database/factories/Accounting/InvoiceFactory.php View File

12
 use App\Models\Common\Client;
12
 use App\Models\Common\Client;
13
 use App\Models\Company;
13
 use App\Models\Company;
14
 use App\Models\Setting\DocumentDefault;
14
 use App\Models\Setting\DocumentDefault;
15
-use App\Utilities\Currency\CurrencyConverter;
16
 use App\Utilities\RateCalculator;
15
 use App\Utilities\RateCalculator;
17
 use Illuminate\Database\Eloquent\Factories\Factory;
16
 use Illuminate\Database\Eloquent\Factories\Factory;
18
 use Illuminate\Support\Carbon;
17
 use Illuminate\Support\Carbon;
18
+use Random\RandomException;
19
 
19
 
20
 /**
20
 /**
21
  * @extends Factory<Invoice>
21
  * @extends Factory<Invoice>
34
      */
34
      */
35
     public function definition(): array
35
     public function definition(): array
36
     {
36
     {
37
-        $invoiceDate = $this->faker->dateTimeBetween('-1 year');
37
+        $invoiceDate = $this->faker->dateTimeBetween('-2 months', '-1 day');
38
 
38
 
39
         return [
39
         return [
40
             'company_id' => 1,
40
             'company_id' => 1,
41
-            'client_id' => fn (array $attributes) => Client::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id'),
41
+            'client_id' => function (array $attributes) {
42
+                return Client::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id')
43
+                    ?? Client::factory()->state([
44
+                        'company_id' => $attributes['company_id'],
45
+                    ]);
46
+            },
42
             'header' => 'Invoice',
47
             'header' => 'Invoice',
43
             'subheader' => 'Invoice',
48
             'subheader' => 'Invoice',
44
             'invoice_number' => $this->faker->unique()->numerify('INV-####'),
49
             'invoice_number' => $this->faker->unique()->numerify('INV-####'),
45
             'order_number' => $this->faker->unique()->numerify('ORD-####'),
50
             'order_number' => $this->faker->unique()->numerify('ORD-####'),
46
             'date' => $invoiceDate,
51
             'date' => $invoiceDate,
47
-            'due_date' => Carbon::parse($invoiceDate)->addDays($this->faker->numberBetween(14, 60)),
52
+            'due_date' => $this->faker->dateTimeInInterval($invoiceDate, '+3 months'),
48
             'status' => InvoiceStatus::Draft,
53
             'status' => InvoiceStatus::Draft,
49
             'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
54
             'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
50
             'discount_computation' => AdjustmentComputation::Percentage,
55
             'discount_computation' => AdjustmentComputation::Percentage,
74
     public function withLineItems(int $count = 3): static
79
     public function withLineItems(int $count = 3): static
75
     {
80
     {
76
         return $this->afterCreating(function (Invoice $invoice) use ($count) {
81
         return $this->afterCreating(function (Invoice $invoice) use ($count) {
82
+            $invoice->lineItems()->delete();
83
+
77
             DocumentLineItem::factory()
84
             DocumentLineItem::factory()
78
                 ->count($count)
85
                 ->count($count)
79
                 ->forInvoice($invoice)
86
                 ->forInvoice($invoice)
86
     public function approved(): static
93
     public function approved(): static
87
     {
94
     {
88
         return $this->afterCreating(function (Invoice $invoice) {
95
         return $this->afterCreating(function (Invoice $invoice) {
89
-            $this->ensureLineItems($invoice);
90
-
91
-            if (! $invoice->canBeApproved()) {
92
-                return;
93
-            }
94
-
95
-            $approvedAt = Carbon::parse($invoice->date)
96
-                ->addHours($this->faker->numberBetween(1, 24));
97
-
98
-            $invoice->approveDraft($approvedAt);
96
+            $this->performApproval($invoice);
99
         });
97
         });
100
     }
98
     }
101
 
99
 
102
     public function sent(): static
100
     public function sent(): static
103
     {
101
     {
104
         return $this->afterCreating(function (Invoice $invoice) {
102
         return $this->afterCreating(function (Invoice $invoice) {
105
-            $this->ensureApproved($invoice);
106
-
107
-            $sentAt = Carbon::parse($invoice->approved_at)
108
-                ->addHours($this->faker->numberBetween(1, 24));
109
-
110
-            $invoice->markAsSent($sentAt);
103
+            $this->performSent($invoice);
111
         });
104
         });
112
     }
105
     }
113
 
106
 
114
     public function partial(int $maxPayments = 4): static
107
     public function partial(int $maxPayments = 4): static
115
     {
108
     {
116
         return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
109
         return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
117
-            $this->ensureSent($invoice);
118
-
119
-            $this->withPayments(max: $maxPayments, invoiceStatus: InvoiceStatus::Partial)
120
-                ->callAfterCreating(collect([$invoice]));
110
+            $this->performSent($invoice);
111
+            $this->performPayments($invoice, $maxPayments, InvoiceStatus::Partial);
121
         });
112
         });
122
     }
113
     }
123
 
114
 
124
     public function paid(int $maxPayments = 4): static
115
     public function paid(int $maxPayments = 4): static
125
     {
116
     {
126
         return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
117
         return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
127
-            $this->ensureSent($invoice);
128
-
129
-            $this->withPayments(max: $maxPayments)
130
-                ->callAfterCreating(collect([$invoice]));
118
+            $this->performSent($invoice);
119
+            $this->performPayments($invoice, $maxPayments, InvoiceStatus::Paid);
131
         });
120
         });
132
     }
121
     }
133
 
122
 
134
     public function overpaid(int $maxPayments = 4): static
123
     public function overpaid(int $maxPayments = 4): static
135
     {
124
     {
136
         return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
125
         return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
137
-            $this->ensureSent($invoice);
138
-
139
-            $this->withPayments(max: $maxPayments, invoiceStatus: InvoiceStatus::Overpaid)
140
-                ->callAfterCreating(collect([$invoice]));
126
+            $this->performSent($invoice);
127
+            $this->performPayments($invoice, $maxPayments, InvoiceStatus::Overpaid);
141
         });
128
         });
142
     }
129
     }
143
 
130
 
148
                 'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
135
                 'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
149
             ])
136
             ])
150
             ->afterCreating(function (Invoice $invoice) {
137
             ->afterCreating(function (Invoice $invoice) {
151
-                $this->ensureApproved($invoice);
138
+                $this->performApproval($invoice);
152
             });
139
             });
153
     }
140
     }
154
 
141
 
155
-    public function withPayments(?int $min = null, ?int $max = null, InvoiceStatus $invoiceStatus = InvoiceStatus::Paid): static
142
+    protected function performApproval(Invoice $invoice): void
156
     {
143
     {
157
-        $min ??= 1;
144
+        if (! $invoice->canBeApproved()) {
145
+            throw new \InvalidArgumentException('Invoice cannot be approved. Current status: ' . $invoice->status->value);
146
+        }
147
+
148
+        $approvedAt = Carbon::parse($invoice->date)
149
+            ->addHours($this->faker->numberBetween(1, 24));
150
+
151
+        if ($approvedAt->isAfter(now())) {
152
+            $approvedAt = Carbon::parse($this->faker->dateTimeBetween($invoice->date, now()));
153
+        }
158
 
154
 
159
-        return $this->afterCreating(function (Invoice $invoice) use ($invoiceStatus, $max, $min) {
160
-            $this->ensureSent($invoice);
155
+        $invoice->approveDraft($approvedAt);
156
+    }
161
 
157
 
162
-            $invoice->refresh();
158
+    protected function performSent(Invoice $invoice): void
159
+    {
160
+        if (! $invoice->wasApproved()) {
161
+            $this->performApproval($invoice);
162
+        }
163
 
163
 
164
-            $amountDue = $invoice->getRawOriginal('amount_due');
164
+        $sentAt = Carbon::parse($invoice->approved_at)
165
+            ->addHours($this->faker->numberBetween(1, 24));
165
 
166
 
166
-            $totalAmountDue = match ($invoiceStatus) {
167
-                InvoiceStatus::Overpaid => $amountDue + random_int(1000, 10000),
168
-                InvoiceStatus::Partial => (int) floor($amountDue * 0.5),
169
-                default => $amountDue,
170
-            };
167
+        if ($sentAt->isAfter(now())) {
168
+            $sentAt = Carbon::parse($this->faker->dateTimeBetween($invoice->approved_at, now()));
169
+        }
171
 
170
 
172
-            if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
173
-                return;
174
-            }
171
+        $invoice->markAsSent($sentAt);
172
+    }
175
 
173
 
176
-            $paymentCount = $max && $min ? $this->faker->numberBetween($min, $max) : $min;
177
-            $paymentAmount = (int) floor($totalAmountDue / $paymentCount);
178
-            $remainingAmount = $totalAmountDue;
174
+    /**
175
+     * @throws RandomException
176
+     */
177
+    protected function performPayments(Invoice $invoice, int $maxPayments, InvoiceStatus $invoiceStatus): void
178
+    {
179
+        $invoice->refresh();
179
 
180
 
180
-            $paymentDate = Carbon::parse($invoice->approved_at);
181
-            $paymentDates = [];
181
+        $amountDue = $invoice->getRawOriginal('amount_due');
182
 
182
 
183
-            for ($i = 0; $i < $paymentCount; $i++) {
184
-                $amount = $i === $paymentCount - 1 ? $remainingAmount : $paymentAmount;
183
+        $totalAmountDue = match ($invoiceStatus) {
184
+            InvoiceStatus::Overpaid => $amountDue + random_int(1000, 10000),
185
+            InvoiceStatus::Partial => (int) floor($amountDue * 0.5),
186
+            default => $amountDue,
187
+        };
185
 
188
 
186
-                if ($amount <= 0) {
187
-                    break;
188
-                }
189
+        if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
190
+            return;
191
+        }
192
+
193
+        $paymentCount = $this->faker->numberBetween(1, $maxPayments);
194
+        $paymentAmount = (int) floor($totalAmountDue / $paymentCount);
195
+        $remainingAmount = $totalAmountDue;
189
 
196
 
190
-                $postedAt = $paymentDate->copy()->addDays($this->faker->numberBetween(1, 30));
191
-                $paymentDates[] = $postedAt;
197
+        $initialPaymentDate = Carbon::parse($invoice->approved_at);
198
+        $maxPaymentDate = now();
192
 
199
 
193
-                $data = [
194
-                    'posted_at' => $postedAt,
195
-                    'amount' => CurrencyConverter::convertCentsToFormatSimple($amount, $invoice->currency_code),
196
-                    'payment_method' => $this->faker->randomElement(PaymentMethod::class),
197
-                    'bank_account_id' => BankAccount::where('company_id', $invoice->company_id)->inRandomOrder()->value('id'),
198
-                    'notes' => $this->faker->sentence,
199
-                ];
200
+        $paymentDates = [];
200
 
201
 
201
-                $invoice->recordPayment($data);
202
-                $remainingAmount -= $amount;
202
+        for ($i = 0; $i < $paymentCount; $i++) {
203
+            $amount = $i === $paymentCount - 1 ? $remainingAmount : $paymentAmount;
204
+
205
+            if ($amount <= 0) {
206
+                break;
207
+            }
208
+
209
+            if ($i === 0) {
210
+                $postedAt = $initialPaymentDate->copy()->addDays($this->faker->numberBetween(1, 15));
211
+            } else {
212
+                $postedAt = $paymentDates[$i - 1]->copy()->addDays($this->faker->numberBetween(1, 10));
203
             }
213
             }
204
 
214
 
205
-            if ($invoiceStatus !== InvoiceStatus::Paid) {
206
-                return;
215
+            if ($postedAt->isAfter($maxPaymentDate)) {
216
+                $postedAt = Carbon::parse($this->faker->dateTimeBetween($initialPaymentDate, $maxPaymentDate));
207
             }
217
             }
208
 
218
 
219
+            $paymentDates[] = $postedAt;
220
+
221
+            $data = [
222
+                'posted_at' => $postedAt,
223
+                'amount' => $amount,
224
+                'payment_method' => $this->faker->randomElement(PaymentMethod::class),
225
+                'bank_account_id' => BankAccount::where('company_id', $invoice->company_id)->inRandomOrder()->value('id'),
226
+                'notes' => $this->faker->sentence,
227
+            ];
228
+
229
+            $invoice->recordPayment($data);
230
+            $remainingAmount -= $amount;
231
+        }
232
+
233
+        if ($invoiceStatus === InvoiceStatus::Paid && ! empty($paymentDates)) {
209
             $latestPaymentDate = max($paymentDates);
234
             $latestPaymentDate = max($paymentDates);
210
             $invoice->updateQuietly([
235
             $invoice->updateQuietly([
211
                 'status' => $invoiceStatus,
236
                 'status' => $invoiceStatus,
212
                 'paid_at' => $latestPaymentDate,
237
                 'paid_at' => $latestPaymentDate,
213
             ]);
238
             ]);
214
-        });
239
+        }
215
     }
240
     }
216
 
241
 
217
     public function configure(): static
242
     public function configure(): static
218
     {
243
     {
219
         return $this->afterCreating(function (Invoice $invoice) {
244
         return $this->afterCreating(function (Invoice $invoice) {
220
-            $this->ensureLineItems($invoice);
245
+            DocumentLineItem::factory()
246
+                ->count(3)
247
+                ->forInvoice($invoice)
248
+                ->create();
249
+
250
+            $this->recalculateTotals($invoice);
221
 
251
 
222
             $number = DocumentDefault::getBaseNumber() + $invoice->id;
252
             $number = DocumentDefault::getBaseNumber() + $invoice->id;
223
 
253
 
226
                 'order_number' => "ORD-{$number}",
256
                 'order_number' => "ORD-{$number}",
227
             ]);
257
             ]);
228
 
258
 
229
-            if ($invoice->wasApproved() && $invoice->is_currently_overdue) {
259
+            if ($invoice->wasApproved() && $invoice->shouldBeOverdue()) {
230
                 $invoice->updateQuietly([
260
                 $invoice->updateQuietly([
231
                     'status' => InvoiceStatus::Overdue,
261
                     'status' => InvoiceStatus::Overdue,
232
                 ]);
262
                 ]);
234
         });
264
         });
235
     }
265
     }
236
 
266
 
237
-    protected function ensureLineItems(Invoice $invoice): void
238
-    {
239
-        if (! $invoice->hasLineItems()) {
240
-            $this->withLineItems()->callAfterCreating(collect([$invoice]));
241
-        }
242
-    }
243
-
244
-    protected function ensureApproved(Invoice $invoice): void
245
-    {
246
-        if (! $invoice->wasApproved()) {
247
-            $this->approved()->callAfterCreating(collect([$invoice]));
248
-        }
249
-    }
250
-
251
-    protected function ensureSent(Invoice $invoice): void
252
-    {
253
-        if (! $invoice->hasBeenSent()) {
254
-            $this->sent()->callAfterCreating(collect([$invoice]));
255
-        }
256
-    }
257
-
258
     protected function recalculateTotals(Invoice $invoice): void
267
     protected function recalculateTotals(Invoice $invoice): void
259
     {
268
     {
260
         $invoice->refresh();
269
         $invoice->refresh();
275
                 $scaledRate = RateCalculator::parseLocalizedRate($invoice->discount_rate);
284
                 $scaledRate = RateCalculator::parseLocalizedRate($invoice->discount_rate);
276
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
285
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
277
             } else {
286
             } else {
278
-                $discountTotalCents = CurrencyConverter::convertToCents($invoice->discount_rate, $invoice->currency_code);
287
+                $discountTotalCents = $invoice->getRawOriginal('discount_rate');
279
             }
288
             }
280
         }
289
         }
281
 
290
 
282
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
291
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
283
-        $currencyCode = $invoice->currency_code;
284
 
292
 
285
         $invoice->update([
293
         $invoice->update([
286
-            'subtotal' => CurrencyConverter::convertCentsToFormatSimple($subtotalCents, $currencyCode),
287
-            'tax_total' => CurrencyConverter::convertCentsToFormatSimple($taxTotalCents, $currencyCode),
288
-            'discount_total' => CurrencyConverter::convertCentsToFormatSimple($discountTotalCents, $currencyCode),
289
-            'total' => CurrencyConverter::convertCentsToFormatSimple($grandTotalCents, $currencyCode),
294
+            'subtotal' => $subtotalCents,
295
+            'tax_total' => $taxTotalCents,
296
+            'discount_total' => $discountTotalCents,
297
+            'total' => $grandTotalCents,
290
         ]);
298
         ]);
291
     }
299
     }
292
 }
300
 }

+ 163
- 64
database/factories/Accounting/RecurringInvoiceFactory.php View File

16
 use App\Models\Accounting\RecurringInvoice;
16
 use App\Models\Accounting\RecurringInvoice;
17
 use App\Models\Common\Client;
17
 use App\Models\Common\Client;
18
 use App\Models\Company;
18
 use App\Models\Company;
19
-use App\Utilities\Currency\CurrencyConverter;
20
 use App\Utilities\RateCalculator;
19
 use App\Utilities\RateCalculator;
21
 use Illuminate\Database\Eloquent\Factories\Factory;
20
 use Illuminate\Database\Eloquent\Factories\Factory;
22
 use Illuminate\Support\Carbon;
21
 use Illuminate\Support\Carbon;
40
     {
39
     {
41
         return [
40
         return [
42
             'company_id' => 1,
41
             'company_id' => 1,
43
-            'client_id' => fn (array $attributes) => Client::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id'),
42
+            'client_id' => function (array $attributes) {
43
+                return Client::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id')
44
+                    ?? Client::factory()->state([
45
+                        'company_id' => $attributes['company_id'],
46
+                    ]);
47
+            },
44
             'header' => 'Invoice',
48
             'header' => 'Invoice',
45
             'subheader' => 'Invoice',
49
             'subheader' => 'Invoice',
46
             'order_number' => $this->faker->unique()->numerify('ORD-####'),
50
             'order_number' => $this->faker->unique()->numerify('ORD-####'),
74
     public function withLineItems(int $count = 3): static
78
     public function withLineItems(int $count = 3): static
75
     {
79
     {
76
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($count) {
80
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($count) {
81
+            // Clear existing line items first
82
+            $recurringInvoice->lineItems()->delete();
83
+
77
             DocumentLineItem::factory()
84
             DocumentLineItem::factory()
78
                 ->count($count)
85
                 ->count($count)
79
                 ->forInvoice($recurringInvoice)
86
                 ->forInvoice($recurringInvoice)
83
         });
90
         });
84
     }
91
     }
85
 
92
 
93
+    public function configure(): static
94
+    {
95
+        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
96
+            DocumentLineItem::factory()
97
+                ->count(3)
98
+                ->forInvoice($recurringInvoice)
99
+                ->create();
100
+
101
+            $this->recalculateTotals($recurringInvoice);
102
+        });
103
+    }
104
+
86
     public function withSchedule(
105
     public function withSchedule(
87
         ?Frequency $frequency = null,
106
         ?Frequency $frequency = null,
88
         ?Carbon $startDate = null,
107
         ?Carbon $startDate = null,
89
         ?EndType $endType = null
108
         ?EndType $endType = null
90
     ): static {
109
     ): static {
91
-        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($frequency, $endType, $startDate) {
92
-            $this->ensureLineItems($recurringInvoice);
93
-
94
-            $frequency ??= $this->faker->randomElement(Frequency::class);
95
-            $endType ??= EndType::Never;
96
-
97
-            // Adjust the start date range based on frequency
98
-            $startDate = match ($frequency) {
99
-                Frequency::Daily => Carbon::parse($this->faker->dateTimeBetween('-30 days')), // At most 30 days back
100
-                default => $startDate ?? Carbon::parse($this->faker->dateTimeBetween('-1 year')),
101
-            };
102
-
103
-            $state = match ($frequency) {
104
-                Frequency::Daily => $this->withDailySchedule($startDate, $endType),
105
-                Frequency::Weekly => $this->withWeeklySchedule($startDate, $endType),
106
-                Frequency::Monthly => $this->withMonthlySchedule($startDate, $endType),
107
-                Frequency::Yearly => $this->withYearlySchedule($startDate, $endType),
108
-                Frequency::Custom => $this->withCustomSchedule($startDate, $endType),
109
-            };
110
-
111
-            $state->callAfterCreating(collect([$recurringInvoice]));
110
+        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($frequency, $startDate, $endType) {
111
+            $this->performScheduleSetup($recurringInvoice, $frequency, $startDate, $endType);
112
         });
112
         });
113
     }
113
     }
114
 
114
 
115
     public function withDailySchedule(Carbon $startDate, EndType $endType): static
115
     public function withDailySchedule(Carbon $startDate, EndType $endType): static
116
     {
116
     {
117
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
117
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
118
-            $this->ensureLineItems($recurringInvoice);
119
-
120
             $recurringInvoice->updateQuietly([
118
             $recurringInvoice->updateQuietly([
121
                 'frequency' => Frequency::Daily,
119
                 'frequency' => Frequency::Daily,
122
                 'start_date' => $startDate,
120
                 'start_date' => $startDate,
128
     public function withWeeklySchedule(Carbon $startDate, EndType $endType): static
126
     public function withWeeklySchedule(Carbon $startDate, EndType $endType): static
129
     {
127
     {
130
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
128
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
131
-            $this->ensureLineItems($recurringInvoice);
132
-
133
             $recurringInvoice->updateQuietly([
129
             $recurringInvoice->updateQuietly([
134
                 'frequency' => Frequency::Weekly,
130
                 'frequency' => Frequency::Weekly,
135
                 'day_of_week' => DayOfWeek::from($startDate->dayOfWeek),
131
                 'day_of_week' => DayOfWeek::from($startDate->dayOfWeek),
142
     public function withMonthlySchedule(Carbon $startDate, EndType $endType): static
138
     public function withMonthlySchedule(Carbon $startDate, EndType $endType): static
143
     {
139
     {
144
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
140
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
145
-            $this->ensureLineItems($recurringInvoice);
146
-
147
             $recurringInvoice->updateQuietly([
141
             $recurringInvoice->updateQuietly([
148
                 'frequency' => Frequency::Monthly,
142
                 'frequency' => Frequency::Monthly,
149
                 'day_of_month' => DayOfMonth::from($startDate->day),
143
                 'day_of_month' => DayOfMonth::from($startDate->day),
156
     public function withYearlySchedule(Carbon $startDate, EndType $endType): static
150
     public function withYearlySchedule(Carbon $startDate, EndType $endType): static
157
     {
151
     {
158
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
152
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
159
-            $this->ensureLineItems($recurringInvoice);
160
-
161
             $recurringInvoice->updateQuietly([
153
             $recurringInvoice->updateQuietly([
162
                 'frequency' => Frequency::Yearly,
154
                 'frequency' => Frequency::Yearly,
163
                 'month' => Month::from($startDate->month),
155
                 'month' => Month::from($startDate->month),
175
         ?int $intervalValue = null
167
         ?int $intervalValue = null
176
     ): static {
168
     ): static {
177
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($intervalType, $intervalValue, $startDate, $endType) {
169
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($intervalType, $intervalValue, $startDate, $endType) {
178
-            $this->ensureLineItems($recurringInvoice);
179
-
180
             $intervalType ??= $this->faker->randomElement(IntervalType::class);
170
             $intervalType ??= $this->faker->randomElement(IntervalType::class);
181
             $intervalValue ??= match ($intervalType) {
171
             $intervalValue ??= match ($intervalType) {
182
                 IntervalType::Day => $this->faker->numberBetween(1, 7),
172
                 IntervalType::Day => $this->faker->numberBetween(1, 7),
216
                     break;
206
                     break;
217
             }
207
             }
218
 
208
 
219
-            return $recurringInvoice->updateQuietly($state);
209
+            $recurringInvoice->updateQuietly($state);
220
         });
210
         });
221
     }
211
     }
222
 
212
 
249
     public function approved(): static
239
     public function approved(): static
250
     {
240
     {
251
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
241
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
252
-            $this->ensureLineItems($recurringInvoice);
242
+            $this->performApproval($recurringInvoice);
243
+        });
244
+    }
253
 
245
 
246
+    public function active(): static
247
+    {
248
+        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
254
             if (! $recurringInvoice->hasSchedule()) {
249
             if (! $recurringInvoice->hasSchedule()) {
255
-                $this->withSchedule()->callAfterCreating(collect([$recurringInvoice]));
250
+                $this->performScheduleSetup($recurringInvoice);
256
                 $recurringInvoice->refresh();
251
                 $recurringInvoice->refresh();
257
             }
252
             }
258
 
253
 
259
-            $approvedAt = $recurringInvoice->start_date
260
-                ? $recurringInvoice->start_date->copy()->subDays($this->faker->numberBetween(1, 7))
261
-                : now()->subDays($this->faker->numberBetween(1, 30));
262
-
263
-            $recurringInvoice->approveDraft($approvedAt);
254
+            $this->performApproval($recurringInvoice);
264
         });
255
         });
265
     }
256
     }
266
 
257
 
267
-    public function active(): static
268
-    {
269
-        return $this->withLineItems()
270
-            ->withSchedule()
271
-            ->approved();
272
-    }
273
-
274
     public function ended(): static
258
     public function ended(): static
275
     {
259
     {
276
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
260
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
277
-            $this->ensureLineItems($recurringInvoice);
278
-
279
             if (! $recurringInvoice->canBeEnded()) {
261
             if (! $recurringInvoice->canBeEnded()) {
280
-                $this->active()->callAfterCreating(collect([$recurringInvoice]));
262
+                if (! $recurringInvoice->hasSchedule()) {
263
+                    $this->performScheduleSetup($recurringInvoice);
264
+                    $recurringInvoice->refresh();
265
+                }
266
+
267
+                $this->performApproval($recurringInvoice);
281
             }
268
             }
282
 
269
 
283
             $endedAt = $recurringInvoice->last_date
270
             $endedAt = $recurringInvoice->last_date
291
         });
278
         });
292
     }
279
     }
293
 
280
 
294
-    public function configure(): static
281
+    protected function performScheduleSetup(
282
+        RecurringInvoice $recurringInvoice,
283
+        ?Frequency $frequency = null,
284
+        ?Carbon $startDate = null,
285
+        ?EndType $endType = null
286
+    ): void {
287
+        $frequency ??= $this->faker->randomElement(Frequency::class);
288
+        $endType ??= EndType::Never;
289
+
290
+        // Adjust the start date range based on frequency
291
+        $startDate = match ($frequency) {
292
+            Frequency::Daily => Carbon::parse($this->faker->dateTimeBetween('-30 days')), // At most 30 days back
293
+            default => $startDate ?? Carbon::parse($this->faker->dateTimeBetween('-1 year')),
294
+        };
295
+
296
+        match ($frequency) {
297
+            Frequency::Daily => $this->performDailySchedule($recurringInvoice, $startDate, $endType),
298
+            Frequency::Weekly => $this->performWeeklySchedule($recurringInvoice, $startDate, $endType),
299
+            Frequency::Monthly => $this->performMonthlySchedule($recurringInvoice, $startDate, $endType),
300
+            Frequency::Yearly => $this->performYearlySchedule($recurringInvoice, $startDate, $endType),
301
+            Frequency::Custom => $this->performCustomSchedule($recurringInvoice, $startDate, $endType),
302
+        };
303
+    }
304
+
305
+    protected function performDailySchedule(RecurringInvoice $recurringInvoice, Carbon $startDate, EndType $endType): void
295
     {
306
     {
296
-        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
297
-            $this->ensureLineItems($recurringInvoice);
298
-        });
307
+        $recurringInvoice->updateQuietly([
308
+            'frequency' => Frequency::Daily,
309
+            'start_date' => $startDate,
310
+            'end_type' => $endType,
311
+        ]);
299
     }
312
     }
300
 
313
 
301
-    protected function ensureLineItems(RecurringInvoice $recurringInvoice): void
314
+    protected function performWeeklySchedule(RecurringInvoice $recurringInvoice, Carbon $startDate, EndType $endType): void
302
     {
315
     {
303
-        if (! $recurringInvoice->hasLineItems()) {
304
-            $this->withLineItems()->callAfterCreating(collect([$recurringInvoice]));
316
+        $recurringInvoice->updateQuietly([
317
+            'frequency' => Frequency::Weekly,
318
+            'day_of_week' => DayOfWeek::from($startDate->dayOfWeek),
319
+            'start_date' => $startDate,
320
+            'end_type' => $endType,
321
+        ]);
322
+    }
323
+
324
+    protected function performMonthlySchedule(RecurringInvoice $recurringInvoice, Carbon $startDate, EndType $endType): void
325
+    {
326
+        $recurringInvoice->updateQuietly([
327
+            'frequency' => Frequency::Monthly,
328
+            'day_of_month' => DayOfMonth::from($startDate->day),
329
+            'start_date' => $startDate,
330
+            'end_type' => $endType,
331
+        ]);
332
+    }
333
+
334
+    protected function performYearlySchedule(RecurringInvoice $recurringInvoice, Carbon $startDate, EndType $endType): void
335
+    {
336
+        $recurringInvoice->updateQuietly([
337
+            'frequency' => Frequency::Yearly,
338
+            'month' => Month::from($startDate->month),
339
+            'day_of_month' => DayOfMonth::from($startDate->day),
340
+            'start_date' => $startDate,
341
+            'end_type' => $endType,
342
+        ]);
343
+    }
344
+
345
+    protected function performCustomSchedule(
346
+        RecurringInvoice $recurringInvoice,
347
+        Carbon $startDate,
348
+        EndType $endType,
349
+        ?IntervalType $intervalType = null,
350
+        ?int $intervalValue = null
351
+    ): void {
352
+        $intervalType ??= $this->faker->randomElement(IntervalType::class);
353
+        $intervalValue ??= match ($intervalType) {
354
+            IntervalType::Day => $this->faker->numberBetween(1, 7),
355
+            IntervalType::Week => $this->faker->numberBetween(1, 4),
356
+            IntervalType::Month => $this->faker->numberBetween(1, 3),
357
+            IntervalType::Year => 1,
358
+        };
359
+
360
+        $state = [
361
+            'frequency' => Frequency::Custom,
362
+            'interval_type' => $intervalType,
363
+            'interval_value' => $intervalValue,
364
+            'start_date' => $startDate,
365
+            'end_type' => $endType,
366
+        ];
367
+
368
+        // Add interval-specific attributes
369
+        switch ($intervalType) {
370
+            case IntervalType::Day:
371
+                // No additional attributes needed
372
+                break;
373
+
374
+            case IntervalType::Week:
375
+                $state['day_of_week'] = DayOfWeek::from($startDate->dayOfWeek);
376
+
377
+                break;
378
+
379
+            case IntervalType::Month:
380
+                $state['day_of_month'] = DayOfMonth::from($startDate->day);
381
+
382
+                break;
383
+
384
+            case IntervalType::Year:
385
+                $state['month'] = Month::from($startDate->month);
386
+                $state['day_of_month'] = DayOfMonth::from($startDate->day);
387
+
388
+                break;
305
         }
389
         }
390
+
391
+        $recurringInvoice->updateQuietly($state);
392
+    }
393
+
394
+    protected function performApproval(RecurringInvoice $recurringInvoice): void
395
+    {
396
+        if (! $recurringInvoice->hasSchedule()) {
397
+            $this->performScheduleSetup($recurringInvoice);
398
+            $recurringInvoice->refresh();
399
+        }
400
+
401
+        $approvedAt = $recurringInvoice->start_date
402
+            ? $recurringInvoice->start_date->copy()->subDays($this->faker->numberBetween(1, 7))
403
+            : now()->subDays($this->faker->numberBetween(1, 30));
404
+
405
+        $recurringInvoice->approveDraft($approvedAt);
306
     }
406
     }
307
 
407
 
308
     protected function recalculateTotals(RecurringInvoice $recurringInvoice): void
408
     protected function recalculateTotals(RecurringInvoice $recurringInvoice): void
325
                 $scaledRate = RateCalculator::parseLocalizedRate($recurringInvoice->discount_rate);
425
                 $scaledRate = RateCalculator::parseLocalizedRate($recurringInvoice->discount_rate);
326
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
426
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
327
             } else {
427
             } else {
328
-                $discountTotalCents = CurrencyConverter::convertToCents($recurringInvoice->discount_rate, $recurringInvoice->currency_code);
428
+                $discountTotalCents = $recurringInvoice->getRawOriginal('discount_rate');
329
             }
429
             }
330
         }
430
         }
331
 
431
 
332
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
432
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
333
-        $currencyCode = $recurringInvoice->currency_code;
334
 
433
 
335
         $recurringInvoice->update([
434
         $recurringInvoice->update([
336
-            'subtotal' => CurrencyConverter::convertCentsToFormatSimple($subtotalCents, $currencyCode),
337
-            'tax_total' => CurrencyConverter::convertCentsToFormatSimple($taxTotalCents, $currencyCode),
338
-            'discount_total' => CurrencyConverter::convertCentsToFormatSimple($discountTotalCents, $currencyCode),
339
-            'total' => CurrencyConverter::convertCentsToFormatSimple($grandTotalCents, $currencyCode),
435
+            'subtotal' => $subtotalCents,
436
+            'tax_total' => $taxTotalCents,
437
+            'discount_total' => $discountTotalCents,
438
+            'total' => $grandTotalCents,
340
         ]);
439
         ]);
341
     }
440
     }
342
 }
441
 }

+ 1
- 1
database/factories/Common/OfferingFactory.php View File

33
             'name' => $this->faker->words(3, true),
33
             'name' => $this->faker->words(3, true),
34
             'description' => $this->faker->sentence,
34
             'description' => $this->faker->sentence,
35
             'type' => $this->faker->randomElement(OfferingType::cases()),
35
             'type' => $this->faker->randomElement(OfferingType::cases()),
36
-            'price' => $this->faker->numberBetween(5, 1000),
36
+            'price' => $this->faker->numberBetween(500, 50000), // $5.00 to $500.00
37
             'sellable' => false,
37
             'sellable' => false,
38
             'purchasable' => false,
38
             'purchasable' => false,
39
             'income_account_id' => null,
39
             'income_account_id' => null,

+ 1
- 0
database/factories/CompanyFactory.php View File

133
 
133
 
134
             Invoice::factory()
134
             Invoice::factory()
135
                 ->count($draftCount)
135
                 ->count($draftCount)
136
+                ->withLineItems()
136
                 ->create([
137
                 ->create([
137
                     'company_id' => $company->id,
138
                     'company_id' => $company->id,
138
                     'created_by' => $company->user_id,
139
                     'created_by' => $company->user_id,

+ 63
- 0
tests/Feature/Accounting/InvoiceTest.php View File

1
+<?php
2
+
3
+use App\Enums\Accounting\InvoiceStatus;
4
+use App\Models\Accounting\Invoice;
5
+use App\Utilities\Currency\CurrencyAccessor;
6
+
7
+beforeEach(function () {
8
+    $this->defaultCurrency = CurrencyAccessor::getDefaultCurrency();
9
+    $this->withOfferings();
10
+});
11
+
12
+it('creates a basic invoice with line items and calculates totals correctly', function () {
13
+    $invoice = Invoice::factory()
14
+        ->withLineItems()
15
+        ->create();
16
+
17
+    $invoice->refresh();
18
+
19
+    expect($invoice)
20
+        ->hasLineItems()->toBeTrue()
21
+        ->lineItems->count()->toBe(3)
22
+        ->subtotal->toBeGreaterThan(0)
23
+        ->total->toBeGreaterThan(0)
24
+        ->amount_due->toBe($invoice->total);
25
+});
26
+
27
+test('approved invoices are marked as Unsent when not Overdue', function () {
28
+    $invoice = Invoice::factory()
29
+        ->withLineItems()
30
+        ->state([
31
+            'due_date' => now()->addDays(30),
32
+        ])
33
+        ->create();
34
+
35
+    $invoice->refresh();
36
+
37
+    $invoice->approveDraft();
38
+
39
+    expect($invoice)
40
+        ->hasLineItems()->toBeTrue()
41
+        ->status->toBe(InvoiceStatus::Unsent)
42
+        ->wasApproved()->toBeTrue()
43
+        ->approvalTransaction->not->toBeNull();
44
+});
45
+
46
+test('approved invoices are marked as Overdue when Overdue', function () {
47
+    $invoice = Invoice::factory()
48
+        ->withLineItems()
49
+        ->state([
50
+            'due_date' => now()->subDays(30),
51
+        ])
52
+        ->create();
53
+
54
+    $invoice->refresh();
55
+
56
+    $invoice->approveDraft();
57
+
58
+    expect($invoice)
59
+        ->hasLineItems()->toBeTrue()
60
+        ->status->toBe(InvoiceStatus::Overdue)
61
+        ->wasApproved()->toBeTrue()
62
+        ->approvalTransaction->not->toBeNull();
63
+});

+ 14
- 0
tests/Feature/Accounting/RecurringInvoiceTest.php View File

10
     $this->withOfferings();
10
     $this->withOfferings();
11
 });
11
 });
12
 
12
 
13
+it('creates a basic recurring invoice with line items and calculates totals correctly', function () {
14
+    $recurringInvoice = RecurringInvoice::factory()
15
+        ->withLineItems()
16
+        ->create();
17
+
18
+    $recurringInvoice->refresh();
19
+
20
+    expect($recurringInvoice)
21
+        ->hasLineItems()->toBeTrue()
22
+        ->lineItems->count()->toBe(3)
23
+        ->subtotal->toBeGreaterThan(0)
24
+        ->total->toBeGreaterThan(0);
25
+});
26
+
13
 test('recurring invoice properly handles months with fewer days for monthly frequency', function () {
27
 test('recurring invoice properly handles months with fewer days for monthly frequency', function () {
14
     // Start from January 31st
28
     // Start from January 31st
15
     Carbon::setTestNow('2024-01-31');
29
     Carbon::setTestNow('2024-01-31');

+ 40
- 36
tests/Feature/Accounting/TransactionTest.php View File

9
 use App\Models\Accounting\Account;
9
 use App\Models\Accounting\Account;
10
 use App\Models\Accounting\Transaction;
10
 use App\Models\Accounting\Transaction;
11
 use App\Utilities\Currency\ConfigureCurrencies;
11
 use App\Utilities\Currency\ConfigureCurrencies;
12
+use App\Utilities\Currency\CurrencyConverter;
12
 use Filament\Tables\Actions\DeleteAction;
13
 use Filament\Tables\Actions\DeleteAction;
13
 use Filament\Tables\Actions\DeleteBulkAction;
14
 use Filament\Tables\Actions\DeleteBulkAction;
14
 use Filament\Tables\Actions\ReplicateAction;
15
 use Filament\Tables\Actions\ReplicateAction;
78
         ->create();
79
         ->create();
79
 
80
 
80
     expect($transaction)
81
     expect($transaction)
81
-        ->journalEntries->sumDebits()->getValue()->toEqual($amount)
82
-        ->journalEntries->sumCredits()->getValue()->toEqual($amount);
82
+        ->journalEntries->sumDebits()->getAmount()->toEqual($amount)
83
+        ->journalEntries->sumCredits()->getAmount()->toEqual($amount);
83
 })->with([
84
 })->with([
84
     ['asDeposit', 'forUncategorizedRevenue', 2000],
85
     ['asDeposit', 'forUncategorizedRevenue', 2000],
85
     ['asWithdrawal', 'forUncategorizedExpense', 500],
86
     ['asWithdrawal', 'forUncategorizedExpense', 500],
122
     $expectedUSDValue = 1500;
123
     $expectedUSDValue = 1500;
123
 
124
 
124
     expect($transaction)
125
     expect($transaction)
125
-        ->amount->toEqual('1,500.00')
126
+        ->amount->toBe(1500)
126
         ->journalEntries->count()->toBe(2)
127
         ->journalEntries->count()->toBe(2)
127
-        ->journalEntries->sumDebits()->getValue()->toEqual($expectedUSDValue)
128
-        ->journalEntries->sumCredits()->getValue()->toEqual($expectedUSDValue);
128
+        ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
129
+        ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
129
 });
130
 });
130
 
131
 
131
 it('handles multi-currency transfers correctly', function () {
132
 it('handles multi-currency transfers correctly', function () {
150
     expect($debitAccount->name)->toBe('Destination Bank Account') // Debit: Destination (USD) account
151
     expect($debitAccount->name)->toBe('Destination Bank Account') // Debit: Destination (USD) account
151
         ->and($creditAccount->is($foreignBankAccount))->toBeTrue(); // Credit: Foreign Bank Account (CAD) account
152
         ->and($creditAccount->is($foreignBankAccount))->toBeTrue(); // Credit: Foreign Bank Account (CAD) account
152
 
153
 
153
-    // The 1500 CAD is worth 1102.94 USD (1500 CAD / 1.36)
154
-    $expectedUSDValue = round(1500 / 1.36, 2);
154
+    // The 1500 CAD is worth approximately 1103 USD (1500 CAD / 1.36)
155
+    $expectedUSDValue = CurrencyConverter::convertBalance(1500, 'CAD', 'USD');
155
 
156
 
156
-    // Verify that the debit is 1102.94 USD and the credit is 1500 CAD converted to 1102.94 USD
157
-    // Transaction amount stays in source bank account currency (cast is applied)
157
+    // Verify that the debit and credit are converted to USD cents
158
+    // Transaction amount stays in source bank account currency
158
     expect($transaction)
159
     expect($transaction)
159
-        ->amount->toEqual('1,500.00')
160
+        ->amount->toBe(1500)
160
         ->journalEntries->count()->toBe(2)
161
         ->journalEntries->count()->toBe(2)
161
-        ->journalEntries->sumDebits()->getValue()->toEqual($expectedUSDValue)
162
-        ->journalEntries->sumCredits()->getValue()->toEqual($expectedUSDValue);
162
+        ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
163
+        ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
163
 });
164
 });
164
 
165
 
165
 it('handles multi-currency deposits correctly', function () {
166
 it('handles multi-currency deposits correctly', function () {
171
 
172
 
172
     ConfigureCurrencies::syncCurrencies();
173
     ConfigureCurrencies::syncCurrencies();
173
 
174
 
174
-    // Create a deposit of 1500 BHD to the foreign bank account
175
+    // Create a deposit of 1500 BHD (in fils - BHD subunits) to the foreign bank account
175
     /** @var Transaction $transaction */
176
     /** @var Transaction $transaction */
176
     $transaction = Transaction::factory()
177
     $transaction = Transaction::factory()
177
         ->forBankAccount($foreignBankAccount->bankAccount)
178
         ->forBankAccount($foreignBankAccount->bankAccount)
184
     expect($debitAccount->is($foreignBankAccount))->toBeTrue() // Debit: Foreign Bank Account (BHD) account
185
     expect($debitAccount->is($foreignBankAccount))->toBeTrue() // Debit: Foreign Bank Account (BHD) account
185
         ->and($creditAccount->name)->toBe('Uncategorized Income'); // Credit: Uncategorized Income (USD) account
186
         ->and($creditAccount->name)->toBe('Uncategorized Income'); // Credit: Uncategorized Income (USD) account
186
 
187
 
187
-    // Convert to USD using the rate 0.38 BHD per USD
188
-    $expectedUSDValue = round(1500 / 0.38, 2);
188
+    $expectedUSDValue = CurrencyConverter::convertBalance(1500, 'BHD', 'USD');
189
 
189
 
190
-    // Verify that the debit is 39473.68 USD and the credit is 1500 BHD converted to 39473.68 USD
190
+    // Verify that journal entries are converted to USD cents
191
     expect($transaction)
191
     expect($transaction)
192
-        ->amount->toEqual('1,500.000')
192
+        ->amount->toBe(1500)
193
         ->journalEntries->count()->toBe(2)
193
         ->journalEntries->count()->toBe(2)
194
-        ->journalEntries->sumDebits()->getValue()->toEqual($expectedUSDValue)
195
-        ->journalEntries->sumCredits()->getValue()->toEqual($expectedUSDValue);
194
+        ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
195
+        ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
196
 });
196
 });
197
 
197
 
198
 it('handles multi-currency withdrawals correctly', function () {
198
 it('handles multi-currency withdrawals correctly', function () {
216
     expect($debitAccount->name)->toBe('Uncategorized Expense')
216
     expect($debitAccount->name)->toBe('Uncategorized Expense')
217
         ->and($creditAccount->is($foreignBankAccount))->toBeTrue();
217
         ->and($creditAccount->is($foreignBankAccount))->toBeTrue();
218
 
218
 
219
-    $expectedUSDValue = round(1500 / 0.76, 2);
219
+    $expectedUSDValue = CurrencyConverter::convertBalance(1500, 'GBP', 'USD');
220
 
220
 
221
     expect($transaction)
221
     expect($transaction)
222
-        ->amount->toEqual('1,500.00')
222
+        ->amount->toBe(1500)
223
         ->journalEntries->count()->toBe(2)
223
         ->journalEntries->count()->toBe(2)
224
-        ->journalEntries->sumDebits()->getValue()->toEqual($expectedUSDValue)
225
-        ->journalEntries->sumCredits()->getValue()->toEqual($expectedUSDValue);
224
+        ->journalEntries->sumDebits()->getAmount()->toEqual($expectedUSDValue)
225
+        ->journalEntries->sumCredits()->getAmount()->toEqual($expectedUSDValue);
226
 });
226
 });
227
 
227
 
228
 it('can add an income or expense transaction', function (TransactionType $transactionType, string $actionName) {
228
 it('can add an income or expense transaction', function (TransactionType $transactionType, string $actionName) {
249
 
249
 
250
     expect($transaction)
250
     expect($transaction)
251
         ->not->toBeNull()
251
         ->not->toBeNull()
252
-        ->amount->toEqual('500.00')
252
+        ->amount->toBe(50000) // 500.00 in cents
253
         ->type->toBe($transactionType)
253
         ->type->toBe($transactionType)
254
         ->bankAccount->is($defaultBankAccount)->toBeTrue()
254
         ->bankAccount->is($defaultBankAccount)->toBeTrue()
255
         ->account->is($defaultAccount)->toBeTrue()
255
         ->account->is($defaultAccount)->toBeTrue()
284
 
284
 
285
     expect($transaction)
285
     expect($transaction)
286
         ->not->toBeNull()
286
         ->not->toBeNull()
287
-        ->amount->toEqual('1,500.00')
287
+        ->amount->toBe(150000) // 1,500.00 in cents
288
         ->type->toBe(TransactionType::Transfer)
288
         ->type->toBe(TransactionType::Transfer)
289
         ->bankAccount->is($sourceBankAccount)->toBeTrue()
289
         ->bankAccount->is($sourceBankAccount)->toBeTrue()
290
         ->account->is($destinationBankAccount)->toBeTrue()
290
         ->account->is($destinationBankAccount)->toBeTrue()
323
 
323
 
324
     expect($transaction)
324
     expect($transaction)
325
         ->not->toBeNull()
325
         ->not->toBeNull()
326
-        ->amount->toEqual('1,000.00')
326
+        ->amount->toBe(100000) // 1,000.00 in cents
327
         ->type->isJournal()->toBeTrue()
327
         ->type->isJournal()->toBeTrue()
328
         ->bankAccount->toBeNull()
328
         ->bankAccount->toBeNull()
329
         ->account->toBeNull()
329
         ->account->toBeNull()
330
         ->journalEntries->count()->toBe(2)
330
         ->journalEntries->count()->toBe(2)
331
-        ->journalEntries->sumDebits()->getValue()->toEqual(1000)
332
-        ->journalEntries->sumCredits()->getValue()->toEqual(1000)
331
+        ->journalEntries->sumDebits()->getAmount()->toEqual(100000)
332
+        ->journalEntries->sumCredits()->getAmount()->toEqual(100000)
333
         ->and($debitAccount->is($defaultDebitAccount))->toBeTrue()
333
         ->and($debitAccount->is($defaultDebitAccount))->toBeTrue()
334
         ->and($creditAccount->is($defaultCreditAccount))->toBeTrue();
334
         ->and($creditAccount->is($defaultCreditAccount))->toBeTrue();
335
 });
335
 });
345
 
345
 
346
     $newDescription = 'Updated Description';
346
     $newDescription = 'Updated Description';
347
 
347
 
348
+    $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($transaction->amount);
349
+
348
     livewire(ListTransactions::class)
350
     livewire(ListTransactions::class)
349
         ->mountTableAction(EditTransactionAction::class, $transaction)
351
         ->mountTableAction(EditTransactionAction::class, $transaction)
350
         ->assertTableActionDataSet([
352
         ->assertTableActionDataSet([
351
             'type' => $transactionType->value,
353
             'type' => $transactionType->value,
352
             'description' => $transaction->description,
354
             'description' => $transaction->description,
353
-            'amount' => $transaction->amount,
355
+            'amount' => $formattedAmount,
354
         ])
356
         ])
355
         ->setTableActionData([
357
         ->setTableActionData([
356
             'description' => $newDescription,
358
             'description' => $newDescription,
362
     $transaction->refresh();
364
     $transaction->refresh();
363
 
365
 
364
     expect($transaction->description)->toBe($newDescription)
366
     expect($transaction->description)->toBe($newDescription)
365
-        ->and($transaction->amount)->toEqual('1,500.00');
367
+        ->and($transaction->amount)->toBe(150000); // 1,500.00 in cents
366
 })->with([
368
 })->with([
367
     TransactionType::Deposit,
369
     TransactionType::Deposit,
368
     TransactionType::Withdrawal,
370
     TransactionType::Withdrawal,
377
 
379
 
378
     $newDescription = 'Updated Transfer Description';
380
     $newDescription = 'Updated Transfer Description';
379
 
381
 
382
+    $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($transaction->amount);
383
+
380
     livewire(ListTransactions::class)
384
     livewire(ListTransactions::class)
381
         ->mountTableAction(EditTransactionAction::class, $transaction)
385
         ->mountTableAction(EditTransactionAction::class, $transaction)
382
         ->assertTableActionDataSet([
386
         ->assertTableActionDataSet([
383
             'type' => TransactionType::Transfer->value,
387
             'type' => TransactionType::Transfer->value,
384
             'description' => $transaction->description,
388
             'description' => $transaction->description,
385
-            'amount' => $transaction->amount,
389
+            'amount' => $formattedAmount,
386
         ])
390
         ])
387
         ->setTableActionData([
391
         ->setTableActionData([
388
             'description' => $newDescription,
392
             'description' => $newDescription,
394
     $transaction->refresh();
398
     $transaction->refresh();
395
 
399
 
396
     expect($transaction->description)->toBe($newDescription)
400
     expect($transaction->description)->toBe($newDescription)
397
-        ->and($transaction->amount)->toEqual('2,000.00');
401
+        ->and($transaction->amount)->toBe(200000); // 2,000.00 in cents
398
 });
402
 });
399
 
403
 
400
 it('replicates a transaction with correct journal entries', function () {
404
 it('replicates a transaction with correct journal entries', function () {
417
 
421
 
418
     expect($replicatedTransaction)
422
     expect($replicatedTransaction)
419
         ->journalEntries->count()->toBe(2)
423
         ->journalEntries->count()->toBe(2)
420
-        ->journalEntries->sumDebits()->getValue()->toEqual(1000)
421
-        ->journalEntries->sumCredits()->getValue()->toEqual(1000)
424
+        ->journalEntries->sumDebits()->getAmount()->toEqual(1000)
425
+        ->journalEntries->sumCredits()->getAmount()->toEqual(1000)
422
         ->description->toBe('(Copy of) ' . $originalTransaction->description)
426
         ->description->toBe('(Copy of) ' . $originalTransaction->description)
423
         ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
427
         ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
424
         ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
428
         ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
451
 
455
 
452
         expect($replicatedTransaction)
456
         expect($replicatedTransaction)
453
             ->journalEntries->count()->toBe(2)
457
             ->journalEntries->count()->toBe(2)
454
-            ->journalEntries->sumDebits()->getValue()->toEqual(1000)
455
-            ->journalEntries->sumCredits()->getValue()->toEqual(1000)
458
+            ->journalEntries->sumDebits()->getAmount()->toEqual(1000)
459
+            ->journalEntries->sumCredits()->getAmount()->toEqual(1000)
456
             ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
460
             ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
457
             ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
461
             ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
458
     });
462
     });

Loading…
Cancel
Save