diff --git a/src/app/Console/Commands/Wallet/TrialEndCommand.php b/src/app/Console/Commands/Wallet/TrialEndCommand.php index b05847f6..2e4e363f 100644 --- a/src/app/Console/Commands/Wallet/TrialEndCommand.php +++ b/src/app/Console/Commands/Wallet/TrialEndCommand.php @@ -1,64 +1,70 @@ join('users', 'users.id', '=', 'wallets.user_id') ->withEnvTenantContext('users') // exclude deleted accounts ->whereNull('users.deleted_at') // exclude "inactive" accounts ->where('users.status', '&', \App\User::STATUS_IMAP_READY) // consider only these created 1 to 2 months ago ->where('users.created_at', '>', \now()->subMonthsNoOverflow(2)) ->where('users.created_at', '<=', \now()->subMonthsNoOverflow(1)) // skip wallets with the notification already sent ->whereNotExists(function ($query) { $query->from('wallet_settings') ->where('wallet_settings.key', 'trial_end_notice') ->whereColumn('wallet_settings.wallet_id', 'wallets.id'); }) // skip users that aren't account owners ->whereExists(function ($query) { $query->from('entitlements') ->where('entitlements.entitleable_type', \App\User::class) ->whereColumn('entitlements.entitleable_id', 'wallets.user_id') ->whereColumn('entitlements.wallet_id', 'wallets.id'); }) ->cursor(); foreach ($wallets as $wallet) { + // Skip accounts with no trial period, or a period longer than a month + $plan = $wallet->plan(); + if (!$plan || $plan->free_months != 1) { + continue; + } + // Send the email asynchronously \App\Jobs\TrialEndEmail::dispatch($wallet->owner); // Store the timestamp $wallet->setSetting('trial_end_notice', (string) \now()); } } } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index dcedd6dd..ac719eaa 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,157 +1,137 @@ The attributes that are mass assignable */ protected $fillable = [ 'sku_id', 'wallet_id', 'entitleable_id', 'entitleable_type', 'cost', 'description', 'fee', ]; /** @var array The attributes that should be cast */ protected $casts = [ 'cost' => 'integer', 'fee' => 'integer' ]; - /** - * Return the costs per day for this entitlement. - * - * @return float - */ - public function costsPerDay() - { - if ($this->cost == 0) { - return (float) 0; - } - - $discount = $this->wallet->getDiscountRate(); - - $daysInLastMonth = \App\Utils::daysInLastMonth(); - - $costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth; - - return $costsPerDay; - } - /** * Create a transaction record for this entitlement. * * @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the * \App\Transaction constants. * @param int $amount The amount involved in cents * * @return string The transaction ID */ public function createTransaction($type, $amount = null) { $transaction = Transaction::create( [ 'object_id' => $this->id, 'object_type' => Entitlement::class, 'type' => $type, 'amount' => $amount ] ); return $transaction->id; } /** * Principally entitleable object such as Domain, User, Group. * Note that it may be trashed (soft-deleted). * * @return mixed */ public function entitleable() { return $this->morphTo()->withTrashed(); // @phpstan-ignore-line } /** * Simplified Entitlement/SKU information for a specified entitleable object * * @param object $object Entitleable object * * @return array Skus list with some metadata */ public static function objectEntitlementsSummary($object): array { $skus = []; // TODO: I agree this format may need to be extended in future foreach ($object->entitlements as $ent) { $sku = $ent->sku; if (!isset($skus[$sku->id])) { $skus[$sku->id] = ['costs' => [], 'count' => 0]; } $skus[$sku->id]['count']++; $skus[$sku->id]['costs'][] = $ent->cost; } return $skus; } /** * The SKU concerned. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { return $this->belongsTo(Sku::class); } /** * The wallet this entitlement is being billed to * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { return $this->belongsTo(Wallet::class); } /** * Cost mutator. Make sure cost is integer. */ public function setCostAttribute($cost): void { $this->attributes['cost'] = round($cost); } } diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php index d97b1787..91bc6a9f 100644 --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,274 +1,278 @@ checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->toArray(); $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); $result['notice'] = $this->getWalletNotice($wallet); return response()->json($result); } /** * Download a receipt in pdf format. * * @param string $id Wallet identifier * @param string $receipt Receipt identifier (YYYY-MM) * * @return \Illuminate\Http\Response */ public function receiptDownload($id, $receipt) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { abort(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { abort(403); } list ($year, $month) = explode('-', $receipt); if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) { abort(404); } if ($receipt >= date('Y-m')) { abort(404); } $params = [ 'id' => sprintf('%04d-%02d', $year, $month), 'site' => \config('app.name') ]; $filename = \trans('documents.receipt-filename', $params); $receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month); $content = $receipt->pdfOutput(); return response($content) ->withHeaders([ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', 'Content-Length' => strlen($content), ]); } /** * Fetch wallet receipts list. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function receipts($id) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->payments() ->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident') ->where('status', PaymentProvider::STATUS_PAID) ->where('amount', '<>', 0) ->orderBy('ident', 'desc') ->get() ->whereNotIn('ident', [date('Y-m')]) // exclude current month ->pluck('ident'); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => false, 'page' => 1, ]); } /** * Fetch wallet transactions. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function transactions($id) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $isAdmin = $this instanceof Admin\WalletsController; if ($transaction = request()->input('transaction')) { // Get sub-transactions for the specified transaction ID, first // check access rights to the transaction's wallet /** @var ?\App\Transaction $transaction */ $transaction = $wallet->transactions()->where('id', $transaction)->first(); if (!$transaction) { return $this->errorResponse(404); } $result = Transaction::where('transaction_id', $transaction->id)->get(); } else { // Get main transactions (paged) $result = $wallet->transactions() // FIXME: Do we know which (type of) transaction has sub-transactions // without the sub-query? ->selectRaw("*, (SELECT count(*) FROM transactions sub " . "WHERE sub.transaction_id = transactions.id) AS cnt") ->whereNull('transaction_id') ->latest() ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } } $result = $result->map(function ($item) use ($isAdmin, $wallet) { $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->shortDescription(), 'amount' => $item->amount, 'currency' => $wallet->currency, 'hasDetails' => !empty($item->cnt), ]; if ($isAdmin && $item->user_email) { $entry['user'] = $item->user_email; } return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } /** * Returns human readable notice about the wallet state. * * @param \App\Wallet $wallet The wallet */ protected function getWalletNotice(Wallet $wallet): ?string { // there is no credit if ($wallet->balance < 0) { return \trans('app.wallet-notice-nocredit'); } // the discount is 100%, no credit is needed if ($wallet->discount && $wallet->discount->discount == 100) { return null; } - // the owner was created less than a month ago - if ($wallet->owner->created_at > Carbon::now()->subMonthsWithoutOverflow(1)) { - // but more than two weeks ago, notice of trial ending - if ($wallet->owner->created_at <= Carbon::now()->subWeeks(2)) { + $plan = $wallet->plan(); + $freeMonths = $plan ? $plan->free_months : 0; + $trialEnd = $freeMonths ? $wallet->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null; + + // the owner is still in the trial period + if ($trialEnd && $trialEnd > Carbon::now()) { + // notice of trial ending if less than 2 weeks left + if ($trialEnd < Carbon::now()->addWeeks(2)) { return \trans('app.wallet-notice-trial-end'); } return \trans('app.wallet-notice-trial'); } if ($until = $wallet->balanceLastsUntil()) { if ($until->isToday()) { return \trans('app.wallet-notice-today'); } // Once in a while we got e.g. "3 weeks" instead of expected "4 weeks". // It's because $until uses full seconds, but $now is more precise. // We make sure both have the same time set. $now = Carbon::now()->setTimeFrom($until); $diffOptions = [ 'syntax' => Carbon::DIFF_ABSOLUTE, 'parts' => 1, ]; if ($now->diff($until)->days > 31) { $diffOptions['parts'] = 2; } $params = [ 'date' => $until->toDateString(), 'days' => $now->diffForHumans($until, $diffOptions), ]; return \trans('app.wallet-notice-date', $params); } return null; } } diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php index 0e2a64ec..47982233 100644 --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -1,173 +1,182 @@ wallet_id); if (!$wallet || !$wallet->owner) { return false; } $sku = \App\Sku::find($entitlement->sku_id); if (!$sku) { return false; } $result = $sku->handler_class::preReq($entitlement, $wallet->owner); if (!$result) { return false; } return true; } /** * Handle the entitlement "created" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function created(Entitlement $entitlement) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED); } /** * Handle the entitlement "deleted" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleted(Entitlement $entitlement) { // Remove all configured 2FA methods from Roundcube database if ($entitlement->sku->title == '2fa') { // FIXME: Should that be an async job? $sf = new \App\Auth\SecondFactor($entitlement->entitleable); $sf->removeFactors(); } if (!$entitlement->entitleable->trashed()) { $entitlement->entitleable->updated_at = Carbon::now(); $entitlement->entitleable->save(); $entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED); } } /** * Handle the entitlement "deleting" event. * * @param \App\Entitlement $entitlement The entitlement. * * @return void */ public function deleting(Entitlement $entitlement) { if ($entitlement->trashed()) { 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; } $owner = $entitlement->wallet->owner; if ($owner->isDegraded()) { return; } - // Determine if we're still within the free first month - $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1); + $now = Carbon::now(); - if ($freeMonthEnds >= Carbon::now()) { - return; + // Determine if we're still within the trial period + $trial = $entitlement->wallet->trialInfo(); + if ( + !empty($trial) + && $entitlement->updated_at < $trial['end'] + && in_array($entitlement->sku_id, $trial['skus']) + ) { + if ($trial['end'] >= $now) { + return; + } + + $entitlement->updated_at = $trial['end']; } - $now = Carbon::now(); - // get the discount rate applied to the wallet. $discount = $entitlement->wallet->getDiscountRate(); // 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 && $owner->tenant && ($wallet = $owner->tenant->wallet())) { $desc = "Charged user {$owner->email}"; $method = $profit > 0 ? 'credit' : 'debit'; $wallet->{$method}(abs($profit), $desc); } if ($cost == 0) { return; } + // FIXME: Shouldn't we create per-entitlement transaction record? + $entitlement->wallet->debit($cost); } } diff --git a/src/app/Plan.php b/src/app/Plan.php index 149a9ef0..d1f9b440 100644 --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -1,120 +1,124 @@ The attributes that are mass assignable */ protected $fillable = [ 'title', 'mode', 'name', 'description', // a start and end datetime for this promotion 'promo_from', 'promo_to', // discounts start at this quantity 'discount_qty', // the rate of the discount for this plan 'discount_rate', + // number of free months (trial) + 'free_months', ]; /** @var array The attributes that should be cast */ protected $casts = [ 'promo_from' => 'datetime:Y-m-d H:i:s', 'promo_to' => 'datetime:Y-m-d H:i:s', 'discount_qty' => 'integer', - 'discount_rate' => 'integer' + 'discount_rate' => 'integer', + 'free_months' => 'integer' ]; /** @var array Translatable properties */ public $translatable = [ 'name', 'description', ]; /** * The list price for this package at the minimum configuration. * * @return int The costs in cents. */ public function cost() { $costs = 0; foreach ($this->packages as $package) { $costs += $package->pivot->cost(); } return $costs; } /** * The relationship to packages. * * The plan contains one or more packages. Each package may have its minimum number (for * billing) or its maximum (to allow topping out "enterprise" customers on a "small business" * plan). * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function packages() { return $this->belongsToMany(Package::class, 'plan_packages') ->using(PlanPackage::class) ->withPivot([ 'qty', 'qty_min', 'qty_max', 'discount_qty', 'discount_rate' ]); } /** * Checks if the plan has any type of domain SKU assigned. * * @return bool */ public function hasDomain(): bool { foreach ($this->packages as $package) { if ($package->isDomain()) { return true; } } return false; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index c7433e0b..1ee2e38f 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,504 +1,559 @@ 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); } } /** * 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 { - // This wallet has been created less than a month ago, this is the trial period - if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) { - // Move all the current entitlement's updated_at timestamps forward to one month after - // this wallet was created. - $freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1); - - foreach ($this->entitlements()->get()->fresh() as $entitlement) { - if ($entitlement->updated_at < $freeMonthEnds) { - $entitlement->updated_at = $freeMonthEnds; - $entitlement->save(); - } - } - - return 0; - } - + $transactions = []; $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); $isDegraded = $this->owner->isDegraded(); + $trial = $this->trialInfo(); if ($apply) { DB::beginTransaction(); } - // used to parent individual entitlement billings to the wallet debit. - $entitlementTransactions = []; - - foreach ($this->entitlements()->get() as $entitlement) { - // This entitlement has been created less than or equal to 14 days ago (this is at + // Get all entitlements... + $entitlements = $this->entitlements() + // Skip entitlements created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). - if ($entitlement->created_at > Carbon::now()->subDays(14)) { - continue; - } + // ->where('created_at', '<=', Carbon::now()->subDays(14)) + // Skip entitlements created, or billed last, less than a month ago. + ->where('updated_at', '<=', Carbon::now()->subMonthsWithoutOverflow(1)) + ->get(); + + foreach ($entitlements as $entitlement) { + // If in trial, move entitlement's updated_at timestamps forward to the trial end. + if ( + !empty($trial) + && $entitlement->updated_at < $trial['end'] + && in_array($entitlement->sku_id, $trial['skus']) + ) { + // TODO: Consider not updating the updated_at to a future date, i.e. bump it + // as many months as possible, but not into the future + // if we're in dry-run, you know... + if ($apply) { + $entitlement->updated_at = $trial['end']; + $entitlement->save(); + } - // This entitlement was created, or billed last, less than a month ago. - if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { continue; } - // updated last more than a month ago -- was it billed? - if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { - $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); + $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); - $cost = (int) ($entitlement->cost * $discount * $diff); - $fee = (int) ($entitlement->fee * $diff); - - if ($isDegraded) { - $cost = 0; - } + if ($diff <= 0) { + continue; + } - $charges += $cost; - $profit += $cost - $fee; + $cost = (int) ($entitlement->cost * $discount * $diff); + $fee = (int) ($entitlement->fee * $diff); - // if we're in dry-run, you know... - if (!$apply) { - continue; - } + if ($isDegraded) { + $cost = 0; + } - $entitlement->updated_at = $entitlement->updated_at->copy() - ->addMonthsWithoutOverflow($diff); + $charges += $cost; + $profit += $cost - $fee; - $entitlement->save(); + // if we're in dry-run, you know... + if (!$apply) { + continue; + } - if ($cost == 0) { - continue; - } + $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); + $entitlement->save(); - $entitlementTransactions[] = $entitlement->createTransaction( - Transaction::ENTITLEMENT_BILLED, - $cost - ); + if ($cost == 0) { + continue; } + + $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost); } if ($apply) { - $this->debit($charges, '', $entitlementTransactions); + $this->debit($charges, '', $transactions); // Credit/debit the reseller if ($profit != 0 && $this->owner->tenant) { // 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); } } 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; } - // retrieve any expected charges - $expectedCharge = $this->expectedCharges(); - - // get the costs per day for all entitlements billed against this wallet - $costsPerDay = $this->costsPerDay(); + $balance = $this->balance; + $discount = $this->getDiscountRate(); + $trial = $this->trialInfo(); + + // Get all entitlements... + $entitlements = $this->entitlements()->orderBy('updated_at')->get() + ->filter(function ($entitlement) { + return $entitlement->cost > 0; + }) + ->map(function ($entitlement) { + return [ + 'date' => $entitlement->updated_at ?: $entitlement->created_at, + 'cost' => $entitlement->cost, + 'sku_id' => $entitlement->sku_id, + ]; + }) + ->all(); + + $max = 12 * 25; + while ($max > 0) { + foreach ($entitlements as &$entitlement) { + $until = $entitlement['date'] = $entitlement['date']->addMonthsWithoutOverflow(1); + + if ( + !empty($trial) + && $entitlement['date'] < $trial['end'] + && in_array($entitlement['sku_id'], $trial['skus']) + ) { + continue; + } - if (!$costsPerDay) { - return null; - } + $balance -= (int) ($entitlement['cost'] * $discount); - // the number of days this balance, minus the expected charges, would last - $daysDelta = floor(($this->balance - $expectedCharge) / $costsPerDay); + if ($balance < 0) { + break 2; + } + } - // calculate from the last entitlement billed - $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); + $max--; + } - $until = $entitlement->updated_at->copy()->addDays($daysDelta); + if (empty($until)) { + return null; + } // Don't return dates from the past - if ($until < Carbon::now() && !$until->isToday()) { + if ($until <= Carbon::now() && !$until->isToday()) { return null; } return $until; } /** * 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 ); } - /** - * Retrieve the costs per day of everything charged to this wallet. - * - * @return float - */ - public function costsPerDay() - { - $costs = (float) 0; - - foreach ($this->entitlements as $entitlement) { - $costs += $entitlement->costsPerDay(); - } - - return $costs; - } - /** * Add an amount of pecunia to this wallet's balance. * * @param int $amount The amount of pecunia to add (in cents). * @param string $description The transaction description * * @return Wallet Self */ public function credit(int $amount, string $description = ''): Wallet { $this->balance += $amount; $this->save(); Transaction::create( [ 'object_id' => $this->id, 'object_type' => Wallet::class, 'type' => Transaction::WALLET_CREDIT, 'amount' => $amount, 'description' => $description ] ); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int $amount The amount of pecunia to deduct (in cents). * @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 $amount, string $description = '', array $eTIDs = []): Wallet { if ($amount == 0) { return $this; } $this->balance -= $amount; $this->save(); $transaction = Transaction::create( [ 'object_id' => $this->id, 'object_type' => Wallet::class, 'type' => Transaction::WALLET_DEBIT, 'amount' => $amount * -1, 'description' => $description ] ); if (!empty($eTIDs)) { Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); } return $this; } /** * 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. * * Ranges from 0 - 100. * * @return int */ public function getDiscount() { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * * Ranges from 0.00 to 1.00. */ public function getDiscountRate() { return (100 - $this->getDiscount()) / 100; } /** * 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') { $amount = round($amount / 100, 2); $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); $result = $nf->formatCurrency($amount, $this->currency); // Replace non-breaking space return str_replace("\xC2\xA0", " ", $result); } /** * 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); } + /** + * 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); } } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { - return Transaction::where( - [ - 'object_id' => $this->id, - 'object_type' => Wallet::class - ] - ); + return Transaction::where('object_id', $this->id)->where('object_type', Wallet::class); + } + + /** + * Returns trial related information. + * + * @return ?array Plan ID, plan SKUs, trial end date, number of free months (planId, skus, end, months) + */ + public function trialInfo(): ?array + { + $plan = $this->plan(); + $freeMonths = $plan ? $plan->free_months : 0; + $trialEnd = $freeMonths ? $this->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null; + + if ($trialEnd) { + // Get all SKUs assigned to the plan (they are free in trial) + // TODO: We could store the list of plan's SKUs in the wallet settings, for two reasons: + // - performance + // - if we change plan definition at some point in time, the old users would use + // the old definition, instead of the current one + // TODO: The same for plan's free_months value + $trialSkus = \App\Sku::select('id') + ->whereIn('id', function ($query) use ($plan) { + $query->select('sku_id') + ->from('package_skus') + ->whereIn('package_id', function ($query) use ($plan) { + $query->select('package_id') + ->from('plan_packages') + ->where('plan_id', $plan->id); + }); + }) + ->whereNot('title', 'storage') + ->pluck('id') + ->all(); + + return [ + 'end' => $trialEnd, + 'skus' => $trialSkus, + 'planId' => $plan->id, + 'months' => $freeMonths, + ]; + } + + return null; } /** * 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(); DB::beginTransaction(); // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; 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(); } $pricePerDay = $entitlement->cost / $daysInMonth; $cost = (int) (round($pricePerDay * $discount * $diffInDays, 0)); } if ($diffInDays > 0) { $entitlement->updated_at = $entitlement->updated_at->setDateFrom($now); $entitlement->save(); } 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 ); } if ($charges > 0) { $this->debit($charges, '', $entitlementTransactions); } DB::commit(); return $charges; } } diff --git a/src/database/migrations/2022_09_08_100000_plans_free_months.php b/src/database/migrations/2022_09_08_100000_plans_free_months.php new file mode 100644 index 00000000..06ef6881 --- /dev/null +++ b/src/database/migrations/2022_09_08_100000_plans_free_months.php @@ -0,0 +1,41 @@ +tinyInteger('free_months')->unsigned()->default(0); + } + ); + + DB::table('plans')->update(['free_months' => 1]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'plans', + function (Blueprint $table) { + $table->dropColumn('free_months'); + } + ); + } +}; diff --git a/src/database/seeds/local/PlanSeeder.php b/src/database/seeds/local/PlanSeeder.php index 81db47ba..a21530f5 100644 --- a/src/database/seeds/local/PlanSeeder.php +++ b/src/database/seeds/local/PlanSeeder.php @@ -1,141 +1,145 @@ Everything you need to get started or try Kolab Now, including:

  • Perfect for anyone wanting to move to Kolab Now
  • Suite of online apps: Secure email, calendar, address book, files and more
  • Access for anywhere: Sync all your devices to your Kolab Now account
  • Secure hosting: Managed right here on our own servers in Switzerland
  • Start protecting your data today, no ads, no crawling, no compromise
  • An ideal replacement for services like Gmail, Office 365, etc…
