Transaction.php 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. <?php
  2. // Copyright 2019 Hackware SpA <human@hackware.cl>
  3. // This file is part of "Hackware Web Services Wallet" and licensed under
  4. // the terms of the GNU Affero General Public License version 3, or (at your
  5. // option) a later version. You should have received a copy of this license
  6. // along with the software. If not, see <https://www.gnu.org/licenses/>.
  7. namespace Hawese\Wallet;
  8. use Hawese\Core\Mailer;
  9. use Hawese\Core\User;
  10. use Hawese\Core\Exceptions\ModelObjectNotFoundException;
  11. use Illuminate\Support\Carbon;
  12. use DateInterval;
  13. // `const` constants cannot be defined on run-time, so...
  14. define('TRANSACTION_TYPES', 'system,' . config('wallet.transaction_types'));
  15. class Transaction extends TableModel
  16. {
  17. const BALANCE_P = 8; // positions after . in a float, used with BCMath
  18. const AMOUNT_P = 8;
  19. private $current_balance;
  20. public static $table = 'transactions';
  21. public static $attributes = [
  22. 'id' => ['nullable', 'integer', 'min:1'],
  23. 'user_uid' => [
  24. 'required', 'string', 'min:3', 'max:100' // exists:wallets,user_uid
  25. ],
  26. 'currency_id' => [
  27. 'required', 'integer', 'min:1', 'exists:currencies,id'
  28. ],
  29. 'type' => [
  30. 'required',
  31. 'in:' . TRANSACTION_TYPES,
  32. 'string',
  33. 'max:100'
  34. ],
  35. 'amount' => ['required', 'regex:/^-?\d{1,8}(?:\.\d{1,8})?$/'],
  36. 'balance' => ['nullable', 'regex:/^-?\d{1,8}(?:\.\d{1,8})?$/'],
  37. 'description' => ['nullable', 'string', 'max:255'],
  38. 'detail' => ['nullable', 'string', 'max:65535'],
  39. 'due_at' => ['nullable', 'date'],
  40. 'created_at' => ['nullable', 'date'],
  41. 'updated_at' => ['nullable', 'date']
  42. ];
  43. public static $foreign_keys = [
  44. 'currency_id' => Currency::class,
  45. 'user_uid' => Wallet::class
  46. ];
  47. public function setDueAt($value) : void
  48. {
  49. $this->dateSetter('due_at', $value);
  50. }
  51. public function update($fields = []) : bool
  52. {
  53. return false;
  54. }
  55. public function delete() : bool
  56. {
  57. return false;
  58. }
  59. public function insert(): bool
  60. {
  61. // FIXME: Validated twice! Check hawese-core issue #5
  62. $this->validate();
  63. return app('db')->transaction(function () {
  64. $wallet = $this->findOrCreateWallet();
  65. $this->current_balance = $this->currentBalance();
  66. $this->balance = bcadd(
  67. $this->current_balance,
  68. $this->amount,
  69. self::BALANCE_P
  70. );
  71. $this->processDueTransactions();
  72. return parent::insert();
  73. });
  74. }
  75. private function findOrCreateWallet() : Wallet
  76. {
  77. try {
  78. $wallet = Wallet::find($this->user_uid);
  79. } catch (ModelObjectNotFoundException $e) {
  80. $wallet = new Wallet([
  81. 'user_uid' => $this->user_uid,
  82. 'currency_code' => $this->foreignObjectGetter('currency')->code,
  83. ]);
  84. $wallet->insert();
  85. $wallet->initialTransaction();
  86. }
  87. return $wallet;
  88. }
  89. private function currentBalance(): string
  90. {
  91. return app('db')->selectOne(
  92. 'SELECT balance' .
  93. ' FROM ' . self::$table .
  94. ' WHERE user_uid = ?' .
  95. ' ORDER BY id desc',
  96. [$this->user_uid]
  97. )->balance ?? '0'; // In case wallet is new, so there's not balance
  98. }
  99. /**
  100. * Process due transactions.
  101. *
  102. * Updates due_at in all related transactions.
  103. * Notice only negative amount transactions can be due_at.
  104. */
  105. private function processDueTransactions(): void
  106. {
  107. // Balance is positive: ($this->balance + $this->amount) >= 0
  108. if (bccomp(
  109. $this->balance,
  110. 0,
  111. self::BALANCE_P
  112. ) != -1) {
  113. $this->due_at = null;
  114. // ...and was negative: $this->current_balance < 0
  115. if (bccomp($this->current_balance, 0, self::BALANCE_P) == -1) {
  116. $this->clearAllTransactionsDueAt(); // ...then clear all due_at
  117. }
  118. // Balance is negative
  119. } else {
  120. // ...but transaction reduces debt: $this->amount >= 0
  121. if (bccomp($this->amount, 0, self::AMOUNT_P) != -1) {
  122. $this->clearUpToNextDue();
  123. // ...and increases debt
  124. } else {
  125. $this->due_at = Carbon::now()->add(
  126. DateInterval::createFromDateString(
  127. config('wallet.due_after')
  128. )
  129. );
  130. }
  131. }
  132. }
  133. private function clearAllTransactionsDueAt()
  134. {
  135. $ids = array_map(
  136. function ($item) {
  137. return $item->id;
  138. },
  139. app('db')->select(
  140. 'SELECT id' .
  141. ' FROM ' . self::$table .
  142. ' WHERE due_at IS NOT NULL' .
  143. ' AND user_uid = ?',
  144. [$this->user_uid]
  145. )
  146. );
  147. self::clearTransactionsDueAt($ids);
  148. }
  149. private static function clearTransactionsDueAt(array $ids): void
  150. {
  151. if (count($ids)) {
  152. app('db')->update(
  153. 'UPDATE ' . self::$table .
  154. ' SET due_at = NULL, updated_at = ?' .
  155. ' WHERE id IN (' . self::commaStrRepeat('?', count($ids)) . ')',
  156. array_merge([Carbon::now()], $ids)
  157. );
  158. PartialPayment::staticDelete($ids);
  159. }
  160. }
  161. private function clearUpToNextDue(): void
  162. {
  163. $this_amount = $this->amount;
  164. $paid_due_transaction_ids = [];
  165. foreach ($this->dueTransactions() as $due_transaction) {
  166. $left_to_pay = $due_transaction->leftToPay();
  167. // $amount is less than amount owed in current transaction:
  168. // $this_amount < $left_to_pay
  169. if (bccomp($this_amount, $left_to_pay, self::AMOUNT_P) == -1
  170. and $this_amount != 0
  171. ) {
  172. PartialPayment::addOrCreate(
  173. $due_transaction->id,
  174. $this_amount
  175. );
  176. break;
  177. // $amount exceeds amount owed in current transaction
  178. } else {
  179. // ...so it's paid
  180. array_push($paid_due_transaction_ids, $due_transaction->id);
  181. $this_amount = bcsub(
  182. $this_amount,
  183. $left_to_pay,
  184. self::AMOUNT_P
  185. );
  186. }
  187. }
  188. self::clearTransactionsDueAt($paid_due_transaction_ids);
  189. }
  190. private function dueTransactions()
  191. {
  192. return Transaction::processCollection(
  193. Transaction::select()
  194. ->where('user_uid', $this->user_uid)
  195. ->whereNotNull('due_at')
  196. ->orderBy('due_at', 'asc')
  197. ->get()
  198. )->get();
  199. }
  200. /**
  201. * Amount left to pay on a Transaction with due_at.
  202. *
  203. * Notice:
  204. * - This function does NOT say if Wallet has a debt, but this transaction.
  205. * - Uses $balance instead of $current_balance since balance is already
  206. * inserted in database (never edit a Transaction amount/balance).
  207. */
  208. public function leftToPay(): string
  209. {
  210. // prev balance > 0: $this->balance > $this->amount
  211. if (bccomp($this->balance, $this->amount, self::BALANCE_P) == 1) {
  212. $amount = $this->balance;
  213. } else {
  214. $amount = $this->amount;
  215. }
  216. $amount = self::bcAbs($amount);
  217. if (isset($this->due_at)) {
  218. try {
  219. return bcsub(
  220. $amount,
  221. PartialPayment::find($this->id)->amount,
  222. self::AMOUNT_P
  223. );
  224. } catch (ModelObjectNotFoundException $e) {
  225. return $amount;
  226. }
  227. }
  228. return '0'; // There's no debt
  229. }
  230. public function isOwner(User $user): bool
  231. {
  232. return $this->user_uid === $user->uid;
  233. }
  234. public function emailNotification(): void
  235. {
  236. $user = User::find($this->user_uid);
  237. $token = $user->generateHumanToken();
  238. $mail = app(Mailer::class);
  239. $mail->addAddress($user->email, $user->display_name);
  240. $mail->Subject = __(
  241. 'Nueva transacción en :app_name',
  242. ['app_name' => config('app.name')]
  243. );
  244. $params = [
  245. 'user' => $user,
  246. 'transaction' => $this,
  247. 'wallet' => Wallet::find($this->user_uid),
  248. 'add_funds_link' => sprintf(
  249. '%s?auth_token=%s:%s',
  250. config('wallet.add_funds_url'),
  251. $token->key,
  252. $token->secret
  253. ),
  254. ];
  255. $mail->Body = view(
  256. 'wallet::emails/notify_transaction_html',
  257. $params
  258. )->render();
  259. $mail->AltBody = view(
  260. 'wallet::emails/notify_transaction_txt',
  261. $params
  262. )->render();
  263. $mail->send();
  264. }
  265. }