diff --git a/src/app/Changelog.php b/src/app/Changelog.php new file mode 100644 --- /dev/null +++ b/src/app/Changelog.php @@ -0,0 +1,15 @@ +expectedCharges(); + + if ($charge > 0) { + $this->info("charging wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}"); + + foreach ($wallet->entitlements as $entitlement) { + $entitlement->chargeWallet(); + } + } + } + } +} diff --git a/src/app/Console/Commands/WalletExpected.php b/src/app/Console/Commands/WalletExpected.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/WalletExpected.php @@ -0,0 +1,55 @@ +expectedCharges(); + + if ($expected > 0) { + $this->info("expect charging wallet {$wallet->id} for user {$wallet->owner->email} with {$expected}"); + } + } + } +} diff --git a/src/app/Domain.php b/src/app/Domain.php --- a/src/app/Domain.php +++ b/src/app/Domain.php @@ -68,6 +68,7 @@ 'owner_id' => $user->id, 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, + 'cost' => $sku->pivot->cost(), '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 @@ -2,7 +2,9 @@ namespace App; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; /** * The eloquent definition of an Entitlement. @@ -16,6 +18,8 @@ */ class Entitlement extends Model { + use SoftDeletes; + /** * This table does not use auto-increment. * @@ -41,9 +45,51 @@ 'wallet_id', 'entitleable_id', 'entitleable_type', + 'cost', 'description' ]; + public function changelogEntry() + { + return \App\Changelog::where('entitlement_id', $this->id)->latest()->first(); + } + + public function chargeWallet() + { + $charges = 0; + + $entry = $this->changelogEntry(); + + \Log::debug("entry: " . var_export($entry->toArray(), true)); + + if ($entry->updated_at >= Carbon::now()->subDays(14)) { + \Log::debug(">= 14 days ago, skipping"); + return $charges; + } + + if ($entry->updated_at > Carbon::now()->subMonths(1)) { + \Log::debug("> 1 month ago, skipping"); + return $charges; + } + + $diff = $entry->updated_at->diffInMonths(Carbon::now()); + + if ($this->cost < 1) { + \Log::debug("{$this->sku->title} is a free entitlement, skipping"); + $entry->updated_at = $entry->updated_at->addMonths($diff); + $entry->save(); + return $charges; + } + + $charges += $this->cost * $diff; + + $this->wallet->debit($this->cost * $diff); + $entry->updated_at = $entry->updated_at->addMonths($diff); + $entry->save(); + + return $charges; + } + /** * Principally entitleable objects such as 'Domain' or 'User'. * diff --git a/src/app/Handlers/Domain.php b/src/app/Handlers/Domain.php --- a/src/app/Handlers/Domain.php +++ b/src/app/Handlers/Domain.php @@ -2,8 +2,6 @@ namespace App\Handlers; -use App\Sku; - class Domain extends \App\Handlers\Base { public static function entitleableClass() diff --git a/src/app/Handlers/DomainHosting.php b/src/app/Handlers/DomainHosting.php --- a/src/app/Handlers/DomainHosting.php +++ b/src/app/Handlers/DomainHosting.php @@ -2,8 +2,6 @@ namespace App\Handlers; -use App\Sku; - class DomainHosting extends \App\Handlers\Base { public static function entitleableClass() diff --git a/src/app/Handlers/DomainRegistration.php b/src/app/Handlers/DomainRegistration.php --- a/src/app/Handlers/DomainRegistration.php +++ b/src/app/Handlers/DomainRegistration.php @@ -2,8 +2,6 @@ namespace App\Handlers; -use App\Sku; - class DomainRegistration extends \App\Handlers\Base { public static function entitleableClass() diff --git a/src/app/Handlers/Groupware.php b/src/app/Handlers/Groupware.php --- a/src/app/Handlers/Groupware.php +++ b/src/app/Handlers/Groupware.php @@ -2,8 +2,6 @@ namespace App\Handlers; -use App\Sku; - class Groupware extends \App\Handlers\Base { public static function entitleableClass() diff --git a/src/app/Handlers/Mailbox.php b/src/app/Handlers/Mailbox.php --- a/src/app/Handlers/Mailbox.php +++ b/src/app/Handlers/Mailbox.php @@ -2,10 +2,6 @@ namespace App\Handlers; -use App\Entitlement; -use App\Sku; -use App\User; - class Mailbox extends \App\Handlers\Base { public static function entitleableClass() diff --git a/src/app/Handlers/Resource.php b/src/app/Handlers/Resource.php --- a/src/app/Handlers/Resource.php +++ b/src/app/Handlers/Resource.php @@ -2,8 +2,6 @@ namespace App\Handlers; -use App\Sku; - class Resource extends \App\Handlers\Base { public static function entitleableClass() diff --git a/src/app/Handlers/SharedFolder.php b/src/app/Handlers/SharedFolder.php --- a/src/app/Handlers/SharedFolder.php +++ b/src/app/Handlers/SharedFolder.php @@ -2,8 +2,6 @@ namespace App\Handlers; -use App\Sku; - class SharedFolder extends \App\Handlers\Base { public static function entitleableClass() diff --git a/src/app/Handlers/Storage.php b/src/app/Handlers/Storage.php --- a/src/app/Handlers/Storage.php +++ b/src/app/Handlers/Storage.php @@ -6,7 +6,7 @@ { public static function entitleableClass() { - return null; + return \App\User::class; } public static function preReq($entitlement, $object) 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 @@ -3,6 +3,7 @@ namespace App\Observers; use App\Entitlement; +use Carbon\Carbon; /** * This is an observer for the Entitlement model definition. @@ -62,11 +63,15 @@ if (!$result) { return false; } + } - // TODO: Handle the first free unit here? - - // TODO: Execute the Sku handler class or function? + public function created(Entitlement $entitlement) + { + \App\Changelog::create(['entitlement_id' => $entitlement->id]); + } - $wallet->debit($sku->cost); + public function deleting(Entitlement $entitlement) + { + \App\Changelog::where('entitlement_id', $entitlement->id)->delete(); } } diff --git a/src/app/Observers/PackageSkuObserver.php b/src/app/Observers/PackageSkuObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/PackageSkuObserver.php @@ -0,0 +1,23 @@ +package; + $sku = $packageSku->sku; + + $cost = ($sku->cost * (100 - $package->discount_rate)) / 100; + + \Log::debug("Setting costs for {$sku->title} on package {$package->title} to {$cost}"); + + $package->skus()->updateExistingPivot( + $sku, + ['cost' => ($sku->cost * (100 - $package->discount_rate)) / 100], + false + ); + } +} 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 @@ -79,6 +79,8 @@ */ public function deleting(User $user) { + \Log::debug("where deleting user {$user->email}"); + // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. $entitlements = \App\Entitlement::where('entitleable_id', $user->id) diff --git a/src/app/Package.php b/src/app/Package.php --- a/src/app/Package.php +++ b/src/app/Package.php @@ -34,12 +34,26 @@ 'discount_rate' ]; + /** + * The costs of this package at its pre-defined, existing configuration. + * + * @return int The costs in cents. + */ public function cost() { $costs = 0; foreach ($this->skus as $sku) { - $costs += ($sku->pivot->qty - $sku->units_free) * $sku->cost; + $units = $sku->pivot->qty - $sku->units_free; + + if ($units < 0) { + \Log::debug("Package {$this->id} is misconfigured for more free units than qty."); + $units = 0; + } + + $ppu = $sku->cost * ((100 - $this->discount_rate) / 100); + + $costs += $units * $ppu; } return $costs; diff --git a/src/app/PackageSku.php b/src/app/PackageSku.php --- a/src/app/PackageSku.php +++ b/src/app/PackageSku.php @@ -12,10 +12,48 @@ protected $fillable = [ 'package_id', 'sku_id', + 'cost', 'qty' ]; protected $casts = [ + 'cost' => 'integer', 'qty' => 'integer' ]; + + /** + * Under this package, how much does this SKU cost? + * + * @return int The costs of this SKU under this package in cents. + */ + 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; + } + + $ppu = $this->sku->cost * ((100 - $this->package->discount_rate) / 100); + + $costs += $units * $ppu; + + return $costs; + } + + public function package() + { + return $this->belongsTo('App\Package'); + } + + 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 @@ -49,7 +49,11 @@ 'description', ]; - + /** + * The list price for this package at the minimum configuration. + * + * @return int The costs in cents. + */ public function cost() { $costs = 0; 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 @@ -28,6 +28,7 @@ \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\Package::observe(\App\Observers\PackageObserver::class); + \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\Plan::observe(\App\Observers\PlanObserver::class); \App\SignupCode::observe(\App\Observers\SignupCodeObserver::class); \App\Sku::observe(\App\Observers\SkuObserver::class); diff --git a/src/app/Sku.php b/src/app/Sku.php --- a/src/app/Sku.php +++ b/src/app/Sku.php @@ -21,6 +21,7 @@ 'description', 'cost', 'units_free', + // persist for annual domain registration 'period', 'handler_class', 'active' @@ -41,6 +42,6 @@ return $this->belongsToMany( 'App\Package', 'package_skus' - )->using('App\PackageSku')->withPivot(['qty']); + )->using('App\PackageSku')->withPivot(['cost', 'qty']); } } diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -118,6 +118,7 @@ 'owner_id' => $this->id, 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, + 'cost' => $sku->pivot->cost(), 'entitleable_id' => $user->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 @@ -3,6 +3,7 @@ namespace App; use App\User; +use Carbon\Carbon; use Iatstuti\Database\Support\NullableFields; use Illuminate\Database\Eloquent\Model; @@ -55,6 +56,44 @@ } } + /** + * Calculate the expected charges to this wallet. + * + * @return int + */ + public function expectedCharges() + { + $charges = 0; + + foreach ($this->entitlements()->get()->fresh() as $entitlement) { + $entry = \App\Changelog::withTrashed() + ->where('entitlement_id', $entitlement->id)->latest()->first(); + + // created less than or equal to 14 days ago (this is the fourteenth 24-hour period) + if ($entry->updated_at >= Carbon::now()->subDays(14)) { + continue; + } + + // created less than or equal to a month ago, can't have been billed + if ($entry->updated_at >= Carbon::now()->subMonths(1)) { + $charges += $entitlement->cost; + } + + // created more than a month ago -- was it billed? + if ($entry->updated_at < Carbon::now()->subMonths(1)) { + $diff = $entry->updated_at->diffInMonths(Carbon::now()); + $charges += $entitlement->cost * $diff; + } + + if ($entry->deleted_at) { + // TODO + \Log::debug("should consider deleted_at"); + } + } + + return $charges; + } + /** * Remove a controller from this wallet. * @@ -94,6 +133,8 @@ */ public function debit(float $amount) { + \Log::debug("debitting wallet {$this->id} with {$amount}"); + $this->balance -= $amount; $this->save(); diff --git a/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php b/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php --- a/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php +++ b/src/database/migrations/2019_09_17_102628_create_sku_entitlements.php @@ -36,6 +36,7 @@ $table->bigInteger('owner_id'); $table->bigInteger('entitleable_id'); $table->string('entitleable_type'); + $table->integer('cost')->default(0)->nullable(); $table->string('wallet_id', 36); $table->string('sku_id', 36); $table->string('description')->nullable(); 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 @@ -4,6 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; +// phpcs:ignore class CreatePackageSkusTable extends Migration { /** @@ -21,6 +22,8 @@ $table->string('sku_id', 36); $table->integer('qty')->default(1); + $table->integer('cost')->default(0)->nullable(); + $table->foreign('package_id')->references('id')->on('packages') ->onDelete('cascade')->onUpdate('cascade'); diff --git a/src/database/migrations/2020_02_11_110959_add_deleted_at.php b/src/database/migrations/2020_02_11_110959_users_add_deleted_at.php rename from src/database/migrations/2020_02_11_110959_add_deleted_at.php rename to src/database/migrations/2020_02_11_110959_users_add_deleted_at.php --- a/src/database/migrations/2020_02_11_110959_add_deleted_at.php +++ b/src/database/migrations/2020_02_11_110959_users_add_deleted_at.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class AddDeletedAt extends Migration +// phpcs:ignore +class UsersAddDeletedAt extends Migration { /** * Run the migrations. @@ -13,13 +14,6 @@ */ public function up() { - Schema::table( - 'domains', - function (Blueprint $table) { - $table->softDeletes(); - } - ); - Schema::table( 'users', function (Blueprint $table) { @@ -35,12 +29,6 @@ */ public function down() { - Schema::table( - 'domains', - function (Blueprint $table) { - $table->dropColumn(['deleted_at']); - } - ); Schema::table( 'users', function (Blueprint $table) { diff --git a/src/database/migrations/2020_02_11_110959_add_deleted_at.php b/src/database/migrations/2020_02_11_110960_domains_add_deleted_at.php copy from src/database/migrations/2020_02_11_110959_add_deleted_at.php copy to src/database/migrations/2020_02_11_110960_domains_add_deleted_at.php --- a/src/database/migrations/2020_02_11_110959_add_deleted_at.php +++ b/src/database/migrations/2020_02_11_110960_domains_add_deleted_at.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class AddDeletedAt extends Migration +// phpcs:ignore +class DomainsAddDeletedAt extends Migration { /** * Run the migrations. @@ -19,13 +20,6 @@ $table->softDeletes(); } ); - - Schema::table( - 'users', - function (Blueprint $table) { - $table->softDeletes(); - } - ); } /** @@ -41,11 +35,5 @@ $table->dropColumn(['deleted_at']); } ); - Schema::table( - 'users', - function (Blueprint $table) { - $table->dropColumn(['deleted_at']); - } - ); } } diff --git a/src/database/migrations/2020_02_24_095604_create_changelogs_table.php b/src/database/migrations/2020_02_24_095604_create_changelogs_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2020_02_24_095604_create_changelogs_table.php @@ -0,0 +1,41 @@ +bigIncrements('id'); + $table->string('entitlement_id', 36); + $table->timestamps(); + + $table->softDeletes(); + + $table->foreign('entitlement_id')->references('id') + ->on('entitlements')->onDelete('cascade'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('changelogs'); + } +} diff --git a/src/database/migrations/2020_02_11_110959_add_deleted_at.php b/src/database/migrations/2020_02_26_085835_entitlements_add_deleted_at.php rename from src/database/migrations/2020_02_11_110959_add_deleted_at.php rename to src/database/migrations/2020_02_26_085835_entitlements_add_deleted_at.php --- a/src/database/migrations/2020_02_11_110959_add_deleted_at.php +++ b/src/database/migrations/2020_02_26_085835_entitlements_add_deleted_at.php @@ -4,7 +4,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class AddDeletedAt extends Migration +// phpcs:ignore +class EntitlementsAddDeletedAt extends Migration { /** * Run the migrations. @@ -14,14 +15,7 @@ public function up() { Schema::table( - 'domains', - function (Blueprint $table) { - $table->softDeletes(); - } - ); - - Schema::table( - 'users', + 'entitlements', function (Blueprint $table) { $table->softDeletes(); } @@ -36,13 +30,7 @@ public function down() { Schema::table( - 'domains', - function (Blueprint $table) { - $table->dropColumn(['deleted_at']); - } - ); - Schema::table( - 'users', + 'entitlements', function (Blueprint $table) { $table->dropColumn(['deleted_at']); } diff --git a/src/database/seeds/PackageSeeder.php b/src/database/seeds/PackageSeeder.php --- a/src/database/seeds/PackageSeeder.php +++ b/src/database/seeds/PackageSeeder.php @@ -14,6 +14,10 @@ */ public function run() { + $skuGroupware = Sku::firstOrCreate(['title' => 'groupware']); + $skuMailbox = Sku::firstOrCreate(['title' => 'mailbox']); + $skuStorage = Sku::firstOrCreate(['title' => 'storage']); + $package = Package::create( [ 'title' => 'kolab', @@ -23,9 +27,9 @@ ); $skus = [ - Sku::firstOrCreate(['title' => 'mailbox']), - Sku::firstOrCreate(['title' => 'storage']), - Sku::firstOrCreate(['title' => 'groupware']) + $skuMailbox, + $skuGroupware, + $skuStorage ]; $package->skus()->saveMany($skus); @@ -33,7 +37,7 @@ // This package contains 2 units of the storage SKU, which just so happens to also // be the number of SKU free units. $package->skus()->updateExistingPivot( - Sku::firstOrCreate(['title' => 'storage']), + $skuStorage, ['qty' => 2], false ); @@ -47,8 +51,8 @@ ); $skus = [ - Sku::firstOrCreate(['title' => 'mailbox']), - Sku::firstOrCreate(['title' => 'storage']) + $skuMailbox, + $skuStorage ]; $package->skus()->saveMany($skus); diff --git a/src/database/seeds/UserSeeder.php b/src/database/seeds/UserSeeder.php --- a/src/database/seeds/UserSeeder.php +++ b/src/database/seeds/UserSeeder.php @@ -1,10 +1,11 @@ wallets()->get(); + $wallet = $john->wallets->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $package_kolab = \App\Package::where('title', 'kolab')->first(); @@ -70,6 +71,14 @@ $john->assignPackage($package_kolab, $jack); + foreach ($john->entitlements as $entitlement) { + $entry = \App\Changelog::where('entitlement_id', $entitlement->id)->latest()->first(); + + $entry->created_at = Carbon::now()->subMonths(1); + $entry->updated_at = Carbon::now()->subMonths(1); + $entry->save(); + } + factory(User::class, 10)->create(); } } diff --git a/src/tests/Feature/BillingTest.php b/src/tests/Feature/BillingTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/BillingTest.php @@ -0,0 +1,340 @@ +deleteTestUser('jane@kolabnow.com'); + $this->deleteTestUser('jack@kolabnow.com'); + + \App\Package::where('title', 'kolab-kube')->delete(); + + $this->user = $this->getTestUser('jane@kolabnow.com'); + $this->package = \App\Package::where('title', 'kolab')->first(); + $this->user->assignPackage($this->package); + + $this->wallet = $this->user->wallets->first(); + + $this->wallet_id = $this->wallet->id; + } + + public function tearDown(): void + { + //$this->deleteTestUser('jane@kolabnow.com'); + $this->deleteTestUser('jack@kolabnow.com'); + + \App\Package::where('title', 'kolab-kube')->delete(); + + parent::tearDown(); + } + + /** + * Test the expected results for a user that registers and is almost immediately gone. + */ + public function testTouchAndGo(): void + { + $this->assertCount(4, $this->changelogEntries($this->wallet)); + + $this->assertEquals(0, $this->wallet->expectedCharges()); + + $this->user->delete(); + + $this->assertCount(0, $this->changelogEntries($this->wallet)); + + $this->assertCount(4, $this->changelogEntries($this->wallet, true)); + } + + /** + * Verify the 13th 24-hour period does not incur charges to the wallet. + */ + public function testNearFullTrial(): void + { + $entries = $this->changelogEntries($this->wallet); + + $this->assertCount(4, $entries); + + foreach ($entries as $entry) { + $entry->created_at = Carbon::now()->subDays(13); + $entry->updated_at = Carbon::now()->subDays(13); + $entry->save(); + } + + $this->assertEquals(0, $this->wallet->expectedCharges()); + } + + /** + * Verify the 14th 24-hour period does incur charges to the wallet. + */ + public function testFullTrial(): void + { + $entries = $this->changelogEntries($this->wallet); + + $this->assertCount(4, $entries); + + foreach ($entries as $entry) { + $entry->created_at = Carbon::now()->subDays(14); + $entry->updated_at = Carbon::now()->subDays(14); + $entry->save(); + } + + $this->assertEquals(999, $this->wallet->expectedCharges()); + } + + /** + * Verify additional storage configuration entitlement created 'early' does incur additional + * charges to the wallet. + */ + public function testAddtStorageEarly(): void + { + $entries = $this->changelogEntries($this->wallet); + + foreach ($entries as $entry) { + $entry->created_at = Carbon::now()->subDays(16); + $entry->updated_at = Carbon::now()->subDays(16); + $entry->save(); + } + + $this->assertCount(4, $entries); + + $this->assertEquals(999, $this->wallet->expectedCharges()); + + $sku = \App\Sku::where(['title' => 'storage'])->first(); + + $entitlement = \App\Entitlement::create( + [ + 'owner_id' => $this->user->id, + 'wallet_id' => $this->wallet_id, + 'sku_id' => $sku->id, + 'cost' => $sku->cost, + 'entitleable_id' => $this->user->id, + 'entitleable_type' => \App\User::class + ] + ); + + $entries = $this->changelogEntries($this->wallet); + + $this->assertCount(5, $entries); + + $entry = \App\Changelog::where('entitlement_id', $entitlement->id)->first(); + $entry->created_at = Carbon::now()->subDays(16); + $entry->updated_at = Carbon::now()->subDays(16); + $entry->save(); + + $this->assertEquals(1024, $this->wallet->expectedCharges()); + } + + /** + * Verify additional storage configuration entitlement created 'late' does not incur additional + * charges to the wallet. + */ + public function testAddtStorageLate(): void + { + $entries = $this->changelogEntries($this->wallet); + + foreach ($entries as $entry) { + $entry->created_at = Carbon::now()->subDays(16); + $entry->updated_at = Carbon::now()->subDays(16); + $entry->save(); + } + + $this->assertCount(4, $entries); + + $this->assertEquals(999, $this->wallet->expectedCharges()); + + $sku = \App\Sku::where(['title' => 'storage'])->first(); + + $entitlement = \App\Entitlement::create( + [ + 'owner_id' => $this->user->id, + 'wallet_id' => $this->wallet_id, + 'sku_id' => $sku->id, + 'cost' => $sku->cost, + 'entitleable_id' => $this->user->id, + 'entitleable_type' => \App\User::class + ] + ); + + $entries = $this->changelogEntries($this->wallet); + + $this->assertCount(5, $entries); + + $entry = \App\Changelog::where('entitlement_id', $entitlement->id)->first(); + $entry->created_at = Carbon::now()->subDays(7); + $entry->updated_at = Carbon::now()->subDays(7); + $entry->save(); + + $this->assertEquals(999, $this->wallet->expectedCharges()); + } + + /** + * Verify that over-running the trial by a single day causes charges to be incurred. + */ + public function testOutRunTrial(): void + { + $entries = $this->changelogEntries($this->wallet); + + foreach ($entries as $entry) { + $entry->created_at = Carbon::now()->subDays(15); + $entry->updated_at = Carbon::now()->subDays(15); + $entry->save(); + } + + $this->assertCount(4, $entries); + + $this->assertEquals(999, $this->wallet->expectedCharges()); + } + + public function testFifthWeek(): void + { + $entries = $this->changelogEntries($this->wallet); + + $this->assertCount(4, $entries); + + $targetDateA = Carbon::now()->subWeeks(5); + $targetDateB = $targetDateA->copy()->addMonths(1); + + foreach ($entries as $entry) { + $entry->created_at = $targetDateA; + $entry->updated_at = $targetDateA; + $entry->save(); + } + + $this->assertEquals(999, $this->wallet->expectedCharges()); + + foreach ($this->wallet->entitlements as $entitlement) { + $entitlement->chargeWallet(); + } + + $this->wallet->refresh(); + + $this->assertEquals(-999, $this->wallet->balance); + + $entries = $this->changelogEntries($this->wallet); + + foreach ($entries as $entry) { + $this->assertTrue($entry->created_at->isSameSecond($targetDateA)); + $this->assertTrue($entry->updated_at->isSameSecond($targetDateB)); + } + } + + public function testSecondMonth(): void + { + $entries = $this->changelogEntries($this->wallet); + + foreach ($entries as $entry) { + $entry->created_at = Carbon::now()->subMonths(2); + $entry->updated_at = Carbon::now()->subMonths(2); + $entry->save(); + } + + $this->assertCount(4, $entries); + + $this->assertEquals(1998, $this->wallet->expectedCharges()); + + $sku = \App\Sku::where(['title' => 'storage'])->first(); + + $entitlement = \App\Entitlement::create( + [ + 'owner_id' => $this->user->id, + 'entitleable_id' => $this->user->id, + 'entitleable_type' => \App\User::class, + 'cost' => $sku->cost, + 'sku_id' => $sku->id, + 'wallet_id' => $this->wallet_id + ] + ); + + // A fresh query should now return 5 entries. + $entries = $this->changelogEntries($this->wallet); + $this->assertCount(5, $entries); + + $entry = \App\Changelog::where('entitlement_id', $entitlement->id)->first(); + $entry->created_at = Carbon::now()->subMonths(1); + $entry->updated_at = Carbon::now()->subMonths(1); + $entry->save(); + + $this->assertEquals(2023, $this->wallet->expectedCharges()); + } + + public function testWithDiscount(): void + { + $package = \App\Package::create( + [ + 'title' => 'kolab-kube', + 'description' => 'Kolab for Kube fans', + 'discount_rate' => 50 + ] + ); + + $skus = [ + \App\Sku::firstOrCreate(['title' => 'mailbox']), + \App\Sku::firstOrCreate(['title' => 'storage']), + \App\Sku::firstOrCreate(['title' => 'groupware']) + ]; + + $package->skus()->saveMany($skus); + + $package->skus()->updateExistingPivot( + \App\Sku::firstOrCreate(['title' => 'storage']), + ['qty' => 2], + false + ); + + $user = $this->getTestUser('jack@kolabnow.com'); + + $user->assignPackage($package); + + $wallet = $user->wallets->first(); + + $wallet_id = $wallet->id; + + $entries = $this->changelogEntries($wallet); + + $this->assertCount(4, $entries); + + foreach ($entries as $entry) { + $entry->created_at = Carbon::now()->subDays(15); + $entry->updated_at = Carbon::now()->subDays(15); + $entry->save(); + } + + $this->assertEquals(500, $wallet->expectedCharges()); + } + + private function changelogEntries($wallet, $withTrashed = false) + { + $entitlement_ids = $this->walletEntitlements($wallet, $withTrashed); + + if ($withTrashed) { + return \App\Changelog::withTrashed() + ->whereIn('entitlement_id', $entitlement_ids)->get(); + } + + return \App\Changelog::whereIn('entitlement_id', $entitlement_ids)->get(); + } + + private function walletEntitlements($wallet, $withTrashed = false) + { + $entitlement_ids = []; + + if ($withTrashed) { + $entitlements = $wallet->entitlements()->withTrashed()->get()->fresh(); + } else { + $entitlements = $wallet->entitlements()->get()->fresh(); + } + + foreach ($entitlements as $entitlement) { + $entitlement_ids[] = $entitlement->id; + } + + return $entitlement_ids; + } +} 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 @@ -4,8 +4,10 @@ use App\Domain; use App\Entitlement; +use App\Package; use App\Sku; use App\User; +use Carbon\Carbon; use Tests\TestCase; class EntitlementTest extends TestCase @@ -32,10 +34,15 @@ */ public function testUserAddEntitlement(): void { - $sku_domain = Sku::firstOrCreate(['title' => 'domain']); - $sku_mailbox = Sku::firstOrCreate(['title' => 'mailbox']); + $package_domain = Package::where('title', 'domain-hosting')->first(); + $package_kolab = Package::where('title', 'kolab')->first(); + + $sku_domain = Sku::where('title', 'domain-hosting')->first(); + $sku_mailbox = Sku::where('title', 'mailbox')->first(); + $owner = $this->getTestUser('entitlement-test@kolabnow.com'); $user = $this->getTestUser('entitled-user@custom-domain.com'); + $domain = $this->getTestDomain( 'custom-domain.com', [ @@ -44,50 +51,28 @@ ] ); - $wallet = $owner->wallets()->first(); + $domain->assignPackage($package_domain, $owner); - $entitlement_own_mailbox = new Entitlement( - [ - 'owner_id' => $owner->id, - 'entitleable_id' => $owner->id, - 'entitleable_type' => User::class, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku_mailbox->id, - 'description' => "Owner Mailbox Entitlement Test" - ] - ); + $owner->assignPackage($package_kolab); + $owner->assignPackage($package_kolab, $user); - $entitlement_domain = new Entitlement( - [ - 'owner_id' => $owner->id, - 'entitleable_id' => $domain->id, - 'entitleable_type' => Domain::class, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku_domain->id, - 'description' => "User Domain Entitlement Test" - ] - ); + $wallet = $owner->wallets->first(); - $entitlement_mailbox = new Entitlement( - [ - 'owner_id' => $owner->id, - 'entitleable_id' => $user->id, - 'entitleable_type' => User::class, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku_mailbox->id, - 'description' => "User Mailbox Entitlement Test" - ] - ); + $this->assertCount(4, $owner->entitlements()->get()); + $this->assertCount(1, $sku_domain->entitlements()->where('owner_id', $owner->id)->get()); + $this->assertCount(2, $sku_mailbox->entitlements()->where('owner_id', $owner->id)->get()); + $this->assertCount(9, $wallet->entitlements); + + foreach ($owner->entitlements()->get() as $entitlement) { + $entry = \App\Changelog::where('entitlement_id', $entitlement->id)->first(); + $entry->created_at = Carbon::now()->subMonths(1); + $entry->updated_at = Carbon::now()->subMonths(1); + $entry->save(); - $owner->addEntitlement($entitlement_own_mailbox); - $owner->addEntitlement($entitlement_domain); - $owner->addEntitlement($entitlement_mailbox); + $entitlement->chargeWallet(); + } - $this->assertTrue($owner->entitlements()->count() == 3); - $this->assertTrue($sku_domain->entitlements()->where('owner_id', $owner->id)->count() == 1); - $this->assertTrue($sku_mailbox->entitlements()->where('owner_id', $owner->id)->count() == 2); - $this->assertTrue($wallet->entitlements()->count() == 3); - $this->assertTrue($wallet->fresh()->balance < 0.00); + $this->assertTrue($wallet->fresh()->balance < 0); } public function testAddExistingEntitlement(): void 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 @@ -2,11 +2,12 @@ namespace Tests\Feature; -use App\Domain; +use App\Changelog; use App\Entitlement; use App\Handlers; +use App\Package; use App\Sku; -use App\User; +use Carbon\Carbon; use Tests\TestCase; class SkuTest extends TestCase @@ -16,228 +17,79 @@ { parent::setUp(); - $this->deleteTestUser('sku-test-user@custom-domain.com'); - $this->deleteTestDomain('custom-domain.com'); + $this->deleteTestUser('jane@kolabnow.com'); } public function tearDown(): void { - $this->deleteTestUser('sku-test-user@custom-domain.com'); - $this->deleteTestDomain('custom-domain.com'); + $this->deleteTestUser('jane@kolabnow.com'); parent::tearDown(); } - /** - * Tests for Sku::registerEntitlements() - */ - public function testRegisterEntitlement(): void + public function testPackageEntitlements(): void { - // TODO: This test depends on seeded SKUs, but probably should not - $domain = $this->getTestDomain( - 'custom-domain.com', - [ - 'status' => Domain::STATUS_NEW, - 'type' => Domain::TYPE_EXTERNAL, - ] - ); - - \Log::debug(var_export($domain->toArray(), true)); + $user = $this->getTestUser('jane@kolabnow.com'); - $user = $this->getTestUser('sku-test-user@custom-domain.com'); $wallet = $user->wallets()->first(); - // \App\Handlers\Mailbox SKU - // Note, we're testing mailbox SKU before domain SKU as it may potentially fail in that - // order - $sku = Sku::where('title', 'mailbox')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $user->id, - 'entitleable_type' => User::class - ] - ); + $package = Package::where('title', 'lite')->first(); - $entitlements = $sku->entitlements()->where('owner_id', $user->id)->get(); - $wallet->refresh(); - - if ($sku->active) { - $balance = -$sku->cost; - $this->assertCount(1, $entitlements); - $this->assertEquals($user->id, $entitlements[0]->entitleable_id); - $this->assertSame( - Handlers\Mailbox::entitleableClass(), - $entitlements[0]->entitleable_type - ); - } else { - $this->assertCount(0, $entitlements); - } - - $this->assertEquals($balance, $wallet->balance); - - // \App\Handlers\Domain SKU - $sku = Sku::where('title', 'domain')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $domain->id, - 'entitleable_type' => Domain::class - ] - ); - - $entitlements = $sku->entitlements->where('owner_id', $user->id); - - foreach ($entitlements as $entitlement) { - \Log::debug(var_export($entitlement->toArray(), true)); - } - - $wallet->refresh(); - - if ($sku->active) { - $balance -= $sku->cost; - $this->assertCount(1, $entitlements); - - $_domain = Domain::find($entitlements->first()->entitleable_id); - - $this->assertEquals( - $domain->id, - $entitlements->first()->entitleable_id, - var_export($_domain->toArray(), true) - ); - - $this->assertSame( - Handlers\Domain::entitleableClass(), - $entitlements->first()->entitleable_type - ); - } else { - $this->assertCount(0, $entitlements); - } - - $this->assertEquals($balance, $wallet->balance); - - // \App\Handlers\DomainRegistration SKU - $sku = Sku::where('title', 'domain-registration')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $user->wallets()->get()[0]->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $domain->id, - 'entitleable_type' => Domain::class - ] - ); + $sku_mailbox = Sku::where('title', 'mailbox')->first(); + $sku_storage = Sku::where('title', 'storage')->first(); - $entitlements = $sku->entitlements->where('owner_id', $user->id); - $wallet->refresh(); + $user = $user->assignPackage($package); - if ($sku->active) { - $balance -= $sku->cost; - $this->assertCount(1, $entitlements); - $this->assertEquals($domain->id, $entitlements->first()->entitleable_id); - $this->assertSame( - Handlers\DomainRegistration::entitleableClass(), - $entitlements->first()->entitleable_type - ); - } else { - $this->assertCount(0, $entitlements); - } + $this->backdateChangelogs($user->fresh()->entitlements, Carbon::now()->subMonths(1)); - $this->assertEquals($balance, $wallet->balance); + $wallet = $this->chargeWallet($wallet); - // \App\Handlers\DomainHosting SKU - $sku = Sku::where('title', 'domain-hosting')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $domain->id, - 'entitleable_type' => Domain::class - ] - ); + $this->assertTrue($wallet->balance < 0); + } - $entitlements = $sku->entitlements->where('owner_id', $user->id); - $wallet->refresh(); - - if ($sku->active) { - $balance -= $sku->cost; - $this->assertCount(1, $entitlements); - $this->assertEquals($domain->id, $entitlements->first()->entitleable_id); - $this->assertSame( - Handlers\DomainHosting::entitleableClass(), - $entitlements->first()->entitleable_type - ); - } else { - $this->assertCount(0, $entitlements); - } - - $this->assertEquals($balance, $wallet->balance); - - // \App\Handlers\Groupware SKU - $sku = Sku::where('title', 'groupware')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $user->wallets()->get()[0]->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $user->id, - 'entitleable_type' => User::class - ] - ); + public function testSkuEntitlements(): void + { + $this->assertCount(2, Sku::where('title', 'mailbox')->first()->entitlements); + } - $entitlements = $sku->entitlements->where('owner_id', $user->id); - $wallet->refresh(); + public function testSkuPackages(): void + { + $this->assertCount(2, Sku::where('title', 'mailbox')->first()->packages); + } - if ($sku->active) { - $balance -= $sku->cost; - $this->assertCount(1, $entitlements); - $this->assertEquals($user->id, $entitlements->first()->entitleable_id); - $this->assertSame( - Handlers\Mailbox::entitleableClass(), - $entitlements->first()->entitleable_type - ); - } else { - $this->assertCount(0, $entitlements); - } + public function testSkuHandlerDomainHosting(): void + { + $sku = Sku::where('title', 'domain-hosting')->first(); - $this->assertEquals($balance, $wallet->balance); + $entitlement = $sku->entitlements->first(); - // \App\Handlers\Storage SKU - $sku = Sku::where('title', 'storage')->first(); - Entitlement::create( - [ - 'owner_id' => $user->id, - 'wallet_id' => $wallet->id, - 'sku_id' => $sku->id, - 'entitleable_id' => $user->id, - 'entitleable_type' => User::class - ] + $this->assertSame( + Handlers\DomainHosting::entitleableClass(), + $entitlement->entitleable_type ); + } - $entitlements = $sku->entitlements->where('owner_id', $user->id); - $wallet->refresh(); + public function testSkuHandlerMailbox(): void + { + $sku = Sku::where('title', 'mailbox')->first(); - if ($sku->active) { - $balance -= $sku->cost; - $this->assertCount(1, $entitlements); - } else { - $this->assertCount(0, $entitlements); - } + $entitlement = $sku->entitlements->first(); - $this->assertEquals($balance, $wallet->balance); + $this->assertSame( + Handlers\Mailbox::entitleableClass(), + $entitlement->entitleable_type + ); } - public function testSkuPackages(): void + public function testSkuHandlerStorage(): void { - $sku = Sku::where('title', 'mailbox')->first(); + $sku = Sku::where('title', 'storage')->first(); - $packages = $sku->packages; + $entitlement = $sku->entitlements->first(); - $this->assertCount(2, $packages); + $this->assertSame( + Handlers\Storage::entitleableClass(), + $entitlement->entitleable_type + ); } } diff --git a/src/tests/Feature/VerificationCodeTest.php b/src/tests/Feature/VerificationCodeTest.php --- a/src/tests/Feature/VerificationCodeTest.php +++ b/src/tests/Feature/VerificationCodeTest.php @@ -48,6 +48,7 @@ $this->assertSame($data['mode'], $code->mode); $this->assertEquals($user->id, $code->user->id); $this->assertInstanceOf(\DateTime::class, $code->expires_at); + $this->assertSame($code_exp_hrs, $code->expires_at->diff($now)->h + 1); $inst = VerificationCode::find($code->code); 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 @@ -47,7 +47,7 @@ { $user = $this->getTestUser('UserWallet1@UserWallet.com'); - $this->assertTrue($user->wallets()->count() == 1); + $this->assertCount(1, $user->wallets); } /** @@ -61,11 +61,11 @@ new Wallet(['currency' => 'USD']) ); - $this->assertTrue($user->wallets()->count() >= 2); + $this->assertCount(2, $user->wallets); $user->wallets()->each( function ($wallet) { - $this->assertTrue($wallet->balance === 0.00); + $this->assertEquals(0, $wallet->balance); } ); } @@ -79,7 +79,7 @@ $user->wallets()->each( function ($wallet) { - $wallet->credit(1.00)->save(); + $wallet->credit(100)->save(); } ); @@ -97,7 +97,7 @@ { $user = $this->getTestUser('UserWallet4@UserWallet.com'); - $this->assertTrue($user->wallets()->count() == 1); + $this->assertCount(1, $user->wallets); $user->wallets()->each( function ($wallet) { @@ -140,15 +140,12 @@ } ); - $this->assertTrue( - $userB->accounts()->count() == 1, - "number of accounts (1 expected): {$userB->accounts()->count()}" - ); + $this->assertCount(1, $userB->accounts); - $aWallet = $userA->wallets()->get(); - $bAccount = $userB->accounts()->get(); + $aWallet = $userA->wallets()->first(); + $bAccount = $userB->accounts()->first(); - $this->assertTrue($bAccount[0]->id === $aWallet[0]->id); + $this->assertTrue($bAccount->id === $aWallet->id); } /** @@ -173,6 +170,6 @@ } ); - $this->assertTrue($userB->accounts()->count() == 0); + $this->assertCount(0, $userB->accounts); } } diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php --- a/src/tests/TestCase.php +++ b/src/tests/TestCase.php @@ -11,10 +11,37 @@ { use CreatesApplication; + protected function backdateChangelogs($entitlements, $targetDate) + { + foreach ($entitlements as $entitlement) { + $entitlement->created_at = $targetDate; + $entitlement->updated_at = $targetDate; + $entitlement->save(); + + $entry = \App\Changelog::where('entitlement_id', $entitlement->id)->first(); + $entry->created_at = $targetDate; + $entry->updated_at = $targetDate; + $entry->save(); + } + } + + protected function chargeWallet($wallet) + { + foreach ($wallet->entitlements as $entitlement) { + $entitlement->chargeWallet(); + } + + $wallet->refresh(); + + return $wallet; + } + protected function deleteTestDomain($name) { Queue::fake(); + $domain = Domain::withTrashed()->where('namespace', $name)->first(); + if (!$domain) { return; } @@ -28,6 +55,7 @@ protected function deleteTestUser($email) { Queue::fake(); + $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { @@ -59,7 +87,17 @@ { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); - return User::firstOrCreate(['email' => $email], $attrib); + $user = User::withTrashed()->where('email', $email)->first(); + + if (!$user) { + return User::firstOrCreate(['email' => $email], $attrib); + } + + if ($user->deleted_at) { + $user->restore(); + } + + return $user; } /**