diff --git a/src/app/Http/Controllers/API/V4/PolicyController.php b/src/app/Http/Controllers/API/V4/PolicyController.php index 109a25a1..27a89d2d 100644 --- a/src/app/Http/Controllers/API/V4/PolicyController.php +++ b/src/app/Http/Controllers/API/V4/PolicyController.php @@ -1,393 +1,396 @@ input(); $request = new \App\Policy\Greylist\Request($data); $shouldDefer = $request->shouldDefer(); if ($shouldDefer) { return response()->json( ['response' => 'DEFER_IF_PERMIT', 'reason' => "Greylisted for 5 minutes. Try again later."], 403 ); } $prependGreylist = $request->headerGreylist(); $result = [ 'response' => 'DUNNO', 'prepend' => [$prependGreylist] ]; return response()->json($result, 200); } /* * Apply a sensible rate limitation to a request. * * @return \Illuminate\Http\JsonResponse */ public function ratelimit() { $data = \request()->input(); list($local, $domain) = \App\Utils::normalizeAddress($data['sender'], true); if (empty($local) || empty($domain)) { return response()->json(['response' => 'HOLD', 'reason' => 'Invalid sender email'], 403); } $sender = $local . '@' . $domain; if (in_array($sender, \config('app.ratelimit_whitelist', []), true)) { return response()->json(['response' => 'DUNNO'], 200); } // // Examine the individual sender // $user = \App\User::withTrashed()->where('email', $sender)->first(); if (!$user) { $alias = \App\UserAlias::where('alias', $sender)->first(); if (!$alias) { // external sender through where this policy is applied return response()->json(['response' => 'DUNNO'], 200); } $user = $alias->user; } if (empty($user) || $user->trashed() || $user->isSuspended()) { // use HOLD, so that it is silent (as opposed to REJECT) return response()->json(['response' => 'HOLD', 'reason' => 'Sender deleted or suspended'], 403); } // // Examine the domain // $domain = \App\Domain::withTrashed()->where('namespace', $domain)->first(); if (!$domain) { // external sender through where this policy is applied return response()->json(['response' => 'DUNNO'], 200); } if ($domain->trashed() || $domain->isSuspended()) { // use HOLD, so that it is silent (as opposed to REJECT) return response()->json(['response' => 'HOLD', 'reason' => 'Sender domain deleted or suspended'], 403); } // see if the user or domain is whitelisted // use ./artisan policy:ratelimit:whitelist:create if (RateLimitWhitelist::isListed($user) || RateLimitWhitelist::isListed($domain)) { return response()->json(['response' => 'DUNNO'], 200); } // user nor domain whitelisted, continue scrutinizing the request $recipients = (array)$data['recipients']; sort($recipients); $recipientCount = count($recipients); $recipientHash = hash('sha256', implode(',', $recipients)); // // Retrieve the wallet to get to the owner // $wallet = $user->wallet(); // wait, there is no wallet? if (!$wallet || !$wallet->owner) { return response()->json(['response' => 'HOLD', 'reason' => 'Sender without a wallet'], 403); } $owner = $wallet->owner; // find or create the request $request = RateLimit::where('recipient_hash', $recipientHash) ->where('user_id', $user->id) ->where('updated_at', '>=', \Carbon\Carbon::now()->subHour()) ->first(); if (!$request) { $request = RateLimit::create([ 'user_id' => $user->id, 'owner_id' => $owner->id, 'recipient_hash' => $recipientHash, 'recipient_count' => $recipientCount ]); } else { // ensure the request has an up to date timestamp $request->updated_at = \Carbon\Carbon::now(); $request->save(); } - // exempt owners that have 100% discount. - if ($wallet->discount && $wallet->discount->discount == 100) { - return response()->json(['response' => 'DUNNO'], 200); - } - // exempt owners that have made at least two payments and currently maintain a positive balance. - if ($wallet->balance > 0) { - $payments = $wallet->payments()->where('amount', '>', 0)->where('status', 'paid'); + // Paying users have a 15 messages per minute limit + if ($wallet->hasMinimumBalanceAndPayments()) { + $ownerRates = RateLimit::where('owner_id', $owner->id) + ->where('updated_at', '>=', \Carbon\Carbon::now()->subMinute()); - if ($payments->count() >= 2) { - return response()->json(['response' => 'DUNNO'], 200); + if (($count = $ownerRates->count()) >= 15) { + $result = [ + 'response' => 'DEFER_IF_PERMIT', + 'reason' => 'The account is at 15 messages per minute, cool down.' + ]; + + return response()->json($result, 403); } + return response()->json(['response' => 'DUNNO'], 200); } // // Examine the rates at which the owner (or its users) is sending // $ownerRates = RateLimit::where('owner_id', $owner->id) ->where('updated_at', '>=', \Carbon\Carbon::now()->subHour()); if (($count = $ownerRates->count()) >= 10) { $result = [ 'response' => 'DEFER_IF_PERMIT', 'reason' => 'The account is at 10 messages per hour, cool down.' ]; // automatically suspend (recursively) if 2.5 times over the original limit and younger than two months $ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); if ($count >= 25 && $owner->created_at > $ageThreshold) { $owner->suspendAccount(); } return response()->json($result, 403); } if (($recipientCount = $ownerRates->sum('recipient_count')) >= 100) { $result = [ 'response' => 'DEFER_IF_PERMIT', 'reason' => 'The account is at 100 recipients per hour, cool down.' ]; // automatically suspend if 2.5 times over the original limit and younger than two months $ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); if ($recipientCount >= 250 && $owner->created_at > $ageThreshold) { $owner->suspendAccount(); } return response()->json($result, 403); } // // Examine the rates at which the user is sending (if not also the owner) // if ($user->id != $owner->id) { $userRates = RateLimit::where('user_id', $user->id) ->where('updated_at', '>=', \Carbon\Carbon::now()->subHour()); if (($count = $userRates->count()) >= 10) { $result = [ 'response' => 'DEFER_IF_PERMIT', 'reason' => 'User is at 10 messages per hour, cool down.' ]; // automatically suspend if 2.5 times over the original limit and younger than two months $ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); if ($count >= 25 && $user->created_at > $ageThreshold) { $user->suspend(); } return response()->json($result, 403); } if (($recipientCount = $userRates->sum('recipient_count')) >= 100) { $result = [ 'response' => 'DEFER_IF_PERMIT', 'reason' => 'User is at 100 recipients per hour, cool down.' ]; // automatically suspend if 2.5 times over the original limit $ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2); if ($recipientCount >= 250 && $user->created_at > $ageThreshold) { $user->suspend(); } return response()->json($result, 403); } } return response()->json(['response' => 'DUNNO'], 200); } /* * Apply the sender policy framework to a request. * * @return \Illuminate\Http\JsonResponse */ public function senderPolicyFramework() { $data = \request()->input(); if (!array_key_exists('client_address', $data)) { \Log::error("SPF: Request without client_address: " . json_encode($data)); return response()->json( [ 'response' => 'DEFER_IF_PERMIT', 'reason' => 'Temporary error. Please try again later.', 'log' => ["SPF: Request without client_address: " . json_encode($data)] ], 403 ); } list($netID, $netType) = \App\Utils::getNetFromAddress($data['client_address']); // This network can not be recognized. if (!$netID) { \Log::error("SPF: Request without recognizable network: " . json_encode($data)); return response()->json( [ 'response' => 'DEFER_IF_PERMIT', 'reason' => 'Temporary error. Please try again later.', 'log' => ["SPF: Request without recognizable network: " . json_encode($data)] ], 403 ); } $senderLocal = 'unknown'; $senderDomain = 'unknown'; if (strpos($data['sender'], '@') !== false) { list($senderLocal, $senderDomain) = explode('@', $data['sender']); if (strlen($senderLocal) >= 255) { $senderLocal = substr($senderLocal, 0, 255); } } if ($data['sender'] === null) { $data['sender'] = ''; } // Compose the cache key we want. $cacheKey = "{$netType}_{$netID}_{$senderDomain}"; $result = \App\Policy\SPF\Cache::get($cacheKey); if (!$result) { $environment = new \SPFLib\Check\Environment( $data['client_address'], $data['client_name'], $data['sender'] ); $result = (new \SPFLib\Checker())->check($environment); \App\Policy\SPF\Cache::set($cacheKey, serialize($result)); } else { $result = unserialize($result); } $fail = false; $prependSPF = ''; switch ($result->getCode()) { case \SPFLib\Check\Result::CODE_ERROR_PERMANENT: $fail = true; $prependSPF = "Received-SPF: Permerror"; break; case \SPFLib\Check\Result::CODE_ERROR_TEMPORARY: $prependSPF = "Received-SPF: Temperror"; break; case \SPFLib\Check\Result::CODE_FAIL: $fail = true; $prependSPF = "Received-SPF: Fail"; break; case \SPFLib\Check\Result::CODE_SOFTFAIL: $prependSPF = "Received-SPF: Softfail"; break; case \SPFLib\Check\Result::CODE_NEUTRAL: $prependSPF = "Received-SPF: Neutral"; break; case \SPFLib\Check\Result::CODE_PASS: $prependSPF = "Received-SPF: Pass"; break; case \SPFLib\Check\Result::CODE_NONE: $prependSPF = "Received-SPF: None"; break; } $prependSPF .= " identity=mailfrom;"; $prependSPF .= " client-ip={$data['client_address']};"; $prependSPF .= " helo={$data['client_name']};"; $prependSPF .= " envelope-from={$data['sender']};"; if ($fail) { // TODO: check the recipient's policy, such as using barracuda for anti-spam and anti-virus as a relay for // inbound mail to a local recipient address. $objects = \App\Utils::findObjectsByRecipientAddress($data['recipient']); if (!empty($objects)) { // check if any of the recipient objects have whitelisted the helo, first one wins. foreach ($objects as $object) { if (method_exists($object, 'senderPolicyFrameworkWhitelist')) { $result = $object->senderPolicyFrameworkWhitelist($data['client_name']); if ($result) { $response = [ 'response' => 'DUNNO', 'prepend' => ["Received-SPF: Pass Check skipped at recipient's discretion"], 'reason' => 'HELO name whitelisted' ]; return response()->json($response, 200); } } } } $result = [ 'response' => 'REJECT', 'prepend' => [$prependSPF], 'reason' => "Prohibited by Sender Policy Framework" ]; return response()->json($result, 403); } $result = [ 'response' => 'DUNNO', 'prepend' => [$prependSPF], 'reason' => "Don't know" ]; return response()->json($result, 200); } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index 6eec844f..5678c1ac 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,725 +1,748 @@ 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; $isDegraded = $this->owner->isDegraded(); $trial = $this->trialInfo(); if ($apply) { DB::beginTransaction(); } // Get all relevant entitlements... $entitlements = $this->entitlements()->withTrashed() // existing entitlements created, or billed last less than a month ago // @phpstan-ignore-next-line ->where(function (Builder $query) { $query->whereNull('deleted_at') ->where('updated_at', '<=', Carbon::now()->subMonthsWithoutOverflow(1)); }) // deleted entitlements not yet charged // @phpstan-ignore-next-line ->orWhere(function (Builder $query) { $query->whereNotNull('deleted_at') ->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. if ($isDegraded) { $cost = 0; } $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) { $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; $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. if (!$withCost) { $cost = 0; } 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); } $this->debit($charges, '', $transactions)->addTenantProfit($profit); DB::commit(); return $charges; } /** * Add profit to the tenant's wallet * * @param int $profit Profit amount (in cents), can be negative */ protected function addTenantProfit($profit): void { // Credit/debit the reseller if ($profit != 0 && $this->owner->tenant) { // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) if ($wallet = $this->owner->tenant->wallet()) { $desc = "Charged user {$this->owner->email}"; if ($profit > 0) { $wallet->credit(abs($profit), $desc); } else { $wallet->debit(abs($profit), $desc); } } } } /** * Update the wallet balance, and create a transaction record */ protected function balanceUpdate(string $type, int|Payment $amount, $description = null, array $eTIDs = []) { if ($amount instanceof Payment) { $amount = $amount->credit_amount; } if ($amount === 0) { return $this; } if (in_array($type, [Transaction::WALLET_CREDIT, Transaction::WALLET_AWARD])) { $amount = abs($amount); } else { $amount = abs($amount) * -1; } $this->balance += $amount; $this->save(); $transaction = Transaction::create([ 'user_email' => \App\Utils::userEmailOrNull(), 'object_id' => $this->id, 'object_type' => Wallet::class, 'type' => $type, 'amount' => $amount, 'description' => $description, ]); if (!empty($eTIDs)) { Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); } return $this; } /** * Calculate entitlement cost/fee for the current charge * * @param Entitlement $entitlement Entitlement object * @param array|null $trial Trial information (result of Wallet::trialInfo()) * @param bool $useCostPerDay Force calculation based on a per-day cost * * @return array Result in form of [cost, fee, end-of-period] */ protected function entitlementCosts(Entitlement $entitlement, array $trial = null, bool $useCostPerDay = false) { $discountRate = $this->getDiscountRate(); $startDate = $entitlement->updated_at; // start of the period to charge for $endDate = Carbon::now(); // end of the period to charge for // Deleted entitlements are always charged for all uncharged days up to the delete date if ($entitlement->trashed()) { $useCostPerDay = true; $endDate = $entitlement->deleted_at->copy(); } // Consider Trial period if (!empty($trial) && $startDate < $trial['end'] && in_array($entitlement->sku_id, $trial['skus'])) { if ($trial['end'] > $endDate) { return [0, 0, $trial['end']]; } $startDate = $trial['end']; } if ($useCostPerDay) { // Note: In this mode we need a full cost including partial periods. // Anything's free for the first 14 days. if ($entitlement->created_at >= $endDate->copy()->subDays(14)) { return [0, 0, $endDate]; } $cost = 0; $fee = 0; // Charge for full months first if (($diff = $startDate->diffInMonths($endDate)) > 0) { $cost += floor($entitlement->cost * $discountRate) * $diff; $fee += $entitlement->fee * $diff; $startDate->addMonthsWithoutOverflow($diff); } // Charge for the rest of the period if (($diff = $startDate->diffInDays($endDate)) > 0) { // The price per day is based on the number of days in the month(s) // Note: The $endDate does not have to be the current month $endMonthDiff = $endDate->day > $diff ? $diff : $endDate->day; $startMonthDiff = $diff - $endMonthDiff; // FIXME: This could be calculated in a few different ways, e.g. rounding or flooring // the daily cost first and then applying discount and number of days. This could lead // to very small values in some cases resulting in a zero result. $cost += floor($entitlement->cost / $endDate->daysInMonth * $discountRate * $endMonthDiff); $fee += floor($entitlement->fee / $endDate->daysInMonth * $endMonthDiff); if ($startMonthDiff) { $cost += floor($entitlement->cost / $startDate->daysInMonth * $discountRate * $startMonthDiff); $fee += floor($entitlement->fee / $startDate->daysInMonth * $startMonthDiff); } } } else { // Note: In this mode we expect to charge the entitlement for full month(s) only $diff = $startDate->diffInMonths($endDate); if ($diff <= 0) { // Do not update updated_at column (not a full month) unless trial end date // is after current updated_at date return [0, 0, $startDate != $entitlement->updated_at ? $startDate : null]; } $endDate = $startDate->addMonthsWithoutOverflow($diff); $cost = floor($entitlement->cost * $discountRate) * $diff; $fee = $entitlement->fee * $diff; } return [(int) $cost, (int) $fee, $endDate]; } + + /** + * Ensure that this wallet has a positive 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 + { + if ($this->getDiscount() == 100) { + return true; + } + + if ($this->balance > 0) { + $payments = $this->payments()->where('amount', '>', 0)->where('status', 'paid'); + + if ($payments->count() >= $minimumPayments) { + return true; + } + } + + return false; + } }