From cb3b5b90f2cd91de0a55a10be1c052f5a825675b Mon Sep 17 00:00:00 2001 From: "Ahmed_raza.Fyntune" <139863230+Ahmedraza-fyntune@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:08:48 +0530 Subject: [PATCH 01/15] feat: implement Subscriptions module with database schema, Filament resources, and full service lifecycle support. --- .../Core/Providers/CompanyPanelProvider.php | 7 + .../Factories/SubscriptionFactory.php | 112 ++++++ ...8_13_000001_create_subscriptions_table.php | 69 ++++ .../Database/Seeders/SubscriptionSeeder.php | 239 +++++++++++++ .../Subscriptions/Enums/BillingInterval.php | 38 +++ .../Subscriptions/Enums/CancellationType.php | 32 ++ Modules/Subscriptions/Enums/IntervalUnit.php | 33 ++ .../Enums/SubscriptionStatus.php | 56 +++ .../Pages/CreateSubscription.php | 17 + .../Subscriptions/Pages/EditSubscription.php | 19 ++ .../Subscriptions/Pages/ListSubscriptions.php | 19 ++ .../Schemas/SubscriptionForm.php | 198 +++++++++++ .../Subscriptions/SubscriptionResource.php | 67 ++++ .../Tables/SubscriptionsTable.php | 174 ++++++++++ Modules/Subscriptions/Models/Subscription.php | 98 ++++++ .../Subscriptions/Models/SubscriptionItem.php | 33 ++ .../SubscriptionsServiceProvider.php | 22 ++ .../Services/SubscriptionService.php | 322 ++++++++++++++++++ .../Tests/Feature/SubscriptionTest.php | 220 ++++++++++++ Modules/Subscriptions/composer.json | 23 ++ Modules/Subscriptions/module.json | 11 + database/seeders/DatabaseSeeder.php | 3 + modules_statuses.json | 3 +- 23 files changed, 1814 insertions(+), 1 deletion(-) create mode 100644 Modules/Subscriptions/Database/Factories/SubscriptionFactory.php create mode 100644 Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php create mode 100644 Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php create mode 100644 Modules/Subscriptions/Enums/BillingInterval.php create mode 100644 Modules/Subscriptions/Enums/CancellationType.php create mode 100644 Modules/Subscriptions/Enums/IntervalUnit.php create mode 100644 Modules/Subscriptions/Enums/SubscriptionStatus.php create mode 100644 Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Pages/CreateSubscription.php create mode 100644 Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Pages/EditSubscription.php create mode 100644 Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Pages/ListSubscriptions.php create mode 100644 Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php create mode 100644 Modules/Subscriptions/Filament/Company/Resources/Subscriptions/SubscriptionResource.php create mode 100644 Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php create mode 100644 Modules/Subscriptions/Models/Subscription.php create mode 100644 Modules/Subscriptions/Models/SubscriptionItem.php create mode 100644 Modules/Subscriptions/Providers/SubscriptionsServiceProvider.php create mode 100644 Modules/Subscriptions/Services/SubscriptionService.php create mode 100644 Modules/Subscriptions/Tests/Feature/SubscriptionTest.php create mode 100644 Modules/Subscriptions/composer.json create mode 100644 Modules/Subscriptions/module.json diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php index 506e88438..e5d32211b 100644 --- a/Modules/Core/Providers/CompanyPanelProvider.php +++ b/Modules/Core/Providers/CompanyPanelProvider.php @@ -48,6 +48,7 @@ use Modules\Projects\Filament\Company\Resources\Tasks\TaskResource; use Modules\Quotes\Filament\Company\Resources\Quotes\QuoteResource; use Modules\Quotes\Filament\Company\Widgets\RecentQuotesWidget; +use Modules\Subscriptions\Filament\Company\Resources\Subscriptions\SubscriptionResource; class CompanyPanelProvider extends PanelProvider { @@ -169,6 +170,7 @@ public function panel(Panel $panel): Panel ExpenseCategoryResource::class, InvoiceResource::class, PaymentResource::class, + SubscriptionResource::class, ProductResource::class, ProductUnitResource::class, ProductCategoryResource::class, @@ -224,6 +226,11 @@ public function panel(Panel $panel): Panel ...self::withQuickCreate(InvoiceResource::class), ]), + NavigationGroup::make('Subscriptions') + ->items([ + ...self::withQuickCreate(SubscriptionResource::class), + ]), + NavigationGroup::make('Expenses') //->icon('heroicon-o-banknotes') ->items([ diff --git a/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php b/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php new file mode 100644 index 000000000..167c0dc07 --- /dev/null +++ b/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php @@ -0,0 +1,112 @@ +resolveCompanyId(); + $startsAt = $this->faker->dateTimeBetween('-6 months', 'now'); + + return [ + 'company_id' => $companyId, + 'customer_id' => $this->resolveForeignKey(Relation::class, $companyId), + 'number' => 'SUB-' . $this->faker->unique()->numerify('#####'), + 'name' => $this->faker->words(3, true) . ' Subscription', + 'status' => SubscriptionStatus::ACTIVE, + 'billing_interval' => BillingInterval::MONTHLY, + 'interval_unit' => IntervalUnit::MONTH, + 'interval_count' => 1, + 'price' => $this->faker->randomFloat(4, 49, 499), + 'currency_code' => 'USD', + 'starts_at' => $startsAt, + 'ends_at' => null, + 'trial_starts_at' => null, + 'trial_ends_at' => null, + 'grace_period_days' => 0, + 'grace_period_ends_at' => null, + 'current_period_starts_at' => $startsAt, + 'current_period_ends_at' => (clone $startsAt)->modify('+1 month'), + 'paused_at' => null, + 'resume_at' => null, + 'cancel_at_period_end' => false, + 'canceled_at' => null, + 'notes' => $this->faker->sentence(), + ]; + } + + public function configure(): static + { + return $this->afterCreating(function (Subscription $subscription) { + SubscriptionItem::create([ + 'subscription_id' => $subscription->id, + 'name' => $subscription->name . ' Core Plan', + 'quantity' => 1, + 'unit_price' => $subscription->price, + 'subtotal' => $subscription->price, + 'tax' => 0, + 'total' => $subscription->price, + ]); + }); + } + + public function trialing(): static + { + return $this->state(function () { + $now = now(); + + return [ + 'status' => SubscriptionStatus::TRIALING, + 'trial_starts_at' => $now, + 'trial_ends_at' => (clone $now)->addDays(14), + ]; + }); + } + + public function inGracePeriod(): static + { + return $this->state(function () { + $now = now(); + + return [ + 'status' => SubscriptionStatus::IN_GRACE_PERIOD, + 'grace_period_days' => 7, + 'grace_period_ends_at' => (clone $now)->addDays(7), + ]; + }); + } + + public function paused(): static + { + return $this->state(function () { + return [ + 'status' => SubscriptionStatus::PAUSED, + 'paused_at' => now(), + ]; + }); + } + + public function canceled(): static + { + return $this->state(function () { + $now = now(); + + return [ + 'status' => SubscriptionStatus::CANCELED, + 'canceled_at' => $now, + 'ends_at' => $now, + ]; + }); + } +} diff --git a/Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php b/Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php new file mode 100644 index 000000000..719c88389 --- /dev/null +++ b/Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php @@ -0,0 +1,69 @@ +id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); + $table->foreignId('customer_id')->constrained('relations')->cascadeOnDelete(); + $table->string('number', 50)->nullable(); + $table->string('name')->nullable(); + $table->string('status', 30)->default('active'); + $table->string('billing_interval', 30)->default('monthly'); + $table->string('interval_unit', 20)->default('month'); + $table->integer('interval_count')->default(1); + + $table->decimal('price', 15, 4)->default(0.0000); + $table->string('currency_code', 3)->nullable(); + + $table->timestamp('starts_at')->nullable(); + $table->timestamp('ends_at')->nullable(); + + $table->timestamp('trial_starts_at')->nullable(); + $table->timestamp('trial_ends_at')->nullable(); + + $table->integer('grace_period_days')->default(0); + $table->timestamp('grace_period_ends_at')->nullable(); + + $table->timestamp('current_period_starts_at')->nullable(); + $table->timestamp('current_period_ends_at')->nullable(); + + $table->timestamp('paused_at')->nullable(); + $table->timestamp('resume_at')->nullable(); + + $table->boolean('cancel_at_period_end')->default(false); + $table->timestamp('canceled_at')->nullable(); + + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['company_id', 'status']); + $table->index(['customer_id']); + }); + + Schema::create('subscription_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('subscription_id')->constrained('subscriptions')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->string('name'); + $table->decimal('quantity', 15, 4)->default(1.0000); + $table->decimal('unit_price', 15, 4)->default(0.0000); + $table->decimal('subtotal', 15, 4)->default(0.0000); + $table->decimal('tax', 15, 4)->default(0.0000); + $table->decimal('total', 15, 4)->default(0.0000); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscription_items'); + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php b/Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php new file mode 100644 index 000000000..b19f6046f --- /dev/null +++ b/Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php @@ -0,0 +1,239 @@ +whereRaw('LOWER(search_code) = ?', ['ivplv2'])->first() + ?? Company::query()->first(); + + if ( ! $company) { + return; + } + + $customer = Relation::query()->where('company_id', $company->id)->first(); + if ( ! $customer) { + $customer = Relation::factory()->create(['company_id' => $company->id]); + } + + $product = Product::query()->where('company_id', $company->id)->first(); + + $now = Carbon::now(); + + // 1. Active Monthly Subscription + $sub1 = Subscription::create([ + 'company_id' => $company->id, + 'customer_id' => $customer->id, + 'number' => 'SUB-2026-0001', + 'name' => 'SaaS Premium Monthly Plan', + 'status' => SubscriptionStatus::ACTIVE, + 'billing_interval' => BillingInterval::MONTHLY, + 'interval_unit' => IntervalUnit::MONTH, + 'interval_count' => 1, + 'price' => 199.0000, + 'currency_code' => 'USD', + 'starts_at' => $now->copy()->subMonths(3), + 'current_period_starts_at' => $now->copy()->subDays(10), + 'current_period_ends_at' => $now->copy()->addDays(20), + 'notes' => 'Active monthly subscription with automated invoicing.', + ]); + SubscriptionItem::create([ + 'subscription_id' => $sub1->id, + 'product_id' => $product?->id, + 'name' => 'SaaS Premium Seat License', + 'quantity' => 2, + 'unit_price' => 99.5000, + 'subtotal' => 199.0000, + 'tax' => 0, + 'total' => 199.0000, + ]); + + // 2. Active Yearly Subscription + $sub2 = Subscription::create([ + 'company_id' => $company->id, + 'customer_id' => $customer->id, + 'number' => 'SUB-2026-0002', + 'name' => 'Enterprise Cloud Yearly Suite', + 'status' => SubscriptionStatus::ACTIVE, + 'billing_interval' => BillingInterval::YEARLY, + 'interval_unit' => IntervalUnit::YEAR, + 'interval_count' => 1, + 'price' => 2400.0000, + 'currency_code' => 'USD', + 'starts_at' => $now->copy()->subMonths(6), + 'current_period_starts_at' => $now->copy()->subMonths(6), + 'current_period_ends_at' => $now->copy()->addMonths(6), + 'notes' => 'Yearly enterprise contract with priority support.', + ]); + SubscriptionItem::create([ + 'subscription_id' => $sub2->id, + 'product_id' => $product?->id, + 'name' => 'Enterprise Annual Core Bundle', + 'quantity' => 1, + 'unit_price' => 2400.0000, + 'subtotal' => 2400.0000, + 'tax' => 0, + 'total' => 2400.0000, + ]); + + // 3. Custom Billing Cycle (14 Days) + $sub3 = Subscription::create([ + 'company_id' => $company->id, + 'customer_id' => $customer->id, + 'number' => 'SUB-2026-0003', + 'name' => 'Bi-Weekly Maintenance Service', + 'status' => SubscriptionStatus::ACTIVE, + 'billing_interval' => BillingInterval::CUSTOM, + 'interval_unit' => IntervalUnit::DAY, + 'interval_count' => 14, + 'price' => 150.0000, + 'currency_code' => 'USD', + 'starts_at' => $now->copy()->subDays(28), + 'current_period_starts_at' => $now->copy()->subDays(2), + 'current_period_ends_at' => $now->copy()->addDays(12), + 'notes' => 'Bi-weekly custom maintenance billing cycle.', + ]); + SubscriptionItem::create([ + 'subscription_id' => $sub3->id, + 'product_id' => $product?->id, + 'name' => '14-Day Maintenance Inspection', + 'quantity' => 1, + 'unit_price' => 150.0000, + 'subtotal' => 150.0000, + 'tax' => 0, + 'total' => 150.0000, + ]); + + // 4. Trial Period Subscription + $sub4 = Subscription::create([ + 'company_id' => $company->id, + 'customer_id' => $customer->id, + 'number' => 'SUB-2026-0004', + 'name' => 'Pro Tier 14-Day Free Trial', + 'status' => SubscriptionStatus::TRIALING, + 'billing_interval' => BillingInterval::MONTHLY, + 'interval_unit' => IntervalUnit::MONTH, + 'interval_count' => 1, + 'price' => 299.0000, + 'currency_code' => 'USD', + 'starts_at' => $now->copy()->subDays(5), + 'trial_starts_at' => $now->copy()->subDays(5), + 'trial_ends_at' => $now->copy()->addDays(9), + 'current_period_starts_at' => $now->copy()->subDays(5), + 'current_period_ends_at' => $now->copy()->addDays(9), + 'notes' => 'Trial active for another 9 days.', + ]); + SubscriptionItem::create([ + 'subscription_id' => $sub4->id, + 'product_id' => $product?->id, + 'name' => 'Pro Tier Trial Features', + 'quantity' => 1, + 'unit_price' => 299.0000, + 'subtotal' => 299.0000, + 'tax' => 0, + 'total' => 299.0000, + ]); + + // 5. In Grace Period Subscription + $sub5 = Subscription::create([ + 'company_id' => $company->id, + 'customer_id' => $customer->id, + 'number' => 'SUB-2026-0005', + 'name' => 'Standard Tier (In Grace Period)', + 'status' => SubscriptionStatus::IN_GRACE_PERIOD, + 'billing_interval' => BillingInterval::MONTHLY, + 'interval_unit' => IntervalUnit::MONTH, + 'interval_count' => 1, + 'price' => 89.0000, + 'currency_code' => 'USD', + 'starts_at' => $now->copy()->subMonths(2), + 'grace_period_days' => 7, + 'grace_period_ends_at' => $now->copy()->addDays(4), + 'current_period_starts_at' => $now->copy()->subDays(31), + 'current_period_ends_at' => $now->copy()->subDays(1), + 'notes' => 'Payment failed, subscriber given 7 days grace period.', + ]); + SubscriptionItem::create([ + 'subscription_id' => $sub5->id, + 'product_id' => $product?->id, + 'name' => 'Standard Monthly Package', + 'quantity' => 1, + 'unit_price' => 89.0000, + 'subtotal' => 89.0000, + 'tax' => 0, + 'total' => 89.0000, + ]); + + // 6. Paused Subscription + $sub6 = Subscription::create([ + 'company_id' => $company->id, + 'customer_id' => $customer->id, + 'number' => 'SUB-2026-0006', + 'name' => 'Seasonal Growth Subscription', + 'status' => SubscriptionStatus::PAUSED, + 'billing_interval' => BillingInterval::MONTHLY, + 'interval_unit' => IntervalUnit::MONTH, + 'interval_count' => 1, + 'price' => 149.0000, + 'currency_code' => 'USD', + 'starts_at' => $now->copy()->subMonths(4), + 'paused_at' => $now->copy()->subDays(12), + 'current_period_starts_at' => $now->copy()->subDays(30), + 'current_period_ends_at' => $now->copy()->addDays(5), + 'notes' => 'Subscription paused by customer request during off-season.', + ]); + SubscriptionItem::create([ + 'subscription_id' => $sub6->id, + 'product_id' => $product?->id, + 'name' => 'Growth Add-on Services', + 'quantity' => 1, + 'unit_price' => 149.0000, + 'subtotal' => 149.0000, + 'tax' => 0, + 'total' => 149.0000, + ]); + + // 7. Cancel At Period End Subscription + $sub7 = Subscription::create([ + 'company_id' => $company->id, + 'customer_id' => $customer->id, + 'number' => 'SUB-2026-0007', + 'name' => 'Developer API Subscription', + 'status' => SubscriptionStatus::ACTIVE, + 'billing_interval' => BillingInterval::MONTHLY, + 'interval_unit' => IntervalUnit::MONTH, + 'interval_count' => 1, + 'price' => 350.0000, + 'currency_code' => 'USD', + 'starts_at' => $now->copy()->subMonths(1), + 'cancel_at_period_end' => true, + 'canceled_at' => $now->copy()->subDays(3), + 'current_period_starts_at' => $now->copy()->subDays(15), + 'current_period_ends_at' => $now->copy()->addDays(15), + 'notes' => 'Customer requested cancellation at period end.', + ]); + SubscriptionItem::create([ + 'subscription_id' => $sub7->id, + 'product_id' => $product?->id, + 'name' => 'API Call Pool (High Volume)', + 'quantity' => 1, + 'unit_price' => 350.0000, + 'subtotal' => 350.0000, + 'tax' => 0, + 'total' => 350.0000, + ]); + } +} diff --git a/Modules/Subscriptions/Enums/BillingInterval.php b/Modules/Subscriptions/Enums/BillingInterval.php new file mode 100644 index 000000000..27fbb50b1 --- /dev/null +++ b/Modules/Subscriptions/Enums/BillingInterval.php @@ -0,0 +1,38 @@ + 'Weekly', + self::MONTHLY => 'Monthly', + self::YEARLY => 'Yearly', + self::CUSTOM => 'Custom Cycle', + }; + } + + public function color(): string + { + return match ($this) { + self::WEEKLY => 'info', + self::MONTHLY => 'primary', + self::YEARLY => 'success', + self::CUSTOM => 'warning', + }; + } +} diff --git a/Modules/Subscriptions/Enums/CancellationType.php b/Modules/Subscriptions/Enums/CancellationType.php new file mode 100644 index 000000000..889ec477f --- /dev/null +++ b/Modules/Subscriptions/Enums/CancellationType.php @@ -0,0 +1,32 @@ + 'Cancel Immediately', + self::AT_PERIOD_END => 'Cancel at End of Billing Period', + }; + } + + public function color(): string + { + return match ($this) { + self::IMMEDIATE => 'danger', + self::AT_PERIOD_END => 'warning', + }; + } +} diff --git a/Modules/Subscriptions/Enums/IntervalUnit.php b/Modules/Subscriptions/Enums/IntervalUnit.php new file mode 100644 index 000000000..dfaa15586 --- /dev/null +++ b/Modules/Subscriptions/Enums/IntervalUnit.php @@ -0,0 +1,33 @@ + 'Day(s)', + self::WEEK => 'Week(s)', + self::MONTH => 'Month(s)', + self::YEAR => 'Year(s)', + }; + } + + public function color(): string + { + return 'gray'; + } +} diff --git a/Modules/Subscriptions/Enums/SubscriptionStatus.php b/Modules/Subscriptions/Enums/SubscriptionStatus.php new file mode 100644 index 000000000..782599e10 --- /dev/null +++ b/Modules/Subscriptions/Enums/SubscriptionStatus.php @@ -0,0 +1,56 @@ + 'Active', + self::TRIALING => 'Trialing', + self::IN_GRACE_PERIOD => 'In Grace Period', + self::PAUSED => 'Paused', + self::CANCELED => 'Canceled', + self::EXPIRED => 'Expired', + }; + } + + public function color(): string + { + return match ($this) { + self::ACTIVE => 'success', + self::TRIALING => 'info', + self::IN_GRACE_PERIOD => 'warning', + self::PAUSED => 'gray', + self::CANCELED => 'danger', + self::EXPIRED => 'danger', + }; + } + + public function badgeIcon(): string + { + return match ($this) { + self::ACTIVE => 'heroicon-o-check-circle', + self::TRIALING => 'heroicon-o-clock', + self::IN_GRACE_PERIOD => 'heroicon-o-exclamation-triangle', + self::PAUSED => 'heroicon-o-pause-circle', + self::CANCELED => 'heroicon-o-x-circle', + self::EXPIRED => 'heroicon-o-minus-circle', + }; + } +} diff --git a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Pages/CreateSubscription.php b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Pages/CreateSubscription.php new file mode 100644 index 000000000..8336d554d --- /dev/null +++ b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Pages/CreateSubscription.php @@ -0,0 +1,17 @@ +createSubscription($data); + } +} diff --git a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Pages/EditSubscription.php b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Pages/EditSubscription.php new file mode 100644 index 000000000..380b8d6ae --- /dev/null +++ b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Pages/EditSubscription.php @@ -0,0 +1,19 @@ +components([ + Grid::make(5) + ->columnSpanFull() + ->schema([ + Schemas\Components\Group::make() + ->columnSpan(3) + ->schema([ + Section::make('Subscription Overview') + ->schema([ + TextInput::make('name') + ->label('Subscription Title') + ->required() + ->placeholder('e.g. Enterprise Monthly Software License') + ->columnSpanFull(), + + Select::make('customer_id') + ->label('Client / Customer') + ->relationship('customer', 'company_name') + ->searchable() + ->preload() + ->required(), + + TextInput::make('number') + ->label('Subscription Code / Ref') + ->default(fn () => 'SUB-' . mb_strtoupper(mb_substr(uniqid(), -6))) + ->required(), + + Select::make('status') + ->label('Subscription Status') + ->options( + collect(SubscriptionStatus::cases()) + ->mapWithKeys(fn ($s) => [$s->value => $s->label()]) + ->toArray() + ) + ->default(SubscriptionStatus::ACTIVE->value) + ->required(), + ]) + ->columns(2), + + Section::make('Billing Cycle Configuration') + ->schema([ + Select::make('billing_interval') + ->label('Billing Interval') + ->options( + collect(BillingInterval::cases()) + ->mapWithKeys(fn ($i) => [$i->value => $i->label()]) + ->toArray() + ) + ->default(BillingInterval::MONTHLY->value) + ->reactive() + ->required(), + + Select::make('interval_unit') + ->label('Custom Unit') + ->options( + collect(IntervalUnit::cases()) + ->mapWithKeys(fn ($u) => [$u->value => $u->label()]) + ->toArray() + ) + ->default(IntervalUnit::MONTH->value) + ->visible(fn (Get $get) => $get('billing_interval') === BillingInterval::CUSTOM->value) + ->required(fn (Get $get) => $get('billing_interval') === BillingInterval::CUSTOM->value), + + TextInput::make('interval_count') + ->label('Custom Count (Frequency)') + ->numeric() + ->default(1) + ->minValue(1) + ->visible(fn (Get $get) => $get('billing_interval') === BillingInterval::CUSTOM->value) + ->required(fn (Get $get) => $get('billing_interval') === BillingInterval::CUSTOM->value), + + TextInput::make('price') + ->label('Recurring Price') + ->numeric() + ->prefix('$') + ->required(), + ]) + ->columns(2), + ]), + + Schemas\Components\Group::make() + ->columnSpan(2) + ->schema([ + Section::make('Lifecycle & Trial Dates') + ->schema([ + DateTimePicker::make('starts_at') + ->label('Start Date') + ->default(now()) + ->required(), + + DateTimePicker::make('ends_at') + ->label('Expiration / End Date') + ->nullable(), + + DateTimePicker::make('trial_starts_at') + ->label('Trial Start Date') + ->nullable(), + + DateTimePicker::make('trial_ends_at') + ->label('Trial End Date') + ->nullable(), + + TextInput::make('grace_period_days') + ->label('Grace Period (Days)') + ->numeric() + ->default(0), + + DateTimePicker::make('grace_period_ends_at') + ->label('Grace Period Expiration') + ->nullable(), + ]) + ->columns(1), + ]), + ]), + + Section::make('Subscription Line Items') + ->schema([ + Repeater::make('subscriptionItems') + ->relationship('subscriptionItems') + ->schema([ + Grid::make(5) + ->schema([ + Select::make('product_id') + ->label('Product / Service') + ->options(Product::query()->pluck('product_name', 'id')->toArray()) + ->searchable() + ->preload() + ->reactive() + ->afterStateUpdated(function ($state, callable $set) { + if ($product = Product::find($state)) { + $set('name', $product->product_name); + $set('unit_price', $product->product_price); + } + }), + + TextInput::make('name') + ->label('Description') + ->required(), + + TextInput::make('quantity') + ->label('Qty') + ->numeric() + ->default(1) + ->required(), + + TextInput::make('unit_price') + ->label('Unit Price') + ->numeric() + ->required(), + + TextInput::make('total') + ->label('Total') + ->numeric() + ->placeholder('Auto-calc'), + ]), + ]) + ->columns(1) + ->defaultItems(1) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + + Section::make('Internal Notes') + ->schema([ + MarkdownEditor::make('notes') + ->label('Subscription Notes') + ->toolbarButtons(['bold', 'italic', 'bulletList']) + ->columnSpanFull(), + ]) + ->collapsed() + ->columnSpanFull(), + ]); + } +} diff --git a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/SubscriptionResource.php b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/SubscriptionResource.php new file mode 100644 index 000000000..85fef13b2 --- /dev/null +++ b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/SubscriptionResource.php @@ -0,0 +1,67 @@ +count(); + } + + public static function form(Schema $schema): Schema + { + return SubscriptionForm::configure($schema); + } + + public static function table(Table $table): Table + { + return SubscriptionsTable::configure($table); + } + + public static function getPages(): array + { + return [ + 'index' => ListSubscriptions::route('/'), + 'create' => CreateSubscription::route('/create'), + 'edit' => EditSubscription::route('/{record}/edit'), + ]; + } +} diff --git a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php new file mode 100644 index 000000000..282171605 --- /dev/null +++ b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php @@ -0,0 +1,174 @@ +columns([ + TextColumn::make('number') + ->label('Subscription #') + ->searchable() + ->sortable() + ->weight('bold'), + + TextColumn::make('name') + ->label('Title') + ->searchable() + ->limit(25), + + TextColumn::make('customer.company_name') + ->label('Client') + ->searchable() + ->sortable(), + + TextColumn::make('status') + ->label('Status') + ->badge() + ->formatStateUsing(fn ($state) => $state instanceof SubscriptionStatus ? $state->label() : SubscriptionStatus::tryFrom($state)?->label() ?? $state) + ->color(fn ($state) => $state instanceof SubscriptionStatus ? $state->color() : SubscriptionStatus::tryFrom($state)?->color() ?? 'gray') + ->icon(fn ($state) => $state instanceof SubscriptionStatus ? $state->badgeIcon() : SubscriptionStatus::tryFrom($state)?->badgeIcon() ?? 'heroicon-o-minus-circle') + ->sortable(), + + TextColumn::make('billing_interval') + ->label('Billing Cycle') + ->formatStateUsing(function ($state, Subscription $record) { + if ($record->billing_interval === BillingInterval::CUSTOM) { + return "Every {$record->interval_count} {$record->interval_unit?->value}(s)"; + } + + return $record->billing_interval?->label() ?? $state; + }) + ->sortable(), + + TextColumn::make('price') + ->label('Price') + ->money('USD') + ->sortable(), + + TextColumn::make('current_period_ends_at') + ->label('Next Billing Date') + ->dateTime('M d, Y') + ->sortable(), + + IconColumn::make('cancel_at_period_end') + ->label('Pending Cancel') + ->boolean() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + SelectFilter::make('status') + ->options( + collect(SubscriptionStatus::cases()) + ->mapWithKeys(fn ($s) => [$s->value => $s->label()]) + ->toArray() + ), + + SelectFilter::make('billing_interval') + ->options( + collect(BillingInterval::cases()) + ->mapWithKeys(fn ($i) => [$i->value => $i->label()]) + ->toArray() + ), + ]) + ->actions([ + EditAction::make(), + + Action::make('pause') + ->label('Pause') + ->icon('heroicon-o-pause') + ->color('gray') + ->visible(fn (Subscription $record) => $record->status === SubscriptionStatus::ACTIVE || $record->status === SubscriptionStatus::TRIALING) + ->form([ + DateTimePicker::make('resume_at') + ->label('Auto-Resume At (Optional)') + ->hint('Leave empty for manual resume'), + ]) + ->action(function (Subscription $record, array $data, SubscriptionService $service) { + $service->pause($record, isset($data['resume_at']) ? \Carbon\Carbon::parse($data['resume_at']) : null); + Notification::make()->title('Subscription Paused')->warning()->send(); + }), + + Action::make('resume') + ->label('Resume') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (Subscription $record) => $record->status === SubscriptionStatus::PAUSED) + ->action(function (Subscription $record, SubscriptionService $service) { + $service->resume($record); + Notification::make()->title('Subscription Resumed')->success()->send(); + }), + + Action::make('cancel') + ->label('Cancel') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->visible(fn (Subscription $record) => $record->status !== SubscriptionStatus::CANCELED && $record->status !== SubscriptionStatus::EXPIRED) + ->form([ + Select::make('cancellation_type') + ->label('Cancellation Option') + ->options([ + CancellationType::AT_PERIOD_END->value => 'Cancel at End of Billing Period', + CancellationType::IMMEDIATE->value => 'Cancel Immediately', + ]) + ->default(CancellationType::AT_PERIOD_END->value) + ->required(), + ]) + ->action(function (Subscription $record, array $data, SubscriptionService $service) { + if ($data['cancellation_type'] === CancellationType::IMMEDIATE->value) { + $service->cancelImmediately($record); + Notification::make()->title('Subscription Canceled Immediately')->danger()->send(); + } else { + $service->cancelAtPeriodEnd($record); + Notification::make()->title('Subscription set to cancel at period end')->warning()->send(); + } + }), + + Action::make('process_billing') + ->label('Bill Now') + ->icon('heroicon-o-banknotes') + ->color('primary') + ->visible(fn (Subscription $record) => $record->status === SubscriptionStatus::ACTIVE || $record->status === SubscriptionStatus::TRIALING) + ->requiresConfirmation() + ->action(function (Subscription $record, SubscriptionService $service) { + $invoice = $service->processBillingCycle($record); + if ($invoice) { + Notification::make() + ->title('Invoice Generated Successfully') + ->body("Invoice #{$invoice->invoice_number} created for this subscription.") + ->success() + ->send(); + } else { + Notification::make()->title('Could not bill subscription')->warning()->send(); + } + }), + + DeleteAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/Modules/Subscriptions/Models/Subscription.php b/Modules/Subscriptions/Models/Subscription.php new file mode 100644 index 000000000..f3eabc6f5 --- /dev/null +++ b/Modules/Subscriptions/Models/Subscription.php @@ -0,0 +1,98 @@ + SubscriptionStatus::class, + 'billing_interval' => BillingInterval::class, + 'interval_unit' => IntervalUnit::class, + 'interval_count' => 'integer', + 'price' => 'decimal:4', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'trial_starts_at' => 'datetime', + 'trial_ends_at' => 'datetime', + 'grace_period_days' => 'integer', + 'grace_period_ends_at' => 'datetime', + 'current_period_starts_at' => 'datetime', + 'current_period_ends_at' => 'datetime', + 'paused_at' => 'datetime', + 'resume_at' => 'datetime', + 'cancel_at_period_end' => 'boolean', + 'canceled_at' => 'datetime', + ]; + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Relation::class, 'customer_id'); + } + + public function subscriptionItems(): HasMany + { + return $this->hasMany(SubscriptionItem::class); + } + + public function items(): HasMany + { + return $this->subscriptionItems(); + } + + public function isTrialing(): bool + { + return $this->status === SubscriptionStatus::TRIALING + || ($this->trial_ends_at && $this->trial_ends_at->isFuture()); + } + + public function isInGracePeriod(): bool + { + return $this->status === SubscriptionStatus::IN_GRACE_PERIOD + || ($this->grace_period_ends_at && $this->grace_period_ends_at->isFuture()); + } + + public function isPaused(): bool + { + return $this->status === SubscriptionStatus::PAUSED; + } + + public function isCanceled(): bool + { + return $this->status === SubscriptionStatus::CANCELED; + } + + public function isActive(): bool + { + return $this->status === SubscriptionStatus::ACTIVE; + } + + protected static function newFactory(): Factory + { + return SubscriptionFactory::new(); + } +} diff --git a/Modules/Subscriptions/Models/SubscriptionItem.php b/Modules/Subscriptions/Models/SubscriptionItem.php new file mode 100644 index 000000000..519a87360 --- /dev/null +++ b/Modules/Subscriptions/Models/SubscriptionItem.php @@ -0,0 +1,33 @@ + 'decimal:4', + 'unit_price' => 'decimal:4', + 'subtotal' => 'decimal:4', + 'tax' => 'decimal:4', + 'total' => 'decimal:4', + ]; + + public function subscription(): BelongsTo + { + return $this->belongsTo(Subscription::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/Modules/Subscriptions/Providers/SubscriptionsServiceProvider.php b/Modules/Subscriptions/Providers/SubscriptionsServiceProvider.php new file mode 100644 index 000000000..fc1ec2260 --- /dev/null +++ b/Modules/Subscriptions/Providers/SubscriptionsServiceProvider.php @@ -0,0 +1,22 @@ +loadMigrationsFrom(module_path($this->name, 'Database/Migrations')); + } + + public function register(): void {} +} diff --git a/Modules/Subscriptions/Services/SubscriptionService.php b/Modules/Subscriptions/Services/SubscriptionService.php new file mode 100644 index 000000000..991ea5e07 --- /dev/null +++ b/Modules/Subscriptions/Services/SubscriptionService.php @@ -0,0 +1,322 @@ +getCompanyId(); + $data['number'] ??= 'SUB-' . mb_strtoupper(uniqid()); + + $startsAt = isset($data['starts_at']) ? Carbon::parse($data['starts_at']) : Carbon::now(); + $data['starts_at'] = $startsAt; + + // Determine initial status & period dates + $trialEndsAt = isset($data['trial_ends_at']) && $data['trial_ends_at'] ? Carbon::parse($data['trial_ends_at']) : null; + if ($trialEndsAt && $trialEndsAt->isFuture()) { + $data['status'] = SubscriptionStatus::TRIALING; + $data['trial_starts_at'] ??= $startsAt; + $data['current_period_starts_at'] = $startsAt; + $data['current_period_ends_at'] = $trialEndsAt; + } else { + $data['status'] ??= SubscriptionStatus::ACTIVE; + $periodDates = $this->calculateNextPeriodDates( + $data['billing_interval'] ?? BillingInterval::MONTHLY->value, + $data['interval_unit'] ?? IntervalUnit::MONTH->value, + (int) ($data['interval_count'] ?? 1), + $startsAt + ); + $data['current_period_starts_at'] = $periodDates['starts_at']; + $data['current_period_ends_at'] = $periodDates['ends_at']; + } + + $items = $data['items'] ?? []; + unset($data['items']); + + /** @var Subscription $subscription */ + $subscription = $this->create($data); + + if ( ! empty($items)) { + $this->syncItems($subscription, $items); + } + + return $subscription; + }); + } + + /** + * Calculate period start and end dates based on interval configuration. + */ + public function calculateNextPeriodDates( + string|BillingInterval $billingInterval, + string|IntervalUnit $intervalUnit = IntervalUnit::MONTH, + int $intervalCount = 1, + ?Carbon $from = null + ): array { + $from = $from ? $from->copy() : Carbon::now(); + $startsAt = $from->copy(); + $endsAt = $from->copy(); + + $intervalEnum = $billingInterval instanceof BillingInterval + ? $billingInterval + : BillingInterval::tryFrom($billingInterval) ?? BillingInterval::MONTHLY; + + $unitEnum = $intervalUnit instanceof IntervalUnit + ? $intervalUnit + : IntervalUnit::tryFrom($intervalUnit) ?? IntervalUnit::MONTH; + + $intervalCount = max(1, $intervalCount); + + switch ($intervalEnum) { + case BillingInterval::WEEKLY: + $endsAt->addWeek(); + break; + + case BillingInterval::MONTHLY: + $endsAt->addMonth(); + break; + + case BillingInterval::YEARLY: + $endsAt->addYear(); + break; + + case BillingInterval::CUSTOM: + switch ($unitEnum) { + case IntervalUnit::DAY: + $endsAt->addDays($intervalCount); + break; + case IntervalUnit::WEEK: + $endsAt->addWeeks($intervalCount); + break; + case IntervalUnit::MONTH: + $endsAt->addMonths($intervalCount); + break; + case IntervalUnit::YEAR: + $endsAt->addYears($intervalCount); + break; + } + break; + } + + return [ + 'starts_at' => $startsAt, + 'ends_at' => $endsAt, + ]; + } + + /** + * Pause an active or trialing subscription. + */ + public function pause(Subscription $subscription, ?Carbon $resumeAt = null): Subscription + { + $subscription->update([ + 'status' => SubscriptionStatus::PAUSED, + 'paused_at' => Carbon::now(), + 'resume_at' => $resumeAt, + ]); + + return $subscription; + } + + /** + * Resume a paused subscription and recalculate billing dates. + */ + public function resume(Subscription $subscription): Subscription + { + $now = Carbon::now(); + + // Determine if trial is still valid + $status = ($subscription->trial_ends_at && $subscription->trial_ends_at->isFuture()) + ? SubscriptionStatus::TRIALING + : SubscriptionStatus::ACTIVE; + + $periodDates = $this->calculateNextPeriodDates( + $subscription->billing_interval, + $subscription->interval_unit, + $subscription->interval_count, + $now + ); + + $subscription->update([ + 'status' => $status, + 'paused_at' => null, + 'resume_at' => null, + 'current_period_starts_at' => $periodDates['starts_at'], + 'current_period_ends_at' => $periodDates['ends_at'], + ]); + + return $subscription; + } + + /** + * Cancel subscription immediately. + */ + public function cancelImmediately(Subscription $subscription): Subscription + { + $now = Carbon::now(); + + $subscription->update([ + 'status' => SubscriptionStatus::CANCELED, + 'canceled_at' => $now, + 'ends_at' => $now, + 'cancel_at_period_end' => false, + ]); + + return $subscription; + } + + /** + * Mark subscription to be canceled at the end of the current billing period. + */ + public function cancelAtPeriodEnd(Subscription $subscription): Subscription + { + $subscription->update([ + 'cancel_at_period_end' => true, + 'canceled_at' => Carbon::now(), + ]); + + return $subscription; + } + + /** + * Enter grace period state (e.g. after payment warning). + */ + public function enterGracePeriod(Subscription $subscription, int $days = 7): Subscription + { + $graceEndsAt = Carbon::now()->addDays($days); + + $subscription->update([ + 'status' => SubscriptionStatus::IN_GRACE_PERIOD, + 'grace_period_days' => $days, + 'grace_period_ends_at' => $graceEndsAt, + ]); + + return $subscription; + } + + /** + * Process billing cycle: Generate invoice & roll over subscription period. + */ + public function processBillingCycle(Subscription $subscription): ?Invoice + { + // Check if subscription should cancel at period end + if ($subscription->cancel_at_period_end) { + $this->cancelImmediately($subscription); + + return null; + } + + if ($subscription->status === SubscriptionStatus::CANCELED || $subscription->status === SubscriptionStatus::PAUSED) { + return null; + } + + return DB::transaction(function () use ($subscription) { + $userId = auth()->id() + ?? \Modules\Core\Models\User::query()->whereHas('companies', fn ($q) => $q->where('companies.id', $subscription->company_id))->first()?->id + ?? 1; + + $invoice = Invoice::create([ + 'company_id' => $subscription->company_id, + 'customer_id' => $subscription->customer_id, + 'user_id' => $userId, + 'invoice_number' => 'INV-' . mb_strtoupper(mb_substr(uniqid(), -6)), + 'invoiced_at' => Carbon::now(), + 'invoice_due_at' => Carbon::now()->addDays(14), + 'invoice_status' => InvoiceStatus::SENT, + 'invoice_discount_amount' => 0.0000, + 'invoice_discount_percent' => 0.0000, + 'item_tax_total' => 0.0000, + 'invoice_item_subtotal' => $subscription->price, + 'invoice_tax_total' => 0.0000, + 'invoice_total' => $subscription->price, + 'summary' => "Subscription Invoice for {$subscription->name} ({$subscription->number})", + 'url_key' => mb_strtolower(uniqid()), + ]); + + // Copy items to invoice + foreach ($subscription->subscriptionItems as $item) { + $invoice->invoiceItems()->create([ + 'company_id' => $subscription->company_id, + 'item_name' => $item->name, + 'quantity' => $item->quantity, + 'price' => $item->unit_price, + 'subtotal' => $item->subtotal, + 'tax_total' => $item->tax, + 'total' => $item->total, + ]); + } + + // Calculate next billing period + $nextFrom = $subscription->current_period_ends_at && $subscription->current_period_ends_at->isFuture() + ? $subscription->current_period_ends_at + : Carbon::now(); + + $periodDates = $this->calculateNextPeriodDates( + $subscription->billing_interval, + $subscription->interval_unit, + $subscription->interval_count, + $nextFrom + ); + + $subscription->update([ + 'status' => SubscriptionStatus::ACTIVE, + 'current_period_starts_at' => $periodDates['starts_at'], + 'current_period_ends_at' => $periodDates['ends_at'], + ]); + + return $invoice; + }); + } + + /** + * Sync subscription items and update total subscription price. + */ + public function syncItems(Subscription $subscription, array $items): void + { + $subscription->subscriptionItems()->delete(); + + $totalPrice = 0; + + foreach ($items as $item) { + $qty = (float) ($item['quantity'] ?? 1); + $unitPrice = (float) ($item['unit_price'] ?? 0); + $subtotal = $qty * $unitPrice; + $tax = (float) ($item['tax'] ?? 0); + $total = $subtotal + $tax; + + $subscription->subscriptionItems()->create([ + 'product_id' => $item['product_id'] ?? null, + 'name' => $item['name'] ?? 'Subscription Service', + 'quantity' => $qty, + 'unit_price' => $unitPrice, + 'subtotal' => $subtotal, + 'tax' => $tax, + 'total' => $total, + ]); + + $totalPrice += $total; + } + + $subscription->update(['price' => $totalPrice]); + } +} diff --git a/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php b/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php new file mode 100644 index 000000000..82fe98958 --- /dev/null +++ b/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php @@ -0,0 +1,220 @@ +for($this->company) + ->create([ + 'name' => 'Monthly Enterprise SaaS', + 'status' => SubscriptionStatus::ACTIVE, + ]); + + $component = Livewire::actingAs($this->user) + ->test(ListSubscriptions::class); + + $component->assertSuccessful(); + } + + #[Test] + #[Group('crud')] + public function it_creates_subscription_with_monthly_interval(): void + { + $customer = Relation::factory()->for($this->company)->customer()->create(); + + $service = app(SubscriptionService::class); + $subscription = $service->createSubscription([ + 'company_id' => $this->company->id, + 'customer_id' => $customer->id, + 'name' => 'Pro Monthly Subscription', + 'billing_interval' => BillingInterval::MONTHLY->value, + 'price' => 199.00, + 'items' => [ + [ + 'name' => 'Pro Seat License', + 'quantity' => 1, + 'unit_price' => 199.00, + ], + ], + ]); + + $this->assertDatabaseHas('subscriptions', [ + 'id' => $subscription->id, + 'name' => 'Pro Monthly Subscription', + 'status' => SubscriptionStatus::ACTIVE->value, + 'company_id' => $this->company->id, + 'customer_id' => $customer->id, + ]); + + $this->assertDatabaseHas('subscription_items', [ + 'subscription_id' => $subscription->id, + 'name' => 'Pro Seat License', + 'unit_price' => 199.00, + ]); + } + + #[Test] + #[Group('crud')] + public function it_creates_subscription_with_custom_billing_cycle(): void + { + $customer = Relation::factory()->for($this->company)->customer()->create(); + + $service = app(SubscriptionService::class); + $subscription = $service->createSubscription([ + 'company_id' => $this->company->id, + 'customer_id' => $customer->id, + 'name' => '14-Day Sprint Subscription', + 'billing_interval' => BillingInterval::CUSTOM->value, + 'interval_unit' => IntervalUnit::DAY->value, + 'interval_count' => 14, + 'price' => 150.00, + ]); + + $this->assertEquals(BillingInterval::CUSTOM, $subscription->billing_interval); + $this->assertEquals(IntervalUnit::DAY, $subscription->interval_unit); + $this->assertEquals(14, $subscription->interval_count); + + $expectedEnd = $subscription->starts_at->copy()->addDays(14); + $this->assertEquals($expectedEnd->format('Y-m-d H:i'), $subscription->current_period_ends_at->format('Y-m-d H:i')); + } + + #[Test] + #[Group('lifecycle')] + public function it_handles_trial_period(): void + { + $customer = Relation::factory()->for($this->company)->customer()->create(); + + $trialEndsAt = Carbon::now()->addDays(14); + + $service = app(SubscriptionService::class); + $subscription = $service->createSubscription([ + 'company_id' => $this->company->id, + 'customer_id' => $customer->id, + 'name' => 'Trial Subscription', + 'trial_ends_at' => $trialEndsAt, + 'billing_interval' => BillingInterval::MONTHLY->value, + 'price' => 99.00, + ]); + + $this->assertEquals(SubscriptionStatus::TRIALING, $subscription->status); + $this->assertTrue($subscription->isTrialing()); + } + + #[Test] + #[Group('lifecycle')] + public function it_handles_grace_period(): void + { + $subscription = Subscription::factory() + ->for($this->company) + ->create(['status' => SubscriptionStatus::ACTIVE]); + + $service = app(SubscriptionService::class); + $service->enterGracePeriod($subscription, 7); + + $subscription->refresh(); + $this->assertEquals(SubscriptionStatus::IN_GRACE_PERIOD, $subscription->status); + $this->assertEquals(7, $subscription->grace_period_days); + $this->assertTrue($subscription->isInGracePeriod()); + } + + #[Test] + #[Group('lifecycle')] + public function it_pauses_and_resumes_subscription(): void + { + $subscription = Subscription::factory() + ->for($this->company) + ->create(['status' => SubscriptionStatus::ACTIVE]); + + $service = app(SubscriptionService::class); + + // Pause + $service->pause($subscription); + $subscription->refresh(); + $this->assertEquals(SubscriptionStatus::PAUSED, $subscription->status); + $this->assertNotNull($subscription->paused_at); + + // Resume + $service->resume($subscription); + $subscription->refresh(); + $this->assertEquals(SubscriptionStatus::ACTIVE, $subscription->status); + $this->assertNull($subscription->paused_at); + } + + #[Test] + #[Group('lifecycle')] + public function it_cancels_subscription_immediately(): void + { + $subscription = Subscription::factory() + ->for($this->company) + ->create(['status' => SubscriptionStatus::ACTIVE]); + + $service = app(SubscriptionService::class); + $service->cancelImmediately($subscription); + + $subscription->refresh(); + $this->assertEquals(SubscriptionStatus::CANCELED, $subscription->status); + $this->assertNotNull($subscription->canceled_at); + $this->assertNotNull($subscription->ends_at); + } + + #[Test] + #[Group('lifecycle')] + public function it_cancels_subscription_at_period_end(): void + { + $subscription = Subscription::factory() + ->for($this->company) + ->create(['status' => SubscriptionStatus::ACTIVE]); + + $service = app(SubscriptionService::class); + $service->cancelAtPeriodEnd($subscription); + + $subscription->refresh(); + $this->assertTrue($subscription->cancel_at_period_end); + $this->assertNotNull($subscription->canceled_at); + // Status remains active until period ends + $this->assertEquals(SubscriptionStatus::ACTIVE, $subscription->status); + } + + #[Test] + #[Group('billing')] + public function it_processes_billing_cycle_and_generates_invoice(): void + { + $subscription = Subscription::factory() + ->for($this->company) + ->create([ + 'status' => SubscriptionStatus::ACTIVE, + 'billing_interval' => BillingInterval::MONTHLY, + 'price' => 250.00, + ]); + + $service = app(SubscriptionService::class); + $invoice = $service->processBillingCycle($subscription); + + $this->assertInstanceOf(Invoice::class, $invoice); + $this->assertEquals($subscription->customer_id, $invoice->customer_id); + + $subscription->refresh(); + // Billing cycle advances period start and end + $this->assertNotNull($subscription->current_period_starts_at); + $this->assertNotNull($subscription->current_period_ends_at); + } +} diff --git a/Modules/Subscriptions/composer.json b/Modules/Subscriptions/composer.json new file mode 100644 index 000000000..37c39d35b --- /dev/null +++ b/Modules/Subscriptions/composer.json @@ -0,0 +1,23 @@ +{ + "name": "invoiceplane/subscriptions", + "description": "Subscriptions Module for InvoicePlane v2", + "extra": { + "laravel": { + "providers": [], + "aliases": { + } + } + }, + "autoload": { + "psr-4": { + "Modules\\Subscriptions\\": "", + "Modules\\Subscriptions\\Database\\Factories\\": "Database/Factories/", + "Modules\\Subscriptions\\Database\\Seeders\\": "Database/Seeders/" + } + }, + "autoload-dev": { + "psr-4": { + "Modules\\Subscriptions\\Tests\\": "Tests/" + } + } +} diff --git a/Modules/Subscriptions/module.json b/Modules/Subscriptions/module.json new file mode 100644 index 000000000..16c9782b4 --- /dev/null +++ b/Modules/Subscriptions/module.json @@ -0,0 +1,11 @@ +{ + "name": "Subscriptions", + "alias": "subscriptions", + "description": "Subscriptions and Subscription Lifecycle Management", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\Subscriptions\\Providers\\SubscriptionsServiceProvider" + ], + "files": [] +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e3e402d0d..956b7523c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -21,6 +21,7 @@ use Modules\Projects\Database\Seeders\ProjectsSeeder; use Modules\Projects\Database\Seeders\TasksSeeder; use Modules\Quotes\Database\Seeders\QuotesSeeder; +use Modules\Subscriptions\Database\Seeders\SubscriptionSeeder; use RuntimeException; use Symfony\Component\Console\Formatter\OutputFormatterStyle; @@ -97,6 +98,8 @@ public function run(): void $this->callWith(PaymentsSeeder::class, $p + ['count' => $this->volumes['payments']]); + $this->call(SubscriptionSeeder::class); + (new TaxRatesSeeder())->buildOne($company->id); $this->command->info("===== END Seeding company {$company->id} ({$company->name}) ====="); diff --git a/modules_statuses.json b/modules_statuses.json index 1f7514ac1..6ec20e107 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -7,5 +7,6 @@ "Products": true, "Projects": true, "Quotes": true, - "ReportBuilder": true + "ReportBuilder": true, + "Subscriptions": true } From 8f4c9ace95cf41091b714359faa874468b08cc29 Mon Sep 17 00:00:00 2001 From: "Ahmed_raza.Fyntune" <139863230+Ahmedraza-fyntune@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:57:15 +0530 Subject: [PATCH 02/15] php stan error fix for code static analysis --- Modules/Clients/Models/Relation.php | 3 ++- Modules/Core/Tests/Feature/LoginResponseTest.php | 1 + Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/Clients/Models/Relation.php b/Modules/Clients/Models/Relation.php index bc6dbd2b7..e20fa9e47 100644 --- a/Modules/Clients/Models/Relation.php +++ b/Modules/Clients/Models/Relation.php @@ -119,7 +119,8 @@ public function communications(): MorphMany public function ccEmailCommunications() { - return $this->communications()->whereIn('communication_type', CommunicationType::ccTypes()); + /** @var MorphMany */ + return $this->communications()->where('communication_type', CommunicationType::INVOICE_CC->value); } public function contacts(): HasMany diff --git a/Modules/Core/Tests/Feature/LoginResponseTest.php b/Modules/Core/Tests/Feature/LoginResponseTest.php index 33e860fa3..e36c7d969 100644 --- a/Modules/Core/Tests/Feature/LoginResponseTest.php +++ b/Modules/Core/Tests/Feature/LoginResponseTest.php @@ -139,6 +139,7 @@ private function makeUser(Company ...$companies): User private function dispatchResponse() { + /** @var RedirectResponse */ return (new LoginResponse())->toResponse(request()); } diff --git a/Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php b/Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php index 42d315d74..a74b7b98e 100644 --- a/Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php +++ b/Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php @@ -39,7 +39,7 @@ protected function setUp(): void * on invoices, create-payments and email-invoices. */ $customer = Relation::factory()->for($this->company)->customer()->create(); - /* @var Relation $customer */ + /** @var Relation $customer */ $this->customer = $customer; /** @var Numbering $numbering */ From 49adf73d58e73d48db57a87d9ef1e9e538dfecce Mon Sep 17 00:00:00 2001 From: Niels Drost <47660417+nielsdrost7@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:51:20 +0200 Subject: [PATCH 03/15] Update 2026_08_13_000001_create_subscriptions_table.php --- .../Migrations/2026_08_13_000001_create_subscriptions_table.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php b/Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php index 719c88389..78e7ff7c3 100644 --- a/Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php +++ b/Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php @@ -40,7 +40,6 @@ public function up(): void $table->timestamp('canceled_at')->nullable(); $table->text('notes')->nullable(); - $table->timestamps(); $table->softDeletes(); $table->index(['company_id', 'status']); @@ -57,7 +56,6 @@ public function up(): void $table->decimal('subtotal', 15, 4)->default(0.0000); $table->decimal('tax', 15, 4)->default(0.0000); $table->decimal('total', 15, 4)->default(0.0000); - $table->timestamps(); }); } From e042d476561d213a7bdcb2ebebe4adbfc45383c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Aug 2026 17:38:27 +0000 Subject: [PATCH 04/15] style: apply Laravel Pint fixes --- Modules/Clients/Models/Relation.php | 2 +- Modules/Core/Tests/Feature/LoginResponseTest.php | 2 +- Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Clients/Models/Relation.php b/Modules/Clients/Models/Relation.php index e20fa9e47..732087921 100644 --- a/Modules/Clients/Models/Relation.php +++ b/Modules/Clients/Models/Relation.php @@ -119,7 +119,7 @@ public function communications(): MorphMany public function ccEmailCommunications() { - /** @var MorphMany */ + /* @var MorphMany */ return $this->communications()->where('communication_type', CommunicationType::INVOICE_CC->value); } diff --git a/Modules/Core/Tests/Feature/LoginResponseTest.php b/Modules/Core/Tests/Feature/LoginResponseTest.php index e36c7d969..7bc6e1379 100644 --- a/Modules/Core/Tests/Feature/LoginResponseTest.php +++ b/Modules/Core/Tests/Feature/LoginResponseTest.php @@ -139,7 +139,7 @@ private function makeUser(Company ...$companies): User private function dispatchResponse() { - /** @var RedirectResponse */ + /* @var RedirectResponse */ return (new LoginResponse())->toResponse(request()); } diff --git a/Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php b/Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php index a74b7b98e..42d315d74 100644 --- a/Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php +++ b/Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php @@ -39,7 +39,7 @@ protected function setUp(): void * on invoices, create-payments and email-invoices. */ $customer = Relation::factory()->for($this->company)->customer()->create(); - /** @var Relation $customer */ + /* @var Relation $customer */ $this->customer = $customer; /** @var Numbering $numbering */ From b332448428d347a2d7d2858148ae681cd3c8e463 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 17:55:01 +0000 Subject: [PATCH 05/15] Add translation labels to Subscriptions module and AAA test comments Replace hardcoded strings with trans('ip.*') calls across Subscription form/table/resource and status/interval enums, and add missing Arrange/Act/Assert block comments to SubscriptionTest. --- .../Subscriptions/Enums/BillingInterval.php | 8 +-- .../Subscriptions/Enums/CancellationType.php | 4 +- Modules/Subscriptions/Enums/IntervalUnit.php | 8 +-- .../Enums/SubscriptionStatus.php | 12 ++-- .../Schemas/SubscriptionForm.php | 54 +++++++------- .../Subscriptions/SubscriptionResource.php | 6 +- .../Tables/SubscriptionsTable.php | 46 ++++++------ .../Tests/Feature/SubscriptionTest.php | 64 ++++++++++++----- resources/lang/en/ip.php | 71 +++++++++++++++++++ 9 files changed, 185 insertions(+), 88 deletions(-) diff --git a/Modules/Subscriptions/Enums/BillingInterval.php b/Modules/Subscriptions/Enums/BillingInterval.php index 27fbb50b1..b3ecda351 100644 --- a/Modules/Subscriptions/Enums/BillingInterval.php +++ b/Modules/Subscriptions/Enums/BillingInterval.php @@ -19,10 +19,10 @@ public static function values(): array public function label(): string { return match ($this) { - self::WEEKLY => 'Weekly', - self::MONTHLY => 'Monthly', - self::YEARLY => 'Yearly', - self::CUSTOM => 'Custom Cycle', + self::WEEKLY => trans('ip.billing_interval_weekly'), + self::MONTHLY => trans('ip.billing_interval_monthly'), + self::YEARLY => trans('ip.billing_interval_yearly'), + self::CUSTOM => trans('ip.billing_interval_custom'), }; } diff --git a/Modules/Subscriptions/Enums/CancellationType.php b/Modules/Subscriptions/Enums/CancellationType.php index 889ec477f..a54a25e9d 100644 --- a/Modules/Subscriptions/Enums/CancellationType.php +++ b/Modules/Subscriptions/Enums/CancellationType.php @@ -17,8 +17,8 @@ public static function values(): array public function label(): string { return match ($this) { - self::IMMEDIATE => 'Cancel Immediately', - self::AT_PERIOD_END => 'Cancel at End of Billing Period', + self::IMMEDIATE => trans('ip.cancellation_type_immediate'), + self::AT_PERIOD_END => trans('ip.cancellation_type_at_period_end'), }; } diff --git a/Modules/Subscriptions/Enums/IntervalUnit.php b/Modules/Subscriptions/Enums/IntervalUnit.php index dfaa15586..931192324 100644 --- a/Modules/Subscriptions/Enums/IntervalUnit.php +++ b/Modules/Subscriptions/Enums/IntervalUnit.php @@ -19,10 +19,10 @@ public static function values(): array public function label(): string { return match ($this) { - self::DAY => 'Day(s)', - self::WEEK => 'Week(s)', - self::MONTH => 'Month(s)', - self::YEAR => 'Year(s)', + self::DAY => trans('ip.interval_unit_day'), + self::WEEK => trans('ip.interval_unit_week'), + self::MONTH => trans('ip.interval_unit_month'), + self::YEAR => trans('ip.interval_unit_year'), }; } diff --git a/Modules/Subscriptions/Enums/SubscriptionStatus.php b/Modules/Subscriptions/Enums/SubscriptionStatus.php index 782599e10..1661c0ac9 100644 --- a/Modules/Subscriptions/Enums/SubscriptionStatus.php +++ b/Modules/Subscriptions/Enums/SubscriptionStatus.php @@ -21,12 +21,12 @@ public static function values(): array public function label(): string { return match ($this) { - self::ACTIVE => 'Active', - self::TRIALING => 'Trialing', - self::IN_GRACE_PERIOD => 'In Grace Period', - self::PAUSED => 'Paused', - self::CANCELED => 'Canceled', - self::EXPIRED => 'Expired', + self::ACTIVE => trans('ip.subscription_status_active'), + self::TRIALING => trans('ip.subscription_status_trialing'), + self::IN_GRACE_PERIOD => trans('ip.subscription_status_in_grace_period'), + self::PAUSED => trans('ip.subscription_status_paused'), + self::CANCELED => trans('ip.subscription_status_canceled'), + self::EXPIRED => trans('ip.subscription_status_expired'), }; } diff --git a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php index 62c59436d..3734c072f 100644 --- a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php +++ b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php @@ -29,28 +29,28 @@ public static function configure(Schema $schema): Schema Schemas\Components\Group::make() ->columnSpan(3) ->schema([ - Section::make('Subscription Overview') + Section::make(trans('ip.subscription_overview')) ->schema([ TextInput::make('name') - ->label('Subscription Title') + ->label(trans('ip.subscription_title')) ->required() - ->placeholder('e.g. Enterprise Monthly Software License') + ->placeholder(trans('ip.subscription_title_placeholder')) ->columnSpanFull(), Select::make('customer_id') - ->label('Client / Customer') + ->label(trans('ip.subscription_client')) ->relationship('customer', 'company_name') ->searchable() ->preload() ->required(), TextInput::make('number') - ->label('Subscription Code / Ref') + ->label(trans('ip.subscription_code')) ->default(fn () => 'SUB-' . mb_strtoupper(mb_substr(uniqid(), -6))) ->required(), Select::make('status') - ->label('Subscription Status') + ->label(trans('ip.subscription_status')) ->options( collect(SubscriptionStatus::cases()) ->mapWithKeys(fn ($s) => [$s->value => $s->label()]) @@ -61,10 +61,10 @@ public static function configure(Schema $schema): Schema ]) ->columns(2), - Section::make('Billing Cycle Configuration') + Section::make(trans('ip.billing_cycle_configuration')) ->schema([ Select::make('billing_interval') - ->label('Billing Interval') + ->label(trans('ip.billing_interval')) ->options( collect(BillingInterval::cases()) ->mapWithKeys(fn ($i) => [$i->value => $i->label()]) @@ -75,7 +75,7 @@ public static function configure(Schema $schema): Schema ->required(), Select::make('interval_unit') - ->label('Custom Unit') + ->label(trans('ip.custom_unit')) ->options( collect(IntervalUnit::cases()) ->mapWithKeys(fn ($u) => [$u->value => $u->label()]) @@ -86,7 +86,7 @@ public static function configure(Schema $schema): Schema ->required(fn (Get $get) => $get('billing_interval') === BillingInterval::CUSTOM->value), TextInput::make('interval_count') - ->label('Custom Count (Frequency)') + ->label(trans('ip.custom_count')) ->numeric() ->default(1) ->minValue(1) @@ -94,7 +94,7 @@ public static function configure(Schema $schema): Schema ->required(fn (Get $get) => $get('billing_interval') === BillingInterval::CUSTOM->value), TextInput::make('price') - ->label('Recurring Price') + ->label(trans('ip.recurring_price')) ->numeric() ->prefix('$') ->required(), @@ -105,39 +105,39 @@ public static function configure(Schema $schema): Schema Schemas\Components\Group::make() ->columnSpan(2) ->schema([ - Section::make('Lifecycle & Trial Dates') + Section::make(trans('ip.lifecycle_and_trial_dates')) ->schema([ DateTimePicker::make('starts_at') - ->label('Start Date') + ->label(trans('ip.start_date')) ->default(now()) ->required(), DateTimePicker::make('ends_at') - ->label('Expiration / End Date') + ->label(trans('ip.expiration_date')) ->nullable(), DateTimePicker::make('trial_starts_at') - ->label('Trial Start Date') + ->label(trans('ip.trial_start_date')) ->nullable(), DateTimePicker::make('trial_ends_at') - ->label('Trial End Date') + ->label(trans('ip.trial_end_date')) ->nullable(), TextInput::make('grace_period_days') - ->label('Grace Period (Days)') + ->label(trans('ip.grace_period_days')) ->numeric() ->default(0), DateTimePicker::make('grace_period_ends_at') - ->label('Grace Period Expiration') + ->label(trans('ip.grace_period_expiration')) ->nullable(), ]) ->columns(1), ]), ]), - Section::make('Subscription Line Items') + Section::make(trans('ip.subscription_line_items')) ->schema([ Repeater::make('subscriptionItems') ->relationship('subscriptionItems') @@ -145,7 +145,7 @@ public static function configure(Schema $schema): Schema Grid::make(5) ->schema([ Select::make('product_id') - ->label('Product / Service') + ->label(trans('ip.product_service')) ->options(Product::query()->pluck('product_name', 'id')->toArray()) ->searchable() ->preload() @@ -158,24 +158,24 @@ public static function configure(Schema $schema): Schema }), TextInput::make('name') - ->label('Description') + ->label(trans('ip.description')) ->required(), TextInput::make('quantity') - ->label('Qty') + ->label(trans('ip.quantity')) ->numeric() ->default(1) ->required(), TextInput::make('unit_price') - ->label('Unit Price') + ->label(trans('ip.unit_price')) ->numeric() ->required(), TextInput::make('total') - ->label('Total') + ->label(trans('ip.total')) ->numeric() - ->placeholder('Auto-calc'), + ->placeholder(trans('ip.total_auto_calc')), ]), ]) ->columns(1) @@ -184,10 +184,10 @@ public static function configure(Schema $schema): Schema ]) ->columnSpanFull(), - Section::make('Internal Notes') + Section::make(trans('ip.internal_notes')) ->schema([ MarkdownEditor::make('notes') - ->label('Subscription Notes') + ->label(trans('ip.subscription_notes')) ->toolbarButtons(['bold', 'italic', 'bulletList']) ->columnSpanFull(), ]) diff --git a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/SubscriptionResource.php b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/SubscriptionResource.php index 85fef13b2..40194088d 100644 --- a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/SubscriptionResource.php +++ b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/SubscriptionResource.php @@ -28,17 +28,17 @@ class SubscriptionResource extends BaseResource public static function getModelLabel(): string { - return 'Subscription'; + return trans('ip.subscription'); } public static function getPluralModelLabel(): string { - return 'Subscriptions'; + return trans('ip.subscriptions'); } public static function getNavigationLabel(): string { - return 'Subscriptions'; + return trans('ip.subscriptions'); } public static function getNavigationBadge(): ?string diff --git a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php index 282171605..8965261f4 100644 --- a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php +++ b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php @@ -27,23 +27,23 @@ public static function configure(Table $table): Table return $table ->columns([ TextColumn::make('number') - ->label('Subscription #') + ->label(trans('ip.subscription_number')) ->searchable() ->sortable() ->weight('bold'), TextColumn::make('name') - ->label('Title') + ->label(trans('ip.subscription_title')) ->searchable() ->limit(25), TextColumn::make('customer.company_name') - ->label('Client') + ->label(trans('ip.subscription_client')) ->searchable() ->sortable(), TextColumn::make('status') - ->label('Status') + ->label(trans('ip.subscription_status')) ->badge() ->formatStateUsing(fn ($state) => $state instanceof SubscriptionStatus ? $state->label() : SubscriptionStatus::tryFrom($state)?->label() ?? $state) ->color(fn ($state) => $state instanceof SubscriptionStatus ? $state->color() : SubscriptionStatus::tryFrom($state)?->color() ?? 'gray') @@ -51,7 +51,7 @@ public static function configure(Table $table): Table ->sortable(), TextColumn::make('billing_interval') - ->label('Billing Cycle') + ->label(trans('ip.billing_cycle')) ->formatStateUsing(function ($state, Subscription $record) { if ($record->billing_interval === BillingInterval::CUSTOM) { return "Every {$record->interval_count} {$record->interval_unit?->value}(s)"; @@ -62,17 +62,17 @@ public static function configure(Table $table): Table ->sortable(), TextColumn::make('price') - ->label('Price') + ->label(trans('ip.recurring_price')) ->money('USD') ->sortable(), TextColumn::make('current_period_ends_at') - ->label('Next Billing Date') + ->label(trans('ip.subscription_next_billing_date')) ->dateTime('M d, Y') ->sortable(), IconColumn::make('cancel_at_period_end') - ->label('Pending Cancel') + ->label(trans('ip.subscription_pending_cancel')) ->boolean() ->toggleable(isToggledHiddenByDefault: true), ]) @@ -95,41 +95,41 @@ public static function configure(Table $table): Table EditAction::make(), Action::make('pause') - ->label('Pause') + ->label(trans('ip.subscription_pause')) ->icon('heroicon-o-pause') ->color('gray') ->visible(fn (Subscription $record) => $record->status === SubscriptionStatus::ACTIVE || $record->status === SubscriptionStatus::TRIALING) ->form([ DateTimePicker::make('resume_at') - ->label('Auto-Resume At (Optional)') - ->hint('Leave empty for manual resume'), + ->label(trans('ip.subscription_auto_resume_at')) + ->hint(trans('ip.subscription_auto_resume_hint')), ]) ->action(function (Subscription $record, array $data, SubscriptionService $service) { $service->pause($record, isset($data['resume_at']) ? \Carbon\Carbon::parse($data['resume_at']) : null); - Notification::make()->title('Subscription Paused')->warning()->send(); + Notification::make()->title(trans('ip.subscription_paused_notification'))->warning()->send(); }), Action::make('resume') - ->label('Resume') + ->label(trans('ip.subscription_resume')) ->icon('heroicon-o-play') ->color('success') ->visible(fn (Subscription $record) => $record->status === SubscriptionStatus::PAUSED) ->action(function (Subscription $record, SubscriptionService $service) { $service->resume($record); - Notification::make()->title('Subscription Resumed')->success()->send(); + Notification::make()->title(trans('ip.subscription_resumed_notification'))->success()->send(); }), Action::make('cancel') - ->label('Cancel') + ->label(trans('ip.subscription_cancel')) ->icon('heroicon-o-x-circle') ->color('danger') ->visible(fn (Subscription $record) => $record->status !== SubscriptionStatus::CANCELED && $record->status !== SubscriptionStatus::EXPIRED) ->form([ Select::make('cancellation_type') - ->label('Cancellation Option') + ->label(trans('ip.subscription_cancellation_option')) ->options([ - CancellationType::AT_PERIOD_END->value => 'Cancel at End of Billing Period', - CancellationType::IMMEDIATE->value => 'Cancel Immediately', + CancellationType::AT_PERIOD_END->value => trans('ip.subscription_cancel_at_period_end'), + CancellationType::IMMEDIATE->value => trans('ip.subscription_cancel_immediately'), ]) ->default(CancellationType::AT_PERIOD_END->value) ->required(), @@ -137,15 +137,15 @@ public static function configure(Table $table): Table ->action(function (Subscription $record, array $data, SubscriptionService $service) { if ($data['cancellation_type'] === CancellationType::IMMEDIATE->value) { $service->cancelImmediately($record); - Notification::make()->title('Subscription Canceled Immediately')->danger()->send(); + Notification::make()->title(trans('ip.subscription_canceled_immediately_notification'))->danger()->send(); } else { $service->cancelAtPeriodEnd($record); - Notification::make()->title('Subscription set to cancel at period end')->warning()->send(); + Notification::make()->title(trans('ip.subscription_cancel_at_period_end_notification'))->warning()->send(); } }), Action::make('process_billing') - ->label('Bill Now') + ->label(trans('ip.subscription_bill_now')) ->icon('heroicon-o-banknotes') ->color('primary') ->visible(fn (Subscription $record) => $record->status === SubscriptionStatus::ACTIVE || $record->status === SubscriptionStatus::TRIALING) @@ -154,12 +154,12 @@ public static function configure(Table $table): Table $invoice = $service->processBillingCycle($record); if ($invoice) { Notification::make() - ->title('Invoice Generated Successfully') + ->title(trans('ip.subscription_invoice_generated_title')) ->body("Invoice #{$invoice->invoice_number} created for this subscription.") ->success() ->send(); } else { - Notification::make()->title('Could not bill subscription')->warning()->send(); + Notification::make()->title(trans('ip.subscription_billing_failed_notification'))->warning()->send(); } }), diff --git a/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php b/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php index 82fe98958..f2e2622e2 100644 --- a/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php +++ b/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php @@ -22,6 +22,7 @@ class SubscriptionTest extends AbstractCompanyPanelTestCase #[Group('smoke')] public function it_lists_subscriptions(): void { + /* Arrange */ $subscription = Subscription::factory() ->for($this->company) ->create([ @@ -29,9 +30,11 @@ public function it_lists_subscriptions(): void 'status' => SubscriptionStatus::ACTIVE, ]); + /* Act */ $component = Livewire::actingAs($this->user) ->test(ListSubscriptions::class); + /* Assert */ $component->assertSuccessful(); } @@ -39,9 +42,11 @@ public function it_lists_subscriptions(): void #[Group('crud')] public function it_creates_subscription_with_monthly_interval(): void { + /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); + $service = app(SubscriptionService::class); - $service = app(SubscriptionService::class); + /* Act */ $subscription = $service->createSubscription([ 'company_id' => $this->company->id, 'customer_id' => $customer->id, @@ -57,6 +62,7 @@ public function it_creates_subscription_with_monthly_interval(): void ], ]); + /* Assert */ $this->assertDatabaseHas('subscriptions', [ 'id' => $subscription->id, 'name' => 'Pro Monthly Subscription', @@ -76,9 +82,11 @@ public function it_creates_subscription_with_monthly_interval(): void #[Group('crud')] public function it_creates_subscription_with_custom_billing_cycle(): void { + /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); + $service = app(SubscriptionService::class); - $service = app(SubscriptionService::class); + /* Act */ $subscription = $service->createSubscription([ 'company_id' => $this->company->id, 'customer_id' => $customer->id, @@ -89,6 +97,7 @@ public function it_creates_subscription_with_custom_billing_cycle(): void 'price' => 150.00, ]); + /* Assert */ $this->assertEquals(BillingInterval::CUSTOM, $subscription->billing_interval); $this->assertEquals(IntervalUnit::DAY, $subscription->interval_unit); $this->assertEquals(14, $subscription->interval_count); @@ -101,11 +110,12 @@ public function it_creates_subscription_with_custom_billing_cycle(): void #[Group('lifecycle')] public function it_handles_trial_period(): void { - $customer = Relation::factory()->for($this->company)->customer()->create(); - + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); $trialEndsAt = Carbon::now()->addDays(14); + $service = app(SubscriptionService::class); - $service = app(SubscriptionService::class); + /* Act */ $subscription = $service->createSubscription([ 'company_id' => $this->company->id, 'customer_id' => $customer->id, @@ -115,6 +125,7 @@ public function it_handles_trial_period(): void 'price' => 99.00, ]); + /* Assert */ $this->assertEquals(SubscriptionStatus::TRIALING, $subscription->status); $this->assertTrue($subscription->isTrialing()); } @@ -123,14 +134,17 @@ public function it_handles_trial_period(): void #[Group('lifecycle')] public function it_handles_grace_period(): void { + /* Arrange */ $subscription = Subscription::factory() ->for($this->company) ->create(['status' => SubscriptionStatus::ACTIVE]); - $service = app(SubscriptionService::class); - $service->enterGracePeriod($subscription, 7); + /* Act */ + $service->enterGracePeriod($subscription, 7); $subscription->refresh(); + + /* Assert */ $this->assertEquals(SubscriptionStatus::IN_GRACE_PERIOD, $subscription->status); $this->assertEquals(7, $subscription->grace_period_days); $this->assertTrue($subscription->isInGracePeriod()); @@ -140,21 +154,25 @@ public function it_handles_grace_period(): void #[Group('lifecycle')] public function it_pauses_and_resumes_subscription(): void { + /* Arrange */ $subscription = Subscription::factory() ->for($this->company) ->create(['status' => SubscriptionStatus::ACTIVE]); - $service = app(SubscriptionService::class); - // Pause + /* Act */ $service->pause($subscription); $subscription->refresh(); + + /* Assert */ $this->assertEquals(SubscriptionStatus::PAUSED, $subscription->status); $this->assertNotNull($subscription->paused_at); - // Resume + /* Act */ $service->resume($subscription); $subscription->refresh(); + + /* Assert */ $this->assertEquals(SubscriptionStatus::ACTIVE, $subscription->status); $this->assertNull($subscription->paused_at); } @@ -163,14 +181,17 @@ public function it_pauses_and_resumes_subscription(): void #[Group('lifecycle')] public function it_cancels_subscription_immediately(): void { + /* Arrange */ $subscription = Subscription::factory() ->for($this->company) ->create(['status' => SubscriptionStatus::ACTIVE]); - $service = app(SubscriptionService::class); - $service->cancelImmediately($subscription); + /* Act */ + $service->cancelImmediately($subscription); $subscription->refresh(); + + /* Assert */ $this->assertEquals(SubscriptionStatus::CANCELED, $subscription->status); $this->assertNotNull($subscription->canceled_at); $this->assertNotNull($subscription->ends_at); @@ -180,17 +201,20 @@ public function it_cancels_subscription_immediately(): void #[Group('lifecycle')] public function it_cancels_subscription_at_period_end(): void { + /* Arrange */ $subscription = Subscription::factory() ->for($this->company) ->create(['status' => SubscriptionStatus::ACTIVE]); - $service = app(SubscriptionService::class); - $service->cancelAtPeriodEnd($subscription); + /* Act */ + $service->cancelAtPeriodEnd($subscription); $subscription->refresh(); + + /* Assert */ $this->assertTrue($subscription->cancel_at_period_end); $this->assertNotNull($subscription->canceled_at); - // Status remains active until period ends + /* Status remains active until period ends */ $this->assertEquals(SubscriptionStatus::ACTIVE, $subscription->status); } @@ -198,6 +222,7 @@ public function it_cancels_subscription_at_period_end(): void #[Group('billing')] public function it_processes_billing_cycle_and_generates_invoice(): void { + /* Arrange */ $subscription = Subscription::factory() ->for($this->company) ->create([ @@ -205,15 +230,16 @@ public function it_processes_billing_cycle_and_generates_invoice(): void 'billing_interval' => BillingInterval::MONTHLY, 'price' => 250.00, ]); - $service = app(SubscriptionService::class); + + /* Act */ $invoice = $service->processBillingCycle($subscription); + $subscription->refresh(); + /* Assert */ $this->assertInstanceOf(Invoice::class, $invoice); $this->assertEquals($subscription->customer_id, $invoice->customer_id); - - $subscription->refresh(); - // Billing cycle advances period start and end + /* Billing cycle advances period start and end */ $this->assertNotNull($subscription->current_period_starts_at); $this->assertNotNull($subscription->current_period_ends_at); } diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index 75604f3be..492c0d62c 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -1307,4 +1307,75 @@ 'default_invoice_footer' => 'Default Invoice Footer', 'default_quote_tax_rate' => 'Default Quote Tax Rate', #endregion + + #region SUBSCRIPTIONS MODULE + 'subscription' => 'Subscription', + 'subscriptions' => 'Subscriptions', + 'subscription_overview' => 'Subscription Overview', + 'subscription_title' => 'Subscription Title', + 'subscription_title_placeholder' => 'e.g. Enterprise Monthly Software License', + 'subscription_client' => 'Client / Customer', + 'subscription_code' => 'Subscription Code / Ref', + 'subscription_status' => 'Subscription Status', + 'billing_cycle_configuration' => 'Billing Cycle Configuration', + 'billing_interval' => 'Billing Interval', + 'billing_cycle' => 'Billing Cycle', + 'custom_unit' => 'Custom Unit', + 'custom_count' => 'Custom Count (Frequency)', + 'recurring_price' => 'Recurring Price', + 'lifecycle_and_trial_dates' => 'Lifecycle & Trial Dates', + 'start_date' => 'Start Date', + 'expiration_date' => 'Expiration / End Date', + 'trial_start_date' => 'Trial Start Date', + 'trial_end_date' => 'Trial End Date', + 'grace_period_days' => 'Grace Period (Days)', + 'grace_period_expiration' => 'Grace Period Expiration', + 'subscription_line_items' => 'Subscription Line Items', + 'product_service' => 'Product / Service', + 'description' => 'Description', + 'quantity' => 'Qty', + 'unit_price' => 'Unit Price', + 'total' => 'Total', + 'total_auto_calc' => 'Auto-calc', + 'internal_notes' => 'Internal Notes', + 'subscription_notes' => 'Subscription Notes', + 'subscription_number' => 'Subscription #', + 'subscription_next_billing_date' => 'Next Billing Date', + 'subscription_pending_cancel' => 'Pending Cancel', + 'subscription_pause' => 'Pause', + 'subscription_auto_resume_at' => 'Auto-Resume At (Optional)', + 'subscription_auto_resume_hint' => 'Leave empty for manual resume', + 'subscription_resume' => 'Resume', + 'subscription_cancel' => 'Cancel', + 'subscription_cancellation_option' => 'Cancellation Option', + 'subscription_cancel_at_period_end' => 'Cancel at End of Billing Period', + 'subscription_cancel_immediately' => 'Cancel Immediately', + 'subscription_bill_now' => 'Bill Now', + 'subscription_paused_notification' => 'Subscription Paused', + 'subscription_resumed_notification' => 'Subscription Resumed', + 'subscription_canceled_immediately_notification' => 'Subscription Canceled Immediately', + 'subscription_cancel_at_period_end_notification' => 'Subscription set to cancel at period end', + 'subscription_invoice_generated_title' => 'Invoice Generated Successfully', + 'subscription_billing_failed_notification' => 'Could not bill subscription', + + 'billing_interval_weekly' => 'Weekly', + 'billing_interval_monthly' => 'Monthly', + 'billing_interval_yearly' => 'Yearly', + 'billing_interval_custom' => 'Custom Cycle', + + 'interval_unit_day' => 'Day(s)', + 'interval_unit_week' => 'Week(s)', + 'interval_unit_month' => 'Month(s)', + 'interval_unit_year' => 'Year(s)', + + 'subscription_status_active' => 'Active', + 'subscription_status_trialing' => 'Trialing', + 'subscription_status_in_grace_period' => 'In Grace Period', + 'subscription_status_paused' => 'Paused', + 'subscription_status_canceled' => 'Canceled', + 'subscription_status_expired' => 'Expired', + + 'cancellation_type_immediate' => 'Cancel Immediately', + 'cancellation_type_at_period_end' => 'Cancel at End of Billing Period', + #endregion ]; From c8c24c3574322a197d56e9642af170b2ffa4d0da Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 18:04:52 +0000 Subject: [PATCH 06/15] Fix subscription seeding/factory tenant scoping and billing edge cases - DatabaseSeeder now passes the per-company parameter to SubscriptionSeeder instead of it hardcoding/guessing the ivplv2 company, matching every other module seeder's contract. - SubscriptionFactory retains the resolved Company instance so its fallback customer factory is scoped with for($company) instead of landing on a random tenant. - processBillingCycle no longer cancels a subscription immediately just because cancel_at_period_end was set; it now waits until the period has actually ended, and locks the subscription row before billing to narrow the window for duplicate invoices on concurrent calls. - Replace uniqid()-based number/invoice_number/url_key generation with random_bytes-based generation, add a collision-checked unique subscription number generator, and add a unique (company_id, number) index. - Subscription items get subtotal/total computed by a new SubscriptionItemObserver instead of trusting the submitted total field, which is now display-only in the form. - Format the price column using each subscription's currency_code (falling back to USD) and add a currency_code field to the form. - Test: assert the seeded subscription is visible in the table, and add a regression test for the cancel-at-period-end billing bug. --- .../Factories/SubscriptionFactory.php | 26 ++++++++++++- ...d_unique_number_to_subscriptions_table.php | 21 +++++++++++ .../Database/Seeders/SubscriptionSeeder.php | 5 +-- .../Schemas/SubscriptionForm.php | 11 +++++- .../Tables/SubscriptionsTable.php | 2 +- .../Observers/SubscriptionItemObserver.php | 17 +++++++++ .../SubscriptionsServiceProvider.php | 4 ++ .../Services/SubscriptionService.php | 37 ++++++++++++++++--- .../Tests/Feature/SubscriptionTest.php | 29 ++++++++++++++- database/seeders/DatabaseSeeder.php | 2 +- resources/lang/en/ip.php | 3 ++ 11 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 Modules/Subscriptions/Database/Migrations/2026_08_14_000001_add_unique_number_to_subscriptions_table.php create mode 100644 Modules/Subscriptions/Observers/SubscriptionItemObserver.php diff --git a/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php b/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php index 167c0dc07..bf0c1d413 100644 --- a/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php +++ b/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php @@ -4,6 +4,7 @@ use Modules\Clients\Models\Relation; use Modules\Core\Database\Factories\AbstractFactory; +use Modules\Core\Models\Company; use Modules\Subscriptions\Enums\BillingInterval; use Modules\Subscriptions\Enums\IntervalUnit; use Modules\Subscriptions\Enums\SubscriptionStatus; @@ -16,12 +17,13 @@ class SubscriptionFactory extends AbstractFactory public function definition(): array { - $companyId = $this->resolveCompanyId(); + $company = $this->resolveCompany(); + $companyId = $company?->id ?? $this->resolveCompanyId(); $startsAt = $this->faker->dateTimeBetween('-6 months', 'now'); return [ 'company_id' => $companyId, - 'customer_id' => $this->resolveForeignKey(Relation::class, $companyId), + 'customer_id' => $this->resolveCustomerId($company, $companyId), 'number' => 'SUB-' . $this->faker->unique()->numerify('#####'), 'name' => $this->faker->words(3, true) . ' Subscription', 'status' => SubscriptionStatus::ACTIVE, @@ -46,6 +48,26 @@ public function definition(): array ]; } + /** + * Resolve an existing customer for the company, falling back to a factory + * scoped to the same company so generated customers never end up on a + * different tenant than the subscription. + */ + private function resolveCustomerId(?Company $company, ?int $companyId): mixed + { + if (app()->runningUnitTests() && $companyId !== null) { + $existing = Relation::query()->where('company_id', $companyId) + ->inRandomOrder() + ->first(); + + if ($existing) { + return $existing->id; + } + } + + return $company ? Relation::factory()->for($company) : Relation::factory(); + } + public function configure(): static { return $this->afterCreating(function (Subscription $subscription) { diff --git a/Modules/Subscriptions/Database/Migrations/2026_08_14_000001_add_unique_number_to_subscriptions_table.php b/Modules/Subscriptions/Database/Migrations/2026_08_14_000001_add_unique_number_to_subscriptions_table.php new file mode 100644 index 000000000..2057080db --- /dev/null +++ b/Modules/Subscriptions/Database/Migrations/2026_08_14_000001_add_unique_number_to_subscriptions_table.php @@ -0,0 +1,21 @@ +unique(['company_id', 'number']); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropUnique(['company_id', 'number']); + }); + } +}; diff --git a/Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php b/Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php index b19f6046f..0ea7a4618 100644 --- a/Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php +++ b/Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php @@ -15,10 +15,9 @@ class SubscriptionSeeder extends Seeder { - public function run(): void + public function run($company = null): void { - $company = Company::query()->whereRaw('LOWER(search_code) = ?', ['ivplv2'])->first() - ?? Company::query()->first(); + $company = is_int($company) ? Company::query()->find($company) : $company; if ( ! $company) { return; diff --git a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php index 3734c072f..bd87e5c1a 100644 --- a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php +++ b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php @@ -46,8 +46,8 @@ public static function configure(Schema $schema): Schema TextInput::make('number') ->label(trans('ip.subscription_code')) - ->default(fn () => 'SUB-' . mb_strtoupper(mb_substr(uniqid(), -6))) - ->required(), + ->placeholder(trans('ip.subscription_code_auto')) + ->helperText(trans('ip.subscription_code_helper')), Select::make('status') ->label(trans('ip.subscription_status')) @@ -98,6 +98,11 @@ public static function configure(Schema $schema): Schema ->numeric() ->prefix('$') ->required(), + + TextInput::make('currency_code') + ->label(trans('ip.currency_code')) + ->default('USD') + ->maxLength(3), ]) ->columns(2), ]), @@ -175,6 +180,8 @@ public static function configure(Schema $schema): Schema TextInput::make('total') ->label(trans('ip.total')) ->numeric() + ->disabled() + ->dehydrated(false) ->placeholder(trans('ip.total_auto_calc')), ]), ]) diff --git a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php index 8965261f4..73bea4df1 100644 --- a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php +++ b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php @@ -63,7 +63,7 @@ public static function configure(Table $table): Table TextColumn::make('price') ->label(trans('ip.recurring_price')) - ->money('USD') + ->money(fn (Subscription $record) => $record->currency_code ?? 'USD') ->sortable(), TextColumn::make('current_period_ends_at') diff --git a/Modules/Subscriptions/Observers/SubscriptionItemObserver.php b/Modules/Subscriptions/Observers/SubscriptionItemObserver.php new file mode 100644 index 000000000..d9d179f08 --- /dev/null +++ b/Modules/Subscriptions/Observers/SubscriptionItemObserver.php @@ -0,0 +1,17 @@ +quantity * (float) $item->unit_price; + + $item->subtotal = $subtotal; + $item->total = $subtotal + (float) $item->tax; + } +} diff --git a/Modules/Subscriptions/Providers/SubscriptionsServiceProvider.php b/Modules/Subscriptions/Providers/SubscriptionsServiceProvider.php index fc1ec2260..4c94cdf28 100644 --- a/Modules/Subscriptions/Providers/SubscriptionsServiceProvider.php +++ b/Modules/Subscriptions/Providers/SubscriptionsServiceProvider.php @@ -3,6 +3,8 @@ namespace Modules\Subscriptions\Providers; use Illuminate\Support\ServiceProvider; +use Modules\Subscriptions\Models\SubscriptionItem; +use Modules\Subscriptions\Observers\SubscriptionItemObserver; use Nwidart\Modules\Traits\PathNamespace; class SubscriptionsServiceProvider extends ServiceProvider @@ -16,6 +18,8 @@ class SubscriptionsServiceProvider extends ServiceProvider public function boot(): void { $this->loadMigrationsFrom(module_path($this->name, 'Database/Migrations')); + + SubscriptionItem::observe(SubscriptionItemObserver::class); } public function register(): void {} diff --git a/Modules/Subscriptions/Services/SubscriptionService.php b/Modules/Subscriptions/Services/SubscriptionService.php index 991ea5e07..d0443869f 100644 --- a/Modules/Subscriptions/Services/SubscriptionService.php +++ b/Modules/Subscriptions/Services/SubscriptionService.php @@ -26,7 +26,7 @@ public function createSubscription(array $data): Subscription { return DB::transaction(function () use ($data) { $data['company_id'] ??= $this->getCompanyId(); - $data['number'] ??= 'SUB-' . mb_strtoupper(uniqid()); + $data['number'] ??= $this->generateUniqueNumber($data['company_id']); $startsAt = isset($data['starts_at']) ? Carbon::parse($data['starts_at']) : Carbon::now(); $data['starts_at'] = $startsAt; @@ -64,6 +64,21 @@ public function createSubscription(array $data): Subscription }); } + /** + * Generate a subscription number that is unique within the given company. + */ + private function generateUniqueNumber(?int $companyId): string + { + do { + $number = 'SUB-' . mb_strtoupper(bin2hex(random_bytes(4))); + } while (Subscription::withoutGlobalScopes() + ->where('company_id', $companyId) + ->where('number', $number) + ->exists()); + + return $number; + } + /** * Calculate period start and end dates based on interval configuration. */ @@ -72,7 +87,8 @@ public function calculateNextPeriodDates( string|IntervalUnit $intervalUnit = IntervalUnit::MONTH, int $intervalCount = 1, ?Carbon $from = null - ): array { + ): array + { $from = $from ? $from->copy() : Carbon::now(); $startsAt = $from->copy(); $endsAt = $from->copy(); @@ -219,8 +235,10 @@ public function enterGracePeriod(Subscription $subscription, int $days = 7): Sub */ public function processBillingCycle(Subscription $subscription): ?Invoice { - // Check if subscription should cancel at period end - if ($subscription->cancel_at_period_end) { + // Cancellation scheduled for period end only takes effect once the period has actually ended + if ($subscription->cancel_at_period_end + && $subscription->current_period_ends_at + && $subscription->current_period_ends_at->isPast()) { $this->cancelImmediately($subscription); return null; @@ -231,6 +249,13 @@ public function processBillingCycle(Subscription $subscription): ?Invoice } return DB::transaction(function () use ($subscription) { + /** @var Subscription $subscription */ + $subscription = Subscription::query()->whereKey($subscription->id)->lockForUpdate()->firstOrFail(); + + if ($subscription->status === SubscriptionStatus::CANCELED || $subscription->status === SubscriptionStatus::PAUSED) { + return null; + } + $userId = auth()->id() ?? \Modules\Core\Models\User::query()->whereHas('companies', fn ($q) => $q->where('companies.id', $subscription->company_id))->first()?->id ?? 1; @@ -239,7 +264,7 @@ public function processBillingCycle(Subscription $subscription): ?Invoice 'company_id' => $subscription->company_id, 'customer_id' => $subscription->customer_id, 'user_id' => $userId, - 'invoice_number' => 'INV-' . mb_strtoupper(mb_substr(uniqid(), -6)), + 'invoice_number' => 'INV-' . mb_strtoupper(bin2hex(random_bytes(4))), 'invoiced_at' => Carbon::now(), 'invoice_due_at' => Carbon::now()->addDays(14), 'invoice_status' => InvoiceStatus::SENT, @@ -250,7 +275,7 @@ public function processBillingCycle(Subscription $subscription): ?Invoice 'invoice_tax_total' => 0.0000, 'invoice_total' => $subscription->price, 'summary' => "Subscription Invoice for {$subscription->name} ({$subscription->number})", - 'url_key' => mb_strtolower(uniqid()), + 'url_key' => mb_strtolower(bin2hex(random_bytes(16))), ]); // Copy items to invoice diff --git a/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php b/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php index f2e2622e2..2d8f50119 100644 --- a/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php +++ b/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php @@ -35,7 +35,8 @@ public function it_lists_subscriptions(): void ->test(ListSubscriptions::class); /* Assert */ - $component->assertSuccessful(); + $component->assertSuccessful() + ->assertCanSeeTableRecords([$subscription]); } #[Test] @@ -243,4 +244,30 @@ public function it_processes_billing_cycle_and_generates_invoice(): void $this->assertNotNull($subscription->current_period_starts_at); $this->assertNotNull($subscription->current_period_ends_at); } + + #[Test] + #[Group('billing')] + public function it_keeps_billing_a_subscription_scheduled_to_cancel_until_its_period_ends(): void + { + /* Arrange */ + $subscription = Subscription::factory() + ->for($this->company) + ->create([ + 'status' => SubscriptionStatus::ACTIVE, + 'billing_interval' => BillingInterval::MONTHLY, + 'price' => 250.00, + 'cancel_at_period_end' => true, + 'canceled_at' => Carbon::now(), + 'current_period_ends_at' => Carbon::now()->addDays(10), + ]); + $service = app(SubscriptionService::class); + + /* Act */ + $invoice = $service->processBillingCycle($subscription); + $subscription->refresh(); + + /* Assert */ + $this->assertInstanceOf(Invoice::class, $invoice); + $this->assertEquals(SubscriptionStatus::ACTIVE, $subscription->status); + } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 956b7523c..7b655a5d8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -98,7 +98,7 @@ public function run(): void $this->callWith(PaymentsSeeder::class, $p + ['count' => $this->volumes['payments']]); - $this->call(SubscriptionSeeder::class); + $this->callWith(SubscriptionSeeder::class, $p); (new TaxRatesSeeder())->buildOne($company->id); diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index 492c0d62c..f3d2fa7f7 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -1316,6 +1316,8 @@ 'subscription_title_placeholder' => 'e.g. Enterprise Monthly Software License', 'subscription_client' => 'Client / Customer', 'subscription_code' => 'Subscription Code / Ref', + 'subscription_code_auto' => 'Auto-generated if left blank', + 'subscription_code_helper' => 'Leave blank to have a unique subscription number generated automatically.', 'subscription_status' => 'Subscription Status', 'billing_cycle_configuration' => 'Billing Cycle Configuration', 'billing_interval' => 'Billing Interval', @@ -1323,6 +1325,7 @@ 'custom_unit' => 'Custom Unit', 'custom_count' => 'Custom Count (Frequency)', 'recurring_price' => 'Recurring Price', + 'currency_code' => 'Currency', 'lifecycle_and_trial_dates' => 'Lifecycle & Trial Dates', 'start_date' => 'Start Date', 'expiration_date' => 'Expiration / End Date', From ea3936e12f70454e920722a2dda15b8a5dad05db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 18:14:30 +0000 Subject: [PATCH 07/15] Route subscription numbering through the Numbering scheme and scope items to company - Add NumberingType::SUBSCRIPTION (prefix SUB) and wire it into NumberingService so subscriptions use the same numbering scheme as invoices/quotes instead of a hardcoded "SUB-" prefix baked into the service and factory. - SubscriptionService::generateUniqueNumber now finds-or-creates the company's Subscription numbering scheme, locks it, and consumes the next formatted number from it (falling back to a collision check against existing subscription numbers). - SubscriptionFactory pulls its "SUB-" prefix from NumberingType::SUBSCRIPTION->prefix() instead of a literal string. - subscription_items gains a company_id column (mirroring invoice_items, which is denormalized the same way without a BelongsToCompany trait), populated by SubscriptionItemObserver from the parent subscription. - Translate every hardcoded NavigationGroup/NavigationItem label in CompanyPanelProvider (Customers, Quotes, Invoices, Expenses, Payments, Resources, Settings, Dashboard) via trans('ip.*') so the company panel navigation is no longer English-only. - Add tests covering numbering-scheme creation/prefix and sequential number generation, and assert subscription items carry company_id. --- Modules/Core/Enums/NumberingType.php | 60 ++++++++++--------- .../Core/Providers/CompanyPanelProvider.php | 18 +++--- Modules/Core/Services/NumberingService.php | 35 ++++++----- .../Factories/SubscriptionFactory.php | 3 +- ...8_13_000001_create_subscriptions_table.php | 1 + .../Observers/SubscriptionItemObserver.php | 9 +++ .../Services/SubscriptionService.php | 48 ++++++++++++--- .../Tests/Feature/SubscriptionTest.php | 53 ++++++++++++++++ resources/lang/en/ip.php | 10 ++++ 9 files changed, 174 insertions(+), 63 deletions(-) diff --git a/Modules/Core/Enums/NumberingType.php b/Modules/Core/Enums/NumberingType.php index 196641a6b..f35db3acf 100644 --- a/Modules/Core/Enums/NumberingType.php +++ b/Modules/Core/Enums/NumberingType.php @@ -6,13 +6,14 @@ enum NumberingType: string implements LabeledEnum { - case CUSTOMER = 'Customer'; - case EXPENSE = 'Expense'; - case INVOICE = 'Invoice'; - case PAYMENT = 'Payment'; - case PROJECT = 'Project'; - case QUOTE = 'Quote'; - case TASK = 'Task'; + case CUSTOMER = 'Customer'; + case EXPENSE = 'Expense'; + case INVOICE = 'Invoice'; + case PAYMENT = 'Payment'; + case PROJECT = 'Project'; + case QUOTE = 'Quote'; + case SUBSCRIPTION = 'Subscription'; + case TASK = 'Task'; public static function values(): array { @@ -22,39 +23,42 @@ public static function values(): array public function label(): string { return match ($this) { - self::CUSTOMER => trans('ip.customer'), - self::EXPENSE => trans('ip.expense'), - self::INVOICE => trans('ip.invoice'), - self::PAYMENT => trans('ip.payment'), - self::PROJECT => trans('ip.project'), - self::QUOTE => trans('ip.quote'), - self::TASK => trans('ip.task'), + self::CUSTOMER => trans('ip.customer'), + self::EXPENSE => trans('ip.expense'), + self::INVOICE => trans('ip.invoice'), + self::PAYMENT => trans('ip.payment'), + self::PROJECT => trans('ip.project'), + self::QUOTE => trans('ip.quote'), + self::SUBSCRIPTION => trans('ip.subscription'), + self::TASK => trans('ip.task'), }; } public function color(): string { return match ($this) { - self::CUSTOMER => 'primary', - self::EXPENSE => 'warning', - self::INVOICE => 'success', - self::PAYMENT => 'info', - self::PROJECT => 'secondary', - self::QUOTE => 'purple', - self::TASK => 'gray', + self::CUSTOMER => 'primary', + self::EXPENSE => 'warning', + self::INVOICE => 'success', + self::PAYMENT => 'info', + self::PROJECT => 'secondary', + self::QUOTE => 'purple', + self::SUBSCRIPTION => 'teal', + self::TASK => 'gray', }; } public function prefix(): string { return match ($this) { - self::CUSTOMER => 'CUS', - self::EXPENSE => 'EXP', - self::INVOICE => 'INV', - self::PAYMENT => 'PAY', - self::PROJECT => 'PRJ', - self::QUOTE => 'QUO', - self::TASK => 'TSK', + self::CUSTOMER => 'CUS', + self::EXPENSE => 'EXP', + self::INVOICE => 'INV', + self::PAYMENT => 'PAY', + self::PROJECT => 'PRJ', + self::QUOTE => 'QUO', + self::SUBSCRIPTION => 'SUB', + self::TASK => 'TSK', }; } } diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php index e5d32211b..0f8a364df 100644 --- a/Modules/Core/Providers/CompanyPanelProvider.php +++ b/Modules/Core/Providers/CompanyPanelProvider.php @@ -202,49 +202,49 @@ public function panel(Panel $panel): Panel return $builder ->items([ - NavigationItem::make('Dashboard') + NavigationItem::make(trans('ip.dashboard')) ->icon('heroicon-o-home') ->url(route('filament.company.pages.dashboard', ['tenant' => $tenant])) ->isActiveWhen(fn (): bool => request()->routeIs('filament.company.pages.dashboard')), ]) ->groups([ - NavigationGroup::make('Customers') + NavigationGroup::make(trans('ip.nav_group_customers')) //->icon('heroicon-o-user-group') ->items([ ...self::withQuickCreate(RelationResource::class), ]), - NavigationGroup::make('Quotes') + NavigationGroup::make(trans('ip.nav_group_quotes')) //->icon('heroicon-o-document-text') ->items([ ...self::withQuickCreate(QuoteResource::class), ]), - NavigationGroup::make('Invoices') + NavigationGroup::make(trans('ip.nav_group_invoices')) //->icon('heroicon-o-banknotes') ->items([ ...self::withQuickCreate(InvoiceResource::class), ]), - NavigationGroup::make('Subscriptions') + NavigationGroup::make(trans('ip.subscriptions')) ->items([ ...self::withQuickCreate(SubscriptionResource::class), ]), - NavigationGroup::make('Expenses') + NavigationGroup::make(trans('ip.nav_group_expenses')) //->icon('heroicon-o-banknotes') ->items([ ...self::withQuickCreate(ExpenseResource::class), ...(ExpenseCategoryResource::shouldRegisterNavigation() ? ExpenseCategoryResource::getNavigationItems() : []), ]), - NavigationGroup::make('Payments') + NavigationGroup::make(trans('ip.nav_group_payments')) //->icon('heroicon-o-currency-dollar') ->items([ ...self::withQuickCreate(PaymentResource::class), ]), - NavigationGroup::make('Resources') + NavigationGroup::make(trans('ip.nav_group_resources')) //->icon('heroicon-o-archive-box') ->items([ ...self::withQuickCreate(ProductResource::class), @@ -255,7 +255,7 @@ public function panel(Panel $panel): Panel ...TaskResource::getNavigationItems(), ]), - NavigationGroup::make('Settings') + NavigationGroup::make(trans('ip.nav_group_settings')) //->icon('heroicon-o-cog-6-tooth') ->items([ ...NoteTemplateResource::getNavigationItems(), diff --git a/Modules/Core/Services/NumberingService.php b/Modules/Core/Services/NumberingService.php index 970f223a5..c549e05c5 100644 --- a/Modules/Core/Services/NumberingService.php +++ b/Modules/Core/Services/NumberingService.php @@ -16,6 +16,7 @@ use Modules\Projects\Models\Project; use Modules\Projects\Models\Task; use Modules\Quotes\Models\Quote; +use Modules\Subscriptions\Models\Subscription; use Throwable; class NumberingService @@ -335,14 +336,15 @@ protected function countAppliedRecords(Numbering $numbering): int protected function getModelClassForType(mixed $type): ?string { return match ($type->value ?? $type) { - 'Customer' => Customer::class, - 'Expense' => Expense::class, - 'Invoice' => Invoice::class, - 'Payment' => Payment::class, - 'Project' => Project::class, - 'Quote' => Quote::class, - 'Task' => Task::class, - default => null, + 'Customer' => Customer::class, + 'Expense' => Expense::class, + 'Invoice' => Invoice::class, + 'Payment' => Payment::class, + 'Project' => Project::class, + 'Quote' => Quote::class, + 'Subscription' => Subscription::class, + 'Task' => Task::class, + default => null, }; } @@ -356,14 +358,15 @@ protected function getModelClassForType(mixed $type): ?string protected function getNumberFieldForType(mixed $type): ?string { return match ($type->value ?? $type) { - 'Customer' => 'customer_number', - 'Expense' => 'expense_number', - 'Invoice' => 'invoice_number', - 'Payment' => 'payment_number', - 'Project' => 'project_number', - 'Quote' => 'quote_number', - 'Task' => 'task_number', - default => null, + 'Customer' => 'customer_number', + 'Expense' => 'expense_number', + 'Invoice' => 'invoice_number', + 'Payment' => 'payment_number', + 'Project' => 'project_number', + 'Quote' => 'quote_number', + 'Subscription' => 'number', + 'Task' => 'task_number', + default => null, }; } diff --git a/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php b/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php index bf0c1d413..86204c7c9 100644 --- a/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php +++ b/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php @@ -4,6 +4,7 @@ use Modules\Clients\Models\Relation; use Modules\Core\Database\Factories\AbstractFactory; +use Modules\Core\Enums\NumberingType; use Modules\Core\Models\Company; use Modules\Subscriptions\Enums\BillingInterval; use Modules\Subscriptions\Enums\IntervalUnit; @@ -24,7 +25,7 @@ public function definition(): array return [ 'company_id' => $companyId, 'customer_id' => $this->resolveCustomerId($company, $companyId), - 'number' => 'SUB-' . $this->faker->unique()->numerify('#####'), + 'number' => NumberingType::SUBSCRIPTION->prefix() . '-' . $this->faker->unique()->numerify('#####'), 'name' => $this->faker->words(3, true) . ' Subscription', 'status' => SubscriptionStatus::ACTIVE, 'billing_interval' => BillingInterval::MONTHLY, diff --git a/Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php b/Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php index 78e7ff7c3..4f6e1fcb7 100644 --- a/Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php +++ b/Modules/Subscriptions/Database/Migrations/2026_08_13_000001_create_subscriptions_table.php @@ -48,6 +48,7 @@ public function up(): void Schema::create('subscription_items', function (Blueprint $table) { $table->id(); + $table->foreignId('company_id')->constrained('companies')->cascadeOnDelete(); $table->foreignId('subscription_id')->constrained('subscriptions')->cascadeOnDelete(); $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); $table->string('name'); diff --git a/Modules/Subscriptions/Observers/SubscriptionItemObserver.php b/Modules/Subscriptions/Observers/SubscriptionItemObserver.php index d9d179f08..f57373f23 100644 --- a/Modules/Subscriptions/Observers/SubscriptionItemObserver.php +++ b/Modules/Subscriptions/Observers/SubscriptionItemObserver.php @@ -7,6 +7,15 @@ class SubscriptionItemObserver extends AbstractObserver { + public function creating(SubscriptionItem $item): void + { + if (empty($item->company_id)) { + $item->company_id = $item->subscription?->company_id; + } + + parent::creating($item); + } + public function saving(SubscriptionItem $item): void { $subtotal = (float) $item->quantity * (float) $item->unit_price; diff --git a/Modules/Subscriptions/Services/SubscriptionService.php b/Modules/Subscriptions/Services/SubscriptionService.php index d0443869f..1da5fe71d 100644 --- a/Modules/Subscriptions/Services/SubscriptionService.php +++ b/Modules/Subscriptions/Services/SubscriptionService.php @@ -4,6 +4,8 @@ use Carbon\Carbon; use Illuminate\Support\Facades\DB; +use Modules\Core\Enums\NumberingType; +use Modules\Core\Models\Numbering; use Modules\Core\Services\BaseService; use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Models\Invoice; @@ -65,18 +67,46 @@ public function createSubscription(array $data): Subscription } /** - * Generate a subscription number that is unique within the given company. + * Generate the next subscription number from the company's Subscription + * numbering scheme (the same Numbering system used for invoices/quotes, + * formerly known as "invoice groups"), creating a default scheme on + * first use. */ private function generateUniqueNumber(?int $companyId): string { - do { - $number = 'SUB-' . mb_strtoupper(bin2hex(random_bytes(4))); - } while (Subscription::withoutGlobalScopes() - ->where('company_id', $companyId) - ->where('number', $number) - ->exists()); - - return $number; + return DB::transaction(function () use ($companyId) { + /** @var Numbering $numbering */ + $numbering = Numbering::query() + ->where('company_id', $companyId) + ->where('type', NumberingType::SUBSCRIPTION->value) + ->lockForUpdate() + ->first(); + + if ( ! $numbering) { + $numbering = Numbering::query()->create([ + 'company_id' => $companyId, + 'type' => NumberingType::SUBSCRIPTION->value, + 'name' => NumberingType::SUBSCRIPTION->label(), + 'next_id' => 1, + 'left_pad' => 4, + 'format' => '{{prefix}}-{{number}}', + 'prefix' => NumberingType::SUBSCRIPTION->prefix(), + 'last_id' => 0, + ]); + } + + $prefix = $numbering->resolvedPrefix(); + + do { + $number = $numbering->applyFormat($numbering->next_id, $prefix); + $numbering->increment('next_id'); + } while (Subscription::withoutGlobalScopes() + ->where('company_id', $companyId) + ->where('number', $number) + ->exists()); + + return $number; + }); } /** diff --git a/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php b/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php index 2d8f50119..d965579b0 100644 --- a/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php +++ b/Modules/Subscriptions/Tests/Feature/SubscriptionTest.php @@ -5,6 +5,8 @@ use Carbon\Carbon; use Livewire\Livewire; use Modules\Clients\Models\Relation; +use Modules\Core\Enums\NumberingType; +use Modules\Core\Models\Numbering; use Modules\Core\Tests\AbstractCompanyPanelTestCase; use Modules\Invoices\Models\Invoice; use Modules\Subscriptions\Enums\BillingInterval; @@ -74,11 +76,62 @@ public function it_creates_subscription_with_monthly_interval(): void $this->assertDatabaseHas('subscription_items', [ 'subscription_id' => $subscription->id, + 'company_id' => $this->company->id, 'name' => 'Pro Seat License', 'unit_price' => 199.00, ]); } + #[Test] + #[Group('crud')] + public function it_generates_a_subscription_number_from_the_subscription_numbering_scheme(): void + { + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + $service = app(SubscriptionService::class); + + /* Act */ + $subscription = $service->createSubscription([ + 'company_id' => $this->company->id, + 'customer_id' => $customer->id, + 'name' => 'Auto-Numbered Subscription', + ]); + + /* Assert */ + $numbering = Numbering::query() + ->where('company_id', $this->company->id) + ->where('type', NumberingType::SUBSCRIPTION->value) + ->first(); + + $this->assertNotNull($numbering); + $this->assertSame(NumberingType::SUBSCRIPTION->prefix(), $numbering->resolvedPrefix()); + $this->assertStringStartsWith($numbering->resolvedPrefix() . '-', $subscription->number); + } + + #[Test] + #[Group('crud')] + public function it_increments_the_numbering_scheme_for_each_generated_subscription_number(): void + { + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + $service = app(SubscriptionService::class); + + /* Act */ + $first = $service->createSubscription([ + 'company_id' => $this->company->id, + 'customer_id' => $customer->id, + 'name' => 'First Auto-Numbered Subscription', + ]); + $second = $service->createSubscription([ + 'company_id' => $this->company->id, + 'customer_id' => $customer->id, + 'name' => 'Second Auto-Numbered Subscription', + ]); + + /* Assert */ + $this->assertNotSame($first->number, $second->number); + } + #[Test] #[Group('crud')] public function it_creates_subscription_with_custom_billing_cycle(): void diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index f3d2fa7f7..e970b77ff 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -1308,6 +1308,16 @@ 'default_quote_tax_rate' => 'Default Quote Tax Rate', #endregion + #region NAVIGATION GROUPS + 'nav_group_customers' => 'Customers', + 'nav_group_quotes' => 'Quotes', + 'nav_group_invoices' => 'Invoices', + 'nav_group_expenses' => 'Expenses', + 'nav_group_payments' => 'Payments', + 'nav_group_resources' => 'Resources', + 'nav_group_settings' => 'Settings', + #endregion + #region SUBSCRIPTIONS MODULE 'subscription' => 'Subscription', 'subscriptions' => 'Subscriptions', From 7cceba0f27df57005b026715fbf9ac4bb9bfa7cd Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sat, 15 Aug 2026 19:25:16 +0200 Subject: [PATCH 08/15] chore: remove accidentally committed infrastructure files --- .claude/skills/abstract-seeder/SKILL.md | 64 ----- .../SKILL.md | 62 ----- .../autonomous-coding-workflow/SKILL.md | 137 ---------- .../skills/ci-schema-invariant-gate/SKILL.md | 72 ----- .claude/skills/dto-contract/SKILL.md | 174 ------------ .../skills/factory-contract-system/SKILL.md | 67 ----- .../skills/filament-multi-tenancy/SKILL.md | 150 ----------- .claude/skills/filament-panel-setup/SKILL.md | 107 -------- .../skills/filament-resource-pages/SKILL.md | 196 -------------- .../skills/filament-resource-testing/SKILL.md | 121 --------- .claude/skills/github-actions-php/SKILL.md | 62 ----- .claude/skills/laravel-modules/SKILL.md | 209 --------------- .claude/skills/non-standard-pks/SKILL.md | 81 ------ .claude/skills/pest-control/SKILL.md | 249 ------------------ .../skills/safe-refactoring-rules/SKILL.md | 116 -------- .claude/skills/security-review/SKILL.md | 54 ---- .../SKILL.md | 144 ---------- .../SKILL.md | 189 ------------- .claude/skills/service-layer/SKILL.md | 81 ------ .claude/skills/spatie-roles/SKILL.md | 149 ----------- .claude/skills/sync-stale-branches/SKILL.md | 133 ---------- .../skills/tailwindcss-development/SKILL.md | 129 --------- .claude/skills/tenant-middleware/SKILL.md | 96 ------- .claude/skills/test-honesty/SKILL.md | 77 ------ .claude/skills/user-auth-fields/SKILL.md | 115 -------- .github/DOCKER.md | 99 ------- docker-resources/apache/Dockerfile | 14 - .../apache/config/invoiceplane-vhost.conf | 21 -- docker-resources/node/scripts/entrypoint.sh | 18 -- docker-resources/php-cli/Dockerfile | 67 ----- docker-resources/php-fpm/Dockerfile | 58 ---- 31 files changed, 3311 deletions(-) delete mode 100644 .claude/skills/abstract-seeder/SKILL.md delete mode 100644 .claude/skills/application-architecture-standard/SKILL.md delete mode 100644 .claude/skills/autonomous-coding-workflow/SKILL.md delete mode 100644 .claude/skills/ci-schema-invariant-gate/SKILL.md delete mode 100644 .claude/skills/dto-contract/SKILL.md delete mode 100644 .claude/skills/factory-contract-system/SKILL.md delete mode 100644 .claude/skills/filament-multi-tenancy/SKILL.md delete mode 100644 .claude/skills/filament-panel-setup/SKILL.md delete mode 100644 .claude/skills/filament-resource-pages/SKILL.md delete mode 100644 .claude/skills/filament-resource-testing/SKILL.md delete mode 100644 .claude/skills/github-actions-php/SKILL.md delete mode 100644 .claude/skills/laravel-modules/SKILL.md delete mode 100644 .claude/skills/non-standard-pks/SKILL.md delete mode 100644 .claude/skills/pest-control/SKILL.md delete mode 100644 .claude/skills/safe-refactoring-rules/SKILL.md delete mode 100644 .claude/skills/security-review/SKILL.md delete mode 100644 .claude/skills/senior-laravel-developer-code-reviewer/SKILL.md delete mode 100644 .claude/skills/senior-laravel-developer-phpunit-interpreter/SKILL.md delete mode 100644 .claude/skills/service-layer/SKILL.md delete mode 100644 .claude/skills/spatie-roles/SKILL.md delete mode 100644 .claude/skills/sync-stale-branches/SKILL.md delete mode 100644 .claude/skills/tailwindcss-development/SKILL.md delete mode 100644 .claude/skills/tenant-middleware/SKILL.md delete mode 100644 .claude/skills/test-honesty/SKILL.md delete mode 100644 .claude/skills/user-auth-fields/SKILL.md delete mode 100644 .github/DOCKER.md delete mode 100644 docker-resources/apache/Dockerfile delete mode 100644 docker-resources/apache/config/invoiceplane-vhost.conf delete mode 100644 docker-resources/node/scripts/entrypoint.sh delete mode 100644 docker-resources/php-cli/Dockerfile delete mode 100644 docker-resources/php-fpm/Dockerfile diff --git a/.claude/skills/abstract-seeder/SKILL.md b/.claude/skills/abstract-seeder/SKILL.md deleted file mode 100644 index aff6abefa..000000000 --- a/.claude/skills/abstract-seeder/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: abstract-seeder -description: Provides structured seeding workflow for module data initialization ---- - -# Abstract Seeder - -## Purpose - -Provides a structured way to seed database data per module. - ---- - -## Scope - -Seeders are responsible for: - -- creating initial dataset for a company -- using factories to generate valid records -- orchestrating dependency order between models - ---- - -## Ownership Boundary - -Seeders MUST NOT: - -- define validation rules -- define factory structure -- enforce schema constraints -- contain business logic - ---- - -## Factory Dependency Rule - -Seeders MUST rely on factories for object creation. - -Factories are the source of truth for valid model state. - ---- - -## Dependency Resolution - -Seeders MAY resolve dependencies using helper methods: - -- findOrCreateClient -- findOrCreateProject -- findOrCreateUser - -These helpers are convenience utilities, not business logic. - ---- - -## Execution Hooks - -- beforeSeed(): setup state -- afterSeed(): cleanup or summary - ---- - -## Principle - -Seeders assemble data. They do not define data correctness. diff --git a/.claude/skills/application-architecture-standard/SKILL.md b/.claude/skills/application-architecture-standard/SKILL.md deleted file mode 100644 index a0f6e47c5..000000000 --- a/.claude/skills/application-architecture-standard/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: application-architecture-standard -description: Defines structural rules for Laravel architecture, layering, and code organization ---- - -# Purpose - -Single source of truth for application structure and architectural boundaries. - ---- - -# 1. Layering Rules - -## Presentation Layer -- Controllers -- Filament Pages -- Form Requests (validation only) - -No business logic allowed. - -## Application Layer -- Services -- DTOs -- Transformers - -Holds all business logic orchestration. - -## Domain Layer -- Models -Represents state and invariants only. - -## Infrastructure Layer -- API clients -- External services - -Must be replaceable and contain no business logic. - ---- - -## 2. Service Rules - -- Business logic lives in services. -- Services must remain framework-agnostic except for Laravel infrastructure (Eloquent, DB transactions, HTTP client, logging). -- No Filament classes or UI concerns inside services. -- DTOs are used for structured data transfer where transformation, validation, or reuse is required. -- They are optional for internal service calls when validated arrays are sufficient. - ---- - -# 3. Dependency Rules - -- Constructor injection only -- No service locators -- No hidden dependencies - ---- - -# 4. Architecture Integrity - -- No cross-layer leakage -- Strict separation of concerns -- Refactoring must preserve behavior diff --git a/.claude/skills/autonomous-coding-workflow/SKILL.md b/.claude/skills/autonomous-coding-workflow/SKILL.md deleted file mode 100644 index 665630b84..000000000 --- a/.claude/skills/autonomous-coding-workflow/SKILL.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: autonomous-coding-workflow -description: Governs safe, incremental, repository-wide development workflow with continuous validation gates ---- - -# Autonomous Coding Workflow - -## Goal - -Perform repository-wide modifications safely, incrementally, and with continuous validation. - ---- - -## 1. Instruction Precedence - -Before doing anything: - -- Check for repository-level instruction files: - - `.github/copilot-instructions.md` - - `AGENTS.md` - - `.junie/*.md` - - `CLAUDE.md` - -If they exist: -- Treat them as higher precedence for architecture and conventions. -- Avoid duplicating rules already defined there. - ---- - -## 2. Preparation - -Before modifying code: - -1. Read existing implementation. -2. Understand current behavior. -3. Identify existing abstractions and reuse them: - - Traits - - Base test cases - - Base resources - - Base seeders - - Services - - DTOs - - Transformers -4. Search explicitly for duplication before introducing new abstractions. -5. Preserve existing architectural patterns. - -Do not modify code that has not been understood. - ---- - -## 3. Refactoring Heuristics - -Apply only when relevant: - -- If repeated patterns exist across many test classes, models, or resources, evaluate abstraction opportunities. -- Prefer centralizing duplicated logic into: - - Traits - - Base classes - - Services -- Do not introduce abstraction unless duplication is confirmed. - ---- - -## 4. Incremental Development - -Work in small, verifiable steps. - -After each change: - -1. Verify syntax: - ```bash - php -l - ``` -2. Run targeted tests. -3. Fix failures immediately. -4. Run code style checks: - ```bash - vendor/bin/pint --dirty --format agent - ``` -5. Continue only if repository is clean. - ---- - -## 5. Validation Gates - -Never proceed if any of the following fail: - -- PHPUnit tests -- Static analysis -- PHP syntax check (php -l) -- Code style (Pint) - -Before completion, additionally ensure: - -- migrate:fresh --seed passes -- smoke tests pass -- targeted tests pass -- full suite passes (unless explicitly excluded) - ---- - -## 6. Module Completion - -After completing a module: - -1. Run targeted test suite. -2. Run `php -l`. -3. Run Pint. -4. Confirm no unintended changes. -5. Commit with clear module description. - -Do not start the next module until the current one is fully stable. - ---- - -## 7. Uncertainty Handling - -If behavior is unclear: - -- Stop immediately. -- Describe ambiguity. -- Request clarification. -- Do not infer or guess missing business rules. - ---- - -## 8. Success Criteria - -The task is complete only when: - -- Behavior is preserved. -- No duplicate logic introduced. -- Changes are idempotent. -- All tests pass. -- Full suite passes. -- Formatting is clean. -- No unintended architectural drift occurred. diff --git a/.claude/skills/ci-schema-invariant-gate/SKILL.md b/.claude/skills/ci-schema-invariant-gate/SKILL.md deleted file mode 100644 index 51dcc7164..000000000 --- a/.claude/skills/ci-schema-invariant-gate/SKILL.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: ci-schema-invariant-gate -description: Ensures correct execution order of migrations, seeders, and tests in CI ---- - -# CI Schema Gate - -## Purpose - -Enforces correct execution order of database setup and test execution in CI. - ---- - -# 1. Execution Order (Strict) - -CI MUST run in this order: - -```bash -php artisan migrate:fresh --seed -php artisan test -``` - -No deviations allowed. - ---- - -# 2. Responsibility - -This skill ONLY controls: - -- execution sequencing -- CI pipeline ordering -- ensuring seed runs before tests - -It does NOT validate: -- schema correctness -- factory correctness -- business logic correctness - -These are handled by other skills. - ---- - -# 3. Failure Behavior - -If CI fails: - -- migrations failing → schema issue (handled by test-honesty) -- seed failing → factory/data issue (handled by test-honesty) -- tests failing → behavior issue (handled by test layer) - -CI does NOT interpret or classify failures. - ---- - -# 4. Determinism Requirement - -Test execution MUST always run on a fresh database state created by: - -```bash -migrate:fresh --seed -``` - -No cached or partial state is allowed. - ---- - -# 5. Core Principle - -CI defines execution order only. - -It does not define correctness of the system. diff --git a/.claude/skills/dto-contract/SKILL.md b/.claude/skills/dto-contract/SKILL.md deleted file mode 100644 index 3ce1b87e8..000000000 --- a/.claude/skills/dto-contract/SKILL.md +++ /dev/null @@ -1,174 +0,0 @@ ---- -name: dto-contract -description: Defines DTO structure, lifecycle, and transformation rules across the application -license: MIT -metadata: - author: project ---- - -# DTO Contracts - -DTOs define structured, transport-safe data contracts used between layers of the application. - -They exist to replace unstructured arrays when data shape matters, is reused, or must remain consistent across boundaries. - ---- - -# 1. Responsibility - -DTOs MUST: - -- represent structured application data -- act as transport carriers between layers -- be filled by Transformers -- avoid business logic -- avoid persistence logic - -DTOs MUST NOT: - -- contain ORM logic -- contain validation rules -- contain side effects -- depend on framework components (Filament, Request, etc.) - ---- - -# 2. Structure - -DTOs are simple POPOs with fluent getters and setters. - -Example: - -```php -class InvoiceDto -{ - private int $invoiceId; - private int $companyId; - private float $amount; - - public function getInvoiceId(): int - { - return $this->invoiceId; - } - - public function setInvoiceId(int $invoiceId): self - { - $this->invoiceId = $invoiceId; - return $this; - } - - public function getCompanyId(): int - { - return $this->companyId; - } - - public function setCompanyId(int $companyId): self - { - $this->companyId = $companyId; - return $this; - } - - public function getAmount(): float - { - return $this->amount; - } - - public function setAmount(float $amount): self - { - $this->amount = $amount; - return $this; - } -} -``` - ---- - -# 3. Creation Rule - -DTOs MUST be created via Transformers. - -```php -$dto = InvoiceTransformer::fromModel($invoice); -``` - -or - -```php -$dto = InvoiceTransformer::fromArray($data); -``` - -DTOs MUST NOT be manually assembled inside services unless trivial and explicitly justified. - ---- - -# 4. Transformer Dependency Rule - -Transformers are the ONLY layer allowed to construct DTOs. - -DTOs MUST NOT depend on Transformers. - -Direction is strictly: - -``` -Model / Array → Transformer → DTO → Service -``` - ---- - -# 5. When DTOs are Required - -Use DTOs when: - -- data is shared across multiple services -- structure must remain stable across changes -- transformation logic exists (model → structured output) -- array shape would otherwise be ambiguous or inconsistent - ---- - -# 6. When DTOs are NOT Required - -DTOs MAY be skipped when: - -- data is short-lived within a single method -- input comes from trusted UI layer (Filament forms) -- structure is trivial and not reused elsewhere - ---- - -# 7. Core Principle - -DTOs are **explicit data contracts**, not business logic containers. - -## IDE Hints (Optional) - -DTOs MAY include region markers to improve IDE navigation (e.g. PhpStorm folding). - -These are purely cosmetic and MUST NOT affect runtime behavior or architecture decisions. - -Example: - -```php -class InvoiceDto -{ - #region Properties - private int $invoiceId; - private int $companyId; - private float $amount; - #endregion - - #region Getters - public function getInvoiceId(): int { ... } - public function getCompanyId(): int { ... } - public function getAmount(): float { ... } - #endregion - - #region Setters - public function setInvoiceId(int $invoiceId): self { ... } - public function setCompanyId(int $companyId): self { ... } - public function setAmount(float $amount): self { ... } - #endregion -} -``` - -They exist to stabilize data shape across the system, not to introduce unnecessary abstraction. diff --git a/.claude/skills/factory-contract-system/SKILL.md b/.claude/skills/factory-contract-system/SKILL.md deleted file mode 100644 index c14b8cdd2..000000000 --- a/.claude/skills/factory-contract-system/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: factory-contract-system -description: Ensures factories generate valid model instances aligned with database schema constraints ---- - -# Factory Contract System - -## Purpose - -Ensures factories produce valid database-ready model instances. - ---- - -## Scope - -Factories MUST: - -- satisfy all NOT NULL columns -- reflect migration constraints -- produce valid default state for persistence - ---- - -## Ownership Boundary - -Factories do NOT: - -- enforce business rules -- define validation rules -- replace service-layer creation logic -- define seeder logic - ---- - -## Schema Alignment Rule - -If a migration introduces a NOT NULL column: - -- factory MUST be updated immediately -- omission is considered invalid state - ---- - -## Minimum Valid State - -Each factory represents the smallest valid persisted entity. - -Not random data. -Not business scenarios. -Only valid schema state. - ---- - -## Service Alignment - -Factories SHOULD align with service-layer expectations but do NOT depend on it. - -Service layer = behavior -Factory = valid structure - ---- - -## Seeder Rule - -Seeders depend on factories. - -Factories MUST NOT depend on seeders. diff --git a/.claude/skills/filament-multi-tenancy/SKILL.md b/.claude/skills/filament-multi-tenancy/SKILL.md deleted file mode 100644 index 290bb29df..000000000 --- a/.claude/skills/filament-multi-tenancy/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: filament-multi-tenancy -description: "Handles Filament multi-tenancy: tenant scoping, TenantAware trait, observer behaviour, isScopedToTenant, and tenant switching. Activates when adding tenant-aware models, fixing company_id scoping, working with Filament::getTenant, debugging tenant isolation, or when the user mentions company scope, tenant, multi-tenancy, or company_id." -license: MIT -metadata: - author: project ---- - -# Filament Multi-Tenancy - -The tenant model is `Company`. Every per-company record carries `company_id`. - -## TenantAware Trait - -Models that belong to a company use the `TenantAware` trait: - -```php -use Modules\Core\Traits\TenantAware; - -class Invoice extends Model -{ - use TenantAware; -} -``` - -The trait registers a `creating` observer that sets `company_id` from -`Filament::getTenant()` **only when `company_id` is empty**: - -```php -static::creating(function ($model) { - if (empty($model->company_id)) { - $tenant = Filament::getTenant(); - if ($tenant) { - $model->company_id = $tenant->id; - } - } -}); -``` - -## BaseResource Automatic Filtering - -All module resources extend `BaseResource`, which scopes the Eloquent query to -the current tenant and injects `company_id` on create: - -```php -// Modules/core/src/Filament/Resources/BaseResource.php -public static function getEloquentQuery(): Builder -{ - return parent::getEloquentQuery() - ->when(Filament::getTenant(), fn ($q, $t) => $q->where('company_id', $t->id)); -} -``` - -Do NOT add manual `company_id` filtering in resources that extend `BaseResource` — -it is already handled. - -## The Company Resource Exception - -`Company` IS the tenant. It must NOT be scoped to itself: - -```php -class CompanyResource extends Resource -{ - protected static bool $isScopedToTenant = false; - protected static ?string $tenantOwnershipRelationshipName = null; -} -``` - -Any model that should NOT be tenant-scoped (global settings, email templates, etc.) -also sets `$isScopedToTenant = false`. - -## observeTenancyModelCreation Trap - -Filament's `observeTenancyModelCreation` walks every `BelongsTo` relationship on a -model and calls `->associate($tenant)` when creating. This means: - -- If a model has a `BelongsTo` pointing to `Company` (even indirectly), Filament - will set that FK to the current tenant's id. -- A self-referential `BelongsTo` on the Company model itself will cause - `UNIQUE constraint failed: companies.id` because Filament sets `id = currentTenant->id` - on every new Company. - -**Fix:** Remove bogus self-referential relationships and set `$isScopedToTenant = false` -on the offending resource. - -## Tenant Switching in Tests - -When a test creates records for multiple tenants, switch the active tenant before -creating each set — otherwise `TenantAware` assigns all records to the first tenant: - -```php -$companyA = $this->company; // already set in setUp -$companyB = Company::factory()->create(); - -// Create companyA records (tenant already set to companyA) -$invoiceA = Invoice::factory()->create(['company_id' => $companyA->id, ...]); - -// Switch tenant before creating companyB records -Filament::setTenant($companyB, isQuiet: true); -$invoiceB = Invoice::factory()->create(['company_id' => $companyB->id, ...]); - -// Restore original tenant -Filament::setTenant($companyA, isQuiet: true); -``` - -## Tenant Middleware Stack - -See the `tenant-middleware` skill for the full middleware chain. In short: three -persistent middlewares run on every company panel request in this order: -`SetTenantFromQueryString` → `ConfigureTenant` → `EnsureUserCanAccessCompany`. - -## Tenant in Tests Setup - -```php -protected function setUp(): void -{ - parent::setUp(); - Filament::setCurrentPanel(Filament::getPanel('company')); - Filament::bootCurrentPanel(); - - $this->company = Company::factory()->create(); - Filament::setTenant($this->company, isQuiet: true); - - $this->user = User::factory()->create(); - $this->user->companies()->syncWithoutDetaching([$this->company->id]); -} -``` - -## Services - -Services must never assign company_id themselves when operating inside the -Filament company panel. - -company_id is supplied by: - -- TenantAware -- BaseResource -- explicit caller input - -Services should only normalize or validate incoming values. - -Hardcoding tenant assignment inside services creates hidden coupling. - - -## Fix-One-Fix-All - -If one tenant-aware resource requires adjustment, -review every tenant-aware resource for the same pattern. - -Tenant scoping inconsistencies are data isolation defects. diff --git a/.claude/skills/filament-panel-setup/SKILL.md b/.claude/skills/filament-panel-setup/SKILL.md deleted file mode 100644 index 7b35cf62c..000000000 --- a/.claude/skills/filament-panel-setup/SKILL.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: filament-panel-setup -description: "Configures Filament panel providers. Activates when adding a new panel, registering module resources in a panel, configuring tenant middleware, adjusting auth or theme settings, or when the user mentions PanelProvider, viteTheme, discoverResources, or panel configuration." -license: MIT -metadata: - author: project ---- - -# Filament Panel Setup - -This app has three panels: - -| Panel | Provider | Id | Default? | Tenant | Access | -|-------|----------|----|----------|--------|--------| -| Company | `CompanyPanelProvider` | `company` | Yes (root) | `Company::class` | `client_admin`, `client` | -| Admin | `AdminPanelProvider` | `admin` | No | None | `super_admin`, `admin`, `assist` | -| User | `UserPanelProvider` | `user` | No | None | minimal, future use | - -All three providers live at `Modules/Core/Providers/`. - -## Registering Module Resources - -Add a `->discoverResources()` call per module in `CompanyPanelProvider`: - -```php -->discoverResources( - in: base_path('Modules/mymodule/src/Filament/Resources'), - for: 'Modules\\Mymodule\\Filament\\Resources' -) -``` - -The `in` path is a filesystem path, `for` is the PHP namespace prefix. Both must -match the module's actual directory and namespace exactly. - -## viteTheme Guard - -`->viteTheme()` calls `app(Vite::class)($theme)` which reads `public/build/manifest.json`. -In test environments there is no built manifest, so wrap it: - -```php -->when( - ! app()->runningUnitTests(), - fn (Panel $panel) => $panel->viteTheme('resources/css/filament/company/nord.css') -) -``` - -`app()->runningUnitTests()` returns `true` when `APP_ENV=testing` (set in `phpunit.xml`). - -## Tenant Panel Required Config - -```php -->tenant(Company::class) // sets the tenant model -->tenantMenu(false) // hides the built-in tenant switcher -->tenantMiddleware([...], isPersistent: true) -``` - -## Auth Flow - -```php -->login(Login::class) // custom login page -->registration() -->passwordReset() -->emailVerification() -``` - -## Colors and Font - -Both panels use: -```php -->colors(['primary' => Color::hex('#88c0d0')]) -``` - -Company panel: `Poppins` via `GoogleFontProvider` -Admin panel: `Albert Sans` via `GoogleFontProvider` - -## SPA Mode - -The admin panel enables SPA mode for fast navigation: -```php -->spa() -``` - -Do NOT add SPA mode to the company panel — it causes issues with tenant middleware -and full-page redirects required for company switching. - -## Panel Responsibilities - -Panels configure: - -- authentication -- navigation -- resources -- middleware -- appearance - -Panels must not contain business logic. - -Business logic belongs in services. - ---- - -## Resource Registration - -If one module registers resources via discoverResources(), -all modules should follow the same convention. - -Avoid mixing manual registration and discovery. diff --git a/.claude/skills/filament-resource-pages/SKILL.md b/.claude/skills/filament-resource-pages/SKILL.md deleted file mode 100644 index afd2eb0ab..000000000 --- a/.claude/skills/filament-resource-pages/SKILL.md +++ /dev/null @@ -1,196 +0,0 @@ ---- -name: filament-resource-pages -description: "Defines the Filament v4 resource page structure used in this project: Resource + Pages + Schemas + Tables split, action patterns, and BaseResource conventions." -license: MIT -metadata: - author: project ---- - -# Filament Resource Pages - -## Resource Directory Layout - -Every resource lives under `Modules/{Name}/Filament/{Panel}/Resources/{Model}/`: - -``` -{Model}Resource.php ← extends BaseResource; declares model, nav, pages -Pages/ - List{Model}.php ← extends ListRecords - Create{Model}.php ← extends CreateRecord - Edit{Model}.php ← extends EditRecord -Schemas/ - {Model}Form.php ← static configure(Schema $schema): Schema -Tables/ - {Model}sTable.php ← static configure(Table $table): Table -RelationManagers/ ← optional -``` - -Schemas and Tables are **separate classes**, never defined inline inside the Resource. - ---- - -## Resource Class - -```php -class InvoiceResource extends BaseResource -{ - protected static ?string $model = Invoice::class; - protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBanknotes; - protected static ?int $navigationSort = 10; - protected static bool $isScopedToTenant = true; - - public static function form(Schema $schema): Schema - { - return InvoiceForm::configure($schema); - } - - public static function table(Table $table): Table - { - return InvoicesTable::configure($table); - } - - public static function getPages(): array - { - return [ - 'index' => Pages\ListInvoices::route('/'), - 'create' => Pages\CreateInvoice::route('/create'), - 'edit' => Pages\EditInvoice::route('/{record}/edit'), - ]; - } -} -``` - -`BaseResource` handles tenant-scoped queries automatically — do NOT add manual `company_id` filters. - ---- - -## List Page - -```php -class ListInvoices extends ListRecords -{ - protected static string $resource = InvoiceResource::class; - - protected function getHeaderActions(): array - { - return [ - CreateAction::make() - ->modalWidth('full') - ->action(function (array $data) { - app(InvoiceService::class)->createInvoice($data); - }), - ]; - } -} -``` - ---- - -## Edit Page - -Override `save()` when you need to route the update through the service layer: - -```php -class EditInvoice extends EditRecord -{ - protected static string $resource = InvoiceResource::class; - - public function save(bool $shouldRedirect = true, bool $shouldSendSavedNotification = true): void - { - $this->authorizeAccess(); - $this->callHook('beforeValidate'); - $data = $this->form->getState(); - $this->callHook('afterValidate'); - $data = $this->mutateFormDataBeforeSave($data); - $this->callHook('beforeSave'); - - app(InvoiceService::class)->updateInvoice($data, $this->getRecord()); - - $this->callHook('afterSave'); - - if ($shouldRedirect) { - $this->redirect($this->getRedirectUrl()); - } - } - - protected function getHeaderActions(): array - { - return [DeleteAction::make()]; - } -} -``` - ---- - -## Schema Class - -```php -class InvoiceForm -{ - public static function configure(Schema $schema): Schema - { - return $schema->components([ - Grid::make(2)->schema([ - Section::make('Details')->schema([ - Select::make('customer_id')->relationship('customer', 'company_name')->required(), - DatePicker::make('invoice_date')->required(), - ]), - ]), - ]); - } -} -``` - ---- - -## Table Class - -```php -class InvoicesTable -{ - public static function configure(Table $table): Table - { - return $table - ->columns([ - TextColumn::make('invoice_number')->searchable()->sortable(), - TextColumn::make('invoice_status')->badge(), - ]) - ->actions([ - EditAction::make(), - DeleteAction::make(), - ]) - ->bulkActions([ - BulkActionGroup::make([DeleteBulkAction::make()]), - ]); - } -} -``` - ---- - -## Action Closure Rule - -Filament action closures do NOT support constructor injection. Always use `app()`: - -```php -->action(function (array $data) { - app(InvoiceService::class)->createInvoice($data); -}) -``` - -This is the only place `app()` is acceptable. Services themselves must never use it. - ---- - -## Panel Registration - -Resources are discovered per module in `CompanyPanelProvider`: - -```php -->discoverResources( - in: base_path('modules/invoices/src/Filament/Company/Resources'), - for: 'Modules\\Invoices\\Filament\\Company\\Resources' -) -``` - -The `in` parameter uses the filesystem path (lowercase with `src/`), while `for` uses the PHP namespace. diff --git a/.claude/skills/filament-resource-testing/SKILL.md b/.claude/skills/filament-resource-testing/SKILL.md deleted file mode 100644 index 22bb97975..000000000 --- a/.claude/skills/filament-resource-testing/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: filament-resource-testing -description: Defines how Filament UI resources are tested using Livewire -license: MIT -metadata: - author: project ---- - -# Filament Resource Testing - -## Purpose - -This skill defines **UI-level testing patterns for Filament resources only**. - -It validates: -- Create/Edit/List pages -- form interaction -- Livewire-based UI flows -- user-visible behavior - -It does NOT define: -- factories -- tenancy rules -- database integrity rules -- security rules -- primary key rules - -These are owned by other skills. - ---- - -# 1. Scope Rule - -This skill ONLY covers: - -- Filament Pages -- Filament Actions -- Livewire interactions -- UI assertions - ---- - -# 2. Test Structure Rule - -Each test MUST validate one UI behavior: - -- listing records -- creating records -- editing records -- deleting records -- validation errors - -No multi-behavior tests allowed. - ---- - -# 3. Livewire Execution Rule - -All Filament tests MUST use Livewire: - -```php -Livewire::actingAs($this->user) - ->test(CreateInvoice::class) -``` - -No direct HTTP testing of Filament pages. - ---- - -# 4. Form Interaction Rule - -Form input MUST use: - -```php -->set('data.field', value) -``` - -Not: -- fillForm -- request payload simulation -- raw HTTP input - ---- - -# 5. Assertion Rule - -Tests MUST assert business outcome: - -- database state change -- UI state change -- form validation error state - -NOT framework internals. - ---- - -# 6. Delete Action Rule - -Delete actions are tested as UI actions only: - -```php -->callAction(DeleteAction::class) -``` - -Outcome MUST be verified via database assertion. - ---- - -# 7. Multi-tenancy Note - -Tenant behavior is NOT owned by this skill. - -If multi-tenancy is present: -- it is assumed to be already configured -- this skill only validates UI behavior within active tenant context - ---- - -# 8. Core Principle - -Filament resource tests verify **what the user sees and does**, not how the system enforces rules internally. diff --git a/.claude/skills/github-actions-php/SKILL.md b/.claude/skills/github-actions-php/SKILL.md deleted file mode 100644 index 644ccb34b..000000000 --- a/.claude/skills/github-actions-php/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: github-actions-php -description: Defines GitHub Actions configuration for running PHP/Laravel CI pipeline ---- - -# GitHub Actions PHP - -## Purpose - -Defines CI workflow structure only. - ---- - -## Scope - -This skill defines: - -- PHP version matrix -- MySQL service setup -- Composer install steps -- test execution trigger -- artifact collection - ---- - -## Non-Scope - -This skill does NOT define: - -- schema validation rules -- factory correctness rules -- test classification logic -- database correctness assumptions - -These belong to domain-specific CI and test skills. - ---- - -## Database Requirement - -CI MUST use MySQL =MariaDB when production uses MySQL / MariaDB. - -SQLite is forbidden in CI when schema integrity matters. - ---- - -## Execution Flow - -CI pipeline MUST follow: - -1. Setup PHP environment -2. Install dependencies -3. Boot MySQL service -4. Run migrations -5. Run seeders -6. Execute tests - ---- - -## Principle - -This skill defines "how CI runs", not "what is correct". diff --git a/.claude/skills/laravel-modules/SKILL.md b/.claude/skills/laravel-modules/SKILL.md deleted file mode 100644 index d6170f3bd..000000000 --- a/.claude/skills/laravel-modules/SKILL.md +++ /dev/null @@ -1,209 +0,0 @@ ---- -name: laravel-modules -description: "Creates and modifies code inside a modular Laravel structure. Targets internachi/modular (modules as real Composer packages with src/). Activates when adding a new module, adding a model/factory/migration/resource/service to an existing module, registering a module with Filament, or when the user mentions modules, modular, or a specific module name." -license: MIT -metadata: - author: project ---- - -# Laravel Modules - -## Package Standard: `internachi/modular` - -New modules use [`internachi/modular`](https://github.com/InterNACHI/modular). -Each module is a **real Composer package** with its own `composer.json`, resolved -from the root via a path repository. This makes modules portable, independently -testable, and properly autoloaded. - -> **InvoicePlane-v2 exception:** This project was built with `nwidart/laravel-modules` -> and has **no `src/` layer** — the module root is the PSR-4 root. If you are -> working in this repo, skip the `src/` wrapper and use uppercase `Database/`, -> `Tests/` directly under the module root. See the nwidart section at the bottom. - ---- - -## `internachi/modular` Directory Layout - -``` -modules/ - {name}/ ← lowercase, kebab-case - src/ ← PSR-4 root - {Name}ServiceProvider.php - Models/ - Enums/ - Events/ Listeners/ Observers/ - Filament/ - Company/ - Resources/ - {Model}/ - {Model}Resource.php - Pages/ - List{Model}.php - Create{Model}.php - Edit{Model}.php - Schemas/ - {Model}Form.php - Tables/ - {Model}sTable.php - Http/ - Services/ - Traits/ - database/ - factories/ - migrations/ - seeders/ - tests/ - Feature/ - Unit/ - composer.json -``` - ---- - -## Module `composer.json` - -```json -{ - "name": "app/{name}", - "description": "The {Name} module", - "type": "library", - "require": {}, - "autoload": { - "psr-4": { - "Modules\\{Name}\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\{Name}\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\{Name}\\{Name}ServiceProvider" - ] - } - }, - "minimum-stability": "dev", - "prefer-stable": true -} -``` - ---- - -## Root `composer.json` Wiring - -```json -{ - "repositories": [ - { - "type": "path", - "url": "./modules/*", - "options": { "symlink": true } - } - ], - "require": { - "app/core": "*", - "app/invoices": "*" - } -} -``` - -Run `composer require app/{name}:*` whenever a new module is added. - ---- - -## Namespace Convention - -``` -Modules\{Name}\ -Modules\{Name}\Models\ -Modules\{Name}\Filament\Company\Resources\{Model}\{Model}Resource -Modules\{Name}\Database\Factories\{Model}Factory -Modules\{Name}\Database\Seeders\{Model}Seeder -Modules\{Name}\Services\{Model}Service -Modules\{Name}\Tests\Feature\{Model}Test -``` - ---- - -## Service Provider - -The service provider is auto-discovered via `composer.json`. It only needs to -load migrations and register observers: - -```php -namespace Modules\Invoices; - -use Illuminate\Support\ServiceProvider; - -class InvoicesServiceProvider extends ServiceProvider -{ - public function boot(): void - { - $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); - $this->loadViewsFrom(__DIR__ . '/../resources/views', 'invoices'); - } -} -``` - -No manual entry in `config/app.php` — Composer's auto-discovery handles it. - ---- - -## Filament Resource Registration - -Add `->discoverResources()` per module in `CompanyPanelProvider`: - -```php -->discoverResources( - in: base_path('modules/invoices/src/Filament/Company/Resources'), - for: 'Modules\\Invoices\\Filament\\Company\\Resources' -) -``` - ---- - -## Test Discovery - -Configure `phpunit.xml` to pick up all module test directories: - -```xml - - modules/*/tests/Unit - - - modules/*/tests/Feature - -``` - ---- - -## Adding a New Module (Checklist) - -1. Create `modules/{name}/` with the directory tree above. -2. Write `modules/{name}/composer.json` (copy from existing module, change name/namespace). -3. Run `composer require app/{name}:*` from the project root. -4. Add `->discoverResources(...)` to `CompanyPanelProvider`. -5. Run `php artisan migrate` to pick up the new module's migrations. - ---- - -## nwidart/laravel-modules (InvoicePlane-v2 Legacy) - -InvoicePlane-v2 uses `nwidart/laravel-modules` ≥ v12. The key differences: - -| | `internachi/modular` | `nwidart` (InvoicePlane-v2) | -|---|---|---| -| Module root | `modules/{name}/` | `Modules/{Name}/` | -| PSR-4 source | `src/` | module root directly | -| Namespace | `Modules\{Name}\` | `Modules\{Name}\` | -| Tests | `tests/` (lowercase) | `Tests/` (uppercase) | -| DB files | `database/` (lowercase) | `Database/` (uppercase) | -| Discovery | Composer path repo | `module.json` + manual provider | -| Registration | `composer require` | add to `config/app.php` | - -When working in InvoicePlane-v2, drop the `src/` layer and follow uppercase -`Database/`, `Tests/` conventions. All else (service structure, Filament patterns, -test base classes) remains the same. diff --git a/.claude/skills/non-standard-pks/SKILL.md b/.claude/skills/non-standard-pks/SKILL.md deleted file mode 100644 index 900e92a09..000000000 --- a/.claude/skills/non-standard-pks/SKILL.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -name: non-standard-pks -description: "Works with models that have non-standard primary key names. Activates when writing factories, tests, relationships, or seeders for models that use a custom primary key instead of id." -license: MIT -metadata: - author: project ---- - -# Non-Standard Primary Keys - -Most models in this app use `id` as their primary key (Laravel default). Only a -handful declare a custom `$primaryKey`. **Never assume a model has a non-standard -PK without checking the model file.** - -## Confirmed Non-Standard PKs - -| Model | Table | Primary Key | -|-------|-------|-------------| -| `ClientCustom` | `client_custom` | `client_custom_id` | -| `Import` | `imports` | `import_id` | - -All other models should be assumed to use `id` unless their model file explicitly -declares `protected $primaryKey = '...'`. - -## Accessing the PK Safely - -Use `$model->getKey()` for generic access. Use the named attribute only when -you know the model's actual PK: - -```php -$custom->client_custom_id // ✓ typed access for ClientCustom -$custom->getKey() // ✓ generic access -$custom->id // ✗ returns null — ClientCustom uses client_custom_id -``` - -## Factories: Pass the FK by Name - -When creating related records that reference a non-standard PK, pass the FK -column explicitly: - -```php -// ClientCustom's PK is client_custom_id, not id -SomeRelated::factory()->create([ - 'client_custom_id' => $custom->client_custom_id, -]); -``` - -## Filament Edit Page - -The `record` parameter expects the PK value: - -```php -Livewire::actingAs($this->user) - ->test(EditClientCustom::class, [ - 'record' => $custom->client_custom_id, // not $custom->id - 'tenant' => $this->company->search_code, - ]) -``` - -## Model Definition - -Always declare `$primaryKey` explicitly for non-standard models: - -```php -class ClientCustom extends Model -{ - protected $table = 'client_custom'; - protected $primaryKey = 'client_custom_id'; - public $timestamps = false; -} -``` - -## Adding a New Non-Standard PK - -When you introduce a model with a non-standard PK, update this skill's -**Confirmed Non-Standard PKs** table immediately. - -## Timestamps - -Almost all models in this app have `$timestamps = false` — they manage date -columns manually. Do not assume `created_at`/`updated_at` exist. diff --git a/.claude/skills/pest-control/SKILL.md b/.claude/skills/pest-control/SKILL.md deleted file mode 100644 index 548882fc9..000000000 --- a/.claude/skills/pest-control/SKILL.md +++ /dev/null @@ -1,249 +0,0 @@ ---- -name: pest-control -description: > - Enforces PHPUnit-only testing in this project. Activates when writing tests, reviewing test - files, or when any Pest syntax appears (it(), test(), describe(), uses(), expect() chains, - beforeEach/afterEach hooks). Scans for and eliminates all Pest references from code, - config, and documentation. -license: MIT -metadata: - author: project ---- - -# Pest Control - -## Rule 0 — Hard Stop - -**Pest is NOT installed in this project and must never be used.** - -This project uses **PHPUnit 12+** exclusively. - -Never write, suggest, or accept: -- `it('description', fn () => ...)` -- `test('description', fn () => ...)` -- `describe('group', fn () => ...)` -- `uses(SomeClass::class)` -- `expect($value)->toBe(...)` -- `beforeEach(fn () => ...)` -- `afterEach(fn () => ...)` -- `pest()` configuration - ---- - -## Rule 1 — Correct Test Class Pattern - -Every test MUST be a class extending one of the three base classes: - -```php -// Company panel tests -class FooTest extends AbstractCompanyPanelTestCase -{ - #[Test] - public function it_does_something(): void - { - // ... - } -} - -// Admin panel tests -class BarTest extends AbstractAdminPanelTestCase -{ - #[Test] - public function it_does_something(): void - { - // ... - } -} - -// Pure unit tests (no DB, no framework boot) -class BazTest extends AbstractTestCase -{ - #[Test] - public function it_does_something(): void - { - // ... - } -} -``` - -Base class locations: `Modules/Core/Tests/` - ---- - -## Rule 2 — Attribute Syntax - -Use PHP 8.1+ attributes for test metadata: - -```php -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\Attributes\CoversClass; - -#[Test] -public function it_creates_an_invoice(): void {} - -#[Test] -#[DataProvider('invoiceDataProvider')] -public function it_validates_invoice_fields(array $data, string $error): void {} -``` - -Never use `/** @test */` docblock annotations — use `#[Test]` attributes. - ---- - -## Rule 3 — Assertion Style - -Use PHPUnit assertions, not Pest chains: - -```php -// Correct -$this->assertSame('expected', $actual); -$this->assertDatabaseHas('invoices', ['status' => 'paid']); -$this->assertCount(3, $results); - -// Wrong — Pest chain -expect($actual)->toBe('expected'); -expect($results)->toHaveCount(3); -``` - ---- - -## Rule 4 — Livewire Testing - -Filament/Livewire tests use the Livewire facade directly: - -```php -use Livewire\Livewire; - -Livewire::actingAs($this->user) - ->test(ListInvoices::class, ['tenant' => 'ivplv2']) - ->assertSuccessful(); -``` - -Or the base class helper: -```php -$this->testLivewire(ListInvoices::class)->assertSuccessful(); -``` - ---- - -## Rule 5 — File Placement - -``` -Modules//Tests/Unit/ ← AbstractTestCase, no DB -Modules//Tests/Feature/ ← AbstractCompanyPanelTestCase or AbstractAdminPanelTestCase -``` - -PHPUnit discovers tests via `phpunit.xml`: -```xml - Modules/*/Tests/Unit -Modules/*/Tests/Feature -``` - ---- - -## Rule 6 — Pest Elimination Checklist - -When asked to eliminate Pest from a codebase, check and fix all of the following: - -### composer.json -- [ ] Remove `"pestphp/pest-plugin": true` from `config.allow-plugins` -- [ ] Remove any `pestphp/pest*` entries from `require-dev` - -### Test files -- [ ] Convert `it('...', fn () => ...)` → class method with `#[Test]` attribute -- [ ] Convert `test('...', fn () => ...)` → class method with `#[Test]` attribute -- [ ] Remove all `uses(...)` declarations -- [ ] Replace `expect(...)->toBe(...)` chains with `$this->assertSame(...)` -- [ ] Replace `beforeEach` → `setUp()`, `afterEach` → `tearDown()` -- [ ] Remove `describe()` wrappers; flatten into separate methods or classes - -### Config / tooling -- [ ] Delete `pest.php` or `tests/Pest.php` if present -- [ ] Remove any `--pest` flag from CI workflow commands -- [ ] Update `.gitignore` comments: `# PHPUnit / Pest` → `# PHPUnit` -- [ ] Update Makefile comments that mention Pest - -### Documentation -- [ ] Update `CLAUDE.md` testing section to state PHPUnit-only -- [ ] Update any README or CONTRIBUTING docs that mention Pest - ---- - -## Rule 7 — Test Method Naming - -Test methods MUST follow the `it_{verb}_{object}` convention. The name must read -as a sentence describing observable behavior. - -```php -// Correct -it_creates_an_invoice -it_rejects_a_duplicate_email -it_returns_404_for_missing_resource -it_assigns_company_id_to_new_invoices - -// Wrong — noun before verb -it_invoice_creates - -// Wrong — no verb -it_invoice -``` - -Never describe implementation. Describe what the system does from the outside. - ---- - -## Rule 8 — Arrange / Act / Assert - -Every test method MUST be structured in three named phases, each preceded by its -own `/* Arrange */`, `/* Act */`, or `/* Assert */` comment. No exceptions. - -```php -#[Test] -public function it_creates_an_invoice(): void -{ - /* Arrange */ - $client = Relation::factory()->for($this->company)->create(); - $payload = ['customer_id' => $client->getKey(), 'invoice_date' => '2026-01-01']; - - /* Act */ - app(InvoiceService::class)->createInvoice($payload); - - /* Assert */ - $this->assertDatabaseHas('invoices', [ - 'customer_id' => $client->getKey(), - 'company_id' => $this->company->id, - ]); -} -``` - -A test with no `/* Arrange */` / `/* Act */` / `/* Assert */` comments is rejected on -review, no matter how correct the assertions are. - -If a phase is genuinely empty (e.g. a pure-assertion unit test with no setup), -keep the comment and leave a blank line — the structure is the contract, not the -line count. - ---- - -## Rule 8 — Conversion Reference - -| Pest | PHPUnit equivalent | -|------|--------------------| -| `it('desc', fn() => ...)` | `#[Test] public function it_desc(): void` | -| `test('desc', fn() => ...)` | `#[Test] public function test_desc(): void` | -| `expect($x)->toBe($y)` | `$this->assertSame($y, $x)` | -| `expect($x)->toEqual($y)` | `$this->assertEquals($y, $x)` | -| `expect($x)->toBeTrue()` | `$this->assertTrue($x)` | -| `expect($x)->toBeFalse()` | `$this->assertFalse($x)` | -| `expect($x)->toBeNull()` | `$this->assertNull($x)` | -| `expect($x)->toBeEmpty()` | `$this->assertEmpty($x)` | -| `expect($x)->toHaveCount(n)` | `$this->assertCount(n, $x)` | -| `expect($x)->toContain($y)` | `$this->assertContains($y, $x)` | -| `expect($x)->toMatchArray([...])` | `$this->assertEquals([...], $x)` | -| `expect($x)->toBeInstanceOf(Cls::class)` | `$this->assertInstanceOf(Cls::class, $x)` | -| `beforeEach(fn() => ...)` | `protected function setUp(): void` | -| `afterEach(fn() => ...)` | `protected function tearDown(): void` | -| `uses(RefreshDatabase::class)` | `use RefreshDatabase;` inside the class | -| `dataset(...)` | `public static function provider(): array` + `#[DataProvider('provider')]` | diff --git a/.claude/skills/safe-refactoring-rules/SKILL.md b/.claude/skills/safe-refactoring-rules/SKILL.md deleted file mode 100644 index 1a42b7a4f..000000000 --- a/.claude/skills/safe-refactoring-rules/SKILL.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: safe-refactoring-rules -description: Ensures all refactoring is deterministic, behavior-preserving, and non-breaking ---- - -# Safe Refactoring Rules - -## Purpose - -Ensure all refactoring is deterministic, non-breaking, and behavior-preserving. - -This skill enforces *how changes are made*, not *how the system is structured*. - ---- - -# 1. Behavior Preservation - -- Never change runtime behavior unless explicitly instructed. -- Any refactoring must preserve observable outputs. -- Moving code between layers must not alter execution results. - ---- - -# 2. Existing Code Respect - -- Never overwrite an existing method if it already satisfies part of the requirement. -- Extend existing implementations instead of replacing them. -- Do not delete or rewrite working logic unless required for a fix. - ---- - -# 3. Dependency Integrity - -- Always preserve constructor injection. -- Never replace dependency injection with service locators (`app()`, `resolve()`). -- Do not introduce new dependencies when existing ones suffice. -- Do not change dependency graphs without explicit intent. - ---- - -# 4. Public API Stability - -- Never change public method signatures unless all call sites are updated in the same change. -- Avoid breaking changes at all costs. -- Prefer internal adaptation over external contract modification. - ---- - -# 5. Idempotency Requirement - -- Refactoring must be idempotent. -- Running the same change twice must produce no further diff. -- No duplicate logic, imports, traits, or methods may be introduced. - ---- - -# 6. Uncertainty Handling - -If any of the following is unclear: - -- intended behavior -- service contract -- domain rule -- expected output - -Then: - -- Stop immediately -- Do not guess -- Report ambiguity explicitly -- Request clarification - ---- - -# 7. Abstraction Reuse Rule (Local Scope Only) - -This skill only enforces reuse during refactoring operations. - -Global abstraction policy is defined in application-architecture-standard. - -Before introducing: - -- Trait -- Service -- DTO -- Transformer -- Base class - -Search for an existing implementation. - -Reuse existing abstractions whenever practical. - -Duplicate abstractions are architectural defects. - ---- - -# 8. Scope Discipline - -This skill does NOT define: - -- architecture layering (handled by application-architecture-standard) -- testing strategy (handled by test-honesty / filament-resource-testing) -- security rules (handled separately if present) - -It ONLY defines safe transformation rules. - ---- - -# 9. Enforcement Priority - -If this skill conflicts with others: - -1. application-architecture-standard -2. domain-specific skills -3. execution workflows -4. this skill (always subordinate to architecture) diff --git a/.claude/skills/security-review/SKILL.md b/.claude/skills/security-review/SKILL.md deleted file mode 100644 index 3533725db..000000000 --- a/.claude/skills/security-review/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: security-review -description: Static review rules for authorization, validation, and privilege escalation risks ---- - -# Security Review - -## Purpose - -Detect security risks in code during review phase. -This skill does NOT enforce security. It identifies issues. - ---- - -## Scope - -This skill evaluates: - -- authorization checks (missing or bypassed) -- policy usage correctness -- privilege escalation risks -- unsafe controller or action exposure -- validation gaps on external input - ---- - -## Ownership Boundary - -Security Review does NOT: - -- implement policies -- define roles/permissions -- execute middleware logic -- enforce runtime access control - -Those belong to application security layers (Policies, Middleware, Gates). - ---- - -## Rules - -- Every sensitive action MUST have explicit authorization check -- No unguarded resource actions (create/update/delete/view) -- No direct access to privileged operations without policy validation -- Input from external sources MUST be validated before use - ---- - -## Escalation Principle - -If a potential security issue is detected: - -- assume it is a defect until proven otherwise -- prioritize security over architectural convenience diff --git a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md b/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md deleted file mode 100644 index 72fcf8347..000000000 --- a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -name: senior-laravel-developer-code-reviewer -description: "Orchestrates existing Laravel skills to produce structured PR reviews as a grumpy, no-nonsense Senior Laravel developer" ---- - -# Senior Laravel Developer — Code Reviewer - -You are a grumpy Senior Laravel developer. You have seen every anti-pattern twice. -You do not sugarcoat. You do not pad your feedback with compliments. You report -exactly what is wrong and exactly how to fix it. - -You are not unkind — you are precise. You want the code to be correct, not to feel -good about itself. - ---- - -# Delegation Model - -Do not invent rules. Delegate evaluation to existing skills: - -**Architecture & code quality** -- `application-architecture-standard` -- `service-layer` -- `laravel-modules` -- `non-standard-pks` -- `dto-contract` -- `safe-refactoring-rules` - -**Tests** -- `filament-resource-testing` -- `test-honesty` -- `pest-control` - -**Security** -- `security-review` -- `spatie-roles` - -**Tenancy** -- `filament-multi-tenancy` -- `tenant-middleware` - ---- - -# Review Process - -## 1. Architecture pass - -Report violations only. Do not restate rules. - -Bad example of what NOT to write: -> "The service layer principle states that services should not use Filament..." - -Good example: -> "`InvoiceService::create()` calls `Filament::getTenant()` directly. Services must not touch Filament." - ---- - -## 2. Test pass - -Focus on: -- Tests that pass even when the feature is broken (assertion on wrong thing) -- Missing failure-path tests -- Hardcoded IDs (violates `test-honesty`) -- Pest syntax in a PHPUnit-only project -- Livewire tests that bypass the service layer and assert nothing in the DB -- **Missing `/* Arrange */` / `/* Act */` / `/* Assert */` phase comments** — every test method requires all three, no exceptions - ---- - -## 3. Security pass - -Report: -- Unguarded resource actions (no policy, no gate, no role check) -- Privilege escalation paths -- Missing input validation at system boundaries - ---- - -## 4. Consolidation - -Group findings into three buckets — and only three: - -- **Must fix** — production bugs, security holes, data integrity risks, broken tests -- **Should fix** — architecture violations, test gaps, maintainability problems -- **Could fix** — cosmetic improvements, style, naming - -Never let "Could fix" items crowd out "Must fix" items. - ---- - -# Output Format - -``` -## Summary -One paragraph. What does this PR do, and is it shippable? - -## Must Fix -- : - -## Should Fix -- : - -## Could Fix -- : - -## Test Risk -- - -## Security -- - -## Suggested Fixes - -``` - ---- - -# Tone Rules - -Say: "This bypasses the service layer and writes directly to the model." -Not: "This could potentially be considered a violation of layered architecture..." - -Say: "Missing authorization. Any authenticated user can delete any invoice." -Not: "It might be worth considering adding an authorization check here..." - -Say: "This test asserts nothing in the database. It passes whether the record was created or not." -Not: "The test coverage could be improved by adding database assertions..." - -If it is wrong, say it is wrong. If it is broken, say it is broken. -If something is genuinely fine, say nothing about it. - ---- - -# Priority Order - -1. Production bugs -2. Security issues -3. Data integrity risks -4. Broken or dishonest tests -5. Architecture violations -6. Maintainability -7. Style - -Never allow item 7 to appear before items 1–4 are exhausted. diff --git a/.claude/skills/senior-laravel-developer-phpunit-interpreter/SKILL.md b/.claude/skills/senior-laravel-developer-phpunit-interpreter/SKILL.md deleted file mode 100644 index 38e87e9c8..000000000 --- a/.claude/skills/senior-laravel-developer-phpunit-interpreter/SKILL.md +++ /dev/null @@ -1,189 +0,0 @@ ---- -name: senior-laravel-developer-phpunit-interpreter -description: Cleans and interprets raw PHPUnit CI logs into a compact, AI-friendly failure report. Use this skill whenever the user pastes or uploads a PHPUnit log, GitHub Actions test output, CI test results, or asks to interpret/summarize/analyze failing tests. Trigger even if the user says things like "here's my test output", "tests are failing", "can you look at my PHPUnit log", or pastes a block of text that contains PHPUnit output. Always use this skill before attempting to diagnose failures. ---- - -# Senior Laravel Developer — PHPUnit Log Interpreter - -Process the **entire** attached PHPUnit log from beginning to end without truncation, stopping early, or summarizing. - -Produce a **highly condensed, AI-friendly report** containing **only actionable test failures and errors**, stripping all infrastructure noise. - ---- - -## General Cleanup - -Remove completely: - -- All timestamps (e.g. `2026-05-14T03:17:38.4840913Z`) -- All ANSI escape sequences and terminal color codes -- GitHub Actions workflow metadata and runner output -- Docker / container startup logs -- Composer commands, dependency installation, download, extraction, and installation output -- Laravel migration, seeding, optimize, cache, bootstrap, and environment setup output -- CI/CD infrastructure noise and progress bars -- All successful tests beginning with `✔` -- Any output unrelated to PHPUnit failures, warnings, deprecations, risky tests, notices, or errors - ---- - -## Path Cleanup - -Remove the absolute project root path prefix from all file paths so only the -relative path remains (e.g. strip `/home/runner/work//` or -`/var/www//` — whatever the CI runner's working directory is). - ---- - -## Stack Trace Processing - -Unless explicitly requested: - -- Remove **all stack traces completely** — every `#0`, `#1`, `#2`, etc. -- Remove all vendor frames, internal frames, and repeated exception rendering - -Keep only: - -- Test name -- Exception type -- Exception message -- Assertion message -- `Caused by` exception (if present) -- `Previous exception` (if present) - ---- - -## Failure Formats - -**Error** — preserve: -``` -Modules\...\Tests\Feature\SomeTest::it_does_something - -ExceptionClass: -Exception message here. -``` - -**Failure** — preserve: -``` -Modules\...\Tests\Feature\SomeTest::it_does_something - -Expected response status code [200] but received 500. - -Failed asserting that 500 is identical to 200. - -UnderlyingException: -Underlying message if present. -``` - ---- - -## Duplicate Removal - -Keep only the first occurrence of: - -- Duplicate exception blocks -- Repeated stack traces -- Repeated "The following exception occurred..." -- Repeated rendering output - ---- - -## Formatting Rules - -- Collapse multiple blank lines into a single blank line -- Do **not** reorder, sort, renumber, or group failures — preserve exact PHPUnit order - ---- - -## Output Structure - -Return the cleaned log as a single Markdown code block: - -````markdown -```text -PHPUnit 11.x by Sebastian Bergmann and contributors. - -Runtime: PHP x.x.x -Configuration: phpunit.xml - - - -Time: xx:xx.xxx, Memory: xx MB - -There were X errors: - -1) FullTestClassName::method_name - -ExceptionClass: -Message. - -2) ... - -There were X failures: - -1) FullTestClassName::method_name - -Assertion message. - -Failed asserting that ... - -UnderlyingException: -Message. - -2) ... - -Tests: N -Assertions: N -Errors: N -Failures: N -Warnings: N (omit if 0) -Skipped: N (omit if 0) -Incomplete: N (omit if 0) -Risky: N (omit if 0) -Deprecations: N (omit if 0) -``` -```` - -Include Warnings, Deprecations, and Risky sections only if present. - ---- - -## Conditional Output Rule - -If the suite has zero errors, failures, warnings, risky tests, and deprecations, output only: - -```text -PHPUnit completed successfully. - -Tests: -Assertions: - -No errors, failures, warnings, risky tests, or deprecations detected. -``` - ---- - -## Objective - -Minimize token usage while preserving **100% of the information required to diagnose failing tests**. Output must be stable, deterministic, compact, and optimized for consumption by both humans and AI systems. - ---- - -## Root Cause Analysis - -When multiple tests fail with the same underlying exception, -identify the earliest failure that explains subsequent failures. - -Do not propose independent fixes for cascading failures. - -Prefer fixing one root cause over many symptoms. - ---- - -## Architectural Diagnosis - -When a failure indicates a missing architectural pattern -(e.g. missing service, missing transaction, missing CoversClass, -missing failure-path tests, missing factory field), - -recommend applying the fix repository-wide rather than only to the failing test. diff --git a/.claude/skills/service-layer/SKILL.md b/.claude/skills/service-layer/SKILL.md deleted file mode 100644 index 1087f62ab..000000000 --- a/.claude/skills/service-layer/SKILL.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -name: service-layer -description: Defines application service structure and business orchestration boundaries -license: MIT -metadata: - author: project ---- - -# Service Layer - -Services are the only place business logic lives. They are framework-agnostic. - ---- - -# 1. Responsibility - -Services MUST: - -- contain business logic -- coordinate models -- enforce domain rules -- return models or DTOs - -Services MUST NOT: - -- import or use Filament -- accept or return HTTP request/response objects -- contain UI logic -- use `app()` or `resolve()` internally - ---- - -# 2. Dependency Rule - -Constructor injection only: - -```php -public function __construct( - private InvoiceRepository $repository -) {} -``` - ---- - -# 3. DTO Rule - -DTOs are **not** required for Filament → Service calls. Arrays are fine when the -source is a trusted Filament form. - -Use DTOs when: -- crossing system boundaries (API, queues, external integrations) -- multiple services share a contract -- the payload must be stable across refactors - -Skip DTOs when: -- input comes from a single Filament form -- the data is short-lived and not reused - ---- - -# 4. Filament Action Exception - -Filament closures do not support constructor DI. `app()` is the only acceptable -escape hatch — and it belongs in the closure, not inside the service: - -```php -Action::make('create') - ->action(function (array $data) { - app(InvoiceService::class)->createInvoice($data); - }); -``` - ---- - -# 5. Standard Shape - -``` -Modules/{Name}/Services/{Model}Service.php -``` - -Standard method names: `createX`, `updateX`, `deleteX`, `findOrFail`, `listForCompany`. diff --git a/.claude/skills/spatie-roles/SKILL.md b/.claude/skills/spatie-roles/SKILL.md deleted file mode 100644 index 6cb9f504c..000000000 --- a/.claude/skills/spatie-roles/SKILL.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -name: spatie-roles -description: "Implements role-based authorization using Spatie Laravel Permission. Activates when assigning roles, checking permissions, seeding roles, writing canAccessPanel logic, or when the user mentions roles, permissions, super_admin, client_admin, UserRole, assignRole, hasRole, or Spatie." -license: MIT -metadata: - author: project ---- - -# Spatie Roles - -## UserRole Enum - -All roles are defined in `Modules\Core\Enums\UserRole`: - -```php -enum UserRole: string -{ - case SUPER_ADMIN = 'super_admin'; // global — no company required - case ADMIN = 'admin'; // elevated - case ASSIST = 'assist'; // elevated, limited - case CUSTOMER_ADMIN = 'client_admin'; // company admin - case CUSTOMER = 'client'; // regular user -} -``` - -Helper methods: -- `UserRole::elevated()` → `['super_admin', 'admin', 'assist']` -- `UserRole::nonAdmin()` → `['client_admin', 'client']` -- `UserRole::values()` → all values - -**Always use the enum**, never hardcode the string value. - -## Panel Access Logic - -`User::canAccessPanel(Panel $panel)` is the Filament gate: - -```php -public function canAccessPanel(Panel $panel): bool -{ - // Elevated roles can access any panel - if ($this->hasRole(UserRole::SUPER_ADMIN->value) - || $this->hasRole(UserRole::ADMIN->value) - || $this->hasRole(UserRole::ASSIST->value)) { - return true; - } - - // Company-level users only see the company panel - if ($panel->getId() === 'company') { - return $this->hasRole(UserRole::CUSTOMER_ADMIN->value) - || $this->hasRole(UserRole::CUSTOMER->value); - } - - return false; -} -``` - -## Seeding Roles - -Always seed roles before assigning them. `Role::firstOrCreate` is idempotent: - -```php -foreach (UserRole::cases() as $role) { - Role::firstOrCreate( - ['name' => $role->value], - ['guard_name' => 'web'], - ); -} -``` - -## Assigning Roles - -```php -$user->assignRole(UserRole::SUPER_ADMIN->value); -$user->assignRole(UserRole::CUSTOMER_ADMIN->value); -``` - -## Checking Roles - -```php -$user->hasRole(UserRole::SUPER_ADMIN->value); -$user->isSuperAdmin(); // shorthand defined on User model -``` - -## Super Admin - -The super admin is a single global user, not tied to any company. Created in the -seeder as: - -```php -$superAdmin = User::factory()->create([ - 'user_name' => 'Super Admin', - 'user_email' => 'superadmin@example.com', - 'user_active' => true, -]); -$superAdmin->assignRole(UserRole::SUPER_ADMIN->value); -``` - -Super admins bypass `canAccessTenant()` via `isSuperAdmin()`: - -```php -public function canAccessTenant(Model $tenant): bool -{ - if ($this->isSuperAdmin()) { - return true; - } - return $this->companies()->whereKey($tenant->getKey())->exists(); -} -``` - -## Company Users - -Per company: 2 `client_admin` + 8 `client` (set by `UsersSeeder`). -Company admins are regular users who have elevated access within their company. -They do NOT have cross-company access. - -## Guard Name - -The Spatie permission guard is `web`. Always pass `guard_name: 'web'` when creating -roles/permissions programmatically. - ---- - -## Authorization - -Never authorize based on role strings directly when a policy, -permission, or helper method already exists. - -Prefer: - -- can() -- policies -- helper methods -- enum methods - -over repeated role checks. - -Duplicate authorization logic is a security risk. - ---- - -## Enum Rule - -Never compare: - -'user_role' == 'admin' - -Always compare against: - -UserRole::ADMIN->value diff --git a/.claude/skills/sync-stale-branches/SKILL.md b/.claude/skills/sync-stale-branches/SKILL.md deleted file mode 100644 index 134666808..000000000 --- a/.claude/skills/sync-stale-branches/SKILL.md +++ /dev/null @@ -1,133 +0,0 @@ ---- -name: sync-stale-branches -description: Brings diverged remote branches up to date with develop — classifies, rescues unique work, then resets or deletes stale branches ---- - -# Skill: sync-stale-branches - -Bring old/diverged remote branches up to date with `develop`. -Run this periodically to keep the branch list clean and PR-able. - ---- - -## Inputs - -- `EXCLUDE` — branches to leave untouched (space-separated, no `origin/` prefix) - Default: `develop master` - ---- - -## Step 1 — List candidate branches - -```bash -git fetch --prune - -# All remote branches minus the exclude list -git branch -r | grep -v 'origin/HEAD' \ - | sed 's|remotes/||' \ - | grep -v -E '^origin/(develop|master)$' -``` - -Add any other branches to exclude to the grep pattern. - ---- - -## Step 2 — Classify each branch - -For every candidate `origin/`: - -**A — unique file count (three-dot diff from merge-base):** -```bash -git diff --name-only origin/develop...origin/ | wc -l -``` - -**B — files ONLY in the branch (not in develop):** -```bash -git diff --name-only --diff-filter=A origin/develop origin/ -``` - -Classify as: -- **EMPTY** — A = 0 AND B = 0 → branch adds nothing, safe to delete -- **COVERED** — B > 0 but every file in B is already present in a known feature branch → safe to reset -- **HAS_UNIQUE** — B > 0 with at least one file not in any feature branch → must rescue first - ---- - -## Step 3 — Handle EMPTY branches - -These branches were never extended beyond the old fork point. - -```bash -git push origin --delete -``` - ---- - -## Step 4 — Handle COVERED branches - -All unique files are already captured in a feature branch we are keeping. -Reset the branch to develop HEAD so it is current but carries no stale code. - -```bash -git push origin origin/develop:refs/heads/ --force -``` - ---- - -## Step 5 — Handle HAS_UNIQUE branches - -Rescue uncovered files before resetting. - -### 5a — Identify which feature branch the files belong to - -Group uncovered files by module/domain: -- `Modules/Foo/…` → belongs to whatever feature owns Foo -- If unclear, create a new feature branch named after the owning issue/feature - -### 5b — Extract files onto the correct feature branch - -On the target feature branch (must already exist and be ahead of develop): - -```bash -git checkout origin/ -- ... -git add -git commit -m "chore: rescue from stale " -git push origin HEAD --force-with-lease -``` - -If the target feature branch does not yet exist, use the feature-branch-extraction -procedure to create it properly on top of develop HEAD first. - -### 5c — Reset the stale branch to develop - -```bash -git push origin origin/develop:refs/heads/ --force -``` - ---- - -## Step 6 — Verify - -```bash -# Confirm each branch is now equal to develop -for branch in ; do - ahead=$(git rev-list origin/develop..origin/$branch --count) - behind=$(git rev-list origin/$branch..origin/develop --count) - echo "$branch → ahead=$ahead behind=$behind" -done -``` - -Expected: all cleaned branches show `ahead=0 behind=0`. - ---- - -## Notes - -- Only force-push to branches that are NOT open PRs unless the PR is yours and you - intend to update it. -- GitHub Copilot branches (`copilot/*`) are AI-generated; resetting them is safe — - Copilot will recreate them if needed. -- The `--diff-filter=A` flag catches files the branch **adds** that develop lacks. - Files the branch **modifies** relative to develop but which also exist in develop - are not "unique" — develop's version is preferred. -- Run `git fetch --prune` first so local remote-tracking refs are current. diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md deleted file mode 100644 index 5fd2f26cf..000000000 --- a/.claude/skills/tailwindcss-development/SKILL.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -name: tailwindcss-development -description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes." -license: MIT -metadata: - author: laravel ---- - -# Tailwind CSS Development - -## When to Apply - -Activate this skill when: - -- Adding styles to components or pages -- Working with responsive design -- Implementing dark mode -- Extracting repeated patterns into components -- Debugging spacing or layout issues - -## Documentation - -Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. - -## Basic Usage - -- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. -- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). -- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. - -## Tailwind CSS v4 Specifics - -- Always use Tailwind CSS v4 and avoid deprecated utilities. -- `corePlugins` is not supported in Tailwind v4. - -### CSS-First Configuration - -In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: - - -```css -@theme { - --color-brand: oklch(0.72 0.11 178); -} -``` - -### Import Syntax - -In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: - - -```diff -- @tailwind base; -- @tailwind components; -- @tailwind utilities; -+ @import "tailwindcss"; -``` - -### Replaced Utilities - -Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. - -| Deprecated | Replacement | -|------------|-------------| -| bg-opacity-* | bg-black/* | -| text-opacity-* | text-black/* | -| border-opacity-* | border-black/* | -| divide-opacity-* | divide-black/* | -| ring-opacity-* | ring-black/* | -| placeholder-opacity-* | placeholder-black/* | -| flex-shrink-* | shrink-* | -| flex-grow-* | grow-* | -| overflow-ellipsis | text-ellipsis | -| decoration-slice | box-decoration-slice | -| decoration-clone | box-decoration-clone | - -## Spacing - -Use `gap` utilities instead of margins for spacing between siblings: - - -```html -
-
Item 1
-
Item 2
-
-``` - -## Dark Mode - -If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: - - -```html -
- Content adapts to color scheme -
-``` - -## Common Patterns - -### Flexbox Layout - - -```html -
-
Left content
-
Right content
-
-``` - -### Grid Layout - - -```html -
-
Card 1
-
Card 2
-
Card 3
-
-``` - -## Common Pitfalls - -- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) -- Using `@tailwind` directives instead of `@import "tailwindcss"` -- Trying to use `tailwind.config.js` instead of CSS `@theme` directive -- Using margins for spacing between siblings instead of gap utilities -- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.claude/skills/tenant-middleware/SKILL.md b/.claude/skills/tenant-middleware/SKILL.md deleted file mode 100644 index cea871f52..000000000 --- a/.claude/skills/tenant-middleware/SKILL.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: tenant-middleware -description: "Understands and modifies the tenant resolution middleware chain. Activates when debugging tenant switching, company access, session-based tenant resolution, URL-based tenant identification, or when the user mentions ConfigureTenant, EnsureUserCanAccessCompany, SetTenantFromQueryString, search_code, or company switching." -license: MIT -metadata: - author: project ---- - -# Tenant Middleware Chain - -Three middlewares run in order on every company panel request. They are registered -as persistent tenant middleware in `CompanyPanelProvider`. - -## 1. SetTenantFromQueryString - -**Purpose:** Handle explicit `?tenant=` in the URL (used when switching company). - -- Reads the `tenant` query parameter (expects a `search_code` string) -- Looks up the company by `search_code` -- Checks the user has access (elevated role OR company membership) -- Sets Filament tenant and writes `company_id` to session -- Updates the `tenant` route parameter to the lowercase `search_code` - -## 2. ConfigureTenant - -**Purpose:** Resolve the active tenant from multiple sources and persist it. - -Resolution order: -1. Route parameter (`{tenant}`) -2. Query string `?tenant=` -3. Session `current_company_id` -4. User's first company (fallback) - -Writes resolved company to session and shares it with views. - -## 3. EnsureUserCanAccessCompany - -**Purpose:** Enforce that the resolved tenant is accessible to the authenticated user. - -- Elevated roles (`super_admin`, `admin`, `assist`) bypass — they can access all companies. -- Regular users must have the company in their `companies()` pivot relationship. -- Aborts 403 if the user has no access. - -## Company Identification - -Tenants are identified in URLs by `search_code` (a short alphanumeric string), -not by numeric `id`. The session stores the numeric `id` (`current_company_id`). - -```php -// URL: /company/invoices?tenant=ivplv2 -// Session: current_company_id = 22 -// Model: Company::where('search_code', 'ivplv2')->first() → id=22 -``` - -## Switching Companies - -The "Switch Company" user menu action redirects with `?tenant=`: - -```php -Action::make('switch-company') - ->modalContent(fn () => view('filament.company.widgets.switch-company-table')) -``` - -The Livewire component inside that modal dispatches a redirect to the new tenant's URL. - -## Testing Tenant Switching - -```php -Livewire::actingAs($this->user) - ->test(SwitchCompanyComponent::class) - ->callAction('switch', ['company_id' => $otherCompany->id]) - ->assertRedirect(route('filament.company.home', ['tenant' => $otherCompany->search_code])); -``` - - -## Single Source of Truth - -Tenant resolution belongs exclusively in the tenant middleware chain. - -Controllers, Resources, Pages, Services, and Models must never independently -resolve the active tenant from the request, session, or URL. - -They must rely on: - -- Filament::getTenant() -- injected Company model -- resolved route parameter - -Duplicating tenant resolution logic is an architectural defect. - -## Fix-One-Fix-All - -If one middleware requires modification due to a tenant resolution bug, -review all three tenant middlewares for equivalent logic and consistency. - -Tenant resolution behavior must remain uniform across the entire middleware chain. diff --git a/.claude/skills/test-honesty/SKILL.md b/.claude/skills/test-honesty/SKILL.md deleted file mode 100644 index 954eb8d6a..000000000 --- a/.claude/skills/test-honesty/SKILL.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -name: test-honesty -description: Ensures factory, seeder, and schema alignment with production database reality ---- - -# Purpose - -Prevents schema drift between migrations, factories, and seeders. - ---- - -# 1. Schema Contract - -Every NOT NULL column defined in migrations must be supported by: - -- factory definition -- or seeder definition (only for seed data) -- or explicit DB default in migration - -This is a **schema-only rule**, not a validation rule. - ---- - -# 2. Factory Rule - -Factories MUST produce valid database rows for the schema. - -Factories are schema-aligned, not business-logic aware. - ---- - -# 3. Seeder Rule - -Seeders MUST only insert schema-valid data. - -No reliance on implicit database defaults. - ---- - -# 4. Database Parity Rule - -MySQL / MariaDB is the canonical database. - -SQLite differences are invalid for schema validation assumptions. - ---- - -# 5. Drift Triggers - -The following indicate schema drift: - -- migration changes -- factory mismatch -- seeder mismatch -- SQLSTATE constraint violations -- CI vs local DB mismatch - ---- - -# 6. Identity Rule - -Primary keys are non-deterministic. - -Tests MUST NOT rely on hardcoded IDs. - ---- - -# 7. Execution Rule (CI boundary) - -Schema validation requires: - -- migrate:fresh -- seed - -before running test suites. - -This ensures schema correctness before test execution. diff --git a/.claude/skills/user-auth-fields/SKILL.md b/.claude/skills/user-auth-fields/SKILL.md deleted file mode 100644 index 365b68a84..000000000 --- a/.claude/skills/user-auth-fields/SKILL.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: user-auth-fields -description: "Works with the User model's non-standard authentication fields. Activates when writing queries, factories, tests, or seeders that reference the user's email, name, or password; or when the user mentions user_email, user_name, user_password, authentication, login, or the User model." -license: MIT -metadata: - author: project ---- - -# User Authentication Fields - -This app's `users` table does NOT use Laravel's default `name`, `email`, and -`password` column names. All three are prefixed with `user_`: - -| Laravel default | This app | -|-----------------|----------| -| `name` | `user_name` | -| `email` | `user_email` | -| `password` | `user_password` | - -## Model Overrides - -The `User` model overrides the auth contract methods: - -```php -public function getAuthIdentifierName(): string -{ - return 'user_name'; -} - -public function getAuthPassword(): string -{ - return 'user_password'; -} -``` - -## Never Use the Default Column Names - -```php -// ✗ WRONG — will cause "Column not found" on MySQL -User::factory()->create(['name' => 'Test', 'email' => 'test@example.com']); - -// ✓ CORRECT -User::factory()->create(['user_name' => 'Test', 'user_email' => 'test@example.com']); -``` - -This includes seeders, tests, and any `User::create()` call. - -## Factory Definition - -```php -public function definition(): array -{ - return [ - 'user_name' => fake()->name(), - 'user_email' => fake()->unique()->safeEmail(), - 'user_password' => Hash::make('password'), - 'user_active' => fake()->boolean(90), - 'user_all_clients' => fake()->boolean(90), - 'user_date_created' => now(), - 'user_date_modified' => now(), - ]; -} -``` - -## Additional Non-Standard Fields - -| Standard concept | This app's column | -|------------------|-------------------| -| Timestamps | Manual: `user_date_created`, `user_date_modified` | -| Active flag | `user_active` (boolean) | -| `$timestamps` | `false` — managed manually | - -## Filament Name Display - -Filament uses `getFilamentName()` not `name`: - -```php -public function getFilamentName(): string -{ - return $this->user_name ?? $this->user_email ?? 'User'; -} -``` - ---- - -## Authentication Queries - -Never query using: - -email -name -password - -Always use: - -user_email -user_name -user_password - -including: - -- validation rules -- login logic -- factories -- tests -- seeders -- authentication providers - ---- - -## Fix-One-Fix-All - -If one occurrence of `email`, `name`, or `password` is corrected to the -application's custom fields, search for equivalent usages throughout the -repository and update them consistently. diff --git a/.github/DOCKER.md b/.github/DOCKER.md deleted file mode 100644 index 4d40d9ac1..000000000 --- a/.github/DOCKER.md +++ /dev/null @@ -1,99 +0,0 @@ -# Docker Setup for InvoicePlane V2 - -This guide explains how to run InvoicePlane V2 using Docker — both the web -stack and the standalone CLI image for running tests and artisan commands. - ---- - -## Prerequisites - -- Docker installed (https://www.docker.com/) -- Docker Compose v2+ - ---- - -## Quick Start - -```bash -git clone https://github.com/InvoicePlane/InvoicePlane-v2.git -cd InvoicePlane-v2 - -cp .env.example .env - -# Install dependencies and bootstrap the app through the CLI container — -# no PHP required on the host: -docker compose run --rm cli composer install -docker compose run --rm cli php artisan key:generate -docker compose up -d -docker compose run --rm cli php artisan migrate --seed -``` - -Visit: http://localhost:8080 (override the port with `APP_PORT` in `.env`). - ---- - -## Services - -| Service | Image | Purpose | -|---|---|---| -| `web` | `docker-resources/apache` (httpd 2.4 alpine) | Serves `public/`, proxies PHP to `app` | -| `app` | `docker-resources/php-fpm` (PHP 8.4 fpm alpine) | Laravel application (FPM) | -| `cli` | `docker-resources/php-cli` (PHP 8.4 cli alpine) | One-off runner for tests / artisan / composer — profile `tools`, never auto-started | -| `db` | `mariadb` | Database (port 3306) | -| `mailcatcher` | `sj26/mailcatcher` | Catches outgoing mail — UI on port 1080 | - -Both PHP images ship the full extension set the app needs: `intl`, `gd`, -`pdo_mysql`, `bcmath`, `zip`, `exif`, `soap`, `redis`. The CLI image also has -Composer, a 1G memory limit for the test suite, and bundled `pdo_sqlite` -(the suite runs on an in-memory sqlite database — no db service needed for -tests). - ---- - -## Running the test suite - -```bash -docker compose run --rm cli vendor/bin/phpunit --exclude-group failing,troubleshooting -``` - -`APP_ENV=testing` is the `cli` service default, so `.env.testing` -(sqlite `:memory:`) is picked up automatically. See `RUNNING_TESTS.md` for -filters, groups, and suites. - -### File ownership on Linux - -The CLI image creates its user with uid/gid `1000`. If your host user -differs, rebuild with your ids so files written into the mounted repo -(vendor/, storage/, compiled views) stay owned by you: - -```bash -docker compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) cli -``` - ---- - -## Useful Commands - -| Action | Command | -|---|---| -| Start services | `docker compose up -d` | -| Stop services | `docker compose down` | -| View logs | `docker compose logs -f` | -| Run artisan | `docker compose run --rm cli php artisan ` | -| Run composer | `docker compose run --rm cli composer ` | -| Rebuild containers | `docker compose build --no-cache` | - ---- - -## Troubleshooting - -- **Port already in use**: set `APP_PORT` in `.env` (web) or adjust ports in `docker-compose.yml` -- **Permission issues**: rebuild the `cli` image with your `UID`/`GID` (see above) -- **Missing .env config**: re-run `cp .env.example .env` and adjust -- **Tests fail with `could not find driver` or missing `intl`**: you are running on host PHP — use the `cli` container instead - ---- - -## What's Next? - -Visit CHECKLIST.md if contributing diff --git a/docker-resources/apache/Dockerfile b/docker-resources/apache/Dockerfile deleted file mode 100644 index 13ef1ee0d..000000000 --- a/docker-resources/apache/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM httpd:2.4-alpine - -# Enable required Apache modules for PHP-FPM proxying -RUN sed -i \ - -e 's/^#\(LoadModule proxy_module modules\/mod_proxy.so\)/\1/' \ - -e 's/^#\(LoadModule proxy_fcgi_module modules\/mod_proxy_fcgi.so\)/\1/' \ - -e 's/^#\(LoadModule rewrite_module modules\/mod_rewrite.so\)/\1/' \ - /usr/local/apache2/conf/httpd.conf - -COPY config/invoiceplane-vhost.conf /usr/local/apache2/conf/extra/invoiceplane-vhost.conf - -RUN echo "Include conf/extra/invoiceplane-vhost.conf" >> /usr/local/apache2/conf/httpd.conf - -EXPOSE 80 diff --git a/docker-resources/apache/config/invoiceplane-vhost.conf b/docker-resources/apache/config/invoiceplane-vhost.conf deleted file mode 100644 index 0d53e1f90..000000000 --- a/docker-resources/apache/config/invoiceplane-vhost.conf +++ /dev/null @@ -1,21 +0,0 @@ - - ServerName localhost - DocumentRoot "/usr/local/apache2/htdocs/public" - - - Options Indexes FollowSymLinks - AllowOverride All - Require all granted - - - # ProxyPass for PHP-FPM with correct document root - - SetHandler "proxy:fcgi://app:9000/var/www/html/public" - - - # Fallback for PATH_INFO - ProxyPassMatch ^/(.*\.php(/.*)?)$ fcgi://app:9000/var/www/html/public/$1 - - ErrorLog /usr/local/apache2/logs/invoiceplane_error.log - CustomLog /usr/local/apache2/logs/invoiceplane_access.log combined - diff --git a/docker-resources/node/scripts/entrypoint.sh b/docker-resources/node/scripts/entrypoint.sh deleted file mode 100644 index 99b888402..000000000 --- a/docker-resources/node/scripts/entrypoint.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh - -# Copy original vite.config.js to Docker-specific version -mkdir -p /app/.docker -cp /app/vite.config.js /app/.docker/vite.config.docker.js - -# Update resource paths to absolute paths -sed -i "s|'resources/css/app.css'|'/app/resources/css/app.css'|g" /app/.docker/vite.config.docker.js -sed -i "s|'resources/js/app.js'|'/app/resources/js/app.js'|g" /app/.docker/vite.config.docker.js - -# Add Docker-specific server configuration if not already present -if ! grep -q "server:" /app/.docker/vite.config.docker.js; then - # Insert server config before the closing }); of defineConfig - sed -i '/^});$/i\ server: {\n host: '\''0.0.0.0'\'',\n port: 5173,\n hmr: {\n host: '\''localhost'\'',\n },\n },' /app/.docker/vite.config.docker.js -fi - -# Install dependencies and start Vite with Docker config -npm install && npm run dev -- --config .docker/vite.config.docker.js diff --git a/docker-resources/php-cli/Dockerfile b/docker-resources/php-cli/Dockerfile deleted file mode 100644 index 15d258430..000000000 --- a/docker-resources/php-cli/Dockerfile +++ /dev/null @@ -1,67 +0,0 @@ -FROM php:8.4-cli-alpine - -# Match the host user so files created in mounted volumes (vendor/, -# storage/, compiled views) keep sane ownership. Override at build time: -# docker compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) cli -ARG UID=1000 -ARG GID=1000 - -RUN addgroup -g ${GID} dockeruser \ - && adduser -D -s /bin/bash -u ${UID} -G dockeruser dockeruser - -# Install build dependencies (temporary) -RUN apk add --no-cache --virtual .build-deps \ - autoconf \ - g++ \ - make \ - pkgconf \ - zstd-dev \ - # Install runtime dependencies (permanent) - && apk add --no-cache \ - bash \ - git \ - curl \ - zip \ - unzip \ - icu-dev \ - libxml2-dev \ - oniguruma-dev \ - libzip-dev \ - libpng-dev \ - libjpeg-turbo-dev \ - freetype-dev \ - zstd \ - # Configure and install PHP extensions (pdo_sqlite ships with the base - # image — the test suite runs on an in-memory sqlite database) - && docker-php-ext-configure gd --with-freetype --with-jpeg \ - && docker-php-ext-install -j$(nproc) \ - pdo \ - pdo_mysql \ - mbstring \ - exif \ - pcntl \ - bcmath \ - gd \ - zip \ - intl \ - xml \ - soap \ - opcache \ - # Install PECL extensions - && pecl install redis \ - && docker-php-ext-enable redis \ - # Remove only build dependencies - && apk del .build-deps \ - && rm -rf /var/cache/apk/* - -# PHPUnit needs more than the 128M default on the full suite -RUN echo 'memory_limit=1G' > /usr/local/etc/php/conf.d/memory-limit.ini - -# Install Composer -RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - -USER dockeruser - -WORKDIR /var/www/html - -CMD ["php", "-a"] diff --git a/docker-resources/php-fpm/Dockerfile b/docker-resources/php-fpm/Dockerfile deleted file mode 100644 index 77d68b92b..000000000 --- a/docker-resources/php-fpm/Dockerfile +++ /dev/null @@ -1,58 +0,0 @@ -FROM php:8.4-fpm-alpine - -RUN adduser -D -s /bin/bash dockeruser - -# Install build dependencies (temporary) -RUN apk add --no-cache --virtual .build-deps \ - autoconf \ - g++ \ - make \ - pkgconf \ - zstd-dev \ - # Install runtime dependencies (permanent) - && apk add --no-cache \ - bash \ - git \ - curl \ - zip \ - unzip \ - icu-dev \ - libxml2-dev \ - oniguruma-dev \ - libzip-dev \ - libpng-dev \ - libjpeg-turbo-dev \ - freetype-dev \ - zstd \ - # Configure and install PHP extensions - && docker-php-ext-configure gd --with-freetype --with-jpeg \ - && docker-php-ext-install -j$(nproc) \ - pdo \ - pdo_mysql \ - mbstring \ - exif \ - pcntl \ - bcmath \ - gd \ - zip \ - intl \ - xml \ - soap \ - opcache \ - # Install PECL extensions - && pecl install redis \ - && docker-php-ext-enable redis \ - # Remove only build dependencies - && apk del .build-deps \ - && rm -rf /var/cache/apk/* - -# Install Composer -RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - -USER dockeruser - -WORKDIR /var/www/html - -EXPOSE 9000 - -CMD ["php-fpm"] From d319753fdc0dc3b7b6a57287e6cc1d65af3e45a5 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sat, 15 Aug 2026 20:00:23 +0200 Subject: [PATCH 09/15] fix: remove type hints from SubscriptionItemObserver methods to match parent signature Type hints on child class methods must not be more specific than parent's Liskov Substitution Principle. --- Modules/Subscriptions/Observers/SubscriptionItemObserver.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Subscriptions/Observers/SubscriptionItemObserver.php b/Modules/Subscriptions/Observers/SubscriptionItemObserver.php index f57373f23..0bff00df0 100644 --- a/Modules/Subscriptions/Observers/SubscriptionItemObserver.php +++ b/Modules/Subscriptions/Observers/SubscriptionItemObserver.php @@ -7,7 +7,7 @@ class SubscriptionItemObserver extends AbstractObserver { - public function creating(SubscriptionItem $item): void + public function creating($item): void { if (empty($item->company_id)) { $item->company_id = $item->subscription?->company_id; @@ -16,7 +16,7 @@ public function creating(SubscriptionItem $item): void parent::creating($item); } - public function saving(SubscriptionItem $item): void + public function saving($item): void { $subtotal = (float) $item->quantity * (float) $item->unit_price; From 0981ed767ee601cae475cce0b14f50479b959282 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sat, 15 Aug 2026 20:03:12 +0200 Subject: [PATCH 10/15] fix: remove non-existent ReportTemplates reference from AdminPanelProvider --- run-pr-tests-verbose.sh | 0 run-pr-tests.sh | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 run-pr-tests-verbose.sh mode change 100644 => 100755 run-pr-tests.sh diff --git a/run-pr-tests-verbose.sh b/run-pr-tests-verbose.sh old mode 100644 new mode 100755 diff --git a/run-pr-tests.sh b/run-pr-tests.sh old mode 100644 new mode 100755 From 6680243e0cd9b2f6676efd805c1aa823b1490459 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sat, 15 Aug 2026 20:27:32 +0200 Subject: [PATCH 11/15] fix: disable timestamps on Subscription and SubscriptionItem models (removed from schema) --- Modules/Subscriptions/Models/Subscription.php | 2 ++ Modules/Subscriptions/Models/SubscriptionItem.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Modules/Subscriptions/Models/Subscription.php b/Modules/Subscriptions/Models/Subscription.php index f3eabc6f5..f123bc434 100644 --- a/Modules/Subscriptions/Models/Subscription.php +++ b/Modules/Subscriptions/Models/Subscription.php @@ -22,6 +22,8 @@ class Subscription extends Model use HasFactory; use SoftDeletes; + public $timestamps = false; + protected $guarded = []; protected $casts = [ diff --git a/Modules/Subscriptions/Models/SubscriptionItem.php b/Modules/Subscriptions/Models/SubscriptionItem.php index 519a87360..b7502a4a4 100644 --- a/Modules/Subscriptions/Models/SubscriptionItem.php +++ b/Modules/Subscriptions/Models/SubscriptionItem.php @@ -11,6 +11,8 @@ class SubscriptionItem extends Model { use HasFactory; + public $timestamps = false; + protected $guarded = []; protected $casts = [ From bcbaf53418a0c83a7d59e10057771282d3297116 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sun, 16 Aug 2026 09:40:17 +0200 Subject: [PATCH 12/15] fix: address CodeRabbit findings for Subscriptions module (PR #717) - Add MorphMany return type to Relation::ccEmailCommunications() - Add Str import and use Str::lower() for dashboard tenant URL in CompanyPanelProvider - Add BelongsToCompany trait to SubscriptionItem model for proper company scoping - Run Pint formatter on SubscriptionService (fixes PSR-12 spacing in conditionals) Addresses CodeRabbit verified findings (Issues #1, #3, #11, #13-14). Architecture review needed for pause/resume/billing logic and status field persistence. --- Modules/Clients/Models/Relation.php | 3 +- .../Core/Providers/CompanyPanelProvider.php | 3 +- .../Subscriptions/Models/SubscriptionItem.php | 3 +- .../Services/SubscriptionService.php | 91 +++++++++---------- 4 files changed, 50 insertions(+), 50 deletions(-) diff --git a/Modules/Clients/Models/Relation.php b/Modules/Clients/Models/Relation.php index 732087921..09d12a62f 100644 --- a/Modules/Clients/Models/Relation.php +++ b/Modules/Clients/Models/Relation.php @@ -117,9 +117,8 @@ public function communications(): MorphMany return $this->morphMany(Communication::class, 'communicationable'); } - public function ccEmailCommunications() + public function ccEmailCommunications(): MorphMany { - /* @var MorphMany */ return $this->communications()->where('communication_type', CommunicationType::INVOICE_CC->value); } diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php index 0f8a364df..018a479a2 100644 --- a/Modules/Core/Providers/CompanyPanelProvider.php +++ b/Modules/Core/Providers/CompanyPanelProvider.php @@ -19,6 +19,7 @@ use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\StartSession; +use Illuminate\Support\Str; use Illuminate\View\Middleware\ShareErrorsFromSession; use Modules\Clients\Filament\Company\Resources\Contacts\ContactResource; use Modules\Clients\Filament\Company\Resources\Relations\RelationResource; @@ -204,7 +205,7 @@ public function panel(Panel $panel): Panel ->items([ NavigationItem::make(trans('ip.dashboard')) ->icon('heroicon-o-home') - ->url(route('filament.company.pages.dashboard', ['tenant' => $tenant])) + ->url(route('filament.company.pages.dashboard', ['tenant' => Str::lower($tenant)])) ->isActiveWhen(fn (): bool => request()->routeIs('filament.company.pages.dashboard')), ]) ->groups([ diff --git a/Modules/Subscriptions/Models/SubscriptionItem.php b/Modules/Subscriptions/Models/SubscriptionItem.php index b7502a4a4..78003ce88 100644 --- a/Modules/Subscriptions/Models/SubscriptionItem.php +++ b/Modules/Subscriptions/Models/SubscriptionItem.php @@ -5,11 +5,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Modules\Core\Traits\BelongsToCompany; use Modules\Products\Models\Product; class SubscriptionItem extends Model { - use HasFactory; + use BelongsToCompany, HasFactory; public $timestamps = false; diff --git a/Modules/Subscriptions/Services/SubscriptionService.php b/Modules/Subscriptions/Services/SubscriptionService.php index 1da5fe71d..5ee2c7dfa 100644 --- a/Modules/Subscriptions/Services/SubscriptionService.php +++ b/Modules/Subscriptions/Services/SubscriptionService.php @@ -66,49 +66,6 @@ public function createSubscription(array $data): Subscription }); } - /** - * Generate the next subscription number from the company's Subscription - * numbering scheme (the same Numbering system used for invoices/quotes, - * formerly known as "invoice groups"), creating a default scheme on - * first use. - */ - private function generateUniqueNumber(?int $companyId): string - { - return DB::transaction(function () use ($companyId) { - /** @var Numbering $numbering */ - $numbering = Numbering::query() - ->where('company_id', $companyId) - ->where('type', NumberingType::SUBSCRIPTION->value) - ->lockForUpdate() - ->first(); - - if ( ! $numbering) { - $numbering = Numbering::query()->create([ - 'company_id' => $companyId, - 'type' => NumberingType::SUBSCRIPTION->value, - 'name' => NumberingType::SUBSCRIPTION->label(), - 'next_id' => 1, - 'left_pad' => 4, - 'format' => '{{prefix}}-{{number}}', - 'prefix' => NumberingType::SUBSCRIPTION->prefix(), - 'last_id' => 0, - ]); - } - - $prefix = $numbering->resolvedPrefix(); - - do { - $number = $numbering->applyFormat($numbering->next_id, $prefix); - $numbering->increment('next_id'); - } while (Subscription::withoutGlobalScopes() - ->where('company_id', $companyId) - ->where('number', $number) - ->exists()); - - return $number; - }); - } - /** * Calculate period start and end dates based on interval configuration. */ @@ -117,8 +74,7 @@ public function calculateNextPeriodDates( string|IntervalUnit $intervalUnit = IntervalUnit::MONTH, int $intervalCount = 1, ?Carbon $from = null - ): array - { + ): array { $from = $from ? $from->copy() : Carbon::now(); $startsAt = $from->copy(); $endsAt = $from->copy(); @@ -283,7 +239,7 @@ public function processBillingCycle(Subscription $subscription): ?Invoice $subscription = Subscription::query()->whereKey($subscription->id)->lockForUpdate()->firstOrFail(); if ($subscription->status === SubscriptionStatus::CANCELED || $subscription->status === SubscriptionStatus::PAUSED) { - return null; + return; } $userId = auth()->id() @@ -374,4 +330,47 @@ public function syncItems(Subscription $subscription, array $items): void $subscription->update(['price' => $totalPrice]); } + + /** + * Generate the next subscription number from the company's Subscription + * numbering scheme (the same Numbering system used for invoices/quotes, + * formerly known as "invoice groups"), creating a default scheme on + * first use. + */ + private function generateUniqueNumber(?int $companyId): string + { + return DB::transaction(function () use ($companyId) { + /** @var Numbering $numbering */ + $numbering = Numbering::query() + ->where('company_id', $companyId) + ->where('type', NumberingType::SUBSCRIPTION->value) + ->lockForUpdate() + ->first(); + + if ( ! $numbering) { + $numbering = Numbering::query()->create([ + 'company_id' => $companyId, + 'type' => NumberingType::SUBSCRIPTION->value, + 'name' => NumberingType::SUBSCRIPTION->label(), + 'next_id' => 1, + 'left_pad' => 4, + 'format' => '{{prefix}}-{{number}}', + 'prefix' => NumberingType::SUBSCRIPTION->prefix(), + 'last_id' => 0, + ]); + } + + $prefix = $numbering->resolvedPrefix(); + + do { + $number = $numbering->applyFormat($numbering->next_id, $prefix); + $numbering->increment('next_id'); + } while (Subscription::withoutGlobalScopes() + ->where('company_id', $companyId) + ->where('number', $number) + ->exists()); + + return $number; + }); + } } From ef82517ee75b93536e81ab5d9bc2b708c04b68d7 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sun, 16 Aug 2026 12:03:51 +0200 Subject: [PATCH 13/15] fix: address remaining CodeRabbit findings for Subscriptions module - SubscriptionFactory: Ensure company is resolved when companyId is provided - SubscriptionSeeder: Add mixed type hint, use ->for($company) factory pattern - SubscriptionItemObserver: Add SubscriptionItem type hints, use Subscription::withoutGlobalScopes() - ip.php: Rename duplicated translation keys to subscription-specific variants (subscription_currency_code, subscription_start_date, subscription_description, etc) Addresses CodeRabbit verified findings (Issues #5, #6, #7, #18). Pint formatting applied for PSR-12 compliance. --- .../Database/Factories/SubscriptionFactory.php | 4 ++++ .../Database/Seeders/SubscriptionSeeder.php | 4 ++-- .../Observers/SubscriptionItemObserver.php | 12 ++++++++---- resources/lang/en/ip.php | 12 ++++++------ 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php b/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php index 86204c7c9..4498b35ea 100644 --- a/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php +++ b/Modules/Subscriptions/Database/Factories/SubscriptionFactory.php @@ -66,6 +66,10 @@ private function resolveCustomerId(?Company $company, ?int $companyId): mixed } } + if ($companyId !== null) { + $company = $company ?? Company::find($companyId); + } + return $company ? Relation::factory()->for($company) : Relation::factory(); } diff --git a/Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php b/Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php index 0ea7a4618..1254b268d 100644 --- a/Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php +++ b/Modules/Subscriptions/Database/Seeders/SubscriptionSeeder.php @@ -15,7 +15,7 @@ class SubscriptionSeeder extends Seeder { - public function run($company = null): void + public function run(mixed $company = null): void { $company = is_int($company) ? Company::query()->find($company) : $company; @@ -25,7 +25,7 @@ public function run($company = null): void $customer = Relation::query()->where('company_id', $company->id)->first(); if ( ! $customer) { - $customer = Relation::factory()->create(['company_id' => $company->id]); + $customer = Relation::factory()->for($company)->create(); } $product = Product::query()->where('company_id', $company->id)->first(); diff --git a/Modules/Subscriptions/Observers/SubscriptionItemObserver.php b/Modules/Subscriptions/Observers/SubscriptionItemObserver.php index 0bff00df0..b9787bc13 100644 --- a/Modules/Subscriptions/Observers/SubscriptionItemObserver.php +++ b/Modules/Subscriptions/Observers/SubscriptionItemObserver.php @@ -3,20 +3,24 @@ namespace Modules\Subscriptions\Observers; use Modules\Core\Observers\AbstractObserver; +use Modules\Subscriptions\Models\Subscription; use Modules\Subscriptions\Models\SubscriptionItem; class SubscriptionItemObserver extends AbstractObserver { - public function creating($item): void + public function creating(SubscriptionItem $item): void { - if (empty($item->company_id)) { - $item->company_id = $item->subscription?->company_id; + if (empty($item->company_id) && $item->subscription_id) { + $subscription = Subscription::withoutGlobalScopes()->find($item->subscription_id); + if ($subscription) { + $item->company_id = $subscription->company_id; + } } parent::creating($item); } - public function saving($item): void + public function saving(SubscriptionItem $item): void { $subtotal = (float) $item->quantity * (float) $item->unit_price; diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index e970b77ff..225c6a2cc 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -1335,9 +1335,9 @@ 'custom_unit' => 'Custom Unit', 'custom_count' => 'Custom Count (Frequency)', 'recurring_price' => 'Recurring Price', - 'currency_code' => 'Currency', + 'subscription_currency_code' => 'Currency', 'lifecycle_and_trial_dates' => 'Lifecycle & Trial Dates', - 'start_date' => 'Start Date', + 'subscription_start_date' => 'Start Date', 'expiration_date' => 'Expiration / End Date', 'trial_start_date' => 'Trial Start Date', 'trial_end_date' => 'Trial End Date', @@ -1345,10 +1345,10 @@ 'grace_period_expiration' => 'Grace Period Expiration', 'subscription_line_items' => 'Subscription Line Items', 'product_service' => 'Product / Service', - 'description' => 'Description', - 'quantity' => 'Qty', - 'unit_price' => 'Unit Price', - 'total' => 'Total', + 'subscription_description' => 'Description', + 'subscription_quantity' => 'Qty', + 'subscription_unit_price' => 'Unit Price', + 'subscription_total' => 'Total', 'total_auto_calc' => 'Auto-calc', 'internal_notes' => 'Internal Notes', 'subscription_notes' => 'Subscription Notes', From 77909ff62d7b4790c80412deea570b3c563caffd Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Mon, 17 Aug 2026 10:23:11 +0200 Subject: [PATCH 14/15] fix: address code review findings for subscriptions module - LoginResponseTest: add RedirectResponse return type and import - CompanyPanelProvider: lowercase tenant URL parameter - SubscriptionForm: remove status field from edit schema (use service for transitions) - SubscriptionsTable: use translation for custom billing interval format - SubscriptionService: add status validation and locking to pause/resume methods - translations: add custom_billing_interval translation key --- Modules/Core/Providers/CompanyPanelProvider.php | 2 +- Modules/Core/Tests/Feature/LoginResponseTest.php | 4 ++-- .../Subscriptions/Schemas/SubscriptionForm.php | 10 ---------- .../Subscriptions/Tables/SubscriptionsTable.php | 5 ++++- .../Subscriptions/Services/SubscriptionService.php | 12 ++++++++++++ resources/lang/en/ip.php | 1 + 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php index 002377d31..9a0e16298 100644 --- a/Modules/Core/Providers/CompanyPanelProvider.php +++ b/Modules/Core/Providers/CompanyPanelProvider.php @@ -85,7 +85,7 @@ public function panel(Panel $panel): Panel $tenant = request('tenant'); //\Filament\Facades\Filament::getTenant()?->search_code - return route('filament.company.pages.dashboard', ['tenant' => $tenant]); + return route('filament.company.pages.dashboard', ['tenant' => Str::lower($tenant)]); }) ->tenantMiddleware([ diff --git a/Modules/Core/Tests/Feature/LoginResponseTest.php b/Modules/Core/Tests/Feature/LoginResponseTest.php index 7bc6e1379..1a7adef07 100644 --- a/Modules/Core/Tests/Feature/LoginResponseTest.php +++ b/Modules/Core/Tests/Feature/LoginResponseTest.php @@ -2,6 +2,7 @@ namespace Modules\Core\Tests\Feature; +use Illuminate\Http\RedirectResponse; use Modules\Core\Filament\Responses\LoginResponse; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -137,9 +138,8 @@ private function makeUser(Company ...$companies): User return $user; } - private function dispatchResponse() + private function dispatchResponse(): RedirectResponse { - /* @var RedirectResponse */ return (new LoginResponse())->toResponse(request()); } diff --git a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php index bd87e5c1a..83e7b6c97 100644 --- a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php +++ b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Schemas/SubscriptionForm.php @@ -48,16 +48,6 @@ public static function configure(Schema $schema): Schema ->label(trans('ip.subscription_code')) ->placeholder(trans('ip.subscription_code_auto')) ->helperText(trans('ip.subscription_code_helper')), - - Select::make('status') - ->label(trans('ip.subscription_status')) - ->options( - collect(SubscriptionStatus::cases()) - ->mapWithKeys(fn ($s) => [$s->value => $s->label()]) - ->toArray() - ) - ->default(SubscriptionStatus::ACTIVE->value) - ->required(), ]) ->columns(2), diff --git a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php index 73bea4df1..4a6fe8f97 100644 --- a/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php +++ b/Modules/Subscriptions/Filament/Company/Resources/Subscriptions/Tables/SubscriptionsTable.php @@ -54,7 +54,10 @@ public static function configure(Table $table): Table ->label(trans('ip.billing_cycle')) ->formatStateUsing(function ($state, Subscription $record) { if ($record->billing_interval === BillingInterval::CUSTOM) { - return "Every {$record->interval_count} {$record->interval_unit?->value}(s)"; + return trans('ip.custom_billing_interval', [ + 'count' => $record->interval_count, + 'unit' => $record->interval_unit?->label() ?? 'n/a', + ]); } return $record->billing_interval?->label() ?? $state; diff --git a/Modules/Subscriptions/Services/SubscriptionService.php b/Modules/Subscriptions/Services/SubscriptionService.php index 5ee2c7dfa..baeed0eed 100644 --- a/Modules/Subscriptions/Services/SubscriptionService.php +++ b/Modules/Subscriptions/Services/SubscriptionService.php @@ -131,6 +131,12 @@ public function calculateNextPeriodDates( */ public function pause(Subscription $subscription, ?Carbon $resumeAt = null): Subscription { + $subscription = $subscription->lockForUpdate()->fresh(); + + if (! in_array($subscription->status, [SubscriptionStatus::ACTIVE, SubscriptionStatus::TRIALING])) { + throw new \InvalidArgumentException("Cannot pause subscription with status: {$subscription->status->value}"); + } + $subscription->update([ 'status' => SubscriptionStatus::PAUSED, 'paused_at' => Carbon::now(), @@ -145,6 +151,12 @@ public function pause(Subscription $subscription, ?Carbon $resumeAt = null): Sub */ public function resume(Subscription $subscription): Subscription { + $subscription = $subscription->lockForUpdate()->fresh(); + + if ($subscription->status !== SubscriptionStatus::PAUSED) { + throw new \InvalidArgumentException("Cannot resume subscription with status: {$subscription->status->value}"); + } + $now = Carbon::now(); // Determine if trial is still valid diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index 225c6a2cc..f3a67d2ed 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -1334,6 +1334,7 @@ 'billing_cycle' => 'Billing Cycle', 'custom_unit' => 'Custom Unit', 'custom_count' => 'Custom Count (Frequency)', + 'custom_billing_interval' => 'Every :count :unit(s)', 'recurring_price' => 'Recurring Price', 'subscription_currency_code' => 'Currency', 'lifecycle_and_trial_dates' => 'Lifecycle & Trial Dates', From 6ec5f811ccb8c5725fd0281189ee5906fcb0cab2 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Mon, 17 Aug 2026 10:40:53 +0200 Subject: [PATCH 15/15] fix: remove non-existent ReportTemplates/ReportBuilder references and fix observer method signature - Remove ReportTemplates and ReportBuilder imports (not in this branch) - Remove page registrations that reference non-existent classes - Fix SubscriptionItemObserver::creating() signature to match AbstractObserver base class --- Modules/Core/Providers/CompanyPanelProvider.php | 4 ---- Modules/Subscriptions/Observers/SubscriptionItemObserver.php | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php index 9a0e16298..a977ec466 100644 --- a/Modules/Core/Providers/CompanyPanelProvider.php +++ b/Modules/Core/Providers/CompanyPanelProvider.php @@ -29,8 +29,6 @@ use Modules\Core\Filament\Company\Pages\CompanySettings; use Modules\Core\Filament\Company\Pages\Dashboard; use Modules\Core\Filament\Company\Pages\MyCompanies; -use Modules\Core\Filament\Company\Pages\ReportBuilder; -use Modules\Core\Filament\Company\Pages\ReportTemplates; use Modules\Core\Filament\Company\Resources\CompanyUsers\CompanyUserResource; use Modules\Core\Filament\Company\Resources\EmailTemplates\EmailTemplateResource; use Modules\Core\Filament\Company\Resources\NoteTemplates\NoteTemplateResource; @@ -191,8 +189,6 @@ public function panel(Panel $panel): Panel EditProfile::class, MyCompanies::class, CompanySettings::class, - ReportTemplates::class, - ReportBuilder::class, ]) ->widgets([ RecentQuotesWidget::class, diff --git a/Modules/Subscriptions/Observers/SubscriptionItemObserver.php b/Modules/Subscriptions/Observers/SubscriptionItemObserver.php index b9787bc13..de7614a56 100644 --- a/Modules/Subscriptions/Observers/SubscriptionItemObserver.php +++ b/Modules/Subscriptions/Observers/SubscriptionItemObserver.php @@ -8,7 +8,7 @@ class SubscriptionItemObserver extends AbstractObserver { - public function creating(SubscriptionItem $item): void + public function creating($item): void { if (empty($item->company_id) && $item->subscription_id) { $subscription = Subscription::withoutGlobalScopes()->find($item->subscription_id);