Giant Update

This commit is contained in:
Wian Drs
2026-07-31 13:57:11 +07:00
parent f68d967c8b
commit b0d08211ec
112 changed files with 6674 additions and 3 deletions
+346
View File
@@ -0,0 +1,346 @@
<?php
namespace App\Services\Billing;
use App\Models\BillingProfile;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\User;
use App\Services\Notification\NotificationManager;
use Carbon\Carbon;
use Illuminate\Database\QueryException;
use Illuminate\Validation\ValidationException;
class InvoiceService
{
public function __construct(protected NotificationManager $notifications) {}
private const RELATIONS = [
'tenant:id,tenant_code,tenant_name',
'customer:id,tenant_id,customer_code,name,whatsapp_number,email,status',
'billingProfile:id,tenant_id,name,schedule_type,invoice_day,send_day,warning_day,isolation_day,invoice_days_before,send_days_before,warning_days_before,isolation_days_after,tax_type,tax_rate',
];
public function list(string $scope, array $filters, User $actor, ?int $tenantId)
{
$query = Invoice::query()->with(self::RELATIONS);
if ($scope === 'running') {
$query->whereIn('status', ['draft', 'published', 'sent', 'warning']);
} elseif ($scope === 'overdue') {
$query->where('status', 'overdue');
} elseif ($scope === 'paid') {
$query->where('status', 'paid');
}
return $query
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
->when(! empty($filters['date_from']), fn ($q) => $q->whereDate('issue_date', '>=', $filters['date_from']))
->when(! empty($filters['date_to']), fn ($q) => $q->whereDate('issue_date', '<=', $filters['date_to']))
->when(! empty($filters['search']), fn ($q) => $q->where(function ($search) use ($filters) {
$term = "%{$filters['search']}%";
$search->where('invoice_number', 'ILIKE', $term)
->orWhere('customer_name_snapshot', 'ILIKE', $term)
->orWhere('customer_code_snapshot', 'ILIKE', $term);
}))
->orderBy($filters['sort_by'] ?? 'id', $filters['sort_direction'] ?? 'desc')
->paginate($filters['per_page'] ?? 10);
}
public function find(int $id): Invoice
{
return Invoice::with(self::RELATIONS)->findOrFail($id);
}
public function options(User $actor, ?int $tenantId): array
{
$tenantScope = fn ($q) => $q
->when(! $actor->isMasterAdmin(), fn ($x) => $x->where('tenant_id', $tenantId))
->when($actor->isMasterAdmin() && $tenantId, fn ($x) => $x->where('tenant_id', $tenantId));
return [
'customers' => $tenantScope(Customer::query())
->with('billingProfile')
->whereIn('status', ['active', 'order'])
->orderBy('name')
->get(['id', 'tenant_id', 'customer_code', 'name', 'billing_profile_id', 'package_profile_id', 'activated_at']),
];
}
public function create(array $data, ?User $actor, ?int $tenantId): Invoice
{
$data['tenant_id'] = $actor?->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
if (! $data['tenant_id']) {
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
}
$customer = Customer::with('packageProfile')->whereKey($data['customer_id'])->where('tenant_id', $data['tenant_id'])->first();
if (! $customer) {
throw ValidationException::withMessages(['customer_id' => 'Customer bukan milik tenant aktif.']);
}
$profile = BillingProfile::whereKey($customer->billing_profile_id)
->where('tenant_id', $data['tenant_id'])
->where('status', 'active')
->first();
if (! $profile) {
throw ValidationException::withMessages([
'customer_id' => 'Customer belum memiliki profile tagihan aktif.',
]);
}
$data = [...$data, ...$this->scheduleDates($profile, $customer, $data)];
$amounts = $this->amounts((float) $data['subtotal'], $profile->tax_type, (float) $profile->tax_rate);
$data = [
...$data,
...$amounts,
'billing_profile_id' => $profile->id,
'invoice_number' => filled($data['invoice_number'] ?? null) ? $data['invoice_number'] : $this->nextNumber((int) $data['tenant_id']),
'status' => $data['status'] ?? $this->statusByDate($data),
'payment_status' => 'unpaid',
'created_by' => $actor?->id,
'updated_by' => $actor?->id,
'customer_code_snapshot' => $customer->customer_code,
'customer_name_snapshot' => $customer->name,
'customer_address_snapshot' => $customer->full_installation_address ?? $customer->installation_address,
'package_name_snapshot' => $customer->packageProfile?->name,
'profile_snapshot' => $profile->only([
'name', 'schedule_type', 'invoice_day', 'send_day', 'warning_day', 'isolation_day',
'invoice_days_before', 'send_days_before', 'warning_days_before',
'isolation_days_after', 'tax_type', 'tax_rate',
]),
];
$invoice = Invoice::create($data)->load(self::RELATIONS);
$this->emitDueNotifications($invoice);
return $invoice;
}
public function processSchedules(): array
{
$created = 0;
$today = now()->startOfDay();
$periods = [
$today->copy()->startOfMonth(),
$today->copy()->addMonthNoOverflow()->startOfMonth(),
];
Customer::query()
->with(['billingProfile', 'packageProfile'])
->where('status', 'active')
->whereNotNull('billing_profile_id')
->whereNotNull('package_profile_id')
->chunkById(200, function ($customers) use (&$created, $periods, $today) {
foreach ($customers as $customer) {
if (! $customer->billingProfile || $customer->billingProfile->status !== 'active') {
continue;
}
if ($customer->billingProfile->schedule_type === 'installation_date' && ! $customer->activated_at) {
continue;
}
foreach ($periods as $period) {
$schedule = $this->scheduleDates(
$customer->billingProfile,
$customer,
['period_start' => $period->toDateString()],
);
if (Carbon::parse($schedule['issue_date'])->startOfDay()->gt($today)) {
continue;
}
$periodEnd = $period->copy()->endOfMonth()->toDateString();
if (Invoice::withTrashed()
->where('customer_id', $customer->id)
->whereDate('period_start', $period->toDateString())
->whereDate('period_end', $periodEnd)
->exists()) {
continue;
}
try {
$this->create([
'tenant_id' => $customer->tenant_id,
'customer_id' => $customer->id,
'period_start' => $period->toDateString(),
'period_end' => $periodEnd,
'subtotal' => (float) $customer->packageProfile->price,
...$schedule,
], null, $customer->tenant_id);
$created++;
} catch (QueryException $exception) {
if (! $this->isDuplicateKey($exception)) {
throw $exception;
}
}
}
}
});
return ['created' => $created, 'updated' => $this->refreshStatuses()];
}
public function update(Invoice $invoice, array $data, User $actor): Invoice
{
unset($data['tenant_id'], $data['customer_id'], $data['billing_profile_id'], $data['invoice_number']);
if (array_key_exists('subtotal', $data)) {
$data = [...$data, ...$this->amounts((float) $data['subtotal'], $invoice->tax_type, (float) $invoice->tax_rate, (float) $invoice->paid_amount)];
}
$data['updated_by'] = $actor->id;
$invoice->update($data);
return $invoice->fresh()->load(self::RELATIONS);
}
public function pay(Invoice $invoice, array $data, User $actor): Invoice
{
if ($invoice->status === 'cancelled') {
throw ValidationException::withMessages(['invoice' => 'Invoice dibatalkan tidak dapat dibayar.']);
}
$paidAmount = min((float) $invoice->total, (float) $invoice->paid_amount + (float) $data['amount']);
$balance = max(0, (float) $invoice->total - $paidAmount);
$invoice->update([
'paid_amount' => $paidAmount,
'balance' => $balance,
'payment_status' => $balance <= 0 ? 'paid' : 'partial',
'status' => $balance <= 0 ? 'paid' : $this->statusByDate($invoice->toArray()),
'paid_at' => $balance <= 0 ? ($data['paid_at'] ?? now()) : null,
'payment_method' => $data['payment_method'] ?? $invoice->payment_method,
'payment_reference' => $data['payment_reference'] ?? $invoice->payment_reference,
'paid_by' => $actor->id,
'updated_by' => $actor->id,
'notes' => $data['notes'] ?? $invoice->notes,
]);
return $invoice->fresh()->load(self::RELATIONS);
}
public function cancel(Invoice $invoice, User $actor): Invoice
{
if ((float) $invoice->paid_amount > 0) {
throw ValidationException::withMessages(['invoice' => 'Invoice yang sudah memiliki pembayaran tidak dapat dibatalkan.']);
}
$invoice->update(['status' => 'cancelled', 'updated_by' => $actor->id]);
return $invoice->fresh()->load(self::RELATIONS);
}
private function amounts(float $subtotal, string $type, float $rate, float $paid = 0): array
{
$tax = match ($type) {
'inclusive' => $rate > 0 ? round($subtotal * $rate / (100 + $rate), 2) : 0,
'exclusive' => round($subtotal * $rate / 100, 2),
default => 0,
};
$total = $type === 'exclusive' ? $subtotal + $tax : $subtotal;
return ['tax_type' => $type, 'tax_rate' => $rate, 'tax_amount' => $tax, 'total' => $total, 'balance' => max(0, $total - $paid)];
}
private function scheduleDates(BillingProfile $profile, Customer $customer, array $data): array
{
$periodDate = Carbon::parse($data['period_start'])->startOfMonth();
if ($profile->schedule_type !== 'installation_date') {
return [
'issue_date' => $periodDate->copy()->day((int) $profile->invoice_day)->toDateString(),
'send_date' => $periodDate->copy()->day((int) $profile->send_day)->toDateString(),
'warning_date' => $periodDate->copy()->day((int) $profile->warning_day)->toDateString(),
'isolation_date' => $periodDate->copy()->day((int) $profile->isolation_day)->toDateString(),
];
}
if (! $customer->activated_at) {
throw ValidationException::withMessages([
'customer_id' => 'Tanggal pemasangan customer belum tersedia untuk profile tagihan ini.',
]);
}
$installationDay = $customer->activated_at->day;
$anchor = $periodDate->copy()->day(min($installationDay, $periodDate->daysInMonth));
return [
'issue_date' => $anchor->copy()->subDays((int) $profile->invoice_days_before)->toDateString(),
'send_date' => $anchor->copy()->subDays((int) $profile->send_days_before)->toDateString(),
'warning_date' => $anchor->copy()->subDays((int) $profile->warning_days_before)->toDateString(),
'isolation_date' => $anchor->copy()->addDays((int) $profile->isolation_days_after)->toDateString(),
];
}
private function statusByDate(array $data): string
{
$today = now()->startOfDay();
if (! empty($data['isolation_date']) && Carbon::parse($data['isolation_date'])->startOfDay()->lte($today)) {
return 'overdue';
}
if (! empty($data['warning_date']) && Carbon::parse($data['warning_date'])->startOfDay()->lte($today)) {
return 'warning';
}
if (! empty($data['send_date']) && Carbon::parse($data['send_date'])->startOfDay()->lte($today)) {
return 'sent';
}
if (! empty($data['issue_date']) && Carbon::parse($data['issue_date'])->startOfDay()->lte($today)) {
return 'published';
}
return 'draft';
}
private function refreshStatuses(?User $actor = null, ?int $tenantId = null): int
{
$updated = 0;
Invoice::query()->whereNotIn('status', ['paid', 'cancelled'])
->when($actor && (! $actor->isMasterAdmin() || $tenantId), fn ($q) => $q->where('tenant_id', $tenantId))
->chunkById(500, function ($invoices) use (&$updated) {
$invoices->each(function (Invoice $invoice) use (&$updated) {
$status = $this->statusByDate($invoice->toArray());
if ($status !== $invoice->status) {
$invoice->updateQuietly(['status' => $status]);
$updated++;
}
$this->emitDueNotifications($invoice->fresh(self::RELATIONS));
});
});
return $updated;
}
private function isDuplicateKey(QueryException $exception): bool
{
return in_array((string) $exception->getCode(), ['23000', '23505'], true);
}
private function emitDueNotifications(Invoice $invoice): void
{
if (in_array($invoice->status, ['paid', 'cancelled'], true) || ! $invoice->customer) {
return;
}
$payload = [
'tenant' => $invoice->tenant?->toArray() ?? [],
'customer' => $invoice->customer->toArray(),
'invoice' => [
...$invoice->toArray(),
'total' => 'Rp '.number_format((float) $invoice->total, 0, ',', '.'),
'balance' => 'Rp '.number_format((float) $invoice->balance, 0, ',', '.'),
],
];
foreach ([
'billing.invoice_sent' => $invoice->send_date,
'billing.warning' => $invoice->warning_date,
'billing.isolated' => $invoice->isolation_date,
] as $event => $date) {
if ($date && $date->copy()->startOfDay()->lte(now()->startOfDay())) {
$this->notifications->emit(
$event,
$invoice->customer,
$invoice,
$payload,
$event.'-'.$invoice->period_start->format('Ym'),
['action_url' => "/billing/running?invoice={$invoice->id}"],
);
}
}
}
private function nextNumber(int $tenantId): string
{
$sequence = Invoice::withTrashed()->where('tenant_id', $tenantId)->whereYear('created_at', now()->year)->count() + 1;
return 'INV-'.now()->format('Ym').'-'.str_pad((string) $sequence, 5, '0', STR_PAD_LEFT);
}
}