diff --git a/bin/phpstan b/bin/phpstan --- a/bin/phpstan +++ b/bin/phpstan @@ -4,7 +4,7 @@ pushd ${cwd}/../src/ -php -dmemory_limit=400M \ +php -dmemory_limit=500M \ vendor/bin/phpstan \ analyse diff --git a/src/app/Console/Commands/DomainSetWallet.php b/src/app/Console/Commands/DomainSetWallet.php --- a/src/app/Console/Commands/DomainSetWallet.php +++ b/src/app/Console/Commands/DomainSetWallet.php @@ -60,6 +60,7 @@ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => 0, + 'fee' => 0, 'entitleable_id' => $domain->id, 'entitleable_type' => Domain::class, ] diff --git a/src/app/Domain.php b/src/app/Domain.php --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -89,6 +89,7 @@ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), + 'fee' => $sku->pivot->fee(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -11,10 +11,18 @@ * * Owned by a {@link \App\User}, billed to a {@link \App\Wallet}. * - * @property \App\User $owner The owner of this entitlement (subject). - * @property \App\Sku $sku The SKU to which this entitlement applies. - * @property \App\Wallet $wallet The wallet to which this entitlement is charged. - * @property \App\Domain|\App\User $entitleable The entitled object (receiver of the entitlement). + * @property int $cost + * @property ?string $description + * @property \App\Domain|\App\User $entitleable The entitled object (receiver of the entitlement). + * @property int $entitleable_id + * @property string $entitleable_type + * @property int $fee + * @property string $id + * @property \App\User $owner The owner of this entitlement (subject). + * @property \App\Sku $sku The SKU to which this entitlement applies. + * @property string $sku_id + * @property \App\Wallet $wallet The wallet to which this entitlement is charged. + * @property string $wallet_id */ class Entitlement extends Model { @@ -45,11 +53,13 @@ 'entitleable_id', 'entitleable_type', 'cost', - 'description' + 'description', + 'fee', ]; protected $casts = [ 'cost' => 'integer', + 'fee' => 'integer' ]; /** diff --git a/src/app/Group.php b/src/app/Group.php --- a/src/app/Group.php +++ b/src/app/Group.php @@ -64,6 +64,7 @@ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => Group::class ]); diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -60,8 +60,9 @@ public function oneOff(Request $request, $id) { $wallet = Wallet::find($id); + $user = Auth::guard()->user(); - if (empty($wallet) || !Auth::guard()->user()->canRead($wallet)) { + if (empty($wallet) || !$user->canRead($wallet)) { return $this->errorResponse(404); } @@ -97,6 +98,14 @@ ] ); + if ($user->role == 'reseller') { + if ($user->tenant && ($tenant_wallet = $user->tenant->wallet())) { + $desc = ($amount > 0 ? 'Awarded' : 'Penalized') . " user {$wallet->owner->email}"; + $method = $amount > 0 ? 'debit' : 'credit'; + $tenant_wallet->{$method}(abs($amount), $desc); + } + } + DB::commit(); $response = [ diff --git a/src/app/Http/Controllers/API/V4/SkusController.php b/src/app/Http/Controllers/API/V4/SkusController.php --- a/src/app/Http/Controllers/API/V4/SkusController.php +++ b/src/app/Http/Controllers/API/V4/SkusController.php @@ -186,7 +186,7 @@ $data['name'] = $sku->name; $data['description'] = $sku->description; - unset($data['handler_class'], $data['created_at'], $data['updated_at']); + unset($data['handler_class'], $data['created_at'], $data['updated_at'], $data['fee'], $data['tenant_id']); return $data; } 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 @@ -123,7 +123,6 @@ return; } - $cost = 0; $now = Carbon::now(); // get the discount rate applied to the wallet. @@ -131,7 +130,8 @@ // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); - $cost += (int) ($entitlement->cost * $discount * $diffInMonths); + $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); @@ -153,8 +153,18 @@ } $pricePerDay = $entitlement->cost / $daysInMonth; + $feePerDay = $entitlement->fee / $daysInMonth; $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); + $fee += (int) (round($feePerDay * $diffInDays, 0)); + + $profit = $cost - $fee; + + if ($profit != 0 && $owner->tenant && ($wallet = $owner->tenant->wallet())) { + $desc = "Charged user {$owner->email}"; + $method = $profit > 0 ? 'credit' : 'debit'; + $wallet->{$method}(abs($profit), $desc); + } if ($cost == 0) { return; diff --git a/src/app/Observers/PackageObserver.php b/src/app/Observers/PackageObserver.php --- a/src/app/Observers/PackageObserver.php +++ b/src/app/Observers/PackageObserver.php @@ -27,5 +27,7 @@ break; } } + + $package->tenant_id = \config('app.tenant_id'); } } diff --git a/src/app/Observers/PackageSkuObserver.php b/src/app/Observers/PackageSkuObserver.php --- a/src/app/Observers/PackageSkuObserver.php +++ b/src/app/Observers/PackageSkuObserver.php @@ -6,6 +6,25 @@ class PackageSkuObserver { + /** + * Handle the "creating" event on an PackageSku relation. + * + * Ensures that the entries belong to the same tenant. + * + * @param \App\PackageSku $packageSku The package-sku relation + * + * @return void + */ + public function creating(PackageSku $packageSku) + { + $package = $packageSku->package; + $sku = $packageSku->sku; + + if ($package->tenant_id != $sku->tenant_id) { + throw new \Exception("Package and SKU owned by different tenants"); + } + } + /** * Handle the "created" event on an PackageSku relation * diff --git a/src/app/Observers/PlanObserver.php b/src/app/Observers/PlanObserver.php --- a/src/app/Observers/PlanObserver.php +++ b/src/app/Observers/PlanObserver.php @@ -27,5 +27,7 @@ break; } } + + $plan->tenant_id = \config('app.tenant_id'); } } diff --git a/src/app/Observers/PlanPackageObserver.php b/src/app/Observers/PlanPackageObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/PlanPackageObserver.php @@ -0,0 +1,27 @@ +package; + $plan = $planPackage->plan; + + if ($package->tenant_id != $plan->tenant_id) { + throw new \Exception("Package and Plan owned by different tenants"); + } + } +} diff --git a/src/app/Observers/SkuObserver.php b/src/app/Observers/SkuObserver.php --- a/src/app/Observers/SkuObserver.php +++ b/src/app/Observers/SkuObserver.php @@ -22,5 +22,9 @@ break; } } + + $sku->tenant_id = \config('app.tenant_id'); + + // TODO: We should make sure that tenant_id + title is unique } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -108,6 +108,18 @@ } }); } + + // Debit the reseller's wallet with the user negative balance + $balance = 0; + foreach ($user->wallets as $wallet) { + // Note: here we assume all user wallets are using the same currency. + // It might get changed in the future + $balance += $wallet->balance; + } + + if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { + $wallet->debit($balance * -1, "Deleted user {$user->email}"); + } } /** diff --git a/src/app/Package.php b/src/app/Package.php --- a/src/app/Package.php +++ b/src/app/Package.php @@ -21,6 +21,13 @@ * * Free package: mailbox + quota. * * Selecting a package will therefore create a set of entitlments from SKUs. + * + * @property string $description + * @property int $discount_rate + * @property string $id + * @property string $name + * @property ?int $tenant_id + * @property string $title */ class Package extends Model { @@ -69,7 +76,10 @@ return $costs; } - public function isDomain() + /** + * Checks whether the package contains a domain SKU. + */ + public function isDomain(): bool { foreach ($this->skus as $sku) { if ($sku->handler_class::entitleableClass() == \App\Domain::class) { @@ -94,4 +104,14 @@ ['qty'] ); } + + /** + * The tenant for this package. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/PackageSku.php b/src/app/PackageSku.php --- a/src/app/PackageSku.php +++ b/src/app/PackageSku.php @@ -35,30 +35,50 @@ */ public function cost() { - $costs = 0; - $units = $this->qty - $this->sku->units_free; if ($units < 0) { - \Log::debug( - "Package {$this->package_id} is misconfigured for more free units than qty." - ); - $units = 0; } + // FIXME: Why package_skus.cost value is not used anywhere? + $ppu = $this->sku->cost * ((100 - $this->package->discount_rate) / 100); - $costs += $units * $ppu; + return $units * $ppu; + } + + /** + * Under this package, what fee this SKU has? + * + * @return int The fee for this SKU under this package in cents. + */ + public function fee() + { + $units = $this->qty - $this->sku->units_free; + + if ($units < 0) { + $units = 0; + } - return $costs; + return $this->sku->fee * $units; } + /** + * The package for this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ public function package() { return $this->belongsTo('App\Package'); } + /** + * The SKU for this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ public function sku() { return $this->belongsTo('App\Sku'); diff --git a/src/app/Plan.php b/src/app/Plan.php --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -13,7 +13,16 @@ * A "Family Plan" as such may exist of "2 or more Kolab packages", * and apply a discount for the third and further Kolab packages. * + * @property string $description + * @property int $discount_qty + * @property int $discount_rate + * @property string $id + * @property string $name * @property \App\Package[] $packages + * @property datetime $promo_from + * @property datetime $promo_to + * @property ?int $tenant_id + * @property string $title */ class Plan extends Model { @@ -105,4 +114,14 @@ return false; } + + /** + * The tenant for this plan. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/PlanPackage.php b/src/app/PlanPackage.php --- a/src/app/PlanPackage.php +++ b/src/app/PlanPackage.php @@ -15,6 +15,7 @@ * @property int $qty_max * @property int $qty_min * @property \App\Package $package + * @property \App\Plan $plan */ class PlanPackage extends Pivot { @@ -54,8 +55,23 @@ return $costs; } + /** + * The package in this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ public function package() { return $this->belongsTo('App\Package'); } + + /** + * The plan in this relation. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function plan() + { + return $this->belongsTo('App\Plan'); + } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -35,6 +35,7 @@ \App\Package::observe(\App\Observers\PackageObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\Plan::observe(\App\Observers\PlanObserver::class); + \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\SignupInvitation::observe(\App\Observers\SignupInvitationObserver::class); \App\Sku::observe(\App\Observers\SkuObserver::class); diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -261,6 +261,8 @@ $refund['status'] = self::STATUS_PAID; $refund['amount'] = -1 * $amount; + // FIXME: Refunds/chargebacks are out of the reseller comissioning for now + $this->storePayment($refund, $wallet->id); } diff --git a/src/app/Sku.php b/src/app/Sku.php --- a/src/app/Sku.php +++ b/src/app/Sku.php @@ -7,6 +7,18 @@ /** * The eloquent definition of a Stock Keeping Unit (SKU). + * + * @property bool $active + * @property int $cost + * @property string $description + * @property int $fee The fee that the tenant pays to us + * @property string $handler_class + * @property string $id + * @property string $name + * @property string $period + * @property ?int $tenant_id + * @property string $title + * @property int $units_free */ class Sku extends Model { @@ -23,6 +35,7 @@ 'active', 'cost', 'description', + 'fee', 'handler_class', 'name', // persist for annual domain registration @@ -59,4 +72,14 @@ 'package_skus' )->using('App\PackageSku')->withPivot(['cost', 'qty']); } + + /** + * The tenant for this SKU. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function tenant() + { + return $this->belongsTo('App\Tenant', 'tenant_id', 'id'); + } } diff --git a/src/app/Tenant.php b/src/app/Tenant.php --- a/src/app/Tenant.php +++ b/src/app/Tenant.php @@ -37,4 +37,16 @@ { return $this->hasMany('App\SignupInvitation'); } + + /* + * Returns the wallet of the tanant (reseller's wallet). + * + * @return ?\App\Wallet A wallet object + */ + public function wallet(): ?Wallet + { + $user = \App\User::where('role', 'reseller')->where('tenant_id', $this->id)->first(); + + return $user ? $user->wallets->first() : null; + } } diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -126,6 +126,7 @@ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), + 'fee' => $sku->pivot->fee(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] @@ -179,6 +180,7 @@ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $exists >= $sku->units_free ? $sku->cost : 0, + 'fee' => $exists >= $sku->units_free ? $sku->fee : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -76,6 +76,7 @@ return 0; } + $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); @@ -101,8 +102,10 @@ $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); + $fee = (int) ($entitlement->fee * $diff); $charges += $cost; + $profit += $cost - $fee; // if we're in dry-run, you know... if (!$apply) { @@ -126,7 +129,17 @@ } if ($apply) { - $this->debit($charges, $entitlementTransactions); + $this->debit($charges, '', $entitlementTransactions); + + // Credit/debit the reseller + if ($profit != 0 && $this->owner->tenant) { + // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) + if ($wallet = $this->owner->tenant->wallet()) { + $desc = "Charged user {$this->owner->email}"; + $method = $profit > 0 ? 'credit' : 'debit'; + $wallet->{$method}(abs($profit), $desc); + } + } } DB::commit(); @@ -234,12 +247,13 @@ /** * Deduct an amount of pecunia from this wallet's balance. * - * @param int $amount The amount of pecunia to deduct (in cents). - * @param array $eTIDs List of transaction IDs for the individual entitlements that make up - * this debit record, if any. + * @param int $amount The amount of pecunia to deduct (in cents). + * @param string $description The transaction description + * @param array $eTIDs List of transaction IDs for the individual entitlements + * that make up this debit record, if any. * @return Wallet Self */ - public function debit(int $amount, array $eTIDs = []): Wallet + public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet { if ($amount == 0) { return $this; @@ -254,11 +268,14 @@ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_DEBIT, - 'amount' => $amount * -1 + 'amount' => $amount * -1, + 'description' => $description ] ); - \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); + if (!empty($eTIDs)) { + \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); + } return $this; } diff --git a/src/database/migrations/2019_12_10_100355_create_package_skus_table.php b/src/database/migrations/2019_12_10_100355_create_package_skus_table.php --- a/src/database/migrations/2019_12_10_100355_create_package_skus_table.php +++ b/src/database/migrations/2019_12_10_100355_create_package_skus_table.php @@ -21,7 +21,6 @@ $table->string('package_id', 36); $table->string('sku_id', 36); $table->integer('qty')->default(1); - $table->integer('cost')->default(0)->nullable(); $table->foreign('package_id')->references('id')->on('packages') diff --git a/src/database/migrations/2020_05_05_095212_create_tenants_table.php b/src/database/migrations/2020_05_05_095212_create_tenants_table.php --- a/src/database/migrations/2020_05_05_095212_create_tenants_table.php +++ b/src/database/migrations/2020_05_05_095212_create_tenants_table.php @@ -23,14 +23,34 @@ } ); - Schema::table( - 'users', - function (Blueprint $table) { - $table->bigInteger('tenant_id')->unsigned()->nullable(); + \App\Tenant::create(['title' => 'Kolab Now']); + + foreach (['users', 'discounts', 'domains', 'plans', 'packages', 'skus'] as $table_name) { + Schema::table( + $table_name, + function (Blueprint $table) { + $table->bigInteger('tenant_id')->unsigned()->nullable(); + $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); + } + ); - $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); + if ($tenant_id = \config('app.tenant_id')) { + DB::statement("UPDATE `{$table_name}` SET `tenant_id` = {$tenant_id}"); } - ); + } + + // Add fee column + foreach (['entitlements', 'skus'] as $table) { + Schema::table( + $table, + function (Blueprint $table) { + $table->integer('fee')->nullable(); + } + ); + } + + // FIXME: Should we also have package_skus.fee ? + // We have package_skus.cost, but I think it is not used anywhere. } /** @@ -40,13 +60,24 @@ */ public function down() { - Schema::table( - 'users', - function (Blueprint $table) { - $table->dropForeign(['tenant_id']); - $table->dropColumn('tenant_id'); - } - ); + foreach (['users', 'discounts', 'domains', 'plans', 'packages', 'skus'] as $table_name) { + Schema::table( + $table_name, + function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->dropColumn('tenant_id'); + } + ); + } + + foreach (['entitlements', 'skus'] as $table) { + Schema::table( + $table, + function (Blueprint $table) { + $table->dropColumn('fee'); + } + ); + } Schema::dropIfExists('tenants'); } diff --git a/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php b/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php --- a/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php +++ b/src/database/migrations/2021_01_26_150000_change_sku_descriptions.php @@ -15,14 +15,20 @@ public function up() { $beta_sku = \App\Sku::where('title', 'beta')->first(); - $beta_sku->name = 'Private Beta (invitation only)'; - $beta_sku->description = 'Access to the private beta program subscriptions'; - $beta_sku->save(); + + if ($beta_sku) { + $beta_sku->name = 'Private Beta (invitation only)'; + $beta_sku->description = 'Access to the private beta program subscriptions'; + $beta_sku->save(); + } $meet_sku = \App\Sku::where('title', 'meet')->first(); - $meet_sku->name = 'Voice & Video Conferencing (public beta)'; - $meet_sku->handler_class = 'App\Handlers\Meet'; - $meet_sku->save(); + + if ($meet_sku) { + $meet_sku->name = 'Voice & Video Conferencing (public beta)'; + $meet_sku->handler_class = 'App\Handlers\Meet'; + $meet_sku->save(); + } } /** diff --git a/src/database/migrations/2021_02_19_093832_discounts_add_tenant_id.php b/src/database/migrations/2021_02_19_093832_discounts_add_tenant_id.php deleted file mode 100644 --- a/src/database/migrations/2021_02_19_093832_discounts_add_tenant_id.php +++ /dev/null @@ -1,42 +0,0 @@ -bigInteger('tenant_id')->unsigned()->default(\config('app.tenant_id'))->nullable(); - - $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); - } - ); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::table( - 'discounts', - function (Blueprint $table) { - $table->dropForeign(['tenant_id']); - $table->dropColumn('tenant_id'); - } - ); - } -} diff --git a/src/database/migrations/2021_02_19_093833_domains_add_tenant_id.php b/src/database/migrations/2021_02_19_093833_domains_add_tenant_id.php deleted file mode 100644 --- a/src/database/migrations/2021_02_19_093833_domains_add_tenant_id.php +++ /dev/null @@ -1,42 +0,0 @@ -bigInteger('tenant_id')->unsigned()->default(\config('app.tenant_id'))->nullable(); - - $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); - } - ); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::table( - 'domains', - function (Blueprint $table) { - $table->dropForeign(['tenant_id']); - $table->dropColumn('tenant_id'); - } - ); - } -} diff --git a/src/database/migrations/2021_05_07_150000_groups_add_tenant_id.php b/src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php rename from src/database/migrations/2021_05_07_150000_groups_add_tenant_id.php rename to src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php --- a/src/database/migrations/2021_05_07_150000_groups_add_tenant_id.php +++ b/src/database/migrations/2021_05_12_150000_groups_add_tenant_id.php @@ -2,6 +2,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; // phpcs:ignore @@ -17,11 +18,14 @@ Schema::table( 'groups', function (Blueprint $table) { - $table->bigInteger('tenant_id')->unsigned()->default(\config('app.tenant_id'))->nullable(); - + $table->bigInteger('tenant_id')->unsigned()->nullable(); $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); } ); + + if ($tenant_id = \config('app.tenant_id')) { + DB::statement("UPDATE `groups` SET `tenant_id` = {$tenant_id}"); + } } /** diff --git a/src/database/seeds/local/TenantSeeder.php b/src/database/seeds/local/TenantSeeder.php --- a/src/database/seeds/local/TenantSeeder.php +++ b/src/database/seeds/local/TenantSeeder.php @@ -14,16 +14,16 @@ */ public function run() { - Tenant::create( - [ - 'title' => 'Kolab Now' - ] - ); + if (!Tenant::find(1)) { + Tenant::create([ + 'title' => 'Kolab Now' + ]); + } - Tenant::create( - [ - 'title' => 'Sample Tenant' - ] - ); + if (!Tenant::find(2)) { + Tenant::create([ + 'title' => 'Sample Tenant' + ]); + } } } diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php --- a/src/tests/Feature/Controller/Admin/WalletsTest.php +++ b/src/tests/Feature/Controller/Admin/WalletsTest.php @@ -76,6 +76,10 @@ $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $wallet = $user->wallets()->first(); $balance = $wallet->balance; + $reseller = $this->getTestUser('reseller@kolabnow.com'); + $wallet = $user->wallets()->first(); + $reseller_wallet = $reseller->wallets()->first(); + $reseller_balance = $reseller_wallet->balance; Transaction::where('object_id', $wallet->id) ->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY]) @@ -109,6 +113,7 @@ $this->assertSame('The bonus has been added to the wallet successfully.', $json['message']); $this->assertSame($balance += 5000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); + $this->assertSame($reseller_balance, $reseller_wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_AWARD)->first(); @@ -128,6 +133,7 @@ $this->assertSame('The penalty has been added to the wallet successfully.', $json['message']); $this->assertSame($balance -= 4000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); + $this->assertSame($reseller_balance, $reseller_wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_PENALTY)->first(); diff --git a/src/tests/Feature/Controller/Reseller/WalletsTest.php b/src/tests/Feature/Controller/Reseller/WalletsTest.php --- a/src/tests/Feature/Controller/Reseller/WalletsTest.php +++ b/src/tests/Feature/Controller/Reseller/WalletsTest.php @@ -94,11 +94,14 @@ $reseller1 = $this->getTestUser('reseller@kolabnow.com'); $reseller2 = $this->getTestUser('reseller@reseller.com'); $wallet = $user->wallets()->first(); + $reseller1_wallet = $reseller1->wallets()->first(); $balance = $wallet->balance; + $reseller1_balance = $reseller1_wallet->balance; Transaction::where('object_id', $wallet->id) ->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY]) ->delete(); + Transaction::where('object_id', $reseller1_wallet->id)->delete(); // Non-admin user $response = $this->actingAs($user)->post("api/v4/wallets/{$wallet->id}/one-off", []); @@ -125,7 +128,7 @@ $this->assertCount(2, $json); $this->assertCount(2, $json['errors']); - // Admin user - a valid bonus + // A valid bonus $post = ['amount' => '50', 'description' => 'A bonus']; $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); @@ -136,6 +139,7 @@ $this->assertSame('The bonus has been added to the wallet successfully.', $json['message']); $this->assertSame($balance += 5000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); + $this->assertSame($reseller1_balance -= 5000, $reseller1_wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_AWARD)->first(); @@ -144,7 +148,14 @@ $this->assertSame(5000, $transaction->amount); $this->assertSame($reseller1->email, $transaction->user_email); - // Admin user - a valid penalty + $transaction = Transaction::where('object_id', $reseller1_wallet->id) + ->where('type', Transaction::WALLET_DEBIT)->first(); + + $this->assertSame("Awarded user {$user->email}", $transaction->description); + $this->assertSame(-5000, $transaction->amount); + $this->assertSame($reseller1->email, $transaction->user_email); + + // A valid penalty $post = ['amount' => '-40', 'description' => 'A penalty']; $response = $this->actingAs($reseller1)->post("api/v4/wallets/{$wallet->id}/one-off", $post); $response->assertStatus(200); @@ -155,6 +166,7 @@ $this->assertSame('The penalty has been added to the wallet successfully.', $json['message']); $this->assertSame($balance -= 4000, $json['balance']); $this->assertSame($balance, $wallet->fresh()->balance); + $this->assertSame($reseller1_balance += 4000, $reseller1_wallet->fresh()->balance); $transaction = Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_PENALTY)->first(); @@ -163,6 +175,13 @@ $this->assertSame(-4000, $transaction->amount); $this->assertSame($reseller1->email, $transaction->user_email); + $transaction = Transaction::where('object_id', $reseller1_wallet->id) + ->where('type', Transaction::WALLET_CREDIT)->first(); + + $this->assertSame("Penalized user {$user->email}", $transaction->description); + $this->assertSame(4000, $transaction->amount); + $this->assertSame($reseller1->email, $transaction->user_email); + // Reseller from a different tenant \config(['app.tenant_id' => 2]); $response = $this->actingAs($reseller2)->post("api/v4/wallets/{$wallet->id}/one-off", []); 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 @@ -82,7 +82,6 @@ ); $domain->assignPackage($packageDomain, $owner); - $owner->assignPackage($packageKolab); $owner->assignPackage($packageKolab, $user); @@ -128,57 +127,4 @@ $this->assertEquals($user->id, $entitlement->entitleable->id); $this->assertTrue($entitlement->entitleable instanceof \App\User); } - - /** - * @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::where('title', 'kolab')->first(); - - $storage = \App\Sku::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 2 original storage and the additional 4 - $this->assertCount(8, $user->fresh()->entitlements); - - $wallet = $user->wallets()->first(); - - $backdate = Carbon::now()->subWeeks(7); - $this->backdateEntitlements($user->entitlements, $backdate); - - $charge = $wallet->chargeEntitlements(); - - $this->assertSame(-1099, $wallet->balance); - - $balance = $wallet->balance; - $discount = \App\Discount::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); - } } diff --git a/src/tests/Feature/PlanTest.php b/src/tests/Feature/PlanTest.php --- a/src/tests/Feature/PlanTest.php +++ b/src/tests/Feature/PlanTest.php @@ -106,4 +106,19 @@ $this->assertTrue($plan->cost() == $package_costs); } + + public function testTenant(): void + { + $plan = Plan::where('title', 'individual')->first(); + + $tenant = $plan->tenant()->first(); + + $this->assertInstanceof(\App\Tenant::class, $tenant); + $this->assertSame(1, $tenant->id); + + $tenant = $plan->tenant; + + $this->assertInstanceof(\App\Tenant::class, $tenant); + $this->assertSame(1, $tenant->id); + } } diff --git a/src/tests/Feature/SkuTest.php b/src/tests/Feature/SkuTest.php --- a/src/tests/Feature/SkuTest.php +++ b/src/tests/Feature/SkuTest.php @@ -91,4 +91,19 @@ $entitlement->entitleable_type ); } + + public function testSkuTenant(): void + { + $sku = Sku::where('title', 'storage')->first(); + + $tenant = $sku->tenant()->first(); + + $this->assertInstanceof(\App\Tenant::class, $tenant); + $this->assertSame(1, $tenant->id); + + $tenant = $sku->tenant; + + $this->assertInstanceof(\App\Tenant::class, $tenant); + $this->assertSame(1, $tenant->id); + } } diff --git a/src/tests/Feature/TenantTest.php b/src/tests/Feature/TenantTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/TenantTest.php @@ -0,0 +1,40 @@ +first(); + + $wallet = $tenant->wallet(); + + $this->assertInstanceof(\App\Wallet::class, $wallet); + $this->assertSame($user->wallets->first()->id, $wallet->id); + } +} diff --git a/src/tests/Feature/UserTest.php b/src/tests/Feature/UserTest.php --- a/src/tests/Feature/UserTest.php +++ b/src/tests/Feature/UserTest.php @@ -464,6 +464,51 @@ Queue::assertPushed(\App\Jobs\Group\UpdateJob::class, 2); } + /** + * Test handling negative balance on user deletion + */ + public function testDeleteWithNegativeBalance(): void + { + $user = $this->getTestUser('user-test@' . \config('app.domain')); + $wallet = $user->wallets()->first(); + $wallet->balance = -1000; + $wallet->save(); + $reseller_wallet = $user->tenant->wallet(); + $reseller_wallet->balance = 0; + $reseller_wallet->save(); + \App\Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); + + $user->delete(); + + $reseller_transactions = \App\Transaction::where('object_id', $reseller_wallet->id) + ->where('object_type', \App\Wallet::class)->get(); + + $this->assertSame(-1000, $reseller_wallet->fresh()->balance); + $this->assertCount(1, $reseller_transactions); + $trans = $reseller_transactions[0]; + $this->assertSame("Deleted user {$user->email}", $trans->description); + $this->assertSame(-1000, $trans->amount); + $this->assertSame(\App\Transaction::WALLET_DEBIT, $trans->type); + } + + /** + * Test handling positive balance on user deletion + */ + public function testDeleteWithPositiveBalance(): void + { + $user = $this->getTestUser('user-test@' . \config('app.domain')); + $wallet = $user->wallets()->first(); + $wallet->balance = 1000; + $wallet->save(); + $reseller_wallet = $user->tenant->wallet(); + $reseller_wallet->balance = 0; + $reseller_wallet->save(); + + $user->delete(); + + $this->assertSame(0, $reseller_wallet->fresh()->balance); + } + /** * Tests for User::aliasExists() */ 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 @@ -5,8 +5,10 @@ use App\Package; use App\User; use App\Sku; +use App\Transaction; use App\Wallet; use Carbon\Carbon; +use Illuminate\Support\Facades\DB; use Tests\TestCase; class WalletTest extends TestCase @@ -39,6 +41,8 @@ $this->deleteTestUser($user); } + Sku::select()->update(['fee' => 0]); + parent::tearDown(); } @@ -279,4 +283,116 @@ $this->assertCount(0, $userB->accounts); } + + /** + * Test for charging and removing entitlements (including tenant commission calculations) + */ + public function testChargeAndDeleteEntitlements(): void + { + $user = $this->getTestUser('jane@kolabnow.com'); + $wallet = $user->wallets()->first(); + $discount = \App\Discount::where('discount', 30)->first(); + $wallet->discount()->associate($discount); + $wallet->save(); + + // Add 40% fee to all SKUs + Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); + + $package = Package::where('title', 'kolab')->first(); + $storage = Sku::where('title', 'storage')->first(); + $user->assignPackage($package); + $user->assignSku($storage, 2); + $user->refresh(); + + // Reset reseller's wallet balance and transactions + $reseller_wallet = $user->tenant->wallet(); + $reseller_wallet->balance = 0; + $reseller_wallet->save(); + Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); + + // ------------------------------------ + // Test normal charging of entitlements + // ------------------------------------ + + // Backdate and chanrge entitlements, we're expecting one month to be charged + // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month + Carbon::setTestNow(Carbon::create(2021, 5, 21, 12)); + $backdate = Carbon::now()->subWeeks(7); + $this->backdateEntitlements($user->entitlements, $backdate); + $charge = $wallet->chargeEntitlements(); + $wallet->refresh(); + $reseller_wallet->refresh(); + + // 388 + 310 + 17 + 17 = 732 + $this->assertSame(-732, $wallet->balance); + // 388 - 555 x 40% + 310 - 444 x 40% + 34 - 50 x 40% = 312 + $this->assertSame(312, $reseller_wallet->balance); + + $transactions = Transaction::where('object_id', $wallet->id) + ->where('object_type', \App\Wallet::class)->get(); + $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) + ->where('object_type', \App\Wallet::class)->get(); + + $this->assertCount(1, $reseller_transactions); + $trans = $reseller_transactions[0]; + $this->assertSame("Charged user jane@kolabnow.com", $trans->description); + $this->assertSame(312, $trans->amount); + $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); + + $this->assertCount(1, $transactions); + $trans = $transactions[0]; + $this->assertSame('', $trans->description); + $this->assertSame(-732, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + + // TODO: Test entitlement transaction records + + // ----------------------------------- + // Test charging on entitlement delete + // ----------------------------------- + + $transactions = Transaction::where('object_id', $wallet->id) + ->where('object_type', \App\Wallet::class)->delete(); + $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) + ->where('object_type', \App\Wallet::class)->delete(); + + $user->removeSku($storage, 2); + + // we expect the wallet to have been charged for 19 days of use of + // 2 deleted storage entitlements + $wallet->refresh(); + $reseller_wallet->refresh(); + + // 2 x round(25 / 31 * 19 * 0.7) = 22 + $this->assertSame(-(732 + 22), $wallet->balance); + // 22 - 2 x round(25 * 0.4 / 31 * 19) = 10 + $this->assertSame(312 + 10, $reseller_wallet->balance); + + $transactions = Transaction::where('object_id', $wallet->id) + ->where('object_type', \App\Wallet::class)->get(); + $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) + ->where('object_type', \App\Wallet::class)->get(); + + $this->assertCount(2, $reseller_transactions); + $trans = $reseller_transactions[0]; + $this->assertSame("Charged user jane@kolabnow.com", $trans->description); + $this->assertSame(5, $trans->amount); + $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); + $trans = $reseller_transactions[1]; + $this->assertSame("Charged user jane@kolabnow.com", $trans->description); + $this->assertSame(5, $trans->amount); + $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); + + $this->assertCount(2, $transactions); + $trans = $transactions[0]; + $this->assertSame('', $trans->description); + $this->assertSame(-11, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + $trans = $transactions[1]; + $this->assertSame('', $trans->description); + $this->assertSame(-11, $trans->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); + + // TODO: Test entitlement transaction records + } } diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -11,14 +11,24 @@ protected function backdateEntitlements($entitlements, $targetDate) { + $wallets = []; + $ids = []; + foreach ($entitlements as $entitlement) { - $entitlement->created_at = $targetDate; - $entitlement->updated_at = $targetDate; - $entitlement->save(); + $ids[] = $entitlement->id; + $wallets[] = $entitlement->wallet_id; + } + + \App\Entitlement::whereIn('id', $ids)->update([ + 'created_at' => $targetDate, + 'updated_at' => $targetDate, + ]); + + if (!empty($wallets)) { + $wallets = array_unique($wallets); + $owners = \App\Wallet::whereIn('id', $wallets)->pluck('user_id')->all(); - $owner = $entitlement->wallet->owner; - $owner->created_at = $targetDate; - $owner->save(); + \App\User::whereIn('id', $owners)->update(['created_at' => $targetDate]); } }