diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index c6981403..07fb38cf 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,111 +1,116 @@ 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); // Update the user IMAP mailbox quota if ($entitlement->sku->title == 'storage') { \App\Jobs\User\UpdateJob::dispatch($entitlement->entitleable_id); } } /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ 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(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); } // 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(); } // Update the user IMAP mailbox quota if ($entitlement->sku->title == 'storage') { \App\Jobs\User\UpdateJob::dispatch($entitlement->entitleable_id); } } /** * Handle the entitlement "deleting" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ 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 index 54d3065e..9da4684d 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,746 +1,722 @@ 0, ]; /** @var array The attributes that are mass assignable */ protected $fillable = [ 'currency', 'description' ]; /** @var array The attributes that can be not set */ protected $nullable = [ 'description', ]; /** @var array The types of attributes to which its values will be cast */ protected $casts = [ 'balance' => 'integer', ]; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } /** * Add an award to this wallet's balance. * * @param int|\App\Payment $amount The amount of award (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function award(int|Payment $amount, string $description = ''): Wallet { 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 * * @param bool $apply Set to false for a dry-run mode * * @return int Charged amount in cents */ public function chargeEntitlements($apply = true): int { $transactions = []; $profit = 0; $charges = 0; - $discount = $this->getDiscountRate(); $isDegraded = $this->owner->isDegraded(); $trial = $this->trialInfo(); if ($apply) { 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; } $charges += $cost; $profit += $cost - $fee; // if we're in dry-run, you know... if (!$apply) { continue; } - $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); - $entitlement->save(); + if ($endDate) { + $entitlement->updated_at = $endDate; + $entitlement->save(); + } if ($cost == 0) { continue; } $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost); } 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(); } return $charges; } /** * Calculate for how long the current balance will last. * * Returns NULL for balance < 0 or discount = 100% or on a fresh account * * @return \Carbon\Carbon|null Date */ public function balanceLastsUntil() { if ($this->balance < 0 || $this->getDiscount() == 100) { return null; } $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; } $balance -= (int) ($entitlement['cost'] * $discount); if ($balance < 0) { break 2; } } $max--; } if (empty($until)) { return null; } // Don't return dates from the past if ($until <= Carbon::now() && !$until->isToday()) { return null; } return $until; } /** * Chargeback an amount of pecunia from this wallet's balance. * * @param int|\App\Payment $amount The amount of pecunia to charge back (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function chargeback(int|Payment $amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_CHARGEBACK, $amount, $description); } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( User::class, // The foreign object definition 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Add an amount of pecunia to this wallet's balance. * * @param int|\App\Payment $amount The amount of pecunia to add (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function credit(int|Payment $amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_CREDIT, $amount, $description); } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int|\App\Payment $amount The amount of pecunia to deduct (in cents) or Payment object * @param string $description The transaction description * @param array $eTIDs List of transaction IDs for the individual entitlements * that make up this debit record, if any. * @return Wallet Self */ public function debit(int|Payment $amount, string $description = '', array $eTIDs = []): Wallet { return $this->balanceUpdate(Transaction::WALLET_DEBIT, $amount, $description, $eTIDs); } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { return $this->belongsTo(Discount::class, 'discount_id', 'id'); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany(Entitlement::class); } /** * Calculate the expected charges to this wallet. * * @return int */ public function expectedCharges() { return $this->chargeEntitlements(false); } /** * Return the exact, numeric version of the discount to be applied. * * @return int Discount in percent, ranges from 0 - 100. */ public function getDiscount(): int { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * * @return float Discount rate, ranges from 0.00 to 1.00. */ public function getDiscountRate(): float { return (100 - $this->getDiscount()) / 100; } /** * The minimum amount of an auto-payment mandate * * @return int Amount in cents */ public function getMinMandateAmount(): int { $min = Payment::MIN_AMOUNT; if ($plan = $this->plan()) { $planCost = (int) ($plan->cost() * $this->getDiscountRate()); if ($planCost > $min) { $min = $planCost; } } return $min; } /** * Check if the specified user is a controller to this wallet. * * @param \App\User $user The user object. * * @return bool True if the user is one of the wallet controllers (including user), False otherwise */ public function isController(User $user): bool { return $user->id == $this->user_id || $this->controllers->contains($user); } /** * A helper to display human-readable amount of money using * the wallet currency and specified locale. * * @param int $amount A amount of money (in cents) * @param string $locale A locale for the output * * @return string String representation, e.g. "9.99 CHF" */ public function money(int $amount, $locale = 'de_DE') { return \App\Utils::money($amount, $this->currency, $locale); } /** * The owner of the wallet -- the wallet is in his/her back pocket. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo(User::class, 'user_id', 'id'); } /** * Payments on this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function payments() { return $this->hasMany(Payment::class); } /** * Add a penalty to this wallet's balance. * * @param int|\App\Payment $amount The amount of penalty (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function penalty(int|Payment $amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_PENALTY, $amount, $description); } /** * 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. * * @return void */ public function removeController(User $user) { if ($this->controllers->contains($user)) { $this->controllers()->detach($user); } } /** * Refund an amount of pecunia from this wallet's balance. * * @param int|\App\Payment $amount The amount of pecunia to refund (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function refund($amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_REFUND, $amount, $description); } /** * Get the VAT rate for the wallet owner country. * * @param ?\DateTime $start Get the rate valid for the specified date-time, * without it the current rate will be returned (if exists). * * @return ?\App\VatRate VAT rate */ public function vatRate(\DateTime $start = null): ?VatRate { $owner = $this->owner; // Make it working with deleted accounts too if (!$owner) { $owner = $this->owner()->withTrashed()->first(); } $country = $owner->getSetting('country'); if (!$country) { return null; } return VatRate::where('country', $country) ->where('start', '<=', ($start ?: now())->format('Y-m-d h:i:s')) ->orderByDesc('start') ->limit(1) ->first(); } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { 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; } /** * Force-update entitlements' updated_at, charge if needed. * * @param bool $withCost When enabled the cost will be charged * * @return int Charged amount in cents */ 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 */ protected function balanceUpdate(string $type, int|Payment $amount, $description = null, array $eTIDs = []) { if ($amount instanceof Payment) { $amount = $amount->credit_amount; } if ($amount === 0) { return $this; } if (in_array($type, [Transaction::WALLET_CREDIT, Transaction::WALLET_AWARD])) { $amount = abs($amount); } else { $amount = abs($amount) * -1; } $this->balance += $amount; $this->save(); $transaction = Transaction::create([ 'user_email' => \App\Utils::userEmailOrNull(), 'object_id' => $this->id, 'object_type' => Wallet::class, 'type' => $type, 'amount' => $amount, 'description' => $description, ]); if (!empty($eTIDs)) { Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); } 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 index 6539bdd9..656786a6 100644 --- a/src/tests/Feature/Console/User/RemoveSkuTest.php +++ b/src/tests/Feature/Console/User/RemoveSkuTest.php @@ -1,79 +1,71 @@ deleteTestUser('remove-entitlement@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('remove-entitlement@kolabnow.com'); parent::tearDown(); } /** * Test command runs */ public function testHandle(): void { $storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first(); $user = $this->getTestUser('remove-entitlement@kolabnow.com'); // Unknown user $this->artisan("user:remove-sku unknown@unknown.org {$storage->id}") ->assertExitCode(1) ->expectsOutput("User not found."); // Unknown SKU $this->artisan("user:remove-sku {$user->email} unknownsku") ->assertExitCode(1) ->expectsOutput("Unable to find the SKU unknownsku."); // Invalid quantity $this->artisan("user:remove-sku {$user->email} {$storage->id} --qty=5") ->assertExitCode(1) ->expectsOutput("There aren't that many entitlements."); $user->assignSku($storage, 80); $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); $this->assertSame(79, $entitlements->count()); // Mass removal $start = microtime(true); $this->artisan("user:remove-sku {$user->email} {$storage->id} --qty=78") ->assertExitCode(0); // 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 index 1fa07e1f..425c7de2 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,245 +1,204 @@ deleteTestUser('entitlement-test@kolabnow.com'); $this->deleteTestUser('entitled-user@custom-domain.com'); $this->deleteTestGroup('test-group@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->deleteTestGroup('test-group@custom-domain.com'); $this->deleteTestDomain('custom-domain.com'); parent::tearDown(); } /** * Tests for EntitlementObserver */ public function testEntitlementObserver(): void { $skuStorage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $user = $this->getTestUser('entitlement-test@kolabnow.com'); $wallet = $user->wallets->first(); // Test dispatching update jobs for the user, on quota update Queue::fake(); $user->assignSku($skuMailbox, 1, $wallet); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); Queue::fake(); $user->assignSku($skuStorage, 1, $wallet); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { return $user->id === TestCase::getObjectProperty($job, 'userId'); } ); Queue::fake(); $user->entitlements()->where('sku_id', $skuMailbox->id)->first()->delete(); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 0); Queue::fake(); $user->entitlements()->where('sku_id', $skuStorage->id)->first()->delete(); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 1); Queue::assertPushed( \App\Jobs\User\UpdateJob::class, function ($job) use ($user) { return $user->id === TestCase::getObjectProperty($job, 'userId'); } ); // TODO: Test all events in the observer in more detail } /** * Tests for entitlements * @todo This really should be in User or Wallet tests file */ public function testEntitlements(): void { $packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $skuDomain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $skuMailbox = Sku::withEnvTenantContext()->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(7, $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(15, $wallet->entitlements); $this->backdateEntitlements( $owner->entitlements, Carbon::now()->subMonthsWithoutOverflow(1) ); $wallet->chargeEntitlements(); $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() */ public function testEntitleableTitle(): void { Queue::fake(); $packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user = $this->getTestUser('entitled-user@custom-domain.com'); $group = $this->getTestGroup('test-group@custom-domain.com'); $domain = $this->getTestDomain( 'custom-domain.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL, ] ); $wallet = $user->wallets->first(); $domain->assignPackage($packageDomain, $user); $user->assignPackage($packageKolab); $group->assignToWallet($wallet); $sku_mailbox = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $sku_group = \App\Sku::withEnvTenantContext()->where('title', 'group')->first(); $sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first(); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_mailbox->id)->first(); $this->assertSame($user->email, $entitlement->entitleable->toString()); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_group->id)->first(); $this->assertSame($group->email, $entitlement->entitleable->toString()); $entitlement = Entitlement::where('wallet_id', $wallet->id) ->where('sku_id', $sku_domain->id)->first(); $this->assertSame($domain->namespace, $entitlement->entitleable->toString()); // Make sure it still works if the entitleable is deleted $domain->delete(); $entitlement->refresh(); $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 index 7c627a3d..104d2168 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,692 +1,910 @@ users as $user) { $this->deleteTestUser($user); } Sku::select()->update(['fee' => 0]); Payment::query()->delete(); VatRate::query()->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } Sku::select()->update(['fee' => 0]); Payment::query()->delete(); VatRate::query()->delete(); Plan::withEnvTenantContext()->where('title', 'individual')->update(['months' => 1]); parent::tearDown(); } /** * Test that turning wallet balance from negative to positive * unsuspends and undegrades the account */ public function testBalanceTurnsPositive(): void { Queue::fake(); $user = $this->getTestUser('UserWallet1@UserWallet.com'); $user->suspend(); $user->degrade(); $wallet = $user->wallets()->first(); $wallet->balance = -100; $wallet->save(); $this->assertTrue($user->isSuspended()); $this->assertTrue($user->isDegraded()); $this->assertNotNull($wallet->getSetting('balance_negative_since')); $wallet->balance = 100; $wallet->save(); $user->refresh(); $this->assertFalse($user->isSuspended()); $this->assertFalse($user->isDegraded()); $this->assertNull($wallet->getSetting('balance_negative_since')); // Test un-restricting users on balance change $this->deleteTestUser('UserWallet1@UserWallet.com'); $owner = $this->getTestUser('UserWallet1@UserWallet.com'); $user1 = $this->getTestUser('UserWallet2@UserWallet.com'); $user2 = $this->getTestUser('UserWallet3@UserWallet.com'); $package = Package::withEnvTenantContext()->where('title', 'lite')->first(); $owner->assignPackage($package, $user1); $owner->assignPackage($package, $user2); $wallet = $owner->wallets()->first(); $owner->restrict(); $user1->restrict(); $user2->restrict(); $this->assertTrue($owner->isRestricted()); $this->assertTrue($user1->isRestricted()); $this->assertTrue($user2->isRestricted()); Queue::fake(); $wallet->balance = 100; $wallet->save(); $this->assertFalse($owner->fresh()->isRestricted()); $this->assertFalse($user1->fresh()->isRestricted()); $this->assertFalse($user2->fresh()->isRestricted()); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // TODO: Test group account and unsuspending domain/members/groups } /** * Test for Wallet::balanceLastsUntil() */ public function testBalanceLastsUntil(): void { // Monthly cost of all entitlements: 990 // 28 days: 35.36 per day // 31 days: 31.93 per day $user = $this->getTestUser('jane@kolabnow.com'); $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $user->assignPlan($plan); $wallet = $user->wallets()->first(); // User/entitlements created today, balance=0 $until = $wallet->balanceLastsUntil(); $this->assertSame( Carbon::now()->addMonthsWithoutOverflow(1)->toDateString(), $until->toDateString() ); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $until = $wallet->balanceLastsUntil(); $this->assertSame(null, $until); // User/entitlements created today, balance=-9,99 CHF (monthly cost) $wallet->balance = 990; $until = $wallet->balanceLastsUntil(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $delta = Carbon::now()->addMonthsWithoutOverflow(1)->addDays($daysInLastMonth)->diff($until)->days; $this->assertTrue($delta <= 1); $this->assertTrue($delta >= -1); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::withEnvTenantContext()->where('discount', 100)->first(); $wallet->discount()->associate($discount); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); // User with no entitlements $wallet->discount()->dissociate($discount); $wallet->entitlements()->delete(); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); } /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $this->assertCount(1, $user->wallets); $this->assertSame(\config('app.currency'), $user->wallets[0]->currency); $this->assertSame(0, $user->wallets[0]->balance); } /** * Verify a user can haz more wallets. */ public function testAddWallet(): void { $user = $this->getTestUser('UserWallet2@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $this->assertCount(2, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertEquals(0, $wallet->balance); } ); // For now all wallets use system currency $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); } /** * Verify we can not delete a user wallet that holds balance. */ public function testDeleteWalletWithCredit(): void { $user = $this->getTestUser('UserWallet3@UserWallet.com'); $user->wallets()->each( function ($wallet) { $wallet->credit(100)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can not delete a wallet that is the last wallet. */ public function testDeleteLastWallet(): void { $user = $this->getTestUser('UserWallet4@UserWallet.com'); $this->assertCount(1, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can remove a wallet that is an additional wallet. */ public function testDeleteAddtWallet(): void { $user = $this->getTestUser('UserWallet5@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); // For now additional wallets with a different currency is not allowed $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); /* $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); */ } /** * Verify a wallet can be assigned a controller. */ public function testAddController(): void { $userA = $this->getTestUser('WalletControllerA@WalletController.com'); $userB = $this->getTestUser('WalletControllerB@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertCount(1, $userB->accounts); $aWallet = $userA->wallets()->first(); $bAccount = $userB->accounts()->first(); $this->assertTrue($bAccount->id === $aWallet->id); } /** * Test Wallet::getMinMandateAmount() */ public function testGetMinMandateAmount(): void { $user = $this->getTestUser('WalletControllerA@WalletController.com'); $user->setSetting('plan_id', null); $wallet = $user->wallets()->first(); // No plan assigned $this->assertSame(Payment::MIN_AMOUNT, $wallet->getMinMandateAmount()); // Plan assigned $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $plan->months = 12; $plan->save(); $user->setSetting('plan_id', $plan->id); $this->assertSame(990 * 12, $wallet->getMinMandateAmount()); // Plan and discount $discount = Discount::where('discount', 30)->first(); $wallet->discount()->associate($discount); $wallet->save(); $this->assertSame((int) (990 * 12 * 0.70), $wallet->getMinMandateAmount()); } /** * Test Wallet::isController() */ public function testIsController(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $jack->wallet(); $this->assertTrue($wallet->isController($john)); $this->assertTrue($wallet->isController($ned)); $this->assertFalse($wallet->isController($jack)); } /** * Verify controllers can also be removed from wallets. */ public function testRemoveWalletController(): void { $userA = $this->getTestUser('WalletController2A@WalletController.com'); $userB = $this->getTestUser('WalletController2B@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $userB->refresh(); $userB->accounts()->each( function ($wallet) use ($userB) { $wallet->removeController($userB); } ); $this->assertCount(0, $userB->accounts); } /** - * 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(); // 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->assignSku($storage, 5); $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(); - 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); $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(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 } /** * Tests for award() and penalty() */ public function testAwardAndPenalty(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $wallet = $user->wallets()->first(); // Test award $this->assertSame($wallet->id, $wallet->award(100, 'test')->id); $this->assertSame(100, $wallet->balance); $this->assertSame(100, $wallet->fresh()->balance); $transaction = $wallet->transactions()->first(); $this->assertSame(100, $transaction->amount); $this->assertSame(Transaction::WALLET_AWARD, $transaction->type); $this->assertSame('test', $transaction->description); $wallet->transactions()->delete(); // Test penalty $this->assertSame($wallet->id, $wallet->penalty(100, 'test')->id); $this->assertSame(0, $wallet->balance); $this->assertSame(0, $wallet->fresh()->balance); $transaction = $wallet->transactions()->first(); $this->assertSame(-100, $transaction->amount); $this->assertSame(Transaction::WALLET_PENALTY, $transaction->type); $this->assertSame('test', $transaction->description); } /** * Tests for chargeback() and refund() */ public function testChargebackAndRefund(): void { $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()); } /** * Tests for vatRate() */ public function testVatRate(): void { $rate1 = VatRate::create([ 'start' => now()->subDay(), 'country' => 'US', 'rate' => 7.5, ]); $rate2 = VatRate::create([ 'start' => now()->subDay(), 'country' => 'DE', 'rate' => 10.0, ]); $user = $this->getTestUser('UserWallet1@UserWallet.com'); $wallet = $user->wallets()->first(); $user->setSetting('country', null); $this->assertSame(null, $wallet->vatRate()); $user->setSetting('country', 'PL'); $this->assertSame(null, $wallet->vatRate()); $user->setSetting('country', 'US'); $this->assertSame($rate1->id, $wallet->vatRate()->id); // @phpstan-ignore-line $user->setSetting('country', 'DE'); $this->assertSame($rate2->id, $wallet->vatRate()->id); // @phpstan-ignore-line // Test $start argument $rate3 = VatRate::create([ 'start' => now()->subYear(), 'country' => 'DE', 'rate' => 5.0, ]); $this->assertSame($rate2->id, $wallet->vatRate()->id); // @phpstan-ignore-line $this->assertSame($rate3->id, $wallet->vatRate(now()->subMonth())->id); $this->assertSame(null, $wallet->vatRate(now()->subYears(2))); } } diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php index f3d17ebf..990c813b 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,766 +1,769 @@ 'John', 'last_name' => 'Doe', 'organization' => 'Test Domain Owner', ]; /** * Some users for the hosted domain, ultimately including the owner. * * @var \App\User[] */ protected $domainUsers = []; /** * A specific user that is a regular user in the hosted domain. * * @var ?\App\User */ protected $jack; /** * A specific user that is a controller on the wallet to which the hosted domain is charged. * * @var ?\App\User */ protected $jane; /** * A specific user that has a second factor configured. * * @var ?\App\User */ protected $joe; /** * One of the domains that is available for public registration. * * @var ?\App\Domain */ protected $publicDomain; /** * A newly generated user in a public domain. * * @var ?\App\User */ protected $publicDomainUser; /** * A placeholder for a password that can be generated. * * Should be generated with `\App\Utils::generatePassphrase()`. * * @var ?string */ protected $userPassword; /** * Register the beta entitlement for a user */ protected function addBetaEntitlement($user, $titles = []): void { // Add beta + $title entitlements $beta_sku = Sku::withEnvTenantContext()->where('title', 'beta')->first(); $user->assignSku($beta_sku); if (!empty($titles)) { Sku::withEnvTenantContext()->whereIn('title', (array) $titles)->get() ->each(function ($sku) use ($user) { $user->assignSku($sku); }); } } /** * Assert that the entitlements for the user match the expected list of entitlements. * * @param \App\User|\App\Domain $object The object for which the entitlements need to be pulled. * @param array $expected An array of expected \App\Sku titles. */ protected function assertEntitlements($object, $expected) { // Assert the user entitlements $skus = $object->entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } /** * Assert content of the SKU element in an API response * * @param string $sku_title The SKU title * @param array $result The result to assert * @param array $other Other items the SKU itself does not include */ protected function assertSkuElement($sku_title, $result, $other = []): void { $sku = Sku::withEnvTenantContext()->where('title', $sku_title)->first(); $this->assertSame($sku->id, $result['id']); $this->assertSame($sku->title, $result['title']); $this->assertSame($sku->name, $result['name']); $this->assertSame($sku->description, $result['description']); $this->assertSame($sku->cost, $result['cost']); $this->assertSame($sku->units_free, $result['units_free']); $this->assertSame($sku->period, $result['period']); $this->assertSame($sku->active, $result['active']); foreach ($other as $key => $value) { $this->assertSame($value, $result[$key]); } $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 = []; foreach ($entitlements as $entitlement) { $ids[] = $entitlement->id; $wallets[] = $entitlement->wallet_id; } \App\Entitlement::whereIn('id', $ids)->update([ 'created_at' => $targetCreatedDate ?: $targetDate, 'updated_at' => $targetDate, ]); if (!empty($wallets)) { $wallets = array_unique($wallets); $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); \App\User::whereIn('id', $owners)->update([ 'created_at' => $targetCreatedDate ?: $targetDate ]); } } /** * Removes all beta entitlements from the database */ protected function clearBetaEntitlements(): void { $beta_handlers = [ 'App\Handlers\Beta', ]; $betas = Sku::whereIn('handler_class', $beta_handlers)->pluck('id')->all(); \App\Entitlement::whereIn('sku_id', $betas)->delete(); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } /** * Create a set of transaction log entries for a wallet */ protected function createTestTransactions($wallet) { $result = []; $date = Carbon::now(); $debit = 0; $entitlementTransactions = []; foreach ($wallet->entitlements as $entitlement) { if ($entitlement->cost) { $debit += $entitlement->cost; $entitlementTransactions[] = $entitlement->createTransaction( Transaction::ENTITLEMENT_BILLED, $entitlement->cost ); } } $transaction = Transaction::create( [ 'user_email' => 'jeroen@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $debit * -1, 'description' => 'Payment', ] ); $result[] = $transaction; Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); $transaction = Transaction::create( [ 'user_email' => null, 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => 2000, 'description' => 'Payment', ] ); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; $types = [ Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY, ]; // The page size is 10, so we generate so many to have at least two pages $loops = 10; while ($loops-- > 0) { $type = $types[count($result) % count($types)]; $transaction = Transaction::create([ 'user_email' => 'jeroen.@jeroen.jeroen', 'object_id' => $wallet->id, 'object_type' => \App\Wallet::class, 'type' => $type, 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1), 'description' => 'TRANS' . $loops, ]); $transaction->created_at = $date->next(Carbon::MONDAY); $transaction->save(); $result[] = $transaction; } return $result; } /** * Delete a test domain whatever it takes. * * @coversNothing */ protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\Domain\DeleteJob($domain->id); $job->handle(); $domain->forceDelete(); } /** * Delete a test group whatever it takes. * * @coversNothing */ protected function deleteTestGroup($email) { Queue::fake(); $group = Group::withTrashed()->where('email', $email)->first(); if (!$group) { return; } LDAP::deleteGroup($group); $group->forceDelete(); } /** * Delete a test resource whatever it takes. * * @coversNothing */ protected function deleteTestResource($email) { Queue::fake(); $resource = Resource::withTrashed()->where('email', $email)->first(); if (!$resource) { return; } LDAP::deleteResource($resource); $resource->forceDelete(); } /** * Delete a test room whatever it takes. * * @coversNothing */ protected function deleteTestRoom($name) { Queue::fake(); $room = \App\Meet\Room::withTrashed()->where('name', $name)->first(); if (!$room) { return; } $room->forceDelete(); } /** * Delete a test shared folder whatever it takes. * * @coversNothing */ protected function deleteTestSharedFolder($email) { Queue::fake(); $folder = SharedFolder::withTrashed()->where('email', $email)->first(); if (!$folder) { return; } LDAP::deleteSharedFolder($folder); $folder->forceDelete(); } /** * Delete a test user whatever it takes. * * @coversNothing */ protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } if (\config('app.with_imap')) { IMAP::deleteUser($user); } if (\config('app.with_ldap')) { LDAP::deleteUser($user); } $user->forceDelete(); } /** * Delete a test companion app whatever it takes. * * @coversNothing */ protected function deleteTestCompanionApp($deviceId) { Queue::fake(); $companionApp = CompanionApp::where('device_id', $deviceId)->first(); if (!$companionApp) { return; } $companionApp->forceDelete(); } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get Group object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestGroup($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Group::firstOrCreate(['email' => $email], $attrib); } /** * Get Resource object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestResource($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $resource = Resource::where('email', $email)->first(); if (!$resource) { list($local, $domain) = explode('@', $email, 2); $resource = new Resource(); $resource->email = $email; $resource->domainName = $domain; if (!isset($attrib['name'])) { $resource->name = $local; } } foreach ($attrib as $key => $val) { $resource->{$key} = $val; } $resource->save(); return $resource; } /** * Get Room object by name, create it if needed. * * @coversNothing */ protected function getTestRoom($name, $wallet = null, $attrib = [], $config = [], $title = null) { $attrib['name'] = $name; $room = \App\Meet\Room::create($attrib); if ($wallet) { $room->assignToWallet($wallet, $title); } if (!empty($config)) { $room->setConfig($config); } return $room; } /** * Get SharedFolder object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestSharedFolder($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $folder = SharedFolder::where('email', $email)->first(); if (!$folder) { list($local, $domain) = explode('@', $email, 2); $folder = new SharedFolder(); $folder->email = $email; $folder->domainName = $domain; if (!isset($attrib['name'])) { $folder->name = $local; } } foreach ($attrib as $key => $val) { $folder->{$key} = $val; } $folder->save(); return $folder; } /** * Get User object by email, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestUser($email, $attrib = [], $createInBackends = false) { // Disable jobs (i.e. skip LDAP oprations) if (!$createInBackends) { Queue::fake(); } $user = User::firstOrCreate(['email' => $email], $attrib); if ($user->trashed()) { // Note: we do not want to use user restore here User::where('id', $user->id)->forceDelete(); $user = User::create(['email' => $email] + $attrib); } return $user; } /** * Get CompanionApp object by deviceId, create it if needed. * Skip LDAP jobs. * * @coversNothing */ protected function getTestCompanionApp($deviceId, $user, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $companionApp = CompanionApp::firstOrCreate( [ 'device_id' => $deviceId, 'user_id' => $user->id, 'notification_token' => '', 'mfa_enabled' => 1 ], $attrib ); return $companionApp; } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = []) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } /** * Extract content of an email message. * * @param \Illuminate\Mail\Mailable $mail Mailable object * * @return array Parsed message data: * - 'plain': Plain text body * - 'html: HTML body * - 'subject': Mail subject */ protected function renderMail(\Illuminate\Mail\Mailable $mail): array { $mail->build(); // @phpstan-ignore-line $result = $this->invokeMethod($mail, 'renderForAssertions'); return [ 'plain' => $result[1], 'html' => $result[0], 'subject' => $mail->subject, ]; } /** * Reset a room after tests */ public function resetTestRoom(string $room_name = 'john', $config = []) { $room = \App\Meet\Room::where('name', $room_name)->first(); $room->setSettings(['password' => null, 'locked' => null, 'nomedia' => null]); if ($room->session_id) { $room->session_id = null; $room->save(); } if (!empty($config)) { $room->setConfig($config); } return $room; } protected function setUpTest() { $this->userPassword = \App\Utils::generatePassphrase(); $this->domainHosted = $this->getTestDomain( 'test.domain', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $this->getTestDomain( 'test2.domain2', [ 'type' => \App\Domain::TYPE_EXTERNAL, 'status' => \App\Domain::STATUS_ACTIVE | \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_VERIFIED ] ); $packageKolab = \App\Package::where('title', 'kolab')->first(); $this->domainOwner = $this->getTestUser('john@test.domain', ['password' => $this->userPassword]); $this->domainOwner->assignPackage($packageKolab); $this->domainOwner->setSettings($this->domainOwnerSettings); $this->domainOwner->setAliases(['alias1@test2.domain2']); // separate for regular user $this->jack = $this->getTestUser('jack@test.domain', ['password' => $this->userPassword]); // separate for wallet controller $this->jane = $this->getTestUser('jane@test.domain', ['password' => $this->userPassword]); $this->joe = $this->getTestUser('joe@test.domain', ['password' => $this->userPassword]); $this->domainUsers[] = $this->jack; $this->domainUsers[] = $this->jane; $this->domainUsers[] = $this->joe; $this->domainUsers[] = $this->getTestUser('jill@test.domain', ['password' => $this->userPassword]); foreach ($this->domainUsers as $user) { $this->domainOwner->assignPackage($packageKolab, $user); } $this->domainUsers[] = $this->domainOwner; // assign second factor to joe $this->joe->assignSku(Sku::where('title', '2fa')->first()); \App\Auth\SecondFactor::seed($this->joe->email); usort( $this->domainUsers, function ($a, $b) { return $a->email > $b->email; } ); $this->domainHosted->assignPackage( \App\Package::where('title', 'domain-hosting')->first(), $this->domainOwner ); $wallet = $this->domainOwner->wallets()->first(); $wallet->addController($this->jane); $this->publicDomain = \App\Domain::where('type', \App\Domain::TYPE_PUBLIC)->first(); $this->publicDomainUser = $this->getTestUser( 'john@' . $this->publicDomain->namespace, ['password' => $this->userPassword] ); $this->publicDomainUser->assignPackage($packageKolab); Cache::forget('duskconfig'); } public function tearDown(): void { foreach ($this->domainUsers as $user) { if ($user == $this->domainOwner) { continue; } $this->deleteTestUser($user->email); } if ($this->domainOwner) { $this->deleteTestUser($this->domainOwner->email); } if ($this->domainHosted) { $this->deleteTestDomain($this->domainHosted->namespace); } if ($this->publicDomainUser) { $this->deleteTestUser($this->publicDomainUser->email); } Cache::forget('duskconfig'); parent::tearDown(); } } diff --git a/src/tests/Unit/WalletTest.php b/src/tests/Unit/WalletTest.php index 5a1b1f58..a5379528 100644 --- a/src/tests/Unit/WalletTest.php +++ b/src/tests/Unit/WalletTest.php @@ -1,30 +1,76 @@ 'CHF']); $money = $wallet->money(-123); $this->assertSame('-1,23 CHF', $money); $wallet = new Wallet(['currency' => 'EUR']); $money = $wallet->money(-123); $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 + } }