Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.89% covered (warning)
72.89%
2767 / 3796
57.45% covered (warning)
57.45%
54 / 94
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProjectService
72.89% covered (warning)
72.89%
2767 / 3796
57.45% covered (warning)
57.45%
54 / 94
12962.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
105 / 105
100.00% covered (success)
100.00%
1 / 1
1
 userCanAccessProject
79.03% covered (warning)
79.03%
49 / 62
0.00% covered (danger)
0.00%
0 / 1
13.33
 getUserMaxAccessLevel
78.12% covered (warning)
78.12%
50 / 64
0.00% covered (danger)
0.00%
0 / 1
16.05
 getGuestAccessLevel
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getShareAccessLevel
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 createProject
100.00% covered (success)
100.00%
65 / 65
100.00% covered (success)
100.00%
1 / 1
11
 deleteProject
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
4
 getProjectInfo
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
1 / 1
5
 getSmallStats
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 getProjectStatistics
97.66% covered (success)
97.66%
250 / 256
0.00% covered (danger)
0.00%
0 / 1
87
 addBill
98.68% covered (success)
98.68%
75 / 76
0.00% covered (danger)
0.00%
0 / 1
31
 deleteBill
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 getMemberById
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
3
 getProjectById
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
2
 getBill
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
3
 deleteBillOwersOfBill
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 autoSettlement
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
6.06
 getProjectSettlement
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 centeredSettle
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 settle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 orderBalance
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 reduceBalance
80.77% covered (warning)
80.77%
21 / 26
0.00% covered (danger)
0.00%
0 / 1
7.35
 sortCreditersDebiters
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
8.23
 editMember
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
24
 editProject
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
22
 addMember
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
14
 getNbBills
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 getBillsWithLimit
55.96% covered (warning)
55.96%
61 / 109
0.00% covered (danger)
0.00%
0 / 1
113.07
 getBillFromRow
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
1
 getBills
69.91% covered (warning)
69.91%
79 / 113
0.00% covered (danger)
0.00%
0 / 1
26.83
 getAllBillIds
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 getMembers
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
6
 getBalance
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
7.02
 isUserInCircle
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 getProjectNames
68.09% covered (warning)
68.09%
64 / 94
0.00% covered (danger)
0.00%
0 / 1
26.39
 getProjects
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getCategoriesOrPaymentModes
100.00% covered (success)
100.00%
113 / 113
100.00% covered (success)
100.00%
1 / 1
19
 getCurrencies
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 getUserShares
89.74% covered (warning)
89.74%
35 / 39
0.00% covered (danger)
0.00%
0 / 1
5.03
 getPublicShares
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
3.01
 getProjectInfoFromShareToken
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
2
 getGroupShares
88.89% covered (warning)
88.89%
32 / 36
0.00% covered (danger)
0.00%
0 / 1
5.03
 getCircleShares
11.76% covered (danger)
11.76%
4 / 34
0.00% covered (danger)
0.00%
0 / 1
22.17
 deleteMember
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
4
 getBillsOfMember
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 getMemberByName
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
3
 getMemberByUserid
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
4
 editBill
98.85% covered (success)
98.85%
86 / 87
0.00% covered (danger)
0.00%
0 / 1
35
 cronRepeatBills
98.41% covered (success)
98.41%
62 / 63
0.00% covered (danger)
0.00%
0 / 1
11
 getProjectTimeZone
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 copyBillPaymentModeOver
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 copyBillCategoryOver
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 moveBill
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
4
 repeatBill
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
9
 getNextRepetitionDate
83.72% covered (warning)
83.72%
36 / 43
0.00% covered (danger)
0.00%
0 / 1
15.97
 addPaymentMode
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 getPaymentMode
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
2
 deletePaymentMode
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 savePaymentModeOrder
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 editPaymentMode
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 addCategory
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 getCategory
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
2
 deleteCategory
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 saveCategoryOrder
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 editCategory
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 addCurrency
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getCurrency
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
2
 deleteCurrency
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 editCurrency
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 addUserShare
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
8
 addPublicShare
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 editShareAccessLevel
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
3
 editShareAccess
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
9.01
 editGuestAccessLevel
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 deleteUserShare
98.36% covered (success)
98.36%
60 / 61
0.00% covered (danger)
0.00%
0 / 1
4
 deletePublicShare
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
3
 addGroupShare
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
4
 deleteGroupShare
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
3
 addCircleShare
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
72
 deleteCircleShare
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
12
 exportCsvSettlement
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
30
 createAndCheckExportDirectory
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 exportCsvStatistics
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
20
 exportCsvProject
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 getJsonProject
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 1
110
 importCsvProjectAtomicWrapper
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 importCsvProject
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 importCsvProjectStream
0.00% covered (danger)
0.00%
0 / 237
0.00% covered (danger)
0.00%
0 / 1
6806
 importSWProject
0.00% covered (danger)
0.00%
0 / 137
0.00% covered (danger)
0.00%
0 / 1
1980
 cronAutoExport
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 1
90
 searchBills
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
2
 applyBillSearchTermCondition