EOD; $plan = Plan::create( [ 'title' => 'individual', 'name' => 'Individual Account', 'description' => $description, + 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0 ] ); $packages = [ Package::where(['title' => 'kolab', 'tenant_id' => \config('app.tenant_id')])->first() ]; $plan->packages()->saveMany($packages); $description = <<<'EOD'

All the features of the Individual Account, with the following extras:

  • Perfect for anyone wanting to move a group or small business to Kolab Now
  • Recommended to support users from 1 to 100
  • Use your own personal domains with Kolab Now
  • Manage and add users through our online admin area
  • Flexible pricing based on user count
EOD; $plan = Plan::create( [ 'title' => 'group', 'name' => 'Group Account', 'description' => $description, + 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0 ] ); $packages = [ Package::where(['title' => 'domain-hosting', 'tenant_id' => \config('app.tenant_id')])->first(), Package::where(['title' => 'kolab', 'tenant_id' => \config('app.tenant_id')])->first() ]; $plan->packages()->saveMany($packages); // We're running in reseller mode, add a sample discount $tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get(); foreach ($tenants as $tenant) { $description = <<<'EOD'

Everything you need to get started or try Kolab Now, including:

  • Perfect for anyone wanting to move to Kolab Now
  • Suite of online apps: Secure email, calendar, address book, files and more
  • Access for anywhere: Sync all your devices to your Kolab Now account
  • Secure hosting: Managed right here on our own servers in Switzerland
  • Start protecting your data today, no ads, no crawling, no compromise
  • An ideal replacement for services like Gmail, Office 365, etc…
