update email template
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user