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
+    }
 }