Skip to content

Commit 16cc2c2

Browse files
committed
Phase 55: Price List Management API
Adds price list CRUD with per-product override pricing and quantity-break tiers, a lookup endpoint to resolve effective price for a product+quantity combination, and default price-list management (setting a new default clears the previous one). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7328d7e commit 16cc2c2

3 files changed

Lines changed: 318 additions & 0 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\Finance\Models\PriceList;
6+
use App\Modules\Finance\Models\PriceListItem;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\Request;
9+
10+
class PriceListApiController extends ApiController
11+
{
12+
public function index(Request $request): JsonResponse
13+
{
14+
$tenantId = $this->tenantId($request);
15+
$priceLists = PriceList::where('tenant_id', $tenantId)
16+
->when($request->boolean('active_only'), fn ($q) => $q->where('is_active', true))
17+
->withCount('items')
18+
->orderByDesc('is_default')
19+
->get();
20+
21+
return $this->success($priceLists);
22+
}
23+
24+
public function store(Request $request): JsonResponse
25+
{
26+
$tenantId = $this->tenantId($request);
27+
28+
$data = $request->validate([
29+
'name' => ['required', 'string', 'max:100'],
30+
'description' => ['nullable', 'string'],
31+
'currency_code' => ['nullable', 'string', 'max:3'],
32+
'discount_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
33+
'valid_from' => ['nullable', 'date'],
34+
'valid_to' => ['nullable', 'date', 'after_or_equal:valid_from'],
35+
'is_default' => ['boolean'],
36+
]);
37+
38+
if (! empty($data['is_default'])) {
39+
PriceList::where('tenant_id', $tenantId)->update(['is_default' => false]);
40+
}
41+
42+
$priceList = PriceList::create([...$data, 'tenant_id' => $tenantId, 'is_active' => true]);
43+
44+
return $this->success($priceList, 201);
45+
}
46+
47+
public function show(PriceList $priceList): JsonResponse
48+
{
49+
return $this->success($priceList->load('items.product:id,name,sku'));
50+
}
51+
52+
public function update(Request $request, PriceList $priceList): JsonResponse
53+
{
54+
$data = $request->validate([
55+
'name' => ['sometimes', 'string', 'max:100'],
56+
'description' => ['nullable', 'string'],
57+
'discount_percent' => ['nullable', 'numeric', 'min:0', 'max:100'],
58+
'valid_from' => ['nullable', 'date'],
59+
'valid_to' => ['nullable', 'date'],
60+
'is_active' => ['boolean'],
61+
'is_default' => ['boolean'],
62+
]);
63+
64+
if (! empty($data['is_default'])) {
65+
PriceList::where('tenant_id', $priceList->tenant_id)->update(['is_default' => false]);
66+
}
67+
68+
$priceList->update($data);
69+
70+
return $this->success($priceList->fresh());
71+
}
72+
73+
public function destroy(PriceList $priceList): JsonResponse
74+
{
75+
$priceList->items()->delete();
76+
$priceList->delete();
77+
78+
return $this->success(['message' => 'Price list deleted.']);
79+
}
80+
81+
// ── Price List Items ──────────────────────────────────────────────────────
82+
83+
public function addItem(Request $request, PriceList $priceList): JsonResponse
84+
{
85+
$tenantId = $this->tenantId($request);
86+
87+
$data = $request->validate([
88+
'product_id' => ['required', 'integer', 'exists:products,id'],
89+
'unit_price' => ['required', 'numeric', 'min:0'],
90+
'min_quantity' => ['nullable', 'integer', 'min:1'],
91+
]);
92+
93+
$item = PriceListItem::updateOrCreate(
94+
['price_list_id' => $priceList->id, 'product_id' => $data['product_id'], 'min_quantity' => $data['min_quantity'] ?? 1],
95+
['tenant_id' => $tenantId, 'unit_price' => $data['unit_price']]
96+
);
97+
98+
return $this->success($item->load('product:id,name,sku'), 201);
99+
}
100+
101+
public function removeItem(PriceList $priceList, PriceListItem $item): JsonResponse
102+
{
103+
$item->delete();
104+
return $this->success(['message' => 'Item removed.']);
105+
}
106+
107+
public function lookup(Request $request): JsonResponse
108+
{
109+
$tenantId = $this->tenantId($request);
110+
111+
$data = $request->validate([
112+
'price_list_id' => ['required', 'integer', 'exists:price_lists,id'],
113+
'product_id' => ['required', 'integer', 'exists:products,id'],
114+
'quantity' => ['nullable', 'numeric', 'min:1'],
115+
]);
116+
117+
$priceList = PriceList::where('tenant_id', $tenantId)->findOrFail($data['price_list_id']);
118+
$price = $priceList->getPriceForProduct($data['product_id'], $data['quantity'] ?? 1);
119+
120+
return $this->success([
121+
'price_list_id' => $data['price_list_id'],
122+
'product_id' => $data['product_id'],
123+
'quantity' => $data['quantity'] ?? 1,
124+
'unit_price' => $price,
125+
'has_override' => $price !== null,
126+
]);
127+
}
128+
129+
private function tenantId(Request $request): int
130+
{
131+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
132+
}
133+
}

