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.

Invoice.php 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. <?php
  2. namespace App\Models\Accounting;
  3. use App\Enums\Accounting\DocumentType;
  4. use App\Enums\Accounting\InvoiceStatus;
  5. use App\Enums\Accounting\JournalEntryType;
  6. use App\Enums\Accounting\TransactionType;
  7. use App\Filament\Company\Resources\Sales\InvoiceResource;
  8. use App\Models\Common\Client;
  9. use App\Models\Setting\DocumentDefault;
  10. use App\Observers\InvoiceObserver;
  11. use App\Utilities\Currency\CurrencyAccessor;
  12. use App\Utilities\Currency\CurrencyConverter;
  13. use Filament\Actions\Action;
  14. use Filament\Actions\MountableAction;
  15. use Filament\Actions\ReplicateAction;
  16. use Illuminate\Database\Eloquent\Attributes\ObservedBy;
  17. use Illuminate\Database\Eloquent\Builder;
  18. use Illuminate\Database\Eloquent\Relations\BelongsTo;
  19. use Illuminate\Database\Eloquent\Relations\MorphOne;
  20. use Illuminate\Support\Carbon;
  21. #[ObservedBy(InvoiceObserver::class)]
  22. class Invoice extends Document
  23. {
  24. protected $table = 'invoices';
  25. protected $fillable = [
  26. ...self::COMMON_FILLABLE,
  27. ...self::INVOICE_FILLABLE,
  28. ];
  29. protected const INVOICE_FILLABLE = [
  30. 'client_id',
  31. 'logo',
  32. 'header',
  33. 'subheader',
  34. 'invoice_number',
  35. 'approved_at',
  36. 'last_sent',
  37. 'terms',
  38. 'footer',
  39. ];
  40. protected function casts(): array
  41. {
  42. return [
  43. ...parent::casts(),
  44. 'approved_at' => 'datetime',
  45. 'last_sent' => 'datetime',
  46. 'status' => InvoiceStatus::class,
  47. ];
  48. }
  49. public static function documentNumberColumn(): string
  50. {
  51. return 'invoice_number';
  52. }
  53. public static function documentType(): DocumentType
  54. {
  55. return DocumentType::Invoice;
  56. }
  57. public static function getDocumentSettings(): DocumentDefault
  58. {
  59. return auth()->user()->currentCompany->defaultInvoice;
  60. }
  61. public function client(): BelongsTo
  62. {
  63. return $this->belongsTo(Client::class);
  64. }
  65. public function approvalTransaction(): MorphOne
  66. {
  67. return $this->morphOne(Transaction::class, 'transactionable')
  68. ->where('type', TransactionType::Journal);
  69. }
  70. public function scopeUnpaid(Builder $query): Builder
  71. {
  72. return $query->whereNotIn('status', [
  73. InvoiceStatus::Paid,
  74. InvoiceStatus::Void,
  75. InvoiceStatus::Draft,
  76. InvoiceStatus::Overpaid,
  77. ]);
  78. }
  79. public function isDraft(): bool
  80. {
  81. return $this->status === InvoiceStatus::Draft;
  82. }
  83. public function canRecordPayment(): bool
  84. {
  85. return ! in_array($this->status, [
  86. InvoiceStatus::Draft,
  87. InvoiceStatus::Paid,
  88. InvoiceStatus::Void,
  89. ]);
  90. }
  91. public function canBulkRecordPayment(): bool
  92. {
  93. return ! in_array($this->status, [
  94. InvoiceStatus::Draft,
  95. InvoiceStatus::Paid,
  96. InvoiceStatus::Void,
  97. InvoiceStatus::Overpaid,
  98. ]) && $this->currency_code === CurrencyAccessor::getDefaultCurrency();
  99. }
  100. public function canBeOverdue(): bool
  101. {
  102. return in_array($this->status, InvoiceStatus::canBeOverdue());
  103. }
  104. public function recordPayment(array $data): void
  105. {
  106. $isRefund = $this->status === InvoiceStatus::Overpaid;
  107. $transactionType = $isRefund
  108. ? TransactionType::Withdrawal // Refunds are withdrawals
  109. : TransactionType::Deposit; // Payments are deposits
  110. $transactionDescription = $isRefund
  111. ? "Invoice #{$this->invoice_number}: Refund to {$this->client->name}"
  112. : "Invoice #{$this->invoice_number}: Payment from {$this->client->name}";
  113. $this->recordTransaction(
  114. $data,
  115. $transactionType,
  116. $transactionDescription,
  117. Account::getAccountsReceivableAccount()->id // Account ID specific to invoices
  118. );
  119. }
  120. public function approveDraft(?Carbon $approvedAt = null): void
  121. {
  122. if (! $this->isDraft()) {
  123. throw new \RuntimeException('Invoice is not in draft status.');
  124. }
  125. $this->createApprovalTransaction();
  126. $approvedAt ??= now();
  127. $this->update([
  128. 'approved_at' => $approvedAt,
  129. 'status' => InvoiceStatus::Unsent,
  130. ]);
  131. }
  132. public function createApprovalTransaction(): void
  133. {
  134. $total = $this->formatAmountToDefaultCurrency($this->getRawOriginal('total'));
  135. $transaction = $this->transactions()->create([
  136. 'company_id' => $this->company_id,
  137. 'type' => TransactionType::Journal,
  138. 'posted_at' => $this->date,
  139. 'amount' => $total,
  140. 'description' => 'Invoice Approval for Invoice #' . $this->invoice_number,
  141. ]);
  142. $baseDescription = "{$this->client->name}: Invoice #{$this->invoice_number}";
  143. $transaction->journalEntries()->create([
  144. 'company_id' => $this->company_id,
  145. 'type' => JournalEntryType::Debit,
  146. 'account_id' => Account::getAccountsReceivableAccount()->id,
  147. 'amount' => $total,
  148. 'description' => $baseDescription,
  149. ]);
  150. $totalLineItemSubtotalCents = $this->convertAmountToDefaultCurrency((int) $this->lineItems()->sum('subtotal'));
  151. $invoiceDiscountTotalCents = $this->convertAmountToDefaultCurrency((int) $this->getRawOriginal('discount_total'));
  152. $remainingDiscountCents = $invoiceDiscountTotalCents;
  153. foreach ($this->lineItems as $index => $lineItem) {
  154. $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
  155. $lineItemSubtotal = $this->formatAmountToDefaultCurrency($lineItem->getRawOriginal('subtotal'));
  156. $transaction->journalEntries()->create([
  157. 'company_id' => $this->company_id,
  158. 'type' => JournalEntryType::Credit,
  159. 'account_id' => $lineItem->offering->income_account_id,
  160. 'amount' => $lineItemSubtotal,
  161. 'description' => $lineItemDescription,
  162. ]);
  163. foreach ($lineItem->adjustments as $adjustment) {
  164. $adjustmentAmount = $this->formatAmountToDefaultCurrency($lineItem->calculateAdjustmentTotalAmount($adjustment));
  165. $transaction->journalEntries()->create([
  166. 'company_id' => $this->company_id,
  167. 'type' => $adjustment->category->isDiscount() ? JournalEntryType::Debit : JournalEntryType::Credit,
  168. 'account_id' => $adjustment->account_id,
  169. 'amount' => $adjustmentAmount,
  170. 'description' => $lineItemDescription,
  171. ]);
  172. }
  173. if ($this->discount_method->isPerDocument() && $totalLineItemSubtotalCents > 0) {
  174. $lineItemSubtotalCents = $this->convertAmountToDefaultCurrency((int) $lineItem->getRawOriginal('subtotal'));
  175. if ($index === $this->lineItems->count() - 1) {
  176. $lineItemDiscount = $remainingDiscountCents;
  177. } else {
  178. $lineItemDiscount = (int) round(
  179. ($lineItemSubtotalCents / $totalLineItemSubtotalCents) * $invoiceDiscountTotalCents
  180. );
  181. $remainingDiscountCents -= $lineItemDiscount;
  182. }
  183. if ($lineItemDiscount > 0) {
  184. $transaction->journalEntries()->create([
  185. 'company_id' => $this->company_id,
  186. 'type' => JournalEntryType::Debit,
  187. 'account_id' => Account::getSalesDiscountAccount()->id,
  188. 'amount' => CurrencyConverter::convertCentsToFormatSimple($lineItemDiscount),
  189. 'description' => "{$lineItemDescription} (Proportional Discount)",
  190. ]);
  191. }
  192. }
  193. }
  194. }
  195. public function updateApprovalTransaction(): void
  196. {
  197. $transaction = $this->approvalTransaction;
  198. if ($transaction) {
  199. $transaction->delete();
  200. }
  201. $this->createApprovalTransaction();
  202. }
  203. public static function getApproveDraftAction(string $action = Action::class): MountableAction
  204. {
  205. return $action::make('approveDraft')
  206. ->label('Approve')
  207. ->icon('heroicon-o-check-circle')
  208. ->visible(function (self $record) {
  209. return $record->isDraft();
  210. })
  211. ->databaseTransaction()
  212. ->successNotificationTitle('Invoice Approved')
  213. ->action(function (self $record, MountableAction $action) {
  214. $record->approveDraft();
  215. $action->success();
  216. });
  217. }
  218. public static function getMarkAsSentAction(string $action = Action::class): MountableAction
  219. {
  220. return $action::make('markAsSent')
  221. ->label('Mark as Sent')
  222. ->icon('heroicon-o-paper-airplane')
  223. ->visible(static function (self $record) {
  224. return ! $record->last_sent;
  225. })
  226. ->successNotificationTitle('Invoice Sent')
  227. ->action(function (self $record, MountableAction $action) {
  228. $record->update([
  229. 'status' => InvoiceStatus::Sent,
  230. 'last_sent' => now(),
  231. ]);
  232. $action->success();
  233. });
  234. }
  235. public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
  236. {
  237. return $action::make()
  238. ->excludeAttributes([
  239. 'status',
  240. 'amount_paid',
  241. 'amount_due',
  242. 'created_by',
  243. 'updated_by',
  244. 'created_at',
  245. 'updated_at',
  246. 'invoice_number',
  247. 'date',
  248. 'due_date',
  249. 'approved_at',
  250. 'paid_at',
  251. 'last_sent',
  252. ])
  253. ->modal(false)
  254. ->beforeReplicaSaved(function (self $original, self $replica) {
  255. $replica->status = InvoiceStatus::Draft;
  256. $replica->invoice_number = self::getNextDocumentNumber();
  257. $replica->date = now();
  258. $replica->due_date = now()->addDays($original->company->defaultInvoice->payment_terms->getDays());
  259. })
  260. ->databaseTransaction()
  261. ->after(function (self $original, self $replica) {
  262. $original->lineItems->each(function (DocumentLineItem $lineItem) use ($replica) {
  263. $replicaLineItem = $lineItem->replicate([
  264. 'documentable_id',
  265. 'documentable_type',
  266. 'subtotal',
  267. 'total',
  268. 'created_by',
  269. 'updated_by',
  270. 'created_at',
  271. 'updated_at',
  272. ]);
  273. $replicaLineItem->documentable_id = $replica->id;
  274. $replicaLineItem->documentable_type = $replica->getMorphClass();
  275. $replicaLineItem->save();
  276. $replicaLineItem->adjustments()->sync($lineItem->adjustments->pluck('id'));
  277. });
  278. })
  279. ->successRedirectUrl(static function (self $replica) {
  280. return InvoiceResource::getUrl('edit', ['record' => $replica]);
  281. });
  282. }
  283. }