Changeset View
Changeset View
Standalone View
Standalone View
src/app/Wallet.php
Show First 20 Lines • Show All 69 Lines • ▼ Show 20 Lines | class Wallet extends Model | ||||
* Charge entitlements in the wallet | * Charge entitlements in the wallet | ||||
* | * | ||||
* @param bool $apply Set to false for a dry-run mode | * @param bool $apply Set to false for a dry-run mode | ||||
* | * | ||||
* @return int Charged amount in cents | * @return int Charged amount in cents | ||||
*/ | */ | ||||
public function chargeEntitlements($apply = true): int | public function chargeEntitlements($apply = true): int | ||||
{ | { | ||||
// This wallet has been created less than a month ago, this is the trial period | $transactions = []; | ||||
if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) { | |||||
// Move all the current entitlement's updated_at timestamps forward to one month after | |||||
// this wallet was created. | |||||
$freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1); | |||||
foreach ($this->entitlements()->get()->fresh() as $entitlement) { | |||||
if ($entitlement->updated_at < $freeMonthEnds) { | |||||
$entitlement->updated_at = $freeMonthEnds; | |||||
$entitlement->save(); | |||||
} | |||||
} | |||||
return 0; | |||||
} | |||||
$profit = 0; | $profit = 0; | ||||
$charges = 0; | $charges = 0; | ||||
$discount = $this->getDiscountRate(); | $discount = $this->getDiscountRate(); | ||||
$isDegraded = $this->owner->isDegraded(); | $isDegraded = $this->owner->isDegraded(); | ||||
$trial = $this->trialInfo(); | |||||
if ($apply) { | if ($apply) { | ||||
DB::beginTransaction(); | DB::beginTransaction(); | ||||
} | } | ||||
// used to parent individual entitlement billings to the wallet debit. | // Get all entitlements... | ||||
$entitlementTransactions = []; | $entitlements = $this->entitlements() | ||||
// Skip entitlements created less than or equal to 14 days ago (this is at | |||||
foreach ($this->entitlements()->get() as $entitlement) { | |||||
// This entitlement has been created less than or equal to 14 days ago (this is at | |||||
// maximum the fourteenth 24-hour period). | // maximum the fourteenth 24-hour period). | ||||
if ($entitlement->created_at > Carbon::now()->subDays(14)) { | // ->where('created_at', '<=', Carbon::now()->subDays(14)) | ||||
continue; | // Skip entitlements created, or billed last, less than a month ago. | ||||
->where('updated_at', '<=', Carbon::now()->subMonthsWithoutOverflow(1)) | |||||
->get(); | |||||
foreach ($entitlements as $entitlement) { | |||||
// If in trial, move entitlement's updated_at timestamps forward to the trial end. | |||||
if ( | |||||
!empty($trial) | |||||
&& $entitlement->updated_at < $trial['end'] | |||||
&& in_array($entitlement->sku_id, $trial['skus']) | |||||
) { | |||||
// TODO: Consider not updating the updated_at to a future date, i.e. bump it | |||||
mollekopf: This does not seem problematic to me, we're just bumping updated_at to the first point in time… | |||||
Done Inline Actions$apply=false is dry-run. machniak: $apply=false is dry-run.
My idea regarding the comment was to never have updated_at in the… | |||||
// as many months as possible, but not into the future | |||||
// if we're in dry-run, you know... | |||||
if ($apply) { | |||||
$entitlement->updated_at = $trial['end']; | |||||
$entitlement->save(); | |||||
} | } | ||||
// This entitlement was created, or billed last, less than a month ago. | |||||
if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { | |||||
continue; | continue; | ||||
} | } | ||||
// updated last more than a month ago -- was it billed? | |||||
if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { | |||||
$diff = $entitlement->updated_at->diffInMonths(Carbon::now()); | $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); | ||||
if ($diff <= 0) { | |||||
continue; | |||||
} | |||||
$cost = (int) ($entitlement->cost * $discount * $diff); | $cost = (int) ($entitlement->cost * $discount * $diff); | ||||
$fee = (int) ($entitlement->fee * $diff); | $fee = (int) ($entitlement->fee * $diff); | ||||
if ($isDegraded) { | if ($isDegraded) { | ||||
$cost = 0; | $cost = 0; | ||||
} | } | ||||
$charges += $cost; | $charges += $cost; | ||||
$profit += $cost - $fee; | $profit += $cost - $fee; | ||||
// if we're in dry-run, you know... | // if we're in dry-run, you know... | ||||
if (!$apply) { | if (!$apply) { | ||||
continue; | continue; | ||||
} | } | ||||
$entitlement->updated_at = $entitlement->updated_at->copy() | $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); | ||||
->addMonthsWithoutOverflow($diff); | |||||
$entitlement->save(); | $entitlement->save(); | ||||
if ($cost == 0) { | if ($cost == 0) { | ||||
continue; | continue; | ||||
} | } | ||||
$entitlementTransactions[] = $entitlement->createTransaction( | $transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost); | ||||
Transaction::ENTITLEMENT_BILLED, | |||||
$cost | |||||
); | |||||
} | |||||
} | } | ||||
if ($apply) { | if ($apply) { | ||||
$this->debit($charges, '', $entitlementTransactions); | $this->debit($charges, '', $transactions); | ||||
// Credit/debit the reseller | // Credit/debit the reseller | ||||
if ($profit != 0 && $this->owner->tenant) { | if ($profit != 0 && $this->owner->tenant) { | ||||
// FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) | // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) | ||||
if ($wallet = $this->owner->tenant->wallet()) { | if ($wallet = $this->owner->tenant->wallet()) { | ||||
$desc = "Charged user {$this->owner->email}"; | $desc = "Charged user {$this->owner->email}"; | ||||
$method = $profit > 0 ? 'credit' : 'debit'; | $method = $profit > 0 ? 'credit' : 'debit'; | ||||
$wallet->{$method}(abs($profit), $desc); | $wallet->{$method}(abs($profit), $desc); | ||||
Show All 14 Lines | class Wallet extends Model | ||||
* @return \Carbon\Carbon|null Date | * @return \Carbon\Carbon|null Date | ||||
*/ | */ | ||||
public function balanceLastsUntil() | public function balanceLastsUntil() | ||||
{ | { | ||||
if ($this->balance < 0 || $this->getDiscount() == 100) { | if ($this->balance < 0 || $this->getDiscount() == 100) { | ||||
return null; | return null; | ||||
} | } | ||||
// retrieve any expected charges | $balance = $this->balance; | ||||
$expectedCharge = $this->expectedCharges(); | $discount = $this->getDiscountRate(); | ||||
$trial = $this->trialInfo(); | |||||
// get the costs per day for all entitlements billed against this wallet | // Get all entitlements... | ||||
$costsPerDay = $this->costsPerDay(); | $entitlements = $this->entitlements()->orderBy('updated_at')->get() | ||||
->filter(function ($entitlement) { | |||||
return $entitlement->cost > 0; | |||||
}) | |||||
->map(function ($entitlement) { | |||||
return [ | |||||
'date' => $entitlement->updated_at ?: $entitlement->created_at, | |||||
'cost' => $entitlement->cost, | |||||
'sku_id' => $entitlement->sku_id, | |||||
]; | |||||
}) | |||||
->all(); | |||||
if (!$costsPerDay) { | $max = 12 * 25; | ||||
return null; | while ($max > 0) { | ||||
foreach ($entitlements as &$entitlement) { | |||||
$until = $entitlement['date'] = $entitlement['date']->addMonthsWithoutOverflow(1); | |||||
if ( | |||||
!empty($trial) | |||||
&& $entitlement['date'] < $trial['end'] | |||||
&& in_array($entitlement['sku_id'], $trial['skus']) | |||||
) { | |||||
continue; | |||||
} | } | ||||
// the number of days this balance, minus the expected charges, would last | $balance -= (int) ($entitlement['cost'] * $discount); | ||||
$daysDelta = floor(($this->balance - $expectedCharge) / $costsPerDay); | |||||
// calculate from the last entitlement billed | if ($balance < 0) { | ||||
$entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); | break 2; | ||||
} | |||||
} | |||||
$until = $entitlement->updated_at->copy()->addDays($daysDelta); | $max--; | ||||
} | |||||
if (empty($until)) { | |||||
return null; | |||||
} | |||||
// Don't return dates from the past | // Don't return dates from the past | ||||
if ($until < Carbon::now() && !$until->isToday()) { | if ($until <= Carbon::now() && !$until->isToday()) { | ||||
return null; | return null; | ||||
} | } | ||||
return $until; | return $until; | ||||
} | } | ||||
/** | /** | ||||
* Controllers of this wallet. | * Controllers of this wallet. | ||||
* | * | ||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany | * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany | ||||
*/ | */ | ||||
public function controllers() | public function controllers() | ||||
{ | { | ||||
return $this->belongsToMany( | return $this->belongsToMany( | ||||
User::class, // The foreign object definition | User::class, // The foreign object definition | ||||
'user_accounts', // The table name | 'user_accounts', // The table name | ||||
'wallet_id', // The local foreign key | 'wallet_id', // The local foreign key | ||||
'user_id' // The remote foreign key | 'user_id' // The remote foreign key | ||||
); | ); | ||||
} | } | ||||
/** | /** | ||||
* Retrieve the costs per day of everything charged to this wallet. | |||||
* | |||||
* @return float | |||||
*/ | |||||
public function costsPerDay() | |||||
{ | |||||
$costs = (float) 0; | |||||
foreach ($this->entitlements as $entitlement) { | |||||
$costs += $entitlement->costsPerDay(); | |||||
} | |||||
return $costs; | |||||
} | |||||
/** | |||||
* Add an amount of pecunia to this wallet's balance. | * Add an amount of pecunia to this wallet's balance. | ||||
* | * | ||||
* @param int $amount The amount of pecunia to add (in cents). | * @param int $amount The amount of pecunia to add (in cents). | ||||
* @param string $description The transaction description | * @param string $description The transaction description | ||||
* | * | ||||
* @return Wallet Self | * @return Wallet Self | ||||
*/ | */ | ||||
public function credit(int $amount, string $description = ''): Wallet | public function credit(int $amount, string $description = ''): Wallet | ||||
▲ Show 20 Lines • Show All 150 Lines • ▼ Show 20 Lines | class Wallet extends Model | ||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany | * @return \Illuminate\Database\Eloquent\Relations\HasMany | ||||
*/ | */ | ||||
public function payments() | public function payments() | ||||
{ | { | ||||
return $this->hasMany(Payment::class); | return $this->hasMany(Payment::class); | ||||
} | } | ||||
/** | /** | ||||
* Plan of the wallet. | |||||
* | |||||
* @return ?\App\Plan | |||||
*/ | |||||
public function plan() | |||||
{ | |||||
$planId = $this->owner->getSetting('plan_id'); | |||||
return $planId ? Plan::find($planId) : null; | |||||
} | |||||
/** | |||||
* Remove a controller from this wallet. | * Remove a controller from this wallet. | ||||
* | * | ||||
* @param \App\User $user The user to remove as a controller from this wallet. | * @param \App\User $user The user to remove as a controller from this wallet. | ||||
* | * | ||||
* @return void | * @return void | ||||
*/ | */ | ||||
public function removeController(User $user) | public function removeController(User $user) | ||||
{ | { | ||||
if ($this->controllers->contains($user)) { | if ($this->controllers->contains($user)) { | ||||
$this->controllers()->detach($user); | $this->controllers()->detach($user); | ||||
} | } | ||||
} | } | ||||
/** | /** | ||||
* Retrieve the transactions against this wallet. | * Retrieve the transactions against this wallet. | ||||
* | * | ||||
* @return \Illuminate\Database\Eloquent\Builder Query builder | * @return \Illuminate\Database\Eloquent\Builder Query builder | ||||
*/ | */ | ||||
public function transactions() | public function transactions() | ||||
{ | { | ||||
return Transaction::where( | return Transaction::where('object_id', $this->id)->where('object_type', Wallet::class); | ||||
[ | } | ||||
'object_id' => $this->id, | |||||
'object_type' => Wallet::class | /** | ||||
] | * Returns trial related information. | ||||
); | * | ||||
* @return ?array Plan ID, plan SKUs, trial end date, number of free months (planId, skus, end, months) | |||||
*/ | |||||
public function trialInfo(): ?array | |||||
{ | |||||
$plan = $this->plan(); | |||||
$freeMonths = $plan ? $plan->free_months : 0; | |||||
$trialEnd = $freeMonths ? $this->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null; | |||||
if ($trialEnd) { | |||||
// Get all SKUs assigned to the plan (they are free in trial) | |||||
// TODO: We could store the list of plan's SKUs in the wallet settings, for two reasons: | |||||
// - performance | |||||
// - if we change plan definition at some point in time, the old users would use | |||||
// the old definition, instead of the current one | |||||
// TODO: The same for plan's free_months value | |||||
$trialSkus = \App\Sku::select('id') | |||||
->whereIn('id', function ($query) use ($plan) { | |||||
$query->select('sku_id') | |||||
->from('package_skus') | |||||
->whereIn('package_id', function ($query) use ($plan) { | |||||
$query->select('package_id') | |||||
->from('plan_packages') | |||||
->where('plan_id', $plan->id); | |||||
}); | |||||
}) | |||||
->whereNot('title', 'storage') | |||||
->pluck('id') | |||||
->all(); | |||||
return [ | |||||
'end' => $trialEnd, | |||||
'skus' => $trialSkus, | |||||
'planId' => $plan->id, | |||||
'months' => $freeMonths, | |||||
]; | |||||
} | |||||
return null; | |||||
} | } | ||||
/** | /** | ||||
* Force-update entitlements' updated_at, charge if needed. | * Force-update entitlements' updated_at, charge if needed. | ||||
* | * | ||||
* @param bool $withCost When enabled the cost will be charged | * @param bool $withCost When enabled the cost will be charged | ||||
* | * | ||||
* @return int Charged amount in cents | * @return int Charged amount in cents | ||||
▲ Show 20 Lines • Show All 62 Lines • Show Last 20 Lines |
This does not seem problematic to me, we're just bumping updated_at to the first point in time where we have to check it again, no?
Also, $apply is the dry run, so we're not bumping it then?