diff --git a/src/app/Console/Commands/Wallet/TrialEndCommand.php b/src/app/Console/Commands/Wallet/TrialEndCommand.php --- a/src/app/Console/Commands/Wallet/TrialEndCommand.php +++ b/src/app/Console/Commands/Wallet/TrialEndCommand.php @@ -54,6 +54,12 @@ ->cursor(); foreach ($wallets as $wallet) { + // Skip accounts with no trial period, or a period longer than a month + $plan = $wallet->plan(); + if (!$plan || $plan->free_months != 1) { + continue; + } + // Send the email asynchronously \App\Jobs\TrialEndEmail::dispatch($wallet->owner); diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -47,26 +47,6 @@ ]; /** - * Return the costs per day for this entitlement. - * - * @return float - */ - public function costsPerDay() - { - if ($this->cost == 0) { - return (float) 0; - } - - $discount = $this->wallet->getDiscountRate(); - - $daysInLastMonth = \App\Utils::daysInLastMonth(); - - $costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth; - - return $costsPerDay; - } - - /** * Create a transaction record for this entitlement. * * @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -232,10 +232,14 @@ return null; } - // the owner was created less than a month ago - if ($wallet->owner->created_at > Carbon::now()->subMonthsWithoutOverflow(1)) { - // but more than two weeks ago, notice of trial ending - if ($wallet->owner->created_at <= Carbon::now()->subWeeks(2)) { + $plan = $wallet->plan(); + $freeMonths = $plan ? $plan->free_months : 0; + $trialEnd = $freeMonths ? $wallet->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null; + + // the owner is still in the trial period + if ($trialEnd && $trialEnd > Carbon::now()) { + // notice of trial ending if less than 2 weeks left + if ($trialEnd < Carbon::now()->addWeeks(2)) { return \trans('app.wallet-notice-trial-end'); } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -114,15 +114,22 @@ return; } - // Determine if we're still within the free first month - $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1); + $now = Carbon::now(); - if ($freeMonthEnds >= Carbon::now()) { - return; + // Determine if we're still within the trial period + $trial = $entitlement->wallet->trialInfo(); + if ( + !empty($trial) + && $entitlement->updated_at < $trial['end'] + && in_array($entitlement->sku_id, $trial['skus']) + ) { + if ($trial['end'] >= $now) { + return; + } + + $entitlement->updated_at = $trial['end']; } - $now = Carbon::now(); - // get the discount rate applied to the wallet. $discount = $entitlement->wallet->getDiscountRate(); @@ -168,6 +175,8 @@ return; } + // FIXME: Shouldn't we create per-entitlement transaction record? + $entitlement->wallet->debit($cost); } } diff --git a/src/app/Plan.php b/src/app/Plan.php --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -18,6 +18,7 @@ * @property string $description * @property int $discount_qty * @property int $discount_rate + * @property int $free_months * @property string $id * @property string $mode Plan signup mode (email|token) * @property string $name @@ -48,6 +49,8 @@ 'discount_qty', // the rate of the discount for this plan 'discount_rate', + // number of free months (trial) + 'free_months', ]; /** @var array The attributes that should be cast */ @@ -55,7 +58,8 @@ 'promo_from' => 'datetime:Y-m-d H:i:s', 'promo_to' => 'datetime:Y-m-d H:i:s', 'discount_qty' => 'integer', - 'discount_rate' => 'integer' + 'discount_rate' => 'integer', + 'free_months' => 'integer' ]; /** @var array Translatable properties */ diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -75,83 +75,77 @@ */ public function chargeEntitlements($apply = true): int { - // This wallet has been created less than a month ago, this is the trial period - if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) { - // Move all the current entitlement's updated_at timestamps forward to one month after - // this wallet was created. - $freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1); - - foreach ($this->entitlements()->get()->fresh() as $entitlement) { - if ($entitlement->updated_at < $freeMonthEnds) { - $entitlement->updated_at = $freeMonthEnds; - $entitlement->save(); - } - } - - return 0; - } - + $transactions = []; $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); $isDegraded = $this->owner->isDegraded(); + $trial = $this->trialInfo(); if ($apply) { DB::beginTransaction(); } - // used to parent individual entitlement billings to the wallet debit. - $entitlementTransactions = []; - - foreach ($this->entitlements()->get() as $entitlement) { - // This entitlement has been created less than or equal to 14 days ago (this is at + // Get all entitlements... + $entitlements = $this->entitlements() + // Skip entitlements created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). - if ($entitlement->created_at > Carbon::now()->subDays(14)) { - continue; - } + // ->where('created_at', '<=', Carbon::now()->subDays(14)) + // Skip entitlements created, or billed last, less than a month ago. + ->where('updated_at', '<=', Carbon::now()->subMonthsWithoutOverflow(1)) + ->get(); + + foreach ($entitlements as $entitlement) { + // If in trial, move entitlement's updated_at timestamps forward to the trial end. + if ( + !empty($trial) + && $entitlement->updated_at < $trial['end'] + && in_array($entitlement->sku_id, $trial['skus']) + ) { + // TODO: Consider not updating the updated_at to a future date, i.e. bump it + // as many months as possible, but not into the future + // if we're in dry-run, you know... + if ($apply) { + $entitlement->updated_at = $trial['end']; + $entitlement->save(); + } - // This entitlement was created, or billed last, less than a month ago. - if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { continue; } - // updated last more than a month ago -- was it billed? - if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { - $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); + $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); - $cost = (int) ($entitlement->cost * $discount * $diff); - $fee = (int) ($entitlement->fee * $diff); - - if ($isDegraded) { - $cost = 0; - } + if ($diff <= 0) { + continue; + } - $charges += $cost; - $profit += $cost - $fee; + $cost = (int) ($entitlement->cost * $discount * $diff); + $fee = (int) ($entitlement->fee * $diff); - // if we're in dry-run, you know... - if (!$apply) { - continue; - } + if ($isDegraded) { + $cost = 0; + } - $entitlement->updated_at = $entitlement->updated_at->copy() - ->addMonthsWithoutOverflow($diff); + $charges += $cost; + $profit += $cost - $fee; - $entitlement->save(); + // if we're in dry-run, you know... + if (!$apply) { + continue; + } - if ($cost == 0) { - continue; - } + $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); + $entitlement->save(); - $entitlementTransactions[] = $entitlement->createTransaction( - Transaction::ENTITLEMENT_BILLED, - $cost - ); + if ($cost == 0) { + continue; } + + $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost); } if ($apply) { - $this->debit($charges, '', $entitlementTransactions); + $this->debit($charges, '', $transactions); // Credit/debit the reseller if ($profit != 0 && $this->owner->tenant) { @@ -182,26 +176,53 @@ return null; } - // retrieve any expected charges - $expectedCharge = $this->expectedCharges(); - - // get the costs per day for all entitlements billed against this wallet - $costsPerDay = $this->costsPerDay(); + $balance = $this->balance; + $discount = $this->getDiscountRate(); + $trial = $this->trialInfo(); + + // Get all entitlements... + $entitlements = $this->entitlements()->orderBy('updated_at')->get() + ->filter(function ($entitlement) { + return $entitlement->cost > 0; + }) + ->map(function ($entitlement) { + return [ + 'date' => $entitlement->updated_at ?: $entitlement->created_at, + 'cost' => $entitlement->cost, + 'sku_id' => $entitlement->sku_id, + ]; + }) + ->all(); + + $max = 12 * 25; + while ($max > 0) { + foreach ($entitlements as &$entitlement) { + $until = $entitlement['date'] = $entitlement['date']->addMonthsWithoutOverflow(1); + + if ( + !empty($trial) + && $entitlement['date'] < $trial['end'] + && in_array($entitlement['sku_id'], $trial['skus']) + ) { + continue; + } - if (!$costsPerDay) { - return null; - } + $balance -= (int) ($entitlement['cost'] * $discount); - // the number of days this balance, minus the expected charges, would last - $daysDelta = floor(($this->balance - $expectedCharge) / $costsPerDay); + if ($balance < 0) { + break 2; + } + } - // calculate from the last entitlement billed - $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); + $max--; + } - $until = $entitlement->updated_at->copy()->addDays($daysDelta); + if (empty($until)) { + return null; + } // Don't return dates from the past - if ($until < Carbon::now() && !$until->isToday()) { + if ($until <= Carbon::now() && !$until->isToday()) { return null; } @@ -224,22 +245,6 @@ } /** - * Retrieve the costs per day of everything charged to this wallet. - * - * @return float - */ - public function costsPerDay() - { - $costs = (float) 0; - - foreach ($this->entitlements as $entitlement) { - $costs += $entitlement->costsPerDay(); - } - - return $costs; - } - - /** * Add an amount of pecunia to this wallet's balance. * * @param int $amount The amount of pecunia to add (in cents). @@ -406,6 +411,18 @@ } /** + * Plan of the wallet. + * + * @return ?\App\Plan + */ + public function plan() + { + $planId = $this->owner->getSetting('plan_id'); + + return $planId ? Plan::find($planId) : null; + } + + /** * Remove a controller from this wallet. * * @param \App\User $user The user to remove as a controller from this wallet. @@ -426,12 +443,50 @@ */ public function transactions() { - return Transaction::where( - [ - 'object_id' => $this->id, - 'object_type' => Wallet::class - ] - ); + return Transaction::where('object_id', $this->id)->where('object_type', Wallet::class); + } + + /** + * Returns trial related information. + * + * @return ?array Plan ID, plan SKUs, trial end date, number of free months (planId, skus, end, months) + */ + public function trialInfo(): ?array + { + $plan = $this->plan(); + $freeMonths = $plan ? $plan->free_months : 0; + $trialEnd = $freeMonths ? $this->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null; + + if ($trialEnd) { + // Get all SKUs assigned to the plan (they are free in trial) + // TODO: We could store the list of plan's SKUs in the wallet settings, for two reasons: + // - performance + // - if we change plan definition at some point in time, the old users would use + // the old definition, instead of the current one + // TODO: The same for plan's free_months value + $trialSkus = \App\Sku::select('id') + ->whereIn('id', function ($query) use ($plan) { + $query->select('sku_id') + ->from('package_skus') + ->whereIn('package_id', function ($query) use ($plan) { + $query->select('package_id') + ->from('plan_packages') + ->where('plan_id', $plan->id); + }); + }) + ->whereNot('title', 'storage') + ->pluck('id') + ->all(); + + return [ + 'end' => $trialEnd, + 'skus' => $trialSkus, + 'planId' => $plan->id, + 'months' => $freeMonths, + ]; + } + + return null; } /** diff --git a/src/database/migrations/2022_09_08_100000_plans_free_months.php b/src/database/migrations/2022_09_08_100000_plans_free_months.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2022_09_08_100000_plans_free_months.php @@ -0,0 +1,41 @@ +tinyInteger('free_months')->unsigned()->default(0); + } + ); + + DB::table('plans')->update(['free_months' => 1]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'plans', + function (Blueprint $table) { + $table->dropColumn('free_months'); + } + ); + } +}; diff --git a/src/database/seeds/local/PlanSeeder.php b/src/database/seeds/local/PlanSeeder.php --- a/src/database/seeds/local/PlanSeeder.php +++ b/src/database/seeds/local/PlanSeeder.php @@ -32,6 +32,7 @@ 'title' => 'individual', 'name' => 'Individual Account', 'description' => $description, + 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0 ] @@ -59,6 +60,7 @@ 'title' => 'group', 'name' => 'Group Account', 'description' => $description, + 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0 ] @@ -91,6 +93,7 @@ [ 'title' => 'individual', 'name' => 'Individual Account', + 'free_months' => 1, 'description' => $description, 'discount_qty' => 0, 'discount_rate' => 0 @@ -122,6 +125,7 @@ 'title' => 'group', 'name' => 'Group Account', 'description' => $description, + 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0 ] diff --git a/src/database/seeds/production/PlanSeeder.php b/src/database/seeds/production/PlanSeeder.php --- a/src/database/seeds/production/PlanSeeder.php +++ b/src/database/seeds/production/PlanSeeder.php @@ -32,6 +32,7 @@ 'title' => 'individual', 'name' => 'Individual Account', 'description' => $description, + 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0 ] @@ -59,6 +60,7 @@ 'title' => 'group', 'name' => 'Group Account', 'description' => $description, + 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0 ] diff --git a/src/tests/Feature/Console/Wallet/TrialEndTest.php b/src/tests/Feature/Console/Wallet/TrialEndTest.php --- a/src/tests/Feature/Console/Wallet/TrialEndTest.php +++ b/src/tests/Feature/Console/Wallet/TrialEndTest.php @@ -38,12 +38,12 @@ { Queue::fake(); - $package = \App\Package::withEnvTenantContext()->where('title', 'lite')->first(); + $plan = \App\Plan::withEnvTenantContext()->where('title', 'individual')->first(); $user = $this->getTestUser('test-user1@kolabnow.com', [ 'status' => User::STATUS_IMAP_READY | User::STATUS_LDAP_READY | User::STATUS_ACTIVE, ]); $wallet = $user->wallets()->first(); - $user->assignPackage($package); + $user->assignPlan($plan); DB::table('users')->update(['created_at' => \now()->clone()->subMonthsNoOverflow(2)->subHours(1)]); @@ -104,6 +104,7 @@ $user2 = $this->getTestUser('test-user2@kolabnow.com', [ 'status' => User::STATUS_IMAP_READY | User::STATUS_LDAP_READY | User::STATUS_ACTIVE, ]); + $package = \App\Package::withEnvTenantContext()->where('title', 'lite')->first(); $user->assignPackage($package, $user2); $user2->created_at = \now()->clone()->subMonthsNoOverflow(1); $user2->save(); diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -37,8 +37,8 @@ public function testGetWalletNotice(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); - $package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first(); - $user->assignPackage($package); + $plan = \App\Plan::withObjectTenantContext($user)->where('title', 'individual')->first(); + $user->assignPlan($plan); $wallet = $user->wallets()->first(); $controller = new WalletsController(); @@ -50,7 +50,7 @@ $this->assertSame('You are in your free trial period.', $notice); - $wallet->owner->created_at = Carbon::now()->subDays(15); + $wallet->owner->created_at = Carbon::now()->subWeeks(3); $wallet->owner->save(); $notice = $method->invoke($controller, $wallet); @@ -64,8 +64,8 @@ $this->assertSame('You are out of credit, top up your balance now.', $notice); // User/entitlements created slightly more than a month ago, balance=9,99 CHF (monthly) - $wallet->owner->created_at = Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1); - $wallet->owner->save(); + $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1)); + $wallet->refresh(); // test "1 month" $wallet->balance = 990; @@ -77,7 +77,7 @@ $wallet->balance = 990 * 2.6; $notice = $method->invoke($controller, $wallet); - $this->assertMatchesRegularExpression('/\(2 months 2 weeks\)/', $notice); + $this->assertMatchesRegularExpression('/\(1 month 4 weeks\)/', $notice); // Change locale to make sure the text is localized by Carbon \app()->setLocale('de'); @@ -86,7 +86,7 @@ $wallet->balance = 990 * 23.5; $notice = $method->invoke($controller, $wallet); - $this->assertMatchesRegularExpression('/\(1 Jahr 11 Monate\)/', $notice); + $this->assertMatchesRegularExpression('/\(1 Jahr 10 Monate\)/', $notice); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -40,28 +40,6 @@ } /** - * Test for Entitlement::costsPerDay() - */ - public function testCostsPerDay(): void - { - // 500 - // 28 days: 17.86 - // 31 days: 16.129 - $user = $this->getTestUser('entitlement-test@kolabnow.com'); - $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); - $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); - - $user->assignPackage($package); - - $entitlement = $user->entitlements->where('sku_id', $mailbox->id)->first(); - - $costsPerDay = $entitlement->costsPerDay(); - - $this->assertTrue($costsPerDay < 17.86); - $this->assertTrue($costsPerDay > 16.12); - } - - /** * Tests for entitlements * @todo This really should be in User or Wallet tests file */ diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature; use App\Package; +use App\Plan; use App\User; use App\Sku; use App\Transaction; @@ -27,6 +28,9 @@ 'jane@kolabnow.com' ]; + /** + * {@inheritDoc} + */ public function setUp(): void { parent::setUp(); @@ -35,8 +39,13 @@ foreach ($this->users as $user) { $this->deleteTestUser($user); } + + Sku::select()->update(['fee' => 0]); } + /** + * {@inheritDoc} + */ public function tearDown(): void { foreach ($this->users as $user) { @@ -90,8 +99,8 @@ // 31 days: 31.93 per day $user = $this->getTestUser('jane@kolabnow.com'); - $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); - $user->assignPackage($package); + $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); + $user->assignPlan($plan); $wallet = $user->wallets()->first(); // User/entitlements created today, balance=0 @@ -138,29 +147,6 @@ } /** - * Test for Wallet::costsPerDay() - */ - public function testCostsPerDay(): void - { - // 990 - // 28 days: 35.36 - // 31 days: 31.93 - $user = $this->getTestUser('jane@kolabnow.com'); - - $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); - $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); - - $user->assignPackage($package); - - $wallet = $user->wallets()->first(); - - $costsPerDay = $wallet->costsPerDay(); - - $this->assertTrue($costsPerDay < 35.38); - $this->assertTrue($costsPerDay > 31.93); - } - - /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void @@ -332,11 +318,11 @@ // Add 40% fee to all SKUs Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); - $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); + $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); - $user->assignPackage($package); + $user->assignPlan($plan); $user->assignSku($storage, 5); - $user->refresh(); + $user->setSetting('plan_id', null); // disable plan and trial // Reset reseller's wallet balance and transactions $reseller_wallet = $user->tenant->wallet(); @@ -357,10 +343,11 @@ $wallet->refresh(); $reseller_wallet->refresh(); - // TODO: Update these comments with what is actually being used to calculate these numbers - // 388 + 310 + 17 + 17 = 732 + // User discount is 30% + // Expected: groupware: 490 x 70% + mailbox: 500 x 70% + storage: 5 x round(25x70%) = 778 $this->assertSame(-778, $wallet->balance); - // 388 - 555 x 40% + 310 - 444 x 40% + 34 - 50 x 40% = 312 + // Reseller fee is 40% + // Expected: groupware: 490 x 30% + mailbox: 500 x 30% + storage: 5 x round(25x30%) = 332 $this->assertSame(332, $reseller_wallet->balance); $transactions = Transaction::where('object_id', $wallet->id) @@ -381,7 +368,9 @@ $this->assertSame(-778, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); - // TODO: Test entitlement transaction records + // Assert all entitlements' updated_at timestamp + $date = $backdate->addMonthsWithoutOverflow(1); + $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get()); // ----------------------------------- // Test charging on entitlement delete @@ -439,6 +428,107 @@ } /** + * Test for charging and removing entitlements when in trial + */ + public function testChargeAndDeleteEntitlementsTrial(): void + { + $user = $this->getTestUser('jane@kolabnow.com'); + $wallet = $user->wallets()->first(); + + $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); + $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); + $user->assignPlan($plan); + $user->assignSku($storage, 5); + + // ------------------------------------ + // Test normal charging of entitlements + // ------------------------------------ + + // Backdate and charge entitlements, we're expecting one month to be charged + // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month + Carbon::setTestNow(Carbon::create(2021, 5, 21, 12)); + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements, $backdate); + $charge = $wallet->chargeEntitlements(); + $wallet->refresh(); + + // Expected: storage: 5 x 25 = 125 (the rest is free in trial) + $this->assertSame($balance = -125, $wallet->balance); + + // Assert wallet transaction + $transactions = $wallet->transactions()->get(); + + $this->assertCount(1, $transactions); + $trans = $transactions[0]; + $this->assertSame('', $trans->description); + $this->assertSame($balance, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + + // Assert entitlement transactions + $etransactions = Transaction::where('transaction_id', $trans->id)->get(); + $this->assertCount(5, $etransactions); + $trans = $etransactions[0]; + $this->assertSame(null, $trans->description); + $this->assertSame(25, $trans->amount); + $this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type); + + // Assert all entitlements' updated_at timestamp + $date = $backdate->addMonthsWithoutOverflow(1); + $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get()); + + // Run again, expect no changes + $charge = $wallet->chargeEntitlements(); + $wallet->refresh(); + + $this->assertSame($balance, $wallet->balance); + $this->assertCount(1, $wallet->transactions()->get()); + $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get()); + + // ----------------------------------- + // Test charging on entitlement delete + // ----------------------------------- + + $wallet->transactions()->delete(); + + $user->removeSku($storage, 2); + + $wallet->refresh(); + + // we expect the wallet to have been charged for 19 days of use of + // 2 deleted storage entitlements: 2 x round(25 / 31 * 19) = 30 + $this->assertSame($balance -= 30, $wallet->balance); + + // Assert wallet transactions + $transactions = $wallet->transactions()->get(); + + $this->assertCount(2, $transactions); + $trans = $transactions[0]; + $this->assertSame('', $trans->description); + $this->assertSame(-15, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + $trans = $transactions[1]; + $this->assertSame('', $trans->description); + $this->assertSame(-15, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + + // Assert entitlement transactions + /* Note: Commented out because the observer does not create per-entitlement transactions + $etransactions = Transaction::where('transaction_id', $transactions[0]->id)->get(); + $this->assertCount(1, $etransactions); + $trans = $etransactions[0]; + $this->assertSame(null, $trans->description); + $this->assertSame(15, $trans->amount); + $this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type); + $etransactions = Transaction::where('transaction_id', $transactions[1]->id)->get(); + $this->assertCount(1, $etransactions); + $trans = $etransactions[0]; + $this->assertSame(null, $trans->description); + $this->assertSame(15, $trans->amount); + $this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type); + */ + } + + /** * Tests for updateEntitlements() */ public function testUpdateEntitlements(): void