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 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ gstwatch = gstwatch.cli:main
@@ -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)