diff --git a/app/Console/Commands/DemoSeedCommand.php b/app/Console/Commands/DemoSeedCommand.php new file mode 100644 index 0000000..17b672c --- /dev/null +++ b/app/Console/Commands/DemoSeedCommand.php @@ -0,0 +1,60 @@ +argument('identity'); + $plan = strtolower((string) $this->option('plan')); + $reset = (bool) $this->option('reset'); + + if (! in_array($plan, ['free', 'pro', 'enterprise'], true)) { + $this->error('Invalid --plan. Use free, pro, or enterprise.'); + + return self::FAILURE; + } + + try { + $result = $seeder->seed($identity, $plan, $reset); + } catch (\InvalidArgumentException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + $user = $result['user']; + $effective = $subscriptions->planKey($user); + + $this->info(sprintf( + 'Seeded POS demo for %s (%s) plan=%s (effective=%s)%s', + $user->email, + $user->public_id, + $result['plan'], + $effective, + $reset ? ' [reset]' : '', + )); + + $this->table( + ['Metric', 'Count'], + collect($result['counts'])->map(fn ($v, $k) => [$k, (string) $v])->values()->all() + ); + + return self::SUCCESS; + } +} diff --git a/app/Services/Pos/DemoSeeder.php b/app/Services/Pos/DemoSeeder.php new file mode 100644 index 0000000..f2e88b9 --- /dev/null +++ b/app/Services/Pos/DemoSeeder.php @@ -0,0 +1,417 @@ + */ + private const VOLUMES = [ + 'free' => [ + 'locations' => 1, + 'products_per_location' => 40, + 'stations' => 0, + 'sales_per_location' => 12, + ], + 'pro' => [ + 'locations' => 3, + 'products_per_location' => 55, + 'stations' => 3, + 'sales_per_location' => 25, + ], + 'enterprise' => [ + 'locations' => 5, + 'products_per_location' => 70, + 'stations' => 5, + 'sales_per_location' => 40, + ], + ]; + + private const PRODUCT_NAMES = [ + 'Bottled Water', 'Soft Drink', 'Rice Pack', 'Chicken Wrap', 'Veggie Bowl', + 'Coffee', 'Tea', 'Pastry', 'Sandwich', 'Energy Bar', + 'Phone Charger', 'USB Cable', 'Notebook', 'Pen Set', 'Tote Bag', + 'Shampoo', 'Soap', 'Toothpaste', 'Detergent', 'Tissue Pack', + ]; + + /** + * @return array{user: User, plan: string, counts: array} + */ + public function seed(string $identity, string $plan, bool $reset = false): array + { + $plan = $this->normalizePlan($plan); + $user = $this->resolveUser($identity); + + if ($reset) { + $this->resetForOwner($user->public_id); + } + + $this->assignPlan($user, $plan); + + $volumes = self::VOLUMES[$plan]; + $stations = $this->seedStations($user->public_id, $volumes['stations']); + $categories = $this->seedCategories($user->public_id); + $locations = $this->seedLocations($user->public_id, $volumes['locations']); + + foreach ($locations as $index => $location) { + $this->seedRegister($user->public_id, $location, $index === 0); + $products = $this->seedProducts( + $user->public_id, + $location, + $categories, + $stations, + $volumes['products_per_location'], + ); + $this->seedCashSales( + $user->public_id, + $location, + $products, + $volumes['sales_per_location'], + ); + } + + if ($plan !== 'free') { + $this->seedTeam($user); + } + + return [ + 'user' => $user, + 'plan' => $plan, + 'counts' => [ + 'locations' => PosLocation::owned($user->public_id)->count(), + 'products' => PosProduct::owned($user->public_id)->count(), + 'stations' => PosStation::owned($user->public_id)->count(), + 'sales' => PosSale::owned($user->public_id)->count(), + 'registers' => PosDevice::owned($user->public_id)->where('type', 'register')->count(), + ], + ]; + } + + public function resolveUser(string $identity): User + { + $identity = trim($identity); + $user = User::query() + ->where(function ($q) use ($identity) { + $q->where('email', $identity)->orWhere('public_id', $identity); + }) + ->first(); + + if ($user) { + return $user; + } + + $email = str_contains($identity, '@') ? $identity : $identity.'@demo.ladill.local'; + $publicId = str_contains($identity, '@') ? (string) Str::uuid() : $identity; + + return User::query()->create([ + 'public_id' => $publicId, + 'name' => 'Demo User', + 'email' => $email, + 'password' => bcrypt('password'), + 'email_verified_at' => now(), + ]); + } + + public function assignPlan(User $user, string $plan): void + { + if ($plan === 'free') { + ProSubscription::query()->where('user_id', $user->id)->delete(); + + return; + } + + $price = $plan === ProSubscription::PLAN_ENTERPRISE + ? (int) config('pos.plans.enterprise.price_minor', 249000) + : (int) config('pos.plans.pro.price_minor', config('pos.pro.price_minor', 179000)); + + ProSubscription::updateOrCreate( + ['user_id' => $user->id], + [ + 'plan' => $plan, + 'status' => ProSubscription::STATUS_ACTIVE, + 'price_minor' => $price, + 'currency' => (string) config('pos.pro.currency', 'GHS'), + 'auto_renew' => false, + 'started_at' => now(), + 'current_period_end' => now()->addYear(), + 'last_charged_at' => now(), + 'canceled_at' => null, + 'last_reference' => 'demo-pos-'.$plan.'-'.$user->id, + 'last_error' => null, + ], + ); + } + + public function resetForOwner(string $ownerRef): void + { + DB::transaction(function () use ($ownerRef) { + $saleIds = PosSale::owned($ownerRef)->pluck('id'); + if ($saleIds->isNotEmpty()) { + $lineIds = PosSaleLine::query()->whereIn('pos_sale_id', $saleIds)->pluck('id'); + if ($lineIds->isNotEmpty() && DB::getSchemaBuilder()->hasTable('pos_sale_line_modifiers')) { + DB::table('pos_sale_line_modifiers')->whereIn('pos_sale_line_id', $lineIds)->delete(); + } + PosSaleLine::query()->whereIn('pos_sale_id', $saleIds)->delete(); + PosPayment::query()->whereIn('pos_sale_id', $saleIds)->delete(); + PosSale::owned($ownerRef)->delete(); + } + + if (DB::getSchemaBuilder()->hasTable('pos_product_modifier_group')) { + $productIds = PosProduct::owned($ownerRef)->pluck('id'); + if ($productIds->isNotEmpty()) { + DB::table('pos_product_modifier_group')->whereIn('product_id', $productIds)->delete(); + } + } + + PosProduct::owned($ownerRef)->delete(); + PosDevice::owned($ownerRef)->delete(); + PosMember::owned($ownerRef)->delete(); + + if (DB::getSchemaBuilder()->hasTable('pos_tables')) { + DB::table('pos_tables')->where('owner_ref', $ownerRef)->delete(); + } + if (DB::getSchemaBuilder()->hasTable('pos_customer_displays')) { + DB::table('pos_customer_displays')->where('owner_ref', $ownerRef)->delete(); + } + if (DB::getSchemaBuilder()->hasTable('pos_modifiers')) { + DB::table('pos_modifiers')->where('owner_ref', $ownerRef)->delete(); + } + if (DB::getSchemaBuilder()->hasTable('pos_modifier_groups')) { + DB::table('pos_modifier_groups')->where('owner_ref', $ownerRef)->delete(); + } + + PosCategory::owned($ownerRef)->delete(); + PosStation::owned($ownerRef)->delete(); + PosLocation::owned($ownerRef)->delete(); + }); + } + + /** @return list */ + private function seedStations(string $ownerRef, int $count): array + { + if ($count <= 0) { + return []; + } + + $stations = PosStation::owned($ownerRef)->orderBy('id')->get()->all(); + $names = ['Hot Kitchen', 'Cold Prep', 'Bar', 'Grill', 'Dessert']; + for ($i = count($stations); $i < $count; $i++) { + $stations[] = PosStation::query()->create([ + 'owner_ref' => $ownerRef, + 'name' => $names[$i] ?? ('Station '.($i + 1)), + 'position' => $i + 1, + ]); + } + + return array_slice($stations, 0, $count); + } + + /** @return list */ + private function seedCategories(string $ownerRef): array + { + $names = ['Beverages', 'Food', 'Retail', 'Services']; + $categories = []; + foreach ($names as $i => $name) { + $categories[] = PosCategory::query()->firstOrCreate( + ['owner_ref' => $ownerRef, 'name' => $name], + ['position' => $i + 1], + ); + } + + return $categories; + } + + /** @return list */ + private function seedLocations(string $ownerRef, int $count): array + { + $locations = PosLocation::owned($ownerRef)->orderBy('id')->get()->all(); + $names = ['Main Register', 'Airport Kiosk', 'Mall Counter', 'Campus Store', 'Harbour Outlet']; + + for ($i = count($locations); $i < $count; $i++) { + $locations[] = PosLocation::query()->create([ + 'owner_ref' => $ownerRef, + 'name' => $names[$i] ?? ('Location '.($i + 1)), + 'currency' => 'GHS', + 'is_default' => count($locations) === 0, + 'service_style' => PosLocation::STYLE_RETAIL, + 'receipt_footer' => 'Thank you for shopping with Demo POS.', + ]); + } + + if ($locations !== [] && ! collect($locations)->contains(fn (PosLocation $l) => $l->is_default)) { + $locations[0]->forceFill(['is_default' => true])->save(); + } + + return array_slice($locations, 0, $count); + } + + private function seedRegister(string $ownerRef, PosLocation $location, bool $primary): void + { + $exists = PosDevice::owned($ownerRef) + ->where('location_id', $location->id) + ->where('type', 'register') + ->exists(); + + if ($exists) { + return; + } + + PosDevice::query()->create([ + 'owner_ref' => $ownerRef, + 'location_id' => $location->id, + 'name' => $location->name.' Till', + 'type' => 'register', + 'status' => PosDevice::STATUS_ONLINE, + 'last_online_at' => now(), + 'config' => ['demo' => true, 'primary' => $primary], + ]); + } + + /** + * @param list $categories + * @param list $stations + * @return list + */ + private function seedProducts( + string $ownerRef, + PosLocation $location, + array $categories, + array $stations, + int $count, + ): array { + $products = PosProduct::owned($ownerRef) + ->where('location_id', $location->id) + ->orderBy('id') + ->get() + ->all(); + + for ($i = count($products); $i < $count; $i++) { + $name = self::PRODUCT_NAMES[$i % count(self::PRODUCT_NAMES)]; + $products[] = PosProduct::query()->create([ + 'owner_ref' => $ownerRef, + 'location_id' => $location->id, + 'category_id' => $categories[$i % count($categories)]->id, + 'station_id' => $stations !== [] ? $stations[$i % count($stations)]->id : null, + 'name' => $name.' '.($i + 1), + 'sku' => 'POS-'.$location->id.'-'.str_pad((string) ($i + 1), 3, '0', STR_PAD_LEFT), + 'price_minor' => 500 + (($i % 20) * 250), + 'currency' => 'GHS', + 'is_active' => true, + ]); + } + + return array_slice($products, 0, $count); + } + + /** + * @param list $products + */ + private function seedCashSales(string $ownerRef, PosLocation $location, array $products, int $count): void + { + if ($products === []) { + return; + } + + $existing = PosSale::owned($ownerRef)->where('location_id', $location->id)->count(); + for ($i = $existing; $i < $count; $i++) { + $lineCount = 1 + ($i % 3); + $lines = []; + $subtotal = 0; + for ($l = 0; $l < $lineCount; $l++) { + $product = $products[($i + $l) % count($products)]; + $qty = 1 + ($l % 2); + $lineTotal = $product->price_minor * $qty; + $subtotal += $lineTotal; + $lines[] = [$product, $qty, $lineTotal]; + } + + $sale = PosSale::query()->create([ + 'owner_ref' => $ownerRef, + 'location_id' => $location->id, + 'reference' => 'DEMO-'.$location->id.'-'.str_pad((string) ($i + 1), 4, '0', STR_PAD_LEFT).'-'.substr(md5($ownerRef.$i), 0, 6), + 'status' => PosSale::STATUS_PAID, + 'payment_method' => PosSale::METHOD_CASH, + 'order_type' => PosSale::ORDER_COUNTER, + 'kitchen_status' => PosSale::KITCHEN_NONE, + 'customer_name' => 'Cash Customer '.($i + 1), + 'subtotal_minor' => $subtotal, + 'total_minor' => $subtotal, + 'currency' => 'GHS', + 'paid_at' => now()->subDays($i % 10)->subMinutes($i * 3), + 'opened_at' => now()->subDays($i % 10)->subMinutes(($i * 3) + 5), + 'closed_at' => now()->subDays($i % 10)->subMinutes($i * 3), + ]); + + foreach ($lines as $pos => [$product, $qty, $lineTotal]) { + PosSaleLine::query()->create([ + 'pos_sale_id' => $sale->id, + 'product_id' => $product->id, + 'station_id' => $product->station_id, + 'name' => $product->name, + 'unit_price_minor' => $product->price_minor, + 'quantity' => $qty, + 'line_total_minor' => $lineTotal, + 'position' => $pos, + ]); + } + + PosPayment::query()->create([ + 'pos_sale_id' => $sale->id, + 'owner_ref' => $ownerRef, + 'amount_minor' => $subtotal, + 'method' => PosPayment::METHOD_CASH, + 'status' => PosPayment::STATUS_PAID, + 'paid_at' => $sale->paid_at, + ]); + } + } + + private function seedTeam(User $owner): void + { + if (PosMember::owned($owner->public_id)->exists()) { + return; + } + + $cashier = User::query()->firstOrCreate( + ['email' => 'demo-cashier-'.$owner->id.'@ladill.local'], + [ + 'public_id' => (string) Str::uuid(), + 'name' => 'Demo Cashier', + 'password' => bcrypt('password'), + 'email_verified_at' => now(), + ], + ); + + $location = PosLocation::owned($owner->public_id)->orderBy('id')->first(); + + PosMember::query()->create([ + 'owner_ref' => $owner->public_id, + 'user_ref' => $cashier->public_id, + 'role' => PosMember::ROLE_CASHIER, + 'location_id' => $location?->id, + ]); + } + + private function normalizePlan(string $plan): string + { + $plan = strtolower(trim($plan)); + if (! in_array($plan, ['free', 'pro', 'enterprise'], true)) { + throw new \InvalidArgumentException('Plan must be free, pro, or enterprise.'); + } + + return $plan; + } +} diff --git a/tests/Feature/DemoSeedCommandTest.php b/tests/Feature/DemoSeedCommandTest.php new file mode 100644 index 0000000..5af7404 --- /dev/null +++ b/tests/Feature/DemoSeedCommandTest.php @@ -0,0 +1,107 @@ + true, + 'billing.api_url' => 'https://billing.test', + 'billing.api_key' => 'test-key', + 'pos.free.max_locations' => 1, + 'pos.free.max_products' => 50, + ]); + } + + public function test_free_seed_respects_location_and_product_caps(): void + { + $user = User::create([ + 'public_id' => 'demo-pos-free', + 'name' => 'Demo Free', + 'email' => 'demo-free@ladill.com', + 'password' => bcrypt('password'), + ]); + + $exit = Artisan::call('demo:seed', ['identity' => $user->email, '--plan' => 'free']); + $this->assertSame(0, $exit); + + $this->assertSame(1, PosLocation::owned($user->public_id)->count()); + $this->assertLessThanOrEqual(50, PosProduct::owned($user->public_id)->count()); + $this->assertGreaterThan(0, PosProduct::owned($user->public_id)->count()); + $this->assertGreaterThan(0, PosSale::owned($user->public_id)->where('payment_method', 'cash')->count()); + $this->assertSame('free', app(SubscriptionService::class)->planKey($user)); + $this->assertNull(ProSubscription::where('user_id', $user->id)->first()); + } + + public function test_pro_seed_multi_location_and_reset_is_tenant_scoped(): void + { + $target = User::create([ + 'public_id' => 'demo-pos-pro', + 'name' => 'Demo Pro', + 'email' => 'demo-pro@ladill.com', + 'password' => bcrypt('password'), + ]); + $other = User::create([ + 'public_id' => 'other-pos-owner', + 'name' => 'Other', + 'email' => 'other-pos@example.com', + 'password' => bcrypt('password'), + ]); + + PosLocation::create([ + 'owner_ref' => $other->public_id, + 'name' => 'Keep Branch', + 'currency' => 'GHS', + 'is_default' => true, + ]); + + Artisan::call('demo:seed', ['identity' => $target->public_id, '--plan' => 'pro']); + $this->assertTrue(app(SubscriptionService::class)->isPro($target)); + $this->assertGreaterThanOrEqual(2, PosLocation::owned($target->public_id)->count()); + $this->assertGreaterThan(50, PosProduct::owned($target->public_id)->count()); + + Artisan::call('demo:seed', [ + 'identity' => $target->email, + '--plan' => 'enterprise', + '--reset' => true, + ]); + + $this->assertDatabaseHas('pos_locations', ['owner_ref' => $other->public_id, 'name' => 'Keep Branch']); + $this->assertSame('enterprise', ProSubscription::where('user_id', $target->id)->value('plan')); + $this->assertGreaterThanOrEqual(4, PosLocation::owned($target->public_id)->count()); + } + + public function test_idempotent_without_reset(): void + { + $user = User::create([ + 'public_id' => 'demo-pos-idem', + 'name' => 'Demo', + 'email' => 'demo-idem-pos@ladill.com', + 'password' => bcrypt('password'), + ]); + + Artisan::call('demo:seed', ['identity' => $user->email, '--plan' => 'free']); + $products = PosProduct::owned($user->public_id)->count(); + $sales = PosSale::owned($user->public_id)->count(); + + Artisan::call('demo:seed', ['identity' => $user->email, '--plan' => 'free']); + $this->assertSame($products, PosProduct::owned($user->public_id)->count()); + $this->assertSame($sales, PosSale::owned($user->public_id)->count()); + } +}