diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -103,7 +103,7 @@ */ public function entitleable() { - return $this->morphTo()->withTrashed(); + return $this->morphTo(); } /** diff --git a/src/app/Exceptions/Handler.php b/src/app/Exceptions/Handler.php --- a/src/app/Exceptions/Handler.php +++ b/src/app/Exceptions/Handler.php @@ -5,6 +5,7 @@ use Exception; use Illuminate\Auth\AuthenticationException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Illuminate\Support\Facades\DB; class Handler extends ExceptionHandler { @@ -49,6 +50,11 @@ */ public function render($request, Exception $exception) { + // Rollback uncommitted transactions + while (DB::transactionLevel() > 0) { + DB::rollBack(); + } + return parent::render($request, $exception); } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -66,7 +66,6 @@ */ public function index() { - \Log::debug("Regular API"); $user = $this->guard()->user(); $result = $user->users()->orderBy('email')->get()->map(function ($user) { @@ -352,48 +351,48 @@ /** * Update user entitlements. * - * @param \App\User $user The user - * @param array|null $skus Set of SKUs for the user + * @param \App\User $user The user + * @param array $rSkus List of SKU IDs requested for the user in the form [id=>qty] */ - protected function updateEntitlements(User $user, $skus) + protected function updateEntitlements(User $user, $rSkus) { - if (!is_array($skus)) { + if (!is_array($rSkus)) { return; } - // Existing SKUs - // FIXME: Is there really no query builder method to get result indexed - // by some column or primary key? - $all_skus = Sku::all()->mapWithKeys(function ($sku) { - return [$sku->id => $sku]; - }); + // list of skus, [id=>obj] + $skus = Sku::all()->mapWithKeys( + function ($sku) { + return [$sku->id => $sku]; + } + ); - // Existing user entitlements - // Note: We sort them by cost, so e.g. for storage we get these free first - $entitlements = $user->entitlements()->orderBy('cost')->get(); + // existing entitlement's SKUs + $eSkus = []; - // Go through existing entitlements and remove those no longer needed - foreach ($entitlements as $ent) { - $sku_id = $ent->sku_id; + $user->entitlements()->groupBy('sku_id') + ->selectRaw('count(*) as total, sku_id')->each( + function ($e) use (&$eSkus) { + $eSkus[$e->sku_id] = $e->total; + } + ); - if (array_key_exists($sku_id, $skus)) { - // An existing entitlement exists on the requested list - $skus[$sku_id] -= 1; + foreach ($skus as $skuID => $sku) { + $e = array_key_exists($skuID, $eSkus) ? $eSkus[$skuID] : 0; + $r = array_key_exists($skuID, $rSkus) ? $rSkus[$skuID] : 0; - if ($skus[$sku_id] < 0) { - $ent->delete(); + if ($sku->handler_class == \App\Handlers\Mailbox::class) { + if ($r != 1) { + throw new \Exception("Invalid quantity of mailboxes"); } - } elseif ($all_skus->get($sku_id)->handler_class != \App\Handlers\Mailbox::class) { - // An existing entitlement does not exists on the requested list - // Never delete 'mailbox' SKU - $ent->delete(); } - } - // Add missing entitlements - foreach ($skus as $sku_id => $count) { - if ($count > 0 && $all_skus->has($sku_id)) { - $user->assignSku($all_skus[$sku_id], $count); + if ($e > $r) { + // remove those entitled more than existing + $user->removeSku($sku, ($e - $r)); + } elseif ($e < $r) { + // add those requested more than entitled + $user->assignSku($sku, ($r - $e)); } } } 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 @@ -92,4 +92,55 @@ $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); } + + public function deleting(Entitlement $entitlement) + { + // Start calculating the costs for the consumption of this entitlement if the + // existing consumption spans >= 14 days. + // anything's free for 14 days + if ($entitlement->created_at >= Carbon::now()->subDays(14)) { + return; + } + + $cost = 0; + + // get the discount rate applied to the wallet. + $discount = $entitlement->wallet->getDiscountRate(); + + // just in case this had not been billed yet, ever + $diffInMonths = $entitlement->updated_at->diffInMonths(Carbon::now()); + $cost += (int) ($entitlement->cost * $discount * $diffInMonths); + + // this moves the hypothetical updated at forward to however many months past the original + $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); + + // now we have the diff in days since the last "billed" period end. + // This may be an entitlement paid up until February 28th, 2020, with today being March + // 12th 2020. Calculating the costs for the entitlement is based on the daily price for the + // past month -- i.e. $price/29 in the case at hand -- times the number of (full) days in + // between the period end and now. + // + // a) The number of days left in the past month, 1 + // b) The cost divided by the number of days in the past month, for example, 555/29, + // c) a) + Todays day-of-month, 12, so 13. + // + + $diffInDays = $updatedAt->diffInDays(Carbon::now()); + + $dayOfThisMonth = Carbon::now()->day; + + // days in the month for the month prior to this one. + // the price per day is based on the number of days left in the last month + $daysInLastMonth = \App\Utils::daysInLastMonth(); + + $pricePerDay = (float)$entitlement->cost / $daysInLastMonth; + + $cost += (int) (round($pricePerDay * $diffInDays, 0)); + + if ($cost == 0) { + return; + } + + $entitlement->wallet->debit($cost); + } } diff --git a/src/app/Transaction.php b/src/app/Transaction.php --- a/src/app/Transaction.php +++ b/src/app/Transaction.php @@ -162,14 +162,14 @@ return null; } - $entitleable = $entitlement->entitleable; + $user = \App\User::withTrashed()->where('id', $entitlement->object_id)->first(); - if (!$entitleable) { + if (!$user) { \Log::debug("No entitleable for {$entitlement->id} ?"); return null; } - return $entitleable->email; + return $user->email; } /** diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -4,6 +4,7 @@ use App\Entitlement; use App\UserAlias; +use App\Sku; use App\Traits\UserAliasesTrait; use App\Traits\SettingsTrait; use App\Wallet; @@ -168,7 +169,7 @@ * @return \App\User Self * @throws \Exception */ - public function assignSku($sku, int $count = 1): User + public function assignSku(Sku $sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); @@ -184,7 +185,7 @@ \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, - 'cost' => $sku->units_free >= $exists ? $sku->cost : 0, + 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); @@ -461,6 +462,39 @@ } /** + * Remove a number of entitlements for the SKU. + * + * @param \App\Sku $sku The SKU + * @param int $count The number of entitlements to remove + * + * @return User Self + */ + public function removeSku(Sku $sku, int $count = 1): User + { + $entitlements = $this->entitlements() + ->where('sku_id', $sku->id) + ->orderBy('cost', 'desc') + ->orderBy('created_at') + ->get(); + + $entitlements_count = count($entitlements); + + foreach ($entitlements as $entitlement) { + if ($entitlements_count <= $sku->units_free) { + continue; + } + + if ($count > 0) { + $entitlement->delete(); + $entitlements_count--; + $count--; + } + } + + return $this; + } + + /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -23,6 +23,7 @@ { parent::setUp(); + $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); @@ -44,6 +45,7 @@ */ public function tearDown(): void { + $this->deleteTestUser('jane@kolabnow.com'); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestUser('UsersControllerTest2@userscontroller.com'); $this->deleteTestUser('UsersControllerTest3@userscontroller.com'); @@ -713,7 +715,11 @@ ->orderBy('cost') ->pluck('cost')->all(); - $this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage']); + $this->assertUserEntitlements( + $user, + ['groupware', 'mailbox', 'storage', 'storage', 'storage'] + ); + $this->assertSame([0, 0, 25], $storage_cost); } @@ -722,8 +728,146 @@ */ public function testUpdateEntitlements(): void { - // TODO: Test more cases of entitlements update - $this->markTestIncomplete(); + $jane = $this->getTestUser('jane@kolabnow.com'); + + $kolab = \App\Package::where('title', 'kolab')->first(); + $storage = \App\Sku::where('title', 'storage')->first(); + $activesync = \App\Sku::where('title', 'activesync')->first(); + $groupware = \App\Sku::where('title', 'groupware')->first(); + $mailbox = \App\Sku::where('title', 'mailbox')->first(); + + // standard package, 1 mailbox, 1 groupware, 2 storage + $jane->assignPackage($kolab); + + // add 2 storage, 1 activesync + $post = [ + 'skus' => [ + $mailbox->id => 1, + $groupware->id => 1, + $storage->id => 4, + $activesync->id => 1 + ] + ]; + + $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); + $response->assertStatus(200); + + $this->assertUserEntitlements( + $jane, + [ + 'activesync', + 'groupware', + 'mailbox', + 'storage', + 'storage', + 'storage', + 'storage' + ] + ); + + // add 2 storage, remove 1 activesync + $post = [ + 'skus' => [ + $mailbox->id => 1, + $groupware->id => 1, + $storage->id => 6, + $activesync->id => 0 + ] + ]; + + $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); + $response->assertStatus(200); + + $this->assertUserEntitlements( + $jane, + [ + 'groupware', + 'mailbox', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage' + ] + ); + + // add mailbox + $post = [ + 'skus' => [ + $mailbox->id => 2, + $groupware->id => 1, + $storage->id => 6, + $activesync->id => 0 + ] + ]; + + $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); + $response->assertStatus(500); + + $this->assertUserEntitlements( + $jane, + [ + 'groupware', + 'mailbox', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage' + ] + ); + + // remove mailbox + $post = [ + 'skus' => [ + $mailbox->id => 0, + $groupware->id => 1, + $storage->id => 6, + $activesync->id => 0 + ] + ]; + + $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); + $response->assertStatus(500); + + $this->assertUserEntitlements( + $jane, + [ + 'groupware', + 'mailbox', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage', + 'storage' + ] + ); + + // less than free storage + $post = [ + 'skus' => [ + $mailbox->id => 1, + $groupware->id => 1, + $storage->id => 1, + $activesync->id => 0 + ] + ]; + + $response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post); + $response->assertStatus(200); + + $this->assertUserEntitlements( + $jane, + [ + 'groupware', + 'mailbox', + 'storage', + 'storage' + ] + ); } /** 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 @@ -56,11 +56,11 @@ */ public function testUserAddEntitlement(): void { - $package_domain = Package::where('title', 'domain-hosting')->first(); - $package_kolab = Package::where('title', 'kolab')->first(); + $packageDomain = Package::where('title', 'domain-hosting')->first(); + $packageKolab = Package::where('title', 'kolab')->first(); - $sku_domain = Sku::where('title', 'domain-hosting')->first(); - $sku_mailbox = Sku::where('title', 'mailbox')->first(); + $skuDomain = Sku::where('title', 'domain-hosting')->first(); + $skuMailbox = Sku::where('title', 'mailbox')->first(); $owner = $this->getTestUser('entitlement-test@kolabnow.com'); $user = $this->getTestUser('entitled-user@custom-domain.com'); @@ -73,19 +73,22 @@ ] ); - $domain->assignPackage($package_domain, $owner); + $domain->assignPackage($packageDomain, $owner); - $owner->assignPackage($package_kolab); - $owner->assignPackage($package_kolab, $user); + $owner->assignPackage($packageKolab); + $owner->assignPackage($packageKolab, $user); $wallet = $owner->wallets->first(); $this->assertCount(4, $owner->entitlements()->get()); - $this->assertCount(1, $sku_domain->entitlements()->where('wallet_id', $wallet->id)->get()); - $this->assertCount(2, $sku_mailbox->entitlements()->where('wallet_id', $wallet->id)->get()); + $this->assertCount(1, $skuDomain->entitlements()->where('wallet_id', $wallet->id)->get()); + $this->assertCount(2, $skuMailbox->entitlements()->where('wallet_id', $wallet->id)->get()); $this->assertCount(9, $wallet->entitlements); - $this->backdateEntitlements($owner->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)); + $this->backdateEntitlements( + $owner->entitlements, + Carbon::now()->subMonthsWithoutOverflow(1) + ); $wallet->chargeEntitlements(); @@ -111,17 +114,55 @@ $sku = \App\Sku::where('title', 'mailbox')->first(); $this->assertNotNull($sku); - $entitlement = Entitlement::where('wallet_id', $wallet->id)->where('sku_id', $sku->id)->first(); + $entitlement = Entitlement::where('wallet_id', $wallet->id) + ->where('sku_id', $sku->id)->first(); + $this->assertNotNull($entitlement); - $e_sku = $entitlement->sku; - $this->assertSame($sku->id, $e_sku->id); + $eSKU = $entitlement->sku; + $this->assertSame($sku->id, $eSKU->id); + + $eWallet = $entitlement->wallet; + $this->assertSame($wallet->id, $eWallet->id); + + $eEntitleable = $entitlement->entitleable; + $this->assertEquals($user->id, $eEntitleable->id); + $this->assertTrue($eEntitleable instanceof \App\User); + } + + public function testBillDeletedEntitlement(): void + { + $user = $this->getTestUser('entitlement-test@kolabnow.com'); + $package = \App\Package::where('title', 'kolab')->first(); + + $storage = \App\Sku::where('title', 'storage')->first(); + + $user->assignPackage($package); + // some additional SKUs so we have something to delete. + $user->assignSku($storage, 4); + + // the mailbox, the groupware, the 2 original storage and the additional 4 + $this->assertCount(8, $user->fresh()->entitlements); + + $wallet = $user->wallets()->first(); + + $this->backdateEntitlements($user->entitlements, Carbon::now()->subWeeks(7)); + + $charge = $wallet->chargeEntitlements(); + + $this->assertTrue($wallet->balance < 0); + + $balance = $wallet->balance; + + $user->removeSku($storage, 4); + + // we expect the wallet to have been charged. + $this->assertTrue($wallet->fresh()->balance < $balance); - $e_wallet = $entitlement->wallet; - $this->assertSame($wallet->id, $e_wallet->id); + $transactions = \App\Transaction::where('object_id', $wallet->id) + ->where('object_type', \App\Wallet::class)->get(); - $e_entitleable = $entitlement->entitleable; - $this->assertEquals($user->id, $e_entitleable->id); - $this->assertTrue($e_entitleable instanceof \App\User); + // one round of the monthly invoicing, four sku deletions getting invoiced + $this->assertCount(5, $transactions); } } diff --git a/src/tests/Unit/TransactionTest.php b/src/tests/Unit/TransactionTest.php --- a/src/tests/Unit/TransactionTest.php +++ b/src/tests/Unit/TransactionTest.php @@ -59,4 +59,25 @@ ] ); } + + public function testEntitlementForWallet(): void + { + $transaction = \App\Transaction::where('object_type', \App\Wallet::class) + ->whereIn('object_id', \App\Wallet::pluck('id'))->first(); + + $entitlement = $transaction->entitlement(); + $this->assertNull($entitlement); + $this->assertNotNull($transaction->wallet()); + } + + public function testWalletForEntitlement(): void + { + $transaction = \App\Transaction::where('object_type', \App\Entitlement::class) + ->whereIn('object_id', \App\Entitlement::pluck('id'))->first(); + + $wallet = $transaction->wallet(); + $this->assertNull($wallet); + + $this->assertNotNull($transaction->entitlement()); + } }