瀏覽代碼

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

Refactor/multicurrency handling
3.x
Andrew Wallo 4 月之前
父節點
當前提交
cec76901fd
No account linked to committer's email address
共有 48 個文件被更改,包括 1228 次插入1170 次删除
  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 查看文件

@@ -1,44 +0,0 @@
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 查看文件

@@ -1,39 +0,0 @@
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 查看文件

@@ -2,23 +2,15 @@
2 2
 
3 3
 namespace App\Casts;
4 4
 
5
-use App\Utilities\Currency\CurrencyAccessor;
6
-use App\Utilities\Currency\CurrencyConverter;
7 5
 use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
8 6
 use Illuminate\Database\Eloquent\Model;
9 7
 use UnexpectedValueException;
10 8
 
11 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,14 +18,6 @@ class MoneyCast implements CastsAttributes
26 18
      */
27 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 查看文件

@@ -16,11 +16,11 @@ class RateCast implements CastsAttributes
16 16
             return '0';
17 17
         }
18 18
 
19
-        $currency_code = $this->getDefaultCurrencyCode();
19
+        $currencyCode = $attributes['currency_code'] ?? $this->getDefaultCurrencyCode();
20 20
         $computation = AdjustmentComputation::parse($attributes['computation'] ?? $attributes['discount_computation'] ?? null);
21 21
 
22 22
         if ($computation?->isFixed()) {
23
-            return money($value, $currency_code)->formatSimple();
23
+            return money($value, $currencyCode)->formatSimple();
24 24
         }
25 25
 
26 26
         return RateCalculator::formatScaledRate($value);
@@ -38,10 +38,10 @@ class RateCast implements CastsAttributes
38 38
 
39 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 43
         if ($computation?->isFixed()) {
44
-            return money($value, $currency_code, true)->getAmount();
44
+            return money($value, $currencyCode, true)->getAmount();
45 45
         }
46 46
 
47 47
         return RateCalculator::parseLocalizedRate($value);

+ 0
- 91
app/Casts/TransactionAmountCast.php 查看文件

@@ -1,91 +0,0 @@
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 查看文件

@@ -1,14 +0,0 @@
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 查看文件

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

+ 1
- 24
app/Concerns/HasTransactionAction.php 查看文件

@@ -9,7 +9,6 @@ use App\Models\Accounting\JournalEntry;
9 9
 use App\Models\Accounting\Transaction;
10 10
 use App\Models\Banking\BankAccount;
11 11
 use App\Utilities\Currency\CurrencyAccessor;
12
-use App\Utilities\Currency\CurrencyConverter;
13 12
 use Awcodes\TableRepeater\Header;
14 13
 use Closure;
15 14
 use Filament\Forms;
@@ -91,17 +90,6 @@ trait HasTransactionAction
91 90
                     ->options(fn (?Transaction $transaction) => Transaction::getBankAccountOptions(currentBankAccountId: $transaction?->bank_account_id))
92 91
                     ->live()
93 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 93
                     ->required(),
106 94
                 Forms\Components\Select::make('type')
107 95
                     ->label('Type')
@@ -144,17 +132,6 @@ trait HasTransactionAction
144 132
                     ->options(fn (Forms\Get $get, ?Transaction $transaction) => Transaction::getBankAccountOptions(excludedAccountId: $get('account_id'), currentBankAccountId: $transaction?->bank_account_id))
145 133
                     ->live()
146 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 135
                     ->required(),
159 136
                 Forms\Components\Select::make('type')
160 137
                     ->label('Type')
@@ -350,7 +327,7 @@ trait HasTransactionAction
350 327
             Forms\Components\TextInput::make('amount')
351 328
                 ->label('Amount')
352 329
                 ->live()
353
-                ->mask(moneyMask(CurrencyAccessor::getDefaultCurrency()))
330
+                ->money()
354 331
                 ->afterStateUpdated(function (Forms\Get $get, Forms\Set $set, ?string $state, ?string $old) {
355 332
                     $this->updateJournalEntryAmount(JournalEntryType::parse($get('type')), $state, $old);
356 333
                 })

+ 4
- 4
app/Concerns/ManagesLineItems.php 查看文件

@@ -97,10 +97,10 @@ trait ManagesLineItems
97 97
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
98 98
 
99 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 查看文件

@@ -94,9 +94,9 @@ readonly class DocumentDTO
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 102
     public function getFontHtml(): Htmlable

+ 6
- 6
app/DTO/DocumentPreviewDTO.php 查看文件

@@ -17,7 +17,7 @@ readonly class DocumentPreviewDTO extends DocumentDTO
17 17
         $paymentTerms = PaymentTerms::parse($data['payment_terms']) ?? $settings->payment_terms;
18 18
 
19 19
         $amountDue = $settings->type !== DocumentType::Estimate ?
20
-            self::formatToMoney('950', null) :
20
+            self::formatToMoney(95000, null) :
21 21
             null;
22 22
 
23 23
         return new self(
@@ -31,11 +31,11 @@ readonly class DocumentPreviewDTO extends DocumentDTO
31 31
             date: $company->locale->date_format->getLabel(),
32 32
             dueDate: $paymentTerms->getDueDate($company->locale->date_format->value),
33 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 39
             company: CompanyDTO::fromModel($company),
40 40
             client: ClientPreviewDTO::fake(),
41 41
             lineItems: LineItemPreviewDTO::fakeItems(),

+ 5
- 1
app/DTO/LineItemDTO.php 查看文件

@@ -26,8 +26,12 @@ readonly class LineItemDTO
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 35
         return CurrencyConverter::formatToMoney($value, $currencyCode);
32 36
     }
33 37
 }

+ 12
- 12
app/DTO/LineItemPreviewDTO.php 查看文件

@@ -8,25 +8,25 @@ readonly class LineItemPreviewDTO extends LineItemDTO
8 8
     {
9 9
         return [
10 10
             new self(
11
-                name: 'Item 1',
12
-                description: 'Sample item description',
11
+                name: 'Professional Services',
12
+                description: 'Consulting and strategic planning',
13 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 17
             new self(
18
-                name: 'Item 2',
19
-                description: 'Another sample item description',
18
+                name: 'Software License',
19
+                description: 'Annual subscription and support',
20 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 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 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 查看文件

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

+ 2
- 3
app/Filament/Company/Resources/Common/OfferingResource.php 查看文件

@@ -12,7 +12,6 @@ use App\Filament\Forms\Components\Banner;
12 12
 use App\Filament\Forms\Components\CreateAccountSelect;
13 13
 use App\Filament\Forms\Components\CreateAdjustmentSelect;
14 14
 use App\Models\Common\Offering;
15
-use App\Utilities\Currency\CurrencyAccessor;
16 15
 use Filament\Forms;
17 16
 use Filament\Forms\Form;
18 17
 use Filament\Resources\Resource;
@@ -103,7 +102,7 @@ class OfferingResource extends Resource
103 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 107
         return Forms\Components\Section::make('Sale Information')
109 108
             ->schema([
@@ -178,7 +177,7 @@ class OfferingResource extends Resource
178 177
                 Tables\Columns\TextColumn::make('type')
179 178
                     ->searchable(),
180 179
                 Tables\Columns\TextColumn::make('price')
181
-                    ->currency(CurrencyAccessor::getDefaultCurrency(), true)
180
+                    ->currency()
182 181
                     ->sortable()
183 182
                     ->description(function (Offering $record) {
184 183
                         $adjustments = $record->adjustments()

+ 18
- 18
app/Filament/Company/Resources/Purchases/BillResource.php 查看文件

@@ -152,7 +152,7 @@ class BillResource extends Resource
152 152
                                         $date = $get('date');
153 153
                                         $paymentTerms = $get('payment_terms');
154 154
 
155
-                                        if (! $date || $paymentTerms === 'custom') {
155
+                                        if (! $date || $paymentTerms === 'custom' || ! $paymentTerms) {
156 156
                                             return;
157 157
                                         }
158 158
 
@@ -261,7 +261,7 @@ class BillResource extends Resource
261 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 266
                                             $set('description', $offeringRecord->description);
267 267
                                             $set('unit_price', $unitPrice);
@@ -282,11 +282,9 @@ class BillResource extends Resource
282 282
                                     ->maxValue(9999999999.99)
283 283
                                     ->default(1),
284 284
                                 Forms\Components\TextInput::make('unit_price')
285
-                                    ->label('Price')
286 285
                                     ->hiddenLabel()
287
-                                    ->numeric()
286
+                                    ->money(useAffix: false)
288 287
                                     ->live()
289
-                                    ->maxValue(9999999999.99)
290 288
                                     ->default(0),
291 289
                                 Forms\Components\Group::make([
292 290
                                     CreateAdjustmentSelect::make('purchaseTaxes')
@@ -327,7 +325,9 @@ class BillResource extends Resource
327 325
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
328 326
                                     ->content(function (Forms\Get $get) {
329 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 331
                                         $purchaseTaxes = $get('purchaseTaxes') ?? [];
332 332
                                         $purchaseDiscounts = $get('purchaseDiscounts') ?? [];
333 333
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
@@ -466,12 +466,12 @@ class BillResource extends Resource
466 466
                                     ->live(onBlur: true)
467 467
                                     ->helperText(function (Bill $record, $state) {
468 468
                                         $billCurrency = $record->currency_code;
469
-                                        if (! CurrencyConverter::isValidAmount($state, $billCurrency)) {
469
+                                        if (! CurrencyConverter::isValidAmount($state, 'USD')) {
470 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 476
                                         if ($amount <= 0) {
477 477
                                             return 'Please enter a valid positive amount';
@@ -486,8 +486,8 @@ class BillResource extends Resource
486 486
                                         };
487 487
                                     })
488 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 491
                                                 $fail('Please enter a valid amount');
492 492
                                             }
493 493
                                         },
@@ -584,11 +584,11 @@ class BillResource extends Resource
584 584
                             }
585 585
                         })
586 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 589
                             $form->fill([
590 590
                                 'posted_at' => now(),
591
-                                'amount' => CurrencyConverter::convertCentsToFormatSimple($totalAmountDue),
591
+                                'amount' => $totalAmountDue,
592 592
                             ]);
593 593
                         })
594 594
                         ->form([
@@ -624,8 +624,8 @@ class BillResource extends Resource
624 624
                                 ->label('Notes'),
625 625
                         ])
626 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 630
                             if ($totalPaymentAmount > $totalAmountDue) {
631 631
                                 $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
@@ -641,18 +641,18 @@ class BillResource extends Resource
641 641
                             }
642 642
                         })
643 643
                         ->action(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
644
-                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
644
+                            $totalPaymentAmount = $data['amount'] ?? 0;
645 645
                             $remainingAmount = $totalPaymentAmount;
646 646
 
647 647
                             $records->each(function (Bill $record) use (&$remainingAmount, $data) {
648
-                                $amountDue = $record->getRawOriginal('amount_due');
648
+                                $amountDue = $record->amount_due;
649 649
 
650 650
                                 if ($amountDue <= 0 || $remainingAmount <= 0) {
651 651
                                     return;
652 652
                                 }
653 653
 
654 654
                                 $paymentAmount = min($amountDue, $remainingAmount);
655
-                                $data['amount'] = CurrencyConverter::convertCentsToFormatSimple($paymentAmount);
655
+                                $data['amount'] = $paymentAmount;
656 656
 
657 657
                                 $record->recordPayment($data);
658 658
                                 $remainingAmount -= $paymentAmount;

+ 1
- 5
app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php 查看文件

@@ -45,11 +45,7 @@ class EditBill extends EditRecord
45 45
 
46 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 50
         return $record;
55 51
     }

+ 9
- 12
app/Filament/Company/Resources/Purchases/BillResource/RelationManagers/PaymentsRelationManager.php 查看文件

@@ -79,19 +79,19 @@ class PaymentsRelationManager extends RelationManager
79 79
 
80 80
                                 $billCurrency = $ownerRecord->currency_code;
81 81
 
82
-                                if (! CurrencyConverter::isValidAmount($state, $billCurrency)) {
82
+                                if (! CurrencyConverter::isValidAmount($state, 'USD')) {
83 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 90
                                 if ($amount <= 0) {
91 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 96
                                 $newAmountDue = $amountDue - $amount + $currentPaymentAmount;
97 97
 
@@ -102,11 +102,8 @@ class PaymentsRelationManager extends RelationManager
102 102
                                 };
103 103
                             })
104 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 107
                                         $fail('Please enter a valid amount');
111 108
                                     }
112 109
                                 },
@@ -122,7 +119,7 @@ class PaymentsRelationManager extends RelationManager
122 119
                         $bill = $livewire->getOwnerRecord();
123 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 123
                             return null;
127 124
                         }
128 125
 
@@ -139,7 +136,7 @@ class PaymentsRelationManager extends RelationManager
139 136
                         }
140 137
 
141 138
                         // Convert amount from bill currency to bank currency
142
-                        $amountInBillCurrencyCents = CurrencyConverter::convertToCents($amount, $billCurrency);
139
+                        $amountInBillCurrencyCents = CurrencyConverter::convertToCents($amount, 'USD');
143 140
                         $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
144 141
                             $amountInBillCurrencyCents,
145 142
                             $billCurrency,
@@ -210,7 +207,7 @@ class PaymentsRelationManager extends RelationManager
210 207
                         }
211 208
                     )
212 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 212
             ->filters([
216 213
                 //

+ 5
- 4
app/Filament/Company/Resources/Sales/EstimateResource.php 查看文件

@@ -259,7 +259,7 @@ class EstimateResource extends Resource
259 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 264
                                             $set('description', $offeringRecord->description);
265 265
                                             $set('unit_price', $unitPrice);
@@ -281,9 +281,8 @@ class EstimateResource extends Resource
281 281
                                     ->default(1),
282 282
                                 Forms\Components\TextInput::make('unit_price')
283 283
                                     ->hiddenLabel()
284
-                                    ->numeric()
284
+                                    ->money(useAffix: false)
285 285
                                     ->live()
286
-                                    ->maxValue(9999999999.99)
287 286
                                     ->default(0),
288 287
                                 Forms\Components\Group::make([
289 288
                                     CreateAdjustmentSelect::make('salesTaxes')
@@ -324,7 +323,9 @@ class EstimateResource extends Resource
324 323
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
325 324
                                     ->content(function (Forms\Get $get) {
326 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 329
                                         $salesTaxes = $get('salesTaxes') ?? [];
329 330
                                         $salesDiscounts = $get('salesDiscounts') ?? [];
330 331
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();

+ 17
- 16
app/Filament/Company/Resources/Sales/InvoiceResource.php 查看文件

@@ -163,7 +163,7 @@ class InvoiceResource extends Resource
163 163
                                         $invoiceDate = $get('date');
164 164
                                         $paymentTerms = $get('payment_terms');
165 165
 
166
-                                        if (! $invoiceDate || $paymentTerms === 'custom') {
166
+                                        if (! $invoiceDate || $paymentTerms === 'custom' || ! $paymentTerms) {
167 167
                                             return;
168 168
                                         }
169 169
 
@@ -272,7 +272,7 @@ class InvoiceResource extends Resource
272 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 277
                                             $set('description', $offeringRecord->description);
278 278
                                             $set('unit_price', $unitPrice);
@@ -294,9 +294,8 @@ class InvoiceResource extends Resource
294 294
                                     ->default(1),
295 295
                                 Forms\Components\TextInput::make('unit_price')
296 296
                                     ->hiddenLabel()
297
-                                    ->numeric()
297
+                                    ->money(useAffix: false)
298 298
                                     ->live()
299
-                                    ->maxValue(9999999999.99)
300 299
                                     ->default(0),
301 300
                                 Forms\Components\Group::make([
302 301
                                     CreateAdjustmentSelect::make('salesTaxes')
@@ -337,7 +336,9 @@ class InvoiceResource extends Resource
337 336
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
338 337
                                     ->content(function (Forms\Get $get) {
339 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 342
                                         $salesTaxes = $get('salesTaxes') ?? [];
342 343
                                         $salesDiscounts = $get('salesDiscounts') ?? [];
343 344
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
@@ -518,13 +519,13 @@ class InvoiceResource extends Resource
518 519
                                     ->live(onBlur: true)
519 520
                                     ->helperText(function (Invoice $record, $state) {
520 521
                                         $invoiceCurrency = $record->currency_code;
521
-                                        if (! CurrencyConverter::isValidAmount($state, $invoiceCurrency)) {
522
+                                        if (! CurrencyConverter::isValidAmount($state, 'USD')) {
522 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 530
                                         if ($amount <= 0) {
530 531
                                             return 'Please enter a valid positive amount';
@@ -543,8 +544,8 @@ class InvoiceResource extends Resource
543 544
                                         };
544 545
                                     })
545 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 549
                                                 $fail('Please enter a valid amount');
549 550
                                             }
550 551
                                         },
@@ -697,7 +698,7 @@ class InvoiceResource extends Resource
697 698
                             }
698 699
                         })
699 700
                         ->mountUsing(function (DocumentCollection $records, Form $form) {
700
-                            $totalAmountDue = $records->sumMoneyFormattedSimple('amount_due');
701
+                            $totalAmountDue = $records->sum('amount_due');
701 702
 
702 703
                             $form->fill([
703 704
                                 'posted_at' => now(),
@@ -737,8 +738,8 @@ class InvoiceResource extends Resource
737 738
                                 ->label('Notes'),
738 739
                         ])
739 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 744
                             if ($totalPaymentAmount > $totalAmountDue) {
744 745
                                 $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
@@ -754,12 +755,12 @@ class InvoiceResource extends Resource
754 755
                             }
755 756
                         })
756 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 760
                             $remainingAmount = $totalPaymentAmount;
760 761
 
761 762
                             $records->each(function (Invoice $record) use (&$remainingAmount, $data) {
762
-                                $amountDue = $record->getRawOriginal('amount_due');
763
+                                $amountDue = $record->amount_due;
763 764
 
764 765
                                 if ($amountDue <= 0 || $remainingAmount <= 0) {
765 766
                                     return;
@@ -767,7 +768,7 @@ class InvoiceResource extends Resource
767 768
 
768 769
                                 $paymentAmount = min($amountDue, $remainingAmount);
769 770
 
770
-                                $data['amount'] = CurrencyConverter::convertCentsToFormatSimple($paymentAmount);
771
+                                $data['amount'] = $paymentAmount;
771 772
 
772 773
                                 $record->recordPayment($data);
773 774
 

+ 7
- 7
app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php 查看文件

@@ -88,19 +88,19 @@ class PaymentsRelationManager extends RelationManager
88 88
 
89 89
                                 $invoiceCurrency = $ownerRecord->currency_code;
90 90
 
91
-                                if (! CurrencyConverter::isValidAmount($state, $invoiceCurrency)) {
91
+                                if (! CurrencyConverter::isValidAmount($state, 'USD')) {
92 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 99
                                 if ($amount <= 0) {
100 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 105
                                 if ($ownerRecord->status === InvoiceStatus::Overpaid) {
106 106
                                     $newAmountDue = $amountDue + $amount - $currentPaymentAmount;
@@ -135,7 +135,7 @@ class PaymentsRelationManager extends RelationManager
135 135
                         $invoice = $livewire->getOwnerRecord();
136 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 139
                             return null;
140 140
                         }
141 141
 
@@ -152,7 +152,7 @@ class PaymentsRelationManager extends RelationManager
152 152
                         }
153 153
 
154 154
                         // Convert amount from invoice currency to bank currency
155
-                        $amountInInvoiceCurrencyCents = CurrencyConverter::convertToCents($amount, $invoiceCurrency);
155
+                        $amountInInvoiceCurrencyCents = CurrencyConverter::convertToCents($amount, 'USD');
156 156
                         $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
157 157
                             $amountInInvoiceCurrencyCents,
158 158
                             $invoiceCurrency,
@@ -223,7 +223,7 @@ class PaymentsRelationManager extends RelationManager
223 223
                         }
224 224
                     )
225 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 228
             ->filters([
229 229
                 //

+ 5
- 4
app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php 查看文件

@@ -185,7 +185,7 @@ class RecurringInvoiceResource extends Resource
185 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 190
                                             $set('description', $offeringRecord->description);
191 191
                                             $set('unit_price', $unitPrice);
@@ -207,9 +207,8 @@ class RecurringInvoiceResource extends Resource
207 207
                                     ->default(1),
208 208
                                 Forms\Components\TextInput::make('unit_price')
209 209
                                     ->hiddenLabel()
210
-                                    ->numeric()
210
+                                    ->money(useAffix: false)
211 211
                                     ->live()
212
-                                    ->maxValue(9999999999.99)
213 212
                                     ->default(0),
214 213
                                 Forms\Components\Group::make([
215 214
                                     CreateAdjustmentSelect::make('salesTaxes')
@@ -250,7 +249,9 @@ class RecurringInvoiceResource extends Resource
250 249
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
251 250
                                     ->content(function (Forms\Get $get) {
252 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 255
                                         $salesTaxes = $get('salesTaxes') ?? [];
255 256
                                         $salesDiscounts = $get('salesDiscounts') ?? [];
256 257
                                         $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();

+ 96
- 71
app/Models/Accounting/Bill.php 查看文件

@@ -2,7 +2,6 @@
2 2
 
3 3
 namespace App\Models\Accounting;
4 4
 
5
-use App\Casts\MoneyCast;
6 5
 use App\Casts\RateCast;
7 6
 use App\Collections\Accounting\DocumentCollection;
8 7
 use App\Enums\Accounting\AdjustmentComputation;
@@ -24,7 +23,6 @@ use Filament\Actions\ReplicateAction;
24 23
 use Illuminate\Database\Eloquent\Attributes\CollectedBy;
25 24
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
26 25
 use Illuminate\Database\Eloquent\Builder;
27
-use Illuminate\Database\Eloquent\Casts\Attribute;
28 26
 use Illuminate\Database\Eloquent\Model;
29 27
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
30 28
 use Illuminate\Database\Eloquent\Relations\MorphMany;
@@ -68,12 +66,6 @@ class Bill extends Document
68 66
         'discount_method' => DocumentDiscountMethod::class,
69 67
         'discount_computation' => AdjustmentComputation::class,
70 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 71
     public function vendor(): BelongsTo
@@ -137,11 +129,9 @@ class Bill extends Document
137 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 137
     public function wasInitialized(): bool
@@ -228,7 +218,7 @@ class Bill extends Document
228 218
         $requiresConversion = $billCurrency !== $bankAccountCurrency;
229 219
 
230 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 223
         if ($requiresConversion) {
234 224
             $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
@@ -236,12 +226,9 @@ class Bill extends Document
236 226
                 $billCurrency,
237 227
                 $bankAccountCurrency
238 228
             );
239
-            $formattedAmountForBankCurrency = CurrencyConverter::convertCentsToFormatSimple(
240
-                $amountInBankCurrencyCents,
241
-                $bankAccountCurrency
242
-            );
229
+            $formattedAmountForBankCurrency = $amountInBankCurrencyCents;
243 230
         } else {
244
-            $formattedAmountForBankCurrency = $data['amount']; // Already in simple format
231
+            $formattedAmountForBankCurrency = $amountInBillCurrencyCents;
245 232
         }
246 233
 
247 234
         // Create transaction with converted amount
@@ -266,98 +253,138 @@ class Bill extends Document
266 253
     public function createInitialTransaction(?Carbon $postedAt = null): void
267 254
     {
268 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 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 262
             'type' => JournalEntryType::Credit,
285 263
             'account_id' => Account::getAccountsPayableAccount($this->company_id)->id,
286
-            'amount' => $total,
264
+            'amount_in_bill_currency' => $totalInBillCurrency,
287 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 272
         foreach ($this->lineItems as $index => $lineItem) {
295 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 277
                 'type' => JournalEntryType::Debit,
302 278
                 'account_id' => $lineItem->offering->expense_account_id,
303
-                'amount' => $lineItemSubtotal,
279
+                'amount_in_bill_currency' => $lineItemSubtotalInBillCurrency,
304 280
                 'description' => $lineItemDescription,
305
-            ]);
281
+            ];
306 282
 
307 283
             foreach ($lineItem->adjustments as $adjustment) {
308
-                $adjustmentAmount = $this->formatAmountToDefaultCurrency($lineItem->calculateAdjustmentTotalAmount($adjustment));
284
+                $adjustmentAmountInBillCurrency = $lineItem->calculateAdjustmentTotalAmount($adjustment);
309 285
 
310 286
                 if ($adjustment->isNonRecoverablePurchaseTax()) {
311
-                    $transaction->journalEntries()->create([
312
-                        'company_id' => $this->company_id,
287
+                    $journalEntryData[] = [
313 288
                         'type' => JournalEntryType::Debit,
314 289
                         'account_id' => $lineItem->offering->expense_account_id,
315
-                        'amount' => $adjustmentAmount,
290
+                        'amount_in_bill_currency' => $adjustmentAmountInBillCurrency,
316 291
                         'description' => "{$lineItemDescription} ({$adjustment->name})",
317
-                    ]);
292
+                    ];
318 293
                 } elseif ($adjustment->account_id) {
319
-                    $transaction->journalEntries()->create([
320
-                        'company_id' => $this->company_id,
294
+                    $journalEntryData[] = [
321 295
                         'type' => $adjustment->category->isDiscount() ? JournalEntryType::Credit : JournalEntryType::Debit,
322 296
                         'account_id' => $adjustment->account_id,
323
-                        'amount' => $adjustmentAmount,
297
+                        'amount_in_bill_currency' => $adjustmentAmountInBillCurrency,
324 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 307
                 if ($index === $this->lineItems->count() - 1) {
333
-                    $lineItemDiscount = $remainingDiscountCents;
308
+                    $lineItemDiscountInBillCurrency = $remainingDiscountInBillCurrency;
334 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 318
                         'type' => JournalEntryType::Credit,
345 319
                         'account_id' => Account::getPurchaseDiscountAccount($this->company_id)->id,
346
-                        'amount' => CurrencyConverter::convertCentsToFormatSimple($lineItemDiscount),
320
+                        'amount_in_bill_currency' => $lineItemDiscountInBillCurrency,
347 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 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 389
         $this->createInitialTransaction();
363 390
     }
@@ -374,11 +401,9 @@ class Bill extends Document
374 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 409
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction

+ 0
- 9
app/Models/Accounting/DocumentLineItem.php 查看文件

@@ -3,7 +3,6 @@
3 3
 namespace App\Models\Accounting;
4 4
 
5 5
 use Akaunting\Money\Money;
6
-use App\Casts\DocumentMoneyCast;
7 6
 use App\Concerns\Blamable;
8 7
 use App\Concerns\CompanyOwned;
9 8
 use App\Enums\Accounting\AdjustmentCategory;
@@ -41,14 +40,6 @@ class DocumentLineItem extends Model
41 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 43
     public function documentable(): MorphTo
53 44
     {
54 45
         return $this->morphTo();

+ 3
- 10
app/Models/Accounting/Estimate.php 查看文件

@@ -2,7 +2,6 @@
2 2
 
3 3
 namespace App\Models\Accounting;
4 4
 
5
-use App\Casts\MoneyCast;
6 5
 use App\Casts\RateCast;
7 6
 use App\Collections\Accounting\DocumentCollection;
8 7
 use App\Enums\Accounting\AdjustmentComputation;
@@ -78,10 +77,6 @@ class Estimate extends Document
78 77
         'discount_method' => DocumentDiscountMethod::class,
79 78
         'discount_computation' => AdjustmentComputation::class,
80 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 82
     protected $appends = [
@@ -135,11 +130,9 @@ class Estimate extends Document
135 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 138
     public function isDraft(): bool
@@ -471,7 +464,7 @@ class Estimate extends Document
471 464
             'currency_code' => $this->currency_code,
472 465
             'discount_method' => $this->discount_method,
473 466
             'discount_computation' => $this->discount_computation,
474
-            'discount_rate' => $this->discount_rate,
467
+            'discount_rate' => $this->getRawOriginal('discount_rate'),
475 468
             'subtotal' => $this->subtotal,
476 469
             'tax_total' => $this->tax_total,
477 470
             'discount_total' => $this->discount_total,

+ 93
- 65
app/Models/Accounting/Invoice.php 查看文件

@@ -2,7 +2,6 @@
2 2
 
3 3
 namespace App\Models\Accounting;
4 4
 
5
-use App\Casts\MoneyCast;
6 5
 use App\Casts\RateCast;
7 6
 use App\Collections\Accounting\DocumentCollection;
8 7
 use App\Enums\Accounting\AdjustmentComputation;
@@ -87,12 +86,6 @@ class Invoice extends Document
87 86
         'discount_method' => DocumentDiscountMethod::class,
88 87
         'discount_computation' => AdjustmentComputation::class,
89 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 91
     protected $appends = [
@@ -205,11 +198,9 @@ class Invoice extends Document
205 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 206
     public function isDraft(): bool
@@ -332,7 +323,7 @@ class Invoice extends Document
332 323
         $requiresConversion = $invoiceCurrency !== $bankAccountCurrency;
333 324
 
334 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 328
         if ($requiresConversion) {
338 329
             $amountInBankCurrencyCents = CurrencyConverter::convertBalance(
@@ -340,12 +331,9 @@ class Invoice extends Document
340 331
                 $invoiceCurrency,
341 332
                 $bankAccountCurrency
342 333
             );
343
-            $formattedAmountForBankCurrency = CurrencyConverter::convertCentsToFormatSimple(
344
-                $amountInBankCurrencyCents,
345
-                $bankAccountCurrency
346
-            );
334
+            $formattedAmountForBankCurrency = $amountInBankCurrencyCents;
347 335
         } else {
348
-            $formattedAmountForBankCurrency = $data['amount']; // Already in simple format
336
+            $formattedAmountForBankCurrency = $amountInInvoiceCurrencyCents;
349 337
         }
350 338
 
351 339
         // Create transaction
@@ -385,87 +373,129 @@ class Invoice extends Document
385 373
 
386 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 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 382
             'type' => JournalEntryType::Debit,
403 383
             'account_id' => Account::getAccountsReceivableAccount($this->company_id)->id,
404
-            'amount' => $total,
384
+            'amount_in_invoice_currency' => $totalInInvoiceCurrency,
405 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 392
         foreach ($this->lineItems as $index => $lineItem) {
413 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 397
                 'type' => JournalEntryType::Credit,
420 398
                 'account_id' => $lineItem->offering->income_account_id,
421
-                'amount' => $lineItemSubtotal,
399
+                'amount_in_invoice_currency' => $lineItemSubtotalInInvoiceCurrency,
422 400
                 'description' => $lineItemDescription,
423
-            ]);
401
+            ];
424 402
 
425 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 407
                     'type' => $adjustment->category->isDiscount() ? JournalEntryType::Debit : JournalEntryType::Credit,
431 408
                     'account_id' => $adjustment->account_id,
432
-                    'amount' => $adjustmentAmount,
409
+                    'amount_in_invoice_currency' => $adjustmentAmountInInvoiceCurrency,
433 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 418
                 if ($index === $this->lineItems->count() - 1) {
441
-                    $lineItemDiscount = $remainingDiscountCents;
419
+                    $lineItemDiscountInInvoiceCurrency = $remainingDiscountInInvoiceCurrency;
442 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 429
                         'type' => JournalEntryType::Debit,
453 430
                         'account_id' => Account::getSalesDiscountAccount($this->company_id)->id,
454
-                        'amount' => CurrencyConverter::convertCentsToFormatSimple($lineItemDiscount),
431
+                        'amount_in_invoice_currency' => $lineItemDiscountInInvoiceCurrency,
455 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 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 500
         $this->createApprovalTransaction();
471 501
     }
@@ -482,11 +512,9 @@ class Invoice extends Document
482 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 520
     // TODO: Potentially handle this another way

+ 0
- 2
app/Models/Accounting/JournalEntry.php 查看文件

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

+ 1
- 6
app/Models/Accounting/RecurringInvoice.php 查看文件

@@ -2,7 +2,6 @@
2 2
 
3 3
 namespace App\Models\Accounting;
4 4
 
5
-use App\Casts\MoneyCast;
6 5
 use App\Casts\RateCast;
7 6
 use App\Collections\Accounting\DocumentCollection;
8 7
 use App\Enums\Accounting\AdjustmentComputation;
@@ -109,10 +108,6 @@ class RecurringInvoice extends Document
109 108
         'discount_method' => DocumentDiscountMethod::class,
110 109
         'discount_computation' => AdjustmentComputation::class,
111 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 113
     protected $appends = [
@@ -643,7 +638,7 @@ class RecurringInvoice extends Document
643 638
             'currency_code' => $this->currency_code,
644 639
             'discount_method' => $this->discount_method,
645 640
             'discount_computation' => $this->discount_computation,
646
-            'discount_rate' => $this->discount_rate,
641
+            'discount_rate' => $this->getRawOriginal('discount_rate'),
647 642
             'subtotal' => $this->subtotal,
648 643
             'tax_total' => $this->tax_total,
649 644
             'discount_total' => $this->discount_total,

+ 1
- 3
app/Models/Accounting/Transaction.php 查看文件

@@ -2,7 +2,6 @@
2 2
 
3 3
 namespace App\Models\Accounting;
4 4
 
5
-use App\Casts\TransactionAmountCast;
6 5
 use App\Concerns\Blamable;
7 6
 use App\Concerns\CompanyOwned;
8 7
 use App\Enums\Accounting\AccountCategory;
@@ -60,7 +59,6 @@ class Transaction extends Model
60 59
     protected $casts = [
61 60
         'type' => TransactionType::class,
62 61
         'payment_method' => PaymentMethod::class,
63
-        'amount' => TransactionAmountCast::class,
64 62
         'pending' => 'boolean',
65 63
         'reviewed' => 'boolean',
66 64
         'posted_at' => 'date',
@@ -110,7 +108,7 @@ class Transaction extends Model
110 108
     public function updateAmountIfBalanced(): void
111 109
     {
112 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 112
             $this->save();
115 113
         }
116 114
     }

+ 0
- 2
app/Models/Common/Offering.php 查看文件

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

+ 7
- 1
app/Observers/BillObserver.php 查看文件

@@ -17,7 +17,13 @@ class BillObserver
17 17
 
18 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 27
             $bill->status = BillStatus::Overdue;
22 28
         }
23 29
     }

+ 2
- 2
app/Observers/EstimateObserver.php 查看文件

@@ -15,13 +15,13 @@ class EstimateObserver
15 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 19
             $estimate->status = $estimate->hasBeenSent() ? EstimateStatus::Sent : EstimateStatus::Unsent;
20 20
 
21 21
             return;
22 22
         }
23 23
 
24
-        if ($estimate->is_currently_expired && $estimate->canBeExpired()) {
24
+        if ($estimate->shouldBeExpired()) {
25 25
             $estimate->status = EstimateStatus::Expired;
26 26
         }
27 27
     }

+ 11
- 1
app/Observers/InvoiceObserver.php 查看文件

@@ -12,7 +12,17 @@ class InvoiceObserver
12 12
 {
13 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 26
             $invoice->status = InvoiceStatus::Overdue;
17 27
         }
18 28
     }

+ 7
- 7
app/Observers/TransactionObserver.php 查看文件

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

+ 28
- 7
app/Providers/MacroServiceProvider.php 查看文件

@@ -20,6 +20,7 @@ use Filament\Forms\Components\Field;
20 20
 use Filament\Forms\Components\TextInput;
21 21
 use Filament\Infolists\Components\TextEntry;
22 22
 use Filament\Support\Enums\IconPosition;
23
+use Filament\Support\RawJs;
23 24
 use Filament\Tables\Columns\TextColumn;
24 25
 use Filament\Tables\Contracts\HasTable;
25 26
 use Illuminate\Contracts\Support\Htmlable;
@@ -64,11 +65,31 @@ class MacroServiceProvider extends ServiceProvider
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 94
             return $this;
74 95
         });
@@ -174,7 +195,7 @@ class MacroServiceProvider extends ServiceProvider
174 195
 
175 196
         TextColumn::macro('currency', function (string | Closure | null $currency = null, ?bool $convert = null): static {
176 197
             $currency ??= CurrencyAccessor::getDefaultCurrency();
177
-            $convert ??= true;
198
+            $convert ??= false;
178 199
 
179 200
             $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency, $convert): ?string {
180 201
                 if (blank($state)) {
@@ -192,7 +213,7 @@ class MacroServiceProvider extends ServiceProvider
192 213
 
193 214
         TextEntry::macro('currency', function (string | Closure | null $currency = null, ?bool $convert = null): static {
194 215
             $currency ??= CurrencyAccessor::getDefaultCurrency();
195
-            $convert ??= true;
216
+            $convert ??= false;
196 217
 
197 218
             $this->formatStateUsing(static function (TextEntry $entry, $state) use ($currency, $convert): ?string {
198 219
                 if (blank($state)) {
@@ -210,7 +231,7 @@ class MacroServiceProvider extends ServiceProvider
210 231
 
211 232
         TextColumn::macro('currencyWithConversion', function (string | Closure | null $currency = null, ?bool $convertFromCents = null): static {
212 233
             $currency ??= CurrencyAccessor::getDefaultCurrency();
213
-            $convertFromCents ??= false;
234
+            $convertFromCents ??= true;
214 235
 
215 236
             $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency, $convertFromCents): ?string {
216 237
                 if (blank($state)) {

+ 1
- 1
app/Services/ReportService.php 查看文件

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

+ 4
- 8
app/Services/TransactionService.php 查看文件

@@ -250,7 +250,7 @@ class TransactionService
250 250
         });
251 251
     }
252 252
 
253
-    private function getConvertedTransactionAmount(Transaction $transaction): string
253
+    private function getConvertedTransactionAmount(Transaction $transaction): int
254 254
     {
255 255
         $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
256 256
         $bankAccountCurrency = $transaction->bankAccount->account->currency_code;
@@ -262,13 +262,9 @@ class TransactionService
262 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 270
     private function hasRelevantChanges(Transaction $transaction): bool
@@ -276,7 +272,7 @@ class TransactionService
276 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 277
         DB::transaction(static function () use ($journalEntry, $account, $convertedTransactionAmount) {
282 278
             $journalEntry->update([

+ 12
- 8
app/View/Models/DocumentTotalViewModel.php 查看文件

@@ -72,22 +72,27 @@ class DocumentTotalViewModel
72 72
     private function calculateLineSubtotalInCents(array $item, string $currencyCode): int
73 73
     {
74 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 79
         $subtotal = $quantity * $unitPrice;
78 80
 
79
-        return CurrencyConverter::convertToCents($subtotal, $currencyCode);
81
+        return CurrencyConverter::convertToCents($subtotal, 'USD');
80 82
     }
81 83
 
82 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 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 92
             $adjustmentIds = $item[$key] ?? [];
88 93
             $lineTotal = $quantity * $unitPrice;
89 94
 
90
-            $lineTotalInCents = CurrencyConverter::convertToCents($lineTotal, $currencyCode);
95
+            $lineTotalInCents = CurrencyConverter::convertToCents($lineTotal, 'USD');
91 96
 
92 97
             $adjustmentTotal = Adjustment::whereIn('id', $adjustmentIds)
93 98
                 ->get()
@@ -120,7 +125,7 @@ class DocumentTotalViewModel
120 125
             return RateCalculator::calculatePercentage($subtotalInCents, $scaledDiscountRate);
121 126
         }
122 127
 
123
-        if (! CurrencyConverter::isValidAmount($discountRate)) {
128
+        if (! CurrencyConverter::isValidAmount($discountRate, $currencyCode)) {
124 129
             $discountRate = '0';
125 130
         }
126 131
 
@@ -152,8 +157,7 @@ class DocumentTotalViewModel
152 157
 
153 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 162
         return $grandTotalInCents - $amountPaidInCents;
159 163
     }

+ 295
- 290
composer.lock
文件差異過大導致無法顯示
查看文件


+ 85
- 90
database/factories/Accounting/BillFactory.php 查看文件

@@ -12,7 +12,6 @@ use App\Models\Banking\BankAccount;
12 12
 use App\Models\Common\Vendor;
13 13
 use App\Models\Company;
14 14
 use App\Models\Setting\DocumentDefault;
15
-use App\Utilities\Currency\CurrencyConverter;
16 15
 use App\Utilities\RateCalculator;
17 16
 use Illuminate\Database\Eloquent\Factories\Factory;
18 17
 use Illuminate\Support\Carbon;
@@ -34,23 +33,20 @@ class BillFactory extends Factory
34 33
      */
35 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 38
         return [
48 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 46
             'bill_number' => $this->faker->unique()->numerify('BILL-####'),
51 47
             'order_number' => $this->faker->unique()->numerify('PO-####'),
52 48
             'date' => $billDate,
53
-            'due_date' => Carbon::parse($billDate)->addDays($dueDays),
49
+            'due_date' => $this->faker->dateTimeInInterval($billDate, '+6 months'),
54 50
             'status' => BillStatus::Open,
55 51
             'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
56 52
             'discount_computation' => AdjustmentComputation::Percentage,
@@ -79,6 +75,9 @@ class BillFactory extends Factory
79 75
     public function withLineItems(int $count = 3): static
80 76
     {
81 77
         return $this->afterCreating(function (Bill $bill) use ($count) {
78
+            // Clear existing line items first
79
+            $bill->lineItems()->delete();
80
+
82 81
             DocumentLineItem::factory()
83 82
                 ->count($count)
84 83
                 ->forBill($bill)
@@ -91,36 +90,23 @@ class BillFactory extends Factory
91 90
     public function initialized(): static
92 91
     {
93 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 97
     public function partial(int $maxPayments = 4): static
108 98
     {
109 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 105
     public function paid(int $maxPayments = 4): static
118 106
     {
119 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,75 +117,99 @@ class BillFactory extends Factory
131 117
                 'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
132 118
             ])
133 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 196
             $latestPaymentDate = max($paymentDates);
192 197
             $bill->updateQuietly([
193 198
                 'status' => $billStatus,
194 199
                 'paid_at' => $latestPaymentDate,
195 200
             ]);
196
-        });
201
+        }
197 202
     }
198 203
 
199 204
     public function configure(): static
200 205
     {
201 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 214
             $number = DocumentDefault::getBaseNumber() + $bill->id;
205 215
 
@@ -208,7 +218,7 @@ class BillFactory extends Factory
208 218
                 'order_number' => "PO-{$number}",
209 219
             ]);
210 220
 
211
-            if ($bill->wasInitialized() && $bill->is_currently_overdue) {
221
+            if ($bill->wasInitialized() && $bill->shouldBeOverdue()) {
212 222
                 $bill->updateQuietly([
213 223
                     'status' => BillStatus::Overdue,
214 224
                 ]);
@@ -216,20 +226,6 @@ class BillFactory extends Factory
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 229
     protected function recalculateTotals(Bill $bill): void
234 230
     {
235 231
         $bill->refresh();
@@ -250,18 +246,17 @@ class BillFactory extends Factory
250 246
                 $scaledRate = RateCalculator::parseLocalizedRate($bill->discount_rate);
251 247
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
252 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 253
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
258
-        $currencyCode = $bill->currency_code;
259 254
 
260 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 查看文件

@@ -10,7 +10,6 @@ use App\Models\Accounting\Estimate;
10 10
 use App\Models\Common\Client;
11 11
 use App\Models\Company;
12 12
 use App\Models\Setting\DocumentDefault;
13
-use App\Utilities\Currency\CurrencyConverter;
14 13
 use App\Utilities\RateCalculator;
15 14
 use Illuminate\Database\Eloquent\Factories\Factory;
16 15
 use Illuminate\Support\Carbon;
@@ -32,17 +31,22 @@ class EstimateFactory extends Factory
32 31
      */
33 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 36
         return [
38 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 44
             'header' => 'Estimate',
41 45
             'subheader' => 'Estimate',
42 46
             'estimate_number' => $this->faker->unique()->numerify('EST-####'),
43 47
             'reference_number' => $this->faker->unique()->numerify('REF-####'),
44 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 50
             'status' => EstimateStatus::Draft,
47 51
             'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
48 52
             'discount_computation' => AdjustmentComputation::Percentage,
@@ -72,6 +76,9 @@ class EstimateFactory extends Factory
72 76
     public function withLineItems(int $count = 3): static
73 77
     {
74 78
         return $this->afterCreating(function (Estimate $estimate) use ($count) {
79
+            // Clear existing line items first
80
+            $estimate->lineItems()->delete();
81
+
75 82
             DocumentLineItem::factory()
76 83
                 ->count($count)
77 84
                 ->forEstimate($estimate)
@@ -84,27 +91,22 @@ class EstimateFactory extends Factory
84 91
     public function approved(): static
85 92
     {
86 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 98
     public function accepted(): static
101 99
     {
102 100
         return $this->afterCreating(function (Estimate $estimate) {
103
-            $this->ensureSent($estimate);
101
+            $this->performSent($estimate);
104 102
 
105 103
             $acceptedAt = Carbon::parse($estimate->last_sent_at)
106 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 110
             $estimate->markAsAccepted($acceptedAt);
109 111
         });
110 112
     }
@@ -113,12 +115,25 @@ class EstimateFactory extends Factory
113 115
     {
114 116
         return $this->afterCreating(function (Estimate $estimate) {
115 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 130
             $convertedAt = Carbon::parse($estimate->accepted_at)
120 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 137
             $estimate->convertToInvoice($convertedAt);
123 138
         });
124 139
     }
@@ -126,11 +141,15 @@ class EstimateFactory extends Factory
126 141
     public function declined(): static
127 142
     {
128 143
         return $this->afterCreating(function (Estimate $estimate) {
129
-            $this->ensureSent($estimate);
144
+            $this->performSent($estimate);
130 145
 
131 146
             $declinedAt = Carbon::parse($estimate->last_sent_at)
132 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 153
             $estimate->markAsDeclined($declinedAt);
135 154
         });
136 155
     }
@@ -138,23 +157,22 @@ class EstimateFactory extends Factory
138 157
     public function sent(): static
139 158
     {
140 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 164
     public function viewed(): static
151 165
     {
152 166
         return $this->afterCreating(function (Estimate $estimate) {
153
-            $this->ensureSent($estimate);
167
+            $this->performSent($estimate);
154 168
 
155 169
             $viewedAt = Carbon::parse($estimate->last_sent_at)
156 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 176
             $estimate->markAsViewed($viewedAt);
159 177
         });
160 178
     }
@@ -166,14 +184,51 @@ class EstimateFactory extends Factory
166 184
                 'expiration_date' => now()->subDays($this->faker->numberBetween(1, 30)),
167 185
             ])
168 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 223
     public function configure(): static
174 224
     {
175 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 233
             $number = DocumentDefault::getBaseNumber() + $estimate->id;
179 234
 
@@ -182,7 +237,7 @@ class EstimateFactory extends Factory
182 237
                 'reference_number' => "REF-{$number}",
183 238
             ]);
184 239
 
185
-            if ($estimate->wasApproved() && $estimate->is_currently_expired) {
240
+            if ($estimate->wasApproved() && $estimate->shouldBeExpired()) {
186 241
                 $estimate->updateQuietly([
187 242
                     'status' => EstimateStatus::Expired,
188 243
                 ]);
@@ -190,27 +245,6 @@ class EstimateFactory extends Factory
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 248
     protected function recalculateTotals(Estimate $estimate): void
215 249
     {
216 250
         $estimate->refresh();
@@ -231,18 +265,17 @@ class EstimateFactory extends Factory
231 265
                 $scaledRate = RateCalculator::parseLocalizedRate($estimate->discount_rate);
232 266
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
233 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 272
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
239
-        $currencyCode = $estimate->currency_code;
240 273
 
241 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 查看文件

@@ -12,10 +12,10 @@ use App\Models\Banking\BankAccount;
12 12
 use App\Models\Common\Client;
13 13
 use App\Models\Company;
14 14
 use App\Models\Setting\DocumentDefault;
15
-use App\Utilities\Currency\CurrencyConverter;
16 15
 use App\Utilities\RateCalculator;
17 16
 use Illuminate\Database\Eloquent\Factories\Factory;
18 17
 use Illuminate\Support\Carbon;
18
+use Random\RandomException;
19 19
 
20 20
 /**
21 21
  * @extends Factory<Invoice>
@@ -34,17 +34,22 @@ class InvoiceFactory extends Factory
34 34
      */
35 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 39
         return [
40 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 47
             'header' => 'Invoice',
43 48
             'subheader' => 'Invoice',
44 49
             'invoice_number' => $this->faker->unique()->numerify('INV-####'),
45 50
             'order_number' => $this->faker->unique()->numerify('ORD-####'),
46 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 53
             'status' => InvoiceStatus::Draft,
49 54
             'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
50 55
             'discount_computation' => AdjustmentComputation::Percentage,
@@ -74,6 +79,8 @@ class InvoiceFactory extends Factory
74 79
     public function withLineItems(int $count = 3): static
75 80
     {
76 81
         return $this->afterCreating(function (Invoice $invoice) use ($count) {
82
+            $invoice->lineItems()->delete();
83
+
77 84
             DocumentLineItem::factory()
78 85
                 ->count($count)
79 86
                 ->forInvoice($invoice)
@@ -86,58 +93,38 @@ class InvoiceFactory extends Factory
86 93
     public function approved(): static
87 94
     {
88 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 100
     public function sent(): static
103 101
     {
104 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 107
     public function partial(int $maxPayments = 4): static
115 108
     {
116 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 115
     public function paid(int $maxPayments = 4): static
125 116
     {
126 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 123
     public function overpaid(int $maxPayments = 4): static
135 124
     {
136 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,76 +135,119 @@ class InvoiceFactory extends Factory
148 135
                 'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
149 136
             ])
150 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 234
             $latestPaymentDate = max($paymentDates);
210 235
             $invoice->updateQuietly([
211 236
                 'status' => $invoiceStatus,
212 237
                 'paid_at' => $latestPaymentDate,
213 238
             ]);
214
-        });
239
+        }
215 240
     }
216 241
 
217 242
     public function configure(): static
218 243
     {
219 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 252
             $number = DocumentDefault::getBaseNumber() + $invoice->id;
223 253
 
@@ -226,7 +256,7 @@ class InvoiceFactory extends Factory
226 256
                 'order_number' => "ORD-{$number}",
227 257
             ]);
228 258
 
229
-            if ($invoice->wasApproved() && $invoice->is_currently_overdue) {
259
+            if ($invoice->wasApproved() && $invoice->shouldBeOverdue()) {
230 260
                 $invoice->updateQuietly([
231 261
                     'status' => InvoiceStatus::Overdue,
232 262
                 ]);
@@ -234,27 +264,6 @@ class InvoiceFactory extends Factory
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 267
     protected function recalculateTotals(Invoice $invoice): void
259 268
     {
260 269
         $invoice->refresh();
@@ -275,18 +284,17 @@ class InvoiceFactory extends Factory
275 284
                 $scaledRate = RateCalculator::parseLocalizedRate($invoice->discount_rate);
276 285
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
277 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 291
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
283
-        $currencyCode = $invoice->currency_code;
284 292
 
285 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 查看文件

@@ -16,7 +16,6 @@ use App\Models\Accounting\DocumentLineItem;
16 16
 use App\Models\Accounting\RecurringInvoice;
17 17
 use App\Models\Common\Client;
18 18
 use App\Models\Company;
19
-use App\Utilities\Currency\CurrencyConverter;
20 19
 use App\Utilities\RateCalculator;
21 20
 use Illuminate\Database\Eloquent\Factories\Factory;
22 21
 use Illuminate\Support\Carbon;
@@ -40,7 +39,12 @@ class RecurringInvoiceFactory extends Factory
40 39
     {
41 40
         return [
42 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 48
             'header' => 'Invoice',
45 49
             'subheader' => 'Invoice',
46 50
             'order_number' => $this->faker->unique()->numerify('ORD-####'),
@@ -74,6 +78,9 @@ class RecurringInvoiceFactory extends Factory
74 78
     public function withLineItems(int $count = 3): static
75 79
     {
76 80
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($count) {
81
+            // Clear existing line items first
82
+            $recurringInvoice->lineItems()->delete();
83
+
77 84
             DocumentLineItem::factory()
78 85
                 ->count($count)
79 86
                 ->forInvoice($recurringInvoice)
@@ -83,40 +90,31 @@ class RecurringInvoiceFactory extends Factory
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 105
     public function withSchedule(
87 106
         ?Frequency $frequency = null,
88 107
         ?Carbon $startDate = null,
89 108
         ?EndType $endType = null
90 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 115
     public function withDailySchedule(Carbon $startDate, EndType $endType): static
116 116
     {
117 117
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
118
-            $this->ensureLineItems($recurringInvoice);
119
-
120 118
             $recurringInvoice->updateQuietly([
121 119
                 'frequency' => Frequency::Daily,
122 120
                 'start_date' => $startDate,
@@ -128,8 +126,6 @@ class RecurringInvoiceFactory extends Factory
128 126
     public function withWeeklySchedule(Carbon $startDate, EndType $endType): static
129 127
     {
130 128
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
131
-            $this->ensureLineItems($recurringInvoice);
132
-
133 129
             $recurringInvoice->updateQuietly([
134 130
                 'frequency' => Frequency::Weekly,
135 131
                 'day_of_week' => DayOfWeek::from($startDate->dayOfWeek),
@@ -142,8 +138,6 @@ class RecurringInvoiceFactory extends Factory
142 138
     public function withMonthlySchedule(Carbon $startDate, EndType $endType): static
143 139
     {
144 140
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
145
-            $this->ensureLineItems($recurringInvoice);
146
-
147 141
             $recurringInvoice->updateQuietly([
148 142
                 'frequency' => Frequency::Monthly,
149 143
                 'day_of_month' => DayOfMonth::from($startDate->day),
@@ -156,8 +150,6 @@ class RecurringInvoiceFactory extends Factory
156 150
     public function withYearlySchedule(Carbon $startDate, EndType $endType): static
157 151
     {
158 152
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
159
-            $this->ensureLineItems($recurringInvoice);
160
-
161 153
             $recurringInvoice->updateQuietly([
162 154
                 'frequency' => Frequency::Yearly,
163 155
                 'month' => Month::from($startDate->month),
@@ -175,8 +167,6 @@ class RecurringInvoiceFactory extends Factory
175 167
         ?int $intervalValue = null
176 168
     ): static {
177 169
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($intervalType, $intervalValue, $startDate, $endType) {
178
-            $this->ensureLineItems($recurringInvoice);
179
-
180 170
             $intervalType ??= $this->faker->randomElement(IntervalType::class);
181 171
             $intervalValue ??= match ($intervalType) {
182 172
                 IntervalType::Day => $this->faker->numberBetween(1, 7),
@@ -216,7 +206,7 @@ class RecurringInvoiceFactory extends Factory
216 206
                     break;
217 207
             }
218 208
 
219
-            return $recurringInvoice->updateQuietly($state);
209
+            $recurringInvoice->updateQuietly($state);
220 210
         });
221 211
     }
222 212
 
@@ -249,35 +239,32 @@ class RecurringInvoiceFactory extends Factory
249 239
     public function approved(): static
250 240
     {
251 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 249
             if (! $recurringInvoice->hasSchedule()) {
255
-                $this->withSchedule()->callAfterCreating(collect([$recurringInvoice]));
250
+                $this->performScheduleSetup($recurringInvoice);
256 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 258
     public function ended(): static
275 259
     {
276 260
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
277
-            $this->ensureLineItems($recurringInvoice);
278
-
279 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 270
             $endedAt = $recurringInvoice->last_date
@@ -291,18 +278,131 @@ class RecurringInvoiceFactory extends Factory
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 408
     protected function recalculateTotals(RecurringInvoice $recurringInvoice): void
@@ -325,18 +425,17 @@ class RecurringInvoiceFactory extends Factory
325 425
                 $scaledRate = RateCalculator::parseLocalizedRate($recurringInvoice->discount_rate);
326 426
                 $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
327 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 432
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
333
-        $currencyCode = $recurringInvoice->currency_code;
334 433
 
335 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 查看文件

@@ -33,7 +33,7 @@ class OfferingFactory extends Factory
33 33
             'name' => $this->faker->words(3, true),
34 34
             'description' => $this->faker->sentence,
35 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 37
             'sellable' => false,
38 38
             'purchasable' => false,
39 39
             'income_account_id' => null,

+ 1
- 0
database/factories/CompanyFactory.php 查看文件

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

+ 63
- 0
tests/Feature/Accounting/InvoiceTest.php 查看文件

@@ -0,0 +1,63 @@
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 查看文件

@@ -10,6 +10,20 @@ beforeEach(function () {
10 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 27
 test('recurring invoice properly handles months with fewer days for monthly frequency', function () {
14 28
     // Start from January 31st
15 29
     Carbon::setTestNow('2024-01-31');

+ 40
- 36
tests/Feature/Accounting/TransactionTest.php 查看文件

@@ -9,6 +9,7 @@ use App\Filament\Tables\Actions\ReplicateBulkAction;
9 9
 use App\Models\Accounting\Account;
10 10
 use App\Models\Accounting\Transaction;
11 11
 use App\Utilities\Currency\ConfigureCurrencies;
12
+use App\Utilities\Currency\CurrencyConverter;
12 13
 use Filament\Tables\Actions\DeleteAction;
13 14
 use Filament\Tables\Actions\DeleteBulkAction;
14 15
 use Filament\Tables\Actions\ReplicateAction;
@@ -78,8 +79,8 @@ it('stores and sums correct debit and credit amounts for different transaction t
78 79
         ->create();
79 80
 
80 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 84
 })->with([
84 85
     ['asDeposit', 'forUncategorizedRevenue', 2000],
85 86
     ['asWithdrawal', 'forUncategorizedExpense', 500],
@@ -122,10 +123,10 @@ it('handles multi-currency transfers without conversion when the source bank acc
122 123
     $expectedUSDValue = 1500;
123 124
 
124 125
     expect($transaction)
125
-        ->amount->toEqual('1,500.00')
126
+        ->amount->toBe(1500)
126 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 132
 it('handles multi-currency transfers correctly', function () {
@@ -150,16 +151,16 @@ it('handles multi-currency transfers correctly', function () {
150 151
     expect($debitAccount->name)->toBe('Destination Bank Account') // Debit: Destination (USD) account
151 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 159
     expect($transaction)
159
-        ->amount->toEqual('1,500.00')
160
+        ->amount->toBe(1500)
160 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 166
 it('handles multi-currency deposits correctly', function () {
@@ -171,7 +172,7 @@ it('handles multi-currency deposits correctly', function () {
171 172
 
172 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 176
     /** @var Transaction $transaction */
176 177
     $transaction = Transaction::factory()
177 178
         ->forBankAccount($foreignBankAccount->bankAccount)
@@ -184,15 +185,14 @@ it('handles multi-currency deposits correctly', function () {
184 185
     expect($debitAccount->is($foreignBankAccount))->toBeTrue() // Debit: Foreign Bank Account (BHD) account
185 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 191
     expect($transaction)
192
-        ->amount->toEqual('1,500.000')
192
+        ->amount->toBe(1500)
193 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 198
 it('handles multi-currency withdrawals correctly', function () {
@@ -216,13 +216,13 @@ it('handles multi-currency withdrawals correctly', function () {
216 216
     expect($debitAccount->name)->toBe('Uncategorized Expense')
217 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 221
     expect($transaction)
222
-        ->amount->toEqual('1,500.00')
222
+        ->amount->toBe(1500)
223 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 228
 it('can add an income or expense transaction', function (TransactionType $transactionType, string $actionName) {
@@ -249,7 +249,7 @@ it('can add an income or expense transaction', function (TransactionType $transa
249 249
 
250 250
     expect($transaction)
251 251
         ->not->toBeNull()
252
-        ->amount->toEqual('500.00')
252
+        ->amount->toBe(50000) // 500.00 in cents
253 253
         ->type->toBe($transactionType)
254 254
         ->bankAccount->is($defaultBankAccount)->toBeTrue()
255 255
         ->account->is($defaultAccount)->toBeTrue()
@@ -284,7 +284,7 @@ it('can add a transfer transaction', function () {
284 284
 
285 285
     expect($transaction)
286 286
         ->not->toBeNull()
287
-        ->amount->toEqual('1,500.00')
287
+        ->amount->toBe(150000) // 1,500.00 in cents
288 288
         ->type->toBe(TransactionType::Transfer)
289 289
         ->bankAccount->is($sourceBankAccount)->toBeTrue()
290 290
         ->account->is($destinationBankAccount)->toBeTrue()
@@ -323,13 +323,13 @@ it('can add a journal transaction', function () {
323 323
 
324 324
     expect($transaction)
325 325
         ->not->toBeNull()
326
-        ->amount->toEqual('1,000.00')
326
+        ->amount->toBe(100000) // 1,000.00 in cents
327 327
         ->type->isJournal()->toBeTrue()
328 328
         ->bankAccount->toBeNull()
329 329
         ->account->toBeNull()
330 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 333
         ->and($debitAccount->is($defaultDebitAccount))->toBeTrue()
334 334
         ->and($creditAccount->is($defaultCreditAccount))->toBeTrue();
335 335
 });
@@ -345,12 +345,14 @@ it('can update a deposit or withdrawal transaction', function (TransactionType $
345 345
 
346 346
     $newDescription = 'Updated Description';
347 347
 
348
+    $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($transaction->amount);
349
+
348 350
     livewire(ListTransactions::class)
349 351
         ->mountTableAction(EditTransactionAction::class, $transaction)
350 352
         ->assertTableActionDataSet([
351 353
             'type' => $transactionType->value,
352 354
             'description' => $transaction->description,
353
-            'amount' => $transaction->amount,
355
+            'amount' => $formattedAmount,
354 356
         ])
355 357
         ->setTableActionData([
356 358
             'description' => $newDescription,
@@ -362,7 +364,7 @@ it('can update a deposit or withdrawal transaction', function (TransactionType $
362 364
     $transaction->refresh();
363 365
 
364 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 368
 })->with([
367 369
     TransactionType::Deposit,
368 370
     TransactionType::Withdrawal,
@@ -377,12 +379,14 @@ it('can update a transfer transaction', function () {
377 379
 
378 380
     $newDescription = 'Updated Transfer Description';
379 381
 
382
+    $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($transaction->amount);
383
+
380 384
     livewire(ListTransactions::class)
381 385
         ->mountTableAction(EditTransactionAction::class, $transaction)
382 386
         ->assertTableActionDataSet([
383 387
             'type' => TransactionType::Transfer->value,
384 388
             'description' => $transaction->description,
385
-            'amount' => $transaction->amount,
389
+            'amount' => $formattedAmount,
386 390
         ])
387 391
         ->setTableActionData([
388 392
             'description' => $newDescription,
@@ -394,7 +398,7 @@ it('can update a transfer transaction', function () {
394 398
     $transaction->refresh();
395 399
 
396 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 404
 it('replicates a transaction with correct journal entries', function () {
@@ -417,8 +421,8 @@ it('replicates a transaction with correct journal entries', function () {
417 421
 
418 422
     expect($replicatedTransaction)
419 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 426
         ->description->toBe('(Copy of) ' . $originalTransaction->description)
423 427
         ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
424 428
         ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
@@ -451,8 +455,8 @@ it('bulk replicates transactions with correct journal entries', function () {
451 455
 
452 456
         expect($replicatedTransaction)
453 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 460
             ->and($replicatedDebitAccount->name)->toBe($originalDebitAccount->name)
457 461
             ->and($replicatedCreditAccount->name)->toBe($originalCreditAccount->name);
458 462
     });

Loading…
取消
儲存