EOD; $plan = Plan::create( [ 'title' => 'individual', 'name' => 'Individual Account', + 'free_months' => 1, 'description' => $description, 'discount_qty' => 0, 'discount_rate' => 0 ] ); $plan->tenant_id = $tenant->id; $plan->save(); $packages = [ Package::where(['title' => 'kolab', 'tenant_id' => $tenant->id])->first() ]; $plan->packages()->saveMany($packages); $description = <<<'EOD'

All the features of the Individual Account, with the following extras:

  • Perfect for anyone wanting to move a group or small business to Kolab Now
  • Recommended to support users from 1 to 100
  • Use your own personal domains with Kolab Now
  • Manage and add users through our online admin area
  • Flexible pricing based on user count
EOD; $plan = Plan::create( [ 'title' => 'group', 'name' => 'Group Account', 'description' => $description, + 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0 ] ); $plan->tenant_id = $tenant->id; $plan->save(); $packages = [ Package::where(['title' => 'domain-hosting', 'tenant_id' => $tenant->id])->first(), Package::where(['title' => 'kolab', 'tenant_id' => $tenant->id])->first() ]; $plan->packages()->saveMany($packages); } } } diff --git a/src/database/seeds/production/PlanSeeder.php b/src/database/seeds/production/PlanSeeder.php index d3c979a0..35c35ef8 100644 --- a/src/database/seeds/production/PlanSeeder.php +++ b/src/database/seeds/production/PlanSeeder.php @@ -1,74 +1,76 @@ Everything you need to get started or try Kolab Now, including:

  • Perfect for anyone wanting to move to Kolab Now
  • Suite of online apps: Secure email, calendar, address book, files and more
  • Access for anywhere: Sync all your devices to your Kolab Now account
  • Secure hosting: Managed right here on our own servers in Switzerland
  • Start protecting your data today, no ads, no crawling, no compromise
  • An ideal replacement for services like Gmail, Office 365, etc…
