From 6e1f731cd2da2c74de50716f05457740f5d14efd Mon Sep 17 00:00:00 2001 From: rdb Date: Sun, 21 Jul 2024 12:25:19 +0200 Subject: [PATCH] Billability changes: - Billable flag on projects in Toggl Track is ignored, only looks at entries - Rename `--unbillable` flag to `--include-unbillable` - Documentation Fixes #4 --- README.md | 23 +++++++++++++++++++++-- pyproject.toml | 2 +- toggl2moneybird/cli.py | 6 ++++-- toggl2moneybird/commands.py | 6 +++++- toggl2moneybird/sync.py | 24 ++++++++++++++++-------- 5 files changed, 47 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 744ec11..6c02059 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ automatically making changes to your administration. toggl2moneybird sync Only time entries corresponding to a project are synced. By default, only -billable items are synced, but you can add the `--unbillable` flag to include -unbillable items as well. +billable items are synced, but you can add the `--include-unbillable` flag to +include unbillable items as well. You can also automatically create a draft invoice for a particular contact and period using the following command: @@ -35,6 +35,25 @@ period using the following command: The invoice will not be sent out automatically. Instead, a browser window will open with the draft invoice in Moneybird, allowing you to send it from there. +## Billable flag + +By default, only entries marked "Billable" in Toggl Track are synchronized. +There are two options to control this behaviour. The `--include-unbillable` +flag will cause all entries to be synchronized. The "Billable" flag is only +set in moneybird for entries that are marked "Billable" in Toggl Track. + +Since the "Billable" tag requires a paid Toggl Track subscription, there is an +option to use a custom tag instead. If you use `--unbillable-tag "My Tag"`, +then all Toggl Track entries will be considered billable, except those with +the tag "My Tag". The option may be repeated for every tag that marks an +unbillable entry. If you would like to mark all imported entries billable in +moneybird, simply use this option with a silly tag name that doesn't exist. + +The options are independent of each other and may be used together, in which +case *all* entries (except those specified by `--exclude-tag`) are imported, +but only the ones without the tags specified by `--unbillable-tag` are marked +as "Billable" in the moneybird administration. + ## Limitations The Toggl Track API only allows accessing the last three months worth of data. diff --git a/pyproject.toml b/pyproject.toml index 9222f90..313b93b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "toggl2moneybird" -version = "0.4.1" +version = "0.5.0" authors = [ {name = "rdb", email = "me@rdb.name"}, ] diff --git a/toggl2moneybird/cli.py b/toggl2moneybird/cli.py index 57da570..95d3ebf 100644 --- a/toggl2moneybird/cli.py +++ b/toggl2moneybird/cli.py @@ -6,6 +6,7 @@ import keyring import getpass from datetime import datetime, date +import argparse from argparse import ArgumentParser from rich.console import Console @@ -29,8 +30,9 @@ def main(): parser.add_argument('-y', action='store_true', dest='yes', help="Do not ask for confirmation") parser.add_argument('--project', action='append', metavar='"Project"', dest='projects', help="Limit to the given project (may be repeated)") parser.add_argument('--exclude-tag', action='append', metavar='"tag"', dest='exclude_tags', help="Exclude entries with the given tag (may be repeated)") - parser.add_argument('--unbillable', action='store_false', dest='only_billable', help="Include unbillable entries") - parser.add_argument('--unbillable-tag', action='store', dest='unbillable_tag', help="Consider all entries billable except those with the given tag") + parser.add_argument('--include-unbillable', action='store_false', dest='only_billable', help="Include entries which are not marked billable in Toggl Track") + parser.add_argument('--unbillable', action='store_false', dest='only_billable', help=argparse.SUPPRESS) + parser.add_argument('--unbillable-tag', action='store', dest='unbillable_tag', help="Consider all Toggl Track entries billable except those with the given tag") parser.add_argument('--rate', action='store', dest='rate', type=float, help="Use the given rate for invoices, overrides rate in Toggl Track") parser.add_argument('--currency', action='store', dest='currency', help="Use the given currency for invoices (e.g. EUR), overrides currency in Toggl Track") args = parser.parse_args() diff --git a/toggl2moneybird/commands.py b/toggl2moneybird/commands.py index 72215c3..c336caf 100644 --- a/toggl2moneybird/commands.py +++ b/toggl2moneybird/commands.py @@ -351,6 +351,8 @@ def cmd_sync(console, args, mb_admin): if not args.projects or tt_project['name'] in args.projects: if args.unbillable_tag: tt_entry['billable'] = args.unbillable_tag not in tt_entry['tags'] + if args.only_billable and not tt_entry['billable']: + continue sync.add_tt_entry(tt_entry, tt_project) with Progress(console=console, transient=True) as progress: @@ -388,7 +390,7 @@ def cmd_sync(console, args, mb_admin): do_mutations(console, args, mb_admin, mutations) if not mutations and args.only_billable and sync.has_missing_projects(False): - console.print("To include unbillable projects, add the [bold]--unbillable[/bold] flag.") + console.print("To include unbillable entries, add the [bold]--include-unbillable[/bold] flag.") def cmd_invoice(console, args, mb_admin): @@ -432,6 +434,8 @@ def cmd_invoice(console, args, mb_admin): if not args.projects or tt_project['name'] in args.projects: if args.unbillable_tag: tt_entry['billable'] = args.unbillable_tag not in tt_entry['tags'] + if args.only_billable and not tt_entry['billable']: + continue sync.add_tt_entry(tt_entry, tt_project) sync.link(entries) diff --git a/toggl2moneybird/sync.py b/toggl2moneybird/sync.py index 53346c5..765fc55 100644 --- a/toggl2moneybird/sync.py +++ b/toggl2moneybird/sync.py @@ -29,6 +29,7 @@ def __init__(self): self.__entry_map = {} self.__project_map: dict[int, mb.Project] = {} self.__missing_projects: dict[int, dict] = {} + self.__missing_projects_billable = set() self.__mb_project_contact_map: dict[mb.Project, mb.Contact] = {} self.__mb_redundant_entries = set() @@ -65,6 +66,8 @@ def add_tt_entry(self, tt_entry, tt_project): entry.mb_project = self.__project_map.get(tt_project_id) if not entry.mb_project: self.__missing_projects[tt_project_id] = tt_project + if tt_entry['billable']: + self.__missing_projects_billable.add(tt_project_id) entry.stop = tt_parse_timestamp(tt_entry['stop']) @@ -101,8 +104,10 @@ def link(self, mb_entries): tt_project = entry.tt_project if mb_project: if tt_project and tt_project['id'] not in self.__project_map: - self.__project_map[tt_project['id']] = entry.mb_project - self.__missing_projects.pop(tt_project['id'], None) + tt_project_id = tt_project['id'] + self.__project_map[tt_project_id] = entry.mb_project + self.__missing_projects.pop(tt_project_id, None) + self.__missing_projects_billable.discard(tt_project_id) if mb_contact: self.__mb_project_contact_map[mb_project] = mb_contact @@ -127,20 +132,22 @@ def map_project(self, tt_project_id, mb_project): assert mb_project self.__project_map[tt_project_id] = mb_project self.__missing_projects.pop(tt_project_id, None) + self.__missing_projects_billable.discard(tt_project_id) for entry in self.entries: if entry.tt_project and entry.tt_project['id'] == tt_project_id: entry.mb_project = mb_project def has_missing_projects(self, only_billable=False): - if not only_billable: + #NB. We used to look at the billable flag on a project, but this + # doesn't actually say anything about whether the entries in the + # project are billable or not, so I decided to only look at whether + # the project has billable entries. See GitHub issue #4. + if only_billable: + return bool(self.__missing_projects_billable) + else: return bool(self.__missing_projects) - for proj in self.__missing_projects.values(): - if proj['billable']: - return True - return False - def map_projects_by_name(self, mb_projects): if not self.entries or not self.__missing_projects: return @@ -160,6 +167,7 @@ def map_projects_by_name(self, mb_projects): entry.mb_project = mb_project self.__project_map[tt_project_id] = mb_project self.__missing_projects.pop(tt_project_id, None) + self.__missing_projects_billable.discard(tt_project_id) return tuple(self.__missing_projects.values())