Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F120824662
Wallet.php
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
22 KB
Referenced Files
None
Subscribers
None
Wallet.php
View Options
<?php
namespace
App
;
use
App\Traits\SettingsTrait
;
use
App\Traits\UuidStrKeyTrait
;
use
Carbon\Carbon
;
use
Dyrynda\Database\Support\NullableFields
;
use
Illuminate\Database\Eloquent\Model
;
use
Illuminate\Database\Eloquent\Builder
;
use
Illuminate\Support\Facades\DB
;
/**
* The eloquent definition of a wallet -- a container with a chunk of change.
*
* A wallet is owned by an {@link \App\User}.
*
* @property int $balance Current balance in cents
* @property string $currency Currency code
* @property ?string $description Description
* @property string $id Unique identifier
* @property ?\App\User $owner Owner (can be null when owner is deleted)
* @property int $user_id Owner's identifier
*/
class
Wallet
extends
Model
{
use
NullableFields
;
use
SettingsTrait
;
use
UuidStrKeyTrait
;
/** @var bool Indicates that the model should be timestamped or not */
public
$timestamps
=
false
;
/** @var array The attributes' default values */
protected
$attributes
=
[
'balance'
=>
0
,
];
/** @var array<int, string> The attributes that are mass assignable */
protected
$fillable
=
[
'currency'
,
'description'
];
/** @var array<int, string> The attributes that can be not set */
protected
$nullable
=
[
'description'
,
];
/** @var array<string, string> The types of attributes to which its values will be cast */
protected
$casts
=
[
'balance'
=>
'integer'
,
];
/**
* Add a controller to this wallet.
*
* @param \App\User $user The user to add as a controller to this wallet.
*
* @return void
*/
public
function
addController
(
User
$user
)
{
if
(!
$this
->
controllers
->
contains
(
$user
))
{
$this
->
controllers
()->
save
(
$user
);
}
}
/**
* Add an award to this wallet's balance.
*
* @param int|\App\Payment $amount The amount of award (in cents) or Payment object
* @param string $description The transaction description
*
* @return Wallet Self
*/
public
function
award
(
int
|
Payment
$amount
,
string
$description
=
''
):
Wallet
{
return
$this
->
balanceUpdate
(
Transaction
::
WALLET_AWARD
,
$amount
,
$description
);
}
/**
* Charge entitlements in the wallet
*
* @param bool $apply Set to false for a dry-run mode
*
* @return int Charged amount in cents
*/
public
function
chargeEntitlements
(
$apply
=
true
):
int
{
$transactions
=
[];
$profit
=
0
;
$charges
=
0
;
$isDegraded
=
$this
->
owner
->
isDegraded
();
$trial
=
$this
->
trialInfo
();
if
(
$apply
)
{
DB
::
beginTransaction
();
}
// Get all relevant entitlements...
$entitlements
=
$this
->
entitlements
()->
withTrashed
()
// existing entitlements created, or billed last less than a month ago
// @phpstan-ignore-next-line
->
where
(
function
(
Builder
$query
)
{
$query
->
whereNull
(
'deleted_at'
)
->
where
(
'updated_at'
,
'<='
,
Carbon
::
now
()->
subMonthsWithoutOverflow
(
1
));
})
// deleted entitlements not yet charged
// @phpstan-ignore-next-line
->
orWhere
(
function
(
Builder
$query
)
{
$query
->
whereNotNull
(
'deleted_at'
)
->
whereColumn
(
'updated_at'
,
'<'
,
'deleted_at'
);
})
->
get
();
foreach
(
$entitlements
as
$entitlement
)
{
// Calculate cost, fee, and end of period
[
$cost
,
$fee
,
$endDate
]
=
$this
->
entitlementCosts
(
$entitlement
,
$trial
);
// Note: Degraded pays nothing, but we get the money from a tenant.
// Therefore $cost = 0, but $profit < 0.
if
(
$isDegraded
)
{
$cost
=
0
;
}
$charges
+=
$cost
;
$profit
+=
$cost
-
$fee
;
// if we're in dry-run, you know...
if
(!
$apply
)
{
continue
;
}
if
(
$endDate
)
{
$entitlement
->
updated_at
=
$endDate
;
$entitlement
->
save
();
}
if
(
$cost
==
0
)
{
continue
;
}
$transactions
[]
=
$entitlement
->
createTransaction
(
Transaction
::
ENTITLEMENT_BILLED
,
$cost
);
}
if
(
$apply
)
{
$this
->
debit
(
$charges
,
''
,
$transactions
)->
addTenantProfit
(
$profit
);
DB
::
commit
();
}
return
$charges
;
}
/**
* Calculate for how long the current balance will last.
*
* Returns NULL for balance < 0 or discount = 100% or on a fresh account
*
* @return \Carbon\Carbon|null Date
*/
public
function
balanceLastsUntil
()
{
if
(
$this
->
balance
<
0
||
$this
->
getDiscount
()
==
100
)
{
return
null
;
}
$balance
=
$this
->
balance
;
$discount
=
$this
->
getDiscountRate
();
$trial
=
$this
->
trialInfo
();
// Get all entitlements...
$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
();
$max
=
12
*
25
;
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
;
}
$balance
-=
(
int
)
(
$entitlement
[
'cost'
]
*
$discount
);
if
(
$balance
<
0
)
{
break
2
;
}
}
$max
--;
}
if
(
empty
(
$until
))
{
return
null
;
}
// Don't return dates from the past
if
(
$until
<=
Carbon
::
now
()
&&
!
$until
->
isToday
())
{
return
null
;
}
return
$until
;
}
/**
* Chargeback an amount of pecunia from this wallet's balance.
*
* @param int|\App\Payment $amount The amount of pecunia to charge back (in cents) or Payment object
* @param string $description The transaction description
*
* @return Wallet Self
*/
public
function
chargeback
(
int
|
Payment
$amount
,
string
$description
=
''
):
Wallet
{
return
$this
->
balanceUpdate
(
Transaction
::
WALLET_CHARGEBACK
,
$amount
,
$description
);
}
/**
* Controllers of this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public
function
controllers
()
{
return
$this
->
belongsToMany
(
User
::
class
,
// The foreign object definition
'user_accounts'
,
// The table name
'wallet_id'
,
// The local foreign key
'user_id'
// The remote foreign key
);
}
/**
* Add an amount of pecunia to this wallet's balance.
*
* @param int|\App\Payment $amount The amount of pecunia to add (in cents) or Payment object
* @param string $description The transaction description
*
* @return Wallet Self
*/
public
function
credit
(
int
|
Payment
$amount
,
string
$description
=
''
):
Wallet
{
return
$this
->
balanceUpdate
(
Transaction
::
WALLET_CREDIT
,
$amount
,
$description
);
}
/**
* Deduct an amount of pecunia from this wallet's balance.
*
* @param int|\App\Payment $amount The amount of pecunia to deduct (in cents) or Payment object
* @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
|
Payment
$amount
,
string
$description
=
''
,
array
$eTIDs
=
[]):
Wallet
{
return
$this
->
balanceUpdate
(
Transaction
::
WALLET_DEBIT
,
$amount
,
$description
,
$eTIDs
);
}
/**
* The discount assigned to the wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public
function
discount
()
{
return
$this
->
belongsTo
(
Discount
::
class
,
'discount_id'
,
'id'
);
}
/**
* Entitlements billed to this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public
function
entitlements
()
{
return
$this
->
hasMany
(
Entitlement
::
class
);
}
/**
* Calculate the expected charges to this wallet.
*
* @return int
*/
public
function
expectedCharges
()
{
return
$this
->
chargeEntitlements
(
false
);
}
/**
* Return the exact, numeric version of the discount to be applied.
*
* @return int Discount in percent, ranges from 0 - 100.
*/
public
function
getDiscount
():
int
{
return
$this
->
discount
?
$this
->
discount
->
discount
:
0
;
}
/**
* The actual discount rate for use in multiplication
*
* @return float Discount rate, ranges from 0.00 to 1.00.
*/
public
function
getDiscountRate
():
float
{
return
(
100
-
$this
->
getDiscount
())
/
100
;
}
/**
* The minimum amount of an auto-payment mandate
*
* @return int Amount in cents
*/
public
function
getMinMandateAmount
():
int
{
$min
=
Payment
::
MIN_AMOUNT
;
if
(
$plan
=
$this
->
plan
())
{
$planCost
=
(
int
)
(
$plan
->
cost
()
*
$this
->
getDiscountRate
());
if
(
$planCost
>
$min
)
{
$min
=
$planCost
;
}
}
return
$min
;
}
/**
* Check if the specified user is a controller to this wallet.
*
* @param \App\User $user The user object.
*
* @return bool True if the user is one of the wallet controllers (including user), False otherwise
*/
public
function
isController
(
User
$user
):
bool
{
return
$user
->
id
==
$this
->
user_id
||
$this
->
controllers
->
contains
(
$user
);
}
/**
* A helper to display human-readable amount of money using
* the wallet currency and specified locale.
*
* @param int $amount A amount of money (in cents)
* @param string $locale A locale for the output
*
* @return string String representation, e.g. "9.99 CHF"
*/
public
function
money
(
int
$amount
,
$locale
=
'de_DE'
)
{
return
\App\Utils
::
money
(
$amount
,
$this
->
currency
,
$locale
);
}
/**
* The owner of the wallet -- the wallet is in his/her back pocket.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public
function
owner
()
{
return
$this
->
belongsTo
(
User
::
class
,
'user_id'
,
'id'
);
}
/**
* Payments on this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public
function
payments
()
{
return
$this
->
hasMany
(
Payment
::
class
);
}
/**
* Add a penalty to this wallet's balance.
*
* @param int|\App\Payment $amount The amount of penalty (in cents) or Payment object
* @param string $description The transaction description
*
* @return Wallet Self
*/
public
function
penalty
(
int
|
Payment
$amount
,
string
$description
=
''
):
Wallet
{
return
$this
->
balanceUpdate
(
Transaction
::
WALLET_PENALTY
,
$amount
,
$description
);
}
/**
* 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.
*
* @param \App\User $user The user to remove as a controller from this wallet.
*
* @return void
*/
public
function
removeController
(
User
$user
)
{
if
(
$this
->
controllers
->
contains
(
$user
))
{
$this
->
controllers
()->
detach
(
$user
);
}
}
/**
* Refund an amount of pecunia from this wallet's balance.
*
* @param int|\App\Payment $amount The amount of pecunia to refund (in cents) or Payment object
* @param string $description The transaction description
*
* @return Wallet Self
*/
public
function
refund
(
$amount
,
string
$description
=
''
):
Wallet
{
return
$this
->
balanceUpdate
(
Transaction
::
WALLET_REFUND
,
$amount
,
$description
);
}
/**
* Get the VAT rate for the wallet owner country.
*
* @param ?\DateTime $start Get the rate valid for the specified date-time,
* without it the current rate will be returned (if exists).
*
* @return ?\App\VatRate VAT rate
*/
public
function
vatRate
(
\DateTime
$start
=
null
):
?
VatRate
{
$owner
=
$this
->
owner
;
// Make it working with deleted accounts too
if
(!
$owner
)
{
$owner
=
$this
->
owner
()->
withTrashed
()->
first
();
}
$country
=
$owner
->
getSetting
(
'country'
);
if
(!
$country
)
{
return
null
;
}
return
VatRate
::
where
(
'country'
,
$country
)
->
where
(
'start'
,
'<='
,
(
$start
?:
now
())->
format
(
'Y-m-d h:i:s'
))
->
orderByDesc
(
'start'
)
->
limit
(
1
)
->
first
();
}
/**
* Retrieve the transactions against this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public
function
transactions
()
{
return
$this
->
hasMany
(
Transaction
::
class
,
'object_id'
)
->
where
(
'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.
*
* @param bool $withCost When enabled the cost will be charged
*
* @return int Charged amount in cents
*/
public
function
updateEntitlements
(
$withCost
=
true
):
int
{
$charges
=
0
;
$profit
=
0
;
$trial
=
$this
->
trialInfo
();
DB
::
beginTransaction
();
$transactions
=
[];
$entitlements
=
$this
->
entitlements
()->
where
(
'updated_at'
,
'<'
,
Carbon
::
now
())->
get
();
foreach
(
$entitlements
as
$entitlement
)
{
// Calculate cost, fee, and end of period
[
$cost
,
$fee
,
$endDate
]
=
$this
->
entitlementCosts
(
$entitlement
,
$trial
,
true
);
// Note: Degraded pays nothing, but we get the money from a tenant.
// Therefore $cost = 0, but $profit < 0.
if
(!
$withCost
)
{
$cost
=
0
;
}
if
(
$endDate
)
{
$entitlement
->
updated_at
=
$entitlement
->
updated_at
->
setDateFrom
(
$endDate
);
$entitlement
->
save
();
}
$charges
+=
$cost
;
$profit
+=
$cost
-
$fee
;
if
(
$cost
==
0
)
{
continue
;
}
// FIXME: Shouldn't we store also cost=0 transactions (to have the full history)?
$transactions
[]
=
$entitlement
->
createTransaction
(
Transaction
::
ENTITLEMENT_BILLED
,
$cost
);
}
$this
->
debit
(
$charges
,
''
,
$transactions
)->
addTenantProfit
(
$profit
);
DB
::
commit
();
return
$charges
;
}
/**
* Add profit to the tenant's wallet
*
* @param int $profit Profit amount (in cents), can be negative
*/
protected
function
addTenantProfit
(
$profit
):
void
{
// 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}"
;
if
(
$profit
>
0
)
{
$wallet
->
credit
(
abs
(
$profit
),
$desc
);
}
else
{
$wallet
->
debit
(
abs
(
$profit
),
$desc
);
}
}
}
}
/**
* Update the wallet balance, and create a transaction record
*/
protected
function
balanceUpdate
(
string
$type
,
int
|
Payment
$amount
,
$description
=
null
,
array
$eTIDs
=
[])
{
if
(
$amount
instanceof
Payment
)
{
$amount
=
$amount
->
credit_amount
;
}
if
(
$amount
===
0
)
{
return
$this
;
}
if
(
in_array
(
$type
,
[
Transaction
::
WALLET_CREDIT
,
Transaction
::
WALLET_AWARD
]))
{
$amount
=
abs
(
$amount
);
}
else
{
$amount
=
abs
(
$amount
)
*
-
1
;
}
$this
->
balance
+=
$amount
;
$this
->
save
();
$transaction
=
Transaction
::
create
([
'user_email'
=>
\App\Utils
::
userEmailOrNull
(),
'object_id'
=>
$this
->
id
,
'object_type'
=>
Wallet
::
class
,
'type'
=>
$type
,
'amount'
=>
$amount
,
'description'
=>
$description
,
]);
if
(!
empty
(
$eTIDs
))
{
Transaction
::
whereIn
(
'id'
,
$eTIDs
)->
update
([
'transaction_id'
=>
$transaction
->
id
]);
}
return
$this
;
}
/**
* Calculate entitlement cost/fee for the current charge
*
* @param Entitlement $entitlement Entitlement object
* @param array|null $trial Trial information (result of Wallet::trialInfo())
* @param bool $useCostPerDay Force calculation based on a per-day cost
*
* @return array Result in form of [cost, fee, end-of-period]
*/
protected
function
entitlementCosts
(
Entitlement
$entitlement
,
array
$trial
=
null
,
bool
$useCostPerDay
=
false
)
{
$discountRate
=
$this
->
getDiscountRate
();
$startDate
=
$entitlement
->
updated_at
;
// start of the period to charge for
$endDate
=
Carbon
::
now
();
// end of the period to charge for
// Deleted entitlements are always charged for all uncharged days up to the delete date
if
(
$entitlement
->
trashed
())
{
$useCostPerDay
=
true
;
$endDate
=
$entitlement
->
deleted_at
->
copy
();
}
// Consider Trial period
if
(!
empty
(
$trial
)
&&
$startDate
<
$trial
[
'end'
]
&&
in_array
(
$entitlement
->
sku_id
,
$trial
[
'skus'
]))
{
if
(
$trial
[
'end'
]
>
$endDate
)
{
return
[
0
,
0
,
$trial
[
'end'
]];
}
$startDate
=
$trial
[
'end'
];
}
if
(
$useCostPerDay
)
{
// Note: In this mode we need a full cost including partial periods.
// Anything's free for the first 14 days.
if
(
$entitlement
->
created_at
>=
$endDate
->
copy
()->
subDays
(
14
))
{
return
[
0
,
0
,
$endDate
];
}
$cost
=
0
;
$fee
=
0
;
// Charge for full months first
if
((
$diff
=
$startDate
->
diffInMonths
(
$endDate
))
>
0
)
{
$cost
+=
floor
(
$entitlement
->
cost
*
$discountRate
)
*
$diff
;
$fee
+=
$entitlement
->
fee
*
$diff
;
$startDate
->
addMonthsWithoutOverflow
(
$diff
);
}
// Charge for the rest of the period
if
((
$diff
=
$startDate
->
diffInDays
(
$endDate
))
>
0
)
{
// The price per day is based on the number of days in the month(s)
// Note: The $endDate does not have to be the current month
$endMonthDiff
=
$endDate
->
day
>
$diff
?
$diff
:
$endDate
->
day
;
$startMonthDiff
=
$diff
-
$endMonthDiff
;
// FIXME: This could be calculated in a few different ways, e.g. rounding or flooring
// the daily cost first and then applying discount and number of days. This could lead
// to very small values in some cases resulting in a zero result.
$cost
+=
floor
(
$entitlement
->
cost
/
$endDate
->
daysInMonth
*
$discountRate
*
$endMonthDiff
);
$fee
+=
floor
(
$entitlement
->
fee
/
$endDate
->
daysInMonth
*
$endMonthDiff
);
if
(
$startMonthDiff
)
{
$cost
+=
floor
(
$entitlement
->
cost
/
$startDate
->
daysInMonth
*
$discountRate
*
$startMonthDiff
);
$fee
+=
floor
(
$entitlement
->
fee
/
$startDate
->
daysInMonth
*
$startMonthDiff
);
}
}
}
else
{
// Note: In this mode we expect to charge the entitlement for full month(s) only
$diff
=
$startDate
->
diffInMonths
(
$endDate
);
if
(
$diff
<=
0
)
{
// Do not update updated_at column (not a full month) unless trial end date
// is after current updated_at date
return
[
0
,
0
,
$startDate
!=
$entitlement
->
updated_at
?
$startDate
:
null
];
}
$endDate
=
$startDate
->
addMonthsWithoutOverflow
(
$diff
);
$cost
=
floor
(
$entitlement
->
cost
*
$discountRate
)
*
$diff
;
$fee
=
$entitlement
->
fee
*
$diff
;
}
return
[(
int
)
$cost
,
(
int
)
$fee
,
$endDate
];
}
}
File Metadata
Details
Attached
Mime Type
text/x-php
Expires
Fri, Apr 24, 10:25 AM (23 h, 35 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18826453
Default Alt Text
Wallet.php (22 KB)
Attached To
Mode
rK kolab
Attached
Detach File
Event Timeline