erp/routes/api.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,18 @@
456456
Route::post('/{contact}/evaluate', [\App\Http\Controllers\Api\V1\VendorPerformanceController::class, 'evaluate']);
457457
});
458458

459+
// Price List Management
460+
Route::prefix('price-lists')->group(function () {
461+
Route::get('/', [\App\Http\Controllers\Api\V1\PriceListApiController::class, 'index']);
462+
Route::post('/', [\App\Http\Controllers\Api\V1\PriceListApiController::class, 'store']);
463+
Route::post('/lookup', [\App\Http\Controllers\Api\V1\PriceListApiController::class, 'lookup']);
464+
Route::get('/{priceList}', [\App\Http\Controllers\Api\V1\PriceListApiController::class, 'show']);
465+
Route::put('/{priceList}', [\App\Http\Controllers\Api\V1\PriceListApiController::class, 'update']);
466+
Route::delete('/{priceList}', [\App\Http\Controllers\Api\V1\PriceListApiController::class, 'destroy']);
467+
Route::post('/{priceList}/items', [\App\Http\Controllers\Api\V1\PriceListApiController::class, 'addItem']);
468+
Route::delete('/{priceList}/items/{item}', [\App\Http\Controllers\Api\V1\PriceListApiController::class, 'removeItem']);
469+
});
470+
459471
// Sales Commission Tracking
460472
Route::prefix('commissions')->group(function () {
461473
Route::get('/rules', [\App\Http\Controllers\Api\V1\CommissionApiController::class, 'indexRules']);
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\Finance\Models\PriceList;
6+
use App\Modules\Finance\Models\PriceListItem;
7+
use App\Modules\Inventory\Models\Product;
8+
use Database\Seeders\RolePermissionSeeder;
9+
10+
beforeEach(function () {
11+
$this->seed(RolePermissionSeeder::class);
12+
$this->tenant = Tenant::create(['name' => 'Price Co', 'slug' => 'price-co-' . uniqid()]);
13+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
14+
$this->user->assignRole('super-admin');
15+
$this->token = $this->user->createToken('test')->plainTextToken;
16+
app()->instance('tenant', $this->tenant);
17+
});
18+
19+
function makePriceProduct(array $attrs = []): Product
20+
{
21+
return Product::create([
22+
'tenant_id' => test()->tenant->id,
23+
'name' => 'Item ' . uniqid(),
24+
'sku' => 'SKU-PL-' . uniqid(),
25+
'sale_price' => 100.00,
26+
'cost_price' => 50.00,
27+
'is_active' => true,
28+
...$attrs,
29+
]);
30+
}
31+
32+
test('can create a price list', function () {
33+
$this->withToken($this->token)
34+
->postJson('/api/v1/price-lists', [
35+
'name' => 'VIP Pricing',
36+
'discount_percent' => 15.0,
37+
'currency_code' => 'USD',
38+
])
39+
->assertStatus(201)
40+
->assertJsonPath('data.name', 'VIP Pricing');
41+
});
42+
43+
test('can list price lists', function () {
44+
PriceList::create([
45+
'tenant_id' => $this->tenant->id,
46+
'name' => 'Standard',
47+
'is_active' => true,
48+
'is_default' => true,
49+
]);
50+
51+
$this->withToken($this->token)
52+
->getJson('/api/v1/price-lists')
53+
->assertStatus(200)
54+
->assertJsonStructure(['data']);
55+
});
56+
57+
test('can view price list with items', function () {
58+
$pl = PriceList::create(['tenant_id' => $this->tenant->id, 'name' => 'Retail', 'is_active' => true, 'is_default' => false]);
59+
$product = makePriceProduct();
60+
61+
PriceListItem::create([
62+
'tenant_id' => $this->tenant->id,
63+
'price_list_id' => $pl->id,
64+
'product_id' => $product->id,
65+
'unit_price' => 85.00,
66+
'min_quantity' => 1,
67+
]);
68+
69+
$response = $this->withToken($this->token)
70+
->getJson("/api/v1/price-lists/{$pl->id}")
71+
->assertStatus(200)
72+
->assertJsonStructure(['data' => ['id', 'name', 'items']]);
73+
74+
expect(count($response->json('data.items')))->toBe(1);
75+
});
76+
77+
test('can add a product to a price list', function () {
78+
$pl = PriceList::create(['tenant_id' => $this->tenant->id, 'name' => 'Wholesale', 'is_active' => true, 'is_default' => false]);
79+
$product = makePriceProduct();
80+
81+
$this->withToken($this->token)
82+
->postJson("/api/v1/price-lists/{$pl->id}/items", [
83+
'product_id' => $product->id,
84+
'unit_price' => 75.00,
85+
'min_quantity' => 10,
86+
])
87+
->assertStatus(201)
88+
->assertJsonPath('data.unit_price', '75.0000');
89+
});
90+
91+
test('can remove a product from a price list', function () {
92+
$pl = PriceList::create(['tenant_id' => $this->tenant->id, 'name' => 'Temp PL', 'is_active' => true, 'is_default' => false]);
93+
$product = makePriceProduct();
94+
95+
$item = PriceListItem::create([
96+
'tenant_id' => $this->tenant->id,
97+
'price_list_id' => $pl->id,
98+
'product_id' => $product->id,
99+
'unit_price' => 90.00,
100+
'min_quantity' => 1,
101+
]);
102+
103+
$this->withToken($this->token)
104+
->deleteJson("/api/v1/price-lists/{$pl->id}/items/{$item->id}")
105+
->assertStatus(200);
106+
107+
expect(PriceListItem::find($item->id))->toBeNull();
108+
});
109+
110+
test('can lookup product price from a price list', function () {
111+
$pl = PriceList::create(['tenant_id' => $this->tenant->id, 'name' => 'Lookup PL', 'is_active' => true, 'is_default' => false]);
112+
$product = makePriceProduct();
113+
114+
PriceListItem::create([
115+
'tenant_id' => $this->tenant->id,
116+
'price_list_id' => $pl->id,
117+
'product_id' => $product->id,
118+
'unit_price' => 80.00,
119+
'min_quantity' => 1,
120+
]);
121+
122+
$response = $this->withToken($this->token)
123+
->postJson('/api/v1/price-lists/lookup', [
124+
'price_list_id' => $pl->id,
125+
'product_id' => $product->id,
126+
'quantity' => 1,
127+
])
128+
->assertStatus(200);
129+
130+
expect((float) $response->json('data.unit_price'))->toBe(80.0);
131+
expect($response->json('data.has_override'))->toBeTrue();
132+
});
133+
134+
test('lookup returns null for products not on price list', function () {
135+
$pl = PriceList::create(['tenant_id' => $this->tenant->id, 'name' => 'Empty PL', 'is_active' => true, 'is_default' => false]);
136+
$product = makePriceProduct();
137+
138+
$response = $this->withToken($this->token)
139+
->postJson('/api/v1/price-lists/lookup', [
140+
'price_list_id' => $pl->id,
141+
'product_id' => $product->id,
142+
])
143+
->assertStatus(200);
144+
145+
expect($response->json('data.unit_price'))->toBeNull();
146+
expect($response->json('data.has_override'))->toBeFalse();
147+
});
148+
149+
test('setting default clears other defaults', function () {
150+
$pl1 = PriceList::create(['tenant_id' => $this->tenant->id, 'name' => 'PL1', 'is_active' => true, 'is_default' => true]);
151+
$pl2 = PriceList::create(['tenant_id' => $this->tenant->id, 'name' => 'PL2', 'is_active' => true, 'is_default' => false]);
152+
153+
$this->withToken($this->token)
154+
->putJson("/api/v1/price-lists/{$pl2->id}", ['is_default' => true])
155+
->assertStatus(200);
156+
157+
expect($pl1->fresh()->is_default)->toBeFalse();
158+
expect($pl2->fresh()->is_default)->toBeTrue();
159+
});
160+
161+
test('can soft delete a price list', function () {
162+
$pl = PriceList::create(['tenant_id' => $this->tenant->id, 'name' => 'Delete Me', 'is_active' => true, 'is_default' => false]);
163+
164+
$this->withToken($this->token)
165+
->deleteJson("/api/v1/price-lists/{$pl->id}")
166+
->assertStatus(200);
167+
168+
expect(PriceList::withTrashed()->find($pl->id)?->deleted_at)->not->toBeNull();
169+
});
170+
171+
test('requires authentication', function () {
172+
$this->getJson('/api/v1/price-lists')->assertStatus(401);
173+
});

0 commit comments

Comments
 (0)