├── 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 | 
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 |
--------------------------------------------------------------------------------