Bläddra i källkod

wip multi-currency

3.x
Andrew Wallo 10 månader sedan
förälder
incheckning
194016dfb5

+ 16
- 28
app/Casts/RateCast.php Visa fil

@@ -2,65 +2,53 @@
2 2
 
3 3
 namespace App\Casts;
4 4
 
5
-use App\Enums\Setting\NumberFormat;
6
-use App\Models\Setting\Localization;
5
+use App\Enums\Accounting\AdjustmentComputation;
7 6
 use App\Utilities\Currency\CurrencyAccessor;
7
+use App\Utilities\RateCalculator;
8 8
 use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
9 9
 use Illuminate\Database\Eloquent\Model;
10 10
 
11 11
 class RateCast implements CastsAttributes
12 12
 {
13
-    private const PRECISION = 4;
14
-
15 13
     public function get($model, string $key, $value, array $attributes): string
16 14
     {
15
+        if (! $value) {
16
+            return '0';
17
+        }
18
+
17 19
         $currency_code = $this->getDefaultCurrencyCode();
18
-        $computation = $attributes['computation'] ?? null;
20
+        $computation = AdjustmentComputation::parse($attributes['computation'] ?? $attributes['discount_computation'] ?? null);
19 21
 
20
-        if ($computation === 'fixed') {
22
+        if ($computation?->isFixed()) {
21 23
             return money($value, $currency_code)->formatSimple();
22 24
         }
23 25
 
24
-        $floatValue = $value / (10 ** self::PRECISION);
25
-
26
-        $format = Localization::firstOrFail()->number_format->value;
27
-        [$decimal_mark, $thousands_separator] = NumberFormat::from($format)->getFormattingParameters();
28
-
29
-        return $this->formatWithoutTrailingZeros($floatValue, $decimal_mark, $thousands_separator);
26
+        return RateCalculator::formatScaledRate($value);
30 27
     }
31 28
 
32 29
     public function set(Model $model, string $key, mixed $value, array $attributes): int
33 30
     {
31
+        if (! $value) {
32
+            return 0;
33
+        }
34
+
34 35
         if (is_int($value)) {
35 36
             return $value;
36 37
         }
37 38
 
38
-        $computation = $attributes['computation'] ?? null;
39
+        $computation = AdjustmentComputation::parse($attributes['computation'] ?? $attributes['discount_computation'] ?? null);
39 40
 
40 41
         $currency_code = $this->getDefaultCurrencyCode();
41 42
 
42
-        if ($computation === 'fixed') {
43
+        if ($computation?->isFixed()) {
43 44
             return money($value, $currency_code, true)->getAmount();
44 45
         }
45 46
 
46
-        $format = Localization::firstOrFail()->number_format->value;
47
-        [$decimal_mark, $thousands_separator] = NumberFormat::from($format)->getFormattingParameters();
48
-
49
-        $intValue = str_replace([$thousands_separator, $decimal_mark], ['', '.'], $value);
50
-
51
-        return (int) round((float) $intValue * (10 ** self::PRECISION));
47
+        return RateCalculator::parseLocalizedRate($value);
52 48
     }
53 49
 
54 50
     private function getDefaultCurrencyCode(): string
55 51
     {
56 52
         return CurrencyAccessor::getDefaultCurrency();
57 53
     }
58
-
59
-    private function formatWithoutTrailingZeros($floatValue, $decimal_mark, $thousands_separator): string
60
-    {
61
-        $formatted = number_format($floatValue, self::PRECISION, $decimal_mark, $thousands_separator);
62
-        $formatted = rtrim($formatted, '0');
63
-
64
-        return rtrim($formatted, $decimal_mark);
65
-    }
66 54
 }

+ 15
- 8
app/Concerns/ManagesLineItems.php Visa fil

@@ -6,7 +6,9 @@ use App\Enums\Accounting\AdjustmentComputation;
6 6
 use App\Enums\Accounting\DocumentDiscountMethod;
7 7
 use App\Models\Accounting\Bill;
8 8
 use App\Models\Accounting\DocumentLineItem;
9
+use App\Utilities\Currency\CurrencyAccessor;
9 10
 use App\Utilities\Currency\CurrencyConverter;
11
+use App\Utilities\RateCalculator;
10 12
 use Illuminate\Database\Eloquent\Model;
11 13
 use Illuminate\Support\Collection;
12 14
 
@@ -79,6 +81,7 @@ trait ManagesLineItems
79 81
 
80 82
     protected function updateDocumentTotals(Model $record, array $data): array
81 83
     {
84
+        $currencyCode = $data['currency_code'] ?? CurrencyAccessor::getDefaultCurrency();
82 85
         $subtotalCents = $record->lineItems()->sum('subtotal');
83 86
         $taxTotalCents = $record->lineItems()->sum('tax_total');
84 87
         $discountTotalCents = $this->calculateDiscountTotal(
@@ -86,16 +89,17 @@ trait ManagesLineItems
86 89
             AdjustmentComputation::parse($data['discount_computation']),
87 90
             $data['discount_rate'] ?? null,
88 91
             $subtotalCents,
89
-            $record
92
+            $record,
93
+            $currencyCode,
90 94
         );
91 95
 
92 96
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
93 97
 
94 98
         return [
95
-            'subtotal' => CurrencyConverter::convertCentsToFormatSimple($subtotalCents),
96
-            'tax_total' => CurrencyConverter::convertCentsToFormatSimple($taxTotalCents),
97
-            'discount_total' => CurrencyConverter::convertCentsToFormatSimple($discountTotalCents),
98
-            'total' => CurrencyConverter::convertCentsToFormatSimple($grandTotalCents),
99
+            'subtotal' => CurrencyConverter::convertCentsToFormatSimple($subtotalCents, $currencyCode),
100
+            'tax_total' => CurrencyConverter::convertCentsToFormatSimple($taxTotalCents, $currencyCode),
101
+            'discount_total' => CurrencyConverter::convertCentsToFormatSimple($discountTotalCents, $currencyCode),
102
+            'total' => CurrencyConverter::convertCentsToFormatSimple($grandTotalCents, $currencyCode),
99 103
         ];
100 104
     }
101 105
 
@@ -104,16 +108,19 @@ trait ManagesLineItems
104 108
         ?AdjustmentComputation $discountComputation,
105 109
         ?string $discountRate,
106 110
         int $subtotalCents,
107
-        Model $record
111
+        Model $record,
112
+        string $currencyCode
108 113
     ): int {
109 114
         if ($discountMethod->isPerLineItem()) {
110 115
             return $record->lineItems()->sum('discount_total');
111 116
         }
112 117
 
113 118
         if ($discountComputation?->isPercentage()) {
114
-            return (int) ($subtotalCents * ((float) $discountRate / 100));
119
+            $scaledRate = RateCalculator::parseLocalizedRate($discountRate);
120
+
121
+            return RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
115 122
         }
116 123
 
117
-        return CurrencyConverter::convertToCents($discountRate);
124
+        return CurrencyConverter::convertToCents($discountRate, $currencyCode);
118 125
     }
119 126
 }

