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:
co-authored by
Claude Sonnet 5.5
parent
ea9a53f742
commit
25f23d541e
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user