347 lines
16 KiB
PHP
347 lines
16 KiB
PHP
<?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);
|
|
}
|
|
}
|