+ 1
- 1
app/Filament/Company/Pages/Accounting/Transactions.php Visa fil

@@ -305,7 +305,7 @@ class Transactions extends Page implements HasTable
305 305
                         }
306 306
                     )
307 307
                     ->sortable()
308
-                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
308
+                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code),
309 309
             ])
310 310
             ->recordClasses(static fn (Transaction $transaction) => $transaction->reviewed ? 'bg-primary-300/10' : null)
311 311
             ->defaultSort('posted_at', 'desc')

+ 1
- 2
app/Filament/Company/Resources/Banking/AccountResource.php Visa fil

@@ -82,8 +82,7 @@ class AccountResource extends Resource
82 82
                                     ->localizeLabel()
83 83
                                     ->required(),
84 84
                                 CreateCurrencySelect::make('currency_code')
85
-                                    ->disabledOn('edit')
86
-                                    ->relationship('currency', 'name'),
85
+                                    ->disabledOn('edit'),
87 86
                             ]),
88 87
                         Forms\Components\Group::make()
89 88
                             ->columns()

+ 0
- 1
app/Filament/Company/Resources/Purchases/VendorResource.php Visa fil

@@ -42,7 +42,6 @@ class VendorResource extends Resource
42 42
                                     ->default(VendorType::Regular)
43 43
                                     ->columnSpanFull(),
44 44
                                 CreateCurrencySelect::make('currency_code')
45
-                                    ->relationship('currency', 'name')
46 45
                                     ->nullable()
47 46
                                     ->visible(fn (Forms\Get $get) => VendorType::parse($get('type')) === VendorType::Regular),
48 47
                                 Forms\Components\Select::make('contractor_type')

+ 1
- 2
app/Filament/Company/Resources/Sales/ClientResource.php Visa fil

@@ -167,8 +167,7 @@ class ClientResource extends Resource
167 167
                     ])->columns(1),
168 168
                 Forms\Components\Section::make('Billing')
169 169
                     ->schema([
170
-                        CreateCurrencySelect::make('currency_code')
171
-                            ->relationship('currency', 'name'),
170
+                        CreateCurrencySelect::make('currency_code'),
172 171
                         CustomSection::make('Billing Address')
173 172
                             ->relationship('billingAddress')
174 173
                             ->contained(false)

+ 49
- 21
app/Filament/Company/Resources/Sales/InvoiceResource.php Visa fil

@@ -9,14 +9,18 @@ use App\Enums\Accounting\PaymentMethod;
9 9
 use App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
10 10
 use App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
11 11
 use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
12
+use App\Filament\Forms\Components\CreateCurrencySelect;
12 13
 use App\Filament\Forms\Components\InvoiceTotals;
13 14
 use App\Filament\Tables\Actions\ReplicateBulkAction;
14 15
 use App\Filament\Tables\Filters\DateRangeFilter;
15 16
 use App\Models\Accounting\Adjustment;
16 17
 use App\Models\Accounting\Invoice;
17 18
 use App\Models\Banking\BankAccount;
19
+use App\Models\Common\Client;
18 20
 use App\Models\Common\Offering;
21
+use App\Utilities\Currency\CurrencyAccessor;
19 22
 use App\Utilities\Currency\CurrencyConverter;
23
+use App\Utilities\RateCalculator;
20 24
 use Awcodes\TableRepeater\Components\TableRepeater;
21 25
 use Awcodes\TableRepeater\Header;
22 26
 use Closure;
@@ -97,7 +101,20 @@ class InvoiceResource extends Resource
97 101
                                     ->relationship('client', 'name')
98 102
                                     ->preload()
99 103
                                     ->searchable()
100
-                                    ->required(),
104
+                                    ->required()
105
+                                    ->live()
106
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
107
+                                        if (! $state) {
108
+                                            return;
109
+                                        }
110
+
111
+                                        $currencyCode = Client::find($state)?->currency_code;
112
+
113
+                                        if ($currencyCode) {
114
+                                            $set('currency_code', $currencyCode);
115
+                                        }
116
+                                    }),
117
+                                CreateCurrencySelect::make('currency_code'),
101 118
                             ]),
