diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 0000000..d323f0a --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,111 @@ +getDirty()), self::FROZEN); + + if ($frozen !== []) { + throw new RuntimeException( + 'A subscription snapshot is immutable; tried to change: '.implode(', ', $frozen). + '. Cancel this subscription and start a new one instead.' + ); + } + }); + } + + protected function casts(): array + { + return [ + 'price_cents' => 'integer', + 'quota_gb' => 'integer', + 'traffic_gb' => 'integer', + 'seats' => 'integer', + 'ram_mb' => 'integer', + 'cores' => 'integer', + 'disk_gb' => 'integer', + 'started_at' => 'datetime', + 'current_period_start' => 'datetime', + 'current_period_end' => 'datetime', + 'pending_effective_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + } + + public function uniqueIds(): array + { + return ['uuid']; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class); + } + + /** Take today's catalogue entry and freeze it onto a new subscription. */ + public static function snapshotFrom(string $plan, string $term = self::TERM_MONTHLY): array + { + $catalogue = (array) config("provisioning.plans.{$plan}"); + + if ($catalogue === []) { + throw new RuntimeException("Unknown plan: {$plan}"); + } + + $monthly = (int) ($catalogue['price_cents'] ?? 0); + + return [ + 'plan' => $plan, + 'term' => $term, + // A yearly term is twelve months of the same price. Any discount + // belongs in the catalogue, not hidden in this conversion. + 'price_cents' => $term === self::TERM_YEARLY ? $monthly * 12 : $monthly, + 'currency' => 'EUR', + 'quota_gb' => (int) ($catalogue['quota_gb'] ?? 0), + 'traffic_gb' => (int) ($catalogue['traffic_gb'] ?? 0), + 'seats' => (int) ($catalogue['seats'] ?? 0), + 'ram_mb' => (int) ($catalogue['ram_mb'] ?? 0), + 'cores' => (int) ($catalogue['cores'] ?? 0), + 'disk_gb' => (int) ($catalogue['disk_gb'] ?? 0), + 'performance' => $catalogue['performance'] ?? null, + ]; + } + + public function isYearly(): bool + { + return $this->term === self::TERM_YEARLY; + } +} diff --git a/app/Services/Billing/PlanChange.php b/app/Services/Billing/PlanChange.php new file mode 100644 index 0000000..164f256 --- /dev/null +++ b/app/Services/Billing/PlanChange.php @@ -0,0 +1,98 @@ +copy() ?? now(); + $target = Subscription::snapshotFrom($targetPlan, $subscription->term); + + $isUpgrade = $target['price_cents'] > $subscription->price_cents; + + // Whole days, and never zero: on the last day of a term there is still + // a day of service left to charge or refund. + $termDays = max(1, (int) $subscription->current_period_start->diffInDays($subscription->current_period_end)); + $remainingDays = (int) max(0, floor($at->diffInDays($subscription->current_period_end, absolute: false))); + + if (! $isUpgrade) { + // Downgrade: nothing changes and nothing is owed until the term ends. + return new self( + isUpgrade: false, + allowedNow: false, + chargeCents: 0, + creditCents: 0, + effectiveAt: $subscription->current_period_end->copy(), + remainingDays: $remainingDays, + termDays: $termDays, + ); + } + + // Pro rata on both sides: the unused part of what they already paid is + // worth something, and only the difference is charged. + $unusedOld = (int) round($subscription->price_cents * $remainingDays / $termDays); + $newForRest = (int) round($target['price_cents'] * $remainingDays / $termDays); + + return new self( + isUpgrade: true, + allowedNow: true, + chargeCents: max(0, $newForRest - $unusedOld), + creditCents: 0, + effectiveAt: $at, + remainingDays: $remainingDays, + termDays: $termDays, + ); + } + + /** + * What a mid-term downgrade would be worth, for the cases where we grant one + * anyway — a goodwill move or a support decision. Never offered to the + * customer as a self-service button, because a downgrade before the term is + * up is a change to the contract, not a setting. + */ + public static function goodwillCredit(Subscription $subscription, string $targetPlan, ?Carbon $at = null): int + { + $change = self::evaluate($subscription, $targetPlan, $at); + + if ($change->isUpgrade) { + throw new RuntimeException('An upgrade is charged, not credited.'); + } + + $target = Subscription::snapshotFrom($targetPlan, $subscription->term); + $difference = $subscription->price_cents - $target['price_cents']; + + // Only the unused part of the difference: they used the expensive plan + // for the days that have passed. + return (int) round($difference * $change->remainingDays / $change->termDays); + } +} diff --git a/database/factories/SubscriptionFactory.php b/database/factories/SubscriptionFactory.php new file mode 100644 index 0000000..456f2c0 --- /dev/null +++ b/database/factories/SubscriptionFactory.php @@ -0,0 +1,37 @@ +startOfMonth(); + + return array_merge(Subscription::snapshotFrom('team'), [ + 'customer_id' => Customer::factory(), + 'started_at' => $start, + 'current_period_start' => $start, + 'current_period_end' => $start->copy()->addMonth(), + 'status' => 'active', + ]); + } + + public function plan(string $plan, string $term = Subscription::TERM_MONTHLY): static + { + return $this->state(function () use ($plan, $term) { + $start = now()->startOfMonth(); + + return array_merge(Subscription::snapshotFrom($plan, $term), [ + 'current_period_start' => $start, + 'current_period_end' => $term === Subscription::TERM_YEARLY + ? $start->copy()->addYear() + : $start->copy()->addMonth(), + ]); + }); + } +} diff --git a/database/migrations/2026_07_26_020000_create_subscriptions_table.php b/database/migrations/2026_07_26_020000_create_subscriptions_table.php new file mode 100644 index 0000000..45762fb --- /dev/null +++ b/database/migrations/2026_07_26_020000_create_subscriptions_table.php @@ -0,0 +1,65 @@ +id(); + $table->uuid()->unique(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->foreignId('instance_id')->nullable()->constrained()->nullOnDelete(); + + $table->string('plan'); + $table->string('term')->default('monthly'); // monthly|yearly + + // ── Snapshot: the conditions this customer is owed ────────────── + $table->unsignedInteger('price_cents'); // net, per term + $table->string('currency', 3)->default('EUR'); + $table->unsignedBigInteger('quota_gb'); + $table->unsignedBigInteger('traffic_gb'); + $table->unsignedInteger('seats'); + $table->unsignedInteger('ram_mb'); + $table->unsignedInteger('cores'); + $table->unsignedBigInteger('disk_gb'); + $table->string('performance')->nullable(); + + $table->timestamp('started_at'); + $table->timestamp('current_period_start'); + $table->timestamp('current_period_end'); + + // A downgrade is scheduled, never immediate: it lands when the term + // the customer already paid for runs out. + $table->string('pending_plan')->nullable(); + $table->timestamp('pending_effective_at')->nullable(); + + $table->string('status')->default('active'); // active|cancelled + $table->timestamp('cancelled_at')->nullable(); + $table->timestamps(); + + $table->index(['customer_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscriptions'); + } +}; diff --git a/tests/Feature/Billing/PlanChangeTest.php b/tests/Feature/Billing/PlanChangeTest.php new file mode 100644 index 0000000..a9b5596 --- /dev/null +++ b/tests/Feature/Billing/PlanChangeTest.php @@ -0,0 +1,125 @@ +plan('team')->create(); + $bookedPrice = $subscription->price_cents; + + config()->set('provisioning.plans.team.price_cents', $bookedPrice * 2); + + // The existing customer keeps their terms — that is the entire point. + expect($subscription->fresh()->price_cents)->toBe($bookedPrice) + ->and(Subscription::snapshotFrom('team')['price_cents'])->toBe($bookedPrice * 2); +}); + +it('refuses to rewrite a snapshot after the fact', function () { + $subscription = Subscription::factory()->plan('team')->create(); + + expect(fn () => $subscription->update(['price_cents' => 1])) + ->toThrow(RuntimeException::class, 'immutable'); + + expect($subscription->fresh()->price_cents)->toBe(Subscription::snapshotFrom('team')['price_cents']); + + // The rejected value is still on the in-memory model, so it has to be + // discarded before anything else can be saved — otherwise the next write + // carries the forbidden change along and is refused too. + $subscription->refresh(); + + // Operational fields still move. + $subscription->update(['pending_plan' => 'start']); + expect($subscription->fresh()->pending_plan)->toBe('start'); +}); + +it('charges only the remaining days when upgrading mid-month', function () { + $start = Carbon::parse('2026-08-01'); + $subscription = Subscription::factory()->plan('start')->create([ + 'current_period_start' => $start, + 'current_period_end' => $start->copy()->addMonth(), + ]); + + $change = PlanChange::evaluate($subscription, 'team', Carbon::parse('2026-08-16')); + + $startPrice = Subscription::snapshotFrom('start')['price_cents']; + $teamPrice = Subscription::snapshotFrom('team')['price_cents']; + $expected = (int) round($teamPrice * 16 / 31) - (int) round($startPrice * 16 / 31); + + expect($change->isUpgrade)->toBeTrue() + ->and($change->allowedNow)->toBeTrue() + ->and($change->chargeCents)->toBe($expected) + ->and($change->chargeCents)->toBeLessThan($teamPrice) + ->and($change->chargeCents)->toBeGreaterThan(0); +}); + +it('makes an upgrade on the last day cost almost nothing', function () { + $start = Carbon::parse('2026-08-01'); + $subscription = Subscription::factory()->plan('start')->create([ + 'current_period_start' => $start, + 'current_period_end' => $start->copy()->addMonth(), + ]); + + $change = PlanChange::evaluate($subscription, 'business', Carbon::parse('2026-08-31')); + + // One day of the difference, not a month of it. + expect($change->remainingDays)->toBe(1) + ->and($change->chargeCents)->toBeLessThan(2000); +}); + +it('holds a downgrade until the term the customer paid for is over', function () { + $start = Carbon::parse('2026-01-01'); + $yearly = Subscription::factory()->plan('business', Subscription::TERM_YEARLY)->create([ + 'current_period_start' => $start, + 'current_period_end' => $start->copy()->addYear(), + ]); + + $change = PlanChange::evaluate($yearly, 'start', Carbon::parse('2026-03-15')); + + // Someone on a yearly contract bought a year, not three months. + expect($change->isUpgrade)->toBeFalse() + ->and($change->allowedNow)->toBeFalse() + ->and($change->chargeCents)->toBe(0) + ->and($change->effectiveAt->toDateString())->toBe('2027-01-01'); +}); + +it('lets a monthly customer move down at the end of the month', function () { + $start = Carbon::parse('2026-08-01'); + $monthly = Subscription::factory()->plan('business')->create([ + 'current_period_start' => $start, + 'current_period_end' => $start->copy()->addMonth(), + ]); + + expect(PlanChange::evaluate($monthly, 'team', Carbon::parse('2026-08-10'))->effectiveAt->toDateString()) + ->toBe('2026-09-01'); +}); + +it('credits only the unused part of the difference for a goodwill downgrade', function () { + $start = Carbon::parse('2026-08-01'); + $subscription = Subscription::factory()->plan('business')->create([ + 'current_period_start' => $start, + 'current_period_end' => $start->copy()->addMonth(), + ]); + + // 11 days left of 31: they used the expensive plan for the other 20. + $credit = PlanChange::goodwillCredit($subscription, 'team', Carbon::parse('2026-08-21')); + + $difference = Subscription::snapshotFrom('business')['price_cents'] - Subscription::snapshotFrom('team')['price_cents']; + expect($credit)->toBe((int) round($difference * 11 / 31)) + ->and($credit)->toBeLessThan($difference); +}); + +it('will not call an upgrade a credit', function () { + $subscription = Subscription::factory()->plan('start')->create(); + + expect(fn () => PlanChange::goodwillCredit($subscription, 'business')) + ->toThrow(RuntimeException::class); +}); + +it('prices a yearly term as twelve months of the same plan', function () { + $monthly = Subscription::snapshotFrom('team'); + $yearly = Subscription::snapshotFrom('team', Subscription::TERM_YEARLY); + + // Any discount belongs in the catalogue, not hidden in the conversion. + expect($yearly['price_cents'])->toBe($monthly['price_cents'] * 12); +});