rcvatkit 0.1.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.
- rcvatkit-0.1.0/LICENSE +21 -0
- rcvatkit-0.1.0/PKG-INFO +206 -0
- rcvatkit-0.1.0/README.md +186 -0
- rcvatkit-0.1.0/pyproject.toml +32 -0
- rcvatkit-0.1.0/rcvatkit/__init__.py +34 -0
- rcvatkit-0.1.0/rcvatkit/checker.py +170 -0
- rcvatkit-0.1.0/rcvatkit/quarters.py +85 -0
- rcvatkit-0.1.0/rcvatkit.egg-info/PKG-INFO +206 -0
- rcvatkit-0.1.0/rcvatkit.egg-info/SOURCES.txt +13 -0
- rcvatkit-0.1.0/rcvatkit.egg-info/dependency_links.txt +1 -0
- rcvatkit-0.1.0/rcvatkit.egg-info/requires.txt +1 -0
- rcvatkit-0.1.0/rcvatkit.egg-info/top_level.txt +1 -0
- rcvatkit-0.1.0/setup.cfg +4 -0
- rcvatkit-0.1.0/tests/test_checker.py +99 -0
- rcvatkit-0.1.0/tests/test_quarters.py +0 -0
rcvatkit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Duckboard
|
|
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.
|
rcvatkit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rcvatkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find UK VAT reverse-charge purchase invoices sitting in Sage 50 at the wrong tax code, and calculate the Box 1 / Box 4 VAT return adjustment to correct it.
|
|
5
|
+
Author: Duckboard
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Duckboard/rcvatkit
|
|
8
|
+
Project-URL: Buy me a coffee, https://ko-fi.com/duckboard
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: sagekit>=0.1.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# rcvatkit
|
|
22
|
+
|
|
23
|
+
Finds UK VAT **reverse-charge** purchase invoices sitting in **Sage 50**
|
|
24
|
+
at the wrong tax code, and calculates the VAT return manual adjustment
|
|
25
|
+
needed to correct it.
|
|
26
|
+
|
|
27
|
+
If this saves you time, consider [buying me a coffee](https://ko-fi.com/duckboard).
|
|
28
|
+
|
|
29
|
+
## What problem this solves
|
|
30
|
+
|
|
31
|
+
If you receive foreign-currency (non-GBP) supplier invoices and use an
|
|
32
|
+
OCR/import tool such as AutoEntry, Dext, or Hubdoc, there's a good
|
|
33
|
+
chance those invoices are landing in Sage at the default "no VAT" tax
|
|
34
|
+
code (often `T0`) instead of the reverse-charge code Sage expects
|
|
35
|
+
(often `T18`). HMRC needs Boxes 1 and 4 of your VAT return to reflect
|
|
36
|
+
these transactions correctly.
|
|
37
|
+
|
|
38
|
+
Sage 50 won't let you post the reverse-charge code on a manual journal
|
|
39
|
+
("This tax code cannot be used for Journal transactions"), so the fix
|
|
40
|
+
isn't a journal at all -- it's a **manual adjustment** on the VAT
|
|
41
|
+
return itself: add the same amount to Box 1 (output VAT) and Box 4
|
|
42
|
+
(input VAT). The net effect on your VAT bill is nil, but the boxes are
|
|
43
|
+
populated the way HMRC expects.
|
|
44
|
+
|
|
45
|
+
This toolkit finds the transactions and does the arithmetic. It does
|
|
46
|
+
not touch Sage or submit anything -- applying the adjustment is a
|
|
47
|
+
manual step in Sage's VAT Return "Adjustments" screen.
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- Finds non-GBP purchase invoices/credits not yet coded with your
|
|
52
|
+
reverse-charge tax code, using [sagekit](https://github.com/Duckboard/sagekit)
|
|
53
|
+
for the Sage 50 ODBC connection and its column auto-detection
|
|
54
|
+
- Calculates the Box 1 / Box 4 adjustment amount
|
|
55
|
+
- Built-in helper for UK VAT "stagger groups" -- works out which VAT
|
|
56
|
+
quarter just finished without you having to calculate the dates
|
|
57
|
+
by hand
|
|
58
|
+
- One worked example, ready to adapt: a CLI script with date prompts,
|
|
59
|
+
a results table, and a saved log file
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install -r requirements.txt
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
or, to install as an editable package:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install -e .
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This installs [sagekit](https://github.com/Duckboard/sagekit)
|
|
74
|
+
automatically -- rcvatkit builds on it rather than reimplementing the
|
|
75
|
+
Sage connection. You'll also need the **Sage Line 50 ODBC driver**
|
|
76
|
+
(installed alongside Sage 50 itself) and Sage 50 running while you
|
|
77
|
+
query it -- see sagekit's README for how to find your DSN.
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
Copy `.env.example` to `.env` if you want to pre-fill values:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
cp .env.example .env
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
SAGE_DSN=SageLine50v33
|
|
89
|
+
SAGE_UID=MANAGER
|
|
90
|
+
SAGE_PWD=
|
|
91
|
+
|
|
92
|
+
RCVAT_STAGGER=1
|
|
93
|
+
RCVAT_TARGET_CODE=T18
|
|
94
|
+
RCVAT_VAT_RATE=0.20
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`.env` is already in `.gitignore` -- never commit real credentials.
|
|
98
|
+
|
|
99
|
+
## Which VAT stagger group are you in?
|
|
100
|
+
|
|
101
|
+
HMRC assigns every VAT-registered business to one of three stagger
|
|
102
|
+
groups, which determine which months your VAT quarters end in. Check
|
|
103
|
+
your VAT registration certificate or HMRC online account if you're not
|
|
104
|
+
sure.
|
|
105
|
+
|
|
106
|
+
| Stagger | Quarters end |
|
|
107
|
+
|---|---|
|
|
108
|
+
| 1 | Mar / Jun / Sep / Dec |
|
|
109
|
+
| 2 | Jan / Apr / Jul / Oct |
|
|
110
|
+
| 3 | Feb / May / Aug / Nov |
|
|
111
|
+
|
|
112
|
+
## Usage
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from datetime import date
|
|
116
|
+
from sagekit import connect_or_exit
|
|
117
|
+
from rcvatkit import find_reverse_charge_candidates, compute_adjustment, last_completed_quarter
|
|
118
|
+
|
|
119
|
+
conn = connect_or_exit()
|
|
120
|
+
cursor = conn.cursor()
|
|
121
|
+
|
|
122
|
+
# Work out the last completed quarter automatically...
|
|
123
|
+
date_from, date_to = last_completed_quarter(stagger=1)
|
|
124
|
+
|
|
125
|
+
# ...or just pass your own dates:
|
|
126
|
+
# date_from, date_to = date(2026, 1, 1), date(2026, 3, 31)
|
|
127
|
+
|
|
128
|
+
lines = find_reverse_charge_candidates(cursor, date_from, date_to)
|
|
129
|
+
adjustment = compute_adjustment(lines)
|
|
130
|
+
|
|
131
|
+
print(f"{len(lines)} invoice line(s) found, net GBP {adjustment['net_gbp']:.2f}")
|
|
132
|
+
print(f"Add £{adjustment['box_1']:.2f} to Box 1 AND Box 4")
|
|
133
|
+
|
|
134
|
+
conn.close()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Example script
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
python examples/check_reverse_charge.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Prompts for a date range (defaulting to the last completed quarter for
|
|
144
|
+
your stagger group), connects to Sage, prints a table of the affected
|
|
145
|
+
invoices grouped by supplier plus the Box 1/Box 4 adjustment, and saves
|
|
146
|
+
everything to a timestamped log file alongside the script. Copy and
|
|
147
|
+
adapt it -- it's a starting point, not a fixed report.
|
|
148
|
+
|
|
149
|
+
### Concept reference
|
|
150
|
+
|
|
151
|
+
| Concept | What it means |
|
|
152
|
+
|---|---|
|
|
153
|
+
| Reverse charge | The VAT treatment HMRC requires for certain foreign-currency purchases -- both output and input VAT are recorded, netting to nil but keeping Boxes 1 and 4 accurate |
|
|
154
|
+
| `T18` | Sage's common default reverse-charge tax code (yours may differ -- check your Sage tax code list) |
|
|
155
|
+
| Stagger group | Which three months your VAT quarters end in -- see table above |
|
|
156
|
+
| `AUDIT_HEADER` / `AUDIT_SPLIT` / `PURCHASE_LEDGER` | The three Sage ODBC tables this toolkit joins to find purchase invoices, their tax code, and the supplier's currency |
|
|
157
|
+
|
|
158
|
+
## A Sage ODBC quirk worth knowing
|
|
159
|
+
|
|
160
|
+
Sage's ODBC driver **rejects `INNER JOIN` across three or more tables**
|
|
161
|
+
("Invalid outer join specification"). `find_reverse_charge_candidates`
|
|
162
|
+
uses the older implicit (comma) join style for exactly this reason --
|
|
163
|
+
if you're adapting the SQL yourself and add a fourth table, keep using
|
|
164
|
+
commas rather than `INNER JOIN`.
|
|
165
|
+
|
|
166
|
+
Also, Sage's ODBC currency IDs are offset by one from what the Sage 50
|
|
167
|
+
UI dropdown shows (UI "0 -- Pound Sterling" is ODBC value `1`, UI
|
|
168
|
+
"1 -- US Dollar" is ODBC value `2`, and so on). `base_currency_id`
|
|
169
|
+
defaults to `1` (the near-universal UK base currency ID), but if your
|
|
170
|
+
own install differs, pass your own value -- run
|
|
171
|
+
`SELECT DISTINCT CURRENCY FROM PURCHASE_LEDGER` via sagekit to check.
|
|
172
|
+
|
|
173
|
+
## Testing
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
pip install pytest
|
|
177
|
+
pytest
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Tests use a fake cursor object, so they run offline without a real
|
|
181
|
+
Sage install, ODBC driver, or sagekit dependency needing to connect
|
|
182
|
+
anywhere.
|
|
183
|
+
|
|
184
|
+
## Troubleshooting
|
|
185
|
+
|
|
186
|
+
| Symptom | Likely cause | Fix |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `Invalid outer join specification` | SQL changed to use `INNER JOIN` across 3+ tables | Use the comma-join style -- see above |
|
|
189
|
+
| `Column not found` / `COLUMN DETECTION ERROR` | Column name varies by Sage version | Check the error message's "Available" list against your install |
|
|
190
|
+
| No rows returned | Wrong date range, or no non-GBP suppliers in that period | Confirm Sage is open, widen the date range, check `RCVAT_TARGET_CODE` matches what your install actually uses |
|
|
191
|
+
| Adjustment looks too big/small | Wrong `RCVAT_VAT_RATE` or wrong `base_currency_id` | Confirm your VAT rate and check `PURCHASE_LEDGER.CURRENCY` values for your GBP accounts |
|
|
192
|
+
| `This tax code cannot be used for Journal transactions` | Expected -- you tried to journal the reverse charge instead of using a manual VAT return adjustment | Use the Adjustments screen on the VAT return, not a journal |
|
|
193
|
+
|
|
194
|
+
## Notes
|
|
195
|
+
|
|
196
|
+
- This calculates the adjustment; it does not post anything to Sage or
|
|
197
|
+
submit your VAT return. Always sanity-check the figures before
|
|
198
|
+
applying them.
|
|
199
|
+
- Not tax advice -- if you're unsure whether reverse charge applies to
|
|
200
|
+
your purchases, check with HMRC guidance or your accountant.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT -- see [LICENSE](LICENSE). Do whatever you like with this; a
|
|
205
|
+
credit or a [Ko-fi tip](https://ko-fi.com/duckboard) is appreciated
|
|
206
|
+
but never required.
|
rcvatkit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# rcvatkit
|
|
2
|
+
|
|
3
|
+
Finds UK VAT **reverse-charge** purchase invoices sitting in **Sage 50**
|
|
4
|
+
at the wrong tax code, and calculates the VAT return manual adjustment
|
|
5
|
+
needed to correct it.
|
|
6
|
+
|
|
7
|
+
If this saves you time, consider [buying me a coffee](https://ko-fi.com/duckboard).
|
|
8
|
+
|
|
9
|
+
## What problem this solves
|
|
10
|
+
|
|
11
|
+
If you receive foreign-currency (non-GBP) supplier invoices and use an
|
|
12
|
+
OCR/import tool such as AutoEntry, Dext, or Hubdoc, there's a good
|
|
13
|
+
chance those invoices are landing in Sage at the default "no VAT" tax
|
|
14
|
+
code (often `T0`) instead of the reverse-charge code Sage expects
|
|
15
|
+
(often `T18`). HMRC needs Boxes 1 and 4 of your VAT return to reflect
|
|
16
|
+
these transactions correctly.
|
|
17
|
+
|
|
18
|
+
Sage 50 won't let you post the reverse-charge code on a manual journal
|
|
19
|
+
("This tax code cannot be used for Journal transactions"), so the fix
|
|
20
|
+
isn't a journal at all -- it's a **manual adjustment** on the VAT
|
|
21
|
+
return itself: add the same amount to Box 1 (output VAT) and Box 4
|
|
22
|
+
(input VAT). The net effect on your VAT bill is nil, but the boxes are
|
|
23
|
+
populated the way HMRC expects.
|
|
24
|
+
|
|
25
|
+
This toolkit finds the transactions and does the arithmetic. It does
|
|
26
|
+
not touch Sage or submit anything -- applying the adjustment is a
|
|
27
|
+
manual step in Sage's VAT Return "Adjustments" screen.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Finds non-GBP purchase invoices/credits not yet coded with your
|
|
32
|
+
reverse-charge tax code, using [sagekit](https://github.com/Duckboard/sagekit)
|
|
33
|
+
for the Sage 50 ODBC connection and its column auto-detection
|
|
34
|
+
- Calculates the Box 1 / Box 4 adjustment amount
|
|
35
|
+
- Built-in helper for UK VAT "stagger groups" -- works out which VAT
|
|
36
|
+
quarter just finished without you having to calculate the dates
|
|
37
|
+
by hand
|
|
38
|
+
- One worked example, ready to adapt: a CLI script with date prompts,
|
|
39
|
+
a results table, and a saved log file
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install -r requirements.txt
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
or, to install as an editable package:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install -e .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This installs [sagekit](https://github.com/Duckboard/sagekit)
|
|
54
|
+
automatically -- rcvatkit builds on it rather than reimplementing the
|
|
55
|
+
Sage connection. You'll also need the **Sage Line 50 ODBC driver**
|
|
56
|
+
(installed alongside Sage 50 itself) and Sage 50 running while you
|
|
57
|
+
query it -- see sagekit's README for how to find your DSN.
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
Copy `.env.example` to `.env` if you want to pre-fill values:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
cp .env.example .env
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
SAGE_DSN=SageLine50v33
|
|
69
|
+
SAGE_UID=MANAGER
|
|
70
|
+
SAGE_PWD=
|
|
71
|
+
|
|
72
|
+
RCVAT_STAGGER=1
|
|
73
|
+
RCVAT_TARGET_CODE=T18
|
|
74
|
+
RCVAT_VAT_RATE=0.20
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`.env` is already in `.gitignore` -- never commit real credentials.
|
|
78
|
+
|
|
79
|
+
## Which VAT stagger group are you in?
|
|
80
|
+
|
|
81
|
+
HMRC assigns every VAT-registered business to one of three stagger
|
|
82
|
+
groups, which determine which months your VAT quarters end in. Check
|
|
83
|
+
your VAT registration certificate or HMRC online account if you're not
|
|
84
|
+
sure.
|
|
85
|
+
|
|
86
|
+
| Stagger | Quarters end |
|
|
87
|
+
|---|---|
|
|
88
|
+
| 1 | Mar / Jun / Sep / Dec |
|
|
89
|
+
| 2 | Jan / Apr / Jul / Oct |
|
|
90
|
+
| 3 | Feb / May / Aug / Nov |
|
|
91
|
+
|
|
92
|
+
## Usage
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from datetime import date
|
|
96
|
+
from sagekit import connect_or_exit
|
|
97
|
+
from rcvatkit import find_reverse_charge_candidates, compute_adjustment, last_completed_quarter
|
|
98
|
+
|
|
99
|
+
conn = connect_or_exit()
|
|
100
|
+
cursor = conn.cursor()
|
|
101
|
+
|
|
102
|
+
# Work out the last completed quarter automatically...
|
|
103
|
+
date_from, date_to = last_completed_quarter(stagger=1)
|
|
104
|
+
|
|
105
|
+
# ...or just pass your own dates:
|
|
106
|
+
# date_from, date_to = date(2026, 1, 1), date(2026, 3, 31)
|
|
107
|
+
|
|
108
|
+
lines = find_reverse_charge_candidates(cursor, date_from, date_to)
|
|
109
|
+
adjustment = compute_adjustment(lines)
|
|
110
|
+
|
|
111
|
+
print(f"{len(lines)} invoice line(s) found, net GBP {adjustment['net_gbp']:.2f}")
|
|
112
|
+
print(f"Add £{adjustment['box_1']:.2f} to Box 1 AND Box 4")
|
|
113
|
+
|
|
114
|
+
conn.close()
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Example script
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
python examples/check_reverse_charge.py
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Prompts for a date range (defaulting to the last completed quarter for
|
|
124
|
+
your stagger group), connects to Sage, prints a table of the affected
|
|
125
|
+
invoices grouped by supplier plus the Box 1/Box 4 adjustment, and saves
|
|
126
|
+
everything to a timestamped log file alongside the script. Copy and
|
|
127
|
+
adapt it -- it's a starting point, not a fixed report.
|
|
128
|
+
|
|
129
|
+
### Concept reference
|
|
130
|
+
|
|
131
|
+
| Concept | What it means |
|
|
132
|
+
|---|---|
|
|
133
|
+
| Reverse charge | The VAT treatment HMRC requires for certain foreign-currency purchases -- both output and input VAT are recorded, netting to nil but keeping Boxes 1 and 4 accurate |
|
|
134
|
+
| `T18` | Sage's common default reverse-charge tax code (yours may differ -- check your Sage tax code list) |
|
|
135
|
+
| Stagger group | Which three months your VAT quarters end in -- see table above |
|
|
136
|
+
| `AUDIT_HEADER` / `AUDIT_SPLIT` / `PURCHASE_LEDGER` | The three Sage ODBC tables this toolkit joins to find purchase invoices, their tax code, and the supplier's currency |
|
|
137
|
+
|
|
138
|
+
## A Sage ODBC quirk worth knowing
|
|
139
|
+
|
|
140
|
+
Sage's ODBC driver **rejects `INNER JOIN` across three or more tables**
|
|
141
|
+
("Invalid outer join specification"). `find_reverse_charge_candidates`
|
|
142
|
+
uses the older implicit (comma) join style for exactly this reason --
|
|
143
|
+
if you're adapting the SQL yourself and add a fourth table, keep using
|
|
144
|
+
commas rather than `INNER JOIN`.
|
|
145
|
+
|
|
146
|
+
Also, Sage's ODBC currency IDs are offset by one from what the Sage 50
|
|
147
|
+
UI dropdown shows (UI "0 -- Pound Sterling" is ODBC value `1`, UI
|
|
148
|
+
"1 -- US Dollar" is ODBC value `2`, and so on). `base_currency_id`
|
|
149
|
+
defaults to `1` (the near-universal UK base currency ID), but if your
|
|
150
|
+
own install differs, pass your own value -- run
|
|
151
|
+
`SELECT DISTINCT CURRENCY FROM PURCHASE_LEDGER` via sagekit to check.
|
|
152
|
+
|
|
153
|
+
## Testing
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pip install pytest
|
|
157
|
+
pytest
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Tests use a fake cursor object, so they run offline without a real
|
|
161
|
+
Sage install, ODBC driver, or sagekit dependency needing to connect
|
|
162
|
+
anywhere.
|
|
163
|
+
|
|
164
|
+
## Troubleshooting
|
|
165
|
+
|
|
166
|
+
| Symptom | Likely cause | Fix |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| `Invalid outer join specification` | SQL changed to use `INNER JOIN` across 3+ tables | Use the comma-join style -- see above |
|
|
169
|
+
| `Column not found` / `COLUMN DETECTION ERROR` | Column name varies by Sage version | Check the error message's "Available" list against your install |
|
|
170
|
+
| No rows returned | Wrong date range, or no non-GBP suppliers in that period | Confirm Sage is open, widen the date range, check `RCVAT_TARGET_CODE` matches what your install actually uses |
|
|
171
|
+
| Adjustment looks too big/small | Wrong `RCVAT_VAT_RATE` or wrong `base_currency_id` | Confirm your VAT rate and check `PURCHASE_LEDGER.CURRENCY` values for your GBP accounts |
|
|
172
|
+
| `This tax code cannot be used for Journal transactions` | Expected -- you tried to journal the reverse charge instead of using a manual VAT return adjustment | Use the Adjustments screen on the VAT return, not a journal |
|
|
173
|
+
|
|
174
|
+
## Notes
|
|
175
|
+
|
|
176
|
+
- This calculates the adjustment; it does not post anything to Sage or
|
|
177
|
+
submit your VAT return. Always sanity-check the figures before
|
|
178
|
+
applying them.
|
|
179
|
+
- Not tax advice -- if you're unsure whether reverse charge applies to
|
|
180
|
+
your purchases, check with HMRC guidance or your accountant.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT -- see [LICENSE](LICENSE). Do whatever you like with this; a
|
|
185
|
+
credit or a [Ko-fi tip](https://ko-fi.com/duckboard) is appreciated
|
|
186
|
+
but never required.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rcvatkit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Find UK VAT reverse-charge purchase invoices sitting in Sage 50 at the wrong tax code, and calculate the Box 1 / Box 4 VAT return adjustment to correct it."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Duckboard" }
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"sagekit>=0.1.0",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Operating System :: Microsoft :: Windows",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Topic :: Office/Business :: Financial :: Accounting",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/Duckboard/rcvatkit"
|
|
29
|
+
"Buy me a coffee" = "https://ko-fi.com/duckboard"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
include = ["rcvatkit*"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rcvatkit -- find and calculate the UK VAT reverse-charge adjustment for
|
|
3
|
+
foreign-currency purchase invoices sitting in Sage 50, when they've been
|
|
4
|
+
posted at the wrong tax code by an OCR/import tool.
|
|
5
|
+
|
|
6
|
+
from rcvatkit import find_reverse_charge_candidates, compute_adjustment
|
|
7
|
+
from rcvatkit import last_completed_quarter
|
|
8
|
+
|
|
9
|
+
See README.md for the full walkthrough.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .checker import (
|
|
13
|
+
DEFAULT_BASE_CURRENCY_ID,
|
|
14
|
+
DEFAULT_TARGET_TAX_CODE,
|
|
15
|
+
DEFAULT_VAT_RATE,
|
|
16
|
+
ReverseChargeLine,
|
|
17
|
+
compute_adjustment,
|
|
18
|
+
find_reverse_charge_candidates,
|
|
19
|
+
)
|
|
20
|
+
from .quarters import VAT_STAGGERS, last_completed_quarter
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"find_reverse_charge_candidates",
|
|
26
|
+
"compute_adjustment",
|
|
27
|
+
"ReverseChargeLine",
|
|
28
|
+
"last_completed_quarter",
|
|
29
|
+
"VAT_STAGGERS",
|
|
30
|
+
"DEFAULT_VAT_RATE",
|
|
31
|
+
"DEFAULT_TARGET_TAX_CODE",
|
|
32
|
+
"DEFAULT_BASE_CURRENCY_ID",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rcvatkit.checker
|
|
3
|
+
================
|
|
4
|
+
Finds foreign-currency purchase invoices/credits in Sage 50 that have
|
|
5
|
+
NOT yet been coded with the reverse-charge tax code, and calculates the
|
|
6
|
+
VAT return manual adjustment needed to correct it.
|
|
7
|
+
|
|
8
|
+
Background
|
|
9
|
+
----------
|
|
10
|
+
Many UK businesses receive non-GBP supplier invoices via an OCR/import
|
|
11
|
+
tool (AutoEntry, Dext, Hubdoc, etc.), and these tools commonly post
|
|
12
|
+
them at the standard "no VAT" tax code (often T0) rather than the
|
|
13
|
+
reverse-charge code Sage expects for EU/foreign purchases (often T18).
|
|
14
|
+
Sage 50 will not accept a reverse-charge tax code on a manual journal
|
|
15
|
+
("This tax code cannot be used for Journal transactions"), so the fix
|
|
16
|
+
is not a journal at all -- it's a manual adjustment on the VAT return
|
|
17
|
+
itself: add the same amount to Box 1 (output VAT) and Box 4 (input
|
|
18
|
+
VAT). The net effect on the VAT bill is nil, but the boxes are
|
|
19
|
+
populated correctly for HMRC.
|
|
20
|
+
|
|
21
|
+
This module finds the underlying transactions and does the arithmetic.
|
|
22
|
+
It does not touch Sage or submit anything -- applying the adjustment is
|
|
23
|
+
a manual step in Sage's VAT Return "Adjustments" screen (see README).
|
|
24
|
+
|
|
25
|
+
A note on tax codes and VAT rates: T18 / 20% are the common UK default,
|
|
26
|
+
but your Sage configuration may differ (different tax code numbering,
|
|
27
|
+
a different rate for what you buy). Both are parameters here, not
|
|
28
|
+
hardcoded assumptions -- check your own Sage tax code list if unsure.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from datetime import date
|
|
33
|
+
from typing import Dict, List, Sequence
|
|
34
|
+
|
|
35
|
+
from sagekit import get_columns
|
|
36
|
+
|
|
37
|
+
DEFAULT_VAT_RATE = 0.20
|
|
38
|
+
DEFAULT_TARGET_TAX_CODE = "T18"
|
|
39
|
+
|
|
40
|
+
# Sage's ODBC currency IDs are offset by one from what the Sage 50 UI
|
|
41
|
+
# dropdown shows (UI "0 -- Pound Sterling" is ODBC value 1, UI "1 -- US
|
|
42
|
+
# Dollar" is ODBC value 2, and so on) -- confirmed by testing against a
|
|
43
|
+
# live Sage 50 install. 1 is the near-universal default for a UK
|
|
44
|
+
# company's base currency, but if your GBP shows up as a different ID,
|
|
45
|
+
# override it -- see README for how to check.
|
|
46
|
+
DEFAULT_BASE_CURRENCY_ID = 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ReverseChargeLine:
|
|
51
|
+
account_ref: str
|
|
52
|
+
txn_type: str
|
|
53
|
+
invoice_ref: str
|
|
54
|
+
txn_date: date
|
|
55
|
+
foreign_gross: float
|
|
56
|
+
gbp_gross: float
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _find_col(available, candidates, label):
|
|
60
|
+
for c in candidates:
|
|
61
|
+
if c.upper() in available:
|
|
62
|
+
return c.upper()
|
|
63
|
+
raise KeyError(
|
|
64
|
+
f"Could not find a column for '{label}'.\n"
|
|
65
|
+
f" Tried: {candidates}\n"
|
|
66
|
+
f" Available: {sorted(available)}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_reverse_charge_candidates(
|
|
71
|
+
cursor,
|
|
72
|
+
date_from: date,
|
|
73
|
+
date_to: date,
|
|
74
|
+
target_tax_code: str = DEFAULT_TARGET_TAX_CODE,
|
|
75
|
+
base_currency_id: int = DEFAULT_BASE_CURRENCY_ID,
|
|
76
|
+
purchase_types: Sequence[str] = ("PI", "PC"),
|
|
77
|
+
) -> List[ReverseChargeLine]:
|
|
78
|
+
"""
|
|
79
|
+
Query Sage 50 for purchase invoices/credits (types PI/PC by
|
|
80
|
+
default) dated between date_from and date_to, in a currency other
|
|
81
|
+
than base_currency_id, that are NOT yet coded with
|
|
82
|
+
target_tax_code.
|
|
83
|
+
|
|
84
|
+
Column names are auto-detected against the live database (Sage
|
|
85
|
+
50's ODBC schema varies a little between versions), so this works
|
|
86
|
+
across installs without editing any SQL.
|
|
87
|
+
"""
|
|
88
|
+
ah_cols = get_columns(cursor, "AUDIT_HEADER")
|
|
89
|
+
as_cols = get_columns(cursor, "AUDIT_SPLIT")
|
|
90
|
+
pl_cols = get_columns(cursor, "PURCHASE_LEDGER")
|
|
91
|
+
|
|
92
|
+
ah_date = _find_col(ah_cols, ["DATE", "INV_DATE", "TRANS_DATE"], "transaction date")
|
|
93
|
+
ah_fgross = _find_col(ah_cols, ["FOREIGN_GROSS_AMOUNT"], "foreign gross amount")
|
|
94
|
+
ah_gross = _find_col(ah_cols, ["GROSS_AMOUNT"], "GBP gross amount")
|
|
95
|
+
ah_ref = _find_col(ah_cols, ["ACCOUNT_REF"], "account ref")
|
|
96
|
+
ah_inv = _find_col(ah_cols, ["INV_REF", "REFERENCE"], "invoice ref")
|
|
97
|
+
ah_del = _find_col(ah_cols, ["DELETED_FLAG", "DELETED", "RECORD_DELETED"], "deleted flag")
|
|
98
|
+
ah_pk = _find_col(ah_cols, ["HEADER_NUMBER", "TRAN_NUMBER", "RECORD_NUMBER"], "header key")
|
|
99
|
+
as_fk = _find_col(as_cols, ["HEADER_NUMBER", "TRAN_NUMBER", "RECORD_NUMBER"], "split key")
|
|
100
|
+
as_tax = _find_col(as_cols, ["TAX_CODE", "TAX_TYPE", "VAT_CODE"], "tax code")
|
|
101
|
+
pl_ref = _find_col(pl_cols, ["ACCOUNT_REF"], "PL account ref")
|
|
102
|
+
pl_curr = _find_col(pl_cols, ["CURRENCY", "CURRENCY_CODE"], "PL currency")
|
|
103
|
+
|
|
104
|
+
type_list = ", ".join(f"'{t}'" for t in purchase_types)
|
|
105
|
+
|
|
106
|
+
# Sage's ODBC driver rejects INNER JOIN across 3+ tables ("Invalid
|
|
107
|
+
# outer join specification") -- the implicit (comma) join style
|
|
108
|
+
# below is required for a 3-table query, not a style choice.
|
|
109
|
+
sql = f"""
|
|
110
|
+
SELECT
|
|
111
|
+
h.{ah_ref} AS ACCOUNT_REF,
|
|
112
|
+
h.TYPE AS TXN_TYPE,
|
|
113
|
+
h.{ah_inv} AS INV_REF,
|
|
114
|
+
h.{ah_date} AS TXN_DATE,
|
|
115
|
+
h.{ah_fgross} AS FOREIGN_GROSS,
|
|
116
|
+
h.{ah_gross} AS GBP_GROSS
|
|
117
|
+
FROM AUDIT_HEADER h, AUDIT_SPLIT s, PURCHASE_LEDGER p
|
|
118
|
+
WHERE h.{ah_pk} = s.{as_fk}
|
|
119
|
+
AND h.{ah_ref} = p.{pl_ref}
|
|
120
|
+
AND h.TYPE IN ({type_list})
|
|
121
|
+
AND h.{ah_del} = 0
|
|
122
|
+
AND p.{pl_curr} <> {int(base_currency_id)}
|
|
123
|
+
AND s.{as_tax} <> '{target_tax_code}'
|
|
124
|
+
AND h.{ah_date} >= {{d '{date_from.strftime("%Y-%m-%d")}'}}
|
|
125
|
+
AND h.{ah_date} <= {{d '{date_to.strftime("%Y-%m-%d")}'}}
|
|
126
|
+
ORDER BY h.{ah_ref}, h.{ah_date}, h.{ah_inv}
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
cursor.execute(sql)
|
|
130
|
+
rows = cursor.fetchall()
|
|
131
|
+
|
|
132
|
+
return [
|
|
133
|
+
ReverseChargeLine(
|
|
134
|
+
account_ref=str(r[0]),
|
|
135
|
+
txn_type=str(r[1]),
|
|
136
|
+
invoice_ref=str(r[2]),
|
|
137
|
+
txn_date=r[3],
|
|
138
|
+
foreign_gross=float(r[4] or 0),
|
|
139
|
+
gbp_gross=float(r[5] or 0),
|
|
140
|
+
)
|
|
141
|
+
for r in rows
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def compute_adjustment(
|
|
146
|
+
lines: List[ReverseChargeLine], vat_rate: float = DEFAULT_VAT_RATE
|
|
147
|
+
) -> Dict[str, float]:
|
|
148
|
+
"""
|
|
149
|
+
Given the lines found by find_reverse_charge_candidates, calculate
|
|
150
|
+
the manual VAT return adjustment: the same amount added to both
|
|
151
|
+
Box 1 (output VAT) and Box 4 (input VAT), so the net effect on the
|
|
152
|
+
VAT bill is nil but both boxes are correctly populated for HMRC.
|
|
153
|
+
|
|
154
|
+
Returns a dict with net_gbp, vat_rate, box_1, box_4 and
|
|
155
|
+
net_liability_effect (always 0.0 -- included so callers don't have
|
|
156
|
+
to know that fact themselves).
|
|
157
|
+
"""
|
|
158
|
+
total_gbp = sum(line.gbp_gross for line in lines)
|
|
159
|
+
# Purchase invoices are stored as negative amounts in Sage; take the
|
|
160
|
+
# absolute value to get a positive net figure to apply the rate to.
|
|
161
|
+
net_gbp = abs(total_gbp)
|
|
162
|
+
vat_gbp = round(net_gbp * vat_rate, 2)
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
"net_gbp": net_gbp,
|
|
166
|
+
"vat_rate": vat_rate,
|
|
167
|
+
"box_1": vat_gbp,
|
|
168
|
+
"box_4": vat_gbp,
|
|
169
|
+
"net_liability_effect": 0.0,
|
|
170
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rcvatkit.quarters
|
|
3
|
+
==================
|
|
4
|
+
Helpers for working with UK VAT quarters.
|
|
5
|
+
|
|
6
|
+
HMRC assigns every VAT-registered business to one of three "stagger
|
|
7
|
+
groups", which determine which months your VAT quarters end in:
|
|
8
|
+
|
|
9
|
+
Stagger 1 -- quarters end Mar / Jun / Sep / Dec
|
|
10
|
+
Stagger 2 -- quarters end Jan / Apr / Jul / Oct
|
|
11
|
+
Stagger 3 -- quarters end Feb / May / Aug / Nov
|
|
12
|
+
|
|
13
|
+
You can find which stagger group you're in from your VAT registration
|
|
14
|
+
certificate or your HMRC online account -- it doesn't change unless you
|
|
15
|
+
specifically request it.
|
|
16
|
+
|
|
17
|
+
These helpers don't touch Sage or make any network calls -- they're
|
|
18
|
+
pure date arithmetic, useful for scripts that need to know "which VAT
|
|
19
|
+
quarter just finished" without hardcoding one business's quarter-end
|
|
20
|
+
months.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import calendar
|
|
24
|
+
from datetime import date
|
|
25
|
+
from typing import Optional, Tuple
|
|
26
|
+
|
|
27
|
+
# The four quarter-end months for each HMRC VAT stagger group.
|
|
28
|
+
VAT_STAGGERS = {
|
|
29
|
+
1: (3, 6, 9, 12),
|
|
30
|
+
2: (1, 4, 7, 10),
|
|
31
|
+
3: (2, 5, 8, 11),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def last_completed_quarter(
|
|
36
|
+
stagger: int = 1, today: Optional[date] = None
|
|
37
|
+
) -> Tuple[date, date]:
|
|
38
|
+
"""
|
|
39
|
+
Return (start_date, end_date) of the most recently *completed* VAT
|
|
40
|
+
quarter for the given stagger group, as of `today` (defaults to the
|
|
41
|
+
real current date).
|
|
42
|
+
|
|
43
|
+
"Completed" means the quarter that has already ended -- if today
|
|
44
|
+
falls inside an in-progress quarter, this returns the one before it,
|
|
45
|
+
not the current one.
|
|
46
|
+
|
|
47
|
+
Example: stagger=3 (quarters ending Feb/May/Aug/Nov). If today is
|
|
48
|
+
any date in Mar/Apr/May, the last completed quarter is Dec-Feb.
|
|
49
|
+
"""
|
|
50
|
+
if stagger not in VAT_STAGGERS:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"Unknown stagger group {stagger!r} -- must be 1, 2, or 3. "
|
|
53
|
+
"Check your VAT registration certificate or HMRC online "
|
|
54
|
+
"account for which group you're in."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
end_months = VAT_STAGGERS[stagger]
|
|
58
|
+
today = today or date.today()
|
|
59
|
+
y, m = today.year, today.month
|
|
60
|
+
|
|
61
|
+
# Walk backwards month by month looking for a quarter-end month whose
|
|
62
|
+
# quarter has genuinely finished (its end date is before the 1st of
|
|
63
|
+
# the month we're in now). A couple of years' worth of lookback is
|
|
64
|
+
# more than enough headroom.
|
|
65
|
+
for offset in range(0, 15):
|
|
66
|
+
mm = m - offset
|
|
67
|
+
yy = y
|
|
68
|
+
while mm <= 0:
|
|
69
|
+
mm += 12
|
|
70
|
+
yy -= 1
|
|
71
|
+
|
|
72
|
+
if mm in end_months:
|
|
73
|
+
end_date = date(yy, mm, calendar.monthrange(yy, mm)[1])
|
|
74
|
+
if end_date < date(y, m, 1):
|
|
75
|
+
start_month = mm - 2
|
|
76
|
+
start_year = yy
|
|
77
|
+
while start_month <= 0:
|
|
78
|
+
start_month += 12
|
|
79
|
+
start_year -= 1
|
|
80
|
+
return date(start_year, start_month, 1), end_date
|
|
81
|
+
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
"Could not determine the last completed VAT quarter -- "
|
|
84
|
+
"this shouldn't happen; please report it as a bug."
|
|
85
|
+
)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rcvatkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find UK VAT reverse-charge purchase invoices sitting in Sage 50 at the wrong tax code, and calculate the Box 1 / Box 4 VAT return adjustment to correct it.
|
|
5
|
+
Author: Duckboard
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Duckboard/rcvatkit
|
|
8
|
+
Project-URL: Buy me a coffee, https://ko-fi.com/duckboard
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: sagekit>=0.1.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# rcvatkit
|
|
22
|
+
|
|
23
|
+
Finds UK VAT **reverse-charge** purchase invoices sitting in **Sage 50**
|
|
24
|
+
at the wrong tax code, and calculates the VAT return manual adjustment
|
|
25
|
+
needed to correct it.
|
|
26
|
+
|
|
27
|
+
If this saves you time, consider [buying me a coffee](https://ko-fi.com/duckboard).
|
|
28
|
+
|
|
29
|
+
## What problem this solves
|
|
30
|
+
|
|
31
|
+
If you receive foreign-currency (non-GBP) supplier invoices and use an
|
|
32
|
+
OCR/import tool such as AutoEntry, Dext, or Hubdoc, there's a good
|
|
33
|
+
chance those invoices are landing in Sage at the default "no VAT" tax
|
|
34
|
+
code (often `T0`) instead of the reverse-charge code Sage expects
|
|
35
|
+
(often `T18`). HMRC needs Boxes 1 and 4 of your VAT return to reflect
|
|
36
|
+
these transactions correctly.
|
|
37
|
+
|
|
38
|
+
Sage 50 won't let you post the reverse-charge code on a manual journal
|
|
39
|
+
("This tax code cannot be used for Journal transactions"), so the fix
|
|
40
|
+
isn't a journal at all -- it's a **manual adjustment** on the VAT
|
|
41
|
+
return itself: add the same amount to Box 1 (output VAT) and Box 4
|
|
42
|
+
(input VAT). The net effect on your VAT bill is nil, but the boxes are
|
|
43
|
+
populated the way HMRC expects.
|
|
44
|
+
|
|
45
|
+
This toolkit finds the transactions and does the arithmetic. It does
|
|
46
|
+
not touch Sage or submit anything -- applying the adjustment is a
|
|
47
|
+
manual step in Sage's VAT Return "Adjustments" screen.
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
- Finds non-GBP purchase invoices/credits not yet coded with your
|
|
52
|
+
reverse-charge tax code, using [sagekit](https://github.com/Duckboard/sagekit)
|
|
53
|
+
for the Sage 50 ODBC connection and its column auto-detection
|
|
54
|
+
- Calculates the Box 1 / Box 4 adjustment amount
|
|
55
|
+
- Built-in helper for UK VAT "stagger groups" -- works out which VAT
|
|
56
|
+
quarter just finished without you having to calculate the dates
|
|
57
|
+
by hand
|
|
58
|
+
- One worked example, ready to adapt: a CLI script with date prompts,
|
|
59
|
+
a results table, and a saved log file
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install -r requirements.txt
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
or, to install as an editable package:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install -e .
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This installs [sagekit](https://github.com/Duckboard/sagekit)
|
|
74
|
+
automatically -- rcvatkit builds on it rather than reimplementing the
|
|
75
|
+
Sage connection. You'll also need the **Sage Line 50 ODBC driver**
|
|
76
|
+
(installed alongside Sage 50 itself) and Sage 50 running while you
|
|
77
|
+
query it -- see sagekit's README for how to find your DSN.
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
Copy `.env.example` to `.env` if you want to pre-fill values:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
cp .env.example .env
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
SAGE_DSN=SageLine50v33
|
|
89
|
+
SAGE_UID=MANAGER
|
|
90
|
+
SAGE_PWD=
|
|
91
|
+
|
|
92
|
+
RCVAT_STAGGER=1
|
|
93
|
+
RCVAT_TARGET_CODE=T18
|
|
94
|
+
RCVAT_VAT_RATE=0.20
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`.env` is already in `.gitignore` -- never commit real credentials.
|
|
98
|
+
|
|
99
|
+
## Which VAT stagger group are you in?
|
|
100
|
+
|
|
101
|
+
HMRC assigns every VAT-registered business to one of three stagger
|
|
102
|
+
groups, which determine which months your VAT quarters end in. Check
|
|
103
|
+
your VAT registration certificate or HMRC online account if you're not
|
|
104
|
+
sure.
|
|
105
|
+
|
|
106
|
+
| Stagger | Quarters end |
|
|
107
|
+
|---|---|
|
|
108
|
+
| 1 | Mar / Jun / Sep / Dec |
|
|
109
|
+
| 2 | Jan / Apr / Jul / Oct |
|
|
110
|
+
| 3 | Feb / May / Aug / Nov |
|
|
111
|
+
|
|
112
|
+
## Usage
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from datetime import date
|
|
116
|
+
from sagekit import connect_or_exit
|
|
117
|
+
from rcvatkit import find_reverse_charge_candidates, compute_adjustment, last_completed_quarter
|
|
118
|
+
|
|
119
|
+
conn = connect_or_exit()
|
|
120
|
+
cursor = conn.cursor()
|
|
121
|
+
|
|
122
|
+
# Work out the last completed quarter automatically...
|
|
123
|
+
date_from, date_to = last_completed_quarter(stagger=1)
|
|
124
|
+
|
|
125
|
+
# ...or just pass your own dates:
|
|
126
|
+
# date_from, date_to = date(2026, 1, 1), date(2026, 3, 31)
|
|
127
|
+
|
|
128
|
+
lines = find_reverse_charge_candidates(cursor, date_from, date_to)
|
|
129
|
+
adjustment = compute_adjustment(lines)
|
|
130
|
+
|
|
131
|
+
print(f"{len(lines)} invoice line(s) found, net GBP {adjustment['net_gbp']:.2f}")
|
|
132
|
+
print(f"Add £{adjustment['box_1']:.2f} to Box 1 AND Box 4")
|
|
133
|
+
|
|
134
|
+
conn.close()
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Example script
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
python examples/check_reverse_charge.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Prompts for a date range (defaulting to the last completed quarter for
|
|
144
|
+
your stagger group), connects to Sage, prints a table of the affected
|
|
145
|
+
invoices grouped by supplier plus the Box 1/Box 4 adjustment, and saves
|
|
146
|
+
everything to a timestamped log file alongside the script. Copy and
|
|
147
|
+
adapt it -- it's a starting point, not a fixed report.
|
|
148
|
+
|
|
149
|
+
### Concept reference
|
|
150
|
+
|
|
151
|
+
| Concept | What it means |
|
|
152
|
+
|---|---|
|
|
153
|
+
| Reverse charge | The VAT treatment HMRC requires for certain foreign-currency purchases -- both output and input VAT are recorded, netting to nil but keeping Boxes 1 and 4 accurate |
|
|
154
|
+
| `T18` | Sage's common default reverse-charge tax code (yours may differ -- check your Sage tax code list) |
|
|
155
|
+
| Stagger group | Which three months your VAT quarters end in -- see table above |
|
|
156
|
+
| `AUDIT_HEADER` / `AUDIT_SPLIT` / `PURCHASE_LEDGER` | The three Sage ODBC tables this toolkit joins to find purchase invoices, their tax code, and the supplier's currency |
|
|
157
|
+
|
|
158
|
+
## A Sage ODBC quirk worth knowing
|
|
159
|
+
|
|
160
|
+
Sage's ODBC driver **rejects `INNER JOIN` across three or more tables**
|
|
161
|
+
("Invalid outer join specification"). `find_reverse_charge_candidates`
|
|
162
|
+
uses the older implicit (comma) join style for exactly this reason --
|
|
163
|
+
if you're adapting the SQL yourself and add a fourth table, keep using
|
|
164
|
+
commas rather than `INNER JOIN`.
|
|
165
|
+
|
|
166
|
+
Also, Sage's ODBC currency IDs are offset by one from what the Sage 50
|
|
167
|
+
UI dropdown shows (UI "0 -- Pound Sterling" is ODBC value `1`, UI
|
|
168
|
+
"1 -- US Dollar" is ODBC value `2`, and so on). `base_currency_id`
|
|
169
|
+
defaults to `1` (the near-universal UK base currency ID), but if your
|
|
170
|
+
own install differs, pass your own value -- run
|
|
171
|
+
`SELECT DISTINCT CURRENCY FROM PURCHASE_LEDGER` via sagekit to check.
|
|
172
|
+
|
|
173
|
+
## Testing
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
pip install pytest
|
|
177
|
+
pytest
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Tests use a fake cursor object, so they run offline without a real
|
|
181
|
+
Sage install, ODBC driver, or sagekit dependency needing to connect
|
|
182
|
+
anywhere.
|
|
183
|
+
|
|
184
|
+
## Troubleshooting
|
|
185
|
+
|
|
186
|
+
| Symptom | Likely cause | Fix |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `Invalid outer join specification` | SQL changed to use `INNER JOIN` across 3+ tables | Use the comma-join style -- see above |
|
|
189
|
+
| `Column not found` / `COLUMN DETECTION ERROR` | Column name varies by Sage version | Check the error message's "Available" list against your install |
|
|
190
|
+
| No rows returned | Wrong date range, or no non-GBP suppliers in that period | Confirm Sage is open, widen the date range, check `RCVAT_TARGET_CODE` matches what your install actually uses |
|
|
191
|
+
| Adjustment looks too big/small | Wrong `RCVAT_VAT_RATE` or wrong `base_currency_id` | Confirm your VAT rate and check `PURCHASE_LEDGER.CURRENCY` values for your GBP accounts |
|
|
192
|
+
| `This tax code cannot be used for Journal transactions` | Expected -- you tried to journal the reverse charge instead of using a manual VAT return adjustment | Use the Adjustments screen on the VAT return, not a journal |
|
|
193
|
+
|
|
194
|
+
## Notes
|
|
195
|
+
|
|
196
|
+
- This calculates the adjustment; it does not post anything to Sage or
|
|
197
|
+
submit your VAT return. Always sanity-check the figures before
|
|
198
|
+
applying them.
|
|
199
|
+
- Not tax advice -- if you're unsure whether reverse charge applies to
|
|
200
|
+
your purchases, check with HMRC guidance or your accountant.
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
MIT -- see [LICENSE](LICENSE). Do whatever you like with this; a
|
|
205
|
+
credit or a [Ko-fi tip](https://ko-fi.com/duckboard) is appreciated
|
|
206
|
+
but never required.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
rcvatkit/__init__.py
|
|
5
|
+
rcvatkit/checker.py
|
|
6
|
+
rcvatkit/quarters.py
|
|
7
|
+
rcvatkit.egg-info/PKG-INFO
|
|
8
|
+
rcvatkit.egg-info/SOURCES.txt
|
|
9
|
+
rcvatkit.egg-info/dependency_links.txt
|
|
10
|
+
rcvatkit.egg-info/requires.txt
|
|
11
|
+
rcvatkit.egg-info/top_level.txt
|
|
12
|
+
tests/test_checker.py
|
|
13
|
+
tests/test_quarters.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sagekit>=0.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rcvatkit
|
rcvatkit-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from rcvatkit.checker import (
|
|
6
|
+
ReverseChargeLine,
|
|
7
|
+
compute_adjustment,
|
|
8
|
+
find_reverse_charge_candidates,
|
|
9
|
+
)
|
|
10
|
+
from conftest import FakeCursor
|
|
11
|
+
|
|
12
|
+
COLUMNS_BY_TABLE = {
|
|
13
|
+
"AUDIT_HEADER": [
|
|
14
|
+
"DATE",
|
|
15
|
+
"FOREIGN_GROSS_AMOUNT",
|
|
16
|
+
"GROSS_AMOUNT",
|
|
17
|
+
"ACCOUNT_REF",
|
|
18
|
+
"INV_REF",
|
|
19
|
+
"DELETED_FLAG",
|
|
20
|
+
"HEADER_NUMBER",
|
|
21
|
+
"TYPE",
|
|
22
|
+
],
|
|
23
|
+
"AUDIT_SPLIT": ["HEADER_NUMBER", "TAX_CODE"],
|
|
24
|
+
"PURCHASE_LEDGER": ["ACCOUNT_REF", "CURRENCY"],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Two lines on the same foreign-currency invoice, still coded T0 --
|
|
28
|
+
# exactly the shape find_reverse_charge_candidates should surface.
|
|
29
|
+
SAMPLE_ROWS = [
|
|
30
|
+
("EURO01", "PI", "INV-100", date(2026, 4, 3), 1000.00, -880.00),
|
|
31
|
+
("EURO01", "PI", "INV-101", date(2026, 4, 20), 500.00, -440.00),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cursor():
|
|
36
|
+
return FakeCursor(COLUMNS_BY_TABLE, SAMPLE_ROWS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_find_reverse_charge_candidates_maps_rows_to_dataclass():
|
|
40
|
+
cursor = _cursor()
|
|
41
|
+
lines = find_reverse_charge_candidates(
|
|
42
|
+
cursor, date_from=date(2026, 3, 1), date_to=date(2026, 5, 31)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
assert len(lines) == 2
|
|
46
|
+
assert all(isinstance(line, ReverseChargeLine) for line in lines)
|
|
47
|
+
assert lines[0].account_ref == "EURO01"
|
|
48
|
+
assert lines[0].invoice_ref == "INV-100"
|
|
49
|
+
assert lines[0].gbp_gross == -880.00
|
|
50
|
+
assert lines[1].foreign_gross == 500.00
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_find_reverse_charge_candidates_uses_implicit_join():
|
|
54
|
+
cursor = _cursor()
|
|
55
|
+
find_reverse_charge_candidates(cursor, date_from=date(2026, 3, 1), date_to=date(2026, 5, 31))
|
|
56
|
+
|
|
57
|
+
# Sage's ODBC driver rejects INNER JOIN across 3+ tables -- the
|
|
58
|
+
# query must use the comma-join style, not INNER JOIN, across all
|
|
59
|
+
# three tables.
|
|
60
|
+
assert "INNER JOIN" not in cursor.last_sql.upper()
|
|
61
|
+
assert "AUDIT_HEADER h, AUDIT_SPLIT s, PURCHASE_LEDGER p" in cursor.last_sql
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_find_reverse_charge_candidates_missing_column_raises():
|
|
65
|
+
incomplete_columns = dict(COLUMNS_BY_TABLE)
|
|
66
|
+
incomplete_columns["AUDIT_HEADER"] = ["ACCOUNT_REF"] # missing everything else
|
|
67
|
+
cursor = FakeCursor(incomplete_columns, SAMPLE_ROWS)
|
|
68
|
+
|
|
69
|
+
with pytest.raises(KeyError):
|
|
70
|
+
find_reverse_charge_candidates(cursor, date_from=date(2026, 3, 1), date_to=date(2026, 5, 31))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_compute_adjustment_basic():
|
|
74
|
+
lines = [
|
|
75
|
+
ReverseChargeLine("EURO01", "PI", "INV-100", date(2026, 4, 3), 1000.00, -880.00),
|
|
76
|
+
ReverseChargeLine("EURO01", "PI", "INV-101", date(2026, 4, 20), 500.00, -440.00),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
adjustment = compute_adjustment(lines, vat_rate=0.20)
|
|
80
|
+
|
|
81
|
+
assert adjustment["net_gbp"] == pytest.approx(1320.00)
|
|
82
|
+
assert adjustment["box_1"] == pytest.approx(264.00)
|
|
83
|
+
assert adjustment["box_4"] == pytest.approx(264.00)
|
|
84
|
+
assert adjustment["box_1"] == adjustment["box_4"]
|
|
85
|
+
assert adjustment["net_liability_effect"] == 0.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_compute_adjustment_empty_lines():
|
|
89
|
+
adjustment = compute_adjustment([], vat_rate=0.20)
|
|
90
|
+
|
|
91
|
+
assert adjustment["net_gbp"] == 0.0
|
|
92
|
+
assert adjustment["box_1"] == 0.0
|
|
93
|
+
assert adjustment["box_4"] == 0.0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_compute_adjustment_custom_rate():
|
|
97
|
+
lines = [ReverseChargeLine("A1", "PI", "INV-1", date(2026, 1, 1), 100.0, -100.0)]
|
|
98
|
+
adjustment = compute_adjustment(lines, vat_rate=0.05)
|
|
99
|
+
assert adjustment["box_1"] == pytest.approx(5.00)
|
|
Binary file
|