From fe68171aa72900341c94d7d5ec9c4be547117b38 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Thu, 22 Oct 2026 22:22:09 +0700 Subject: [PATCH] feat(services): partial/unpaid payment on admin create, payment history per invoice, zip attachments Co-Authored-By: Claude Opus 5.5 --- .../Concerns/ManagesCustomerServices.php | 14 ++- .../Concerns/HandlesServiceAttachments.php | 2 +- .../Api/Admin/Service/ServiceController.php | 8 +- .../Resources/Api/Admin/OrderResource.php | 2 + app/Models/ServiceAttachment.php | 2 + .../CustomerServiceProvisioningService.php | 69 ++++++----- app/Services/Order/ServiceOrderRecorder.php | 2 + app/Support/Billing/InvoicePaymentHistory.php | 83 +++++++++++++ .../Admin/Billing/OrderInvoiceExportTest.php | 2 +- .../CreateDomainServicePricingTest.php | 2 +- .../CreateServicePaymentStatusTest.php | 113 ++++++++++++++++++ .../DeleteCustomerServiceOrdersTest.php | 2 +- tests/Feature/Api/Admin/MarkTest.php | 2 +- .../Admin/Service/ServiceAttachmentTest.php | 16 ++- .../ServiceListProductTypeFilterTest.php | 2 +- 15 files changed, 281 insertions(+), 40 deletions(-) create mode 100644 app/Support/Billing/InvoicePaymentHistory.php create mode 100644 tests/Feature/Api/Admin/Customer/CreateServicePaymentStatusTest.php diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php index 49bab95..10489f0 100644 --- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php @@ -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(); diff --git a/app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php b/app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php index 6d39d32..cdc77bf 100644 --- a/app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php +++ b/app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php @@ -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([ diff --git a/app/Http/Controllers/Api/Admin/Service/ServiceController.php b/app/Http/Controllers/Api/Admin/Service/ServiceController.php index 7e9b1f1..bbe7392 100644 --- a/app/Http/Controllers/Api/Admin/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Admin/Service/ServiceController.php @@ -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 diff --git a/app/Http/Resources/Api/Admin/OrderResource.php b/app/Http/Resources/Api/Admin/OrderResource.php index 562e661..0b02e8b 100644 --- a/app/Http/Resources/Api/Admin/OrderResource.php +++ b/app/Http/Resources/Api/Admin/OrderResource.php @@ -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, diff --git a/app/Models/ServiceAttachment.php b/app/Models/ServiceAttachment.php index 71bd4c1..16986d9 100644 --- a/app/Models/ServiceAttachment.php +++ b/app/Models/ServiceAttachment.php @@ -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']; diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php index 47be794..cf8ec3b 100644 --- a/app/Services/Customer/CustomerServiceProvisioningService.php +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -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 diff --git a/app/Services/Order/ServiceOrderRecorder.php b/app/Services/Order/ServiceOrderRecorder.php index 5697051..568bcd8 100644 --- a/app/Services/Order/ServiceOrderRecorder.php +++ b/app/Services/Order/ServiceOrderRecorder.php @@ -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(), diff --git a/app/Support/Billing/InvoicePaymentHistory.php b/app/Support/Billing/InvoicePaymentHistory.php new file mode 100644 index 0000000..bbb25b4 --- /dev/null +++ b/app/Support/Billing/InvoicePaymentHistory.php @@ -0,0 +1,83 @@ + $invoices + * @return array>> 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, + ]; + } +} diff --git a/tests/Feature/Api/Admin/Billing/OrderInvoiceExportTest.php b/tests/Feature/Api/Admin/Billing/OrderInvoiceExportTest.php index 8732608..cf99ae1 100644 --- a/tests/Feature/Api/Admin/Billing/OrderInvoiceExportTest.php +++ b/tests/Feature/Api/Admin/Billing/OrderInvoiceExportTest.php @@ -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(); } } diff --git a/tests/Feature/Api/Admin/Customer/CreateDomainServicePricingTest.php b/tests/Feature/Api/Admin/Customer/CreateDomainServicePricingTest.php index 9d74e42..94a7be9 100644 --- a/tests/Feature/Api/Admin/Customer/CreateDomainServicePricingTest.php +++ b/tests/Feature/Api/Admin/Customer/CreateDomainServicePricingTest.php @@ -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]); } diff --git a/tests/Feature/Api/Admin/Customer/CreateServicePaymentStatusTest.php b/tests/Feature/Api/Admin/Customer/CreateServicePaymentStatusTest.php new file mode 100644 index 0000000..d02583d --- /dev/null +++ b/tests/Feature/Api/Admin/Customer/CreateServicePaymentStatusTest.php @@ -0,0 +1,113 @@ + '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); + } +} diff --git a/tests/Feature/Api/Admin/Customer/DeleteCustomerServiceOrdersTest.php b/tests/Feature/Api/Admin/Customer/DeleteCustomerServiceOrdersTest.php index ca67f9c..31e389e 100644 --- a/tests/Feature/Api/Admin/Customer/DeleteCustomerServiceOrdersTest.php +++ b/tests/Feature/Api/Admin/Customer/DeleteCustomerServiceOrdersTest.php @@ -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(); diff --git a/tests/Feature/Api/Admin/MarkTest.php b/tests/Feature/Api/Admin/MarkTest.php index ceb18df..b3def8d 100644 --- a/tests/Feature/Api/Admin/MarkTest.php +++ b/tests/Feature/Api/Admin/MarkTest.php @@ -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']); diff --git a/tests/Feature/Api/Admin/Service/ServiceAttachmentTest.php b/tests/Feature/Api/Admin/Service/ServiceAttachmentTest.php index 0d175d9..79a2e7a 100644 --- a/tests/Feature/Api/Admin/Service/ServiceAttachmentTest.php +++ b/tests/Feature/Api/Admin/Service/ServiceAttachmentTest.php @@ -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 diff --git a/tests/Feature/Api/Admin/Service/ServiceListProductTypeFilterTest.php b/tests/Feature/Api/Admin/Service/ServiceListProductTypeFilterTest.php index b396c94..06a9f10 100644 --- a/tests/Feature/Api/Admin/Service/ServiceListProductTypeFilterTest.php +++ b/tests/Feature/Api/Admin/Service/ServiceListProductTypeFilterTest.php @@ -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]);