customer = Customer::factory()->create(['total' => 10_000_000]); $this->product = Product::factory()->create(['product_type' => 'vps', 'is_active' => true]); ProductPricings::create([ 'product_id' => $this->product->id, 'billing_cycle' => 'monthly', 'price' => 100_000, 'is_active' => true, ]); } private function coupon(array $overrides = []): Coupon { return Coupon::create(array_merge([ 'code' => 'SALE10', 'type' => Coupon::TYPE_PERCENT, 'value' => 10, 'applies_to' => Coupon::APPLIES_NEW, 'is_active' => true, ], $overrides)); } private function as(Customer $customer): static { return $this->actingAs($customer, 'client') ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); } private function order(string $code, array $extra = [], ?Customer $customer = null): TestResponse { return $this->as($customer ?? $this->customer)->postJson('/v1/orders', array_merge([ 'product_id' => $this->product->uuid, 'billing_cycle' => 'monthly', 'coupon_code' => $code, ], $extra)); } public function test_preview_returns_discount_capped_by_max_discount(): void { $this->coupon(['value' => 50, 'max_discount' => 20_000]); $this->as($this->customer)->postJson('/v1/orders/coupon-preview', [ 'product_id' => $this->product->uuid, 'billing_cycle' => 'monthly', 'coupon_code' => ' sale10 ', ])->assertOk() ->assertJsonPath('data.discount', 20_000) ->assertJsonPath('data.total', 80_000); } public function test_order_stores_discount_on_order_invoice_and_charges_discounted_total(): void { Bus::fake(); $this->coupon(); $response = $this->order('SALE10', ['discount_amount' => 99_999, 'total' => 1, 'price' => 1]) ->assertCreated(); $order = Order::where('order_num', $response->json('data.order.order_num'))->firstOrFail(); $this->assertSame(100_000, (int) $order->sub_total); $this->assertSame(10_000, (int) $order->discount_amount); $this->assertSame(90_000, (int) $order->total); $this->assertSame('SALE10', $order->coupon_code); $this->assertSame(10_000, (int) $order->invoice->discount_amount); $this->assertSame(90_000, (int) $order->invoice->total); $this->assertSame(100_000, (int) $order->invoice->sub_total); $this->assertSame('SALE10', $order->invoice->coupon_code); $this->assertSame(1, CouponRedemption::where('order_id', $order->id)->whereNull('released_at')->count()); app(OrderService::class)->pay($order->fresh(['invoice', 'customer']), 'balance'); $this->assertSame(10_000_000 - 90_000, $this->customer->fresh()->total); } public function test_client_cannot_override_price_without_coupon(): void { Bus::fake(); $response = $this->order('', ['coupon_code' => null, 'discount_amount' => 50_000, 'total' => 1])->assertCreated(); $order = Order::where('order_num', $response->json('data.order.order_num'))->firstOrFail(); $this->assertSame(0, (int) $order->discount_amount); $this->assertSame(100_000, (int) $order->total); } public function test_expired_and_not_started_and_inactive_codes_are_rejected(): void { $this->coupon(['code' => 'OLD', 'ends_at' => now()->subDay()]); $this->coupon(['code' => 'SOON', 'starts_at' => now()->addDay()]); $this->coupon(['code' => 'OFF', 'is_active' => false]); foreach (['OLD', 'SOON', 'OFF', 'MISSING'] as $code) { $this->order($code)->assertStatus(422); } $this->assertSame(0, Order::count()); } public function test_scope_and_minimum_order_are_enforced(): void { $this->coupon(['code' => 'HOST', 'product_types' => ['hosting']]); $this->coupon(['code' => 'CYCLE', 'billing_cycles' => ['annually']]); $this->coupon(['code' => 'BIG', 'min_order' => 500_000]); $this->coupon(['code' => 'RENEW', 'applies_to' => Coupon::APPLIES_RENEWAL]); $this->coupon(['code' => 'OTHERPROD', 'product_ids' => [$this->product->id + 999]]); foreach (['HOST', 'CYCLE', 'BIG', 'RENEW', 'OTHERPROD'] as $code) { $this->order($code)->assertStatus(422); } $this->assertSame(0, Order::count()); } public function test_usage_limit_is_enforced_across_customers(): void { Bus::fake(); $this->coupon(['usage_limit' => 1]); $other = Customer::factory()->create(['total' => 10_000_000]); $this->order('SALE10')->assertCreated(); $this->order('SALE10', [], $other)->assertStatus(422) ->assertJsonPath('message', 'Mã giảm giá đã hết lượt sử dụng.'); } public function test_per_customer_limit_is_enforced(): void { Bus::fake(); $this->coupon(['per_customer_limit' => 1]); $first = $this->order('SALE10')->assertCreated(); Order::where('order_num', $first->json('data.order.order_num'))->update(['status' => OrderStatus::ACTIVE->value]); $this->order('SALE10')->assertStatus(422) ->assertJsonPath('message', 'Bạn đã dùng hết số lần cho phép của mã giảm giá này.'); } public function test_cancelling_order_releases_redemption_and_restoring_reclaims_it(): void { Bus::fake(); $this->coupon(['usage_limit' => 1]); $response = $this->order('SALE10')->assertCreated(); $order = Order::where('order_num', $response->json('data.order.order_num'))->firstOrFail(); $order->update(['status' => OrderStatus::CANCELLED->value]); $this->assertNotNull(CouponRedemption::where('order_id', $order->id)->value('released_at')); $order->update(['status' => OrderStatus::PENDING->value]); $this->assertNull(CouponRedemption::where('order_id', $order->id)->value('released_at')); } public function test_discount_never_drops_total_below_minimum_payable(): void { Bus::fake(); $this->coupon(['code' => 'FREE', 'type' => Coupon::TYPE_FIXED, 'value' => 5_000_000]); $this->order('FREE')->assertCreated(); $this->assertSame(1_000, (int) Order::first()->invoice->total); } }