Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F262049
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
66 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Jun 28, 1:07 PM (18 h, 17 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
201437
Default Alt Text
(66 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment