receipt-report-generator 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. receipt_report_generator-1.0.0/LICENSE +21 -0
  2. receipt_report_generator-1.0.0/PKG-INFO +260 -0
  3. receipt_report_generator-1.0.0/README.md +213 -0
  4. receipt_report_generator-1.0.0/pyproject.toml +42 -0
  5. receipt_report_generator-1.0.0/receipt_report/__init__.py +3 -0
  6. receipt_report_generator-1.0.0/receipt_report/__main__.py +8 -0
  7. receipt_report_generator-1.0.0/receipt_report/config.py +107 -0
  8. receipt_report_generator-1.0.0/receipt_report/mail_client.py +149 -0
  9. receipt_report_generator-1.0.0/receipt_report/mailer.py +60 -0
  10. receipt_report_generator-1.0.0/receipt_report/main.py +135 -0
  11. receipt_report_generator-1.0.0/receipt_report/notify.py +41 -0
  12. receipt_report_generator-1.0.0/receipt_report/pdf_builder.py +171 -0
  13. receipt_report_generator-1.0.0/receipt_report/providers.json +93 -0
  14. receipt_report_generator-1.0.0/receipt_report/providers.py +48 -0
  15. receipt_report_generator-1.0.0/receipt_report/receipt_parser.py +157 -0
  16. receipt_report_generator-1.0.0/receipt_report_generator.egg-info/PKG-INFO +260 -0
  17. receipt_report_generator-1.0.0/receipt_report_generator.egg-info/SOURCES.txt +20 -0
  18. receipt_report_generator-1.0.0/receipt_report_generator.egg-info/dependency_links.txt +1 -0
  19. receipt_report_generator-1.0.0/receipt_report_generator.egg-info/entry_points.txt +2 -0
  20. receipt_report_generator-1.0.0/receipt_report_generator.egg-info/requires.txt +5 -0
  21. receipt_report_generator-1.0.0/receipt_report_generator.egg-info/top_level.txt +1 -0
  22. receipt_report_generator-1.0.0/setup.cfg +4 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Barton
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.
@@ -0,0 +1,260 @@
1
+ Metadata-Version: 2.4
2
+ Name: receipt-report-generator
3
+ Version: 1.0.0
4
+ Summary: Turn emailed receipts into a single expense-report PDF (cover summary + indexed appendix).
5
+ Author: David Barton
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 David Barton
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/dbarton1974/receipt-report-generator
29
+ Project-URL: Repository, https://github.com/dbarton1974/receipt-report-generator
30
+ Project-URL: Issues, https://github.com/dbarton1974/receipt-report-generator/issues
31
+ Keywords: imap,receipts,pdf,expense-report,email,invoice,bookkeeping
32
+ Classifier: Programming Language :: Python :: 3
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Operating System :: OS Independent
35
+ Classifier: Environment :: Console
36
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
37
+ Classifier: Topic :: Communications :: Email
38
+ Requires-Python: >=3.10
39
+ Description-Content-Type: text/markdown
40
+ License-File: LICENSE
41
+ Requires-Dist: imap-tools>=1.7.0
42
+ Requires-Dist: pdfplumber>=0.11.0
43
+ Requires-Dist: reportlab>=4.1.0
44
+ Requires-Dist: pypdf>=4.2.0
45
+ Requires-Dist: python-dotenv>=1.0.0
46
+ Dynamic: license-file
47
+
48
+ # receipt-report-generator
49
+
50
+ Turn receipts that land in your inbox into a single, tidy **expense-report PDF** — a cover
51
+ sheet that summarises every receipt with a running total, followed by each original receipt
52
+ as an indexed appendix. Optionally emails the finished PDF onwards and files the processed
53
+ messages away so they are never counted twice.
54
+
55
+ Originally built to collect public-transport receipts for monthly bookkeeping, it is
56
+ **metadata-driven**: point it at any sender, any IMAP mailbox, and any currency.
57
+
58
+ ## What it does
59
+
60
+ 1. Connects to your mailbox over **IMAP**.
61
+ 2. Finds every email from a configured **sender** that has a **PDF attachment**.
62
+ 3. Reads the **amount** and **date** out of each receipt PDF.
63
+ 4. Builds one PDF:
64
+ - **Cover sheet** — a title, today's date, and a table of `# | Date | Description |
65
+ (Purpose) | Amount`, ending in a **Total**.
66
+ - **Appendix** — every original receipt, stamped `Appendix N` to match its table row,
67
+ with PDF bookmarks.
68
+ 5. **Moves** the processed emails to a folder so the next run only sees new receipts.
69
+ 6. *(Optional)* **Emails** the PDF to an archive address and saves a copy in your Sent folder.
70
+ 7. *(Optional, in CI)* **Emails you** if a scheduled run fails.
71
+
72
+ Because "what's already processed" is represented by *moving the emails out of the inbox*,
73
+ there is no database or local state to maintain — the mailbox is the single source of truth,
74
+ which is what makes running it in the cloud trivial.
75
+
76
+ ## Requirements
77
+
78
+ - Python 3.10+
79
+ - An email account reachable over IMAP + SMTP (see [supported servers](#supported-mail-servers))
80
+
81
+ ## Installation
82
+
83
+ ```bash
84
+ git clone https://github.com/<you>/receipt-report-generator.git
85
+ cd receipt-report-generator
86
+ python -m venv .venv && source .venv/bin/activate
87
+ pip install -r requirements.txt
88
+ cp .env.example .env # then edit .env
89
+ ```
90
+
91
+ Or install it as a package to get the `receipt-report` command on your PATH:
92
+
93
+ ```bash
94
+ pip install git+https://github.com/<you>/receipt-report-generator.git
95
+ # then, in a directory containing your .env:
96
+ receipt-report --dry-run
97
+ ```
98
+
99
+ ## Configuration
100
+
101
+ All configuration is via environment variables (read from `.env` locally, or injected by
102
+ your host in the cloud). The most important ones:
103
+
104
+ | Variable | Description |
105
+ | --- | --- |
106
+ | `MAIL_PROVIDER` | A preset from [`providers.json`](receipt_report/providers.json) (`gmail`, `outlook`, `icloud`, …). Fills in IMAP/SMTP host, port and security. |
107
+ | `IMAP_USER` / `IMAP_PASSWORD` | Mailbox login. Use an **app password** where the provider supports one. |
108
+ | `RECEIPT_SENDER` | Sender address of the receipts. A substring works (e.g. `sl.se`). |
109
+ | `PROCESSED_FOLDER` | Where processed emails are moved (default `Processed/Receipts`, created if missing). |
110
+ | `RECIPIENT_NAME` / `REPORT_TITLE` | Name and cover title (`{name}` is substituted). |
111
+ | `CURRENCY` | Label after each amount (default `kr`). |
112
+ | `PURPOSE` | Optional value for a "Purpose" column; empty hides the column. |
113
+ | `DESCRIPTION_MATCH` | Optional regex to extract a description from the receipt text. |
114
+ | `ARCHIVE_RECIPIENT` | If set, the report is emailed here on a real run. |
115
+ | `NOTIFY_RECIPIENT` | Where CI failure notices go (defaults to `IMAP_USER`). |
116
+
117
+ To use a server not in the presets, leave `MAIL_PROVIDER` empty and set `IMAP_HOST`,
118
+ `IMAP_PORT`, `SMTP_HOST`, `SMTP_PORT` and `SMTP_SECURITY` (`ssl` or `starttls`) directly.
119
+ See [`.env.example`](.env.example) for the full list.
120
+
121
+ ## Supported mail servers
122
+
123
+ Presets live in [`providers.json`](receipt_report/providers.json) and are easy to extend.
124
+
125
+ | `MAIL_PROVIDER` | Service | IMAP | SMTP |
126
+ | --- | --- | --- | --- |
127
+ | `gmail` | Gmail / Google Workspace | imap.gmail.com:993 | smtp.gmail.com:465 (SSL) |
128
+ | `outlook` | Outlook.com / Hotmail | outlook.office365.com:993 | smtp-mail.outlook.com:587 (STARTTLS) |
129
+ | `office365` | Microsoft 365 (work/school) | outlook.office365.com:993 | smtp.office365.com:587 (STARTTLS) |
130
+ | `icloud` | iCloud Mail | imap.mail.me.com:993 | smtp.mail.me.com:587 (STARTTLS) |
131
+ | `yahoo` | Yahoo Mail | imap.mail.yahoo.com:993 | smtp.mail.yahoo.com:465 (SSL) |
132
+ | `fastmail` | Fastmail | imap.fastmail.com:993 | smtp.fastmail.com:465 (SSL) |
133
+ | `gmx` | GMX | imap.gmx.com:993 | mail.gmx.com:465 (SSL) |
134
+ | `zoho` | Zoho Mail | imap.zoho.com:993 | smtp.zoho.com:465 (SSL) |
135
+ | `opensrs` | OpenSRS / Tucows Hosted Email | imap.hostedemail.com:993 | smtp.hostedemail.com:465 (SSL) |
136
+
137
+ Any IMAP/SMTP server works via explicit host settings — the presets are just conveniences.
138
+
139
+ ## Setting up your mail account
140
+
141
+ Most providers need two things:
142
+
143
+ 1. **IMAP access enabled.** On by default for many; for some (Yahoo, GMX, Zoho) you toggle
144
+ it in settings.
145
+ 2. **An app password** if the account uses 2-factor authentication. The generator logs in
146
+ with a plain username + password, so create a provider-specific *app password* and use it
147
+ as `IMAP_PASSWORD`:
148
+ - **Gmail:** Google Account → Security → App passwords (requires 2-Step Verification).
149
+ - **iCloud:** appleid.apple.com → Sign-In and Security → App-Specific Passwords.
150
+ - **Yahoo / Fastmail / Zoho:** Account security → generate an app password.
151
+ - **OpenSRS/hosted email:** usually the normal mailbox password works.
152
+
153
+ > **Microsoft note:** Outlook.com and many Microsoft 365 tenants have disabled basic
154
+ > IMAP/SMTP auth. If password login is refused, an admin may need to allow it, or the account
155
+ > may require OAuth (not handled by this tool).
156
+
157
+ The same account is used both to **read** receipts and to **send** the report, so make sure
158
+ SMTP submission is allowed too. The per-provider notes in `providers.json` summarise this.
159
+
160
+ ## Usage
161
+
162
+ Dry run (build the PDF; touch nothing in the mailbox):
163
+
164
+ ```bash
165
+ python -m receipt_report.main --dry-run
166
+ ```
167
+
168
+ Real run (build the PDF, move processed emails, and email the report if `ARCHIVE_RECIPIENT`
169
+ is set):
170
+
171
+ ```bash
172
+ python -m receipt_report.main
173
+ ```
174
+
175
+ Real run without emailing:
176
+
177
+ ```bash
178
+ python -m receipt_report.main --no-email
179
+ ```
180
+
181
+ Output lands in `output/expense-report_<YYYY-MM-DD>.pdf`.
182
+
183
+ ## Run it in the cloud (GitHub Actions)
184
+
185
+ [`.github/workflows/monthly.yml`](.github/workflows/monthly.yml) runs the generator on the
186
+ **last day of each month** — no machine of your own needs to be on.
187
+
188
+ Set these in your repository (**Settings → Secrets and variables → Actions**):
189
+
190
+ **Secrets** (sensitive):
191
+
192
+ | Secret | Value |
193
+ | --- | --- |
194
+ | `IMAP_USER` | Your mailbox address |
195
+ | `IMAP_PASSWORD` | App password / mailbox password |
196
+ | `ARCHIVE_RECIPIENT` | *(optional)* where to email the report |
197
+
198
+ **Variables** (non-sensitive):
199
+
200
+ | Variable | Example |
201
+ | --- | --- |
202
+ | `MAIL_PROVIDER` | `gmail` |
203
+ | `RECEIPT_SENDER` | `receipts@vendor.com` |
204
+ | `RECIPIENT_NAME` | `Jane Doe` |
205
+ | `CURRENCY` | `kr` |
206
+ | `PURPOSE` | *(optional)* |
207
+
208
+ Then:
209
+
210
+ - **Test anytime:** Actions tab → *Monthly expense report* → **Run workflow**. A manual run
211
+ skips the last-day guard and runs immediately.
212
+ - **Test the failure alert:** run the workflow with the **test_fail** input checked — it forces
213
+ a failure and emails `NOTIFY_RECIPIENT` (or `IMAP_USER`).
214
+ - The generated PDF is also kept as a **run artifact** for 90 days.
215
+ - Scheduled workflows only run on the repository's default branch.
216
+
217
+ ## Customising the parsing
218
+
219
+ Receipt layouts vary. The parser ([`receipt_parser.py`](receipt_report/receipt_parser.py)) is
220
+ built to be tuned:
221
+
222
+ - **Amounts** are matched in both `1,234.50` and `1 234,50` styles; lines containing keywords
223
+ like *total*, *amount due*, *summa* are preferred, otherwise the largest amount wins.
224
+ - **Dates** are read as ISO, `dd.mm.yyyy`, or `12 June 2026` (English + Swedish month names),
225
+ falling back to the email's date.
226
+ - **Descriptions** default to the first meaningful text line; set `DESCRIPTION_MATCH` to a
227
+ regex to grab something specific (group 1 if present).
228
+
229
+ Receipts where an amount or date can't be read are still included and listed as warnings so
230
+ you can check them.
231
+
232
+ ## Security
233
+
234
+ - Credentials live only in `.env` (git-ignored) or your host's secret store. Nothing sensitive
235
+ is committed.
236
+ - The processed-email move happens **after** the PDF is written successfully, so an interrupted
237
+ run never loses receipts.
238
+
239
+ ## Publishing to PyPI
240
+
241
+ [`.github/workflows/publish.yml`](.github/workflows/publish.yml) publishes the package to
242
+ PyPI whenever you create a **GitHub Release**, authenticating with a PyPI API token. One-time
243
+ setup:
244
+
245
+ 1. **Create an API token.** On [pypi.org](https://pypi.org) → *Account settings → API tokens*
246
+ → *Add API token* (scope "Entire account" for the first upload; you can scope it to the
247
+ project afterwards).
248
+ 2. **Store it as a secret.** Repo → *Settings → Secrets and variables → Actions* → new secret
249
+ named `PYPI_API_TOKEN` with the token value. Or via CLI:
250
+ `gh secret set PYPI_API_TOKEN --repo <owner>/<repo>`
251
+ 3. **Release.** Bump `version` in [`pyproject.toml`](pyproject.toml), then create a GitHub
252
+ Release (e.g. tag `v1.0.0`). The workflow builds and uploads automatically.
253
+
254
+ Prefer no stored token? PyPI also supports OIDC *trusted publishing* — remove the `password:`
255
+ line from the workflow, add `permissions: id-token: write` to the publish job, and register a
256
+ trusted publisher on PyPI (owner, repo, workflow `publish.yml`, no environment).
257
+
258
+ ## License
259
+
260
+ [MIT](LICENSE)
@@ -0,0 +1,213 @@
1
+ # receipt-report-generator
2
+
3
+ Turn receipts that land in your inbox into a single, tidy **expense-report PDF** — a cover
4
+ sheet that summarises every receipt with a running total, followed by each original receipt
5
+ as an indexed appendix. Optionally emails the finished PDF onwards and files the processed
6
+ messages away so they are never counted twice.
7
+
8
+ Originally built to collect public-transport receipts for monthly bookkeeping, it is
9
+ **metadata-driven**: point it at any sender, any IMAP mailbox, and any currency.
10
+
11
+ ## What it does
12
+
13
+ 1. Connects to your mailbox over **IMAP**.
14
+ 2. Finds every email from a configured **sender** that has a **PDF attachment**.
15
+ 3. Reads the **amount** and **date** out of each receipt PDF.
16
+ 4. Builds one PDF:
17
+ - **Cover sheet** — a title, today's date, and a table of `# | Date | Description |
18
+ (Purpose) | Amount`, ending in a **Total**.
19
+ - **Appendix** — every original receipt, stamped `Appendix N` to match its table row,
20
+ with PDF bookmarks.
21
+ 5. **Moves** the processed emails to a folder so the next run only sees new receipts.
22
+ 6. *(Optional)* **Emails** the PDF to an archive address and saves a copy in your Sent folder.
23
+ 7. *(Optional, in CI)* **Emails you** if a scheduled run fails.
24
+
25
+ Because "what's already processed" is represented by *moving the emails out of the inbox*,
26
+ there is no database or local state to maintain — the mailbox is the single source of truth,
27
+ which is what makes running it in the cloud trivial.
28
+
29
+ ## Requirements
30
+
31
+ - Python 3.10+
32
+ - An email account reachable over IMAP + SMTP (see [supported servers](#supported-mail-servers))
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ git clone https://github.com/<you>/receipt-report-generator.git
38
+ cd receipt-report-generator
39
+ python -m venv .venv && source .venv/bin/activate
40
+ pip install -r requirements.txt
41
+ cp .env.example .env # then edit .env
42
+ ```
43
+
44
+ Or install it as a package to get the `receipt-report` command on your PATH:
45
+
46
+ ```bash
47
+ pip install git+https://github.com/<you>/receipt-report-generator.git
48
+ # then, in a directory containing your .env:
49
+ receipt-report --dry-run
50
+ ```
51
+
52
+ ## Configuration
53
+
54
+ All configuration is via environment variables (read from `.env` locally, or injected by
55
+ your host in the cloud). The most important ones:
56
+
57
+ | Variable | Description |
58
+ | --- | --- |
59
+ | `MAIL_PROVIDER` | A preset from [`providers.json`](receipt_report/providers.json) (`gmail`, `outlook`, `icloud`, …). Fills in IMAP/SMTP host, port and security. |
60
+ | `IMAP_USER` / `IMAP_PASSWORD` | Mailbox login. Use an **app password** where the provider supports one. |
61
+ | `RECEIPT_SENDER` | Sender address of the receipts. A substring works (e.g. `sl.se`). |
62
+ | `PROCESSED_FOLDER` | Where processed emails are moved (default `Processed/Receipts`, created if missing). |
63
+ | `RECIPIENT_NAME` / `REPORT_TITLE` | Name and cover title (`{name}` is substituted). |
64
+ | `CURRENCY` | Label after each amount (default `kr`). |
65
+ | `PURPOSE` | Optional value for a "Purpose" column; empty hides the column. |
66
+ | `DESCRIPTION_MATCH` | Optional regex to extract a description from the receipt text. |
67
+ | `ARCHIVE_RECIPIENT` | If set, the report is emailed here on a real run. |
68
+ | `NOTIFY_RECIPIENT` | Where CI failure notices go (defaults to `IMAP_USER`). |
69
+
70
+ To use a server not in the presets, leave `MAIL_PROVIDER` empty and set `IMAP_HOST`,
71
+ `IMAP_PORT`, `SMTP_HOST`, `SMTP_PORT` and `SMTP_SECURITY` (`ssl` or `starttls`) directly.
72
+ See [`.env.example`](.env.example) for the full list.
73
+
74
+ ## Supported mail servers
75
+
76
+ Presets live in [`providers.json`](receipt_report/providers.json) and are easy to extend.
77
+
78
+ | `MAIL_PROVIDER` | Service | IMAP | SMTP |
79
+ | --- | --- | --- | --- |
80
+ | `gmail` | Gmail / Google Workspace | imap.gmail.com:993 | smtp.gmail.com:465 (SSL) |
81
+ | `outlook` | Outlook.com / Hotmail | outlook.office365.com:993 | smtp-mail.outlook.com:587 (STARTTLS) |
82
+ | `office365` | Microsoft 365 (work/school) | outlook.office365.com:993 | smtp.office365.com:587 (STARTTLS) |
83
+ | `icloud` | iCloud Mail | imap.mail.me.com:993 | smtp.mail.me.com:587 (STARTTLS) |
84
+ | `yahoo` | Yahoo Mail | imap.mail.yahoo.com:993 | smtp.mail.yahoo.com:465 (SSL) |
85
+ | `fastmail` | Fastmail | imap.fastmail.com:993 | smtp.fastmail.com:465 (SSL) |
86
+ | `gmx` | GMX | imap.gmx.com:993 | mail.gmx.com:465 (SSL) |
87
+ | `zoho` | Zoho Mail | imap.zoho.com:993 | smtp.zoho.com:465 (SSL) |
88
+ | `opensrs` | OpenSRS / Tucows Hosted Email | imap.hostedemail.com:993 | smtp.hostedemail.com:465 (SSL) |
89
+
90
+ Any IMAP/SMTP server works via explicit host settings — the presets are just conveniences.
91
+
92
+ ## Setting up your mail account
93
+
94
+ Most providers need two things:
95
+
96
+ 1. **IMAP access enabled.** On by default for many; for some (Yahoo, GMX, Zoho) you toggle
97
+ it in settings.
98
+ 2. **An app password** if the account uses 2-factor authentication. The generator logs in
99
+ with a plain username + password, so create a provider-specific *app password* and use it
100
+ as `IMAP_PASSWORD`:
101
+ - **Gmail:** Google Account → Security → App passwords (requires 2-Step Verification).
102
+ - **iCloud:** appleid.apple.com → Sign-In and Security → App-Specific Passwords.
103
+ - **Yahoo / Fastmail / Zoho:** Account security → generate an app password.
104
+ - **OpenSRS/hosted email:** usually the normal mailbox password works.
105
+
106
+ > **Microsoft note:** Outlook.com and many Microsoft 365 tenants have disabled basic
107
+ > IMAP/SMTP auth. If password login is refused, an admin may need to allow it, or the account
108
+ > may require OAuth (not handled by this tool).
109
+
110
+ The same account is used both to **read** receipts and to **send** the report, so make sure
111
+ SMTP submission is allowed too. The per-provider notes in `providers.json` summarise this.
112
+
113
+ ## Usage
114
+
115
+ Dry run (build the PDF; touch nothing in the mailbox):
116
+
117
+ ```bash
118
+ python -m receipt_report.main --dry-run
119
+ ```
120
+
121
+ Real run (build the PDF, move processed emails, and email the report if `ARCHIVE_RECIPIENT`
122
+ is set):
123
+
124
+ ```bash
125
+ python -m receipt_report.main
126
+ ```
127
+
128
+ Real run without emailing:
129
+
130
+ ```bash
131
+ python -m receipt_report.main --no-email
132
+ ```
133
+
134
+ Output lands in `output/expense-report_<YYYY-MM-DD>.pdf`.
135
+
136
+ ## Run it in the cloud (GitHub Actions)
137
+
138
+ [`.github/workflows/monthly.yml`](.github/workflows/monthly.yml) runs the generator on the
139
+ **last day of each month** — no machine of your own needs to be on.
140
+
141
+ Set these in your repository (**Settings → Secrets and variables → Actions**):
142
+
143
+ **Secrets** (sensitive):
144
+
145
+ | Secret | Value |
146
+ | --- | --- |
147
+ | `IMAP_USER` | Your mailbox address |
148
+ | `IMAP_PASSWORD` | App password / mailbox password |
149
+ | `ARCHIVE_RECIPIENT` | *(optional)* where to email the report |
150
+
151
+ **Variables** (non-sensitive):
152
+
153
+ | Variable | Example |
154
+ | --- | --- |
155
+ | `MAIL_PROVIDER` | `gmail` |
156
+ | `RECEIPT_SENDER` | `receipts@vendor.com` |
157
+ | `RECIPIENT_NAME` | `Jane Doe` |
158
+ | `CURRENCY` | `kr` |
159
+ | `PURPOSE` | *(optional)* |
160
+
161
+ Then:
162
+
163
+ - **Test anytime:** Actions tab → *Monthly expense report* → **Run workflow**. A manual run
164
+ skips the last-day guard and runs immediately.
165
+ - **Test the failure alert:** run the workflow with the **test_fail** input checked — it forces
166
+ a failure and emails `NOTIFY_RECIPIENT` (or `IMAP_USER`).
167
+ - The generated PDF is also kept as a **run artifact** for 90 days.
168
+ - Scheduled workflows only run on the repository's default branch.
169
+
170
+ ## Customising the parsing
171
+
172
+ Receipt layouts vary. The parser ([`receipt_parser.py`](receipt_report/receipt_parser.py)) is
173
+ built to be tuned:
174
+
175
+ - **Amounts** are matched in both `1,234.50` and `1 234,50` styles; lines containing keywords
176
+ like *total*, *amount due*, *summa* are preferred, otherwise the largest amount wins.
177
+ - **Dates** are read as ISO, `dd.mm.yyyy`, or `12 June 2026` (English + Swedish month names),
178
+ falling back to the email's date.
179
+ - **Descriptions** default to the first meaningful text line; set `DESCRIPTION_MATCH` to a
180
+ regex to grab something specific (group 1 if present).
181
+
182
+ Receipts where an amount or date can't be read are still included and listed as warnings so
183
+ you can check them.
184
+
185
+ ## Security
186
+
187
+ - Credentials live only in `.env` (git-ignored) or your host's secret store. Nothing sensitive
188
+ is committed.
189
+ - The processed-email move happens **after** the PDF is written successfully, so an interrupted
190
+ run never loses receipts.
191
+
192
+ ## Publishing to PyPI
193
+
194
+ [`.github/workflows/publish.yml`](.github/workflows/publish.yml) publishes the package to
195
+ PyPI whenever you create a **GitHub Release**, authenticating with a PyPI API token. One-time
196
+ setup:
197
+
198
+ 1. **Create an API token.** On [pypi.org](https://pypi.org) → *Account settings → API tokens*
199
+ → *Add API token* (scope "Entire account" for the first upload; you can scope it to the
200
+ project afterwards).
201
+ 2. **Store it as a secret.** Repo → *Settings → Secrets and variables → Actions* → new secret
202
+ named `PYPI_API_TOKEN` with the token value. Or via CLI:
203
+ `gh secret set PYPI_API_TOKEN --repo <owner>/<repo>`
204
+ 3. **Release.** Bump `version` in [`pyproject.toml`](pyproject.toml), then create a GitHub
205
+ Release (e.g. tag `v1.0.0`). The workflow builds and uploads automatically.
206
+
207
+ Prefer no stored token? PyPI also supports OIDC *trusted publishing* — remove the `password:`
208
+ line from the workflow, add `permissions: id-token: write` to the publish job, and register a
209
+ trusted publisher on PyPI (owner, repo, workflow `publish.yml`, no environment).
210
+
211
+ ## License
212
+
213
+ [MIT](LICENSE)
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "receipt-report-generator"
7
+ version = "1.0.0"
8
+ description = "Turn emailed receipts into a single expense-report PDF (cover summary + indexed appendix)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "David Barton" }]
13
+ keywords = ["imap", "receipts", "pdf", "expense-report", "email", "invoice", "bookkeeping"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Environment :: Console",
19
+ "Topic :: Office/Business :: Financial :: Accounting",
20
+ "Topic :: Communications :: Email",
21
+ ]
22
+ dependencies = [
23
+ "imap-tools>=1.7.0",
24
+ "pdfplumber>=0.11.0",
25
+ "reportlab>=4.1.0",
26
+ "pypdf>=4.2.0",
27
+ "python-dotenv>=1.0.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/dbarton1974/receipt-report-generator"
32
+ Repository = "https://github.com/dbarton1974/receipt-report-generator"
33
+ Issues = "https://github.com/dbarton1974/receipt-report-generator/issues"
34
+
35
+ [project.scripts]
36
+ receipt-report = "receipt_report.main:main"
37
+
38
+ [tool.setuptools]
39
+ packages = ["receipt_report"]
40
+
41
+ [tool.setuptools.package-data]
42
+ receipt_report = ["providers.json"]
@@ -0,0 +1,3 @@
1
+ """receipt-report-generator: turn emailed receipts into a single expense-report PDF."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,8 @@
1
+ """Makes the package runnable with `python -m receipt_report`."""
2
+
3
+ import sys
4
+
5
+ from .main import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,107 @@
1
+ """Configuration, loaded from environment variables (12-factor / metadata-driven).
2
+
3
+ A named preset from providers.json can be selected with MAIL_PROVIDER, which fills in
4
+ IMAP/SMTP host, port and security. Any explicit host/port/security env var overrides it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ try:
14
+ from dotenv import load_dotenv
15
+ except ImportError: # python-dotenv is optional; in CI the vars are set directly.
16
+ def load_dotenv(*args, **kwargs): # type: ignore[misc]
17
+ return False
18
+
19
+ from .providers import get_provider
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Config:
24
+ # IMAP
25
+ imap_host: str
26
+ imap_port: int
27
+ imap_user: str
28
+ imap_password: str
29
+ # SMTP
30
+ smtp_host: str
31
+ smtp_port: int
32
+ smtp_security: str # "ssl" or "starttls"
33
+ # What to collect
34
+ receipt_sender: str
35
+ inbox_folder: str
36
+ processed_folder: str
37
+ # Report
38
+ output_dir: Path
39
+ recipient_name: str
40
+ report_title: str
41
+ purpose: str # empty string => no "Purpose" column
42
+ currency: str
43
+ description_match: str # optional regex to extract a description; empty => first line
44
+ # Delivery / alerts
45
+ archive_recipient: str # empty => don't email the report
46
+ notify_recipient: str # empty => defaults to imap_user
47
+
48
+
49
+ def _require(name: str, value: str) -> str:
50
+ if not value:
51
+ raise SystemExit(
52
+ f"Configuration error: {name} is missing. "
53
+ f"Copy .env.example to .env and fill in the values."
54
+ )
55
+ return value
56
+
57
+
58
+ def load_config() -> Config:
59
+ load_dotenv()
60
+
61
+ # Resolve provider preset (if any) for host/port/security defaults.
62
+ provider_key = os.getenv("MAIL_PROVIDER", "").strip()
63
+ preset = get_provider(provider_key) if provider_key else None
64
+ if provider_key and preset is None:
65
+ raise SystemExit(
66
+ f"Unknown MAIL_PROVIDER '{provider_key}'. See providers.json for valid keys, "
67
+ f"or set IMAP_HOST/SMTP_HOST explicitly instead."
68
+ )
69
+
70
+ def _host_default(env_name: str, preset_value: str | None) -> str:
71
+ return os.getenv(env_name, "").strip() or (preset_value or "")
72
+
73
+ imap_host = _host_default("IMAP_HOST", preset.imap_host if preset else None)
74
+ smtp_host = _host_default("SMTP_HOST", preset.smtp_host if preset else None)
75
+
76
+ imap_port = int(os.getenv("IMAP_PORT", str(preset.imap_port if preset else 993)))
77
+ smtp_port = int(os.getenv("SMTP_PORT", str(preset.smtp_port if preset else 465)))
78
+ smtp_security = (
79
+ os.getenv("SMTP_SECURITY", "").strip().lower()
80
+ or (preset.smtp_security if preset else "ssl")
81
+ )
82
+
83
+ recipient_name = os.getenv("RECIPIENT_NAME", "Me")
84
+ report_title = os.getenv("REPORT_TITLE", "Expense report for {name}").replace(
85
+ "{name}", recipient_name
86
+ )
87
+
88
+ return Config(
89
+ imap_host=_require("IMAP_HOST (or MAIL_PROVIDER)", imap_host),
90
+ imap_port=imap_port,
91
+ imap_user=_require("IMAP_USER", os.getenv("IMAP_USER", "").strip()),
92
+ imap_password=_require("IMAP_PASSWORD", os.getenv("IMAP_PASSWORD", "").strip()),
93
+ smtp_host=smtp_host or imap_host,
94
+ smtp_port=smtp_port,
95
+ smtp_security=smtp_security,
96
+ receipt_sender=_require("RECEIPT_SENDER", os.getenv("RECEIPT_SENDER", "").strip()),
97
+ inbox_folder=os.getenv("INBOX_FOLDER", "INBOX"),
98
+ processed_folder=os.getenv("PROCESSED_FOLDER", "Processed/Receipts"),
99
+ output_dir=Path(os.getenv("OUTPUT_DIR", "./output")).expanduser(),
100
+ recipient_name=recipient_name,
101
+ report_title=report_title,
102
+ purpose=os.getenv("PURPOSE", "").strip(),
103
+ currency=os.getenv("CURRENCY", "kr"),
104
+ description_match=os.getenv("DESCRIPTION_MATCH", "").strip(),
105
+ archive_recipient=os.getenv("ARCHIVE_RECIPIENT", "").strip(),
106
+ notify_recipient=os.getenv("NOTIFY_RECIPIENT", "").strip(),
107
+ )