Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117836572
D4597.1775306318.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
49 KB
Referenced Files
None
Subscribers
None
D4597.1775306318.diff
View Options
diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php
--- a/src/app/Observers/EntitlementObserver.php
+++ b/src/app/Observers/EntitlementObserver.php
@@ -78,6 +78,9 @@
public function deleted(Entitlement $entitlement)
{
if (!$entitlement->entitleable->trashed()) {
+ // TODO: This is useless, remove this, but also maybe refactor the whole method,
+ // i.e. move job invoking to App\Handlers (don't depend on SKU title).
+ // Also make sure the transaction is always being created
$entitlement->entitleable->updated_at = Carbon::now();
$entitlement->entitleable->save();
@@ -106,6 +109,8 @@
*/
public function deleting(Entitlement $entitlement)
{
- $entitlement->wallet->chargeEntitlement($entitlement);
+ // Disable updating of updated_at column on delete, we need it unchanged to later
+ // charge the wallet for the uncharged period before the entitlement has been deleted
+ $entitlement->timestamps = false;
}
}
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -7,6 +7,7 @@
use Carbon\Carbon;
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
/**
@@ -79,96 +80,6 @@
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
*
@@ -181,7 +92,6 @@
$transactions = [];
$profit = 0;
$charges = 0;
- $discount = $this->getDiscountRate();
$isDegraded = $this->owner->isDegraded();
$trial = $this->trialInfo();
@@ -189,42 +99,26 @@
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 all relevant entitlements...
+ $entitlements = $this->entitlements()->withTrashed()
+ // existing entitlements created, or billed last less than a month ago
+ ->where(function (Builder $query) {
+ $query->whereNull('deleted_at')
+ ->where('updated_at', '<=', Carbon::now()->subMonthsWithoutOverflow(1));
+ })
+ // deleted entitlements not yet charged
+ ->orWhere(function (Builder $query) {
+ $query->whereNotNull('deleted_at')
+ ->whereColumn('updated_at', '<', 'deleted_at');
+ })
->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);
+ // Calculate cost, fee, and end of period
+ [$cost, $fee, $endDate] = $this->entitlementCosts($entitlement, $trial);
+ // Note: Degraded pays nothing, but we get the money from a tenant.
+ // Therefore $cost = 0, but $profit < 0.
if ($isDegraded) {
$cost = 0;
}
@@ -237,8 +131,10 @@
continue;
}
- $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff);
- $entitlement->save();
+ if ($endDate) {
+ $entitlement->updated_at = $endDate;
+ $entitlement->save();
+ }
if ($cost == 0) {
continue;
@@ -248,17 +144,7 @@
}
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);
- }
- }
+ $this->debit($charges, '', $transactions)->addTenantProfit($profit);
DB::commit();
}
@@ -648,64 +534,69 @@
public function updateEntitlements($withCost = true): int
{
$charges = 0;
- $discount = $this->getDiscountRate();
- $now = Carbon::now();
+ $profit = 0;
+ $trial = $this->trialInfo();
DB::beginTransaction();
- // used to parent individual entitlement billings to the wallet debit.
- $entitlementTransactions = [];
+ $transactions = [];
- 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();
- }
+ $entitlements = $this->entitlements()->where('updated_at', '<', Carbon::now())->get();
- $pricePerDay = $entitlement->cost / $daysInMonth;
+ foreach ($entitlements as $entitlement) {
+ // Calculate cost, fee, and end of period
+ [$cost, $fee, $endDate] = $this->entitlementCosts($entitlement, $trial, true);
- $cost = (int) (round($pricePerDay * $discount * $diffInDays, 0));
+ // Note: Degraded pays nothing, but we get the money from a tenant.
+ // Therefore $cost = 0, but $profit < 0.
+ if (!$withCost) {
+ $cost = 0;
}
- if ($diffInDays > 0) {
- $entitlement->updated_at = $entitlement->updated_at->setDateFrom($now);
+ if ($endDate) {
+ $entitlement->updated_at = $entitlement->updated_at->setDateFrom($endDate);
$entitlement->save();
}
+ $charges += $cost;
+ $profit += $cost - $fee;
+
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
- );
+ $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost);
}
- if ($charges > 0) {
- $this->debit($charges, '', $entitlementTransactions);
- }
+ $this->debit($charges, '', $transactions)->addTenantProfit($profit);
DB::commit();
return $charges;
}
+ /**
+ * Add profit to the tenant's wallet
+ *
+ * @param int $profit Profit amount (in cents), can be negative
+ */
+ protected function addTenantProfit($profit): void
+ {
+ // Credit/debit the reseller
+ if ($profit != 0 && $this->owner->tenant) {
+ // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s)
+ if ($wallet = $this->owner->tenant->wallet()) {
+ $desc = "Charged user {$this->owner->email}";
+ if ($profit > 0) {
+ $wallet->credit(abs($profit), $desc);
+ } else {
+ $wallet->debit(abs($profit), $desc);
+ }
+ }
+ }
+ }
+
/**
* Update the wallet balance, and create a transaction record
*/
@@ -743,4 +634,89 @@
return $this;
}
+
+ /**
+ * Calculate entitlement cost/fee for the current charge
+ *
+ * @param Entitlement $entitlement Entitlement object
+ * @param array|null $trial Trial information (result of Wallet::trialInfo())
+ * @param bool $useCostPerDay Force calculation based on a per-day cost
+ *
+ * @return array Result in form of [cost, fee, end-of-period]
+ */
+ protected function entitlementCosts(Entitlement $entitlement, array $trial = null, bool $useCostPerDay = false)
+ {
+ $discountRate = $this->getDiscountRate();
+ $startDate = $entitlement->updated_at; // start of the period to charge for
+ $endDate = Carbon::now(); // end of the period to charge for
+
+ // Deleted entitlements are always charged for all uncharged days up to the delete date
+ if ($entitlement->trashed()) {
+ $useCostPerDay = true;
+ $endDate = $entitlement->deleted_at->copy();
+ }
+
+ // Consider Trial period
+ if (!empty($trial) && $startDate < $trial['end'] && in_array($entitlement->sku_id, $trial['skus'])) {
+ if ($trial['end'] > $endDate) {
+ return [0, 0, $trial['end']];
+ }
+
+ $startDate = $trial['end'];
+ }
+
+ if ($useCostPerDay) {
+ // Note: In this mode we need a full cost including partial periods.
+
+ // Anything's free for the first 14 days.
+ if ($entitlement->created_at >= $endDate->copy()->subDays(14)) {
+ return [0, 0, $endDate];
+ }
+
+ $cost = 0;
+ $fee = 0;
+
+ // Charge for full months first
+ if (($diff = $startDate->diffInMonths($endDate)) > 0) {
+ $cost += floor($entitlement->cost * $discountRate) * $diff;
+ $fee += $entitlement->fee * $diff;
+ $startDate->addMonthsWithoutOverflow($diff);
+ }
+
+ // Charge for the rest of the period
+ if (($diff = $startDate->diffInDays($endDate)) > 0) {
+ // The price per day is based on the number of days in the month(s)
+ // Note: The $endDate does not have to be the current month
+ $endMonthDiff = $endDate->day > $diff ? $diff : $endDate->day;
+ $startMonthDiff = $diff - $endMonthDiff;
+
+ // FIXME: This could be calculated in a few different ways, e.g. rounding or flooring
+ // the daily cost first and then applying discount and number of days. This could lead
+ // to very small values in some cases resulting in a zero result.
+ $cost += floor($entitlement->cost / $endDate->daysInMonth * $discountRate * $endMonthDiff);
+ $fee += floor($entitlement->fee / $endDate->daysInMonth * $endMonthDiff);
+
+ if ($startMonthDiff) {
+ $cost += floor($entitlement->cost / $startDate->daysInMonth * $discountRate * $startMonthDiff);
+ $fee += floor($entitlement->fee / $startDate->daysInMonth * $startMonthDiff);
+ }
+ }
+ } else {
+ // Note: In this mode we expect to charge the entitlement for full month(s) only
+ $diff = $startDate->diffInMonths($endDate);
+
+ if ($diff <= 0) {
+ // Do not update updated_at column (not a full month) unless trial end date
+ // is after current updated_at date
+ return [0, 0, $startDate != $entitlement->updated_at ? $startDate : null];
+ }
+
+ $endDate = $startDate->addMonthsWithoutOverflow($diff);
+
+ $cost = floor($entitlement->cost * $discountRate) * $diff;
+ $fee = $entitlement->fee * $diff;
+ }
+
+ return [(int) $cost, (int) $fee, $endDate];
+ }
}
diff --git a/src/tests/Feature/Console/User/RemoveSkuTest.php b/src/tests/Feature/Console/User/RemoveSkuTest.php
--- a/src/tests/Feature/Console/User/RemoveSkuTest.php
+++ b/src/tests/Feature/Console/User/RemoveSkuTest.php
@@ -53,13 +53,6 @@
$entitlements = $user->entitlements()->where('sku_id', $storage->id);
$this->assertSame(80, $entitlements->count());
- // Backdate entitlements so they are charged on removal
- $this->backdateEntitlements(
- $entitlements->get(),
- \Carbon\Carbon::now()->clone()->subWeeks(4),
- \Carbon\Carbon::now()->clone()->subWeeks(4)
- );
-
// Remove single entitlement
$this->artisan("user:remove-sku {$user->email} {$storage->title}")
->assertExitCode(0);
@@ -73,7 +66,6 @@
// 5GB is free, so it should stay at 5
$this->assertSame(5, $entitlements->count());
- $this->assertTrue($user->wallet()->balance < 0);
- $this->assertTrue(microtime(true) - $start < 6); // TODO: Make it faster
+ $this->assertThat(microtime(true) - $start, $this->lessThan(2));
}
}
diff --git a/src/tests/Feature/EntitlementTest.php b/src/tests/Feature/EntitlementTest.php
--- a/src/tests/Feature/EntitlementTest.php
+++ b/src/tests/Feature/EntitlementTest.php
@@ -125,71 +125,6 @@
$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()
*/
@@ -242,4 +177,28 @@
$this->assertSame($domain->namespace, $entitlement->entitleable->toString());
$this->assertNotNull($entitlement->entitleable);
}
+
+ /**
+ * Test for EntitleableTrait::removeSku()
+ */
+ public function testEntitleableRemoveSku(): void
+ {
+ $user = $this->getTestUser('entitlement-test@kolabnow.com');
+ $storage = \App\Sku::withEnvTenantContext()->where('title', 'storage')->first();
+
+ $user->assignSku($storage, 6);
+
+ $this->assertCount(6, $user->fresh()->entitlements);
+
+ $backdate = Carbon::now()->subWeeks(7);
+ $this->backdateEntitlements($user->entitlements, $backdate);
+
+ $user->removeSku($storage, 2);
+
+ // Expect free units to be not deleted
+ $this->assertCount(5, $user->fresh()->entitlements);
+
+ // Here we make sure that updated_at does not change on delete
+ $this->assertSame(6, $user->entitlements()->withTrashed()->whereDate('updated_at', $backdate)->count());
+ }
}
diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php
--- a/src/tests/Feature/WalletTest.php
+++ b/src/tests/Feature/WalletTest.php
@@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Discount;
+use App\Entitlement;
use App\Payment;
use App\Package;
use App\Plan;
@@ -371,13 +372,13 @@
}
/**
- * Test for charging and removing entitlements (including tenant commission calculations)
+ * Test for charging entitlements (including tenant commission calculations)
*/
- public function testChargeAndDeleteEntitlements(): void
+ public function testChargeEntitlements(): void
{
$user = $this->getTestUser('jane@kolabnow.com');
- $wallet = $user->wallets()->first();
$discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first();
+ $wallet = $user->wallets()->first();
$wallet->discount()->associate($discount);
$wallet->save();
@@ -386,6 +387,8 @@
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
+ $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
+ $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$user->assignPlan($plan);
$user->assignSku($storage, 5);
$user->setSetting('plan_id', null); // disable plan and trial
@@ -394,136 +397,248 @@
$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();
+ $reseller_wallet->transactions()->delete();
+
+ // 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));
+
+ // ------------------------------------------------
+ // Test skipping entitlements before a month passed
+ // ------------------------------------------------
+
+ $backdate = Carbon::now()->subWeeks(3);
+ $this->backdateEntitlements($user->entitlements, $backdate);
+
+ // we expect no charges
+ $this->assertSame(0, $wallet->chargeEntitlements());
+ $this->assertSame(0, $wallet->balance);
+ $this->assertSame(0, $reseller_wallet->balance);
+ $this->assertSame(0, $wallet->transactions()->count());
+ $this->assertSame(12, $user->entitlements()->where('updated_at', $backdate)->count());
// ------------------------------------
// 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);
+
+ // Test with $apply=false argument
+ $charge = $wallet->chargeEntitlements(false);
+
+ $this->assertSame(778, $charge);
+ $this->assertSame(0, $wallet->balance);
+ $this->assertSame(0, $wallet->transactions()->count());
+
$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
+ // Expected: groupware: floor(490 * 70%) + mailbox: floor(500 * 70%) + storage: 5 * floor(25 * 70%) = 778
+ $this->assertSame(778, $charge);
$this->assertSame(-778, $wallet->balance);
// Reseller fee is 40%
- // Expected: groupware: 490 x 30% + mailbox: 500 x 30% + storage: 5 x round(25x30%) = 332
+ // Expected: 778 - groupware: floor(490 * 40%) - mailbox: floor(500 * 40%) - storage: 5 * floor(25 * 40%) = 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();
+ $transactions = $wallet->transactions()->get();
+ $this->assertCount(1, $transactions);
+ $trans = $transactions[0];
+ $this->assertSame('', $trans->description);
+ $this->assertSame(-778, $trans->amount);
+ $this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
+ $reseller_transactions = $reseller_wallet->transactions()->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());
+ // Assert per-entitlement transactions
+ $entitlement_transactions = Transaction::where('transaction_id', $transactions[0]->id)
+ ->where('type', Transaction::ENTITLEMENT_BILLED)
+ ->get();
+ $this->assertSame(7, $entitlement_transactions->count());
+ $this->assertSame(778, $entitlement_transactions->sum('amount'));
+ $groupware_entitlement = $user->entitlements->where('sku_id', '===', $groupware->id)->first();
+ $mailbox_entitlement = $user->entitlements->where('sku_id', '===', $mailbox->id)->first();
+ $this->assertSame(1, $entitlement_transactions->where('object_id', $groupware_entitlement->id)->count());
+ $this->assertSame(1, $entitlement_transactions->where('object_id', $mailbox_entitlement->id)->count());
+ $excludes = [$mailbox_entitlement->id, $groupware_entitlement->id];
+ $this->assertSame(5, $entitlement_transactions->whereNotIn('object_id', $excludes)->count());
+
// -----------------------------------
- // Test charging on entitlement delete
+ // Test charging deleted entitlements
// -----------------------------------
+ $wallet->balance = 0;
+ $wallet->save();
+ $wallet->transactions()->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();
+ $reseller_wallet->transactions()->delete();
$user->removeSku($storage, 2);
- // we expect the wallet to have been charged for 19 days of use of
- // 2 deleted storage entitlements
+ // we expect the wallet to have been charged for 19 days of use of 2 deleted storage entitlements
+ $charge = $wallet->chargeEntitlements();
$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);
+ // 2 * floor(25 / 31 * 70% * 19) = 20
+ $this->assertSame(20, $charge);
+ $this->assertSame(-20, $wallet->balance);
+ // 20 - 2 * floor(25 / 31 * 40% * 19) = 8
+ $this->assertSame(8, $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();
+ $transactions = $wallet->transactions()->get();
+ $this->assertCount(1, $transactions);
+ $trans = $transactions[0];
+ $this->assertSame('', $trans->description);
+ $this->assertSame(-20, $trans->amount);
+ $this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
- $this->assertCount(2, $reseller_transactions);
+ $reseller_transactions = $reseller_wallet->transactions()->get();
+ $this->assertCount(1, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Charged user jane@kolabnow.com", $trans->description);
- $this->assertSame(5, $trans->amount);
+ $this->assertSame(8, $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);
+ // Assert per-entitlement transactions
+ $entitlement_transactions = Transaction::where('transaction_id', $transactions[0]->id)
+ ->where('type', Transaction::ENTITLEMENT_BILLED)
+ ->get();
+ $storage_entitlements = $user->entitlements->where('sku_id', $storage->id)->where('cost', '>', 0)->pluck('id');
+ $this->assertSame(2, $entitlement_transactions->count());
+ $this->assertSame(20, $entitlement_transactions->sum('amount'));
+ $this->assertSame(2, $entitlement_transactions->whereIn('object_id', $storage_entitlements)->count());
- $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);
+ // --------------------------------------------------
+ // Test skipping deleted entitlements already charged
+ // --------------------------------------------------
+
+ $wallet->balance = 0;
+ $wallet->save();
+ $wallet->transactions()->delete();
+ $reseller_wallet->balance = 0;
+ $reseller_wallet->save();
+ $reseller_wallet->transactions()->delete();
+
+ // we expect no charges
+ $this->assertSame(0, $wallet->chargeEntitlements());
+ $this->assertSame(0, $wallet->balance);
+ $this->assertSame(0, $wallet->transactions()->count());
+ $this->assertSame(0, $reseller_wallet->fresh()->balance);
+
+ // ---------------------------------------------------------
+ // Test (not) charging entitlements deleted before 14 days
+ // ---------------------------------------------------------
+
+ $backdate = Carbon::now()->subDays(13);
+
+ $ent = $user->entitlements->where('sku_id', $groupware->id)->first();
+ Entitlement::where('id', $ent->id)->update([
+ 'created_at' => $backdate,
+ 'updated_at' => $backdate,
+ 'deleted_at' => Carbon::now(),
+ ]);
+
+ // we expect no charges
+ $this->assertSame(0, $wallet->chargeEntitlements());
+ $this->assertSame(0, $wallet->balance);
+ $this->assertSame(0, $wallet->transactions()->count());
+ $this->assertSame(0, $reseller_wallet->fresh()->balance);
+ // expect update of updated_at timestamp
+ $this->assertSame(Carbon::now()->toDateTimeString(), $ent->fresh()->updated_at->toDateTimeString());
+
+ // -------------------------------------------------------
+ // Test charging a degraded account
+ // Test both deleted and non-deleted in the same operation
+ // -------------------------------------------------------
+
+ // At this point user has: mailbox + 8 x storage
+ $backdate = Carbon::now()->subWeeks(7);
+ $this->backdateEntitlements($user->entitlements->fresh(), $backdate);
+
+ $user->status |= User::STATUS_DEGRADED;
+ $user->saveQuietly();
+
+ $wallet->refresh();
+ $wallet->balance = 0;
+ $wallet->save();
+ $reseller_wallet->balance = 0;
+ $reseller_wallet->save();
+ Transaction::truncate();
- // TODO: Test entitlement transaction records
+ $charge = $wallet->chargeEntitlements();
+ $reseller_wallet->refresh();
+
+ // User would be charged if not degraded: mailbox: floor(500 * 70%) + storage: 3 * floor(25 * 70%) = 401
+ $this->assertSame(0, $charge);
+ $this->assertSame(0, $wallet->balance);
+ // Expected: 0 - mailbox: floor(500 * 40%) - storage: 3 * floor(25 * 40%) = -230
+ $this->assertSame(-230, $reseller_wallet->balance);
+
+ // Assert all entitlements' updated_at timestamp
+ $date = $backdate->addMonthsWithoutOverflow(1);
+ $this->assertSame(9, $wallet->entitlements()->where('updated_at', $date)->count());
+ // There should be only one transaction at this point (for the reseller wallet)
+ $this->assertSame(1, Transaction::count());
}
/**
- * Test for charging and removing entitlements when in trial
+ * Test for charging entitlements when in trial
*/
- public function testChargeAndDeleteEntitlementsTrial(): void
+ public function testChargeEntitlementsTrial(): void
{
$user = $this->getTestUser('jane@kolabnow.com');
$wallet = $user->wallets()->first();
+ // 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);
+ // Reset reseller's wallet balance and transactions
+ $reseller_wallet = $user->tenant->wallet();
+ $reseller_wallet->balance = 0;
+ $reseller_wallet->save();
+ $reseller_wallet->transactions()->delete();
+
+ // 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));
+
// ------------------------------------
// 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);
+ $backdate = Carbon::now()->subWeeks(7); // 2021-04-02
+ $this->backdateEntitlements($user->entitlements, $backdate, $backdate);
$charge = $wallet->chargeEntitlements();
- $wallet->refresh();
+ $reseller_wallet->refresh();
- // Expected: storage: 5 x 25 = 125 (the rest is free in trial)
+ // Expected: storage: 5 * 25 = 125 (the rest is free in trial)
$this->assertSame($balance = -125, $wallet->balance);
+ $this->assertSame(-$balance, $charge);
+
+ // Reseller fee is 40%
+ // Expected: 125 - 5 * floor(25 * 40%) = 75
+ $this->assertSame($reseller_balance = 75, $reseller_wallet->balance);
// Assert wallet transaction
$transactions = $wallet->transactions()->get();
-
$this->assertCount(1, $transactions);
$trans = $transactions[0];
$this->assertSame('', $trans->description);
@@ -546,52 +661,57 @@
$charge = $wallet->chargeEntitlements();
$wallet->refresh();
+ $this->assertSame(0, $charge);
$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
+ // Test charging deleted entitlements
// -----------------------------------
- $wallet->transactions()->delete();
+ $wallet->balance = 0;
+ $wallet->save();
+ $reseller_wallet->balance = 0;
+ $reseller_wallet->save();
+ Transaction::truncate();
$user->removeSku($storage, 2);
+ $charge = $wallet->chargeEntitlements();
+
$wallet->refresh();
+ $reseller_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);
+ // 2 deleted storage entitlements: 2 * floor(25 / 31 * 19) = 30
+ $this->assertSame(-30, $wallet->balance);
+ $this->assertSame(30, $charge);
+
+ // Reseller fee is 40%
+ // Expected: 30 - 2 * floor(25 / 31 * 40% * 19) = 18
+ $this->assertSame(18, $reseller_wallet->balance);
// Assert wallet transactions
$transactions = $wallet->transactions()->get();
-
- $this->assertCount(2, $transactions);
+ $this->assertCount(1, $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(-30, $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);
+ $etransactions = Transaction::where('transaction_id', $trans->id)->get();
+ $this->assertCount(2, $etransactions);
$trans = $etransactions[0];
$this->assertSame(null, $trans->description);
$this->assertSame(15, $trans->amount);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type);
- */
+
+ // Assert the deleted entitlements' updated_at timestamp was bumped
+ $this->assertSame(2, $wallet->entitlements()->withTrashed()->whereColumn('updated_at', 'deleted_at')->count());
+
+ // TODO: Test a case when trial ends after the entitlement deletion date
}
/**
@@ -631,20 +751,118 @@
$this->markTestIncomplete();
}
- /**
- * Tests for chargeEntitlement()
- */
- public function testChargeEntitlement(): void
- {
- $this->markTestIncomplete();
- }
-
/**
* Tests for updateEntitlements()
*/
public function testUpdateEntitlements(): void
{
- $this->markTestIncomplete();
+ $user = $this->getTestUser('jane@kolabnow.com');
+ $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first();
+ $wallet = $user->wallets()->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();
+ $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
+ $groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
+ $user->assignPlan($plan);
+ $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();
+ $reseller_wallet->transactions()->delete();
+
+ // 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));
+ $now = Carbon::now();
+
+ // Backdate and charge entitlements
+ $backdate = Carbon::now()->subWeeks(3)->setHour(10);
+ $this->backdateEntitlements($user->entitlements, $backdate);
+
+ // ---------------------------------------
+ // Update entitlements with no cost charge
+ // ---------------------------------------
+
+ // Test with $withCost=false argument
+ $charge = $wallet->updateEntitlements(false);
+ $wallet->refresh();
+ $reseller_wallet->refresh();
+
+ $this->assertSame(0, $charge);
+ $this->assertSame(0, $wallet->balance);
+ $this->assertSame(0, $wallet->transactions()->count());
+ // Expected: 0 - groupware: floor(490 / 31 * 21 * 40%) - mailbox: floor(500 / 31 * 21 * 40%) = -267
+ $this->assertSame(-267, $reseller_wallet->balance);
+
+ // Assert all entitlements' updated_at timestamp
+ $date = $now->copy()->setTimeFrom($backdate);
+ $this->assertCount(7, $wallet->entitlements()->where('updated_at', $date)->get());
+
+ $reseller_transactions = $reseller_wallet->transactions()->get();
+ $this->assertCount(1, $reseller_transactions);
+ $trans = $reseller_transactions[0];
+ $this->assertSame("Charged user jane@kolabnow.com", $trans->description);
+ $this->assertSame(-267, $trans->amount);
+ $this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
+
+ // ------------------------------------
+ // Update entitlements with cost charge
+ // ------------------------------------
+
+ $reseller_wallet = $user->tenant->wallet();
+ $reseller_wallet->balance = 0;
+ $reseller_wallet->save();
+ $reseller_wallet->transactions()->delete();
+
+ $this->backdateEntitlements($user->entitlements, $backdate);
+
+ $charge = $wallet->updateEntitlements();
+ $wallet->refresh();
+ $reseller_wallet->refresh();
+
+ // User discount is 30%
+ // Expected: groupware: floor(490 / 31 * 21 * 70%) + mailbox: floor(500 / 31 * 21 * 70%) = 469
+ $this->assertSame(469, $charge);
+ $this->assertSame(-469, $wallet->balance);
+ // Reseller fee is 40%
+ // Expected: 469 - groupware: floor(490 / 31 * 21 * 40%) - mailbox: floor(500 / 31 * 21 * 40%) = 202
+ $this->assertSame(202, $reseller_wallet->balance);
+
+ $transactions = $wallet->transactions()->get();
+ $this->assertCount(1, $transactions);
+ $trans = $transactions[0];
+ $this->assertSame('', $trans->description);
+ $this->assertSame(-469, $trans->amount);
+ $this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
+
+ $reseller_transactions = $reseller_wallet->transactions()->get();
+ $this->assertCount(1, $reseller_transactions);
+ $trans = $reseller_transactions[0];
+ $this->assertSame("Charged user jane@kolabnow.com", $trans->description);
+ $this->assertSame(202, $trans->amount);
+ $this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
+
+ // Assert all entitlements' updated_at timestamp
+ $date = $now->copy()->setTimeFrom($backdate);
+ $this->assertCount(7, $wallet->entitlements()->where('updated_at', $date)->get());
+
+ // Assert per-entitlement transactions
+ $groupware_entitlement = $user->entitlements->where('sku_id', '===', $groupware->id)->first();
+ $mailbox_entitlement = $user->entitlements->where('sku_id', '===', $mailbox->id)->first();
+ $entitlement_transactions = Transaction::where('transaction_id', $transactions[0]->id)
+ ->where('type', Transaction::ENTITLEMENT_BILLED)
+ ->get();
+ $this->assertSame(2, $entitlement_transactions->count());
+ $this->assertSame(469, $entitlement_transactions->sum('amount'));
+ $this->assertSame(1, $entitlement_transactions->where('object_id', $groupware_entitlement->id)->count());
+ $this->assertSame(1, $entitlement_transactions->where('object_id', $mailbox_entitlement->id)->count());
}
/**
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -160,7 +160,10 @@
$this->assertCount(8 + count($other), $result);
}
- protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null)
+ /**
+ * Set a specific date to existing entitlements
+ */
+ protected function backdateEntitlements($entitlements, $targetDate, $targetCreatedDate = null): void
{
$wallets = [];
$ids = [];
diff --git a/src/tests/Unit/WalletTest.php b/src/tests/Unit/WalletTest.php
--- a/src/tests/Unit/WalletTest.php
+++ b/src/tests/Unit/WalletTest.php
@@ -3,14 +3,13 @@
namespace Tests\Unit;
use App\Wallet;
+use Carbon\Carbon;
use Tests\TestCase;
class WalletTest extends TestCase
{
/**
* Test Wallet::money()
- *
- * @return void
*/
public function testMoney()
{
@@ -27,4 +26,51 @@
$this->assertSame('-1,23 €', $money);
}
+
+ /**
+ * Test Wallet::entitlementCosts()
+ */
+ public function testEntitlementCosts()
+ {
+ $discount = \App\Discount::where('discount', 30)->first();
+ $wallet = new Wallet(['currency' => 'CHF', 'id' => 123]);
+ $ent = new \App\Entitlement([
+ 'wallet_id' => 123,
+ 'sku_id' => 456,
+ 'cost' => 100,
+ 'fee' => 50,
+ 'entitleable_id' => 789,
+ 'entitleable_type' => \App\User::class,
+ ]);
+
+ $wallet->discount = $discount; // @phpstan-ignore-line
+
+ // Test calculating with daily price, period spread over two months
+ Carbon::setTestNow(Carbon::create(2021, 5, 5, 12));
+ $ent->created_at = Carbon::now()->subDays(20);
+ $ent->updated_at = Carbon::now()->subDays(20);
+
+ $result = $this->invokeMethod($wallet, 'entitlementCosts', [$ent, null, true]);
+
+ // cost: floor(100 / 30 * 15 * 70%) + floor(100 / 31 * 5 * 70%) = 46
+ $this->assertSame(46, $result[0]);
+ // fee: floor(50 / 30 * 15) + floor(50 / 31 * 5) = 33
+ $this->assertSame(33, $result[1]);
+ $this->assertTrue(Carbon::now()->equalTo($result[2])); // end of period
+
+ // Test calculating with daily price, period spread over three months
+ Carbon::setTestNow(Carbon::create(2021, 5, 5, 12));
+ $ent->created_at = Carbon::create(2021, 3, 25, 12);
+ $ent->updated_at = Carbon::create(2021, 3, 25, 12);
+
+ $result = $this->invokeMethod($wallet, 'entitlementCosts', [$ent, null, true]);
+
+ // cost: floor(100 * 70%) + floor(100 / 30 * 5 * 70%) + floor(100 / 31 * 5 * 70%) = 92
+ $this->assertSame(92, $result[0]);
+ // fee: 50 + floor(50 / 30 * 5) + floor(50 / 31 * 5) = 66
+ $this->assertSame(66, $result[1]);
+ $this->assertTrue(Carbon::now()->equalTo($result[2])); // end of period
+
+ // TODO: More cases
+ }
}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 12:38 PM (8 h, 5 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18827391
Default Alt Text
D4597.1775306318.diff (49 KB)
Attached To
Mode
D4597: Asynchronous charging of deleted entitlements
Attached
Detach File
Event Timeline