102 119
                             Forms\Components\Group::make([
103 120
                                 Forms\Components\TextInput::make('invoice_number')
@@ -227,27 +244,36 @@ class InvoiceResource extends Resource
227 244
                                         $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
228 245
                                         $salesTaxes = $get('salesTaxes') ?? [];
229 246
                                         $salesDiscounts = $get('salesDiscounts') ?? [];
247
+                                        $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
230 248
 
231 249
                                         $subtotal = $quantity * $unitPrice;
232 250
 
233
-                                        // Calculate tax amount based on subtotal
234
-                                        $taxAmount = 0;
235
-                                        if (! empty($salesTaxes)) {
236
-                                            $taxRates = Adjustment::whereIn('id', $salesTaxes)->pluck('rate');
237
-                                            $taxAmount = collect($taxRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
238
-                                        }
239
-
240
-                                        // Calculate discount amount based on subtotal
241
-                                        $discountAmount = 0;
242
-                                        if (! empty($salesDiscounts)) {
243
-                                            $discountRates = Adjustment::whereIn('id', $salesDiscounts)->pluck('rate');
244
-                                            $discountAmount = collect($discountRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
245
-                                        }
251
+                                        $subtotalInCents = CurrencyConverter::convertToCents($subtotal, $currencyCode);
252
+
253
+                                        $taxAmountInCents = Adjustment::whereIn('id', $salesTaxes)
254
+                                            ->get()
255
+                                            ->sum(function (Adjustment $adjustment) use ($subtotalInCents) {
256
+                                                if ($adjustment->computation->isPercentage()) {
257
+                                                    return RateCalculator::calculatePercentage($subtotalInCents, $adjustment->getRawOriginal('rate'));
258
+                                                } else {
259
+                                                    return $adjustment->getRawOriginal('rate');
260
+                                                }
261
+                                            });
262
+
263
+                                        $discountAmountInCents = Adjustment::whereIn('id', $salesDiscounts)
264
+                                            ->get()
265
+                                            ->sum(function (Adjustment $adjustment) use ($subtotalInCents) {
266
+                                                if ($adjustment->computation->isPercentage()) {
267
+                                                    return RateCalculator::calculatePercentage($subtotalInCents, $adjustment->getRawOriginal('rate'));
268
+                                                } else {
269
+                                                    return $adjustment->getRawOriginal('rate');
270
+                                                }
271
+                                            });
246 272
 
247 273
                                         // Final total
248
-                                        $total = $subtotal + ($taxAmount - $discountAmount);
274
+                                        $totalInCents = $subtotalInCents + ($taxAmountInCents - $discountAmountInCents);
249 275
 
250
-                                        return CurrencyConverter::formatToMoney($total);
276
+                                        return CurrencyConverter::formatCentsToMoney($totalInCents, $currencyCode);
251 277
                                     }),
252 278
                             ]),
253 279
                         InvoiceTotals::make(),
@@ -291,15 +317,17 @@ class InvoiceResource extends Resource
291 317
                     ->sortable()
292 318
                     ->searchable(),
293 319
                 Tables\Columns\TextColumn::make('total')
294
-                    ->currency()
295
-                    ->sortable(),
320
+                    ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
321
+                    ->sortable()
322
+                    ->toggleable(),
296 323
                 Tables\Columns\TextColumn::make('amount_paid')
297 324
                     ->label('Amount Paid')
298
-                    ->currency()
299
-                    ->sortable(),
325
+                    ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
326
+                    ->sortable()
327
+                    ->toggleable(),
300 328
                 Tables\Columns\TextColumn::make('amount_due')
301 329
                     ->label('Amount Due')
302
-                    ->currency()
330
+                    ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
303 331
                     ->sortable(),
304 332
             ])
305 333
             ->filters([

+ 1
- 1
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php Visa fil

@@ -72,7 +72,7 @@ class ViewInvoice extends ViewRecord
72 72
                                     ->url(static fn (Invoice $record) => ClientResource::getUrl('edit', ['record' => $record->client_id])),
73 73
                                 TextEntry::make('amount_due')
74 74
                                     ->label('Amount Due')
75
-                                    ->money(),
75
+                                    ->currency(static fn (Invoice $record) => $record->currency_code),
76 76
                                 TextEntry::make('due_date')
77 77
                                     ->label('Due')
78 78
                                     ->asRelativeDay(),

+ 15
- 10
app/Filament/Forms/Components/CreateCurrencySelect.php Visa fil

@@ -26,6 +26,20 @@ class CreateCurrencySelect extends Select
26 26
             ->required()
27 27
             ->createOptionForm($this->createCurrencyForm())
28 28
             ->createOptionAction(fn (Action $action) => $this->createCurrencyAction($action));
29
+
30
+        $this->relationship('currency', 'name');
31
+
32
+        $this->createOptionUsing(static function (array $data) {
33
+            return DB::transaction(static function () use ($data) {
34
+                $currency = CreateCurrency::create(
35
+                    $data['code'],
36
+                    $data['name'],
37
+                    $data['rate']
38
+                );
39
+
40
+                return $currency->code;
41
+            });
42
+        });
29 43
     }
30 44
 
31 45
     protected function createCurrencyForm(): array
@@ -56,15 +70,6 @@ class CreateCurrencySelect extends Select
56 70
         return $action
57 71
             ->label('Add Currency')
58 72
             ->slideOver()
59
-            ->modalWidth(MaxWidth::Medium)
60
-            ->action(static function (array $data) {
61
-                return DB::transaction(static function () use ($data) {
62
-                    $code = $data['code'];
63
-                    $name = $data['name'];
64
-                    $rate = $data['rate'];
65
-
66
-                    return CreateCurrency::create($code, $name, $rate);
67
-                });
68
-            });
73
+            ->modalWidth(MaxWidth::Medium);
69 74
     }
70 75
 }

+ 6
- 0
app/Models/Accounting/Bill.php Visa fil

