diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index c5f82ac8..f9db6619 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,156 +1,154 @@ {$entitlement->getKeyName()} = $allegedly_unique; break; } } // can't dispatch job here because it'll fail serialization // Make sure the owner is at least a controller on the wallet $wallet = \App\Wallet::find($entitlement->wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } return true; } /** * Handle the entitlement "created" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function created(Entitlement $entitlement) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED); } /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleted(Entitlement $entitlement) { // Remove all configured 2FA methods from Roundcube database if ($entitlement->sku->title == '2fa') { // FIXME: Should that be an async job? $sf = new \App\Auth\SecondFactor($entitlement->entitleable); $sf->removeFactors(); } $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $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. // // Effect is that anything's free for the first 14 days if ($entitlement->created_at >= Carbon::now()->subDays(14)) { return; } $owner = $entitlement->wallet->owner; // Determine if we're still within the free first month $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1); if ($freeMonthEnds >= Carbon::now()) { return; } $cost = 0; + $now = Carbon::now(); // 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()); + $diffInMonths = $entitlement->updated_at->diffInMonths($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. - // + // 12th 2020. Calculating the costs for the entitlement is based on the daily price - $diffInDays = $updatedAt->diffInDays(Carbon::now()); + // the price per day is based on the number of days in the last month + // or the current month if the period does not overlap with the previous month + // FIXME: This really should be simplified to $daysInMonth=30 - $dayOfThisMonth = Carbon::now()->day; + $diffInDays = $updatedAt->diffInDays($now); - // 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(); + if ($now->day >= $diffInDays) { + $daysInMonth = $now->daysInMonth; + } else { + $daysInMonth = \App\Utils::daysInLastMonth(); + } - $pricePerDay = (float)$entitlement->cost / $daysInLastMonth; + $pricePerDay = $entitlement->cost / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); if ($cost == 0) { return; } $entitlement->wallet->debit($cost); } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index 3b4c4c74..7f86d0a4 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,172 +1,186 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); parent::tearDown(); } public function testCostsPerDay(): void { // 444 // 28 days: 15.86 // 31 days: 14.32 $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = Package::where('title', 'kolab')->first(); $mailbox = Sku::where('title', 'mailbox')->first(); $user->assignPackage($package); $entitlement = $user->entitlements->where('sku_id', $mailbox->id)->first(); $costsPerDay = $entitlement->costsPerDay(); $this->assertTrue($costsPerDay < 15.86); $this->assertTrue($costsPerDay > 14.32); } /** * Tests for User::AddEntitlement() */ public function testUserAddEntitlement(): void { $packageDomain = Package::where('title', 'domain-hosting')->first(); $packageKolab = Package::where('title', 'kolab')->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'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $domain->assignPackage($packageDomain, $owner); $owner->assignPackage($packageKolab); $owner->assignPackage($packageKolab, $user); $wallet = $owner->wallets->first(); $this->assertCount(4, $owner->entitlements()->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) ); $wallet->chargeEntitlements(); $this->assertTrue($wallet->fresh()->balance < 0); } public function testAddExistingEntitlement(): void { $this->markTestIncomplete(); } public function testEntitlementFunctions(): void { $user = $this->getTestUser('entitlement-test@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $this->assertNotNull($wallet); $sku = \App\Sku::where('title', 'mailbox')->first(); $this->assertNotNull($sku); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku->id)->first(); $this->assertNotNull($entitlement); $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)); + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements, $backdate); $charge = $wallet->chargeEntitlements(); - $this->assertTrue($wallet->balance < 0); + $this->assertSame(-1099, $wallet->balance); $balance = $wallet->balance; + $discount = \App\Discount::where('discount', 30)->first(); + $wallet->discount()->associate($discount); + $wallet->save(); $user->removeSku($storage, 4); - // we expect the wallet to have been charged. - $this->assertTrue($wallet->fresh()->balance < $balance); + // we expect the wallet to have been charged for ~3 weeks of use of + // 4 deleted storage entitlements, it should also take discount into account + $backdate->addMonthsWithoutOverflow(1); + $diffInDays = $backdate->diffInDays(Carbon::now()); + + // entitlements-num * cost * discount * days-in-month + $max = intval(4 * 25 * 0.7 * $diffInDays / 28); + $min = intval(4 * 25 * 0.7 * $diffInDays / 31); + + $wallet->refresh(); + $this->assertTrue($wallet->balance >= $balance - $max); + $this->assertTrue($wallet->balance <= $balance - $min); $transactions = \App\Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->get(); // one round of the monthly invoicing, four sku deletions getting invoiced $this->assertCount(5, $transactions); } }