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.

BillFactory.php 8.5KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. <?php
  2. namespace Database\Factories\Accounting;
  3. use App\Enums\Accounting\AdjustmentComputation;
  4. use App\Enums\Accounting\BillStatus;
  5. use App\Enums\Accounting\DocumentDiscountMethod;
  6. use App\Enums\Accounting\PaymentMethod;
  7. use App\Models\Accounting\Bill;
  8. use App\Models\Accounting\DocumentLineItem;
  9. use App\Models\Banking\BankAccount;
  10. use App\Models\Common\Vendor;
  11. use App\Models\Company;
  12. use App\Models\Setting\DocumentDefault;
  13. use App\Utilities\RateCalculator;
  14. use Illuminate\Database\Eloquent\Factories\Factory;
  15. use Illuminate\Support\Carbon;
  16. /**
  17. * @extends Factory<Bill>
  18. */
  19. class BillFactory extends Factory
  20. {
  21. /**
  22. * The name of the factory's corresponding model.
  23. */
  24. protected $model = Bill::class;
  25. /**
  26. * Define the model's default state.
  27. *
  28. * @return array<string, mixed>
  29. */
  30. public function definition(): array
  31. {
  32. $billDate = $this->faker->dateTimeBetween('-1 year', '-1 day');
  33. return [
  34. 'company_id' => 1,
  35. 'vendor_id' => function (array $attributes) {
  36. return Vendor::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id')
  37. ?? Vendor::factory()->state([
  38. 'company_id' => $attributes['company_id'],
  39. ]);
  40. },
  41. 'bill_number' => $this->faker->unique()->numerify('BILL-####'),
  42. 'order_number' => $this->faker->unique()->numerify('PO-####'),
  43. 'date' => $billDate,
  44. 'due_date' => $this->faker->dateTimeInInterval($billDate, '+6 months'),
  45. 'status' => BillStatus::Open,
  46. 'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
  47. 'discount_computation' => AdjustmentComputation::Percentage,
  48. 'discount_rate' => function (array $attributes) {
  49. $discountMethod = DocumentDiscountMethod::parse($attributes['discount_method']);
  50. if ($discountMethod?->isPerDocument()) {
  51. return $this->faker->numberBetween(50000, 200000); // 5% - 20%
  52. }
  53. return 0;
  54. },
  55. 'currency_code' => function (array $attributes) {
  56. $vendor = Vendor::find($attributes['vendor_id']);
  57. return $vendor->currency_code ??
  58. Company::find($attributes['company_id'])->default->currency_code ??
  59. 'USD';
  60. },
  61. 'notes' => $this->faker->sentence,
  62. 'created_by' => 1,
  63. 'updated_by' => 1,
  64. ];
  65. }
  66. public function withLineItems(int $count = 3): static
  67. {
  68. return $this->afterCreating(function (Bill $bill) use ($count) {
  69. // Clear existing line items first
  70. $bill->lineItems()->delete();
  71. DocumentLineItem::factory()
  72. ->count($count)
  73. ->forBill($bill)
  74. ->create();
  75. $this->recalculateTotals($bill);
  76. });
  77. }
  78. public function initialized(): static
  79. {
  80. return $this->afterCreating(function (Bill $bill) {
  81. $this->performInitialization($bill);
  82. });
  83. }
  84. public function partial(int $maxPayments = 4): static
  85. {
  86. return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
  87. $this->performInitialization($bill);
  88. $this->performPayments($bill, $maxPayments, BillStatus::Partial);
  89. });
  90. }
  91. public function paid(int $maxPayments = 4): static
  92. {
  93. return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
  94. $this->performInitialization($bill);
  95. $this->performPayments($bill, $maxPayments, BillStatus::Paid);
  96. });
  97. }
  98. public function overdue(): static
  99. {
  100. return $this
  101. ->state([
  102. 'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
  103. ])
  104. ->afterCreating(function (Bill $bill) {
  105. $this->performInitialization($bill);
  106. });
  107. }
  108. protected function performInitialization(Bill $bill): void
  109. {
  110. if ($bill->wasInitialized()) {
  111. return;
  112. }
  113. $postedAt = Carbon::parse($bill->date)
  114. ->addHours($this->faker->numberBetween(1, 24));
  115. if ($postedAt->isAfter(now())) {
  116. $postedAt = Carbon::parse($this->faker->dateTimeBetween($bill->date, now()));
  117. }
  118. $bill->createInitialTransaction($postedAt);
  119. }
  120. protected function performPayments(Bill $bill, int $maxPayments, BillStatus $billStatus): void
  121. {
  122. $bill->refresh();
  123. $amountDue = $bill->getRawOriginal('amount_due');
  124. $totalAmountDue = match ($billStatus) {
  125. BillStatus::Partial => (int) floor($amountDue * 0.5),
  126. default => $amountDue,
  127. };
  128. if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
  129. return;
  130. }
  131. $paymentCount = $this->faker->numberBetween(1, $maxPayments);
  132. $paymentAmount = (int) floor($totalAmountDue / $paymentCount);
  133. $remainingAmount = $totalAmountDue;
  134. $initialPaymentDate = Carbon::parse($bill->initialTransaction->posted_at);
  135. $maxPaymentDate = now();
  136. $paymentDates = [];
  137. for ($i = 0; $i < $paymentCount; $i++) {
  138. $amount = $i === $paymentCount - 1 ? $remainingAmount : $paymentAmount;
  139. if ($amount <= 0) {
  140. break;
  141. }
  142. if ($i === 0) {
  143. $postedAt = $initialPaymentDate->copy()->addDays($this->faker->numberBetween(1, 15));
  144. } else {
  145. $postedAt = $paymentDates[$i - 1]->copy()->addDays($this->faker->numberBetween(1, 10));
  146. }
  147. if ($postedAt->isAfter($maxPaymentDate)) {
  148. $postedAt = Carbon::parse($this->faker->dateTimeBetween($initialPaymentDate, $maxPaymentDate));
  149. }
  150. $paymentDates[] = $postedAt;
  151. $data = [
  152. 'posted_at' => $postedAt,
  153. 'amount' => $amount,
  154. 'payment_method' => $this->faker->randomElement(PaymentMethod::class),
  155. 'bank_account_id' => BankAccount::where('company_id', $bill->company_id)->inRandomOrder()->value('id'),
  156. 'notes' => $this->faker->sentence,
  157. ];
  158. $bill->recordPayment($data);
  159. $remainingAmount -= $amount;
  160. }
  161. if ($billStatus === BillStatus::Paid && ! empty($paymentDates)) {
  162. $latestPaymentDate = max($paymentDates);
  163. $bill->updateQuietly([
  164. 'status' => $billStatus,
  165. 'paid_at' => $latestPaymentDate,
  166. ]);
  167. }
  168. }
  169. public function configure(): static
  170. {
  171. return $this->afterCreating(function (Bill $bill) {
  172. DocumentLineItem::factory()
  173. ->count(3)
  174. ->forBill($bill)
  175. ->create();
  176. $this->recalculateTotals($bill);
  177. $number = DocumentDefault::getBaseNumber() + $bill->id;
  178. $bill->updateQuietly([
  179. 'bill_number' => "BILL-{$number}",
  180. 'order_number' => "PO-{$number}",
  181. ]);
  182. if ($bill->wasInitialized() && $bill->shouldBeOverdue()) {
  183. $bill->updateQuietly([
  184. 'status' => BillStatus::Overdue,
  185. ]);
  186. }
  187. });
  188. }
  189. protected function recalculateTotals(Bill $bill): void
  190. {
  191. $bill->refresh();
  192. if (! $bill->hasLineItems()) {
  193. return;
  194. }
  195. $subtotalCents = $bill->lineItems()->sum('subtotal');
  196. $taxTotalCents = $bill->lineItems()->sum('tax_total');
  197. $discountTotalCents = 0;
  198. if ($bill->discount_method?->isPerLineItem()) {
  199. $discountTotalCents = $bill->lineItems()->sum('discount_total');
  200. } elseif ($bill->discount_method?->isPerDocument() && $bill->discount_rate) {
  201. if ($bill->discount_computation?->isPercentage()) {
  202. $scaledRate = RateCalculator::parseLocalizedRate($bill->discount_rate);
  203. $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
  204. } else {
  205. $discountTotalCents = $bill->getRawOriginal('discount_rate');
  206. }
  207. }
  208. $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
  209. $bill->update([
  210. 'subtotal' => $subtotalCents,
  211. 'tax_total' => $taxTotalCents,
  212. 'discount_total' => $discountTotalCents,
  213. 'total' => $grandTotalCents,
  214. ]);
  215. }
  216. }