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,8 @@
     public function deleted(Entitlement $entitlement)
     {
         if (!$entitlement->entitleable->trashed()) {
+            // FIXME: Is this useful at all? Also, with this code here the UpdateJob dispatch
+            // below is probably redundant
             $entitlement->entitleable->updated_at = Carbon::now();
             $entitlement->entitleable->save();
 
@@ -106,6 +108,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,25 @@
             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);
 
+            // FIXME: Degraded pays nothing, but we get the money from a tenant, really?
             if ($isDegraded) {
                 $cost = 0;
             }
@@ -237,8 +130,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 +143,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 +533,64 @@
     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 = [];
-
-        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();
-                }
+        $transactions = [];
+
+        $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));
+            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}";
+                $method = $profit > 0 ? 'credit' : 'debit';
+                $wallet->{$method}(abs($profit), $desc);
+            }
+        }
+    }
+
     /**
      * Update the wallet balance, and create a transaction record
      */
@@ -743,4 +628,72 @@
 
         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) {
+            // Anything's free for the first 14 days.
+            if ($entitlement->created_at >= $endDate->copy()->subDays(14)) {
+                $startDate = $endDate;
+            }
+
+            // FIXME: A single day costs 1/30th of a monthly cost.
+            // Do we have to make it more complicated than that?
+
+            $diff = $startDate->diffInDays($endDate);
+
+            if ($diff <= 0) {
+                // Do not update updated_at column (updated_at in future), unless it is a deleted entitlement
+                return [0, 0, $entitlement->trashed() ? $endDate : null];
+            }
+
+            $cost = (int) (round(($entitlement->cost / 30) * $discountRate * $diff));
+            $fee = (int) (round(($entitlement->fee / 30) * $diff));
+        } else {
+            $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);
+
+            // FIXME: I don't know why we decided to not round() here, but round() when using per-day cost above
+
+            $cost = (int) ($entitlement->cost * $discountRate * $diff);
+            $fee = (int) ($entitlement->fee * $diff);
+        }
+
+        return [$cost, $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,249 @@
         $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: 490 * 70% + mailbox: 500 * 70% + storage: 5 * round(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: 490 * 40% - mailbox: 500 * 40% - storage: 5 * round(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());
+        $this->assertSame(5, $entitlement_transactions->whereNotIn('object_id',
+            [$mailbox_entitlement->id, $groupware_entitlement->id])->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
+        // 2 * round(25 / 30 * 19 * 0.7) = 22
+        $this->assertSame(22, $charge);
+        $this->assertSame(-22, $wallet->balance);
+        // 22 - 2 * round(25 * 0.4 / 30 * 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();
+        $transactions = $wallet->transactions()->get();
+        $this->assertCount(1, $transactions);
+        $trans = $transactions[0];
+        $this->assertSame('', $trans->description);
+        $this->assertSame(-22, $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(10, $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(22, $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: 500 * 70% + storage: 3 * round(25 * 70%) = 401
+        $this->assertSame(0, $charge);
+        $this->assertSame(0, $wallet->balance);
+        // Reseller fee is 40%
+        // Expected: 0 - mailbox: 500 * 40% - storage: 3 * round(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 * round(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 +662,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 * round(25 / 30 * 19) = 32
+        $this->assertSame(-32, $wallet->balance);
+        $this->assertSame(32, $charge);
+
+        // Reseller fee is 40%
+        // Expected: 32 - 2 * round(25 / 30 * 40% * 19) = 20
+        $this->assertSame(20, $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(-32, $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(16, $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 +752,120 @@
         $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();
+
+        // User discount is 30%
+        // Expected: groupware: round(490 / 30 * 21 * 70%) + mailbox: round(500 / 30 * 21 * 70%) = 485
+        $this->assertSame(0, $charge);
+        $this->assertSame(0, $wallet->balance);
+        $this->assertSame(0, $wallet->transactions()->count());
+        // Expected: 0 - groupware: round(490 / 30 * 21 * 40%) - mailbox: round(500 / 30 * 21 * 40%) = -277
+        $this->assertSame(-277, $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(-277, $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: round(490 / 30 * 21 * 70%) + mailbox: round(500 / 30 * 21 * 70%) = 485
+        $this->assertSame(485, $charge);
+        $this->assertSame(-485, $wallet->balance);
+        // Reseller fee is 40%
+        // Expected: 485 - groupware: round(490 / 30 * 21 * 40%) - mailbox: round(500 / 30 * 21 * 40%) = 208
+        $this->assertSame(208, $reseller_wallet->balance);
+
+        $transactions = $wallet->transactions()->get();
+        $this->assertCount(1, $transactions);
+        $trans = $transactions[0];
+        $this->assertSame('', $trans->description);
+        $this->assertSame(-485, $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(208, $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(485, $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 = [];