diff --git a/src/app/Console/Commands/Wallet/TrialEndCommand.php b/src/app/Console/Commands/Wallet/TrialEndCommand.php
index b05847f6..2e4e363f 100644
--- a/src/app/Console/Commands/Wallet/TrialEndCommand.php
+++ b/src/app/Console/Commands/Wallet/TrialEndCommand.php
@@ -1,64 +1,70 @@
join('users', 'users.id', '=', 'wallets.user_id')
->withEnvTenantContext('users')
// exclude deleted accounts
->whereNull('users.deleted_at')
// exclude "inactive" accounts
->where('users.status', '&', \App\User::STATUS_IMAP_READY)
// consider only these created 1 to 2 months ago
->where('users.created_at', '>', \now()->subMonthsNoOverflow(2))
->where('users.created_at', '<=', \now()->subMonthsNoOverflow(1))
// skip wallets with the notification already sent
->whereNotExists(function ($query) {
$query->from('wallet_settings')
->where('wallet_settings.key', 'trial_end_notice')
->whereColumn('wallet_settings.wallet_id', 'wallets.id');
})
// skip users that aren't account owners
->whereExists(function ($query) {
$query->from('entitlements')
->where('entitlements.entitleable_type', \App\User::class)
->whereColumn('entitlements.entitleable_id', 'wallets.user_id')
->whereColumn('entitlements.wallet_id', 'wallets.id');
})
->cursor();
foreach ($wallets as $wallet) {
+ // Skip accounts with no trial period, or a period longer than a month
+ $plan = $wallet->plan();
+ if (!$plan || $plan->free_months != 1) {
+ continue;
+ }
+
// Send the email asynchronously
\App\Jobs\TrialEndEmail::dispatch($wallet->owner);
// Store the timestamp
$wallet->setSetting('trial_end_notice', (string) \now());
}
}
}
diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php
index dcedd6dd..ac719eaa 100644
--- a/src/app/Entitlement.php
+++ b/src/app/Entitlement.php
@@ -1,157 +1,137 @@
The attributes that are mass assignable */
protected $fillable = [
'sku_id',
'wallet_id',
'entitleable_id',
'entitleable_type',
'cost',
'description',
'fee',
];
/** @var array The attributes that should be cast */
protected $casts = [
'cost' => 'integer',
'fee' => 'integer'
];
- /**
- * Return the costs per day for this entitlement.
- *
- * @return float
- */
- public function costsPerDay()
- {
- if ($this->cost == 0) {
- return (float) 0;
- }
-
- $discount = $this->wallet->getDiscountRate();
-
- $daysInLastMonth = \App\Utils::daysInLastMonth();
-
- $costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth;
-
- return $costsPerDay;
- }
-
/**
* Create a transaction record for this entitlement.
*
* @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the
* \App\Transaction constants.
* @param int $amount The amount involved in cents
*
* @return string The transaction ID
*/
public function createTransaction($type, $amount = null)
{
$transaction = Transaction::create(
[
'object_id' => $this->id,
'object_type' => Entitlement::class,
'type' => $type,
'amount' => $amount
]
);
return $transaction->id;
}
/**
* Principally entitleable object such as Domain, User, Group.
* Note that it may be trashed (soft-deleted).
*
* @return mixed
*/
public function entitleable()
{
return $this->morphTo()->withTrashed(); // @phpstan-ignore-line
}
/**
* Simplified Entitlement/SKU information for a specified entitleable object
*
* @param object $object Entitleable object
*
* @return array Skus list with some metadata
*/
public static function objectEntitlementsSummary($object): array
{
$skus = [];
// TODO: I agree this format may need to be extended in future
foreach ($object->entitlements as $ent) {
$sku = $ent->sku;
if (!isset($skus[$sku->id])) {
$skus[$sku->id] = ['costs' => [], 'count' => 0];
}
$skus[$sku->id]['count']++;
$skus[$sku->id]['costs'][] = $ent->cost;
}
return $skus;
}
/**
* The SKU concerned.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function sku()
{
return $this->belongsTo(Sku::class);
}
/**
* The wallet this entitlement is being billed to
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function wallet()
{
return $this->belongsTo(Wallet::class);
}
/**
* Cost mutator. Make sure cost is integer.
*/
public function setCostAttribute($cost): void
{
$this->attributes['cost'] = round($cost);
}
}
diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php
index d97b1787..91bc6a9f 100644
--- a/src/app/Http/Controllers/API/V4/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/WalletsController.php
@@ -1,274 +1,278 @@
checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$result = $wallet->toArray();
$provider = \App\Providers\PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
$result['notice'] = $this->getWalletNotice($wallet);
return response()->json($result);
}
/**
* Download a receipt in pdf format.
*
* @param string $id Wallet identifier
* @param string $receipt Receipt identifier (YYYY-MM)
*
* @return \Illuminate\Http\Response
*/
public function receiptDownload($id, $receipt)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
abort(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
abort(403);
}
list ($year, $month) = explode('-', $receipt);
if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) {
abort(404);
}
if ($receipt >= date('Y-m')) {
abort(404);
}
$params = [
'id' => sprintf('%04d-%02d', $year, $month),
'site' => \config('app.name')
];
$filename = \trans('documents.receipt-filename', $params);
$receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month);
$content = $receipt->pdfOutput();
return response($content)
->withHeaders([
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => strlen($content),
]);
}
/**
* Fetch wallet receipts list.
*
* @param string $id Wallet identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function receipts($id)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$result = $wallet->payments()
->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident')
->where('status', PaymentProvider::STATUS_PAID)
->where('amount', '<>', 0)
->orderBy('ident', 'desc')
->get()
->whereNotIn('ident', [date('Y-m')]) // exclude current month
->pluck('ident');
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => false,
'page' => 1,
]);
}
/**
* Fetch wallet transactions.
*
* @param string $id Wallet identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function transactions($id)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$pageSize = 10;
$page = intval(request()->input('page')) ?: 1;
$hasMore = false;
$isAdmin = $this instanceof Admin\WalletsController;
if ($transaction = request()->input('transaction')) {
// Get sub-transactions for the specified transaction ID, first
// check access rights to the transaction's wallet
/** @var ?\App\Transaction $transaction */
$transaction = $wallet->transactions()->where('id', $transaction)->first();
if (!$transaction) {
return $this->errorResponse(404);
}
$result = Transaction::where('transaction_id', $transaction->id)->get();
} else {
// Get main transactions (paged)
$result = $wallet->transactions()
// FIXME: Do we know which (type of) transaction has sub-transactions
// without the sub-query?
->selectRaw("*, (SELECT count(*) FROM transactions sub "
. "WHERE sub.transaction_id = transactions.id) AS cnt")
->whereNull('transaction_id')
->latest()
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
}
$result = $result->map(function ($item) use ($isAdmin, $wallet) {
$entry = [
'id' => $item->id,
'createdAt' => $item->created_at->format('Y-m-d H:i'),
'type' => $item->type,
'description' => $item->shortDescription(),
'amount' => $item->amount,
'currency' => $wallet->currency,
'hasDetails' => !empty($item->cnt),
];
if ($isAdmin && $item->user_email) {
$entry['user'] = $item->user_email;
}
return $entry;
});
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
'page' => $page,
]);
}
/**
* Returns human readable notice about the wallet state.
*
* @param \App\Wallet $wallet The wallet
*/
protected function getWalletNotice(Wallet $wallet): ?string
{
// there is no credit
if ($wallet->balance < 0) {
return \trans('app.wallet-notice-nocredit');
}
// the discount is 100%, no credit is needed
if ($wallet->discount && $wallet->discount->discount == 100) {
return null;
}
- // the owner was created less than a month ago
- if ($wallet->owner->created_at > Carbon::now()->subMonthsWithoutOverflow(1)) {
- // but more than two weeks ago, notice of trial ending
- if ($wallet->owner->created_at <= Carbon::now()->subWeeks(2)) {
+ $plan = $wallet->plan();
+ $freeMonths = $plan ? $plan->free_months : 0;
+ $trialEnd = $freeMonths ? $wallet->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null;
+
+ // the owner is still in the trial period
+ if ($trialEnd && $trialEnd > Carbon::now()) {
+ // notice of trial ending if less than 2 weeks left
+ if ($trialEnd < Carbon::now()->addWeeks(2)) {
return \trans('app.wallet-notice-trial-end');
}
return \trans('app.wallet-notice-trial');
}
if ($until = $wallet->balanceLastsUntil()) {
if ($until->isToday()) {
return \trans('app.wallet-notice-today');
}
// Once in a while we got e.g. "3 weeks" instead of expected "4 weeks".
// It's because $until uses full seconds, but $now is more precise.
// We make sure both have the same time set.
$now = Carbon::now()->setTimeFrom($until);
$diffOptions = [
'syntax' => Carbon::DIFF_ABSOLUTE,
'parts' => 1,
];
if ($now->diff($until)->days > 31) {
$diffOptions['parts'] = 2;
}
$params = [
'date' => $until->toDateString(),
'days' => $now->diffForHumans($until, $diffOptions),
];
return \trans('app.wallet-notice-date', $params);
}
return null;
}
}
diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php
index 0e2a64ec..47982233 100644
--- a/src/app/Observers/EntitlementObserver.php
+++ b/src/app/Observers/EntitlementObserver.php
@@ -1,173 +1,182 @@
wallet_id);
if (!$wallet || !$wallet->owner) {
return false;
}
$sku = \App\Sku::find($entitlement->sku_id);
if (!$sku) {
return false;
}
$result = $sku->handler_class::preReq($entitlement, $wallet->owner);
if (!$result) {
return false;
}
return true;
}
/**
* Handle the entitlement "created" event.
*
* @param \App\Entitlement $entitlement The entitlement.
*
* @return void
*/
public function created(Entitlement $entitlement)
{
$entitlement->entitleable->updated_at = Carbon::now();
$entitlement->entitleable->save();
$entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED);
}
/**
* Handle the entitlement "deleted" event.
*
* @param \App\Entitlement $entitlement The entitlement.
*
* @return void
*/
public function deleted(Entitlement $entitlement)
{
// Remove all configured 2FA methods from Roundcube database
if ($entitlement->sku->title == '2fa') {
// FIXME: Should that be an async job?
$sf = new \App\Auth\SecondFactor($entitlement->entitleable);
$sf->removeFactors();
}
if (!$entitlement->entitleable->trashed()) {
$entitlement->entitleable->updated_at = Carbon::now();
$entitlement->entitleable->save();
$entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED);
}
}
/**
* Handle the entitlement "deleting" event.
*
* @param \App\Entitlement $entitlement The entitlement.
*
* @return void
*/
public function deleting(Entitlement $entitlement)
{
if ($entitlement->trashed()) {
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;
}
$owner = $entitlement->wallet->owner;
if ($owner->isDegraded()) {
return;
}
- // Determine if we're still within the free first month
- $freeMonthEnds = $owner->created_at->copy()->addMonthsWithoutOverflow(1);
+ $now = Carbon::now();
- if ($freeMonthEnds >= Carbon::now()) {
- return;
+ // Determine if we're still within the trial period
+ $trial = $entitlement->wallet->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'];
}
- $now = Carbon::now();
-
// get the discount rate applied to the wallet.
$discount = $entitlement->wallet->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 && $owner->tenant && ($wallet = $owner->tenant->wallet())) {
$desc = "Charged user {$owner->email}";
$method = $profit > 0 ? 'credit' : 'debit';
$wallet->{$method}(abs($profit), $desc);
}
if ($cost == 0) {
return;
}
+ // FIXME: Shouldn't we create per-entitlement transaction record?
+
$entitlement->wallet->debit($cost);
}
}
diff --git a/src/app/Plan.php b/src/app/Plan.php
index 149a9ef0..d1f9b440 100644
--- a/src/app/Plan.php
+++ b/src/app/Plan.php
@@ -1,120 +1,124 @@
The attributes that are mass assignable */
protected $fillable = [
'title',
'mode',
'name',
'description',
// a start and end datetime for this promotion
'promo_from',
'promo_to',
// discounts start at this quantity
'discount_qty',
// the rate of the discount for this plan
'discount_rate',
+ // number of free months (trial)
+ 'free_months',
];
/** @var array The attributes that should be cast */
protected $casts = [
'promo_from' => 'datetime:Y-m-d H:i:s',
'promo_to' => 'datetime:Y-m-d H:i:s',
'discount_qty' => 'integer',
- 'discount_rate' => 'integer'
+ 'discount_rate' => 'integer',
+ 'free_months' => 'integer'
];
/** @var array Translatable properties */
public $translatable = [
'name',
'description',
];
/**
* The list price for this package at the minimum configuration.
*
* @return int The costs in cents.
*/
public function cost()
{
$costs = 0;
foreach ($this->packages as $package) {
$costs += $package->pivot->cost();
}
return $costs;
}
/**
* The relationship to packages.
*
* The plan contains one or more packages. Each package may have its minimum number (for
* billing) or its maximum (to allow topping out "enterprise" customers on a "small business"
* plan).
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function packages()
{
return $this->belongsToMany(Package::class, 'plan_packages')
->using(PlanPackage::class)
->withPivot([
'qty',
'qty_min',
'qty_max',
'discount_qty',
'discount_rate'
]);
}
/**
* Checks if the plan has any type of domain SKU assigned.
*
* @return bool
*/
public function hasDomain(): bool
{
foreach ($this->packages as $package) {
if ($package->isDomain()) {
return true;
}
}
return false;
}
}
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
index c7433e0b..1ee2e38f 100644
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -1,504 +1,559 @@
0,
];
/** @var array The attributes that are mass assignable */
protected $fillable = [
'currency',
'description'
];
/** @var array The attributes that can be not set */
protected $nullable = [
'description',
];
/** @var array The types of attributes to which its values will be cast */
protected $casts = [
'balance' => 'integer',
];
/**
* Add a controller to this wallet.
*
* @param \App\User $user The user to add as a controller to this wallet.
*
* @return void
*/
public function addController(User $user)
{
if (!$this->controllers->contains($user)) {
$this->controllers()->save($user);
}
}
/**
* 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
{
- // This wallet has been created less than a month ago, this is the trial period
- if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) {
- // Move all the current entitlement's updated_at timestamps forward to one month after
- // this wallet was created.
- $freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1);
-
- foreach ($this->entitlements()->get()->fresh() as $entitlement) {
- if ($entitlement->updated_at < $freeMonthEnds) {
- $entitlement->updated_at = $freeMonthEnds;
- $entitlement->save();
- }
- }
-
- return 0;
- }
-
+ $transactions = [];
$profit = 0;
$charges = 0;
$discount = $this->getDiscountRate();
$isDegraded = $this->owner->isDegraded();
+ $trial = $this->trialInfo();
if ($apply) {
DB::beginTransaction();
}
- // used to parent individual entitlement billings to the wallet debit.
- $entitlementTransactions = [];
-
- foreach ($this->entitlements()->get() as $entitlement) {
- // This entitlement has been created less than or equal to 14 days ago (this is at
+ // 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).
- if ($entitlement->created_at > Carbon::now()->subDays(14)) {
- continue;
- }
+ // ->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();
+ }
- // This entitlement was created, or billed last, less than a month ago.
- if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) {
continue;
}
- // updated last more than a month ago -- was it billed?
- if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) {
- $diff = $entitlement->updated_at->diffInMonths(Carbon::now());
+ $diff = $entitlement->updated_at->diffInMonths(Carbon::now());
- $cost = (int) ($entitlement->cost * $discount * $diff);
- $fee = (int) ($entitlement->fee * $diff);
-
- if ($isDegraded) {
- $cost = 0;
- }
+ if ($diff <= 0) {
+ continue;
+ }
- $charges += $cost;
- $profit += $cost - $fee;
+ $cost = (int) ($entitlement->cost * $discount * $diff);
+ $fee = (int) ($entitlement->fee * $diff);
- // if we're in dry-run, you know...
- if (!$apply) {
- continue;
- }
+ if ($isDegraded) {
+ $cost = 0;
+ }
- $entitlement->updated_at = $entitlement->updated_at->copy()
- ->addMonthsWithoutOverflow($diff);
+ $charges += $cost;
+ $profit += $cost - $fee;
- $entitlement->save();
+ // if we're in dry-run, you know...
+ if (!$apply) {
+ continue;
+ }
- if ($cost == 0) {
- continue;
- }
+ $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff);
+ $entitlement->save();
- $entitlementTransactions[] = $entitlement->createTransaction(
- Transaction::ENTITLEMENT_BILLED,
- $cost
- );
+ if ($cost == 0) {
+ continue;
}
+
+ $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost);
}
if ($apply) {
- $this->debit($charges, '', $entitlementTransactions);
+ $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;
}
- // retrieve any expected charges
- $expectedCharge = $this->expectedCharges();
-
- // get the costs per day for all entitlements billed against this wallet
- $costsPerDay = $this->costsPerDay();
+ $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;
+ }
- if (!$costsPerDay) {
- return null;
- }
+ $balance -= (int) ($entitlement['cost'] * $discount);
- // the number of days this balance, minus the expected charges, would last
- $daysDelta = floor(($this->balance - $expectedCharge) / $costsPerDay);
+ if ($balance < 0) {
+ break 2;
+ }
+ }
- // calculate from the last entitlement billed
- $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first();
+ $max--;
+ }
- $until = $entitlement->updated_at->copy()->addDays($daysDelta);
+ if (empty($until)) {
+ return null;
+ }
// Don't return dates from the past
- if ($until < Carbon::now() && !$until->isToday()) {
+ if ($until <= Carbon::now() && !$until->isToday()) {
return null;
}
return $until;
}
/**
* 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
);
}
- /**
- * Retrieve the costs per day of everything charged to this wallet.
- *
- * @return float
- */
- public function costsPerDay()
- {
- $costs = (float) 0;
-
- foreach ($this->entitlements as $entitlement) {
- $costs += $entitlement->costsPerDay();
- }
-
- return $costs;
- }
-
/**
* Add an amount of pecunia to this wallet's balance.
*
* @param int $amount The amount of pecunia to add (in cents).
* @param string $description The transaction description
*
* @return Wallet Self
*/
public function credit(int $amount, string $description = ''): Wallet
{
$this->balance += $amount;
$this->save();
Transaction::create(
[
'object_id' => $this->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_CREDIT,
'amount' => $amount,
'description' => $description
]
);
return $this;
}
/**
* Deduct an amount of pecunia from this wallet's balance.
*
* @param int $amount The amount of pecunia to deduct (in cents).
* @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 $amount, string $description = '', array $eTIDs = []): Wallet
{
if ($amount == 0) {
return $this;
}
$this->balance -= $amount;
$this->save();
$transaction = Transaction::create(
[
'object_id' => $this->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_DEBIT,
'amount' => $amount * -1,
'description' => $description
]
);
if (!empty($eTIDs)) {
Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]);
}
return $this;
}
/**
* 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
*/
public function getDiscount()
{
return $this->discount ? $this->discount->discount : 0;
}
/**
* The actual discount rate for use in multiplication
*
* Ranges from 0.00 to 1.00.
*/
public function getDiscountRate()
{
return (100 - $this->getDiscount()) / 100;
}
/**
* 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')
{
$amount = round($amount / 100, 2);
$nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
$result = $nf->formatCurrency($amount, $this->currency);
// Replace non-breaking space
return str_replace("\xC2\xA0", " ", $result);
}
/**
* 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);
}
+ /**
+ * 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);
}
}
/**
* Retrieve the transactions against this wallet.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function transactions()
{
- return Transaction::where(
- [
- 'object_id' => $this->id,
- 'object_type' => Wallet::class
- ]
- );
+ 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;
}
}
diff --git a/src/database/migrations/2022_09_08_100000_plans_free_months.php b/src/database/migrations/2022_09_08_100000_plans_free_months.php
new file mode 100644
index 00000000..06ef6881
--- /dev/null
+++ b/src/database/migrations/2022_09_08_100000_plans_free_months.php
@@ -0,0 +1,41 @@
+tinyInteger('free_months')->unsigned()->default(0);
+ }
+ );
+
+ DB::table('plans')->update(['free_months' => 1]);
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'plans',
+ function (Blueprint $table) {
+ $table->dropColumn('free_months');
+ }
+ );
+ }
+};
diff --git a/src/database/seeds/local/PlanSeeder.php b/src/database/seeds/local/PlanSeeder.php
index 81db47ba..a21530f5 100644
--- a/src/database/seeds/local/PlanSeeder.php
+++ b/src/database/seeds/local/PlanSeeder.php
@@ -1,141 +1,145 @@
Everything you need to get started or try Kolab Now, including:
- Perfect for anyone wanting to move to Kolab Now
- Suite of online apps: Secure email, calendar, address book, files and more
- Access for anywhere: Sync all your devices to your Kolab Now account
- Secure hosting: Managed right here on our own servers in Switzerland
- Start protecting your data today, no ads, no crawling, no compromise
- An ideal replacement for services like Gmail, Office 365, etc…
EOD;
$plan = Plan::create(
[
'title' => 'individual',
'name' => 'Individual Account',
'description' => $description,
+ 'free_months' => 1,
'discount_qty' => 0,
'discount_rate' => 0
]
);
$packages = [
Package::where(['title' => 'kolab', 'tenant_id' => \config('app.tenant_id')])->first()
];
$plan->packages()->saveMany($packages);
$description = <<<'EOD'
All the features of the Individual Account, with the following extras:
- Perfect for anyone wanting to move a group or small business to Kolab Now
- Recommended to support users from 1 to 100
- Use your own personal domains with Kolab Now
- Manage and add users through our online admin area
- Flexible pricing based on user count
EOD;
$plan = Plan::create(
[
'title' => 'group',
'name' => 'Group Account',
'description' => $description,
+ 'free_months' => 1,
'discount_qty' => 0,
'discount_rate' => 0
]
);
$packages = [
Package::where(['title' => 'domain-hosting', 'tenant_id' => \config('app.tenant_id')])->first(),
Package::where(['title' => 'kolab', 'tenant_id' => \config('app.tenant_id')])->first()
];
$plan->packages()->saveMany($packages);
// We're running in reseller mode, add a sample discount
$tenants = \App\Tenant::where('id', '!=', \config('app.tenant_id'))->get();
foreach ($tenants as $tenant) {
$description = <<<'EOD'
Everything you need to get started or try Kolab Now, including:
- Perfect for anyone wanting to move to Kolab Now
- Suite of online apps: Secure email, calendar, address book, files and more
- Access for anywhere: Sync all your devices to your Kolab Now account
- Secure hosting: Managed right here on our own servers in Switzerland
- Start protecting your data today, no ads, no crawling, no compromise
- An ideal replacement for services like Gmail, Office 365, etc…
EOD;
$plan = Plan::create(
[
'title' => 'individual',
'name' => 'Individual Account',
+ 'free_months' => 1,
'description' => $description,
'discount_qty' => 0,
'discount_rate' => 0
]
);
$plan->tenant_id = $tenant->id;
$plan->save();
$packages = [
Package::where(['title' => 'kolab', 'tenant_id' => $tenant->id])->first()
];
$plan->packages()->saveMany($packages);
$description = <<<'EOD'
All the features of the Individual Account, with the following extras:
- Perfect for anyone wanting to move a group or small business to Kolab Now
- Recommended to support users from 1 to 100
- Use your own personal domains with Kolab Now
- Manage and add users through our online admin area
- Flexible pricing based on user count
EOD;
$plan = Plan::create(
[
'title' => 'group',
'name' => 'Group Account',
'description' => $description,
+ 'free_months' => 1,
'discount_qty' => 0,
'discount_rate' => 0
]
);
$plan->tenant_id = $tenant->id;
$plan->save();
$packages = [
Package::where(['title' => 'domain-hosting', 'tenant_id' => $tenant->id])->first(),
Package::where(['title' => 'kolab', 'tenant_id' => $tenant->id])->first()
];
$plan->packages()->saveMany($packages);
}
}
}
diff --git a/src/database/seeds/production/PlanSeeder.php b/src/database/seeds/production/PlanSeeder.php
index d3c979a0..35c35ef8 100644
--- a/src/database/seeds/production/PlanSeeder.php
+++ b/src/database/seeds/production/PlanSeeder.php
@@ -1,74 +1,76 @@
Everything you need to get started or try Kolab Now, including:
- Perfect for anyone wanting to move to Kolab Now
- Suite of online apps: Secure email, calendar, address book, files and more
- Access for anywhere: Sync all your devices to your Kolab Now account
- Secure hosting: Managed right here on our own servers in Switzerland
- Start protecting your data today, no ads, no crawling, no compromise
- An ideal replacement for services like Gmail, Office 365, etc…
EOD;
$plan = Plan::create(
[
'title' => 'individual',
'name' => 'Individual Account',
'description' => $description,
+ 'free_months' => 1,
'discount_qty' => 0,
'discount_rate' => 0
]
);
$packages = [
Package::firstOrCreate(['title' => 'kolab'])
];
$plan->packages()->saveMany($packages);
$description = <<<'EOD'
All the features of the Individual Account, with the following extras:
- Perfect for anyone wanting to move a group or small business to Kolab Now
- Recommended to support users from 1 to 100
- Use your own personal domains with Kolab Now
- Manage and add users through our online admin area
- Flexible pricing based on user count
EOD;
$plan = Plan::create(
[
'title' => 'group',
'name' => 'Group Account',
'description' => $description,
+ 'free_months' => 1,
'discount_qty' => 0,
'discount_rate' => 0
]
);
$packages = [
Package::firstOrCreate(['title' => 'domain-hosting']),
Package::firstOrCreate(['title' => 'kolab']),
];
$plan->packages()->saveMany($packages);
}
}
diff --git a/src/tests/Feature/Console/Wallet/TrialEndTest.php b/src/tests/Feature/Console/Wallet/TrialEndTest.php
index e7d9381b..dbe1869b 100644
--- a/src/tests/Feature/Console/Wallet/TrialEndTest.php
+++ b/src/tests/Feature/Console/Wallet/TrialEndTest.php
@@ -1,115 +1,116 @@
deleteTestUser('test-user1@kolabnow.com');
$this->deleteTestUser('test-user22@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('test-user1@kolabnow.com');
$this->deleteTestUser('test-user22@kolabnow.com');
parent::tearDown();
}
/**
* Test command run
*/
public function testHandle(): void
{
Queue::fake();
- $package = \App\Package::withEnvTenantContext()->where('title', 'lite')->first();
+ $plan = \App\Plan::withEnvTenantContext()->where('title', 'individual')->first();
$user = $this->getTestUser('test-user1@kolabnow.com', [
'status' => User::STATUS_IMAP_READY | User::STATUS_LDAP_READY | User::STATUS_ACTIVE,
]);
$wallet = $user->wallets()->first();
- $user->assignPackage($package);
+ $user->assignPlan($plan);
DB::table('users')->update(['created_at' => \now()->clone()->subMonthsNoOverflow(2)->subHours(1)]);
// No wallets in after-trial state, no email sent
Queue::fake();
$code = \Artisan::call("wallet:trial-end");
Queue::assertNothingPushed();
// Expect no email sent (out of time boundaries)
$user->created_at = \now()->clone()->subMonthsNoOverflow(1)->addHour();
$user->save();
Queue::fake();
$code = \Artisan::call("wallet:trial-end");
Queue::assertNothingPushed();
// Test an email sent
$user->created_at = \now()->clone()->subMonthsNoOverflow(1);
$user->save();
Queue::fake();
$code = \Artisan::call("wallet:trial-end");
Queue::assertPushed(\App\Jobs\TrialEndEmail::class, 1);
Queue::assertPushed(\App\Jobs\TrialEndEmail::class, function ($job) use ($user) {
$job_user = TestCase::getObjectProperty($job, 'account');
return $job_user->id === $user->id;
});
$dt = $wallet->getSetting('trial_end_notice');
$this->assertMatchesRegularExpression('/^' . date('Y-m-d') . ' [0-9]{2}:[0-9]{2}:[0-9]{2}$/', $dt);
// Test no duplicate email sent for the same wallet
Queue::fake();
$code = \Artisan::call("wallet:trial-end");
Queue::assertNothingPushed();
// Test not imap ready user - no email sent
$wallet->setSetting('trial_end_notice', null);
$user->status = User::STATUS_NEW | User::STATUS_LDAP_READY | User::STATUS_ACTIVE;
$user->save();
Queue::fake();
$code = \Artisan::call("wallet:trial-end");
Queue::assertNothingPushed();
// Test deleted user - no email sent
$user->status = User::STATUS_NEW | User::STATUS_LDAP_READY | User::STATUS_ACTIVE | User::STATUS_IMAP_READY;
$user->save();
$user->delete();
Queue::fake();
$code = \Artisan::call("wallet:trial-end");
Queue::assertNothingPushed();
$this->assertNull($wallet->getSetting('trial_end_notice'));
// Make sure the non-controller users are omitted
$user2 = $this->getTestUser('test-user2@kolabnow.com', [
'status' => User::STATUS_IMAP_READY | User::STATUS_LDAP_READY | User::STATUS_ACTIVE,
]);
+ $package = \App\Package::withEnvTenantContext()->where('title', 'lite')->first();
$user->assignPackage($package, $user2);
$user2->created_at = \now()->clone()->subMonthsNoOverflow(1);
$user2->save();
Queue::fake();
$code = \Artisan::call("wallet:trial-end");
Queue::assertNothingPushed();
}
}
diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php
index 72785b3e..75abdab5 100644
--- a/src/tests/Feature/Controller/WalletsTest.php
+++ b/src/tests/Feature/Controller/WalletsTest.php
@@ -1,356 +1,356 @@
deleteTestUser('wallets-controller@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('wallets-controller@kolabnow.com');
parent::tearDown();
}
/**
* Test for getWalletNotice() method
*/
public function testGetWalletNotice(): void
{
$user = $this->getTestUser('wallets-controller@kolabnow.com');
- $package = \App\Package::withObjectTenantContext($user)->where('title', 'kolab')->first();
- $user->assignPackage($package);
+ $plan = \App\Plan::withObjectTenantContext($user)->where('title', 'individual')->first();
+ $user->assignPlan($plan);
$wallet = $user->wallets()->first();
$controller = new WalletsController();
$method = new \ReflectionMethod($controller, 'getWalletNotice');
$method->setAccessible(true);
// User/entitlements created today, balance=0
$notice = $method->invoke($controller, $wallet);
$this->assertSame('You are in your free trial period.', $notice);
- $wallet->owner->created_at = Carbon::now()->subDays(15);
+ $wallet->owner->created_at = Carbon::now()->subWeeks(3);
$wallet->owner->save();
$notice = $method->invoke($controller, $wallet);
$this->assertSame('Your free trial is about to end, top up to continue.', $notice);
// User/entitlements created today, balance=-10 CHF
$wallet->balance = -1000;
$notice = $method->invoke($controller, $wallet);
$this->assertSame('You are out of credit, top up your balance now.', $notice);
// User/entitlements created slightly more than a month ago, balance=9,99 CHF (monthly)
- $wallet->owner->created_at = Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1);
- $wallet->owner->save();
+ $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1));
+ $wallet->refresh();
// test "1 month"
$wallet->balance = 990;
$notice = $method->invoke($controller, $wallet);
$this->assertMatchesRegularExpression('/\((1 month|4 weeks)\)/', $notice);
// test "2 months"
$wallet->balance = 990 * 2.6;
$notice = $method->invoke($controller, $wallet);
- $this->assertMatchesRegularExpression('/\(2 months 2 weeks\)/', $notice);
+ $this->assertMatchesRegularExpression('/\(1 month 4 weeks\)/', $notice);
// Change locale to make sure the text is localized by Carbon
\app()->setLocale('de');
// test "almost 2 years"
$wallet->balance = 990 * 23.5;
$notice = $method->invoke($controller, $wallet);
- $this->assertMatchesRegularExpression('/\(1 Jahr 11 Monate\)/', $notice);
+ $this->assertMatchesRegularExpression('/\(1 Jahr 10 Monate\)/', $notice);
// Old entitlements, 100% discount
$this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40));
$discount = \App\Discount::withObjectTenantContext($user)->where('discount', 100)->first();
$wallet->discount()->associate($discount);
$notice = $method->invoke($controller, $wallet->refresh());
$this->assertSame(null, $notice);
}
/**
* Test fetching pdf receipt
*/
public function testReceiptDownload(): void
{
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$john = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Unauth access not allowed
$response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05");
$response->assertStatus(401);
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05");
$response->assertStatus(403);
// Invalid receipt id (current month)
$receiptId = date('Y-m');
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}");
$response->assertStatus(404);
// Invalid receipt id
$receiptId = '1000-03';
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}");
$response->assertStatus(404);
// Valid receipt id
$year = intval(date('Y')) - 1;
$receiptId = "$year-12";
$filename = \config('app.name') . " Receipt for $year-12";
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}");
$response->assertStatus(200);
$response->assertHeader('content-type', 'application/pdf');
$response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"');
$response->assertHeader('content-length');
$length = $response->headers->get('content-length');
$content = $response->content();
$this->assertStringStartsWith("%PDF-1.", $content);
$this->assertEquals(strlen($content), $length);
}
/**
* Test fetching list of receipts
*/
public function testReceipts(): void
{
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$john = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->payments()->delete();
// Unauth access not allowed
$response = $this->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(401);
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(403);
// Empty list expected
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame([], $json['list']);
$this->assertSame(1, $json['page']);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
// Insert a payment to the database
$date = Carbon::create(intval(date('Y')) - 1, 4, 30);
$payment = Payment::create([
'id' => 'AAA1',
'status' => PaymentProvider::STATUS_PAID,
'type' => PaymentProvider::TYPE_ONEOFF,
'description' => 'Paid in April',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
'currency' => 'CHF',
'currency_amount' => 1111,
]);
$payment->updated_at = $date;
$payment->save();
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame([$date->format('Y-m')], $json['list']);
$this->assertSame(1, $json['page']);
$this->assertSame(1, $json['count']);
$this->assertSame(false, $json['hasMore']);
}
/**
* Test fetching a wallet (GET /api/v4/wallets/:id)
*/
public function testShow(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$wallet = $john->wallets()->first();
$wallet->balance = -100;
$wallet->save();
// Accessing a wallet of someone else
$response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(403);
// Accessing non-existing wallet
$response = $this->actingAs($jack)->get("api/v4/wallets/aaa");
$response->assertStatus(404);
// Wallet owner
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($wallet->id, $json['id']);
$this->assertSame('CHF', $json['currency']);
$this->assertSame($wallet->balance, $json['balance']);
$this->assertTrue(empty($json['description']));
$this->assertTrue(!empty($json['notice']));
}
/**
* Test fetching wallet transactions
*/
public function testTransactions(): void
{
$package_kolab = \App\Package::where('title', 'kolab')->first();
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$user->assignPackage($package_kolab);
$john = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Unauth access not allowed
$response = $this->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(401);
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(403);
// Expect empty list
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame([], $json['list']);
$this->assertSame(1, $json['page']);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
// Create some sample transactions
$transactions = $this->createTestTransactions($wallet);
$transactions = array_reverse($transactions);
$pages = array_chunk($transactions, 10 /* page size*/);
// Get the first page
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(1, $json['page']);
$this->assertSame(10, $json['count']);
$this->assertSame(true, $json['hasMore']);
$this->assertCount(10, $json['list']);
foreach ($pages[0] as $idx => $transaction) {
$this->assertSame($transaction->id, $json['list'][$idx]['id']);
$this->assertSame($transaction->type, $json['list'][$idx]['type']);
$this->assertSame(\config('app.currency'), $json['list'][$idx]['currency']);
$this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']);
$this->assertFalse($json['list'][$idx]['hasDetails']);
$this->assertFalse(array_key_exists('user', $json['list'][$idx]));
}
$search = null;
// Get the second page
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(2, $json['page']);
$this->assertSame(2, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(2, $json['list']);
foreach ($pages[1] as $idx => $transaction) {
$this->assertSame($transaction->id, $json['list'][$idx]['id']);
$this->assertSame($transaction->type, $json['list'][$idx]['type']);
$this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']);
$this->assertSame(
$transaction->type == Transaction::WALLET_DEBIT,
$json['list'][$idx]['hasDetails']
);
$this->assertFalse(array_key_exists('user', $json['list'][$idx]));
if ($transaction->type == Transaction::WALLET_DEBIT) {
$search = $transaction->id;
}
}
// Get a non-existing page
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(3, $json['page']);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(0, $json['list']);
// Sub-transaction searching
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123");
$response->assertStatus(404);
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(1, $json['page']);
$this->assertSame(2, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(2, $json['list']);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']);
// Test that John gets 404 if he tries to access
// someone else's transaction ID on his wallet's endpoint
$wallet = $john->wallets()->first();
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}");
$response->assertStatus(404);
}
}
diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php
index 570cbb42..3a918829 100644
--- a/src/tests/Feature/EntitlementTest.php
+++ b/src/tests/Feature/EntitlementTest.php
@@ -1,225 +1,203 @@
deleteTestUser('entitlement-test@kolabnow.com');
$this->deleteTestUser('entitled-user@custom-domain.com');
$this->deleteTestGroup('test-group@custom-domain.com');
$this->deleteTestDomain('custom-domain.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('entitlement-test@kolabnow.com');
$this->deleteTestUser('entitled-user@custom-domain.com');
$this->deleteTestGroup('test-group@custom-domain.com');
$this->deleteTestDomain('custom-domain.com');
parent::tearDown();
}
- /**
- * Test for Entitlement::costsPerDay()
- */
- public function testCostsPerDay(): void
- {
- // 500
- // 28 days: 17.86
- // 31 days: 16.129
- $user = $this->getTestUser('entitlement-test@kolabnow.com');
- $package = Package::withEnvTenantContext()->where('title', 'kolab')->first();
- $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
-
- $user->assignPackage($package);
-
- $entitlement = $user->entitlements->where('sku_id', $mailbox->id)->first();
-
- $costsPerDay = $entitlement->costsPerDay();
-
- $this->assertTrue($costsPerDay < 17.86);
- $this->assertTrue($costsPerDay > 16.12);
- }
-
/**
* Tests for entitlements
* @todo This really should be in User or Wallet tests file
*/
public function testEntitlements(): void
{
$packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$skuDomain = Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$skuMailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$owner = $this->getTestUser('entitlement-test@kolabnow.com');
$user = $this->getTestUser('entitled-user@custom-domain.com');
$domain = $this->getTestDomain(
'custom-domain.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$domain->assignPackage($packageDomain, $owner);
$owner->assignPackage($packageKolab);
$owner->assignPackage($packageKolab, $user);
$wallet = $owner->wallets->first();
$this->assertCount(7, $owner->entitlements()->get());
$this->assertCount(1, $skuDomain->entitlements()->where('wallet_id', $wallet->id)->get());
$this->assertCount(2, $skuMailbox->entitlements()->where('wallet_id', $wallet->id)->get());
$this->assertCount(15, $wallet->entitlements);
$this->backdateEntitlements(
$owner->entitlements,
Carbon::now()->subMonthsWithoutOverflow(1)
);
$wallet->chargeEntitlements();
$this->assertTrue($wallet->fresh()->balance < 0);
}
/**
* @todo This really should be in User or Wallet tests file
*/
public function testBillDeletedEntitlement(): void
{
$user = $this->getTestUser('entitlement-test@kolabnow.com');
$package = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
$user->assignPackage($package);
// some additional SKUs so we have something to delete.
$user->assignSku($storage, 4);
// the mailbox, the groupware, the 5 original storage and the additional 4
$this->assertCount(11, $user->fresh()->entitlements);
$wallet = $user->wallets()->first();
$backdate = Carbon::now()->subWeeks(7);
$this->backdateEntitlements($user->entitlements, $backdate);
$charge = $wallet->chargeEntitlements();
$this->assertSame(-1090, $wallet->balance);
$balance = $wallet->balance;
$discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first();
$wallet->discount()->associate($discount);
$wallet->save();
$user->removeSku($storage, 4);
// we expect the wallet to have been charged for ~3 weeks of use of
// 4 deleted storage entitlements, it should also take discount into account
$backdate->addMonthsWithoutOverflow(1);
$diffInDays = $backdate->diffInDays(Carbon::now());
// entitlements-num * cost * discount * days-in-month
$max = intval(4 * 25 * 0.7 * $diffInDays / 28);
$min = intval(4 * 25 * 0.7 * $diffInDays / 31);
$wallet->refresh();
$this->assertTrue($wallet->balance >= $balance - $max);
$this->assertTrue($wallet->balance <= $balance - $min);
$transactions = \App\Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->get();
// one round of the monthly invoicing, four sku deletions getting invoiced
$this->assertCount(5, $transactions);
// Test that deleting an entitlement on a degraded account costs nothing
$balance = $wallet->balance;
User::where('id', $user->id)->update(['status' => $user->status | User::STATUS_DEGRADED]);
$backdate = Carbon::now()->subWeeks(7);
$this->backdateEntitlements($user->entitlements()->get(), $backdate);
$groupware = \App\Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$entitlement = $wallet->entitlements()->where('sku_id', $groupware->id)->first();
$entitlement->delete();
$this->assertSame($wallet->refresh()->balance, $balance);
}
/**
* Test EntitleableTrait::toString()
*/
public function testEntitleableTitle(): void
{
Queue::fake();
$packageDomain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$packageKolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$user = $this->getTestUser('entitled-user@custom-domain.com');
$group = $this->getTestGroup('test-group@custom-domain.com');
$domain = $this->getTestDomain(
'custom-domain.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$wallet = $user->wallets->first();
$domain->assignPackage($packageDomain, $user);
$user->assignPackage($packageKolab);
$group->assignToWallet($wallet);
$sku_mailbox = \App\Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$sku_group = \App\Sku::withEnvTenantContext()->where('title', 'group')->first();
$sku_domain = \App\Sku::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$entitlement = Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku_mailbox->id)->first();
$this->assertSame($user->email, $entitlement->entitleable->toString());
$entitlement = Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku_group->id)->first();
$this->assertSame($group->email, $entitlement->entitleable->toString());
$entitlement = Entitlement::where('wallet_id', $wallet->id)
->where('sku_id', $sku_domain->id)->first();
$this->assertSame($domain->namespace, $entitlement->entitleable->toString());
// Make sure it still works if the entitleable is deleted
$domain->delete();
$entitlement->refresh();
$this->assertSame($domain->namespace, $entitlement->entitleable->toString());
$this->assertNotNull($entitlement->entitleable);
}
}
diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php
index b0527803..9e2cb154 100644
--- a/src/tests/Feature/WalletTest.php
+++ b/src/tests/Feature/WalletTest.php
@@ -1,448 +1,538 @@
users as $user) {
$this->deleteTestUser($user);
}
+
+ Sku::select()->update(['fee' => 0]);
}
+ /**
+ * {@inheritDoc}
+ */
public function tearDown(): void
{
foreach ($this->users as $user) {
$this->deleteTestUser($user);
}
Sku::select()->update(['fee' => 0]);
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'));
// 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');
- $package = Package::withEnvTenantContext()->where('title', 'kolab')->first();
- $user->assignPackage($package);
+ $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);
}
- /**
- * Test for Wallet::costsPerDay()
- */
- public function testCostsPerDay(): void
- {
- // 990
- // 28 days: 35.36
- // 31 days: 31.93
- $user = $this->getTestUser('jane@kolabnow.com');
-
- $package = Package::withEnvTenantContext()->where('title', 'kolab')->first();
- $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
-
- $user->assignPackage($package);
-
- $wallet = $user->wallets()->first();
-
- $costsPerDay = $wallet->costsPerDay();
-
- $this->assertTrue($costsPerDay < 35.38);
- $this->assertTrue($costsPerDay > 31.93);
- }
-
/**
* 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
{
$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::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")]);
- $package = Package::withEnvTenantContext()->where('title', 'kolab')->first();
+ $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
- $user->assignPackage($package);
+ $user->assignPlan($plan);
$user->assignSku($storage, 5);
- $user->refresh();
+ $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();
- // TODO: Update these comments with what is actually being used to calculate these numbers
- // 388 + 310 + 17 + 17 = 732
+ // User discount is 30%
+ // Expected: groupware: 490 x 70% + mailbox: 500 x 70% + storage: 5 x round(25x70%) = 778
$this->assertSame(-778, $wallet->balance);
- // 388 - 555 x 40% + 310 - 444 x 40% + 34 - 50 x 40% = 312
+ // 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);
- // TODO: Test entitlement transaction records
+ // 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 updateEntitlements()
*/
public function testUpdateEntitlements(): void
{
$this->markTestIncomplete();
}
}