@@ -13,6 +13,7 @@ use App\Enums\Accounting\JournalEntryType;
13 13
 use App\Enums\Accounting\TransactionType;
14 14
 use App\Filament\Company\Resources\Purchases\BillResource;
15 15
 use App\Models\Common\Vendor;
16
+use App\Models\Setting\Currency;
16 17
 use App\Observers\BillObserver;
17 18
 use App\Utilities\Currency\CurrencyConverter;
18 19
 use Filament\Actions\MountableAction;
@@ -75,6 +76,11 @@ class Bill extends Model
75 76
         'amount_due' => MoneyCast::class,
76 77
     ];
77 78
 
79
+    public function currency(): BelongsTo
80
+    {
81
+        return $this->belongsTo(Currency::class, 'currency_code', 'code');
82
+    }
83
+
78 84
     public function vendor(): BelongsTo
79 85
     {
80 86
         return $this->belongsTo(Vendor::class);

+ 44
- 0
app/Models/Accounting/DocumentLineItem.php Visa fil

@@ -12,6 +12,7 @@ use App\Enums\Accounting\AdjustmentType;
12 12
 use App\Models\Common\Offering;
13 13
 use App\Observers\DocumentLineItemObserver;
14 14
 use App\Utilities\Currency\CurrencyAccessor;
15
+use App\Utilities\RateCalculator;
15 16
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
16 17
 use Illuminate\Database\Eloquent\Factories\HasFactory;
17 18
 use Illuminate\Database\Eloquent\Model;
@@ -113,6 +114,21 @@ class DocumentLineItem extends Model
113 114
         );
114 115
     }
115 116
 
117
+    public function calculateTaxTotalAmount(): int
118
+    {
119
+        $subtotalInCents = $this->getRawOriginal('subtotal');
120
+
121
+        return $this->taxes->reduce(function (int $carry, Adjustment $tax) use ($subtotalInCents) {
122
+            if ($tax->computation->isPercentage()) {
123
+                $scaledRate = $tax->getRawOriginal('rate');
124
+
125
+                return $carry + RateCalculator::calculatePercentage($subtotalInCents, $scaledRate);
126
+            } else {
127
+                return $carry + $tax->getRawOriginal('rate');
128
+            }
129
+        }, 0);
130
+    }
131
+
116 132
     public function calculateDiscountTotal(): Money
117 133
     {
118 134
         $subtotal = money($this->subtotal, CurrencyAccessor::getDefaultCurrency());
@@ -123,10 +139,38 @@ class DocumentLineItem extends Model
123 139
         );
124 140
     }
125 141
 
142
+    public function calculateDiscountTotalAmount(): int
143
+    {
144
+        $subtotalInCents = $this->getRawOriginal('subtotal');
145
+
146
+        return $this->discounts->reduce(function (int $carry, Adjustment $discount) use ($subtotalInCents) {
147
+            if ($discount->computation->isPercentage()) {
148
+                $scaledRate = $discount->getRawOriginal('rate');
149
+
150
+                return $carry + RateCalculator::calculatePercentage($subtotalInCents, $scaledRate);
151
+            } else {
152
+                return $carry + $discount->getRawOriginal('rate');
153
+            }
154
+        }, 0);
155
+    }
156
+
126 157
     public function calculateAdjustmentTotal(Adjustment $adjustment): Money
127 158
     {
128 159
         $subtotal = money($this->subtotal, CurrencyAccessor::getDefaultCurrency());
129 160
 
130 161
         return $subtotal->multiply($adjustment->rate / 100);
131 162
     }
163
+
164
+    public function calculateAdjustmentTotalAmount(Adjustment $adjustment): int
165
+    {
166
+        $subtotalInCents = $this->getRawOriginal('subtotal');
167
+
168
+        if ($adjustment->computation->isPercentage()) {
169
+            $scaledRate = $adjustment->getRawOriginal('rate');
170
+
171
+            return RateCalculator::calculatePercentage($subtotalInCents, $scaledRate);
172
+        } else {
173
+            return $adjustment->getRawOriginal('rate');
174
+        }
175
+    }
132 176
 }

+ 41
- 9
app/Models/Accounting/Invoice.php Visa fil

@@ -14,7 +14,9 @@ use App\Enums\Accounting\JournalEntryType;
14 14
 use App\Enums\Accounting\TransactionType;
15 15
 use App\Filament\Company\Resources\Sales\InvoiceResource;
16 16
 use App\Models\Common\Client;
17
+use App\Models\Setting\Currency;
17 18
 use App\Observers\InvoiceObserver;
19
+use App\Utilities\Currency\CurrencyAccessor;
18 20
 use App\Utilities\Currency\CurrencyConverter;
19 21
 use Filament\Actions\Action;
20 22
 use Filament\Actions\MountableAction;
@@ -92,6 +94,11 @@ class Invoice extends Model
92 94
         return $this->belongsTo(Client::class);
93 95
     }
94 96
 
97
+    public function currency(): BelongsTo
98
+    {
99
+        return $this->belongsTo(Currency::class, 'currency_code', 'code');
100
+    }
101
+
95 102
     public function lineItems(): MorphMany
96 103
     {
97 104
         return $this->morphMany(DocumentLineItem::class, 'documentable');
@@ -252,11 +259,13 @@ class Invoice extends Model
252 259
 
253 260
     public function createApprovalTransaction(): void
254 261
     {
262
+        $total = $this->formatAmountToDefaultCurrency($this->getRawOriginal('total'));
263
+
255 264
         $transaction = $this->transactions()->create([
256 265
             'company_id' => $this->company_id,
257 266
             'type' => TransactionType::Journal,
258 267
             'posted_at' => $this->date,
259
-            'amount' => $this->total,
268
+            'amount' => $total,
260 269
             'description' => 'Invoice Approval for Invoice #' . $this->invoice_number,
261 270
         ]);
262 271
 
@@ -266,43 +275,47 @@ class Invoice extends Model
266 275
             'company_id' => $this->company_id,
267 276
             'type' => JournalEntryType::Debit,
268 277
             'account_id' => Account::getAccountsReceivableAccount()->id,
269
-            'amount' => $this->total,
278
+            'amount' => $total,
270 279
             'description' => $baseDescription,
271 280
         ]);
