%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();
}
}
}