diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php index 08ca1393..d47b216e 100644 --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -1,606 +1,591 @@ guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $mandate = self::walletMandate($wallet); return response()->json($mandate); } /** * Create a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateCreate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, ]); $mandate = [ 'currency' => $wallet->currency, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup', 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD, ]; // Normally the auto-payment setup operation is 0, if the balance is below the threshold // we'll top-up the wallet with the configured auto-payment amount if ($wallet->balance < intval($request->balance * 100)) { $mandate['amount'] = intval($request->amount * 100); self::addTax($wallet, $mandate); } $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); $result['status'] = 'success'; return response()->json($result); } /** * Revoke the auto-payment mandate. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateDelete() { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $provider = PaymentProvider::factory($wallet); $provider->deleteMandate($wallet); $wallet->setSetting('mandate_disabled', null); return response()->json([ 'status' => 'success', 'message' => \trans('app.mandate-delete-success'), ]); } /** * Update a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateUpdate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, // Re-enable the mandate to give it a chance to charge again // after it has been disabled (e.g. because the mandate amount was too small) 'mandate_disabled' => null, ]); // Trigger auto-payment if the balance is below the threshold if ($wallet->balance < intval($request->balance * 100)) { \App\Jobs\WalletCharge::dispatch($wallet); } $result = self::walletMandate($wallet); $result['status'] = 'success'; $result['message'] = \trans('app.mandate-update-success'); return response()->json($result); } /** * Reset the auto-payment mandate, create a new payment for it. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateReset(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $mandate = [ 'currency' => $wallet->currency, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup', 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD, 'redirectUrl' => \App\Utils::serviceUrl('/payment/status', $user->tenant_id), ]; $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); $result['status'] = 'success'; return response()->json($result); } /** * Validate an auto-payment mandate request. * * @param \Illuminate\Http\Request $request The API request. * @param \App\Wallet $wallet The wallet * * @return array|null List of errors on error or Null on success */ protected static function mandateValidate(Request $request, Wallet $wallet) { $rules = [ 'amount' => 'required|numeric', 'balance' => 'required|numeric|min:0', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return $v->errors()->toArray(); } $amount = (int) ($request->amount * 100); // Validate the minimum value // It has to be at least minimum payment amount and must cover current debt, // and must be more than a yearly/monthly payment (according to the plan) - $min = Payment::MIN_AMOUNT; + $min = $wallet->getMinMandateAmount(); $label = 'minamount'; - if (($plan = $wallet->plan()) && $plan->months >= 1) { - $planCost = $plan->cost() * $plan->months; - if ($planCost > $min) { - $min = $planCost; - } - } - if ($wallet->balance < 0 && $wallet->balance < $min * -1) { $min = $wallet->balance * -1; $label = 'minamountdebt'; } if ($amount < $min) { return ['amount' => \trans("validation.{$label}", ['amount' => $wallet->money($min)])]; } return null; } /** * Get status of the last payment. * * @return \Illuminate\Http\JsonResponse The response */ public function paymentStatus() { $user = $this->guard()->user(); $wallet = $user->wallets()->first(); $payment = $wallet->payments()->orderBy('created_at', 'desc')->first(); if (empty($payment)) { return $this->errorResponse(404); } $done = [Payment::STATUS_PAID, Payment::STATUS_CANCELED, Payment::STATUS_FAILED, Payment::STATUS_EXPIRED]; if (in_array($payment->status, $done)) { $label = "app.payment-status-{$payment->status}"; } else { $label = "app.payment-status-checking"; } return response()->json([ 'id' => $payment->id, 'status' => $payment->status, 'type' => $payment->type, 'statusMessage' => \trans($label), 'description' => $payment->description, ]); } /** * Create a new payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $rules = [ 'amount' => 'required|numeric', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); // Validate the minimum value if ($amount < Payment::MIN_AMOUNT) { $min = $wallet->money(Payment::MIN_AMOUNT); $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $currency = $request->currency; $request = [ 'type' => Payment::TYPE_ONEOFF, 'currency' => $currency, 'amount' => $amount, 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment', ]; self::addTax($wallet, $request); $provider = PaymentProvider::factory($wallet, $currency); $result = $provider->payment($wallet, $request); $result['status'] = 'success'; return response()->json($result); } /** * Delete a pending payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ // TODO currently unused // public function cancel(Request $request) // { // $user = $this->guard()->user(); // // TODO: Wallet selection // $wallet = $user->wallets()->first(); // $paymentId = $request->payment; // $user_owns_payment = Payment::where('id', $paymentId) // ->where('wallet_id', $wallet->id) // ->exists(); // if (!$user_owns_payment) { // return $this->errorResponse(404); // } // $provider = PaymentProvider::factory($wallet); // if ($provider->cancel($wallet, $paymentId)) { // $result = ['status' => 'success']; // return response()->json($result); // } // return $this->errorResponse(404); // } /** * Update payment status (and balance). * * @param string $provider Provider name * * @return \Illuminate\Http\Response The response */ public function webhook($provider) { $code = 200; if ($provider = PaymentProvider::factory($provider)) { $code = $provider->webhook(); } return response($code < 400 ? 'Success' : 'Server error', $code); } /** * Top up a wallet with a "recurring" payment. * * @param \App\Wallet $wallet The wallet to charge * * @return bool True if the payment has been initialized */ public static function topUpWallet(Wallet $wallet): bool { $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); \Log::debug("Requested top-up for wallet {$wallet->id}"); if (!empty($settings['mandate_disabled'])) { \Log::debug("Top-up for wallet {$wallet->id}: mandate disabled"); return false; } $min_balance = (int) (floatval($settings['mandate_balance']) * 100); $amount = (int) (floatval($settings['mandate_amount']) * 100); // The wallet balance is greater than the auto-payment threshold if ($wallet->balance >= $min_balance) { // Do nothing return false; } $provider = PaymentProvider::factory($wallet); $mandate = (array) $provider->getMandate($wallet); if (empty($mandate['isValid'])) { \Log::debug("Top-up for wallet {$wallet->id}: mandate invalid"); return false; } // The defined top-up amount is not enough // Disable auto-payment and notify the user if ($wallet->balance + $amount < 0) { // Disable (not remove) the mandate $wallet->setSetting('mandate_disabled', 1); \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet); return false; } $request = [ 'type' => Payment::TYPE_RECURRING, 'currency' => $wallet->currency, 'amount' => $amount, 'methodId' => PaymentProvider::METHOD_CREDITCARD, 'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment', ]; self::addTax($wallet, $request); $result = $provider->payment($wallet, $request); return !empty($result); } /** * Returns auto-payment mandate info for the specified wallet * * @param \App\Wallet $wallet A wallet object * * @return array A mandate metadata */ public static function walletMandate(Wallet $wallet): array { $provider = PaymentProvider::factory($wallet); $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); // Get the Mandate info $mandate = (array) $provider->getMandate($wallet); - $mandate['amount'] = $mandate['minAmount'] = (int) ceil(Payment::MIN_AMOUNT / 100); + $mandate['amount'] = $mandate['minAmount'] = round($wallet->getMinMandateAmount() / 100, 2); $mandate['balance'] = 0; $mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled']; $mandate['isValid'] = !empty($mandate['isValid']); foreach (['amount', 'balance'] as $key) { if (($value = $settings["mandate_{$key}"]) !== null) { $mandate[$key] = $value; } } - // If this is a multi-month plan, we calculate the expected amount to be payed. - if (($plan = $wallet->plan()) && $plan->months >= 1) { - $planCost = round($plan->cost() * $plan->months / 100, 2); - if ($planCost > $mandate['minAmount']) { - $mandate['minAmount'] = $planCost; - } - } - // Unrestrict the wallet owner if mandate is valid if (!empty($mandate['isValid']) && $wallet->owner->isRestricted()) { $wallet->owner->unrestrict(); } return $mandate; } /** * List supported payment methods. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function paymentMethods(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $methods = PaymentProvider::paymentMethods($wallet, $request->type); \Log::debug("Provider methods" . var_export(json_encode($methods), true)); return response()->json($methods); } /** * Check for pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function hasPayments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $exists = Payment::where('wallet_id', $wallet->id) ->where('type', Payment::TYPE_ONEOFF) ->whereIn('status', [ Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED ]) ->exists(); return response()->json([ 'status' => 'success', 'hasPending' => $exists ]); } /** * List pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function payments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $result = Payment::where('wallet_id', $wallet->id) ->where('type', Payment::TYPE_ONEOFF) ->whereIn('status', [ Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED ]) ->orderBy('created_at', 'desc') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } $result = $result->map(function ($item) use ($wallet) { $provider = PaymentProvider::factory($item->provider); $payment = $provider->getPayment($item->id); $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->description, 'amount' => $item->amount, 'currency' => $wallet->currency, // note: $item->currency/$item->currency_amount might be different 'status' => $item->status, 'isCancelable' => $payment['isCancelable'], 'checkoutUrl' => $payment['checkoutUrl'] ]; return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } /** * Calculates tax for the payment, fills the request with additional properties * * @param \App\Wallet $wallet The wallet * @param array $request The request data with the payment amount */ protected static function addTax(Wallet $wallet, array &$request): void { $request['vat_rate_id'] = null; $request['credit_amount'] = $request['amount']; if ($rate = $wallet->vatRate()) { $request['vat_rate_id'] = $rate->id; switch (\config('app.vat.mode')) { case 1: // In this mode tax is added on top of the payment. The amount // to pay grows, but we keep wallet balance without tax. $request['amount'] = $request['amount'] + round($request['amount'] * $rate->rate / 100); break; default: // In this mode tax is "swallowed" by the vendor. The payment // amount does not change break; } } } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index 9b980089..18c5e2cc 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,728 +1,746 @@ 0, ]; /** @var array The attributes that are mass assignable */ protected $fillable = [ 'currency', 'description' ]; /** @var array The attributes that can be not set */ protected $nullable = [ 'description', ]; /** @var array The types of attributes to which its values will be cast */ protected $casts = [ 'balance' => 'integer', ]; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } /** * Add an award to this wallet's balance. * * @param int|\App\Payment $amount The amount of award (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function award(int|Payment $amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_AWARD, $amount, $description); } /** * Charge a specific entitlement (for use on entitlement delete). * * @param \App\Entitlement $entitlement The entitlement. */ public function chargeEntitlement(Entitlement $entitlement): void { // Sanity checks if ($entitlement->trashed() || $entitlement->wallet->id != $this->id || !$this->owner) { return; } // Start calculating the costs for the consumption of this entitlement if the // existing consumption spans >= 14 days. // // Effect is that anything's free for the first 14 days if ($entitlement->created_at >= Carbon::now()->subDays(14)) { return; } if ($this->owner->isDegraded()) { return; } $now = Carbon::now(); // Determine if we're still within the trial period $trial = $this->trialInfo(); if ( !empty($trial) && $entitlement->updated_at < $trial['end'] && in_array($entitlement->sku_id, $trial['skus']) ) { if ($trial['end'] >= $now) { return; } $entitlement->updated_at = $trial['end']; } // get the discount rate applied to the wallet. $discount = $this->getDiscountRate(); // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); $cost = (int) ($entitlement->cost * $discount * $diffInMonths); $fee = (int) ($entitlement->fee * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); // now we have the diff in days since the last "billed" period end. // This may be an entitlement paid up until February 28th, 2020, with today being March // 12th 2020. Calculating the costs for the entitlement is based on the daily price // the price per day is based on the number of days in the last month // or the current month if the period does not overlap with the previous month // FIXME: This really should be simplified to $daysInMonth=30 $diffInDays = $updatedAt->diffInDays($now); if ($now->day >= $diffInDays) { $daysInMonth = $now->daysInMonth; } else { $daysInMonth = \App\Utils::daysInLastMonth(); } $pricePerDay = $entitlement->cost / $daysInMonth; $feePerDay = $entitlement->fee / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); $fee += (int) (round($feePerDay * $diffInDays, 0)); $profit = $cost - $fee; if ($profit != 0 && $this->owner->tenant && ($wallet = $this->owner->tenant->wallet())) { $desc = "Charged user {$this->owner->email}"; $method = $profit > 0 ? 'credit' : 'debit'; $wallet->{$method}(abs($profit), $desc); } if ($cost == 0) { return; } // TODO: Create per-entitlement transaction record? $this->debit($cost); } /** * Charge entitlements in the wallet * * @param bool $apply Set to false for a dry-run mode * * @return int Charged amount in cents */ public function chargeEntitlements($apply = true): int { $transactions = []; $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); $isDegraded = $this->owner->isDegraded(); $trial = $this->trialInfo(); if ($apply) { DB::beginTransaction(); } // Get all entitlements... $entitlements = $this->entitlements() // Skip entitlements created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). // ->where('created_at', '<=', Carbon::now()->subDays(14)) // Skip entitlements created, or billed last, less than a month ago. ->where('updated_at', '<=', Carbon::now()->subMonthsWithoutOverflow(1)) ->get(); foreach ($entitlements as $entitlement) { // If in trial, move entitlement's updated_at timestamps forward to the trial end. if ( !empty($trial) && $entitlement->updated_at < $trial['end'] && in_array($entitlement->sku_id, $trial['skus']) ) { // TODO: Consider not updating the updated_at to a future date, i.e. bump it // as many months as possible, but not into the future // if we're in dry-run, you know... if ($apply) { $entitlement->updated_at = $trial['end']; $entitlement->save(); } continue; } $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); if ($diff <= 0) { continue; } $cost = (int) ($entitlement->cost * $discount * $diff); $fee = (int) ($entitlement->fee * $diff); if ($isDegraded) { $cost = 0; } $charges += $cost; $profit += $cost - $fee; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); $entitlement->save(); if ($cost == 0) { continue; } $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost); } if ($apply) { $this->debit($charges, '', $transactions); // Credit/debit the reseller if ($profit != 0 && $this->owner->tenant) { // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) if ($wallet = $this->owner->tenant->wallet()) { $desc = "Charged user {$this->owner->email}"; $method = $profit > 0 ? 'credit' : 'debit'; $wallet->{$method}(abs($profit), $desc); } } 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. * - * Ranges from 0 - 100. - * - * @return int + * @return int Discount in percent, ranges from 0 - 100. */ - public function getDiscount() + public function getDiscount(): int { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * - * Ranges from 0.00 to 1.00. + * @return float Discount rate, ranges from 0.00 to 1.00. */ - public function getDiscountRate() + 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() * $plan->months * $this->getDiscountRate()); + + if ($planCost > $min) { + $min = $planCost; + } + } + + return $min; + } + /** * Check if the specified user is a controller to this wallet. * * @param \App\User $user The user object. * * @return bool True if the user is one of the wallet controllers (including user), False otherwise */ public function isController(User $user): bool { return $user->id == $this->user_id || $this->controllers->contains($user); } /** * A helper to display human-readable amount of money using * the wallet currency and specified locale. * * @param int $amount A amount of money (in cents) * @param string $locale A locale for the output * * @return string String representation, e.g. "9.99 CHF" */ public function money(int $amount, $locale = 'de_DE') { return \App\Utils::money($amount, $this->currency, $locale); } /** * The owner of the wallet -- the wallet is in his/her back pocket. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo(User::class, 'user_id', 'id'); } /** * Payments on this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function payments() { return $this->hasMany(Payment::class); } /** * Add a penalty to this wallet's balance. * * @param int|\App\Payment $amount The amount of penalty (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function penalty(int|Payment $amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_PENALTY, $amount, $description); } /** * Plan of the wallet. * * @return ?\App\Plan */ public function plan() { $planId = $this->owner->getSetting('plan_id'); return $planId ? Plan::find($planId) : null; } /** * Remove a controller from this wallet. * * @param \App\User $user The user to remove as a controller from this wallet. * * @return void */ public function removeController(User $user) { if ($this->controllers->contains($user)) { $this->controllers()->detach($user); } } /** * Refund an amount of pecunia from this wallet's balance. * * @param int|\App\Payment $amount The amount of pecunia to refund (in cents) or Payment object * @param string $description The transaction description * * @return Wallet Self */ public function refund($amount, string $description = ''): Wallet { return $this->balanceUpdate(Transaction::WALLET_REFUND, $amount, $description); } /** * Get the VAT rate for the wallet owner country. * * @param ?\DateTime $start Get the rate valid for the specified date-time, * without it the current rate will be returned (if exists). * * @return ?\App\VatRate VAT rate */ public function vatRate(\DateTime $start = null): ?VatRate { $owner = $this->owner; // Make it working with deleted accounts too if (!$owner) { $owner = $this->owner()->withTrashed()->first(); } $country = $owner->getSetting('country'); if (!$country) { return null; } return VatRate::where('country', $country) ->where('start', '<=', ($start ?: now())->format('Y-m-d h:i:s')) ->orderByDesc('start') ->limit(1) ->first(); } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { return Transaction::where('object_id', $this->id)->where('object_type', Wallet::class); } /** * Returns trial related information. * * @return ?array Plan ID, plan SKUs, trial end date, number of free months (planId, skus, end, months) */ public function trialInfo(): ?array { $plan = $this->plan(); $freeMonths = $plan ? $plan->free_months : 0; $trialEnd = $freeMonths ? $this->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null; if ($trialEnd) { // Get all SKUs assigned to the plan (they are free in trial) // TODO: We could store the list of plan's SKUs in the wallet settings, for two reasons: // - performance // - if we change plan definition at some point in time, the old users would use // the old definition, instead of the current one // TODO: The same for plan's free_months value $trialSkus = \App\Sku::select('id') ->whereIn('id', function ($query) use ($plan) { $query->select('sku_id') ->from('package_skus') ->whereIn('package_id', function ($query) use ($plan) { $query->select('package_id') ->from('plan_packages') ->where('plan_id', $plan->id); }); }) ->whereNot('title', 'storage') ->pluck('id') ->all(); return [ 'end' => $trialEnd, 'skus' => $trialSkus, 'planId' => $plan->id, 'months' => $freeMonths, ]; } return null; } /** * Force-update entitlements' updated_at, charge if needed. * * @param bool $withCost When enabled the cost will be charged * * @return int Charged amount in cents */ public function updateEntitlements($withCost = true): int { $charges = 0; $discount = $this->getDiscountRate(); $now = Carbon::now(); 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; } /** * 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; } } diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index 6341af27..40d08fdf 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,640 +1,671 @@ users as $user) { $this->deleteTestUser($user); } Sku::select()->update(['fee' => 0]); Payment::query()->delete(); VatRate::query()->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } Sku::select()->update(['fee' => 0]); Payment::query()->delete(); VatRate::query()->delete(); + Plan::withEnvTenantContext()->where('title', 'individual')->update(['months' => 1]); parent::tearDown(); } /** * Test that turning wallet balance from negative to positive * unsuspends and undegrades the account */ public function testBalanceTurnsPositive(): void { Queue::fake(); $user = $this->getTestUser('UserWallet1@UserWallet.com'); $user->suspend(); $user->degrade(); $wallet = $user->wallets()->first(); $wallet->balance = -100; $wallet->save(); $this->assertTrue($user->isSuspended()); $this->assertTrue($user->isDegraded()); $this->assertNotNull($wallet->getSetting('balance_negative_since')); $wallet->balance = 100; $wallet->save(); $user->refresh(); $this->assertFalse($user->isSuspended()); $this->assertFalse($user->isDegraded()); $this->assertNull($wallet->getSetting('balance_negative_since')); // Test un-restricting users on balance change $this->deleteTestUser('UserWallet1@UserWallet.com'); $owner = $this->getTestUser('UserWallet1@UserWallet.com'); $user1 = $this->getTestUser('UserWallet2@UserWallet.com'); $user2 = $this->getTestUser('UserWallet3@UserWallet.com'); $package = Package::withEnvTenantContext()->where('title', 'lite')->first(); $owner->assignPackage($package, $user1); $owner->assignPackage($package, $user2); $wallet = $owner->wallets()->first(); $owner->restrict(); $user1->restrict(); $user2->restrict(); $this->assertTrue($owner->isRestricted()); $this->assertTrue($user1->isRestricted()); $this->assertTrue($user2->isRestricted()); Queue::fake(); $wallet->balance = 100; $wallet->save(); $this->assertFalse($owner->fresh()->isRestricted()); $this->assertFalse($user1->fresh()->isRestricted()); $this->assertFalse($user2->fresh()->isRestricted()); Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3); // TODO: Test group account and unsuspending domain/members/groups } /** * Test for Wallet::balanceLastsUntil() */ public function testBalanceLastsUntil(): void { // Monthly cost of all entitlements: 990 // 28 days: 35.36 per day // 31 days: 31.93 per day $user = $this->getTestUser('jane@kolabnow.com'); $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $user->assignPlan($plan); $wallet = $user->wallets()->first(); // User/entitlements created today, balance=0 $until = $wallet->balanceLastsUntil(); $this->assertSame( Carbon::now()->addMonthsWithoutOverflow(1)->toDateString(), $until->toDateString() ); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $until = $wallet->balanceLastsUntil(); $this->assertSame(null, $until); // User/entitlements created today, balance=-9,99 CHF (monthly cost) $wallet->balance = 990; $until = $wallet->balanceLastsUntil(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $delta = Carbon::now()->addMonthsWithoutOverflow(1)->addDays($daysInLastMonth)->diff($until)->days; $this->assertTrue($delta <= 1); $this->assertTrue($delta >= -1); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::withEnvTenantContext()->where('discount', 100)->first(); $wallet->discount()->associate($discount); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); // User with no entitlements $wallet->discount()->dissociate($discount); $wallet->entitlements()->delete(); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); } /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $this->assertCount(1, $user->wallets); $this->assertSame(\config('app.currency'), $user->wallets[0]->currency); $this->assertSame(0, $user->wallets[0]->balance); } /** * Verify a user can haz more wallets. */ public function testAddWallet(): void { $user = $this->getTestUser('UserWallet2@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $this->assertCount(2, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertEquals(0, $wallet->balance); } ); // For now all wallets use system currency $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); } /** * Verify we can not delete a user wallet that holds balance. */ public function testDeleteWalletWithCredit(): void { $user = $this->getTestUser('UserWallet3@UserWallet.com'); $user->wallets()->each( function ($wallet) { $wallet->credit(100)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can not delete a wallet that is the last wallet. */ public function testDeleteLastWallet(): void { $user = $this->getTestUser('UserWallet4@UserWallet.com'); $this->assertCount(1, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can remove a wallet that is an additional wallet. */ public function testDeleteAddtWallet(): void { $user = $this->getTestUser('UserWallet5@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); // For now additional wallets with a different currency is not allowed $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); /* $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); */ } /** * Verify a wallet can be assigned a controller. */ - public function testAddWalletController(): void + public function testAddController(): void { $userA = $this->getTestUser('WalletControllerA@WalletController.com'); $userB = $this->getTestUser('WalletControllerB@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertCount(1, $userB->accounts); $aWallet = $userA->wallets()->first(); $bAccount = $userB->accounts()->first(); $this->assertTrue($bAccount->id === $aWallet->id); } + /** + * Test Wallet::getMinMandateAmount() + */ + public function testGetMinMandateAmount(): void + { + $user = $this->getTestUser('WalletControllerA@WalletController.com'); + $user->setSetting('plan_id', null); + $wallet = $user->wallets()->first(); + + // No plan assigned + $this->assertSame(Payment::MIN_AMOUNT, $wallet->getMinMandateAmount()); + + // Plan assigned + $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); + $plan->months = 12; + $plan->save(); + + $user->setSetting('plan_id', $plan->id); + + $this->assertSame(990 * 12, $wallet->getMinMandateAmount()); + + // Plan and discount + $discount = Discount::where('discount', 30)->first(); + $wallet->discount()->associate($discount); + $wallet->save(); + + $this->assertSame((int) (990 * 12 * 0.70), $wallet->getMinMandateAmount()); + } + /** * Test Wallet::isController() */ public function testIsController(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $ned = $this->getTestUser('ned@kolab.org'); $wallet = $jack->wallet(); $this->assertTrue($wallet->isController($john)); $this->assertTrue($wallet->isController($ned)); $this->assertFalse($wallet->isController($jack)); } /** * Verify controllers can also be removed from wallets. */ public function testRemoveWalletController(): void { $userA = $this->getTestUser('WalletController2A@WalletController.com'); $userB = $this->getTestUser('WalletController2B@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $userB->refresh(); $userB->accounts()->each( function ($wallet) use ($userB) { $wallet->removeController($userB); } ); $this->assertCount(0, $userB->accounts); } /** * Test for charging and removing entitlements (including tenant commission calculations) */ 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")]); $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $user->assignPlan($plan); $user->assignSku($storage, 5); $user->setSetting('plan_id', null); // disable plan and trial // Reset reseller's wallet balance and transactions $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); // ------------------------------------ // 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(); // User discount is 30% // Expected: groupware: 490 x 70% + mailbox: 500 x 70% + storage: 5 x round(25x70%) = 778 $this->assertSame(-778, $wallet->balance); // 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); // 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 award() and penalty() */ public function testAwardAndPenalty(): void { $this->markTestIncomplete(); } /** * Tests for chargeback() and refund() */ public function testChargebackAndRefund(): void { $this->markTestIncomplete(); } /** * Tests for chargeEntitlement() */ public function testChargeEntitlement(): void { $this->markTestIncomplete(); } /** * Tests for updateEntitlements() */ public function testUpdateEntitlements(): void { $this->markTestIncomplete(); } /** * Tests for vatRate() */ public function testVatRate(): void { $rate1 = VatRate::create([ 'start' => now()->subDay(), 'country' => 'US', 'rate' => 7.5, ]); $rate2 = VatRate::create([ 'start' => now()->subDay(), 'country' => 'DE', 'rate' => 10.0, ]); $user = $this->getTestUser('UserWallet1@UserWallet.com'); $wallet = $user->wallets()->first(); $user->setSetting('country', null); $this->assertSame(null, $wallet->vatRate()); $user->setSetting('country', 'PL'); $this->assertSame(null, $wallet->vatRate()); $user->setSetting('country', 'US'); $this->assertSame($rate1->id, $wallet->vatRate()->id); // @phpstan-ignore-line $user->setSetting('country', 'DE'); $this->assertSame($rate2->id, $wallet->vatRate()->id); // @phpstan-ignore-line // Test $start argument $rate3 = VatRate::create([ 'start' => now()->subYear(), 'country' => 'DE', 'rate' => 5.0, ]); $this->assertSame($rate2->id, $wallet->vatRate()->id); // @phpstan-ignore-line $this->assertSame($rate3->id, $wallet->vatRate(now()->subMonth())->id); $this->assertSame(null, $wallet->vatRate(now()->subYears(2))); } }