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 @@ -78,6 +78,9 @@ public function deleted(Entitlement $entitlement) { if (!$entitlement->entitleable->trashed()) { + // TODO: This is useless, remove this, but also maybe refactor the whole method, + // i.e. move job invoking to App\Handlers (don't depend on SKU title). + // Also make sure the transaction is always being created $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); @@ -106,6 +109,8 @@ */ public function deleting(Entitlement $entitlement) { - $entitlement->wallet->chargeEntitlement($entitlement); + // Disable updating of updated_at column on delete, we need it unchanged to later + // charge the wallet for the uncharged period before the entitlement has been deleted + $entitlement->timestamps = false; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -7,6 +7,7 @@ use Carbon\Carbon; use Dyrynda\Database\Support\NullableFields; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; /** @@ -79,96 +80,6 @@ return $this->balanceUpdate(Transaction::WALLET_AWARD, $amount, $description); } - /** - * Charge a specific entitlement (for use on entitlement delete). - * - * @param \App\Entitlement $entitlement The entitlement. - */ - public function chargeEntitlement(Entitlement $entitlement): void - { - // Sanity checks - if ($entitlement->trashed() || $entitlement->wallet->id != $this->id || !$this->owner) { - return; - } - - // 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; - } - - if ($this->owner->isDegraded()) { - return; - } - - $now = Carbon::now(); - - // Determine if we're still within the trial period - $trial = $this->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']; - } - - // get the discount rate applied to the wallet. - $discount = $this->getDiscountRate(); - - // just in case this had not been billed yet, ever - $diffInMonths = $entitlement->updated_at->diffInMonths($now); - $cost = (int) ($entitlement->cost * $discount * $diffInMonths); - $fee = (int) ($entitlement->fee * $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 - - // 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 - - $diffInDays = $updatedAt->diffInDays($now); - - if ($now->day >= $diffInDays) { - $daysInMonth = $now->daysInMonth; - } else { - $daysInMonth = \App\Utils::daysInLastMonth(); - } - - $pricePerDay = $entitlement->cost / $daysInMonth; - $feePerDay = $entitlement->fee / $daysInMonth; - - $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); - $fee += (int) (round($feePerDay * $diffInDays, 0)); - - $profit = $cost - $fee; - - if ($profit != 0 && $this->owner->tenant && ($wallet = $this->owner->tenant->wallet())) { - $desc = "Charged user {$this->owner->email}"; - $method = $profit > 0 ? 'credit' : 'debit'; - $wallet->{$method}(abs($profit), $desc); - } - - if ($cost == 0) { - return; - } - - // TODO: Create per-entitlement transaction record? - - $this->debit($cost); - } - /** * Charge entitlements in the wallet * @@ -181,7 +92,6 @@ $transactions = []; $profit = 0; $charges = 0; - $discount = $this->getDiscountRate(); $isDegraded = $this->owner->isDegraded(); $trial = $this->trialInfo(); @@ -189,42 +99,26 @@ DB::beginTransaction(); } - // 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). - // ->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 all relevant entitlements... + $entitlements = $this->entitlements()->withTrashed() + // existing entitlements created, or billed last less than a month ago + ->where(function (Builder $query) { + $query->whereNull('deleted_at') + ->where('updated_at', '<=', Carbon::now()->subMonthsWithoutOverflow(1)); + }) + // deleted entitlements not yet charged + ->orWhere(function (Builder $query) { + $query->whereNotNull('deleted_at') + ->whereColumn('updated_at', '<', 'deleted_at'); + }) ->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(); - } - - continue; - } - - $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); - - if ($diff <= 0) { - continue; - } - - $cost = (int) ($entitlement->cost * $discount * $diff); - $fee = (int) ($entitlement->fee * $diff); + // Calculate cost, fee, and end of period + [$cost, $fee, $endDate] = $this->entitlementCosts($entitlement, $trial); + // Note: Degraded pays nothing, but we get the money from a tenant. + // Therefore $cost = 0, but $profit < 0. if ($isDegraded) { $cost = 0; } @@ -237,8 +131,10 @@ continue; } - $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); - $entitlement->save(); + if ($endDate) { + $entitlement->updated_at = $endDate; + $entitlement->save(); + } if ($cost == 0) { continue; @@ -248,17 +144,7 @@ } if ($apply) { - $this->debit($charges, '', $transactions); - - // Credit/debit the reseller - if ($profit != 0 && $this->owner->tenant) { - // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) - if ($wallet = $this->owner->tenant->wallet()) { - $desc = "Charged user {$this->owner->email}"; - $method = $profit > 0 ? 'credit' : 'debit'; - $wallet->{$method}(abs($profit), $desc); - } - } + $this->debit($charges, '', $transactions)->addTenantProfit($profit); DB::commit(); } @@ -648,64 +534,69 @@ public function updateEntitlements($withCost = true): int { $charges = 0; - $discount = $this->getDiscountRate(); - $now = Carbon::now(); + $profit = 0; + $trial = $this->trialInfo(); DB::beginTransaction(); - // used to parent individual entitlement billings to the wallet debit. - $entitlementTransactions = []; + $transactions = []; - foreach ($this->entitlements()->get() as $entitlement) { - $cost = 0; - $diffInDays = $entitlement->updated_at->diffInDays($now); - - // This entitlement has been 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)) { - // $cost=0 - } elseif ($withCost && $diffInDays > 0) { - // 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 constant $daysInMonth=30 - if ($now->day >= $diffInDays && $now->month == $entitlement->updated_at->month) { - $daysInMonth = $now->daysInMonth; - } else { - $daysInMonth = \App\Utils::daysInLastMonth(); - } + $entitlements = $this->entitlements()->where('updated_at', '<', Carbon::now())->get(); - $pricePerDay = $entitlement->cost / $daysInMonth; + foreach ($entitlements as $entitlement) { + // Calculate cost, fee, and end of period + [$cost, $fee, $endDate] = $this->entitlementCosts($entitlement, $trial, true); - $cost = (int) (round($pricePerDay * $discount * $diffInDays, 0)); + // Note: Degraded pays nothing, but we get the money from a tenant. + // Therefore $cost = 0, but $profit < 0. + if (!$withCost) { + $cost = 0; } - if ($diffInDays > 0) { - $entitlement->updated_at = $entitlement->updated_at->setDateFrom($now); + if ($endDate) { + $entitlement->updated_at = $entitlement->updated_at->setDateFrom($endDate); $entitlement->save(); } + $charges += $cost; + $profit += $cost - $fee; + if ($cost == 0) { continue; } - $charges += $cost; - // FIXME: Shouldn't we store also cost=0 transactions (to have the full history)? - $entitlementTransactions[] = $entitlement->createTransaction( - Transaction::ENTITLEMENT_BILLED, - $cost - ); + $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost); } - if ($charges > 0) { - $this->debit($charges, '', $entitlementTransactions); - } + $this->debit($charges, '', $transactions)->addTenantProfit($profit); DB::commit(); return $charges; } + /** + * Add profit to the tenant's wallet + * + * @param int $profit Profit amount (in cents), can be negative + */ + protected function addTenantProfit($profit): void + { + // Credit/debit the reseller + if ($profit != 0 && $this->owner->tenant) { + // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) + if ($wallet = $this->owner->tenant->wallet()) { + $desc = "Charged user {$this->owner->email}"; + if ($profit > 0) { + $wallet->credit(abs($profit), $desc); + } else { + $wallet->debit(abs($profit), $desc); + } + } + } + } + /** * Update the wallet balance, and create a transaction record */ @@ -743,4 +634,89 @@ return $this; } + + /** + * Calculate entitlement cost/fee for the current charge + * + * @param Entitlement $entitlement Entitlement object + * @param array|null $trial Trial information (result of Wallet::trialInfo()) + * @param bool $useCostPerDay Force calculation based on a per-day cost + * + * @return array Result in form of [cost, fee, end-of-period] + */ + protected function entitlementCosts(Entitlement $entitlement, array $trial = null, bool $useCostPerDay = false) + { + $discountRate = $this->getDiscountRate(); + $startDate = $entitlement->updated_at; // start of the period to charge for + $endDate = Carbon::now(); // end of the period to charge for + + // Deleted entitlements are always charged for all uncharged days up to the delete date + if ($entitlement->trashed()) { + $useCostPerDay = true; + $endDate = $entitlement->deleted_at->copy(); + } + + // Consider Trial period + if (!empty($trial) && $startDate < $trial['end'] && in_array($entitlement->sku_id, $trial['skus'])) { + if ($trial['end'] > $endDate) { + return [0, 0, $trial['end']]; + } + + $startDate = $trial['end']; + } + + if ($useCostPerDay) { + // Note: In this mode we need a full cost including partial periods. + + // Anything's free for the first 14 days. + if ($entitlement->created_at >= $endDate->copy()->subDays(14)) { + return [0, 0, $endDate]; + } + + $cost = 0; + $fee = 0; + + // Charge for full months first + if (($diff = $startDate->diffInMonths($endDate)) > 0) { + $cost += floor($entitlement->cost * $discountRate) * $diff; + $fee += $entitlement->fee * $diff; + $startDate->addMonthsWithoutOverflow($diff); + } + + // Charge for the rest of the period + if (($diff = $startDate->diffInDays($endDate)) > 0) { + // The price per day is based on the number of days in the month(s) + // Note: The $endDate does not have to be the current month + $endMonthDiff = $endDate->day > $diff ? $diff : $endDate->day; + $startMonthDiff = $diff - $endMonthDiff; + + // FIXME: This could be calculated in a few different ways, e.g. rounding or flooring + // the daily cost first and then applying discount and number of days. This could lead + // to very small values in some cases resulting in a zero result. + $cost += floor($entitlement->cost / $endDate->daysInMonth * $discountRate * $endMonthDiff); + $fee += floor($entitlement->fee / $endDate->daysInMonth * $endMonthDiff); + + if ($startMonthDiff) { + $cost += floor($entitlement->cost / $startDate->daysInMonth * $discountRate * $startMonthDiff); + $fee += floor($entitlement->fee / $startDate->daysInMonth * $startMonthDiff); + } + } + } else { + // Note: In this mode we expect to charge the entitlement for full month(s) only + $diff = $startDate->diffInMonths($endDate); + + if ($diff <= 0) { + // Do not update updated_at column (not a full month) unless trial end date + // is after current updated_at date + return [0, 0, $startDate != $entitlement->updated_at ? $startDate : null]; + } + + $endDate = $startDate->addMonthsWithoutOverflow($diff); + + $cost = floor($entitlement->cost * $discountRate) * $diff; + $fee = $entitlement->fee * $diff; + } + + return [(int) $cost, (int) $fee, $endDate]; + } } diff --git a/src/tests/Feature/Console/User/RemoveSkuTest.php b/src/tests/Feature/Console/User/RemoveSkuTest.php --- a/src/tests/Feature/Console/User/RemoveSkuTest.php +++ b/src/tests/Feature/Console/User/RemoveSkuTest.php @@ -53,13 +53,6 @@ $entitlements = $user->entitlements()->where('sku_id', $storage->id); $this->assertSame(80, $entitlements->count()); - // Backdate entitlements so they are charged on removal - $this->backdateEntitlements( - $entitlements->get(), - \Carbon\Carbon::now()->clone()->subWeeks(4), - \Carbon\Carbon::now()->clone()->subWeeks(4) - ); - // Remove single entitlement $this->artisan("user:remove-sku {$user->email} {$storage->title}") ->assertExitCode(0); @@ -73,7 +66,6 @@ // 5GB is free, so it should stay at 5 $this->assertSame(5, $entitlements->count()); - $this->assertTrue($user->wallet()->balance < 0); - $this->assertTrue(microtime(true) - $start < 6); // TODO: Make it faster + $this->assertThat(microtime(true) - $start, $this->lessThan(2)); } } 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 @@ -125,71 +125,6 @@ $this->assertTrue($wallet->fresh()->balance < 0); } - /** - * @todo This really should be in User or Wallet tests file - */ - public function testBillDeletedEntitlement(): void - { - $user = $this->getTestUser('entitlement-test@kolabnow.com'); - $package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first(); - $storage = \App\Sku::withEnvTenantContext()->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 5 original storage and the additional 4 - $this->assertCount(11, $user->fresh()->entitlements); - - $wallet = $user->wallets()->first(); - - $backdate = Carbon::now()->subWeeks(7); - $this->backdateEntitlements($user->entitlements, $backdate); - - $charge = $wallet->chargeEntitlements(); - - $this->assertSame(-1090, $wallet->balance); - - $balance = $wallet->balance; - $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); - $wallet->discount()->associate($discount); - $wallet->save(); - - $user->removeSku($storage, 4); - - // 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); - - // Test that deleting an entitlement on a degraded account costs nothing - $balance = $wallet->balance; - User::where('id', $user->id)->update(['status' => $user->status | User::STATUS_DEGRADED]); - - $backdate = Carbon::now()->subWeeks(7); - $this->backdateEntitlements($user->entitlements()->get(), $backdate); - - $groupware = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first(); - $entitlement = $wallet->entitlements()->where('sku_id', $groupware->id)->first(); - $entitlement->delete(); - - $this->assertSame($wallet->refresh()->balance, $balance); - } - /** * Test EntitleableTrait::toString() */ @@ -242,4 +177,28 @@ $this->assertSame($domain->namespace, $entitlement->entitleable->toString()); $this->assertNotNull($entitlement->entitleable); } + + /** + * Test for EntitleableTrait::removeSku() + */ + public function testEntitleableRemoveSku(): void + { + $user = $this->getTestUser('entitlement-test@kolabnow.com'); + $storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); + + $user->assignSku($storage, 6); + + $this->assertCount(6, $user->fresh()->entitlements); + + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements, $backdate); + + $user->removeSku($storage, 2); + + // Expect free units to be not deleted + $this->assertCount(5, $user->fresh()->entitlements); + + // Here we make sure that updated_at does not change on delete + $this->assertSame(6, $user->entitlements()->withTrashed()->whereDate('updated_at', $backdate)->count()); + } } 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\Discount; +use App\Entitlement; use App\Payment; use App\Package; use App\Plan; @@ -371,13 +372,13 @@ } /** - * Test for charging and removing entitlements (including tenant commission calculations) + * Test for charging entitlements (including tenant commission calculations) */ - public function testChargeAndDeleteEntitlements(): void + public function testChargeEntitlements(): void { $user = $this->getTestUser('jane@kolabnow.com'); - $wallet = $user->wallets()->first(); $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); + $wallet = $user->wallets()->first(); $wallet->discount()->associate($discount); $wallet->save(); @@ -386,6 +387,8 @@ $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); + $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); + $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); $user->assignPlan($plan); $user->assignSku($storage, 5); $user->setSetting('plan_id', null); // disable plan and trial @@ -394,136 +397,248 @@ $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); - Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); + $reseller_wallet->transactions()->delete(); + + // 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)); + + // ------------------------------------------------ + // Test skipping entitlements before a month passed + // ------------------------------------------------ + + $backdate = Carbon::now()->subWeeks(3); + $this->backdateEntitlements($user->entitlements, $backdate); + + // we expect no charges + $this->assertSame(0, $wallet->chargeEntitlements()); + $this->assertSame(0, $wallet->balance); + $this->assertSame(0, $reseller_wallet->balance); + $this->assertSame(0, $wallet->transactions()->count()); + $this->assertSame(12, $user->entitlements()->where('updated_at', $backdate)->count()); // ------------------------------------ // 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); + + // Test with $apply=false argument + $charge = $wallet->chargeEntitlements(false); + + $this->assertSame(778, $charge); + $this->assertSame(0, $wallet->balance); + $this->assertSame(0, $wallet->transactions()->count()); + $charge = $wallet->chargeEntitlements(); $wallet->refresh(); $reseller_wallet->refresh(); // User discount is 30% - // Expected: groupware: 490 x 70% + mailbox: 500 x 70% + storage: 5 x round(25x70%) = 778 + // Expected: groupware: floor(490 * 70%) + mailbox: floor(500 * 70%) + storage: 5 * floor(25 * 70%) = 778 + $this->assertSame(778, $charge); $this->assertSame(-778, $wallet->balance); // Reseller fee is 40% - // Expected: groupware: 490 x 30% + mailbox: 500 x 30% + storage: 5 x round(25x30%) = 332 + // Expected: 778 - groupware: floor(490 * 40%) - mailbox: floor(500 * 40%) - storage: 5 * floor(25 * 40%) = 332 $this->assertSame(332, $reseller_wallet->balance); - $transactions = Transaction::where('object_id', $wallet->id) - ->where('object_type', \App\Wallet::class)->get(); - - $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) - ->where('object_type', \App\Wallet::class)->get(); + $transactions = $wallet->transactions()->get(); + $this->assertCount(1, $transactions); + $trans = $transactions[0]; + $this->assertSame('', $trans->description); + $this->assertSame(-778, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + $reseller_transactions = $reseller_wallet->transactions()->get(); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(332, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); - $this->assertCount(1, $transactions); - $trans = $transactions[0]; - $this->assertSame('', $trans->description); - $this->assertSame(-778, $trans->amount); - $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); - // Assert all entitlements' updated_at timestamp $date = $backdate->addMonthsWithoutOverflow(1); $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get()); + // Assert per-entitlement transactions + $entitlement_transactions = Transaction::where('transaction_id', $transactions[0]->id) + ->where('type', Transaction::ENTITLEMENT_BILLED) + ->get(); + $this->assertSame(7, $entitlement_transactions->count()); + $this->assertSame(778, $entitlement_transactions->sum('amount')); + $groupware_entitlement = $user->entitlements->where('sku_id', '===', $groupware->id)->first(); + $mailbox_entitlement = $user->entitlements->where('sku_id', '===', $mailbox->id)->first(); + $this->assertSame(1, $entitlement_transactions->where('object_id', $groupware_entitlement->id)->count()); + $this->assertSame(1, $entitlement_transactions->where('object_id', $mailbox_entitlement->id)->count()); + $excludes = [$mailbox_entitlement->id, $groupware_entitlement->id]; + $this->assertSame(5, $entitlement_transactions->whereNotIn('object_id', $excludes)->count()); + // ----------------------------------- - // Test charging on entitlement delete + // Test charging deleted entitlements // ----------------------------------- + $wallet->balance = 0; + $wallet->save(); + $wallet->transactions()->delete(); $reseller_wallet->balance = 0; $reseller_wallet->save(); - - $transactions = Transaction::where('object_id', $wallet->id) - ->where('object_type', \App\Wallet::class)->delete(); - - $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) - ->where('object_type', \App\Wallet::class)->delete(); + $reseller_wallet->transactions()->delete(); $user->removeSku($storage, 2); - // we expect the wallet to have been charged for 19 days of use of - // 2 deleted storage entitlements + // we expect the wallet to have been charged for 19 days of use of 2 deleted storage entitlements + $charge = $wallet->chargeEntitlements(); $wallet->refresh(); $reseller_wallet->refresh(); - // 2 x round(25 / 31 * 19 * 0.7) = 22 - $this->assertSame(-(778 + 22), $wallet->balance); - // 22 - 2 x round(25 * 0.4 / 31 * 19) = 10 - $this->assertSame(10, $reseller_wallet->balance); + // 2 * floor(25 / 31 * 70% * 19) = 20 + $this->assertSame(20, $charge); + $this->assertSame(-20, $wallet->balance); + // 20 - 2 * floor(25 / 31 * 40% * 19) = 8 + $this->assertSame(8, $reseller_wallet->balance); - $transactions = Transaction::where('object_id', $wallet->id) - ->where('object_type', \App\Wallet::class)->get(); - - $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) - ->where('object_type', \App\Wallet::class)->get(); + $transactions = $wallet->transactions()->get(); + $this->assertCount(1, $transactions); + $trans = $transactions[0]; + $this->assertSame('', $trans->description); + $this->assertSame(-20, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); - $this->assertCount(2, $reseller_transactions); + $reseller_transactions = $reseller_wallet->transactions()->get(); + $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); - $this->assertSame(5, $trans->amount); + $this->assertSame(8, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); - $trans = $reseller_transactions[1]; - $this->assertSame("Charged user jane@kolabnow.com", $trans->description); - $this->assertSame(5, $trans->amount); - $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); + // Assert per-entitlement transactions + $entitlement_transactions = Transaction::where('transaction_id', $transactions[0]->id) + ->where('type', Transaction::ENTITLEMENT_BILLED) + ->get(); + $storage_entitlements = $user->entitlements->where('sku_id', $storage->id)->where('cost', '>', 0)->pluck('id'); + $this->assertSame(2, $entitlement_transactions->count()); + $this->assertSame(20, $entitlement_transactions->sum('amount')); + $this->assertSame(2, $entitlement_transactions->whereIn('object_id', $storage_entitlements)->count()); - $this->assertCount(2, $transactions); - $trans = $transactions[0]; - $this->assertSame('', $trans->description); - $this->assertSame(-11, $trans->amount); - $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); - $trans = $transactions[1]; - $this->assertSame('', $trans->description); - $this->assertSame(-11, $trans->amount); - $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + // -------------------------------------------------- + // Test skipping deleted entitlements already charged + // -------------------------------------------------- + + $wallet->balance = 0; + $wallet->save(); + $wallet->transactions()->delete(); + $reseller_wallet->balance = 0; + $reseller_wallet->save(); + $reseller_wallet->transactions()->delete(); + + // we expect no charges + $this->assertSame(0, $wallet->chargeEntitlements()); + $this->assertSame(0, $wallet->balance); + $this->assertSame(0, $wallet->transactions()->count()); + $this->assertSame(0, $reseller_wallet->fresh()->balance); + + // --------------------------------------------------------- + // Test (not) charging entitlements deleted before 14 days + // --------------------------------------------------------- + + $backdate = Carbon::now()->subDays(13); + + $ent = $user->entitlements->where('sku_id', $groupware->id)->first(); + Entitlement::where('id', $ent->id)->update([ + 'created_at' => $backdate, + 'updated_at' => $backdate, + 'deleted_at' => Carbon::now(), + ]); + + // we expect no charges + $this->assertSame(0, $wallet->chargeEntitlements()); + $this->assertSame(0, $wallet->balance); + $this->assertSame(0, $wallet->transactions()->count()); + $this->assertSame(0, $reseller_wallet->fresh()->balance); + // expect update of updated_at timestamp + $this->assertSame(Carbon::now()->toDateTimeString(), $ent->fresh()->updated_at->toDateTimeString()); + + // ------------------------------------------------------- + // Test charging a degraded account + // Test both deleted and non-deleted in the same operation + // ------------------------------------------------------- + + // At this point user has: mailbox + 8 x storage + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements->fresh(), $backdate); + + $user->status |= User::STATUS_DEGRADED; + $user->saveQuietly(); + + $wallet->refresh(); + $wallet->balance = 0; + $wallet->save(); + $reseller_wallet->balance = 0; + $reseller_wallet->save(); + Transaction::truncate(); - // TODO: Test entitlement transaction records + $charge = $wallet->chargeEntitlements(); + $reseller_wallet->refresh(); + + // User would be charged if not degraded: mailbox: floor(500 * 70%) + storage: 3 * floor(25 * 70%) = 401 + $this->assertSame(0, $charge); + $this->assertSame(0, $wallet->balance); + // Expected: 0 - mailbox: floor(500 * 40%) - storage: 3 * floor(25 * 40%) = -230 + $this->assertSame(-230, $reseller_wallet->balance); + + // Assert all entitlements' updated_at timestamp + $date = $backdate->addMonthsWithoutOverflow(1); + $this->assertSame(9, $wallet->entitlements()->where('updated_at', $date)->count()); + // There should be only one transaction at this point (for the reseller wallet) + $this->assertSame(1, Transaction::count()); } /** - * Test for charging and removing entitlements when in trial + * Test for charging entitlements when in trial */ - public function testChargeAndDeleteEntitlementsTrial(): void + public function testChargeEntitlementsTrial(): void { $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); + // Add 40% fee to all SKUs + Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); + $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $user->assignPlan($plan); $user->assignSku($storage, 5); + // Reset reseller's wallet balance and transactions + $reseller_wallet = $user->tenant->wallet(); + $reseller_wallet->balance = 0; + $reseller_wallet->save(); + $reseller_wallet->transactions()->delete(); + + // 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)); + // ------------------------------------ // 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); + $backdate = Carbon::now()->subWeeks(7); // 2021-04-02 + $this->backdateEntitlements($user->entitlements, $backdate, $backdate); $charge = $wallet->chargeEntitlements(); - $wallet->refresh(); + $reseller_wallet->refresh(); - // Expected: storage: 5 x 25 = 125 (the rest is free in trial) + // Expected: storage: 5 * 25 = 125 (the rest is free in trial) $this->assertSame($balance = -125, $wallet->balance); + $this->assertSame(-$balance, $charge); + + // Reseller fee is 40% + // Expected: 125 - 5 * floor(25 * 40%) = 75 + $this->assertSame($reseller_balance = 75, $reseller_wallet->balance); // Assert wallet transaction $transactions = $wallet->transactions()->get(); - $this->assertCount(1, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); @@ -546,52 +661,57 @@ $charge = $wallet->chargeEntitlements(); $wallet->refresh(); + $this->assertSame(0, $charge); $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 + // Test charging deleted entitlements // ----------------------------------- - $wallet->transactions()->delete(); + $wallet->balance = 0; + $wallet->save(); + $reseller_wallet->balance = 0; + $reseller_wallet->save(); + Transaction::truncate(); $user->removeSku($storage, 2); + $charge = $wallet->chargeEntitlements(); + $wallet->refresh(); + $reseller_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); + // 2 deleted storage entitlements: 2 * floor(25 / 31 * 19) = 30 + $this->assertSame(-30, $wallet->balance); + $this->assertSame(30, $charge); + + // Reseller fee is 40% + // Expected: 30 - 2 * floor(25 / 31 * 40% * 19) = 18 + $this->assertSame(18, $reseller_wallet->balance); // Assert wallet transactions $transactions = $wallet->transactions()->get(); - - $this->assertCount(2, $transactions); + $this->assertCount(1, $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(-30, $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); + $etransactions = Transaction::where('transaction_id', $trans->id)->get(); + $this->assertCount(2, $etransactions); $trans = $etransactions[0]; $this->assertSame(null, $trans->description); $this->assertSame(15, $trans->amount); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type); - */ + + // Assert the deleted entitlements' updated_at timestamp was bumped + $this->assertSame(2, $wallet->entitlements()->withTrashed()->whereColumn('updated_at', 'deleted_at')->count()); + + // TODO: Test a case when trial ends after the entitlement deletion date } /** @@ -631,20 +751,118 @@ $this->markTestIncomplete(); } - /** - * Tests for chargeEntitlement() - */ - public function testChargeEntitlement(): void - { - $this->markTestIncomplete(); - } - /** * Tests for updateEntitlements() */ public function testUpdateEntitlements(): void { - $this->markTestIncomplete(); + $user = $this->getTestUser('jane@kolabnow.com'); + $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); + $wallet = $user->wallets()->first(); + $wallet->discount()->associate($discount); + $wallet->save(); + + // Add 40% fee to all SKUs + Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); + + $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); + $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); + $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); + $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first(); + $user->assignPlan($plan); + $user->setSetting('plan_id', null); // disable plan and trial + + // Reset reseller's wallet balance and transactions + $reseller_wallet = $user->tenant->wallet(); + $reseller_wallet->balance = 0; + $reseller_wallet->save(); + $reseller_wallet->transactions()->delete(); + + // 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)); + $now = Carbon::now(); + + // Backdate and charge entitlements + $backdate = Carbon::now()->subWeeks(3)->setHour(10); + $this->backdateEntitlements($user->entitlements, $backdate); + + // --------------------------------------- + // Update entitlements with no cost charge + // --------------------------------------- + + // Test with $withCost=false argument + $charge = $wallet->updateEntitlements(false); + $wallet->refresh(); + $reseller_wallet->refresh(); + + $this->assertSame(0, $charge); + $this->assertSame(0, $wallet->balance); + $this->assertSame(0, $wallet->transactions()->count()); + // Expected: 0 - groupware: floor(490 / 31 * 21 * 40%) - mailbox: floor(500 / 31 * 21 * 40%) = -267 + $this->assertSame(-267, $reseller_wallet->balance); + + // Assert all entitlements' updated_at timestamp + $date = $now->copy()->setTimeFrom($backdate); + $this->assertCount(7, $wallet->entitlements()->where('updated_at', $date)->get()); + + $reseller_transactions = $reseller_wallet->transactions()->get(); + $this->assertCount(1, $reseller_transactions); + $trans = $reseller_transactions[0]; + $this->assertSame("Charged user jane@kolabnow.com", $trans->description); + $this->assertSame(-267, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + + // ------------------------------------ + // Update entitlements with cost charge + // ------------------------------------ + + $reseller_wallet = $user->tenant->wallet(); + $reseller_wallet->balance = 0; + $reseller_wallet->save(); + $reseller_wallet->transactions()->delete(); + + $this->backdateEntitlements($user->entitlements, $backdate); + + $charge = $wallet->updateEntitlements(); + $wallet->refresh(); + $reseller_wallet->refresh(); + + // User discount is 30% + // Expected: groupware: floor(490 / 31 * 21 * 70%) + mailbox: floor(500 / 31 * 21 * 70%) = 469 + $this->assertSame(469, $charge); + $this->assertSame(-469, $wallet->balance); + // Reseller fee is 40% + // Expected: 469 - groupware: floor(490 / 31 * 21 * 40%) - mailbox: floor(500 / 31 * 21 * 40%) = 202 + $this->assertSame(202, $reseller_wallet->balance); + + $transactions = $wallet->transactions()->get(); + $this->assertCount(1, $transactions); + $trans = $transactions[0]; + $this->assertSame('', $trans->description); + $this->assertSame(-469, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + + $reseller_transactions = $reseller_wallet->transactions()->get(); + $this->assertCount(1, $reseller_transactions); + $trans = $reseller_transactions[0]; + $this->assertSame("Charged user jane@kolabnow.com", $trans->description); + $this->assertSame(202, $trans->amount); + $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); + + // Assert all entitlements' updated_at timestamp + $date = $now->copy()->setTimeFrom($backdate); + $this->assertCount(7, $wallet->entitlements()->where('updated_at', $date)->get()); + + // Assert per-entitlement transactions + $groupware_entitlement = $user->entitlements->where('sku_id', '===', $groupware->id)->first(); + $mailbox_entitlement = $user->entitlements->where('sku_id', '===', $mailbox->id)->first(); + $entitlement_transactions = Transaction::where('transaction_id', $transactions[0]->id) + ->where('type', Transaction::ENTITLEMENT_BILLED) + ->get(); + $this->assertSame(2, $entitlement_transactions->count()); + $this->assertSame(469, $entitlement_transactions->sum('amount')); + $this->assertSame(1, $entitlement_transactions->where('object_id', $groupware_entitlement->id)->count()); + $this->assertSame(1, $entitlement_transactions->where('object_id', $mailbox_entitlement->id)->count()); } /** diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -160,7 +160,10 @@ $this->assertCount(8 + count($other), $result); } - protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null) + /** + * Set a specific date to existing entitlements + */ + protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null): void { $wallets = []; $ids = []; diff --git a/src/tests/Unit/WalletTest.php b/src/tests/Unit/WalletTest.php --- a/src/tests/Unit/WalletTest.php +++ b/src/tests/Unit/WalletTest.php @@ -3,14 +3,13 @@ namespace Tests\Unit; use App\Wallet; +use Carbon\Carbon; use Tests\TestCase; class WalletTest extends TestCase { /** * Test Wallet::money() - * - * @return void */ public function testMoney() { @@ -27,4 +26,51 @@ $this->assertSame('-1,23 €', $money); } + + /** + * Test Wallet::entitlementCosts() + */ + public function testEntitlementCosts() + { + $discount = \App\Discount::where('discount', 30)->first(); + $wallet = new Wallet(['currency' => 'CHF', 'id' => 123]); + $ent = new \App\Entitlement([ + 'wallet_id' => 123, + 'sku_id' => 456, + 'cost' => 100, + 'fee' => 50, + 'entitleable_id' => 789, + 'entitleable_type' => \App\User::class, + ]); + + $wallet->discount = $discount; // @phpstan-ignore-line + + // Test calculating with daily price, period spread over two months + Carbon::setTestNow(Carbon::create(2021, 5, 5, 12)); + $ent->created_at = Carbon::now()->subDays(20); + $ent->updated_at = Carbon::now()->subDays(20); + + $result = $this->invokeMethod($wallet, 'entitlementCosts', [$ent, null, true]); + + // cost: floor(100 / 30 * 15 * 70%) + floor(100 / 31 * 5 * 70%) = 46 + $this->assertSame(46, $result[0]); + // fee: floor(50 / 30 * 15) + floor(50 / 31 * 5) = 33 + $this->assertSame(33, $result[1]); + $this->assertTrue(Carbon::now()->equalTo($result[2])); // end of period + + // Test calculating with daily price, period spread over three months + Carbon::setTestNow(Carbon::create(2021, 5, 5, 12)); + $ent->created_at = Carbon::create(2021, 3, 25, 12); + $ent->updated_at = Carbon::create(2021, 3, 25, 12); + + $result = $this->invokeMethod($wallet, 'entitlementCosts', [$ent, null, true]); + + // cost: floor(100 * 70%) + floor(100 / 30 * 5 * 70%) + floor(100 / 31 * 5 * 70%) = 92 + $this->assertSame(92, $result[0]); + // fee: 50 + floor(50 / 30 * 5) + floor(50 / 31 * 5) = 66 + $this->assertSame(66, $result[1]); + $this->assertTrue(Carbon::now()->equalTo($result[2])); // end of period + + // TODO: More cases + } }