diff --git a/app/Concerns/ManagesLineItems.php b/app/Concerns/ManagesLineItems.php index ae424ea3..2bbb6668 100644 --- a/app/Concerns/ManagesLineItems.php +++ b/app/Concerns/ManagesLineItems.php @@ -4,14 +4,15 @@ use App\Enums\Accounting\AdjustmentComputation; use App\Enums\Accounting\DocumentDiscountMethod; +use App\Models\Accounting\Bill; use App\Models\Accounting\DocumentLineItem; -use App\Models\Accounting\Invoice; use App\Utilities\Currency\CurrencyConverter; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; trait ManagesLineItems { - protected function handleLineItems(Invoice $record, Collection $lineItems): void + protected function handleLineItems(Model $record, Collection $lineItems): void { foreach ($lineItems as $itemData) { $lineItem = isset($itemData['id']) @@ -36,7 +37,7 @@ protected function handleLineItems(Invoice $record, Collection $lineItems): void } } - protected function deleteRemovedLineItems(Invoice $record, Collection $lineItems): void + protected function deleteRemovedLineItems(Model $record, Collection $lineItems): void { $existingLineItemIds = $record->lineItems->pluck('id'); $updatedLineItemIds = $lineItems->pluck('id')->filter(); @@ -52,8 +53,13 @@ protected function deleteRemovedLineItems(Invoice $record, Collection $lineItems protected function handleLineItemAdjustments(DocumentLineItem $lineItem, array $itemData, DocumentDiscountMethod $discountMethod): void { - $adjustmentIds = collect($itemData['salesTaxes'] ?? []) - ->merge($discountMethod->isPerLineItem() ? ($itemData['salesDiscounts'] ?? []) : []) + $isBill = $lineItem->documentable instanceof Bill; + + $taxType = $isBill ? 'purchaseTaxes' : 'salesTaxes'; + $discountType = $isBill ? 'purchaseDiscounts' : 'salesDiscounts'; + + $adjustmentIds = collect($itemData[$taxType] ?? []) + ->merge($discountMethod->isPerLineItem() ? ($itemData[$discountType] ?? []) : []) ->filter() ->unique(); @@ -71,7 +77,7 @@ protected function updateLineItemTotals(DocumentLineItem $lineItem, DocumentDisc ]); } - protected function updateInvoiceTotals(Invoice $record, array $data): array + protected function updateDocumentTotals(Model $record, array $data): array { $subtotalCents = $record->lineItems()->sum('subtotal'); $taxTotalCents = $record->lineItems()->sum('tax_total'); @@ -98,7 +104,7 @@ protected function calculateDiscountTotal( ?AdjustmentComputation $discountComputation, ?string $discountRate, int $subtotalCents, - Invoice $record + Model $record ): int { if ($discountMethod->isPerLineItem()) { return $record->lineItems()->sum('discount_total'); diff --git a/app/Filament/Company/Resources/Purchases/BillResource.php b/app/Filament/Company/Resources/Purchases/BillResource.php index 4a6f9852..e935e363 100644 --- a/app/Filament/Company/Resources/Purchases/BillResource.php +++ b/app/Filament/Company/Resources/Purchases/BillResource.php @@ -3,13 +3,14 @@ namespace App\Filament\Company\Resources\Purchases; use App\Enums\Accounting\BillStatus; +use App\Enums\Accounting\DocumentDiscountMethod; use App\Enums\Accounting\PaymentMethod; use App\Filament\Company\Resources\Purchases\BillResource\Pages; +use App\Filament\Forms\Components\BillTotals; use App\Filament\Tables\Actions\ReplicateBulkAction; use App\Filament\Tables\Filters\DateRangeFilter; use App\Models\Accounting\Adjustment; use App\Models\Accounting\Bill; -use App\Models\Accounting\DocumentLineItem; use App\Models\Banking\BankAccount; use App\Models\Common\Offering; use App\Utilities\Currency\CurrencyConverter; @@ -26,7 +27,6 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Auth; class BillResource extends Resource @@ -71,129 +71,44 @@ public static function form(Form $form): Form return now()->addDays($company->defaultBill->payment_terms->getDays()); }) ->required(), + Forms\Components\Select::make('discount_method') + ->label('Discount Method') + ->options(DocumentDiscountMethod::class) + ->selectablePlaceholder(false) + ->default(DocumentDiscountMethod::PerLineItem) + ->afterStateUpdated(function ($state, Forms\Set $set) { + $discountMethod = DocumentDiscountMethod::parse($state); + + if ($discountMethod->isPerDocument()) { + $set('lineItems.*.purchaseDiscounts', []); + } + }) + ->live(), ])->grow(true), ])->from('md'), TableRepeater::make('lineItems') ->relationship() - ->saveRelationshipsUsing(function (TableRepeater $component, Forms\Contracts\HasForms $livewire, ?array $state) { - if (! is_array($state)) { - $state = []; - } - - $relationship = $component->getRelationship(); - - $existingRecords = $component->getCachedExistingRecords(); - - $recordsToDelete = []; - - foreach ($existingRecords->pluck($relationship->getRelated()->getKeyName()) as $keyToCheckForDeletion) { - if (array_key_exists("record-{$keyToCheckForDeletion}", $state)) { - continue; - } - - $recordsToDelete[] = $keyToCheckForDeletion; - $existingRecords->forget("record-{$keyToCheckForDeletion}"); + ->saveRelationshipsUsing(null) + ->dehydrated(true) + ->headers(function (Forms\Get $get) { + $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem(); + + $headers = [ + Header::make('Items')->width($hasDiscounts ? '15%' : '20%'), + Header::make('Description')->width($hasDiscounts ? '25%' : '30%'), // Increase when no discounts + Header::make('Quantity')->width('10%'), + Header::make('Price')->width('10%'), + Header::make('Taxes')->width($hasDiscounts ? '15%' : '20%'), // Increase when no discounts + ]; + + if ($hasDiscounts) { + $headers[] = Header::make('Discounts')->width('15%'); } - $relationship - ->whereKey($recordsToDelete) - ->get() - ->each(static fn (Model $record) => $record->delete()); - - $childComponentContainers = $component->getChildComponentContainers( - withHidden: $component->shouldSaveRelationshipsWhenHidden(), - ); - - $itemOrder = 1; - $orderColumn = $component->getOrderColumn(); - - $translatableContentDriver = $livewire->makeFilamentTranslatableContentDriver(); - - foreach ($childComponentContainers as $itemKey => $item) { - $itemData = $item->getState(shouldCallHooksBefore: false); - - if ($orderColumn) { - $itemData[$orderColumn] = $itemOrder; - - $itemOrder++; - } - - if ($record = ($existingRecords[$itemKey] ?? null)) { - $itemData = $component->mutateRelationshipDataBeforeSave($itemData, record: $record); - - if ($itemData === null) { - continue; - } - - $translatableContentDriver ? - $translatableContentDriver->updateRecord($record, $itemData) : - $record->fill($itemData)->save(); - - continue; - } - - $relatedModel = $component->getRelatedModel(); - - $itemData = $component->mutateRelationshipDataBeforeCreate($itemData); - - if ($itemData === null) { - continue; - } - - if ($translatableContentDriver) { - $record = $translatableContentDriver->makeRecord($relatedModel, $itemData); - } else { - $record = new $relatedModel; - $record->fill($itemData); - } + $headers[] = Header::make('Amount')->width('10%')->align('right'); - $record = $relationship->save($record); - $item->model($record)->saveRelationships(); - $existingRecords->push($record); - } - - $component->getRecord()->setRelation($component->getRelationshipName(), $existingRecords); - - /** @var Bill $bill */ - $bill = $component->getRecord(); - - // Recalculate totals for line items - $bill->lineItems()->each(function (DocumentLineItem $lineItem) { - $lineItem->updateQuietly([ - 'tax_total' => $lineItem->calculateTaxTotal()->getAmount(), - 'discount_total' => $lineItem->calculateDiscountTotal()->getAmount(), - ]); - }); - - $subtotal = $bill->lineItems()->sum('subtotal') / 100; - $taxTotal = $bill->lineItems()->sum('tax_total') / 100; - $discountTotal = $bill->lineItems()->sum('discount_total') / 100; - $grandTotal = $subtotal + $taxTotal - $discountTotal; - - $bill->updateQuietly([ - 'subtotal' => $subtotal, - 'tax_total' => $taxTotal, - 'discount_total' => $discountTotal, - 'total' => $grandTotal, - ]); - - $bill->refresh(); - - if (! $bill->initialTransaction) { - $bill->createInitialTransaction(); - } else { - $bill->updateInitialTransaction(); - } + return $headers; }) - ->headers([ - Header::make('Items')->width('15%'), - Header::make('Description')->width('25%'), - Header::make('Quantity')->width('10%'), - Header::make('Price')->width('10%'), - Header::make('Taxes')->width('15%'), - Header::make('Discounts')->width('15%'), - Header::make('Amount')->width('10%')->align('right'), - ]) ->schema([ Forms\Components\Select::make('offering_id') ->relationship('purchasableOffering', 'name') @@ -209,7 +124,11 @@ public static function form(Form $form): Form $set('description', $offeringRecord->description); $set('unit_price', $offeringRecord->price); $set('purchaseTaxes', $offeringRecord->purchaseTaxes->pluck('id')->toArray()); - $set('purchaseDiscounts', $offeringRecord->purchaseDiscounts->pluck('id')->toArray()); + + $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method')); + if ($discountMethod->isPerLineItem()) { + $set('purchaseDiscounts', $offeringRecord->purchaseDiscounts->pluck('id')->toArray()); + } } }), Forms\Components\TextInput::make('description'), @@ -225,15 +144,24 @@ public static function form(Form $form): Form ->default(0), Forms\Components\Select::make('purchaseTaxes') ->relationship('purchaseTaxes', 'name') + ->saveRelationshipsUsing(null) + ->dehydrated(true) ->preload() ->multiple() ->live() ->searchable(), Forms\Components\Select::make('purchaseDiscounts') ->relationship('purchaseDiscounts', 'name') + ->saveRelationshipsUsing(null) + ->dehydrated(true) ->preload() ->multiple() ->live() + ->hidden(function (Forms\Get $get) { + $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method')); + + return $discountMethod->isPerDocument(); + }) ->searchable(), Forms\Components\Placeholder::make('total') ->hiddenLabel() @@ -265,13 +193,7 @@ public static function form(Form $form): Form return CurrencyConverter::formatToMoney($total); }), ]), - Forms\Components\Grid::make(6) - ->schema([ - Forms\Components\ViewField::make('totals') - ->columnStart(5) - ->columnSpan(2) - ->view('filament.forms.components.bill-totals'), - ]), + BillTotals::make(), ]), ]); } @@ -281,6 +203,11 @@ public static function table(Table $table): Table return $table ->defaultSort('due_date') ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('ID') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true) + ->searchable(), Tables\Columns\TextColumn::make('status') ->badge() ->searchable(), diff --git a/app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php b/app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php index 4e708d96..6128fde4 100644 --- a/app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php +++ b/app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php @@ -2,13 +2,17 @@ namespace App\Filament\Company\Resources\Purchases\BillResource\Pages; +use App\Concerns\ManagesLineItems; use App\Concerns\RedirectToListPage; use App\Filament\Company\Resources\Purchases\BillResource; +use App\Models\Accounting\Bill; use Filament\Resources\Pages\CreateRecord; use Filament\Support\Enums\MaxWidth; +use Illuminate\Database\Eloquent\Model; class CreateBill extends CreateRecord { + use ManagesLineItems; use RedirectToListPage; protected static string $resource = BillResource::class; @@ -17,4 +21,22 @@ public function getMaxContentWidth(): MaxWidth | string | null { return MaxWidth::Full; } + + protected function handleRecordCreation(array $data): Model + { + /** @var Bill $record */ + $record = parent::handleRecordCreation($data); + + $this->handleLineItems($record, collect($data['lineItems'] ?? [])); + + $totals = $this->updateDocumentTotals($record, $data); + + $record->updateQuietly($totals); + + if (! $record->initialTransaction) { + $record->createInitialTransaction(); + } + + return $record; + } } diff --git a/app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php b/app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php index 2f38dded..488e22b9 100644 --- a/app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php +++ b/app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php @@ -2,14 +2,18 @@ namespace App\Filament\Company\Resources\Purchases\BillResource\Pages; +use App\Concerns\ManagesLineItems; use App\Concerns\RedirectToListPage; use App\Filament\Company\Resources\Purchases\BillResource; +use App\Models\Accounting\Bill; use Filament\Actions; use Filament\Resources\Pages\EditRecord; use Filament\Support\Enums\MaxWidth; +use Illuminate\Database\Eloquent\Model; class EditBill extends EditRecord { + use ManagesLineItems; use RedirectToListPage; protected static string $resource = BillResource::class; @@ -25,4 +29,28 @@ public function getMaxContentWidth(): MaxWidth | string | null { return MaxWidth::Full; } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + /** @var Bill $record */ + $lineItems = collect($data['lineItems'] ?? []); + + $this->deleteRemovedLineItems($record, $lineItems); + + $this->handleLineItems($record, $lineItems); + + $totals = $this->updateDocumentTotals($record, $data); + + $data = array_merge($data, $totals); + + $record = parent::handleRecordUpdate($record, $data); + + if (! $record->initialTransaction) { + $record->createInitialTransaction(); + } else { + $record->updateInitialTransaction(); + } + + return $record; + } } diff --git a/app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php b/app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php index 09eff936..c58a969e 100644 --- a/app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php +++ b/app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php @@ -29,7 +29,7 @@ protected function handleRecordCreation(array $data): Model $this->handleLineItems($record, collect($data['lineItems'] ?? [])); - $totals = $this->updateInvoiceTotals($record, $data); + $totals = $this->updateDocumentTotals($record, $data); $record->updateQuietly($totals); diff --git a/app/Filament/Company/Resources/Sales/InvoiceResource/Pages/EditInvoice.php b/app/Filament/Company/Resources/Sales/InvoiceResource/Pages/EditInvoice.php index 9df5584c..27db7329 100644 --- a/app/Filament/Company/Resources/Sales/InvoiceResource/Pages/EditInvoice.php +++ b/app/Filament/Company/Resources/Sales/InvoiceResource/Pages/EditInvoice.php @@ -39,7 +39,7 @@ protected function handleRecordUpdate(Model $record, array $data): Model $this->handleLineItems($record, $lineItems); - $totals = $this->updateInvoiceTotals($record, $data); + $totals = $this->updateDocumentTotals($record, $data); $data = array_merge($data, $totals); diff --git a/app/Filament/Forms/Components/BillTotals.php b/app/Filament/Forms/Components/BillTotals.php new file mode 100644 index 00000000..06409f94 --- /dev/null +++ b/app/Filament/Forms/Components/BillTotals.php @@ -0,0 +1,37 @@ +schema([ + TextInput::make('discount_rate') + ->label('Discount Rate') + ->hiddenLabel() + ->live() + ->rate(computation: static fn (Get $get) => $get('discount_computation'), showAffix: false), + Select::make('discount_computation') + ->label('Discount Computation') + ->hiddenLabel() + ->options([ + 'percentage' => '%', + 'fixed' => '$', + ]) + ->default(AdjustmentComputation::Percentage) + ->selectablePlaceholder(false) + ->live(), + ]); + } +} diff --git a/app/Models/Accounting/Account.php b/app/Models/Accounting/Account.php index de519928..f33c9b57 100644 --- a/app/Models/Accounting/Account.php +++ b/app/Models/Accounting/Account.php @@ -138,6 +138,11 @@ public static function getSalesDiscountAccount(): self return self::where('name', 'Sales Discount')->firstOrFail(); } + public static function getPurchaseDiscountAccount(): self + { + return self::where('name', 'Purchase Discount')->firstOrFail(); + } + protected static function newFactory(): Factory { return AccountFactory::new(); diff --git a/app/Models/Accounting/Bill.php b/app/Models/Accounting/Bill.php index 465d9e14..2f7eafa4 100644 --- a/app/Models/Accounting/Bill.php +++ b/app/Models/Accounting/Bill.php @@ -14,6 +14,7 @@ use App\Filament\Company\Resources\Purchases\BillResource; use App\Models\Common\Vendor; use App\Observers\BillObserver; +use App\Utilities\Currency\CurrencyConverter; use Filament\Actions\MountableAction; use Filament\Actions\ReplicateAction; use Illuminate\Database\Eloquent\Attributes\ObservedBy; @@ -224,7 +225,11 @@ public function createInitialTransaction(?Carbon $postedAt = null): void 'description' => $baseDescription, ]); - foreach ($this->lineItems as $lineItem) { + $totalLineItemSubtotal = (int) $this->lineItems()->sum('subtotal'); + $billDiscountTotalCents = (int) $this->getRawOriginal('discount_total'); + $remainingDiscountCents = $billDiscountTotalCents; + + foreach ($this->lineItems as $index => $lineItem) { $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}"; $transaction->journalEntries()->create([ @@ -254,6 +259,29 @@ public function createInitialTransaction(?Carbon $postedAt = null): void ]); } } + + if ($this->discount_method->isPerDocument() && $totalLineItemSubtotal > 0) { + $lineItemSubtotalCents = (int) $lineItem->getRawOriginal('subtotal'); + + if ($index === $this->lineItems->count() - 1) { + $lineItemDiscount = $remainingDiscountCents; + } else { + $lineItemDiscount = (int) round( + ($lineItemSubtotalCents / $totalLineItemSubtotal) * $billDiscountTotalCents + ); + $remainingDiscountCents -= $lineItemDiscount; + } + + if ($lineItemDiscount > 0) { + $transaction->journalEntries()->create([ + 'company_id' => $this->company_id, + 'type' => JournalEntryType::Credit, + 'account_id' => Account::getPurchaseDiscountAccount()->id, + 'amount' => CurrencyConverter::convertCentsToFormatSimple($lineItemDiscount), + 'description' => "{$lineItemDescription} (Proportional Discount)", + ]); + } + } } } diff --git a/app/View/Models/BillTotalViewModel.php b/app/View/Models/BillTotalViewModel.php index cdd12678..fcfd0847 100644 --- a/app/View/Models/BillTotalViewModel.php +++ b/app/View/Models/BillTotalViewModel.php @@ -2,6 +2,8 @@ namespace App\View\Models; +use App\Enums\Accounting\AdjustmentComputation; +use App\Enums\Accounting\DocumentDiscountMethod; use App\Models\Accounting\Adjustment; use App\Models\Accounting\Bill; use App\Utilities\Currency\CurrencyConverter; @@ -17,7 +19,7 @@ public function buildViewData(): array { $lineItems = collect($this->data['lineItems'] ?? []); - $subtotal = $lineItems->sum(function ($item) { + $subtotal = $lineItems->sum(static function ($item) { $quantity = max((float) ($item['quantity'] ?? 0), 0); $unitPrice = max((float) ($item['unit_price'] ?? 0), 0); @@ -37,18 +39,32 @@ public function buildViewData(): array return $carry + $taxAmount; }, 0); - $discountTotal = $lineItems->reduce(function ($carry, $item) { - $quantity = max((float) ($item['quantity'] ?? 0), 0); - $unitPrice = max((float) ($item['unit_price'] ?? 0), 0); - $purchaseDiscounts = $item['purchaseDiscounts'] ?? []; - $lineTotal = $quantity * $unitPrice; + // Calculate discount based on method + $discountMethod = DocumentDiscountMethod::parse($this->data['discount_method']) ?? DocumentDiscountMethod::PerLineItem; - $discountAmount = Adjustment::whereIn('id', $purchaseDiscounts) - ->pluck('rate') - ->sum(fn ($rate) => $lineTotal * ($rate / 100)); + if ($discountMethod->isPerLineItem()) { + $discountTotal = $lineItems->reduce(function ($carry, $item) { + $quantity = max((float) ($item['quantity'] ?? 0), 0); + $unitPrice = max((float) ($item['unit_price'] ?? 0), 0); + $purchaseDiscounts = $item['purchaseDiscounts'] ?? []; + $lineTotal = $quantity * $unitPrice; - return $carry + $discountAmount; - }, 0); + $discountAmount = Adjustment::whereIn('id', $purchaseDiscounts) + ->pluck('rate') + ->sum(fn ($rate) => $lineTotal * ($rate / 100)); + + return $carry + $discountAmount; + }, 0); + } else { + $discountComputation = AdjustmentComputation::parse($this->data['discount_computation']) ?? AdjustmentComputation::Percentage; + $discountRate = (float) ($this->data['discount_rate'] ?? 0); + + if ($discountComputation->isPercentage()) { + $discountTotal = $subtotal * ($discountRate / 100); + } else { + $discountTotal = $discountRate; + } + } $grandTotal = $subtotal + ($taxTotal - $discountTotal); diff --git a/resources/views/filament/forms/components/bill-totals.blade.php b/resources/views/filament/forms/components/bill-totals.blade.php index 676a86ef..e0c3a85e 100644 --- a/resources/views/filament/forms/components/bill-totals.blade.php +++ b/resources/views/filament/forms/components/bill-totals.blade.php @@ -1,34 +1,61 @@ -@use('App\Utilities\Currency\CurrencyAccessor') - @php + use App\Enums\Accounting\DocumentDiscountMethod; + use App\Utilities\Currency\CurrencyAccessor; + use App\View\Models\BillTotalViewModel; + $data = $this->form->getRawState(); - $viewModel = new \App\View\Models\BillTotalViewModel($this->record, $data); - extract($viewModel->buildViewData(), \EXTR_SKIP); + $viewModel = new BillTotalViewModel($this->record, $data); + extract($viewModel->buildViewData(), EXTR_SKIP); + + $discountMethod = DocumentDiscountMethod::parse($data['discount_method']); + $isPerDocumentDiscount = $discountMethod->isPerDocument(); @endphp
Subtotal: | -{{ $subtotal }} | -||||||
Taxes: | -{{ $taxTotal }} | ++ | Subtotal: | +{{ $subtotal }} | |||
Discounts: | -({{ $discountTotal }}) | ++ | Taxes: | +{{ $taxTotal }} | |||
Discount: | +
+
+ @foreach($getChildComponentContainer()->getComponents() as $component)
+
+ {{ $component }}
+ @endforeach
+ |
+ ({{ $discountTotal }}) | +|||||
+ | Discounts: | +({{ $discountTotal }}) | +|||||
Total: | -{{ $grandTotal }} | ++ | Total: | +{{ $grandTotal }} |