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->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/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -123,6 +123,7 @@ return; } + $fee = 0; $cost = 0; $now = Carbon::now(); @@ -132,6 +133,7 @@ // just in case this had not been billed yet, ever $diffInMonths = $entitlement->updated_at->diffInMonths($now); $cost += (int) ($entitlement->cost * $discount * $diffInMonths); + $fee += (int) ($entitlement->fee * $diffInMonths); // this moves the hypothetical updated at forward to however many months past the original $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); @@ -153,8 +155,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 {$this->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/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/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 + { + \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->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) { @@ -127,6 +130,16 @@ if ($apply) { $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(); diff --git a/src/database/migrations/2020_10_29_100000_add_beta_skus.php b/src/database/migrations/2020_10_29_100000_add_beta_skus.php --- a/src/database/migrations/2020_10_29_100000_add_beta_skus.php +++ b/src/database/migrations/2020_10_29_100000_add_beta_skus.php @@ -14,31 +14,7 @@ */ public function up() { - if (!\App\Sku::where('title', 'beta')->first()) { - \App\Sku::create([ - 'title' => 'beta', - 'name' => 'Beta program', - 'description' => 'Access to beta program subscriptions', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta', - 'active' => false, - ]); - } - - if (!\App\Sku::where('title', 'meet')->first()) { - \App\Sku::create([ - 'title' => 'meet', - 'name' => 'Video chat', - 'description' => 'Video conferencing tool', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Beta\Meet', - 'active' => true, - ]); - } + // empty } /** diff --git a/src/database/migrations/2020_12_28_140000_create_groups_table.php b/src/database/migrations/2020_12_28_140000_create_groups_table.php --- a/src/database/migrations/2020_12_28_140000_create_groups_table.php +++ b/src/database/migrations/2020_12_28_140000_create_groups_table.php @@ -28,19 +28,6 @@ $table->primary('id'); } ); - - if (!\App\Sku::where('title', 'group')->first()) { - \App\Sku::create([ - 'title' => 'group', - 'name' => 'Group', - 'description' => 'Distribution list', - 'cost' => 0, - 'units_free' => 0, - 'period' => 'monthly', - 'handler_class' => 'App\Handlers\Group', - 'active' => true, - ]); - } } /** 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 --- 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 @@ -17,7 +17,7 @@ Schema::table( 'discounts', 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'); } 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 --- 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 @@ -17,7 +17,7 @@ Schema::table( 'domains', 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'); } diff --git a/src/database/migrations/2021_04_09_100000_tenant_comissioning.php b/src/database/migrations/2021_04_09_100000_tenant_comissioning.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_04_09_100000_tenant_comissioning.php @@ -0,0 +1,71 @@ +bigInteger('tenant_id')->unsigned()->nullable(); + + $table->foreign('tenant_id')->references('id')->on('tenants') + ->onDelete('cascade')->onUpdate('cascade'); + } + ); + } + + // 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. + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + foreach (['plans', 'packages', 'skus'] as $table) { + Schema::table( + $table, + 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'); + } + ); + } + } +}