272 281
 
273
-        $totalLineItemSubtotal = (int) $this->lineItems()->sum('subtotal');
274
-        $invoiceDiscountTotalCents = (int) $this->getRawOriginal('discount_total');
282
+        $totalLineItemSubtotalCents = $this->convertAmountToDefaultCurrency((int) $this->lineItems()->sum('subtotal'));
283
+        $invoiceDiscountTotalCents = $this->convertAmountToDefaultCurrency((int) $this->getRawOriginal('discount_total'));
275 284
         $remainingDiscountCents = $invoiceDiscountTotalCents;
276 285
 
277 286
         foreach ($this->lineItems as $index => $lineItem) {
278 287
             $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
279 288
 
289
+            $lineItemSubtotal = $this->formatAmountToDefaultCurrency($lineItem->getRawOriginal('subtotal'));
290
+
280 291
             $transaction->journalEntries()->create([
281 292
                 'company_id' => $this->company_id,
282 293
                 'type' => JournalEntryType::Credit,
283 294
                 'account_id' => $lineItem->offering->income_account_id,
284
-                'amount' => $lineItem->subtotal,
295
+                'amount' => $lineItemSubtotal,
285 296
                 'description' => $lineItemDescription,
286 297
             ]);
287 298
 
288 299
             foreach ($lineItem->adjustments as $adjustment) {
300
+                $adjustmentAmount = $this->formatAmountToDefaultCurrency($lineItem->calculateAdjustmentTotalAmount($adjustment));
301
+
289 302
                 $transaction->journalEntries()->create([
290 303
                     'company_id' => $this->company_id,
291 304
                     'type' => $adjustment->category->isDiscount() ? JournalEntryType::Debit : JournalEntryType::Credit,
292 305
                     'account_id' => $adjustment->account_id,
293
-                    'amount' => $lineItem->calculateAdjustmentTotal($adjustment)->getAmount(),
306
+                    'amount' => $adjustmentAmount,
294 307
                     'description' => $lineItemDescription,
295 308
                 ]);
296 309
             }
297 310
 
298
-            if ($this->discount_method->isPerDocument() && $totalLineItemSubtotal > 0) {
299
-                $lineItemSubtotalCents = (int) $lineItem->getRawOriginal('subtotal');
311
+            if ($this->discount_method->isPerDocument() && $totalLineItemSubtotalCents > 0) {
312
+                $lineItemSubtotalCents = $this->convertAmountToDefaultCurrency((int) $lineItem->getRawOriginal('subtotal'));
300 313
 
301 314
                 if ($index === $this->lineItems->count() - 1) {
302 315
                     $lineItemDiscount = $remainingDiscountCents;
303 316
                 } else {
304 317
                     $lineItemDiscount = (int) round(
305
-                        ($lineItemSubtotalCents / $totalLineItemSubtotal) * $invoiceDiscountTotalCents
318
+                        ($lineItemSubtotalCents / $totalLineItemSubtotalCents) * $invoiceDiscountTotalCents
306 319
                     );
307 320
                     $remainingDiscountCents -= $lineItemDiscount;
308 321
                 }
@@ -331,6 +344,25 @@ class Invoice extends Model
331 344
         $this->createApprovalTransaction();
332 345
     }
333 346
 
347
+    public function convertAmountToDefaultCurrency(int $amountCents): int
348
+    {
349
+        $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
350
+        $needsConversion = $this->currency_code !== $defaultCurrency;
351
+
352
+        if ($needsConversion) {
353
+            return CurrencyConverter::convertBalance($amountCents, $this->currency_code, $defaultCurrency);
354
+        }
355
+
356
+        return $amountCents;
357
+    }
358
+
359
+    public function formatAmountToDefaultCurrency(int $amountCents): string
360
+    {
361
+        $convertedCents = $this->convertAmountToDefaultCurrency($amountCents);
362
+
363
+        return CurrencyConverter::convertCentsToFormatSimple($convertedCents);
364
+    }
365
+
334 366
     public static function getApproveDraftAction(string $action = Action::class): MountableAction
335 367
     {
336 368
         return $action::make('approveDraft')

+ 88
- 0
app/Providers/MacroServiceProvider.php Visa fil

@@ -9,6 +9,7 @@ use App\Models\Accounting\AccountSubtype;
9 9
 use App\Models\Setting\Localization;
10 10
 use App\Utilities\Accounting\AccountCode;
11 11
 use App\Utilities\Currency\CurrencyAccessor;
12
+use App\Utilities\Currency\CurrencyConverter;
12 13
 use BackedEnum;
13 14
 use Carbon\CarbonInterface;
14 15
 use Closure;
@@ -102,6 +103,93 @@ class MacroServiceProvider extends ServiceProvider
102 103
             return $this;
103 104
         });
104 105
 
106
+        TextEntry::macro('currency', function (string | Closure | null $currency = null, ?bool $convert = null): static {
107
+            $currency ??= CurrencyAccessor::getDefaultCurrency();
108
+            $convert ??= true;
109
+
110
+            $this->formatStateUsing(static function (TextEntry $entry, $state) use ($currency, $convert): ?string {
111
+                if (blank($state)) {
112
+                    return null;
113
+                }
114
+
115
+                $currency = $entry->evaluate($currency);
116
+                $convert = $entry->evaluate($convert);
117
+
118
+                return money($state, $currency, $convert)->format();
119
+            });
120
+
121
+            return $this;
122
+        });
123
+
124
+        TextColumn::macro('currencyWithConversion', function (string | Closure | null $currency = null): static {
125
+            $currency ??= CurrencyAccessor::getDefaultCurrency();
126
+
127
+            $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency): ?string {
128
+                if (blank($state)) {
129
+                    return null;
130
+                }
131
+
132
+                $currency = $column->evaluate($currency);
133
+
134
+                return CurrencyConverter::formatToMoney($state, $currency);
135
+            });
136
+
137
+            $this->description(static function (TextColumn $column, $state) use ($currency): ?string {
138
+                if (blank($state)) {
139
+                    return null;
140
+                }
141
+
142
+                $oldCurrency = $column->evaluate($currency);
143
+                $newCurrency = CurrencyAccessor::getDefaultCurrency();
144
+
145
+                if ($oldCurrency === $newCurrency) {
146
+                    return null;
147
+                }
148
+
149
+                $balanceInCents = CurrencyConverter::convertToCents($state, $oldCurrency);
150
+
151
+                $convertedBalanceInCents = CurrencyConverter::convertBalance($balanceInCents, $oldCurrency, $newCurrency);
152
+
153
+                return CurrencyConverter::formatCentsToMoney($convertedBalanceInCents, $newCurrency, true);
154
+            });
155
+
156
+            return $this;
157
+        });
158
+
159
+        TextEntry::macro('currencyWithConversion', function (string | Closure | null $currency = null): static {
160
+            $currency ??= CurrencyAccessor::getDefaultCurrency();
161
+
162
+            $this->formatStateUsing(static function (TextEntry $entry, $state) use ($currency): ?string {
163
+                if (blank($state)) {
164
+                    return null;
165
+                }
166
+
167
+                $currency = $entry->evaluate($currency);
168
+
169
+                return CurrencyConverter::formatToMoney($state, $currency);
170
+            });
171
+
172
+            $this->helperText(static function (TextEntry $entry, $state) use ($currency): ?string {
173
+                if (blank($state)) {
174
+                    return null;
175
+                }
176
+
177
+                $oldCurrency = $entry->evaluate($currency);
178
+                $newCurrency = CurrencyAccessor::getDefaultCurrency();
179
+
180
+                if ($oldCurrency === $newCurrency) {
181
+                    return null;
182
+                }
183
+
184
+                $balanceInCents = CurrencyConverter::convertToCents($state, $oldCurrency);
185
+                $convertedBalanceInCents = CurrencyConverter::convertBalance($balanceInCents, $oldCurrency, $newCurrency);
186
+
187
+                return CurrencyConverter::formatCentsToMoney($convertedBalanceInCents, $newCurrency, true);
188
+            });
189
+
190
+            return $this;
191
+        });
192
+
105 193
         TextInput::macro('rate', function (string | Closure | null $computation = null, bool $showAffix = true): static {
106 194
             $this
107 195
                 ->when(

+ 53
- 0
app/Utilities/RateCalculator.php Visa fil

@@ -0,0 +1,53 @@
1
+<?php
2
+
3
+namespace App\Utilities;
4
+
5
+use App\Enums\Setting\NumberFormat;
6
+use App\Models\Setting\Localization;
7
+
8
+class RateCalculator
9
+{
10
+    public const PRECISION = 4;
11
+
12
+    public const SCALING_FACTOR = 10 ** self::PRECISION;
13
+
14
+    public const PERCENTAGE_SCALING_FACTOR = self::SCALING_FACTOR * 100;
15
+
16
+    public static function calculatePercentage(int $value, int $scaledRate): int
17
+    {
18
+        return (int) round(($value * $scaledRate) / self::PERCENTAGE_SCALING_FACTOR);
19
+    }
20
+
21
+    public static function scaledRateToDecimal(int $scaledRate): float
22
+    {
23
+        return $scaledRate / self::PERCENTAGE_SCALING_FACTOR;
24
+    }
25
+
26
+    public static function decimalToScaledRate(float $decimalRate): int
27
+    {
28
+        return (int) round($decimalRate * self::PERCENTAGE_SCALING_FACTOR);
29
+    }
30
+
31
+    public static function parseLocalizedRate(string $value): int
32
+    {
33
+        $format = Localization::firstOrFail()->number_format->value;
34
+        [$decimalMark, $thousandsSeparator] = NumberFormat::from($format)->getFormattingParameters();
35
+
36
+        $floatValue = (float) str_replace([$thousandsSeparator, $decimalMark], ['', '.'], $value);
37
+
38
+        return (int) round($floatValue * self::SCALING_FACTOR);
39
+    }
40
+
41
+    public static function formatScaledRate(int $scaledRate): string
42
+    {
43
+        $format = Localization::firstOrFail()->number_format->value;
44
+        [$decimalMark, $thousandsSeparator] = NumberFormat::from($format)->getFormattingParameters();
45
+
46
+        $percentageValue = $scaledRate / self::SCALING_FACTOR;
47
+
48
+        $formatted = number_format($percentageValue, self::PRECISION, $decimalMark, $thousandsSeparator);
49
+        $formatted = rtrim($formatted, '0');
50
+
51
+        return rtrim($formatted, $decimalMark);
52
+    }
53
+}

+ 61
- 23
app/View/Models/InvoiceTotalViewModel.php Visa fil

@@ -6,7 +6,9 @@ use App\Enums\Accounting\AdjustmentComputation;
6 6
 use App\Enums\Accounting\DocumentDiscountMethod;
7 7
 use App\Models\Accounting\Adjustment;
8 8
 use App\Models\Accounting\Invoice;
9
+use App\Utilities\Currency\CurrencyAccessor;
9 10
 use App\Utilities\Currency\CurrencyConverter;
11
+use App\Utilities\RateCalculator;
10 12
 
11 13
 class InvoiceTotalViewModel
12 14
 {
@@ -17,62 +19,98 @@ class InvoiceTotalViewModel
17 19
 
18 20
     public function buildViewData(): array
19 21
     {
22
+        $currencyCode = $this->data['currency_code'] ?? CurrencyAccessor::getDefaultCurrency();
23
+        $defaultCurrencyCode = CurrencyAccessor::getDefaultCurrency();
24
+
20 25
         $lineItems = collect($this->data['lineItems'] ?? []);
21 26
 
22
-        $subtotal = $lineItems->sum(static function ($item) {
27
+        $subtotalInCents = $lineItems->sum(static function ($item) use ($currencyCode) {
23 28
             $quantity = max((float) ($item['quantity'] ?? 0), 0);
24 29
             $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
25 30
 
26
-            return $quantity * $unitPrice;
31
+            $subtotal = $quantity * $unitPrice;
32
+
33
+            return CurrencyConverter::convertToCents($subtotal, $currencyCode);
27 34
         });
28 35
 
29
-        $taxTotal = $lineItems->reduce(function ($carry, $item) {
36
+        $taxTotalInCents = $lineItems->reduce(function ($carry, $item) use ($currencyCode) {
30 37
             $quantity = max((float) ($item['quantity'] ?? 0), 0);
31 38
             $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
32 39
             $salesTaxes = $item['salesTaxes'] ?? [];
33 40
             $lineTotal = $quantity * $unitPrice;
34 41
 
35
-            $taxAmount = Adjustment::whereIn('id', $salesTaxes)
36
-                ->pluck('rate')
37
-                ->sum(fn ($rate) => $lineTotal * ($rate / 100));
42
+            $lineTotalInCents = CurrencyConverter::convertToCents($lineTotal, $currencyCode);
43
+
44
+            $taxAmountInCents = Adjustment::whereIn('id', $salesTaxes)
45
+                ->get()
46
+                ->sum(function (Adjustment $adjustment) use ($lineTotalInCents) {
47
+                    if ($adjustment->computation->isPercentage()) {
48
+                        return RateCalculator::calculatePercentage($lineTotalInCents, $adjustment->getRawOriginal('rate'));
49
+                    } else {
50
+                        return $adjustment->getRawOriginal('rate');
51
+                    }
52
+                });
38 53
 
39
-            return $carry + $taxAmount;
54
+            return $carry + $taxAmountInCents;
40 55
         }, 0);
41 56
 
42 57
         // Calculate discount based on method
43 58
         $discountMethod = DocumentDiscountMethod::parse($this->data['discount_method']) ?? DocumentDiscountMethod::PerLineItem;
44 59
 
45 60
         if ($discountMethod->isPerLineItem()) {
46
-            $discountTotal = $lineItems->reduce(function ($carry, $item) {
61
+            $discountTotalInCents = $lineItems->reduce(function ($carry, $item) use ($currencyCode) {
47 62
                 $quantity = max((float) ($item['quantity'] ?? 0), 0);
48 63
                 $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
49 64
                 $salesDiscounts = $item['salesDiscounts'] ?? [];
50
-                $lineTotal = $quantity * $unitPrice;
51
-
52
-                $discountAmount = Adjustment::whereIn('id', $salesDiscounts)
53
-                    ->pluck('rate')
54
-                    ->sum(fn ($rate) => $lineTotal * ($rate / 100));
55
-
56
-                return $carry + $discountAmount;
65
+                $lineTotalInCents = CurrencyConverter::convertToCents($quantity * $unitPrice, $currencyCode);
66
+
67
+                $discountAmountInCents = Adjustment::whereIn('id', $salesDiscounts)
68
+                    ->get()
69
+                    ->sum(function (Adjustment $adjustment) use ($lineTotalInCents) {
70
+                        if ($adjustment->computation->isPercentage()) {
71
+                            return RateCalculator::calculatePercentage($lineTotalInCents, $adjustment->getRawOriginal('rate'));
72
+                        } else {
73
+                            return $adjustment->getRawOriginal('rate');
74
+                        }
75
+                    });
76
+
77
+                return $carry + $discountAmountInCents;
57 78
             }, 0);
58 79
         } else {
59 80
             $discountComputation = AdjustmentComputation::parse($this->data['discount_computation']) ?? AdjustmentComputation::Percentage;
60
-            $discountRate = (float) ($this->data['discount_rate'] ?? 0);
81
+            $discountRate = $this->data['discount_rate'] ?? '0';
61 82
 
62 83
             if ($discountComputation->isPercentage()) {
63
-                $discountTotal = $subtotal * ($discountRate / 100);
84
+                $scaledDiscountRate = RateCalculator::parseLocalizedRate($discountRate);
85
+                $discountTotalInCents = RateCalculator::calculatePercentage($subtotalInCents, $scaledDiscountRate);
64 86
             } else {
65
-                $discountTotal = $discountRate;
87
+                $discountTotalInCents = CurrencyConverter::convertToCents($discountRate, $currencyCode);
66 88
             }
67 89
         }
68 90
 
69
-        $grandTotal = $subtotal + ($taxTotal - $discountTotal);
91
+        $grandTotalInCents = $subtotalInCents + ($taxTotalInCents - $discountTotalInCents);
92
+
93
+        $conversionMessage = null;
94
+
95
+        if ($currencyCode !== $defaultCurrencyCode) {
96
+            $rate = currency($currencyCode)->getRate();
97
+
98
+            $convertedTotalInCents = CurrencyConverter::convertBalance($grandTotalInCents, $currencyCode, $defaultCurrencyCode);
99
+
100
+            $conversionMessage = sprintf(
101
+                'Currency conversion: %s (%s) at an exchange rate of %s',
102
+                CurrencyConverter::formatCentsToMoney($convertedTotalInCents, $defaultCurrencyCode),
103
+                $defaultCurrencyCode,
104
+                $rate
105
+            );
106
+        }
70 107
 
71 108
         return [
72
-            'subtotal' => CurrencyConverter::formatToMoney($subtotal),
73
-            'taxTotal' => CurrencyConverter::formatToMoney($taxTotal),
74
-            'discountTotal' => CurrencyConverter::formatToMoney($discountTotal),
75
-            'grandTotal' => CurrencyConverter::formatToMoney($grandTotal),
109
+            'subtotal' => CurrencyConverter::formatCentsToMoney($subtotalInCents, $currencyCode),
110
+            'taxTotal' => CurrencyConverter::formatCentsToMoney($taxTotalInCents, $currencyCode),
111
+            'discountTotal' => CurrencyConverter::formatCentsToMoney($discountTotalInCents, $currencyCode),
112
+            'grandTotal' => CurrencyConverter::formatCentsToMoney($grandTotalInCents, $currencyCode),
113
+            'conversionMessage' => $conversionMessage,
76 114
         ];
77 115
     }
78 116
 }

+ 1
- 1
config/money.php Visa fil

@@ -1101,7 +1101,7 @@ return [
1101 1101
             'symbol' => 'Fr.',
1102 1102
             'symbol_first' => true,
1103 1103
             'decimal_mark' => '.',
1104
-            'thousands_separator' => ',',
1104
+            'thousands_separator' => "'",
1105 1105
         ],
1106 1106
 
1107 1107
         'MOP' => [

+ 7
- 7
resources/views/filament/company/resources/sales/invoices/components/invoice-view.blade.php Visa fil

@@ -113,8 +113,8 @@
113 113
                         @endif
114 114
                     </td>
115 115
                     <td class="text-center py-3">{{ $item->quantity }}</td>
116
-                    <td class="text-right py-3">{{ CurrencyConverter::formatToMoney($item->unit_price) }}</td>
117
-                    <td class="text-right pr-6 py-3">{{ CurrencyConverter::formatToMoney($item->subtotal) }}</td>
116
+                    <td class="text-right py-3">{{ CurrencyConverter::formatToMoney($item->unit_price, $invoice->currency_code) }}</td>
117
+                    <td class="text-right pr-6 py-3">{{ CurrencyConverter::formatToMoney($item->subtotal, $invoice->currency_code) }}</td>
118 118
                 </tr>
119 119
             @endforeach
120 120
             </tbody>
@@ -122,14 +122,14 @@
122 122
             <tr>
123 123
                 <td class="pl-6 py-2" colspan="2"></td>
124 124
                 <td class="text-right font-semibold py-2">Subtotal:</td>
125
-                <td class="text-right pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->subtotal) }}</td>
125
+                <td class="text-right pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->subtotal, $invoice->currency_code) }}</td>
126 126
             </tr>
127 127
             @if($invoice->discount_total)
128 128
                 <tr class="text-success-800 dark:text-success-600">
129 129
                     <td class="pl-6 py-2" colspan="2"></td>
130 130
                     <td class="text-right py-2">Discount:</td>
131 131
                     <td class="text-right pr-6 py-2">
132
-                        ({{ CurrencyConverter::formatToMoney($invoice->discount_total) }})
132
+                        ({{ CurrencyConverter::formatToMoney($invoice->discount_total, $invoice->currency_code) }})
133 133
                     </td>
134 134
                 </tr>
135 135
             @endif
@@ -137,20 +137,20 @@
137 137
                 <tr>
138 138
                     <td class="pl-6 py-2" colspan="2"></td>
139 139
                     <td class="text-right py-2">Tax:</td>
140
-                    <td class="text-right pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->tax_total) }}</td>
140
+                    <td class="text-right pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->tax_total, $invoice->currency_code) }}</td>
141 141
                 </tr>
142 142
             @endif
143 143
             <tr>
144 144
                 <td class="pl-6 py-2" colspan="2"></td>
145 145
                 <td class="text-right font-semibold border-t py-2">Total:</td>
146
-                <td class="text-right border-t pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->total) }}</td>
146
+                <td class="text-right border-t pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->total, $invoice->currency_code) }}</td>
147 147
             </tr>
148 148
             <tr>
149 149
                 <td class="pl-6 py-2" colspan="2"></td>
150 150
                 <td class="text-right font-semibold border-t-4 border-double py-2">Amount Due
151 151
                     ({{ $invoice->currency_code }}):
152 152
                 </td>
153
-                <td class="text-right border-t-4 border-double pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->amount_due) }}</td>
153
+                <td class="text-right border-t-4 border-double pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->amount_due, $invoice->currency_code) }}</td>
154 154
             </tr>
155 155
             </tfoot>
156 156
         </table>

+ 7
- 0
resources/views/filament/forms/components/invoice-totals.blade.php Visa fil

@@ -56,6 +56,13 @@
56 56
                 <td class="text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Total:</td>
57 57
                 <td class="text-sm pl-4 py-2 leading-6">{{ $grandTotal }}</td>
58 58
             </tr>
59
+            @if($conversionMessage)
60
+                <tr>
61
+                    <td colspan="6" class="text-sm pl-4 py-2 leading-6 text-gray-600">
62
+                        {{ $conversionMessage }}
63
+                    </td>
64
+                </tr>
65
+            @endif
59 66
         </tbody>
60 67
     </table>
61 68
 </div>

Laddar…
Avbryt
Spara