Page MenuHomePhorge

No OneTemporary

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 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Tenant;
use App\Wallet;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentsController extends Controller
{
/**
* Get the auto-payment mandate info.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandate()
{
$user = $this->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 @@
<?php
namespace App;
use App\Traits\SettingsTrait;
use App\Traits\UuidStrKeyTrait;
use Carbon\Carbon;
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* The eloquent definition of a wallet -- a container with a chunk of change.
*
* A wallet is owned by an {@link \App\User}.
*
* @property int $balance Current balance in cents
* @property string $currency Currency code
* @property ?string $description Description
* @property string $id Unique identifier
* @property ?\App\User $owner Owner (can be null when owner is deleted)
* @property int $user_id Owner's identifier
*/
class Wallet extends Model
{
use NullableFields;
use SettingsTrait;
use UuidStrKeyTrait;
/** @var bool Indicates that the model should be timestamped or not */
public $timestamps = false;
/** @var array The attributes' default values */
protected $attributes = [
'balance' => 0,
];
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = [
'currency',
'description'
];
/** @var array<int, string> The attributes that can be not set */
protected $nullable = [
'description',
];
/** @var array<string, string> 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 @@
<?php
namespace Tests\Feature;
+use App\Discount;
use App\Payment;
use App\Package;
use App\Plan;
use App\User;
use App\Sku;
use App\Transaction;
use App\Wallet;
use App\VatRate;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class WalletTest extends TestCase
{
private $users = [
'UserWallet1@UserWallet.com',
'UserWallet2@UserWallet.com',
'UserWallet3@UserWallet.com',
'UserWallet4@UserWallet.com',
'UserWallet5@UserWallet.com',
'WalletControllerA@WalletController.com',
'WalletControllerB@WalletController.com',
'WalletController2A@WalletController.com',
'WalletController2B@WalletController.com',
'jane@kolabnow.com'
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::createFromDate(2022, 02, 02));
foreach ($this->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)));
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Jun 28, 1:07 PM (13 h, 46 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
201437
Default Alt Text
(66 KB)

Event Timeline