├── requirements ├── mac.txt ├── ubuntu.txt ├── windows.txt ├── dev.txt └── base.txt ├── monarchmoneyamazontagger ├── __init__.py ├── category.py ├── gift_card_parser.py ├── my_progress.py ├── micro_usd.py ├── micro_usd_test.py ├── mmclient.py ├── cli.py ├── mm_test.py ├── args.py ├── mm.py ├── qt.py ├── tagger_test.py ├── amazon_test.py ├── amazon.py └── main.py ├── icons ├── Icon.ico ├── base │ ├── 16.png │ ├── 24.png │ ├── 32.png │ ├── 48.png │ └── 64.png ├── linux │ ├── 128.png │ ├── 256.png │ ├── 512.png │ └── 1024.png ├── mac.iconset │ ├── icon_128x128.png │ ├── icon_16x16.png │ ├── icon_256x256.png │ ├── icon_32x32.png │ ├── icon_512x512.png │ ├── icon_64x64.png │ └── icon_1024x1024.png └── README.md ├── .mm └── mm_session.pickle ├── monarchmoney ├── __init__.py ├── LICENSE └── README.md ├── .dockerignore ├── release ├── entitlements.plist ├── pypi.sh ├── ubuntu.sh ├── dmg.applescript ├── win64.ps1 └── macOS.sh ├── .gitignore ├── dev ├── mac.sh ├── win64.ps1 └── ubuntu.sh ├── Dockerfile ├── .github └── FUNDING.yml ├── .vscode └── launch.json ├── LICENSE ├── setup.py └── README.md /requirements/mac.txt: -------------------------------------------------------------------------------- 1 | PyInstaller 2 | -------------------------------------------------------------------------------- /requirements/ubuntu.txt: -------------------------------------------------------------------------------- 1 | PyInstaller 2 | -------------------------------------------------------------------------------- /requirements/windows.txt: -------------------------------------------------------------------------------- 1 | pywin32 2 | wheel 3 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = "1.0" 2 | -------------------------------------------------------------------------------- /icons/Icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/Icon.ico -------------------------------------------------------------------------------- /monarchmoneyamazontagger/category.py: -------------------------------------------------------------------------------- 1 | # The default Mint category. 2 | DEFAULT_CATEGORY = "Shopping" 3 | -------------------------------------------------------------------------------- /icons/base/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/base/16.png -------------------------------------------------------------------------------- /icons/base/24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/base/24.png -------------------------------------------------------------------------------- /icons/base/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/base/32.png -------------------------------------------------------------------------------- /icons/base/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/base/48.png -------------------------------------------------------------------------------- /icons/base/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/base/64.png -------------------------------------------------------------------------------- /icons/linux/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/linux/128.png -------------------------------------------------------------------------------- /icons/linux/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/linux/256.png -------------------------------------------------------------------------------- /icons/linux/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/linux/512.png -------------------------------------------------------------------------------- /.mm/mm_session.pickle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/.mm/mm_session.pickle -------------------------------------------------------------------------------- /icons/linux/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/linux/1024.png -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | ipython 3 | pytest 4 | pytype 5 | recordtype 6 | tabula-py 7 | twine 8 | wheel 9 | -------------------------------------------------------------------------------- /icons/mac.iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/mac.iconset/icon_128x128.png -------------------------------------------------------------------------------- /icons/mac.iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/mac.iconset/icon_16x16.png -------------------------------------------------------------------------------- /icons/mac.iconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/mac.iconset/icon_256x256.png -------------------------------------------------------------------------------- /icons/mac.iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/mac.iconset/icon_32x32.png -------------------------------------------------------------------------------- /icons/mac.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/mac.iconset/icon_512x512.png -------------------------------------------------------------------------------- /icons/mac.iconset/icon_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/mac.iconset/icon_64x64.png -------------------------------------------------------------------------------- /icons/mac.iconset/icon_1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jprouty/monarchmoney-amazon-tagger/HEAD/icons/mac.iconset/icon_1024x1024.png -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | # Latest PyQt6 has a PyInstaller issue. See: https://github.com/pyinstaller/pyinstaller/issues/7789 2 | PyQt6==6.4.2 3 | aiohttp 4 | chardet 5 | gql 6 | # monarchmoney 7 | oathtool 8 | outdated 9 | progress 10 | readchar -------------------------------------------------------------------------------- /monarchmoney/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | monarchmoney 3 | 4 | A Python API for interacting with MonarchMoney. 5 | """ 6 | 7 | from .monarchmoney import ( 8 | LoginFailedException, 9 | MonarchMoneyEndpoints, 10 | MonarchMoney, 11 | RequireMFAException, 12 | ) 13 | 14 | __version__ = "0.1.6" 15 | __author__ = "hammem" 16 | -------------------------------------------------------------------------------- /icons/README.md: -------------------------------------------------------------------------------- 1 | ![Sample app icon](linux/128.png) 2 | 3 | The difference between the icons on Mac and the other platforms is that on Mac, 4 | they contain a ~5% transparent margin. This is because otherwise they look too 5 | big (eg. in the Dock or in the app switcher). 6 | 7 | You can create Icon.ico from the .png files with 8 | [an online tool](http://icoconvert.com/Multi_Image_to_one_icon/). 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Distribution / packaging 2 | build/ 3 | dist/ 4 | release_venv/ 5 | dev_venv/ 6 | pypi_test_venv/ 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | .pytest_cache 13 | 14 | # -*- mode: gitignore; -*- 15 | *~ 16 | \#*\# 17 | /.emacs.desktop 18 | /.emacs.desktop.lock 19 | *.elc 20 | auto-save-list 21 | tramp 22 | .\#* 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /release/entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | com.apple.security.cs.allow-unsigned-executable-memory 7 | 8 | com.apple.security.cs.disable-library-validation 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Distribution / packaging 2 | build/ 3 | dist/ 4 | release_venv/ 5 | dev_venv/ 6 | pypi_test_venv/ 7 | MonarchMoneyAmazonTagger.spec 8 | debug-data/ 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | .pytest_cache 15 | 16 | # -*- mode: gitignore; -*- 17 | *~ 18 | \#*\# 19 | /.emacs.desktop 20 | /.emacs.desktop.lock 21 | *.elc 22 | auto-save-list 23 | tramp 24 | .\#* 25 | .DS_Store 26 | chromedriver 27 | LICENSE.chromedriver 28 | -------------------------------------------------------------------------------- /dev/mac.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # A script that creates, updates, and activates a virtual environment 3 | # specifically for development. 4 | # Must call this script via: 5 | # source dev/mac.sh 6 | 7 | if [ -d "dev_venv" ] 8 | then 9 | source dev_venv/bin/activate 10 | pip install --upgrade -r requirements/base.txt -r requirements/mac.txt -r requirements/dev.txt 11 | else 12 | python3 -m venv dev_venv 13 | source dev_venv/bin/activate 14 | pip install --upgrade -r requirements/base.txt -r requirements/mac.txt -r requirements/dev.txt 15 | fi 16 | -------------------------------------------------------------------------------- /dev/win64.ps1: -------------------------------------------------------------------------------- 1 | # A script that creates, updates, and activates a virtual environment 2 | # specifically for development. 3 | # Must call this script via: 4 | # source dev/win64.ps1 5 | 6 | if (Test-Path -Path 'dev_venv') { 7 | .\dev_venv\Scripts\activate.ps1 8 | pip install --upgrade -r requirements/base.txt -r requirements/windows.txt -r requirements/dev.txt 9 | } else { 10 | python -m venv dev_venv 11 | .\dev_venv\Scripts\activate.ps1 12 | pip install --upgrade -r requirements/base.txt -r requirements/windows.txt -r requirements/dev.txt 13 | } 14 | -------------------------------------------------------------------------------- /dev/ubuntu.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # A script that creates, updates, and activates a virtual environment 3 | # specifically for development. 4 | # Must call this script via: 5 | # source dev/ubuntu.sh 6 | 7 | if [ -d "dev_venv" ] 8 | then 9 | source dev_venv/bin/activate 10 | pip install --upgrade pip 11 | pip install --upgrade -r requirements/base.txt -r requirements/ubuntu.txt -r requirements/dev.txt 12 | else 13 | python3 -m venv dev_venv 14 | source dev_venv/bin/activate 15 | pip install --upgrade pip 16 | pip install --upgrade -r requirements/base.txt -r requirements/ubuntu.txt -r requirements/dev.txt 17 | fi 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.10 2 | 3 | LABEL maintainer="jeff.prouty@gmail.com" 4 | 5 | RUN apt-get update && apt-get install -y \ 6 | python3 \ 7 | python3-pip \ 8 | wget 9 | 10 | # Add Chrome repo. 11 | RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 12 | && echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list 13 | 14 | # Install stable chrome. 15 | RUN apt-get update && apt-get install -y \ 16 | google-chrome-stable 17 | 18 | RUN pip3 install --upgrade pip 19 | 20 | COPY . /var/app 21 | WORKDIR /var/app 22 | 23 | RUN pip3 install -r /var/app/requirements/base.txt -r /var/app/requirements/ubuntu.txt 24 | 25 | CMD ["python3", "-m", "monarchmoneyamazontagger.cli", "--headless"] -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [jprouty] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /release/pypi.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # exit when any command fails 4 | set -e 5 | 6 | cd "$(dirname "$0")/.." 7 | 8 | # python3 setup.py block_on_version | 9 | python3 setup.py block_on_version clean sdist bdist_wheel || exit 10 | 11 | # Publish. 12 | python3 -m twine upload --repository monarchmoney-amazon-tagger dist/* 13 | 14 | # Verify the package is installable in a virtual env. 15 | python3 -m venv pypi_test_venv 16 | source pypi_test_venv/bin/activate 17 | 18 | pip install --upgrade pip 19 | # Wait 180 seconds for pypi before attempting to install the newly published version. 20 | sleep 180 21 | pip install --no-cache-dir monarchmoney-amazon-tagger 22 | 23 | # Get out of the root directory so the live src version isn't used when verifying the pypi module. 24 | cd pypi_test_venv 25 | python -m monarchmoneyamazontagger.main 26 | cd .. 27 | 28 | deactivate 29 | rm -rf pypi_test_venv 30 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "MMAT GUI", 9 | "type": "python", 10 | "request": "launch", 11 | "module": "monarchmoneyamazontagger.main", 12 | "justMyCode": true 13 | }, 14 | { 15 | "name": "MMAT CLI", 16 | "type": "python", 17 | "request": "launch", 18 | "module": "monarchmoneyamazontagger.cli", 19 | "justMyCode": true, 20 | "args": [ 21 | "--amazon_export=/Users/jeff/Downloads/Your Orders.zip", 22 | "--dry_run", 23 | "--mm_email=a", 24 | "--mm_password=a" 25 | ] 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jeff Prouty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF descriptionABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /monarchmoney/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 hammem 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /release/ubuntu.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # exit when any command fails 4 | set -e 5 | 6 | cd "$(dirname "$0")/.." 7 | 8 | # "app_name": "MonarchMoneyAmazonTagger", 9 | # "author": "Jeff Prouty", 10 | # "main_module": "src/main/python/monarchmoneyamazontagger/main.py", 11 | # "version": "1.0.6", 12 | # "gpg_key": "CB1608BF9A09BE99908045E6E93C791A0BFE386F", 13 | # "gpg_name": "Jeff Prouty", 14 | # "url": "https://github.com/jprouty/monarchmoney-amazon-tagger" 15 | # "categories": "Utility;", 16 | # "description": "Monarch Money Amazon tagger matches amazon purchases with your monarch transactions, giving them useful descriptions.", 17 | # "author_email": "jeff.prouty@gmail.com", 18 | 19 | echo "Clean everything" 20 | python3 setup.py clean 21 | 22 | echo "Setup the release venv" 23 | python3 -m venv release_venv 24 | source release_venv/bin/activate 25 | pip install --upgrade pip 26 | pip install --upgrade -r requirements/base.txt -r requirements/ubuntu.txt 27 | 28 | pyinstaller \ 29 | --name="MonarchMoneyAmazonTagger" \ 30 | --windowed \ 31 | --onefile \ 32 | --icon=icons/Icon.ico \ 33 | monarchmoneyamazontagger/main.py 34 | 35 | echo "Now verify the built version works" 36 | dist/MonarchMoneyAmazonTagger 37 | 38 | deactivate 39 | rm -rf release_venv 40 | 41 | echo "TODO: Package as a .deb" 42 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/gift_card_parser.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from pprint import pprint 3 | from recordtype import recordtype 4 | import tabula 5 | 6 | 7 | def main(): 8 | tables = tabula.read_pdf("Retail.GiftCertificates.Transactions.pdf", pages="all") 9 | # Only keep the ones with 4 columns: 10 | tables = [t for t in tables if len(t.columns) == 4] 11 | # First table has the correct column names: 12 | GCTransaction = recordtype("GCTransaction", list(tables[0].columns)) 13 | first_table = tables[0] 14 | trans = [] 15 | for row in first_table.values: 16 | trans.append(GCTransaction(*row)) 17 | 18 | for table in tables[1:]: 19 | # The header (or column names) are actually a data row: 20 | trans.append(GCTransaction(*table.columns)) 21 | for row in table.values: 22 | trans.append(GCTransaction(*row)) 23 | for t in trans: 24 | # Parse all dates into datetime. 25 | format = "%d-%m-%Y %H:%M" 26 | t.transactionDate = datetime.strptime(t.transactionDate, format) 27 | 28 | # Only look at MarkShipmentCompletion records: 29 | trans = [t for t in trans if t.transactionType == "MarkShipmentCompletion"] 30 | pprint(trans) 31 | print(len(trans)) 32 | 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/my_progress.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | import time 3 | 4 | from progress.bar import IncrementalBar 5 | from progress.counter import Counter 6 | from progress.spinner import Spinner 7 | 8 | 9 | class AsyncProgress: 10 | def __init__(self, progress): 11 | super() 12 | self.progress = progress 13 | self.spinning = True 14 | self.timer = Thread(target=self.runnable) 15 | self.timer.start() 16 | 17 | def next(self, i=1): 18 | pass 19 | 20 | def runnable(self): 21 | while self.spinning: 22 | self.progress.next() 23 | time.sleep(0.1) 24 | 25 | def finish(self): 26 | self.spinning = False 27 | self.progress.finish() 28 | print() 29 | 30 | 31 | class NoProgress: 32 | def next(self, i=1): 33 | pass 34 | 35 | def finish(self): 36 | pass 37 | 38 | 39 | class QtProgress: 40 | def __init__(self, msg, max, emitter): 41 | self.msg = msg 42 | self.curr = 0 43 | self.max = max 44 | self.emitter = emitter 45 | 46 | self.emitter(self.msg, self.max, self.curr) 47 | 48 | def next(self, incr=1): 49 | self.curr += incr 50 | if self.curr > self.max: 51 | self.max += self.max if self.max else 32 52 | self.emitter(self.msg, self.max, self.curr) 53 | 54 | def finish(self): 55 | pass 56 | 57 | 58 | def no_progress_factory(msg, max): 59 | return NoProgress() 60 | 61 | 62 | def indeterminate_progress_cli(msg, max=0): 63 | return AsyncProgress(Spinner(msg)) 64 | 65 | 66 | def determinate_progress_cli(msg, max): 67 | return IncrementalBar(msg, max=max) 68 | 69 | 70 | def counter_progress_cli(msg, max=0): 71 | return Counter(msg + " - ") 72 | -------------------------------------------------------------------------------- /release/dmg.applescript: -------------------------------------------------------------------------------- 1 | on run (volumeName) 2 | tell application "Finder" 3 | tell disk (volumeName as string) 4 | open 5 | 6 | set theXOrigin to 10 7 | set theYOrigin to 60 8 | set theWidth to 500 9 | set theHeight to 350 10 | 11 | set theBottomRightX to (theXOrigin + theWidth) 12 | set theBottomRightY to (theYOrigin + theHeight) 13 | set dsStore to "\"" & "/Volumes/" & volumeName & "/" & ".DS_STORE\"" 14 | 15 | tell container window 16 | set current view to icon view 17 | set toolbar visible to false 18 | set statusbar visible to false 19 | set the bounds to {theXOrigin, theYOrigin, theBottomRightX, theBottomRightY} 20 | set statusbar visible to false 21 | end tell 22 | 23 | set opts to the icon view options of container window 24 | tell opts 25 | set icon size to 128 26 | set text size to 16 27 | set arrangement to not arranged 28 | end tell 29 | 30 | -- Positioning 31 | set position of item "MintAmazonTagger.app" to {0, 10} 32 | 33 | -- Hiding 34 | 35 | -- Application and QL Link Clauses 36 | set position of item "Applications" to {170, 10} 37 | close 38 | open 39 | -- Force saving of the size 40 | delay 1 41 | 42 | tell container window 43 | set statusbar visible to false 44 | set the bounds to {theXOrigin, theYOrigin, theBottomRightX - 10, theBottomRightY - 10} 45 | end tell 46 | end tell 47 | 48 | delay 1 49 | 50 | tell disk (volumeName as string) 51 | tell container window 52 | set statusbar visible to false 53 | set the bounds to {theXOrigin, theYOrigin, theBottomRightX, theBottomRightY} 54 | end tell 55 | end tell 56 | 57 | --give the finder some time to write the .DS_Store file 58 | delay 3 59 | 60 | set waitTime to 0 61 | set ejectMe to false 62 | repeat while ejectMe is false 63 | delay 1 64 | set waitTime to waitTime + 1 65 | 66 | if (do shell script "[ -f " & dsStore & " ]; echo $?") = "0" then set ejectMe to true 67 | end repeat 68 | log "waited " & waitTime & " seconds for .DS_STORE to be created." 69 | end tell 70 | end run 71 | -------------------------------------------------------------------------------- /release/win64.ps1: -------------------------------------------------------------------------------- 1 | $scriptpath = $MyInvocation.MyCommand.Path 2 | $dir = Split-Path $scriptpath 3 | Push-Location $dir\.. 4 | 5 | python setup.py clean 6 | 7 | echo "Setup the release venv" 8 | python -m venv release_venv 9 | .\release_venv\Scripts\activate.ps1 10 | pip install --upgrade pip 11 | pip install --upgrade -r requirements/base.txt -r requirements/windows.txt 12 | 13 | echo "Install PyInstaller locally, with a locally built bootloader. This helps avoid any anti-virus conflation with other PyInstaller apps from the publicly built version." 14 | # See more here: https://stackoverflow.com/questions/43777106/program-made-with-pyinstaller-now-seen-as-a-trojan-horse-by-avg 15 | $pyinstaller = Join-Path 'C:\' $(New-Guid) | %{ mkdir $_ } 16 | Push-Location $pyinstaller 17 | 18 | $PyInstallerArchiveUrl = "https://github.com/pyinstaller/pyinstaller/archive/refs/tags/v5.0.zip" 19 | $PyInstallerLocalZip = Join-Path $pyinstaller 'PyInstaller_v5.0.zip' 20 | Invoke-WebRequest -OutFile $PyInstallerLocalZip $PyInstallerArchiveUrl 21 | $PyInstallerLocalZip | Expand-Archive -DestinationPath $pyinstaller -Force 22 | 23 | Push-Location pyinstaller-5.0 24 | Push-Location bootloader 25 | python ./waf all 26 | Pop-Location 27 | python setup.py install 28 | Pop-Location 29 | Pop-Location 30 | 31 | echo "Build it" 32 | pyinstaller ` 33 | --name="MonarchMoneyAmazonTagger" ` 34 | --onefile ` 35 | --windowed ` 36 | --icon .\icons\Icon.ico ` 37 | .\monarchmoneyamazontagger\main.py 38 | 39 | echo "Signing the app" 40 | $password = Get-Content .\sign\password -Raw 41 | signtool sign ` 42 | /f .\sign\certificate.pfx ` 43 | /p $password.trim() ` 44 | /fd sha256 ` 45 | /d "Monarch Money Amazon tagger matches amazon purchases with your monarch transactions, giving them useful descriptions." ` 46 | /du "https://github.com/jprouty/monarchmoney-amazon-tagger" ` 47 | /tr http://timestamp.sectigo.com ` 48 | /td sha256 ` 49 | .\dist\MonarchMoneyAmazonTagger.exe 50 | 51 | echo "Now verify the built version works" 52 | .\dist\MonarchMoneyAmazonTagger.exe | Out-Null 53 | 54 | deactivate 55 | Remove-Item -Recurse -Force .\release_venv\ 56 | Remove-Item -Recurse -Force $pyinstaller 57 | Pop-Location 58 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/micro_usd.py: -------------------------------------------------------------------------------- 1 | # 50 Micro dollars we'll consider equal (this allows for some 2 | # division/multiplication rounding wiggle room). 3 | from typing import Any 4 | 5 | 6 | MICRO_USD_EPS = 50 7 | CENT_MICRO_USD = 10000 8 | 9 | DOLLAR_EPS = 0.0001 10 | 11 | 12 | class MicroUSD: 13 | def __init__(self, micro_usd: int): 14 | self.micro_usd = micro_usd 15 | 16 | def __repr__(self) -> str: 17 | return f"MicroUSD({self.micro_usd})" 18 | 19 | def __str__(self) -> str: 20 | return ( 21 | f"{'' if self.micro_usd >= -5000 else '-'}$" f"{abs(self.to_float()):.2f}" 22 | ) 23 | 24 | def __eq__(self, other: object) -> bool: 25 | if not isinstance(other, MicroUSD): 26 | return False 27 | return abs(self.micro_usd - other.micro_usd) < MICRO_USD_EPS 28 | 29 | def __neg__(self) -> "MicroUSD": 30 | return MicroUSD(-self.micro_usd) 31 | 32 | def __add__(self, other: "MicroUSD") -> "MicroUSD": 33 | return MicroUSD(self.micro_usd + other.micro_usd) 34 | 35 | def __sub__(self, other: "MicroUSD") -> "MicroUSD": 36 | return MicroUSD(self.micro_usd - other.micro_usd) 37 | 38 | def __mul__(self, other: Any) -> "MicroUSD": 39 | return MicroUSD(self.micro_usd * other) 40 | 41 | def round_to_cent(self) -> "MicroUSD": 42 | """Rounds to the nearest cent.""" 43 | return MicroUSD.from_float(self.to_float()) 44 | 45 | def to_float(self) -> float: 46 | return round(self.micro_usd / 1000000.0 + DOLLAR_EPS, 2) 47 | 48 | @classmethod 49 | def from_float(cls, float_usd: float) -> "MicroUSD": 50 | return MicroUSD(round(float_usd * 1000000)) 51 | 52 | @classmethod 53 | def parse(cls, amount: float | str) -> "MicroUSD": 54 | if isinstance(amount, float): 55 | return cls.from_float(amount) 56 | # Remove any formatting/grouping commas. 57 | amount = amount.replace(",", "") 58 | # Remove any quoting. 59 | amount = amount.replace("'", "") 60 | negate = False 61 | if "-" == amount[0]: 62 | negate = True 63 | amount = amount[1:] 64 | if "$" == amount[0]: 65 | amount = amount[1:] 66 | return cls.from_float(float(amount) if not negate else -float(amount)) 67 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/micro_usd_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from monarchmoneyamazontagger.micro_usd import MicroUSD 4 | 5 | 6 | class MicroUSDTest(unittest.TestCase): 7 | def test_equality(self): 8 | self.assertEqual(MicroUSD(0), MicroUSD(-10)) 9 | self.assertEqual(MicroUSD(0), MicroUSD(10)) 10 | self.assertEqual(MicroUSD(-10), MicroUSD(0)) 11 | self.assertEqual(MicroUSD(10), MicroUSD(0)) 12 | self.assertEqual(MicroUSD(10), MicroUSD(-10)) 13 | self.assertEqual(MicroUSD(-10), MicroUSD(10)) 14 | 15 | self.assertEqual(MicroUSD(42143241), MicroUSD(42143239)) 16 | self.assertEqual(MicroUSD(42143241), MicroUSD(42143243)) 17 | 18 | self.assertNotEqual(MicroUSD(0), MicroUSD(400)) 19 | self.assertNotEqual(MicroUSD(0), MicroUSD(-200)) 20 | self.assertNotEqual(MicroUSD(-500), MicroUSD(0)) 21 | self.assertNotEqual(MicroUSD(200), MicroUSD(0)) 22 | 23 | def test_to_float(self): 24 | self.assertEqual(MicroUSD(30000300).to_float(), 30.0) 25 | self.assertEqual(MicroUSD(103000).to_float(), 0.10) 26 | self.assertEqual(MicroUSD(303103000).to_float(), 303.10) 27 | self.assertEqual(MicroUSD(-103100000).to_float(), -103.10) 28 | self.assertEqual(MicroUSD(5050500).to_float(), 5.05) 29 | self.assertEqual(MicroUSD(150500).to_float(), 0.15) 30 | self.assertEqual(MicroUSD(500).to_float(), 0) 31 | self.assertEqual(MicroUSD(-500).to_float(), 0) 32 | 33 | def test_round_to_cent(self): 34 | self.assertEqual(MicroUSD(50505050).round_to_cent().micro_usd, 50510000) 35 | self.assertEqual(MicroUSD(50514550).round_to_cent().micro_usd, 50510000) 36 | self.assertEqual(MicroUSD(-550).round_to_cent().micro_usd, 0) 37 | self.assertEqual(MicroUSD(550).round_to_cent().micro_usd, 0) 38 | self.assertEqual(MicroUSD(-66130000).round_to_cent().micro_usd, -66130000) 39 | self.assertEqual(MicroUSD(32940000).round_to_cent().micro_usd, 32940000) 40 | self.assertEqual(MicroUSD(-32870000).round_to_cent().micro_usd, -32870000) 41 | self.assertEqual(MicroUSD(-33120000).round_to_cent().micro_usd, -33120000) 42 | self.assertEqual(MicroUSD(-67070000).round_to_cent().micro_usd, -67070000) 43 | 44 | def test_from_float(self): 45 | self.assertEqual(MicroUSD.from_float(32.94).micro_usd, 32940000) 46 | 47 | def test_str(self): 48 | self.assertEqual(str(MicroUSD(1230040)), "$1.23") 49 | self.assertEqual(str(MicroUSD(-123000)), "-$0.12") 50 | self.assertEqual(str(MicroUSD(-1900)), "$0.00") 51 | self.assertEqual(str(MicroUSD(-10000)), "-$0.01") 52 | 53 | def test_parse(self): 54 | self.assertEqual(MicroUSD.parse("$1.23").micro_usd, 1230000) 55 | self.assertEqual(MicroUSD.parse("$0.00").micro_usd, 0) 56 | self.assertEqual(MicroUSD.parse("-$0.00").micro_usd, 0) 57 | self.assertEqual(MicroUSD.parse("$55").micro_usd, 55000000) 58 | self.assertEqual(MicroUSD.parse("$12.23").micro_usd, 12230000) 59 | self.assertEqual(MicroUSD.parse("-$12.23").micro_usd, -12230000) 60 | 61 | 62 | if __name__ == "__main__": 63 | unittest.main() 64 | -------------------------------------------------------------------------------- /monarchmoney/README.md: -------------------------------------------------------------------------------- 1 | # Monarch Money 2 | 3 | Python library for accessing [Monarch Money](https://www.monarchmoney.com/referral/ngam2i643l) data. 4 | 5 | # Installation 6 | 7 | ## From Source Code 8 | 9 | Clone this repository from Git 10 | 11 | `git clone https://github.com/hammem/monarchmoney.git` 12 | 13 | ## Via `pip` 14 | 15 | `pip install monarchmoney` 16 | # Instantiate & Login 17 | 18 | There are two ways to use this library: interactive and non-interactive. 19 | 20 | ## Interactive 21 | 22 | If you're using this library in something like iPython or Jupyter, you can run an interactive-login which supports multi-factor authentication: 23 | 24 | ```python 25 | mm = MonarchMoney() 26 | await mm.interactive_login() 27 | ``` 28 | This will prompt you for the email, password and, if needed, the multi-factor token. 29 | 30 | ## Non-interactive 31 | 32 | For a non-interactive session, you'll need to create an instance and login: 33 | 34 | ```python 35 | mm = MonarchMoney() 36 | mm.login(email, password) 37 | ``` 38 | 39 | This may throw a `RequireMFAException`. If it does, you'll need to get a multi-factor token and call the following method: 40 | 41 | ```python 42 | mm.multi_factor_authenticate(email, password, multi_factor_code) 43 | ``` 44 | 45 | Alternatively, you can provide the MFA Secret Key. The MFA Secret Key is found when setting up the MFA in Monarch Money by going to Settings -> Security -> Enable MFA -> and copy the "Two-factor text code". Then provide it in the login() method: 46 | ```python 47 | await mm.login( 48 | email=email, 49 | password=password, 50 | save_session=False, 51 | use_saved_session=False, 52 | mfa_secret_key=mfa_secret_key, 53 | ) 54 | 55 | ``` 56 | 57 | # Accessing Data 58 | 59 | As of writing this README, the following methods are supported: 60 | 61 | - `get_accounts` - all the accounts linked to Monarch Money 62 | - `get_account_holdings` - all of the securities in a brokerage or similar type of account 63 | - `get_subscription_details` - the Monarch Money account's status (e.g. paid or trial) 64 | - `get_transactions` - transaction data, defaults to returning the last 100 transactions; can also be searched by date range 65 | - `get_transaction_categories` all of the categories configured in the account 66 | - `get_transaction_tags` - all of the tags configured in the account 67 | - `get_cashflow` - cashflow data (by category, category group, merchant and a summary) 68 | - `get_cashflow_summary` - cashflow summary (income, expense, savings, savings rate) 69 | 70 | # Contributing 71 | 72 | Any and all contributions -- code, documentation, feature requests, feedback -- are welcome! 73 | 74 | If you plan to submit up a pull request, you can expect a timely review. There aren't any strict requirements around the environment you need to configure aside from using [Black](https://github.com/psf/black) to auto-format the code. An action is configured in this repo to run against all PRs and merges and will block them from being committed. 75 | 76 | # FAQ 77 | 78 | **How do I use this API if I login to Monarch via Google?** 79 | 80 | If you currently use Google or 'Continue with Google' to access your Monarch account, you'll need to set a password to leverage this API. You can set a password on your Monarch account by going to your [security settings](https://app.monarchmoney.com/settings/security). 81 | 82 | Don't forget to use a password unique to your Monarch account and to enable multi-factor authentication! 83 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | from outdated import check_outdated 3 | from monarchmoneyamazontagger import VERSION 4 | from distutils.errors import DistutilsError 5 | 6 | 7 | with open("README.md", "r") as fh: 8 | long_description = fh.read() 9 | 10 | 11 | class CleanCommand(setuptools.Command): 12 | """Custom clean command to tidy up the project root.""" 13 | user_options = [] 14 | 15 | def initialize_options(self): 16 | pass 17 | 18 | def finalize_options(self): 19 | pass 20 | 21 | def run(self): 22 | import shutil 23 | dirs = [ 24 | 'build', 25 | 'dist', 26 | 'tagger-release', 27 | 'target', 28 | 'release_venv', 29 | 'cache', 30 | 'monarchmoney_amazon_tagger.egg-info', 31 | ] 32 | for tree in dirs: 33 | shutil.rmtree(tree, ignore_errors=True) 34 | import os 35 | from glob import glob 36 | globs = ('**/*.pyc', '**/*.tgz', '**/*.pyo') 37 | for g in globs: 38 | for file in glob(g, recursive=True): 39 | try: 40 | os.remove(file) 41 | except OSError: 42 | print(f"Error while deleting file: {file}") 43 | 44 | 45 | class BlockReleaseCommand(setuptools.Command): 46 | """Raises an error if VERSION is already present on PyPI.""" 47 | user_options = [] 48 | 49 | def initialize_options(self): 50 | pass 51 | 52 | def finalize_options(self): 53 | pass 54 | 55 | def run(self): 56 | try: 57 | stale, latest = check_outdated('monarchmoney-amazon-tagger', VERSION) 58 | raise DistutilsError( 59 | 'Please update VERSION in __init__. ' 60 | f'Current {VERSION} PyPI latest {latest}') 61 | except ValueError: 62 | pass 63 | 64 | 65 | setuptools.setup( 66 | name="monarchmoney-amazon-tagger", 67 | version=VERSION, 68 | author="Jeff Prouty", 69 | author_email="jeff.prouty@gmail.com", 70 | description=("Fetches your Amazon order history and matching/tags your " 71 | "Monarch Money transactions"), 72 | keywords='amazon monarch money tagger transactions order history', 73 | long_description=long_description, 74 | long_description_content_type="text/markdown", 75 | url="https://github.com/jprouty/monarchmoney-amazon-tagger", 76 | packages=setuptools.find_packages(), 77 | python_requires='>=3', 78 | classifiers=[ 79 | "Programming Language :: Python :: 3", 80 | "License :: OSI Approved :: MIT License", 81 | "Operating System :: OS Independent", 82 | "Topic :: Office/Business :: Financial", 83 | ], 84 | install_requires=[ 85 | 'PyQt6', 86 | 'mock', 87 | 'outdated', 88 | 'progress', 89 | 'range-key-dict', 90 | ], 91 | entry_points=dict( 92 | console_scripts=[ 93 | 'monarchmoney-amazon-tagger-cli=monarchmoneyamazontagger.cli:main', 94 | 'monarchmoney-amazon-tagger=monarchmoneyamazontagger.main:main', 95 | 'monarchmoney-amazon-tagger-repro_selenium_issue=monarchmoneyamazontagger.repro_mac_issue:main' 96 | ], 97 | ), 98 | cmdclass={ 99 | 'clean': CleanCommand, 100 | 'block_on_version': BlockReleaseCommand, 101 | }, 102 | ) 103 | -------------------------------------------------------------------------------- /release/macOS.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # To release for Intel, run: 4 | # /usr/bin/arch -x86_64 ./release/macOS.sh 5 | 6 | # exit when any command fails 7 | set -e 8 | 9 | readonly app_name="MonarchMoneyAmazonTagger" 10 | readonly bundle_ident="com.jeffprouty.monarchmoneyamazontagger" 11 | readonly app_identity="Developer ID Application: Jeff Prouty (NRC455QXS5)" 12 | readonly email="jeff.prouty@gmail.com" 13 | 14 | readonly app_dir="dist/MonarchMoneyAmazonTagger.app" 15 | readonly dmg_path="dist/${app_name}.dmg" 16 | readonly entitlements="release/entitlements.plist" 17 | readonly icon_icns="build/Icon.icns" 18 | 19 | cd "$(dirname "$0")/.." 20 | 21 | echo "Clean everything" 22 | python3 setup.py clean 23 | 24 | echo "Setup the release venv" 25 | python3 -m venv release_venv 26 | source release_venv/bin/activate 27 | pip install --upgrade pip 28 | pip install --upgrade -r requirements/base.txt -r requirements/mac.txt 29 | # --no-binary psutil,pandas,numpy numpy==1.25.2 pandas==1.5.3 30 | 31 | mkdir build 32 | 33 | # Create an icns file 34 | iconutil -c icns icons/mac.iconset --output="${icon_icns}" 35 | 36 | # TODO: Add support for universal2 binaries. Try this: 37 | # https://github.com/pyinstaller/pyinstaller/issues/5315#issuecomment-971341261 38 | # Hidden Import: https://github.com/Ousret/charset_normalizer/issues/253 39 | # --target-arch="universal2" \ 40 | # --hidden-import="charset_normalizer.md__mypyc" \ 41 | pyinstaller \ 42 | --windowed \ 43 | --name="${app_name}" \ 44 | --icon="${icon_icns}" \ 45 | --osx-bundle-identifier="${bundle_ident}" \ 46 | monarchmoneyamazontagger/main.py 47 | 48 | deactivate 49 | rm -rf release_venv 50 | 51 | echo "Signing the app and selenium-manager" 52 | codesign --verify --verbose --force --deep --sign \ 53 | "${app_identity}" \ 54 | --entitlements "${entitlements}" \ 55 | --options=runtime \ 56 | "${app_dir}" 57 | 58 | echo "Creating installer/disk image" 59 | readonly temp_dmg="dist/${app_name}.temp.dmg" 60 | hdiutil create \ 61 | -srcfolder "${app_dir}" \ 62 | -volname "${app_name}" \ 63 | -fs HFS+ \ 64 | -fsargs "-c c=64,a=16,e=16" \ 65 | -format UDRW \ 66 | "${temp_dmg}" 67 | 68 | readonly mount_path="/Volumes/${app_name}" 69 | dev_name=$(hdiutil info | egrep --color=never '^/dev/' | sed 1q | awk '{print $1}') 70 | test -d "${mount_path}" && hdiutil detach "${dev_name}" 71 | 72 | # Mount the image as RW. 73 | dev_name=$(hdiutil attach -readwrite -noverify -noautoopen "${temp_dmg}" | egrep --color=never '^/dev/' | sed 1q | awk '{print $1}') 74 | # Link in the apps dir 75 | ln -s /Applications "$mount_path/Applications" 76 | # Copy the icon, for fun. 77 | cp "${icon_icns}" "$mount_path/.VolumeIcon.icns" 78 | SetFile -c icnC "$mount_path/.VolumeIcon.icns" 79 | 80 | # Run the thing 81 | "/usr/bin/osascript" "release/dmg.applescript" "${app_name}" || true 82 | sleep 2 83 | 84 | chmod -Rf go-w "${mount_path}" &> /dev/null || true 85 | 86 | bless --folder "${mount_path}" 87 | 88 | # tell the volume that it has a special file attribute for the icons. 89 | SetFile -a C "${mount_path}" 90 | 91 | hdiutil detach "${dev_name}" 92 | hdiutil convert "${temp_dmg}" \ 93 | -format "UDZO" \ 94 | -imagekey zlib-level=9 \ 95 | -o "${dmg_path}" 96 | rm -f "${temp_dmg}" 97 | 98 | echo "Signing dmg" 99 | codesign --verify --verbose --force --deep --sign \ 100 | "${app_identity}" \ 101 | --entitlements "${entitlements}" \ 102 | --options=runtime \ 103 | "${dmg_path}" 104 | 105 | echo "Creating an Apple notary request" 106 | xcrun altool --notarize-app \ 107 | --primary-bundle-id "${bundle_ident}" \ 108 | --username "${email}" \ 109 | --password "@keychain:AC_PASSWORD" \ 110 | --file "${dmg_path}" 111 | 112 | echo "Check notary status later via: " 113 | echo " xcrun altool --notarization-history 0 -u \"${email}\" -p \"@keychain:AC_PASSWORD\"" 114 | echo " AND" 115 | echo " xcrun altool --notarization-info -u \"${email}\" -p \"@keychain:AC_PASSWORD\"" 116 | echo 117 | echo "Once successful, staple and you're done!:" 118 | echo " xcrun stapler staple \"${dmg_path}\"" 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Monarch Monery Transactions Tagger for Amazon Purchases 2 | 3 | ## UPDATE - This tool is IN PROGRESS, migrating from Mint over to Monarch Money. Please wait for this message to disappear before attempting to use it. ## 4 | 5 | ## Overview ## 6 | 7 | # TODO: Rewrite sans Mint 8 | 9 | Do you order a lot from Amazon? Tired of everything showing up as "Amazon" 10 | and category "Shopping" in mint.com? Then this tool is for you! 11 | 12 | This tool takes an Amazon "Request your data" Order export and matches 13 | your Amazon order history with your Mint transactions. If it finds an exact matches 14 | it will either: 15 | 16 | - Update the transaction description and category if there was only 1 item 17 | - Itemize the transaction - one line-item per item in the order (via Mint transaction splits) 18 | 19 | If the tool chooses poor categories for your transactions simply change it! The next time you run the tool it will remember your past personalized category edits and attempt to apply it to future purchases of the same 20 | item. This only works if the item names match exactly. Also, you must 21 | change all (or the majority of) all the past, tagged examples of that item for the tool to pick up the hint. Put another way: if you only change 1 past transaction and you have 10 purchases of that same item the tool will take whatever the most common category used for that item. 22 | 23 | This tool **will not** retag or touch transactions that have already been 24 | tagged. Feel free to adjust categories after the fact without fear that the 25 | next run will wipe everything out. If you do want to re-tag 26 | previously tagged transactions take a look at the retag_changed option. 27 | 28 | This tool **does not** save your username or password. The tool is powered by the [Selenium framework](https://www.selenium.dev/) to automate an instance of the Chrome/Chromium browser. When running the tool it will prompt for the username and password and then enter it into the browser for you. There are options that allow for manual user operation of the login flows for both Mint and Amazon. 29 | 30 | This tool **does not** require an Amazon store card/Visa. All you need is to pay for your Amazon charges with an account that is synchronized with Mint. For example, if you alternate between 5 different credit cards to pay for purchases on your Amazon account, only the transactions from credit cards synchronized with Mint will get tagged. 31 | 32 | Some things the tagger cannot do: 33 | 34 | - Amazon credit card award points are not reported anywhere in the order/item reports. 35 | - Amazon gift cards are not yet supported (see [issue #59](https://github.com/jprouty/mint-amazon-tagger/issues/59)) 36 | 37 | ## Sponsorship ## 38 | 39 | This project has been a passion project of [mine](https://github.com/jprouty) to better understand cashflow (critical to trend analysis and budgeting). 40 | 41 | If you have found this tool useful, please consider [sponsoring me](https://github.com/sponsors/jprouty). 42 | 43 | ## Install and Getting started ## 44 | 45 | ### EASIEST - Pre-built binaries ### 46 | 47 | Please download the latest version from [github's releases page](https://github.com/jprouty/mint-amazon-tagger/releases) 48 | 49 | ### ADVANCED - Docker Headless CLI ### 50 | 51 | You can run the Mint Amazon Tagger via docker like so: 52 | 53 | ``` 54 | # Check out this git repo if not already: 55 | git clone https://github.com/jprouty/mint-amazon-tagger.git 56 | cd mint-amazon-tagger 57 | 58 | # Build the image: 59 | docker build -t mint-amazon-tagger . 60 | 61 | # Run the container: 62 | docker run -it --rm mint-amazon-tagger 63 | ``` 64 | 65 | If you're using ARM, you need to build with: 66 | 67 | ``` 68 | docker build --platform linux/amd64 -t mint-amazon-tagger . 69 | ``` 70 | 71 | ### ADVANCED - Run from python source ### 72 | 73 | #### Setup #### 74 | 75 | 1. `pip3 install mint-amazon-tagger` 76 | 77 | 2. To get the latest from time to time, update your version: 78 | `pip3 install --upgrade mint-amazon-tagger` 79 | 80 | 3. Chromedriver should be fetched automatically. But if you run into issues, 81 | try this: 82 | 83 | ``` 84 | # Mac: 85 | brew tap homebrew/cask 86 | brew cask install chromedriver 87 | 88 | # Ubuntu/Debian: 89 | # See also: https://askubuntu.com/questions/539498/where-does-chromedriver-install-to 90 | sudo apt-get install chromium-chromedriver 91 | ``` 92 | 93 | #### Running - Full Auto GUI #### 94 | 95 | This mode will fetch your Amazon Order History for you as well as tag mint. 96 | 97 | 1. `mint-amazon-tagger` 98 | 99 | 1. Plug in all your info into the app! 100 | 101 | #### Running - Full Auto CLI #### 102 | 103 | This mode will fetch your Amazon Order History for you as well as tag mint. 104 | 105 | 1. `mint-amazon-tagger-cli --amazon_email email@cool.com --mint_email couldbedifferent@aol.com` 106 | 107 | #### Running - Semi-Auto #### 108 | 109 | This mode requires you to fetch your Amazon Order History manually, then the 110 | tagger automates the rest. 111 | 112 | 1. Generate and download your Amazon Order History Reports. 113 | 114 | a. Login and visit [Amazon Order History 115 | Reports](https://www.amazon.com/gp/b2b/reports) 116 | 117 | b. "Request Report" for "Items", "charges and shipments", and "Refunds". Make sure the 118 | date ranges are the same. 119 | 120 | c. Download the completed reports. Let's called them 121 | `Items.csv charges.csv Refunds.csv` for this walk-through. Note that 122 | Refunds is optional! Yay. 123 | 124 | 2. (Optional) Do a dry run! Make sure everything looks right first. Run: 125 | `mint-amazon-tagger-cli --items_csv Items.csv --charges_csv charges.csv --refunds_csv Refunds.csv --dry_run --mint_email yourEmail@here.com` 126 | 127 | 3. Now perform the actual updates, without `--dry_run`: 128 | `mint-amazon-tagger-cli --items_csv Items.csv --charges_csv charges.csv --refunds_csv Refunds.csv --mint_email yourEmail@here.com` 129 | 130 | 4. Sit back and relax! The run time depends on the speed of your machine, 131 | quality of internet connection, and total number of transactions. For 132 | reference, my machine did about 14k Mint transactions, finding 2k Amazon 133 | matches in under 10 minutes. 134 | 135 | To see all options, see: 136 | `mint-amazon-tagger-cli --help` 137 | 138 | ## Tips and Tricks ## 139 | 140 | Not every bank treats Amazon purchases the same, or processes transactions as quickly as others. If you are having a low match rate (look at the terminal output after completion), then try adjusting some of the options or command line flags. To see a complete list, run `mint-amazon-tagger-cli --help`. 141 | 142 | Some common options to try: 143 | 144 | - --mint_input_include_inferred_description. This allows for more generous consideration of Mint transactions for matching. See [more context here](https://github.com/jprouty/mint-amazon-tagger/issues/50) 145 | - --mint_input_include_user_description. Similar to above; considers the current description as shown in the Mint tool (including any user edits). 146 | - --max_days_between_payment_and_shipping. If your bank is slow at posting payments, adjusting this value up to 7 or more will increase your chance of matching. If you have a high volume of purchases, this can increase your chance of mis-tagging items. 147 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/mmclient.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import logging 4 | import os 5 | import time 6 | import typing 7 | 8 | from monarchmoney import MonarchMoney 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class MonarchMoneyClient: 14 | args = None 15 | mm = None 16 | # To signify a successful user login when args.mm_user_will_login is present. 17 | user_login_success = False 18 | 19 | def __init__(self, args): 20 | self.args = args 21 | 22 | def hasValidCredentialsForLogin(self): 23 | return self.args.mm_email and self.args.mm_password 24 | 25 | def is_logged_in(self): 26 | return self.mm is not None 27 | 28 | async def login(self): 29 | if self.is_logged_in(): 30 | return True 31 | if not self.hasValidCredentialsForLogin(): 32 | logger.error("Missing Monarch Money email or password.") 33 | return False 34 | 35 | self.mm = MonarchMoney() 36 | await self.mm.login(self.args.mm_email, self.args.mm_password) 37 | if self.args.mm_wait_for_sync: 38 | await self.mm.request_accounts_refresh_and_wait( 39 | account_ids=self.args.mm_account_ids 40 | ) 41 | 42 | return True 43 | 44 | async def get_transactions( 45 | self, 46 | from_date: typing.Optional[datetime.date] = None, 47 | to_date: typing.Optional[datetime.date] = None, 48 | ): 49 | if self.args.use_json_backup: 50 | json_path = _json_transactions_path( 51 | self.args.mm_json_backup_path, self.args.use_json_backup 52 | ) 53 | if not os.path.exists(json_path): 54 | raise Exception(f"JSON backup file not found: {json_path}") 55 | logger.info(f"Loading Transactions from json file: {json_path}") 56 | with open(json_path, "r") as json_in: 57 | results = json.load(json_in) 58 | return results 59 | 60 | if not await self.login(): 61 | logger.error("Cannot login") 62 | return [] 63 | logger.info( 64 | f"Getting all Monarch Money transactions since {from_date} to {to_date}." 65 | ) 66 | 67 | limit = 100 68 | offset = 0 69 | start_date = None 70 | if from_date: 71 | start_date = from_date.strftime("%Y-%m-%d") 72 | end_date = None 73 | if to_date: 74 | end_date = to_date.strftime("%Y-%m-%d") 75 | 76 | response = await self.mm.get_transactions( 77 | limit=limit, 78 | offset=offset, 79 | start_date=start_date, 80 | end_date=end_date, 81 | account_ids=self.args.mm_account_ids or [], 82 | ) 83 | results = [] 84 | while True: 85 | if ( 86 | not response 87 | or response["allTransactions"]["totalCount"] == 0 88 | or not response["allTransactions"]["results"] 89 | ): 90 | return results 91 | num_transactions = len(response["allTransactions"]["results"]) 92 | total_count = len(response["allTransactions"]["totalCount"]) 93 | logger.info(f"Received {num_transactions} transactions.") 94 | logger.info(f"Total of {total_count} transactions.") 95 | results.extend(response["allTransactions"]["results"]) 96 | offset += num_transactions 97 | if offset == total_count: 98 | break 99 | response = await self.mm.get_transactions( 100 | limit=limit, 101 | offset=offset, 102 | start_date=start_date, 103 | end_date=end_date, 104 | account_ids=self.args.mm_account_ids or [], 105 | ) 106 | 107 | if self.args.save_json_backup: 108 | json_path = _json_transactions_path( 109 | self.args.mm_json_backup_path, int(time.time()) 110 | ) 111 | logger.info(f"Saving Transactions to json file: {json_path}") 112 | with open(json_path, "w") as json_out: 113 | json.dump(results, json_out) 114 | 115 | return results 116 | 117 | async def get_categories(self): 118 | if self.args.use_json_backup: 119 | json_path = _json_categories_path( 120 | self.args.mm_json_backup_path, self.args.use_json_backup 121 | ) 122 | if not os.path.exists(json_path): 123 | raise Exception(f"JSON backup file not found: {json_path}") 124 | logger.info(f"Loading Categories from json file: {json_path}") 125 | with open(json_path, "r") as json_in: 126 | results = json.load(json_in) 127 | return results 128 | if not await self.login(): 129 | logger.error("Cannot login") 130 | return [] 131 | logger.info("Getting Monarch Money categories.") 132 | 133 | results = [] 134 | 135 | if self.args.save_json_backup: 136 | json_path = _json_categories_path( 137 | self.args.mm_json_backup_path, int(time.time()) 138 | ) 139 | logger.info(f"Saving Categories to json file: {json_path}") 140 | with open(json_path, "w") as json_out: 141 | json.dump(results, json_out) 142 | 143 | return results 144 | 145 | def send_updates(self, updates, progress, ignore_category: bool = False): 146 | if not self.login(): 147 | logger.error("Cannot login") 148 | return 0 149 | num_requests = 0 150 | return num_requests 151 | # for orig_trans, new_trans in updates: 152 | # if len(new_trans) == 1: 153 | # # Update the existing transaction. 154 | # trans = new_trans[0] 155 | # modify_trans = { 156 | # "type": trans.type, 157 | # "description": trans.description, 158 | # "notes": trans.notes, 159 | # } 160 | # if not ignore_category: 161 | # modify_trans = { 162 | # **modify_trans, 163 | # "category": {"id": trans.category.id}, 164 | # } 165 | 166 | # logger.debug(f'Sending a "modify" transaction request: {modify_trans}') 167 | # response = self.webdriver.request( 168 | # "PUT", 169 | # f"{MINT_TRANSACTIONS}/{trans.id}", 170 | # json=modify_trans, 171 | # headers=self.get_api_header(), 172 | # ) 173 | # logger.debug(f"Received response: {response.__dict__}") 174 | # progress.next() 175 | # num_requests += 1 176 | # else: 177 | # # Split the existing transaction into many. 178 | # split_children = [] 179 | # for trans in new_trans: 180 | # category = ( 181 | # orig_trans.category if ignore_category else trans.category 182 | # ) 183 | # itemized_split = { 184 | # "amount": f"{micro_usd_to_float_usd(trans.amount)}", 185 | # "description": trans.description, 186 | # "category": {"id": category.id, "name": category.name}, 187 | # "notes": trans.notes, 188 | # } 189 | # split_children.append(itemized_split) 190 | 191 | # split_edit = { 192 | # "type": orig_trans.type, 193 | # "amount": micro_usd_to_float_usd(orig_trans.amount), 194 | # "splitData": {"children": split_children}, 195 | # } 196 | # logger.debug(f'Sending a "split" transaction request: {split_edit}') 197 | # response = self.webdriver.request( 198 | # "PUT", 199 | # f"{MINT_TRANSACTIONS}/{trans.id}", 200 | # json=split_edit, 201 | # headers=self.get_api_header(), 202 | # ) 203 | # logger.debug(f"Received response: {response.__dict__}") 204 | # progress.next() 205 | # num_requests += 1 206 | 207 | # progress.finish() 208 | # return num_requests 209 | 210 | 211 | def _json_transactions_path(prefix: str, time_epoch: int): 212 | return os.path.join(prefix, f"{time_epoch} Transactions.json") 213 | 214 | 215 | def _json_categories_path(prefix: str, time_epoch: int): 216 | return os.path.join(prefix, f"{time_epoch} Categories.json") 217 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This script fetches Amazon "Order History Reports" and annotates your Monarch Money 4 | # transactions based on actual items in each purchase. It can handle charges 5 | # that are split into multiple shipments/charges, and can even itemized each 6 | # transaction for maximal control over categorization. 7 | 8 | import argparse 9 | import atexit 10 | from collections import defaultdict 11 | import getpass 12 | import logging 13 | import os 14 | from signal import signal, SIGINT 15 | import time 16 | 17 | from outdated import check_outdated 18 | 19 | from monarchmoneyamazontagger import amazon 20 | from monarchmoneyamazontagger import mm 21 | from monarchmoneyamazontagger import tagger 22 | from monarchmoneyamazontagger import VERSION 23 | from monarchmoneyamazontagger.args import define_cli_args, TAGGER_BASE_PATH 24 | from monarchmoneyamazontagger.my_progress import ( 25 | counter_progress_cli, 26 | determinate_progress_cli, 27 | indeterminate_progress_cli, 28 | ) 29 | from monarchmoneyamazontagger.mmclient import MonarchMoneyClient 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | 34 | def main(): 35 | root_logger = logging.getLogger() 36 | root_logger.setLevel(logging.INFO) 37 | root_logger.addHandler(logging.StreamHandler()) 38 | # Disable noisy log spam from filelock from within tldextract. 39 | logging.getLogger("filelock").setLevel(logging.WARN) 40 | 41 | # For helping remote debugging, also log to file. 42 | # Developers should be vigilant to NOT log any PII, ever (including being 43 | # mindful of what exceptions might be thrown). 44 | log_directory = os.path.join(TAGGER_BASE_PATH, "Tagger Logs") 45 | os.makedirs(log_directory, exist_ok=True) 46 | log_filename = os.path.join( 47 | log_directory, f'{time.strftime("%Y-%m-%d_%H-%M-%S")}.log' 48 | ) 49 | file_handler = logging.FileHandler(log_filename) 50 | file_handler.setFormatter( 51 | logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") 52 | ) 53 | file_handler.setLevel(logging.DEBUG) 54 | root_logger.addHandler(file_handler) 55 | 56 | logger.info(f"Running version {VERSION}") 57 | # try: 58 | # is_outdated, latest_version = check_outdated( 59 | # "monarchmoney-amazon-tagger", VERSION 60 | # ) 61 | # if is_outdated: 62 | # logger.warning( 63 | # "Please update your version by running:\n" 64 | # "pip3 install monarchmoney-amazon-tagger --upgrade\n\n" 65 | # ) 66 | # except ValueError: 67 | # logger.error(f"Version {VERSION} is newer than PyPY version") 68 | 69 | parser = argparse.ArgumentParser( 70 | description="Tag Monarch Money transactions based on itemized Amazon history." 71 | ) 72 | define_cli_args(parser) 73 | args = parser.parse_args() 74 | 75 | if args.version: 76 | print(f"monarchmoney-amazon-tagger {VERSION}\nBy: Jeff Prouty") 77 | exit(0) 78 | 79 | mmc = MonarchMoneyClient(args) 80 | 81 | if not args.amazon_export: 82 | logger.critical("One or more Amazon Export Zip files required.") 83 | exit(1) 84 | 85 | if args.dry_run: 86 | logger.info("\nDry Run; no modifications being sent to Monarch Money.\n") 87 | 88 | def on_critical(msg): 89 | logger.critical(msg) 90 | exit(1) 91 | 92 | maybe_prompt_for_credentials(args) 93 | results = tagger.create_updates( 94 | args, 95 | mmc, 96 | on_critical=on_critical, 97 | indeterminate_progress_factory=indeterminate_progress_cli, 98 | determinate_progress_factory=determinate_progress_cli, 99 | counter_progress_factory=counter_progress_cli, 100 | ) 101 | 102 | if not results.success: 103 | logger.critical("Uncaught error from create_updates. Exiting") 104 | exit(1) 105 | 106 | log_amazon_stats(results.items, results.charges) # , results.refunds) 107 | log_processing_stats(results.stats) 108 | 109 | if args.print_unmatched and results.unmatched_charges: 110 | logger.warning( 111 | "The following were not matched to Monarch Money transactions:\n" 112 | ) 113 | by_oid = defaultdict(list) 114 | for uo in results.unmatched_charges: 115 | by_oid[uo.order_id()].append(uo) 116 | 117 | for unmatched_by_oid in by_oid.values(): 118 | c = amazon.Charge.merge(unmatched_by_oid) 119 | print_unmatched(c) 120 | 121 | if not results.updates: 122 | logger.info("All done; no new tags to be updated at this point in time!") 123 | exit(0) 124 | 125 | if args.dry_run: 126 | logger.info("Dry run. Following are proposed changes:") 127 | if args.skip_dry_print: 128 | logger.info("Dry run print results skipped!") 129 | else: 130 | tagger.print_dry_run( 131 | results.updates, ignore_category=args.no_tag_categories 132 | ) 133 | else: 134 | num_updates = mmc.send_updates( 135 | results.updates, 136 | progress=determinate_progress_cli( 137 | "Updating Monarch Money", max=len(results.updates) 138 | ), 139 | ignore_category=args.no_tag_categories, 140 | ) 141 | 142 | logger.info(f"Sent {num_updates} updates to Monarch Money") 143 | 144 | 145 | def maybe_prompt_for_credentials(args): 146 | if not args.mm_email and not args.use_json_backup: 147 | args.mm_email = input("Money Monarch email: ") 148 | if not args.mm_password and not args.use_json_backup: 149 | args.mm_password = getpass.getpass("Money Monarch password: ") 150 | 151 | 152 | def log_amazon_stats(items, charges): 153 | logger.info("\nAmazon Stats:") 154 | if len(charges) == 0 or len(items) == 0: 155 | logger.info("\tThere were not Amazon charges/items!") 156 | return 157 | oid = set([i.order_id for i in items]) 158 | logger.info( 159 | f'\n{len(oid)} total Amazon orders\n{len(charges)} payment "charges"\n{sum([i.quantity for i in items])} total items ordered' 160 | ) 161 | 162 | first_order_date = min([o.transact_date() for o in charges if o.transact_date()]) 163 | last_order_date = max([o.transact_date() for o in charges if o.transact_date()]) 164 | logger.info(f"Charges ranging from {first_order_date} to {last_order_date}") 165 | 166 | per_item_totals = [i.total() for i in items] 167 | per_order_totals = [c.total_owed() for c in charges] 168 | 169 | logger.info(f"{str(sum(per_order_totals))} total spend") 170 | 171 | logger.info( 172 | f"{str(sum(per_order_totals) / len(oid))} avg " 173 | f"order total (range: {str(min(per_order_totals))}" 174 | f" - {str(max(per_order_totals))})" 175 | ) 176 | logger.info( 177 | f"{str(sum(per_item_totals) / len(items))} avg " 178 | f"item price (range: {str(min(per_item_totals))}" 179 | f" - {str(max(per_item_totals))})" 180 | ) 181 | 182 | # if refunds: 183 | # first_refund_date = min( 184 | # [r.refund_date for r in refunds if r.refund_date]) 185 | # last_refund_date = max( 186 | # [r.refund_date for r in refunds if r.refund_date]) 187 | # logger.info( 188 | # f'\n{len(refunds)} refunds dating from ' 189 | # f'{first_refund_date} to {last_refund_date}') 190 | 191 | # per_refund_totals = [r.total_refund_amount for r in refunds] 192 | 193 | # logger.info( 194 | # f'{str(sum(per_refund_totals))} ' 195 | # 'total refunded') 196 | 197 | 198 | def log_processing_stats(stats): 199 | # 'Refunds matched w/ transactions: {refund_match} (unmatched refunds: ' 200 | # '{refund_unmatch})\n' 201 | 202 | logger.info( 203 | "\n{trans} Monarch Money transactions from {earliest_transaction_date} to {latest_transaction_date}\n" 204 | 'Transactions w/ "Amazon" in description: {amazon_in_desc}\n' 205 | "Transactions ignored: is pending: {pending}\n" 206 | "\n" 207 | "charges matched w/ transactions: {order_match} (unmatched charges: " 208 | "{order_unmatch})\n" 209 | "Transactions matched w/ charges: {trans_match} (unmatched: " 210 | "{trans_unmatch})\n" 211 | "\n" 212 | "charges skipped: not shipped: {skipped_charges_unshipped}\n" 213 | "charges skipped: gift card used: {skipped_charges_gift_card}\n" 214 | "\n" 215 | "Order fix-up: incorrect tax itemization: {adjust_itemized_tax}\n" 216 | "Order fix-up: remove erroneous shipping: {rm_shipping_error}\n" 217 | "Order fix-up: has a misc charges (e.g. gift wrap): {misc_charge}\n" 218 | "\n" 219 | "Transactions ignored; already tagged & up to date: " 220 | "{already_up_to_date}\n" 221 | "Transactions ignored; ignore retags: {no_retag}\n" 222 | "Transactions ignored; user skipped retag: {user_skipped_retag}\n" 223 | "\n" 224 | "Transactions with personalize categories: {personal_cat}\n" 225 | "\n" 226 | "Transactions to be retagged: {retag}\n" 227 | "Transactions to be newly tagged: {new_tag}\n".format(**stats) 228 | ) 229 | 230 | 231 | def print_unmatched(amzn_obj): 232 | proposed_desc = mm.summarize_title( 233 | [i.get_title() for i in amzn_obj.items], f"{amzn_obj.website()}: " 234 | ) 235 | logger.warning(proposed_desc) 236 | logger.warning( 237 | "\t{}\t{}\t{}".format( 238 | amzn_obj.transact_date() if amzn_obj.transact_date() else "Never shipped!", 239 | str(amzn_obj.transact_amount()), 240 | amazon.get_invoice_url(amzn_obj.order_id()), 241 | ) 242 | ) 243 | logger.warning("") 244 | 245 | 246 | if __name__ == "__main__": 247 | main() 248 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/mm_test.py: -------------------------------------------------------------------------------- 1 | # from datetime import date 2 | # import unittest 3 | 4 | # from monarchmoneyamazontagger import category 5 | # from monarchmoneyamazontagger import mint 6 | # from monarchmoneyamazontagger.mint import Transaction 7 | # from monarchmoneyamazontagger.mockdata import transaction, MINT_CATEGORIES 8 | 9 | 10 | # class HelpMethods(unittest.TestCase): 11 | # def test_truncate_title(self): 12 | # self.assertEqual( 13 | # mint.truncate_title("Some great title [", 20), "Some great title" 14 | # ) 15 | # self.assertEqual(mint.truncate_title(" Some great title abc", 5), "Some") 16 | # self.assertEqual(mint.truncate_title("S", 1), "S") 17 | # self.assertEqual( 18 | # mint.truncate_title("Some great title [", 20, "Amazon: "), 19 | # "Amazon: Some great", 20 | # ) 21 | # self.assertEqual( 22 | # mint.truncate_title("Some great title [", 20, "2x: "), 23 | # "2x: Some great title", 24 | # ) 25 | 26 | # def test_convertCamel_to_underscores(self): 27 | # self.assertEqual( 28 | # mint.convertCamel_to_underscores("somethingGreatIsThis"), 29 | # "something_great_is_this", 30 | # ) 31 | # self.assertEqual(mint.convertCamel_to_underscores("oneword"), "oneword") 32 | # self.assertEqual(mint.convertCamel_to_underscores("CapCase?"), "cap_case?") 33 | 34 | # def test_parse_mint_date(self): 35 | # self.assertEqual(mint.parse_mint_date("2020-01-10"), date(2020, 1, 10)) 36 | # self.assertEqual(mint.parse_mint_date("2022-11-30"), date(2022, 11, 30)) 37 | # self.assertEqual(mint.parse_mint_date("2019-10-08"), date(2019, 10, 8)) 38 | 39 | 40 | # class TransactionClass(unittest.TestCase): 41 | # def test_constructor(self): 42 | # trans = transaction() 43 | # self.assertEqual(trans.amount, -11950000) 44 | # self.assertEqual(trans.date, date(2014, 2, 28)) 45 | # self.assertFalse(trans.matched) 46 | # self.assertEqual(trans.charges, []) 47 | # self.assertEqual(trans.children, []) 48 | 49 | # trans = transaction(amount=-423.12) 50 | # self.assertEqual(trans.amount, -423120000) 51 | 52 | # def test_split(self): 53 | # trans = transaction() 54 | # strans = trans.split(1234, "Shopping", "Some new item", "Test note") 55 | # self.assertNotEqual(trans, strans) 56 | # self.assertEqual(strans.amount, 1234) 57 | # self.assertEqual(strans.category.name, "Shopping") 58 | # self.assertEqual(strans.description, "Some new item") 59 | # self.assertEqual(strans.notes, "Test note") 60 | 61 | # def test_match(self): 62 | # trans = transaction() 63 | # charges = [1, 2, 3] 64 | # trans.match(charges) 65 | 66 | # self.assertTrue(trans.matched) 67 | # self.assertEqual(trans.charges, charges) 68 | 69 | # def test_bastardize(self): 70 | # child = transaction(parent_id=123) 71 | # self.assertEqual(child.parent_id, 123) 72 | # child.bastardize() 73 | # self.assertEqual(child.parent_id, None) 74 | 75 | # def test_update_category_id(self): 76 | # trans = transaction() 77 | # # Give it a mismatch initially: 78 | # trans.category.id = 99 79 | # trans.update_category_id(MINT_CATEGORIES) 80 | # self.assertEqual(trans.category.id, "8_4") 81 | 82 | # # Invalid name will silently retain the old id: 83 | # trans.category.name = "SOME INVALID CAT" 84 | # self.assertEqual(trans.category.id, "8_4") 85 | 86 | # trans.category.name = "Shopping" 87 | # trans.update_category_id(MINT_CATEGORIES) 88 | # self.assertEqual(trans.category.id, "8_2") 89 | 90 | # def test_get_compare_tuple(self): 91 | # trans = transaction(description="Simple Title", amount=-1.00) 92 | # self.assertEqual( 93 | # trans.get_compare_tuple(), 94 | # ("Simple Title", "-$1.00", "Great note here", "Personal Care"), 95 | # ) 96 | 97 | # trans2 = transaction(description="Simple Refund", amount=2.01) 98 | # self.assertEqual( 99 | # trans2.get_compare_tuple(True), 100 | # ("Simple Refund", "$2.01", "Great note here"), 101 | # ) 102 | 103 | # def test_dry_run_str(self): 104 | # trans = transaction() 105 | 106 | # self.assertTrue("2014-02-28" in trans.dry_run_str()) 107 | # self.assertTrue("-$11.95" in trans.dry_run_str()) 108 | # self.assertTrue("Personal Care" in trans.dry_run_str()) 109 | # self.assertTrue("Amazon" in trans.dry_run_str()) 110 | 111 | # self.assertTrue("--IGNORED--" in trans.dry_run_str(True)) 112 | # self.assertFalse("Personal Care" in trans.dry_run_str(True)) 113 | 114 | # def test_sum_amounts(self): 115 | # self.assertEqual(Transaction.sum_amounts([]), 0) 116 | 117 | # trans1 = transaction(amount=-2.34) 118 | # self.assertEqual(Transaction.sum_amounts([trans1]), -2340000) 119 | 120 | # trans2 = transaction(amount=-8.00) 121 | # self.assertEqual(Transaction.sum_amounts([trans1, trans2]), -10340000) 122 | 123 | # credit = transaction(amount=20.20) 124 | # self.assertEqual(Transaction.sum_amounts([trans1, credit, trans2]), 9860000) 125 | 126 | # def test_unsplit(self): 127 | # self.assertEqual(Transaction.unsplit([]), []) 128 | 129 | # not_child1 = transaction(amount=-1.00) 130 | # self.assertEqual(Transaction.unsplit([not_child1]), [not_child1]) 131 | 132 | # not_child2 = transaction(amount=-2.00) 133 | # self.assertEqual( 134 | # Transaction.unsplit([not_child1, not_child2]), [not_child1, not_child2] 135 | # ) 136 | 137 | # child1_to_1 = transaction(amount=-3.00, parent_id=1) 138 | # child2_to_1 = transaction(amount=-4.00, parent_id=1) 139 | # child3_to_1 = transaction(amount=8.00, parent_id=1) 140 | # child1_to_99 = transaction(amount=-5.00, parent_id=99) 141 | 142 | # one_child_actual = Transaction.unsplit([child1_to_1]) 143 | # self.assertEqual(one_child_actual[0].amount, child1_to_1.amount) 144 | # self.assertEqual(one_child_actual[0].parent_id, None) 145 | # self.assertEqual(one_child_actual[0].id, 1) 146 | # self.assertEqual(one_child_actual[0].children, [child1_to_1]) 147 | 148 | # three_children = [child1_to_1, child2_to_1, child3_to_1] 149 | # three_child_actual = Transaction.unsplit(three_children) 150 | # self.assertEqual(three_child_actual[0].amount, 1000000) 151 | # self.assertEqual(three_child_actual[0].parent_id, None) 152 | # self.assertEqual(three_child_actual[0].id, 1) 153 | # self.assertEqual(three_child_actual[0].children, three_children) 154 | 155 | # crazy_actual = Transaction.unsplit( 156 | # [not_child1, not_child2] + three_children + [child1_to_99] 157 | # ) 158 | # self.assertEqual(crazy_actual[0], not_child1) 159 | # self.assertEqual(crazy_actual[1], not_child2) 160 | # self.assertEqual(crazy_actual[2].id, 1) 161 | # self.assertEqual(crazy_actual[2].children, three_children) 162 | # self.assertEqual(crazy_actual[3].id, 99) 163 | # self.assertEqual(crazy_actual[3].children, [child1_to_99]) 164 | 165 | # def test_old_and_new_are_identical(self): 166 | # trans1 = transaction(amount=-5.00, description="ABC") 167 | # trans2 = transaction(amount=-5.00, description="ABC", category="Shipping") 168 | 169 | # self.assertTrue(Transaction.old_and_new_are_identical(trans1, [trans1])) 170 | # self.assertFalse(Transaction.old_and_new_are_identical(trans1, [trans2])) 171 | # self.assertTrue(Transaction.old_and_new_are_identical(trans1, [trans2], True)) 172 | 173 | # new_trans = [ 174 | # transaction(amount=-2.50, description="ABC"), 175 | # transaction(amount=-2.50, description="ABC"), 176 | # ] 177 | # trans1.children = new_trans 178 | # self.assertTrue(Transaction.old_and_new_are_identical(trans1, new_trans)) 179 | 180 | # def test_itemize_new_trans(self): 181 | # self.assertEqual(mint.itemize_new_trans([], "Sweet: "), []) 182 | 183 | # trans = [ 184 | # transaction(amount=-5.00, description="ABC"), 185 | # transaction(amount=-15.00, description="CBA"), 186 | # ] 187 | # itemized_trans = mint.itemize_new_trans(trans, "Sweet: ") 188 | # self.assertEqual(itemized_trans[0].description, "Sweet: CBA") 189 | # self.assertEqual(itemized_trans[0].amount, -15000000) 190 | # self.assertEqual(itemized_trans[1].description, "Sweet: ABC") 191 | # self.assertEqual(itemized_trans[1].amount, -5000000) 192 | 193 | # def test_summarize_new_trans(self): 194 | # original_trans = transaction( 195 | # amount=-40.00, description="Amazon", notes="Test note" 196 | # ) 197 | 198 | # item1 = transaction(amount=-15.00, description="Item 1") 199 | # item2 = transaction(amount=-25.00, description="Item 2") 200 | # shipping = transaction(amount=-5.00, description="Shipping") 201 | # free_shipping = transaction(amount=-5.00, description="Promotion(s)") 202 | 203 | # actual_summary = mint.summarize_new_trans( 204 | # original_trans, [item1, item2, shipping, free_shipping], "Amazon.com: " 205 | # )[0] 206 | 207 | # self.assertEqual(actual_summary.amount, original_trans.amount) 208 | # self.assertEqual(actual_summary.category.name, category.DEFAULT_MINT_CATEGORY) 209 | # self.assertEqual(actual_summary.description, "Amazon.com: Item 1, Item 2") 210 | # self.assertTrue("Item 1" in actual_summary.notes) 211 | # self.assertTrue("Item 2" in actual_summary.notes) 212 | # self.assertTrue("Shipping" in actual_summary.notes) 213 | # self.assertTrue("Promotion(s)" in actual_summary.notes) 214 | 215 | # def test_summarize_new_trans_one_item_keeps_category(self): 216 | # original_trans = transaction( 217 | # amount=-40.00, description="Amazon", notes="Test note" 218 | # ) 219 | 220 | # item1 = transaction( 221 | # amount=-15.00, 222 | # description="Giant paper shredder", 223 | # category="Office Supplies", 224 | # ) 225 | # shipping = transaction(amount=-5.00, description="Shipping") 226 | # free_shipping = transaction(amount=-5.00, description="Promotion(s)") 227 | 228 | # actual_summary = mint.summarize_new_trans( 229 | # original_trans, [item1, shipping, free_shipping], "Amazon.com: " 230 | # )[0] 231 | 232 | # self.assertEqual(actual_summary.amount, original_trans.amount) 233 | # self.assertEqual(actual_summary.category.name, "Office Supplies") 234 | # self.assertEqual(actual_summary.description, "Amazon.com: Giant paper shredder") 235 | # self.assertTrue("Giant paper shredder" in actual_summary.notes) 236 | # self.assertTrue("Shipping" in actual_summary.notes) 237 | # self.assertTrue("Promotion(s)" in actual_summary.notes) 238 | 239 | 240 | # if __name__ == "__main__": 241 | # unittest.main() 242 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/args.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | 4 | TAGGER_BASE_PATH = os.path.join(os.path.expanduser("~"), "MintAmazonTagger") 5 | 6 | 7 | def get_name_to_help_dict(parser): 8 | return dict([(a.dest, a.help) for a in parser._actions]) 9 | 10 | 11 | def define_common_args(parser): 12 | """Parseargs shared between both CLI & GUI programs.""" 13 | # Amazon Input, as zip file: 14 | parser.add_argument( 15 | "--amazon_export", 16 | nargs="+", 17 | type=argparse.FileType("r", encoding="utf-8"), 18 | help=( 19 | 'One or more Amazon Data Exports zip file (type either "Orders" or "All Data").' 20 | ), 21 | ) 22 | 23 | # Monarch Money creds: 24 | parser.add_argument( 25 | "--mm_email", default=None, help=("Monarch Money e-mail address for login.") 26 | ) 27 | parser.add_argument( 28 | "--mm_password", default=None, help=("Monarch Money password for login.") 29 | ) 30 | 31 | # Prefix customization: 32 | parser.add_argument( 33 | "--description_prefix_override", 34 | type=str, 35 | help=( 36 | "The prefix to use when updating the description for each Mint " 37 | "transaction. By default, the 'Website' value from the Amazon " 38 | "Data Export is used. If a string is provided, use " 39 | "this instead for all matched transactions. If given, this is " 40 | "used in conjunction with amazon_domains to detect if a " 41 | "transaction has already been tagged by this tool." 42 | ), 43 | ) 44 | parser.add_argument( 45 | "--description_return_prefix_override", 46 | type=str, 47 | help=( 48 | "The prefix to use when updating the description for each Mint " 49 | "refund. By default, the 'Website' value from the Amazon " 50 | "Data Export is used with refund appended (e.g. " 51 | "'Amazon.com Refund: ...'. If a string is provided here, use " 52 | "this instead for all matched refunds. If given, this is " 53 | "used in conjunction with amazon_domains to detect if a " 54 | "refund has already been tagged by this tool." 55 | ), 56 | ) 57 | parser.add_argument( 58 | "--amazon_domains", 59 | type=str, 60 | # From: https://en.wikipedia.org/wiki/Amazon_(company)#Website 61 | default=( 62 | "amazon.com,amazon.cn,amazon.in,amazon.co.jp,amazon.com.sg," 63 | "amazon.com.tr,amazon.fr,amazon.de,amazon.it,amazon.nl," 64 | "amazon.es,amazon.co.uk,amazon.ca,amazon.com.mx," 65 | "amazon.com.au,amazon.com.br" 66 | ), 67 | help=( 68 | "A list of all valid Amazon domains/websites. These should " 69 | "match the website column from the Amazon Data Export and is used to " 70 | "detect if a transaction has already been tagged by this tool." 71 | ), 72 | ) 73 | 74 | # To itemize or not to itemize; that is the question: 75 | parser.add_argument( 76 | "--verbose_itemize", 77 | action="store_true", 78 | help=( 79 | "Itemize everything, instead of the default behavior, which is " 80 | "to not itemize out shipping/promos/etc if " 81 | "there is only one item per Monarch Money transaction. Will also remove " 82 | "free shipping." 83 | ), 84 | ) 85 | parser.add_argument( 86 | "--no_itemize", 87 | action="store_true", 88 | help=( 89 | "Do not split Monarch Money transactions into individual items with " 90 | "attempted categorization." 91 | ), 92 | ) 93 | 94 | parser.add_argument( 95 | "--num_updates", 96 | type=int, 97 | default=0, 98 | help=( 99 | "Only send the first N updates to Monarch Money (or print N updates at " 100 | "dry run). If not present, all updates are sent or printed." 101 | ), 102 | ) 103 | parser.add_argument( 104 | "--retag_changed", 105 | action="store_true", 106 | help=( 107 | "For transactions that have been previously tagged by this " 108 | "script, override any edits (like adjusting the category). This " 109 | 'feature works by looking for "Amazon.com: " at the start of a ' 110 | "transaction. If the user changes the description, then the " 111 | "tagger won't know to leave it alone." 112 | ), 113 | ) 114 | 115 | parser.add_argument( 116 | "--max_unmatched_charges_combinations", 117 | type=int, 118 | default=20, 119 | help=( 120 | "Maximum number of charges to attempt to combinatorically match with " 121 | "transactions. The current implementation is pretty memory intensive, " 122 | "so setting this higher will potentially consume all available memory, " 123 | "and cause the tagger to take a while." 124 | ), 125 | ) 126 | # Tagging options: 127 | parser.add_argument( 128 | "--no_tag_categories", 129 | action="store_true", 130 | help=( 131 | "Do not update Monarch Money categories. This is useful as " 132 | "Amazon doesn't provide the best categorization and it is " 133 | "pretty common user behavior to manually change the categories. " 134 | "This flag prevents tagger from wiping out that user work." 135 | ), 136 | ) 137 | parser.add_argument( 138 | "--do_not_predict_categories", 139 | action="store_true", 140 | help=( 141 | "Do not attempt to predict custom category tagging based on any " 142 | "tagging overrides. By default (no arg) tagger will attempt to " 143 | "find items that you have manually changed categories for." 144 | ), 145 | ) 146 | parser.add_argument( 147 | "--max_days_between_payment_and_shipping", 148 | type=int, 149 | default=5, 150 | help=( 151 | "How many days are allowed to pass between when Amazon has " 152 | "shipped an order and when the payment has posted to your " 153 | "bank account (as per Monarch Money's view)." 154 | ), 155 | ) 156 | parser.add_argument( 157 | "--mm_wait_for_sync", 158 | action="store_true", 159 | help=("Wait for Monarch Money to sync accounts immediately after login."), 160 | ) 161 | parser.add_argument( 162 | "--mm_account_ids", 163 | type=list, 164 | default=None, 165 | help=( 166 | "Only consider tagging transactions for the given Monarch Money accounts, " 167 | "provided by ID. To find the appropriate account ID, navigate to the " 168 | "account details page on the Monarch Money website and inspect the URL. " 169 | "Look for: https://app.monarchmoney.com/accounts/details/ACCOUNT_ID_NUMBER" 170 | ), 171 | ) 172 | parser.add_argument( 173 | "--mm_input_description_filter", 174 | type=str, 175 | default="amazon,amzn", 176 | help=( 177 | "Only consider Monarch Money transactions that have one of these strings " 178 | "in the description field. Case-insensitive comma-separated." 179 | ), 180 | ) 181 | parser.add_argument( 182 | "--mm_input_include_user_description", 183 | action="store_true", 184 | help=( 185 | "Consider using the current description from Monarch Money when " 186 | "determining if a transaction is an Amazon purchase. This will " 187 | "include any user edits or previous runs of MonarchMoneyAmazonTagger. " 188 | "This is similar to --mm_input_include_inferred_description." 189 | ), 190 | ) 191 | # TODO(jprouty): Revisit if this is still accurate in the FIData message. 192 | parser.add_argument( 193 | "--mm_input_include_inferred_description", 194 | action="store_true", 195 | help=( 196 | "Consider using the inferred description from Monarch Money's " 197 | '"FinancialInstitutionData" when determining if ' 198 | "a transaction is an Amazon purchase. This may be necessary " 199 | 'when a bank renames transactions to "Debit card payment". ' 200 | 'Monarch Money sometimes auto-recovers these into "Amazon", and flipping ' 201 | "this flag will help match these. To know if you should use it, " 202 | "find a transaction in the Monarch Money tool, and click on the details. " 203 | 'Look for "Appears on your BANK ACCOUNT NAME statement as NOT ' 204 | 'USEFUL NAME on DATE".' 205 | ), 206 | ) 207 | parser.add_argument( 208 | "--mm_input_categories_filter", 209 | type=str, 210 | help=( 211 | "Only consider Monarch Money transactions that match one of " 212 | "the given categories here. Comma separated list of Mint " 213 | "categories." 214 | ), 215 | ) 216 | 217 | parser.add_argument( 218 | "--save_json_backup", 219 | action="store_true", 220 | default=False, 221 | help=( 222 | "Saves a backup of your Monarch Money transactions to a json file, " 223 | "just in case anything goes wrong or for rapid " 224 | "development so you don't have to download from Monarch Money every " 225 | "time the tool is run. Off by default to prevent storing " 226 | "sensitive information locally without a user knowing it." 227 | ), 228 | ) 229 | parser.add_argument( 230 | "--use_json_backup", 231 | type=int, 232 | help=( 233 | "Do not fetch categories or transactions from Monarch Money. Use json " 234 | "backups from the given epoch instead. If coupled with --dry_run, no " 235 | "connection to Monarch Money is established." 236 | ), 237 | ) 238 | default_json_path = os.path.join(TAGGER_BASE_PATH, "Monarch Money Backup") 239 | parser.add_argument( 240 | "--mm_json_backup_path", 241 | type=str, 242 | default=default_json_path, 243 | help="Where to store the Monarch Money backup json files.", 244 | ) 245 | 246 | 247 | def define_gui_args(parser): 248 | define_common_args(parser) 249 | 250 | # TODO: Clean up and remove. 251 | parser.add_argument( 252 | "--prompt_retag", 253 | default=False, 254 | action="store_false", 255 | help=("Unsupported for gui; but must be defined to false."), 256 | ) 257 | 258 | 259 | def define_cli_args(parser): 260 | define_common_args(parser) 261 | 262 | # Debugging/testing. 263 | parser.add_argument( 264 | "--dry_run", 265 | action="store_true", 266 | help=( 267 | "Do not modify Mint transaction; instead print the proposed " 268 | "changes to console." 269 | ), 270 | ) 271 | parser.add_argument( 272 | "--skip_dry_print", 273 | action="store_true", 274 | help=("Do not print dry run results (useful for development)."), 275 | ) 276 | 277 | parser.add_argument( 278 | "-V", "--version", action="store_true", help="Shows the app version and quits." 279 | ) 280 | 281 | # Retag transactions that have already been tagged previously: 282 | parser.add_argument( 283 | "--prompt_retag", 284 | action="store_true", 285 | help=( 286 | "For transactions that have been previously tagged by this " 287 | "script, override any edits (like adjusting the category) but " 288 | "only after confirming each change. More gentle than " 289 | "--retag_changed" 290 | ), 291 | ) 292 | parser.add_argument( 293 | "--print_unmatched", 294 | action="store_true", 295 | help=("At completion, print unmatched charges to help manual tagging."), 296 | ) 297 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/mm.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from copy import deepcopy 3 | import datetime 4 | from dateutil.parser import parse as dateutil_parse 5 | import logging 6 | import re 7 | from typing import Any, List, Optional 8 | 9 | from monarchmoneyamazontagger import category 10 | from monarchmoneyamazontagger.micro_usd import MicroUSD 11 | from monarchmoneyamazontagger.my_progress import NoProgress 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def truncate_title(title, target_length, base_str=None): 17 | words = [] 18 | if base_str: 19 | words.extend([w for w in base_str.split(" ") if w]) 20 | target_length -= len(base_str) 21 | for word in title.strip().split(" "): 22 | if len(word) / 2 < target_length: 23 | words.append(word) 24 | target_length -= len(word) + 1 25 | else: 26 | break 27 | truncated = " ".join(words) 28 | # Remove any trailing symbol-y crap. 29 | while truncated and truncated[-1] in ",.-([]{}\\/|~!@#$%^&*_+=`'\" ": 30 | truncated = truncated[:-1] 31 | return truncated 32 | 33 | 34 | class Category(object): 35 | """A Monarch Money category.""" 36 | 37 | id: str 38 | name: str 39 | icon: Optional[str] 40 | __typename: str = "Category" 41 | 42 | def __init__(self, id, name, icon=None): 43 | self.id = id 44 | self.name = name 45 | self.icon = icon 46 | 47 | # def update_category_id(self, categories): 48 | # if self.name in categories: 49 | # self.id = categories[self.name]["id"] 50 | 51 | def __repr__(self): 52 | return f"{self.name}({self.id})" 53 | 54 | 55 | class Merchant: 56 | """A Monarch Money category.""" 57 | 58 | id: str 59 | name: str 60 | logoUrl: Optional[str] 61 | recurringTransactionStream: Optional[Any] 62 | transactionsCount: Optional[int] 63 | __typename: str = "Merchant" 64 | 65 | def __init__( 66 | self, 67 | id, 68 | name, 69 | logoUrl=None, 70 | transactionsCount=None, 71 | recurringTransactionStream=None, 72 | ): 73 | self.id = id 74 | self.name = name 75 | self.logoUrl = logoUrl 76 | self.transactionsCount = transactionsCount 77 | self.recurringTransactionStream = recurringTransactionStream 78 | 79 | 80 | class AccountSubtype: 81 | """A Monarch Money account subtype.""" 82 | 83 | display: str 84 | __typename: str = "AccountSubtype" 85 | 86 | def __init__(self, display): 87 | self.display = display 88 | 89 | 90 | class Account: 91 | """A Monarch Money account.""" 92 | 93 | id: str 94 | displayName: str 95 | icon: Optional[str] 96 | logoUrl: Optional[str] # Example: "data:image/png;base64,..." 97 | mask: Optional[str] # Not present for get_transactions() 98 | subtype: Optional[AccountSubtype] 99 | __typename: str = "Account" 100 | 101 | def __init__( 102 | self, id, displayName, icon=None, logoUrl=None, mask=None, subtype=None 103 | ): 104 | self.id = id 105 | self.displayName = displayName 106 | self.icon = icon 107 | self.logoUrl = logoUrl 108 | self.mask = mask 109 | self.subtype = subtype 110 | 111 | 112 | class SplitTransaction: 113 | """A Monarch Money split transaction.""" 114 | 115 | id: str 116 | amount: float 117 | merchant: Merchant 118 | category: Category 119 | 120 | def __init__(self, id, amount, merchant, category): 121 | self.id = id 122 | self.amount = amount 123 | self.merchant = merchant 124 | self.category = category 125 | 126 | 127 | def strptime_or_none(datetime_str, format_str): 128 | if not datetime_str or not format_str: 129 | return None 130 | try: 131 | return datetime.datetime.strptime(datetime_str, format_str) 132 | except ValueError: 133 | return None 134 | 135 | 136 | class Transaction: 137 | """A Monarch Money transaction.""" 138 | 139 | id: str 140 | amount: MicroUSD # Add comment to signage and it's meaning as a debit/credit on both types of accounts (savings / cc / loan) 141 | date: datetime.date # Parse as: "2024-01-03", 142 | originalDate: Optional[datetime.date] # Parse as: "2024-01-03", 143 | pending: bool 144 | needsReview: bool 145 | needsReviewByUser: Optional[Any] # Need example 146 | reviewStatus: Optional[Any] # Need example 147 | reviewedAt: Optional[datetime.datetime] # Need example 148 | reviewedByUser: Optional[Any] # Need example 149 | isRecurring: bool 150 | isSplitTransaction: bool 151 | hideFromReports: bool 152 | # For split transactions only: parent that have one or more splits. 153 | splitTransactions: List["Transaction"] 154 | # For split transactions only: the transaction ID of the parent transaction. 155 | originalTransaction: Optional[str] 156 | createdAt: datetime.datetime # "2024-01-03T15:43:18.634009+00:00", 157 | updatedAt: datetime.datetime # "2024-01-03T16:32:08.539592+00:00", 158 | 159 | category: Category 160 | merchant: Merchant 161 | account: Account 162 | 163 | notes: Optional[str] 164 | tags: List[Any] # Need example 165 | attachments: List[Any] # Need example 166 | goal: Optional[Any] # Need example 167 | plaidName: Optional[str] # example: "MACYS AUTO PYMT 240102", 168 | __typename: str = "Transaction" 169 | 170 | matched = False 171 | # AmazonCharges: 172 | charges = [] 173 | item = None # Set in the case of itemized new transactions. 174 | 175 | def __init__( 176 | self, 177 | id, 178 | amount, 179 | date, 180 | originalDate, 181 | pending, 182 | needsReview, 183 | isRecurring, 184 | isSplitTransaction, 185 | hideFromReports, 186 | splitTransactions, 187 | originalTransaction, 188 | createdAt, 189 | updatedAt, 190 | category, 191 | merchant, 192 | account, 193 | notes, 194 | tags, 195 | attachments, 196 | goal, 197 | plaidName, 198 | ): 199 | self.id = id 200 | self.amount = MicroUSD.from_float(amount) 201 | # Required - will raise ValueError if not valid: 202 | self.date = datetime.datetime.strptime(date, "%Y-%m-%d").date() 203 | # Optional: 204 | originalDate = strptime_or_none(originalDate, "%Y-%m-%d") 205 | if originalDate: 206 | self.originalDate = originalDate.date() 207 | self.pending = pending 208 | self.needsReview = needsReview 209 | self.isRecurring = isRecurring 210 | self.isSplitTransaction = isSplitTransaction 211 | self.hideFromReports = hideFromReports 212 | self.splitTransactions = splitTransactions 213 | self.originalTransaction = originalTransaction 214 | self.createdAt = dateutil_parse(createdAt) 215 | self.updatedAt = dateutil_parse(updatedAt) 216 | 217 | self.category = Category(**category) 218 | self.merchant = Merchant(**merchant) 219 | self.account = Account(**account) 220 | 221 | self.notes = notes 222 | self.tags = tags 223 | self.attachments = attachments 224 | self.goal = goal 225 | self.plaidName = plaidName 226 | 227 | def clone(self): 228 | """Returns a clone of this Transaction.""" 229 | clone = deepcopy(self) 230 | # Itemized should NOT have this info, otherwise there are some lovely cycles. 231 | clone.matched = False 232 | clone.charges = [] 233 | return clone 234 | 235 | def match(self, charges): 236 | self.matched = True 237 | self.charges = charges 238 | 239 | # def bastardize(self): 240 | # """Severs the child from the parent making this a parent itself.""" 241 | # self.parent_id = None 242 | 243 | # def update_category_id(self, categories): 244 | # self.category.update_category_id(categories) 245 | 246 | def get_compare_tuple(self, ignore_category=False): 247 | """Returns a 3-tuple used to determine if 2 transactions are equal.""" 248 | base = (self.merchant.name, str(self.amount), self.notes) 249 | return base if ignore_category else base + (self.category.name,) 250 | 251 | def dry_run_str(self, ignore_category=False): 252 | return ( 253 | f'{self.date.strftime("%Y-%m-%d")} \t' 254 | f"{str(self.amount)} \t" 255 | f'{"--IGNORED--" if ignore_category else self.category} \t' 256 | f"{self.merchant.name}" 257 | ) 258 | 259 | def __repr__(self): 260 | return ( 261 | f"Transaction({self.id}): {str(self.amount)} " 262 | f"{self.date} {self.merchant.name} {self.category} " 263 | f'{"with notes" if self.notes else ""}' 264 | ) 265 | 266 | @classmethod 267 | def parse_from_json(cls, json_objs: List[Any], progress=NoProgress()): 268 | result = [] 269 | for json_obj in json_objs: 270 | result.append(cls(**json_obj)) 271 | progress.next() 272 | return result 273 | 274 | @staticmethod 275 | def sum_amounts(trans): 276 | return sum([t.amount for t in trans]) 277 | 278 | # @staticmethod 279 | # def unsplit(trans): 280 | # """Reconstitutes splits/itemizations into parent transaction.""" 281 | # parent_id_to_trans = defaultdict(list) 282 | # result = [] 283 | # for t in trans: 284 | # if t.parent_id: 285 | # parent_id_to_trans[t.parent_id].append(t) 286 | # else: 287 | # result.append(t) 288 | 289 | # for parent_id, children in parent_id_to_trans.items(): 290 | # parent = deepcopy(children[0]) 291 | 292 | # parent.id = parent_id 293 | # parent.bastardize() 294 | # parent.amount = round_micro_usd_to_cent(Transaction.sum_amounts(children)) 295 | # parent.children = children 296 | 297 | # result.append(parent) 298 | 299 | # return result 300 | 301 | @staticmethod 302 | def old_and_new_are_identical(old, new, ignore_category=False): 303 | """Returns True if there is zero difference between old and new.""" 304 | old_set = set( 305 | [c.get_compare_tuple(ignore_category) for c in old.children] 306 | if old.children 307 | else [old.get_compare_tuple(ignore_category)] 308 | ) 309 | new_set = set([t.get_compare_tuple(ignore_category) for t in new]) 310 | return old_set == new_set 311 | 312 | 313 | def itemize_new_trans(new_trans, prefix): 314 | # Add a prefix to all itemized transactions for easy keyword searching 315 | # within Monarch Money. Use the same prefix, based on if the original transaction 316 | for nt in new_trans: 317 | nt.description = prefix + nt.description 318 | 319 | # Turns out the first entry is typically displayed last in the Monarch Money 320 | # UI. Reverse everything for ideal readability. 321 | return new_trans[::-1] 322 | 323 | 324 | NON_ITEM_DESCRIPTIONS = set( 325 | ["Misc Charge (Gift wrap, etc)", "Promotion(s)", "Shipping", "Tax adjustment"] 326 | ) 327 | 328 | 329 | def summarize_title(titles, prefix): 330 | trun_len = (100 - len(prefix) - 2 * len(titles)) / len(titles) 331 | return prefix + (", ".join([truncate_title(t, trun_len) for t in titles])) 332 | 333 | 334 | def summarize_new_trans(t, new_trans, prefix): 335 | # When not itemizing, create a description by concatenating the items. Store 336 | # the full information in the transaction notes. Category is untouched when 337 | # there's more than one item (this is why itemizing is better!). 338 | title = summarize_title( 339 | [ 340 | nt.description 341 | for nt in new_trans 342 | if nt.description not in NON_ITEM_DESCRIPTIONS 343 | ], 344 | prefix, 345 | ) 346 | notes = "{}\nItem(s):\n{}".format( 347 | new_trans[0].notes, "\n".join([" - " + nt.description for nt in new_trans]) 348 | ) 349 | 350 | summary_trans = deepcopy(t) 351 | summary_trans.description = title 352 | if ( 353 | len([nt for nt in new_trans if nt.description not in NON_ITEM_DESCRIPTIONS]) 354 | == 1 355 | ): 356 | summary_trans.category = new_trans[0].category 357 | else: 358 | summary_trans.category.name = category.DEFAULT_CATEGORY 359 | summary_trans.notes = notes 360 | return [summary_trans] 361 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/qt.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import operator 3 | 4 | from PyQt6.QtCore import Qt, QAbstractTableModel, QUrl # type: ignore 5 | from PyQt6.QtGui import QDesktopServices # type: ignore 6 | from PyQt6.QtWidgets import ( 7 | QAbstractItemView, 8 | QDialog, 9 | QLabel, 10 | QPushButton, 11 | QTableView, 12 | QVBoxLayout, 13 | ) # type: ignore 14 | 15 | from monarchmoneyamazontagger import amazon 16 | from monarchmoneyamazontagger import mm 17 | 18 | 19 | class MMUpdatesTableModel(QAbstractTableModel): 20 | def __init__(self, updates, **kwargs): 21 | super(MMUpdatesTableModel, self).__init__(**kwargs) 22 | self.my_data = [] 23 | for i, update in enumerate(updates): 24 | orig_trans, new_trans = update 25 | 26 | descriptions = [] 27 | category_names = [] 28 | amounts = [] 29 | 30 | if orig_trans.children: 31 | for trans in orig_trans.children: 32 | descriptions.append("CURRENTLY: " + trans.description) 33 | category_names.append(trans.category.name) 34 | amounts.append(str(trans.amount)) 35 | else: 36 | descriptions.append("CURRENTLY: " + orig_trans.description) 37 | category_names.append(orig_trans.category.name) 38 | amounts.append(str(orig_trans.amount)) 39 | 40 | if len(new_trans) == 1: 41 | trans = new_trans[0] 42 | descriptions.append("PROPOSED: " + trans.description) 43 | category_names.append(trans.category.name) 44 | amounts.append(str(trans.amount)) 45 | else: 46 | for trans in reversed(new_trans): 47 | descriptions.append("PROPOSED: " + trans.description) 48 | category_names.append(trans.category.name) 49 | amounts.append(str(trans.amount)) 50 | 51 | self.my_data.append( 52 | [ 53 | update, 54 | True, 55 | orig_trans.date.strftime("%Y/%m/%d"), 56 | "\n".join(descriptions), 57 | "\n".join(category_names), 58 | "\n".join(amounts), 59 | orig_trans.charges[0].order_id(), 60 | ] 61 | ) 62 | 63 | self.header = ["", "Date", "Description", "Category", "Amount", "Amazon Order"] 64 | 65 | def rowCount(self, parent): 66 | return len(self.my_data) 67 | 68 | def columnCount(self, parent): 69 | return len(self.header) 70 | 71 | def data(self, index, role): 72 | if not index.isValid(): 73 | return None 74 | if index.column() == 0: 75 | value = "" if self.my_data[index.row()][index.column() + 1] else "Skip" 76 | else: 77 | value = self.my_data[index.row()][index.column() + 1] 78 | if role == Qt.ItemDataRole.EditRole: 79 | return value 80 | elif role == Qt.ItemDataRole.DisplayRole: 81 | return value 82 | elif role == Qt.ItemDataRole.CheckStateRole: 83 | if index.column() == 0: 84 | return ( 85 | Qt.CheckState.Checked 86 | if self.my_data[index.row()][index.column() + 1] 87 | else Qt.CheckState.Unchecked 88 | ) 89 | 90 | def headerData(self, col, orientation, role): 91 | if ( 92 | orientation == Qt.Orientation.Horizontal 93 | and role == Qt.ItemDataRole.DisplayRole 94 | ): 95 | return self.header[col] 96 | return None 97 | 98 | def sort(self, col, order): 99 | self.layoutAboutToBeChanged.emit() 100 | self.my_data = sorted(self.my_data, key=operator.itemgetter(col + 1)) 101 | if order == Qt.SortOrder.DescendingOrder: 102 | self.my_data.reverse() 103 | self.layoutChanged.emit() 104 | 105 | def flags(self, index): 106 | if not index.isValid(): 107 | return None 108 | if index.column() == 0: 109 | return ( 110 | Qt.ItemFlag.ItemIsEnabled 111 | | Qt.ItemFlag.ItemIsSelectable 112 | | Qt.ItemFlag.ItemIsUserCheckable 113 | ) 114 | else: 115 | return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable 116 | 117 | def setData(self, index, value, role): 118 | if not index.isValid(): 119 | return False 120 | if role == Qt.ItemDataRole.CheckStateRole and index.column() == 0: 121 | print(self.my_data[index.row()][index.column() + 1]) 122 | print(value) 123 | print(Qt.CheckState.Checked) 124 | self.my_data[index.row()][index.column() + 1] = ( 125 | value == Qt.CheckState.Checked.value 126 | ) 127 | print(self.my_data[index.row()][index.column() + 1]) 128 | self.dataChanged.emit(index, index) 129 | return True 130 | 131 | def get_selected_updates(self): 132 | return [d[0] for d in self.my_data if d[1]] 133 | 134 | 135 | class AmazonUnmatchedTableDialog(QDialog): 136 | def __init__(self, unmatched_charges, **kwargs): 137 | super(AmazonUnmatchedTableDialog, self).__init__(**kwargs) 138 | self.setWindowTitle("Unmatched Amazon charges/Refunds") 139 | self.setModal(True) 140 | self.model = AmazonUnmatchedTableModel(unmatched_charges) 141 | v_layout = QVBoxLayout() 142 | self.setLayout(v_layout) 143 | 144 | label = QLabel( 145 | f"Below are the {len(unmatched_charges)} Amazon charges/Refunds " 146 | "which did not match a Mint transaction." 147 | ) 148 | v_layout.addWidget(label) 149 | 150 | table = QTableView() 151 | table.doubleClicked.connect(self.on_double_click) 152 | table.clicked.connect(self.on_activated) 153 | 154 | def resize(): 155 | table.resizeColumnsToContents() 156 | table.resizeRowsToContents() 157 | min_width = sum(table.columnWidth(i) for i in range(5)) 158 | table.setMinimumSize(min_width + 20, 600) 159 | 160 | table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) 161 | table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) 162 | table.setModel(self.model) 163 | table.setSortingEnabled(True) 164 | resize() 165 | self.model.layoutChanged.connect(resize) 166 | 167 | v_layout.addWidget(table) 168 | 169 | close_button = QPushButton("Close") 170 | v_layout.addWidget(close_button) 171 | close_button.clicked.connect(self.close) 172 | 173 | def open_amazon_order_id(self, order_id): 174 | if order_id: 175 | QDesktopServices.openUrl(QUrl(amazon.get_invoice_url(order_id))) 176 | 177 | def on_activated(self, index): 178 | # Only handle clicks on the order_id cell. 179 | if index.column() != 3: 180 | return 181 | order_id = self.model.data(index, Qt.ItemDataRole.DisplayRole) 182 | self.open_amazon_order_id(order_id) 183 | 184 | def on_double_click(self, index): 185 | if index.column() == 3: 186 | # Ignore double clicks on the order_id cell. 187 | return 188 | order_id_cell = self.model.createIndex(index.row(), 3) 189 | order_id = self.model.data(order_id_cell, Qt.ItemDataRole.DisplayRole) 190 | self.open_amazon_order_id(order_id) 191 | 192 | 193 | class AmazonUnmatchedTableModel(QAbstractTableModel): 194 | def __init__(self, unmatched_charges, **kwargs): 195 | super(AmazonUnmatchedTableModel, self).__init__(**kwargs) 196 | 197 | self.header = [ 198 | "Ship Date", 199 | "Proposed Mint Description", 200 | "Amount", 201 | "Order ID", 202 | ] 203 | self.my_data = [] 204 | by_oid = defaultdict(list) 205 | for uo in unmatched_charges: 206 | by_oid[uo.order_id()].append(uo) 207 | for unmatched_by_oid in by_oid.values(): 208 | merged = amazon.Charge.merge(unmatched_by_oid) 209 | self.my_data.append(self._create_row(merged)) 210 | 211 | def _create_row(self, amzn_obj): 212 | proposed_mint_desc = mm.summarize_title( 213 | [i.get_title() for i in amzn_obj.items], f"{amzn_obj.website()}" f": " 214 | ) 215 | return [ 216 | amzn_obj.transact_date().strftime("%Y/%m/%d") 217 | if amzn_obj.transact_date() 218 | else "Never shipped!", 219 | proposed_mint_desc, 220 | str(amzn_obj.transact_amount()), 221 | amzn_obj.order_id(), 222 | ] 223 | 224 | def rowCount(self, parent): 225 | return len(self.my_data) 226 | 227 | def columnCount(self, parent): 228 | return len(self.header) 229 | 230 | def data(self, index, role): 231 | if index.isValid() and role == Qt.ItemDataRole.DisplayRole: 232 | return self.my_data[index.row()][index.column()] 233 | 234 | def headerData(self, col, orientation, role): 235 | if ( 236 | orientation == Qt.Orientation.Horizontal 237 | and role == Qt.ItemDataRole.DisplayRole 238 | ): 239 | return self.header[col] 240 | return None 241 | 242 | def sort(self, col, order): 243 | self.layoutAboutToBeChanged.emit() 244 | self.my_data = sorted(self.my_data, key=operator.itemgetter(col)) 245 | if order == Qt.SortOrder.DescendingOrder: 246 | self.my_data.reverse() 247 | self.layoutChanged.emit() 248 | 249 | def flags(self, index): 250 | if not index.isValid(): 251 | return None 252 | return Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable 253 | 254 | def setData(self, index, value, role): 255 | if not index.isValid(): 256 | return False 257 | return True 258 | 259 | 260 | class AmazonStatsDialog(QDialog): 261 | def __init__(self, items, charges, refunds, **kwargs): 262 | super(AmazonStatsDialog, self).__init__(**kwargs) 263 | self.setWindowTitle("Amazon Stats for Items/charges/Refunds") 264 | self.setModal(True) 265 | v_layout = QVBoxLayout() 266 | self.setLayout(v_layout) 267 | 268 | v_layout.addWidget(QLabel("Amazon Stats:")) 269 | if len(charges) == 0 or len(items) == 0: 270 | v_layout.addWidget(QLabel("There were not Amazon charges/items!")) 271 | 272 | close_button = QPushButton("Close") 273 | v_layout.addWidget(close_button) 274 | close_button.clicked.connect(self.close) 275 | return 276 | 277 | first_order_date = min([d for c in charges for d in c.order_dates()]) 278 | last_order_date = max([d for c in charges for d in c.order_dates()]) 279 | 280 | v_layout.addWidget( 281 | QLabel(f"charges ranging from {first_order_date} to {last_order_date}") 282 | ) 283 | 284 | per_item_totals = [i.total() for i in items] 285 | per_order_totals = [c.total_owed() for c in charges] 286 | 287 | v_layout.addWidget(QLabel(f"{str(sum(per_order_totals))} total spend")) 288 | v_layout.addWidget( 289 | QLabel( 290 | f"{str(sum(per_order_totals) / len(charges))} " 291 | "avg order total (range: " 292 | f"{str(min(per_order_totals))} - " 293 | f"{str(max(per_order_totals))})" 294 | ) 295 | ) 296 | v_layout.addWidget( 297 | QLabel( 298 | f"{str(sum(per_item_totals) / len(items))} " 299 | "avg item price (range: " 300 | f"{str(min(per_item_totals))} - " 301 | f"{str(max(per_item_totals))})" 302 | ) 303 | ) 304 | 305 | if refunds: 306 | first_refund_date = min([r.refund_date for r in refunds if r.refund_date]) 307 | last_refund_date = max([r.refund_date for r in refunds if r.refund_date]) 308 | v_layout.addWidget( 309 | QLabel( 310 | f"\n{len(refunds)} refunds dating from " 311 | f"{first_refund_date} to {last_refund_date}" 312 | ) 313 | ) 314 | 315 | per_refund_totals = [r.total_refund_amount for r in refunds] 316 | 317 | v_layout.addWidget( 318 | QLabel(f"{str(sum(per_refund_totals))} " "total refunded") 319 | ) 320 | 321 | close_button = QPushButton("Close") 322 | v_layout.addWidget(close_button) 323 | close_button.clicked.connect(self.close) 324 | 325 | 326 | class TaggerStatsDialog(QDialog): 327 | def __init__(self, stats, **kwargs): 328 | super(TaggerStatsDialog, self).__init__(**kwargs) 329 | self.setWindowTitle("Tagger Stats") 330 | self.setModal(True) 331 | v_layout = QVBoxLayout() 332 | self.setLayout(v_layout) 333 | 334 | v_layout.addWidget( 335 | QLabel( 336 | "\nTransactions: {trans}\n" 337 | 'Transactions w/ "Amazon" in description: {amazon_in_desc}\n' 338 | "Transactions ignored: is pending: {pending}\n" 339 | "\n" 340 | "charges matched w/ transactions: {order_match} (unmatched charges: " 341 | "{order_unmatch})\n" 342 | "Transactions matched w/ charges/refunds: {trans_match} " 343 | "(unmatched: " 344 | "{trans_unmatch})\n" 345 | "\n" 346 | "charges skipped: not shipped: {skipped_charges_unshipped}\n" 347 | "charges skipped: gift card used: {skipped_charges_gift_card}\n" 348 | "\n" 349 | "Order fix-up: incorrect tax itemization: {adjust_itemized_tax}\n" 350 | "Order fix-up: has a misc charges (e.g. gift wrap): " 351 | "{misc_charge}\n" 352 | "\n" 353 | "Transactions ignored; already tagged & up to date: " 354 | "{already_up_to_date}\n" 355 | "Transactions ignored; ignore retags: {no_retag}\n" 356 | "Transactions ignored; user skipped retag: {user_skipped_retag}\n" 357 | "\n" 358 | "Transactions with personalize categories: {personal_cat}\n" 359 | "\n" 360 | "Transactions to be retagged: {retag}\n" 361 | "Transactions to be newly tagged: {new_tag}\n".format(**stats) 362 | ) 363 | ) 364 | 365 | close_button = QPushButton("Close") 366 | v_layout.addWidget(close_button) 367 | close_button.clicked.connect(self.close) 368 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/tagger_test.py: -------------------------------------------------------------------------------- 1 | # from collections import Counter 2 | # import unittest 3 | 4 | # from monarchmoneyamazontagger import tagger 5 | # from monarchmoneyamazontagger.mockdata import MINT_CATEGORIES 6 | 7 | 8 | # class Args: 9 | # def __init__(self, **kwds): 10 | # self.__dict__.update(kwds) 11 | 12 | 13 | # def get_args( 14 | # description_prefix_override='Amazon.com: ', 15 | # description_return_prefix_override='Amazon.com: ', 16 | # amazon_domains='amazon.com,amazon.co.uk', 17 | # mint_input_description_filter='amazon', 18 | # mint_input_include_user_description=False, 19 | # mint_input_include_inferred_description=False, 20 | # mint_input_categories_filter=None, 21 | # verbose_itemize=False, 22 | # no_itemize=False, 23 | # no_tag_categories=False, 24 | # prompt_retag=False, 25 | # num_updates=0, 26 | # retag_changed=False, 27 | # do_not_predict_categories=True, 28 | # max_days_between_payment_and_shipping=3, 29 | # max_unmatched_order_combinations=10): 30 | # return Args( 31 | # description_prefix_override=description_prefix_override, 32 | # description_return_prefix_override=description_return_prefix_override, 33 | # amazon_domains=amazon_domains, 34 | # mint_input_description_filter=mint_input_description_filter, 35 | # mint_input_include_user_description=( 36 | # mint_input_include_user_description), 37 | # mint_input_include_inferred_description=( 38 | # mint_input_include_inferred_description), 39 | # mint_input_categories_filter=mint_input_categories_filter, 40 | # verbose_itemize=verbose_itemize, 41 | # no_itemize=no_itemize, 42 | # no_tag_categories=no_tag_categories, 43 | # prompt_retag=prompt_retag, 44 | # num_updates=num_updates, 45 | # retag_changed=retag_changed, 46 | # do_not_predict_categories=do_not_predict_categories, 47 | # max_days_between_payment_and_shipping=( 48 | # max_days_between_payment_and_shipping), 49 | # max_unmatched_order_combinations=max_unmatched_order_combinations 50 | # ) 51 | 52 | 53 | # class Tagger(unittest.TestCase): 54 | # def test_get_mint_updates_empty_input(self): 55 | # updates, _ = tagger.get_mint_updates( 56 | # [], [], 57 | # [], 58 | # get_args(), Counter(), 59 | # MINT_CATEGORIES) 60 | # self.assertEqual(len(updates), 0) 61 | 62 | 63 | # # TODO: REVIVE 64 | # # def test_get_mint_updates_simple_match(self): 65 | # # i1 = item() 66 | # # o1 = order() 67 | # # t1 = transaction() 68 | 69 | # # stats = Counter() 70 | # # updates, _ = tagger.get_mint_updates( 71 | # # [o1], [i1], [], 72 | # # [t1], 73 | # # get_args(), stats, 74 | # # MINT_CATEGORIES) 75 | 76 | # # self.assertEqual(len(updates), 1) 77 | # # orig_t, new_trans = updates[0] 78 | # # self.assertTrue(orig_t is t1) 79 | # # self.assertEqual(len(new_trans), 1) 80 | # # self.assertEqual(new_trans[0].description, 81 | # # 'Amazon.com: 2x Duracell AAs') 82 | # # self.assertEqual(new_trans[0].category.name, 'Electronics & Software') 83 | # # self.assertEqual(new_trans[0].amount, -11950000) 84 | # # self.assertEqual(new_trans[0].parent_id, None) 85 | 86 | # # self.assertEqual(stats['new_tag'], 1) 87 | 88 | # # def test_get_mint_updates_simple_match_refund(self): 89 | # # r1 = refund( 90 | # # title='Cool item', 91 | # # refund_amount='$10.95', 92 | # # refund_tax_amount='$1.00', 93 | # # refund_date='3/12/14') 94 | # # t1 = transaction(amount=11.95, date='2014-03-12') 95 | 96 | # # stats = Counter() 97 | # # updates, _ = tagger.get_mint_updates( 98 | # # [], [], [r1], 99 | # # [t1], 100 | # # get_args(), stats, 101 | # # MINT_CATEGORIES) 102 | 103 | # # self.assertEqual(len(updates), 1) 104 | # # orig_t, new_trans = updates[0] 105 | # # self.assertTrue(orig_t is t1) 106 | # # self.assertEqual(len(new_trans), 1) 107 | # # self.assertEqual(new_trans[0].description, 'Amazon.com: 2x Cool item') 108 | # # self.assertEqual(new_trans[0].category.name, 'Returned Purchase') 109 | # # self.assertEqual(new_trans[0].amount, 11950000) 110 | # # self.assertEqual(new_trans[0].parent_id, None) 111 | 112 | # # self.assertEqual(stats['new_tag'], 1) 113 | 114 | # # def test_get_mint_updates_refund_no_date(self): 115 | # # r1 = refund( 116 | # # title='Cool item2', 117 | # # refund_amount='$10.95', 118 | # # refund_tax_amount='$1.00', 119 | # # refund_date=None) 120 | # # t1 = transaction(amount=11.95, date='2014-03-12') 121 | 122 | # # stats = Counter() 123 | # # updates, _ = tagger.get_mint_updates( 124 | # # [], [], [r1], 125 | # # [t1], 126 | # # get_args(), stats, 127 | # # MINT_CATEGORIES) 128 | 129 | # # self.assertEqual(len(updates), 0) 130 | # # self.assertEqual(stats['new_tag'], 0) 131 | 132 | # # def test_get_mint_updates_skip_already_tagged(self): 133 | # # i1 = item() 134 | # # o1 = order() 135 | # # t1 = transaction(description='SomeRandoCustomPrefix: already tagged') 136 | 137 | # # stats = Counter() 138 | # # updates, _ = tagger.get_mint_updates( 139 | # # [o1], [i1], [], 140 | # # [t1], 141 | # # get_args(description_prefix_override='SomeRandoCustomPrefix: '), 142 | # # stats, 143 | # # MINT_CATEGORIES) 144 | 145 | # # self.assertEqual(len(updates), 0) 146 | # # self.assertEqual(stats['no_retag'], 1) 147 | 148 | # # def test_get_mint_updates_retag_arg(self): 149 | # # i1 = item() 150 | # # o1 = order() 151 | # # t1 = transaction(description='Amazon.com: already tagged') 152 | 153 | # # stats = Counter() 154 | # # updates, _ = tagger.get_mint_updates( 155 | # # [o1], [i1], [], 156 | # # [t1], 157 | # # get_args(retag_changed=True), stats, 158 | # # MINT_CATEGORIES) 159 | 160 | # # self.assertEqual(len(updates), 1) 161 | # # self.assertEqual(stats['retag'], 1) 162 | 163 | # # def test_get_mint_updates_multi_domains_no_retag(self): 164 | # # i1 = item() 165 | # # o1 = order() 166 | # # t1 = transaction(description='Amazon.co.uk: already tagged') 167 | 168 | # # stats = Counter() 169 | # # updates, _ = tagger.get_mint_updates( 170 | # # [o1], [i1], [], 171 | # # [t1], 172 | # # get_args(), stats, 173 | # # MINT_CATEGORIES) 174 | 175 | # # self.assertEqual(len(updates), 0) 176 | 177 | # # def test_get_mint_updates_no_update_for_identical(self): 178 | # # i1 = item() 179 | # # o1 = order() 180 | # # t1 = transaction( 181 | # # description='Amazon.com: 2x Duracell AAs', 182 | # # category='Electronics & Software', 183 | # # notes=o1.get_notes() + '\nItem(s):\n - 2x Duracell AAs') 184 | 185 | # # stats = Counter() 186 | # # updates, _ = tagger.get_mint_updates( 187 | # # [o1], [i1], [], 188 | # # [t1], 189 | # # get_args(retag_changed=True), stats, 190 | # # MINT_CATEGORIES) 191 | 192 | # # self.assertEqual(len(updates), 0) 193 | # # self.assertEqual(stats['already_up_to_date'], 1) 194 | 195 | # # def test_get_mint_updates_no_tag_categories_arg(self): 196 | # # i1 = item() 197 | # # o1 = order() 198 | # # t1 = transaction( 199 | # # description='Amazon.com: 2x Duracell AAs', 200 | # # notes=o1.get_notes() + '\nItem(s):\n - 2x Duracell AAs') 201 | 202 | # # stats = Counter() 203 | # # updates, _ = tagger.get_mint_updates( 204 | # # [o1], [i1], [], 205 | # # [t1], 206 | # # get_args(no_tag_categories=True), stats, 207 | # # MINT_CATEGORIES) 208 | 209 | # # self.assertEqual(len(updates), 0) 210 | # # self.assertEqual(stats['already_up_to_date'], 1) 211 | 212 | # # def test_get_mint_updates_verbose_itemize_arg(self): 213 | # # i1 = item() 214 | # # o1 = order(shipping_charge='$3.99', total_discounts='$3.99') 215 | # # t1 = transaction() 216 | 217 | # # stats = Counter() 218 | # # updates, _ = tagger.get_mint_updates( 219 | # # [o1], [i1], [], 220 | # # [t1], 221 | # # get_args(verbose_itemize=True), stats, 222 | # # MINT_CATEGORIES) 223 | 224 | # # self.assertEqual(len(updates), 1) 225 | # # orig_t, new_trans = updates[0] 226 | # # self.assertTrue(orig_t is t1) 227 | # # self.assertEqual(len(new_trans), 3) 228 | # # self.assertEqual(new_trans[0].description, 'Amazon.com: Promotion(s)') 229 | # # self.assertEqual(new_trans[0].category.name, 'Shipping') 230 | # # self.assertEqual(new_trans[0].amount, 3990000) 231 | # # self.assertEqual(new_trans[1].description, 'Amazon.com: Shipping') 232 | # # self.assertEqual(new_trans[1].category.name, 'Shipping') 233 | # # self.assertEqual(new_trans[1].amount, -3990000) 234 | # # self.assertEqual(new_trans[2].description, 235 | # # 'Amazon.com: 2x Duracell AAs') 236 | # # self.assertEqual(new_trans[2].category.name, 'Electronics & Software') 237 | # # self.assertEqual(new_trans[2].amount, -11950000) 238 | 239 | # # self.assertEqual(stats['new_tag'], 1) 240 | 241 | # # def test_get_mint_updates_no_itemize_arg_single_item(self): 242 | # # i1 = item() 243 | # # o1 = order(total_charged='$15.94', shipping_charge='$3.99') 244 | # # t1 = transaction(amount=-15.94) 245 | 246 | # # stats = Counter() 247 | # # updates, _ = tagger.get_mint_updates( 248 | # # [o1], [i1], [], 249 | # # [t1], 250 | # # get_args(no_itemize=True), stats, 251 | # # MINT_CATEGORIES) 252 | 253 | # # self.assertEqual(len(updates), 1) 254 | # # orig_t, new_trans = updates[0] 255 | # # self.assertTrue(orig_t is t1) 256 | # # self.assertEqual(len(new_trans), 1) 257 | # # self.assertEqual(new_trans[0].description, 258 | # # 'Amazon.com: 2x Duracell AAs') 259 | # # self.assertEqual(new_trans[0].category.name, 'Electronics & Software') 260 | # # self.assertEqual(new_trans[0].amount, -15940000) 261 | 262 | # # def test_get_mint_updates_no_itemize_arg_three_items(self): 263 | # # i1 = item( 264 | # # title='Really cool watch', 265 | # # quantity=1, 266 | # # item_subtotal='$10.00', 267 | # # item_subtotal_tax='$1.00', 268 | # # item_total='$11.00') 269 | # # i2 = item( 270 | # # title='Organic water', 271 | # # quantity=1, 272 | # # item_subtotal='$6.00', 273 | # # item_subtotal_tax='$0.00', 274 | # # item_total='$6.00') 275 | # # o1 = order( 276 | # # subtotal='$16.00', 277 | # # tax_charged='$1.00', 278 | # # total_charged='$17.00') 279 | # # t1 = transaction(amount=-17.00) 280 | 281 | # # stats = Counter() 282 | # # updates, _ = tagger.get_mint_updates( 283 | # # [o1], [i1, i2], [], 284 | # # [t1], 285 | # # get_args(no_itemize=True), stats, 286 | # # MINT_CATEGORIES) 287 | 288 | # # self.assertEqual(len(updates), 1) 289 | # # orig_t, new_trans = updates[0] 290 | # # self.assertTrue(orig_t is t1) 291 | # # self.assertEqual(len(new_trans), 1) 292 | # # self.assertEqual(new_trans[0].description, 293 | # # 'Amazon.com: Really cool watch, Organic water') 294 | # # self.assertEqual(new_trans[0].category.name, 'Shopping') 295 | # # self.assertEqual(new_trans[0].amount, -17000000) 296 | 297 | # # def test_get_mint_updates_multi_charges_trans_same_date_and_amount(self): 298 | # # i1 = item(order_id='A') 299 | # # o1 = order(order_id='A') 300 | # # i2 = item(order_id='B') 301 | # # o2 = order(order_id='B') 302 | # # t1 = transaction() 303 | # # t2 = transaction() 304 | 305 | # # stats = Counter() 306 | # # updates, _ = tagger.get_mint_updates( 307 | # # [o1, o2], [i1, i2], [], 308 | # # [t1, t2], 309 | # # get_args(), stats, 310 | # # MINT_CATEGORIES) 311 | 312 | # # self.assertEqual(len(updates), 2) 313 | 314 | # # # Verify functionality of num_updates truncates the 2 updates down to 1. 315 | # # updates2, _ = tagger.get_mint_updates( 316 | # # [o1, o2], [i1, i2], [], 317 | # # [t1, t2], 318 | # # get_args(num_updates=1), stats, 319 | # # MINT_CATEGORIES) 320 | 321 | # # self.assertEqual(len(updates2), 1) 322 | 323 | # # def test_get_mint_updates_one_trans_one_oid_multiple_charges(self): 324 | # # # Test example from https://github.com/jprouty/mint-amazon-tagger/issues/133 325 | # # i1 = item( 326 | # # order_id='A', 327 | # # title='Nature\'s Miracle High-Sided Litter Box, 23 x 18.5 x 11 inches', 328 | # # item_subtotal='$21.55', 329 | # # item_subtotal_tax='$1.43', 330 | # # item_total='$22.98', 331 | # # purchase_price_per_unit='$21.55', 332 | # # quantity=1) 333 | # # o1 = order( 334 | # # order_id='A', 335 | # # subtotal='$21.55', 336 | # # shipping_charge='$0.00', 337 | # # tax_charged='$1.43', 338 | # # tax_before_promotions='$1.43', 339 | # # total_charged='$22.98') 340 | 341 | # # i2 = item( 342 | # # order_id='A', 343 | # # title='Cat\'s Pride Max Power Clumping Clay Multi-Cat Litter 15 Pounds', 344 | # # item_subtotal='$11.49', 345 | # # item_subtotal_tax='$0.76', 346 | # # item_total='$12.25', 347 | # # purchase_price_per_unit='$12.25', 348 | # # quantity=1 349 | # # ) 350 | # # o2 = order( 351 | # # order_id='A', 352 | # # subtotal='$11.49', 353 | # # shipping_charge='$0.00', 354 | # # tax_charged='$0.76', 355 | # # tax_before_promotions='$0.76', 356 | # # total_charged='$12.25') 357 | # # # TODO: Verify that the original and current description are the same 358 | # # # TODO: Get full description 359 | # # description = 'AMAZON.COM AMZN.CO' 360 | # # t1 = transaction( 361 | # # description=description, 362 | # # original_description=description, 363 | # # category='Shopping', 364 | # # amount=-35.23) 365 | 366 | # # stats = Counter() 367 | # # updates, _ = tagger.get_mint_updates( 368 | # # [o1, o2], [i1, i2], [], 369 | # # [t1], 370 | # # get_args(), stats, 371 | # # MINT_CATEGORIES) 372 | 373 | # # self.assertEqual(len(updates), 1) 374 | 375 | 376 | # if __name__ == '__main__': 377 | # unittest.main() 378 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/amazon_test.py: -------------------------------------------------------------------------------- 1 | # from datetime import datetime 2 | # import unittest 3 | 4 | # from monarchmoneyamazontagger import amazon 5 | # from monarchmoneyamazontagger.amazon import Item, Charge 6 | # from monarchmoneyamazontagger.mockdata import item 7 | 8 | 9 | # class HelperMethods(unittest.TestCase): 10 | # def test_parse_amazon_date(self): 11 | # self.assertEqual(amazon.parse_amazon_date("10/8/10"), datetime(2010, 10, 8)) 12 | # self.assertEqual(amazon.parse_amazon_date("1/23/10"), datetime(2010, 1, 23)) 13 | # self.assertEqual(amazon.parse_amazon_date("6/1/01"), datetime(2001, 6, 1)) 14 | 15 | # self.assertEqual(amazon.parse_amazon_date("07/21/2010"), datetime(2010, 7, 21)) 16 | # self.assertEqual(amazon.parse_amazon_date("1/23/1989"), datetime(1989, 1, 23)) 17 | 18 | 19 | # # TODO: Revive as a Charge test: 20 | # # class OrderClass(unittest.TestCase): 21 | # # def test_constructor(self): 22 | # # o = order() 23 | 24 | # # self.assertEqual(o.order_id, '123-3211232-7655671') 25 | # # self.assertEqual(o.order_status, 'Shipped') 26 | 27 | # # # Currency fields are all in microusd. 28 | # # self.assertEqual(o.shipping_charge, 0) 29 | # # self.assertEqual(o.subtotal, 10900000) 30 | # # self.assertEqual(o.tax_charged, 1050000) 31 | # # self.assertEqual(o.tax_before_promotions, 1050000) 32 | # # self.assertEqual(o.total_charged, 11950000) 33 | # # self.assertEqual(o.total_discounts(), 0) 34 | 35 | # # # Dates are parsed: 36 | # # self.assertEqual(o.order_date, date(2014, 2, 26)) 37 | # # self.assertEqual(o.shipment_date, date(2014, 2, 28)) 38 | 39 | # # # Tracking is renamed: 40 | # # self.assertEqual(o.tracking, 'AMZN(ABC123)') 41 | 42 | # # def test_sum_subtotals(self): 43 | # # self.assertEqual(Order.sum_subtotals([]), 0) 44 | 45 | # # o1 = order(subtotal='$5.55') 46 | # # self.assertEqual(Order.sum_subtotals([o1]), 5550000) 47 | 48 | # # o2 = order(subtotal='$1.01') 49 | # # self.assertEqual(Order.sum_subtotals([o1, o2]), 6560000) 50 | # # self.assertEqual(Order.sum_subtotals([o2, o1]), 6560000) 51 | 52 | # # o3 = order(subtotal='-$6.01') 53 | # # self.assertEqual(Order.sum_subtotals([o1, o3]), -460000) 54 | 55 | # # def test_total_by_items(self): 56 | # # o1 = order(subtotal='$5.55') 57 | # # self.assertEqual(o1.total_by_items(), 0) 58 | 59 | # # o1.set_items([ 60 | # # item(item_total='$4.44'), 61 | # # ]) 62 | # # self.assertEqual(o1.total_by_items(), 4440000) 63 | 64 | # # o2 = order(subtotal='$5.55') 65 | # # o2.set_items([ 66 | # # item(item_total='$4.44'), 67 | # # item(item_total='$0.01'), 68 | # # item(item_total='$60.00'), 69 | # # ]) 70 | # # self.assertEqual(o2.total_by_items(), 64450000) 71 | 72 | # # o3 = order( 73 | # # subtotal='$5.55', 74 | # # shipping_charge='$4.99', 75 | # # total_discounts='$4.99') 76 | # # o3.set_items([ 77 | # # item(item_total='$6.44'), 78 | # # ]) 79 | # # self.assertEqual(o3.total_by_items(), 6440000) 80 | 81 | # # def test_total_by_subtotals(self): 82 | # # o1 = order( 83 | # # subtotal='$5.55', 84 | # # tax_charged='$0.61', 85 | # # shipping_charge='$4.99', 86 | # # total_discounts='$4.99') 87 | # # self.assertEqual(o1.total_by_subtotals(), 6160000) 88 | 89 | # # o1.set_items([ 90 | # # item(item_total='$4.44'), 91 | # # ]) 92 | # # self.assertEqual(o1.total_by_subtotals(), 6160000) 93 | 94 | # # o2 = order( 95 | # # subtotal='$5.55', 96 | # # tax_charged='$0.61', 97 | # # shipping_charge='$4.99', 98 | # # total_discounts='$0.00') 99 | # # self.assertEqual(o2.total_by_subtotals(), 11150000) 100 | 101 | # # def test_transact_date(self): 102 | # # self.assertEqual(order().transact_date(), date(2014, 2, 28)) 103 | 104 | # # def test_transact_amount(self): 105 | # # self.assertEqual(order().transact_amount(), -11950000) 106 | 107 | # # def test_match(self): 108 | # # o = order() 109 | 110 | # # self.assertFalse(o.matched) 111 | # # self.assertEqual(o.trans_id, None) 112 | 113 | # # o.match(transaction(id='abc')) 114 | 115 | # # self.assertTrue(o.matched) 116 | # # self.assertEqual(o.trans_id, 'abc') 117 | 118 | # # def test_set_items(self): 119 | # # o = order() 120 | # # self.assertFalse(o.items_matched) 121 | # # self.assertEqual(o.items, []) 122 | 123 | # # i1 = item() 124 | # # i2 = item() 125 | # # self.assertFalse(i1.matched) 126 | # # self.assertEqual(i1.order, None) 127 | # # self.assertFalse(i2.matched) 128 | # # self.assertEqual(i2.order, None) 129 | 130 | # # o.set_items([i1, i2]) 131 | # # self.assertTrue(o.items_matched) 132 | # # self.assertEqual(o.items, [i1, i2]) 133 | 134 | # # self.assertTrue(i1.matched) 135 | # # self.assertEqual(i1.order, o) 136 | # # self.assertTrue(i2.matched) 137 | # # self.assertEqual(i2.order, o) 138 | 139 | # # def test_get_notes(self): 140 | # # self.assertTrue( 141 | # # 'Amazon order id: 123-3211232-7655671' in order().get_notes()) 142 | # # self.assertTrue( 143 | # # 'Buyer: Some Great Buyer (yup@aol.com)' in order().get_notes()) 144 | # # self.assertTrue('Order date: 2014-02-26' in order().get_notes()) 145 | # # self.assertTrue('Ship date: 2014-02-28' in order().get_notes()) 146 | # # self.assertTrue('Tracking: AMZN(ABC123)' in order().get_notes()) 147 | 148 | # # def test_attribute_subtotal_diff_to_misc_charge_no_diff(self): 149 | # # o = order(total_charged='$10.00', subtotal='$10.00') 150 | # # i = item(item_total='$10.00') 151 | # # o.set_items([i]) 152 | 153 | # # self.assertFalse(o.attribute_subtotal_diff_to_misc_charge()) 154 | 155 | # # def test_attribute_subtotal_diff_to_misc_charge(self): 156 | # # o = order( 157 | # # total_charged='$10.00', subtotal='$6.01', tax_charged='$0.00') 158 | # # i = item(item_total='$6.01') 159 | # # o.set_items([i]) 160 | 161 | # # self.assertTrue(o.attribute_subtotal_diff_to_misc_charge()) 162 | # # self.assertEqual(o.subtotal, 10000000) 163 | # # self.assertEqual(len(o.items), 2) 164 | # # self.assertEqual(o.items[1].item_total, 3990000) 165 | # # self.assertEqual(o.items[1].item_subtotal, 3990000) 166 | # # self.assertEqual(o.items[1].item_subtotal_tax, 0) 167 | # # self.assertEqual(o.items[1].quantity, 1) 168 | # # self.assertEqual(o.items[1].category, 'Shopping') 169 | # # self.assertEqual(o.items[1].title, 'Misc Charge (Gift wrap, etc)') 170 | 171 | # # def test_attribute_itemized_diff_to_shipping_tax_no_shipping(self): 172 | # # self.assertFalse(order().attribute_itemized_diff_to_shipping_tax()) 173 | 174 | # # def test_attribute_itemized_diff_to_shipping_tax_matches(self): 175 | # # o = order( 176 | # # total_charged='$10.00', 177 | # # subtotal='$6.01', 178 | # # tax_charged='$0.00', 179 | # # shipping_charge='$3.99') 180 | # # i = item(item_total='$6.01') 181 | # # o.set_items([i]) 182 | 183 | # # self.assertFalse(o.attribute_itemized_diff_to_shipping_tax()) 184 | 185 | # # def test_attribute_itemized_diff_to_shipping_tax_mismatch(self): 186 | # # o = order( 187 | # # total_charged='$10.00', 188 | # # subtotal='$5.50', 189 | # # tax_charged='$1.40', 190 | # # tax_before_promotions='$1.40', 191 | # # shipping_charge='$3.99') 192 | # # i = item(item_total='$5.50') 193 | # # o.set_items([i]) 194 | 195 | # # self.assertTrue(o.attribute_itemized_diff_to_shipping_tax()) 196 | # # self.assertEqual(o.shipping_charge, 4500000) 197 | # # self.assertEqual(o.tax_charged, 890000) 198 | # # self.assertEqual(o.tax_before_promotions, 890000) 199 | 200 | # # def test_attribute_itemized_diff_to_per_item_tax_correct(self): 201 | # # o = order( 202 | # # total_charged='$10.00', 203 | # # subtotal='$9.00', 204 | # # tax_charged='$1.00') 205 | # # i = item( 206 | # # item_total='$10.00', 207 | # # item_subtotal='$9.00', 208 | # # item_subtotal_tax='$1.00') 209 | # # o.set_items([i]) 210 | 211 | # # self.assertFalse(o.attribute_itemized_diff_to_per_item_tax()) 212 | 213 | # # def test_attribute_itemized_diff_to_per_item_tax_not_just_tax(self): 214 | # # o = order( 215 | # # total_charged='$10.00', 216 | # # subtotal='$9.00', 217 | # # tax_charged='$1.00') 218 | # # # The difference in the order total and the itemized total ($.50) is 219 | # # # _not_ equal to the difference in order total tax and itemized total 220 | # # # tax ($0.25). 221 | # # i = item( 222 | # # item_total='$9.50', 223 | # # item_subtotal='$9.00', 224 | # # item_subtotal_tax='$0.75') 225 | # # o.set_items([i]) 226 | 227 | # # self.assertFalse(o.attribute_itemized_diff_to_per_item_tax()) 228 | 229 | # # def test_attribute_itemized_diff_to_per_item_tax_one_item_under(self): 230 | # # o = order( 231 | # # total_charged='$10.00', 232 | # # subtotal='$9.00', 233 | # # tax_charged='$1.00') 234 | # # i = item( 235 | # # item_total='$9.90', 236 | # # item_subtotal='$9.00', 237 | # # item_subtotal_tax='$0.90') 238 | # # o.set_items([i]) 239 | 240 | # # self.assertTrue(o.attribute_itemized_diff_to_per_item_tax()) 241 | # # self.assertEqual(i.item_total, 10000000) 242 | # # self.assertEqual(i.item_subtotal, 9000000) 243 | # # self.assertEqual(i.item_subtotal_tax, 1000000) 244 | 245 | # # def test_attribute_itemized_diff_to_per_item_tax_one_item_over(self): 246 | # # o = order( 247 | # # total_charged='$10.00', 248 | # # subtotal='$9.00', 249 | # # tax_charged='$1.00') 250 | # # i = item( 251 | # # item_total='$10.90', 252 | # # item_subtotal='$9.00', 253 | # # item_subtotal_tax='$1.90') 254 | # # o.set_items([i]) 255 | 256 | # # self.assertTrue(o.attribute_itemized_diff_to_per_item_tax()) 257 | # # self.assertEqual(i.item_total, 10000000) 258 | # # self.assertEqual(i.item_subtotal, 9000000) 259 | # # self.assertEqual(i.item_subtotal_tax, 1000000) 260 | 261 | # # def test_attribute_itemized_diff_to_per_item_tax_two_items_under(self): 262 | # # o = order( 263 | # # total_charged='$20.00', 264 | # # subtotal='$18.00', 265 | # # tax_charged='$2.00') 266 | # # # This item has no tax, so all of the diff should go to the second 267 | # # # item. 268 | # # i1 = item( 269 | # # item_total='$9.00', 270 | # # item_subtotal='$9.00', 271 | # # item_subtotal_tax='$0.00') 272 | # # i2 = item( 273 | # # item_total='$9.99', 274 | # # item_subtotal='$9.00', 275 | # # item_subtotal_tax='$0.99') 276 | # # o.set_items([i1, i2]) 277 | 278 | # # self.assertTrue(o.attribute_itemized_diff_to_per_item_tax()) 279 | # # self.assertEqual(i1.item_total, 9000000) 280 | # # self.assertEqual(i1.item_subtotal, 9000000) 281 | # # self.assertEqual(i1.item_subtotal_tax, 0) 282 | # # self.assertEqual(i2.item_total, 11000000) 283 | # # self.assertEqual(i2.item_subtotal, 9000000) 284 | # # self.assertEqual(i2.item_subtotal_tax, 2000000) 285 | 286 | # # def test_attribute_itemized_diff_to_per_item_tax_two_items_notax(self): 287 | # # o = order( 288 | # # total_charged='$20.00', 289 | # # subtotal='$18.00', 290 | # # tax_charged='$2.00') 291 | # # # Both itemes have no tax, so all of the diff should go to the first 292 | # # # item (the default fallback). 293 | # # i1 = item( 294 | # # item_total='$9.00', 295 | # # item_subtotal='$9.00', 296 | # # item_subtotal_tax='$0.00') 297 | # # i2 = item( 298 | # # item_total='$9.00', 299 | # # item_subtotal='$9.00', 300 | # # item_subtotal_tax='$0.00') 301 | # # o.set_items([i1, i2]) 302 | 303 | # # self.assertTrue(o.attribute_itemized_diff_to_per_item_tax()) 304 | # # self.assertEqual(i1.item_total, 11000000) 305 | # # self.assertEqual(i1.item_subtotal, 9000000) 306 | # # self.assertEqual(i1.item_subtotal_tax, 2000000) 307 | # # self.assertEqual(i2.item_total, 9000000) 308 | # # self.assertEqual(i2.item_subtotal, 9000000) 309 | # # self.assertEqual(i2.item_subtotal_tax, 0) 310 | 311 | # # def test_attribute_itemized_diff_to_per_item_tax_two_items_over(self): 312 | # # o = order( 313 | # # total_charged='$20.00', 314 | # # subtotal='$18.00', 315 | # # tax_charged='$2.00') 316 | # # i1 = item( 317 | # # item_total='$5.50', 318 | # # item_subtotal='$4.50', 319 | # # item_subtotal_tax='$1.00') 320 | # # i2 = item( 321 | # # item_total='$15.50', 322 | # # item_subtotal='$13.50', 323 | # # item_subtotal_tax='$2.00') 324 | # # o.set_items([i1, i2]) 325 | 326 | # # self.assertTrue(o.attribute_itemized_diff_to_per_item_tax()) 327 | # # self.assertEqual(i1.item_total, 5000000) 328 | # # self.assertEqual(i1.item_subtotal, 4500000) 329 | # # self.assertEqual(i1.item_subtotal_tax, 500000) 330 | # # self.assertEqual(i2.item_total, 15000000) 331 | # # self.assertEqual(i2.item_subtotal, 13500000) 332 | # # self.assertEqual(i2.item_subtotal_tax, 1500000) 333 | 334 | # # def test_to_mint_transactions_free_shipping(self): 335 | # # orig_trans = transaction(amount=-20.00) 336 | 337 | # # o = order( 338 | # # total_charged='$20.00', 339 | # # shipping_charge='$3.99', 340 | # # total_discounts='$3.99') 341 | # # i1 = item(title='Item 1', item_total='$6.00', quantity='1') 342 | # # i2 = item(title='Item 2', item_total='$14.00', quantity='3') 343 | # # o.set_items([i1, i2]) 344 | 345 | # # mint_trans_ship = o.to_mint_transactions( 346 | # # orig_trans, skip_free_shipping=False) 347 | # # self.assertEqual(len(mint_trans_ship), 4) 348 | # # self.assertEqual(mint_trans_ship[0].description, '3x Item 2') 349 | # # self.assertEqual(mint_trans_ship[0].amount, -14000000) 350 | # # self.assertEqual(mint_trans_ship[1].description, 'Item 1') 351 | # # self.assertEqual(mint_trans_ship[1].amount, -6000000) 352 | # # self.assertEqual(mint_trans_ship[2].description, 'Shipping') 353 | # # self.assertEqual(mint_trans_ship[2].category.name, 'Shipping') 354 | # # self.assertEqual(mint_trans_ship[2].amount, -3990000) 355 | # # self.assertEqual(mint_trans_ship[3].description, 'Promotion(s)') 356 | # # self.assertEqual(mint_trans_ship[3].category.name, 'Shipping') 357 | # # self.assertEqual(mint_trans_ship[3].amount, 3990000) 358 | 359 | # # mint_trans_noship = o.to_mint_transactions( 360 | # # orig_trans, skip_free_shipping=True) 361 | # # self.assertEqual(len(mint_trans_noship), 2) 362 | # # self.assertEqual(mint_trans_noship[0].description, '3x Item 2') 363 | # # self.assertEqual(mint_trans_noship[0].amount, -14000000) 364 | # # self.assertEqual(mint_trans_noship[1].description, 'Item 1') 365 | # # self.assertEqual(mint_trans_noship[1].amount, -6000000) 366 | 367 | # # def test_to_mint_transactions_ship_promo_mismatch(self): 368 | # # orig_trans = transaction(amount=-20.00) 369 | 370 | # # o = order( 371 | # # total_charged='$20.00', 372 | # # shipping_charge='$3.99', 373 | # # total_discounts='$1.00') 374 | # # i = item(title='Item 1', item_total='$20.00', quantity='4') 375 | # # o.set_items([i]) 376 | 377 | # # mint_trans_ship = o.to_mint_transactions( 378 | # # orig_trans, skip_free_shipping=True) 379 | # # self.assertEqual(len(mint_trans_ship), 3) 380 | # # self.assertEqual(mint_trans_ship[0].description, '4x Item 1') 381 | # # self.assertEqual(mint_trans_ship[0].amount, -20000000) 382 | # # self.assertEqual(mint_trans_ship[1].description, 'Shipping') 383 | # # self.assertEqual(mint_trans_ship[1].category.name, 'Shipping') 384 | # # self.assertEqual(mint_trans_ship[1].amount, -3990000) 385 | # # self.assertEqual(mint_trans_ship[2].description, 'Promotion(s)') 386 | # # self.assertEqual(mint_trans_ship[2].category.name, 'Shopping') 387 | # # self.assertEqual(mint_trans_ship[2].amount, 1000000) 388 | 389 | # # def test_merge_one_order(self): 390 | # # o1 = order() 391 | # # i1 = item() 392 | # # i2 = item() 393 | # # o1.set_items([i1, i2]) 394 | 395 | # # merged = Order.merge([o1]) 396 | # # self.assertTrue(merged is o1) 397 | 398 | # # self.assertEqual(len(merged.items), 1) 399 | # # self.assertEqual(merged.items[0].quantity, 4) 400 | # # self.assertEqual(merged.items[0].item_total, 23900000) 401 | 402 | # # def test_merge_multi_charges(self): 403 | # # o1 = order() 404 | # # i1 = item() 405 | # # i2 = item() 406 | # # o1.set_items([i1, i2]) 407 | 408 | # # o2 = order() 409 | # # i3 = item() 410 | # # o2.set_items([i3]) 411 | 412 | # # o3 = order() 413 | # # i4 = item() 414 | # # i5 = item(title='Different!') 415 | # # o3.set_items([i4, i5]) 416 | 417 | # # merged = Order.merge([o1, o2, o3]) 418 | # # self.assertEqual(merged.total_charged, 35850000) 419 | # # self.assertEqual(merged.tax_charged, 3150000) 420 | # # self.assertEqual(merged.shipping_charge, 0) 421 | 422 | # # self.assertEqual(len(merged.items), 2) 423 | # # self.assertEqual(merged.items[0].quantity, 8) 424 | # # self.assertEqual(merged.items[0].item_total, 47800000) 425 | # # self.assertEqual(merged.items[1].quantity, 2) 426 | # # self.assertEqual(merged.items[1].item_total, 11950000) 427 | 428 | 429 | # # class ItemClass(unittest.TestCase): 430 | # # def test_constructor(self): 431 | # # i = item() 432 | 433 | # # self.assertEqual(i.order_id, '123-3211232-7655671') 434 | # # self.assertEqual(i.order_status, 'Shipped') 435 | # # self.assertEqual(i.title, 'Duracell AAs') 436 | # # self.assertEqual(i.quantity, 2) 437 | 438 | # # # Currency fields are all in microusd. 439 | # # self.assertEqual(i.purchase_price_per_unit, 5450000) 440 | # # self.assertEqual(i.item_subtotal, 10900000) 441 | # # self.assertEqual(i.item_subtotal_tax, 1050000) 442 | # # self.assertEqual(i.item_total, 11950000) 443 | 444 | # # # Dates are parsed: 445 | # # self.assertEqual(i.order_date, date(2014, 2, 26)) 446 | # # self.assertEqual(i.shipment_date, date(2014, 2, 28)) 447 | 448 | # # # Tracking is renamed: 449 | # # self.assertEqual(i.tracking, 'AMZN(ABC123)') 450 | 451 | # # def test_sum_subtotals(self): 452 | # # self.assertEqual(Item.sum_subtotals([]), 0) 453 | 454 | # # i1 = item(item_subtotal='$5.43') 455 | # # self.assertEqual(Item.sum_subtotals([i1]), 5430000) 456 | 457 | # # i2 = item(item_subtotal='$44.11') 458 | # # self.assertEqual(Item.sum_subtotals([i1, i2]), 49540000) 459 | # # self.assertEqual(Item.sum_subtotals([i2, i1]), 49540000) 460 | 461 | # # i3 = item(item_subtotal='-$2.11') 462 | # # self.assertEqual(Item.sum_subtotals([i2, i1, i3]), 47430000) 463 | 464 | # # def test_sum_totals(self): 465 | # # self.assertEqual(Item.sum_totals([]), 0) 466 | 467 | # # i1 = item(item_total='-$5.43') 468 | # # self.assertEqual(Item.sum_totals([i1]), -5430000) 469 | 470 | # # i2 = item(item_total='-$44.11') 471 | # # self.assertEqual(Item.sum_totals([i1, i2]), -49540000) 472 | # # self.assertEqual(Item.sum_totals([i2, i1]), -49540000) 473 | 474 | # # i3 = item(item_total='$2.11') 475 | # # self.assertEqual(Item.sum_totals([i2, i1, i3]), -47430000) 476 | 477 | # # def test_sum_subtotals_tax(self): 478 | # # self.assertEqual(Item.sum_subtotals_tax([]), 0) 479 | 480 | # # i1 = item(item_subtotal_tax='$0.43') 481 | # # self.assertEqual(Item.sum_subtotals_tax([i1]), 430000) 482 | 483 | # # i2 = item(item_subtotal_tax='$0.00') 484 | # # self.assertEqual(Item.sum_subtotals_tax([i1, i2]), 430000) 485 | # # self.assertEqual(Item.sum_subtotals_tax([i2, i1]), 430000) 486 | 487 | # # i3 = item(item_subtotal_tax='$2.11') 488 | # # self.assertEqual(Item.sum_subtotals_tax([i2, i1, i3]), 2540000) 489 | 490 | # # def test_get_title(self): 491 | # # i = item(title='The best item ever!') 492 | # # self.assertEqual(i.get_title(), '2x The best item ever') 493 | # # self.assertEqual(i.get_title(10), '2x The best') 494 | 495 | # # i2 = item(title='Something alright (]][', quantity=1) 496 | # # self.assertEqual(i2.get_title(), 'Something alright') 497 | 498 | # # def test_is_cancelled(self): 499 | # # self.assertTrue(item(order_status='Cancelled').is_cancelled()) 500 | # # self.assertFalse(item(order_status='Shipped').is_cancelled()) 501 | 502 | # # def test_set_quantity(self): 503 | # # i = item() 504 | # # i.set_quantity(3) 505 | 506 | # # self.assertEqual(i.quantity, 3) 507 | # # self.assertEqual(i.item_subtotal, 16350000) 508 | # # self.assertEqual(i.item_subtotal_tax, 1575000) 509 | # # self.assertEqual(i.item_total, 17925000) 510 | 511 | # # i.set_quantity(1) 512 | 513 | # # self.assertEqual(i.quantity, 1) 514 | # # self.assertEqual(i.item_subtotal, 5450000) 515 | # # self.assertEqual(i.item_subtotal_tax, 525000) 516 | # # self.assertEqual(i.item_total, 5975000) 517 | 518 | # # def test_split_by_quantity(self): 519 | # # i = item() 520 | # # items = i.split_by_quantity() 521 | 522 | # # self.assertEqual(len(items), 2) 523 | # # for it in items: 524 | # # self.assertEqual(it.quantity, 1) 525 | # # self.assertEqual(it.item_subtotal, 5450000) 526 | # # self.assertEqual(it.item_subtotal_tax, 525000) 527 | # # self.assertEqual(it.item_total, 5975000) 528 | 529 | # # def test_merge(self): 530 | # # i1 = item() 531 | # # i2 = item() 532 | # # i3 = item(title='Something diff') 533 | 534 | # # merged = Item.merge([i1, i2, i3]) 535 | 536 | # # self.assertEqual(len(merged), 2) 537 | 538 | # # self.assertEqual(merged[0].quantity, 4) 539 | # # self.assertEqual(merged[0].item_subtotal, 21800000) 540 | # # self.assertEqual(merged[0].item_subtotal_tax, 2100000) 541 | # # self.assertEqual(merged[0].item_total, 23900000) 542 | 543 | # # self.assertEqual(merged[1].quantity, 2) 544 | # # self.assertEqual(merged[1].item_subtotal, 10900000) 545 | # # self.assertEqual(merged[1].item_subtotal_tax, 1050000) 546 | # # self.assertEqual(merged[1].item_total, 11950000) 547 | 548 | 549 | # if __name__ == "__main__": 550 | # unittest.main() 551 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/amazon.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | import csv 3 | from datetime import datetime, timezone 4 | from typing import List, Optional 5 | from dateutil import parser 6 | import io 7 | import logging 8 | from pprint import pformat 9 | import re 10 | import string 11 | 12 | from monarchmoneyamazontagger import category 13 | from monarchmoneyamazontagger.micro_usd import MicroUSD, CENT_MICRO_USD, MICRO_USD_EPS 14 | from monarchmoneyamazontagger.mm import truncate_title 15 | from monarchmoneyamazontagger.my_progress import no_progress_factory 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | PRINTABLE = set(string.printable) 20 | 21 | 22 | ORDER_HISTORY_CSV_PATTERN = re.compile( 23 | r"Retail.OrderHistory.\d+/Retail.OrderHistory.\d+.csv" 24 | ) 25 | 26 | 27 | def is_order_history_csv(zip_file_name): 28 | return bool(ORDER_HISTORY_CSV_PATTERN.match(zip_file_name)) 29 | 30 | 31 | def rm_leading_qty(item_title): 32 | """Removes the '2x Item Name' from the front of an item title.""" 33 | return re.sub(r"^\d+x ", "", item_title) 34 | 35 | 36 | def get_title(amzn_obj, target_length): 37 | # Also works for a Refund record. 38 | qty = amzn_obj.quantity 39 | base_str = None 40 | if qty > 1: 41 | base_str = str(qty) + "x" 42 | # Remove non-ASCII characters from the title. 43 | clean_title = "".join(filter(lambda x: x in PRINTABLE, amzn_obj.product_name)) 44 | return truncate_title(clean_title, target_length, base_str) 45 | 46 | 47 | CURRENCY_FIELD_NAMES = set( 48 | [ 49 | "Unit Price", 50 | "Unit Price Tax", 51 | "Shipping Charge", 52 | "Total Discounts", 53 | "Total Owed", 54 | "Shipment Item Subtotal", 55 | "Shipment Item Subtotal Tax", 56 | ] 57 | ) 58 | 59 | DATE_FIELD_NAMES = set( 60 | [ 61 | "Order Date", 62 | "Ship Date", 63 | ] 64 | ) 65 | 66 | # TODO: Fix quoting issue with Website". 67 | RENAME_FIELD_NAMES = { 68 | "Carrier Name & Tracking Number": "tracking", 69 | 'Website"': "website", 70 | } 71 | 72 | MULTI_SPLIT_BY_AND = set( 73 | ["Order Date", "Ship Date", "tracking", "Payment Instrument Type"] 74 | ) 75 | 76 | 77 | def parse_from_csv_common( 78 | cls, csv_file, progress_label="Parse from CSV", progress_factory=no_progress_factory 79 | ): 80 | # contents = csv_file.read().decode() 81 | contents = csv_file.read().decode("utf-8") 82 | # Strip a leading FEFF if present. 83 | if contents[0:1] == "\ufeff": 84 | contents = contents[2:] 85 | 86 | num_records = sum(1 for c in contents if c == "\n") - 1 87 | result = [] 88 | if not num_records: 89 | return result 90 | 91 | progress = progress_factory(progress_label, num_records) 92 | reader = csv.DictReader(io.StringIO(contents)) 93 | # Convert input fieldnames into pythonic names. Feels somewhat naughty but works: 94 | reader.fieldnames = [ 95 | fn.replace('"', "").replace(" ", "_").replace("&", "and").lower() 96 | for fn in reader.fieldnames 97 | ] 98 | for csv_dict in reader: 99 | result.append(cls(**csv_dict)) 100 | progress.next() 101 | progress.finish() 102 | return result 103 | 104 | 105 | def parse_amazon_date(date_str: str) -> datetime | None: 106 | if not date_str or date_str == "Not Available": 107 | return None 108 | return parser.parse(date_str) 109 | 110 | 111 | def get_invoice_url(order_id: str) -> str: 112 | return ( 113 | "https://www.amazon.com/gp/css/summary/print.html?ie=UTF8&" 114 | f"orderID={order_id}" 115 | ) 116 | 117 | 118 | def datetime_list_to_dates_str(dates: List[datetime]) -> str: 119 | return ", ".join([d.strftime("%Y-%m-%d") for d in dates]) 120 | 121 | 122 | class Charge: 123 | """A Charge represents a set of items corresponding to one payment. 124 | 125 | A Charge can have (TODO: validate each): 126 | - One or more items with one or more per quantity each 127 | - One or more tracking numbers 128 | - One or more shipment dates/times 129 | - One or more payment instruments, including partial or complete usage of gift cards. 130 | 131 | A Charge cannot have: 132 | - Items from different order IDs 133 | """ 134 | 135 | matched = False 136 | trans_id = None 137 | items = [] 138 | 139 | def __init__(self, items): 140 | self.items = items 141 | 142 | # def subtotal(self): 143 | # return sum([i.amount_charged for i in self.items]) 144 | 145 | # @staticmethod 146 | # def sum_subtotals(charges): 147 | # return sum([o.subtotal for o in charges]) 148 | 149 | def has_hidden_shipping_fee(self): 150 | # Colorado - https://tax.colorado.gov/retail-delivery-fee 151 | # "Effective July 1, 2022, Colorado imposes a retail delivery fee on 152 | # all deliveries by motor vehicle to a location in Colorado with at 153 | # least one item of tangible personal property subject to state sales 154 | # or use tax." 155 | # Rate July 2022 to June 2023: $0.27 156 | # This is not the case as of 8/31/2022 for Amazon Order Reports. 157 | # "Retailers that make retail deliveries must show the total of the 158 | # fees on the receipt or invoice as one item called “retail delivery 159 | # fees”." 160 | # TODO: Improve the ' CO ' Matching, consider a regex w/ a zip code element. 161 | ship_dates = self.ship_dates() 162 | return ( 163 | " CO " in self.ship_address() 164 | and self.tax() > 0 165 | and ship_dates 166 | and max(ship_dates) >= datetime(2022, 7, 1, tzinfo=timezone.utc) 167 | ) 168 | 169 | def hidden_shipping_fee(self) -> MicroUSD: 170 | return MicroUSD.from_float(0.27) 171 | 172 | def hidden_shipping_fee_note(self) -> str: 173 | return "CO Retail Delivery Fee" 174 | 175 | def total_by_items(self): 176 | return ( 177 | Item.sum_totals(self.items) 178 | + (self.hidden_shipping_fee() if self.has_hidden_shipping_fee() else 0) 179 | + self.shipping_charge() 180 | + self.total_discounts() 181 | ) 182 | 183 | # def total_by_subtotals(self): 184 | # return ( 185 | # self.subtotal + self.tax_charged 186 | # + self.shipping_charge + self.total_discounts()) 187 | 188 | def set_items(self, items, assert_unmatched=False): 189 | # Make a new list (to prevent retaining the given list). 190 | self.items = [] 191 | self.items.extend(items) 192 | self.items_matched = True 193 | for i in items: 194 | if assert_unmatched: 195 | assert not i.matched 196 | i.matched = True 197 | i.charge = self 198 | 199 | def total_quantity(self): 200 | return sum([i.quantity for i in self.items]) 201 | 202 | def order_id(self): 203 | return self.items[0].order_id 204 | 205 | def order_status(self): 206 | return self.items[0].order_status 207 | 208 | def ship_status(self): 209 | return self.items[0].shipment_status 210 | 211 | def website(self): 212 | return self.items[0].website 213 | 214 | def ship_address(self): 215 | return self.items[0].shipping_address 216 | 217 | def payment_instrument_types(self): 218 | return set([pit for i in self.items for pit in i.payment_instrument_type]) 219 | 220 | def order_dates(self): 221 | return [date for items in self.items for date in items.order_date] 222 | 223 | def ship_dates(self): 224 | return [date for items in self.items for date in items.ship_date] 225 | 226 | def unique_order_dates(self): 227 | return list(set([d.date() for d in self.order_dates()])) 228 | 229 | def unique_ship_dates(self): 230 | return list(set([d.date() for d in self.ship_dates()])) 231 | 232 | def subtotal(self): 233 | return Item.sum_subtotals(self.items) 234 | 235 | def tax(self): 236 | return Item.sum_subtotals_tax(self.items) 237 | 238 | def total(self): 239 | """This should be = subtotal + tax.""" 240 | return Item.sum_totals(self.items) 241 | 242 | def shipping_charge(self): 243 | return sum([i.shipping_charge for i in self.items]) 244 | 245 | def total_discounts(self): 246 | return sum([i.total_discounts for i in self.items]) 247 | 248 | def total_owed(self): 249 | """This should be = total + shipping_charge + total_discounts.""" 250 | return sum([i.total_owed for i in self.items]) 251 | 252 | def tracking_numbers(self): 253 | return list(set([items.tracking for items in self.items])) 254 | 255 | def transact_date(self): 256 | """The latest ship date in local time zone.""" 257 | dates = [d for i in self.items if i.ship_date for d in i.ship_date] 258 | if not dates: 259 | return None 260 | # Use the local timezone (report has them in UTC). 261 | # UTC will cause matching to be incorrect. 262 | return max(dates).astimezone().date() 263 | 264 | # if self.items[0].ship_date and self.items[0].ship_date[0]: 265 | # 266 | # return self.items[0].ship_date[0].astimezone().date() 267 | 268 | def transact_amount(self) -> MicroUSD: 269 | if self.has_hidden_shipping_fee(): 270 | return -(self.total_owed() + self.hidden_shipping_fee()).round_to_cent() 271 | return -self.total_owed() 272 | 273 | def match(self, trans): 274 | self.matched = True 275 | self.trans_id = trans.id 276 | # if self.order_id() in ('112-9523119-2065026', '113-7797306-4423467', '112-5028447-9842607'): 277 | # print('A match made in MAT') 278 | # print(self) 279 | # print(trans) 280 | 281 | def get_notes(self): 282 | note = ( 283 | f"Amazon order id: {self.order_id()}\n" 284 | f"Order date: {datetime_list_to_dates_str(self.unique_order_dates())}\n" 285 | f"Ship date: {datetime_list_to_dates_str(self.unique_ship_dates())}\n" 286 | f'Tracking: {", ".join(self.tracking_numbers())}\n' 287 | f"Invoice url: {get_invoice_url(self.order_id())}" 288 | ) 289 | # Notes max out at 1000 as of 2023/12/31. If at or above the limit, use a simplified note: 290 | if len(note) >= 1000: 291 | logger.warn("Truncating note for Amazon Charge due to excessive length") 292 | note = ( 293 | f"Amazon order id: {self.order_id()}\n" 294 | f"Invoice url: {get_invoice_url(self.order_id())}" 295 | ) 296 | return note 297 | 298 | def attribute_subtotal_diff_to_misc_charge(self): 299 | """Sometimes gift wrapping or other misc charge is captured within 'total_owed' for an item but it doesn't belong there.""" 300 | diff = self.total_owed() - self.total_by_items() 301 | if diff < MICRO_USD_EPS: 302 | return False 303 | 304 | adjustments = 0 305 | for i in self.items: 306 | item_diff = i.total_owed - i.total_owed_by_parts() 307 | if item_diff > MICRO_USD_EPS: 308 | i.total_owed -= item_diff 309 | 310 | adjustment = deepcopy(self.items[0]) 311 | adjustment.product_name = "Misc Charge (Gift wrap, etc)" 312 | adjustment.category = "Shopping" 313 | adjustment.quantity = 1 314 | adjustment.shipping_charge = 0 315 | adjustment.total_discounts = 0 316 | 317 | adjustment.unit_price = item_diff 318 | adjustment.shipment_item_subtotal = item_diff 319 | adjustment.total_owed = item_diff 320 | 321 | adjustment.unit_price_tax = 0 322 | adjustment.shipment_item_subtotal_tax = 0 323 | adjustment.unit_price_tax = 0 324 | 325 | self.items.append(adjustment) 326 | adjustments += 1 327 | 328 | return adjustments > 0 329 | 330 | def attribute_itemized_diff_to_shipping_error(self): 331 | # Shipping is sometimes wrong. Remove shipping off of items if it is the only thing preventing a clean reconcile. 332 | if not self.shipping_charge(): 333 | return False 334 | 335 | diff = self.total_by_items() - self.total_owed() 336 | if diff < MICRO_USD_EPS: 337 | return False 338 | 339 | # Find an item with a non-zero shipping charge and add on the tax. 340 | adjustments = 0 341 | for i in self.items: 342 | item_diff = i.total_owed_by_parts() - i.total_owed 343 | if item_diff == i.shipping_charge: 344 | i.shipping_charge = 0 345 | adjustments += 1 346 | return adjustments > 0 347 | 348 | def attribute_itemized_diff_to_item_fractional_tax(self): 349 | """Correct for a slight mismatch when multiple quantities cause per-item taxes to not add up.""" 350 | if self.total_quantity() < 2: 351 | return False 352 | 353 | itemized_diff = self.total_owed() - self.total_by_items() 354 | if abs(itemized_diff) < MICRO_USD_EPS: 355 | return False 356 | 357 | # Only correct for a maximum amount of rounding errors up to the quantity of items in the charge. 358 | if itemized_diff < CENT_MICRO_USD * self.total_quantity(): 359 | per_item_tax_adjustment = itemized_diff / self.total_quantity() 360 | for i in self.items: 361 | i.unit_price_tax += per_item_tax_adjustment 362 | return True 363 | return False 364 | 365 | def to_mint_transactions(self, t, skip_free_shipping=False): 366 | new_transactions = [] 367 | 368 | # More expensive items are always more interesting when it comes to 369 | # budgeting, so show those first (for both itemized and concatted). 370 | items = sorted(self.items, key=lambda item: item.unit_price, reverse=True) 371 | 372 | # Itemize line-items: 373 | for i in items: 374 | item = t.split( 375 | amount=-i.total(), 376 | category_name=t.category.name, 377 | description=i.get_title(88), 378 | notes=self.get_notes(), 379 | ) 380 | new_transactions.append(item) 381 | 382 | if self.has_hidden_shipping_fee(): 383 | ship_fee = t.split( 384 | amount=-self.hidden_shipping_fee(), 385 | category_name="Shipping", 386 | description=self.hidden_shipping_fee_note(), 387 | notes=self.get_notes(), 388 | ) 389 | new_transactions.append(ship_fee) 390 | 391 | # Itemize the shipping cost, if any. 392 | is_free_shipping = ( 393 | self.shipping_charge() 394 | and self.total_discounts() 395 | and self.total_discounts() == self.shipping_charge() 396 | ) 397 | 398 | if is_free_shipping and skip_free_shipping: 399 | return new_transactions 400 | 401 | if self.shipping_charge(): 402 | ship = t.split( 403 | amount=-self.shipping_charge(), 404 | category_name="Shipping", 405 | description="Shipping", 406 | notes=self.get_notes(), 407 | ) 408 | new_transactions.append(ship) 409 | 410 | # All promotion(s) as one line-item. 411 | if self.total_discounts(): 412 | # If there was a promo that matches the shipping cost, it's nearly 413 | # certainly a Free One-day/same-day/etc promo. In this case, 414 | # categorize the promo instead as 'Shipping', which will cancel out 415 | # in Mint trends. 416 | 417 | # Note: Since the move to Amazon Request My Data, same/next day 418 | # shipping that was e.g. $2.99 and then comp'd is no longer 419 | # included in these reports (discounts and shipping are both zero'd 420 | # out). 421 | cat = "Shipping" if is_free_shipping else category.DEFAULT_CATEGORY 422 | promo = t.split( 423 | amount=-self.total_discounts(), 424 | category_name=cat, 425 | description="Promotion(s)", 426 | notes=self.get_notes(), 427 | ) 428 | new_transactions.append(promo) 429 | 430 | return new_transactions 431 | 432 | @classmethod 433 | def merge(cls, charges): 434 | if len(charges) == 1: 435 | return charges[0] 436 | # result.set_items(Item.merge(result.items)) 437 | # return result 438 | 439 | return Charge([i for c in charges for i in c.items]) 440 | # result = deepcopy(charges[0]) 441 | # result.set_items(Item.merge([i for o in charges for i in o.items])) 442 | # # for key in ORDER_MERGE_FIELDS: 443 | # # result.__dict__[key] = sum([o.__dict__[key] for o in charges]) 444 | # return result 445 | 446 | def __repr__(self): 447 | return ( 448 | f"Charge ({self.order_id()}): {self.ship_dates() or self.order_dates()}" 449 | f" Total {str(self.total_owed())}\t" 450 | f" Total by part {str(self.total_by_items())}\t" 451 | f"Subtotal {str(self.subtotal())}\t" 452 | f"Tax {str(self.tax())}\t" 453 | f"Promo {str(self.total_discounts())}\t" 454 | f"Ship {str(self.shipping_charge())}\t" 455 | f"Items: \n{pformat(self.items)}" 456 | ) 457 | 458 | 459 | MULTI_VALUE_SPLIT = " and " 460 | 461 | 462 | def parse_optional(value: str) -> Optional[str]: 463 | if value == "Not Available": 464 | return None 465 | return value 466 | 467 | 468 | class Item: 469 | """A charge comprises of one or more Items with one or more quantity. 470 | 471 | The general formula for an item is: 472 | REVISE!!! 473 | shipment_item_subtotal = unit_price * quantity + other items in same charge 474 | shipment_item_tax = unit_price_tax * quantity + other items in same charge 475 | shipment_item_total = shipment_item_subtotal + shipment_item_tax 476 | total_owed = shipment_item_total + shipping_charge + total_discounts 477 | """ 478 | 479 | # Fields in order as they appear in CSV export 480 | 481 | website: str 482 | order_id: str 483 | order_date: List[datetime] # Example: "2023-12-31T00:21:42Z" 484 | purchase_order_number: Optional[str] 485 | currency: str 486 | unit_price: MicroUSD 487 | unit_price_tax: MicroUSD 488 | shipping_charge: MicroUSD 489 | total_discounts: MicroUSD 490 | total_owed: MicroUSD 491 | shipment_item_subtotal: Optional[MicroUSD] 492 | shipment_item_subtotal_tax: Optional[MicroUSD] 493 | asin: str 494 | product_condition: str 495 | quantity: int 496 | payment_instrument_type: List[str] 497 | order_status: str 498 | shipment_status: Optional[str] 499 | ship_date: List[datetime] # "2023-12-31T10:21:37Z" 500 | shipping_option: str # Example: "expd-consolidated-us" 501 | shipping_address: str 502 | billing_address: str 503 | carrier_name_and_tracking_number: List[str] # Example: "AMZN_US(TBA310866232294)" 504 | product_name: str 505 | gift_message: Optional[str] 506 | gift_sender_name: Optional[str] 507 | gift_recipient_contact_details: Optional[str] 508 | 509 | def __init__( 510 | self, 511 | website, 512 | order_id, 513 | order_date, 514 | purchase_order_number, 515 | currency, 516 | unit_price, 517 | unit_price_tax, 518 | shipping_charge, 519 | total_discounts, 520 | total_owed, 521 | shipment_item_subtotal, 522 | shipment_item_subtotal_tax, 523 | asin, 524 | product_condition, 525 | quantity, 526 | payment_instrument_type, 527 | order_status, 528 | shipment_status, 529 | ship_date, 530 | shipping_option, 531 | shipping_address, 532 | billing_address, 533 | carrier_name_and_tracking_number, 534 | product_name, 535 | gift_message, 536 | gift_sender_name, 537 | gift_recipient_contact_details, 538 | ): 539 | self.website = website 540 | self.order_id = order_id 541 | self.order_date = [ 542 | parse_amazon_date(d) 543 | for d in order_date.split(MULTI_VALUE_SPLIT) 544 | if parse_amazon_date(d) 545 | ] # type: ignore 546 | self.purchase_order_number = ( 547 | None 548 | if purchase_order_number == "Not Applicable" 549 | else parse_optional(purchase_order_number) 550 | ) 551 | self.currency = currency 552 | self.unit_price = MicroUSD.parse(unit_price) 553 | self.unit_price_tax = MicroUSD.parse(unit_price_tax) 554 | self.shipping_charge = MicroUSD.parse(shipping_charge) 555 | self.total_discounts = MicroUSD.parse(total_discounts) 556 | self.total_owed = MicroUSD.parse(total_owed) 557 | shipment_item_subtotal = parse_optional(shipment_item_subtotal) 558 | shipment_item_subtotal_tax = parse_optional(shipment_item_subtotal_tax) 559 | self.shipment_item_subtotal = ( 560 | MicroUSD.parse(shipment_item_subtotal) 561 | if parse_optional(shipment_item_subtotal) 562 | else None 563 | ) 564 | self.shipment_item_subtotal_tax = ( 565 | MicroUSD.parse(shipment_item_subtotal_tax) 566 | if parse_optional(shipment_item_subtotal_tax) 567 | else None 568 | ) 569 | self.asin = asin 570 | self.product_condition = product_condition 571 | self.quantity = int(quantity) 572 | self.payment_instrument_type = payment_instrument_type.split(MULTI_VALUE_SPLIT) 573 | self.order_status = order_status 574 | self.shipment_status = parse_optional(shipment_status) 575 | self.ship_date = [] 576 | for d in ship_date.split(MULTI_VALUE_SPLIT): 577 | d = parse_optional(d) 578 | if d: 579 | parsed_date = parse_amazon_date(d) 580 | self.ship_date.append(parsed_date) 581 | self.shipping_option = shipping_option 582 | self.shipping_address = shipping_address 583 | self.billing_address = billing_address 584 | self.carrier_name_and_tracking_number = carrier_name_and_tracking_number.split( 585 | MULTI_VALUE_SPLIT 586 | ) 587 | self.product_name = product_name 588 | self.gift_message = parse_optional(gift_message) 589 | self.gift_sender_name = parse_optional(gift_sender_name) 590 | self.gift_recipient_contact_details = parse_optional( 591 | gift_recipient_contact_details 592 | ) 593 | 594 | @classmethod 595 | def parse_from_csv(cls, csv_file, progress_factory=no_progress_factory): 596 | return parse_from_csv_common( 597 | cls, csv_file, "Parsing Amazon Items", progress_factory 598 | ) 599 | 600 | @staticmethod 601 | def sum_subtotals(items): 602 | return sum([i.subtotal() for i in items]) 603 | 604 | @staticmethod 605 | def sum_subtotals_tax(items): 606 | return sum([i.subtotal_tax() for i in items]) 607 | 608 | @staticmethod 609 | def sum_totals(items): 610 | return sum([i.total() for i in items]) 611 | 612 | def subtotal(self): 613 | return self.unit_price * self.quantity 614 | 615 | def subtotal_tax(self): 616 | return self.unit_price_tax * self.quantity 617 | 618 | def total(self): 619 | """Prior to shipping_charge and total_discounts.""" 620 | return self.subtotal() + self.subtotal_tax() 621 | 622 | def total_owed_by_parts(self): 623 | """Prior to shipping_charge and total_discounts.""" 624 | return self.total() + self.total_discounts + self.shipping_charge 625 | 626 | # def adjust_unit_tax_based_on_total_owed(self): 627 | # """ 628 | # Returns true if per unit taxes are fractionally adjusted to align total_owed with per unit prices. 629 | 630 | # This happens when quantity is greater than one and is illustrated as: 631 | # total_owed != quantity * (unit_price + unit_price_tax) + total_discounts + shipping_charge 632 | # unit_price_tax is rounded, which causes a mismatch when devining total_owed from per-unit prices. 633 | # """ 634 | # subtotal = self.total_owed - self.total_discounts - self.shipping_charge 635 | # per_unit_subtotal = self.total() 636 | # if subtotal == per_unit_subtotal: 637 | # return False 638 | 639 | # print(subtotal) 640 | # print(per_unit_subtotal) 641 | # print(self.__dict__) 642 | # # exit() 643 | # # # TODO: Adjust tax to be fractional - more precise. Do this for all of the original charges (prior to merge). Adjust per-item amounts such that the total_owed always works out (Within reason - ie 2 cents?) 644 | # # if i.total() + i.total_discounts != i.total_owed: 645 | # # print(i.total() + i.total_discounts) 646 | # # print(i.total_owed) 647 | # # print(i) 648 | 649 | # 9990000 + 1010000 650 | 651 | def tax_rate(self) -> float: 652 | return round( 653 | self.unit_price_tax.to_float() * 100.0 / self.unit_price.to_float(), 1 654 | ) 655 | 656 | def get_title(self, target_length=100) -> str: 657 | return get_title(self, target_length) 658 | 659 | def is_cancelled(self) -> bool: 660 | return self.order_status == "Cancelled" 661 | 662 | def __repr__(self): 663 | return ( 664 | f"{self.quantity} of Item: " 665 | f"Order ID {self.order_id}\t" 666 | f"Status {self.order_status}\t" 667 | f"Ship Status {self.shipment_status}\t" 668 | f"Order Date {self.order_date}\t" 669 | f"Ship Date {self.ship_date}\t" 670 | f"Tracking {self.carrier_name_and_tracking_number}\t" 671 | f"Unit Price {str(self.unit_price)}\t" 672 | f"Unit Tax {str(self.unit_price_tax)}\t" 673 | f"Total Owed {str(self.total_owed)}\t" 674 | f"Shipping Charge {str(self.shipping_charge)}\t" 675 | f"Discounts {str(self.total_discounts)}\t" 676 | f"{self.product_name}" 677 | ) 678 | 679 | 680 | # class Refund: 681 | # matched = False 682 | # trans_id = None 683 | # is_refund = True 684 | 685 | # def __init__(self, raw_dict): 686 | # # Refunds are rad: AMZN doesn't total the tax + sub-total for you. 687 | # fields = pythonify_amazon_dict(raw_dict) 688 | # fields['total_refund_amount'] = ( 689 | # fields['refund_amount'] + fields['refund_tax_amount']) 690 | # self.__dict__.update(fields) 691 | 692 | # @staticmethod 693 | # def sum_total_refunds(refunds): 694 | # return sum([r.total_refund_amount for r in refunds]) 695 | 696 | # @classmethod 697 | # def parse_from_csv(cls, csv_file, progress_factory=no_progress_factory): 698 | # return parse_from_csv_common( 699 | # cls, csv_file, 'Parsing Amazon Refunds', progress_factory) 700 | 701 | # def match(self, trans): 702 | # self.matched = True 703 | # self.trans_id = trans.id 704 | 705 | # def transact_date(self): 706 | # return self.refund_date 707 | 708 | # def transact_amount(self): 709 | # return self.total_refund_amount 710 | 711 | # def get_title(self, target_length=100): 712 | # return get_title(self, target_length) 713 | 714 | # def get_notes(self): 715 | # return ( 716 | # f'Amazon refund for order id: {self.order_id}\n' 717 | # f'Buyer: {self.buyer_name}\n' 718 | # f'Order date: {self.order_date}\n' 719 | # f'Refund date: {self.refund_date}\n' 720 | # f'Refund reason: {self.refund_reason}\n' 721 | # f'Invoice url: {get_invoice_url(self.order_id)}') 722 | 723 | # def to_mint_transaction(self, t): 724 | # # Refunds have a positive amount. 725 | # result = t.split( 726 | # description=self.get_title(88), 727 | # category_name=category.DEFAULT_MINT_RETURN_CATEGORY, 728 | # amount=self.total_refund_amount, 729 | # notes=self.get_notes()) 730 | # return result 731 | 732 | # @staticmethod 733 | # def merge(refunds): 734 | # """Collapses identical items by using quantity.""" 735 | # if len(refunds) <= 1: 736 | # return refunds 737 | # unique_refund_items = defaultdict(list) 738 | # for r in refunds: 739 | # key = ( 740 | # f'{r.refund_date}-{r.refund_reason}-{r.title}-' 741 | # f'{r.total_refund_amount}-{r.asin_isbn}') 742 | # unique_refund_items[key].append(r) 743 | # results = [] 744 | # for same_items in unique_refund_items.values(): 745 | # qty = sum([i.quantity for i in same_items]) 746 | # if qty == 1: 747 | # results.extend(same_items) 748 | # continue 749 | 750 | # refund = same_items[0] 751 | # refund.quantity = qty 752 | # refund.total_refund_amount *= qty 753 | # refund.refund_amount *= qty 754 | # refund.refund_tax_amount *= qty 755 | 756 | # results.append(refund) 757 | # return results 758 | 759 | # def __repr__(self): 760 | # return ( 761 | # f'{self.quantity} of Refund: ' 762 | # f'Total {micro_usd_to_usd_string(self.total_refund_amount)}\t' 763 | # f'Subtotal {micro_usd_to_usd_string(self.refund_amount)}\t' 764 | # f'Tax {micro_usd_to_usd_string(self.refund_tax_amount)} ' 765 | # f'{self.title}') 766 | -------------------------------------------------------------------------------- /monarchmoneyamazontagger/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # This tool takes an Amazon Data Export and annotates Monarch Money 4 | # transactions based on the actual items in each order. It can handle charges 5 | # that are split into multiple shipments/charges and can itemized each 6 | # transaction for maximal control over categorization. 7 | 8 | import argparse 9 | import atexit 10 | import datetime 11 | from functools import partial 12 | import logging 13 | import os 14 | from signal import signal, SIGINT 15 | import sys 16 | import time 17 | from urllib.parse import urlencode 18 | import webbrowser 19 | 20 | from PyQt6.QtCore import ( 21 | Q_ARG, 22 | QDate, 23 | QEventLoop, 24 | Qt, 25 | QMetaObject, 26 | QObject, 27 | QTimer, 28 | QThread, 29 | QUrl, 30 | pyqtSlot, 31 | pyqtSignal, 32 | ) # type: ignore 33 | from PyQt6.QtGui import QDesktopServices, QKeySequence, QShortcut # type: ignore 34 | from PyQt6.QtWidgets import ( 35 | QAbstractItemView, 36 | QApplication, 37 | QCalendarWidget, 38 | QCheckBox, 39 | QComboBox, 40 | QDialog, 41 | QErrorMessage, 42 | QFileDialog, 43 | QFormLayout, 44 | QGroupBox, 45 | QHBoxLayout, 46 | QInputDialog, 47 | QLabel, 48 | QLineEdit, 49 | QMainWindow, 50 | QProgressBar, 51 | QPushButton, 52 | QTableView, 53 | QWidget, 54 | QVBoxLayout, 55 | ) # type: ignore 56 | from outdated import check_outdated 57 | 58 | from monarchmoneyamazontagger import amazon 59 | from monarchmoneyamazontagger import tagger 60 | from monarchmoneyamazontagger import VERSION 61 | from monarchmoneyamazontagger.args import ( 62 | define_gui_args, 63 | get_name_to_help_dict, 64 | TAGGER_BASE_PATH, 65 | ) 66 | from monarchmoneyamazontagger.qt import ( 67 | MMUpdatesTableModel, 68 | AmazonUnmatchedTableDialog, 69 | AmazonStatsDialog, 70 | TaggerStatsDialog, 71 | ) 72 | from monarchmoneyamazontagger.mmclient import MonarchMoneyClient 73 | from monarchmoneyamazontagger.my_progress import QtProgress 74 | 75 | logger = logging.getLogger(__name__) 76 | 77 | NEVER_SAVE_MSG = "Email & password are *never* saved." 78 | 79 | 80 | class TaggerGui: 81 | def __init__(self, args, arg_name_to_help, log_filename): 82 | self.args = args 83 | self.arg_name_to_help = arg_name_to_help 84 | self.log_filename = log_filename 85 | self.window = None 86 | 87 | def create_gui(self): 88 | app = QApplication(sys.argv) 89 | 90 | timer = QTimer() 91 | timer.start(500) 92 | timer.timeout.connect(lambda: None) 93 | 94 | app.setStyle("Fusion") 95 | version_string = f"Monarch Money Amazon Tagger v{VERSION}" 96 | app.setApplicationName(version_string) 97 | self.window = QMainWindow() 98 | self.window.setWindowTitle(version_string) 99 | 100 | self.quit_shortcuts = [] 101 | for seq in ("Ctrl+Q", "Ctrl+C", "Ctrl+W", "ESC"): 102 | s = QShortcut(QKeySequence(seq), self.window) 103 | s.activated.connect(app.exit) 104 | self.quit_shortcuts.append(s) 105 | 106 | logger.info(f"Running version {VERSION}") 107 | # try: 108 | # is_outdated, latest_version = check_outdated( 109 | # "monarchmoney-amazon-tagger", VERSION 110 | # ) 111 | # if is_outdated: 112 | # outdate_msg = QErrorMessage(self.window) 113 | # outdate_msg.showMessage( 114 | # "A new version is available. Please update for the best " 115 | # "experience. " 116 | # "https://github.com/jprouty/monarchmoney-amazon-tagger" 117 | # ) 118 | # logger.warning( 119 | # "Running out of date software is bad. Latest is " 120 | # f"{latest_version}" 121 | # ) 122 | # except ValueError: 123 | # logger.error(f"Version {VERSION} is newer than PyPY version") 124 | 125 | v_layout = QVBoxLayout() 126 | h_layout = QHBoxLayout() 127 | v_layout.addLayout(h_layout) 128 | 129 | amazon_group = QGroupBox("Amazon Order History") 130 | amazon_group.setMinimumWidth(300) 131 | amazon_layout = QVBoxLayout() 132 | 133 | amazon_mode_layout = self.create_amazon_import_layout() 134 | 135 | amazon_layout.addLayout(amazon_mode_layout) 136 | amazon_group.setLayout(amazon_layout) 137 | h_layout.addWidget(amazon_group) 138 | 139 | mm_group = QGroupBox("Monarch Money Login && Options") 140 | mm_group.setMinimumWidth(350) 141 | mm_layout = QFormLayout() 142 | 143 | mm_layout.addRow( 144 | self.create_line_label("Email:", "mm_email"), 145 | self.create_line_edit("mm_email", tool_tip=NEVER_SAVE_MSG), 146 | ) 147 | mm_layout.addRow( 148 | self.create_line_label("Password:", "mm_password"), 149 | self.create_line_edit( 150 | "mm_password", tool_tip=NEVER_SAVE_MSG, password=True 151 | ), 152 | ) 153 | mm_layout.addRow( 154 | self.create_line_label("Sync first?", "mm_wait_for_sync"), 155 | self.create_checkbox("mm_wait_for_sync"), 156 | ) 157 | 158 | mm_layout.addRow( 159 | self.create_line_label("Description Filter", "mm_input_description_filter"), 160 | self.create_line_edit("mm_input_description_filter"), 161 | ) 162 | mm_layout.addRow( 163 | self.create_line_label( 164 | "Include user description", "mm_input_include_user_description" 165 | ), 166 | self.create_checkbox("mm_input_include_user_description"), 167 | ) 168 | mm_layout.addRow( 169 | self.create_line_label( 170 | "Include inferred description", 171 | "mm_input_include_inferred_description", 172 | ), 173 | self.create_checkbox("mm_input_include_inferred_description"), 174 | ) 175 | mm_layout.addRow( 176 | self.create_line_label( 177 | "Input Categories Filter", "mm_input_categories_filter" 178 | ), 179 | self.create_line_edit("mm_input_categories_filter"), 180 | ) 181 | mm_group.setLayout(mm_layout) 182 | h_layout.addWidget(mm_group) 183 | 184 | tagger_group = QGroupBox("Tagger Options") 185 | tagger_layout = QHBoxLayout() 186 | tagger_left = QFormLayout() 187 | 188 | tagger_left.addRow( 189 | self.create_line_label("Verbose Itemize", "verbose_itemize"), 190 | self.create_checkbox("verbose_itemize"), 191 | ) 192 | tagger_left.addRow( 193 | self.create_line_label("Do not Itemize", "no_itemize"), 194 | self.create_checkbox("no_itemize"), 195 | ) 196 | tagger_left.addRow( 197 | self.create_line_label("Retag Changed", "retag_changed"), 198 | self.create_checkbox("retag_changed"), 199 | ) 200 | 201 | tagger_right = QFormLayout() 202 | tagger_right.addRow( 203 | self.create_line_label("Do not tag categories", "no_tag_categories"), 204 | self.create_checkbox("no_tag_categories"), 205 | ) 206 | tagger_right.addRow( 207 | self.create_line_label( 208 | "Do not predict categories", "do_not_predict_categories" 209 | ), 210 | self.create_checkbox("do_not_predict_categories"), 211 | ) 212 | tagger_right.addRow( 213 | self.create_line_label( 214 | "Max days between payment/shipment", 215 | "max_days_between_payment_and_shipping", 216 | ), 217 | self.create_combobox( 218 | "max_days_between_payment_and_shipping", 219 | [3, 4, 5, 6, 7, 8, 9, 10], 220 | lambda x: int(x), 221 | ), 222 | ) 223 | 224 | tagger_layout.addLayout(tagger_left) 225 | tagger_layout.addLayout(tagger_right) 226 | tagger_group.setLayout(tagger_layout) 227 | v_layout.addWidget(tagger_group) 228 | 229 | self.start_button = QPushButton("Start Tagging") 230 | self.start_button.setAutoDefault(True) 231 | self.start_button.clicked.connect(self.on_start_button_clicked) 232 | v_layout.addWidget(self.start_button) 233 | 234 | main_widget = QWidget() 235 | main_widget.setLayout(v_layout) 236 | self.window.setCentralWidget(main_widget) 237 | self.window.show() 238 | # return app.exec_() 239 | return app.exec() 240 | 241 | def create_amazon_import_layout(self): 242 | amazon_import_layout = QFormLayout() 243 | 244 | order_history_link = QLabel() 245 | order_history_link.setText( 246 | """ 247 | Export Amazon Data
248 | Wait for the export to complete
249 | Download the file to your computer
250 | Select the file below:""" 251 | ) 252 | order_history_link.setOpenExternalLinks(True) 253 | amazon_import_layout.addRow(order_history_link) 254 | 255 | amazon_import_layout.addRow( 256 | self.create_line_label("Data Export:", "amazon_export"), 257 | self.create_file_edit("amazon_export", "Select Amazon Data Export"), 258 | ) 259 | return amazon_import_layout 260 | 261 | def on_quit(self): 262 | pass 263 | 264 | def on_tagger_dialog_closed(self): 265 | self.start_button.setEnabled(True) 266 | # Reset any csv file handles, as there might have been an error and 267 | # the user may try again (could already be consumed/closed). 268 | attr_name = "amazon_export" 269 | files = getattr(self.args, attr_name) 270 | if files: 271 | files = [open(file.name, "r", encoding="utf-8") for file in files] 272 | setattr(self.args, attr_name, files) 273 | 274 | def on_start_button_clicked(self): 275 | self.start_button.setEnabled(False) 276 | # If the fetch tab is selected for Amazon order history, clear out any 277 | # provided csv file paths, so the tagger actually fetches (versus using 278 | # the given paths). 279 | args = argparse.Namespace(**vars(self.args)) 280 | # Input validation for Amazon Export Zip: 281 | if not getattr(self.args, "amazon_export"): 282 | error_dialog = QErrorMessage(self.window) 283 | error_dialog.showMessage("Please provide valid Amazon Export Zip file") 284 | logger.error("User did not provide Amazon Export zip") 285 | self.on_tagger_dialog_closed() 286 | return 287 | 288 | # Input validation for login credentials: 289 | if not getattr(self.args, "mm_email") or not getattr(self.args, "mm_password"): 290 | error_dialog = QErrorMessage(self.window) 291 | error_dialog.showMessage("Please provide an email and password.") 292 | self.on_tagger_dialog_closed() 293 | return 294 | 295 | self.tagger = TaggerDialog( 296 | args=args, parent=self.window, log_filename=self.log_filename 297 | ) 298 | self.tagger.show() 299 | self.tagger.finished.connect(self.on_tagger_dialog_closed) 300 | 301 | def clear_layout(self, layout): 302 | if layout: 303 | while layout.count(): 304 | child = layout.takeAt(0) 305 | if child.widget() is not None: 306 | child.widget().deleteLater() 307 | elif child.layout() is not None: 308 | self.clear_layout(child.layout()) 309 | 310 | def create_checkbox(self, name, tool_tip=None, invert=False): 311 | x_box = QCheckBox() 312 | x_box.setTristate(False) 313 | x_box.setCheckState( 314 | Qt.CheckState.Checked 315 | if getattr(self.args, name) 316 | else Qt.CheckState.Unchecked 317 | ) 318 | if not tool_tip and name in self.arg_name_to_help: 319 | tool_tip = "When checked, " + self.arg_name_to_help[name] 320 | if tool_tip: 321 | x_box.setToolTip(tool_tip) 322 | 323 | def on_changed(state): 324 | setattr( 325 | self.args, 326 | name, 327 | state != Qt.CheckState.Checked.value 328 | if invert 329 | else state == Qt.CheckState.Checked.value, 330 | ) 331 | 332 | x_box.stateChanged.connect(on_changed) 333 | return x_box 334 | 335 | def advance_focus(self): 336 | self.window.focusNextChild() 337 | 338 | def create_line_label(self, label, tool_tip_name=None, tool_tip=None): 339 | line_edit = QLabel(label) 340 | if not tool_tip and tool_tip_name: 341 | tool_tip = self.arg_name_to_help[tool_tip_name] 342 | if tool_tip: 343 | line_edit.setToolTip(tool_tip) 344 | return line_edit 345 | 346 | def create_line_edit(self, name, tool_tip=None, password=False): 347 | line_edit = QLineEdit(getattr(self.args, name)) 348 | if not tool_tip: 349 | tool_tip = self.arg_name_to_help[name] 350 | if tool_tip: 351 | line_edit.setToolTip(tool_tip) 352 | if password: 353 | line_edit.setEchoMode(QLineEdit.EchoMode.PasswordEchoOnEdit) 354 | 355 | def on_changed(state): 356 | setattr(self.args, name, state) 357 | 358 | def on_return(): 359 | self.advance_focus() 360 | 361 | line_edit.textChanged.connect(on_changed) 362 | line_edit.returnPressed.connect(on_return) 363 | return line_edit 364 | 365 | def create_date_edit( 366 | self, name, popup_title, max_date=datetime.date.today(), tool_tip=None 367 | ): 368 | date_edit = QPushButton(str(getattr(self.args, name))) 369 | date_edit.setAutoDefault(True) 370 | if not tool_tip: 371 | tool_tip = self.arg_name_to_help[name] 372 | if tool_tip: 373 | date_edit.setToolTip(tool_tip) 374 | 375 | def on_date_edit_clicked(): 376 | dlg = QDialog(self.window) 377 | dlg.setWindowTitle(popup_title) 378 | layout = QVBoxLayout() 379 | cal = QCalendarWidget() 380 | cal.setMaximumDate(QDate(max_date)) 381 | cal.setSelectedDate(QDate(getattr(self.args, name))) 382 | cal.selectionChanged.connect(lambda: dlg.accept()) 383 | layout.addWidget(cal) 384 | okay = QPushButton("Select") 385 | okay.clicked.connect(lambda: dlg.accept()) 386 | layout.addWidget(okay) 387 | dlg.setLayout(layout) 388 | dlg.exec() 389 | 390 | setattr(self.args, name, cal.selectedDate().toPyDate()) 391 | date_edit.setText(str(getattr(self.args, name))) 392 | 393 | date_edit.clicked.connect(on_date_edit_clicked) 394 | return date_edit 395 | 396 | def create_file_edit( 397 | self, name, popup_title, filter="Zip files (*.zip)", tool_tip=None 398 | ): 399 | label = "Select a file" 400 | files = getattr(self.args, name) 401 | if files: 402 | if not isinstance(files, list): 403 | files = [files] 404 | 405 | label = " AND ".join([os.path.split(file.name)[1] for file in files]) 406 | file_button = QPushButton(label) 407 | 408 | if not tool_tip: 409 | tool_tip = self.arg_name_to_help[name] 410 | if tool_tip: 411 | file_button.setToolTip(tool_tip) 412 | 413 | def on_button(): 414 | dlg = QFileDialog() 415 | selection = dlg.getOpenFileNames(self.window, popup_title, filter=filter) 416 | if selection[0]: 417 | prev_files = getattr(self.args, name) 418 | if prev_files: 419 | for file in prev_files: 420 | file.close() 421 | new_files = [open(file, "r", encoding="utf-8") for file in selection[0]] 422 | setattr(self.args, name, new_files) 423 | label = " AND ".join( 424 | [os.path.split(file.name)[1] for file in new_files] 425 | ) 426 | file_button.setText(label) 427 | 428 | file_button.clicked.connect(on_button) 429 | return file_button 430 | 431 | def create_combobox(self, name, items, transform, tool_tip=None): 432 | combo = QComboBox() 433 | # combo.setFocusPolicy(Qt.StrongFocus) 434 | if not tool_tip: 435 | tool_tip = self.arg_name_to_help[name] 436 | if tool_tip: 437 | combo.setToolTip(tool_tip) 438 | combo.addItems([str(i) for i in items]) 439 | 440 | default_value = getattr(self.args, name) 441 | combo.setCurrentIndex(items.index(default_value)) 442 | 443 | def on_change(option): 444 | setattr(self.args, name, transform(option)) 445 | 446 | combo.currentTextChanged.connect(on_change) 447 | return combo 448 | 449 | 450 | class TaggerDialog(QDialog): 451 | def __init__(self, args, log_filename, **kwargs): 452 | super(TaggerDialog, self).__init__(**kwargs) 453 | 454 | self.reviewing = False 455 | self.args = args 456 | self.log_filename = log_filename 457 | 458 | self.worker = TaggerWorker() 459 | self.thread = QThread() 460 | self.worker.moveToThread(self.thread) 461 | 462 | self.worker.on_error.connect(self.on_error) 463 | self.worker.on_review_ready.connect(self.on_review_ready) 464 | self.worker.on_stopped.connect(self.on_stopped) 465 | self.worker.on_progress.connect(self.on_progress) 466 | self.worker.on_updates_sent.connect(self.on_updates_sent) 467 | self.worker.on_mfa.connect(self.on_mfa) 468 | 469 | self.thread.started.connect(partial(self.worker.create_updates, args, self)) 470 | self.thread.start() 471 | 472 | self.init_ui() 473 | 474 | def init_ui(self): 475 | self.setWindowTitle("Tagger is running...") 476 | self.setModal(True) 477 | self.v_layout = QVBoxLayout() 478 | self.setLayout(self.v_layout) 479 | 480 | self.label = QLabel() 481 | self.v_layout.addWidget(self.label) 482 | 483 | self.progress = 0 484 | self.progress_bar = QProgressBar() 485 | self.progress_bar.setRange(0, 0) 486 | self.v_layout.addWidget(self.progress_bar) 487 | 488 | self.button_bar = QHBoxLayout() 489 | self.v_layout.addLayout(self.button_bar) 490 | 491 | self.cancel_button = QPushButton("Cancel") 492 | self.button_bar.addWidget(self.cancel_button) 493 | self.cancel_button.clicked.connect(self.on_cancel) 494 | 495 | def on_error(self, msg): 496 | logger.error(msg) 497 | self.label.setText(f"Error: {msg}") 498 | self.label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) 499 | self.label.setStyleSheet("QLabel { color: red; font-weight: bold; }") 500 | 501 | self.report_issue_button = QPushButton("Report Issue on Github") 502 | self.button_bar.addWidget(self.report_issue_button) 503 | self.report_issue_button.clicked.connect(self.on_report_issue) 504 | 505 | self.cancel_button.setText("Close") 506 | self.cancel_button.clicked.connect(self.on_stopped) 507 | 508 | def on_report_issue(self): 509 | logger.info("Report Issue Clicked") 510 | url_params = { 511 | "title": f"In-app Report for v{VERSION} on {sys.platform}", 512 | "body": ( 513 | "Behavior observed: \n\n\n" 514 | "Expected behavior: \n\n\n" 515 | f"Please attach your log file: {self.log_filename}" 516 | ), 517 | } 518 | webbrowser.open( 519 | "https://github.com/jprouty/monarchmoney-amazon-tagger/issues/" 520 | f"new?{urlencode(url_params)}" 521 | ) 522 | self.on_stopped() 523 | 524 | def open_amazon_order_id(self, order_id): 525 | if order_id: 526 | QDesktopServices.openUrl(QUrl(amazon.get_invoice_url(order_id))) 527 | 528 | def on_activated(self, index): 529 | # Only handle clicks on the order_id cell. 530 | if index.column() != 5: 531 | return 532 | order_id = self.updates_table_model.data(index, Qt.ItemDataRole.DisplayRole) 533 | self.open_amazon_order_id(order_id) 534 | 535 | def on_double_click(self, index): 536 | if index.column() == 5: 537 | # Ignore double clicks on the order_id cell. 538 | return 539 | order_id_cell = self.updates_table_model.createIndex(index.row(), 5) 540 | order_id = self.updates_table_model.data( 541 | order_id_cell, Qt.ItemDataRole.DisplayRole 542 | ) 543 | self.open_amazon_order_id(order_id) 544 | 545 | def on_review_ready(self, results): 546 | self.reviewing = True 547 | self.progress_bar.hide() 548 | 549 | self.label.setText("Select below which updates to send to Monarch Money.") 550 | 551 | self.updates_table_model = MMUpdatesTableModel(results.updates) 552 | self.updates_table = QTableView() 553 | self.updates_table.doubleClicked.connect(self.on_double_click) 554 | self.updates_table.clicked.connect(self.on_activated) 555 | 556 | def resize(): 557 | self.updates_table.resizeColumnsToContents() 558 | self.updates_table.resizeRowsToContents() 559 | min_width = sum(self.updates_table.columnWidth(i) for i in range(6)) 560 | self.updates_table.setMinimumSize(min_width + 20, 600) 561 | 562 | self.updates_table.setSelectionMode( 563 | QAbstractItemView.SelectionMode.SingleSelection 564 | ) 565 | self.updates_table.setSelectionBehavior( 566 | QAbstractItemView.SelectionBehavior.SelectRows 567 | ) 568 | self.updates_table.setModel(self.updates_table_model) 569 | self.updates_table.setSortingEnabled(True) 570 | resize() 571 | self.updates_table_model.layoutChanged.connect(resize) 572 | 573 | self.v_layout.insertWidget(2, self.updates_table) 574 | 575 | unmatched_button = QPushButton("View Unmatched Amazon charges") 576 | self.button_bar.addWidget(unmatched_button) 577 | unmatched_button.clicked.connect( 578 | partial(self.on_open_unmatched, results.unmatched_charges) 579 | ) 580 | 581 | amazon_stats_button = QPushButton("Amazon Stats") 582 | self.button_bar.addWidget(amazon_stats_button) 583 | amazon_stats_button.clicked.connect( 584 | partial(self.on_open_amazon_stats, results.items, results.charges, []) 585 | ) 586 | 587 | tagger_stats_button = QPushButton("Tagger Stats") 588 | self.button_bar.addWidget(tagger_stats_button) 589 | tagger_stats_button.clicked.connect( 590 | partial(self.on_open_tagger_stats, results.stats) 591 | ) 592 | 593 | self.confirm_button = QPushButton("Send to Monarch Money") 594 | self.button_bar.addWidget(self.confirm_button) 595 | self.confirm_button.clicked.connect(self.on_send) 596 | 597 | self.setGeometry(50, 50, self.width(), self.height()) 598 | 599 | def on_updates_sent(self, num_sent): 600 | self.label.setText(f"All done! {num_sent} newly tagged transactions") 601 | self.cancel_button.setText("Close") 602 | 603 | def on_open_unmatched(self, unmatched): 604 | self.unmatched_dialog = AmazonUnmatchedTableDialog(unmatched) 605 | self.unmatched_dialog.show() 606 | 607 | def on_open_amazon_stats(self, items, charges, refunds): 608 | self.amazon_stats_dialog = AmazonStatsDialog(items, charges, refunds) 609 | self.amazon_stats_dialog.show() 610 | 611 | def on_open_tagger_stats(self, stats): 612 | self.tagger_stats_dialog = TaggerStatsDialog(stats) 613 | self.tagger_stats_dialog.show() 614 | 615 | def on_send(self): 616 | self.progress_bar.show() 617 | updates = self.updates_table_model.get_selected_updates() 618 | 619 | self.confirm_button.hide() 620 | self.updates_table.hide() 621 | self.confirm_button.deleteLater() 622 | self.updates_table.deleteLater() 623 | self.adjustSize() 624 | 625 | QMetaObject.invokeMethod( 626 | self.worker, 627 | "send_updates", 628 | Qt.ConnectionType.QueuedConnection, 629 | Q_ARG(list, updates), 630 | Q_ARG(object, self.args), 631 | ) 632 | 633 | def on_stopped(self): 634 | self.close() 635 | 636 | def on_progress(self, msg, max, value): 637 | self.label.setText(msg) 638 | self.progress_bar.setRange(0, max) 639 | self.progress_bar.setValue(value) 640 | 641 | def on_cancel(self): 642 | if not self.reviewing: 643 | QMetaObject.invokeMethod( 644 | self.worker, "stop", Qt.ConnectionType.QueuedConnection 645 | ) 646 | else: 647 | self.on_stopped() 648 | 649 | def on_mfa(self): 650 | mfa_code, ok = QInputDialog().getText( 651 | self, "Please enter your MFA/OTP Code.", "Code:" 652 | ) 653 | self.worker.mfa_code = mfa_code 654 | QMetaObject.invokeMethod( 655 | self.worker, 656 | "mfa_code", 657 | Qt.ConnectionType.QueuedConnection, 658 | Q_ARG(str, mfa_code), 659 | ) 660 | self.worker.on_mfa_done.emit() 661 | 662 | 663 | class TaggerWorker(QObject): 664 | """This class is required to prevent locking up the main Qt thread.""" 665 | 666 | on_error = pyqtSignal(str) 667 | on_review_ready = pyqtSignal(tagger.UpdatesResult) 668 | on_updates_sent = pyqtSignal(int) 669 | on_stopped = pyqtSignal() 670 | on_mfa = pyqtSignal() 671 | on_mfa_done = pyqtSignal() 672 | on_progress = pyqtSignal(str, int, int) 673 | stopping = False 674 | 675 | @pyqtSlot() 676 | def stop(self): 677 | self.stopping = True 678 | 679 | @pyqtSlot(str) 680 | def mfa_code(self, code): 681 | logger.info(code) 682 | self.mfa_code = code 683 | 684 | @pyqtSlot(object) 685 | def create_updates(self, args, parent): 686 | try: 687 | self.do_create_updates(args, parent) 688 | except Exception as e: 689 | msg = f"Internal error while creating updates: {e}" 690 | self.on_error.emit(msg) 691 | logger.exception(msg) 692 | 693 | @pyqtSlot(list, object) 694 | def send_updates(self, updates, args): 695 | try: 696 | self.do_send_updates(updates, args) 697 | except Exception as e: 698 | msg = f"Internal error while sending updates: {e}" 699 | self.on_error.emit(msg) 700 | logger.exception(msg) 701 | 702 | def do_create_updates(self, args, parent): 703 | # Factory that handles indeterminate, determinate, and counter style. 704 | def progress_factory(msg, max=0): 705 | return QtProgress(msg, max, self.on_progress.emit) 706 | 707 | self.mmc = MonarchMoneyClient(args) 708 | 709 | results = tagger.create_updates( 710 | args, 711 | self.mmc, 712 | on_critical=self.on_error.emit, 713 | indeterminate_progress_factory=progress_factory, 714 | determinate_progress_factory=progress_factory, 715 | counter_progress_factory=progress_factory, 716 | ) 717 | 718 | if results.success and not self.stopping: 719 | self.on_review_ready.emit(results) 720 | 721 | def do_send_updates(self, updates, args): 722 | num_updates = self.mmc.send_updates( 723 | updates, 724 | progress=QtProgress( 725 | "Sending updates to Monarch Money", len(updates), self.on_progress.emit 726 | ), 727 | ignore_category=args.no_tag_categories, 728 | ) 729 | self.on_updates_sent.emit(num_updates) 730 | 731 | 732 | def main(): 733 | root_logger = logging.getLogger() 734 | root_logger.setLevel(logging.INFO) 735 | root_logger.addHandler(logging.StreamHandler()) 736 | # Disable noisy log spam from filelock from within tldextract. 737 | logging.getLogger("filelock").setLevel(logging.WARN) 738 | # For helping remote debugging, also log to file. 739 | # Developers should be vigilant to NOT log any PII, ever (including being 740 | # mindful of what exceptions might be thrown). 741 | log_directory = os.path.join(TAGGER_BASE_PATH, "Tagger Logs") 742 | os.makedirs(log_directory, exist_ok=True) 743 | log_filename = os.path.join( 744 | log_directory, f'{time.strftime("%Y-%m-%d_%H-%M-%S")}.log' 745 | ) 746 | file_handler = logging.FileHandler(log_filename) 747 | file_handler.setFormatter( 748 | logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") 749 | ) 750 | file_handler.setLevel(logging.DEBUG) 751 | root_logger.addHandler(file_handler) 752 | 753 | parser = argparse.ArgumentParser( 754 | description="Tag Monarch Money transactions based on itemized Amazon history." 755 | ) 756 | define_gui_args(parser) 757 | args = parser.parse_args() 758 | 759 | def sigint_handler(signal, frame): 760 | logger.warning("Keyboard interrupt caught") 761 | QApplication.quit() 762 | sys.exit(0) 763 | 764 | signal(SIGINT, sigint_handler) 765 | sys.exit(TaggerGui(args, get_name_to_help_dict(parser), log_filename).create_gui()) 766 | 767 | 768 | if __name__ == "__main__": 769 | main() 770 | --------------------------------------------------------------------------------