EOD; $plan = Plan::create( [ 'title' => 'individual', 'name' => 'Individual Account', 'description' => $description, + 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0 ] ); $packages = [ Package::firstOrCreate(['title' => 'kolab']) ]; $plan->packages()->saveMany($packages); $description = <<<'EOD'

All the features of the Individual Account, with the following extras:

  • Perfect for anyone wanting to move a group or small business to Kolab Now
  • Recommended to support users from 1 to 100
  • Use your own personal domains with Kolab Now
  • Manage and add users through our online admin area
  • Flexible pricing based on user count
EOD; $plan = Plan::create( [ 'title' => 'group', 'name' => 'Group Account', 'description' => $description, + 'free_months' => 1, 'discount_qty' => 0, 'discount_rate' => 0 ] ); $packages = [ Package::firstOrCreate(['title' => 'domain-hosting']), Package::firstOrCreate(['title' => 'kolab']), ]; $plan->packages()->saveMany($packages); } } diff --git a/src/tests/Feature/Console/Wallet/TrialEndTest.php b/src/tests/Feature/Console/Wallet/TrialEndTest.php index e7d9381b..dbe1869b 100644 --- a/src/tests/Feature/Console/Wallet/TrialEndTest.php +++ b/src/tests/Feature/Console/Wallet/TrialEndTest.php @@ -1,115 +1,116 @@ deleteTestUser('test-user1@kolabnow.com'); $this->deleteTestUser('test-user22@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('test-user1@kolabnow.com'); $this->deleteTestUser('test-user22@kolabnow.com'); parent::tearDown(); } /** * Test command run */ public function testHandle(): void { Queue::fake(); - $package = \App\Package::withEnvTenantContext()->where('title', 'lite')->first(); + $plan = \App\Plan::withEnvTenantContext()->where('title', 'individual')->first(); $user = $this->getTestUser('test-user1@kolabnow.com', [ 'status' => User::STATUS_IMAP_READY | User::STATUS_LDAP_READY | User::STATUS_ACTIVE, ]); $wallet = $user->wallets()->first(); - $user->assignPackage($package); + $user->assignPlan($plan); DB::table('users')->update(['created_at' => \now()->clone()->subMonthsNoOverflow(2)->subHours(1)]); // No wallets in after-trial state, no email sent Queue::fake(); $code = \Artisan::call("wallet:trial-end"); Queue::assertNothingPushed(); // Expect no email sent (out of time boundaries) $user->created_at = \now()->clone()->subMonthsNoOverflow(1)->addHour(); $user->save(); Queue::fake(); $code = \Artisan::call("wallet:trial-end"); Queue::assertNothingPushed(); // Test an email sent $user->created_at = \now()->clone()->subMonthsNoOverflow(1); $user->save(); Queue::fake(); $code = \Artisan::call("wallet:trial-end"); Queue::assertPushed(\App\Jobs\TrialEndEmail::class, 1); Queue::assertPushed(\App\Jobs\TrialEndEmail::class, function ($job) use ($user) { $job_user = TestCase::getObjectProperty($job, 'account'); return $job_user->id === $user->id; }); $dt = $wallet->getSetting('trial_end_notice'); $this->assertMatchesRegularExpression('/^' . date('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/', $dt); // Test no duplicate email sent for the same wallet Queue::fake(); $code = \Artisan::call("wallet:trial-end"); Queue::assertNothingPushed(); // Test not imap ready user - no email sent $wallet->setSetting('trial_end_notice', null); $user->status = User::STATUS_NEW | User::STATUS_LDAP_READY | User::STATUS_ACTIVE; $user->save(); Queue::fake(); $code = \Artisan::call("wallet:trial-end"); Queue::assertNothingPushed(); // Test deleted user - no email sent $user->status = User::STATUS_NEW | User::STATUS_LDAP_READY | User::STATUS_ACTIVE | User::STATUS_IMAP_READY; $user->save(); $user->delete(); Queue::fake(); $code = \Artisan::call("wallet:trial-end"); Queue::assertNothingPushed(); $this->assertNull($wallet->getSetting('trial_end_notice')); // Make sure the non-controller users are omitted $user2 = $this->getTestUser('test-user2@kolabnow.com', [ 'status' => User::STATUS_IMAP_READY | User::STATUS_LDAP_READY | User::STATUS_ACTIVE, ]); + $package = \App\Package::withEnvTenantContext()->where('title', 'lite')->first(); $user->assignPackage($package, $user2); $user2->created_at = \now()->clone()->subMonthsNoOverflow(1); $user2->save(); Queue::fake(); $code = \Artisan::call("wallet:trial-end"); Queue::assertNothingPushed(); } } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php index 72785b3e..75abdab5 100644 --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -1,356 +1,356 @@ deleteTestUser('wallets-controller@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); parent::tearDown(); } /** * Test for getWalletNotice() method */ public function testGetWalletNotice(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); - $package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first(); - $user->assignPackage($package); + $plan = \App\Plan::withObjectTenantContext($user)->where('title', 'individual')->first(); + $user->assignPlan($plan); $wallet = $user->wallets()->first(); $controller = new WalletsController(); $method = new \ReflectionMethod($controller, 'getWalletNotice'); $method->setAccessible(true); // User/entitlements created today, balance=0 $notice = $method->invoke($controller, $wallet); $this->assertSame('You are in your free trial period.', $notice); - $wallet->owner->created_at = Carbon::now()->subDays(15); + $wallet->owner->created_at = Carbon::now()->subWeeks(3); $wallet->owner->save(); $notice = $method->invoke($controller, $wallet); $this->assertSame('Your free trial is about to end, top up to continue.', $notice); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $notice = $method->invoke($controller, $wallet); $this->assertSame('You are out of credit, top up your balance now.', $notice); // User/entitlements created slightly more than a month ago, balance=9,99 CHF (monthly) - $wallet->owner->created_at = Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1); - $wallet->owner->save(); + $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1)); + $wallet->refresh(); // test "1 month" $wallet->balance = 990; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\((1 month|4 weeks)\)/', $notice); // test "2 months" $wallet->balance = 990 * 2.6; $notice = $method->invoke($controller, $wallet); - $this->assertMatchesRegularExpression('/\(2 months 2 weeks\)/', $notice); + $this->assertMatchesRegularExpression('/\(1 month 4 weeks\)/', $notice); // Change locale to make sure the text is localized by Carbon \app()->setLocale('de'); // test "almost 2 years" $wallet->balance = 990 * 23.5; $notice = $method->invoke($controller, $wallet); - $this->assertMatchesRegularExpression('/\(1 Jahr 11 Monate\)/', $notice); + $this->assertMatchesRegularExpression('/\(1 Jahr 10 Monate\)/', $notice); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::withObjectTenantContext($user)->where('discount', 100)->first(); $wallet->discount()->associate($discount); $notice = $method->invoke($controller, $wallet->refresh()); $this->assertSame(null, $notice); } /** * Test fetching pdf receipt */ public function testReceiptDownload(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(403); // Invalid receipt id (current month) $receiptId = date('Y-m'); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Invalid receipt id $receiptId = '1000-03'; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Valid receipt id $year = intval(date('Y')) - 1; $receiptId = "$year-12"; $filename = \config('app.name') . " Receipt for $year-12"; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(200); $response->assertHeader('content-type', 'application/pdf'); $response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"'); $response->assertHeader('content-length'); $length = $response->headers->get('content-length'); $content = $response->content(); $this->assertStringStartsWith("%PDF-1.", $content); $this->assertEquals(strlen($content), $length); } /** * Test fetching list of receipts */ public function testReceipts(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(403); // Empty list expected $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Insert a payment to the database $date = Carbon::create(intval(date('Y')) - 1, 4, 30); $payment = Payment::create([ 'id' => 'AAA1', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in April', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, 'currency' => 'CHF', 'currency_amount' => 1111, ]); $payment->updated_at = $date; $payment->save(); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([$date->format('Y-m')], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(1, $json['count']); $this->assertSame(false, $json['hasMore']); } /** * Test fetching a wallet (GET /api/v4/wallets/:id) */ public function testShow(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $wallet = $john->wallets()->first(); $wallet->balance = -100; $wallet->save(); // Accessing a wallet of someone else $response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Accessing non-existing wallet $response = $this->actingAs($jack)->get("api/v4/wallets/aaa"); $response->assertStatus(404); // Wallet owner $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(200); $json = $response->json(); $this->assertSame($wallet->id, $json['id']); $this->assertSame('CHF', $json['currency']); $this->assertSame($wallet->balance, $json['balance']); $this->assertTrue(empty($json['description'])); $this->assertTrue(!empty($json['notice'])); } /** * Test fetching wallet transactions */ public function testTransactions(): void { $package_kolab = \App\Package::where('title', 'kolab')->first(); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $user->assignPackage($package_kolab); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Expect empty list $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the first page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(10, $json['count']); $this->assertSame(true, $json['hasMore']); $this->assertCount(10, $json['list']); foreach ($pages[0] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame(\config('app.currency'), $json['list'][$idx]['currency']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertFalse($json['list'][$idx]['hasDetails']); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); } $search = null; // Get the second page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(2, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); foreach ($pages[1] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertSame( $transaction->type == Transaction::WALLET_DEBIT, $json['list'][$idx]['hasDetails'] ); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); if ($transaction->type == Transaction::WALLET_DEBIT) { $search = $transaction->id; } } // Get a non-existing page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(3, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); // Sub-transaction searching $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123"); $response->assertStatus(404); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']); // Test that John gets 404 if he tries to access // someone else's transaction ID on his wallet's endpoint $wallet = $john->wallets()->first(); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(404); } } diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php index 570cbb42..3a918829 100644 --- a/src/tests/Feature/EntitlementTest.php +++ b/src/tests/Feature/EntitlementTest.php @@ -1,225 +1,203 @@ 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(); } - /** - * Test for Entitlement::costsPerDay() - */ - public function testCostsPerDay(): void - { - // 500 - // 28 days: 17.86 - // 31 days: 16.129 - $user = $this->getTestUser('entitlement-test@kolabnow.com'); - $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); - $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); - - $user->assignPackage($package); - - $entitlement = $user->entitlements->where('sku_id', $mailbox->id)->first(); - - $costsPerDay = $entitlement->costsPerDay(); - - $this->assertTrue($costsPerDay < 17.86); - $this->assertTrue($costsPerDay > 16.12); - } - /** * Tests for entitlements * @todo This really should be in User or Wallet tests file */ 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); } } diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index b0527803..9e2cb154 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,448 +1,538 @@ users as $user) { $this->deleteTestUser($user); } + + Sku::select()->update(['fee' => 0]); } + /** + * {@inheritDoc} + */ public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } Sku::select()->update(['fee' => 0]); 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')); // 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'); - $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); - $user->assignPackage($package); + $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); + $user->assignPlan($plan); $wallet = $user->wallets()->first(); // User/entitlements created today, balance=0 $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); } - /** - * Test for Wallet::costsPerDay() - */ - public function testCostsPerDay(): void - { - // 990 - // 28 days: 35.36 - // 31 days: 31.93 - $user = $this->getTestUser('jane@kolabnow.com'); - - $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); - $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); - - $user->assignPackage($package); - - $wallet = $user->wallets()->first(); - - $costsPerDay = $wallet->costsPerDay(); - - $this->assertTrue($costsPerDay < 35.38); - $this->assertTrue($costsPerDay > 31.93); - } - /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void { $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 testAddWalletController(): 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::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) */ public function testChargeAndDeleteEntitlements(): void { $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); $wallet->discount()->associate($discount); $wallet->save(); // Add 40% fee to all SKUs Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); - $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); + $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); - $user->assignPackage($package); + $user->assignPlan($plan); $user->assignSku($storage, 5); - $user->refresh(); + $user->setSetting('plan_id', null); // disable plan and trial // Reset reseller's wallet balance and transactions $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); // ------------------------------------ // Test normal charging of entitlements // ------------------------------------ // Backdate and charge entitlements, we're expecting one month to be charged // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month Carbon::setTestNow(Carbon::create(2021, 5, 21, 12)); $backdate = Carbon::now()->subWeeks(7); $this->backdateEntitlements($user->entitlements, $backdate); $charge = $wallet->chargeEntitlements(); $wallet->refresh(); $reseller_wallet->refresh(); - // TODO: Update these comments with what is actually being used to calculate these numbers - // 388 + 310 + 17 + 17 = 732 + // User discount is 30% + // Expected: groupware: 490 x 70% + mailbox: 500 x 70% + storage: 5 x round(25x70%) = 778 $this->assertSame(-778, $wallet->balance); - // 388 - 555 x 40% + 310 - 444 x 40% + 34 - 50 x 40% = 312 + // Reseller fee is 40% + // Expected: groupware: 490 x 30% + mailbox: 500 x 30% + storage: 5 x round(25x30%) = 332 $this->assertSame(332, $reseller_wallet->balance); $transactions = Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->get(); $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->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); - // TODO: Test entitlement transaction records + // Assert all entitlements' updated_at timestamp + $date = $backdate->addMonthsWithoutOverflow(1); + $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get()); // ----------------------------------- // Test charging on entitlement delete // ----------------------------------- $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(); $user->removeSku($storage, 2); // we expect the wallet to have been charged for 19 days of use of // 2 deleted storage entitlements $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); $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(); $this->assertCount(2, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(5, $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); $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); // TODO: Test entitlement transaction records } + /** + * Test for charging and removing entitlements when in trial + */ + public function testChargeAndDeleteEntitlementsTrial(): void + { + $user = $this->getTestUser('jane@kolabnow.com'); + $wallet = $user->wallets()->first(); + + $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); + $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); + $user->assignPlan($plan); + $user->assignSku($storage, 5); + + // ------------------------------------ + // Test normal charging of entitlements + // ------------------------------------ + + // Backdate and charge entitlements, we're expecting one month to be charged + // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month + Carbon::setTestNow(Carbon::create(2021, 5, 21, 12)); + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements, $backdate); + $charge = $wallet->chargeEntitlements(); + $wallet->refresh(); + + // Expected: storage: 5 x 25 = 125 (the rest is free in trial) + $this->assertSame($balance = -125, $wallet->balance); + + // Assert wallet transaction + $transactions = $wallet->transactions()->get(); + + $this->assertCount(1, $transactions); + $trans = $transactions[0]; + $this->assertSame('', $trans->description); + $this->assertSame($balance, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + + // Assert entitlement transactions + $etransactions = Transaction::where('transaction_id', $trans->id)->get(); + $this->assertCount(5, $etransactions); + $trans = $etransactions[0]; + $this->assertSame(null, $trans->description); + $this->assertSame(25, $trans->amount); + $this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type); + + // Assert all entitlements' updated_at timestamp + $date = $backdate->addMonthsWithoutOverflow(1); + $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get()); + + // Run again, expect no changes + $charge = $wallet->chargeEntitlements(); + $wallet->refresh(); + + $this->assertSame($balance, $wallet->balance); + $this->assertCount(1, $wallet->transactions()->get()); + $this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get()); + + // ----------------------------------- + // Test charging on entitlement delete + // ----------------------------------- + + $wallet->transactions()->delete(); + + $user->removeSku($storage, 2); + + $wallet->refresh(); + + // we expect the wallet to have been charged for 19 days of use of + // 2 deleted storage entitlements: 2 x round(25 / 31 * 19) = 30 + $this->assertSame($balance -= 30, $wallet->balance); + + // Assert wallet transactions + $transactions = $wallet->transactions()->get(); + + $this->assertCount(2, $transactions); + $trans = $transactions[0]; + $this->assertSame('', $trans->description); + $this->assertSame(-15, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + $trans = $transactions[1]; + $this->assertSame('', $trans->description); + $this->assertSame(-15, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + + // Assert entitlement transactions + /* Note: Commented out because the observer does not create per-entitlement transactions + $etransactions = Transaction::where('transaction_id', $transactions[0]->id)->get(); + $this->assertCount(1, $etransactions); + $trans = $etransactions[0]; + $this->assertSame(null, $trans->description); + $this->assertSame(15, $trans->amount); + $this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type); + $etransactions = Transaction::where('transaction_id', $transactions[1]->id)->get(); + $this->assertCount(1, $etransactions); + $trans = $etransactions[0]; + $this->assertSame(null, $trans->description); + $this->assertSame(15, $trans->amount); + $this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type); + */ + } + /** * Tests for updateEntitlements() */ public function testUpdateEntitlements(): void { $this->markTestIncomplete(); } }