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:
co-authored by
Claude Opus 5.5
parent
02daefe4c2
commit
fe68171aa7
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user