gstwatch 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.
- gstwatch-0.1.0/LICENSE +21 -0
- gstwatch-0.1.0/PKG-INFO +70 -0
- gstwatch-0.1.0/README.md +55 -0
- gstwatch-0.1.0/pyproject.toml +24 -0
- gstwatch-0.1.0/setup.cfg +4 -0
- gstwatch-0.1.0/src/gstwatch/__init__.py +11 -0
- gstwatch-0.1.0/src/gstwatch/cli.py +127 -0
- gstwatch-0.1.0/src/gstwatch/evaluate.py +144 -0
- gstwatch-0.1.0/src/gstwatch.egg-info/PKG-INFO +70 -0
- gstwatch-0.1.0/src/gstwatch.egg-info/SOURCES.txt +13 -0
- gstwatch-0.1.0/src/gstwatch.egg-info/dependency_links.txt +1 -0
- gstwatch-0.1.0/src/gstwatch.egg-info/entry_points.txt +2 -0
- gstwatch-0.1.0/src/gstwatch.egg-info/top_level.txt +1 -0
- gstwatch-0.1.0/tests/test_cli.py +69 -0
- gstwatch-0.1.0/tests/test_evaluate.py +99 -0
gstwatch-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 calasdan-sketch
|
|
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.
|
gstwatch-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gstwatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GST/HST small-supplier companion: C$30k threshold tests, registration timing, filing frequency, 6-year record retention
|
|
5
|
+
Author: calasdan-sketch
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: gst,hst,cra,small-supplier,threshold,registration,canada,tax
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# gstwatch
|
|
17
|
+
|
|
18
|
+
**The C$30,000 GST/HST threshold has two tests, a 29-day registration clock, and a 6-year memory.** `gstwatch` applies Canada's published small-supplier rules to your quarterly revenue and tells you: are you still a small supplier, when registration takes effect, when you must register by, what filing frequency CRA will assign, and how long to keep records.
|
|
19
|
+
|
|
20
|
+
Rules verified against Canada.ca, October 2026. **Not tax advice** — it applies published rules to your numbers and shows its work.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install gstwatch
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Check the threshold
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
gstwatch check --quarter 2025-12-31=8000 --quarter 2026-03-31=8000 \
|
|
32
|
+
--quarter 2026-06-30=8000 --quarter 2026-09-30=8000
|
|
33
|
+
|
|
34
|
+
# or from a CSV: quarter_end,amount
|
|
35
|
+
gstwatch check quarters.csv
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Both CRA tests are run:
|
|
39
|
+
|
|
40
|
+
1. **Single quarter** — one calendar quarter alone over C$30,000: you must charge GST/HST on the supply that takes you over; effective registration is no later than that supply; **register within 29 days**. Pass `--crossing YYYY-MM-DD` if you know the exact supply date (otherwise the quarter end is used and the output says so).
|
|
41
|
+
2. **Four-or-fewer consecutive quarters** — total over C$30,000: small-supplier status ends at the **end of the month following** the quarter in which you crossed; collect from the first supply after that; register within 29 days of that effective date.
|
|
42
|
+
|
|
43
|
+
Exit codes: `0` still a small supplier, `3` registration required (distinct on purpose, so scripts can branch), `2` usage error. `--json` for automation.
|
|
44
|
+
|
|
45
|
+
## Filing frequency
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
gstwatch freq 1400000 # -> annual (assigned: annual <= C$1.5M)
|
|
49
|
+
gstwatch freq 7000000 # -> monthly (quarterly up to C$6M, monthly above)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Record retention
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
gstwatch records 2026-05-10 # -> keep records until 2032-12-31
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Six years from the **end of the year** the record relates to.
|
|
59
|
+
|
|
60
|
+
## What's modeled — and what isn't
|
|
61
|
+
|
|
62
|
+
Modeled: the two threshold tests, 29-day registration clock, effective-date rules (single vs rolling), assigned filing bands, 6-year retention.
|
|
63
|
+
|
|
64
|
+
**Not** modeled: the detailed supply inclusions/exclusions (exempt supplies, financial services, capital property, goodwill, associates' rules), voluntary-registration date elections and the one-year minimum, deregistration decisions, and filing obligations themselves. The threshold test includes worldwide taxable supplies (including zero-rated) per CRA's description — bring your accountant for the exclusions.
|
|
65
|
+
|
|
66
|
+
Sources: [when to register and start charging](https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/gst-hst-businesses/when-register-charge.html) · [assigned reporting periods](https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/gst-hst-businesses/gst-hst-account/change-account.html) · [records to keep](https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/gst-hst-businesses/calculate-prepare-report/gst-hst-records-keep.html)
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT-style: use it, ship it, no warranty.
|
gstwatch-0.1.0/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# gstwatch
|
|
2
|
+
|
|
3
|
+
**The C$30,000 GST/HST threshold has two tests, a 29-day registration clock, and a 6-year memory.** `gstwatch` applies Canada's published small-supplier rules to your quarterly revenue and tells you: are you still a small supplier, when registration takes effect, when you must register by, what filing frequency CRA will assign, and how long to keep records.
|
|
4
|
+
|
|
5
|
+
Rules verified against Canada.ca, October 2026. **Not tax advice** — it applies published rules to your numbers and shows its work.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install gstwatch
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Check the threshold
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gstwatch check --quarter 2025-12-31=8000 --quarter 2026-03-31=8000 \
|
|
17
|
+
--quarter 2026-06-30=8000 --quarter 2026-09-30=8000
|
|
18
|
+
|
|
19
|
+
# or from a CSV: quarter_end,amount
|
|
20
|
+
gstwatch check quarters.csv
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Both CRA tests are run:
|
|
24
|
+
|
|
25
|
+
1. **Single quarter** — one calendar quarter alone over C$30,000: you must charge GST/HST on the supply that takes you over; effective registration is no later than that supply; **register within 29 days**. Pass `--crossing YYYY-MM-DD` if you know the exact supply date (otherwise the quarter end is used and the output says so).
|
|
26
|
+
2. **Four-or-fewer consecutive quarters** — total over C$30,000: small-supplier status ends at the **end of the month following** the quarter in which you crossed; collect from the first supply after that; register within 29 days of that effective date.
|
|
27
|
+
|
|
28
|
+
Exit codes: `0` still a small supplier, `3` registration required (distinct on purpose, so scripts can branch), `2` usage error. `--json` for automation.
|
|
29
|
+
|
|
30
|
+
## Filing frequency
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gstwatch freq 1400000 # -> annual (assigned: annual <= C$1.5M)
|
|
34
|
+
gstwatch freq 7000000 # -> monthly (quarterly up to C$6M, monthly above)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Record retention
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
gstwatch records 2026-05-10 # -> keep records until 2032-12-31
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Six years from the **end of the year** the record relates to.
|
|
44
|
+
|
|
45
|
+
## What's modeled — and what isn't
|
|
46
|
+
|
|
47
|
+
Modeled: the two threshold tests, 29-day registration clock, effective-date rules (single vs rolling), assigned filing bands, 6-year retention.
|
|
48
|
+
|
|
49
|
+
**Not** modeled: the detailed supply inclusions/exclusions (exempt supplies, financial services, capital property, goodwill, associates' rules), voluntary-registration date elections and the one-year minimum, deregistration decisions, and filing obligations themselves. The threshold test includes worldwide taxable supplies (including zero-rated) per CRA's description — bring your accountant for the exclusions.
|
|
50
|
+
|
|
51
|
+
Sources: [when to register and start charging](https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/gst-hst-businesses/when-register-charge.html) · [assigned reporting periods](https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/gst-hst-businesses/gst-hst-account/change-account.html) · [records to keep](https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/gst-hst-businesses/calculate-prepare-report/gst-hst-records-keep.html)
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT-style: use it, ship it, no warranty.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gstwatch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "GST/HST small-supplier companion: C$30k threshold tests, registration timing, filing frequency, 6-year record retention"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "calasdan-sketch" }]
|
|
13
|
+
keywords = ["gst", "hst", "cra", "small-supplier", "threshold", "registration", "canada", "tax"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Topic :: Office/Business :: Financial",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
gstwatch = "gstwatch.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
gstwatch-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""gstwatch — GST/HST small-supplier threshold companion.
|
|
2
|
+
|
|
3
|
+
Rules verified against Canada.ca in October 2026 (see README). Not tax advice:
|
|
4
|
+
it applies the published rules to the numbers you give it and shows its work.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .evaluate import Status, evaluate, filing_frequency, retention_deadline, quarter_end
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = ["Status", "evaluate", "filing_frequency", "retention_deadline", "quarter_end",
|
|
11
|
+
"__version__"]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""gstwatch CLI: threshold check, filing frequency, record retention dates."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import csv
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .evaluate import Quarter, evaluate, filing_frequency, retention_deadline
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _date(text: str) -> dt.date:
|
|
16
|
+
try:
|
|
17
|
+
return dt.date.fromisoformat(text)
|
|
18
|
+
except ValueError:
|
|
19
|
+
raise argparse.ArgumentTypeError(f"expected YYYY-MM-DD, got {text!r}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_quarters(path: str) -> List[Quarter]:
|
|
23
|
+
"""CSV with columns quarter_end,amount (extra columns ignored)."""
|
|
24
|
+
out: List[Quarter] = []
|
|
25
|
+
try:
|
|
26
|
+
with open(path, newline="", encoding="utf-8-sig") as fh:
|
|
27
|
+
reader = csv.DictReader(fh)
|
|
28
|
+
fields = {(f or "").strip().lower() for f in (reader.fieldnames or [])}
|
|
29
|
+
if "quarter_end" not in fields or "amount" not in fields:
|
|
30
|
+
raise ValueError("CSV needs quarter_end and amount columns")
|
|
31
|
+
for row in reader:
|
|
32
|
+
norm = {(k or "").strip().lower(): (v or "").strip() for k, v in row.items()}
|
|
33
|
+
out.append((dt.date.fromisoformat(norm["quarter_end"]),
|
|
34
|
+
float(norm["amount"].replace(",", "").replace("$", ""))))
|
|
35
|
+
except FileNotFoundError:
|
|
36
|
+
raise SystemExit(f"gstwatch: not found: {path}")
|
|
37
|
+
if not out:
|
|
38
|
+
raise SystemExit(f"gstwatch: no quarters in {path}")
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv: list[str] | None = None) -> int:
|
|
43
|
+
ap = argparse.ArgumentParser(
|
|
44
|
+
prog="gstwatch",
|
|
45
|
+
description="GST/HST small-supplier threshold tests and dates (CRA rules, Oct 2026). "
|
|
46
|
+
"Not tax advice.",
|
|
47
|
+
)
|
|
48
|
+
ap.add_argument("--version", action="version", version=f"gstwatch {__version__}")
|
|
49
|
+
ap.add_argument("--json", action="store_true")
|
|
50
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
51
|
+
|
|
52
|
+
c = sub.add_parser("check", help="run the small-supplier tests over your quarters")
|
|
53
|
+
c.add_argument("csvfile", nargs="?", help="CSV: quarter_end,amount")
|
|
54
|
+
c.add_argument("--quarter", action="append", metavar="YYYY-MM-DD=AMOUNT",
|
|
55
|
+
help="inline quarter (repeatable; combined with csvfile if given)")
|
|
56
|
+
c.add_argument("--crossing", type=_date, help="exact date of the supply that crossed the threshold")
|
|
57
|
+
c.add_argument("--json", action="store_true", default=argparse.SUPPRESS,
|
|
58
|
+
help="machine-readable output (also accepted before the subcommand)")
|
|
59
|
+
|
|
60
|
+
f = sub.add_parser("freq", help="assigned filing frequency from annualized supplies")
|
|
61
|
+
f.add_argument("annualized", type=float, help="worldwide taxable supplies, annualized")
|
|
62
|
+
f.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
|
|
63
|
+
|
|
64
|
+
r = sub.add_parser("records", help="6-year record retention deadline for a transaction")
|
|
65
|
+
r.add_argument("transaction", type=_date)
|
|
66
|
+
r.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
|
|
67
|
+
|
|
68
|
+
args = ap.parse_args(argv)
|
|
69
|
+
|
|
70
|
+
if args.cmd == "freq":
|
|
71
|
+
band = filing_frequency(args.annualized)
|
|
72
|
+
if args.json:
|
|
73
|
+
print(json.dumps({"annualized": args.annualized, "filing_frequency": band}))
|
|
74
|
+
else:
|
|
75
|
+
print(f"C${args.annualized:,.0f} annualized -> {band} filing "
|
|
76
|
+
"(CRA assigned periods: annual <= 1.5M, quarterly <= 6M, monthly above)")
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
if args.cmd == "records":
|
|
80
|
+
deadline = retention_deadline(args.transaction)
|
|
81
|
+
if args.json:
|
|
82
|
+
print(json.dumps({"transaction": args.transaction.isoformat(),
|
|
83
|
+
"retain_until": deadline.isoformat()}))
|
|
84
|
+
else:
|
|
85
|
+
print(f"Transaction {args.transaction.isoformat()}: keep records until "
|
|
86
|
+
f"{deadline.isoformat()} (6 years from the end of the year it relates to)")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
quarters: List[Quarter] = []
|
|
90
|
+
if args.csvfile:
|
|
91
|
+
try:
|
|
92
|
+
quarters.extend(load_quarters(args.csvfile))
|
|
93
|
+
except ValueError as e:
|
|
94
|
+
print(f"gstwatch: {e}", file=sys.stderr)
|
|
95
|
+
return 2
|
|
96
|
+
for inline in args.quarter or []:
|
|
97
|
+
try:
|
|
98
|
+
d, amt = inline.split("=", 1)
|
|
99
|
+
quarters.append((dt.date.fromisoformat(d), float(amt.replace(",", ""))))
|
|
100
|
+
except (ValueError, AttributeError):
|
|
101
|
+
print(f"gstwatch: bad --quarter {inline!r}, expected YYYY-MM-DD=AMOUNT", file=sys.stderr)
|
|
102
|
+
return 2
|
|
103
|
+
if not quarters:
|
|
104
|
+
print("gstwatch: give a CSV file or at least one --quarter", file=sys.stderr)
|
|
105
|
+
return 2
|
|
106
|
+
|
|
107
|
+
status = evaluate(quarters, crossing_date=args.crossing)
|
|
108
|
+
if args.json:
|
|
109
|
+
print(json.dumps(status.as_dict(), indent=2))
|
|
110
|
+
return 0 if status.small_supplier else 3
|
|
111
|
+
|
|
112
|
+
print(f"Total across {len(quarters)} quarter(s): C${status.total:,.2f}")
|
|
113
|
+
print(f"Filing frequency (assigned): {status.filing_frequency}")
|
|
114
|
+
print(f"Small supplier: {'YES' if status.small_supplier else 'NO'} (trigger: {status.trigger})")
|
|
115
|
+
if status.effective_date:
|
|
116
|
+
print(f"Effective registration date: {status.effective_date.isoformat()}")
|
|
117
|
+
print(f"Register by: {status.register_by.isoformat()} "
|
|
118
|
+
"(29 days from the effective date)")
|
|
119
|
+
for note in status.notes:
|
|
120
|
+
print(f" - {note}")
|
|
121
|
+
print("\nPublished CRA rules applied to your numbers — not tax advice.")
|
|
122
|
+
# 3 = exceeded, useful for scripts that treat "needs registration" distinctly
|
|
123
|
+
return 0 if status.small_supplier else 3
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__": # pragma: no cover
|
|
127
|
+
sys.exit(main())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Small-supplier tests and the dates CRA's rules produce.
|
|
2
|
+
|
|
3
|
+
Verified October 2026 (canada.ca):
|
|
4
|
+
* C$30,000 threshold: exceeded in ONE calendar quarter, or across FOUR OR
|
|
5
|
+
FEWER consecutive calendar quarters (worldwide taxable supplies incl.
|
|
6
|
+
zero-rated supplies and associates', minus the listed exclusions).
|
|
7
|
+
* Single-quarter exceedance: charge GST/HST on the supply that takes you over;
|
|
8
|
+
effective registration no later than that supply; register within 29 days.
|
|
9
|
+
* Rolling exceedance: small-supplier status ends the END OF THE MONTH
|
|
10
|
+
FOLLOWING the quarter in which the threshold was exceeded; collect from the
|
|
11
|
+
first supply after that; register within 29 days of the effective date.
|
|
12
|
+
* Assigned filing periods: annual at <= C$1.5M; quarterly > C$1.5M to C$6M;
|
|
13
|
+
monthly > C$6M.
|
|
14
|
+
* Records: keep 6 years from the end of the year to which they relate.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import calendar
|
|
19
|
+
import datetime as dt
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import List, Optional, Sequence, Tuple
|
|
22
|
+
|
|
23
|
+
THRESHOLD = 30000
|
|
24
|
+
REGISTER_DAYS = 29
|
|
25
|
+
FILING_ANNUAL_MAX = 1_500_000
|
|
26
|
+
FILING_QUARTERLY_MAX = 6_000_000
|
|
27
|
+
|
|
28
|
+
Quarter = Tuple[dt.date, float] # (quarter end, taxable supplies)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def quarter_end(year: int, quarter: int) -> dt.date:
|
|
32
|
+
month = quarter * 3
|
|
33
|
+
return dt.date(year, month, calendar.monthrange(year, month)[1])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def next_month_end(d: dt.date) -> dt.date:
|
|
37
|
+
year, month = (d.year + 1, 1) if d.month == 12 else (d.year, d.month + 1)
|
|
38
|
+
return dt.date(year, month, calendar.monthrange(year, month)[1])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Status:
|
|
43
|
+
total: float
|
|
44
|
+
small_supplier: bool
|
|
45
|
+
trigger: str # none | single-quarter | rolling
|
|
46
|
+
crossing_quarter: Optional[dt.date] = None
|
|
47
|
+
window: List[dt.date] = field(default_factory=list)
|
|
48
|
+
window_total: float = 0.0
|
|
49
|
+
effective_date: Optional[dt.date] = None
|
|
50
|
+
register_by: Optional[dt.date] = None
|
|
51
|
+
filing_frequency: str = ""
|
|
52
|
+
notes: List[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
def as_dict(self):
|
|
55
|
+
return {
|
|
56
|
+
"total": self.total,
|
|
57
|
+
"small_supplier": self.small_supplier,
|
|
58
|
+
"trigger": self.trigger,
|
|
59
|
+
"crossing_quarter": self.crossing_quarter.isoformat() if self.crossing_quarter else None,
|
|
60
|
+
"window": [d.isoformat() for d in self.window],
|
|
61
|
+
"window_total": self.window_total,
|
|
62
|
+
"effective_date": self.effective_date.isoformat() if self.effective_date else None,
|
|
63
|
+
"register_by": self.register_by.isoformat() if self.register_by else None,
|
|
64
|
+
"filing_frequency": self.filing_frequency,
|
|
65
|
+
"notes": self.notes,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def evaluate(quarters: Sequence[Quarter],
|
|
70
|
+
threshold: float = THRESHOLD,
|
|
71
|
+
crossing_date: Optional[dt.date] = None) -> Status:
|
|
72
|
+
"""Apply both small-supplier tests to ordered (quarter_end, amount) pairs."""
|
|
73
|
+
qs = sorted(quarters, key=lambda q: q[0])
|
|
74
|
+
if not qs:
|
|
75
|
+
raise ValueError("no quarters supplied")
|
|
76
|
+
total = sum(amount for _, amount in qs)
|
|
77
|
+
annualized = total * 4 if len(qs) < 4 else total
|
|
78
|
+
freq = filing_frequency(annualized)
|
|
79
|
+
|
|
80
|
+
# Test 1: a single quarter over the threshold
|
|
81
|
+
for end, amount in qs:
|
|
82
|
+
if amount > threshold:
|
|
83
|
+
effective = crossing_date or end
|
|
84
|
+
return Status(
|
|
85
|
+
total=total, small_supplier=False, trigger="single-quarter",
|
|
86
|
+
crossing_quarter=end, window=[end], window_total=amount,
|
|
87
|
+
effective_date=effective,
|
|
88
|
+
register_by=effective + dt.timedelta(days=REGISTER_DAYS),
|
|
89
|
+
filing_frequency=freq,
|
|
90
|
+
notes=[
|
|
91
|
+
f"one quarter alone is C${amount:,.2f} > C${threshold:,.0f}",
|
|
92
|
+
"charge GST/HST on the supply that takes you over the threshold; "
|
|
93
|
+
f"effective registration is no later than that supply"
|
|
94
|
+
+ (f" (you supplied {crossing_date.isoformat()})" if crossing_date else
|
|
95
|
+
" — pass --crossing YYYY-MM-DD if you know the exact supply date; "
|
|
96
|
+
"using the quarter end as the best known date"),
|
|
97
|
+
f"register within {REGISTER_DAYS} days of the effective date",
|
|
98
|
+
],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Test 2: four-or-fewer consecutive quarters together over the threshold
|
|
102
|
+
best: Optional[Tuple[List[Quarter], float]] = None
|
|
103
|
+
for size in range(2, 5): # 2, 3, 4 consecutive quarters
|
|
104
|
+
for i in range(len(qs) - size + 1):
|
|
105
|
+
window = qs[i:i + size]
|
|
106
|
+
s = sum(a for _, a in window)
|
|
107
|
+
if s > threshold and (best is None or window[-1][0] < best[0][-1][0]):
|
|
108
|
+
best = (window, s)
|
|
109
|
+
if best:
|
|
110
|
+
window, s = best
|
|
111
|
+
crossing_end = window[-1][0]
|
|
112
|
+
effective = next_month_end(crossing_end)
|
|
113
|
+
return Status(
|
|
114
|
+
total=total, small_supplier=False, trigger="rolling",
|
|
115
|
+
crossing_quarter=crossing_end, window=[w[0] for w in window], window_total=s,
|
|
116
|
+
effective_date=effective,
|
|
117
|
+
register_by=effective + dt.timedelta(days=REGISTER_DAYS),
|
|
118
|
+
filing_frequency=freq,
|
|
119
|
+
notes=[
|
|
120
|
+
f"{len(window)} consecutive quarter(s) total C${s:,.2f} > C${threshold:,.0f}",
|
|
121
|
+
"small-supplier status ends at the end of the month following the quarter "
|
|
122
|
+
"in which the threshold was exceeded",
|
|
123
|
+
"start charging on the first supply after that date; "
|
|
124
|
+
f"register within {REGISTER_DAYS} days of the effective date",
|
|
125
|
+
],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return Status(total=total, small_supplier=True, trigger="none", filing_frequency=freq,
|
|
129
|
+
notes=[f"total C${total:,.2f} stays within C${threshold:,.0f} "
|
|
130
|
+
"for both tests (any single quarter, and 4-or-fewer consecutive quarters)"])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def filing_frequency(annualized: float) -> str:
|
|
134
|
+
"""CRA assigned reporting periods by worldwide taxable supplies."""
|
|
135
|
+
if annualized <= FILING_ANNUAL_MAX:
|
|
136
|
+
return "annual"
|
|
137
|
+
if annualized <= FILING_QUARTERLY_MAX:
|
|
138
|
+
return "quarterly"
|
|
139
|
+
return "monthly"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def retention_deadline(transaction_date: dt.date) -> dt.date:
|
|
143
|
+
"""Keep records 6 years from the END of the year they relate to."""
|
|
144
|
+
return dt.date(transaction_date.year + 6, 12, 31)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gstwatch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GST/HST small-supplier companion: C$30k threshold tests, registration timing, filing frequency, 6-year record retention
|
|
5
|
+
Author: calasdan-sketch
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: gst,hst,cra,small-supplier,threshold,registration,canada,tax
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# gstwatch
|
|
17
|
+
|
|
18
|
+
**The C$30,000 GST/HST threshold has two tests, a 29-day registration clock, and a 6-year memory.** `gstwatch` applies Canada's published small-supplier rules to your quarterly revenue and tells you: are you still a small supplier, when registration takes effect, when you must register by, what filing frequency CRA will assign, and how long to keep records.
|
|
19
|
+
|
|
20
|
+
Rules verified against Canada.ca, October 2026. **Not tax advice** — it applies published rules to your numbers and shows its work.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install gstwatch
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Check the threshold
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
gstwatch check --quarter 2025-12-31=8000 --quarter 2026-03-31=8000 \
|
|
32
|
+
--quarter 2026-06-30=8000 --quarter 2026-09-30=8000
|
|
33
|
+
|
|
34
|
+
# or from a CSV: quarter_end,amount
|
|
35
|
+
gstwatch check quarters.csv
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Both CRA tests are run:
|
|
39
|
+
|
|
40
|
+
1. **Single quarter** — one calendar quarter alone over C$30,000: you must charge GST/HST on the supply that takes you over; effective registration is no later than that supply; **register within 29 days**. Pass `--crossing YYYY-MM-DD` if you know the exact supply date (otherwise the quarter end is used and the output says so).
|
|
41
|
+
2. **Four-or-fewer consecutive quarters** — total over C$30,000: small-supplier status ends at the **end of the month following** the quarter in which you crossed; collect from the first supply after that; register within 29 days of that effective date.
|
|
42
|
+
|
|
43
|
+
Exit codes: `0` still a small supplier, `3` registration required (distinct on purpose, so scripts can branch), `2` usage error. `--json` for automation.
|
|
44
|
+
|
|
45
|
+
## Filing frequency
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
gstwatch freq 1400000 # -> annual (assigned: annual <= C$1.5M)
|
|
49
|
+
gstwatch freq 7000000 # -> monthly (quarterly up to C$6M, monthly above)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Record retention
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
gstwatch records 2026-05-10 # -> keep records until 2032-12-31
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Six years from the **end of the year** the record relates to.
|
|
59
|
+
|
|
60
|
+
## What's modeled — and what isn't
|
|
61
|
+
|
|
62
|
+
Modeled: the two threshold tests, 29-day registration clock, effective-date rules (single vs rolling), assigned filing bands, 6-year retention.
|
|
63
|
+
|
|
64
|
+
**Not** modeled: the detailed supply inclusions/exclusions (exempt supplies, financial services, capital property, goodwill, associates' rules), voluntary-registration date elections and the one-year minimum, deregistration decisions, and filing obligations themselves. The threshold test includes worldwide taxable supplies (including zero-rated) per CRA's description — bring your accountant for the exclusions.
|
|
65
|
+
|
|
66
|
+
Sources: [when to register and start charging](https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/gst-hst-businesses/when-register-charge.html) · [assigned reporting periods](https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/gst-hst-businesses/gst-hst-account/change-account.html) · [records to keep](https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/gst-hst-businesses/calculate-prepare-report/gst-hst-records-keep.html)
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT-style: use it, ship it, no warranty.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/gstwatch/__init__.py
|
|
5
|
+
src/gstwatch/cli.py
|
|
6
|
+
src/gstwatch/evaluate.py
|
|
7
|
+
src/gstwatch.egg-info/PKG-INFO
|
|
8
|
+
src/gstwatch.egg-info/SOURCES.txt
|
|
9
|
+
src/gstwatch.egg-info/dependency_links.txt
|
|
10
|
+
src/gstwatch.egg-info/entry_points.txt
|
|
11
|
+
src/gstwatch.egg-info/top_level.txt
|
|
12
|
+
tests/test_cli.py
|
|
13
|
+
tests/test_evaluate.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gstwatch
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""CLI tests."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
from gstwatch.cli import main
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def write_csv(tmp, rows):
|
|
8
|
+
p = tmp / "quarters.csv"
|
|
9
|
+
p.write_text("quarter_end,amount\n" + "\n".join(f"{d},{a}" for d, a in rows) + "\n",
|
|
10
|
+
encoding="utf-8")
|
|
11
|
+
return str(p)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_check_below_threshold(capsys):
|
|
15
|
+
assert main(["check", "--quarter", "2026-03-31=7000", "--quarter", "2026-06-30=7000"]) == 0
|
|
16
|
+
out = capsys.readouterr().out
|
|
17
|
+
assert "Small supplier: YES" in out and "not tax advice" in out
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_check_exceeded_returns_3(tmp_path, capsys):
|
|
21
|
+
csv = write_csv(tmp_path, [("2026-03-31", "31000")])
|
|
22
|
+
assert main(["check", csv]) == 3
|
|
23
|
+
out = capsys.readouterr().out
|
|
24
|
+
assert "Register by: 2026-04-29" in out
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_check_json(tmp_path, capsys):
|
|
28
|
+
csv = write_csv(tmp_path, [("2026-03-31", "8000"), ("2026-06-30", "8000"),
|
|
29
|
+
("2026-09-30", "8000"), ("2026-12-31", "8000")])
|
|
30
|
+
assert main(["check", csv, "--json"]) == 3
|
|
31
|
+
data = json.loads(capsys.readouterr().out)
|
|
32
|
+
assert data["trigger"] == "rolling" and data["effective_date"] == "2027-01-31"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_crossing_flag(tmp_path, capsys):
|
|
36
|
+
csv = write_csv(tmp_path, [("2026-03-31", "31000")])
|
|
37
|
+
assert main(["check", csv, "--crossing", "2026-02-15"]) == 3
|
|
38
|
+
assert "2026-02-15" in capsys.readouterr().out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_freq(capsys):
|
|
42
|
+
assert main(["freq", "1400000"]) == 0
|
|
43
|
+
assert "annual" in capsys.readouterr().out
|
|
44
|
+
assert main(["freq", "7000000"]) == 0
|
|
45
|
+
assert "monthly" in capsys.readouterr().out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_records(capsys):
|
|
49
|
+
assert main(["records", "2026-05-10"]) == 0
|
|
50
|
+
assert "2032-12-31" in capsys.readouterr().out
|
|
51
|
+
assert main(["records", "2026-05-10", ]) == 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_records_json(capsys):
|
|
55
|
+
assert main(["records", "2026-05-10"]) == 0
|
|
56
|
+
capsys.readouterr()
|
|
57
|
+
assert main(["--json", "records", "2026-05-10"]) == 0
|
|
58
|
+
assert json.loads(capsys.readouterr().out)["retain_until"] == "2032-12-31"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_no_input_is_usage_error(capsys):
|
|
62
|
+
assert main(["check"]) == 2
|
|
63
|
+
assert main(["check", "--quarter", "bogus"]) == 2
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_bad_csv_columns(tmp_path, capsys):
|
|
67
|
+
p = tmp_path / "bad.csv"
|
|
68
|
+
p.write_text("date,amount\n2026-03-31,100\n", encoding="utf-8")
|
|
69
|
+
assert main(["check", str(p)]) == 2
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Threshold evaluation tests — dates hand-computed from the verified rules."""
|
|
2
|
+
import datetime as dt
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from gstwatch.evaluate import (
|
|
7
|
+
evaluate,
|
|
8
|
+
filing_frequency,
|
|
9
|
+
next_month_end,
|
|
10
|
+
quarter_end,
|
|
11
|
+
retention_deadline,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
D = dt.date
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_quarter_ends():
|
|
18
|
+
assert quarter_end(2026, 1) == D(2026, 3, 31)
|
|
19
|
+
assert quarter_end(2026, 4) == D(2026, 12, 31)
|
|
20
|
+
assert quarter_end(2028, 1) == D(2028, 3, 31) # leap Feb
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_next_month_end():
|
|
24
|
+
assert next_month_end(D(2026, 9, 30)) == D(2026, 10, 31)
|
|
25
|
+
assert next_month_end(D(2026, 12, 31)) == D(2027, 1, 31)
|
|
26
|
+
assert next_month_end(D(2026, 2, 28)) == D(2026, 3, 31)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_below_threshold_is_small_supplier():
|
|
30
|
+
qs = [(D(2025, 12, 31), 7000), (D(2026, 3, 31), 7000),
|
|
31
|
+
(D(2026, 6, 30), 7000), (D(2026, 9, 30), 7000)]
|
|
32
|
+
s = evaluate(qs)
|
|
33
|
+
assert s.small_supplier and s.trigger == "none"
|
|
34
|
+
assert s.effective_date is None and s.filing_frequency == "annual"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_single_quarter_trigger_and_dates():
|
|
38
|
+
s = evaluate([(D(2026, 3, 31), 31000)])
|
|
39
|
+
assert not s.small_supplier and s.trigger == "single-quarter"
|
|
40
|
+
assert s.effective_date == D(2026, 3, 31)
|
|
41
|
+
assert s.register_by == D(2026, 4, 29) # +29 days
|
|
42
|
+
assert any("supply that takes you over" in n for n in s.notes)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_crossing_date_override():
|
|
46
|
+
s = evaluate([(D(2026, 3, 31), 31000)], crossing_date=D(2026, 2, 15))
|
|
47
|
+
assert s.effective_date == D(2026, 2, 15)
|
|
48
|
+
assert s.register_by == D(2026, 3, 16) # Feb 15 + 29
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_exact_threshold_not_exceeded():
|
|
52
|
+
# "exceed" means strictly more than C$30,000
|
|
53
|
+
assert evaluate([(D(2026, 3, 31), 30000)]).small_supplier
|
|
54
|
+
qs = [(D(2025, 12, 31), 10000), (D(2026, 3, 31), 10000),
|
|
55
|
+
(D(2026, 6, 30), 10000), (D(2026, 9, 30), 0)]
|
|
56
|
+
assert evaluate(qs).small_supplier # four quarters = 30,000 exactly
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_rolling_four_quarter_trigger():
|
|
60
|
+
qs = [(D(2025, 12, 31), 8000), (D(2026, 3, 31), 8000),
|
|
61
|
+
(D(2026, 6, 30), 8000), (D(2026, 9, 30), 8000)]
|
|
62
|
+
s = evaluate(qs)
|
|
63
|
+
assert not s.small_supplier and s.trigger == "rolling"
|
|
64
|
+
assert s.crossing_quarter == D(2026, 9, 30)
|
|
65
|
+
assert s.window_total == 32000
|
|
66
|
+
assert s.effective_date == D(2026, 10, 31) # end of month FOLLOWING the quarter
|
|
67
|
+
assert s.register_by == D(2026, 11, 29) # +29 days
|
|
68
|
+
assert any("end of the month following" in n for n in s.notes)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_rolling_window_of_two():
|
|
72
|
+
qs = [(D(2026, 3, 31), 16000), (D(2026, 6, 30), 16000)]
|
|
73
|
+
s = evaluate(qs)
|
|
74
|
+
assert s.trigger == "rolling" and len(s.window) == 2
|
|
75
|
+
assert s.effective_date == D(2026, 7, 31)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_filing_frequency_bands():
|
|
79
|
+
assert filing_frequency(0) == "annual"
|
|
80
|
+
assert filing_frequency(1_500_000) == "annual" # <= 1.5M
|
|
81
|
+
assert filing_frequency(1_500_001) == "quarterly"
|
|
82
|
+
assert filing_frequency(6_000_000) == "quarterly" # <= 6M
|
|
83
|
+
assert filing_frequency(6_000_001) == "monthly"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_retention_deadline():
|
|
87
|
+
assert retention_deadline(D(2026, 5, 10)) == D(2032, 12, 31) # 6 years from end of year
|
|
88
|
+
assert retention_deadline(D(2026, 12, 31)) == D(2032, 12, 31)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_empty_input_rejected():
|
|
92
|
+
with pytest.raises(ValueError):
|
|
93
|
+
evaluate([])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_as_dict_shape():
|
|
97
|
+
d = evaluate([(D(2026, 3, 31), 31000)]).as_dict()
|
|
98
|
+
assert d["trigger"] == "single-quarter" and d["register_by"] == "2026-04-29"
|
|
99
|
+
assert isinstance(d["notes"], list)
|