Transaction.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  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', 'max:65535'], // JSON
  39. 'due_at' => ['nullable', 'date'],
  40. 'created_at' => ['nullable', 'date'],
  41. 'updated_at' => ['nullable', 'date']
  42. ];
  43. protected $appended_attributes = ['currency_code'];
  44. public static $foreign_keys = [
  45. 'currency_id' => Currency::class,
  46. 'user_uid' => Wallet::class
  47. ];
  48. public function getDetail()
  49. {
  50. return json_decode($this->data['detail']);
  51. }
  52. public function setDetail($value)
  53. {
  54. $this->data['detail'] = json_encode($value);
  55. }
  56. // FIXME: N+1 problem.
  57. public function getCurrencyCode()
  58. {
  59. return $this->foreignObjectGetter('currency')->code;
  60. }
  61. public function setDueAt($value) : void
  62. {
  63. $this->dateSetter('due_at', $value);
  64. }
  65. public function update($fields = []) : bool
  66. {
  67. return false;
  68. }
  69. public function delete() : bool
  70. {
  71. return false;
  72. }
  73. public function insert(): bool
  74. {
  75. // FIXME: Validated twice! Check hawese-core issue #5
  76. $this->validate();
  77. return app('db')->transaction(function () {
  78. $wallet = $this->findOrCreateWallet();
  79. $this->current_balance = $this->currentBalance();
  80. $this->balance = bcadd(
  81. $this->current_balance,
  82. $this->amount,
  83. self::BALANCE_P
  84. );
  85. $this->processDueTransactions();
  86. return parent::insert();
  87. });
  88. }
  89. private function findOrCreateWallet() : Wallet
  90. {
  91. try {
  92. $wallet = Wallet::find($this->user_uid);
  93. } catch (ModelObjectNotFoundException $e) {
  94. $wallet = new Wallet([
  95. 'user_uid' => $this->user_uid,
  96. 'currency_code' => $this->foreignObjectGetter('currency')->code,
  97. ]);
  98. $wallet->insert();
  99. $wallet->initialTransaction();
  100. }
  101. return $wallet;
  102. }
  103. private function currentBalance(): string
  104. {
  105. return app('db')->selectOne(
  106. 'SELECT balance' .
  107. ' FROM ' . self::$table .
  108. ' WHERE user_uid = ?' .
  109. ' ORDER BY id desc',
  110. [$this->user_uid]
  111. )->balance ?? '0'; // In case wallet is new, so there's not balance
  112. }
  113. /**
  114. * Process due transactions.
  115. *
  116. * Updates due_at in all related transactions.
  117. * Notice only negative amount transactions can be due_at.
  118. */
  119. private function processDueTransactions(): void
  120. {
  121. // Balance is positive: ($this->balance + $this->amount) >= 0
  122. if (bccomp(
  123. $this->balance,
  124. 0,
  125. self::BALANCE_P
  126. ) != -1) {
  127. $this->due_at = null;
  128. // ...and was negative: $this->current_balance < 0
  129. if (bccomp($this->current_balance, 0, self::BALANCE_P) == -1) {
  130. $this->clearAllTransactionsDueAt(); // ...then clear all due_at
  131. }
  132. // Balance is negative
  133. } else {
  134. // ...but transaction reduces debt: $this->amount >= 0
  135. if (bccomp($this->amount, 0, self::AMOUNT_P) != -1) {
  136. $this->clearUpToNextDue();
  137. // ...and increases debt
  138. } else {
  139. $this->due_at = Carbon::now()->add(
  140. DateInterval::createFromDateString(
  141. config('wallet.due_after')
  142. )
  143. );
  144. }
  145. }
  146. }
  147. private function clearAllTransactionsDueAt()
  148. {
  149. $ids = array_map(
  150. function ($item) {
  151. return $item->id;
  152. },
  153. app('db')->select(
  154. 'SELECT id' .
  155. ' FROM ' . self::$table .
  156. ' WHERE due_at IS NOT NULL' .
  157. ' AND user_uid = ?',
  158. [$this->user_uid]
  159. )
  160. );
  161. self::clearTransactionsDueAt($ids);
  162. }
  163. private static function clearTransactionsDueAt(array $ids): void
  164. {
  165. if (count($ids)) {
  166. app('db')->update(
  167. 'UPDATE ' . self::$table .
  168. ' SET due_at = NULL, updated_at = ?' .
  169. ' WHERE id IN (' . self::commaStrRepeat('?', count($ids)) . ')',
  170. array_merge([Carbon::now()], $ids)
  171. );
  172. PartialPayment::staticDelete($ids);
  173. }
  174. }
  175. private function clearUpToNextDue(): void
  176. {
  177. $this_amount = $this->amount;
  178. $paid_due_transaction_ids = [];
  179. foreach ($this->dueTransactions() as $due_transaction) {
  180. $left_to_pay = $due_transaction->leftToPay();
  181. // $amount is less than amount owed in current transaction:
  182. // $this_amount < $left_to_pay
  183. if (bccomp($this_amount, $left_to_pay, self::AMOUNT_P) == -1
  184. and $this_amount != 0
  185. ) {
  186. PartialPayment::addOrCreate(
  187. $due_transaction->id,
  188. $this_amount
  189. );
  190. break;
  191. // $amount exceeds amount owed in current transaction
  192. } else {
  193. // ...so it's paid
  194. array_push($paid_due_transaction_ids, $due_transaction->id);
  195. $this_amount = bcsub(
  196. $this_amount,
  197. $left_to_pay,
  198. self::AMOUNT_P
  199. );
  200. }
  201. }
  202. self::clearTransactionsDueAt($paid_due_transaction_ids);
  203. }
  204. private function dueTransactions()
  205. {
  206. return Transaction::processCollection(
  207. Transaction::select()
  208. ->where('user_uid', $this->user_uid)
  209. ->whereNotNull('due_at')
  210. ->orderBy('due_at', 'asc')
  211. ->get()
  212. )->get();
  213. }
  214. /**
  215. * Amount left to pay on a Transaction with due_at.
  216. *
  217. * Notice:
  218. * - This function does NOT say if Wallet has a debt, but this transaction.
  219. * - Uses $balance instead of $current_balance since balance is already
  220. * inserted in database (never edit a Transaction amount/balance).
  221. */
  222. public function leftToPay(): string
  223. {
  224. // prev balance > 0: $this->balance > $this->amount
  225. if (bccomp($this->balance, $this->amount, self::BALANCE_P) == 1) {
  226. $amount = $this->balance;
  227. } else {
  228. $amount = $this->amount;
  229. }
  230. $amount = self::bcAbs($amount);
  231. if (isset($this->due_at)) {
  232. try {
  233. return bcsub(
  234. $amount,
  235. PartialPayment::find($this->id)->amount,
  236. self::AMOUNT_P
  237. );
  238. } catch (ModelObjectNotFoundException $e) {
  239. return $amount;
  240. }
  241. }
  242. return '0'; // There's no debt
  243. }
  244. public function isOwner(User $user): bool
  245. {
  246. return $this->user_uid === $user->uid;
  247. }
  248. /**
  249. * @param $to string non-default email.
  250. * @param $type string notification or expiration
  251. * @return bool succesfully sent?
  252. */
  253. public function emailNotification(
  254. string $type='transaction',
  255. string $to = null
  256. ): bool {
  257. $subjects = [
  258. 'transaction' => __('Nueva transacción'),
  259. 'remainder' => __('Próxima expiración de su pago'),
  260. 'expiration' => __('El pago de su servicio ha expirado'),
  261. ];
  262. $user = User::find($this->user_uid);
  263. $token = $user->generateHumanToken();
  264. $mail = app(Mailer::class);
  265. $mail->addAddress($to ?? $user->email, $user->display_name);
  266. $mail->Subject = __($subjects[$type]);
  267. $params = [
  268. 'user' => $user,
  269. 'transaction' => $this,
  270. 'wallet' => Wallet::find($this->user_uid),
  271. 'add_funds_link' => sprintf(
  272. '%s?auth_token=%s:%s',
  273. config('wallet.add_funds_url'),
  274. $token->key,
  275. $token->secret
  276. ),
  277. ];
  278. $mail->Body = view(
  279. "wallet::emails/notify_${type}",
  280. $params
  281. )->render();
  282. $mail->AltBody = strip_tags($mail->Body);
  283. return $mail->send();
  284. }
  285. public static function emailDailyRemainders(): int {
  286. $remainders = preg_split('/, ?/', config('wallet.remind_due_at'));
  287. $query = static::select();
  288. foreach ($remainders as $remainder) {
  289. $query->orWhere([
  290. ['due_at', '>', Carbon::now()->add($remainder)->sub('1 day')],
  291. ['due_at', '<=', Carbon::now()->add($remainder)],
  292. ]);
  293. }
  294. $query->orderBy('due_at', 'asc');
  295. $notified_uids = [];
  296. foreach ($query->get() as $transaction) {
  297. if (!in_array($transaction->user_uid, $notified_uids)) { // Notify 1st due only
  298. (new static($transaction))->emailNotification('remainder');
  299. $notified_uids[] = $transaction->user_uid;
  300. }
  301. }
  302. return count($notified_uids);
  303. }
  304. public static function emailDailyExpirations(): int {
  305. $query = static::select()->where([
  306. ['due_at', '>', Carbon::now()->sub('1 day')],
  307. ['due_at', '<=', Carbon::now()],
  308. ]);
  309. foreach ($query->get() as $transaction) {
  310. (new static($transaction))->emailNotification('expiration');
  311. }
  312. return $query->count();
  313. }
  314. protected static function bcAbs(string $value)
  315. {
  316. return trim($value, '-');
  317. }
  318. }