update email template

This commit is contained in:
baodev0
2026-09-09 16:56:22 +07:00
parent d3d9f76ba0
commit 2365ef72e9
210 changed files with 6335 additions and 4688 deletions
+8 -24
View File
@@ -6,7 +6,6 @@ namespace App\Services\Api\Order;
use App\DTOs\Order\ClientOrderFilters;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
use App\Helpers\OrderHelper;
use App\Jobs\VirtualMachine\Action\VMJobs;
use App\Jobs\VirtualMachine\Provision\RetryProvisioningOrchestrator;
@@ -234,33 +233,16 @@ class OrderQueryService
];
}
$invoice = DB::transaction(function () use ($service, $orderItem, $price, $months) {
$order = Order::create([
'order_num' => OrderHelper::generateOrderNumber(),
'customer_id' => $service->customer_id,
'sub_total' => $price,
'discount_amount' => 0,
'total' => $price,
'status' => OrderStatus::PENDING->value,
'client_ip' => request()?->ip(),
'note' => 'Đơn hàng gia hạn dịch vụ #' . $service->id,
]);
OrderItem::create([
'order_id' => $order->id,
'product_id' => $service->product_id,
'billing_cycle' => $orderItem->billing_cycle,
'months' => $months,
'os_id' => $orderItem->os_id,
'quantity' => 1,
'server_name' => $service->server_name,
'price' => $price,
]);
// Gia hạn là một billing cycle mới của CÙNG một Order/Service — không tạo Order mới.
// Order gốc chính là order đã tạo ra service này (qua order_item -> order_id).
$periodStart = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now();
$periodEnd = $periodStart->copy()->addMonths($months);
$invoice = DB::transaction(function () use ($service, $orderItem, $price, $months, $periodStart, $periodEnd) {
$invoice = Invoice::create([
'invoice_number' => OrderHelper::generateOrderNumber('INV'),
'customer_id' => $service->customer_id,
'order_id' => $order->id,
'order_id' => $orderItem->order_id,
'invoice_type' => 2,
'sub_total' => $price,
'discount_amount' => 0,
@@ -268,6 +250,8 @@ class OrderQueryService
'total' => $price,
'status' => InvoiceStatus::UNPAID->value,
'due_date' => now()->addDays(7)->toDateString(),
'billing_period_start' => $periodStart->toDateString(),
'billing_period_end' => $periodEnd->toDateString(),
]);
\App\Models\InvoiceItem::create([
+2 -14
View File
@@ -83,7 +83,7 @@ class OrderService
$this->orderRepo->createItem($order->id, [
'product_id' => $product->id,
'billing_cycle' => $data['billing_cycle'],
'months' => $this->cycleToMonths($data['billing_cycle']),
'months' => OrderHelper::cycleToMonths($data['billing_cycle']),
'server_name' => $data['server_name'] ?? null,
'os_id' => $data['os_id'] ?? null,
'quantity' => 1,
@@ -151,7 +151,7 @@ class OrderService
private function provisionService(Order $order, OrderItem $item, $customer): void
{
$invoice = $order->invoice;
$months = $item->months ?: $this->cycleToMonths($item->billing_cycle);
$months = $item->months ?: OrderHelper::cycleToMonths($item->billing_cycle);
$expiresAt = now()->addMonths($months);
$service = Service::create([
@@ -253,18 +253,6 @@ class OrderService
// Helpers
// =========================================================================
private function cycleToMonths(?string $cycle): int
{
return match ($cycle) {
'monthly' => 1,
'quarterly' => 3,
'semi_annually' => 6,
'annually' => 12,
'biennially' => 24,
'triennially' => 36,
default => 1,
};
}
private function dispatchAdminNotification(Order $order, $customer): void
{
@@ -95,7 +95,7 @@ class CustomerBillingService
{
$customer = $this->resolveCustomer($uuid);
return $this->orderRepository->filter(['client' => $customer->id], ['customer', 'payment', 'invoice']);
return $this->orderRepository->filter(['client' => $customer->id], ['customer', 'payment', 'invoices']);
}
/**
@@ -114,6 +114,6 @@ class CustomerBillingService
// (dùng ở getOrders) được xoá theo — xem ghi chú ở updateInvoice.
$this->orderRepository->update($orderId, $data);
return $order->refresh()->load(['payment', 'invoice']);
return $order->refresh()->load(['payment', 'invoices']);
}
}
@@ -177,7 +177,7 @@ class CustomerServiceProvisioningService
);
$billingCycle = $data['billing_cycle'] ?? 'monthly';
$months = $this->cycleToMonths($billingCycle);
$months = OrderHelper::cycleToMonths($billingCycle);
$pricing = $product->pricings->where('billing_cycle', $billingCycle)->first();
// Số tiền đã thanh toán: admin nhập tay để khớp với thực tế (có thể lệch giá niêm yết do khuyến mãi/thoả thuận)
@@ -392,16 +392,4 @@ class CustomerServiceProvisioningService
});
}
private function cycleToMonths(?string $cycle): int
{
return match ($cycle) {
'monthly' => 1,
'quarterly' => 3,
'semi_annually' => 6,
'annually' => 12,
'biennially' => 24,
'triennially' => 36,
default => 1,
};
}
}
@@ -0,0 +1,69 @@
<?php
namespace App\Services\Email;
use App\Exceptions\InvalidTemplateVariableException;
use App\Models\Template;
/**
* Render Template subject/message: chỉ thay thế placeholder dạng {{key}} đã khai báo
* trong metadata.variables. Không eval(), không compile Blade/PHP.
*/
class EmailTemplateRenderer
{
private const PLACEHOLDER_PATTERN = '/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/';
/**
* @return array{subject: string, html: string}
*/
public function render(Template $template, array $data): array
{
$variables = $template->variables();
$this->assertKnownVariables($template->scenario ?? $template->name ?? 'unknown', (string) $template->subject, $variables);
$this->assertKnownVariables($template->scenario ?? $template->name ?? 'unknown', (string) $template->message, $variables);
return [
'subject' => $this->replace((string) $template->subject, $variables, $data),
'html' => $this->replace((string) $template->message, $variables, $data),
];
}
private function assertKnownVariables(string $scenario, string $content, array $variables): void
{
$used = $this->extractPlaceholders($content);
$invalid = array_values(array_diff($used, array_keys($variables)));
if ($invalid !== []) {
throw new InvalidTemplateVariableException($scenario, $invalid);
}
}
private function extractPlaceholders(string $content): array
{
preg_match_all(self::PLACEHOLDER_PATTERN, $content, $matches);
return array_values(array_unique($matches[1]));
}
private function replace(string $content, array $variables, array $data): string
{
return preg_replace_callback(self::PLACEHOLDER_PATTERN, function (array $match) use ($variables, $data) {
$key = $match[1];
$definition = $variables[$key] ?? [];
$value = $data[$key] ?? $definition['example'] ?? '';
return $this->formatValue((string) $value, $definition['type'] ?? 'string');
}, $content) ?? $content;
}
private function formatValue(string $value, string $type): string
{
return match ($type) {
// html: nội dung tin cậy do Email Builder sinh ra sẵn (đã được builder escape khi build).
'html' => $value,
'url' => filter_var($value, FILTER_VALIDATE_URL) ? htmlspecialchars($value, ENT_QUOTES, 'UTF-8') : '',
default => htmlspecialchars($value, ENT_QUOTES, 'UTF-8'),
};
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Services\Email;
use App\Jobs\Mail\SendTemplateEmailJob;
use App\Models\Template;
use Illuminate\Support\Facades\Cache;
/**
* Entry point chung để gửi email theo scenario. Load Template (có cache),
* render qua EmailTemplateRenderer, gửi qua queue (SendTemplateEmailJob).
*/
class EmailTemplateService
{
public function __construct(
private readonly EmailTemplateRenderer $renderer,
) {}
/**
* @param string|array<int, string> $to
*/
public function send(string $scenario, string|array $to, array $data = []): void
{
SendTemplateEmailJob::dispatch($scenario, is_array($to) ? $to : [$to], $data);
}
/**
* Render ngay (không gửi) — dùng cho preview trong Email Builder.
*
* @return array{subject: string, html: string}
*/
public function preview(string $scenario, array $data = []): array
{
return $this->renderer->render($this->loadTemplate($scenario), $data);
}
public function loadTemplate(string $scenario): Template
{
return Cache::remember(
Template::CACHE_PREFIX . $scenario,
3600,
fn () => Template::where('scenario', $scenario)->where('status', 1)->firstOrFail(),
);
}
}
+36 -32
View File
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
use App\Enums\TransactionStatus;
use App\Helpers\OrderHelper;
use App\Jobs\Hosting\ProvisionHostingAccountJob;
use App\Jobs\VirtualMachine\Action\VMJobs;
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
@@ -28,23 +29,34 @@ class InvoicePaymentService
throw new \InvalidArgumentException('Hóa đơn không thuộc tài khoản này.');
}
// Balance/status are re-checked below under row locks — this is just a fast
// fail-fast path so we don't open a transaction for an already-paid invoice.
if ((int) $invoice->status === InvoiceStatus::PAID->value) {
return $invoice->fresh(['order.orderItems.product', 'services', 'customer']);
}
if (! in_array((int) $invoice->status, [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value], true)) {
throw new \InvalidArgumentException('Hóa đơn không ở trạng thái có thể thanh toán.');
}
if ((int) $customer->total < (int) $invoice->total) {
throw new \InvalidArgumentException(sprintf(
'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.',
number_format((int) $customer->total),
number_format((int) $invoice->total)
));
}
return DB::transaction(function () use ($invoice, $customer, $method) {
// Lock both rows so two concurrent payment requests for the same invoice/customer
// (double-click, retry) cannot both pass the balance check before either commits.
$invoice = Invoice::where('id', $invoice->id)->lockForUpdate()->firstOrFail();
$customer = Customer::where('id', $customer->id)->lockForUpdate()->firstOrFail();
if ((int) $invoice->status === InvoiceStatus::PAID->value) {
return $invoice->fresh(['order.orderItems.product', 'services', 'customer']);
}
if (! in_array((int) $invoice->status, [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value], true)) {
throw new \InvalidArgumentException('Hóa đơn không ở trạng thái có thể thanh toán.');
}
if ((int) $customer->total < (int) $invoice->total) {
throw new \InvalidArgumentException(sprintf(
'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.',
number_format((int) $customer->total),
number_format((int) $invoice->total)
));
}
$customer->decrement('total', (int) $invoice->total);
$invoice->update([
@@ -128,12 +140,13 @@ class InvoicePaymentService
return;
}
if ((int) $order->status !== OrderStatus::ACTIVE->value) {
$order->update([
'status' => OrderStatus::ACTIVE->value,
'payment_method' => $invoice->payment_method,
]);
}
// payment_method/paid_at always mirror the invoice that was just paid, even if the
// order's status doesn't change (e.g. it's already ACTIVE from an earlier payment).
$order->update([
'status' => OrderStatus::ACTIVE->value,
'payment_method' => $invoice->payment_method,
'paid_at' => $invoice->paid_at,
]);
foreach ($order->orderItems as $item) {
$isHostingProvision = ! $item->os_id
@@ -150,7 +163,7 @@ class InvoicePaymentService
'status' => ($item->os_id || $isHostingProvision) ? 'pending' : 'active',
'auto_renew' => false,
'started_at' => now(),
'expires_at' => now()->addMonths($item->months ?: $this->cycleToMonths($item->billing_cycle)),
'expires_at' => now()->addMonths($item->months ?: OrderHelper::cycleToMonths($item->billing_cycle)),
]
);
@@ -177,10 +190,13 @@ class InvoicePaymentService
return;
}
if ($invoice->order && (int) $invoice->order->status !== OrderStatus::ACTIVE->value) {
if ($invoice->order) {
// Same rule as activateOrder(): payment_method/paid_at always mirror the invoice
// that was just paid, regardless of whether the order's status actually changes.
$invoice->order->update([
'status' => OrderStatus::ACTIVE->value,
'payment_method' => $invoice->payment_method,
'paid_at' => $invoice->paid_at,
]);
}
@@ -239,16 +255,4 @@ class InvoicePaymentService
}
}
private function cycleToMonths(?string $cycle): int
{
return match ($cycle) {
'monthly' => 1,
'quarterly' => 3,
'semi_annually' => 6,
'annually' => 12,
'biennially' => 24,
'triennially' => 36,
default => 1,
};
}
}
+7 -2
View File
@@ -139,14 +139,16 @@ class OrderService
[$productItems, $relIdData, $total] = $this->buildProductItems($data);
$orderTotal = (int) ($total + ($total * (10 / 100)));
// Giá niêm yết đã bao gồm thuế (khớp với client-facing OrderService::create()) —
// không cộng thêm thuế lên trên ở đây.
$orderTotal = (int) $total;
$invoiceData = [
'invoice_number' => Helper::generateUniqueCode(),
'due_date' => Carbon::today()->addDays(7)->toDateString(),
'paid_at' => $effectiveInvoiceStatus === InvoiceStatus::PAID->value ? Carbon::now() : null,
'sub_total' => $total,
'tax' => $total * (10 / 100),
'tax' => 0,
'total' => $orderTotal,
'payment_method' => $paymentRecord?->id,
'customer_id' => $data['customer_id'],
@@ -163,6 +165,9 @@ class OrderService
'payment_proof' => $data['payment_proof'] ?? null,
'user_id' => Auth::guard('admin')->id(),
'status' => $effectiveOrderStatus,
// Order.paid_at mirrors invoice.paid_at — set together, same rule as
// InvoicePaymentService::activateOrder()/activateRenewal().
'paid_at' => $invoiceData['paid_at'],
'client_ip' => request()->getClientIp(),
];