%PDF- %PDF-
Direktori : /www/varak.net/nextcloud.varak.net/apps_old/apps/cospend/lib/Service/ |
Current File : //www/varak.net/nextcloud.varak.net/apps_old/apps/cospend/lib/Service/ProjectService.php |
<?php /** * Nextcloud - cospend * * This file is licensed under the Affero General Public License version 3 or * later. See the COPYING file. * * @author Julien Veyssier * @copyright Julien Veyssier 2019 */ namespace OCA\Cospend\Service; use DateInterval; use DateTime; use DateTimeImmutable; use DateTimeZone; use Exception; use Generator; use OC\User\NoUserException; use OCA\Circles\Exceptions\InitiatorNotFoundException; use OCA\Circles\Exceptions\RequestBuilderException; use OCA\Cospend\Activity\ActivityManager; use OCA\Cospend\AppInfo\Application; use OCA\Cospend\Db\Bill; use OCA\Cospend\Db\BillMapper; use OCA\Cospend\Db\Member; use OCA\Cospend\Db\MemberMapper; use OCA\Cospend\Db\ProjectMapper; use OCA\Cospend\ResponseDefinitions; use OCA\Cospend\Utils; use OCP\App\IAppManager; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IConfig; use OCP\IDateTimeZone; use OCP\IDBConnection; use OCP\IGroupManager; use OCP\IL10N; use OCP\IUserManager; use OCP\Notification\IManager as INotificationManager; use Throwable; use function str_replace; /** * @psalm-import-type CospendProjectInfoPlusExtra from ResponseDefinitions * @psalm-import-type CospendMember from ResponseDefinitions */ class ProjectService { public array $defaultCategories; public array $defaultPaymentModes; private array $hardCodedCategoryNames; public function __construct( private IL10N $l10n, private IConfig $config, private ProjectMapper $projectMapper, private BillMapper $billMapper, private MemberMapper $memberMapper, private ActivityManager $activityManager, private IUserManager $userManager, private IAppManager $appManager, private IGroupManager $groupManager, private IDateTimeZone $dateTimeZone, private IRootFolder $root, private INotificationManager $notificationManager, private IDBConnection $db, ) { $this->defaultCategories = [ [ 'name' => $this->l10n->t('Grocery'), 'icon' => '🛒', 'color' => '#ffaa00', ], [ 'name' => $this->l10n->t('Bar/Party'), 'icon' => '🎉', 'color' => '#aa55ff', ], [ 'name' => $this->l10n->t('Rent'), 'icon' => '🏠', 'color' => '#da8733', ], [ 'name' => $this->l10n->t('Bill'), 'icon' => '🌩', 'color' => '#4aa6b0', ], [ 'name' => $this->l10n->t('Excursion/Culture'), 'icon' => '🚸', 'color' => '#0055ff', ], [ 'name' => $this->l10n->t('Health'), 'icon' => '💚', 'color' => '#bf090c', ], [ 'name' => $this->l10n->t('Shopping'), 'icon' => '🛍', 'color' => '#e167d1', ], [ 'name' => $this->l10n->t('Restaurant'), 'icon' => '🍴', 'color' => '#d0d5e1', ], [ 'name' => $this->l10n->t('Accommodation'), 'icon' => '🛌', 'color' => '#5de1a3', ], [ 'name' => $this->l10n->t('Transport'), 'icon' => '🚌', 'color' => '#6f2ee1', ], [ 'name' => $this->l10n->t('Sport'), 'icon' => '🎾', 'color' => '#69e177', ], ]; $this->defaultPaymentModes = [ [ 'name' => $this->l10n->t('Credit card'), 'icon' => '💳', 'color' => '#FF7F50', 'old_id' => 'c', ], [ 'name' => $this->l10n->t('Cash'), 'icon' => '💵', 'color' => '#556B2F', 'old_id' => 'b', ], [ 'name' => $this->l10n->t('Check'), 'icon' => '🎫', 'color' => '#A9A9A9', 'old_id' => 'f', ], [ 'name' => $this->l10n->t('Transfer'), 'icon' => '⇄', 'color' => '#00CED1', 'old_id' => 't', ], [ 'name' => $this->l10n->t('Online service'), 'icon' => '🌎', 'color' => '#9932CC', 'old_id' => 'o', ], ]; $this->hardCodedCategoryNames = [ '-11' => $this->l10n->t('Reimbursement'), ]; } /** * Get max access level of a given user for a given project * * @param string $userId * @param string $projectId * @return int */ public function getUserMaxAccessLevel(string $userId, string $projectId): int { $result = Application::ACCESS_LEVEL_NONE; $dbProject = $this->projectMapper->find($projectId); if ($dbProject !== null) { // does the user own the project ? if ($dbProject->getUserid() === $userId) { return Application::ACCESS_LEVEL_ADMIN; } else { $qb = $this->db->getQueryBuilder(); // is the project shared with the user ? $qb->select('userid', 'projectid', 'accesslevel') ->from('cospend_shares') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_USER, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('userid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); $dbProjectId = null; $dbAccessLevel = null; while ($row = $req->fetch()) { $dbProjectId = $row['projectid']; $dbAccessLevel = (int) $row['accesslevel']; break; } $req->closeCursor(); $qb = $qb->resetQueryParts(); if ($dbProjectId !== null && $dbAccessLevel > $result) { $result = $dbAccessLevel; } // is the project shared with a group containing the user? $userO = $this->userManager->get($userId); $qb->select('userid', 'accesslevel') ->from('cospend_shares') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_GROUP, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $groupId = $row['userid']; $dbAccessLevel = (int) $row['accesslevel']; if ($this->groupManager->groupExists($groupId) && $this->groupManager->get($groupId)->inGroup($userO) && $dbAccessLevel > $result ) { $result = $dbAccessLevel; } } $req->closeCursor(); $qb = $qb->resetQueryParts(); // are circles enabled and is the project shared with a circle containing the user $circlesEnabled = $this->appManager->isEnabledForUser('circles'); if ($circlesEnabled) { $qb->select('userid', 'accesslevel') ->from('cospend_shares') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_CIRCLE, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $circleId = $row['userid']; $dbAccessLevel = (int) $row['accesslevel']; if ($this->isUserInCircle($userId, $circleId) && $dbAccessLevel > $result) { $result = $dbAccessLevel; } } } } } return $result; } /** * Get access level of a shared access * * @param string $projectId * @param int $shId * @return int */ public function getShareAccessLevel(string $projectId, int $shId): int { $result = 0; $qb = $this->db->getQueryBuilder(); $qb->select('accesslevel') ->from('cospend_shares') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $result = (int) $row['accesslevel']; break; } $req->closeCursor(); $qb->resetQueryParts(); return $result; } /** * Create a project * * @param string $name * @param string $id * @param string|null $contact_email * @param string $userId * @param bool $createDefaultCategories * @param bool $createDefaultPaymentModes * @return array */ public function createProject( string $name, string $id, ?string $contact_email, string $userId = '', bool $createDefaultCategories = true, bool $createDefaultPaymentModes = true ): array { return $this->projectMapper->createProject( $name, $id, $contact_email, $this->defaultCategories, $this->defaultPaymentModes, $userId, $createDefaultCategories, $createDefaultPaymentModes ); } /** * Delete a project and all associated data * * @param string $projectId * @return array */ public function deleteProject(string $projectId): array { $dbProjectToDelete = $this->projectMapper->find($projectId); if ($dbProjectToDelete === null) { return ['error' => $this->l10n->t('Not Found')]; } $this->projectMapper->deleteBillOwersOfProject($projectId); $associatedTableNames = [ 'cospend_bills', 'cospend_members', 'cospend_shares', 'cospend_currencies', 'cospend_categories', 'cospend_paymentmodes' ]; $qb = $this->db->getQueryBuilder(); foreach ($associatedTableNames as $tableName) { $qb->delete($tableName) ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb = $qb->resetQueryParts(); } $this->projectMapper->delete($dbProjectToDelete); return ['message' => 'DELETED']; } /** * Get all project data * * @param string $projectId * @return CospendProjectInfoPlusExtra|null * @throws \OCP\DB\Exception */ public function getProjectInfo(string $projectId): ?array { try { $dbProject = $this->projectMapper->find($projectId); } catch (Exception | Throwable $e) { return null; } $dbProjectId = $dbProject->getId(); $smallStats = $this->getSmallStats($dbProjectId); $members = $this->getMembers($dbProjectId, 'lowername'); $activeMembers = []; foreach ($members as $member) { if ($member['activated']) { $activeMembers[] = $member; } } $balance = $this->getBalance($dbProjectId); $currencies = $this->getCurrencies($dbProjectId); $categories = $this->getCategoriesOrPaymentModes($dbProjectId); $paymentModes = $this->getCategoriesOrPaymentModes($dbProjectId, false); // get all shares $userShares = $this->getUserShares($dbProjectId); $groupShares = $this->getGroupShares($dbProjectId); $circleShares = $this->getCircleShares($dbProjectId); $publicShares = $this->getPublicShares($dbProjectId); $shares = array_merge($userShares, $groupShares, $circleShares, $publicShares); $extraProjectInfo = [ 'active_members' => $activeMembers, 'members' => $members, 'balance' => $balance, 'nb_bills' => $smallStats['nb_bills'], 'total_spent' => $smallStats['total_spent'], 'nb_trashbin_bills' => $smallStats['nb_trashbin_bills'], 'shares' => $shares, 'currencies' => $currencies, 'categories' => $categories, 'paymentmodes' => $paymentModes, ]; return array_merge($extraProjectInfo, $dbProject->jsonSerialize()); } /** * Get number of bills and total spent amount for a given project * * @param string $projectId * @return array * @throws \OCP\DB\Exception */ private function getSmallStats(string $projectId): array { $totalSpent = 0; $qb = $this->db->getQueryBuilder(); $qb->selectAlias($qb->createFunction('SUM(amount)'), 'sum_amount') ->from('cospend_bills') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('deleted', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $totalSpent = (float) $row['sum_amount']; } $qb->resetQueryParts(); return [ 'nb_bills' => $this->billMapper->countBills($projectId, null, null, null, 0), 'total_spent' => $totalSpent, 'nb_trashbin_bills' => $this->billMapper->countBills($projectId, null, null, null, 1), ]; } /** * Get project statistics * * @param string $projectId * @param string|null $memberOrder * @param int|null $tsMin * @param int|null $tsMax * @param int|null $paymentModeId * @param int|null $categoryId * @param float|null $amountMin * @param float|null $amountMax * @param bool $showDisabled * @param int|null $currencyId * @param int|null $payerId * @return array * @throws \OCP\DB\Exception */ public function getProjectStatistics( string $projectId, ?string $memberOrder = null, ?int $tsMin = null, ?int $tsMax = null, ?int $paymentModeId = null, ?int $categoryId = null, ?float $amountMin = null, ?float $amountMax = null, bool $showDisabled = true, ?int $currencyId = null, ?int $payerId = null ): array { $timeZone = $this->dateTimeZone->getTimeZone(); $membersWeight = []; $membersNbBills = []; $membersBalance = []; $membersFilteredBalance = []; $membersPaid = [ 'total' => [], ]; $membersSpent = []; $membersPaidFor = []; $currency = null; if ($currencyId !== null && $currencyId !== 0) { $currency = $this->getCurrency($projectId, $currencyId); } $projectCategories = $this->getCategoriesOrPaymentModes($projectId); $projectPaymentModes = $this->getCategoriesOrPaymentModes($projectId, false); // get the real global balances with no filters $balances = $this->getBalance($projectId); $members = $this->getMembers($projectId, $memberOrder); foreach ($members as $member) { $memberId = $member['id']; $memberWeight = $member['weight']; $membersWeight[$memberId] = $memberWeight; $membersNbBills[$memberId] = 0; $membersBalance[$memberId] = $balances[$memberId]; $membersFilteredBalance[$memberId] = 0.0; $membersPaid[$memberId] = 0.0; $membersSpent[$memberId] = 0.0; $membersPaidFor[$memberId] = []; foreach ($members as $m) { $membersPaidFor[$memberId][$m['id']] = 0.0; } $membersPaidFor['total'][$memberId] = 0.0; } // build list of members to display $membersToDisplay = []; $allMembersIds = []; foreach ($members as $member) { $memberId = $member['id']; $allMembersIds[] = $memberId; // only take enabled members or those with non-zero balance $mBalance = (float) $membersBalance[$memberId]; if ($showDisabled || $member['activated'] || $mBalance >= 0.01 || $mBalance <= -0.01) { $membersToDisplay[$memberId] = $member; } } // compute stats $bills = $this->billMapper->getBills( $projectId, $tsMin, $tsMax, null, $paymentModeId, $categoryId, $amountMin, $amountMax, null, null, false, $payerId ); /* $firstBillTs = $bills[0]['timestamp']; $firstBillDate = DateTime::createFromFormat('U', $firstBillTs); $firstBillDate->setTimezone($timeZone); $firstBillDate->modify('first day of'); $firstBillDate->setTime(0, 0); $year1 = (int) $firstBillDate->format('Y'); $month1 = (int) $firstBillDate->format('m'); $lastBillTs = $bills[count($bills) - 1]['timestamp']; $lastBillDate = DateTime::createFromFormat('U', $lastBillTs); $lastBillDate->setTimezone($timeZone); $lastBillDate->modify('first day of'); $lastBillDate->setTime(0, 0); $year2 = (int) $lastBillDate->format('Y'); $month2 = (int) $lastBillDate->format('m'); $fullMonthNumber = (($year2 - $year1) * 12) + ($month2 - $month1 + 1); */ // compute classic stats foreach ($bills as $bill) { $payerId = $bill['payer_id']; $amount = $bill['amount']; $owers = $bill['owers']; $membersNbBills[$payerId]++; $membersFilteredBalance[$payerId] += $amount; $membersPaid[$payerId] += $amount; $nbOwerShares = 0.0; foreach ($owers as $ower) { $owerWeight = $ower['weight']; if ($owerWeight === 0.0) { $owerWeight = 1.0; } $nbOwerShares += $owerWeight; } foreach ($owers as $ower) { $owerWeight = $ower['weight']; if ($owerWeight === 0.0) { $owerWeight = 1.0; } $owerId = $ower['id']; $spent = $amount / $nbOwerShares * $owerWeight; $membersFilteredBalance[$owerId] -= $spent; $membersSpent[$owerId] += $spent; // membersPaidFor $membersPaidFor[$payerId][$owerId] += $spent; $membersPaidFor['total'][$owerId] += $spent; } } foreach ($members as $member) { $memberId = $member['id']; $membersPaidFor[$memberId]['total'] = $membersPaid[$memberId]; } // build global stats data $statistics = []; if ($currency === null) { foreach ($membersToDisplay as $memberId => $member) { $statistic = [ 'balance' => $membersBalance[$memberId], 'filtered_balance' => $membersFilteredBalance[$memberId], 'paid' => $membersPaid[$memberId], 'spent' => $membersSpent[$memberId], 'member' => $member ]; $statistics[] = $statistic; } } else { foreach ($membersToDisplay as $memberId => $member) { $statistic = [ 'balance' => ($membersBalance[$memberId] === 0.0) ? 0 : $membersBalance[$memberId] / $currency['exchange_rate'], 'filtered_balance' => ($membersFilteredBalance[$memberId] === 0.0) ? 0 : $membersFilteredBalance[$memberId] / $currency['exchange_rate'], 'paid' => ($membersPaid[$memberId] === 0.0) ? 0 : $membersPaid[$memberId] / $currency['exchange_rate'], 'spent' => ($membersSpent[$memberId] === 0.0) ? 0 : $membersSpent[$memberId] / $currency['exchange_rate'], 'member' => $member ]; $statistics[] = $statistic; } } // compute monthly member stats $memberMonthlyPaidStats = []; $memberMonthlySpentStats = []; $allMembersKey = 0; foreach ($bills as $bill) { $payerId = $bill['payer_id']; $amount = $bill['amount']; $owers = $bill['owers']; $date = DateTime::createFromFormat('U', $bill['timestamp']); $date->setTimezone($timeZone); $month = $date->format('Y-m'); //////////////// PAID // initialize this month if (!array_key_exists($month, $memberMonthlyPaidStats)) { $memberMonthlyPaidStats[$month] = []; foreach ($membersToDisplay as $memberId => $member) { $memberMonthlyPaidStats[$month][$memberId] = 0; } $memberMonthlyPaidStats[$month][$allMembersKey] = 0; } // add paid amount if (array_key_exists($payerId, $membersToDisplay)) { $memberMonthlyPaidStats[$month][$payerId] += $amount; $memberMonthlyPaidStats[$month][$allMembersKey] += $amount; } //////////////// SPENT // initialize this month if (!array_key_exists($month, $memberMonthlySpentStats)) { $memberMonthlySpentStats[$month] = []; foreach ($membersToDisplay as $memberId => $member) { $memberMonthlySpentStats[$month][$memberId] = 0; } $memberMonthlySpentStats[$month][$allMembersKey] = 0; } // spent value for all members is the bill amount (like the paid value) $memberMonthlySpentStats[$month][$allMembersKey] += $amount; // compute number of shares $nbOwerShares = 0.0; foreach ($owers as $ower) { $owerWeight = $ower['weight']; if ($owerWeight === 0.0) { $owerWeight = 1.0; } $nbOwerShares += $owerWeight; } // compute how much each ower has spent foreach ($owers as $ower) { $owerWeight = $ower['weight']; if ($owerWeight === 0.0) { $owerWeight = 1.0; } $owerId = $ower['id']; $spent = $amount / $nbOwerShares * $owerWeight; if (array_key_exists($owerId, $membersToDisplay)) { $memberMonthlySpentStats[$month][$owerId] += $spent; } } } // monthly paid and spent average $averageKey = $this->l10n->t('Average per month'); // number of months with actual bills $nbMonth = count(array_keys($memberMonthlyPaidStats)); $realMonths = array_keys($memberMonthlyPaidStats); if ($nbMonth > 0) { ////////////////////// PAID $averagePaidStats = []; foreach ($membersToDisplay as $memberId => $member) { $sum = 0; foreach ($memberMonthlyPaidStats as $month => $mStat) { $sum += $memberMonthlyPaidStats[$month][$memberId]; } $averagePaidStats[$memberId] = $sum / $nbMonth; } // average for all members $sum = 0; foreach ($memberMonthlyPaidStats as $month => $mStat) { $sum += $memberMonthlyPaidStats[$month][$allMembersKey]; } $averagePaidStats[$allMembersKey] = $sum / $nbMonth; $memberMonthlyPaidStats[$averageKey] = $averagePaidStats; ////////////////////// SPENT $averageSpentStats = []; foreach ($membersToDisplay as $memberId => $member) { $sum = 0; foreach ($memberMonthlySpentStats as $month => $mStat) { $sum += $memberMonthlySpentStats[$month][$memberId]; } $averageSpentStats[$memberId] = $sum / $nbMonth; } // average for all members $sum = 0; foreach ($memberMonthlySpentStats as $month => $mStat) { $sum += $memberMonthlySpentStats[$month][$allMembersKey]; } $averageSpentStats[$allMembersKey] = $sum / $nbMonth; $memberMonthlySpentStats[$averageKey] = $averageSpentStats; } // convert if necessary if ($currency !== null) { foreach ($memberMonthlyPaidStats as $month => $mStat) { foreach ($mStat as $mid => $val) { $memberMonthlyPaidStats[$month][$mid] = ($memberMonthlyPaidStats[$month][$mid] === 0.0) ? 0 : $memberMonthlyPaidStats[$month][$mid] / $currency['exchange_rate']; } } foreach ($memberMonthlySpentStats as $month => $mStat) { foreach ($mStat as $mid => $val) { $memberMonthlySpentStats[$month][$mid] = ($memberMonthlySpentStats[$month][$mid] === 0.0) ? 0 : $memberMonthlySpentStats[$month][$mid] / $currency['exchange_rate']; } } } // compute category and payment mode stats $categoryStats = []; $paymentModeStats = []; foreach ($bills as $bill) { // category $billCategoryId = $bill['categoryid']; if (!array_key_exists(strval($billCategoryId), $this->hardCodedCategoryNames) && !array_key_exists(strval($billCategoryId), $projectCategories) ) { $billCategoryId = 0; } $amount = $bill['amount']; if (!array_key_exists($billCategoryId, $categoryStats)) { $categoryStats[$billCategoryId] = 0; } $categoryStats[$billCategoryId] += $amount; // payment mode $paymentModeId = $bill['paymentmodeid']; if (!array_key_exists(strval($paymentModeId), $projectPaymentModes)) { $paymentModeId = 0; } $amount = $bill['amount']; if (!array_key_exists($paymentModeId, $paymentModeStats)) { $paymentModeStats[$paymentModeId] = 0; } $paymentModeStats[$paymentModeId] += $amount; } // convert if necessary if ($currency !== null) { foreach ($categoryStats as $catId => $val) { $categoryStats[$catId] = ($val === 0.0) ? 0 : $val / $currency['exchange_rate']; } foreach ($paymentModeStats as $pmId => $val) { $paymentModeStats[$pmId] = ($val === 0.0) ? 0 : $val / $currency['exchange_rate']; } } // compute category per member stats $categoryMemberStats = []; foreach ($bills as $bill) { $payerId = $bill['payer_id']; $billCategoryId = $bill['categoryid']; if (!array_key_exists(strval($billCategoryId), $this->hardCodedCategoryNames) && !array_key_exists(strval($billCategoryId), $projectCategories) ) { $billCategoryId = 0; } $amount = $bill['amount']; if (!array_key_exists($billCategoryId, $categoryMemberStats)) { $categoryMemberStats[$billCategoryId] = []; foreach ($membersToDisplay as $memberId => $member) { $categoryMemberStats[$billCategoryId][$memberId] = 0; } } if (array_key_exists($payerId, $membersToDisplay)) { $categoryMemberStats[$billCategoryId][$payerId] += $amount; } } // convert if necessary if ($currency !== null) { foreach ($categoryMemberStats as $catId => $mStat) { foreach ($mStat as $mid => $val) { $categoryMemberStats[$catId][$mid] = ($val === 0.0) ? 0 : $val / $currency['exchange_rate']; } } } // compute category/payment mode per month stats $categoryMonthlyStats = []; $paymentModeMonthlyStats = []; foreach ($bills as $bill) { $amount = $bill['amount']; $date = DateTime::createFromFormat('U', $bill['timestamp']); $date->setTimezone($timeZone); $month = $date->format('Y-m'); // category $billCategoryId = $bill['categoryid']; if (!array_key_exists($billCategoryId, $categoryMonthlyStats)) { $categoryMonthlyStats[$billCategoryId] = []; } if (!array_key_exists($month, $categoryMonthlyStats[$billCategoryId])) { $categoryMonthlyStats[$billCategoryId][$month] = 0; } $categoryMonthlyStats[$billCategoryId][$month] += $amount; // payment mode $paymentModeId = $bill['paymentmodeid']; if (!array_key_exists($paymentModeId, $paymentModeMonthlyStats)) { $paymentModeMonthlyStats[$paymentModeId] = []; } if (!array_key_exists($month, $paymentModeMonthlyStats[$paymentModeId])) { $paymentModeMonthlyStats[$paymentModeId][$month] = 0; } $paymentModeMonthlyStats[$paymentModeId][$month] += $amount; } // average per month foreach ($categoryMonthlyStats as $catId => $monthValues) { $sum = 0; foreach ($monthValues as $month => $value) { $sum += $value; } $avg = $sum / $nbMonth; $categoryMonthlyStats[$catId][$averageKey] = $avg; } foreach ($paymentModeMonthlyStats as $pmId => $monthValues) { $sum = 0; foreach ($monthValues as $month => $value) { $sum += $value; } $avg = $sum / $nbMonth; $paymentModeMonthlyStats[$pmId][$averageKey] = $avg; } // convert if necessary if ($currency !== null) { foreach ($categoryMonthlyStats as $catId => $cStat) { foreach ($cStat as $month => $val) { $categoryMonthlyStats[$catId][$month] = ($val === 0.0) ? 0 : $val / $currency['exchange_rate']; } } foreach ($paymentModeMonthlyStats as $pmId => $pmStat) { foreach ($pmStat as $month => $val) { $paymentModeMonthlyStats[$pmId][$month] = ($val === 0.0) ? 0 : $val / $currency['exchange_rate']; } } } return [ 'stats' => $statistics, 'memberMonthlyPaidStats' => count($memberMonthlyPaidStats) > 0 ? $memberMonthlyPaidStats : null, 'memberMonthlySpentStats' => count($memberMonthlySpentStats) > 0 ? $memberMonthlySpentStats : null, 'categoryStats' => $categoryStats, 'categoryMonthlyStats' => $categoryMonthlyStats, 'paymentModeStats' => $paymentModeStats, 'paymentModeMonthlyStats' => $paymentModeMonthlyStats, 'categoryMemberStats' => $categoryMemberStats, 'memberIds' => array_keys($membersToDisplay), 'allMemberIds' => $allMembersIds, 'membersPaidFor' => $membersPaidFor, 'realMonths' => $realMonths, ]; } /** * Add a bill in a given project * * @param string $projectId * @param string|null $date * @param string|null $what * @param int|null $payer * @param string|null $payed_for * @param float|null $amount * @param string|null $repeat * @param string|null $paymentmode * @param int|null $paymentmodeid * @param int|null $categoryid * @param int $repeatallactive * @param string|null $repeatuntil * @param int|null $timestamp * @param string|null $comment * @param int|null $repeatfreq * @param array|null $paymentModes * @param int $deleted * @return array * @throws \OCP\DB\Exception */ public function createBill( string $projectId, ?string $date, ?string $what, ?int $payer, ?string $payed_for, ?float $amount, ?string $repeat, ?string $paymentmode = null, ?int $paymentmodeid = null, ?int $categoryid = null, int $repeatallactive = 0, ?string $repeatuntil = null, ?int $timestamp = null, ?string $comment = null, ?int $repeatfreq = null, ?array $paymentModes = null, int $deleted = 0 ): array { // if we don't have the payment modes, get them now if ($paymentModes === null) { $paymentModes = $this->getCategoriesOrPaymentModes($projectId, false); } if ($repeat === null || $repeat === '' || strlen($repeat) !== 1) { return ['repeat' => $this->l10n->t('Invalid value')]; } elseif (!in_array($repeat, Application::FREQUENCIES)) { return ['repeat' => $this->l10n->t('Invalid frequency')]; } if ($repeatuntil !== null && $repeatuntil === '') { $repeatuntil = null; } // priority to timestamp (moneybuster might send both for a moment) if ($timestamp === null) { if ($date === null || $date === '') { return ['message' => $this->l10n->t('Timestamp (or date) field is required')]; } else { $datetime = DateTime::createFromFormat('Y-m-d', $date); if ($datetime === false) { return ['date' => $this->l10n->t('Invalid date')]; } $dateTs = $datetime->getTimestamp(); } } else { $dateTs = $timestamp; } if ($what === null) { $what = ''; } if ($amount === null) { return ['amount' => $this->l10n->t('This field is required')]; } if ($payer === null) { return ['payer' => $this->l10n->t('This field is required')]; } if ($this->getMemberById($projectId, $payer) === null) { return ['payer' => $this->l10n->t('Not a valid choice')]; } // check owers $owerIds = explode(',', $payed_for); if ($payed_for === null || $payed_for === '' || empty($owerIds)) { return ['payed_for' => $this->l10n->t('Invalid value')]; } foreach ($owerIds as $owerId) { if (!is_numeric($owerId)) { return ['payed_for' => $this->l10n->t('Invalid value')]; } if ($this->getMemberById($projectId, (int) $owerId) === null) { return ['payed_for' => $this->l10n->t('Not a valid choice')]; } } // payment mode if (!is_null($paymentmodeid)) { // is the old_id set for this payment mode? if yes, use it for old 'paymentmode' column $paymentmode = 'n'; if (isset($paymentModes[$paymentmodeid], $paymentModes[$paymentmodeid]['old_id']) && $paymentModes[$paymentmodeid]['old_id'] !== null && $paymentModes[$paymentmodeid]['old_id'] !== '' ) { $paymentmode = $paymentModes[$paymentmodeid]['old_id']; } } elseif (!is_null($paymentmode)) { // is there a pm with this old id? if yes, use it for new id $paymentmodeid = 0; foreach ($paymentModes as $id => $pm) { if ($pm['old_id'] === $paymentmode) { $paymentmodeid = $id; break; } } } // last modification timestamp is now $ts = (new DateTime())->getTimestamp(); $newBill = new Bill(); $newBill->setProjectid($projectId); $newBill->setWhat($what); if ($comment !== null) { $newBill->setComment($comment); } $newBill->setTimestamp($dateTs); $newBill->setAmount($amount); $newBill->setPayerid($payer); $newBill->setRepeat($repeat); $newBill->setRepeatallactive($repeatallactive); $newBill->setRepeatuntil($repeatuntil); $newBill->setRepeatfreq($repeatfreq ?? 1); $newBill->setCategoryid($categoryid ?? 0); $newBill->setPaymentmode($paymentmode ?? 'n'); $newBill->setPaymentmodeid($paymentmodeid ?? 0); $newBill->setLastchanged($ts); $newBill->setDeleted($deleted); $createdBill = $this->billMapper->insert($newBill); $insertedBillId = $createdBill->getId(); // insert bill owers $qb = $this->db->getQueryBuilder(); foreach ($owerIds as $owerId) { $qb->insert('cospend_bill_owers') ->values([ 'billid' => $qb->createNamedParameter($insertedBillId, IQueryBuilder::PARAM_INT), 'memberid' => $qb->createNamedParameter($owerId, IQueryBuilder::PARAM_INT) ]); $qb->executeStatement(); $qb = $qb->resetQueryParts(); } $this->projectMapper->updateProjectLastChanged($projectId, $ts); return ['inserted_id' => $insertedBillId]; } /** * Delete a bill * * @param string $projectId * @param int $billId * @param bool $force Ignores any deletion protection and forces the deletion of the bill * @param bool $moveToTrash * @return array */ public function deleteBill(string $projectId, int $billId, bool $force = false, bool $moveToTrash = true): array { if ($force === false) { $project = $this->getProjectInfo($projectId); if ($project['deletiondisabled']) { return ['message' => 'forbidden']; } } $billToDelete = $this->billMapper->getBillEntity($projectId, $billId); if ($billToDelete !== null) { // really delete bills that already are in the trashbin if ($moveToTrash && $billToDelete->getDeleted() === 0) { $billToDelete->setDeleted(1); $this->billMapper->update($billToDelete); } else { $this->billMapper->deleteBillOwersOfBill($billId); $this->billMapper->delete($billToDelete); } $ts = (new DateTime())->getTimestamp(); $this->projectMapper->updateProjectLastChanged($projectId, $ts); return ['success' => true]; } else { return ['message' => 'not found']; } } /** * Get a member * * @param string $projectId * @param int $memberId * @return array|null */ public function getMemberById(string $projectId, int $memberId): ?array { $member = $this->memberMapper->getMemberById($projectId, $memberId); return $member?->jsonSerialize(); } /** * Generate bills to automatically settle a project * * @param string $projectId * @param int|null $centeredOn * @param int $precision * @param int|null $maxTimestamp * @return array */ public function autoSettlement(string $projectId, ?int $centeredOn = null, int $precision = 2, ?int $maxTimestamp = null): array { $settlement = $this->getProjectSettlement($projectId, $centeredOn, $maxTimestamp); $transactions = $settlement['transactions']; if (!is_array($transactions)) { return ['message' => $this->l10n->t('Error when getting project settlement transactions')]; } $members = $this->getMembers($projectId); $memberIdToName = []; foreach ($members as $member) { $memberIdToName[$member['id']] = $member['name']; } if ($maxTimestamp) { $ts = $maxTimestamp - 1; } else { $ts = (new DateTime())->getTimestamp(); } $paymentModes = []; foreach ($transactions as $transaction) { $fromId = $transaction['from']; $toId = $transaction['to']; $amount = round((float) $transaction['amount'], $precision); $billTitle = $memberIdToName[$fromId].' → '.$memberIdToName[$toId]; $addBillResult = $this->createBill( $projectId, null, $billTitle, $fromId, $toId, $amount, Application::FREQUENCY_NO, 'n', 0, Application::CATEGORY_REIMBURSEMENT, 0, null, $ts, null, null, $paymentModes ); if (!isset($addBillResult['inserted_id'])) { return ['message' => $this->l10n->t('Error when adding a bill')]; } } return ['success' => true]; } /** * Get project settlement plan * * @param string $projectId * @param int|null $centeredOn * @param int|null $maxTimestamp * @return array */ public function getProjectSettlement(string $projectId, ?int $centeredOn = null, ?int $maxTimestamp = null): array { $balances = $this->getBalance($projectId, $maxTimestamp); if ($centeredOn === null) { $transactions = $this->settle($balances); } else { $transactions = $this->centeredSettle($balances, $centeredOn); } return [ 'transactions' => $transactions, 'balances' => $balances, ]; } /** * Get a settlement plan centered on a member * * @param array $balances * @param int $centeredOn * @return array */ private function centeredSettle(array $balances, int $centeredOn): array { $transactions = []; foreach ($balances as $memberId => $balance) { if ($memberId !== $centeredOn) { if ($balance > 0.0) { $transactions[] = [ 'from' => $centeredOn, 'to' => $memberId, 'amount' => $balance ]; } elseif ($balance < 0.0) { $transactions[] = [ 'from' => $memberId, 'to' => $centeredOn, 'amount' => -$balance ]; } } } return $transactions; } /** * Get optimal settlement of a balance list * * @param array $balances * @return array */ private function settle(array $balances): ?array { $debitersCrediters = $this->orderBalance($balances); $debiters = $debitersCrediters[0]; $crediters = $debitersCrediters[1]; return $this->reduceBalance($crediters, $debiters); } /** * Separate crediter and debiter balances * * @param array $balances * @return array */ private function orderBalance(array $balances): array { $crediters = []; $debiters = []; foreach ($balances as $id => $balance) { if ($balance > 0.0) { $crediters[] = [$id, $balance]; } elseif ($balance < 0.0) { $debiters[] = [$id, $balance]; } } return [$debiters, $crediters]; } /** * Recursively produce transaction list of the settlement plan * * @param array $crediters * @param array $debiters * @param array|null $results * @return array */ private function reduceBalance(array $crediters, array $debiters, ?array $results = null): ?array { if (count($crediters) === 0 || count($debiters) === 0) { return $results; } if ($results === null) { $results = []; } $crediters = $this->sortCreditersDebiters($crediters); $debiters = $this->sortCreditersDebiters($debiters, true); $deb = array_pop($debiters); $debiter = $deb[0]; $debiterBalance = $deb[1]; $cred = array_pop($crediters); $crediter = $cred[0]; $crediterBalance = $cred[1]; if (abs($debiterBalance) > abs($crediterBalance)) { $amount = abs($crediterBalance); } else { $amount = abs($debiterBalance); } $newResults = $results; $newResults[] = ['to' => $crediter, 'amount' => $amount, 'from' => $debiter]; $newDebiterBalance = $debiterBalance + $amount; if ($newDebiterBalance < 0.0) { $debiters[] = [$debiter, $newDebiterBalance]; $debiters = $this->sortCreditersDebiters($debiters, true); } $newCrediterBalance = $crediterBalance - $amount; if ($newCrediterBalance > 0.0) { $crediters[] = [$crediter, $newCrediterBalance]; $crediters = $this->sortCreditersDebiters($crediters); } return $this->reduceBalance($crediters, $debiters, $newResults); } /** * Sort crediters or debiters array by balance value * * @param array $arr * @param bool $reverse * @return array */ private function sortCreditersDebiters(array $arr, bool $reverse = false): array { $res = []; if ($reverse) { foreach ($arr as $elem) { $i = 0; while ($i < count($res) && $elem[1] < $res[$i][1]) { $i++; } array_splice($res, $i, 0, [$elem]); } } else { foreach ($arr as $elem) { $i = 0; while ($i < count($res) && $elem[1] >= $res[$i][1]) { $i++; } array_splice($res, $i, 0, [$elem]); } } return $res; } /** * Edit a member * * @param string $projectId * @param int $memberId * @param string|null $name * @param string|null $userId * @param float|null $weight * @param bool $activated * @param string|null $color * @return array */ public function editMember( string $projectId, int $memberId, ?string $name = null, ?string $userId = null, ?float $weight = null, ?bool $activated = null, ?string $color = null ): array { $dbMember = $this->memberMapper->getMemberById($projectId, $memberId); if ($dbMember === null) { return ['name' => $this->l10n->t('This project have no such member')]; } $member = $dbMember->jsonSerialize(); // delete member if it has no bill and we are disabling it if ($member['activated'] && $activated === false && count($this->memberMapper->getBillIdsOfMember($memberId)) === 0 ) { $this->memberMapper->delete($dbMember); return []; } if ($name !== null) { if (str_contains($name, '/')) { return ['name' => $this->l10n->t('Invalid member name')]; } else { // get existing member with this name $memberWithSameName = $this->getMemberByName($projectId, $name); if ($memberWithSameName && $memberWithSameName['id'] !== $memberId) { return ['name' => $this->l10n->t('Name already exists')]; } } } if ($color !== null) { $color = preg_replace('/^#/', '', $color); if ($color === '' || ((strlen($color) === 3 || strlen($color) === 6) && preg_match('/^[0-9A-Fa-f]+/', $color) !== false) ) { // fine } else { return ['color' => $this->l10n->t('Invalid value')]; } } if ($weight !== null && $weight <= 0.0) { return ['weight' => $this->l10n->t('Not a valid decimal value')]; } // UPDATE $ts = (new DateTime())->getTimestamp(); $dbMember->setLastchanged($ts); if ($weight !== null) { $dbMember->setWeight($weight); } if ($activated !== null) { $dbMember->setActivated($activated ? 1 : 0); } if ($name !== null) { $dbMember->setName($name); } if ($color !== null) { $dbMember->setColor($color === '' ? null : $color); } if ($userId !== null) { $dbMember->setUserid($userId === '' ? null : $userId); } $this->memberMapper->update($dbMember); return $dbMember->jsonSerialize(); } /** * Edit a project * * @param string $projectId * @param string|null $name * @param string|null $contact_email * @param string|null $autoexport * @param string|null $currencyname * @param bool|null $deletion_disabled * @param string|null $categorysort * @param string|null $paymentmodesort * @param int|null $archivedTs * @return array * @throws \OCP\DB\Exception */ public function editProject( string $projectId, ?string $name = null, ?string $contact_email = null, ?string $autoexport = null, ?string $currencyname = null, ?bool $deletion_disabled = null, ?string $categorysort = null, ?string $paymentmodesort = null, ?int $archivedTs = null ): array { $dbProject = $this->projectMapper->find($projectId); if ($dbProject === null) { return ['message' => $this->l10n->t('There is no such project')]; } if ($name === '') { return ['name' => [$this->l10n->t('Name can\'t be empty')]]; } if ($contact_email !== null && $contact_email !== '') { if (filter_var($contact_email, FILTER_VALIDATE_EMAIL)) { } else { return ['contact_email' => [$this->l10n->t('Invalid email address')]]; } } if ($autoexport !== null && $autoexport !== '') { if (in_array($autoexport, Application::FREQUENCIES)) { } else { return ['autoexport' => [$this->l10n->t('Invalid frequency')]]; } } if ($categorysort !== null && $categorysort !== '') { if (in_array($categorysort, Application::SORT_ORDERS)) { } else { return ['categorysort' => [$this->l10n->t('Invalid sort order')]]; } } if ($paymentmodesort !== null && $paymentmodesort !== '') { if (in_array($paymentmodesort, Application::SORT_ORDERS)) { } else { return ['paymentmodesort' => [$this->l10n->t('Invalid sort order')]]; } } if ($archivedTs !== null) { if ($archivedTs === ProjectMapper::ARCHIVED_TS_NOW) { $dbTs = (new DateTime())->getTimestamp(); } elseif ($archivedTs === ProjectMapper::ARCHIVED_TS_UNSET) { $dbTs = null; } else { $dbTs = $archivedTs; } $dbProject->setArchivedTs($dbTs); } if ($name !== null) { $dbProject->setName($name); } if ($contact_email !== null && $contact_email !== '') { $dbProject->setEmail($contact_email); } if ($autoexport !== null && $autoexport !== '') { $dbProject->setAutoexport($autoexport); } if ($categorysort !== null && $categorysort !== '') { $dbProject->setCategorysort($categorysort); } if ($paymentmodesort !== null && $paymentmodesort !== '') { $dbProject->setPaymentmodesort($paymentmodesort); } if ($deletion_disabled !== null) { $dbProject->setDeletiondisabled($deletion_disabled ? 1 : 0); } if ($currencyname !== null) { $dbProject->setCurrencyname($currencyname === '' ? null : $currencyname); } $ts = (new DateTime())->getTimestamp(); $dbProject->setLastchanged($ts); $this->projectMapper->update($dbProject); return ['success' => true]; } /** * Add a member to a project * * @param string $projectId * @param string $name * @param float|null $weight * @param bool $active * @param string|null $color * @param string|null $userId * @return array{error: string}|CospendMember * @throws \OCP\DB\Exception */ public function createMember( string $projectId, string $name, ?float $weight = 1.0, bool $active = true, ?string $color = null, ?string $userId = null ): array { if ($name === '') { return ['error' => $this->l10n->t('Name field is required')]; } if (str_contains($name, '/')) { return ['error' => $this->l10n->t('Invalid member name')]; } if ($weight !== null && $weight <= 0.0) { return ['error' => $this->l10n->t('Weight is not a valid decimal value')]; } if ($color !== null && $color !== '' && strlen($color) !== 4 && strlen($color) !== 7) { return ['error' => $this->l10n->t('Invalid color value')]; } if ($this->memberMapper->getMemberByName($projectId, $name) !== null) { return ['error' => $this->l10n->t('This project already has this member')]; } if ($userId !== null && $this->memberMapper->getMemberByUserid($projectId, $userId) !== null) { return ['error' => $this->l10n->t('This project already has this member (user)')]; } $newMember = new Member(); $weightToInsert = $weight === null ? 1.0 : $weight; $newMember->setWeight($weightToInsert); if ($color !== null && (strlen($color) === 4 || strlen($color) === 7) && preg_match('/^#[0-9A-Fa-f]+/', $color) !== false ) { $newMember->setColor($color); } $ts = (new DateTime())->getTimestamp(); $newMember->setLastchanged($ts); $newMember->setProjectid($projectId); if ($userId !== null) { $newMember->setUserid($userId); } $newMember->setActivated($active ? 1 : 0); $newMember->setName($name); $createdMember = $this->memberMapper->insert($newMember); return $createdMember->jsonSerialize(); } /** * Get members of a project * * @param string $projectId * @param string|null $order * @param int|null $lastchanged * @return array */ public function getMembers(string $projectId, ?string $order = null, ?int $lastchanged = null): array { $members = $this->memberMapper->getMembers($projectId, $order, $lastchanged); return array_map(static function (Member $dbMember) { return $dbMember->jsonSerialize(); }, $members); } /** * Get members balances for a project * * @param string $projectId * @param int|null $maxTimestamp * @return array */ private function getBalance(string $projectId, ?int $maxTimestamp = null): array { $membersWeight = []; $membersBalance = []; $members = $this->getMembers($projectId); foreach ($members as $member) { $memberId = $member['id']; $memberWeight = $member['weight']; $membersWeight[$memberId] = $memberWeight; $membersBalance[$memberId] = 0.0; } $bills = $this->billMapper->getBills($projectId, null, $maxTimestamp); foreach ($bills as $bill) { $payerId = $bill['payer_id']; $amount = $bill['amount']; $owers = $bill['owers']; $membersBalance[$payerId] += $amount; $nbOwerShares = 0.0; foreach ($owers as $ower) { $owerWeight = $ower['weight']; if ($owerWeight === 0.0) { $owerWeight = 1.0; } $nbOwerShares += $owerWeight; } foreach ($owers as $ower) { $owerWeight = $ower['weight']; if ($owerWeight === 0.0) { $owerWeight = 1.0; } $owerId = $ower['id']; $spent = $amount / $nbOwerShares * $owerWeight; $membersBalance[$owerId] -= $spent; } } return $membersBalance; } /** * Check if a user is member of a given circle * * @param string $userId * @param string $circleId * @return bool */ private function isUserInCircle(string $userId, string $circleId): bool { try { $circlesManager = \OC::$server->get(\OCA\Circles\CirclesManager::class); $circlesManager->startSuperSession(); } catch (Exception $e) { return false; } try { $circle = $circlesManager->getCircle($circleId); } catch (\OCA\Circles\Exceptions\CircleNotFoundException $e) { $circlesManager->stopSession(); return false; } // is the circle owner $owner = $circle->getOwner(); // the owner is also a member so this might be useless... if ($owner->getUserType() === 1 && $owner->getUserId() === $userId) { $circlesManager->stopSession(); return true; } else { $members = $circle->getMembers(); foreach ($members as $m) { // is member of this circle if ($m->getUserType() === 1 && $m->getUserId() === $userId) { $circlesManager->stopSession(); return true; } } } $circlesManager->stopSession(); return false; } /** * For all projects the user has access to, get id => name * * @param string|null $userId * @return array * @throws \OCP\DB\Exception */ public function getProjectNames(?string $userId): array { if (is_null($userId)) { return []; } $projectNames = []; $qb = $this->db->getQueryBuilder(); $qb->select('id', 'name') ->from('cospend_projects', 'p') ->where( $qb->expr()->eq('userid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $projectNames[$row['id']] = $row['name']; } $req->closeCursor(); $qb = $qb->resetQueryParts(); // shared with user $qb->select('p.id', 'p.name') ->from('cospend_projects', 'p') ->innerJoin('p', 'cospend_shares', 's', $qb->expr()->eq('p.id', 's.projectid')) ->where( $qb->expr()->eq('s.userid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('s.type', $qb->createNamedParameter(Application::SHARE_TYPE_USER, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { // avoid putting twice the same project // this can happen with a share loop if (!isset($projectNames[$row['id']])) { $projectNames[$row['id']] = $row['name']; } } $req->closeCursor(); $qb = $qb->resetQueryParts(); // shared with one of the groups the user is member of $userO = $this->userManager->get($userId); // get group with which a project is shared $candidateGroupIds = []; $qb->select('userid') ->from('cospend_shares', 's') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_GROUP, IQueryBuilder::PARAM_STR)) ) ->groupBy('userid'); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $groupId = $row['userid']; $candidateGroupIds[] = $groupId; } $req->closeCursor(); $qb = $qb->resetQueryParts(); // is the user member of these groups? foreach ($candidateGroupIds as $candidateGroupId) { $group = $this->groupManager->get($candidateGroupId); if ($group !== null && $group->inGroup($userO)) { // get projects shared with this group $qb->select('p.id', 'p.name') ->from('cospend_projects', 'p') ->innerJoin('p', 'cospend_shares', 's', $qb->expr()->eq('p.id', 's.projectid')) ->where( $qb->expr()->eq('s.userid', $qb->createNamedParameter($candidateGroupId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('s.type', $qb->createNamedParameter(Application::SHARE_TYPE_GROUP, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { // avoid putting twice the same project // this can happen with a share loop if (!isset($projectNames[$row['id']])) { $projectNames[$row['id']] = $row['name']; } } $req->closeCursor(); $qb = $qb->resetQueryParts(); } } $circlesEnabled = $this->appManager->isEnabledForUser('circles'); if ($circlesEnabled) { // get circles with which a project is shared $candidateCircleIds = []; $qb->select('userid') ->from('cospend_shares', 's') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_CIRCLE, IQueryBuilder::PARAM_STR)) ) ->groupBy('userid'); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $circleId = $row['userid']; $candidateCircleIds[] = $circleId; } $req->closeCursor(); $qb = $qb->resetQueryParts(); // is the user member of these circles? foreach ($candidateCircleIds as $candidateCircleId) { if ($this->isUserInCircle($userId, $candidateCircleId)) { // get projects shared with this circle $qb->select('p.id', 'p.name') ->from('cospend_projects', 'p') ->innerJoin('p', 'cospend_shares', 's', $qb->expr()->eq('p.id', 's.projectid')) ->where( $qb->expr()->eq('s.userid', $qb->createNamedParameter($candidateCircleId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('s.type', $qb->createNamedParameter(Application::SHARE_TYPE_CIRCLE, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { // avoid putting twice the same project // this can happen with a share loop or multiple shares if (!isset($projectNames[$row['id']])) { $projectNames[$row['id']] = $row['name']; } } $req->closeCursor(); $qb = $qb->resetQueryParts(); } } } return $projectNames; } /** * Get detailed project list for a given NC user * * @param string $userId * @return array */ public function getProjects(string $userId): array { $projectids = array_keys($this->getProjectNames($userId)); // get the projects $projects = []; foreach ($projectids as $projectid) { $project = $this->getProjectInfo($projectid); $project['myaccesslevel'] = $this->getUserMaxAccessLevel($userId, $projectid); $projects[] = $project; } return $projects; } /** * Get categories of a given project * * @param string $projectId * @param bool $getCategories * @return array * @throws \OCP\DB\Exception */ public function getCategoriesOrPaymentModes(string $projectId, bool $getCategories = true): array { $elements = []; $qb = $this->db->getQueryBuilder(); if ($getCategories) { $sortOrderField = 'categorysort'; $billTableField = 'categoryid'; $dbTable = 'cospend_categories'; $alias = 'cat'; } else { $sortOrderField = 'paymentmodesort'; $billTableField = 'paymentmodeid'; $dbTable = 'cospend_paymentmodes'; $alias = 'pm'; } // get sort method $qb->select($sortOrderField) ->from('cospend_projects', 'p') ->where( $qb->expr()->eq('id', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); $sortMethod = Application::SORT_ORDER_ALPHA; while ($row = $req->fetch()) { $sortMethod = $row[$sortOrderField]; break; } $req->closeCursor(); $qb->resetQueryParts(); if ($sortMethod === Application::SORT_ORDER_MANUAL || $sortMethod === Application::SORT_ORDER_ALPHA) { if ($getCategories) { $qb = $qb->select('name', 'id', 'encoded_icon', 'color', 'order'); } else { $qb = $qb->select('name', 'id', 'encoded_icon', 'color', 'order', 'old_id'); } $qb->from($dbTable, 'c') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $dbName = $row['name']; $dbIcon = $row['encoded_icon'] === null ? null : urldecode($row['encoded_icon']); $dbColor = $row['color']; $dbId = (int) $row['id']; $dbOrder = (int) $row['order']; $elements[$dbId] = [ 'name' => $dbName, 'icon' => $dbIcon, 'color' => $dbColor, 'id' => $dbId, 'order' => $dbOrder, ]; if (!$getCategories) { $elements[$dbId]['old_id'] = $row['old_id']; } } $req->closeCursor(); $qb->resetQueryParts(); } elseif ($sortMethod === Application::SORT_ORDER_MOST_USED || $sortMethod === Application::SORT_ORDER_RECENTLY_USED) { // get all categories/paymentmodes if ($getCategories) { $qb = $qb->select('name', 'id', 'encoded_icon', 'color'); } else { $qb = $qb->select('name', 'id', 'encoded_icon', 'color', 'old_id'); } $qb->from($dbTable, 'c') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $dbName = $row['name']; $dbIcon = $row['encoded_icon'] === null ? null : urldecode($row['encoded_icon']); $dbColor = $row['color']; $dbId = (int) $row['id']; $elements[$dbId] = [ 'name' => $dbName, 'icon' => $dbIcon, 'color' => $dbColor, 'id' => $dbId, 'order' => null, ]; if (!$getCategories) { $elements[$dbId]['old_id'] = $row['old_id']; } } $req->closeCursor(); $qb->resetQueryParts(); // now we get the order if ($sortMethod === Application::SORT_ORDER_MOST_USED) { // sort by most used // first get list of most used $mostUsedOrder = []; $qb->select($alias . '.id') ->from($dbTable, $alias) ->innerJoin($alias, 'cospend_bills', 'bill', $qb->expr()->eq($alias . '.id', 'bill.' . $billTableField)) ->where( $qb->expr()->eq($alias . '.projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('bill.deleted', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) ) ->orderBy($qb->func()->count($alias . '.id'), 'DESC') ->groupBy($alias . '.id'); $req = $qb->executeQuery(); $order = 0; while ($row = $req->fetch()) { $dbId = (int) $row['id']; $mostUsedOrder[$dbId] = $order++; } $req->closeCursor(); $qb->resetQueryParts(); // affect order foreach ($elements as $cid => $cat) { // fallback order is more than max order $elements[$cid]['order'] = $mostUsedOrder[$cid] ?? $order; } } else { // sort by most recently used $mostUsedOrder = []; $qb->select($alias . '.id') ->from($dbTable, $alias) ->innerJoin($alias, 'cospend_bills', 'bill', $qb->expr()->eq($alias . '.id', 'bill.' . $billTableField)) ->where( $qb->expr()->eq($alias . '.projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('bill.deleted', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) ) ->orderBy($qb->func()->max('bill.timestamp'), 'DESC') ->groupBy($alias . '.id'); $req = $qb->executeQuery(); $order = 0; while ($row = $req->fetch()) { $dbId = (int) $row['id']; $mostUsedOrder[$dbId] = $order++; } $req->closeCursor(); $qb->resetQueryParts(); // affect order foreach ($elements as $elemId => $element) { // fallback order is more than max order $elements[$elemId]['order'] = $mostUsedOrder[$elemId] ?? $order; } } } return $elements; } /** * Get currencies of a project * * @param string $projectId * @return array */ private function getCurrencies(string $projectId): array { $currencies = []; $qb = $this->db->getQueryBuilder(); $qb->select('name', 'id', 'exchange_rate') ->from('cospend_currencies') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $dbName = $row['name']; $dbId = (int) $row['id']; $dbExchangeRate = (float) $row['exchange_rate']; $currencies[] = [ 'name' => $dbName, 'exchange_rate' => $dbExchangeRate, 'id' => $dbId, ]; } $req->closeCursor(); $qb->resetQueryParts(); return $currencies; } /** * Get user shared access of a project * * @param string $projectId * @return array */ private function getUserShares(string $projectId): array { $shares = []; $userIdToName = []; $sharesToDelete = []; $qb = $this->db->getQueryBuilder(); $qb->select('projectid', 'userid', 'id', 'accesslevel', 'manually_added') ->from('cospend_shares') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_USER, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $dbuserId = $row['userid']; $dbId = (int) $row['id']; $dbAccessLevel = (int) $row['accesslevel']; $dbManuallyAdded = (int) $row['manually_added']; if (array_key_exists($dbuserId, $userIdToName)) { $name = $userIdToName[$dbuserId]; } else { $user = $this->userManager->get($dbuserId); if ($user !== null) { $userIdToName[$user->getUID()] = $user->getDisplayName(); $name = $user->getDisplayName(); } else { $sharesToDelete[] = $dbId; continue; } } $shares[] = [ 'userid' => $dbuserId, 'name' => $name, 'id' => $dbId, 'accesslevel' => $dbAccessLevel, 'type' => Application::SHARE_TYPE_USER, 'manually_added' => $dbManuallyAdded === 1, ]; } $req->closeCursor(); $qb->resetQueryParts(); // delete shares pointing to unfound users foreach ($sharesToDelete as $shId) { $this->deleteUserShare($projectId, $shId); } return $shares; } /** * Get public links of a project * * @param string $projectId * @param int|null $maxAccessLevel * @return array */ public function getPublicShares(string $projectId, ?int $maxAccessLevel = null): array { $shares = []; $qb = $this->db->getQueryBuilder(); $qb->select('projectid', 'userid', 'id', 'accesslevel', 'label', 'password') ->from('cospend_shares') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_PUBLIC_LINK, IQueryBuilder::PARAM_STR)) ); if (!is_null($maxAccessLevel)) { $qb->andWhere( $qb->expr()->lte('accesslevel', $qb->createNamedParameter($maxAccessLevel, IQueryBuilder::PARAM_INT)) ); } $req = $qb->executeQuery(); while ($row = $req->fetch()) { $dbToken = $row['userid']; $dbId = (int) $row['id']; $dbAccessLevel = (int) $row['accesslevel']; $dbLabel = $row['label']; $dbPassword = $row['password']; $shares[] = [ 'token' => $dbToken, 'id' => $dbId, 'accesslevel' => $dbAccessLevel, 'label' => $dbLabel, 'password' => $dbPassword, 'type' => Application::SHARE_TYPE_PUBLIC_LINK, ]; } $req->closeCursor(); $qb->resetQueryParts(); return $shares; } /** * Get project info for a given public share token * * @param string $token * @return array|null * @throws \OCP\DB\Exception */ public function getShareInfoFromShareToken(string $token): ?array { $projectInfo = null; $qb = $this->db->getQueryBuilder(); $qb->select('projectid', 'accesslevel', 'label', 'password') ->from('cospend_shares') ->where( $qb->expr()->eq('userid', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_PUBLIC_LINK, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $projectId = $row['projectid']; $label = $row['label']; $password = $row['password']; $accessLevel = (int) $row['accesslevel']; $projectInfo = [ 'projectid' => $projectId, 'accesslevel' => $accessLevel, 'label' => $label, 'password' => $password, ]; break; } $req->closeCursor(); $qb->resetQueryParts(); return $projectInfo; } /** * Get group shared access list of a project * * @param string $projectId * @return array */ private function getGroupShares(string $projectId): array { $shares = []; $groupIdToName = []; $sharesToDelete = []; $qb = $this->db->getQueryBuilder(); $qb->select('projectid', 'userid', 'id', 'accesslevel') ->from('cospend_shares') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_GROUP, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $dbGroupId = $row['userid']; $dbId = (int) $row['id']; $dbAccessLevel = (int) $row['accesslevel']; if (array_key_exists($dbGroupId, $groupIdToName)) { $name = $groupIdToName[$dbGroupId]; } else { if ($this->groupManager->groupExists($dbGroupId)) { $name = $this->groupManager->get($dbGroupId)->getDisplayName(); $groupIdToName[$dbGroupId] = $name; } else { $sharesToDelete[] = $dbId; continue; } } $shares[] = [ 'groupid' => $dbGroupId, 'name' => $name, 'id' => $dbId, 'accesslevel' => $dbAccessLevel, 'type' => Application::SHARE_TYPE_GROUP, ]; } $req->closeCursor(); $qb->resetQueryParts(); foreach ($sharesToDelete as $shId) { $this->deleteGroupShare($projectId, $shId); } return $shares; } /** * Get circle shared access list of a project * * @param string $projectId * @return array */ private function getCircleShares(string $projectId): array { $shares = []; $circlesEnabled = $this->appManager->isEnabledForUser('circles'); if ($circlesEnabled) { try { $circlesManager = \OC::$server->get(\OCA\Circles\CirclesManager::class); $circlesManager->startSuperSession(); } catch (Exception $e) { return []; } $qb = $this->db->getQueryBuilder(); $qb->select('projectid', 'userid', 'id', 'accesslevel') ->from('cospend_shares') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_CIRCLE, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $dbCircleId = $row['userid']; $dbId = (int) $row['id']; $dbAccessLevel = (int) $row['accesslevel']; try { $circle = $circlesManager->getCircle($dbCircleId); $shares[] = [ 'circleid' => $dbCircleId, 'name' => $circle->getDisplayName(), 'id' => $dbId, 'accesslevel' => $dbAccessLevel, 'type' => Application::SHARE_TYPE_CIRCLE, ]; } catch (\OCA\Circles\Exceptions\CircleNotFoundException $e) { } } $req->closeCursor(); $qb->resetQueryParts(); $circlesManager->stopSession(); } return $shares; } /** * Delete a member * * @param string $projectId * @param int $memberId * @return array */ public function deleteMember(string $projectId, int $memberId): array { $dbMemberToDelete = $this->memberMapper->getMemberById($projectId, $memberId); if ($dbMemberToDelete !== null) { $memberToDelete = $dbMemberToDelete->jsonSerialize(); if (count($this->memberMapper->getBillIdsOfMember($memberId)) === 0) { $this->memberMapper->delete($dbMemberToDelete); } elseif ($memberToDelete['activated']) { $dbMemberToDelete->setActivated(0); $this->memberMapper->update($dbMemberToDelete); } return ['success' => true]; } else { return ['error' => 'Not Found']; } } /** * Get a member from its name * * @param string $projectId * @param string $name * @return array|null */ public function getMemberByName(string $projectId, string $name): ?array { $member = $this->memberMapper->getMemberByName($projectId, $name); return $member?->jsonSerialize(); } /** * Get a member from its user ID * * @param string $projectId * @param string|null $userId * @return array|null */ public function getMemberByUserid(string $projectId, ?string $userId): ?array { if ($userId === null) { return null; } $member = $this->memberMapper->getMemberByUserid($projectId, $userId); return $member?->jsonSerialize(); } /** * Edit a bill * * @param string $projectId * @param int $billId * @param string|null $date * @param string|null $what * @param int|null $payer * @param string|null $payed_for * @param float|null $amount * @param string|null $repeat * @param string|null $paymentmode * @param int|null $paymentmodeid * @param int|null $categoryid * @param int|null $repeatallactive * @param string|null $repeatuntil * @param int|null $timestamp * @param string|null $comment * @param int|null $repeatfreq * @param array|null $paymentModes * @param int|null $deleted * @return array * @throws \OCP\DB\Exception */ public function editBill( string $projectId, int $billId, ?string $date, ?string $what, ?int $payer, ?string $payed_for, ?float $amount, ?string $repeat, ?string $paymentmode = null, ?int $paymentmodeid = null, ?int $categoryid = null, ?int $repeatallactive = null, ?string $repeatuntil = null, ?int $timestamp = null, ?string $comment = null, ?int $repeatfreq = null, ?array $paymentModes = null, ?int $deleted = null ): array { // if we don't have the payment modes, get them now if ($paymentModes === null) { $paymentModes = $this->getCategoriesOrPaymentModes($projectId, false); } $dbBill = $this->billMapper->getBillEntity($projectId, $billId); // first check the bill exists if ($dbBill === null) { return ['message' => $this->l10n->t('There is no such bill')]; } // validate params if ($repeat !== null && $repeat !== '') { if (!in_array($repeat, Application::FREQUENCIES)) { return ['repeat' => $this->l10n->t('Invalid value')]; } } if ($timestamp === null && $date !== null && $date !== '') { $datetime = DateTime::createFromFormat('Y-m-d', $date); if ($datetime === false) { return ['date' => $this->l10n->t('Invalid value')]; } } if ($payer !== null) { $dbPayer = $this->memberMapper->getMemberById($projectId, $payer); if ($dbPayer === null) { return ['payer' => $this->l10n->t('Not a valid choice')]; } } // validate owers $owerIds = null; // check owers if ($payed_for !== null && $payed_for !== '') { $owerIds = explode(',', $payed_for); if (empty($owerIds)) { return ['payed_for' => $this->l10n->t('Invalid value')]; } else { foreach ($owerIds as $owerId) { if (!is_numeric($owerId)) { return ['payed_for' => $this->l10n->t('Invalid value')]; } if ($this->getMemberById($projectId, (int) $owerId) === null) { return ['payed_for' => $this->l10n->t('Not a valid choice')]; } } } } // UPDATE $qb = $this->db->getQueryBuilder(); $qb->update('cospend_bills'); // set last modification timestamp $ts = (new DateTime())->getTimestamp(); $dbBill->setLastchanged($ts); if ($what !== null) { $dbBill->setWhat($what); } if ($comment !== null) { $dbBill->setComment($comment); } if ($deleted !== null) { $dbBill->setDeleted($deleted); } if ($repeat !== null && $repeat !== '') { if (in_array($repeat, Application::FREQUENCIES)) { $dbBill->setRepeat($repeat); } } if ($repeatfreq !== null) { $dbBill->setRepeatfreq($repeatfreq); } if ($repeatuntil !== null) { $dbBill->setRepeatuntil($repeatuntil === '' ? null : $repeatuntil); } if ($repeatallactive !== null) { $dbBill->setRepeatallactive($repeatallactive); } // payment mode if ($paymentmodeid !== null) { // is the old_id set for this payment mode? if yes, use it for old 'paymentmode' column $paymentmode = 'n'; if (isset($paymentModes[$paymentmodeid], $paymentModes[$paymentmodeid]['old_id']) && $paymentModes[$paymentmodeid]['old_id'] !== null && $paymentModes[$paymentmodeid]['old_id'] !== '' ) { $paymentmode = $paymentModes[$paymentmodeid]['old_id']; } $dbBill->setPaymentmodeid($paymentmodeid); $dbBill->setPaymentmode($paymentmode); } elseif ($paymentmode !== null) { // is there a pm with this old id? if yes, use it for new id $paymentmodeid = 0; foreach ($paymentModes as $id => $pm) { if ($pm['old_id'] === $paymentmode) { $paymentmodeid = $id; break; } } $dbBill->setPaymentmodeid($paymentmodeid); $dbBill->setPaymentmode($paymentmode); } if ($categoryid !== null) { $dbBill->setCategoryid($categoryid); } // priority to timestamp (moneybuster might send both for a moment) if ($timestamp !== null) { $dbBill->setTimestamp($timestamp); } elseif ($date !== null && $date !== '') { $datetime = DateTime::createFromFormat('Y-m-d', $date); if ($datetime !== false) { $dateTs = $datetime->getTimestamp(); $dbBill->setTimestamp($dateTs); } } if ($amount !== null) { $dbBill->setAmount($amount); } if ($payer !== null) { $dbBill->setPayerid($payer); } $this->billMapper->update($dbBill); // edit the bill owers if ($owerIds !== null) { // delete old bill owers $this->billMapper->deleteBillOwersOfBill($billId); // insert bill owers foreach ($owerIds as $owerId) { $qb->insert('cospend_bill_owers') ->values([ 'billid' => $qb->createNamedParameter($billId, IQueryBuilder::PARAM_INT), 'memberid' => $qb->createNamedParameter($owerId, IQueryBuilder::PARAM_INT) ]); $qb->executeStatement(); $qb = $qb->resetQueryParts(); } } $this->projectMapper->updateProjectLastChanged($projectId, $ts); return ['edited_bill_id' => $billId]; } /** * daily check of repeated bills * * @param int|null $billId * @return array */ public function cronRepeatBills(?int $billId = null): array { $result = []; $projects = []; $now = new DateTimeImmutable(); // in case cron job wasn't executed during several days, // continue trying to repeat bills as long as there was at least one repeated $continue = true; while ($continue) { $continue = false; // get bills with repetition flag $qb = $this->db->getQueryBuilder(); $qb->select('id', 'projectid', 'what', 'timestamp', 'amount', 'payerid', 'repeat', 'repeatallactive', 'repeatfreq') ->from('cospend_bills', 'b') ->where( $qb->expr()->neq('repeat', $qb->createNamedParameter(Application::FREQUENCY_NO, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('deleted', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) ); // we only repeat one bill if (!is_null($billId)) { $qb->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($billId, IQueryBuilder::PARAM_INT)) ); } $req = $qb->executeQuery(); $bills = []; /** @var DateTimeZone[] $timezoneByProjectId */ $timezoneByProjectId = []; while ($row = $req->fetch()) { $id = $row['id']; $what = $row['what']; $repeat = $row['repeat']; $repeatallactive = $row['repeatallactive']; $repeatfreq = (int) $row['repeatfreq']; $timestamp = $row['timestamp']; $projectid = $row['projectid']; $bills[] = [ 'id' => $id, 'what' => $what, 'repeat' => $repeat, 'repeatallactive' => $repeatallactive, 'repeatfreq' => $repeatfreq, 'projectid' => $projectid, 'timestamp' => $timestamp ]; if (!isset($timezoneByProjectId[$projectid])) { $timezoneByProjectId[$projectid] = $this->getProjectTimeZone($projectid); } } $req->closeCursor(); $qb->resetQueryParts(); foreach ($bills as $bill) { $billProjectId = $bill['projectid']; $billDate = (new DateTimeImmutable())->setTimestamp($bill['timestamp'])->setTimezone($timezoneByProjectId[$billProjectId]); $nextDate = $this->getNextRepetitionDate($bill, $billDate); // Unknown repeat interval if ($nextDate === null) { continue; } // Repeat if $nextDate is in the past (or today) $nowTs = $now->getTimestamp(); $nextDateTs = $nextDate->getTimestamp(); if ($nowTs > $nextDateTs || $nextDate->format('Y-m-d') === $now->format('Y-m-d')) { $newBillId = $this->repeatBill($bill['projectid'], $bill['id'], $nextDate); // bill was not repeated (because of disabled owers or repeatuntil) if ($newBillId === null) { continue; } if (!array_key_exists($bill['projectid'], $projects)) { $projects[$bill['projectid']] = $this->getProjectInfo($bill['projectid']); } $result[] = [ 'new_bill_id' => $newBillId, 'date_orig' => $billDate->format('Y-m-d'), 'date_repeat' => $nextDate->format('Y-m-d'), 'what' => $bill['what'], 'project_name' => $projects[$bill['projectid']]['name'], ]; $continue = true; // when only repeating one bill, this newly created bill is the one we want to potentially repeat $billId = $newBillId; } } } return $result; } private function getProjectTimeZone(string $projectId): DateTimeZone { $projectInfo = $this->getProjectInfo($projectId); $userId = $projectInfo['userid']; $timeZone = $this->config->getUserValue($userId, 'core', 'timezone', null); $serverTimeZone = date_default_timezone_get() ?: 'UTC'; if ($timeZone === null) { $timeZone = $serverTimeZone; } try { return new DateTimeZone($timeZone); } catch (Exception $e) { return new DateTimeZone($serverTimeZone); } } private function copyBillPaymentModeOver(string $projectId, array $bill, string $toProjectId): int { $originPayments = $this->getCategoriesOrPaymentModes($projectId, false); $destinationPayments = $this->getCategoriesOrPaymentModes($toProjectId, false); if ($bill['paymentmodeid'] !== 0) { $originPayment = array_filter($originPayments, static function ($val) use ($bill) { return $val['id'] === $bill['paymentmodeid']; }); $originPayment = array_shift($originPayment); // find a payment mode with the same name $paymentNameMatches = array_filter($destinationPayments, static function ($val) use ($originPayment) { return $val['name'] === $originPayment['name']; }); // no payment mode match, means new mode if (count($paymentNameMatches) === 0) { return $this->createPaymentMode($toProjectId, $originPayment['name'], $originPayment['icon'], $originPayment['color']); } else { return array_shift($paymentNameMatches)['id']; } } return $bill['paymentmodeid']; } private function copyBillCategoryOver(string $projectId, array $bill, string $toProjectId): int { $originCategories = $this->getCategoriesOrPaymentModes($projectId); $destinationCategories = $this->getCategoriesOrPaymentModes($toProjectId); if ($bill['categoryid'] !== 0 && $bill['categoryid'] !== Application::CATEGORY_REIMBURSEMENT) { $originCategory = array_filter($originCategories, static function ($val) use ($bill) { return $val['id'] === $bill['categoryid']; }); $originCategory = array_shift($originCategory); // find a category with the same name $categoryNameMatches = array_filter($destinationCategories, static function ($val) use ($originCategory) { return $val['name'] === $originCategory['name']; }); // no category match, means new category if (count($categoryNameMatches) === 0) { return $this->createCategory($toProjectId, $originCategory['name'], $originCategory['icon'], $originCategory['color']); } else { return array_shift($categoryNameMatches)['id']; } } return $bill['categoryid']; } /** * @param string $projectId * @param int $billId * @param string $toProjectId * @return array * @throws \OCP\DB\Exception */ public function moveBill(string $projectId, int $billId, string $toProjectId): array { $bill = $this->billMapper->getBill($projectId, $billId); // get all members in all the projects and try to match them $originMembers = $this->getMembers($projectId, 'lowername'); $destinationMembers = $this->getMembers($toProjectId, 'lowername'); // try to match them $originalPayer = $originMembers; $originalPayer = array_filter($originalPayer, static function ($val) use ($bill) { return $val['id'] === $bill['payer_id']; }); $originalPayer = array_shift($originalPayer); $newPayer = $destinationMembers; $newPayer = array_filter($newPayer, static function ($val) use ($originalPayer) { return $val['name'] === $originalPayer['name']; }); if (count($newPayer) < 1) { return ['message' => $this->l10n->t('Cannot match payer')]; } $newPayer = array_shift($newPayer); // match owers too, these do not mind that much, the user will be able to modify the new invoice just after moving it $newOwers = array_filter($destinationMembers, static function ($member) use ($bill) { $matches = array_filter($bill['owers'], static function ($oldMember) use ($member) { return $oldMember['name'] === $member['name']; }); if (count($matches) === 0) { return false; } return true; }); $newCategoryId = $this->copyBillCategoryOver($projectId, $bill, $toProjectId); $newPaymentId = $this->copyBillPaymentModeOver($projectId, $bill, $toProjectId); $result = $this->createBill( $toProjectId, null, $bill['what'], $newPayer['id'], implode(',', array_column($newOwers, 'id')), $bill['amount'], $bill['repeat'], $bill['paymentmode'], $newPaymentId, $newCategoryId, $bill['repeatallactive'], $bill['repeatuntil'], $bill['timestamp'], $bill['comment'], $bill['repeatfreq'], null, $bill['deleted'] ); if (!isset($result['inserted_id'])) { return ['message' => $this->l10n->t('Cannot create new bill: %1$s', $result['message'])]; } // remove the old bill $this->deleteBill($projectId, $billId, true); return $result; } /** * duplicate the bill today and give it the repeat flag * remove the repeat flag on original bill * * @param string $projectId * @param int $billId * @param DateTimeImmutable $targetDatetime * @return int|null * @throws \OCP\DB\Exception */ private function repeatBill(string $projectId, int $billId, DateTimeImmutable $targetDatetime): ?int { $bill = $this->billMapper->getBill($projectId, $billId); $owerIds = []; if (((int) $bill['repeatallactive']) === 1) { $pInfo = $this->getProjectInfo($projectId); foreach ($pInfo['active_members'] as $am) { $owerIds[] = $am['id']; } } else { foreach ($bill['owers'] as $ower) { if ($ower['activated']) { $owerIds[] = $ower['id']; } } } $owerIdsStr = implode(',', $owerIds); // if all owers are disabled, don't try to repeat the bill and remove repeat flag if (count($owerIds) === 0) { $this->editBill( $projectId, $billId, null, null, null, null, null, Application::FREQUENCY_NO, null, null, null, null ); return null; } // if bill should be repeated only until... if ($bill['repeatuntil'] !== null && $bill['repeatuntil'] !== '') { $untilDate = DateTimeImmutable::createFromFormat('Y-m-d', $bill['repeatuntil']); if ($targetDatetime > $untilDate) { $this->editBill( $projectId, $billId, null, null, null, null, null, Application::FREQUENCY_NO, null, null, null, null ); return null; } } $addBillResult = $this->createBill( $projectId, null, $bill['what'], $bill['payer_id'], $owerIdsStr, $bill['amount'], $bill['repeat'], $bill['paymentmode'], $bill['paymentmodeid'], $bill['categoryid'], $bill['repeatallactive'], $bill['repeatuntil'], $targetDatetime->getTimestamp(), $bill['comment'], $bill['repeatfreq'] ); $newBillId = $addBillResult['inserted_id'] ?? 0; $billObj = $this->billMapper->find($newBillId); $this->activityManager->triggerEvent( ActivityManager::COSPEND_OBJECT_BILL, $billObj, ActivityManager::SUBJECT_BILL_CREATE, [] ); // now we can remove repeat flag on original bill $this->editBill($projectId, $billId, null, $bill['what'], $bill['payer_id'], null, $bill['amount'], Application::FREQUENCY_NO, null, null, null, null); return $newBillId; } /** * Get next repetition date of a bill * * @param array $bill * @param DateTimeImmutable $billDate * @return DateTimeImmutable|null * @throws Exception */ private function getNextRepetitionDate(array $bill, DateTimeImmutable $billDate): ?DateTimeImmutable { switch ($bill['repeat']) { case Application::FREQUENCY_DAILY: if ($bill['repeatfreq'] < 2) { return $billDate->add(new DateInterval('P1D')); } else { return $billDate->add(new DateInterval('P' . $bill['repeatfreq'] . 'D')); } break; case Application::FREQUENCY_WEEKLY: if ($bill['repeatfreq'] < 2) { return $billDate->add(new DateInterval('P7D')); } else { $nbDays = 7 * $bill['repeatfreq']; return $billDate->add(new DateInterval('P' . $nbDays . 'D')); } break; case Application::FREQUENCY_BI_WEEKLY: return $billDate->add(new DateInterval('P14D')); break; case Application::FREQUENCY_SEMI_MONTHLY: $day = (int) $billDate->format('d'); $month = (int) $billDate->format('m'); $year = (int) $billDate->format('Y'); // first of next month if ($day >= 15) { if ($month === 12) { $nextYear = $year + 1; $nextMonth = 1; return $billDate->setDate($nextYear, $nextMonth, 1); } else { $nextMonth = $month + 1; return $billDate->setDate($year, $nextMonth, 1); } } else { // 15 of same month return $billDate->setDate($year, $month, 15); } break; case Application::FREQUENCY_MONTHLY: $freq = ($bill['repeatfreq'] < 2) ? 1 : $bill['repeatfreq']; $billMonth = (int) $billDate->format('m'); $yearDelta = intdiv($billMonth + $freq - 1, 12); $nextYear = ((int) $billDate->format('Y')) + $yearDelta; $nextMonth = (($billMonth + $freq - 1) % 12) + 1; // same day of month if possible, otherwise at end of month $firstOfNextMonth = $billDate->setDate($nextYear, $nextMonth, 1); $billDay = (int) $billDate->format('d'); $nbDaysInTargetMonth = (int) $firstOfNextMonth->format('t'); if ($billDay > $nbDaysInTargetMonth) { return $billDate->setDate($nextYear, $nextMonth, $nbDaysInTargetMonth); } else { return $billDate->setDate($nextYear, $nextMonth, $billDay); } break; case Application::FREQUENCY_YEARLY: $freq = ($bill['repeatfreq'] < 2) ? 1 : $bill['repeatfreq']; $billYear = (int) $billDate->format('Y'); $billMonth = (int) $billDate->format('m'); $billDay = (int) $billDate->format('d'); $nextYear = $billYear + $freq; // same day of month if possible, otherwise at end of month + same month $firstDayOfTargetMonth = $billDate->setDate($nextYear, $billMonth, 1); $nbDaysInTargetMonth = (int) $firstDayOfTargetMonth->format('t'); if ($billDay > $nbDaysInTargetMonth) { return $billDate->setDate($nextYear, $billMonth, $nbDaysInTargetMonth); } else { return $billDate->setDate($nextYear, $billMonth, $billDay); } break; } return null; } /** * @param string $projectId * @param string $name * @param string|null $icon * @param string $color * @param int|null $order * @return int */ public function createPaymentMode(string $projectId, string $name, ?string $icon, string $color, ?int $order = 0): int { $qb = $this->db->getQueryBuilder(); $encIcon = $icon; if ($icon !== null && $icon !== '') { $encIcon = urlencode($icon); } $qb->insert('cospend_paymentmodes') ->values([ 'projectid' => $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR), 'encoded_icon' => $qb->createNamedParameter($encIcon, IQueryBuilder::PARAM_STR), 'color' => $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR), 'name' => $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR), 'order' => $qb->createNamedParameter(is_null($order) ? 0 : $order, IQueryBuilder::PARAM_INT) ]); $qb->executeStatement(); $qb = $qb->resetQueryParts(); return $qb->getLastInsertId(); } /** * @param string $projectId * @param int $pmId * @return array|null * @throws \OCP\DB\Exception */ public function getPaymentMode(string $projectId, int $pmId): ?array { $pm = null; $qb = $this->db->getQueryBuilder(); $qb->select('id', 'name', 'projectid', 'encoded_icon', 'color', 'old_id') ->from('cospend_paymentmodes', 'pm') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($pmId, IQueryBuilder::PARAM_INT)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $dbPmId = (int) $row['id']; $dbName = $row['name']; $dbIcon = $row['encoded_icon'] === null ? null : urldecode($row['encoded_icon']); $dbColor = $row['color']; $dbOldId = $row['old_id']; $pm = [ 'name' => $dbName, 'icon' => $dbIcon, 'color' => $dbColor, 'id' => $dbPmId, 'projectid' => $projectId, 'old_id' => $dbOldId, ]; break; } $req->closeCursor(); $qb->resetQueryParts(); return $pm; } /** * @param string $projectId * @param int $pmId * @return array|true[] * @throws \OCP\DB\Exception */ public function deletePaymentMode(string $projectId, int $pmId): array { $pmToDelete = $this->getPaymentMode($projectId, $pmId); if ($pmToDelete !== null) { $qb = $this->db->getQueryBuilder(); $qb->delete('cospend_paymentmodes') ->where( $qb->expr()->eq('id', $qb->createNamedParameter($pmId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); // then get rid of this pm in bills $qb = $this->db->getQueryBuilder(); $qb->update('cospend_bills'); $qb->set('paymentmodeid', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) ->where( $qb->expr()->eq('paymentmodeid', $qb->createNamedParameter($pmId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); return ['success' => true]; } else { return ['message' => $this->l10n->t('Not found')]; } } /** * @param string $projectId * @param array $order * @return bool */ public function savePaymentModeOrder(string $projectId, array $order): bool { $qb = $this->db->getQueryBuilder(); foreach ($order as $o) { $qb->update('cospend_paymentmodes'); $qb->set('order', $qb->createNamedParameter($o['order'], IQueryBuilder::PARAM_INT)); $qb->where( $qb->expr()->eq('id', $qb->createNamedParameter($o['id'], IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb = $qb->resetQueryParts(); } return true; } /** * @param string $projectId * @param int $pmId * @param string|null $name * @param string|null $icon * @param string|null $color * @return array */ public function editPaymentMode( string $projectId, int $pmId, ?string $name = null, ?string $icon = null, ?string $color = null ): array { if ($name !== null && $name !== '') { $encIcon = $icon; if ($icon !== null && $icon !== '') { $encIcon = urlencode($icon); } if ($this->getPaymentMode($projectId, $pmId) !== null) { $qb = $this->db->getQueryBuilder(); $qb->update('cospend_paymentmodes'); $qb->set('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR)); $qb->set('encoded_icon', $qb->createNamedParameter($encIcon, IQueryBuilder::PARAM_STR)); $qb->set('color', $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR)); $qb->where( $qb->expr()->eq('id', $qb->createNamedParameter($pmId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); $pm = $this->getPaymentMode($projectId, $pmId); if ($pm === null) { return ['message' => $this->l10n->t('Impossible to get the edited payment mode')]; } return $pm; } else { return ['message' => $this->l10n->t('This project has no such payment mode')]; } } else { return ['message' => $this->l10n->t('Incorrect field values')]; } } /** * Add a new category * * @param string $projectId * @param string $name * @param string|null $icon * @param string $color * @param int|null $order * @return int */ public function createCategory(string $projectId, string $name, ?string $icon, string $color, ?int $order = 0): int { $qb = $this->db->getQueryBuilder(); $encIcon = $icon; if ($icon !== null && $icon !== '') { $encIcon = urlencode($icon); } $qb->insert('cospend_categories') ->values([ 'projectid' => $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR), 'encoded_icon' => $qb->createNamedParameter($encIcon, IQueryBuilder::PARAM_STR), 'color' => $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR), 'name' => $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR), 'order' => $qb->createNamedParameter(is_null($order) ? 0 : $order, IQueryBuilder::PARAM_INT) ]); $qb->executeStatement(); $qb = $qb->resetQueryParts(); return $qb->getLastInsertId(); } /** * Get a category * * @param string $projectId * @param int $categoryId * @return array|null */ public function getCategory(string $projectId, int $categoryId): ?array { $category = null; $qb = $this->db->getQueryBuilder(); $qb->select('id', 'name', 'projectid', 'encoded_icon', 'color') ->from('cospend_categories') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $dbCategoryId = (int) $row['id']; $dbName = $row['name']; $dbIcon = $row['encoded_icon'] === null ? null : urldecode($row['encoded_icon']); $dbColor = $row['color']; $category = [ 'id' => $dbCategoryId, 'projectid' => $projectId, 'name' => $dbName, 'icon' => $dbIcon, 'color' => $dbColor, ]; break; } $req->closeCursor(); $qb->resetQueryParts(); return $category; } /** * Delete a category * * @param string $projectId * @param int $categoryId * @return array * @throws \OCP\DB\Exception */ public function deleteCategory(string $projectId, int $categoryId): array { $categoryToDelete = $this->getCategory($projectId, $categoryId); if ($categoryToDelete !== null) { $qb = $this->db->getQueryBuilder(); $qb->delete('cospend_categories') ->where( $qb->expr()->eq('id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); // then get rid of this category in bills $qb = $this->db->getQueryBuilder(); $qb->update('cospend_bills'); $qb->set('categoryid', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) ->where( $qb->expr()->eq('categoryid', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); return ['success' => true]; } else { return ['message' => $this->l10n->t('Not found')]; } } /** * Save the manual category order * * @param string $projectId * @param array $order * @return bool * @throws \OCP\DB\Exception */ public function saveCategoryOrder(string $projectId, array $order): bool { $qb = $this->db->getQueryBuilder(); foreach ($order as $o) { $qb->update('cospend_categories'); $qb->set('order', $qb->createNamedParameter($o['order'], IQueryBuilder::PARAM_INT)); $qb->where( $qb->expr()->eq('id', $qb->createNamedParameter($o['id'], IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb = $qb->resetQueryParts(); } return true; } /** * Edit a category * * @param string $projectId * @param int $categoryId * @param string|null $name * @param string|null $icon * @param string|null $color * @return array * @throws \OCP\DB\Exception */ public function editCategory( string $projectId, int $categoryId, ?string $name = null, ?string $icon = null, ?string $color = null ): array { if ($name !== null && $name !== '') { $encIcon = $icon; if ($icon !== null && $icon !== '') { $encIcon = urlencode($icon); } if ($this->getCategory($projectId, $categoryId) !== null) { $qb = $this->db->getQueryBuilder(); $qb->update('cospend_categories'); $qb->set('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR)); $qb->set('encoded_icon', $qb->createNamedParameter($encIcon, IQueryBuilder::PARAM_STR)); $qb->set('color', $qb->createNamedParameter($color, IQueryBuilder::PARAM_STR)); $qb->where( $qb->expr()->eq('id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); $category = $this->getCategory($projectId, $categoryId); if ($category === null) { return ['message' => $this->l10n->t('Impossible to get the edited category')]; } return $category; } else { return ['message' => $this->l10n->t('This project has no such category')]; } } else { return ['message' => $this->l10n->t('Incorrect field values')]; } } /** * Add a currency * * @param string $projectId * @param string $name * @param float $rate * @return int * @throws \OCP\DB\Exception */ public function createCurrency(string $projectId, string $name, float $rate): int { $qb = $this->db->getQueryBuilder(); $qb->insert('cospend_currencies') ->values([ 'projectid' => $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR), 'name' => $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR), 'exchange_rate' => $qb->createNamedParameter($rate, IQueryBuilder::PARAM_STR) ]); $qb->executeStatement(); $qb = $qb->resetQueryParts(); return $qb->getLastInsertId(); } /** * Get one currency * * @param string $projectId * @param int $currencyId * @return array|null * @throws \OCP\DB\Exception */ private function getCurrency(string $projectId, int $currencyId): ?array { $currency = null; $qb = $this->db->getQueryBuilder(); $qb->select('id', 'name', 'exchange_rate', 'projectid') ->from('cospend_currencies') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($currencyId, IQueryBuilder::PARAM_INT)) ); $req = $qb->executeQuery(); while ($row = $req->fetch()) { $dbCurrencyId = (int) $row['id']; $dbRate = (float) $row['exchange_rate']; $dbName = $row['name']; $currency = [ 'name' => $dbName, 'id' => $dbCurrencyId, 'exchange_rate' => $dbRate, 'projectid' => $projectId, ]; break; } $req->closeCursor(); $qb->resetQueryParts(); return $currency; } /** * Delete one currency * * @param string $projectId * @param int $currencyId * @return array * @throws \OCP\DB\Exception */ public function deleteCurrency(string $projectId, int $currencyId): array { $currencyToDelete = $this->getCurrency($projectId, $currencyId); if ($currencyToDelete !== null) { $qb = $this->db->getQueryBuilder(); $qb->delete('cospend_currencies') ->where( $qb->expr()->eq('id', $qb->createNamedParameter($currencyId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); return ['success' => true]; } else { return ['message' => $this->l10n->t('Not found')]; } } /** * Edit a currency * * @param string $projectId * @param int $currencyId * @param string $name * @param float $exchange_rate * @return array * @throws \OCP\DB\Exception */ public function editCurrency(string $projectId, int $currencyId, string $name, float $exchange_rate): array { if ($name !== '' && $exchange_rate !== 0.0) { if ($this->getCurrency($projectId, $currencyId) !== null) { $qb = $this->db->getQueryBuilder(); $qb->update('cospend_currencies'); $qb->set('exchange_rate', $qb->createNamedParameter($exchange_rate, IQueryBuilder::PARAM_STR)); $qb->set('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR)); $qb->where( $qb->expr()->eq('id', $qb->createNamedParameter($currencyId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); $currency = $this->getCurrency($projectId, $currencyId); if ($currency === null) { return ['message' => $this->l10n->t('Impossible to get the edited currency')]; } return $currency; } else { return ['message' => $this->l10n->t('This project have no such currency')]; } } else { return ['message' => $this->l10n->t('Incorrect field values')]; } } /** * Add a user shared access to a project * * @param string $projectId * @param string $userId * @param string $fromUserId * @param int $accesslevel * @param bool $manually_added * @return array * @throws \OCP\DB\Exception */ public function createUserShare( string $projectId, string $userId, string $fromUserId, int $accesslevel = Application::ACCESS_LEVEL_PARTICIPANT, bool $manually_added = true ): array { $user = $this->userManager->get($userId); if ($user !== null && $userId !== $fromUserId) { $userName = $user->getDisplayName(); $qb = $this->db->getQueryBuilder(); $projectInfo = $this->getProjectInfo($projectId); // check if someone tries to share the project with its owner if ($userId !== $projectInfo['userid']) { // check if user share exists $qb->select('userid', 'projectid') ->from('cospend_shares', 's') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_USER, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('userid', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); $dbuserId = null; while ($row = $req->fetch()) { $dbuserId = $row['userid']; break; } $req->closeCursor(); $qb = $qb->resetQueryParts(); if ($dbuserId === null) { if ($this->getUserMaxAccessLevel($fromUserId, $projectId) >= $accesslevel) { $qb->insert('cospend_shares') ->values([ 'projectid' => $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR), 'userid' => $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR), 'type' => $qb->createNamedParameter(Application::SHARE_TYPE_USER, IQueryBuilder::PARAM_STR), 'accesslevel' => $qb->createNamedParameter($accesslevel, IQueryBuilder::PARAM_INT), 'manually_added' => $qb->createNamedParameter($manually_added ? 1 : 0, IQueryBuilder::PARAM_INT), ]); $qb->executeStatement(); $qb = $qb->resetQueryParts(); $insertedShareId = $qb->getLastInsertId(); $response = [ 'id' => $insertedShareId, 'name' => $userName, 'accesslevel' => $accesslevel, 'manually_added' => $manually_added, 'userid' => $userId, 'type' => Application::SHARE_TYPE_USER, ]; // activity $projectObj = $this->projectMapper->find($projectId); $this->activityManager->triggerEvent( ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj, ActivityManager::SUBJECT_PROJECT_SHARE, ['who' => $userId, 'type' => Application::SHARE_TYPE_USER] ); // SEND NOTIFICATION $manager = $this->notificationManager; $notification = $manager->createNotification(); $acceptAction = $notification->createAction(); $acceptAction->setLabel('accept') ->setLink('/apps/cospend', 'GET'); $declineAction = $notification->createAction(); $declineAction->setLabel('decline') ->setLink('/apps/cospend', 'GET'); $notification->setApp('cospend') ->setUser($userId) ->setDateTime(new DateTime()) ->setObject('addusershare', $projectId) ->setSubject('add_user_share', [$fromUserId, $projectInfo['name']]) ->addAction($acceptAction) ->addAction($declineAction); $manager->notify($notification); return $response; } else { return ['message' => $this->l10n->t('You are not authorized to give such access level')]; } } else { return ['message' => $this->l10n->t('Already shared with this user')]; } } else { return ['message' => $this->l10n->t('Impossible to share the project with its owner')]; } } else { return ['message' => $this->l10n->t('No such user')]; } } /** * Add public share access (public link with token) * * @param string $projectId * @param string|null $label * @param string|null $password * @param int $accesslevel * @return array * @throws \OCP\DB\Exception */ public function createPublicShare( string $projectId, ?string $label = null, ?string $password = null, int $accesslevel = Application::ACCESS_LEVEL_PARTICIPANT ): array { $qb = $this->db->getQueryBuilder(); // generate token $token = md5($projectId.rand()); $qb->insert('cospend_shares') ->values([ 'projectid' => $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR), 'userid' => $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR), 'type' => $qb->createNamedParameter(Application::SHARE_TYPE_PUBLIC_LINK, IQueryBuilder::PARAM_STR), 'accesslevel' => $qb->createNamedParameter($accesslevel, IQueryBuilder::PARAM_INT), 'label' => $qb->createNamedParameter($label, IQueryBuilder::PARAM_STR), 'password' => $qb->createNamedParameter($password, IQueryBuilder::PARAM_STR), ]); $qb->executeStatement(); $qb = $qb->resetQueryParts(); $insertedShareId = $qb->getLastInsertId(); //// activity //$projectObj = $this->projectMapper->find($projectid); //$this->activityManager->triggerEvent( // ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj, // ActivityManager::SUBJECT_PROJECT_SHARE, // ['who' => $userid, 'type' => 'u'] //); //// SEND NOTIFICATION //$projectInfo = $this->getProjectInfo($projectid); //$manager = $this->notificationManager; //$notification = $manager->createNotification(); //$acceptAction = $notification->createAction(); //$acceptAction->setLabel('accept') // ->setLink('/apps/cospend', 'GET'); //$declineAction = $notification->createAction(); //$declineAction->setLabel('decline') // ->setLink('/apps/cospend', 'GET'); //$notification->setApp('cospend') // ->setUser($userid) // ->setDateTime(new DateTime()) // ->setObject('addusershare', $projectid) // ->setSubject('add_user_share', [$fromUserId, $projectInfo['name']]) // ->addAction($acceptAction) // ->addAction($declineAction) // ; //$manager->notify($notification); return [ 'token' => $token, 'id' => $insertedShareId, 'accesslevel' => $accesslevel, 'label' => $label, 'password' => $password, 'type' => Application::SHARE_TYPE_PUBLIC_LINK, ]; } /** * Change shared access permissions * * @param string $projectId * @param int $shId * @param int $accessLevel * @return array * @throws \OCP\DB\Exception */ public function editShareAccessLevel(string $projectId, int $shId, int $accessLevel): array { // check if user share exists $qb = $this->db->getQueryBuilder(); $qb->select('id', 'projectid') ->from('cospend_shares', 's') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ); $req = $qb->executeQuery(); $dbId = null; while ($row = $req->fetch()) { $dbId = $row['id']; break; } $req->closeCursor(); $qb = $qb->resetQueryParts(); if ($dbId !== null) { // set the accesslevel $qb->update('cospend_shares') ->set('accesslevel', $qb->createNamedParameter($accessLevel, IQueryBuilder::PARAM_INT)) ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ); $qb->executeStatement(); $qb->resetQueryParts(); return ['success' => true]; } else { return ['message' => $this->l10n->t('No such share')]; } } /** * Change shared access permissions * * @param string $projectId * @param int $shId * @param string|null $label * @param string|null $password * @return array * @throws \OCP\DB\Exception */ public function editShareAccess(string $projectId, int $shId, ?string $label = null, ?string $password = null): array { // check if user share exists $qb = $this->db->getQueryBuilder(); $qb->select('id', 'projectid') ->from('cospend_shares', 's') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ); $req = $qb->executeQuery(); $dbId = null; while ($row = $req->fetch()) { $dbId = $row['id']; break; } $req->closeCursor(); $qb = $qb->resetQueryParts(); if (!is_null($dbId) && (!is_null($label) || !is_null($password))) { $qb->update('cospend_shares'); if (!is_null($label)) { if ($label === '') { $label = null; } $qb->set('label', $qb->createNamedParameter($label, IQueryBuilder::PARAM_STR)); } if (!is_null($password)) { if ($password === '') { $password = null; } $qb->set('password', $qb->createNamedParameter($password, IQueryBuilder::PARAM_STR)); } $qb->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ); $qb->executeStatement(); $qb->resetQueryParts(); return ['success' => true]; } else { return ['message' => $this->l10n->t('No such share')]; } } /** * Delete user shared access * * @param string $projectId * @param int $shId * @param string|null $fromUserId * @return array * @throws \OCP\DB\Exception */ public function deleteUserShare(string $projectId, int $shId, ?string $fromUserId = null): array { // check if user share exists $qb = $this->db->getQueryBuilder(); $qb->select('id', 'userid', 'projectid') ->from('cospend_shares', 's') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_USER, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ); $req = $qb->executeQuery(); $dbId = null; $dbUserId = null; while ($row = $req->fetch()) { $dbId = $row['id']; $dbUserId = $row['userid']; break; } $req->closeCursor(); $qb = $qb->resetQueryParts(); if ($dbId !== null) { // delete $qb->delete('cospend_shares') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_USER, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); // activity $projectObj = $this->projectMapper->find($projectId); $this->activityManager->triggerEvent( ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj, ActivityManager::SUBJECT_PROJECT_UNSHARE, ['who' => $dbUserId, 'type' => Application::SHARE_TYPE_USER] ); // SEND NOTIFICATION if (!is_null($fromUserId)) { $projectInfo = $this->getProjectInfo($projectId); $manager = $this->notificationManager; $notification = $manager->createNotification(); $acceptAction = $notification->createAction(); $acceptAction->setLabel('accept') ->setLink('/apps/cospend', 'GET'); $declineAction = $notification->createAction(); $declineAction->setLabel('decline') ->setLink('/apps/cospend', 'GET'); $notification->setApp('cospend') ->setUser($dbUserId) ->setDateTime(new DateTime()) ->setObject('deleteusershare', $projectId) ->setSubject('delete_user_share', [$fromUserId, $projectInfo['name']]) ->addAction($acceptAction) ->addAction($declineAction) ; $manager->notify($notification); } return ['success' => true]; } else { return ['message' => $this->l10n->t('No such share')]; } } /** * Delete public shared access * * @param string $projectId * @param int $shId * @return array * @throws \OCP\DB\Exception */ public function deletePublicShare(string $projectId, int $shId): array { // check if public share exists $qb = $this->db->getQueryBuilder(); $qb->select('id', 'userid', 'projectid') ->from('cospend_shares', 's') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_PUBLIC_LINK, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ); $req = $qb->executeQuery(); $dbId = null; while ($row = $req->fetch()) { $dbId = $row['id']; break; } $req->closeCursor(); $qb = $qb->resetQueryParts(); if ($dbId !== null) { // delete $qb->delete('cospend_shares') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_PUBLIC_LINK, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); //// activity //$projectObj = $this->projectMapper->find($projectid); //$this->activityManager->triggerEvent( // ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj, // ActivityManager::SUBJECT_PROJECT_UNSHARE, // ['who' => $dbuserId, 'type' => 'u'] //); //// SEND NOTIFICATION //$projectInfo = $this->getProjectInfo($projectid); //$manager = $this->notificationManager; //$notification = $manager->createNotification(); //$acceptAction = $notification->createAction(); //$acceptAction->setLabel('accept') // ->setLink('/apps/cospend', 'GET'); //$declineAction = $notification->createAction(); //$declineAction->setLabel('decline') // ->setLink('/apps/cospend', 'GET'); //$notification->setApp('cospend') // ->setUser($dbuserId) // ->setDateTime(new DateTime()) // ->setObject('deleteusershare', $projectid) // ->setSubject('delete_user_share', [$fromUserId, $projectInfo['name']]) // ->addAction($acceptAction) // ->addAction($declineAction) // ; //$manager->notify($notification); return ['success' => true]; } else { return ['message' => $this->l10n->t('No such shared access')]; } } /** * Add group shared access * * @param string $projectId * @param string $groupId * @param string|null $fromUserId * @param int $accesslevel * @return array * @throws \OCP\DB\Exception */ public function createGroupShare( string $projectId, string $groupId, ?string $fromUserId = null, int $accesslevel = Application::ACCESS_LEVEL_PARTICIPANT ): array { if ($this->groupManager->groupExists($groupId)) { $groupName = $this->groupManager->get($groupId)->getDisplayName(); $qb = $this->db->getQueryBuilder(); // check if user share exists $qb->select('userid', 'projectid') ->from('cospend_shares', 's') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_GROUP, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('userid', $qb->createNamedParameter($groupId, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); $dbGroupId = null; while ($row = $req->fetch()) { $dbGroupId = $row['userid']; break; } $req->closeCursor(); $qb = $qb->resetQueryParts(); if ($dbGroupId === null) { $qb->insert('cospend_shares') ->values([ 'projectid' => $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR), 'userid' => $qb->createNamedParameter($groupId, IQueryBuilder::PARAM_STR), 'type' => $qb->createNamedParameter(Application::SHARE_TYPE_GROUP, IQueryBuilder::PARAM_STR), 'accesslevel' => $qb->createNamedParameter($accesslevel, IQueryBuilder::PARAM_INT), ]); $qb->executeStatement(); $qb = $qb->resetQueryParts(); $insertedShareId = $qb->getLastInsertId(); // activity $projectObj = $this->projectMapper->find($projectId); $this->activityManager->triggerEvent( ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj, ActivityManager::SUBJECT_PROJECT_SHARE, ['who' => $groupId, 'type' => Application::SHARE_TYPE_GROUP] ); return [ 'id' => $insertedShareId, 'name' => $groupName, 'groupid' => $groupId, 'accesslevel' => $accesslevel, 'type' => Application::SHARE_TYPE_GROUP, ]; } else { return ['message' => $this->l10n->t('Already shared with this group')]; } } else { return ['message' => $this->l10n->t('No such group')]; } } /** * Delete group shared access * * @param string $projectId * @param int $shId * @param string|null $fromUserId * @return array * @throws \OCP\DB\Exception */ public function deleteGroupShare(string $projectId, int $shId, ?string $fromUserId = null): array { // check if group share exists $qb = $this->db->getQueryBuilder(); $qb->select('userid', 'projectid', 'id') ->from('cospend_shares', 's') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_GROUP, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ); $req = $qb->executeQuery(); $dbGroupId = null; while ($row = $req->fetch()) { $dbGroupId = $row['userid']; break; } $req->closeCursor(); $qb = $qb->resetQueryParts(); if ($dbGroupId !== null) { // delete $qb->delete('cospend_shares') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_GROUP, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); // activity $projectObj = $this->projectMapper->find($projectId); $this->activityManager->triggerEvent( ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj, ActivityManager::SUBJECT_PROJECT_UNSHARE, ['who' => $dbGroupId, 'type' => Application::SHARE_TYPE_GROUP] ); return ['success' => true]; } else { return ['message' => $this->l10n->t('No such share')]; } } /** * Add circle shared access * * @param string $projectId * @param string $circleId * @param string|null $fromUserId * @param int $accesslevel * @return array * @throws InitiatorNotFoundException * @throws RequestBuilderException * @throws \OCP\DB\Exception */ public function createCircleShare(string $projectId, string $circleId, ?string $fromUserId = null, int $accesslevel = Application::ACCESS_LEVEL_PARTICIPANT): array { // check if circleId exists $circlesEnabled = $this->appManager->isEnabledForUser('circles'); if ($circlesEnabled) { try { $circlesManager = \OC::$server->get(\OCA\Circles\CirclesManager::class); $circlesManager->startSuperSession(); } catch (Exception $e) { return ['message' => $this->l10n->t('Impossible to get the circle manager')]; } $exists = true; $circleName = ''; try { $circle = $circlesManager->getCircle($circleId); $circleName = $circle->getDisplayName(); } catch (\OCA\Circles\Exceptions\CircleNotFoundException $e) { $exists = false; } if ($circleId !== '' && $exists) { $qb = $this->db->getQueryBuilder(); // check if circle share exists $qb->select('userid', 'projectid') ->from('cospend_shares', 's') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_CIRCLE, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('userid', $qb->createNamedParameter($circleId, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); $dbCircleId = null; while ($row = $req->fetch()) { $dbCircleId = $row['userid']; break; } $req->closeCursor(); $qb = $qb->resetQueryParts(); if ($dbCircleId === null) { $qb->insert('cospend_shares') ->values([ 'projectid' => $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR), 'userid' => $qb->createNamedParameter($circleId, IQueryBuilder::PARAM_STR), 'type' => $qb->createNamedParameter(Application::SHARE_TYPE_CIRCLE, IQueryBuilder::PARAM_STR), 'accesslevel' => $qb->createNamedParameter($accesslevel, IQueryBuilder::PARAM_INT), ]); $qb->executeStatement(); $qb = $qb->resetQueryParts(); $insertedShareId = $qb->getLastInsertId(); // activity $projectObj = $this->projectMapper->find($projectId); $this->activityManager->triggerEvent( ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj, ActivityManager::SUBJECT_PROJECT_SHARE, ['who' => $circleId, 'type' => Application::SHARE_TYPE_CIRCLE] ); $circlesManager->stopSession(); return [ 'id' => $insertedShareId, 'name' => $circleName, 'circleid' => $circleId, 'accesslevel' => $accesslevel, 'type' => Application::SHARE_TYPE_CIRCLE, ]; } else { $circlesManager->stopSession(); return ['message' => $this->l10n->t('Already shared with this circle')]; } } else { $circlesManager->stopSession(); return ['message' => $this->l10n->t('No such circle')]; } } else { return ['message' => $this->l10n->t('Circles app is not enabled')]; } } /** * Delete circle shared access * * @param string $projectId * @param int $shId * @param string|null $fromUserId * @return array * @throws \OCP\DB\Exception */ public function deleteCircleShare(string $projectId, int $shId, ?string $fromUserId = null): array { // check if circle share exists $qb = $this->db->getQueryBuilder(); $qb->select('userid', 'projectid', 'id') ->from('cospend_shares', 's') ->where( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_CIRCLE, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ); $req = $qb->executeQuery(); $dbCircleId = null; while ($row = $req->fetch()) { $dbCircleId = $row['userid']; break; } $req->closeCursor(); $qb = $qb->resetQueryParts(); if ($dbCircleId !== null) { // delete $qb->delete('cospend_shares') ->where( $qb->expr()->eq('projectid', $qb->createNamedParameter($projectId, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->eq('id', $qb->createNamedParameter($shId, IQueryBuilder::PARAM_INT)) ) ->andWhere( $qb->expr()->eq('type', $qb->createNamedParameter(Application::SHARE_TYPE_CIRCLE, IQueryBuilder::PARAM_STR)) ); $qb->executeStatement(); $qb->resetQueryParts(); // activity $projectObj = $this->projectMapper->find($projectId); $this->activityManager->triggerEvent( ActivityManager::COSPEND_OBJECT_PROJECT, $projectObj, ActivityManager::SUBJECT_PROJECT_UNSHARE, ['who' => $dbCircleId, 'type' => Application::SHARE_TYPE_CIRCLE] ); $response = ['success' => true]; } else { $response = ['message' => $this->l10n->t('No such share')]; } return $response; } /** * Export settlement plan in CSV * * @param string $projectId * @param string $userId * @param int|null $centeredOn * @param int|null $maxTimestamp * @return array * @throws NotFoundException * @throws NotPermittedException * @throws NoUserException */ public function exportCsvSettlement(string $projectId, string $userId, ?int $centeredOn = null, ?int $maxTimestamp = null): array { // create export directory if needed $outPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend'); $userFolder = $this->root->getUserFolder($userId); $msg = $this->createAndCheckExportDirectory($userFolder, $outPath); if ($msg !== '') { return ['message' => $msg]; } $folder = $userFolder->get($outPath); if (!$folder instanceof Folder) { return ['message' => $outPath . ' is not a directory']; } // create file if ($folder->nodeExists($projectId.'-settlement.csv')) { $folder->get($projectId.'-settlement.csv')->delete(); } $file = $folder->newFile($projectId.'-settlement.csv'); $handler = $file->fopen('w'); fwrite( $handler, '"' . $this->l10n->t('Who pays?') . '","' . $this->l10n->t('To whom?') . '","' . $this->l10n->t('How much?') . '"' . "\n" ); $settlement = $this->getProjectSettlement($projectId, $centeredOn, $maxTimestamp); $transactions = $settlement['transactions']; $members = $this->getMembers($projectId); $memberIdToName = []; foreach ($members as $member) { $memberIdToName[$member['id']] = $member['name']; } foreach ($transactions as $transaction) { fwrite( $handler, '"' . $memberIdToName[$transaction['from']] . '","' . $memberIdToName[$transaction['to']] . '",' . (float) $transaction['amount'] . "\n" ); } fclose($handler); $file->touch(); return ['path' => $outPath . '/' . $projectId . '-settlement.csv']; } /** * Create directory where things will be exported * * @param Folder $userFolder * @param string $outPath * @return string * @throws NotFoundException * @throws NotPermittedException */ private function createAndCheckExportDirectory(Folder $userFolder, string $outPath): string { if (!$userFolder->nodeExists($outPath)) { $userFolder->newFolder($outPath); } if ($userFolder->nodeExists($outPath)) { $folder = $userFolder->get($outPath); if (!$folder instanceof Folder) { return $this->l10n->t('%1$s is not a folder', [$outPath]); } elseif (!$folder->isCreatable()) { return $this->l10n->t('%1$s is not writeable', [$outPath]); } else { return ''; } } else { return $this->l10n->t('Impossible to create %1$s', [$outPath]); } } /** * @param string $projectId * @param string $userId * @param int|null $tsMin * @param int|null $tsMax * @param int|null $paymentModeId * @param int|null $category * @param float|null $amountMin * @param float|null $amountMax * @param bool $showDisabled * @param int|null $currencyId * @return array * @throws \OCP\DB\Exception * @throws \OCP\Files\NotFoundException * @throws \OCP\Files\NotPermittedException * @throws \OC\User\NoUserException */ public function exportCsvStatistics( string $projectId, string $userId, ?int $tsMin = null, ?int $tsMax = null, ?int $paymentModeId = null, ?int $category = null, ?float $amountMin = null, ?float $amountMax = null, bool $showDisabled = true, ?int $currencyId = null ): array { // create export directory if needed $outPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend'); $userFolder = $this->root->getUserFolder($userId); $msg = $this->createAndCheckExportDirectory($userFolder, $outPath); if ($msg !== '') { return ['message' => $msg]; } $folder = $userFolder->get($outPath); if (!$folder instanceof Folder) { return ['message' => $outPath . ' is not a directory']; } // create file if ($folder->nodeExists($projectId.'-stats.csv')) { $folder->get($projectId.'-stats.csv')->delete(); } $file = $folder->newFile($projectId.'-stats.csv'); $handler = $file->fopen('w'); fwrite( $handler, $this->l10n->t('Member name') . ',' . $this->l10n->t('Paid') . ',' . $this->l10n->t('Spent') . ',' . $this->l10n->t('Balance') . "\n" ); $allStats = $this->getProjectStatistics( $projectId, 'lowername', $tsMin, $tsMax, $paymentModeId, $category, $amountMin, $amountMax, $showDisabled, $currencyId ); $stats = $allStats['stats']; foreach ($stats as $stat) { fwrite( $handler, '"' . $stat['member']['name'] . '",' . (float) $stat['paid'] . ',' . (float) $stat['spent'] . ',' . (float) $stat['balance'] . "\n" ); } fclose($handler); $file->touch(); return ['path' => $outPath . '/' . $projectId . '-stats.csv']; } /** * Export project in CSV * * @param string $projectId * @param string|null $name * @param string $userId * @return array * @throws \OCP\Files\NotFoundException * @throws \OCP\Files\NotPermittedException * @throws \OC\User\NoUserException */ public function exportCsvProject(string $projectId, string $userId, ?string $name = null): array { // create export directory if needed $outPath = $this->config->getUserValue($userId, 'cospend', 'outputDirectory', '/Cospend'); $userFolder = $this->root->getUserFolder($userId); $msg = $this->createAndCheckExportDirectory($userFolder, $outPath); if ($msg !== '') { return ['message' => $msg]; } $folder = $userFolder->get($outPath); if (!$folder instanceof Folder) { return ['message' => $outPath . ' is not a directory']; } // create file $filename = $projectId.'.csv'; if ($name !== null) { $filename = $name; if (!str_ends_with($filename, '.csv')) { $filename .= '.csv'; } } if ($folder->nodeExists($filename)) { $folder->get($filename)->delete(); } $file = $folder->newFile($filename); $handler = $file->fopen('w'); foreach ($this->getJsonProject($projectId) as $chunk) { fwrite($handler, $chunk); } fclose($handler); $file->touch(); return ['path' => $outPath . '/' . $filename]; } /** * @param string $projectId * @return Generator * @throws \OCP\DB\Exception */ public function getJsonProject(string $projectId): Generator { // members yield "name,weight,active,color\n"; $projectInfo = $this->getProjectInfo($projectId); $members = $projectInfo['members']; $memberIdToName = []; $memberIdToWeight = []; $memberIdToActive = []; foreach ($members as $member) { $memberIdToName[$member['id']] = $member['name']; $memberIdToWeight[$member['id']] = $member['weight']; $memberIdToActive[$member['id']] = (int) $member['activated']; $c = $member['color']; yield '"' . $member['name'] . '",' . (float) $member['weight'] . ',' . (int) $member['activated'] . ',"' . sprintf("#%02x%02x%02x", $c['r'] ?? 0, $c['g'] ?? 0, $c['b'] ?? 0) . '"' . "\n"; } // bills yield "\nwhat,amount,date,timestamp,payer_name,payer_weight,payer_active,owers,repeat,repeatfreq,repeatallactive,repeatuntil,categoryid,paymentmode,paymentmodeid,comment,deleted\n"; $bills = $this->billMapper->getBills( $projectId, null, null, null, null, null, null, null, null, null, false, null, null ); foreach ($bills as $bill) { $owerNames = []; foreach ($bill['owers'] as $ower) { $owerNames[] = $ower['name']; } $owersTxt = implode(',', $owerNames); $payer_id = $bill['payer_id']; $payer_name = $memberIdToName[$payer_id]; $payer_weight = $memberIdToWeight[$payer_id]; $payer_active = $memberIdToActive[$payer_id]; $dateTime = DateTime::createFromFormat('U', $bill['timestamp']); $oldDateStr = $dateTime->format('Y-m-d'); yield '"' . $bill['what'] . '",' . (float) $bill['amount'] . ',' . $oldDateStr . ',' . $bill['timestamp'] . ',"' . $payer_name . '",' . (float) $payer_weight . ',' . $payer_active . ',"' . $owersTxt . '",' . $bill['repeat'] . ',' . $bill['repeatfreq'] . ',' . $bill['repeatallactive'] .',' . $bill['repeatuntil'] . ',' . $bill['categoryid'] . ',' . $bill['paymentmode'] . ',' . $bill['paymentmodeid'] . ',"' . urlencode($bill['comment']) . '",' . $bill['deleted'] . "\n"; } // write categories $categories = $projectInfo['categories']; if (count($categories) > 0) { yield "\ncategoryname,categoryid,icon,color\n"; foreach ($categories as $id => $cat) { yield '"' . $cat['name'] . '",' . (int) $id . ',"' . $cat['icon'] . '","' . $cat['color'] . '"' . "\n"; } } // write payment modes $paymentModes = $projectInfo['paymentmodes']; if (count($paymentModes) > 0) { yield "\npaymentmodename,paymentmodeid,icon,color\n"; foreach ($paymentModes as $id => $pm) { yield '"' . $pm['name'] . '",' . (int) $id . ',"' . $pm['icon'] . '","' . $pm['color'] . '"' . "\n"; } } // write currencies $currencies = $projectInfo['currencies']; if (count($currencies) > 0) { yield "\ncurrencyname,exchange_rate\n"; // main currency yield '"' . $projectInfo['currencyname'] . '",1' . "\n"; foreach ($currencies as $cur) { yield '"' . $cur['name'] . '",' . (float) $cur['exchange_rate'] . "\n"; } } return []; } /** * Wrap the import process in an atomic DB transaction * This increases insert performance a lot * * importCsvProject() still takes care of cleaning up created entities in case of error * but this could be done by rollBack * * This could be done with TTransactional::atomic() when we drop support for NC < 24 * * @param $handle * @param string $userId * @param string $projectName * @return array * @throws Throwable * @throws \OCP\DB\Exception */ public function importCsvProjectAtomicWrapper($handle, string $userId, string $projectName): array { $this->db->beginTransaction(); try { $result = $this->importCsvProjectStream($handle, $userId, $projectName); $this->db->commit(); return $result; } catch (Throwable $e) { $this->db->rollBack(); throw $e; } } /** * Import CSV project file * * @param string $path * @param string $userId * @return array * @throws NoUserException * @throws NotFoundException * @throws NotPermittedException * @throws Throwable * @throws \OCP\DB\Exception */ public function importCsvProject(string $path, string $userId): array { $cleanPath = str_replace(['../', '..\\'], '', $path); $userFolder = $this->root->getUserFolder($userId); if ($userFolder->nodeExists($cleanPath)) { $file = $userFolder->get($cleanPath); if ($file instanceof File) { if (($handle = $file->fopen('r')) !== false) { $projectName = preg_replace('/\.csv$/', '', $file->getName()); return $this->importCsvProjectAtomicWrapper($handle, $userId, $projectName); } else { return ['message' => $this->l10n->t('Access denied')]; } } else { return ['message' => $this->l10n->t('Access denied')]; } } else { return ['message' => $this->l10n->t('Access denied')]; } } /** * @param $handle * @param string $userId * @param string $projectName * @return array * @throws \OCP\DB\Exception */ public function importCsvProjectStream($handle, string $userId, string $projectName): array { $columns = []; $membersByName = []; $bills = []; $currencies = []; $mainCurrencyName = null; $categories = []; $categoryIdConv = []; $paymentModes = []; $paymentModeIdConv = []; $previousLineEmpty = false; $currentSection = null; $row = 0; while (($data = fgetcsv($handle, 0, ',')) !== false) { $uni = array_unique($data); if ($data === [null] || (count($uni) === 1 && $uni[0] === '')) { $previousLineEmpty = true; } elseif ($row === 0 || $previousLineEmpty) { // determine which section we're entering $previousLineEmpty = false; $nbCol = count($data); $columns = []; for ($c = 0; $c < $nbCol; $c++) { if ($data[$c] !== '') { $columns[$data[$c]] = $c; } } if (array_key_exists('what', $columns) && array_key_exists('amount', $columns) && (array_key_exists('date', $columns) || array_key_exists('timestamp', $columns)) && array_key_exists('payer_name', $columns) && array_key_exists('payer_weight', $columns) && array_key_exists('owers', $columns) ) { $currentSection = 'bills'; } elseif (array_key_exists('name', $columns) && array_key_exists('weight', $columns) && array_key_exists('active', $columns) && array_key_exists('color', $columns) ) { $currentSection = 'members'; } elseif (array_key_exists('icon', $columns) && array_key_exists('color', $columns) && array_key_exists('paymentmodeid', $columns) && array_key_exists('paymentmodename', $columns) ) { $currentSection = 'paymentmodes'; } elseif (array_key_exists('icon', $columns) && array_key_exists('color', $columns) && array_key_exists('categoryid', $columns) && array_key_exists('categoryname', $columns) ) { $currentSection = 'categories'; } elseif (array_key_exists('exchange_rate', $columns) && array_key_exists('currencyname', $columns) ) { $currentSection = 'currencies'; } else { fclose($handle); return ['message' => $this->l10n->t('Malformed CSV, bad column names at line %1$s', [$row + 1])]; } } else { // normal line: bill/category/payment mode/currency $previousLineEmpty = false; if ($currentSection === 'categories') { if (mb_strlen($data[$columns['icon']], 'UTF-8') && preg_match('!\S!u', $data[$columns['icon']])) { $icon = $data[$columns['icon']]; } else { $icon = null; } $color = $data[$columns['color']]; $categoryname = $data[$columns['categoryname']]; if (!is_numeric($data[$columns['categoryid']])) { fclose($handle); return ['message' => $this->l10n->t('Error when adding category %1$s', [$categoryname])]; } $categoryid = (int) $data[$columns['categoryid']]; $categories[] = [ 'icon' => $icon, 'color' => $color, 'id' => $categoryid, 'name' => $categoryname, ]; } elseif ($currentSection === 'paymentmodes') { if (mb_strlen($data[$columns['icon']], 'UTF-8') && preg_match('!\S!u', $data[$columns['icon']])) { $icon = $data[$columns['icon']]; } else { $icon = null; } $paymentmodename = $data[$columns['paymentmodename']]; if (!is_numeric($data[$columns['paymentmodeid']])) { fclose($handle); return ['message' => $this->l10n->t('Error when adding payment mode %1$s', [$paymentmodename])]; } $color = $data[$columns['color']]; $paymentmodeid = (int) $data[$columns['paymentmodeid']]; $paymentModes[] = [ 'icon' => $icon, 'color' => $color, 'id' => $paymentmodeid, 'name' => $paymentmodename, ]; } elseif ($currentSection === 'currencies') { $name = $data[$columns['currencyname']]; if (!is_numeric($data[$columns['exchange_rate']])) { fclose($handle); return ['message' => $this->l10n->t('Error when adding currency %1$s', [$name])]; } $exchange_rate = (float) $data[$columns['exchange_rate']]; if (($exchange_rate) === 1.0) { $mainCurrencyName = $name; } else { $currencies[] = [ 'name' => $name, 'exchange_rate' => $exchange_rate, ]; } } elseif ($currentSection === 'members') { $name = trim($data[$columns['name']]); if (!is_numeric($data[$columns['weight']]) || !is_numeric($data[$columns['active']])) { fclose($handle); return ['message' => $this->l10n->t('Error when adding member %1$s', [$name])]; } $weight = (float) $data[$columns['weight']]; $active = (int) $data[$columns['active']]; $color = $data[$columns['color']]; if (strlen($name) > 0 && preg_match('/^#[0-9A-Fa-f]+$/', $color) !== false ) { $membersByName[$name] = [ 'weight' => $weight, 'active' => $active !== 0, 'color' => $color, ]; } else { fclose($handle); return ['message' => $this->l10n->t('Malformed CSV, invalid member on line %1$s', [$row + 1])]; } } elseif ($currentSection === 'bills') { $what = $data[$columns['what']]; if (!is_numeric($data[$columns['amount']])) { fclose($handle); return ['message' => $this->l10n->t('Malformed CSV, invalid amount on line %1$s', [$row + 1])]; } $amount = (float) $data[$columns['amount']]; $timestamp = null; // priority to timestamp if (array_key_exists('timestamp', $columns)) { $timestamp = (int) $data[$columns['timestamp']]; } elseif (array_key_exists('date', $columns)) { $date = $data[$columns['date']]; $datetime = DateTime::createFromFormat('Y-m-d', $date); if ($datetime !== false) { $timestamp = $datetime->getTimestamp(); } } if ($timestamp === null) { fclose($handle); return ['message' => $this->l10n->t('Malformed CSV, missing or invalid date/timestamp on line %1$s', [$row + 1])]; } $payer_name = $data[$columns['payer_name']]; $payer_weight = $data[$columns['payer_weight']]; $owers = $data[$columns['owers']]; $payer_active = array_key_exists('payer_active', $columns) ? $data[$columns['payer_active']] : 1; $repeat = array_key_exists('repeat', $columns) ? $data[$columns['repeat']] : Application::FREQUENCY_NO; $categoryid = array_key_exists('categoryid', $columns) ? (int) $data[$columns['categoryid']] : null; $paymentmode = array_key_exists('paymentmode', $columns) ? $data[$columns['paymentmode']] : null; $paymentmodeid = array_key_exists('paymentmodeid', $columns) ? (int) $data[$columns['paymentmodeid']] : null; $repeatallactive = array_key_exists('repeatallactive', $columns) ? (int) $data[$columns['repeatallactive']] : 0; $repeatuntil = array_key_exists('repeatuntil', $columns) ? $data[$columns['repeatuntil']] : null; $repeatfreq = array_key_exists('repeatfreq', $columns) ? (int) $data[$columns['repeatfreq']] : 1; $comment = array_key_exists('comment', $columns) ? urldecode($data[$columns['comment']] ?? '') : null; $deleted = array_key_exists('deleted', $columns) ? (int) $data[$columns['deleted']] : 0; // manage members if (!isset($membersByName[$payer_name])) { $membersByName[$payer_name] = [ 'active' => ((int) $payer_active) !== 0, 'weight' => 1.0, 'color' => null, ]; if (is_numeric($payer_weight)) { $membersByName[$payer_name]['weight'] = (float) $payer_weight; } else { fclose($handle); return ['message' => $this->l10n->t('Malformed CSV, invalid payer weight on line %1$s', [$row + 1])]; } } if (strlen($owers) === 0) { fclose($handle); return ['message' => $this->l10n->t('Malformed CSV, invalid owers on line %1$s', [$row + 1])]; } if ($what !== 'deleteMeIfYouWant') { $owersArray = explode(',', $owers); foreach ($owersArray as $ower) { $strippedOwer = trim($ower); if (strlen($strippedOwer) === 0) { fclose($handle); return ['message' => $this->l10n->t('Malformed CSV, invalid owers on line %1$s', [$row + 1])]; } if (!isset($membersByName[$strippedOwer])) { $membersByName[$strippedOwer]['weight'] = 1.0; $membersByName[$strippedOwer]['active'] = true; $membersByName[$strippedOwer]['color'] = null; } } $bills[] = [ 'what' => $what, 'comment' => $comment, 'timestamp' => $timestamp, 'amount' => $amount, 'payer_name' => $payer_name, 'owers' => $owersArray, 'paymentmode' => $paymentmode, 'paymentmodeid' => $paymentmodeid, 'categoryid' => $categoryid, 'repeat' => $repeat, 'repeatuntil' => $repeatuntil, 'repeatallactive' => $repeatallactive, 'repeatfreq' => $repeatfreq, 'deleted' => $deleted, ]; } } } $row++; } fclose($handle); $memberNameToId = []; // add project $user = $this->userManager->get($userId); $userEmail = $user->getEMailAddress(); $projectid = Utils::slugify($projectName); $createDefaultCategories = (count($categories) === 0); $createDefaultPaymentModes = (count($paymentModes) === 0); $projResult = $this->createProject( $projectName, $projectid, $userEmail, $userId, $createDefaultCategories, $createDefaultPaymentModes ); if (!isset($projResult['id'])) { return ['message' => $this->l10n->t('Error in project creation, %1$s', [$projResult['message'] ?? ''])]; } // set project main currency if ($mainCurrencyName !== null) { $this->editProject($projectid, $projectName, null, null, $mainCurrencyName); } // add payment modes foreach ($paymentModes as $pm) { $insertedPmId = $this->createPaymentMode($projectid, $pm['name'], $pm['icon'], $pm['color']); $paymentModeIdConv[$pm['id']] = $insertedPmId; } // add categories foreach ($categories as $cat) { $insertedCatId = $this->createCategory($projectid, $cat['name'], $cat['icon'], $cat['color']); $categoryIdConv[$cat['id']] = $insertedCatId; } // add currencies foreach ($currencies as $cur) { $insertedCurId = $this->createCurrency($projectid, $cur['name'], $cur['exchange_rate']); } // add members foreach ($membersByName as $memberName => $member) { $insertedMember = $this->createMember( $projectid, $memberName, $member['weight'], $member['active'], $member['color'] ?? null ); $memberNameToId[$memberName] = $insertedMember['id']; } $dbPaymentModes = $this->getCategoriesOrPaymentModes($projectid, false); // add bills foreach ($bills as $bill) { // manage category id if this is a custom category $catId = $bill['categoryid']; if ($catId !== null && $catId > 0) { $catId = $categoryIdConv[$catId]; } // manage payment mode id if this is a custom payment mode $pmId = $bill['paymentmodeid']; if ($pmId !== null && $pmId > 0) { $pmId = $paymentModeIdConv[$pmId]; } $payerId = $memberNameToId[$bill['payer_name']]; $owerIds = []; foreach ($bill['owers'] as $owerName) { $strippedOwer = trim($owerName); $owerIds[] = $memberNameToId[$strippedOwer]; } $owerIdsStr = implode(',', $owerIds); $addBillResult = $this->createBill( $projectid, null, $bill['what'], $payerId, $owerIdsStr, $bill['amount'], $bill['repeat'], $bill['paymentmode'], $pmId, $catId, $bill['repeatallactive'], $bill['repeatuntil'], $bill['timestamp'], $bill['comment'], $bill['repeatfreq'], $dbPaymentModes, $bill['deleted'] ?? 0 ); if (!isset($addBillResult['inserted_id'])) { $this->deleteProject($projectid); return ['message' => $this->l10n->t('Error when adding bill %1$s', [$bill['what']])]; } } return ['project_id' => $projectid]; } /** * Import SplitWise project file * * @param string $path * @param string $userId * @return array * @throws NoUserException * @throws NotFoundException * @throws NotPermittedException * @throws \OCP\DB\Exception */ public function importSWProject(string $path, string $userId): array { $cleanPath = str_replace(['../', '..\\'], '', $path); $userFolder = $this->root->getUserFolder($userId); if ($userFolder->nodeExists($cleanPath)) { $file = $userFolder->get($cleanPath); if ($file instanceof File) { if (($handle = $file->fopen('r')) !== false) { $columns = []; $membersWeight = []; $bills = []; $owersArray = []; $categoryNames = []; $row = 0; $nbCol = 0; $columnNamesLineFound = false; while (($data = fgetcsv($handle, 1000, ',')) !== false) { // look for column order line if (!$columnNamesLineFound) { $nbCol = count($data); for ($c = 0; $c < $nbCol; $c++) { $columns[$data[$c]] = $c; } if (!array_key_exists('Date', $columns) || !array_key_exists('Description', $columns) || !array_key_exists('Category', $columns) || !array_key_exists('Cost', $columns) || !array_key_exists('Currency', $columns) ) { $columns = []; $row++; continue; } $columnNamesLineFound = true; // manage members $m = 0; for ($c = 5; $c < $nbCol; $c++) { $owersArray[$m] = $data[$c]; $m++; } foreach ($owersArray as $ower) { if (strlen($ower) === 0) { fclose($handle); return ['message' => $this->l10n->t('Malformed CSV, cannot have an empty ower')]; } if (!array_key_exists($ower, $membersWeight)) { $membersWeight[$ower] = 1.0; } } } elseif (!isset($data[$columns['Date']]) || empty($data[$columns['Date']])) { // skip empty lines } elseif (isset($data[$columns['Description']]) && $data[$columns['Description']] === 'Total balance') { // skip the total lines } else { // normal line : bill $what = $data[$columns['Description']]; $cost = trim($data[$columns['Cost']]); if (empty($cost)) { // skip lines with no cost, it might be the balances line $row++; continue; } $date = $data[$columns['Date']]; $datetime = DateTime::createFromFormat('Y-m-d', $date); if ($datetime === false) { fclose($handle); return ['message' => $this->l10n->t('Malformed CSV, missing or invalid date/timestamp on line %1$s', [$row])]; } $timestamp = $datetime->getTimestamp(); $categoryName = null; // manage categories if (array_key_exists('Category', $columns) && $data[$columns['Category']] !== null && $data[$columns['Category']] !== '') { $categoryName = $data[$columns['Category']]; if (!in_array($categoryName, $categoryNames)) { $categoryNames[] = $categoryName; } } // new algorithm // get those with a negative value, they will be the owers in generated bills $negativeCols = []; for ($c = 5; $c < $nbCol; $c++) { if (!is_numeric($data[$c])) { fclose($handle); return ['message' => $this->l10n->t('Malformed CSV, bad amount on line %1$s', [$row])]; } $amount = (float) $data[$c]; if ($amount < 0) { $negativeCols[] = $c; } } $owersList = array_map(static function ($c) use ($owersArray) { return $owersArray[$c - 5]; }, $negativeCols); // each positive one: bill with member-specific amount (not the full amount), owers are the negative ones for ($c = 5; $c < $nbCol; $c++) { $amount = (float) $data[$c]; if ($amount > 0) { $payer_name = $owersArray[$c - 5]; if (empty($payer_name)) { fclose($handle); return ['message' => $this->l10n->t('Malformed CSV, no payer on line %1$s', [$row])]; } $bill = [ 'what' => $what, 'timestamp' => $timestamp, 'amount' => $amount, 'payer_name' => $payer_name, 'owers' => $owersList ]; if ($categoryName !== null) { $bill['category_name'] = $categoryName; } $bills[] = $bill; } } } $row++; } fclose($handle); if (!$columnNamesLineFound) { return ['message' => $this->l10n->t('Malformed CSV, impossible to find the column names. Make sure your Splitwise account language is set to English first, then export the project again.')]; } $memberNameToId = []; // add project $user = $this->userManager->get($userId); $userEmail = $user->getEMailAddress(); $projectName = preg_replace('/\.csv$/', '', $file->getName()); $projectid = Utils::slugify($projectName); // create default categories only if none are found in the CSV $createDefaultCategories = (count($categoryNames) === 0); $projResult = $this->createProject( $projectName, $projectid, $userEmail, $userId, $createDefaultCategories ); if (!isset($projResult['id'])) { return ['message' => $this->l10n->t('Error in project creation, %1$s', [$projResult['message'] ?? ''])]; } // add categories $catNameToId = []; foreach ($categoryNames as $categoryName) { $insertedCatId = $this->createCategory($projectid, $categoryName, null, '#000000'); /* if (!is_numeric($insertedCatId)) { $this->deleteProject($projectid); return ['message' => $this->l10n->t('Error when adding category %1$s', [$categoryName])]; } */ $catNameToId[$categoryName] = $insertedCatId; } // add members foreach ($membersWeight as $memberName => $weight) { $insertedMember = $this->createMember($projectid, $memberName, $weight); /* if (!is_array($insertedMember)) { $this->deleteProject($projectid); return ['message' => $this->l10n->t('Error when adding member %1$s', [$memberName])]; } */ $memberNameToId[$memberName] = $insertedMember['id']; } // add bills foreach ($bills as $bill) { $payerId = $memberNameToId[$bill['payer_name']]; $owerIds = []; foreach ($bill['owers'] as $owerName) { $owerIds[] = $memberNameToId[$owerName]; } $owerIdsStr = implode(',', $owerIds); // category $catId = null; if (array_key_exists('category_name', $bill) && array_key_exists($bill['category_name'], $catNameToId)) { $catId = $catNameToId[$bill['category_name']]; } $addBillResult = $this->createBill( $projectid, null, $bill['what'], $payerId, $owerIdsStr, $bill['amount'], Application::FREQUENCY_NO, null, 0, $catId, 0, null, $bill['timestamp'], null, null, [] ); if (!isset($addBillResult['inserted_id'])) { $this->deleteProject($projectid); return ['message' => $this->l10n->t('Error when adding bill %1$s', [$bill['what']])]; } } return ['project_id' => $projectid]; } else { return ['message' => $this->l10n->t('Access denied')]; } } else { return ['message' => $this->l10n->t('Access denied')]; } } else { return ['message' => $this->l10n->t('Access denied')]; } } /** * auto export * triggered by NC cron job * * export projects */ public function cronAutoExport(): void { date_default_timezone_set('UTC'); // last day $now = new DateTime(); $y = $now->format('Y'); $m = $now->format('m'); $d = $now->format('d'); // get begining of today $dateMaxDay = new DateTime($y . '-' . $m . '-' . $d); $maxDayTimestamp = $dateMaxDay->getTimestamp(); $minDayTimestamp = $maxDayTimestamp - (24 * 60 * 60); $dateMaxDay->modify('-1 day'); $dailySuffix = '_'.$this->l10n->t('daily').'_'.$dateMaxDay->format('Y-m-d'); // last week $now = new DateTime(); while (((int) $now->format('N')) !== 1) { $now->modify('-1 day'); } $y = $now->format('Y'); $m = $now->format('m'); $d = $now->format('d'); $dateWeekMax = new DateTime($y.'-'.$m.'-'.$d); $maxWeekTimestamp = $dateWeekMax->getTimestamp(); $minWeekTimestamp = $maxWeekTimestamp - (7 * 24 * 60 * 60); $dateWeekMin = new DateTime($y.'-'.$m.'-'.$d); $dateWeekMin->modify('-7 day'); $weeklySuffix = '_'.$this->l10n->t('weekly').'_'.$dateWeekMin->format('Y-m-d'); // last month $now = new DateTime(); while (((int) $now->format('d')) !== 1) { $now->modify('-1 day'); } $y = $now->format('Y'); $m = $now->format('m'); $d = $now->format('d'); $dateMonthMax = new DateTime($y.'-'.$m.'-'.$d); $maxMonthTimestamp = $dateMonthMax->getTimestamp(); $now->modify('-1 day'); while (((int) $now->format('d')) !== 1) { $now->modify('-1 day'); } $y = (int) $now->format('Y'); $m = (int) $now->format('m'); $d = (int) $now->format('d'); $dateMonthMin = new DateTime($y.'-'.$m.'-'.$d); $minMonthTimestamp = $dateMonthMin->getTimestamp(); $monthlySuffix = '_'.$this->l10n->t('monthly').'_'.$dateMonthMin->format('Y-m'); // $weekFilterArray = []; // $weekFilterArray['tsmin'] = $minWeekTimestamp; // $weekFilterArray['tsmax'] = $maxWeekTimestamp; // $dayFilterArray = []; // $dayFilterArray['tsmin'] = $minDayTimestamp; // $dayFilterArray['tsmax'] = $maxDayTimestamp; // $monthFilterArray = []; // $monthFilterArray['tsmin'] = $minMonthTimestamp; // $monthFilterArray['tsmax'] = $maxMonthTimestamp; $qb = $this->db->getQueryBuilder(); foreach ($this->userManager->search('') as $u) { $uid = $u->getUID(); $outPath = $this->config->getUserValue($uid, 'cospend', 'outputDirectory', '/Cospend'); $qb->select('id', 'name', 'autoexport') ->from('cospend_projects') ->where( $qb->expr()->eq('userid', $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR)) ) ->andWhere( $qb->expr()->neq('autoexport', $qb->createNamedParameter(Application::FREQUENCY_NO, IQueryBuilder::PARAM_STR)) ); $req = $qb->executeQuery(); $dbProjectId = null; while ($row = $req->fetch()) { $dbProjectId = $row['id']; $autoexport = $row['autoexport']; $suffix = $dailySuffix; // TODO add suffix for all frequencies if ($autoexport === Application::FREQUENCY_WEEKLY) { $suffix = $weeklySuffix; } elseif ($autoexport === Application::FREQUENCY_MONTHLY) { $suffix = $monthlySuffix; } // check if file already exists $exportName = $dbProjectId . $suffix . '.csv'; $userFolder = $this->root->getUserFolder($uid); if (!$userFolder->nodeExists($outPath . '/' . $exportName)) { $this->exportCsvProject($dbProjectId, $uid, $exportName); } } $req->closeCursor(); $qb = $qb->resetQueryParts(); } } }