52.17% covered (warning)
52.17%
12 / 23
0.00% covered (danger)
0.00%
0 / 1
2.44
 getBillActivity
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 updateProjectLastChanged
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Nextcloud - cospend
5 *
6 * This file is licensed under the Affero General Public License version 3 or
7 * later. See the COPYING file.
8 *
9 * @author Julien Veyssier
10 * @copyright Julien Veyssier 2019
11 */
12
13namespace OCA\Cospend\Service;
14
15use DateTimeZone;
16use Exception;
17use Generator;
18use OCP\Files\FileInfo;
19use OCP\IL10N;
20use OCP\IConfig;
21use OCP\DB\QueryBuilder\IQueryBuilder;
22
23use OCP\App\IAppManager;
24use OCP\IGroupManager;
25use OCP\IAvatarManager;
26use OCP\Notification\IManager as INotificationManager;
27
28use OCP\IUserManager;
29use OCP\IDBConnection;
30use OCP\IDateTimeZone;
31use OCP\Files\Folder;
32use OCP\Files\IRootFolder;
33
34use DateTimeImmutable;
35use DateInterval;
36use DateTime;
37
38use OCA\Cospend\Utils;
39use OCA\Cospend\AppInfo\Application;
40use OCA\Cospend\Activity\ActivityManager;
41use OCA\Cospend\Db\ProjectMapper;
42use OCA\Cospend\Db\BillMapper;
43use Throwable;
44use function str_replace;
45
46class ProjectService {
47
48    /**
49     * @var IL10N
50     */
51    private $trans;
52    /**
53     * @var IConfig
54     */
55    private $config;
56    /**
57     * @var ProjectMapper
58     */
59    private $projectMapper;
60    /**
61     * @var BillMapper
62     */
63    private $billMapper;
64    /**
65     * @var ActivityManager
66     */
67    private $activityManager;
68    /**
69     * @var IAvatarManager
70     */
71    private $avatarManager;
72    /**
73     * @var IUserManager
74     */
75    private $userManager;
76    /**
77     * @var IAppManager
78     */
79    private $appManager;
80    /**
81     * @var IGroupManager
82     */
83    private $groupManager;
84    /**
85     * @var IDateTimeZone
86     */
87    private $dateTimeZone;
88    /**
89     * @var IRootFolder
90     */
91    private $root;
92    /**
93     * @var INotificationManager
94     */
95    private $notificationManager;
96    /**
97     * @var IDBConnection
98     */
99    private $db;
100    /**
101     * @var array
102     */
103    private $hardCodedCategoryNames;
104    /**
105     * @var array[]
106     */
107    public $defaultCategories;
108    /**
109     * @var array[]
110     */
111    public $defaultPaymentModes;
112
113    public function __construct (IL10N $trans,
114                                 IConfig $config,
115                                 ProjectMapper $projectMapper,
116                                 BillMapper $billMapper,
117                                 ActivityManager $activityManager,
118                                 IAvatarManager $avatarManager,
119                                 IUserManager $userManager,
120                                 IAppManager $appManager,
121                                 IGroupManager $groupManager,
122                                 IDateTimeZone $dateTimeZone,
123                                 IRootFolder $root,
124                                 INotificationManager $notificationManager,
125                                 IDBConnection $db) {
126        $this->trans = $trans;
127        $this->config = $config;
128        $this->projectMapper = $projectMapper;
129        $this->billMapper = $billMapper;
130        $this->activityManager = $activityManager;
131        $this->avatarManager = $avatarManager;
132        $this->userManager = $userManager;
133        $this->appManager = $appManager;
134        $this->groupManager = $groupManager;
135        $this->dateTimeZone = $dateTimeZone;
136        $this->root = $root;
137        $this->notificationManager = $notificationManager;
138        $this->db = $db;
139
140        $this->defaultCategories = [
141            [
142                'name' => $this->trans->t('Grocery'),
143                'icon' => '🛒',
144                'color' => '#ffaa00',
145            ],
146            [
147                'name' => $this->trans->t('Bar/Party'),
148                'icon' => '🎉',
149                'color' => '#aa55ff',
150            ],
151            [
152                'name' => $this->trans->t('Rent'),
153                'icon' => '🏠',
154                'color' => '#da8733',
155            ],
156            [
157                'name' => $this->trans->t('Bill'),
158                'icon' => '🌩',
159                'color' => '#4aa6b0',
160            ],
161            [
162                'name' => $this->trans->t('Excursion/Culture'),
163                'icon' => '🚸',
164                'color' => '#0055ff',
165            ],
166            [
167                'name' => $this->trans->t('Health'),
168                'icon' => '💚',
169                'color' => '#bf090c',
170            ],
171            [
172                'name' => $this->trans->t('Shopping'),
173                'icon' => '🛍',
174                'color' => '#e167d1',
175            ],
176            [
177                'name' => $this->trans->t('Restaurant'),
178                'icon' => '🍴',
179                'color' => '#d0d5e1',
180            ],
181            [
182                'name' => $this->trans->t('Accommodation'),
183                'icon' => '🛌',
184                'color' => '#5de1a3',
185            ],
186            [
187                'name' => $this->trans->t('Transport'),
188                'icon' => '🚌',
189                'color' => '#6f2ee1',
190            ],
191            [
192                'name' => $this->trans->t('Sport'),
193                'icon' => '🎾',
194                'color' => '#69e177',
195            ],
196        ];
197
198        $this->defaultPaymentModes = [
199            [
200                'name' => $this->trans->t('Credit card'),
201                'icon' => '💳',
202                'color' => '#FF7F50',
203                'old_id' => 'c',
204            ],
205            [
206                'name' => $this->trans->t('Cash'),
207                'icon' => '💵',
208                'color' => '#556B2F',
209                'old_id' => 'b',
210            ],
211            [
212                'name' => $this->trans->t('Check'),
213                'icon' => '🎫',
214                'color' => '#A9A9A9',
215                'old_id' => 'f',
216            ],
217            [
218                'name' => $this->trans->t('Transfer'),
219                'icon' => '⇄',
220                'color' => '#00CED1',
221                'old_id' => 't',
222            ],
223            [
224                'name' => $this->trans->t('Online service'),
225                'icon' => '🌎',
226                'color' => '#9932CC',
227                'old_id' => 'o',
228            ],
229        ];
230
231        $this->hardCodedCategoryNames = [
232            '-11' => $this->trans->t('Reimbursement'),
233        ];
234    }
235
236    /**
237     * check if user owns the project
238     * or if the project is shared with the user
239     *
240     * @param string $userid
241     * @param string $projectid
242     * @return bool
243     */
244    public function userCanAccessProject(string $userid, string $projectid): bool {
245        $projectInfo = $this->getProjectInfo($projectid);
246        if ($projectInfo !== null) {
247            // does the user own the project ?
248            if ($projectInfo['userid'] === $userid) {
249                return true;
250            } else {
251                $qb = $this->db->getQueryBuilder();
252                // is the project shared with the user ?
253                $qb->select('userid', 'projectid')
254                    ->from('cospend_shares', 's')
255                    ->where(
256                        $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['user'], IQueryBuilder::PARAM_STR))
257                    )
258                    ->andWhere(
259                        $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
260                    )
261                    ->andWhere(
262                        $qb->expr()->eq('userid', $qb->createNamedParameter($userid, IQueryBuilder::PARAM_STR))
263                    );
264                $req = $qb->executeQuery();
265                $dbProjectId = null;
266                while ($row = $req->fetch()) {
267                    $dbProjectId = $row['projectid'];
268                    break;
269                }
270                $req->closeCursor();
271                $qb = $qb->resetQueryParts();
272
273                if ($dbProjectId !== null) {
274                    return true;
275                } else {
276                    // if not, is the project shared with a group containing the user?
277                    $userO = $this->userManager->get($userid);
278                    $accessWithGroup = null;
279
280                    $qb->select('userid')
281                        ->from('cospend_shares', 's')
282                        ->where(
283                            $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['group'], IQueryBuilder::PARAM_STR))
284                        )
285                        ->andWhere(
286                            $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
287                        );
288                    $req = $qb->executeQuery();
289                    while ($row = $req->fetch()) {
290                        $groupId = $row['userid'];
291                        if ($this->groupManager->groupExists($groupId) && $this->groupManager->get($groupId)->inGroup($userO)) {
292                            $accessWithGroup = $groupId;
293                            break;
294                        }
295                    }
296                    $req->closeCursor();
297                    $qb = $qb->resetQueryParts();
298
299                    if ($accessWithGroup !== null) {
300                        return true;
301                    } else {
302                        // if not, are circles enabled and is the project shared with a circle containing the user?
303                        $circlesEnabled = $this->appManager->isEnabledForUser('circles');
304                        if ($circlesEnabled) {
305                            $qb->select('userid')
306                                ->from('cospend_shares', 's')
307                                ->where(
308                                    $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['circle'], IQueryBuilder::PARAM_STR))
309                                )
310                                ->andWhere(
311                                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
312                                );
313                            $req = $qb->executeQuery();
314                            while ($row = $req->fetch()) {
315                                $circleId = $row['userid'];
316                                if ($this->isUserInCircle($userid, $circleId)) {
317                                    return true;
318                                }
319                            }
320                        }
321                        return false;
322                    }
323
324                }
325            }
326        } else {
327            return false;
328        }
329    }
330
331    /**
332     * Get max access level of a given user for a given project
333     *
334     * @param string $userid
335     * @param string $projectid
336     * @return int
337     */
338    public function getUserMaxAccessLevel(string $userid, string $projectid): int {
339        $result = 0;
340        $projectInfo = $this->getProjectInfo($projectid);
341        if ($projectInfo !== null) {
342            // does the user own the project ?
343            if ($projectInfo['userid'] === $userid) {
344                return Application::ACCESS_LEVELS['admin'];
345            } else {
346                $qb = $this->db->getQueryBuilder();
347                // is the project shared with the user ?
348                $qb->select('userid', 'projectid', 'accesslevel')
349                    ->from('cospend_shares')
350                    ->where(
351                        $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['user'], IQueryBuilder::PARAM_STR))
352                    )
353                    ->andWhere(
354                        $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
355                    )
356                    ->andWhere(
357                        $qb->expr()->eq('userid', $qb->createNamedParameter($userid, IQueryBuilder::PARAM_STR))
358                    );
359                $req = $qb->executeQuery();
360                $dbProjectId = null;
361                $dbAccessLevel = null;
362                while ($row = $req->fetch()) {
363                    $dbProjectId = $row['projectid'];
364                    $dbAccessLevel = (int) $row['accesslevel'];
365                    break;
366                }
367                $req->closeCursor();
368                $qb = $qb->resetQueryParts();
369
370                if ($dbProjectId !== null && $dbAccessLevel > $result) {
371                    $result = $dbAccessLevel;
372                }
373
374                // is the project shared with a group containing the user?
375                $userO = $this->userManager->get($userid);
376
377                $qb->select('userid', 'accesslevel')
378                    ->from('cospend_shares')
379                    ->where(
380                        $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['group'], IQueryBuilder::PARAM_STR))
381                    )
382                    ->andWhere(
383                        $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
384                    );
385                $req = $qb->executeQuery();
386                while ($row = $req->fetch()){
387                    $groupId = $row['userid'];
388                    $dbAccessLevel = (int) $row['accesslevel'];
389                    if ($this->groupManager->groupExists($groupId)
390                        && $this->groupManager->get($groupId)->inGroup($userO)
391                        && $dbAccessLevel > $result
392                    ) {
393                        $result = $dbAccessLevel;
394                    }
395                }
396                $req->closeCursor();
397                $qb = $qb->resetQueryParts();
398
399                // are circles enabled and is the project shared with a circle containing the user
400                $circlesEnabled = $this->appManager->isEnabledForUser('circles');
401                if ($circlesEnabled) {
402                    $qb->select('userid', 'accesslevel')
403                        ->from('cospend_shares')
404                        ->where(
405                            $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['circle'], IQueryBuilder::PARAM_STR))
406                        )
407                        ->andWhere(
408                            $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
409                        );
410                    $req = $qb->executeQuery();
411                    while ($row = $req->fetch()) {
412                        $circleId = $row['userid'];
413                        $dbAccessLevel = (int) $row['accesslevel'];
414                        if ($this->isUserInCircle($userid, $circleId) && $dbAccessLevel > $result) {
415                            $result = $dbAccessLevel;
416                        }
417                    }
418                }
419            }
420        }
421
422        return $result;
423    }
424
425    /**
426     * Get guest access level of a given project
427     *
428     * @param string $projectid
429     * @return int
430     */
431    public function getGuestAccessLevel(string $projectid): int {
432        $projectInfo = $this->getProjectInfo($projectid);
433        if ($projectInfo !== null) {
434            return (int) $projectInfo['guestaccesslevel'];
435        } else {
436            return Application::ACCESS_LEVELS['none'];
437        }
438    }
439
440    /**
441     * Get access level of a shared access
442     *
443     * @param string $projectid
444     * @param int $shid
445     * @return int
446     */
447    public function getShareAccessLevel(string $projectid, int $shid): int {
448        $result = 0;
449        $qb = $this->db->getQueryBuilder();
450        $qb->select('accesslevel')
451            ->from('cospend_shares')
452            ->where(
453                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
454            )
455            ->andWhere(
456                $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
457            );
458        $req = $qb->executeQuery();
459        while ($row = $req->fetch()){
460            $result = (int) $row['accesslevel'];
461            break;
462        }
463        $req->closeCursor();
464        $qb->resetQueryParts();
465
466        return $result;
467    }
468
469    /**
470     * Create a project
471     *
472     * @param string $name
473     * @param string $id
474     * @param string|null $password
475     * @param string|null $contact_email
476     * @param string $userid
477     * @param bool $createDefaultCategories
478     * @return array
479     * @throws \OCP\DB\Exception
480     */
481    public function createProject(string $name, string $id, ?string $password, ?string $contact_email, string $userid = '',
482                                  bool $createDefaultCategories = true, bool $createDefaultPaymentModes = true): array {
483        $qb = $this->db->getQueryBuilder();
484
485        $qb->select('id')
486            ->from('cospend_projects', 'p')
487            ->where(
488                $qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_STR))
489            );
490        $req = $qb->executeQuery();
491
492        $dbid = null;
493        while ($row = $req->fetch()){
494            $dbid = $row['id'];
495            break;
496        }
497        $req->closeCursor();
498        $qb = $qb->resetQueryParts();
499        if ($dbid === null) {
500            // check if id is valid
501            if (strpos($id, '/') !== false) {
502                return ['message' => $this->trans->t('Invalid project id')];
503            }
504            $dbPassword = '';
505            if ($password !== null && $password !== '') {
506                $dbPassword = password_hash($password, PASSWORD_DEFAULT);
507            }
508            if ($contact_email === null) {
509                $contact_email = '';
510            }
511            $ts = (new DateTime())->getTimestamp();
512            $qb->insert('cospend_projects')
513                ->values([
514                    'userid' => $qb->createNamedParameter($userid, IQueryBuilder::PARAM_STR),
515                    'id' => $qb->createNamedParameter($id, IQueryBuilder::PARAM_STR),
516                    'name' => $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR),
517                    'password' => $qb->createNamedParameter($dbPassword, IQueryBuilder::PARAM_STR),
518                    'email' => $qb->createNamedParameter($contact_email, IQueryBuilder::PARAM_STR),
519                    'lastchanged' => $qb->createNamedParameter($ts, IQueryBuilder::PARAM_INT)
520                ]);
521            $qb->executeStatement();
522            $qb = $qb->resetQueryParts();
523
524            // create default categories
525            if ($createDefaultCategories) {
526                foreach ($this->defaultCategories as $category) {
527                    $icon = urlencode($category['icon']);
528                    $color = $category['color'];
529                    $name = $category['name'];
530                    $qb->insert('cospend_categories')
531                        ->values([
532                            'projectid' => $qb->createNamedParameter($id, IQueryBuilder::PARAM_STR),
533                            'encoded_icon' => $qb->createNamedParameter($icon, IQueryBuilder::PARAM_STR),
534                            'color' => $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR),
535                            'name' => $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR),
536                        ]);
537                    $qb->executeStatement();
538                    $qb = $qb->resetQueryParts();
539                }
540            }
541
542            // create default payment modes
543            if ($createDefaultPaymentModes) {
544                foreach ($this->defaultPaymentModes as $pm) {
545                    $icon = urlencode($pm['icon']);
546                    $color = $pm['color'];
547                    $name = $pm['name'];
548                    $oldId = $pm['old_id'];
549                    $qb->insert('cospend_paymentmodes')
550                        ->values([
551                            'projectid' => $qb->createNamedParameter($id, IQueryBuilder::PARAM_STR),
552                            'encoded_icon' => $qb->createNamedParameter($icon, IQueryBuilder::PARAM_STR),
553                            'color' => $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR),
554                            'name' => $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR),
555                            'old_id' => $qb->createNamedParameter($oldId, IQueryBuilder::PARAM_STR),
556                        ]);
557                    $qb->executeStatement();
558                    $qb = $qb->resetQueryParts();
559                }
560            }
561
562            return ['id' => $id];
563        } else {
564            return ['message' => $this->trans->t('A project with id "%1$s" already exists', [$id])];
565        }
566    }
567
568    /**
569     * Delete a project and all associated data
570     *
571     * @param string $projectid
572     * @return array
573     */
574    public function deleteProject(string $projectid): array {
575        $projectToDelete = $this->getProjectById($projectid);
576        if ($projectToDelete !== null) {
577            $qb = $this->db->getQueryBuilder();
578
579            // TODO do that with one request
580            // delete project bills
581            $bills = $this->getBills($projectid);
582            foreach ($bills as $bill) {
583                $this->deleteBillOwersOfBill($bill['id']);
584            }
585
586            $associatedTableNames = [
587                'cospend_bills',
588                'cospend_members',
589                'cospend_shares',
590                'cospend_currencies',
591                'cospend_categories',
592                'cospend_paymentmodes'
593            ];
594
595            foreach ($associatedTableNames as $tableName) {
596                $qb->delete($tableName)
597                    ->where(
598                        $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
599                    );
600                $qb->executeStatement();
601                $qb = $qb->resetQueryParts();
602            }
603
604            // delete project
605            $qb->delete('cospend_projects')
606                ->where(
607                    $qb->expr()->eq('id', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
608                );
609            $qb->executeStatement();
610            $qb->resetQueryParts();
611
612            return ['message' => 'DELETED'];
613        } else {
614            return ['error' => $this->trans->t('Not Found')];
615        }
616    }
617
618    /**
619     * Get all project data
620     *
621     * @param string $projectid
622     * @return array
623     */
624    public function getProjectInfo(string $projectid): ?array {
625        $projectInfo = null;
626
627        $qb = $this->db->getQueryBuilder();
628
629        $qb->select('id', 'password', 'name', 'email', 'userid', 'lastchanged', 'guestaccesslevel',
630            'autoexport', 'currencyname', 'deletiondisabled', 'categorysort', 'paymentmodesort')
631            ->from('cospend_projects')
632            ->where(
633                $qb->expr()->eq('id', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
634            );
635        $req = $qb->executeQuery();
636
637        $dbProjectId = null;
638        while ($row = $req->fetch()){
639            $dbProjectId = $row['id'];
640            $dbName = $row['name'];
641            $dbEmail= $row['email'];
642            $dbUserId = $row['userid'];
643            $dbGuestAccessLevel = (int) $row['guestaccesslevel'];
644            $dbLastchanged = (int) $row['lastchanged'];
645            $dbAutoexport= $row['autoexport'];
646            $dbCurrencyName = $row['currencyname'];
647            $dbDeletionDisabled = ((int) $row['deletiondisabled']) === 1;
648            $dbCategorySort = $row['categorysort'];
649            $dbPaymentModeSort = $row['paymentmodesort'];
650            break;
651        }
652        $req->closeCursor();
653        $qb->resetQueryParts();
654        if ($dbProjectId !== null) {
655            $smallStats = $this->getSmallStats($dbProjectId);
656            $members = $this->getMembers($dbProjectId, 'lowername');
657            $activeMembers = [];
658            foreach ($members as $member) {
659                if ($member['activated']) {
660                    $activeMembers[] = $member;
661                }
662            }
663            $balance = $this->getBalance($dbProjectId);
664            $currencies = $this->getCurrencies($dbProjectId);
665            $categories = $this->getCategoriesOrPaymentModes($dbProjectId);
666            $paymentModes = $this->getCategoriesOrPaymentModes($dbProjectId, false);
667            // get all shares
668            $userShares = $this->getUserShares($dbProjectId);
669            $groupShares = $this->getGroupShares($dbProjectId);
670            $circleShares = $this->getCircleShares($dbProjectId);
671            $publicShares = $this->getPublicShares($dbProjectId);
672            $shares = array_merge($userShares, $groupShares, $circleShares, $publicShares);
673
674            $projectInfo = [
675                'userid' => $dbUserId,
676                'name' => $dbName,
677                'contact_email' => $dbEmail,
678                'id' => $dbProjectId,
679                'guestaccesslevel' => $dbGuestAccessLevel,
680                'autoexport' => $dbAutoexport,
681                'currencyname' => $dbCurrencyName,
682                'lastchanged' => $dbLastchanged,
683                'active_members' => $activeMembers,
684                'members' => $members,
685                'balance' => $balance,
686                'nb_bills' => $smallStats['nb_bills'],
687                'total_spent' => $smallStats['total_spent'],
688                'shares' => $shares,
689                'currencies' => $currencies,
690                'categories' => $categories,
691                'paymentmodes' => $paymentModes,
692                'deletion_disabled' => $dbDeletionDisabled,
693                'categorysort' => $dbCategorySort,
694                'paymentmodesort' => $dbPaymentModeSort,
695            ];
696        }
697
698        return $projectInfo;
699    }
700
701    /**
702     * Get number of bills and total spent amount for a givne project
703     *
704     * @param string $projectId
705     * @return array
706     */
707    private function getSmallStats(string $projectId): array {
708        $nbBills = 0;
709        $qb = $this->db->getQueryBuilder();
710        $qb->selectAlias($qb->createFunction('COUNT(*)'), 'count_bills')
711            ->from('cospend_bills')
712            ->where(
713                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
714            );
715        $req = $qb->executeQuery();
716        while ($row = $req->fetch()) {
717            $nbBills = (int) $row['count_bills'];
718        }
719        $qb = $qb->resetQueryParts();
720
721        $totalSpent = 0;
722        $qb->selectAlias($qb->createFunction('SUM(amount)'), 'sum_amount')
723            ->from('cospend_bills')
724            ->where(
725                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
726            );
727        $req = $qb->executeQuery();
728        while ($row = $req->fetch()) {
729            $totalSpent = (int) $row['sum_amount'];
730        }
731        $qb->resetQueryParts();
732        return [
733            'nb_bills' => $nbBills,
734            'total_spent' => $totalSpent,
735        ];
736    }
737
738    /**
739     * Get project statistics
740     *
741     * @param string $projectId
742     * @param string|null $memberOrder
743     * @param int|null $tsMin
744     * @param int|null $tsMax
745     * @param int|null $paymentModeId
746     * @param int|null $categoryId
747     * @param float|null $amountMin
748     * @param float|null $amountMax
749     * @param bool|null $showDisabled
750     * @param int|null $currencyId
751     * @param int|null $payerId
752     * @return array
753     * @throws \OCP\DB\Exception
754     */
755    public function getProjectStatistics(string $projectId, ?string $memberOrder = null, ?int $tsMin = null, ?int $tsMax = null,
756                                         ?int   $paymentModeId = null, ?int $categoryId = null, ?float $amountMin = null, ?float $amountMax = null,
757                                         bool   $showDisabled = true, ?int $currencyId = null, ?int $payerId = null): array {
758        $timeZone = $this->dateTimeZone->getTimeZone();
759        $membersWeight = [];
760        $membersNbBills = [];
761        $membersBalance = [];
762        $membersFilteredBalance = [];
763        $membersPaid = [
764            'total' => [],
765        ];
766        $membersSpent = [];
767        $membersPaidFor = [];
768
769        $currency = null;
770        if ($currencyId !== null && $currencyId !== 0) {
771            $currency = $this->getCurrency($projectId, $currencyId);
772        }
773
774        $projectCategories = $this->getCategoriesOrPaymentModes($projectId);
775        $projectPaymentModes = $this->getCategoriesOrPaymentModes($projectId, false);
776
777        // get the real global balances with no filters
778        $balances = $this->getBalance($projectId);
779
780        $members = $this->getMembers($projectId, $memberOrder);
781        foreach ($members as $member) {
782            $memberId = $member['id'];
783            $memberWeight = $member['weight'];
784            $membersWeight[$memberId] = $memberWeight;
785            $membersNbBills[$memberId] = 0;
786            $membersBalance[$memberId] = $balances[$memberId];
787            $membersFilteredBalance[$memberId] = 0.0;
788            $membersPaid[$memberId] = 0.0;
789            $membersSpent[$memberId] = 0.0;
790            $membersPaidFor[$memberId] = [];
791            foreach ($members as $m) {
792                $membersPaidFor[$memberId][$m['id']] = 0.0;
793            }
794            $membersPaidFor['total'][$memberId] = 0.0;
795        }
796
797        // build list of members to display
798        $membersToDisplay = [];
799        $allMembersIds = [];
800        foreach ($members as $member) {
801            $memberId = $member['id'];
802            $allMembersIds[] = $memberId;
803            // only take enabled members or those with non-zero balance
804            $mBalance = (float) $membersBalance[$memberId];
805            if ($showDisabled || $member['activated'] || $mBalance >= 0.01 || $mBalance <= -0.01) {
806                $membersToDisplay[$memberId] = $member;
807            }
808        }
809
810        // compute stats
811        $bills = $this->getBills(
812            $projectId, $tsMin, $tsMax, null, $paymentModeId, $categoryId,
813            $amountMin, $amountMax, null, null, false, $payerId
814        );
815
816        /*
817        $firstBillTs = $bills[0]['timestamp'];
818        $firstBillDate = DateTime::createFromFormat('U', $firstBillTs);
819        $firstBillDate->setTimezone($timeZone);
820        $firstBillDate->modify('first day of');
821        $firstBillDate->setTime(0, 0);
822        $year1 = (int) $firstBillDate->format('Y');
823        $month1 = (int) $firstBillDate->format('m');
824
825        $lastBillTs = $bills[count($bills) - 1]['timestamp'];
826        $lastBillDate = DateTime::createFromFormat('U', $lastBillTs);
827        $lastBillDate->setTimezone($timeZone);
828        $lastBillDate->modify('first day of');
829        $lastBillDate->setTime(0, 0);
830        $year2 = (int) $lastBillDate->format('Y');
831        $month2 = (int) $lastBillDate->format('m');
832
833        $fullMonthNumber = (($year2 - $year1) * 12) + ($month2 - $month1 + 1);
834        */
835
836        // compute classic stats
837        foreach ($bills as $bill) {
838            $payerId = $bill['payer_id'];
839            $amount = $bill['amount'];
840            $owers = $bill['owers'];
841
842            $membersNbBills[$payerId]++;
843            $membersFilteredBalance[$payerId] += $amount;
844            $membersPaid[$payerId] += $amount;
845
846            $nbOwerShares = 0.0;
847            foreach ($owers as $ower) {
848                $owerWeight = $ower['weight'];
849                if ($owerWeight === 0.0) {
850                    $owerWeight = 1.0;
851                }
852                $nbOwerShares += $owerWeight;
853            }
854            foreach ($owers as $ower) {
855                $owerWeight = $ower['weight'];
856                if ($owerWeight === 0.0) {
857                    $owerWeight = 1.0;
858                }
859                $owerId = $ower['id'];
860                $spent = $amount / $nbOwerShares * $owerWeight;
861                $membersFilteredBalance[$owerId] -= $spent;
862                $membersSpent[$owerId] += $spent;
863                // membersPaidFor
864                $membersPaidFor[$payerId][$owerId] += $spent;
865                $membersPaidFor['total'][$owerId] += $spent;
866            }
867        }
868        foreach ($members as $member) {
869            $memberId = $member['id'];
870            $membersPaidFor[$memberId]['total'] = $membersPaid[$memberId];
871        }
872
873        // build global stats data
874        $statistics = [];
875        if ($currency === null) {
876            foreach ($membersToDisplay as $memberId => $member) {
877                $statistic = [
878                    'balance' => $membersBalance[$memberId],
879                    'filtered_balance' => $membersFilteredBalance[$memberId],
880                    'paid' => $membersPaid[$memberId],
881                    'spent' => $membersSpent[$memberId],
882                    'member' => $member
883                ];
884                $statistics[] = $statistic;
885            }
886        }
887        else {
888            foreach ($membersToDisplay as $memberId => $member) {
889                $statistic = [
890                    'balance' => ($membersBalance[$memberId] === 0.0) ? 0 : $membersBalance[$memberId] / $currency['exchange_rate'],
891                    'filtered_balance' => ($membersFilteredBalance[$memberId] === 0.0) ? 0 : $membersFilteredBalance[$memberId] / $currency['exchange_rate'],
892                    'paid' => ($membersPaid[$memberId] === 0.0) ? 0 : $membersPaid[$memberId] / $currency['exchange_rate'],
893                    'spent' => ($membersSpent[$memberId] === 0.0) ? 0 : $membersSpent[$memberId] / $currency['exchange_rate'],
894                    'member' => $member
895                ];
896                $statistics[] = $statistic;
897            }
898        }
899
900        // compute monthly member stats
901        $memberMonthlyPaidStats = [];
902        $memberMonthlySpentStats = [];
903        $allMembersKey = 0;
904        foreach ($bills as $bill) {
905            $payerId = $bill['payer_id'];
906            $amount = $bill['amount'];
907            $owers = $bill['owers'];
908            $date = DateTime::createFromFormat('U', $bill['timestamp']);
909            $date->setTimezone($timeZone);
910            $month = $date->format('Y-m');
911            //////////////// PAID
912            // initialize this month
913            if (!array_key_exists($month, $memberMonthlyPaidStats)) {
914                $memberMonthlyPaidStats[$month] = [];
915                foreach ($membersToDisplay as $memberId => $member) {
916                    $memberMonthlyPaidStats[$month][$memberId] = 0;
917                }
918                $memberMonthlyPaidStats[$month][$allMembersKey] = 0;
919            }
920
921            // add paid amount
922            if (array_key_exists($payerId, $membersToDisplay)) {
923                $memberMonthlyPaidStats[$month][$payerId] += $amount;
924                $memberMonthlyPaidStats[$month][$allMembersKey] += $amount;
925            }
926            //////////////// SPENT
927            // initialize this month
928            if (!array_key_exists($month, $memberMonthlySpentStats)) {
929                $memberMonthlySpentStats[$month] = [];
930                foreach ($membersToDisplay as $memberId => $member) {
931                    $memberMonthlySpentStats[$month][$memberId] = 0;
932                }
933                $memberMonthlySpentStats[$month][$allMembersKey] = 0;
934            }
935            // spent value for all members is the bill amount (like the paid value)
936            $memberMonthlySpentStats[$month][$allMembersKey] += $amount;
937            // compute number of shares
938            $nbOwerShares = 0.0;
939            foreach ($owers as $ower) {
940                $owerWeight = $ower['weight'];
941                if ($owerWeight === 0.0) {
942                    $owerWeight = 1.0;
943                }
944                $nbOwerShares += $owerWeight;
945            }
946            // compute how much each ower has spent
947            foreach ($owers as $ower) {
948                $owerWeight = $ower['weight'];
949                if ($owerWeight === 0.0) {
950                    $owerWeight = 1.0;
951                }
952                $owerId = $ower['id'];
953                $spent = $amount / $nbOwerShares * $owerWeight;
954                if (array_key_exists($owerId, $membersToDisplay)) {
955                    $memberMonthlySpentStats[$month][$owerId] += $spent;
956                }
957            }
958        }
959        // monthly paid and spent average
960        $averageKey = $this->trans->t('Average per month');
961        // number of months with actual bills
962        $nbMonth = count(array_keys($memberMonthlyPaidStats));
963        $realMonths = array_keys($memberMonthlyPaidStats);
964        if ($nbMonth > 0) {
965            ////////////////////// PAID
966            $averagePaidStats = [];
967            foreach ($membersToDisplay as $memberId => $member) {
968                $sum = 0;
969                foreach ($memberMonthlyPaidStats as $month => $mStat) {
970                    $sum += $memberMonthlyPaidStats[$month][$memberId];
971                }
972                $averagePaidStats[$memberId] = $sum / $nbMonth;
973            }
974            // average for all members
975            $sum = 0;
976            foreach ($memberMonthlyPaidStats as $month => $mStat) {
977                $sum += $memberMonthlyPaidStats[$month][$allMembersKey];
978            }
979            $averagePaidStats[$allMembersKey] = $sum / $nbMonth;
980
981            $memberMonthlyPaidStats[$averageKey] = $averagePaidStats;
982            ////////////////////// SPENT
983            $averageSpentStats = [];
984            foreach ($membersToDisplay as $memberId => $member) {
985                $sum = 0;
986                foreach ($memberMonthlySpentStats as $month => $mStat) {
987                    $sum += $memberMonthlySpentStats[$month][$memberId];
988                }
989                $averageSpentStats[$memberId] = $sum / $nbMonth;
990            }
991            // average for all members
992            $sum = 0;
993            foreach ($memberMonthlySpentStats as $month => $mStat) {
994                $sum += $memberMonthlySpentStats[$month][$allMembersKey];
995            }
996            $averageSpentStats[$allMembersKey] = $sum / $nbMonth;
997
998            $memberMonthlySpentStats[$averageKey] = $averageSpentStats;
999        }
1000        // convert if necessary
1001        if ($currency !== null) {
1002            foreach ($memberMonthlyPaidStats as $month => $mStat) {
1003                foreach ($mStat as $mid => $val) {
1004                    $memberMonthlyPaidStats[$month][$mid] = ($memberMonthlyPaidStats[$month][$mid] === 0.0)
1005                        ? 0
1006                        : $memberMonthlyPaidStats[$month][$mid] / $currency['exchange_rate'];
1007                }
1008            }
1009            foreach ($memberMonthlySpentStats as $month => $mStat) {
1010                foreach ($mStat as $mid => $val) {
1011                    $memberMonthlySpentStats[$month][$mid] = ($memberMonthlySpentStats[$month][$mid] === 0.0)
1012                        ? 0
1013                        : $memberMonthlySpentStats[$month][$mid] / $currency['exchange_rate'];
1014                }
1015            }
1016        }
1017        // compute category and payment mode stats
1018        $categoryStats = [];
1019        $paymentModeStats = [];
1020        foreach ($bills as $bill) {
1021            // category
1022            $billCategoryId = $bill['categoryid'];
1023            if (!array_key_exists(strval($billCategoryId), $this->hardCodedCategoryNames) &&
1024                !array_key_exists(strval($billCategoryId), $projectCategories)
1025            ) {
1026                $billCategoryId = 0;
1027            }
1028            $amount = $bill['amount'];
1029            if (!array_key_exists($billCategoryId, $categoryStats)) {
1030                $categoryStats[$billCategoryId] = 0;
1031            }
1032            $categoryStats[$billCategoryId] += $amount;
1033
1034            // payment mode
1035            $paymentModeId = $bill['paymentmodeid'];
1036            if (!array_key_exists(strval($paymentModeId), $projectPaymentModes)) {
1037                $paymentModeId = 0;
1038            }
1039            $amount = $bill['amount'];
1040            if (!array_key_exists($paymentModeId, $paymentModeStats)) {
1041                $paymentModeStats[$paymentModeId] = 0;
1042            }
1043            $paymentModeStats[$paymentModeId] += $amount;
1044        }
1045        // convert if necessary
1046        if ($currency !== null) {
1047            foreach ($categoryStats as $catId => $val) {
1048                $categoryStats[$catId] = ($val === 0.0) ? 0 : $val / $currency['exchange_rate'];
1049            }
1050            foreach ($paymentModeStats as $pmId => $val) {
1051                $paymentModeStats[$pmId] = ($val === 0.0) ? 0 : $val / $currency['exchange_rate'];
1052            }
1053        }
1054        // compute category per member stats
1055        $categoryMemberStats = [];
1056        foreach ($bills as $bill) {
1057            $payerId = $bill['payer_id'];
1058            $billCategoryId = $bill['categoryid'];
1059            if (!array_key_exists(strval($billCategoryId), $this->hardCodedCategoryNames) &&
1060                !array_key_exists(strval($billCategoryId), $projectCategories)
1061            ) {
1062                $billCategoryId = 0;
1063            }
1064            $amount = $bill['amount'];
1065            if (!array_key_exists($billCategoryId, $categoryMemberStats)) {
1066                $categoryMemberStats[$billCategoryId] = [];
1067                foreach ($membersToDisplay as $memberId => $member) {
1068                    $categoryMemberStats[$billCategoryId][$memberId] = 0;
1069                }
1070            }
1071            if (array_key_exists($payerId, $membersToDisplay)) {
1072                $categoryMemberStats[$billCategoryId][$payerId] += $amount;
1073            }
1074        }
1075        // convert if necessary
1076        if ($currency !== null) {
1077            foreach ($categoryMemberStats as $catId => $mStat) {
1078                foreach ($mStat as $mid => $val) {
1079                    $categoryMemberStats[$catId][$mid] = ($val === 0.0) ? 0 : $val / $currency['exchange_rate'];
1080                }
1081            }
1082        }
1083        // compute category/payment mode per month stats
1084        $categoryMonthlyStats = [];
1085        $paymentModeMonthlyStats = [];
1086        foreach ($bills as $bill) {
1087            $amount = $bill['amount'];
1088            $date = DateTime::createFromFormat('U', $bill['timestamp']);
1089            $date->setTimezone($timeZone);
1090            $month = $date->format('Y-m');
1091
1092            // category
1093            $billCategoryId = $bill['categoryid'];
1094            if (!array_key_exists($billCategoryId, $categoryMonthlyStats)) {
1095                $categoryMonthlyStats[$billCategoryId] = [];
1096            }
1097            if (!array_key_exists($month, $categoryMonthlyStats[$billCategoryId])) {
1098                $categoryMonthlyStats[$billCategoryId][$month] = 0;
1099            }
1100            $categoryMonthlyStats[$billCategoryId][$month] += $amount;
1101
1102            // payment mode
1103            $paymentModeId = $bill['paymentmodeid'];
1104            if (!array_key_exists($paymentModeId, $paymentModeMonthlyStats)) {
1105                $paymentModeMonthlyStats[$paymentModeId] = [];
1106            }
1107            if (!array_key_exists($month, $paymentModeMonthlyStats[$paymentModeId])) {
1108                $paymentModeMonthlyStats[$paymentModeId][$month] = 0;
1109            }
1110            $paymentModeMonthlyStats[$paymentModeId][$month] += $amount;
1111        }
1112        // average per month
1113        foreach ($categoryMonthlyStats as $catId => $monthValues) {
1114            $sum = 0;
1115            foreach ($monthValues as $month => $value) {
1116                $sum += $value;
1117            }
1118            $avg = $sum / $nbMonth;
1119            $categoryMonthlyStats[$catId][$averageKey] = $avg;
1120        }
1121        foreach ($paymentModeMonthlyStats as $pmId => $monthValues) {
1122            $sum = 0;
1123            foreach ($monthValues as $month => $value) {
1124                $sum += $value;
1125            }
1126            $avg = $sum / $nbMonth;
1127            $paymentModeMonthlyStats[$pmId][$averageKey] = $avg;
1128        }
1129        // convert if necessary
1130        if ($currency !== null) {
1131            foreach ($categoryMonthlyStats as $catId => $cStat) {
1132                foreach ($cStat as $month => $val) {
1133                    $categoryMonthlyStats[$catId][$month] = ($val === 0.0) ? 0 : $val / $currency['exchange_rate'];
1134                }
1135            }
1136            foreach ($paymentModeMonthlyStats as $pmId => $pmStat) {
1137                foreach ($pmStat as $month => $val) {
1138                    $paymentModeMonthlyStats[$pmId][$month] = ($val === 0.0) ? 0 : $val / $currency['exchange_rate'];
1139                }
1140            }
1141        }
1142
1143        return [
1144            'stats' => $statistics,
1145            'memberMonthlyPaidStats' => count($memberMonthlyPaidStats) > 0 ? $memberMonthlyPaidStats : null,
1146            'memberMonthlySpentStats' => count($memberMonthlySpentStats) > 0 ? $memberMonthlySpentStats : null,
1147            'categoryStats' => $categoryStats,
1148            'categoryMonthlyStats' => $categoryMonthlyStats,
1149            'paymentModeStats' => $paymentModeStats,
1150            'paymentModeMonthlyStats' => $paymentModeMonthlyStats,
1151            'categoryMemberStats' => $categoryMemberStats,
1152            'memberIds' => array_keys($membersToDisplay),
1153            'allMemberIds' => $allMembersIds,
1154            'membersPaidFor' => $membersPaidFor,
1155            'realMonths' => $realMonths,
1156        ];
1157    }
1158
1159    /**
1160     * Add a bill in a given project
1161     *
1162     * @param string $projectid
1163     * @param string|null $date
1164     * @param string|null $what
1165     * @param int|null $payer
1166     * @param string|null $payed_for
1167     * @param float|null $amount
1168     * @param string|null $repeat
1169     * @param string|null $paymentmode
1170     * @param int|null $paymentmodeid
1171     * @param int|null $categoryid
1172     * @param int|null $repeatallactive
1173     * @param string|null $repeatuntil
1174     * @param int|null $timestamp
1175     * @param string|null $comment
1176     * @param int|null $repeatfreq
1177     * @param array|null $paymentModes
1178     * @return array
1179     * @throws \OCP\DB\Exception
1180     */
1181    public function addBill(string $projectid, ?string $date, ?string $what, ?int $payer, ?string $payed_for,
1182                            ?float $amount, ?string $repeat, ?string $paymentmode = null, ?int $paymentmodeid = null,
1183                            ?int $categoryid = null, int $repeatallactive = 0, ?string $repeatuntil = null,
1184                            ?int $timestamp = null, ?string $comment = null, ?int $repeatfreq = null,
1185                            ?array $paymentModes = null): array {
1186        // if we don't have the payment modes, get them now
1187        if (is_null($paymentModes)) {
1188            $paymentModes = $this->getCategoriesOrPaymentModes($projectid, false);
1189        }
1190
1191        if ($repeat === null || $repeat === '' || strlen($repeat) !== 1) {
1192            return ['repeat' => $this->trans->t('Invalid value')];
1193        } elseif (!in_array($repeat, array_values(Application::FREQUENCIES))) {
1194            return ['repeat' => $this->trans->t('Invalid frequency')];
1195        }
1196        if ($repeatuntil !== null && $repeatuntil === '') {
1197            $repeatuntil = null;
1198        }
1199        // priority to timestamp (moneybuster might send both for a moment)
1200        if ($timestamp === null || !is_numeric($timestamp)) {
1201            if ($date === null || $date === '') {
1202                return ['message' => $this->trans->t('Timestamp (or date) field is required')];
1203            } else {
1204                $datetime = DateTime::createFromFormat('Y-m-d', $date);
1205                if ($datetime === false) {
1206                    return ['date' => $this->trans->t('Invalid date')];
1207                }
1208                $dateTs = $datetime->getTimestamp();
1209            }
1210        } else {
1211            $dateTs = (int) $timestamp;
1212        }
1213        if ($what === null) {
1214            $what = '';
1215        }
1216        if ($amount === null) {
1217            return ['amount' => $this->trans->t('This field is required')];
1218        }
1219        if ($payer === null) {
1220            return ['payer' => $this->trans->t('This field is required')];
1221        }
1222        if ($this->getMemberById($projectid, $payer) === null) {
1223            return ['payer' => $this->trans->t('Not a valid choice')];
1224        }
1225        // check owers
1226        $owerIds = explode(',', $payed_for);
1227        if ($payed_for === null || $payed_for === '' || count($owerIds) === 0) {
1228            return ['payed_for' => $this->trans->t('Invalid value')];
1229        }
1230        foreach ($owerIds as $owerId) {
1231            if (!is_numeric($owerId)) {
1232                return ['payed_for' => $this->trans->t('Invalid value')];
1233            }
1234            if ($this->getMemberById($projectid, $owerId) === null) {
1235                return ['payed_for' => $this->trans->t('Not a valid choice')];
1236            }
1237        }
1238        // payment mode
1239        if (!is_null($paymentmodeid)) {
1240            // is the old_id set for this payment mode? if yes, use it for old 'paymentmode' column
1241            $paymentmode = 'n';
1242            if (isset($paymentModes[$paymentmodeid], $paymentModes[$paymentmodeid]['old_id'])
1243                && $paymentModes[$paymentmodeid]['old_id'] !== null
1244                && $paymentModes[$paymentmodeid]['old_id'] !== ''
1245            ) {
1246                $paymentmode = $paymentModes[$paymentmodeid]['old_id'];
1247            }
1248        } elseif (!is_null($paymentmode)) {
1249            // is there a pm with this old id? if yes, use it for new id
1250            $paymentmodeid = 0;
1251            foreach ($paymentModes as $id => $pm) {
1252                if ($pm['old_id'] === $paymentmode) {
1253                    $paymentmodeid = $id;
1254                    break;
1255                }
1256            }
1257        }
1258
1259        // last modification timestamp is now
1260        $ts = (new DateTime())->getTimestamp();
1261
1262        // do it already !
1263        $qb = $this->db->getQueryBuilder();
1264        $qb->insert('cospend_bills')
1265            ->values([
1266                'projectid' => $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR),
1267                'what' => $qb->createNamedParameter($what, IQueryBuilder::PARAM_STR),
1268                'comment' => $qb->createNamedParameter($comment, IQueryBuilder::PARAM_STR),
1269                'timestamp' => $qb->createNamedParameter($dateTs, IQueryBuilder::PARAM_INT),
1270                'amount' => $qb->createNamedParameter($amount, IQueryBuilder::PARAM_STR),
1271                'payerid' => $qb->createNamedParameter($payer, IQueryBuilder::PARAM_INT),
1272                'repeat' => $qb->createNamedParameter($repeat, IQueryBuilder::PARAM_STR),
1273                'repeatallactive' => $qb->createNamedParameter($repeatallactive, IQueryBuilder::PARAM_INT),
1274                'repeatuntil' => $qb->createNamedParameter($repeatuntil, IQueryBuilder::PARAM_STR),
1275                'repeatfreq' => $qb->createNamedParameter($repeatfreq ?? 1, IQueryBuilder::PARAM_INT),
1276                'categoryid' => $qb->createNamedParameter($categoryid ?? 0, IQueryBuilder::PARAM_INT),
1277                'paymentmode' => $qb->createNamedParameter($paymentmode ?? 'n', IQueryBuilder::PARAM_STR),
1278                'paymentmodeid' => $qb->createNamedParameter($paymentmodeid ?? 0, IQueryBuilder::PARAM_INT),
1279                'lastchanged' => $qb->createNamedParameter($ts, IQueryBuilder::PARAM_INT)
1280            ]);
1281        $qb->executeStatement();
1282        $qb = $qb->resetQueryParts();
1283
1284        $insertedBillId = $qb->getLastInsertId();
1285
1286        // insert bill owers
1287        foreach ($owerIds as $owerId) {
1288            $qb->insert('cospend_bill_owers')
1289                ->values([
1290                    'billid' => $qb->createNamedParameter($insertedBillId, IQueryBuilder::PARAM_INT),
1291                    'memberid' => $qb->createNamedParameter($owerId, IQueryBuilder::PARAM_INT)
1292                ]);
1293            $qb->executeStatement();
1294            $qb = $qb->resetQueryParts();
1295        }
1296
1297        $this->updateProjectLastChanged($projectid, $ts);
1298
1299        return ['inserted_id' => $insertedBillId];
1300    }
1301
1302    /**
1303     * Delete a bill
1304     *
1305     * @param string $projectid
1306     * @param int $billid
1307     * @param bool $force Ignores any deletion protection and forces the deletion of the bill
1308     * @return array
1309     */
1310    public function deleteBill(string $projectid, int $billid, bool $force = false): array {
1311        if ($force === false) {
1312            $project = $this->getProjectInfo($projectid);
1313            if ($project['deletion_disabled']) {
1314                return ['message' => 'Forbidden'];
1315            }
1316        }
1317        $billToDelete = $this->getBill($projectid, $billid);
1318        if ($billToDelete !== null) {
1319            $this->deleteBillOwersOfBill($billid);
1320
1321            $qb = $this->db->getQueryBuilder();
1322            $qb->delete('cospend_bills')
1323                ->where(
1324                    $qb->expr()->eq('id', $qb->createNamedParameter($billid, IQueryBuilder::PARAM_INT))
1325                )
1326                ->andWhere(
1327                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
1328                );
1329            $qb->executeStatement();
1330            $qb->resetQueryParts();
1331
1332            $ts = (new DateTime())->getTimestamp();
1333            $this->updateProjectLastChanged($projectid, $ts);
1334
1335            return ['success' => true];
1336        } else {
1337            return ['message' => $this->trans->t('Not Found')];
1338        }
1339    }
1340
1341    /**
1342     * Get a member
1343     *
1344     * @param string $projectId
1345     * @param int $memberId
1346     * @return array|null
1347     */
1348    public function getMemberById(string $projectId, int $memberId): ?array {
1349        $member = null;
1350
1351        $qb = $this->db->getQueryBuilder();
1352        $qb->select('id', 'userid', 'name', 'weight', 'color', 'activated')
1353            ->from('cospend_members')
1354            ->where(
1355                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
1356            )
1357            ->andWhere(
1358                $qb->expr()->eq('id', $qb->createNamedParameter($memberId, IQueryBuilder::PARAM_INT))
1359            );
1360        $req = $qb->executeQuery();
1361
1362        while ($row = $req->fetch()) {
1363            $dbMemberId = (int) $row['id'];
1364            $dbWeight = (float) $row['weight'];
1365            $dbUserid = $row['userid'];
1366            $dbName = $row['name'];
1367            $dbActivated = (int) $row['activated'];
1368            $dbColor = $row['color'];
1369            if ($dbColor === null) {
1370                $av = $this->avatarManager->getGuestAvatar($dbName);
1371                $dbColor = $av->avatarBackgroundColor($dbName);
1372                $dbColor = [
1373                    'r' => $dbColor->red(),
1374                    'g' => $dbColor->green(),
1375                    'b' => $dbColor->blue(),
1376                ];
1377            } else {
1378                $dbColor = Utils::hexToRgb($dbColor);
1379            }
1380
1381            $member = [
1382                'activated' => ($dbActivated === 1),
1383                'userid' => $dbUserid,
1384                'name' => $dbName,
1385                'id' => $dbMemberId,
1386                'weight' => $dbWeight,
1387                'color' => $dbColor,
1388            ];
1389            break;
1390        }
1391        $req->closeCursor();
1392        $qb->resetQueryParts();
1393        return $member;
1394    }
1395
1396    /**
1397     * Get project info
1398     *
1399     * @param string $projectId
1400     * @return array|null
1401     * @throws \OCP\DB\Exception
1402     */
1403    public function getProjectById(string $projectId): ?array {
1404        $project = null;
1405
1406        $qb = $this->db->getQueryBuilder();
1407        $qb->select('id', 'userid', 'name', 'email', 'password', 'currencyname', 'autoexport', 'guestaccesslevel', 'lastchanged')
1408            ->from('cospend_projects')
1409            ->where(
1410                $qb->expr()->eq('id', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
1411            );
1412        $req = $qb->executeQuery();
1413
1414        while ($row = $req->fetch()){
1415            $dbId = $row['id'];
1416            $dbPassword = $row['password'];
1417            $dbName = $row['name'];
1418            $dbUserId = $row['userid'];
1419            $dbEmail = $row['email'];
1420            $dbCurrencyName = $row['currencyname'];
1421            $dbAutoexport = $row['autoexport'];
1422            $dbLastchanged = (int) $row['lastchanged'];
1423            $dbGuestAccessLevel = (int) $row['guestaccesslevel'];
1424            $project = [
1425                'id' => $dbId,
1426                'name' => $dbName,
1427                'userid' => $dbUserId,
1428                'password' => $dbPassword,
1429                'email' => $dbEmail,
1430                'lastchanged' => $dbLastchanged,
1431                'currencyname' => $dbCurrencyName,
1432                'autoexport' => $dbAutoexport,
1433                'guestaccesslevel' => $dbGuestAccessLevel,
1434            ];
1435            break;
1436        }
1437        $req->closeCursor();
1438        $qb->resetQueryParts();
1439        return $project;
1440    }
1441
1442    /**
1443     * Get bill info
1444     *
1445     * @param string $projectId
1446     * @param int $billId
1447     * @return array|null
1448     */
1449    public function getBill(string $projectId, int $billId): ?array {
1450        $bill = null;
1451        // get bill owers
1452        $billOwers = [];
1453        $billOwerIds = [];
1454
1455        $qb = $this->db->getQueryBuilder();
1456
1457        $qb->select('memberid', 'm.name', 'm.weight', 'm.activated')
1458            ->from('cospend_bill_owers', 'bo')
1459            ->innerJoin('bo', 'cospend_members', 'm', $qb->expr()->eq('bo.memberid', 'm.id'))
1460            ->where(
1461                $qb->expr()->eq('bo.billid', $qb->createNamedParameter($billId, IQueryBuilder::PARAM_INT))
1462            );
1463        $req = $qb->executeQuery();
1464
1465        while ($row = $req->fetch()){
1466            $dbWeight = (float) $row['weight'];
1467            $dbName = $row['name'];
1468            $dbActivated = (((int) $row['activated']) === 1);
1469            $dbOwerId= (int) $row['memberid'];
1470            $billOwers[] = [
1471                'id' => $dbOwerId,
1472                'weight' => $dbWeight,
1473                'name' => $dbName,
1474                'activated' => $dbActivated,
1475            ];
1476            $billOwerIds[] = $dbOwerId;
1477        }
1478        $req->closeCursor();
1479        $qb = $qb->resetQueryParts();
1480
1481        // get the bill
1482        $qb->select('id', 'what', 'comment', 'timestamp', 'amount', 'payerid', 'repeat',
1483            'repeatallactive', 'paymentmode', 'paymentmodeid', 'categoryid', 'repeatuntil', 'repeatfreq')
1484            ->from('cospend_bills')
1485            ->where(
1486                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
1487            )
1488            ->andWhere(
1489                $qb->expr()->eq('id', $qb->createNamedParameter($billId, IQueryBuilder::PARAM_INT))
1490            );
1491        $req = $qb->executeQuery();
1492        while ($row = $req->fetch()){
1493            $bill = [
1494                'id' => (int) $row['id'],
1495                'amount' => (float) $row['amount'],
1496                'what' => $row['what'],
1497                'comment' => $row['comment'],
1498                'date' => DateTime::createFromFormat('U', $row['timestamp'])->format('Y-m-d'),
1499                'timestamp' => (int) $row['timestamp'],
1500                'payer_id' => (int) $row['payerid'],
1501                'owers' => $billOwers,
1502                'owerIds' => $billOwerIds,
1503                'repeat' => $row['repeat'],
1504                'repeatallactive' => (int) $row['repeatallactive'],
1505                'repeatuntil' => $row['repeatuntil'],
1506                'repeatfreq' => (int) $row['repeatfreq'],
1507                'paymentmode' => $row['paymentmode'],
1508                'paymentmodeid' => (int) $row['paymentmodeid'],
1509                'categoryid' => (int) $row['categoryid'],
1510            ];
1511        }
1512        $req->closeCursor();
1513        $qb->resetQueryParts();
1514
1515        return $bill;
1516    }
1517
1518    /**
1519     * Delete bill owers of given bill
1520     *
1521     * @param int $billid
1522     * @return void
1523     */
1524    private function deleteBillOwersOfBill(int $billid): void {
1525        $qb = $this->db->getQueryBuilder();
1526        $qb->delete('cospend_bill_owers')
1527            ->where(
1528                $qb->expr()->eq('billid', $qb->createNamedParameter($billid, IQueryBuilder::PARAM_INT))
1529            );
1530        $qb->executeStatement();
1531        $qb->resetQueryParts();
1532    }
1533
1534    /**
1535     * Generate bills to automatically settle a project
1536     *
1537     * @param string $projectid
1538     * @param int|null $centeredOn
1539     * @param int $precision
1540     * @param int|null $maxTimestamp
1541     * @return array
1542     */
1543    public function autoSettlement(string $projectid, ?int $centeredOn = null, int $precision = 2, ?int $maxTimestamp = null): array {
1544        $settlement = $this->getProjectSettlement($projectid, $centeredOn, $maxTimestamp);
1545        $transactions = $settlement['transactions'];
1546        if (!is_array($transactions)) {
1547            return ['message' => $this->trans->t('Error when getting project settlement transactions')];
1548        }
1549
1550        $members = $this->getMembers($projectid);
1551        $memberIdToName = [];
1552        foreach ($members as $member) {
1553            $memberIdToName[$member['id']] = $member['name'];
1554        }
1555
1556        if ($maxTimestamp) {
1557            $ts = $maxTimestamp - 1;
1558        } else {
1559            $ts = (new DateTime())->getTimestamp();
1560        }
1561
1562        $paymentModes = [];
1563        foreach ($transactions as $transaction) {
1564            $fromId = $transaction['from'];
1565            $toId = $transaction['to'];
1566            $amount = round((float) $transaction['amount'], $precision);
1567            $billTitle = $memberIdToName[$fromId].' → '.$memberIdToName[$toId];
1568            $addBillResult = $this->addBill(
1569                $projectid, null, $billTitle, $fromId, $toId, $amount,
1570                Application::FREQUENCIES['no'], 'n', 0, Application::CAT_REIMBURSEMENT,
1571                0, null, $ts, null, null, $paymentModes
1572            );
1573            if (!isset($addBillResult['inserted_id'])) {
1574                return ['message' => $this->trans->t('Error when adding a bill')];
1575            }
1576        }
1577        return ['success' => true];
1578    }
1579
1580    /**
1581     * Get project settlement plan
1582     *
1583     * @param string $projectId
1584     * @param int|null $centeredOn
1585     * @param int|null $maxTimestamp
1586     * @return array
1587     */
1588    public function getProjectSettlement(string $projectId, ?int $centeredOn = null, ?int $maxTimestamp = null): array {
1589        $balances = $this->getBalance($projectId, $maxTimestamp);
1590        if ($centeredOn === null) {
1591            $transactions = $this->settle($balances);
1592        } else {
1593            $transactions = $this->centeredSettle($balances, $centeredOn);
1594        }
1595        return [
1596            'transactions' => $transactions,
1597            'balances' => $balances,
1598        ];
1599    }
1600
1601    /**
1602     * Get a settlement plan centered on a member
1603     *
1604     * @param array $balances
1605     * @param int $centeredOn
1606     * @return array
1607     */
1608    private function centeredSettle(array $balances, int $centeredOn): array {
1609        $transactions = [];
1610        foreach ($balances as $memberId => $balance) {
1611            if ($memberId !== $centeredOn) {
1612                if ($balance > 0.0) {
1613                    $transactions[] = [
1614                        'from' => $centeredOn,
1615                        'to' => $memberId,
1616                        'amount' => $balance
1617                    ];
1618                } elseif ($balance < 0.0) {
1619                    $transactions[] = [
1620                        'from' => $memberId,
1621                        'to' => $centeredOn,
1622                        'amount' => -$balance
1623                    ];
1624                }
1625            }
1626        }
1627        return $transactions;
1628    }
1629
1630    /**
1631     * Get optimal settlement of a balance list
1632     *
1633     * @param array $balances
1634     * @return array
1635     */
1636    private function settle(array $balances): ?array {
1637        $debitersCrediters = $this->orderBalance($balances);
1638        $debiters = $debitersCrediters[0];
1639        $crediters = $debitersCrediters[1];
1640        return $this->reduceBalance($crediters, $debiters);
1641    }
1642
1643    /**
1644     * Separate crediter and debiter balances
1645     *
1646     * @param array $balances
1647     * @return array
1648     */
1649    private function orderBalance(array $balances): array {
1650        $crediters = [];
1651        $debiters = [];
1652        foreach ($balances as $id => $balance) {
1653            if ($balance > 0.0) {
1654                $crediters[] = [$id, $balance];
1655            } elseif ($balance < 0.0) {
1656                $debiters[] = [$id, $balance];
1657            }
1658        }
1659
1660        return [$debiters, $crediters];
1661    }
1662
1663    /**
1664     * Recursively produce transaction list of the settlement plan
1665     *
1666     * @param array $crediters
1667     * @param array $debiters
1668     * @param array|null $results
1669     * @return array
1670     */
1671    private function reduceBalance(array $crediters, array $debiters, ?array $results = null): ?array {
1672        if (count($crediters) === 0 || count($debiters) === 0) {
1673            return $results;
1674        }
1675
1676        if ($results === null) {
1677            $results = [];
1678        }
1679
1680        $crediters = $this->sortCreditersDebiters($crediters);
1681        $debiters = $this->sortCreditersDebiters($debiters, true);
1682
1683        $deb = array_pop($debiters);
1684        $debiter = $deb[0];
1685        $debiterBalance = $deb[1];
1686
1687        $cred = array_pop($crediters);
1688        $crediter = $cred[0];
1689        $crediterBalance = $cred[1];
1690
1691        if (abs($debiterBalance) > abs($crediterBalance)) {
1692            $amount = abs($crediterBalance);
1693        } else {
1694            $amount = abs($debiterBalance);
1695        }
1696
1697        $newResults = $results;
1698        $newResults[] = ['to' => $crediter, 'amount' => $amount, 'from' => $debiter];
1699
1700        $newDebiterBalance = $debiterBalance + $amount;
1701        if ($newDebiterBalance < 0.0) {
1702            $debiters[] = [$debiter, $newDebiterBalance];
1703            $debiters = $this->sortCreditersDebiters($debiters, true);
1704        }
1705
1706        $newCrediterBalance = $crediterBalance - $amount;
1707        if ($newCrediterBalance > 0.0) {
1708            $crediters[] = [$crediter, $newCrediterBalance];
1709            $crediters = $this->sortCreditersDebiters($crediters);
1710        }
1711
1712        return $this->reduceBalance($crediters, $debiters, $newResults);
1713    }
1714
1715    /**
1716     * Sort crediters or debiters array by balance value
1717     *
1718     * @param array $arr
1719     * @param bool $reverse
1720     * @return array
1721     */
1722    private function sortCreditersDebiters(array $arr, bool $reverse = false): array {
1723        $res = [];
1724        if ($reverse) {
1725            foreach ($arr as $elem) {
1726                $i = 0;
1727                while ($i < count($res) && $elem[1] < $res[$i][1]) {
1728                    $i++;
1729                }
1730                array_splice($res, $i, 0, [$elem]);
1731            }
1732        } else {
1733            foreach ($arr as $elem) {
1734                $i = 0;
1735                while ($i < count($res) && $elem[1] >= $res[$i][1]) {
1736                    $i++;
1737                }
1738                array_splice($res, $i, 0, [$elem]);
1739            }
1740        }
1741        return $res;
1742    }
1743
1744    /**
1745     * Edit a member
1746     *
1747     * @param string $projectid
1748     * @param int $memberid
1749     * @param string|null $name
1750     * @param string|null $userid
1751     * @param float|null $weight
1752     * @param bool $activated
1753     * @param string|null $color
1754     * @return array
1755     */
1756    public function editMember(string $projectid, int $memberid, ?string $name = null, ?string $userid = null,
1757                               ?float $weight = null, ?bool $activated = null, ?string $color = null): array {
1758        $member = $this->getMemberById($projectid, $memberid);
1759        if (!is_null($member)) {
1760            $qb = $this->db->getQueryBuilder();
1761            // delete member if it has no bill and we are disabling it
1762            if ($member['activated']
1763                && (!is_null($activated) && $activated === false)
1764                && count($this->getBillsOfMember($memberid)) === 0
1765            ) {
1766                $qb->delete('cospend_members')
1767                    ->where(
1768                        $qb->expr()->eq('id', $qb->createNamedParameter($memberid, IQueryBuilder::PARAM_INT))
1769                    );
1770                $qb->executeStatement();
1771                $qb->resetQueryParts();
1772                return [];
1773            }
1774
1775            if (!is_null($name)) {
1776                if (strpos($name, '/') !== false) {
1777                    return ['name' => $this->trans->t('Invalid member name')];
1778                } else {
1779                    // get existing member with this name
1780                    $memberWithSameName = $this->getMemberByName($projectid, $name);
1781                    if ($memberWithSameName && $memberWithSameName['id'] !== $memberid) {
1782                        return ['name' => $this->trans->t('Name already exists')];
1783                    }
1784                }
1785            }
1786
1787            if ($color !== null) {
1788                $color = preg_replace('/^#/', '', $color);
1789                if ($color === ''
1790                    || ((strlen($color) === 3 || strlen($color) === 6)
1791                        && preg_match('/^[0-9A-Fa-f]+/', $color) !== false)
1792                ) {
1793                    // fine
1794                } else {
1795                    return ['color' => $this->trans->t('Invalid value')];
1796                }
1797            }
1798
1799            $qb->update('cospend_members');
1800            if ($weight !== null) {
1801                if ($weight > 0.0) {
1802                    $qb->set('weight', $qb->createNamedParameter($weight, IQueryBuilder::PARAM_STR));
1803                } else {
1804                    return ['weight' => $this->trans->t('Not a valid decimal value')];
1805                }
1806            }
1807            if (!is_null($activated)) {
1808                $qb->set('activated', $qb->createNamedParameter(($activated ? 1 : 0), IQueryBuilder::PARAM_INT));
1809            }
1810
1811            $ts = (new DateTime())->getTimestamp();
1812            $qb->set('lastchanged', $qb->createNamedParameter($ts, IQueryBuilder::PARAM_INT));
1813
1814            if (!is_null($name)) {
1815                $qb->set('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR));
1816            }
1817            if ($color !== null) {
1818                if ($color === '') {
1819                    $qb->set('color', $qb->createNamedParameter(null, IQueryBuilder::PARAM_STR));
1820                } else {
1821                    $qb->set('color', $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR));
1822                }
1823            }
1824            if ($userid !== null) {
1825                if ($userid === '') {
1826                    $qb->set('userid', $qb->createNamedParameter(null, IQueryBuilder::PARAM_STR));
1827                } else {
1828                    $qb->set('userid', $qb->createNamedParameter($userid, IQueryBuilder::PARAM_STR));
1829                }
1830            }
1831            $qb->where(
1832                $qb->expr()->eq('id', $qb->createNamedParameter($memberid, IQueryBuilder::PARAM_INT))
1833            )
1834                ->andWhere(
1835                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
1836                );
1837            $qb->executeStatement();
1838            $qb->resetQueryParts();
1839
1840            return $this->getMemberById($projectid, $memberid);
1841        } else {
1842            return ['name' => $this->trans->t('This project have no such member')];
1843        }
1844    }
1845
1846    /**
1847     * Edit a project
1848     *
1849     * @param string $projectid
1850     * @param string|null $name
1851     * @param string|null $contact_email
1852     * @param string|null $password
1853     * @param string|null $autoexport
1854     * @param string|null $currencyname
1855     * @param bool|null $deletion_disabled
1856     * @param string|null $categorysort
1857     * @param string|null $paymentmodesort
1858     * @return array
1859     * @throws \OCP\DB\Exception
1860     */
1861    public function editProject(string $projectid, ?string $name = null, ?string $contact_email = null, ?string $password = null,
1862                                ?string $autoexport = null, ?string $currencyname = null, ?bool $deletion_disabled = null,
1863                                ?string $categorysort = null, ?string $paymentmodesort = null): array {
1864        $qb = $this->db->getQueryBuilder();
1865        $qb->update('cospend_projects');
1866
1867        if ($name !== null) {
1868            if ($name === '') {
1869                return ['name' => [$this->trans->t('Name can\'t be empty')]];
1870            }
1871            $qb->set('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR));
1872        }
1873
1874        if ($contact_email !== null && $contact_email !== '') {
1875            if (filter_var($contact_email, FILTER_VALIDATE_EMAIL)) {
1876                $qb->set('email', $qb->createNamedParameter($contact_email, IQueryBuilder::PARAM_STR));
1877            } else {
1878                return ['contact_email' => [$this->trans->t('Invalid email address')]];
1879            }
1880        }
1881        if ($password !== null && $password !== '') {
1882            $dbPassword = password_hash($password, PASSWORD_DEFAULT);
1883            $qb->set('password', $qb->createNamedParameter($dbPassword, IQueryBuilder::PARAM_STR));
1884        }
1885        if ($autoexport !== null && $autoexport !== '') {
1886            if (in_array($autoexport, array_values(Application::FREQUENCIES))) {
1887                $qb->set('autoexport', $qb->createNamedParameter($autoexport, IQueryBuilder::PARAM_STR));
1888            } else {
1889                return ['autoexport' => [$this->trans->t('Invalid frequency')]];
1890            }
1891        }
1892        if ($categorysort !== null && $categorysort !== '') {
1893            if (in_array($categorysort, array_values(Application::SORT_ORDERS))) {
1894                $qb->set('categorysort', $qb->createNamedParameter($categorysort, IQueryBuilder::PARAM_STR));
1895            } else {
1896                return ['categorysort' => [$this->trans->t('Invalid sort order')]];
1897            }
1898        }
1899        if ($paymentmodesort !== null && $paymentmodesort !== '') {
1900            if (in_array($paymentmodesort, array_values(Application::SORT_ORDERS))) {
1901                $qb->set('paymentmodesort', $qb->createNamedParameter($paymentmodesort, IQueryBuilder::PARAM_STR));
1902            } else {
1903                return ['paymentmodesort' => [$this->trans->t('Invalid sort order')]];
1904            }
1905        }
1906        if ($deletion_disabled !== null) {
1907            $qb->set('deletiondisabled', $qb->createNamedParameter($deletion_disabled ? 1 : 0, IQueryBuilder::PARAM_INT));
1908        }
1909        if ($currencyname !== null) {
1910            if ($currencyname === '') {
1911                $qb->set('currencyname', $qb->createNamedParameter(null, IQueryBuilder::PARAM_STR));
1912            } else {
1913                $qb->set('currencyname', $qb->createNamedParameter($currencyname, IQueryBuilder::PARAM_STR));
1914            }
1915        }
1916        if ($this->getProjectById($projectid) !== null) {
1917            $ts = (new DateTime())->getTimestamp();
1918            $qb->set('lastchanged', $qb->createNamedParameter($ts, IQueryBuilder::PARAM_INT));
1919            $qb->where(
1920                $qb->expr()->eq('id', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
1921            );
1922            $qb->executeStatement();
1923            $qb->resetQueryParts();
1924
1925            return ['success' => true];
1926        } else {
1927            return ['message' => $this->trans->t('There is no such project')];
1928        }
1929    }
1930
1931    /**
1932     * Add a member to a project
1933     *
1934     * @param string $projectid
1935     * @param string $name
1936     * @param float|null $weight
1937     * @param bool $active
1938     * @param string|null $color
1939     * @param string|null $userid
1940     * @return array
1941     * @throws \OCP\DB\Exception
1942     */
1943    public function addMember(string $projectid, string $name, ?float $weight = 1.0, bool $active = true,
1944                              ?string $color = null, ?string $userid = null): array {
1945        if ($name !== null && $name !== '') {
1946            if ($this->getMemberByName($projectid, $name) === null && $this->getMemberByUserid($projectid, $userid) === null) {
1947                if (strpos($name, '/') !== false) {
1948                    return ['error' => $this->trans->t('Invalid member name')];
1949                }
1950
1951                $weightToInsert = 1.0;
1952                if ($weight !== null) {
1953                    if ($weight > 0.0) {
1954                        $weightToInsert = $weight;
1955                    } else {
1956                        return ['error' => $this->trans->t('Weight is not a valid decimal value')];
1957                    }
1958                }
1959
1960                if ($color !== null) {
1961                    if ($color === '') {
1962                        $color = null;
1963                    } elseif ((strlen($color) === 4 || strlen($color) === 7)
1964                        && preg_match('/^#[0-9A-Fa-f]+/', $color) !== false
1965                    ) {
1966                        // fine
1967                    } else {
1968                        return ['error' => $this->trans->t('Invalid color value')];
1969                    }
1970                }
1971
1972                $ts = (new DateTime())->getTimestamp();
1973
1974                $qb = $this->db->getQueryBuilder();
1975                $qb->insert('cospend_members')
1976                    ->values([
1977                        'projectid' => $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR),
1978                        'userid' => $qb->createNamedParameter($userid, IQueryBuilder::PARAM_STR),
1979                        'name' => $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR),
1980                        'weight' => $qb->createNamedParameter($weightToInsert, IQueryBuilder::PARAM_STR),
1981                        'activated' => $qb->createNamedParameter($active ? 1 : 0, IQueryBuilder::PARAM_INT),
1982                        'color' => $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR),
1983                        'lastchanged' => $qb->createNamedParameter($ts, IQueryBuilder::PARAM_INT)
1984                    ]);
1985                $qb->executeStatement();
1986                $qb->resetQueryParts();
1987
1988                return $this->getMemberByName($projectid, $name);
1989            } else {
1990                return ['error' => $this->trans->t('This project already has this member')];
1991            }
1992        } else {
1993            return ['error' => $this->trans->t('Name field is required')];
1994        }
1995    }
1996
1997    /**
1998     * Get number of bills in a project
1999     *
2000     * @param string $projectId
2001     * @param int|null $payerId
2002     * @return int
2003     * @throws \OCP\DB\Exception
2004     */
2005    public function getNbBills(string $projectId, ?int $payerId = null, ?int $categoryId = null, ?int $paymentModeId = null): int {
2006        $nb = 0;
2007        $qb = $this->db->getQueryBuilder();
2008        $qb->selectAlias($qb->createFunction('COUNT(*)'), 'count_bills')
2009            ->from('cospend_bills', 'bi')
2010            ->where(
2011                $qb->expr()->eq('bi.projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
2012            );
2013        if ($payerId !== null) {
2014            $qb->andWhere(
2015                $qb->expr()->eq('payerid', $qb->createNamedParameter($payerId, IQueryBuilder::PARAM_INT))
2016            );
2017        }
2018        if ($categoryId !== null) {
2019            $qb->andWhere(
2020                $qb->expr()->eq('categoryid', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT))
2021            );
2022        }
2023        if ($paymentModeId !== null) {
2024            $qb->andWhere(
2025                $qb->expr()->eq('paymentmodeid', $qb->createNamedParameter($paymentModeId, IQueryBuilder::PARAM_INT))
2026            );
2027        }
2028        $req = $qb->executeQuery();
2029        while ($row = $req->fetch()) {
2030            $nb = (int) $row['count_bills'];
2031        }
2032        return $nb;
2033    }
2034
2035    /**
2036     * Get filtered list of bills for a project
2037     *
2038     * @param string $projectId
2039     * @param int|null $tsMin
2040     * @param int|null $tsMax
2041     * @param string|null $paymentMode
2042     * @param int|null $category
2043     * @param float|null $amountMin
2044     * @param float|null $amountMax
2045     * @param int|null $lastchanged
2046     * @param int|null $limit
2047     * @param bool $reverse
2048     * @param int $offset
2049     * @return array
2050     */
2051    public function getBillsWithLimit(string $projectId, ?int $tsMin = null, ?int $tsMax = null,
2052                                      ?string $paymentMode = null, ?int $paymentModeId = null,
2053                                      ?int $category = null, ?float $amountMin = null, ?float $amountMax = null,
2054                                      ?int $lastchanged = null, ?int $limit = null,
2055                                      bool $reverse = false, ?int $offset = 0, ?int $payerId = null,
2056                                      ?int $includeBillId = null, ?string $searchTerm = null): array {
2057        $qb = $this->db->getQueryBuilder();
2058        $qb->select('id', 'what', 'comment', 'timestamp', 'amount', 'payerid', 'repeat',
2059            'paymentmode', 'paymentmodeid', 'categoryid', 'lastchanged', 'repeatallactive',
2060            'repeatuntil', 'repeatfreq')
2061            ->from('cospend_bills', 'bi')
2062            ->where(
2063                $qb->expr()->eq('bi.projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
2064            );
2065        // take bills that have changed after $lastchanged
2066        if ($lastchanged !== null) {
2067            $qb->andWhere(
2068                $qb->expr()->gt('bi.lastchanged', $qb->createNamedParameter($lastchanged, IQueryBuilder::PARAM_INT))
2069            );
2070        }
2071        if ($payerId !== null) {
2072            $qb->andWhere(
2073                $qb->expr()->eq('payerid', $qb->createNamedParameter($payerId, IQueryBuilder::PARAM_INT))
2074            );
2075        }
2076        if ($tsMin !== null) {
2077            $qb->andWhere(
2078                $qb->expr()->gte('timestamp', $qb->createNamedParameter($tsMin, IQueryBuilder::PARAM_INT))
2079            );
2080        }
2081        if ($tsMax !== null) {
2082            $qb->andWhere(
2083                $qb->expr()->lte('timestamp', $qb->createNamedParameter($tsMax, IQueryBuilder::PARAM_INT))
2084            );
2085        }
2086        if ($paymentMode !== null && $paymentMode !== '' && $paymentMode !== 'n') {
2087            $qb->andWhere(
2088                $qb->expr()->eq('paymentmode', $qb->createNamedParameter($paymentMode, IQueryBuilder::PARAM_STR))
2089            );
2090        } elseif (!is_null($paymentModeId)) {
2091            $qb->andWhere(
2092                $qb->expr()->eq('paymentmodeid', $qb->createNamedParameter($paymentModeId, IQueryBuilder::PARAM_INT))
2093            );
2094        }
2095        if ($category !== null) {
2096            if ($category === -100) {
2097                $or = $qb->expr()->orx();
2098                $or->add($qb->expr()->isNull('categoryid'));
2099                $or->add($qb->expr()->neq('categoryid', $qb->createNamedParameter(Application::CAT_REIMBURSEMENT, IQueryBuilder::PARAM_INT)));
2100                $qb->andWhere($or);
2101            } else {
2102                $qb->andWhere(
2103                    $qb->expr()->eq('categoryid', $qb->createNamedParameter($category, IQueryBuilder::PARAM_INT))
2104                );
2105            }
2106        }
2107        if ($amountMin !== null) {
2108            $qb->andWhere(
2109                $qb->expr()->gte('amount', $qb->createNamedParameter($amountMin, IQueryBuilder::PARAM_STR))
2110            );
2111        }
2112        if ($amountMax !== null) {
2113            $qb->andWhere(
2114                $qb->expr()->lte('amount', $qb->createNamedParameter($amountMax, IQueryBuilder::PARAM_STR))
2115            );
2116        }
2117        // handle the search term (what, comment, amount+-1)
2118        if ($searchTerm !== null && $searchTerm !== '') {
2119            $qb = $this->applyBillSearchTermCondition($qb, $searchTerm, 'bi');
2120        }
2121        if ($reverse) {
2122            $qb->orderBy('timestamp', 'DESC');
2123        } else {
2124            $qb->orderBy('timestamp', 'ASC');
2125        }
2126        if ($limit) {
2127            $qb->setMaxResults($limit);
2128        }
2129        if ($offset) {
2130            $qb->setFirstResult($offset);
2131        }
2132        $req = $qb->executeQuery();
2133
2134        $bills = [];
2135        $includeBillFound = false;
2136        while ($row = $req->fetch()){
2137            if ($includeBillId !== null && $includeBillId === (int) $row['id']) {
2138                $includeBillFound = true;
2139            }
2140            $bills[] = $this->getBillFromRow($row);
2141        }
2142        $req->closeCursor();
2143
2144        // look further if we want to include a specific bill
2145        if ($includeBillId !== null && $includeBillFound === false && $limit && $offset === 0) {
2146            $lastResultCount = count($bills);
2147            while ($lastResultCount > 0 && $includeBillFound === false) {
2148                $offset = $offset + $limit;
2149                $qb->setFirstResult($offset);
2150                $req = $qb->executeQuery();
2151                $lastResultCount = 0;
2152                while ($row = $req->fetch()){
2153                    if ($includeBillId === (int) $row['id']) {
2154                        $includeBillFound = true;
2155                    }
2156                    $lastResultCount++;
2157                    $bills[] = $this->getBillFromRow($row);
2158                }
2159                $req->closeCursor();
2160            }
2161        }
2162
2163        $qb = $qb->resetQueryParts();
2164
2165        // get owers
2166        foreach ($bills as $i => $bill) {
2167            $billId = $bill['id'];
2168            $billOwers = [];
2169            $billOwerIds = [];
2170
2171            $qb->select('memberid', 'm.name', 'm.weight', 'm.activated')
2172                ->from('cospend_bill_owers', 'bo')
2173                ->innerJoin('bo', 'cospend_members', 'm', $qb->expr()->eq('bo.memberid', 'm.id'))
2174                ->where(
2175                    $qb->expr()->eq('bo.billid', $qb->createNamedParameter($billId, IQueryBuilder::PARAM_INT))
2176                );
2177            $qb->setFirstResult(0);
2178            $req = $qb->executeQuery();
2179            while ($row = $req->fetch()){
2180                $dbWeight = (float) $row['weight'];
2181                $dbName = $row['name'];
2182                $dbActivated = ((int) $row['activated']) === 1;
2183                $dbOwerId= (int) $row['memberid'];
2184                $billOwers[] = [
2185                    'id' => $dbOwerId,
2186                    'weight' => $dbWeight,
2187                    'name' => $dbName,
2188                    'activated' => $dbActivated,
2189                ];
2190                $billOwerIds[] = $dbOwerId;
2191            }
2192            $req->closeCursor();
2193            $qb = $qb->resetQueryParts();
2194            $bills[$i]['owers'] = $billOwers;
2195            $bills[$i]['owerIds'] = $billOwerIds;
2196        }
2197
2198        return $bills;
2199    }
2200
2201    private function getBillFromRow(array $row): array {
2202        $dbBillId = (int) $row['id'];
2203        $dbAmount = (float) $row['amount'];
2204        $dbWhat = $row['what'];
2205        $dbComment = $row['comment'];
2206        $dbTimestamp = (int) $row['timestamp'];
2207        $dbDate = DateTime::createFromFormat('U', $dbTimestamp);
2208        $dbRepeat = $row['repeat'];
2209        $dbPayerId = (int) $row['payerid'];
2210        $dbPaymentMode = $row['paymentmode'];
2211        $dbPaymentModeId = (int) $row['paymentmodeid'];
2212        $dbCategoryId = (int) $row['categoryid'];
2213        $dbLastchanged = (int) $row['lastchanged'];
2214        $dbRepeatAllActive = (int) $row['repeatallactive'];
2215        $dbRepeatUntil = $row['repeatuntil'];
2216        $dbRepeatFreq = (int) $row['repeatfreq'];
2217        return [
2218            'id' => $dbBillId,
2219            'amount' => $dbAmount,
2220            'what' => $dbWhat,
2221            'comment' => $dbComment,
2222            'timestamp' => $dbTimestamp,
2223            'date' => $dbDate->format('Y-m-d'),
2224            'payer_id' => $dbPayerId,
2225            'owers' => [],
2226            'owerIds' => [],
2227            'repeat' => $dbRepeat,
2228            'paymentmode' => $dbPaymentMode,
2229            'paymentmodeid' => $dbPaymentModeId,
2230            'categoryid' => $dbCategoryId,
2231            'lastchanged' => $dbLastchanged,
2232            'repeatallactive' => $dbRepeatAllActive,
2233            'repeatuntil' => $dbRepeatUntil,
2234            'repeatfreq' => $dbRepeatFreq,
2235        ];
2236    }
2237
2238    /**
2239     * Get filtered list of bills for a project
2240     *
2241     * @param string $projectId
2242     * @param int|null $tsMin
2243     * @param int|null $tsMax
2244     * @param string|null $paymentMode
2245     * @param int|null $category
2246     * @param float|null $amountMin
2247     * @param float|null $amountMax
2248     * @param int|null $lastchanged
2249     * @param int|null $limit
2250     * @param bool $reverse
2251     * @param int|null $payerId
2252     * @return array
2253     * @throws \OCP\DB\Exception
2254     */
2255    public function getBills(string $projectId, ?int $tsMin = null, ?int $tsMax = null,
2256                             ?string $paymentMode = null, ?int $paymentModeId = null,
2257                             ?int $category = null, ?float $amountMin = null, ?float $amountMax = null,
2258                             ?int $lastchanged = null, ?int $limit = null,
2259                             bool $reverse = false, ?int $payerId = null): array {
2260        $qb = $this->db->getQueryBuilder();
2261        $qb->select('bi.id', 'what', 'comment', 'timestamp', 'amount', 'payerid', 'repeat',
2262            'paymentmode', 'paymentmodeid', 'categoryid', 'bi.lastchanged', 'repeatallactive', 'repeatuntil', 'repeatfreq',
2263            'memberid', 'm.name', 'm.weight', 'm.activated')
2264            ->from('cospend_bill_owers', 'bo')
2265            ->innerJoin('bo', 'cospend_bills', 'bi', $qb->expr()->eq('bo.billid', 'bi.id'))
2266            ->innerJoin('bo', 'cospend_members', 'm', $qb->expr()->eq('bo.memberid', 'm.id'))
2267            ->where(
2268                $qb->expr()->eq('bi.projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
2269            );
2270        // take bills that have changed after $lastchanged
2271        if ($lastchanged !== null) {
2272            $qb->andWhere(
2273                $qb->expr()->gt('bi.lastchanged', $qb->createNamedParameter($lastchanged, IQueryBuilder::PARAM_INT))
2274            );
2275        }
2276        if ($payerId !== null) {
2277            $qb->andWhere(
2278                $qb->expr()->eq('bi.payerid', $qb->createNamedParameter($payerId, IQueryBuilder::PARAM_INT))
2279            );
2280        }
2281        if ($tsMin !== null) {
2282            $qb->andWhere(
2283                $qb->expr()->gte('timestamp', $qb->createNamedParameter($tsMin, IQueryBuilder::PARAM_INT))
2284            );
2285        }
2286        if ($tsMax !== null) {
2287            $qb->andWhere(
2288                $qb->expr()->lte('timestamp', $qb->createNamedParameter($tsMax, IQueryBuilder::PARAM_INT))
2289            );
2290        }
2291        if ($paymentMode !== null && $paymentMode !== '' && $paymentMode !== 'n') {
2292            $qb->andWhere(
2293                $qb->expr()->eq('paymentmode', $qb->createNamedParameter($paymentMode, IQueryBuilder::PARAM_STR))
2294            );
2295        } elseif (!is_null($paymentModeId)) {
2296            $qb->andWhere(
2297                $qb->expr()->eq('paymentmodeid', $qb->createNamedParameter($paymentModeId, IQueryBuilder::PARAM_INT))
2298            );
2299        }
2300        if ($category !== null) {
2301            if ($category === -100) {
2302                $or = $qb->expr()->orx();
2303                $or->add($qb->expr()->isNull('categoryid'));
2304                $or->add($qb->expr()->neq('categoryid', $qb->createNamedParameter(Application::CAT_REIMBURSEMENT, IQueryBuilder::PARAM_INT)));
2305                $qb->andWhere($or);
2306            } else {
2307                $qb->andWhere(
2308                    $qb->expr()->eq('categoryid', $qb->createNamedParameter($category, IQueryBuilder::PARAM_INT))
2309                );
2310            }
2311        }
2312        if ($amountMin !== null) {
2313            $qb->andWhere(
2314                $qb->expr()->gte('amount', $qb->createNamedParameter($amountMin, IQueryBuilder::PARAM_STR))
2315            );
2316        }
2317        if ($amountMax !== null) {
2318            $qb->andWhere(
2319                $qb->expr()->lte('amount', $qb->createNamedParameter($amountMax, IQueryBuilder::PARAM_STR))
2320            );
2321        }
2322        if ($reverse) {
2323            $qb->orderBy('timestamp', 'DESC');
2324        } else {
2325            $qb->orderBy('timestamp', 'ASC');
2326        }
2327        if ($limit) {
2328            $qb->setMaxResults($limit);
2329        }
2330        $req = $qb->executeQuery();
2331
2332        // bills by id
2333        $billDict = [];
2334        // ordered list of bill ids
2335        $orderedBillIds = [];
2336        while ($row = $req->fetch()){
2337            $dbBillId = (int) $row['id'];
2338            // if first time we see the bill : add it to bill list
2339            if (!array_key_exists($dbBillId, $billDict)) {
2340                $dbAmount = (float) $row['amount'];
2341                $dbWhat = $row['what'];
2342                $dbComment = $row['comment'];
2343                $dbTimestamp = (int) $row['timestamp'];
2344                $dbDate = DateTime::createFromFormat('U', $dbTimestamp);
2345                $dbRepeat = $row['repeat'];
2346                $dbPayerId = (int) $row['payerid'];
2347                $dbPaymentMode = $row['paymentmode'];
2348                $dbPaymentModeId = (int) $row['paymentmodeid'];
2349                $dbCategoryId = (int) $row['categoryid'];
2350                $dbLastchanged = (int) $row['lastchanged'];
2351                $dbRepeatAllActive = (int) $row['repeatallactive'];
2352                $dbRepeatUntil = $row['repeatuntil'];
2353                $dbRepeatFreq = (int) $row['repeatfreq'];
2354                $billDict[$dbBillId] = [
2355                    'id' => $dbBillId,
2356                    'amount' => $dbAmount,
2357                    'what' => $dbWhat,
2358                    'comment' => $dbComment,
2359                    'timestamp' => $dbTimestamp,
2360                    'date' => $dbDate->format('Y-m-d'),
2361                    'payer_id' => $dbPayerId,
2362                    'owers' => [],
2363                    'owerIds' => [],
2364                    'repeat' => $dbRepeat,
2365                    'paymentmode' => $dbPaymentMode,
2366                    'paymentmodeid' => $dbPaymentModeId,
2367                    'categoryid' => $dbCategoryId,
2368                    'lastchanged' => $dbLastchanged,
2369                    'repeatallactive' => $dbRepeatAllActive,
2370                    'repeatuntil' => $dbRepeatUntil,
2371                    'repeatfreq' => $dbRepeatFreq,
2372                ];
2373                // keep order of bills
2374                $orderedBillIds[] = $dbBillId;
2375            }
2376            // anyway add an ower
2377            $dbWeight = (float) $row['weight'];
2378            $dbName = $row['name'];
2379            $dbActivated = ((int) $row['activated']) === 1;
2380            $dbOwerId= (int) $row['memberid'];
2381            $billDict[$dbBillId]['owers'][] = [
2382                'id' => $dbOwerId,
2383                'weight' => $dbWeight,
2384                'name' => $dbName,
2385                'activated' => $dbActivated,
2386            ];
2387            $billDict[$dbBillId]['owerIds'][] = $dbOwerId;
2388        }
2389        $req->closeCursor();
2390        $qb->resetQueryParts();
2391
2392        $resultBills = [];
2393        foreach ($orderedBillIds as $bid) {
2394            $resultBills[] = $billDict[$bid];
2395        }
2396
2397        return $resultBills;
2398    }
2399
2400    /**
2401     * Get all bill IDs of a project
2402     *
2403     * @param string $projectId
2404     * @return array
2405     */
2406    public function getAllBillIds(string $projectId): array {
2407        $billIds = [];
2408        $qb = $this->db->getQueryBuilder();
2409        $qb->select('id')
2410            ->from('cospend_bills', 'b')
2411            ->where(
2412                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
2413            );
2414        $req = $qb->executeQuery();
2415
2416        while ($row = $req->fetch()){
2417            $billIds[] = (int) $row['id'];
2418        }
2419        $req->closeCursor();
2420        $qb->resetQueryParts();
2421
2422        return $billIds;
2423    }
2424
2425    /**
2426     * Get members of a project
2427     *
2428     * @param string $projectId
2429     * @param string|null $order
2430     * @param int|null $lastchanged
2431     * @return array
2432     */
2433    public function getMembers(string $projectId, ?string $order = null, ?int $lastchanged = null): array {
2434        $members = [];
2435        $qb = $this->db->getQueryBuilder();
2436
2437        $sqlOrder = 'name';
2438        if ($order !== null) {
2439            if ($order === 'lowername') {
2440                $sqlOrder = $qb->func()->lower('name');
2441            } else {
2442                $sqlOrder = $order;
2443            }
2444        }
2445
2446        $qb->select('id', 'userid', 'name', 'weight', 'color', 'activated', 'lastchanged')
2447            ->from('cospend_members')
2448            ->where(
2449                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
2450            );
2451        if ($lastchanged !== null) {
2452            $qb->andWhere(
2453                $qb->expr()->gt('lastchanged', $qb->createNamedParameter($lastchanged, IQueryBuilder::PARAM_INT))
2454            );
2455        }
2456        $qb->orderBy($sqlOrder, 'ASC');
2457        $req = $qb->executeQuery();
2458
2459        while ($row = $req->fetch()){
2460            $dbMemberId = (int) $row['id'];
2461            $dbWeight = (float) $row['weight'];
2462            $dbUserid = $row['userid'];
2463            $dbName = $row['name'];
2464            $dbActivated = (int) $row['activated'];
2465            $dbLastchanged = (int) $row['lastchanged'];
2466            $dbColor = $row['color'];
2467            if ($dbColor === null) {
2468                $av = $this->avatarManager->getGuestAvatar($dbName);
2469                $avatarBgColor = $av->avatarBackgroundColor($dbName);
2470                $dbColor = [
2471                    'r' => $avatarBgColor->red(),
2472                    'g' => $avatarBgColor->green(),
2473                    'b' => $avatarBgColor->blue(),
2474                ];
2475            } else {
2476                $dbColor = Utils::hexToRgb($dbColor);
2477            }
2478
2479            $members[] = [
2480                'activated' => $dbActivated === 1,
2481                'userid' => $dbUserid,
2482                'name' => $dbName,
2483                'id' => $dbMemberId,
2484                'weight' => $dbWeight,
2485                'color' => $dbColor,
2486                'lastchanged' => $dbLastchanged,
2487            ];
2488        }
2489        $req->closeCursor();
2490        $qb->resetQueryParts();
2491        return $members;
2492    }
2493
2494    /**
2495     * Get members balances for a project
2496     *
2497     * @param string $projectId
2498     * @param int|null $maxTimestamp
2499     * @return array
2500     */
2501    private function getBalance(string $projectId, ?int $maxTimestamp = null): array {
2502        $membersWeight = [];
2503        $membersBalance = [];
2504
2505        $members = $this->getMembers($projectId);
2506        foreach ($members as $member) {
2507            $memberId = $member['id'];
2508            $memberWeight = $member['weight'];
2509            $membersWeight[$memberId] = $memberWeight;
2510            $membersBalance[$memberId] = 0.0;
2511        }
2512
2513        $bills = $this->getBills($projectId, null, $maxTimestamp);
2514        foreach ($bills as $bill) {
2515            $payerId = $bill['payer_id'];
2516            $amount = $bill['amount'];
2517            $owers = $bill['owers'];
2518
2519            $membersBalance[$payerId] += $amount;
2520
2521            $nbOwerShares = 0.0;
2522            foreach ($owers as $ower) {
2523                $owerWeight = $ower['weight'];
2524                if ($owerWeight === 0.0) {
2525                    $owerWeight = 1.0;
2526                }
2527                $nbOwerShares += $owerWeight;
2528            }
2529            foreach ($owers as $ower) {
2530                $owerWeight = $ower['weight'];
2531                if ($owerWeight === 0.0) {
2532                    $owerWeight = 1.0;
2533                }
2534                $owerId = $ower['id'];
2535                $spent = $amount / $nbOwerShares * $owerWeight;
2536                $membersBalance[$owerId] -= $spent;
2537            }
2538        }
2539
2540        return $membersBalance;
2541    }
2542
2543    /**
2544     * Check if a user is member of a given circle
2545     *
2546     * @param string $userId
2547     * @param string $circleId
2548     * @return bool
2549     */
2550    private function isUserInCircle(string $userId, string $circleId): bool {
2551        try {
2552            $circlesManager = \OC::$server->get(\OCA\Circles\CirclesManager::class);
2553            $circlesManager->startSuperSession();
2554        } catch (Exception $e) {
2555            return false;
2556        }
2557        try {
2558            $circle = $circlesManager->getCircle($circleId);
2559        } catch (\OCA\Circles\Exceptions\CircleNotFoundException $e) {
2560            $circlesManager->stopSession();
2561            return false;
2562        }
2563        // is the circle owner
2564        $owner = $circle->getOwner();
2565        // the owner is also a member so this might be useless...
2566        if ($owner->getUserType() === 1 && $owner->getUserId() === $userId) {
2567            $circlesManager->stopSession();
2568            return true;
2569        } else {
2570            $members = $circle->getMembers();
2571            foreach ($members as $m) {
2572                // is member of this circle
2573                if ($m->getUserType() === 1 && $m->getUserId() === $userId) {
2574                    $circlesManager->stopSession();
2575                    return true;
2576                }
2577            }
2578        }
2579        $circlesManager->stopSession();
2580        return false;
2581    }
2582
2583    /**
2584     * For all projects the user has access to, get id => name
2585     *
2586     * @param string $userId
2587     * @return array
2588     * @throws \OCP\DB\Exception
2589     */
2590    public function getProjectNames(?string $userId): array {
2591        if (is_null($userId)) {
2592            return [];
2593        }
2594
2595        $projectNames = [];
2596
2597        $qb = $this->db->getQueryBuilder();
2598
2599        $qb->select('id', 'name')
2600            ->from('cospend_projects', 'p')
2601            ->where(
2602                $qb->expr()->eq('userid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
2603            );
2604        $req = $qb->executeQuery();
2605
2606        while ($row = $req->fetch()){
2607            $projectNames[$row['id']] = $row['name'];
2608        }
2609        $req->closeCursor();
2610
2611        $qb = $qb->resetQueryParts();
2612
2613        // shared with user
2614        $qb->select('p.id', 'p.name')
2615            ->from('cospend_projects', 'p')
2616            ->innerJoin('p', 'cospend_shares', 's', $qb->expr()->eq('p.id', 's.projectid'))
2617            ->where(
2618                $qb->expr()->eq('s.userid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
2619            )
2620            ->andWhere(
2621                $qb->expr()->eq('s.type', $qb->createNamedParameter(Application::SHARE_TYPES['user'], IQueryBuilder::PARAM_STR))
2622            );
2623        $req = $qb->executeQuery();
2624
2625        while ($row = $req->fetch()){
2626            // avoid putting twice the same project
2627            // this can happen with a share loop
2628            if (!isset($projectNames[$row['id']])) {
2629                $projectNames[$row['id']] = $row['name'];
2630            }
2631        }
2632        $req->closeCursor();
2633        $qb = $qb->resetQueryParts();
2634
2635        // shared with one of the groups the user is member of
2636        $userO = $this->userManager->get($userId);
2637
2638        // get group with which a project is shared
2639        $candidateGroupIds = [];
2640        $qb->select('userid')
2641            ->from('cospend_shares', 's')
2642            ->where(
2643                $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['group'], IQueryBuilder::PARAM_STR))
2644            )
2645            ->groupBy('userid');
2646        $req = $qb->executeQuery();
2647        while ($row = $req->fetch()){
2648            $groupId = $row['userid'];
2649            $candidateGroupIds[] = $groupId;
2650        }
2651        $req->closeCursor();
2652        $qb = $qb->resetQueryParts();
2653
2654        // is the user member of these groups?
2655        foreach ($candidateGroupIds as $candidateGroupId) {
2656            $group = $this->groupManager->get($candidateGroupId);
2657            if ($group !== null && $group->inGroup($userO)) {
2658                // get projects shared with this group
2659                $qb->select('p.id', 'p.name')
2660                    ->from('cospend_projects', 'p')
2661                    ->innerJoin('p', 'cospend_shares', 's', $qb->expr()->eq('p.id', 's.projectid'))
2662                    ->where(
2663                        $qb->expr()->eq('s.userid', $qb->createNamedParameter($candidateGroupId, IQueryBuilder::PARAM_STR))
2664                    )
2665                    ->andWhere(
2666                        $qb->expr()->eq('s.type', $qb->createNamedParameter(Application::SHARE_TYPES['group'], IQueryBuilder::PARAM_STR))
2667                    );
2668                $req = $qb->executeQuery();
2669
2670                while ($row = $req->fetch()){
2671                    // avoid putting twice the same project
2672                    // this can happen with a share loop
2673                    if (!isset($projectNames[$row['id']])) {
2674                        $projectNames[$row['id']] = $row['name'];
2675                    }
2676                }
2677                $req->closeCursor();
2678                $qb = $qb->resetQueryParts();
2679            }
2680        }
2681
2682        $circlesEnabled = $this->appManager->isEnabledForUser('circles');
2683        if ($circlesEnabled) {
2684            // get circles with which a project is shared
2685            $candidateCircleIds = [];
2686            $qb->select('userid')
2687                ->from('cospend_shares', 's')
2688                ->where(
2689                    $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['circle'], IQueryBuilder::PARAM_STR))
2690                )
2691                ->groupBy('userid');
2692            $req = $qb->executeQuery();
2693            while ($row = $req->fetch()){
2694                $circleId = $row['userid'];
2695                $candidateCircleIds[] = $circleId;
2696            }
2697            $req->closeCursor();
2698            $qb = $qb->resetQueryParts();
2699
2700            // is the user member of these circles?
2701            foreach ($candidateCircleIds as $candidateCircleId) {
2702                if ($this->isUserInCircle($userId, $candidateCircleId)) {
2703                    // get projects shared with this circle
2704                    $qb->select('p.id', 'p.name')
2705                        ->from('cospend_projects', 'p')
2706                        ->innerJoin('p', 'cospend_shares', 's', $qb->expr()->eq('p.id', 's.projectid'))
2707                        ->where(
2708                            $qb->expr()->eq('s.userid', $qb->createNamedParameter($candidateCircleId, IQueryBuilder::PARAM_STR))
2709                        )
2710                        ->andWhere(
2711                            $qb->expr()->eq('s.type', $qb->createNamedParameter(Application::SHARE_TYPES['circle'], IQueryBuilder::PARAM_STR))
2712                        );
2713                    $req = $qb->executeQuery();
2714
2715                    while ($row = $req->fetch()){
2716                        // avoid putting twice the same project
2717                        // this can happen with a share loop or multiple shares
2718                        if (!isset($projectNames[$row['id']])) {
2719                            $projectNames[$row['id']] = $row['name'];
2720                        }
2721                    }
2722                    $req->closeCursor();
2723                    $qb = $qb->resetQueryParts();
2724                }
2725            }
2726        }
2727        return $projectNames;
2728    }
2729
2730    /**
2731     * Get detailed project list for a given NC user
2732     *
2733     * @param string $userId
2734     * @return array
2735     */
2736    public function getProjects(string $userId): array {
2737        $projectids = array_keys($this->getProjectNames($userId));
2738
2739        // get the projects
2740        $projects = [];
2741        foreach ($projectids as $projectid) {
2742            $project = $this->getProjectInfo($projectid);
2743            $project['myaccesslevel'] = $this->getUserMaxAccessLevel($userId, $projectid);
2744            $projects[] = $project;
2745        }
2746
2747        return $projects;
2748    }
2749
2750    /**
2751     * Get categories of a given project
2752     *
2753     * @param string $projectid
2754     * @param bool $getCategories
2755     * @return array
2756     * @throws \OCP\DB\Exception
2757     */
2758    public function getCategoriesOrPaymentModes(string $projectid, bool $getCategories = true): array {
2759        $elements = [];
2760
2761        $qb = $this->db->getQueryBuilder();
2762
2763        if ($getCategories) {
2764            $sortOrderField = 'categorysort';
2765            $billTableField = 'categoryid';
2766            $dbTable = 'cospend_categories';
2767            $alias = 'cat';
2768        } else {
2769            $sortOrderField = 'paymentmodesort';
2770            $billTableField = 'paymentmodeid';
2771            $dbTable = 'cospend_paymentmodes';
2772            $alias = 'pm';
2773        }
2774
2775        // get sort method
2776        $qb->select($sortOrderField)
2777            ->from('cospend_projects', 'p')
2778            ->where(
2779                $qb->expr()->eq('id', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
2780            );
2781        $req = $qb->executeQuery();
2782        $sortMethod = Application::SORT_ORDERS['alpha'];
2783        while ($row = $req->fetch()) {
2784            $sortMethod = $row[$sortOrderField];
2785            break;
2786        }
2787        $req->closeCursor();
2788        $qb->resetQueryParts();
2789
2790        if ($sortMethod === Application::SORT_ORDERS['manual'] || $sortMethod === Application::SORT_ORDERS['alpha']) {
2791            if ($getCategories) {
2792                $qb = $qb->select('name', 'id', 'encoded_icon', 'color', 'order');
2793            } else {
2794                $qb = $qb->select('name', 'id', 'encoded_icon', 'color', 'order', 'old_id');
2795            }
2796            $qb->from($dbTable, 'c')
2797                ->where(
2798                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
2799                );
2800            $req = $qb->executeQuery();
2801            while ($row = $req->fetch()) {
2802                $dbName = $row['name'];
2803                $dbIcon = urldecode($row['encoded_icon']);
2804                $dbColor = $row['color'];
2805                $dbId = (int) $row['id'];
2806                $dbOrder = (int) $row['order'];
2807                $elements[$dbId] = [
2808                    'name' => $dbName,
2809                    'icon' => $dbIcon,
2810                    'color' => $dbColor,
2811                    'id' => $dbId,
2812                    'order' => $dbOrder,
2813                ];
2814                if (!$getCategories) {
2815                    $elements[$dbId]['old_id'] = $row['old_id'];
2816                }
2817            }
2818            $req->closeCursor();
2819            $qb->resetQueryParts();
2820        } elseif ($sortMethod === Application::SORT_ORDERS['most_used'] || $sortMethod === Application::SORT_ORDERS['most_recently_used']) {
2821            // get all categories/paymentmodes
2822            if ($getCategories) {
2823                $qb = $qb->select('name', 'id', 'encoded_icon', 'color');
2824            } else {
2825                $qb = $qb->select('name', 'id', 'encoded_icon', 'color', 'old_id');
2826            }
2827            $qb->from($dbTable, 'c')
2828                ->where(
2829                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
2830                );
2831            $req = $qb->executeQuery();
2832            while ($row = $req->fetch()) {
2833                $dbName = $row['name'];
2834                $dbIcon = urldecode($row['encoded_icon']);
2835                $dbColor = $row['color'];
2836                $dbId = (int) $row['id'];
2837                $elements[$dbId] = [
2838                    'name' => $dbName,
2839                    'icon' => $dbIcon,
2840                    'color' => $dbColor,
2841                    'id' => $dbId,
2842                    'order' => null,
2843                ];
2844                if (!$getCategories) {
2845                    $elements[$dbId]['old_id'] = $row['old_id'];
2846                }
2847            }
2848            $req->closeCursor();
2849            $qb->resetQueryParts();
2850            // now we get the order
2851            if ($sortMethod === Application::SORT_ORDERS['most_used']) {
2852                // sort by most used
2853                // first get list of most used
2854                $mostUsedOrder = [];
2855                $qb->select($alias . '.id')
2856                    ->from($dbTable, $alias)
2857                    ->innerJoin($alias, 'cospend_bills', 'bill', $qb->expr()->eq($alias . '.id', 'bill.' . $billTableField))
2858                    ->where(
2859                        $qb->expr()->eq($alias . '.projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
2860                    )
2861                    ->orderBy($qb->func()->count($alias . '.id'), 'DESC')
2862                    ->groupBy($alias . '.id');
2863                $req = $qb->executeQuery();
2864                $order = 0;
2865                while ($row = $req->fetch()) {
2866                    $dbId = (int) $row['id'];
2867                    $mostUsedOrder[$dbId] = $order++;
2868                }
2869                $req->closeCursor();
2870                $qb->resetQueryParts();
2871                // affect order
2872                foreach ($elements as $cid => $cat) {
2873                    // fallback order is more than max order
2874                    $elements[$cid]['order'] = $mostUsedOrder[$cid] ?? $order;
2875                }
2876            } elseif ($sortMethod === Application::SORT_ORDERS['most_recently_used']) {
2877                // sort by most recently used
2878                $mostUsedOrder = [];
2879                $qb->select($alias . '.id')
2880                    ->from($dbTable, $alias)
2881                    ->innerJoin($alias, 'cospend_bills', 'bill', $qb->expr()->eq($alias . '.id', 'bill.' . $billTableField))
2882                    ->where(
2883                        $qb->expr()->eq($alias . '.projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
2884                    )
2885                    ->orderBy($qb->func()->max('bill.timestamp'), 'DESC')
2886                    ->groupBy($alias . '.id');
2887                $req = $qb->executeQuery();
2888                $order = 0;
2889                while ($row = $req->fetch()) {
2890                    $dbId = (int) $row['id'];
2891                    $mostUsedOrder[$dbId] = $order++;
2892                }
2893                $req->closeCursor();
2894                $qb->resetQueryParts();
2895                // affect order
2896                foreach ($elements as $elemId => $element) {
2897                    // fallback order is more than max order
2898                    $elements[$elemId]['order'] = $mostUsedOrder[$elemId] ?? $order;
2899                }
2900            }
2901        }
2902
2903        return $elements;
2904    }
2905
2906    /**
2907     * Get currencies of a project
2908     *
2909     * @param string $projectid
2910     * @return array
2911     */
2912    private function getCurrencies(string $projectid): array {
2913        $currencies = [];
2914
2915        $qb = $this->db->getQueryBuilder();
2916        $qb->select('name', 'id', 'exchange_rate')
2917            ->from('cospend_currencies')
2918            ->where(
2919                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
2920            );
2921        $req = $qb->executeQuery();
2922        while ($row = $req->fetch()){
2923            $dbName = $row['name'];
2924            $dbId = (int) $row['id'];
2925            $dbExchangeRate = (float) $row['exchange_rate'];
2926            $currencies[] = [
2927                'name' => $dbName,
2928                'exchange_rate' => $dbExchangeRate,
2929                'id' => $dbId,
2930            ];
2931        }
2932        $req->closeCursor();
2933        $qb->resetQueryParts();
2934
2935        return $currencies;
2936    }
2937
2938    /**
2939     * Get user shared access of a project
2940     *
2941     * @param string $projectid
2942     * @return array
2943     */
2944    private function getUserShares(string $projectid): array {
2945        $shares = [];
2946        $userIdToName = [];
2947        $sharesToDelete = [];
2948
2949        $qb = $this->db->getQueryBuilder();
2950        $qb->select('projectid', 'userid', 'id', 'accesslevel', 'manually_added')
2951            ->from('cospend_shares')
2952            ->where(
2953                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
2954            )
2955            ->andWhere(
2956                $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['user'], IQueryBuilder::PARAM_STR))
2957            );
2958        $req = $qb->executeQuery();
2959        while ($row = $req->fetch()){
2960            $dbuserId = $row['userid'];
2961            $dbId = (int) $row['id'];
2962            $dbAccessLevel = (int) $row['accesslevel'];
2963            $dbManuallyAdded = (int) $row['manually_added'];
2964            if (array_key_exists($dbuserId, $userIdToName)) {
2965                $name = $userIdToName[$dbuserId];
2966            } else {
2967                $user = $this->userManager->get($dbuserId);
2968                if ($user !== null) {
2969                    $userIdToName[$user->getUID()] = $user->getDisplayName();
2970                    $name = $user->getDisplayName();
2971                } else {
2972                    $sharesToDelete[] = $dbId;
2973                    continue;
2974                }
2975            }
2976            $shares[] = [
2977                'userid' => $dbuserId,
2978                'name' => $name,
2979                'id' => $dbId,
2980                'accesslevel' => $dbAccessLevel,
2981                'type' => Application::SHARE_TYPES['user'],
2982                'manually_added' => $dbManuallyAdded === 1,
2983            ];
2984        }
2985        $req->closeCursor();
2986        $qb->resetQueryParts();
2987
2988        // delete shares pointing to unfound users
2989        foreach ($sharesToDelete as $shId) {
2990            $this->deleteUserShare($projectid, $shId);
2991        }
2992
2993        return $shares;
2994    }
2995
2996    /**
2997     * Get public links of a project
2998     *
2999     * @param string $projectid
3000     * @param int|null $maxAccessLevel
3001     * @return array
3002     */
3003    public function getPublicShares(string $projectid, ?int $maxAccessLevel = null): array {
3004        $shares = [];
3005
3006        $qb = $this->db->getQueryBuilder();
3007        $qb->select('projectid', 'userid', 'id', 'accesslevel', 'label', 'password')
3008            ->from('cospend_shares')
3009            ->where(
3010                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
3011            )
3012            ->andWhere(
3013                $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['public_link'], IQueryBuilder::PARAM_STR))
3014            );
3015        if (!is_null($maxAccessLevel)) {
3016            $qb->andWhere(
3017                $qb->expr()->lte('accesslevel', $qb->createNamedParameter($maxAccessLevel, IQueryBuilder::PARAM_INT))
3018            );
3019        }
3020        $req = $qb->executeQuery();
3021        while ($row = $req->fetch()){
3022            $dbToken = $row['userid'];
3023            $dbId = (int) $row['id'];
3024            $dbAccessLevel = (int) $row['accesslevel'];
3025            $dbLabel = $row['label'];
3026            $dbPassword = $row['password'];
3027            $shares[] = [
3028                'token' => $dbToken,
3029                'id' => $dbId,
3030                'accesslevel' => $dbAccessLevel,
3031                'label' => $dbLabel,
3032                'password' => $dbPassword,
3033                'type' => Application::SHARE_TYPES['public_link'],
3034            ];
3035        }
3036        $req->closeCursor();
3037        $qb->resetQueryParts();
3038
3039        return $shares;
3040    }
3041
3042    /**
3043     * Get project info for a given public share token
3044     *
3045     * @param string $token
3046     * @return array
3047     */
3048    public function getProjectInfoFromShareToken(string $token): ?array {
3049        $projectInfo = null;
3050
3051        $qb = $this->db->getQueryBuilder();
3052        $qb->select('projectid', 'accesslevel', 'label', 'password')
3053            ->from('cospend_shares')
3054            ->where(
3055                $qb->expr()->eq('userid', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR))
3056            )
3057            ->andWhere(
3058                $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['public_link'], IQueryBuilder::PARAM_STR))
3059            );
3060        $req = $qb->executeQuery();
3061        while ($row = $req->fetch()){
3062            $projectId = $row['projectid'];
3063            $label = $row['label'];
3064            $password = $row['password'];
3065            $accessLevel = (int) $row['accesslevel'];
3066            $projectInfo =  [
3067                'projectid' => $projectId,
3068                'accesslevel' => $accessLevel,
3069                'label' => $label,
3070                'password' => $password,
3071            ];
3072            break;
3073        }
3074        $req->closeCursor();
3075        $qb->resetQueryParts();
3076
3077        return $projectInfo;
3078    }
3079
3080    /**
3081     * Get group shared access list of a project
3082     *
3083     * @param string $projectid
3084     * @return array
3085     */
3086    private function getGroupShares(string $projectid): array {
3087        $shares = [];
3088        $groupIdToName = [];
3089        $sharesToDelete = [];
3090
3091        $qb = $this->db->getQueryBuilder();
3092        $qb->select('projectid', 'userid', 'id', 'accesslevel')
3093            ->from('cospend_shares')
3094            ->where(
3095                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
3096            )
3097            ->andWhere(
3098                $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['group'], IQueryBuilder::PARAM_STR))
3099            );
3100        $req = $qb->executeQuery();
3101        while ($row = $req->fetch()){
3102            $dbGroupId = $row['userid'];
3103            $dbId = (int) $row['id'];
3104            $dbAccessLevel = (int) $row['accesslevel'];
3105            if (array_key_exists($dbGroupId, $groupIdToName)) {
3106                $name = $groupIdToName[$dbGroupId];
3107            } else {
3108                if ($this->groupManager->groupExists($dbGroupId)) {
3109                    $name = $this->groupManager->get($dbGroupId)->getDisplayName();
3110                    $groupIdToName[$dbGroupId] = $name;
3111                } else {
3112                    $sharesToDelete[] = $dbId;
3113                    continue;
3114                }
3115            }
3116            $shares[] = [
3117                'groupid' => $dbGroupId,
3118                'name' => $name,
3119                'id' => $dbId,
3120                'accesslevel' => $dbAccessLevel,
3121                'type' => Application::SHARE_TYPES['group'],
3122            ];
3123        }
3124        $req->closeCursor();
3125        $qb->resetQueryParts();
3126
3127        foreach ($sharesToDelete as $shId) {
3128            $this->deleteGroupShare($projectid, $shId);
3129        }
3130
3131        return $shares;
3132    }
3133
3134    /**
3135     * Get circle shared access list of a project
3136     *
3137     * @param string $projectid
3138     * @return array
3139     */
3140    private function getCircleShares(string $projectid): array {
3141        $shares = [];
3142
3143        $circlesEnabled = $this->appManager->isEnabledForUser('circles');
3144        if ($circlesEnabled) {
3145            try {
3146                $circlesManager = \OC::$server->get(\OCA\Circles\CirclesManager::class);
3147                $circlesManager->startSuperSession();
3148            } catch (Exception $e) {
3149                return [];
3150            }
3151            $qb = $this->db->getQueryBuilder();
3152            $qb->select('projectid', 'userid', 'id', 'accesslevel')
3153                ->from('cospend_shares')
3154                ->where(
3155                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
3156                )
3157                ->andWhere(
3158                    $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['circle'], IQueryBuilder::PARAM_STR))
3159                );
3160            $req = $qb->executeQuery();
3161            while ($row = $req->fetch()) {
3162                $dbCircleId = $row['userid'];
3163                $dbId = (int) $row['id'];
3164                $dbAccessLevel = (int) $row['accesslevel'];
3165                try {
3166                    $circle = $circlesManager->getCircle($dbCircleId);
3167                    $shares[] = [
3168                        'circleid' => $dbCircleId,
3169                        'name' => $circle->getDisplayName(),
3170                        'id' => $dbId,
3171                        'accesslevel' => $dbAccessLevel,
3172                        'type' => Application::SHARE_TYPES['circle'],
3173                    ];
3174                } catch (\OCA\Circles\Exceptions\CircleNotFoundException $e) {
3175                }
3176            }
3177            $req->closeCursor();
3178            $qb->resetQueryParts();
3179            $circlesManager->stopSession();
3180        }
3181        return $shares;
3182    }
3183
3184    /**
3185     * Delete a member
3186     *
3187     * @param string $projectid
3188     * @param int $memberid
3189     * @return array
3190     */
3191    public function deleteMember(string $projectid, int $memberid): array {
3192        $memberToDelete = $this->getMemberById($projectid, $memberid);
3193        if ($memberToDelete !== null) {
3194            $qb = $this->db->getQueryBuilder();
3195            if (count($this->getBillsOfMember($memberid)) === 0) {
3196                $qb->delete('cospend_members')
3197                    ->where(
3198                        $qb->expr()->eq('id', $qb->createNamedParameter($memberid, IQueryBuilder::PARAM_INT))
3199                    );
3200                $qb->executeStatement();
3201                $qb->resetQueryParts();
3202            } elseif ($memberToDelete['activated']) {
3203                $qb->update('cospend_members');
3204                $qb->set('activated', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT));
3205                $qb->where(
3206                    $qb->expr()->eq('id', $qb->createNamedParameter($memberid, IQueryBuilder::PARAM_INT))
3207                )
3208                    ->andWhere(
3209                        $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
3210                    );
3211                $qb->executeStatement();
3212                $qb->resetQueryParts();
3213            }
3214            return ['success' => true];
3215        } else {
3216            return ['Not Found'];
3217        }
3218    }
3219
3220    /**
3221     * Get bills involving a member (as a payer or an ower)
3222     *
3223     * @param int $memberid
3224     * @return array
3225     * @throws \OCP\DB\Exception
3226     */
3227    public function getBillsOfMember(int $memberid): array {
3228        $qb = $this->db->getQueryBuilder();
3229        $qb->select('bi.id')
3230            ->from('cospend_bill_owers', 'bo')
3231            ->innerJoin('bo', 'cospend_bills', 'bi', $qb->expr()->eq('bo.billid', 'bi.id'))
3232            ->innerJoin('bo', 'cospend_members', 'm', $qb->expr()->eq('bo.memberid', 'm.id'))
3233            ->where(
3234                $qb->expr()->eq('bi.payerid', $qb->createNamedParameter($memberid, IQueryBuilder::PARAM_INT))
3235            )
3236            ->orWhere(
3237                $qb->expr()->eq('bo.memberid', $qb->createNamedParameter($memberid, IQueryBuilder::PARAM_INT))
3238            );
3239        $req = $qb->executeQuery();
3240
3241        $billIds = [];
3242        while ($row = $req->fetch()) {
3243            $billIds[] = $row['id'];
3244        }
3245        return $billIds;
3246    }
3247
3248    /**
3249     * Get a member from its name
3250     *
3251     * @param string $projectId
3252     * @param string $name
3253     * @return array|null
3254     */
3255    public function getMemberByName(string $projectId, string $name): ?array {
3256        $member = null;
3257        $qb = $this->db->getQueryBuilder();
3258        $qb->select('id', 'userid', 'name', 'weight', 'color', 'activated')
3259            ->from('cospend_members')
3260            ->where(
3261                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
3262            )
3263            ->andWhere(
3264                $qb->expr()->eq('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR))
3265            );
3266        $req = $qb->executeQuery();
3267
3268        while ($row = $req->fetch()){
3269            $dbMemberId = (int) $row['id'];
3270            $dbWeight = (float) $row['weight'];
3271            $dbUserid = $row['userid'];
3272            $dbName = $row['name'];
3273            $dbActivated= (int) $row['activated'];
3274            $dbColor = $row['color'];
3275            if ($dbColor === null) {
3276                $av = $this->avatarManager->getGuestAvatar($dbName);
3277                $dbColor = $av->avatarBackgroundColor($dbName);
3278                $dbColor = [
3279                    'r' => $dbColor->red(),
3280                    'g' => $dbColor->green(),
3281                    'b' => $dbColor->blue(),
3282                ];
3283            } else {
3284                $dbColor = Utils::hexToRgb($dbColor);
3285            }
3286            $member = [
3287                'activated' => $dbActivated === 1,
3288                'userid' => $dbUserid,
3289                'name' => $dbName,
3290                'id' => $dbMemberId,
3291                'weight' => $dbWeight,
3292                'color' => $dbColor,
3293            ];
3294            break;
3295        }
3296        $req->closeCursor();
3297        $qb->resetQueryParts();
3298        return $member;
3299    }
3300
3301    /**
3302     * Get a member from its user ID
3303     *
3304     * @param string $projectId
3305     * @param string|null $userid
3306     * @return array|null
3307     */
3308    public function getMemberByUserid(string $projectId, ?string $userid): ?array {
3309        $member = null;
3310        if ($userid !== null) {
3311            $qb = $this->db->getQueryBuilder();
3312            $qb->select('id', 'userid', 'name', 'weight', 'color', 'activated')
3313                ->from('cospend_members')
3314                ->where(
3315                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
3316                )
3317                ->andWhere(
3318                    $qb->expr()->eq('userid', $qb->createNamedParameter($userid, IQueryBuilder::PARAM_STR))
3319                );
3320            $req = $qb->executeQuery();
3321
3322            while ($row = $req->fetch()){
3323                $dbMemberId = (int) $row['id'];
3324                $dbWeight = (float) $row['weight'];
3325                $dbUserid = $row['userid'];
3326                $dbName = $row['name'];
3327                $dbActivated= (int) $row['activated'];
3328                $dbColor = $row['color'];
3329                if ($dbColor === null) {
3330                    $av = $this->avatarManager->getGuestAvatar($dbName);
3331                    $dbColor = $av->avatarBackgroundColor($dbName);
3332                    $dbColor = [
3333                        'r' => $dbColor->red(),
3334                        'g' => $dbColor->green(),
3335                        'b' => $dbColor->blue(),
3336                    ];
3337                } else {
3338                    $dbColor = Utils::hexToRgb($dbColor);
3339                }
3340                $member = [
3341                    'activated' => $dbActivated === 1,
3342                    'userid' => $dbUserid,
3343                    'name' => $dbName,
3344                    'id' => $dbMemberId,
3345                    'weight' => $dbWeight,
3346                    'color' => $dbColor,
3347                ];
3348                break;
3349            }
3350            $req->closeCursor();
3351            $qb->resetQueryParts();
3352        }
3353        return $member;
3354    }
3355
3356    /**
3357     * Edit a bill
3358     *
3359     * @param string $projectid
3360     * @param int $billid
3361     * @param string|null $date
3362     * @param string|null $what
3363     * @param int|null $payer
3364     * @param string|null $payed_for
3365     * @param float|null $amount
3366     * @param string|null $repeat
3367     * @param string|null $paymentmode
3368     * @param int|null $paymentmodeid
3369     * @param int|null $categoryid
3370     * @param int|null $repeatallactive
3371     * @param string|null $repeatuntil
3372     * @param int|null $timestamp
3373     * @param string|null $comment
3374     * @param int|null $repeatfreq
3375     * @param array|null $paymentModes
3376     * @return array
3377     * @throws \OCP\DB\Exception
3378     */
3379    public function editBill(string $projectid, int $billid, ?string $date, ?string $what, ?int $payer, ?string $payed_for,
3380                             ?float $amount, ?string $repeat, ?string $paymentmode = null, ?int $paymentmodeid = null,
3381                             ?int $categoryid = null, ?int $repeatallactive = null, ?string $repeatuntil = null,
3382                             ?int $timestamp = null, ?string $comment = null, ?int $repeatfreq = null,
3383                             ?array $paymentModes = null): array {
3384        // if we don't have the payment modes, get them now
3385        if (is_null($paymentModes)) {
3386            $paymentModes = $this->getCategoriesOrPaymentModes($projectid, false);
3387        }
3388
3389        $qb = $this->db->getQueryBuilder();
3390        $qb->update('cospend_bills');
3391
3392        // set last modification timestamp
3393        $ts = (new DateTime())->getTimestamp();
3394        $qb->set('lastchanged', $qb->createNamedParameter($ts, IQueryBuilder::PARAM_INT));
3395
3396        // first check the bill exists
3397        if ($this->getBill($projectid, $billid) === null) {
3398            return ['message' => $this->trans->t('There is no such bill')];
3399        }
3400        // then edit the hell of it
3401        if ($what !== null) {
3402            $qb->set('what', $qb->createNamedParameter($what, IQueryBuilder::PARAM_STR));
3403        }
3404
3405        if ($comment !== null) {
3406            $qb->set('comment', $qb->createNamedParameter($comment, IQueryBuilder::PARAM_STR));
3407        }
3408
3409        if ($repeat !== null && $repeat !== '') {
3410            if (in_array($repeat, array_values(Application::FREQUENCIES))) {
3411                $qb->set('repeat', $qb->createNamedParameter($repeat, IQueryBuilder::PARAM_STR));
3412            } else {
3413                return ['repeat' => $this->trans->t('Invalid value')];
3414            }
3415        }
3416
3417        if ($repeatfreq !== null) {
3418            $qb->set('repeatfreq', $qb->createNamedParameter($repeatfreq, IQueryBuilder::PARAM_INT));
3419        }
3420
3421        if ($repeatuntil !== null) {
3422            if ($repeatuntil === '') {
3423                $qb->set('repeatuntil', $qb->createNamedParameter(null, IQueryBuilder::PARAM_STR));
3424            } else {
3425                $qb->set('repeatuntil', $qb->createNamedParameter($repeatuntil, IQueryBuilder::PARAM_STR));
3426            }
3427        }
3428        if ($repeatallactive !== null) {
3429            $qb->set('repeatallactive', $qb->createNamedParameter($repeatallactive, IQueryBuilder::PARAM_INT));
3430        }
3431        // payment mode
3432        if (!is_null($paymentmodeid)) {
3433            // is the old_id set for this payment mode? if yes, use it for old 'paymentmode' column
3434            $paymentmode = 'n';
3435            if (isset($paymentModes[$paymentmodeid], $paymentModes[$paymentmodeid]['old_id'])
3436                && $paymentModes[$paymentmodeid]['old_id'] !== null
3437                && $paymentModes[$paymentmodeid]['old_id'] !== ''
3438            ) {
3439                $paymentmode = $paymentModes[$paymentmodeid]['old_id'];
3440            }
3441            $qb->set('paymentmodeid', $qb->createNamedParameter($paymentmodeid, IQueryBuilder::PARAM_INT));
3442            $qb->set('paymentmode', $qb->createNamedParameter($paymentmode, IQueryBuilder::PARAM_STR));
3443        } elseif (!is_null($paymentmode)) {
3444            // is there a pm with this old id? if yes, use it for new id
3445            $paymentmodeid = 0;
3446            foreach ($paymentModes as $id => $pm) {
3447                if ($pm['old_id'] === $paymentmode) {
3448                    $paymentmodeid = $id;
3449                    break;
3450                }
3451            }
3452            $qb->set('paymentmodeid', $qb->createNamedParameter($paymentmodeid, IQueryBuilder::PARAM_INT));
3453            $qb->set('paymentmode', $qb->createNamedParameter($paymentmode, IQueryBuilder::PARAM_STR));
3454        }
3455        if ($categoryid !== null) {
3456            $qb->set('categoryid', $qb->createNamedParameter($categoryid, IQueryBuilder::PARAM_INT));
3457        }
3458        // priority to timestamp (moneybuster might send both for a moment)
3459        if ($timestamp !== null) {
3460            $qb->set('timestamp', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT));
3461        } elseif ($date !== null && $date !== '') {
3462            $datetime = DateTime::createFromFormat('Y-m-d', $date);
3463            if ($datetime !== false) {
3464                $dateTs = $datetime->getTimestamp();
3465                $qb->set('timestamp', $qb->createNamedParameter($dateTs, IQueryBuilder::PARAM_INT));
3466            } else {
3467                return ['date' => $this->trans->t('Invalid value')];
3468            }
3469        }
3470        if ($amount !== null) {
3471            $qb->set('amount', $qb->createNamedParameter($amount, IQueryBuilder::PARAM_STR));
3472        }
3473        if ($payer !== null) {
3474            $member = $this->getMemberById($projectid, $payer);
3475            if ($member === null) {
3476                return ['payer' => $this->trans->t('Not a valid choice')];
3477            } else {
3478                $qb->set('payerid', $qb->createNamedParameter($payer, IQueryBuilder::PARAM_INT));
3479            }
3480        }
3481
3482        $owerIds = null;
3483        // check owers
3484        if ($payed_for !== null && $payed_for !== '') {
3485            $owerIds = explode(',', $payed_for);
3486            if (count($owerIds) === 0) {
3487                return ['payed_for' => $this->trans->t('Invalid value')];
3488            } else {
3489                foreach ($owerIds as $owerId) {
3490                    if (!is_numeric($owerId)) {
3491                        return ['payed_for' => $this->trans->t('Invalid value')];
3492                    }
3493                    if ($this->getMemberById($projectid, $owerId) === null) {
3494                        return ['payed_for' => $this->trans->t('Not a valid choice')];
3495                    }
3496                }
3497            }
3498        }
3499
3500        // do it already!
3501        $qb->where(
3502            $qb->expr()->eq('id', $qb->createNamedParameter($billid, IQueryBuilder::PARAM_INT))
3503        )
3504            ->andWhere(
3505                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
3506            );
3507        $qb->executeStatement();
3508        $qb = $qb->resetQueryParts();
3509
3510        // edit the bill owers
3511        if ($owerIds !== null) {
3512            // delete old bill owers
3513            $this->deleteBillOwersOfBill($billid);
3514            // insert bill owers
3515            foreach ($owerIds as $owerId) {
3516                $qb->insert('cospend_bill_owers')
3517                    ->values([
3518                        'billid' => $qb->createNamedParameter($billid, IQueryBuilder::PARAM_INT),
3519                        'memberid' => $qb->createNamedParameter($owerId, IQueryBuilder::PARAM_INT)
3520                    ]);
3521                $qb->executeStatement();
3522                $qb = $qb->resetQueryParts();
3523            }
3524        }
3525
3526        $this->updateProjectLastChanged($projectid, $ts);
3527
3528        return ['edited_bill_id' => $billid];
3529    }
3530
3531    /**
3532     * daily check of repeated bills
3533     *
3534     * @param int|null $billId
3535     * @return array
3536     */
3537    public function cronRepeatBills(?int $billId = null): array {
3538        $result = [];
3539        $projects = [];
3540        $now = new DateTimeImmutable();
3541        // in case cron job wasn't executed during several days,
3542        // continue trying to repeat bills as long as there was at least one repeated
3543        $continue = true;
3544        while ($continue) {
3545            $continue = false;
3546            // get bills whith repetition flag
3547            $qb = $this->db->getQueryBuilder();
3548            $qb->select('id', 'projectid', 'what', 'timestamp', 'amount', 'payerid', 'repeat', 'repeatallactive', 'repeatfreq')
3549                ->from('cospend_bills', 'b')
3550                ->where(
3551                    $qb->expr()->neq('repeat', $qb->createNamedParameter(Application::FREQUENCIES['no'], IQueryBuilder::PARAM_STR))
3552                );
3553            // we only repeat one bill
3554            if (!is_null($billId)) {
3555                $qb->andWhere(
3556                    $qb->expr()->eq('id', $qb->createNamedParameter($billId, IQueryBuilder::PARAM_INT))
3557                );
3558            }
3559            $req = $qb->executeQuery();
3560            $bills = [];
3561            /** @var DateTimeZone[] $timezoneByProjectId */
3562            $timezoneByProjectId = [];
3563            while ($row = $req->fetch()) {
3564                $id = $row['id'];
3565                $what = $row['what'];
3566                $repeat = $row['repeat'];
3567                $repeatallactive = $row['repeatallactive'];
3568                $repeatfreq = (int) $row['repeatfreq'];
3569                $timestamp = $row['timestamp'];
3570                $projectid = $row['projectid'];
3571                $bills[] = [
3572                    'id' => $id,
3573                    'what' => $what,
3574                    'repeat' => $repeat,
3575                    'repeatallactive' => $repeatallactive,
3576                    'repeatfreq' => $repeatfreq,
3577                    'projectid' => $projectid,
3578                    'timestamp' => $timestamp
3579                ];
3580                if (!isset($timezoneByProjectId[$projectid])) {
3581                    $timezoneByProjectId[$projectid] = $this->getProjectTimeZone($projectid);
3582                }
3583            }
3584            $req->closeCursor();
3585            $qb->resetQueryParts();
3586
3587            foreach ($bills as $bill) {
3588                $billProjectId = $bill['projectid'];
3589                $billDate = (new DateTimeImmutable())->setTimestamp($bill['timestamp'])->setTimezone($timezoneByProjectId[$billProjectId]);
3590                $nextDate = $this->getNextRepetitionDate($bill, $billDate);
3591
3592                // Unknown repeat interval
3593                if ($nextDate === null) {
3594                    continue;
3595                }
3596
3597                // Repeat if $nextDate is in the past (or today)
3598                $diff = $now->diff($nextDate);
3599                if ($nextDate->format('Y-m-d') === $now->format('Y-m-d') || $diff->invert) {
3600                    $newBillId = $this->repeatBill($bill['projectid'], $bill['id'], $nextDate);
3601                    // bill was not repeated (because of disabled owers or repeatuntil)
3602                    if ($newBillId === null) {
3603                        continue;
3604                    }
3605                    if (!array_key_exists($bill['projectid'], $projects)) {
3606                        $projects[$bill['projectid']] = $this->getProjectInfo($bill['projectid']);
3607                    }
3608                    $result[] = [
3609                        'new_bill_id' => $newBillId,
3610                        'date_orig' => $billDate->format('Y-m-d'),
3611                        'date_repeat' => $nextDate->format('Y-m-d'),
3612                        'what' => $bill['what'],
3613                        'project_name' => $projects[$bill['projectid']]['name'],
3614                    ];
3615                    $continue = true;
3616                    // when only repeating one bill, this newly created bill is the one we want to potentially repeat
3617                    $billId = $newBillId;
3618                }
3619            }
3620        }
3621        return $result;
3622    }
3623
3624    private function getProjectTimeZone(string $projectId): DateTimeZone {
3625        $projectInfo = $this->getProjectInfo($projectId);
3626        $userId = $projectInfo['userid'];
3627        $timeZone = $this->config->getUserValue($userId, 'core', 'timezone', null);
3628        $serverTimeZone = date_default_timezone_get() ?: 'UTC';
3629
3630        if ($timeZone === null) {
3631            $timeZone = $serverTimeZone;
3632        }
3633
3634        try {
3635            return new DateTimeZone($timeZone);
3636        } catch (Exception $e) {
3637            return new DateTimeZone($serverTimeZone);
3638        }
3639    }
3640
3641    private function copyBillPaymentModeOver(string $projectid, array $bill, string $toProjectId): int {
3642        $originPayments = $this->getCategoriesOrPaymentModes($projectid, false);
3643        $destinationPayments = $this->getCategoriesOrPaymentModes($toProjectId, false);
3644
3645        if ($bill['paymentmodeid'] !== 0) {
3646            $originPayment = array_filter($originPayments, static function ($val) use ($bill) {
3647                return $val['id'] === $bill['paymentmodeid'];
3648            });
3649            $originPayment = array_shift($originPayment);
3650
3651            // find a payment mode with the same name
3652            $paymentNameMatches = array_filter($destinationPayments, static function ($val) use ($originPayment) {
3653                return $val['name'] === $originPayment['name'];
3654            });
3655
3656            // no payment mode match, means new mode
3657            if (count($paymentNameMatches) === 0) {
3658                return $this->addPaymentMode($toProjectId, $originPayment['name'], $originPayment['icon'], $originPayment['color']);
3659            } else {
3660                return array_shift($paymentNameMatches)['id'];
3661            }
3662        }
3663
3664        return $bill['paymentmodeid'];
3665    }
3666
3667    private function copyBillCategoryOver(string $projectid, array $bill, string $toProjectId): int {
3668        $originCategories = $this->getCategoriesOrPaymentModes($projectid);
3669        $destinationCategories = $this->getCategoriesOrPaymentModes($toProjectId);
3670
3671        if ($bill['categoryid'] !== 0) {
3672            $originCategory = array_filter($originCategories, static function ($val) use ($bill) {
3673                return $val['id'] === $bill['categoryid'];
3674            });
3675            $originCategory = array_shift($originCategory);
3676
3677            // find a category with the same name
3678            $categoryNameMatches = array_filter($destinationCategories, static function ($val) use ($originCategory) {
3679                return $val['name'] === $originCategory['name'];
3680            });
3681
3682            // no category match, means new category
3683            if (count($categoryNameMatches) === 0) {
3684                return $this->addCategory($toProjectId, $originCategory['name'], $originCategory['icon'], $originCategory['color']);
3685            } else {
3686                return array_shift($categoryNameMatches)['id'];
3687            }
3688        }
3689
3690        return $bill['categoryid'];
3691    }
3692
3693    public function moveBill(string $projectid, int $billid, string $toProjectId): array {
3694        $bill = $this->getBill($projectid, $billid);
3695
3696        // get all members in all the projects and try to match them
3697        $originMembers = $this->getMembers($projectid, 'lowername');
3698        $destinationMembers = $this->getMembers($toProjectId, 'lowername');
3699
3700        // try to match them
3701        $originalPayer = $originMembers;
3702        $originalPayer = array_filter($originalPayer, static function ($val) use ($bill) {
3703            return $val['id'] === $bill['payer_id'];
3704        });
3705        $originalPayer = array_shift($originalPayer);
3706
3707        $newPayer = $destinationMembers;
3708        $newPayer = array_filter($newPayer, static function ($val) use ($originalPayer) {
3709            return $val['name'] === $originalPayer['name'];
3710        });
3711
3712        if (count($newPayer) < 1) {
3713            return ['message' => $this->trans->t('Cannot match payer')];
3714        }
3715
3716        $newPayer = array_shift($newPayer);
3717
3718        // match owers too, these do not mind that much, the user will be able to modify the new invoice just after moving it
3719        $newOwers = array_filter($destinationMembers, static function ($member) use ($bill) {
3720            $matches = array_filter($bill['owers'], static function ($oldMember) use ($member) {
3721                return $oldMember['name'] === $member['name'];
3722            });
3723
3724            if (count($matches) === 0) {
3725                return false;
3726            }
3727
3728            return true;
3729        });
3730
3731        $newCategoryId = $this->copyBillCategoryOver($projectid, $bill, $toProjectId);
3732        $newPaymentId = $this->copyBillPaymentModeOver($projectid, $bill, $toProjectId);
3733
3734        $result = $this->addBill(
3735            $toProjectId, null, $bill['what'], $newPayer['id'],
3736            implode(',', array_column($newOwers, 'id')), $bill['amount'], $bill['repeat'],
3737            $bill['paymentmode'], $newPaymentId,
3738            $newCategoryId, $bill['repeatallactive'], $bill['repeatuntil'],
3739            $bill['timestamp'], $bill['comment'], $bill['repeatfreq']
3740        );
3741
3742        if (!isset($result['inserted_id'])) {
3743            return ['message' => $this->trans->t('Cannot create new bill: %1$s', $result['message'])];
3744        }
3745
3746        // remove the old bill
3747        $this->deleteBill($projectid, $billid, true);
3748
3749        return $result;
3750    }
3751
3752    /**
3753     * duplicate the bill today and give it the repeat flag
3754     * remove the repeat flag on original bill
3755     *
3756     * @param string $projectid
3757     * @param int $billid
3758     * @param DateTimeImmutable $targetDatetime
3759     * @return int|null
3760     * @throws \OCP\DB\Exception
3761     */
3762    private function repeatBill(string $projectid, int $billid, DateTimeImmutable $targetDatetime): ?int {
3763        $bill = $this->getBill($projectid, $billid);
3764
3765        $owerIds = [];
3766        if (((int) $bill['repeatallactive']) === 1) {
3767            $pInfo = $this->getProjectInfo($projectid);
3768            foreach ($pInfo['active_members'] as $am) {
3769                $owerIds[] = $am['id'];
3770            }
3771        } else {
3772            foreach ($bill['owers'] as $ower) {
3773                if ($ower['activated']) {
3774                    $owerIds[] = $ower['id'];
3775                }
3776            }
3777        }
3778        $owerIdsStr = implode(',', $owerIds);
3779        // if all owers are disabled, don't try to repeat the bill and remove repeat flag
3780        if (count($owerIds) === 0) {
3781            $this->editBill(
3782                $projectid, $billid, null, null, null, null,
3783                null, Application::FREQUENCIES['no'], null, null,
3784                null, null
3785            );
3786            return null;
3787        }
3788
3789        // if bill should be repeated only until...
3790        if ($bill['repeatuntil'] !== null && $bill['repeatuntil'] !== '') {
3791            $untilDate = DateTimeImmutable::createFromFormat('Y-m-d', $bill['repeatuntil']);
3792            if ($targetDatetime > $untilDate) {
3793                $this->editBill(
3794                    $projectid, $billid, null, null, null, null,
3795                    null, Application::FREQUENCIES['no'], null, null,
3796                    null, null
3797                );
3798                return null;
3799            }
3800        }
3801
3802        $addBillResult = $this->addBill(
3803            $projectid, null, $bill['what'], $bill['payer_id'],
3804            $owerIdsStr, $bill['amount'], $bill['repeat'],
3805            $bill['paymentmode'], $bill['paymentmodeid'],
3806            $bill['categoryid'], $bill['repeatallactive'], $bill['repeatuntil'],
3807            $targetDatetime->getTimestamp(), $bill['comment'], $bill['repeatfreq']
3808        );
3809
3810        $newBillId = $addBillResult['inserted_id'] ?? 0;
3811
3812        $billObj = $this->billMapper->find($newBillId);
3813        $this->activityManager->triggerEvent(
3814            ActivityManager::COSPEND_OBJECT_BILL, $billObj,
3815            ActivityManager::SUBJECT_BILL_CREATE,
3816            []
3817        );
3818
3819        // now we can remove repeat flag on original bill
3820        $this->editBill($projectid, $billid, null, $bill['what'], $bill['payer_id'], null,
3821            $bill['amount'], Application::FREQUENCIES['no'], null, null, null, null);
3822        return $newBillId;
3823    }
3824
3825    /**
3826     * Get next repetition date of a bill
3827     *
3828     * @param array $bill
3829     * @param DateTimeImmutable $billDate
3830     * @return DateTimeImmutable|null
3831     * @throws Exception
3832     */
3833    private function getNextRepetitionDate(array $bill, DateTimeImmutable $billDate): ?DateTimeImmutable {
3834        switch ($bill['repeat']) {
3835            case Application::FREQUENCIES['daily']:
3836                if ($bill['repeatfreq'] < 2) {
3837                    return $billDate->add(new DateInterval('P1D'));
3838                } else {
3839                    return $billDate->add(new DateInterval('P' . $bill['repeatfreq'] . 'D'));
3840                }
3841                break;
3842
3843            case Application::FREQUENCIES['weekly']:
3844                if ($bill['repeatfreq'] < 2) {
3845                    return $billDate->add(new DateInterval('P7D'));
3846                } else {
3847                    $nbDays = 7 * $bill['repeatfreq'];
3848                    return $billDate->add(new DateInterval('P' . $nbDays . 'D'));
3849                }
3850                break;
3851
3852            case Application::FREQUENCIES['bi_weekly']:
3853                return $billDate->add(new DateInterval('P14D'));
3854                break;
3855
3856            case Application::FREQUENCIES['semi_monthly']:
3857                $day = (int) $billDate->format('d');
3858                $month = (int) $billDate->format('m');
3859                $year = (int) $billDate->format('Y');
3860
3861                // first of next month
3862                if ($day >= 15) {
3863                    if ($month === 12) {
3864                        $nextYear = $year + 1;
3865                        $nextMonth = 1;
3866                        return $billDate->setDate($nextYear, $nextMonth, 1);
3867                    } else {
3868                        $nextMonth = $month + 1;
3869                        return $billDate->setDate($year, $nextMonth, 1);
3870                    }
3871                } else {
3872                    // 15 of same month
3873                    return $billDate->setDate($year, $month, 15);
3874                }
3875                break;
3876
3877            case Application::FREQUENCIES['monthly']:
3878                $freq = ($bill['repeatfreq'] < 2) ? 1 : $bill['repeatfreq'];
3879                $billMonth = (int) $billDate->format('m');
3880                $yearDelta = intdiv($billMonth + $freq - 1, 12);
3881                $nextYear = ((int) $billDate->format('Y')) + $yearDelta;
3882                $nextMonth = (($billMonth + $freq - 1) % 12) + 1;
3883
3884                // same day of month if possible, otherwise at end of month
3885                $firstOfNextMonth = $billDate->setDate($nextYear, $nextMonth, 1);
3886                $billDay = (int) $billDate->format('d');
3887                $nbDaysInTargetMonth = (int) $firstOfNextMonth->format('t');
3888                if ($billDay > $nbDaysInTargetMonth) {
3889                    return $billDate->setDate($nextYear, $nextMonth, $nbDaysInTargetMonth);
3890                } else {
3891                    return $billDate->setDate($nextYear, $nextMonth, $billDay);
3892                }
3893                break;
3894
3895            case Application::FREQUENCIES['yearly']:
3896                $freq = ($bill['repeatfreq'] < 2) ? 1 : $bill['repeatfreq'];
3897                $billYear = (int) $billDate->format('Y');
3898                $billMonth = (int) $billDate->format('m');
3899                $billDay = (int) $billDate->format('d');
3900                $nextYear = $billYear + $freq;
3901
3902                // same day of month if possible, otherwise at end of month + same month
3903                $firstDayOfTargetMonth = $billDate->setDate($nextYear, $billMonth, 1);
3904                $nbDaysInTargetMonth = (int) $firstDayOfTargetMonth->format('t');
3905                if ($billDay > $nbDaysInTargetMonth) {
3906                    return $billDate->setDate($nextYear, $billMonth, $nbDaysInTargetMonth);
3907                } else {
3908                    return $billDate->setDate($nextYear, $billMonth, $billDay);
3909                }
3910                break;
3911        }
3912
3913        return null;
3914    }
3915
3916    /**
3917     * @param string $projectid
3918     * @param string $name
3919     * @param string|null $icon
3920     * @param string $color
3921     * @param int|null $order
3922     * @return int
3923     */
3924    public function addPaymentMode(string $projectid, string $name, ?string $icon, string $color, ?int $order = 0): int {
3925        $qb = $this->db->getQueryBuilder();
3926
3927        $encIcon = $icon;
3928        if ($icon !== null && $icon !== '') {
3929            $encIcon = urlencode($icon);
3930        }
3931        $qb->insert('cospend_paymentmodes')
3932            ->values([
3933                'projectid' => $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR),
3934                'encoded_icon' => $qb->createNamedParameter($encIcon, IQueryBuilder::PARAM_STR),
3935                'color' => $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR),
3936                'name' => $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR),
3937                'order' => $qb->createNamedParameter(is_null($order) ? 0 : $order, IQueryBuilder::PARAM_INT)
3938            ]);
3939        $qb->executeStatement();
3940        $qb = $qb->resetQueryParts();
3941
3942        return $qb->getLastInsertId();
3943    }
3944
3945    /**
3946     * @param string $projectId
3947     * @param int $pmid
3948     * @return array|null
3949     * @throws \OCP\DB\Exception
3950     */
3951    public function getPaymentMode(string $projectId, int $pmid): ?array {
3952        $pm = null;
3953
3954        $qb = $this->db->getQueryBuilder();
3955        $qb->select('id', 'name', 'projectid', 'encoded_icon', 'color', 'old_id')
3956            ->from('cospend_paymentmodes', 'pm')
3957            ->where(
3958                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
3959            )
3960            ->andWhere(
3961                $qb->expr()->eq('id', $qb->createNamedParameter($pmid, IQueryBuilder::PARAM_INT))
3962            );
3963        $req = $qb->executeQuery();
3964
3965        while ($row = $req->fetch()) {
3966            $dbPmId = (int) $row['id'];
3967            $dbName = $row['name'];
3968            $dbIcon = urldecode($row['encoded_icon']);
3969            $dbColor = $row['color'];
3970            $dbOldId = $row['old_id'];
3971            $pm = [
3972                'name' => $dbName,
3973                'icon' => $dbIcon,
3974                'color' => $dbColor,
3975                'id' => $dbPmId,
3976                'projectid' => $projectId,
3977                'old_id' => $dbOldId,
3978            ];
3979            break;
3980        }
3981        $req->closeCursor();
3982        $qb->resetQueryParts();
3983        return $pm;
3984    }
3985
3986    /**
3987     * @param string $projectid
3988     * @param int $pmid
3989     * @return array
3990     */
3991    public function deletePaymentMode(string $projectid, int $pmid): array {
3992        $pmToDelete = $this->getPaymentMode($projectid, $pmid);
3993        if ($pmToDelete !== null) {
3994            $qb = $this->db->getQueryBuilder();
3995            $qb->delete('cospend_paymentmodes')
3996                ->where(
3997                    $qb->expr()->eq('id', $qb->createNamedParameter($pmid, IQueryBuilder::PARAM_INT))
3998                )
3999                ->andWhere(
4000                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4001                );
4002            $qb->executeStatement();
4003            $qb->resetQueryParts();
4004
4005            // then get rid of this pm in bills
4006            $qb = $this->db->getQueryBuilder();
4007            $qb->update('cospend_bills');
4008            $qb->set('paymentmodeid', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT));
4009            $qb->where(
4010                $qb->expr()->eq('paymentmodeid', $qb->createNamedParameter($pmid, IQueryBuilder::PARAM_INT))
4011            )
4012                ->andWhere(
4013                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4014                );
4015            $qb->executeStatement();
4016            $qb->resetQueryParts();
4017
4018            return ['success' => true];
4019        } else {
4020            return ['message' => $this->trans->t('Not found')];
4021        }
4022    }
4023
4024    /**
4025     * @param string $projectid
4026     * @param array $order
4027     * @return bool
4028     */
4029    public function savePaymentModeOrder(string $projectid, array $order): bool {
4030        $qb = $this->db->getQueryBuilder();
4031        foreach ($order as $o) {
4032            $qb->update('cospend_paymentmodes');
4033            $qb->set('order', $qb->createNamedParameter($o['order'], IQueryBuilder::PARAM_INT));
4034            $qb->where(
4035                $qb->expr()->eq('id', $qb->createNamedParameter($o['id'], IQueryBuilder::PARAM_INT))
4036            )
4037                ->andWhere(
4038                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4039                );
4040            $qb->executeStatement();
4041            $qb = $qb->resetQueryParts();
4042        }
4043        return true;
4044    }
4045
4046    /**
4047     * @param string $projectid
4048     * @param int $pmid
4049     * @param string|null $name
4050     * @param string|null $icon
4051     * @param string|null $color
4052     * @return array
4053     */
4054    public function editPaymentMode(string $projectid, int $pmid, ?string $name = null,
4055                                    ?string $icon = null, ?string $color = null): array {
4056        if ($name !== null && $name !== '') {
4057            $encIcon = $icon;
4058            if ($icon !== null && $icon !== '') {
4059                $encIcon = urlencode($icon);
4060            }
4061            if ($this->getPaymentMode($projectid, $pmid) !== null) {
4062                $qb = $this->db->getQueryBuilder();
4063                $qb->update('cospend_paymentmodes');
4064                $qb->set('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR));
4065                $qb->set('encoded_icon', $qb->createNamedParameter($encIcon, IQueryBuilder::PARAM_STR));
4066                $qb->set('color', $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR));
4067                $qb->where(
4068                    $qb->expr()->eq('id', $qb->createNamedParameter($pmid, IQueryBuilder::PARAM_INT))
4069                )
4070                    ->andWhere(
4071                        $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4072                    );
4073                $qb->executeStatement();
4074                $qb->resetQueryParts();
4075
4076                return $this->getPaymentMode($projectid, $pmid);
4077            } else {
4078                return ['message' => $this->trans->t('This project has no such payment mode')];
4079            }
4080        } else {
4081            return ['message' => $this->trans->t('Incorrect field values')];
4082        }
4083    }
4084
4085    /**
4086     * Add a new category
4087     *
4088     * @param string $projectid
4089     * @param string $name
4090     * @param string|null $icon
4091     * @param string $color
4092     * @param int|null $order
4093     * @return int
4094     */
4095    public function addCategory(string $projectid, string $name, ?string $icon, string $color, ?int $order = 0): int {
4096        $qb = $this->db->getQueryBuilder();
4097
4098        $encIcon = $icon;
4099        if ($icon !== null && $icon !== '') {
4100            $encIcon = urlencode($icon);
4101        }
4102        $qb->insert('cospend_categories')
4103            ->values([
4104                'projectid' => $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR),
4105                'encoded_icon' => $qb->createNamedParameter($encIcon, IQueryBuilder::PARAM_STR),
4106                'color' => $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR),
4107                'name' => $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR),
4108                'order' => $qb->createNamedParameter(is_null($order) ? 0 : $order, IQueryBuilder::PARAM_INT)
4109            ]);
4110        $qb->executeStatement();
4111        $qb = $qb->resetQueryParts();
4112
4113        return $qb->getLastInsertId();
4114    }
4115
4116    /**
4117     * Get a category
4118     *
4119     * @param string $projectId
4120     * @param int $categoryid
4121     * @return array|null
4122     */
4123    public function getCategory(string $projectId, int $categoryid): ?array {
4124        $category = null;
4125
4126        $qb = $this->db->getQueryBuilder();
4127        $qb->select('id', 'name', 'projectid', 'encoded_icon', 'color')
4128            ->from('cospend_categories')
4129            ->where(
4130                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
4131            )
4132            ->andWhere(
4133                $qb->expr()->eq('id', $qb->createNamedParameter($categoryid, IQueryBuilder::PARAM_INT))
4134            );
4135        $req = $qb->executeQuery();
4136
4137        while ($row = $req->fetch()) {
4138            $dbCategoryId = (int) $row['id'];
4139            $dbName = $row['name'];
4140            $dbIcon = urldecode($row['encoded_icon']);
4141            $dbColor = $row['color'];
4142            $category = [
4143                'name' => $dbName,
4144                'icon' => $dbIcon,
4145                'color' => $dbColor,
4146                'id' => $dbCategoryId,
4147                'projectid' => $projectId,
4148            ];
4149            break;
4150        }
4151        $req->closeCursor();
4152        $qb->resetQueryParts();
4153        return $category;
4154    }
4155
4156    /**
4157     * Delete a category
4158     *
4159     * @param string $projectid
4160     * @param int $categoryid
4161     * @return array
4162     */
4163    public function deleteCategory(string $projectid, int $categoryid): array {
4164        $categoryToDelete = $this->getCategory($projectid, $categoryid);
4165        if ($categoryToDelete !== null) {
4166            $qb = $this->db->getQueryBuilder();
4167            $qb->delete('cospend_categories')
4168                ->where(
4169                    $qb->expr()->eq('id', $qb->createNamedParameter($categoryid, IQueryBuilder::PARAM_INT))
4170                )
4171                ->andWhere(
4172                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4173                );
4174            $qb->executeStatement();
4175            $qb->resetQueryParts();
4176
4177            // then get rid of this category in bills
4178            $qb = $this->db->getQueryBuilder();
4179            $qb->update('cospend_bills');
4180            $qb->set('categoryid', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT));
4181            $qb->where(
4182                $qb->expr()->eq('categoryid', $qb->createNamedParameter($categoryid, IQueryBuilder::PARAM_INT))
4183            )
4184                ->andWhere(
4185                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4186                );
4187            $qb->executeStatement();
4188            $qb->resetQueryParts();
4189
4190            return ['success' => true];
4191        } else {
4192            return ['message' => $this->trans->t('Not found')];
4193        }
4194    }
4195
4196    /**
4197     * Save the manual category order
4198     *
4199     * @param string $projectid
4200     * @param array $order
4201     * @return bool
4202     */
4203    public function saveCategoryOrder(string $projectid, array $order): bool {
4204        $qb = $this->db->getQueryBuilder();
4205        foreach ($order as $o) {
4206            $qb->update('cospend_categories');
4207            $qb->set('order', $qb->createNamedParameter($o['order'], IQueryBuilder::PARAM_INT));
4208            $qb->where(
4209                $qb->expr()->eq('id', $qb->createNamedParameter($o['id'], IQueryBuilder::PARAM_INT))
4210            )
4211                ->andWhere(
4212                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4213                );
4214            $qb->executeStatement();
4215            $qb = $qb->resetQueryParts();
4216        }
4217        return true;
4218    }
4219
4220    /**
4221     * Edit a category
4222     *
4223     * @param string $projectid
4224     * @param int $categoryid
4225     * @param string|null $name
4226     * @param string|null $icon
4227     * @param string|null $color
4228     * @return array
4229     */
4230    public function editCategory(string $projectid, int $categoryid, ?string $name = null,
4231                                 ?string $icon = null, ?string $color = null): array {
4232        if ($name !== null && $name !== '') {
4233            $encIcon = $icon;
4234            if ($icon !== null && $icon !== '') {
4235                $encIcon = urlencode($icon);
4236            }
4237            if ($this->getCategory($projectid, $categoryid) !== null) {
4238                $qb = $this->db->getQueryBuilder();
4239                $qb->update('cospend_categories');
4240                $qb->set('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR));
4241                $qb->set('encoded_icon', $qb->createNamedParameter($encIcon, IQueryBuilder::PARAM_STR));
4242                $qb->set('color', $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR));
4243                $qb->where(
4244                    $qb->expr()->eq('id', $qb->createNamedParameter($categoryid, IQueryBuilder::PARAM_INT))
4245                )
4246                    ->andWhere(
4247                        $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4248                    );
4249                $qb->executeStatement();
4250                $qb->resetQueryParts();
4251
4252                return $this->getCategory($projectid, $categoryid);
4253            } else {
4254                return ['message' => $this->trans->t('This project has no such category')];
4255            }
4256        } else {
4257            return ['message' => $this->trans->t('Incorrect field values')];
4258        }
4259    }
4260
4261    /**
4262     * Add a currency
4263     *
4264     * @param string $projectid
4265     * @param string $name
4266     * @param float $rate
4267     * @return int
4268     */
4269    public function addCurrency(string $projectid, string $name, float $rate): int {
4270        $qb = $this->db->getQueryBuilder();
4271
4272        $qb->insert('cospend_currencies')
4273            ->values([
4274                'projectid' => $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR),
4275                'name' => $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR),
4276                'exchange_rate' => $qb->createNamedParameter($rate, IQueryBuilder::PARAM_STR)
4277            ]);
4278        $qb->executeStatement();
4279        $qb = $qb->resetQueryParts();
4280
4281        return $qb->getLastInsertId();
4282    }
4283
4284    /**
4285     * Get one currency
4286     *
4287     * @param string $projectId
4288     * @param int $currencyid
4289     * @return array|null
4290     */
4291    private function getCurrency(string $projectId, int $currencyid): ?array {
4292        $currency = null;
4293
4294        $qb = $this->db->getQueryBuilder();
4295        $qb->select('id', 'name', 'exchange_rate', 'projectid')
4296            ->from('cospend_currencies')
4297            ->where(
4298                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
4299            )
4300            ->andWhere(
4301                $qb->expr()->eq('id', $qb->createNamedParameter($currencyid, IQueryBuilder::PARAM_INT))
4302            );
4303        $req = $qb->executeQuery();
4304
4305        while ($row = $req->fetch()) {
4306            $dbCurrencyId = (int) $row['id'];
4307            $dbRate = (float) $row['exchange_rate'];
4308            $dbName = $row['name'];
4309            $currency = [
4310                'name' => $dbName,
4311                'id' => $dbCurrencyId,
4312                'exchange_rate' => $dbRate,
4313                'projectid' => $projectId,
4314            ];
4315            break;
4316        }
4317        $req->closeCursor();
4318        $qb->resetQueryParts();
4319        return $currency;
4320    }
4321
4322    /**
4323     * Delete one currency
4324     *
4325     * @param string $projectid
4326     * @param int $currencyid
4327     * @return array
4328     */
4329    public function deleteCurrency(string $projectid, int $currencyid): array {
4330        $currencyToDelete = $this->getCurrency($projectid, $currencyid);
4331        if ($currencyToDelete !== null) {
4332            $qb = $this->db->getQueryBuilder();
4333            $qb->delete('cospend_currencies')
4334                ->where(
4335                    $qb->expr()->eq('id', $qb->createNamedParameter($currencyid, IQueryBuilder::PARAM_INT))
4336                )
4337                ->andWhere(
4338                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4339                );
4340            $qb->executeStatement();
4341            $qb->resetQueryParts();
4342
4343            return ['success' => true];
4344        } else {
4345            return ['message' => $this->trans->t('Not found')];
4346        }
4347    }
4348
4349    /**
4350     * Edit a currency
4351     *
4352     * @param string $projectid
4353     * @param int $currencyid
4354     * @param string $name
4355     * @param float $exchange_rate
4356     * @return array
4357     */
4358    public function editCurrency(string $projectid, int $currencyid, string $name, float $exchange_rate): array {
4359        if ($name !== '' && $exchange_rate !== 0.0) {
4360            if ($this->getCurrency($projectid, $currencyid) !== null) {
4361                $qb = $this->db->getQueryBuilder();
4362                $qb->update('cospend_currencies');
4363                $qb->set('exchange_rate', $qb->createNamedParameter($exchange_rate, IQueryBuilder::PARAM_STR));
4364                $qb->set('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR));
4365                $qb->where(
4366                    $qb->expr()->eq('id', $qb->createNamedParameter($currencyid, IQueryBuilder::PARAM_INT))
4367                )
4368                    ->andWhere(
4369                        $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4370                    );
4371                $qb->executeStatement();
4372                $qb->resetQueryParts();
4373
4374                return $this->getCurrency($projectid, $currencyid);
4375            } else {
4376                return ['message' => $this->trans->t('This project have no such currency')];
4377            }
4378        } else {
4379            return ['message' => $this->trans->t('Incorrect field values')];
4380        }
4381    }
4382
4383    /**
4384     * Add a user shared access to a project
4385     *
4386     * @param string $projectid
4387     * @param string $userid
4388     * @param string $fromUserId
4389     * @param int $accesslevel
4390     * @param bool $manually_added
4391     * @return array
4392     */
4393    public function addUserShare(string $projectid, string $userid, string $fromUserId,
4394                                 int $accesslevel = Application::ACCESS_LEVELS['participant'], bool $manually_added = true): array {
4395        $user = $this->userManager->get($userid);
4396        if ($user !== null && $userid !== $fromUserId) {
4397            $userName = $user->getDisplayName();
4398            $qb = $this->db->getQueryBuilder();
4399            $projectInfo = $this->getProjectInfo($projectid);
4400            // check if someone tries to share the project with its owner
4401            if ($userid !== $projectInfo['userid']) {
4402                // check if user share exists
4403                $qb->select('userid', 'projectid')
4404                    ->from('cospend_shares', 's')
4405                    ->where(
4406                        $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['user'], IQueryBuilder::PARAM_STR))
4407                    )
4408                    ->andWhere(
4409                        $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4410                    )
4411                    ->andWhere(
4412                        $qb->expr()->eq('userid', $qb->createNamedParameter($userid, IQueryBuilder::PARAM_STR))
4413                    );
4414                $req = $qb->executeQuery();
4415                $dbuserId = null;
4416                while ($row = $req->fetch()){
4417                    $dbuserId = $row['userid'];
4418                    break;
4419                }
4420                $req->closeCursor();
4421                $qb = $qb->resetQueryParts();
4422
4423                if ($dbuserId === null) {
4424                    if ($this->getUserMaxAccessLevel($fromUserId, $projectid) >= $accesslevel) {
4425                        $qb->insert('cospend_shares')
4426                            ->values([
4427                                'projectid' => $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR),
4428                                'userid' => $qb->createNamedParameter($userid, IQueryBuilder::PARAM_STR),
4429                                'type' => $qb->createNamedParameter(Application::SHARE_TYPES['user'], IQueryBuilder::PARAM_STR),
4430                                'accesslevel' => $qb->createNamedParameter($accesslevel, IQueryBuilder::PARAM_INT),
4431                                'manually_added' => $qb->createNamedParameter($manually_added ? 1 : 0, IQueryBuilder::PARAM_INT),
4432                            ]);
4433                        $qb->executeStatement();
4434                        $qb = $qb->resetQueryParts();
4435
4436                        $insertedShareId = $qb->getLastInsertId();
4437                        $response = [
4438                            'id' => $insertedShareId,
4439                            'name' => $userName,
4440                        ];
4441
4442                        // activity
4443                        $projectObj = $this->projectMapper->find($projectid);
4444                        $this->activityManager->triggerEvent(
4445                            ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj,
4446                            ActivityManager::SUBJECT_PROJECT_SHARE,
4447                            ['who' => $userid, 'type' => Application::SHARE_TYPES['user']]
4448                        );
4449
4450                        // SEND NOTIFICATION
4451                        $manager = $this->notificationManager;
4452                        $notification = $manager->createNotification();
4453
4454                        $acceptAction = $notification->createAction();
4455                        $acceptAction->setLabel('accept')
4456                            ->setLink('/apps/cospend', 'GET');
4457
4458                        $declineAction = $notification->createAction();
4459                        $declineAction->setLabel('decline')
4460                            ->setLink('/apps/cospend', 'GET');
4461
4462                        $notification->setApp('cospend')
4463                            ->setUser($userid)
4464                            ->setDateTime(new DateTime())
4465                            ->setObject('addusershare', $projectid)
4466                            ->setSubject('add_user_share', [$fromUserId, $projectInfo['name']])
4467                            ->addAction($acceptAction)
4468                            ->addAction($declineAction);
4469
4470                        $manager->notify($notification);
4471
4472                        return $response;
4473                    } else {
4474                        return ['message' => $this->trans->t('You are not authorized to give such access level')];
4475                    }
4476                } else {
4477                    return ['message' => $this->trans->t('Already shared with this user')];
4478                }
4479            } else {
4480                return ['message' => $this->trans->t('Impossible to share the project with its owner')];
4481            }
4482        } else {
4483            return ['message' => $this->trans->t('No such user')];
4484        }
4485    }
4486
4487    /**
4488     * Add public share access (public link with token)
4489     *
4490     * @param string $projectid
4491     * @return array
4492     */
4493    public function addPublicShare(string $projectid): array {
4494        $qb = $this->db->getQueryBuilder();
4495        // generate token
4496        $token = md5($projectid.rand());
4497
4498        $qb->insert('cospend_shares')
4499            ->values([
4500                'projectid' => $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR),
4501                'userid' => $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR),
4502                'type' => $qb->createNamedParameter(Application::SHARE_TYPES['public_link'], IQueryBuilder::PARAM_STR)
4503            ]);
4504        $qb->executeStatement();
4505        $qb = $qb->resetQueryParts();
4506
4507        $insertedShareId = $qb->getLastInsertId();
4508
4509        //// activity
4510        //$projectObj = $this->projectMapper->find($projectid);
4511        //$this->activityManager->triggerEvent(
4512        //    ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj,
4513        //    ActivityManager::SUBJECT_PROJECT_SHARE,
4514        //    ['who' => $userid, 'type' => 'u']
4515        //);
4516
4517        //// SEND NOTIFICATION
4518        //$projectInfo = $this->getProjectInfo($projectid);
4519        //$manager = $this->notificationManager;
4520        //$notification = $manager->createNotification();
4521
4522        //$acceptAction = $notification->createAction();
4523        //$acceptAction->setLabel('accept')
4524        //    ->setLink('/apps/cospend', 'GET');
4525
4526        //$declineAction = $notification->createAction();
4527        //$declineAction->setLabel('decline')
4528        //    ->setLink('/apps/cospend', 'GET');
4529
4530        //$notification->setApp('cospend')
4531        //    ->setUser($userid)
4532        //    ->setDateTime(new DateTime())
4533        //    ->setObject('addusershare', $projectid)
4534        //    ->setSubject('add_user_share', [$fromUserId, $projectInfo['name']])
4535        //    ->addAction($acceptAction)
4536        //    ->addAction($declineAction)
4537        //    ;
4538
4539        //$manager->notify($notification);
4540
4541        return [
4542            'token' => $token,
4543            'id' => $insertedShareId
4544        ];
4545    }
4546
4547    /**
4548     * Change shared access permissions
4549     *
4550     * @param string $projectid
4551     * @param int $shid
4552     * @param int $accesslevel
4553     * @return array
4554     */
4555    public function editShareAccessLevel(string $projectid, int $shid, int $accesslevel): array {
4556        // check if user share exists
4557        $qb = $this->db->getQueryBuilder();
4558        $qb->select('id', 'projectid')
4559            ->from('cospend_shares', 's')
4560            ->where(
4561                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4562            )
4563            ->andWhere(
4564                $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
4565            );
4566        $req = $qb->executeQuery();
4567        $dbId = null;
4568        while ($row = $req->fetch()){
4569            $dbId = $row['id'];
4570            break;
4571        }
4572        $req->closeCursor();
4573        $qb = $qb->resetQueryParts();
4574
4575        if ($dbId !== null) {
4576            // set the accesslevel
4577            $qb->update('cospend_shares')
4578                ->set('accesslevel', $qb->createNamedParameter($accesslevel, IQueryBuilder::PARAM_INT))
4579                ->where(
4580                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4581                )
4582                ->andWhere(
4583                    $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
4584                );
4585            $qb->executeStatement();
4586            $qb->resetQueryParts();
4587
4588            return ['success' => true];
4589        } else {
4590            return ['message' => $this->trans->t('No such share')];
4591        }
4592    }
4593
4594    /**
4595     * Change shared access permissions
4596     *
4597     * @param string $projectid
4598     * @param int $shid
4599     * @param string|null $label
4600     * @return array
4601     * @throws \OCP\DB\Exception
4602     */
4603    public function editShareAccess(string $projectid, int $shid, ?string $label = null, ?string $password = null): array {
4604        // check if user share exists
4605        $qb = $this->db->getQueryBuilder();
4606        $qb->select('id', 'projectid')
4607            ->from('cospend_shares', 's')
4608            ->where(
4609                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4610            )
4611            ->andWhere(
4612                $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
4613            );
4614        $req = $qb->executeQuery();
4615        $dbId = null;
4616        while ($row = $req->fetch()){
4617            $dbId = $row['id'];
4618            break;
4619        }
4620        $req->closeCursor();
4621        $qb = $qb->resetQueryParts();
4622
4623        if (!is_null($dbId) && (!is_null($label) || !is_null($password))) {
4624            $qb->update('cospend_shares');
4625            if (!is_null($label)) {
4626                if ($label === '') {
4627                    $label = null;
4628                }
4629                $qb->set('label', $qb->createNamedParameter($label, IQueryBuilder::PARAM_STR));
4630            }
4631            if (!is_null($password)) {
4632                if ($password === '') {
4633                    $password = null;
4634                }
4635                $qb->set('password', $qb->createNamedParameter($password, IQueryBuilder::PARAM_STR));
4636            }
4637            $qb->where(
4638                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4639            )
4640                ->andWhere(
4641                    $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
4642                );
4643            $qb->executeStatement();
4644            $qb->resetQueryParts();
4645
4646            return ['success' => true];
4647        } else {
4648            return ['message' => $this->trans->t('No such share')];
4649        }
4650    }
4651
4652    /**
4653     * Change guest access permissions
4654     *
4655     * @param string $projectid
4656     * @param int $accesslevel
4657     * @return array
4658     */
4659    public function editGuestAccessLevel(string $projectid, int $accesslevel): array {
4660        // check if project exists
4661        $qb = $this->db->getQueryBuilder();
4662
4663        // set the access level
4664        $qb->update('cospend_projects')
4665            ->set('guestaccesslevel', $qb->createNamedParameter($accesslevel, IQueryBuilder::PARAM_INT))
4666            ->where(
4667                $qb->expr()->eq('id', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4668            );
4669        $qb->executeStatement();
4670        $qb->resetQueryParts();
4671
4672        return ['success' => true];
4673    }
4674
4675    /**
4676     * Delete user shared access
4677     *
4678     * @param string $projectid
4679     * @param int $shid
4680     * @param string|null $fromUserId
4681     * @return array
4682     */
4683    public function deleteUserShare(string $projectid, int $shid, ?string $fromUserId = null): array {
4684        // check if user share exists
4685        $qb = $this->db->getQueryBuilder();
4686        $qb->select('id', 'userid', 'projectid')
4687            ->from('cospend_shares', 's')
4688            ->where(
4689                $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['user'], IQueryBuilder::PARAM_STR))
4690            )
4691            ->andWhere(
4692                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4693            )
4694            ->andWhere(
4695                $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
4696            );
4697        $req = $qb->executeQuery();
4698        $dbId = null;
4699        $dbuserId = null;
4700        while ($row = $req->fetch()){
4701            $dbId = $row['id'];
4702            $dbuserId = $row['userid'];
4703            break;
4704        }
4705        $req->closeCursor();
4706        $qb = $qb->resetQueryParts();
4707
4708        if ($dbId !== null) {
4709            // delete
4710            $qb->delete('cospend_shares')
4711                ->where(
4712                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4713                )
4714                ->andWhere(
4715                    $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
4716                )
4717                ->andWhere(
4718                    $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['user'], IQueryBuilder::PARAM_STR))
4719                );
4720            $qb->executeStatement();
4721            $qb->resetQueryParts();
4722
4723            // activity
4724            $projectObj = $this->projectMapper->find($projectid);
4725            $this->activityManager->triggerEvent(
4726                ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj,
4727                ActivityManager::SUBJECT_PROJECT_UNSHARE,
4728                ['who' => $dbuserId, 'type' => Application::SHARE_TYPES['user']]
4729            );
4730
4731            // SEND NOTIFICATION
4732            if (!is_null($fromUserId)) {
4733                $projectInfo = $this->getProjectInfo($projectid);
4734
4735                $manager = $this->notificationManager;
4736                $notification = $manager->createNotification();
4737
4738                $acceptAction = $notification->createAction();
4739                $acceptAction->setLabel('accept')
4740                    ->setLink('/apps/cospend', 'GET');
4741
4742                $declineAction = $notification->createAction();
4743                $declineAction->setLabel('decline')
4744                    ->setLink('/apps/cospend', 'GET');
4745
4746                $notification->setApp('cospend')
4747                    ->setUser($dbuserId)
4748                    ->setDateTime(new DateTime())
4749                    ->setObject('deleteusershare', $projectid)
4750                    ->setSubject('delete_user_share', [$fromUserId, $projectInfo['name']])
4751                    ->addAction($acceptAction)
4752                    ->addAction($declineAction)
4753                ;
4754
4755                $manager->notify($notification);
4756            }
4757
4758            return ['success' => true];
4759        } else {
4760            return ['message' => $this->trans->t('No such share')];
4761        }
4762    }
4763
4764    /**
4765     * Delete public shared access
4766     *
4767     * @param string $projectid
4768     * @param int $shid
4769     * @return array
4770     */
4771    public function deletePublicShare(string $projectid, int $shid): array {
4772        // check if public share exists
4773        $qb = $this->db->getQueryBuilder();
4774        $qb->select('id', 'userid', 'projectid')
4775            ->from('cospend_shares', 's')
4776            ->where(
4777                $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['public_link'], IQueryBuilder::PARAM_STR))
4778            )
4779            ->andWhere(
4780                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4781            )
4782            ->andWhere(
4783                $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
4784            );
4785        $req = $qb->executeQuery();
4786        $dbId = null;
4787        while ($row = $req->fetch()){
4788            $dbId = $row['id'];
4789            break;
4790        }
4791        $req->closeCursor();
4792        $qb = $qb->resetQueryParts();
4793
4794        if ($dbId !== null) {
4795            // delete
4796            $qb->delete('cospend_shares')
4797                ->where(
4798                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4799                )
4800                ->andWhere(
4801                    $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
4802                )
4803                ->andWhere(
4804                    $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['public_link'], IQueryBuilder::PARAM_STR))
4805                );
4806            $qb->executeStatement();
4807            $qb->resetQueryParts();
4808
4809            //// activity
4810            //$projectObj = $this->projectMapper->find($projectid);
4811            //$this->activityManager->triggerEvent(
4812            //    ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj,
4813            //    ActivityManager::SUBJECT_PROJECT_UNSHARE,
4814            //    ['who' => $dbuserId, 'type' => 'u']
4815            //);
4816
4817            //// SEND NOTIFICATION
4818            //$projectInfo = $this->getProjectInfo($projectid);
4819
4820            //$manager = $this->notificationManager;
4821            //$notification = $manager->createNotification();
4822
4823            //$acceptAction = $notification->createAction();
4824            //$acceptAction->setLabel('accept')
4825            //    ->setLink('/apps/cospend', 'GET');
4826
4827            //$declineAction = $notification->createAction();
4828            //$declineAction->setLabel('decline')
4829            //    ->setLink('/apps/cospend', 'GET');
4830
4831            //$notification->setApp('cospend')
4832            //    ->setUser($dbuserId)
4833            //    ->setDateTime(new DateTime())
4834            //    ->setObject('deleteusershare', $projectid)
4835            //    ->setSubject('delete_user_share', [$fromUserId, $projectInfo['name']])
4836            //    ->addAction($acceptAction)
4837            //    ->addAction($declineAction)
4838            //    ;
4839
4840            //$manager->notify($notification);
4841
4842            return ['success' => true];
4843        } else {
4844            return ['message' => $this->trans->t('No such shared access')];
4845        }
4846    }
4847
4848    /**
4849     * Add group shared access
4850     *
4851     * @param string $projectid
4852     * @param string $groupid
4853     * @param string|null $fromUserId
4854     * @return array
4855     */
4856    public function addGroupShare(string $projectid, string $groupid, ?string $fromUserId = null): array {
4857        if ($this->groupManager->groupExists($groupid)) {
4858            $groupName = $this->groupManager->get($groupid)->getDisplayName();
4859            $qb = $this->db->getQueryBuilder();
4860            // check if user share exists
4861            $qb->select('userid', 'projectid')
4862                ->from('cospend_shares', 's')
4863                ->where(
4864                    $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['group'], IQueryBuilder::PARAM_STR))
4865                )
4866                ->andWhere(
4867                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4868                )
4869                ->andWhere(
4870                    $qb->expr()->eq('userid', $qb->createNamedParameter($groupid, IQueryBuilder::PARAM_STR))
4871                );
4872            $req = $qb->executeQuery();
4873            $dbGroupId = null;
4874            while ($row = $req->fetch()){
4875                $dbGroupId = $row['userid'];
4876                break;
4877            }
4878            $req->closeCursor();
4879            $qb = $qb->resetQueryParts();
4880
4881            if ($dbGroupId === null) {
4882                $qb->insert('cospend_shares')
4883                    ->values([
4884                        'projectid' => $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR),
4885                        'userid' => $qb->createNamedParameter($groupid, IQueryBuilder::PARAM_STR),
4886                        'type' => $qb->createNamedParameter(Application::SHARE_TYPES['group'], IQueryBuilder::PARAM_STR)
4887                    ]);
4888                $qb->executeStatement();
4889                $qb = $qb->resetQueryParts();
4890
4891                $insertedShareId = $qb->getLastInsertId();
4892
4893                // activity
4894                $projectObj = $this->projectMapper->find($projectid);
4895                $this->activityManager->triggerEvent(
4896                    ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj,
4897                    ActivityManager::SUBJECT_PROJECT_SHARE,
4898                    ['who' => $groupid, 'type' => Application::SHARE_TYPES['group']]
4899                );
4900
4901                return [
4902                    'id' => $insertedShareId,
4903                    'name' => $groupName,
4904                ];
4905            } else {
4906                return ['message' => $this->trans->t('Already shared with this group')];
4907            }
4908        } else {
4909            return ['message' => $this->trans->t('No such group')];
4910        }
4911    }
4912
4913    /**
4914     * Delete group shared access
4915     *
4916     * @param string $projectid
4917     * @param int shid
4918     * @param string|null $fromUserId
4919     * @return array
4920     */
4921    public function deleteGroupShare(string $projectid, int $shid, ?string $fromUserId = null): array {
4922        // check if group share exists
4923        $qb = $this->db->getQueryBuilder();
4924        $qb->select('userid', 'projectid', 'id')
4925            ->from('cospend_shares', 's')
4926            ->where(
4927                $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['group'], IQueryBuilder::PARAM_STR))
4928            )
4929            ->andWhere(
4930                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4931            )
4932            ->andWhere(
4933                $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
4934            );
4935        $req = $qb->executeQuery();
4936        $dbGroupId = null;
4937        while ($row = $req->fetch()){
4938            $dbGroupId = $row['userid'];
4939            break;
4940        }
4941        $req->closeCursor();
4942        $qb = $qb->resetQueryParts();
4943
4944        if ($dbGroupId !== null) {
4945            // delete
4946            $qb->delete('cospend_shares')
4947                ->where(
4948                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
4949                )
4950                ->andWhere(
4951                    $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
4952                )
4953                ->andWhere(
4954                    $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['group'], IQueryBuilder::PARAM_STR))
4955                );
4956            $qb->executeStatement();
4957            $qb->resetQueryParts();
4958
4959            // activity
4960            $projectObj = $this->projectMapper->find($projectid);
4961            $this->activityManager->triggerEvent(
4962                ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj,
4963                ActivityManager::SUBJECT_PROJECT_UNSHARE,
4964                ['who' => $dbGroupId, 'type' => Application::SHARE_TYPES['group']]
4965            );
4966
4967            return ['success' => true];
4968        } else {
4969            return ['message' => $this->trans->t('No such share')];
4970        }
4971    }
4972
4973    /**
4974     * Add circle shaed access
4975     *
4976     * @param string $projectid
4977     * @param string $circleid
4978     * @param string|null $fromUserId
4979     * @return array
4980     */
4981    public function addCircleShare(string $projectid, string $circleid, ?string $fromUserId = null): array {
4982        // check if circleId exists
4983        $circlesEnabled = $this->appManager->isEnabledForUser('circles');
4984        if ($circlesEnabled) {
4985            try {
4986                $circlesManager = \OC::$server->get(\OCA\Circles\CirclesManager::class);
4987                $circlesManager->startSuperSession();
4988            } catch (Exception $e) {
4989                return ['message' => $this->trans->t('Impossible to get the circle manager')];
4990            }
4991
4992            $exists = true;
4993            $circleName = '';
4994            try {
4995                $circle = $circlesManager->getCircle($circleid);
4996                $circleName = $circle->getDisplayName();
4997            } catch (\OCA\Circles\Exceptions\CircleNotFoundException $e) {
4998                $exists = false;
4999            }
5000
5001            if ($circleid !== '' && $exists) {
5002                $qb = $this->db->getQueryBuilder();
5003                // check if circle share exists
5004                $qb->select('userid', 'projectid')
5005                    ->from('cospend_shares', 's')
5006                    ->where(
5007                        $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['circle'], IQueryBuilder::PARAM_STR))
5008                    )
5009                    ->andWhere(
5010                        $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
5011                    )
5012                    ->andWhere(
5013                        $qb->expr()->eq('userid', $qb->createNamedParameter($circleid, IQueryBuilder::PARAM_STR))
5014                    );
5015                $req = $qb->executeQuery();
5016                $dbCircleId = null;
5017                while ($row = $req->fetch()){
5018                    $dbCircleId = $row['userid'];
5019                    break;
5020                }
5021                $req->closeCursor();
5022                $qb = $qb->resetQueryParts();
5023
5024                if ($dbCircleId === null) {
5025                    $qb->insert('cospend_shares')
5026                        ->values([
5027                            'projectid' => $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR),
5028                            'userid' => $qb->createNamedParameter($circleid, IQueryBuilder::PARAM_STR),
5029                            'type' => $qb->createNamedParameter(Application::SHARE_TYPES['circle'], IQueryBuilder::PARAM_STR)
5030                        ]);
5031                    $qb->executeStatement();
5032                    $qb = $qb->resetQueryParts();
5033
5034                    $insertedShareId = $qb->getLastInsertId();
5035
5036                    // activity
5037                    $projectObj = $this->projectMapper->find($projectid);
5038                    $this->activityManager->triggerEvent(
5039                        ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj,
5040                        ActivityManager::SUBJECT_PROJECT_SHARE,
5041                        ['who' => $circleid, 'type' => Application::SHARE_TYPES['circle']]
5042                    );
5043
5044                    $circlesManager->stopSession();
5045                    return [
5046                        'id' => $insertedShareId,
5047                        'name' => $circleName,
5048                    ];
5049                } else {
5050                    $circlesManager->stopSession();
5051                    return ['message' => $this->trans->t('Already shared with this circle')];
5052                }
5053            } else {
5054                $circlesManager->stopSession();
5055                return ['message' => $this->trans->t('No such circle')];
5056            }
5057        } else {
5058            return ['message' => $this->trans->t('Circles app is not enabled')];
5059        }
5060    }
5061
5062    /**
5063     * Delete circle shared access
5064     *
5065     * @param string $projectid
5066     * @param int $shid
5067     * @param string|null $fromUserId
5068     * @return array
5069     */
5070    public function deleteCircleShare(string $projectid, int $shid, ?string $fromUserId = null): array {
5071        // check if circle share exists
5072        $qb = $this->db->getQueryBuilder();
5073        $qb->select('userid', 'projectid', 'id')
5074            ->from('cospend_shares', 's')
5075            ->where(
5076                $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['circle'], IQueryBuilder::PARAM_STR))
5077            )
5078            ->andWhere(
5079                $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
5080            )
5081            ->andWhere(
5082                $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
5083            );
5084        $req = $qb->executeQuery();
5085        $dbCircleId = null;
5086        while ($row = $req->fetch()){
5087            $dbCircleId = $row['userid'];
5088            break;
5089        }
5090        $req->closeCursor();
5091        $qb = $qb->resetQueryParts();
5092
5093        if ($dbCircleId !== null) {
5094            // delete
5095            $qb->delete('cospend_shares')
5096                ->where(
5097                    $qb->expr()->eq('projectid', $qb->createNamedParameter($projectid, IQueryBuilder::PARAM_STR))
5098                )
5099                ->andWhere(
5100                    $qb->expr()->eq('id', $qb->createNamedParameter($shid, IQueryBuilder::PARAM_INT))
5101                )
5102                ->andWhere(
5103                    $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPES['circle'], IQueryBuilder::PARAM_STR))
5104                );
5105            $qb->executeStatement();
5106            $qb->resetQueryParts();
5107
5108            // activity
5109            $projectObj = $this->projectMapper->find($projectid);
5110            $this->activityManager->triggerEvent(
5111                ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj,
5112                ActivityManager::SUBJECT_PROJECT_UNSHARE,
5113                ['who' => $dbCircleId, 'type' => Application::SHARE_TYPES['circle']]
5114            );
5115
5116            $response = ['success' => true];
5117        } else {
5118            $response = ['message' => $this->trans->t('No such share')];
5119        }
5120        return $response;
5121    }
5122
5123    /**
5124     * Export settlement plan in CSV
5125     *
5126     * @param string $projectid
5127     * @param string $userId
5128     * @param int|null $centeredOn
5129     * @param int|null $maxTimestamp
5130     * @return array
5131     */
5132    public function exportCsvSettlement(string $projectid, string $userId, ?int $centeredOn = null, ?int $maxTimestamp = null): array {
5133        // create export directory if needed
5134        $outPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend');
5135        $userFolder = $this->root->getUserFolder($userId);
5136        $msg = $this->createAndCheckExportDirectory($userFolder, $outPath);
5137        if ($msg !== '') {
5138            return ['message' => $msg];
5139        }
5140        $folder = $userFolder->get($outPath);
5141
5142        // create file
5143        if ($folder->nodeExists($projectid.'-settlement.csv')) {
5144            $folder->get($projectid.'-settlement.csv')->delete();
5145        }
5146        $file = $folder->newFile($projectid.'-settlement.csv');
5147        $handler = $file->fopen('w');
5148        fwrite(
5149            $handler,
5150            '"' . $this->trans->t('Who pays?')
5151            . '","' . $this->trans->t('To whom?')
5152            . '","' . $this->trans->t('How much?')
5153            . '"' . "\n"
5154        );
5155        $settlement = $this->getProjectSettlement($projectid, $centeredOn, $maxTimestamp);
5156        $transactions = $settlement['transactions'];
5157
5158        $members = $this->getMembers($projectid);
5159        $memberIdToName = [];
5160        foreach ($members as $member) {
5161            $memberIdToName[$member['id']] = $member['name'];
5162        }
5163
5164        foreach ($transactions as $transaction) {
5165            fwrite(
5166                $handler,
5167                '"' . $memberIdToName[$transaction['from']]
5168                . '","' . $memberIdToName[$transaction['to']]
5169                . '",' . (float) $transaction['amount']
5170                . "\n"
5171            );
5172        }
5173
5174        fclose($handler);
5175        $file->touch();
5176        return ['path' => $outPath . '/' . $projectid . '-settlement.csv'];
5177    }
5178
5179    /**
5180     * Create directory where things will be exported
5181     *
5182     * @param Folder $userFolder
5183     * @param string $outPath
5184     * @return string
5185     */
5186    private function createAndCheckExportDirectory(Folder $userFolder, string $outPath): string {
5187        if (!$userFolder->nodeExists($outPath)) {
5188            $userFolder->newFolder($outPath);
5189        }
5190        if ($userFolder->nodeExists($outPath)) {
5191            $folder = $userFolder->get($outPath);
5192            if ($folder->getType() !== FileInfo::TYPE_FOLDER) {
5193                return $this->trans->t('%1$s is not a folder', [$outPath]);
5194            } elseif (!$folder->isCreatable()) {
5195                return $this->trans->t('%1$s is not writeable', [$outPath]);
5196            } else {
5197                return '';
5198            }
5199        } else {
5200            return $this->trans->t('Impossible to create %1$s', [$outPath]);
5201        }
5202    }
5203
5204    /**
5205     * @param string $projectid
5206     * @param string $userId
5207     * @param int|null $tsMin
5208     * @param int|null $tsMax
5209     * @param int|null $paymentModeId
5210     * @param int|null $category
5211     * @param float|null $amountMin
5212     * @param float|null $amountMax
5213     * @param bool $showDisabled
5214     * @param int|null $currencyId
5215     * @return array
5216     * @throws \OCP\DB\Exception
5217     * @throws \OCP\Files\NotFoundException
5218     * @throws \OCP\Files\NotPermittedException
5219     * @throws \OC\User\NoUserException
5220     */
5221    public function exportCsvStatistics(string $projectid, string $userId, ?int $tsMin = null, ?int $tsMax = null,
5222                                        ?int $paymentModeId = null, ?int $category = null,
5223                                        ?float $amountMin = null, ?float $amountMax = null,
5224                                        bool $showDisabled = true, ?int $currencyId = null): array {
5225        // create export directory if needed
5226        $outPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend');
5227        $userFolder = $this->root->getUserFolder($userId);
5228        $msg = $this->createAndCheckExportDirectory($userFolder, $outPath);
5229        if ($msg !== '') {
5230            return ['message' => $msg];
5231        }
5232        $folder = $userFolder->get($outPath);
5233
5234        // create file
5235        if ($folder->nodeExists($projectid.'-stats.csv')) {
5236            $folder->get($projectid.'-stats.csv')->delete();
5237        }
5238        $file = $folder->newFile($projectid.'-stats.csv');
5239        $handler = $file->fopen('w');
5240        fwrite(
5241            $handler,
5242            $this->trans->t('Member name')
5243            . ',' . $this->trans->t('Paid')
5244            . ',' . $this->trans->t('Spent')
5245            . ',' . $this->trans->t('Balance')
5246            . "\n"
5247        );
5248        $allStats = $this->getProjectStatistics(
5249            $projectid, 'lowername', $tsMin, $tsMax, $paymentModeId,
5250            $category, $amountMin, $amountMax, $showDisabled, $currencyId
5251        );
5252        $stats = $allStats['stats'];
5253
5254        foreach ($stats as $stat) {
5255            fwrite(
5256                $handler,
5257                '"' . $stat['member']['name']
5258                . '",' . (float) $stat['paid']
5259                . ',' . (float) $stat['spent']
5260                . ',' . (float) $stat['balance']
5261                . "\n"
5262            );
5263        }
5264
5265        fclose($handler);
5266        $file->touch();
5267        return ['path' => $outPath . '/' . $projectid . '-stats.csv'];
5268    }
5269
5270    /**
5271     * Export project in CSV
5272     *
5273     * @param string $projectid
5274     * @param string|null $name
5275     * @param string $userId
5276     * @return array
5277     * @throws \OCP\Files\NotFoundException
5278     * @throws \OCP\Files\NotPermittedException
5279     * @throws \OC\User\NoUserException
5280     */
5281    public function exportCsvProject(string $projectid, string $userId, ?string $name = null): array {
5282        // create export directory if needed
5283        $outPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend');
5284        $userFolder = $this->root->getUserFolder($userId);
5285        $msg = $this->createAndCheckExportDirectory($userFolder, $outPath);
5286        if ($msg !== '') {
5287            return ['message' => $msg];
5288        }
5289        $folder = $userFolder->get($outPath);
5290
5291
5292        // create file
5293        $filename = $projectid.'.csv';
5294        if ($name !== null) {
5295            $filename = $name;
5296            if (!Utils::endswith($filename, '.csv')) {
5297                $filename .= '.csv';
5298            }
5299        }
5300        if ($folder->nodeExists($filename)) {
5301            $folder->get($filename)->delete();
5302        }
5303        $file = $folder->newFile($filename);
5304        $handler = $file->fopen('w');
5305        foreach ($this->getJsonProject($projectid) as $chunk) {
5306            fwrite($handler, $chunk);
5307        }
5308
5309        fclose($handler);
5310        $file->touch();
5311        return ['path' => $outPath . '/' . $filename];
5312    }
5313
5314    public function getJsonProject(string $projectId): Generator {
5315        // members
5316        yield "name,weight,active,color\n";
5317        $projectInfo = $this->getProjectInfo($projectId);
5318        $members = $projectInfo['members'];
5319        $memberIdToName = [];
5320        $memberIdToWeight = [];
5321        $memberIdToActive = [];
5322        foreach ($members as $member) {
5323            $memberIdToName[$member['id']] = $member['name'];
5324            $memberIdToWeight[$member['id']] = $member['weight'];
5325            $memberIdToActive[$member['id']] = (int) $member['activated'];
5326            $c = $member['color'];
5327            yield '"' . $member['name'] . '",'
5328                . (float) $member['weight'] . ','
5329                . (int) $member['activated'] . ',"'
5330                . sprintf("#%02x%02x%02x", $c['r'] ?? 0, $c['g'] ?? 0, $c['b'] ?? 0) . '"'
5331                . "\n";
5332        }
5333        // bills
5334        yield "\nwhat,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,repeatfreq,repeatallactive,repeatuntil,categoryid,paymentmode,paymentmodeid,comment\n";
5335        $bills = $this->getBills($projectId);
5336        foreach ($bills as $bill) {
5337            $owerNames = [];
5338            foreach ($bill['owers'] as $ower) {
5339                $owerNames[] = $ower['name'];
5340            }
5341            $owersTxt = implode(',', $owerNames);
5342
5343            $payer_id = $bill['payer_id'];
5344            $payer_name = $memberIdToName[$payer_id];
5345            $payer_weight = $memberIdToWeight[$payer_id];
5346            $payer_active = $memberIdToActive[$payer_id];
5347            $dateTime = DateTime::createFromFormat('U', $bill['timestamp']);
5348            $oldDateStr = $dateTime->format('Y-m-d');
5349            yield '"' . $bill['what'] . '",'
5350                . (float) $bill['amount'] . ','
5351                . $oldDateStr . ','
5352                . $bill['timestamp'] . ',"'
5353                . $payer_name . '",'
5354                . (float) $payer_weight . ','
5355                . $payer_active . ',"'
5356                . $owersTxt . '",'
5357                . $bill['repeat'] . ','
5358                . $bill['repeatfreq'] . ','
5359                . $bill['repeatallactive'] .','
5360                . $bill['repeatuntil'] . ','
5361                . $bill['categoryid'] . ','
5362                . $bill['paymentmode'] . ','
5363                . $bill['paymentmodeid'] . ',"'
5364                . urlencode($bill['comment']) . '"'
5365                . "\n";
5366        }
5367
5368        // write categories
5369        $categories = $projectInfo['categories'];
5370        if (count($categories) > 0) {
5371            yield "\ncategoryname,categoryid,icon,color\n";
5372            foreach ($categories as $id => $cat) {
5373                yield '"' . $cat['name'] . '",' .
5374                    (int) $id . ',"' .
5375                    $cat['icon'] . '","' .
5376                    $cat['color'] . '"' .
5377                    "\n";
5378            }
5379        }
5380
5381        // write payment modes
5382        $paymentModes = $projectInfo['paymentmodes'];
5383        if (count($paymentModes) > 0) {
5384            yield "\npaymentmodename,paymentmodeid,icon,color\n";
5385            foreach ($paymentModes as $id => $pm) {
5386                yield '"' . $pm['name'] . '",' .
5387                    (int) $id . ',"' .
5388                    $pm['icon'] . '","' .
5389                    $pm['color'] . '"' .
5390                    "\n";
5391            }
5392        }
5393
5394        // write currencies
5395        $currencies = $projectInfo['currencies'];
5396        if (count($currencies) > 0) {
5397            yield "\ncurrencyname,exchange_rate\n";
5398            // main currency
5399            yield '"' . $projectInfo['currencyname'] . '",1' . "\n";
5400            foreach ($currencies as $cur) {
5401                yield '"' . $cur['name']
5402                    . '",' . (float) $cur['exchange_rate']
5403                    . "\n";
5404            }
5405        }
5406
5407        return [];
5408    }
5409
5410    /**
5411     * Wrap the import process in an atomic DB transaction
5412     * This increases insert performance a lot
5413     *
5414     * importCsvProject() still takes care of cleaning up created entities in case of error
5415     * but this could be done by rollBack
5416     *
5417     * This could be done with TTransactional::atomic() when we drop support for NC < 24
5418     *
5419     * @param $handle
5420     * @param string $userId
5421     * @param string $projectName
5422     * @return array
5423     * @throws Throwable
5424     * @throws \OCP\DB\Exception
5425     */
5426    public function importCsvProjectAtomicWrapper($handle, string $userId, string $projectName): array {
5427        $this->db->beginTransaction();
5428        try {
5429            $result = $this->importCsvProjectStream($handle, $userId, $projectName);
5430            $this->db->commit();
5431            return $result;
5432        } catch (Throwable $e) {
5433            $this->db->rollBack();
5434            throw $e;
5435        }
5436    }
5437
5438    /**
5439     * Import CSV project file
5440     *
5441     * @param string $path
5442     * @param string $userId
5443     * @return array
5444     */
5445    public function importCsvProject(string $path, string $userId): array {
5446        $cleanPath = str_replace(['../', '..\\'], '',  $path);
5447        $userFolder = $this->root->getUserFolder($userId);
5448        if ($userFolder->nodeExists($cleanPath)) {
5449            $file = $userFolder->get($cleanPath);
5450            if ($file->getType() === FileInfo::TYPE_FILE) {
5451                if (($handle = $file->fopen('r')) !== false) {
5452                    $projectName = preg_replace('/\.csv$/', '', $file->getName());
5453                    return $this->importCsvProjectAtomicWrapper($handle, $userId, $projectName);
5454                } else {
5455                    return ['message' => $this->trans->t('Access denied')];
5456                }
5457            } else {
5458                return ['message' => $this->trans->t('Access denied')];
5459            }
5460        } else {
5461            return ['message' => $this->trans->t('Access denied')];
5462        }
5463    }
5464
5465    public function importCsvProjectStream($handle, string $userId, string $projectName): array {
5466        $columns = [];
5467        $membersByName = [];
5468        $bills = [];
5469        $currencies = [];
5470        $mainCurrencyName = null;
5471        $categories = [];
5472        $categoryIdConv = [];
5473        $paymentModes = [];
5474        $paymentModeIdConv = [];
5475        $previousLineEmpty = false;
5476        $currentSection = null;
5477        $row = 0;
5478        while (($data = fgetcsv($handle, 0, ',')) !== false) {
5479            $uni = array_unique($data);
5480            if ($data === [null] || (count($uni) === 1 && $uni[0] === '')) {
5481                $previousLineEmpty = true;
5482            } elseif ($row === 0 || $previousLineEmpty) {
5483                // determine which section we're entering
5484                $previousLineEmpty = false;
5485                $nbCol = count($data);
5486                $columns = [];
5487                for ($c = 0; $c < $nbCol; $c++) {
5488                    if ($data[$c] !== '') {
5489                        $columns[$data[$c]] = $c;
5490                    }
5491                }
5492                if (array_key_exists('what', $columns)
5493                    && array_key_exists('amount', $columns)
5494                    && (array_key_exists('date', $columns) || array_key_exists('timestamp', $columns))
5495                    && array_key_exists('payer_name', $columns)
5496                    && array_key_exists('payer_weight', $columns)
5497                    && array_key_exists('owers', $columns)
5498                ) {
5499                    $currentSection = 'bills';
5500                } elseif (array_key_exists('name', $columns)
5501                    && array_key_exists('weight', $columns)
5502                    && array_key_exists('active', $columns)
5503                    && array_key_exists('color', $columns)
5504                ) {
5505                    $currentSection = 'members';
5506                } elseif (array_key_exists('icon', $columns)
5507                    && array_key_exists('color', $columns)
5508                    && array_key_exists('paymentmodeid', $columns)
5509                    && array_key_exists('paymentmodename', $columns)
5510                ) {
5511                    $currentSection = 'paymentmodes';
5512                } elseif (array_key_exists('icon', $columns)
5513                    && array_key_exists('color', $columns)
5514                    && array_key_exists('categoryid', $columns)
5515                    && array_key_exists('categoryname', $columns)
5516                ) {
5517                    $currentSection = 'categories';
5518                } elseif (array_key_exists('exchange_rate', $columns)
5519                    && array_key_exists('currencyname', $columns)
5520                ) {
5521                    $currentSection = 'currencies';
5522                } else {
5523                    fclose($handle);
5524                    return ['message' => $this->trans->t('Malformed CSV, bad column names at line %1$s', [$row + 1])];
5525                }
5526            } else {
5527                // normal line: bill/category/payment mode/currency
5528                $previousLineEmpty = false;
5529                if ($currentSection === 'categories') {
5530                    if (mb_strlen($data[$columns['icon']], 'UTF-8') && preg_match('!\S!u', $data[$columns['icon']])) {
5531                        $icon = $data[$columns['icon']];
5532                    } else {
5533                        $icon = null;
5534                    }
5535                    $color = $data[$columns['color']];
5536                    $categoryid = $data[$columns['categoryid']];
5537                    $categoryname = $data[$columns['categoryname']];
5538                    $categories[] = [
5539                        'icon' => $icon,
5540                        'color' => $color,
5541                        'id' => $categoryid,
5542                        'name' => $categoryname,
5543                    ];
5544                } elseif ($currentSection === 'paymentmodes') {
5545                    if (mb_strlen($data[$columns['icon']], 'UTF-8') && preg_match('!\S!u', $data[$columns['icon']])) {
5546                        $icon = $data[$columns['icon']];
5547                    } else {
5548                        $icon = null;
5549                    }
5550                    $color = $data[$columns['color']];
5551                    $paymentmodeid = $data[$columns['paymentmodeid']];
5552                    $paymentmodename = $data[$columns['paymentmodename']];
5553                    $paymentModes[] = [
5554                        'icon' => $icon,
5555                        'color' => $color,
5556                        'id' => $paymentmodeid,
5557                        'name' => $paymentmodename,
5558                    ];
5559                } elseif ($currentSection === 'currencies') {
5560                    $name = $data[$columns['currencyname']];
5561                    $exchange_rate = $data[$columns['exchange_rate']];
5562                    if (((float) $exchange_rate) === 1.0) {
5563                        $mainCurrencyName = $name;
5564                    } else {
5565                        $currencies[] = [
5566                            'name' => $name,
5567                            'exchange_rate' => $exchange_rate,
5568                        ];
5569                    }
5570                } elseif ($currentSection === 'members') {
5571                    $name = trim($data[$columns['name']]);
5572                    $weight = $data[$columns['weight']];
5573                    $active = $data[$columns['active']];
5574                    $color = $data[$columns['color']];
5575                    if (strlen($name) > 0
5576                        && is_numeric($weight)
5577                        && is_numeric($active)
5578                        && preg_match('/^#[0-9A-Fa-f]+$/', $color) !== false
5579                    ) {
5580                        $membersByName[$name] = [
5581                            'weight' => $weight,
5582                            'active' => (int) $active !== 0,
5583                            'color' => $color,
5584                        ];
5585                    } else {
5586                        fclose($handle);
5587                        return ['message' => $this->trans->t('Malformed CSV, invalid member on line %1$s', [$row + 1])];
5588                    }
5589                } elseif ($currentSection === 'bills') {
5590                    $what = $data[$columns['what']];
5591                    $amount = $data[$columns['amount']];
5592                    $timestamp = null;
5593                    // priority to timestamp
5594                    if (array_key_exists('timestamp', $columns)) {
5595                        $timestamp = $data[$columns['timestamp']];
5596                    } elseif (array_key_exists('date', $columns)) {
5597                        $date = $data[$columns['date']];
5598                        $datetime = DateTime::createFromFormat('Y-m-d', $date);
5599                        if ($datetime !== false) {
5600                            $timestamp = $datetime->getTimestamp();
5601                        }
5602                    }
5603                    if ($timestamp === null) {
5604                        fclose($handle);
5605                        return ['message' => $this->trans->t('Malformed CSV, missing or invalid date/timestamp on line %1$s', [$row + 1])];
5606                    }
5607                    $payer_name = $data[$columns['payer_name']];
5608                    $payer_weight = $data[$columns['payer_weight']];
5609                    $owers = $data[$columns['owers']];
5610                    $payer_active = array_key_exists('payer_active', $columns) ? $data[$columns['payer_active']] : 1;
5611                    $repeat = array_key_exists('repeat', $columns) ? $data[$columns['repeat']] : Application::FREQUENCIES['no'];
5612                    $categoryid = array_key_exists('categoryid', $columns) ? (int) $data[$columns['categoryid']] : null;
5613                    $paymentmode = array_key_exists('paymentmode', $columns) ? $data[$columns['paymentmode']] : null;
5614                    $paymentmodeid = array_key_exists('paymentmodeid', $columns) ? $data[$columns['paymentmodeid']] : null;
5615                    $repeatallactive = array_key_exists('repeatallactive', $columns) ? $data[$columns['repeatallactive']] : 0;
5616                    $repeatuntil = array_key_exists('repeatuntil', $columns) ? $data[$columns['repeatuntil']] : null;
5617                    $repeatfreq = array_key_exists('repeatfreq', $columns) ? $data[$columns['repeatfreq']] : 1;
5618                    $comment = array_key_exists('comment', $columns) ? urldecode($data[$columns['comment']] ?? '') : null;
5619
5620                    // manage members
5621                    if (!isset($membersByName[$payer_name])) {
5622                        $membersByName[$payer_name] = [
5623                            'active' => ((int) $payer_active) !== 0,
5624                            'weight' => 1.0,
5625                            'color' => null,
5626                        ];
5627                        if (is_numeric($payer_weight)) {
5628                            $membersByName[$payer_name]['weight'] = (float) $payer_weight;
5629                        } else {
5630                            fclose($handle);
5631                            return ['message' => $this->trans->t('Malformed CSV, invalid payer weight on line %1$s', [$row + 1])];
5632                        }
5633                    }
5634                    if (strlen($owers) === 0) {
5635                        fclose($handle);
5636                        return ['message' => $this->trans->t('Malformed CSV, invalid owers on line %1$s', [$row + 1])];
5637                    }
5638                    if ($what !== 'deleteMeIfYouWant') {
5639                        $owersArray = explode(',', $owers);
5640                        foreach ($owersArray as $ower) {
5641                            $strippedOwer = trim($ower);
5642                            if (strlen($strippedOwer) === 0) {
5643                                fclose($handle);
5644                                return ['message' => $this->trans->t('Malformed CSV, invalid owers on line %1$s', [$row + 1])];
5645                            }
5646                            if (!isset($membersByName[$strippedOwer])) {
5647                                $membersByName[$strippedOwer]['weight'] = 1.0;
5648                                $membersByName[$strippedOwer]['active'] = true;
5649                                $membersByName[$strippedOwer]['color'] = null;
5650                            }
5651                        }
5652                        if (!is_numeric($amount)) {
5653                            fclose($handle);
5654                            return ['message' => $this->trans->t('Malformed CSV, invalid amount on line %1$s', [$row + 1])];
5655                        }
5656                        $bills[] = [
5657                            'what' => $what,
5658                            'comment' => $comment,
5659                            'timestamp' => $timestamp,
5660                            'amount' => $amount,
5661                            'payer_name' => $payer_name,
5662                            'owers' => $owersArray,
5663                            'paymentmode' => $paymentmode,
5664                            'paymentmodeid' => $paymentmodeid,
5665                            'categoryid' => $categoryid,
5666                            'repeat' => $repeat,
5667                            'repeatuntil' => $repeatuntil,
5668                            'repeatallactive' => $repeatallactive,
5669                            'repeatfreq' => $repeatfreq,
5670                        ];
5671                    }
5672                }
5673            }
5674            $row++;
5675        }
5676        fclose($handle);
5677
5678        $memberNameToId = [];
5679
5680        // add project
5681        $user = $this->userManager->get($userId);
5682        $userEmail = $user->getEMailAddress();
5683        $projectid = Utils::slugify($projectName);
5684        $createDefaultCategories = (count($categories) === 0);
5685        $createDefaultPaymentModes = (count($paymentModes) === 0);
5686        $projResult = $this->createProject(
5687            $projectName, $projectid, '', $userEmail, $userId,
5688            $createDefaultCategories, $createDefaultPaymentModes
5689        );
5690        if (!isset($projResult['id'])) {
5691            return ['message' => $this->trans->t('Error in project creation, %1$s', [$projResult['message'] ?? ''])];
5692        }
5693        // set project main currency
5694        if ($mainCurrencyName !== null) {
5695            $this->editProject($projectid, $projectName, null, null, null, $mainCurrencyName);
5696        }
5697        // add payment modes
5698        foreach ($paymentModes as $pm) {
5699            $insertedPmId = $this->addPaymentMode($projectid, $pm['name'], $pm['icon'], $pm['color']);
5700            if (!is_numeric($insertedPmId)) {
5701                $this->deleteProject($projectid);
5702                return ['message' => $this->trans->t('Error when adding payment mode %1$s', [$pm['name']])];
5703            }
5704            $paymentModeIdConv[$pm['id']] = $insertedPmId;
5705        }
5706        // add categories
5707        foreach ($categories as $cat) {
5708            $insertedCatId = $this->addCategory($projectid, $cat['name'], $cat['icon'], $cat['color']);
5709            if (!is_numeric($insertedCatId)) {
5710                $this->deleteProject($projectid);
5711                return ['message' => $this->trans->t('Error when adding category %1$s', [$cat['name']])];
5712            }
5713            $categoryIdConv[$cat['id']] = $insertedCatId;
5714        }
5715        // add currencies
5716        foreach ($currencies as $cur) {
5717            $insertedCurId = $this->addCurrency($projectid, $cur['name'], $cur['exchange_rate']);
5718            if (!is_numeric($insertedCurId)) {
5719                $this->deleteProject($projectid);
5720                return ['message' => $this->trans->t('Error when adding currency %1$s', [$cur['name']])];
5721            }
5722        }
5723        // add members
5724        foreach ($membersByName as $memberName => $member) {
5725            $insertedMember = $this->addMember(
5726                $projectid, $memberName, $member['weight'], $member['active'], $member['color'] ?? null
5727            );
5728            if (!is_array($insertedMember)) {
5729                $this->deleteProject($projectid);
5730                return ['message' => $this->trans->t('Error when adding member %1$s', [$memberName])];
5731            }
5732            $memberNameToId[$memberName] = $insertedMember['id'];
5733        }
5734        $dbPaymentModes = $this->getCategoriesOrPaymentModes($projectid, false);
5735        // add bills
5736        foreach ($bills as $bill) {
5737            // manage category id if this is a custom category
5738            $catId = $bill['categoryid'];
5739            if (is_numeric($catId) && (int) $catId > 0) {
5740                $catId = $categoryIdConv[$catId];
5741            }
5742            // manage payment mode id if this is a custom payment mode
5743            $pmId = $bill['paymentmodeid'];
5744            if (is_numeric($pmId) && (int) $pmId > 0) {
5745                $pmId = $paymentModeIdConv[$pmId];
5746            }
5747            $payerId = $memberNameToId[$bill['payer_name']];
5748            $owerIds = [];
5749            foreach ($bill['owers'] as $owerName) {
5750                $strippedOwer = trim($owerName);
5751                $owerIds[] = $memberNameToId[$strippedOwer];
5752            }
5753            $owerIdsStr = implode(',', $owerIds);
5754            $addBillResult = $this->addBill(
5755                $projectid, null, $bill['what'], $payerId,
5756                $owerIdsStr, $bill['amount'], $bill['repeat'],
5757                $bill['paymentmode'], $pmId,
5758                $catId, $bill['repeatallactive'],
5759                $bill['repeatuntil'], $bill['timestamp'], $bill['comment'], $bill['repeatfreq'],
5760                $dbPaymentModes
5761            );
5762            if (!isset($addBillResult['inserted_id'])) {
5763                $this->deleteProject($projectid);
5764                return ['message' => $this->trans->t('Error when adding bill %1$s', [$bill['what']])];
5765            }
5766        }
5767        return ['project_id' => $projectid];
5768    }
5769
5770    /**
5771     * Import SplitWise project file
5772     *
5773     * @param string $path
5774     * @param string $userId
5775     * @return array
5776     */
5777    public function importSWProject(string $path, string $userId): array {
5778        $cleanPath = str_replace(['../', '..\\'], '',  $path);
5779        $userFolder = $this->root->getUserFolder($userId);
5780        if ($userFolder->nodeExists($cleanPath)) {
5781            $file = $userFolder->get($cleanPath);
5782            if ($file->getType() === FileInfo::TYPE_FILE) {
5783                if (($handle = $file->fopen('r')) !== false) {
5784                    $columns = [];
5785                    $membersWeight = [];
5786                    $bills = [];
5787                    $owersArray = [];
5788                    $categoryNames = [];
5789                    $row = 0;
5790                    $nbCol = 0;
5791
5792                    $columnNamesLineFound = false;
5793                    while (($data = fgetcsv($handle, 1000, ',')) !== false) {
5794                        // look for column order line
5795                        if (!$columnNamesLineFound) {
5796                            $nbCol = count($data);
5797                            for ($c = 0; $c < $nbCol; $c++) {
5798                                $columns[$data[$c]] = $c;
5799                            }
5800                            if (!array_key_exists('Date', $columns)
5801                                || !array_key_exists('Description', $columns)
5802                                || !array_key_exists('Category', $columns)
5803                                || !array_key_exists('Cost', $columns)
5804                                || !array_key_exists('Currency', $columns)
5805                            ) {
5806                                $columns = [];
5807                                $row++;
5808                                continue;
5809                            }
5810                            $columnNamesLineFound = true;
5811                            // manage members
5812                            $m = 0;
5813                            for ($c = 5; $c < $nbCol; $c++) {
5814                                $owersArray[$m] = $data[$c];
5815                                $m++;
5816                            }
5817                            foreach ($owersArray as $ower) {
5818                                if (strlen($ower) === 0) {
5819                                    fclose($handle);
5820                                    return ['message' => $this->trans->t('Malformed CSV, cannot have an empty ower')];
5821                                }
5822                                if (!array_key_exists($ower, $membersWeight)) {
5823                                    $membersWeight[$ower] = 1.0;
5824                                }
5825                            }
5826                        } elseif (!isset($data[$columns['Date']]) || empty($data[$columns['Date']])) {
5827                            // skip empty lines
5828                        } elseif (isset($data[$columns['Description']]) && $data[$columns['Description']] === 'Total balance') {
5829                            // skip the total lines
5830                        } else {
5831                            // normal line : bill
5832                            $what = $data[$columns['Description']];
5833                            $cost = trim($data[$columns['Cost']]);
5834                            if (empty($cost)) {
5835                                // skip lines with no cost, it might be the balances line
5836                                $row++;
5837                                continue;
5838                            }
5839                            $date = $data[$columns['Date']];
5840                            $datetime = DateTime::createFromFormat('Y-m-d', $date);
5841                            if ($datetime === false) {
5842                                fclose($handle);
5843                                return ['message' => $this->trans->t('Malformed CSV, missing or invalid date/timestamp on line %1$s', [$row])];
5844                            }
5845                            $timestamp = $datetime->getTimestamp();
5846
5847                            $categoryName = null;
5848                            // manage categories
5849                            if (array_key_exists('Category', $columns)
5850                                && $data[$columns['Category']] !== null
5851                                && $data[$columns['Category']] !== '') {
5852                                $categoryName = $data[$columns['Category']];
5853                                if (!in_array($categoryName, $categoryNames)) {
5854                                    $categoryNames[] = $categoryName;
5855                                }
5856                            }
5857
5858                            // new algorithm
5859                            // get those with a negative value, they will be the owers in generated bills
5860                            $negativeCols = [];
5861                            for ($c = 5; $c < $nbCol; $c++) {
5862                                $amount = $data[$c];
5863                                if (!is_numeric($amount)) {
5864                                    fclose($handle);
5865                                    return ['message' => $this->trans->t('Malformed CSV, bad amount on line %1$s', [$row])];
5866                                }
5867                                if ($amount < 0) {
5868                                    $negativeCols[] = $c;
5869                                }
5870                            }
5871                            $owersList = array_map(static function($c) use ($owersArray) {
5872                                return $owersArray[$c - 5];
5873                            }, $negativeCols);
5874                            // each positive one: bill with member-specific amount (not the full amount), owers are the negative ones
5875                            for ($c = 5; $c < $nbCol; $c++) {
5876                                $amount = $data[$c];
5877                                if ($amount > 0) {
5878                                    $payer_name = $owersArray[$c - 5];
5879                                    if (empty($payer_name)) {
5880                                        fclose($handle);
5881                                        return ['message' => $this->trans->t('Malformed CSV, no payer on line %1$s', [$row])];
5882                                    }
5883                                    $bill = [
5884                                        'what' => $what,
5885                                        'timestamp' => $timestamp,
5886                                        'amount' => $amount,
5887                                        'payer_name' => $payer_name,
5888                                        'owers' => $owersList
5889                                    ];
5890                                    if ($categoryName !== null) {
5891                                        $bill['category_name'] = $categoryName;
5892                                    }
5893                                    $bills[] = $bill;
5894                                }
5895                            }
5896                        }
5897                        $row++;
5898                    }
5899                    fclose($handle);
5900
5901                    if (!$columnNamesLineFound) {
5902                        return ['message' => $this->trans->t('Malformed CSV, impossible to find the column names')];
5903                    }
5904
5905                    $memberNameToId = [];
5906
5907                    // add project
5908                    $user = $this->userManager->get($userId);
5909                    $userEmail = $user->getEMailAddress();
5910                    $projectName = preg_replace('/\.csv$/', '', $file->getName());
5911                    $projectid = Utils::slugify($projectName);
5912                    // create default categories only if none are found in the CSV
5913                    $createDefaultCategories = (count($categoryNames) === 0);
5914                    $projResult = $this->createProject(
5915                        $projectName, $projectid, '', $userEmail,
5916                        $userId, $createDefaultCategories
5917                    );
5918                    if (!isset($projResult['id'])) {
5919                        return ['message' => $this->trans->t('Error in project creation, %1$s', [$projResult['message'] ?? ''])];
5920                    }
5921                    // add categories
5922                    $catNameToId = [];
5923                    foreach ($categoryNames as $categoryName) {
5924                        $insertedCatId = $this->addCategory($projectid, $categoryName, null, '#000000');
5925                        if (!is_numeric($insertedCatId)) {
5926                            $this->deleteProject($projectid);
5927                            return ['message' => $this->trans->t('Error when adding category %1$s', [$categoryName])];
5928                        }
5929                        $catNameToId[$categoryName] = $insertedCatId;
5930                    }
5931                    // add members
5932                    foreach ($membersWeight as $memberName => $weight) {
5933                        $insertedMember = $this->addMember($projectid, $memberName, $weight);
5934                        if (!is_array($insertedMember)) {
5935                            $this->deleteProject($projectid);
5936                            return ['message' => $this->trans->t('Error when adding member %1$s', [$memberName])];
5937                        }
5938                        $memberNameToId[$memberName] = $insertedMember['id'];
5939                    }
5940                    // add bills
5941                    foreach ($bills as $bill) {
5942                        $payerId = $memberNameToId[$bill['payer_name']];
5943                        $owerIds = [];
5944                        foreach ($bill['owers'] as $owerName) {
5945                            $owerIds[] = $memberNameToId[$owerName];
5946                        }
5947                        $owerIdsStr = implode(',', $owerIds);
5948                        // category
5949                        $catId = null;
5950                        if (array_key_exists('category_name', $bill)
5951                            && array_key_exists($bill['category_name'], $catNameToId)) {
5952                            $catId = $catNameToId[$bill['category_name']];
5953                        }
5954                        $addBillResult = $this->addBill(
5955                            $projectid, null, $bill['what'], $payerId, $owerIdsStr,
5956                            $bill['amount'], Application::FREQUENCIES['no'],null, 0, $catId,
5957                            0, null, $bill['timestamp'], null, null, []
5958                        );
5959                        if (!isset($addBillResult['inserted_id'])) {
5960                            $this->deleteProject($projectid);
5961                            return ['message' => $this->trans->t('Error when adding bill %1$s', [$bill['what']])];
5962                        }
5963                    }
5964                    return ['project_id' => $projectid];
5965                } else {
5966                    return ['message' => $this->trans->t('Access denied')];
5967                }
5968            } else {
5969                return ['message' => $this->trans->t('Access denied')];
5970            }
5971        } else {
5972            return ['message' => $this->trans->t('Access denied')];
5973        }
5974    }
5975
5976    /**
5977     * auto export
5978     * triggered by NC cron job
5979     *
5980     * export projects
5981     */
5982    public function cronAutoExport(): void {
5983        date_default_timezone_set('UTC');
5984        // last day
5985        $now = new DateTime();
5986        $y = $now->format('Y');
5987        $m = $now->format('m');
5988        $d = $now->format('d');
5989
5990        // get begining of today
5991        $dateMaxDay = new DateTime($y.'-'.$m.'-'.$d);
5992        $maxDayTimestamp = $dateMaxDay->getTimestamp();
5993        $minDayTimestamp = $maxDayTimestamp - 24*60*60;
5994
5995        $dateMaxDay->modify('-1 day');
5996        $dailySuffix = '_'.$this->trans->t('daily').'_'.$dateMaxDay->format('Y-m-d');
5997
5998        // last week
5999        $now = new DateTime();
6000        while (((int) $now->format('N')) !== 1) {
6001            $now->modify('-1 day');
6002        }
6003        $y = $now->format('Y');
6004        $m = $now->format('m');
6005        $d = $now->format('d');
6006        $dateWeekMax = new DateTime($y.'-'.$m.'-'.$d);
6007        $maxWeekTimestamp = $dateWeekMax->getTimestamp();
6008        $minWeekTimestamp = $maxWeekTimestamp - 7*24*60*60;
6009        $dateWeekMin = new DateTime($y.'-'.$m.'-'.$d);
6010        $dateWeekMin->modify('-7 day');
6011        $weeklySuffix = '_'.$this->trans->t('weekly').'_'.$dateWeekMin->format('Y-m-d');
6012
6013        // last month
6014        $now = new DateTime();
6015        while (((int) $now->format('d')) !== 1) {
6016            $now->modify('-1 day');
6017        }
6018        $y = $now->format('Y');
6019        $m = $now->format('m');
6020        $d = $now->format('d');
6021        $dateMonthMax = new DateTime($y.'-'.$m.'-'.$d);
6022        $maxMonthTimestamp = $dateMonthMax->getTimestamp();
6023        $now->modify('-1 day');
6024        while (((int) $now->format('d')) !== 1) {
6025            $now->modify('-1 day');
6026        }
6027        $y = (int) $now->format('Y');
6028        $m = (int) $now->format('m');
6029        $d = (int) $now->format('d');
6030        $dateMonthMin = new DateTime($y.'-'.$m.'-'.$d);
6031        $minMonthTimestamp = $dateMonthMin->getTimestamp();
6032        $monthlySuffix = '_'.$this->trans->t('monthly').'_'.$dateMonthMin->format('Y-m');
6033
6034//        $weekFilterArray = [];
6035//        $weekFilterArray['tsmin'] = $minWeekTimestamp;
6036//        $weekFilterArray['tsmax'] = $maxWeekTimestamp;
6037//        $dayFilterArray = [];
6038//        $dayFilterArray['tsmin'] = $minDayTimestamp;
6039//        $dayFilterArray['tsmax'] = $maxDayTimestamp;
6040//        $monthFilterArray = [];
6041//        $monthFilterArray['tsmin'] = $minMonthTimestamp;
6042//        $monthFilterArray['tsmax'] = $maxMonthTimestamp;
6043
6044        $qb = $this->db->getQueryBuilder();
6045
6046        foreach ($this->userManager->search('') as $u) {
6047            $uid = $u->getUID();
6048            $outPath = $this->config->getUserValue($uid, 'cospend', 'outputDirectory', '/Cospend');
6049
6050            $qb->select('id', 'name', 'autoexport')
6051                ->from('cospend_projects')
6052                ->where(
6053                    $qb->expr()->eq('userid', $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR))
6054                )
6055                ->andWhere(
6056                    $qb->expr()->neq('autoexport', $qb->createNamedParameter(Application::FREQUENCIES['no'], IQueryBuilder::PARAM_STR))
6057                );
6058            $req = $qb->executeQuery();
6059
6060            $dbProjectId = null;
6061            while ($row = $req->fetch()) {
6062                $dbProjectId = $row['id'];
6063                $autoexport = $row['autoexport'];
6064
6065                $suffix = $dailySuffix;
6066                // TODO add suffix for all frequencies
6067                if ($autoexport === Application::FREQUENCIES['weekly']) {
6068                    $suffix = $weeklySuffix;
6069                } elseif ($autoexport === Application::FREQUENCIES['monthly']) {
6070                    $suffix = $monthlySuffix;
6071                }
6072                // check if file already exists
6073                $exportName = $dbProjectId . $suffix . '.csv';
6074
6075                $userFolder = $this->root->getUserFolder($uid);
6076                if (!$userFolder->nodeExists($outPath . '/' . $exportName)) {
6077                    $this->exportCsvProject($dbProjectId, $uid, $exportName);
6078                }
6079            }
6080            $req->closeCursor();
6081            $qb = $qb->resetQueryParts();
6082        }
6083    }
6084
6085    /**
6086     * Search bills with query string
6087     *
6088     * @param string $projectId
6089     * @param string $term
6090     * @return array
6091     */
6092    public function searchBills(string $projectId, string $term): array {
6093        $qb = $this->db->getQueryBuilder();
6094        $qb->select(
6095            'b.id', 'what', 'comment', 'amount', 'timestamp',
6096            'paymentmode', 'paymentmodeid', 'categoryid',
6097            'pr.currencyname', 'me.name', 'me.userid'
6098        )
6099            ->from('cospend_bills', 'b')
6100            ->innerJoin('b', 'cospend_projects', 'pr', $qb->expr()->eq('b.projectid', 'pr.id'))
6101            ->innerJoin('b', 'cospend_members', 'me', $qb->expr()->eq('b.payerid', 'me.id'))
6102            ->where(
6103                $qb->expr()->eq('b.projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
6104            );
6105        $qb = $this->applyBillSearchTermCondition($qb, $term, 'b');
6106        $qb->orderBy('timestamp', 'ASC');
6107        $req = $qb->executeQuery();
6108
6109        // bills by id
6110        $bills = [];
6111        while ($row = $req->fetch()){
6112            $dbBillId = (int) $row['id'];
6113            $dbAmount = (float) $row['amount'];
6114            $dbWhat = $row['what'];
6115            $dbTimestamp = (int) $row['timestamp'];
6116            $dbComment = $row['comment'];
6117            $dbPaymentMode = $row['paymentmode'];
6118            $dbPaymentModeId = (int) $row['paymentmodeid'];
6119            $dbCategoryId = (int) $row['categoryid'];
6120            $dbProjectCurrencyName = $row['currencyname'];
6121            $dbPayerName = $row['name'];
6122            $dbPayerUserId = $row['userid'];
6123            $bills[] = [
6124                'id' => $dbBillId,
6125                'projectId' => $projectId,
6126                'amount' => $dbAmount,
6127                'what' => $dbWhat,
6128                'timestamp' => $dbTimestamp,
6129                'comment' => $dbComment,
6130                'paymentmode' => $dbPaymentMode,
6131                'paymentmodeid' => $dbPaymentModeId,
6132                'categoryid' => $dbCategoryId,
6133                'currencyname' => $dbProjectCurrencyName,
6134                'payer_name' => $dbPayerName,
6135                'payer_user_id' => $dbPayerUserId,
6136            ];
6137        }
6138        $req->closeCursor();
6139        $qb->resetQueryParts();
6140
6141        return $bills;
6142    }
6143
6144    private function applyBillSearchTermCondition(IQueryBuilder $qb, string $term, string $billTableAlias): IQueryBuilder {
6145        $term = strtolower($term);
6146        $or = $qb->expr()->orx();
6147        $or->add(
6148            $qb->expr()->iLike($billTableAlias . '.what', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($term) . '%', IQueryBuilder::PARAM_STR))
6149        );
6150        $or->add(
6151            $qb->expr()->iLike($billTableAlias . '.comment', $qb->createNamedParameter('%' . $this->db->escapeLikeParameter($term) . '%', IQueryBuilder::PARAM_STR))
6152        );
6153        // search amount
6154        $noCommaTerm = str_replace(',', '.', $term);
6155        if (is_numeric($noCommaTerm)) {
6156            $amount = (float) $noCommaTerm;
6157            $amountMin = $amount - 1.0;
6158            $amountMax = $amount + 1.0;
6159            $andExpr = $qb->expr()->andX();
6160            $andExpr->add(
6161                $qb->expr()->gte($billTableAlias . '.amount', $qb->createNamedParameter($amountMin, IQueryBuilder::PARAM_STR))
6162            );
6163            $andExpr->add(
6164                $qb->expr()->lte($billTableAlias . '.amount', $qb->createNamedParameter($amountMax, IQueryBuilder::PARAM_STR))
6165            );
6166            $or->add($andExpr);
6167        }
6168        $qb->andWhere($or);
6169        return $qb;
6170    }
6171
6172    /**
6173     * Get Cospend bill activity
6174     *
6175     * @param string $userId
6176     * @param int|null $since
6177     * @return array
6178     */
6179    public function getBillActivity(string $userId, ?int $since): array {
6180        // get projects
6181        $projects = $this->getProjects($userId);
6182
6183        // get bills (7 max)
6184        $bills = [];
6185        foreach ($projects as $project) {
6186            $pid = $project['id'];
6187            $bl = $this->getBills($pid, null, null, null, null, null, null, null, $since, 20, true);
6188
6189            // get members by id
6190            $membersById = [];
6191            foreach ($project['members'] as $m) {
6192                $membersById[$m['id']] = $m;
6193            }
6194            // add information
6195            foreach ($bl as $i => $bill) {
6196                $payerId = $bill['payer_id'];
6197                $bl[$i]['payer'] = $membersById[$payerId];
6198                $bl[$i]['project_id'] = $pid;
6199                $bl[$i]['project_name'] = $project['name'];
6200            }
6201
6202            $bills = array_merge($bills, $bl);
6203        }
6204
6205        // sort bills by date
6206        usort($bills, function($a, $b) {
6207            $ta = $a['timestamp'];
6208            $tb = $b['timestamp'];
6209            return ($ta > $tb) ? -1 : 1;
6210        });
6211
6212        // take 7 firsts
6213        return array_slice($bills, 0, 7);
6214    }
6215
6216    /**
6217     * Touch a project
6218     *
6219     * @param string $projectId
6220     * @param int $timestamp
6221     * @return void
6222     */
6223    private function updateProjectLastChanged(string $projectId, int $timestamp): void {
6224        $qb = $this->db->getQueryBuilder();
6225        $qb->update('cospend_projects');
6226        $qb->set('lastchanged', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT));
6227        $qb->where(
6228            $qb->expr()->eq('id', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR))
6229        );
6230        $qb->executeStatement();
6231        $qb->resetQueryParts();
6232    }
6233}