feat(services): partial/unpaid payment on admin create, payment history per invoice, zip attachments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-22 22:22:09 +07:00
co-authored by Claude Opus 5.5
parent 02daefe4c2
commit fe68171aa7
15 changed files with 281 additions and 40 deletions
@@ -215,8 +215,10 @@ trait ManagesCustomerServices
'expires_at' => 'nullable|date',
'note' => 'nullable|string',
'billing_cycle' => ['nullable', Rule::in(BillingCycleCatalog::codes())],
'payment_method' => 'required|in:transfer,balance',
'amount_paid' => 'nullable|numeric|min:0',
'price' => 'nullable|numeric|min:0',
'payment_status' => 'nullable|in:paid,partial,unpaid',
'payment_method' => 'required_unless:payment_status,unpaid|nullable|in:transfer,balance',
'amount_paid' => 'required_if:payment_status,partial|nullable|numeric|gt:0',
'proof_images' => 'nullable|array',
'proof_images.*' => 'file|image|max:5120',
'os_id' => 'nullable|integer|exists:os,id',
@@ -230,8 +232,12 @@ trait ManagesCustomerServices
'vm_port' => 'nullable|integer|min:1|max:65535',
]);
// Upload proof images (chuyển khoản) — I/O ở tầng HTTP, service chỉ nhận đường dẫn đã lưu.
if ($data['payment_method'] === 'transfer' && $request->hasFile('proof_images')) {
$data['payment_status'] ??= 'paid';
if ($data['payment_status'] === 'unpaid') {
$data['payment_method'] = null;
}
if (($data['payment_method'] ?? null) === 'transfer' && $request->hasFile('proof_images')) {
$data['_proof_paths'] = collect($request->file('proof_images'))
->map(fn($file) => $file->store('payment-proofs', 'public'))
->all();
@@ -28,7 +28,7 @@ trait HandlesServiceAttachments
public function uploadAttachment(Request $request, int $id): JsonResponse
{
Service::findOrFail($id);
$request->validate(['file' => InvoiceAttachment::UPLOAD_RULE]);
$request->validate(['file' => ServiceAttachment::UPLOAD_RULE]);
$file = $request->file('file');
$attachment = ServiceAttachment::create([
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Jobs\VirtualMachine\Provision\ResendVmCredentialsEmailJob;
use App\Models\ActivityLog;
use App\Models\DomainPricing;
use App\Support\Billing\InvoicePaymentHistory;
use App\Models\Invoice;
use App\Models\ModuleVmware;
use App\Models\Service;
@@ -93,7 +94,10 @@ class ServiceController extends Controller
ModuleVmware::resolveVirtualMachineIds([$service->vmware]);
$service->setAttribute('linked_invoices', $this->linkedInvoices(collect([$service])));
$invoices = $this->linkedInvoices(collect([$service]));
$payments = InvoicePaymentHistory::forInvoices($invoices);
$invoices->each(fn (Invoice $invoice) => $invoice->setAttribute('payments', $payments[$invoice->id]));
$service->setAttribute('linked_invoices', $invoices);
$service->setAttribute('activity_logs', ActivityLog::where('subject_type', Service::class)
->where('subject_id', $service->id)
->latest('id')
@@ -109,7 +113,7 @@ class ServiceController extends Controller
return Invoice::ofServices($services)
->with('order:id,order_num')
->latest('id')
->get(['id', 'order_id', 'service_id', 'invoice_number', 'invoice_type', 'status', 'total', 'created_at']);
->get(['id', 'customer_id', 'order_id', 'service_id', 'invoice_number', 'invoice_type', 'status', 'total', 'amount_paid', 'due_date', 'created_at']);
}
public function bulkAction(Request $request): JsonResponse
@@ -76,6 +76,8 @@ class OrderResource extends JsonResource
'url' => $attachment->url,
'mime_type' => $attachment->mime_type,
'size' => $attachment->size,
'uploaded_by' => $attachment->uploaded_by,
'created_at' => $attachment->created_at,
])
: [],
// Which service(s)/product(s) this invoice actually bills for — without this,
+2
View File
@@ -7,6 +7,8 @@ use Illuminate\Support\Facades\Storage;
class ServiceAttachment extends Model
{
public const UPLOAD_RULE = 'required|file|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,zip|max:51200';
protected $guarded = [];
protected $appends = ['url'];
@@ -204,38 +204,49 @@ class CustomerServiceProvisioningService
$billingCycle = $data['billing_cycle'] ?? 'monthly';
$months = OrderHelper::cycleToMonths($billingCycle);
// 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)
$listPrice = $this->orderRecorder->listPrice($product, $billingCycle, $data['server_name'] ?? null);
abort_if(
!isset($data['amount_paid']) && $listPrice === null && $product->product_type === 'domain',
!isset($data['price']) && $listPrice === null && $product->product_type === 'domain',
422,
'Tên miền không có giá trong bảng giá tên miền, vui lòng nhập giá.',
);
$basePrice = isset($data['amount_paid']) ? (int) $data['amount_paid'] : (int) $listPrice;
$basePrice = (int) round((float) ($data['price'] ?? $listPrice));
$paymentMethod = $data['payment_method'];
// Đối với balance: kiểm tra số dư trước khi vào transaction
$paymentStatus = $data['payment_status'] ?? 'paid';
$paidAmount = match ($paymentStatus) {
'unpaid' => 0,
'partial' => (int) round((float) $data['amount_paid']),
default => $basePrice,
};
abort_if(
$paymentMethod === 'balance' && (int) $customer->total < $basePrice,
$paymentStatus === 'partial' && $paidAmount >= $basePrice,
422,
'Số tiền trả trước phải nhỏ hơn giá dịch vụ. Khách trả đủ thì chọn "Đã thanh toán".',
);
$paymentMethod = $paymentStatus === 'unpaid' ? null : $data['payment_method'];
abort_if(
$paymentMethod === 'balance' && (int) $customer->total < $paidAmount,
422,
sprintf(
'Số dư khách không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.',
number_format((int) $customer->total),
number_format($basePrice),
number_format($paidAmount),
),
);
return DB::transaction(function () use ($customer, $product, $data, $billingCycle, $months, $basePrice, $paymentMethod, $clientIp) {
return DB::transaction(function () use ($customer, $product, $data, $billingCycle, $months, $basePrice, $paidAmount, $paymentStatus, $paymentMethod, $clientIp) {
['order' => $order, 'orderItem' => $orderItem, 'invoice' => $invoice] = $this->orderRecorder->create(
$customer->id,
$product,
$billingCycle,
$basePrice,
$data['server_name'] ?? null,
paid: true,
paid: $paymentStatus === 'paid',
paymentMethod: $paymentMethod,
clientIp: $clientIp,
amountPaid: $paidAmount,
);
// Ngày bắt đầu/hết hạn dùng chung cho cả module_vmware (next_due_date/next_invoice) và services.
@@ -246,28 +257,32 @@ class CustomerServiceProvisioningService
// resolve VM qua service->orderItem->order->vmWare, không qua services.vm_id.
$vmId = $this->createModuleVmwareIfNeeded($data, $customer, $order, $basePrice, $expiresAt);
// 4) Payment processing
if ($paymentMethod === 'balance') {
$customer->total = (int) $customer->total - $basePrice;
$customer->total = (int) $customer->total - $paidAmount;
$customer->save();
}
$proofPaths = $data['_proof_paths'] ?? [];
Transaction::create([
'uuid' => (string) Str::uuid(),
'public_code' => Transaction::newPublicCode('SVC'),
'customer_id' => $customer->id,
'user_id' => Auth::id(),
'trans' => $order->order_num,
'amount' => $basePrice,
'type' => $paymentMethod === 'transfer' ? 'transfer' : 'balance',
'status' => 1, // paid
'note' => $paymentMethod === 'transfer'
if ($paidAmount > 0) {
$proofPaths = $data['_proof_paths'] ?? [];
$paymentNote = $paymentMethod === 'transfer'
? 'Admin tạo dịch vụ - thanh toán chuyển khoản'
: 'Admin tạo dịch vụ - trừ số dư khách hàng',
'proof_image' => !empty($proofPaths) ? json_encode($proofPaths) : null,
]);
: 'Admin tạo dịch vụ - trừ số dư khách hàng';
Transaction::create([
'uuid' => (string) Str::uuid(),
'public_code' => Transaction::newPublicCode('SVC'),
'customer_id' => $customer->id,
'user_id' => Auth::id(),
'trans' => $order->order_num,
'amount' => $paidAmount,
'type' => $paymentMethod === 'transfer' ? 'transfer' : 'balance',
'status' => 1,
'note' => $paymentStatus === 'partial'
? sprintf('%s (trả trước %s/%s VNĐ)', $paymentNote, number_format($paidAmount), number_format($basePrice))
: $paymentNote,
'proof_image' => !empty($proofPaths) ? json_encode($proofPaths) : null,
]);
}
// 5) Service — updateOrCreate để tránh trùng khi cùng VM được gán lại
$serviceLookup = $vmId
@@ -30,6 +30,7 @@ class ServiceOrderRecorder
bool $paid,
?string $paymentMethod = null,
?string $clientIp = null,
int $amountPaid = 0,
): array {
$months = OrderHelper::cycleToMonths($billingCycle);
$isDomain = $product?->product_type === 'domain';
@@ -69,6 +70,7 @@ class ServiceOrderRecorder
'tax' => 0,
'total' => $price,
'status' => $paid ? InvoiceStatus::PAID->value : InvoiceStatus::UNPAID->value,
'amount_paid' => $paid ? $price : $amountPaid,
'payment_method' => $paymentMethod,
'paid_at' => $paid ? now() : null,
'due_date' => $paid ? now()->addMonths($months)->toDateString() : now()->toDateString(),
@@ -0,0 +1,83 @@
<?php
namespace App\Support\Billing;
use App\Models\Invoice;
use App\Models\Transaction;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class InvoicePaymentHistory
{
private const SERVICE_CREATION_PREFIX = 'SVC-';
/**
* @param Collection<int, Invoice> $invoices
* @return array<int, list<array<string, mixed>>> keyed by invoice id, oldest payment first
*/
public static function forInvoices(Collection $invoices): array
{
$history = $invoices->mapWithKeys(fn (Invoice $invoice) => [$invoice->id => []])->all();
if ($invoices->isEmpty()) {
return $history;
}
$invoiceByOrderNum = $invoices
->filter(fn (Invoice $invoice) => (int) $invoice->invoice_type === 1 && $invoice->order?->order_num)
->keyBy(fn (Invoice $invoice) => $invoice->order->order_num);
$transactions = Transaction::with('user')
->whereIn('customer_id', $invoices->pluck('customer_id')->unique())
->where(function ($query) use ($invoices, $invoiceByOrderNum) {
foreach ($invoices as $invoice) {
$query->orWhere('trans', $invoice->invoice_number)
->orWhere('trans', 'like', $invoice->invoice_number . '-%');
}
if ($invoiceByOrderNum->isNotEmpty()) {
$query->orWhere(fn ($creation) => $creation
->whereIn('trans', $invoiceByOrderNum->keys())
->where('public_code', 'like', self::SERVICE_CREATION_PREFIX . '%'));
}
})
->oldest('id')
->get();
$invoiceByNumber = $invoices->keyBy('invoice_number');
foreach ($transactions as $transaction) {
$invoice = self::matchInvoice($transaction, $invoiceByNumber, $invoiceByOrderNum);
if ($invoice) {
$history[$invoice->id][] = self::present($transaction);
}
}
return $history;
}
private static function matchInvoice(Transaction $transaction, Collection $invoiceByNumber, Collection $invoiceByOrderNum): ?Invoice
{
if (Str::startsWith((string) $transaction->public_code, self::SERVICE_CREATION_PREFIX)) {
return $invoiceByOrderNum->get($transaction->trans);
}
$number = preg_replace('/-(P\d+|REFUND)$/', '', (string) $transaction->trans);
return $invoiceByNumber->get($number);
}
private static function present(Transaction $transaction): array
{
$isRefund = Str::endsWith((string) $transaction->trans, '-REFUND');
return [
'id' => $transaction->id,
'public_code' => $transaction->public_code,
'kind' => $isRefund ? 'refund' : 'payment',
'amount' => abs((int) $transaction->amount),
'type' => $transaction->type,
'note' => $transaction->note,
'recorded_by' => $transaction->user?->fullname ?: $transaction->user?->username,
'created_at' => $transaction->created_at,
];
}
}
@@ -35,7 +35,7 @@ class OrderInvoiceExportTest extends TestCase
foreach (['first.vn', 'second.vn'] as $serverName) {
$this->actingAs($this->admin, 'admin')->postJson(
"/api/v1/administrator/customers/{$customer->uuid}/services",
['product_id' => $product->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'amount_paid' => 0],
['product_id' => $product->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'price' => 0],
)->assertCreated();
}
}
@@ -60,7 +60,7 @@ class CreateDomainServicePricingTest extends TestCase
$this->createService(['server_name' => 'example.xyz'])->assertStatus(422);
$this->assertSame(0, Service::count());
$this->createService(['server_name' => 'example.xyz', 'amount_paid' => 250_000])->assertCreated();
$this->createService(['server_name' => 'example.xyz', 'price' => 250_000])->assertCreated();
$this->assertDatabaseHas('order_items', ['domain_name' => 'example.xyz', 'price' => 250_000]);
}
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Customer;
use App\Enums\Invoice\InvoiceStatus;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Product;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class CreateServicePaymentStatusTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private Customer $customer;
private Product $product;
protected function setUp(): void
{
parent::setUp();
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$this->admin = User::factory()->create();
$this->admin->assignRole('Full Administrator');
$this->customer = Customer::factory()->create(['total' => 1_000_000]);
$this->product = Product::factory()->create(['product_type' => 'hosting']);
}
private function createService(array $data)
{
return $this->actingAs($this->admin, 'admin')->postJson(
"/api/v1/administrator/customers/{$this->customer->uuid}/services",
['product_id' => $this->product->id, 'server_name' => 'site.vn', 'price' => 500_000, ...$data],
);
}
public function test_paid_in_full_marks_invoice_paid(): void
{
$this->createService(['payment_status' => 'paid', 'payment_method' => 'balance'])->assertCreated();
$invoice = Invoice::sole();
$this->assertSame(InvoiceStatus::PAID->value, (int) $invoice->status);
$this->assertSame(500_000, (int) $invoice->amount_paid);
$this->assertSame(500_000, (int) $this->customer->fresh()->total);
}
public function test_partial_payment_keeps_invoice_unpaid_with_prepaid_amount(): void
{
$this->createService(['payment_status' => 'partial', 'payment_method' => 'balance', 'amount_paid' => 200_000])->assertCreated();
$invoice = Invoice::sole();
$this->assertSame(InvoiceStatus::UNPAID->value, (int) $invoice->status);
$this->assertSame(500_000, (int) $invoice->total);
$this->assertSame(200_000, (int) $invoice->amount_paid);
$this->assertSame(200_000, (int) Transaction::sole()->amount);
$this->assertSame(800_000, (int) $this->customer->fresh()->total);
}
public function test_service_detail_lists_every_payment_installment_of_an_invoice(): void
{
$serviceId = $this->createService(['payment_status' => 'partial', 'payment_method' => 'balance', 'amount_paid' => 200_000])
->assertCreated()
->json('data.id');
$invoice = Invoice::sole();
$admin = $this->actingAs($this->admin, 'admin');
$admin->postJson("/api/v1/administrator/invoices/{$invoice->id}/paid", ['received_amount' => 100_000])->assertOk();
$admin->postJson("/api/v1/administrator/invoices/{$invoice->id}/paid", ['note' => 'Đợt cuối'])->assertOk();
$payments = $admin->getJson("/api/v1/administrator/services/{$serviceId}")
->assertOk()
->json('data.linked_invoices.0.payments');
$this->assertSame([200_000, 100_000, 200_000], array_column($payments, 'amount'));
$this->assertSame(InvoiceStatus::PAID->value, (int) $invoice->fresh()->status);
}
public function test_decimal_amounts_are_rounded_to_whole_dong(): void
{
$this->createService(['price' => 731.33, 'payment_status' => 'partial', 'payment_method' => 'balance', 'amount_paid' => 400.6])->assertCreated();
$invoice = Invoice::sole();
$this->assertSame(731, (int) $invoice->total);
$this->assertSame(401, (int) $invoice->amount_paid);
}
public function test_partial_payment_must_be_less_than_price(): void
{
$this->createService(['payment_status' => 'partial', 'payment_method' => 'balance', 'amount_paid' => 500_000])->assertStatus(422);
$this->createService(['payment_status' => 'partial', 'payment_method' => 'balance'])->assertStatus(422);
$this->assertSame(0, Invoice::count());
}
public function test_unpaid_creates_unpaid_invoice_without_transaction(): void
{
$this->createService(['payment_status' => 'unpaid'])->assertCreated();
$invoice = Invoice::sole();
$this->assertSame(InvoiceStatus::UNPAID->value, (int) $invoice->status);
$this->assertSame(0, (int) $invoice->amount_paid);
$this->assertSame(0, Transaction::count());
$this->assertSame(1_000_000, (int) $this->customer->fresh()->total);
}
}
@@ -39,7 +39,7 @@ class DeleteCustomerServiceOrdersTest extends TestCase
{
$this->actingAs($this->admin, 'admin')->postJson(
"/api/v1/administrator/customers/{$this->customer->uuid}/services",
['product_id' => $this->product->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'amount_paid' => 0],
['product_id' => $this->product->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'price' => 0],
)->assertCreated();
return Service::where('server_name', $serverName)->firstOrFail();
+1 -1
View File
@@ -127,7 +127,7 @@ class MarkTest extends TestCase
foreach (['a.vn', 'b.vn'] as $serverName) {
$this->actingAs($admin, 'admin')->postJson(
"/api/v1/administrator/customers/{$customer->uuid}/services",
['product_id' => $product->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'amount_paid' => 0],
['product_id' => $product->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'price' => 0],
)->assertCreated();
}
$tag = Tag::create(['title' => 'Gấp', 'color' => '#fa8c16']);
@@ -73,6 +73,19 @@ class ServiceAttachmentTest extends TestCase
Storage::disk('public')->assertExists($copy->path);
}
public function test_upload_accepts_zip_and_rejects_executables(): void
{
$base = "/api/v1/administrator/services/{$this->service->id}/attachments";
$admin = $this->actingAs($this->admin, 'admin');
$admin->post($base, ['file' => UploadedFile::fake()->create('source.zip', 10, 'application/zip')], ['Accept' => 'application/json'])
->assertCreated();
$admin->post($base, ['file' => UploadedFile::fake()->create('run.exe', 10, 'application/x-msdownload')], ['Accept' => 'application/json'])
->assertUnprocessable();
$this->assertDatabaseCount('service_attachments', 1);
}
public function test_list_and_detail_expose_linked_order_and_invoices(): void
{
$admin = $this->actingAs($this->admin, 'admin');
@@ -85,7 +98,8 @@ class ServiceAttachmentTest extends TestCase
$admin->getJson("/api/v1/administrator/services/{$this->service->id}")
->assertOk()
->assertJsonPath('data.linked_invoices.0.id', $this->invoice->id);
->assertJsonPath('data.linked_invoices.0.id', $this->invoice->id)
->assertJsonPath('data.linked_invoices.0.amount_paid', 0);
}
public function test_rejects_unsupported_file_type(): void
@@ -26,7 +26,7 @@ class ServiceListProductTypeFilterTest extends TestCase
foreach (['email' => 'mail.vn', 'hardware' => 'box.vn'] as $type => $serverName) {
$this->actingAs($admin, 'admin')->postJson(
"/api/v1/administrator/customers/{$customer->uuid}/services",
['product_id' => Product::factory()->create(['product_type' => $type])->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'amount_paid' => 0],
['product_id' => Product::factory()->create(['product_type' => $type])->id, 'payment_method' => 'transfer', 'server_name' => $serverName, 'price' => 0],
)->assertCreated();
}
Service::where('server_name', 'box.vn')->update(['type' => null]);