123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363 |
- <?php
- // Copyright 2019 Hackware SpA <human@hackware.cl>
- // This file is part of "Hackware Web Services Wallet" and licensed under
- // the terms of the GNU Affero General Public License version 3, or (at your
- // option) a later version. You should have received a copy of this license
- // along with the software. If not, see <https://www.gnu.org/licenses/>.
- namespace Hawese\Wallet;
- use Hawese\Core\Mailer;
- use Hawese\Core\User;
- use Hawese\Core\Exceptions\ModelObjectNotFoundException;
- use Illuminate\Support\Carbon;
- use DateInterval;
- // `const` constants cannot be defined on run-time, so...
- define('TRANSACTION_TYPES', 'system,' . config('wallet.transaction_types'));
- class Transaction extends TableModel
- {
- const BALANCE_P = 8; // positions after . in a float, used with BCMath
- const AMOUNT_P = 8;
- private $current_balance;
- public static $table = 'transactions';
- public static $attributes = [
- 'id' => ['nullable', 'integer', 'min:1'],
- 'user_uid' => [
- 'required', 'string', 'min:3', 'max:100' // exists:wallets,user_uid
- ],
- 'currency_id' => [
- 'required', 'integer', 'min:1', 'exists:currencies,id'
- ],
- 'type' => [
- 'required',
- 'in:' . TRANSACTION_TYPES,
- 'string',
- 'max:100'
- ],
- 'amount' => ['required', 'regex:/^-?\d{1,8}(?:\.\d{1,8})?$/'],
- 'balance' => ['nullable', 'regex:/^-?\d{1,8}(?:\.\d{1,8})?$/'],
- 'description' => ['nullable', 'string', 'max:255'],
- 'detail' => ['nullable', 'max:65535'], // JSON
- 'due_at' => ['nullable', 'date'],
- 'created_at' => ['nullable', 'date'],
- 'updated_at' => ['nullable', 'date']
- ];
- protected $appended_attributes = ['currency_code'];
- public static $foreign_keys = [
- 'currency_id' => Currency::class,
- 'user_uid' => Wallet::class
- ];
- public function getDetail()
- {
- return json_decode($this->data['detail']);
- }
- public function setDetail($value)
- {
- $this->data['detail'] = json_encode($value);
- }
- // FIXME: N+1 problem.
- public function getCurrencyCode()
- {
- return $this->foreignObjectGetter('currency')->code;
- }
- public function setDueAt($value) : void
- {
- $this->dateSetter('due_at', $value);
- }
- public function update($fields = []) : bool
- {
- return false;
- }
- public function delete() : bool
- {
- return false;
- }
- public function insert(): bool
- {
- // FIXME: Validated twice! Check hawese-core issue #5
- $this->validate();
- return app('db')->transaction(function () {
- $wallet = $this->findOrCreateWallet();
- $this->current_balance = $this->currentBalance();
- $this->balance = bcadd(
- $this->current_balance,
- $this->amount,
- self::BALANCE_P
- );
- $this->processDueTransactions();
- return parent::insert();
- });
- }
- private function findOrCreateWallet() : Wallet
- {
- try {
- $wallet = Wallet::find($this->user_uid);
- } catch (ModelObjectNotFoundException $e) {
- $wallet = new Wallet([
- 'user_uid' => $this->user_uid,
- 'currency_code' => $this->foreignObjectGetter('currency')->code,
- ]);
- $wallet->insert();
- $wallet->initialTransaction();
- }
- return $wallet;
- }
- private function currentBalance(): string
- {
- return app('db')->selectOne(
- 'SELECT balance' .
- ' FROM ' . self::$table .
- ' WHERE user_uid = ?' .
- ' ORDER BY id desc',
- [$this->user_uid]
- )->balance ?? '0'; // In case wallet is new, so there's not balance
- }
- /**
- * Process due transactions.
- *
- * Updates due_at in all related transactions.
- * Notice only negative amount transactions can be due_at.
- */
- private function processDueTransactions(): void
- {
- // Balance is positive: ($this->balance + $this->amount) >= 0
- if (bccomp(
- $this->balance,
- 0,
- self::BALANCE_P
- ) != -1) {
- $this->due_at = null;
- // ...and was negative: $this->current_balance < 0
- if (bccomp($this->current_balance, 0, self::BALANCE_P) == -1) {
- $this->clearAllTransactionsDueAt(); // ...then clear all due_at
- }
- // Balance is negative
- } else {
- // ...but transaction reduces debt: $this->amount >= 0
- if (bccomp($this->amount, 0, self::AMOUNT_P) != -1) {
- $this->clearUpToNextDue();
- // ...and increases debt
- } else {
- $this->due_at = Carbon::now()->add(
- DateInterval::createFromDateString(
- config('wallet.due_after')
- )
- );
- }
- }
- }
- private function clearAllTransactionsDueAt()
- {
- $ids = array_map(
- function ($item) {
- return $item->id;
- },
- app('db')->select(
- 'SELECT id' .
- ' FROM ' . self::$table .
- ' WHERE due_at IS NOT NULL' .
- ' AND user_uid = ?',
- [$this->user_uid]
- )
- );
- self::clearTransactionsDueAt($ids);
- }
- private static function clearTransactionsDueAt(array $ids): void
- {
- if (count($ids)) {
- app('db')->update(
- 'UPDATE ' . self::$table .
- ' SET due_at = NULL, updated_at = ?' .
- ' WHERE id IN (' . self::commaStrRepeat('?', count($ids)) . ')',
- array_merge([Carbon::now()], $ids)
- );
- PartialPayment::staticDelete($ids);
- }
- }
- private function clearUpToNextDue(): void
- {
- $this_amount = $this->amount;
- $paid_due_transaction_ids = [];
- foreach ($this->dueTransactions() as $due_transaction) {
- $left_to_pay = $due_transaction->leftToPay();
- // $amount is less than amount owed in current transaction:
- // $this_amount < $left_to_pay
- if (bccomp($this_amount, $left_to_pay, self::AMOUNT_P) == -1
- and $this_amount != 0
- ) {
- PartialPayment::addOrCreate(
- $due_transaction->id,
- $this_amount
- );
- break;
- // $amount exceeds amount owed in current transaction
- } else {
- // ...so it's paid
- array_push($paid_due_transaction_ids, $due_transaction->id);
- $this_amount = bcsub(
- $this_amount,
- $left_to_pay,
- self::AMOUNT_P
- );
- }
- }
- self::clearTransactionsDueAt($paid_due_transaction_ids);
- }
- private function dueTransactions()
- {
- return Transaction::processCollection(
- Transaction::select()
- ->where('user_uid', $this->user_uid)
- ->whereNotNull('due_at')
- ->orderBy('due_at', 'asc')
- ->get()
- )->get();
- }
- /**
- * Amount left to pay on a Transaction with due_at.
- *
- * Notice:
- * - This function does NOT say if Wallet has a debt, but this transaction.
- * - Uses $balance instead of $current_balance since balance is already
- * inserted in database (never edit a Transaction amount/balance).
- */
- public function leftToPay(): string
- {
- // prev balance > 0: $this->balance > $this->amount
- if (bccomp($this->balance, $this->amount, self::BALANCE_P) == 1) {
- $amount = $this->balance;
- } else {
- $amount = $this->amount;
- }
- $amount = self::bcAbs($amount);
- if (isset($this->due_at)) {
- try {
- return bcsub(
- $amount,
- PartialPayment::find($this->id)->amount,
- self::AMOUNT_P
- );
- } catch (ModelObjectNotFoundException $e) {
- return $amount;
- }
- }
- return '0'; // There's no debt
- }
- public function isOwner(User $user): bool
- {
- return $this->user_uid === $user->uid;
- }
- /**
- * @param $to string non-default email.
- * @param $type string notification or expiration
- * @return bool succesfully sent?
- */
- public function emailNotification(
- string $type='transaction',
- string $to = null
- ): bool {
- $subjects = [
- 'transaction' => __('Nueva transacción'),
- 'remainder' => __('Próxima expiración de su pago'),
- 'expiration' => __('El pago de su servicio ha expirado'),
- ];
- $user = User::find($this->user_uid);
- $token = $user->generateHumanToken();
- $mail = app(Mailer::class);
- $mail->addAddress($to ?? $user->email, $user->display_name);
- $mail->Subject = __($subjects[$type]);
- $params = [
- 'user' => $user,
- 'transaction' => $this,
- 'wallet' => Wallet::find($this->user_uid),
- 'add_funds_link' => sprintf(
- '%s?auth_token=%s:%s',
- config('wallet.add_funds_url'),
- $token->key,
- $token->secret
- ),
- ];
- $mail->Body = view(
- "wallet::emails/notify_${type}",
- $params
- )->render();
- $mail->AltBody = strip_tags($mail->Body);
- return $mail->send();
- }
- public static function emailDailyRemainders(): int {
- $remainders = preg_split('/, ?/', config('wallet.remind_due_at'));
- $query = static::select();
- foreach ($remainders as $remainder) {
- $query->orWhere([
- ['due_at', '>', Carbon::now()->add($remainder)->sub('1 day')],
- ['due_at', '<=', Carbon::now()->add($remainder)],
- ]);
- }
- $query->orderBy('due_at', 'asc');
- $notified_uids = [];
- foreach ($query->get() as $transaction) {
- if (!in_array($transaction->user_uid, $notified_uids)) { // Notify 1st due only
- (new static($transaction))->emailNotification('remainder');
- $notified_uids[] = $transaction->user_uid;
- }
- }
- return count($notified_uids);
- }
- public static function emailDailyExpirations(): int {
- $query = static::select()->where([
- ['due_at', '>', Carbon::now()->sub('1 day')],
- ['due_at', '<=', Carbon::now()],
- ]);
- foreach ($query->get() as $transaction) {
- (new static($transaction))->emailNotification('expiration');
- }
- return $query->count();
- }
- protected static function bcAbs(string $value)
- {
- return trim($value, '-');
- }
- }
|