You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

BillResource.php 26KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. <?php
  2. namespace App\Filament\Company\Resources\Purchases;
  3. use App\Enums\Accounting\BillStatus;
  4. use App\Enums\Accounting\DocumentDiscountMethod;
  5. use App\Enums\Accounting\PaymentMethod;
  6. use App\Filament\Company\Resources\Purchases\BillResource\Pages;
  7. use App\Filament\Forms\Components\BillTotals;
  8. use App\Filament\Tables\Actions\ReplicateBulkAction;
  9. use App\Filament\Tables\Filters\DateRangeFilter;
  10. use App\Models\Accounting\Adjustment;
  11. use App\Models\Accounting\Bill;
  12. use App\Models\Banking\BankAccount;
  13. use App\Models\Common\Offering;
  14. use App\Utilities\Currency\CurrencyConverter;
  15. use Awcodes\TableRepeater\Components\TableRepeater;
  16. use Awcodes\TableRepeater\Header;
  17. use Closure;
  18. use Filament\Forms;
  19. use Filament\Forms\Form;
  20. use Filament\Notifications\Notification;
  21. use Filament\Resources\Resource;
  22. use Filament\Support\Enums\Alignment;
  23. use Filament\Support\Enums\MaxWidth;
  24. use Filament\Tables;
  25. use Filament\Tables\Table;
  26. use Illuminate\Database\Eloquent\Builder;
  27. use Illuminate\Database\Eloquent\Collection;
  28. use Illuminate\Support\Facades\Auth;
  29. class BillResource extends Resource
  30. {
  31. protected static ?string $model = Bill::class;
  32. protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
  33. public static function form(Form $form): Form
  34. {
  35. $company = Auth::user()->currentCompany;
  36. return $form
  37. ->schema([
  38. Forms\Components\Section::make('Bill Details')
  39. ->schema([
  40. Forms\Components\Split::make([
  41. Forms\Components\Group::make([
  42. Forms\Components\Select::make('vendor_id')
  43. ->relationship('vendor', 'name')
  44. ->preload()
  45. ->searchable()
  46. ->required(),
  47. ]),
  48. Forms\Components\Group::make([
  49. Forms\Components\TextInput::make('bill_number')
  50. ->label('Bill Number')
  51. ->default(fn () => Bill::getNextDocumentNumber())
  52. ->required(),
  53. Forms\Components\TextInput::make('order_number')
  54. ->label('P.O/S.O Number'),
  55. Forms\Components\DatePicker::make('date')
  56. ->label('Bill Date')
  57. ->default(now())
  58. ->disabled(function (?Bill $record) {
  59. return $record?->hasPayments();
  60. })
  61. ->required(),
  62. Forms\Components\DatePicker::make('due_date')
  63. ->label('Due Date')
  64. ->default(function () use ($company) {
  65. return now()->addDays($company->defaultBill->payment_terms->getDays());
  66. })
  67. ->required(),
  68. Forms\Components\Select::make('discount_method')
  69. ->label('Discount Method')
  70. ->options(DocumentDiscountMethod::class)
  71. ->selectablePlaceholder(false)
  72. ->default(DocumentDiscountMethod::PerLineItem)
  73. ->afterStateUpdated(function ($state, Forms\Set $set) {
  74. $discountMethod = DocumentDiscountMethod::parse($state);
  75. if ($discountMethod->isPerDocument()) {
  76. $set('lineItems.*.purchaseDiscounts', []);
  77. }
  78. })
  79. ->live(),
  80. ])->grow(true),
  81. ])->from('md'),
  82. TableRepeater::make('lineItems')
  83. ->relationship()
  84. ->saveRelationshipsUsing(null)
  85. ->dehydrated(true)
  86. ->headers(function (Forms\Get $get) {
  87. $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
  88. $headers = [
  89. Header::make('Items')->width($hasDiscounts ? '15%' : '20%'),
  90. Header::make('Description')->width($hasDiscounts ? '25%' : '30%'), // Increase when no discounts
  91. Header::make('Quantity')->width('10%'),
  92. Header::make('Price')->width('10%'),
  93. Header::make('Taxes')->width($hasDiscounts ? '15%' : '20%'), // Increase when no discounts
  94. ];
  95. if ($hasDiscounts) {
  96. $headers[] = Header::make('Discounts')->width('15%');
  97. }
  98. $headers[] = Header::make('Amount')->width('10%')->align('right');
  99. return $headers;
  100. })
  101. ->schema([
  102. Forms\Components\Select::make('offering_id')
  103. ->relationship('purchasableOffering', 'name')
  104. ->preload()
  105. ->searchable()
  106. ->required()
  107. ->live()
  108. ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
  109. $offeringId = $state;
  110. $offeringRecord = Offering::with(['purchaseTaxes', 'purchaseDiscounts'])->find($offeringId);
  111. if ($offeringRecord) {
  112. $set('description', $offeringRecord->description);
  113. $set('unit_price', $offeringRecord->price);
  114. $set('purchaseTaxes', $offeringRecord->purchaseTaxes->pluck('id')->toArray());
  115. $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
  116. if ($discountMethod->isPerLineItem()) {
  117. $set('purchaseDiscounts', $offeringRecord->purchaseDiscounts->pluck('id')->toArray());
  118. }
  119. }
  120. }),
  121. Forms\Components\TextInput::make('description'),
  122. Forms\Components\TextInput::make('quantity')
  123. ->required()
  124. ->numeric()
  125. ->live()
  126. ->default(1),
  127. Forms\Components\TextInput::make('unit_price')
  128. ->hiddenLabel()
  129. ->numeric()
  130. ->live()
  131. ->default(0),
  132. Forms\Components\Select::make('purchaseTaxes')
  133. ->relationship('purchaseTaxes', 'name')
  134. ->saveRelationshipsUsing(null)
  135. ->dehydrated(true)
  136. ->preload()
  137. ->multiple()
  138. ->live()
  139. ->searchable(),
  140. Forms\Components\Select::make('purchaseDiscounts')
  141. ->relationship('purchaseDiscounts', 'name')
  142. ->saveRelationshipsUsing(null)
  143. ->dehydrated(true)
  144. ->preload()
  145. ->multiple()
  146. ->live()
  147. ->hidden(function (Forms\Get $get) {
  148. $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
  149. return $discountMethod->isPerDocument();
  150. })
  151. ->searchable(),
  152. Forms\Components\Placeholder::make('total')
  153. ->hiddenLabel()
  154. ->content(function (Forms\Get $get) {
  155. $quantity = max((float) ($get('quantity') ?? 0), 0);
  156. $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
  157. $purchaseTaxes = $get('purchaseTaxes') ?? [];
  158. $purchaseDiscounts = $get('purchaseDiscounts') ?? [];
  159. $subtotal = $quantity * $unitPrice;
  160. // Calculate tax amount based on subtotal
  161. $taxAmount = 0;
  162. if (! empty($purchaseTaxes)) {
  163. $taxRates = Adjustment::whereIn('id', $purchaseTaxes)->pluck('rate');
  164. $taxAmount = collect($taxRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
  165. }
  166. // Calculate discount amount based on subtotal
  167. $discountAmount = 0;
  168. if (! empty($purchaseDiscounts)) {
  169. $discountRates = Adjustment::whereIn('id', $purchaseDiscounts)->pluck('rate');
  170. $discountAmount = collect($discountRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
  171. }
  172. // Final total
  173. $total = $subtotal + ($taxAmount - $discountAmount);
  174. return CurrencyConverter::formatToMoney($total);
  175. }),
  176. ]),
  177. BillTotals::make(),
  178. ]),
  179. ]);
  180. }
  181. public static function table(Table $table): Table
  182. {
  183. return $table
  184. ->defaultSort('due_date')
  185. ->columns([
  186. Tables\Columns\TextColumn::make('id')
  187. ->label('ID')
  188. ->sortable()
  189. ->toggleable(isToggledHiddenByDefault: true)
  190. ->searchable(),
  191. Tables\Columns\TextColumn::make('status')
  192. ->badge()
  193. ->searchable(),
  194. Tables\Columns\TextColumn::make('due_date')
  195. ->label('Due')
  196. ->asRelativeDay()
  197. ->sortable(),
  198. Tables\Columns\TextColumn::make('date')
  199. ->date()
  200. ->sortable(),
  201. Tables\Columns\TextColumn::make('bill_number')
  202. ->label('Number')
  203. ->searchable()
  204. ->sortable(),
  205. Tables\Columns\TextColumn::make('vendor.name')
  206. ->sortable(),
  207. Tables\Columns\TextColumn::make('total')
  208. ->currency()
  209. ->sortable(),
  210. Tables\Columns\TextColumn::make('amount_paid')
  211. ->label('Amount Paid')
  212. ->currency()
  213. ->sortable(),
  214. Tables\Columns\TextColumn::make('amount_due')
  215. ->label('Amount Due')
  216. ->currency()
  217. ->sortable(),
  218. ])
  219. ->filters([
  220. Tables\Filters\SelectFilter::make('vendor')
  221. ->relationship('vendor', 'name')
  222. ->searchable()
  223. ->preload(),
  224. Tables\Filters\SelectFilter::make('status')
  225. ->options(BillStatus::class)
  226. ->native(false),
  227. Tables\Filters\TernaryFilter::make('has_payments')
  228. ->label('Has Payments')
  229. ->queries(
  230. true: fn (Builder $query) => $query->whereHas('payments'),
  231. false: fn (Builder $query) => $query->whereDoesntHave('payments'),
  232. ),
  233. DateRangeFilter::make('date')
  234. ->fromLabel('From Date')
  235. ->untilLabel('To Date')
  236. ->indicatorLabel('Date'),
  237. DateRangeFilter::make('due_date')
  238. ->fromLabel('From Due Date')
  239. ->untilLabel('To Due Date')
  240. ->indicatorLabel('Due'),
  241. ])
  242. ->actions([
  243. Tables\Actions\ActionGroup::make([
  244. Tables\Actions\EditAction::make(),
  245. Tables\Actions\ViewAction::make(),
  246. Tables\Actions\DeleteAction::make(),
  247. Bill::getReplicateAction(Tables\Actions\ReplicateAction::class),
  248. Tables\Actions\Action::make('recordPayment')
  249. ->label('Record Payment')
  250. ->stickyModalHeader()
  251. ->stickyModalFooter()
  252. ->modalFooterActionsAlignment(Alignment::End)
  253. ->modalWidth(MaxWidth::TwoExtraLarge)
  254. ->icon('heroicon-o-credit-card')
  255. ->visible(function (Bill $record) {
  256. return $record->canRecordPayment();
  257. })
  258. ->mountUsing(function (Bill $record, Form $form) {
  259. $form->fill([
  260. 'posted_at' => now(),
  261. 'amount' => $record->amount_due,
  262. ]);
  263. })
  264. ->databaseTransaction()
  265. ->successNotificationTitle('Payment Recorded')
  266. ->form([
  267. Forms\Components\DatePicker::make('posted_at')
  268. ->label('Date'),
  269. Forms\Components\TextInput::make('amount')
  270. ->label('Amount')
  271. ->required()
  272. ->money()
  273. ->live(onBlur: true)
  274. ->helperText(function (Bill $record, $state) {
  275. if (! CurrencyConverter::isValidAmount($state)) {
  276. return null;
  277. }
  278. $amountDue = $record->getRawOriginal('amount_due');
  279. $amount = CurrencyConverter::convertToCents($state);
  280. if ($amount <= 0) {
  281. return 'Please enter a valid positive amount';
  282. }
  283. $newAmountDue = $amountDue - $amount;
  284. return match (true) {
  285. $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue),
  286. $newAmountDue === 0 => 'Bill will be fully paid',
  287. default => 'Amount exceeds bill total by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue)),
  288. };
  289. })
  290. ->rules([
  291. static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
  292. if (! CurrencyConverter::isValidAmount($value)) {
  293. $fail('Please enter a valid amount');
  294. }
  295. },
  296. ]),
  297. Forms\Components\Select::make('payment_method')
  298. ->label('Payment Method')
  299. ->required()
  300. ->options(PaymentMethod::class),
  301. Forms\Components\Select::make('bank_account_id')
  302. ->label('Account')
  303. ->required()
  304. ->options(BankAccount::query()
  305. ->get()
  306. ->pluck('account.name', 'id'))
  307. ->searchable(),
  308. Forms\Components\Textarea::make('notes')
  309. ->label('Notes'),
  310. ])
  311. ->action(function (Bill $record, Tables\Actions\Action $action, array $data) {
  312. $record->recordPayment($data);
  313. $action->success();
  314. }),
  315. ]),
  316. ])
  317. ->bulkActions([
  318. Tables\Actions\BulkActionGroup::make([
  319. Tables\Actions\DeleteBulkAction::make(),
  320. ReplicateBulkAction::make()
  321. ->label('Replicate')
  322. ->modalWidth(MaxWidth::Large)
  323. ->modalDescription('Replicating bills will also replicate their line items. Are you sure you want to proceed?')
  324. ->successNotificationTitle('Bills Replicated Successfully')
  325. ->failureNotificationTitle('Failed to Replicate Bills')
  326. ->databaseTransaction()
  327. ->deselectRecordsAfterCompletion()
  328. ->excludeAttributes([
  329. 'status',
  330. 'amount_paid',
  331. 'amount_due',
  332. 'created_by',
  333. 'updated_by',
  334. 'created_at',
  335. 'updated_at',
  336. 'bill_number',
  337. 'date',
  338. 'due_date',
  339. 'paid_at',
  340. ])
  341. ->beforeReplicaSaved(function (Bill $replica) {
  342. $replica->status = BillStatus::Unpaid;
  343. $replica->bill_number = Bill::getNextDocumentNumber();
  344. $replica->date = now();
  345. $replica->due_date = now()->addDays($replica->company->defaultBill->payment_terms->getDays());
  346. })
  347. ->withReplicatedRelationships(['lineItems'])
  348. ->withExcludedRelationshipAttributes('lineItems', [
  349. 'subtotal',
  350. 'total',
  351. 'created_by',
  352. 'updated_by',
  353. 'created_at',
  354. 'updated_at',
  355. ]),
  356. Tables\Actions\BulkAction::make('recordPayments')
  357. ->label('Record Payments')
  358. ->icon('heroicon-o-credit-card')
  359. ->stickyModalHeader()
  360. ->stickyModalFooter()
  361. ->modalFooterActionsAlignment(Alignment::End)
  362. ->modalWidth(MaxWidth::TwoExtraLarge)
  363. ->databaseTransaction()
  364. ->successNotificationTitle('Payments Recorded')
  365. ->failureNotificationTitle('Failed to Record Payments')
  366. ->deselectRecordsAfterCompletion()
  367. ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
  368. $cantRecordPayments = $records->contains(fn (Bill $bill) => ! $bill->canRecordPayment());
  369. if ($cantRecordPayments) {
  370. Notification::make()
  371. ->title('Payment Recording Failed')
  372. ->body('Bills that are either paid or voided cannot be processed through bulk payments. Please adjust your selection and try again.')
  373. ->persistent()
  374. ->danger()
  375. ->send();
  376. $action->cancel(true);
  377. }
  378. })
  379. ->mountUsing(function (Collection $records, Form $form) {
  380. $totalAmountDue = $records->sum(fn (Bill $bill) => $bill->getRawOriginal('amount_due'));
  381. $form->fill([
  382. 'posted_at' => now(),
  383. 'amount' => CurrencyConverter::convertCentsToFormatSimple($totalAmountDue),
  384. ]);
  385. })
  386. ->form([
  387. Forms\Components\DatePicker::make('posted_at')
  388. ->label('Date'),
  389. Forms\Components\TextInput::make('amount')
  390. ->label('Amount')
  391. ->required()
  392. ->money()
  393. ->rules([
  394. static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
  395. if (! CurrencyConverter::isValidAmount($value)) {
  396. $fail('Please enter a valid amount');
  397. }
  398. },
  399. ]),
  400. Forms\Components\Select::make('payment_method')
  401. ->label('Payment Method')
  402. ->required()
  403. ->options(PaymentMethod::class),
  404. Forms\Components\Select::make('bank_account_id')
  405. ->label('Account')
  406. ->required()
  407. ->options(BankAccount::query()
  408. ->get()
  409. ->pluck('account.name', 'id'))
  410. ->searchable(),
  411. Forms\Components\Textarea::make('notes')
  412. ->label('Notes'),
  413. ])
  414. ->before(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
  415. $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
  416. $totalAmountDue = $records->sum(fn (Bill $bill) => $bill->getRawOriginal('amount_due'));
  417. if ($totalPaymentAmount > $totalAmountDue) {
  418. $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
  419. Notification::make()
  420. ->title('Excess Payment Amount')
  421. ->body("The payment amount exceeds the total amount due of {$formattedTotalAmountDue}. Please adjust the payment amount and try again.")
  422. ->persistent()
  423. ->warning()
  424. ->send();
  425. $action->halt(true);
  426. }
  427. })
  428. ->action(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
  429. $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
  430. $remainingAmount = $totalPaymentAmount;
  431. $records->each(function (Bill $record) use (&$remainingAmount, $data) {
  432. $amountDue = $record->getRawOriginal('amount_due');
  433. if ($amountDue <= 0 || $remainingAmount <= 0) {
  434. return;
  435. }
  436. $paymentAmount = min($amountDue, $remainingAmount);
  437. $data['amount'] = CurrencyConverter::convertCentsToFormatSimple($paymentAmount);
  438. $record->recordPayment($data);
  439. $remainingAmount -= $paymentAmount;
  440. });
  441. $action->success();
  442. }),
  443. ]),
  444. ]);
  445. }
  446. public static function getRelations(): array
  447. {
  448. return [
  449. BillResource\RelationManagers\PaymentsRelationManager::class,
  450. ];
  451. }
  452. public static function getPages(): array
  453. {
  454. return [
  455. 'index' => Pages\ListBills::route('/'),
  456. 'create' => Pages\CreateBill::route('/create'),
  457. 'view' => Pages\ViewBill::route('/{record}'),
  458. 'edit' => Pages\EditBill::route('/{record}/edit'),
  459. ];
  460. }
  461. public static function getWidgets(): array
  462. {
  463. return [
  464. BillResource\Widgets\BillOverview::class,
  465. ];
  466. }
  467. }