diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index 1f2cc28b..f14c3490 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,320 +1,308 @@ email = \strtolower($user->email); $user->status |= User::STATUS_NEW; } /** * Handle the "created" event. * * Ensures the user has at least one wallet. * * Should ensure some basic settings are available as well. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { $settings = [ 'country' => \App\Utils::countryForRequest(), 'currency' => \config('app.currency'), /* 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '', 'phone' => '', 'external_email' => '', */ ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'user_id' => $user->id, ]; } // Note: Don't use setSettings() here to bypass UserSetting observers // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); $user->wallets()->create(); // Create user record in the backend (LDAP and IMAP) \App\Jobs\User\CreateJob::dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email); } } /** * Handle the "deleted" event. * * @param \App\User $user The user deleted. * * @return void */ public function deleted(User $user) { // Remove the user from existing groups $wallet = $user->wallet(); if ($wallet && $wallet->owner) { $wallet->owner->groups()->each(function ($group) use ($user) { if (in_array($user->email, $group->members)) { $group->members = array_diff($group->members, [$user->email]); $group->save(); } }); } // TODO: Remove Permission records for the user // TODO: Remove file permissions for the user } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { // Remove owned users/domains/groups/resources/etc self::removeRelatedObjects($user, $user->isForceDeleting()); // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? if (!$user->isForceDeleting()) { \App\Jobs\User\DeleteJob::dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyDeleteJob::dispatch($user->id, $user->email); } - - // Debit the reseller's wallet with the user negative balance - $balance = 0; - foreach ($user->wallets as $wallet) { - // Note: here we assume all user wallets are using the same currency. - // It might get changed in the future - $balance += $wallet->balance; - } - - if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { - $wallet->debit($balance * -1, "Deleted user {$user->email}"); - } } } /** * Handle the user "restoring" event. * * @param \App\User $user The user * * @return void */ public function restoring(User $user) { // Reset the status $user->status = User::STATUS_NEW; // Note: $user->save() is invoked between 'restoring' and 'restored' events } /** * Handle the user "restored" event. * * @param \App\User $user The user * * @return void */ public function restored(User $user) { // We need at least the user domain so it can be created in ldap. // FIXME: What if the domain is owned by someone else? $domain = $user->domain(); if ($domain->trashed() && !$domain->isPublic()) { // Note: Domain entitlements will be restored by the DomainObserver $domain->restore(); } // FIXME: Should we reset user aliases? or re-validate them in any way? // Create user record in the backend (LDAP and IMAP) \App\Jobs\User\CreateJob::dispatch($user->id); } /** * Handle the "updated" event. * * @param \App\User $user The user that is being updated. * * @return void */ public function updated(User $user) { \App\Jobs\User\UpdateJob::dispatch($user->id); $oldStatus = $user->getOriginal('status'); $newStatus = $user->status; if (($oldStatus & User::STATUS_DEGRADED) !== ($newStatus & User::STATUS_DEGRADED)) { $wallets = []; $isDegraded = $user->isDegraded(); // Charge all entitlements as if they were being deleted, // but don't delete them. Just debit the wallet and update // entitlements' updated_at timestamp. On un-degrade we still // update updated_at, but with no debit (the cost is 0 on a degraded account). foreach ($user->wallets as $wallet) { $wallet->updateEntitlements($isDegraded); // Remember time of the degradation for sending periodic reminders // and reset it on un-degradation $val = $isDegraded ? \Carbon\Carbon::now()->toDateTimeString() : null; $wallet->setSetting('degraded_last_reminder', $val); $wallets[] = $wallet->id; } // (Un-)degrade users by invoking an update job. // LDAP backend will read the wallet owner's degraded status and // set LDAP attributes accordingly. // We do not change their status as their wallets have its own state \App\Entitlement::whereIn('wallet_id', $wallets) ->where('entitleable_id', '!=', $user->id) ->where('entitleable_type', User::class) ->pluck('entitleable_id') ->unique() ->each(function ($user_id) { \App\Jobs\User\UpdateJob::dispatch($user_id); }); } // Save the old password in the password history $oldPassword = $user->getOriginal('password'); if ($oldPassword && $user->password != $oldPassword) { self::saveOldPassword($user, $oldPassword); } } /** * Remove entities related to the user (in user's wallets), entitlements, transactions, etc. * * @param \App\User $user The user * @param bool $force Force-delete mode */ private static function removeRelatedObjects(User $user, $force = false): void { $wallets = $user->wallets->pluck('id')->all(); \App\Entitlement::withTrashed() ->select('entitleable_id', 'entitleable_type') ->distinct() ->whereIn('wallet_id', $wallets) ->get() ->each(function ($entitlement) use ($user, $force) { // Skip the current user (infinite recursion loop) if ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id == $user->id) { return; } if (!$entitlement->entitleable) { return; } // Objects need to be deleted one by one to make sure observers can do the proper cleanup if ($force) { $entitlement->entitleable->forceDelete(); } elseif (!$entitlement->entitleable->trashed()) { $entitlement->entitleable->delete(); } }); if ($force) { // Remove "wallet" transactions, they have no foreign key constraint \App\Transaction::where('object_type', Wallet::class) ->whereIn('object_id', $wallets) ->delete(); // Remove EventLog records \App\EventLog::where('object_id', $user->id)->where('object_type', User::class)->delete(); } // regardless of force delete, we're always purging whitelists... just in case \App\Policy\RateLimitWhitelist::where( [ 'whitelistable_id' => $user->id, 'whitelistable_type' => User::class ] )->delete(); } /** * Store the old password in user password history. Make sure * we do not store more passwords than we need in the history. * * @param \App\User $user The user * @param string $password The old password */ private static function saveOldPassword(User $user, string $password): void { // Remember the timestamp of the last password change and unset the last warning date $user->setSettings([ 'password_expiration_warning' => null, // Note: We could get this from user_passwords table, but only if the policy // enables storing of old passwords there. 'password_update' => now()->format('Y-m-d H:i:s'), ]); // Note: All this is kinda heavy and complicated because we don't want to store // more old passwords than we need. However, except the complication/performance, // there's one issue with it. E.g. the policy changes from 2 to 4, and we already // removed the old passwords that were excessive before, but not now. // Get the account password policy $policy = new \App\Rules\Password($user->walletOwner()); $rules = $policy->rules(); // Password history disabled? if (empty($rules['last']) || $rules['last']['param'] < 2) { return; } // Store the old password $user->passwords()->create(['password' => $password]); // Remove passwords that we don't need anymore $limit = $rules['last']['param'] - 1; $ids = $user->passwords()->latest()->limit($limit)->pluck('id')->all(); if (count($ids) >= $limit) { $user->passwords()->where('id', '<', $ids[count($ids) - 1])->delete(); } } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index 2aa7a3d4..b1515103 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,753 +1,770 @@ 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 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; + $fees = 0; $isDegraded = $this->owner->isDegraded(); $trial = $this->trialInfo(); if ($apply) { DB::beginTransaction(); } // Get all relevant entitlements... $entitlements = $this->entitlements()->withTrashed() // @phpstan-ignore-next-line ->where(function (Builder $query) { // existing entitlements created, or billed last less than a month ago $query->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->whereColumn('updated_at', '<', 'deleted_at'); }); }) ->get(); foreach ($entitlements as $entitlement) { // 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. + // No balance changes for degraded users if ($isDegraded) { $cost = 0; + } else { + $charges += $cost; + $fees += $fee; } - $charges += $cost; - $profit += $cost - $fee; - // if we're in dry-run, you know... if (!$apply) { continue; } if ($endDate) { $entitlement->updated_at = $endDate; $entitlement->save(); } if ($cost == 0) { continue; } $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost); } + if ($apply) { + // limit profit by wallet balance so a reseller can't build profit by users building debt + $profit = min($charges, $this->balance); + // We always take the full fee + $profit -= $fees; $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\Relations\HasMany */ public function transactions() { return $this->hasMany(Transaction::class, 'object_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; $profit = 0; + $fees = 0; $trial = $this->trialInfo(); DB::beginTransaction(); $transactions = []; $entitlements = $this->entitlements()->where('updated_at', '<', Carbon::now())->get(); foreach ($entitlements as $entitlement) { // Calculate cost, fee, and end of period [$cost, $fee, $endDate] = $this->entitlementCosts($entitlement, $trial, true); - // Note: Degraded pays nothing, but we get the money from a tenant. - // Therefore $cost = 0, but $profit < 0. + // No balance changes for degraded users if (!$withCost) { $cost = 0; + } else { + $charges += $cost; + $fees += $fee; } if ($endDate) { $entitlement->updated_at = $entitlement->updated_at->setDateFrom($endDate); $entitlement->save(); } - $charges += $cost; - $profit += $cost - $fee; - if ($cost == 0) { continue; } // FIXME: Shouldn't we store also cost=0 transactions (to have the full history)? $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost); } + // limit profit by wallet balance so a reseller can't build profit by users building debt + $profit = min($charges, $this->balance); + // We always take the full fee + $profit -= $fees; $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; } + if ($this->balance < 0 && ($this->balance + $amount) > 0) { + // We already took our fee when the wallet went below 0 + // TODO a bonus topup could result in reseller wallet balance, which we don't want I think. + // But I suppose that's a fundamental of paying out a percentage of wallet credit, and not payments. + $negativeBalance = abs($this->balance); + $this->addTenantProfit($negativeBalance); + } + $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) { if ($entitlement->wallet_id != $this->id) { throw new \Exception("Entitlement assigned to another wallet"); } $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]; } /** * Ensure that this wallet has a minimum balance and a minimum number of payments, * or a 100% discount (in which case there are no payments). * * @return bool */ public function hasMinimumBalanceAndPayments($minimumPayments = 2): bool { $minimumBalance = -10; if ($this->getDiscount() == 100) { return true; } if ($this->balance > $minimumBalance) { $payments = $this->payments()->where('amount', '>', 0)->where('status', 'paid'); if ($payments->count() >= $minimumPayments) { return true; } } return false; } }