diff --git a/src/app/Console/Commands/DomainList.php b/src/app/Console/Commands/DomainDelete.php similarity index 63% copy from src/app/Console/Commands/DomainList.php copy to src/app/Console/Commands/DomainDelete.php index 03b56a79..a1d67a89 100644 --- a/src/app/Console/Commands/DomainList.php +++ b/src/app/Console/Commands/DomainDelete.php @@ -1,47 +1,48 @@ orderBy('namespace')->each( - function ($domain) { - $this->info($domain->namespace); - } - ); + $domain = \App\Domain::where('id', $this->argument('domain'))->first(); + + if (!$domain) { + return 1; + } + + $domain->delete(); } } diff --git a/src/app/Console/Commands/DomainList.php b/src/app/Console/Commands/DomainList.php index 03b56a79..4ca0ecf3 100644 --- a/src/app/Console/Commands/DomainList.php +++ b/src/app/Console/Commands/DomainList.php @@ -1,47 +1,59 @@ orderBy('namespace')->each( + if ($this->option('deleted')) { + $domains = Domain::withTrashed()->orderBy('namespace'); + } else { + $domains = Domain::orderBy('namespace'); + } + + $domains->each( function ($domain) { - $this->info($domain->namespace); + $msg = $domain->namespace; + + if ($domain->deleted_at) { + $msg .= " (deleted at {$domain->deleted_at})"; + } + + $this->info($msg); } ); } } diff --git a/src/app/Console/Commands/DomainListUsers.php b/src/app/Console/Commands/DomainListUsers.php new file mode 100644 index 00000000..833dcd1b --- /dev/null +++ b/src/app/Console/Commands/DomainListUsers.php @@ -0,0 +1,83 @@ +argument('domain'))->first(); + + if (!$domain) { + return 1; + } + + if ($domain->isPublic()) { + $this->error("This domain is a public registration domain."); + return 1; + } + + // TODO: actually implement listing users + $wallet = $domain->wallet(); + + if (!$wallet) { + $this->error("This domain isn't billed to a wallet."); + return 1; + } + + $mailboxSKU = \App\Sku::where('title', 'mailbox')->first(); + + if (!$mailboxSKU) { + $this->error("No mailbox SKU available."); + } + + $entitlements = $wallet->entitlements() + ->where('entitleable_type', \App\User::class) + ->where('sku_id', $mailboxSKU->id)->get(); + + $users = []; + + foreach ($entitlements as $entitlement) { + $users[] = $entitlement->entitleable; + } + + usort($users, function ($a, $b) { + return $a->email > $b->email; + }); + + foreach ($users as $user) { + $this->info($user->email); + } + } +} diff --git a/src/app/Console/Commands/DomainList.php b/src/app/Console/Commands/UserDelete.php similarity index 63% copy from src/app/Console/Commands/DomainList.php copy to src/app/Console/Commands/UserDelete.php index 03b56a79..780a01fc 100644 --- a/src/app/Console/Commands/DomainList.php +++ b/src/app/Console/Commands/UserDelete.php @@ -1,47 +1,48 @@ orderBy('namespace')->each( - function ($domain) { - $this->info($domain->namespace); - } - ); + $user = \App\User::where('email', $this->argument('user'))->first(); + + if (!$user) { + return 1; + } + + $user->delete(); } } diff --git a/src/app/Console/Commands/UserDomains.php b/src/app/Console/Commands/UserDomains.php index 9aaa15e5..17ebf5f0 100644 --- a/src/app/Console/Commands/UserDomains.php +++ b/src/app/Console/Commands/UserDomains.php @@ -1,51 +1,53 @@ argument('userid'))->first(); - $this->info("Found user: {$user->id}"); + if (!$user) { + return 1; + } foreach ($user->domains() as $domain) { - $this->info("Domain: {$domain->namespace}"); + $this->info("{$domain->namespace}"); } } } diff --git a/src/app/Console/Commands/UserEntitlements.php b/src/app/Console/Commands/UserEntitlements.php index e103bb75..8d8d34ed 100644 --- a/src/app/Console/Commands/UserEntitlements.php +++ b/src/app/Console/Commands/UserEntitlements.php @@ -1,61 +1,65 @@ argument('userid'))->first(); + if (!$user) { + return 1; + } + $this->info("Found user: {$user->id}"); $skus_counted = []; foreach ($user->entitlements as $entitlement) { if (!array_key_exists($entitlement->sku_id, $skus_counted)) { $skus_counted[$entitlement->sku_id] = 1; } else { $skus_counted[$entitlement->sku_id] += 1; } } foreach ($skus_counted as $id => $qty) { $sku = Sku::find($id); $this->info("SKU: {$sku->title} ({$qty})"); } } } diff --git a/src/app/Console/Commands/WalletBalances.php b/src/app/Console/Commands/WalletBalances.php new file mode 100644 index 00000000..8264fd3a --- /dev/null +++ b/src/app/Console/Commands/WalletBalances.php @@ -0,0 +1,65 @@ +each( + function ($wallet) { + if ($wallet->balance == 0) { + return; + } + + $user = \App\User::where('id', $wallet->user_id)->first(); + + if (!$user) { + return; + } + + $this->info( + sprintf( + "%s: %8s (account: %s/%s (%s))", + $wallet->id, + $wallet->balance, + "https://kolabnow.com/cockpit/admin/accounts/show", + $user->id, + $user->email + ) + ); + } + ); + } +} diff --git a/src/app/Console/Commands/WalletExpected.php b/src/app/Console/Commands/WalletExpected.php index 1802c976..74e7e926 100644 --- a/src/app/Console/Commands/WalletExpected.php +++ b/src/app/Console/Commands/WalletExpected.php @@ -1,51 +1,76 @@ option('user')) { + $user = \App\User::where('email', $this->option('user')) + ->orWhere('id', $this->option('user'))->first(); + + if (!$user) { + return 1; + } + + $wallets = $user->wallets; + } else { + $wallets = \App\Wallet::all(); + } foreach ($wallets as $wallet) { $charge = 0; $expected = $wallet->expectedCharges(); - if ($expected > 0) { - $this->info("expect charging wallet {$wallet->id} for user {$wallet->owner->email} with {$expected}"); + if (!$wallet->owner) { + \Log::debug("{$wallet->id} has no owner: {$wallet->user_id}"); + continue; } + + if ($this->option('non-zero') && $expected < 1) { + continue; + } + + $this->info( + sprintf( + "expect charging wallet %s for user %s with %d", + $wallet->id, + $wallet->owner->email, + $expected + ) + ); } } } diff --git a/src/app/Console/Commands/DomainList.php b/src/app/Console/Commands/WalletGetBalance.php similarity index 62% copy from src/app/Console/Commands/DomainList.php copy to src/app/Console/Commands/WalletGetBalance.php index 03b56a79..27f2fc25 100644 --- a/src/app/Console/Commands/DomainList.php +++ b/src/app/Console/Commands/WalletGetBalance.php @@ -1,47 +1,48 @@ orderBy('namespace')->each( - function ($domain) { - $this->info($domain->namespace); - } - ); + $wallet = \App\Wallet::find($this->argument('wallet')); + + if (!$wallet) { + return 1; + } + + $this->info($wallet->balance); } } diff --git a/src/app/Console/Commands/UserDomains.php b/src/app/Console/Commands/WalletGetDiscount.php similarity index 54% copy from src/app/Console/Commands/UserDomains.php copy to src/app/Console/Commands/WalletGetDiscount.php index 9aaa15e5..6058f84a 100644 --- a/src/app/Console/Commands/UserDomains.php +++ b/src/app/Console/Commands/WalletGetDiscount.php @@ -1,51 +1,53 @@ argument('userid'))->first(); + $wallet = \App\Wallet::find($this->argument('wallet')); - $this->info("Found user: {$user->id}"); + if (!$wallet) { + return 1; + } - foreach ($user->domains() as $domain) { - $this->info("Domain: {$domain->namespace}"); + if (!$wallet->discount) { + $this->info("No discount on this wallet."); + return 0; } + + $this->info($wallet->discount->discount); } } diff --git a/src/app/Console/Commands/DomainList.php b/src/app/Console/Commands/WalletSetBalance.php similarity index 58% copy from src/app/Console/Commands/DomainList.php copy to src/app/Console/Commands/WalletSetBalance.php index 03b56a79..62106119 100644 --- a/src/app/Console/Commands/DomainList.php +++ b/src/app/Console/Commands/WalletSetBalance.php @@ -1,47 +1,49 @@ orderBy('namespace')->each( - function ($domain) { - $this->info($domain->namespace); - } - ); + $wallet = \App\Wallet::find($this->argument('wallet')); + + if (!$wallet) { + return 1; + } + + $wallet->balance = (int)($this->argument('balance')); + $wallet->save(); } } diff --git a/src/app/Console/Commands/WalletSetDiscount.php b/src/app/Console/Commands/WalletSetDiscount.php new file mode 100644 index 00000000..79e0b45d --- /dev/null +++ b/src/app/Console/Commands/WalletSetDiscount.php @@ -0,0 +1,62 @@ +argument('wallet'))->first(); + + if (!$wallet) { + return 1; + } + + // FIXME: Using '0' for delete might be not that obvious + + if ($this->argument('discount') === '0') { + $wallet->discount()->dissociate(); + } else { + $discount = \App\Discount::find($this->argument('discount')); + + if (!$discount) { + return 1; + } + + $wallet->discount()->associate($discount); + } + + $wallet->save(); + } +} diff --git a/src/app/Discount.php b/src/app/Discount.php index f0362cc4..32c6da87 100644 --- a/src/app/Discount.php +++ b/src/app/Discount.php @@ -1,59 +1,65 @@ 'integer', ]; protected $fillable = [ 'active', 'code', 'description', 'discount', ]; /** @var array Translatable properties */ public $translatable = [ 'description', ]; /** * Discount value mutator * * @throws \Exception */ public function setDiscountAttribute($discount) { $discount = (int) $discount; - if ($discount < 0 || $discount > 100) { - throw new \Exception("Invalid discount value, expected integer in range of 0-100"); + if ($discount < 0) { + \Log::warning("Expecting a discount rate >= 0"); + $discount = 0; + } + + if ($discount > 100) { + \Log::warning("Expecting a discount rate <= 100"); + $discount = 100; } $this->attributes['discount'] = $discount; } /** * List of wallets with this discount assigned. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } } diff --git a/src/app/Domain.php b/src/app/Domain.php index 87a63d0a..ece91c56 100644 --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -1,383 +1,406 @@ isPublic()) { + return $this; + } + + // See if this domain is already owned by another user. + $wallet = $this->wallet(); + + if ($wallet) { + \Log::error( + "Domain {$this->namespace} is already assigned to {$wallet->owner->email}" + ); + + return $this; + } + $wallet_id = $user->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $this->id, 'entitleable_type' => Domain::class ] ); } } return $this; } - public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Return list of public+active domain names */ public static function getPublicDomains(): array { $where = sprintf('(type & %s)', Domain::TYPE_PUBLIC); return self::whereRaw($where)->get(['namespace'])->pluck('namespace')->toArray(); } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is confirmed the ownership of. * * @return bool */ public function isConfirmed(): bool { return ($this->status & self::STATUS_CONFIRMED) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this domain is registered with us. * * @return bool */ public function isExternal(): bool { return ($this->type & self::TYPE_EXTERNAL) > 0; } /** * Returns whether this domain is hosted with us. * * @return bool */ public function isHosted(): bool { return ($this->type & self::TYPE_HOSTED) > 0; } /** * Returns whether this domain is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is public. * * @return bool */ public function isPublic(): bool { return ($this->type & self::TYPE_PUBLIC) > 0; } /** * Returns whether this domain is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isVerified(): bool { return ($this->status & self::STATUS_VERIFIED) > 0; } + /** + * Ensure the namespace is appropriately cased. + */ + public function setNamespaceAttribute($namespace) + { + $this->attributes['namespace'] = strtolower($namespace); + } + /** * Domain status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_CONFIRMED, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_VERIFIED, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid domain status: {$status}"); } $this->attributes['status'] = $new_status; } /** * Ownership verification by checking for a TXT (or CNAME) record * in the domain's DNS (that matches the verification hash). * * @return bool True if verification was successful, false otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function confirm(): bool { if ($this->isConfirmed()) { return true; } $hash = $this->hash(self::HASH_TEXT); $confirmed = false; // Get DNS records and find a matching TXT entry $records = \dns_get_record($this->namespace, DNS_TXT); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $record) { if ($record['txt'] === $hash) { $confirmed = true; break; } } // Get DNS records and find a matching CNAME entry // Note: some servers resolve every non-existing name // so we need to define left and right side of the CNAME record // i.e.: kolab-verify IN CNAME .domain.tld. if (!$confirmed) { $cname = $this->hash(self::HASH_CODE) . '.' . $this->namespace; $records = \dns_get_record('kolab-verify.' . $this->namespace, DNS_CNAME); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } foreach ($records as $records) { if ($records['target'] === $cname) { $confirmed = true; break; } } } if ($confirmed) { $this->status |= Domain::STATUS_CONFIRMED; $this->save(); } return $confirmed; } /** * Generate a verification hash for this domain * * @param int $mod One of: HASH_CNAME, HASH_CODE (Default), HASH_TEXT * * @return string Verification hash */ public function hash($mod = null): string { $cname = 'kolab-verify'; if ($mod === self::HASH_CNAME) { return $cname; } $hash = \md5('hkccp-verify-' . $this->namespace); return $mod === self::HASH_TEXT ? "$cname=$hash" : $hash; } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= Domain::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= Domain::STATUS_SUSPENDED; $this->save(); } /** * Verify if a domain exists in DNS * * @return bool True if registered, False otherwise * @throws \Exception Throws exception on DNS or DB errors */ public function verify(): bool { if ($this->isVerified()) { return true; } $records = \dns_get_record($this->namespace, DNS_ANY); if ($records === false) { throw new \Exception("Failed to get DNS record for {$this->namespace}"); } // It may happen that result contains other domains depending on the host DNS setup // that's why in_array() and not just !empty() if (in_array($this->namespace, array_column($records, 'host'))) { $this->status |= Domain::STATUS_VERIFIED; $this->save(); return true; } return false; } /** * Returns the wallet by which the domain is controlled * * @return \App\Wallet A wallet object */ public function wallet(): ?Wallet { // Note: Not all domains have a entitlement/wallet $entitlement = $this->entitlement()->first(); return $entitlement ? $entitlement->wallet : null; } } diff --git a/src/tests/Feature/DiscountTest.php b/src/tests/Feature/DiscountTest.php index 4ceba461..62e31014 100644 --- a/src/tests/Feature/DiscountTest.php +++ b/src/tests/Feature/DiscountTest.php @@ -1,31 +1,31 @@ expectException(\Exception::class); - $discount = new Discount(); $discount->discount = -1; + + $this->assertTrue($discount->discount == 0); } /** * Test setting discount value */ public function testDiscountValueMoreThanHundred(): void { - $this->expectException(\Exception::class); - $discount = new Discount(); $discount->discount = 101; + + $this->assertTrue($discount->discount == 100); } }