feat(billing): renewals, coupons, VAT numbering, billing cycles and invoice payment tracking

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-19 16:37:56 +07:00
co-authored by Claude Sonnet 5.5
parent ea9a53f742
commit 25f23d541e
134 changed files with 4926 additions and 185 deletions
@@ -0,0 +1,202 @@
<?php
namespace App\Support\Billing;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class BillingDuplicateDetector
{
public const RESUBMIT_WINDOW_HOURS = 24;
public const REASON_RESUBMIT = 'rapid_resubmit';
public const REASON_SAME_SERVICE = 'same_service';
public const REASON_DOMAIN_TAKEN = 'domain_taken';
public const REASON_SAME_PERIOD = 'same_period';
public const LABELS = [
self::REASON_RESUBMIT => 'Đặt lặp trong thời gian ngắn',
self::REASON_SAME_SERVICE => 'Cùng khách, sản phẩm và tên dịch vụ',
self::REASON_DOMAIN_TAKEN => 'Tên miền đã có ở đơn khác',
self::REASON_SAME_PERIOD => 'Cùng đơn, loại và kỳ thanh toán',
];
private static ?array $orders = null;
private static ?array $invoices = null;
public static function flush(): void
{
self::$orders = self::$invoices = null;
}
public static function forOrder(int $id): ?array
{
return self::orders()[$id] ?? null;
}
public static function forInvoice(int $id): ?array
{
return self::invoices()[$id] ?? null;
}
/** @return array<int, array{reason: string, label: string, with: string[]}> */
public static function orders(): array
{
return self::$orders ??= self::detectOrders();
}
/** @return array<int, array{reason: string, label: string, with: string[]}> */
public static function invoices(): array
{
return self::$invoices ??= self::detectInvoices();
}
private static function detectOrders(): array
{
$lines = DB::table('orders as o')
->join('order_items as oi', 'oi.order_id', '=', 'o.id')
->whereNull('o.deleted_at')
->where('o.status', '!=', OrderStatus::CANCELLED->value)
->get([
'o.id', 'o.order_num', 'o.customer_id', 'o.total', 'o.created_at',
'oi.product_id', 'oi.billing_cycle', 'oi.domain_name', 'oi.server_name',
]);
$orders = $lines->groupBy('id')->map(function (Collection $items) {
$first = $items->first();
return (object) [
'id' => $first->id,
'ref' => $first->order_num,
'customer' => $first->customer_id,
'total' => $first->total,
'at' => Carbon::parse($first->created_at),
'fingerprint' => $items->map(fn ($i) => $i->product_id . ':' . $i->billing_cycle . ':' . self::normalize($i->server_name ?: $i->domain_name))->sort()->implode('|'),
'service_keys' => $items->map(fn ($i) => $first->customer_id . '|' . $i->product_id . '|' . self::normalize($i->server_name ?: $i->domain_name))
->filter(fn ($key) => !str_ends_with($key, '|'))->unique()->values(),
'domains' => $items->map(fn ($i) => self::normalize($i->domain_name))->filter()->unique()->values(),
];
});
$found = self::resubmits($orders->groupBy(fn ($o) => $o->customer . '|' . $o->total . '|' . $o->fingerprint));
$found += self::sharedKeys($orders, 'service_keys', self::REASON_SAME_SERVICE, $found);
$found += self::sharedKeys($orders, 'domains', self::REASON_DOMAIN_TAKEN, $found);
return $found;
}
private static function detectInvoices(): array
{
$invoices = DB::table('invoices')
->whereNull('deleted_at')
->where('status', '!=', InvoiceStatus::CANCELLED->value)
->get(['id', 'invoice_number', 'order_id', 'customer_id', 'invoice_type', 'total', 'billing_period_start', 'created_at']);
$found = [];
$samePeriod = $invoices->filter(fn ($i) => $i->order_id && in_array((int) $i->invoice_type, [1, 2], true))
->groupBy(fn ($i) => $i->order_id . '|' . $i->invoice_type . '|' . ($i->billing_period_start ?? ''))
->filter(fn (Collection $group) => $group->count() > 1);
foreach ($samePeriod as $group) {
self::link($found, $group->pluck('id', 'invoice_number')->flip()->all(), self::REASON_SAME_PERIOD);
}
$candidates = $invoices->groupBy(fn ($i) => $i->customer_id . '|' . $i->invoice_type . '|' . $i->total)
->filter(fn (Collection $group) => $group->count() > 1)
->flatten(1);
$itemsByInvoice = DB::table('invoice_items')
->whereIn('invoice_id', $candidates->pluck('id'))
->get(['invoice_id', 'product_id', 'description', 'total_price'])
->groupBy('invoice_id');
$fingerprinted = $candidates->map(fn ($i) => (object) [
'id' => $i->id,
'ref' => $i->invoice_number,
'at' => Carbon::parse($i->created_at),
'group' => $i->customer_id . '|' . $i->invoice_type . '|' . $i->total . '|' . ($i->billing_period_start ?? '') . '|' . ($itemsByInvoice[$i->id] ?? collect())
->map(fn ($item) => $item->product_id . ':' . $item->total_price . ':' . self::normalize($item->description))->sort()->implode('|'),
]);
$resubmits = self::resubmits($fingerprinted->groupBy('group'));
return $found + array_diff_key($resubmits, $found);
}
private static function resubmits(Collection $groups): array
{
$found = [];
foreach ($groups->filter(fn (Collection $group) => $group->count() > 1) as $group) {
$sorted = $group->sortBy('at')->values();
$cluster = [$sorted->first()];
foreach ($sorted->slice(1) as $row) {
if (end($cluster)->at->diffInHours($row->at, true) <= self::RESUBMIT_WINDOW_HOURS) {
$cluster[] = $row;
continue;
}
self::linkCluster($found, $cluster);
$cluster = [$row];
}
self::linkCluster($found, $cluster);
}
return $found;
}
private static function linkCluster(array &$found, array $cluster): void
{
if (count($cluster) > 1) {
self::link($found, collect($cluster)->pluck('id', 'ref')->flip()->all(), self::REASON_RESUBMIT);
}
}
private static function sharedKeys(Collection $orders, string $field, string $reason, array $already): array
{
$found = [];
$byKey = [];
foreach ($orders as $order) {
if (isset($already[$order->id])) {
continue;
}
foreach ($order->{$field} as $key) {
$byKey[$key][] = $order;
}
}
foreach ($byKey as $group) {
$group = collect($group)->unique('id');
$distinctCustomers = $group->pluck('customer')->unique()->count();
$qualifies = $reason === self::REASON_DOMAIN_TAKEN ? $distinctCustomers > 1 : $group->count() > 1;
if ($qualifies) {
self::link($found, $group->pluck('id', 'ref')->flip()->all(), $reason);
}
}
return $found;
}
/** @param array<string|int, int|string> $members id => ref */
private static function link(array &$found, array $members, string $reason): void
{
foreach ($members as $id => $ref) {
$found[$id] = [
'reason' => $reason,
'label' => self::LABELS[$reason],
'with' => array_values(array_map('strval', array_diff_key($members, [$id => true]))),
];
}
}
private static function normalize(?string $value): string
{
return mb_strtolower(trim((string) $value));
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\Product\ProductBillingCycle;
use App\Models\Setting;
final class BillingCycleCatalog
{
public const SETTING_KEY = 'custom_billing_months';
public const CODE_PREFIX = 'months_';
public const MAX_MONTHS = 600;
/**
* @return list<string>
*/
public static function builtInCodes(): array
{
return array_values(array_map(
fn (ProductBillingCycle $cycle) => $cycle->value,
array_filter(ProductBillingCycle::cases(), fn (ProductBillingCycle $cycle) => $cycle->months() > 0)
));
}
/**
* @return list<int>
*/
public static function builtInMonths(): array
{
return array_values(array_filter(array_map(
fn (ProductBillingCycle $cycle) => $cycle->months(),
ProductBillingCycle::cases()
)));
}
/**
* @return list<int>
*/
public static function customMonths(): array
{
$saved = json_decode((string) Setting::where('key', self::SETTING_KEY)->value('value'), true);
return self::normalizeMonths(is_array($saved) ? $saved : []);
}
/**
* @param array<int, mixed> $months
* @return list<int>
*/
public static function saveCustomMonths(array $months): array
{
$normalized = self::normalizeMonths($months);
Setting::updateOrCreate(['key' => self::SETTING_KEY], ['value' => json_encode($normalized)]);
return $normalized;
}
/**
* @return list<string>
*/
public static function codes(): array
{
$codes = [...self::builtInCodes(), ...array_map(self::codeFor(...), self::customMonths())];
usort($codes, fn (string $a, string $b) => self::monthsFor($a) <=> self::monthsFor($b));
return $codes;
}
public static function codeFor(int $months): string
{
return self::CODE_PREFIX . $months;
}
public static function monthsFor(?string $code): ?int
{
if ($code === null || $code === '') {
return null;
}
$builtIn = ProductBillingCycle::tryFrom($code);
if ($builtIn) {
return $builtIn->months();
}
return preg_match('/^' . self::CODE_PREFIX . '(\d{1,3})$/', $code, $m) && (int) $m[1] > 0
? (int) $m[1]
: null;
}
public static function labelForMonths(int $months): string
{
return $months > 0 && $months % 12 === 0 ? ($months / 12) . ' năm' : $months . ' tháng';
}
public static function labelForCode(?string $code): ?string
{
$months = self::monthsFor($code);
return $months ? self::labelForMonths($months) : null;
}
/**
* @param array<int, mixed> $months
* @return list<int>
*/
private static function normalizeMonths(array $months): array
{
$valid = array_filter(
array_map('intval', $months),
fn (int $m) => $m >= 1 && $m <= self::MAX_MONTHS && !in_array($m, self::builtInMonths(), true)
);
$unique = array_values(array_unique($valid));
sort($unique);
return $unique;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Support;
use App\Models\Customer;
class CustomerBillingProfile
{
public const FIELDS = ['invoice_company', 'tax_code', 'invoice_address', 'invoice_email'];
public static function payload(Customer $customer): array
{
return [
'invoice_company' => $customer->invoice_company,
'tax_code' => $customer->tax_code,
'invoice_address' => $customer->invoice_address,
'invoice_email' => $customer->invoice_email,
];
}
public static function save(Customer $customer, array $input): array
{
$customer->update(array_map(
fn ($value) => is_string($value) && trim($value) === '' ? null : $value,
array_intersect_key($input, array_flip(self::FIELDS)) + array_fill_keys(self::FIELDS, null),
));
return self::payload($customer->fresh());
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Support;
use App\Models\Template;
final class EmailHealth
{
public const LIFECYCLE_SCENARIOS = [
'service_expiry',
'service_renewal_reminder',
'invoice_notification',
'invoice_payment_reminder',
'auto_renew_failed',
];
public static function report(): array
{
$templates = Template::query()->get(['key', 'scenario', 'status']);
$disabled = AutoEmailGate::disabledScenarios();
$mailEnabled = AutoEmailGate::mailEnabled();
$scenarios = array_map(function (string $key) use ($templates, $disabled, $mailEnabled) {
$matches = $templates->filter(fn ($template) => $template->key === $key || $template->scenario === $key);
$hasTemplate = $matches->isNotEmpty();
$templateActive = $matches->contains(fn ($template) => (int) $template->status === 1);
$autoEnabled = ! in_array($key, $disabled, true);
return [
'key' => $key,
'label' => EmailScenarioRegistry::SCENARIOS[$key]['label'],
'has_template' => $hasTemplate,
'template_active' => $templateActive,
'auto_enabled' => $autoEnabled,
'ready' => $mailEnabled && $hasTemplate && $templateActive && $autoEnabled,
];
}, self::LIFECYCLE_SCENARIOS);
$smtpConfigured = MailSettings::isConfigured();
$smtpDelivering = MailSettings::isDelivering();
return [
'mail_enabled' => $mailEnabled,
'smtp_configured' => $smtpConfigured,
'smtp_delivering' => $smtpDelivering,
'scenarios' => $scenarios,
'ready' => $mailEnabled && $smtpConfigured && $smtpDelivering && ! in_array(false, array_column($scenarios, 'ready'), true),
];
}
}
+12
View File
@@ -93,6 +93,14 @@ final class EmailScenarioRegistry
'label' => 'Nhắc gia hạn dịch vụ', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['product_name', 'expires_at'],
],
'service_renewed' => [
'label' => 'Dịch vụ đã được gia hạn', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['product_name', 'expires_at'],
],
'auto_renew_failed' => [
'label' => 'Tự động gia hạn thất bại (thiếu số dư)', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['product_name', 'invoice_number'],
],
'vm_creation_failed' => [
'label' => 'Khởi tạo VPS thất bại', 'category' => self::BUSINESS, 'group' => 'SERVICE',
'required_variables' => ['order_num'],
@@ -157,6 +165,10 @@ final class EmailScenarioRegistry
'label' => '[Admin] Khách hàng thanh toán', 'category' => self::INTERNAL, 'group' => 'BILLING',
'required_variables' => ['reference', 'amount_formatted'],
],
'admin_service_renewed' => [
'label' => '[Admin] Dịch vụ được gia hạn', 'category' => self::INTERNAL, 'group' => 'SERVICE',
'required_variables' => ['product_name', 'expires_at'],
],
'admin_domain_order_new' => [
'label' => '[Admin] Đơn đăng ký tên miền mới', 'category' => self::INTERNAL, 'group' => 'ORDER',
'required_variables' => ['domain_name', 'order_num'],
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Support;
use App\Models\Customer;
use App\Models\Invoice;
class InvoiceBuyer
{
public static function fromCustomer(?Customer $customer): array
{
return [
'name' => $customer?->fullname ?? '',
'company' => $customer?->invoice_company ?: ($customer?->company ?? ''),
'tax_code' => $customer?->tax_code ?? '',
'address' => $customer?->invoice_address ?: ($customer?->address ?? ''),
'email' => $customer?->invoice_email ?: ($customer?->email ?? ''),
];
}
public static function forInvoice(Invoice $invoice): array
{
return $invoice->buyer_snapshot ?: self::fromCustomer($invoice->customer);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Support;
use App\Enums\Invoice\InvoiceStatus;
use App\Models\Invoice;
class InvoiceLookup
{
public static function find(string $invoiceNumber, string $lookupCode): ?Invoice
{
$invoice = Invoice::query()
->where('invoice_number', $invoiceNumber)
->where('status', '!=', InvoiceStatus::CANCELLED->value)
->first();
if (!$invoice || !hash_equals((string) $invoice->lookup_code, strtoupper(trim($lookupCode)))) {
return null;
}
return $invoice;
}
public static function emailRow(Invoice $invoice): string
{
return $invoice->lookup_code ? EmailTemplateShell::row('Mã tra cứu', '<strong>' . e($invoice->lookup_code) . '</strong>') : '';
}
public static function emailNote(Invoice $invoice): string
{
if (!$invoice->lookup_code) {
return '';
}
$url = MailGlobals::frontendUrl() . '/tra-cuu-hoa-don';
return EmailTemplateShell::paragraph(
'Quý khách có thể tra cứu và tải hoá đơn tại <a href="' . e($url) . '">' . e($url) . '</a> bằng số hoá đơn <strong>'
. e($invoice->invoice_number) . '</strong> và mã tra cứu <strong>' . e($invoice->lookup_code) . '</strong>.'
);
}
public static function payload(Invoice $invoice): array
{
$invoice->loadMissing(['invoiceItems', 'customer']);
$buyer = InvoiceBuyer::forInvoice($invoice);
return [
'invoice_number' => $invoice->invoice_number,
'vat_serial' => $invoice->vat_serial,
'vat_number' => $invoice->vat_number,
'vat_issued_at' => $invoice->vat_issued_at?->toDateString(),
'date' => $invoice->date?->toDateString(),
'status' => $invoice->status,
'status_text' => $invoice->status_label,
'sub_total' => (int) $invoice->sub_total,
'discount_amount' => (int) $invoice->discount_amount,
'tax' => (int) $invoice->tax,
'total' => (int) $invoice->total,
'buyer' => [
'company' => $buyer['company'],
'tax_code' => $buyer['tax_code'],
],
'items' => $invoice->invoiceItems->map(fn ($item) => [
'description' => $item->description,
'quantity' => (float) ($item->quantity ?? 1),
'unit_price' => (int) $item->unit_price,
'total_price' => (int) $item->total_price,
])->values(),
];
}
}
+10
View File
@@ -44,6 +44,16 @@ final class MailSettings
Config::set('mail', $config);
}
public static function isConfigured(): bool
{
return filled(self::resolve()['mailer']['host'] ?? null);
}
public static function isDelivering(): bool
{
return ! in_array(config('mail.default'), self::NON_DELIVERING_MAILERS, true);
}
public static function transport(): ?string
{
return self::resolve()['transport'] ?? null;
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Enums\Product\ProductTypeEnums;
use App\Models\Setting;
use Illuminate\Validation\Rule;
final class ProductBillingCycles
{
public const SETTING_KEY = 'product_billing_cycles';
private const YEARLY_TYPES = ['domain', 'ssl'];
/**
* @return array<string, list<string>>
*/
public static function all(): array
{
$saved = json_decode((string) Setting::where('key', self::SETTING_KEY)->value('value'), true);
$saved = is_array($saved) ? $saved : [];
$result = [];
foreach (self::types() as $type) {
$result[$type] = self::normalize($saved[$type] ?? self::defaults($type));
}
return $result;
}
/**
* @return list<string>
*/
public static function forType(?string $type): array
{
return self::all()[$type] ?? BillingCycleCatalog::codes();
}
public static function allows(?string $type, string $cycle): bool
{
return $cycle === 'onetime' || in_array($cycle, self::forType($type), true);
}
/**
* @return array<string, mixed>
*/
public static function rules(): array
{
return [
'cycles' => 'required|array',
'cycles.*' => 'array',
'cycles.*.*' => ['string', Rule::in(BillingCycleCatalog::codes())],
];
}
/**
* @param array<string, array<int, string>> $cycles
* @return array<string, list<string>>
*/
public static function save(array $cycles): array
{
$merged = array_merge(self::all(), array_intersect_key($cycles, array_flip(self::types())));
$normalized = array_map(self::normalize(...), $merged);
Setting::updateOrCreate(['key' => self::SETTING_KEY], ['value' => json_encode($normalized)]);
return $normalized;
}
/**
* @return list<array{type: string, label: string}>
*/
public static function typeOptions(): array
{
return array_map(
fn (string $type) => ['type' => $type, 'label' => ProductTypeEnums::from($type)->label()],
self::types()
);
}
/**
* @return list<string>
*/
private static function types(): array
{
return array_values(array_filter(
array_column(ProductTypeEnums::cases(), 'value'),
fn (string $type) => $type !== ProductTypeEnums::ADDON->value
));
}
/**
* @return list<string>
*/
private static function defaults(string $type): array
{
return in_array($type, self::YEARLY_TYPES, true) ? ['annually'] : BillingCycleCatalog::codes();
}
/**
* @param array<int, string> $cycles
* @return list<string>
*/
private static function normalize(array $cycles): array
{
return array_values(array_intersect(BillingCycleCatalog::codes(), $cycles));
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Support;
use App\Models\ActivityLog;
use App\Models\Service;
use Carbon\Carbon;
final class RenewalPeriodGuard
{
public static function violation(Service $service, int $months): ?string
{
$start = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now();
$end = $start->copy()->addMonths($months);
if ($end->lte(now())) {
return 'Không thể gia hạn cho khoảng thời gian trong quá khứ.';
}
$overlapping = ActivityLog::query()
->where('subject_type', Service::class)
->where('subject_id', $service->id)
->where('event', 'renewed')
->get(['old_values', 'new_values'])
->first(fn (ActivityLog $log) => self::overlaps($log, $start, $end));
if (! $overlapping) {
return null;
}
return sprintf(
'Khoảng thời gian %s – %s đã được gia hạn trước đó, không thể gia hạn trùng.',
$start->format('d/m/Y'),
$end->format('d/m/Y'),
);
}
private static function overlaps(ActivityLog $log, Carbon $start, Carbon $end): bool
{
$loggedEnd = $log->new_values['expires_at'] ?? null;
if (! $loggedEnd) {
return false;
}
$loggedEnd = Carbon::parse($loggedEnd);
$loggedStart = ($log->old_values['expires_at'] ?? null)
? Carbon::parse($log->old_values['expires_at'])
: $loggedEnd->copy()->subMonths((int) ($log->new_values['months'] ?? 1));
return $loggedStart->lt($end) && $loggedEnd->gt($start);
}
}
+33 -13
View File
@@ -4,33 +4,53 @@ declare(strict_types=1);
namespace App\Support;
use App\Helpers\OrderHelper;
use App\Models\Addon;
use App\Models\Product;
use App\Models\Service;
class RenewalPricing
{
/** @return list<int> */
public static function validMonths(string $billingCycle): array
/** @return list<array{billing_cycle: string, months: int, price: int}> */
public static function options(?Product $product): array
{
$cycleMonths = OrderHelper::cycleToMonths($billingCycle);
if ($cycleMonths > 24) {
return [$cycleMonths];
if (! $product) {
return [];
}
$maxMultiple = intdiv(24, $cycleMonths);
$options = [];
foreach ($product->pricings()->where('is_active', 1)->get() as $pricing) {
$cycle = (string) $pricing->billing_cycle;
$months = BillingCycleCatalog::monthsFor($cycle);
return array_map(static fn (int $n): int => $n * $cycleMonths, range(1, $maxMultiple));
if (! $months || (int) $pricing->price <= 0 || ! ProductBillingCycles::allows($product->product_type, $cycle)) {
continue;
}
$options[$months] = ['billing_cycle' => $cycle, 'months' => $months, 'price' => (int) $pricing->price];
}
ksort($options);
return array_values($options);
}
public static function addonTotal(Service $service, int $cycles): int
public static function option(?Product $product, int $months): ?array
{
foreach (self::options($product) as $option) {
if ($option['months'] === $months) {
return $option;
}
}
return null;
}
public static function addonTotal(Service $service, float $cycles): int
{
return (int) array_sum(array_column(self::addonInvoiceItems($service, $cycles, 0), 'total_price'));
}
/** @return list<array<string, mixed>> */
public static function addonInvoiceItems(Service $service, int $cycles, int $months): array
public static function addonInvoiceItems(Service $service, float $cycles, int $months): array
{
$meta = array_filter(
(array) ($service->orderItem?->addons_meta ?? []),
@@ -47,7 +67,7 @@ class RenewalPricing
foreach ($meta as $entry) {
$qty = (int) $entry['qty'];
$unitPrice = (int) ($currentPrices[$entry['addon_id'] ?? null] ?? $entry['price'] ?? 0);
$totalPrice = $unitPrice * $qty * $cycles;
$totalPrice = (int) round($unitPrice * $qty * $cycles);
if ($totalPrice <= 0) {
continue;
@@ -59,7 +79,7 @@ class RenewalPricing
'description' => 'Addon: ' . ($entry['name'] ?? strtoupper((string) ($entry['code'] ?? ''))) . ' x' . $qty,
'months' => $months,
'quantity' => $qty,
'unit_price' => $unitPrice * $cycles,
'unit_price' => (int) round($unitPrice * $cycles),
'total_price' => $totalPrice,
];
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Support;
use App\Models\ModuleVmware;
use App\Models\Service;
final class ServiceExpirySync
{
public static function toVmware(Service $service): int
{
if (! $service->vm_id || ! $service->expires_at) {
return 0;
}
$timestamp = $service->expires_at->timestamp;
return ModuleVmware::query()
->whereKey($service->vm_id)
->where(fn ($query) => $query->whereNull('next_due_date')->orWhere('next_due_date', '!=', $timestamp))
->update(['next_due_date' => $timestamp]);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Support;
use App\Models\Invoice;
class VatCalculator
{
public static function refresh(Invoice $invoice): Invoice
{
if ($invoice->vat_number) {
return $invoice;
}
$tax = self::taxFor($invoice);
if ((int) $invoice->tax !== $tax) {
$invoice->forceFill(['tax' => $tax])->saveQuietly();
}
return $invoice;
}
public static function taxFor(Invoice $invoice): int
{
$items = $invoice->invoiceItems()->with('product:id,product_type')->get();
$gross = (int) $items->sum('total_price');
if ($gross <= 0) {
return 0;
}
$payable = min((int) $invoice->total, $gross);
$tax = 0.0;
foreach ($items as $item) {
$rate = VatSettings::rateFor($item->product?->product_type);
$share = $payable * ((int) $item->total_price / $gross);
$tax += $share * $rate / (100 + $rate);
}
return (int) round($tax);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Support;
use App\Enums\Invoice\InvoiceStatus;
use App\Models\Invoice;
use App\Models\Setting;
use Illuminate\Support\Facades\DB;
class VatNumbering
{
public static function settle(Invoice $invoice): Invoice
{
VatCalculator::refresh($invoice);
return self::issueIfAuto($invoice);
}
public static function issueIfAuto(Invoice $invoice): Invoice
{
if (!$invoice->vat_number
&& (int) $invoice->status === InvoiceStatus::PAID->value
&& (int) $invoice->total > 0
&& VatSettings::numbering()['auto_issue']) {
return self::issue($invoice);
}
return $invoice;
}
public static function issue(Invoice $invoice): Invoice
{
return DB::transaction(function () use ($invoice) {
$locked = Invoice::query()->with('customer')->lockForUpdate()->findOrFail($invoice->id);
if ($locked->vat_number) {
return $locked;
}
Setting::query()->firstOrCreate(['key' => VatSettings::NUMBERING_KEY], ['value' => json_encode(VatSettings::numbering())]);
Setting::query()->where('key', VatSettings::NUMBERING_KEY)->lockForUpdate()->first();
$config = VatSettings::numbering();
VatCalculator::refresh($locked);
$next = $config['next_number'];
while (Invoice::query()->where('vat_serial', $config['serial'])->where('vat_number', self::format($next, $config['padding']))->exists()) {
$next++;
}
$locked->fill([
'vat_serial' => $config['serial'],
'vat_number' => self::format($next, $config['padding']),
'vat_issued_at' => now()->toDateString(),
'buyer_snapshot' => InvoiceBuyer::fromCustomer($locked->customer),
])->save();
VatSettings::saveNumbering([...$config, 'next_number' => $next + 1]);
return $locked;
});
}
private static function format(int $number, int $padding): string
{
return str_pad((string) $number, $padding, '0', STR_PAD_LEFT);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Support;
use App\Models\Setting;
class VatSettings
{
public const RATES_KEY = 'vat_settings';
public const NUMBERING_KEY = 'vat_numbering';
private static ?array $rates = null;
public static function rateFor(?string $productType): float
{
if (!$productType) {
return 0.0;
}
return (float) (self::rates()[$productType] ?? 0);
}
public static function numbering(): array
{
$saved = self::read(self::NUMBERING_KEY);
return [
'serial' => (string) ($saved['serial'] ?? '1C26TCP'),
'next_number' => max(1, (int) ($saved['next_number'] ?? 1)),
'padding' => min(12, max(1, (int) ($saved['padding'] ?? 8))),
'auto_issue' => (bool) ($saved['auto_issue'] ?? false),
];
}
public static function saveNumbering(array $config): void
{
Setting::updateOrCreate(
['key' => self::NUMBERING_KEY],
['value' => json_encode($config, JSON_UNESCAPED_UNICODE)]
);
}
public static function forget(): void
{
self::$rates = null;
}
private static function rates(): array
{
return self::$rates ??= collect(self::read(self::RATES_KEY))
->filter(fn ($row) => is_array($row) && isset($row['type']))
->mapWithKeys(fn ($row) => [$row['type'] => (float) ($row['vat_rate'] ?? 0)])
->all();
}
private static function read(string $key): array
{
$value = Setting::where('key', $key)->value('value');
$decoded = is_string($value) ? json_decode($value, true) : null;
return is_array($decoded) ? $decoded : [];
}
}