skilldrop-cli 1.8.0 → 1.10.0
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.
- package/README.md +2 -1
- package/catalogue.json +10 -1
- package/model-routing.json +12 -0
- package/package.json +1 -1
- package/packs/startup-legal/pack.json +66 -0
- package/packs/startup-legal/skills/cap-table/SKILL.md +131 -0
- package/packs/startup-legal/skills/cap-table/evals/eval_queries.json +30 -0
- package/packs/startup-legal/skills/cap-table/evals/evals.json +46 -0
- package/packs/startup-legal/skills/cap-table/examples/seed-to-series-a.md +81 -0
- package/packs/startup-legal/skills/cap-table/manifest.json +34 -0
- package/packs/startup-legal/skills/cap-table/scripts/cap_table.py +344 -0
- package/packs/startup-legal/skills/cap-table/templates/cap.json +23 -0
- package/packs/startup-legal/skills/contract-check/SKILL.md +118 -0
- package/packs/startup-legal/skills/contract-check/evals/eval_queries.json +30 -0
- package/packs/startup-legal/skills/contract-check/evals/evals.json +46 -0
- package/packs/startup-legal/skills/contract-check/evals/files/1/saas-agreement.md +68 -0
- package/packs/startup-legal/skills/contract-check/evals/files/2/consulting-agreement.md +64 -0
- package/packs/startup-legal/skills/contract-check/evals/files/3/reseller-agreement.md +69 -0
- package/packs/startup-legal/skills/contract-check/examples/inputs/saas-agreement.md +68 -0
- package/packs/startup-legal/skills/contract-check/examples/saas-agreement.md +72 -0
- package/packs/startup-legal/skills/contract-check/manifest.json +34 -0
- package/packs/startup-legal/skills/contract-check/reference.md +109 -0
- package/packs/startup-legal/skills/contract-check/scripts/contract_check.py +413 -0
- package/packs/startup-legal/skills/lease-check/SKILL.md +119 -0
- package/packs/startup-legal/skills/lease-check/evals/eval_queries.json +30 -0
- package/packs/startup-legal/skills/lease-check/evals/evals.json +45 -0
- package/packs/startup-legal/skills/lease-check/evals/files/1/heads-of-terms.md +15 -0
- package/packs/startup-legal/skills/lease-check/evals/files/2/heads-of-terms.md +32 -0
- package/packs/startup-legal/skills/lease-check/evals/files/3/heads-of-agreement.md +29 -0
- package/packs/startup-legal/skills/lease-check/examples/first-office.md +75 -0
- package/packs/startup-legal/skills/lease-check/examples/inputs/heads-of-terms.md +15 -0
- package/packs/startup-legal/skills/lease-check/manifest.json +33 -0
- package/packs/startup-legal/skills/lease-check/reference.md +70 -0
- package/packs/startup-legal/skills/lease-check/scripts/lease_terms.py +276 -0
- package/packs/startup-legal/skills/lease-check/templates/terms.json +15 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Heads of Agreement (non-binding) - Commercial Lease
|
|
2
|
+
|
|
3
|
+
**Premises:** Lot 7, 112 Wattle Street, Brunswick VIC 3056 - ground floor laboratory and office, approx. 410 m2
|
|
4
|
+
|
|
5
|
+
**Lessor:** Wattle Street Holdings Pty Ltd
|
|
6
|
+
|
|
7
|
+
**Lessee:** Quokka Bio Pty Ltd (ACN 600 000 000)
|
|
8
|
+
|
|
9
|
+
**Term:** 3 years commencing 1 September 2027 and expiring 31 August 2030
|
|
10
|
+
|
|
11
|
+
**Option to renew:** One further term of 3 years. The option must be exercised by written notice given not more than 6 months and not less than 3 months before the expiry date.
|
|
12
|
+
|
|
13
|
+
**Base rent:** A$96,000 per annum plus GST, payable monthly in advance.
|
|
14
|
+
|
|
15
|
+
**Rent-free (incentive):** 3 months' rent-free from the commencement date. Rent commencement date: 1 November 2027.
|
|
16
|
+
|
|
17
|
+
**Annual rent reviews:** On each anniversary of the commencement date (1 September 2028 and 1 September 2029) the base rent is reviewed by CPI (All Groups, Melbourne), subject to a minimum increase of 2% and a maximum increase of 5% per review.
|
|
18
|
+
|
|
19
|
+
**Review on renewal:** At the start of any option term the rent is reviewed to current market rent, provided the rent will not be less than the rent payable immediately before the review.
|
|
20
|
+
|
|
21
|
+
**Outgoings:** Lessee to pay 100% of the outgoings for the premises (rates, land tax, insurance, building services, common area cleaning and maintenance). Estimate for year 1: A$14,400 per annum plus GST. No cap on annual increases.
|
|
22
|
+
|
|
23
|
+
**Lessee break:** The lessee may end the lease on 31 August 2029 by giving not less than 6 months' written notice, provided that (a) the lessee is not in breach of any term of the lease at the date of the notice or at the break date, and (b) the lessee pays a break fee equal to 3 months' base rent at the rate payable at the break date.
|
|
24
|
+
|
|
25
|
+
**Make good:** At the end of the lease the lessee must remove all fit-out and laboratory services and return the premises to base-building condition, at its cost.
|
|
26
|
+
|
|
27
|
+
**Permitted use:** PC2 laboratory and ancillary office.
|
|
28
|
+
|
|
29
|
+
**Security:** Bank guarantee equal to 3 months' base rent plus GST.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Worked example: heads of terms for a first office
|
|
2
|
+
|
|
3
|
+
## Input
|
|
4
|
+
|
|
5
|
+
> *"We've been offered heads of terms for our first office
|
|
6
|
+
> ([inputs/heads-of-terms.md](inputs/heads-of-terms.md)). The agent wants an answer this week.
|
|
7
|
+
> What are we committing to, and when could we get out?"*
|
|
8
|
+
|
|
9
|
+
The terms extracted are [`../templates/terms.json`](../templates/terms.json).
|
|
10
|
+
|
|
11
|
+
## What it computed
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
# Lease: dates and money
|
|
15
|
+
|
|
16
|
+
Term: 2027-01-01 to 2031-12-31 (60 months).
|
|
17
|
+
|
|
18
|
+
## Deadlines
|
|
19
|
+
|
|
20
|
+
| Date | What | Detail |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| 2027-04-01 | rent starts | after 3 months rent-free |
|
|
23
|
+
| 2029-07-01 | tenant break notice | for the break on 2030-01-01; conditions: all rent paid; vacant possession |
|
|
24
|
+
| 2030-01-01 | rent review | cpi, upward only |
|
|
25
|
+
| 2031-06-30 | notice to leave at the end | for the end on 2031-12-31 |
|
|
26
|
+
|
|
27
|
+
## Committed cost
|
|
28
|
+
|
|
29
|
+
| Up to | Rent | Service charge | Total |
|
|
30
|
+
|---|---:|---:|---:|
|
|
31
|
+
| to the break on 2030-01-01 | £99,000 | £14,400 | £113,400 |
|
|
32
|
+
| to the end of the term (2031-12-31) | £179,990 | £24,000 | £203,990 |
|
|
33
|
+
|
|
34
|
+
## Rent by year
|
|
35
|
+
|
|
36
|
+
| Year | From | Yearly rent | Paid that year |
|
|
37
|
+
|---:|---|---:|---:|
|
|
38
|
+
| 1 | 2027-01-01 | £36,000 | £27,000 |
|
|
39
|
+
| 2 | 2028-01-01 | £36,000 | £36,000 |
|
|
40
|
+
| 3 | 2029-01-01 | £36,000 | £36,000 |
|
|
41
|
+
| 4 | 2030-01-01 | £40,495 | £40,495 |
|
|
42
|
+
| 5 | 2031-01-01 | £40,495 | £40,495 |
|
|
43
|
+
|
|
44
|
+
Deposit: £18,000.
|
|
45
|
+
|
|
46
|
+
WARN the break on 2030-01-01 is conditional (all rent paid; vacant possession): one unmet condition, such as a small unpaid sum, can make it fail
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Reply
|
|
50
|
+
|
|
51
|
+
**Signing commits Brightloop to £113,400 up to the break on 1 January 2030** (33 months' rent
|
|
52
|
+
after the rent-free, plus service charge), **and £203,990 if you stay to the end** on 31
|
|
53
|
+
December 2031. The break needs written notice by **1 July 2029**, and as drafted it fails if any
|
|
54
|
+
sum is unpaid or the unit isn't empty on the day, which would keep you in to 2031 for another
|
|
55
|
+
£90,590.
|
|
56
|
+
|
|
57
|
+
**Fixes before signing**
|
|
58
|
+
- 🟥 **Personal guarantee.** Ask for: *"No personal guarantee; the Tenant will provide a rent
|
|
59
|
+
deposit of six months' rent instead."* The deposit is already there.
|
|
60
|
+
- 🟥 **Break conditions.** Ask for: *"The only condition of the break is that the Tenant has paid
|
|
61
|
+
the principal rent due up to the break date."*
|
|
62
|
+
- 🟧 **Full repairing with no schedule of condition.** Ask for a photographic schedule of
|
|
63
|
+
condition, attached to the lease, and repair limited to that condition.
|
|
64
|
+
- 🟧 **Service charge with no cap.** Ask for a cap at £4,800 rising with CPI, and no charges for
|
|
65
|
+
improvements or replacing plant.
|
|
66
|
+
- 🟨 **No sharing.** Ask for the right to share with group companies without consent, and to
|
|
67
|
+
sublet with consent not to be unreasonably withheld.
|
|
68
|
+
|
|
69
|
+
**Deadlines to diarise**
|
|
70
|
+
- 1 April 2027: rent starts.
|
|
71
|
+
- 1 July 2029: last day to serve the break notice.
|
|
72
|
+
- 1 January 2030: rent review, to £40,495 a year at the capped 4 % (CPI below the cap would mean less).
|
|
73
|
+
- 30 June 2031: notice to leave at the end.
|
|
74
|
+
|
|
75
|
+
**Worth a lawyer's hour:** the personal guarantee, if the landlord won't drop it.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Heads of terms — Unit 4, Foundry Yard, Example Town
|
|
2
|
+
|
|
3
|
+
- Landlord: Foundry Yard Estates Ltd
|
|
4
|
+
- Tenant: Brightloop Labs Ltd
|
|
5
|
+
- Premises: Unit 4, first floor, 2,100 sq ft
|
|
6
|
+
- Term: 5 years from 1 January 2027
|
|
7
|
+
- Rent: £36,000 a year exclusive, payable quarterly in advance
|
|
8
|
+
- Rent-free: first 3 months
|
|
9
|
+
- Rent review: at the end of year 3, to CPI over the preceding three years, compounded, with a cap of 4% a year and a collar of 1% a year; upward only
|
|
10
|
+
- Service charge: estimated £4,800 a year, no cap
|
|
11
|
+
- Deposit: 6 months' rent
|
|
12
|
+
- Tenant break: end of year 3, on 6 months' prior written notice, conditional on all sums due under the lease having been paid and vacant possession being given
|
|
13
|
+
- Repair: full repairing; no schedule of condition
|
|
14
|
+
- Alienation: no subletting or sharing
|
|
15
|
+
- Personal guarantee: from one director for the first 3 years
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lease-check",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Check a commercial lease before a startup signs it for its first office, studio or lab — a script turns the lease's terms into every date and sum the tenant must plan around (rent year by year through stepped rents, fixed and index-linked reviews with caps and collars, rent-free months, what the company is committed to pay up to each break and to the end, and each break, renewal and notice deadline as a date), then flags terms that can't all be true and deadlines already passed — and reads the lease for the clauses that hurt a small tenant. Use when a founder says \"review this office lease\", \"what's our break clause notice date\", \"how much are we committing to on this lease\", \"check this lease before I sign\", \"when can we get out of this lease\", or \"what does the rent review do\".",
|
|
5
|
+
"entrypoint": "SKILL.md",
|
|
6
|
+
"deps": {
|
|
7
|
+
"npm": [],
|
|
8
|
+
"pip": []
|
|
9
|
+
},
|
|
10
|
+
"env": {
|
|
11
|
+
"required": [],
|
|
12
|
+
"optional": []
|
|
13
|
+
},
|
|
14
|
+
"related": [],
|
|
15
|
+
"tags": [
|
|
16
|
+
"lease",
|
|
17
|
+
"office",
|
|
18
|
+
"rent-review",
|
|
19
|
+
"break-clause",
|
|
20
|
+
"startup",
|
|
21
|
+
"property"
|
|
22
|
+
],
|
|
23
|
+
"permissions": {
|
|
24
|
+
"network": [],
|
|
25
|
+
"commands": [],
|
|
26
|
+
"files": "named-paths",
|
|
27
|
+
"notes": "Reads the terms.json it is given and writes nothing."
|
|
28
|
+
},
|
|
29
|
+
"model": {
|
|
30
|
+
"tier": "standard",
|
|
31
|
+
"rationale": "Extracting lease terms and reading clauses from the tenant's side is bounded; the script does the dates and sums."
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Lease clauses that hurt a small tenant
|
|
2
|
+
|
|
3
|
+
For each: what to look for, why it hurts, and wording to ask for. Adapt numbers to the deal.
|
|
4
|
+
|
|
5
|
+
## Break clause
|
|
6
|
+
|
|
7
|
+
- **Look for:** conditions ("all rent paid", "material compliance with covenants", "vacant
|
|
8
|
+
possession"); notice in a strict form or to a specific address; a penalty; rent-free clawed
|
|
9
|
+
back on breaking.
|
|
10
|
+
- **Why it hurts:** courts apply break conditions strictly. A few pounds of unpaid service charge
|
|
11
|
+
or one item left behind can void the break and keep the tenant in to the end.
|
|
12
|
+
- **Ask for:** *"The only condition of the break is that the Tenant has paid the principal rent
|
|
13
|
+
due up to the break date."* Delete "material compliance" and "vacant possession" conditions, or
|
|
14
|
+
narrow vacant possession to "free of the Tenant's occupation".
|
|
15
|
+
|
|
16
|
+
## Rent review
|
|
17
|
+
|
|
18
|
+
- **Look for:** open-market reviews (unknown increase); upward-only wording; index-linked reviews
|
|
19
|
+
that compound several years at once; no cap.
|
|
20
|
+
- **Ask for:** a fixed increase, or index-linked with a cap of 3–4 % a year (compounded) and no
|
|
21
|
+
collar; reviews no more often than every three years.
|
|
22
|
+
|
|
23
|
+
## Repair, dilapidations and reinstatement
|
|
24
|
+
|
|
25
|
+
- **Look for:** "full repairing" obligations, especially on an older building; reinstatement of
|
|
26
|
+
all alterations at the end; no schedule of condition.
|
|
27
|
+
- **Why it hurts:** the tenant can be billed at the end to put the building in better condition
|
|
28
|
+
than when it moved in.
|
|
29
|
+
- **Ask for:** *"The Tenant is not required to put the Premises in better repair than shown in
|
|
30
|
+
the attached Schedule of Condition."* Photograph and attach it. Reinstatement only of
|
|
31
|
+
alterations the tenant made, and only if the landlord asks six months before the end.
|
|
32
|
+
|
|
33
|
+
## Service charge
|
|
34
|
+
|
|
35
|
+
- **Look for:** no cap; capital improvements or replacement of plant charged to tenants;
|
|
36
|
+
management fees as a percentage with no limit.
|
|
37
|
+
- **Ask for:** a cap at a stated sum rising with CPI; no charges for improvements, replacing
|
|
38
|
+
structure or plant, or remedying defects in the original build.
|
|
39
|
+
|
|
40
|
+
## Alienation (sharing, subletting, assigning)
|
|
41
|
+
|
|
42
|
+
- **Look for:** an absolute ban on sharing or subletting; consent needed to share with a group
|
|
43
|
+
company.
|
|
44
|
+
- **Ask for:** the right to share with group companies without consent, and to sublet or assign
|
|
45
|
+
with the landlord's consent "not to be unreasonably withheld or delayed".
|
|
46
|
+
|
|
47
|
+
## Guarantees and deposits
|
|
48
|
+
|
|
49
|
+
- **Look for:** a personal guarantee from a founder; a deposit of more than six months' rent;
|
|
50
|
+
no terms for its return.
|
|
51
|
+
- **Ask for:** no personal guarantee; a deposit of three to six months' rent, held in a
|
|
52
|
+
separate account, returned within 28 days of the end, and released early after two years of
|
|
53
|
+
on-time payment.
|
|
54
|
+
|
|
55
|
+
## Use, alterations and insurance
|
|
56
|
+
|
|
57
|
+
- **Look for:** a narrow permitted use that blocks growth (no lab use, no events); consent needed
|
|
58
|
+
for any internal alteration; insurance excluding terrorism or uninsured risks.
|
|
59
|
+
- **Ask for:** use "as offices and any other use within the same planning class"; internal
|
|
60
|
+
non-structural alterations without consent; rent suspended if the premises can't be used
|
|
61
|
+
after damage, including by uninsured risks, with a right to terminate if not restored within
|
|
62
|
+
a year.
|
|
63
|
+
|
|
64
|
+
## When a lawyer earns the fee
|
|
65
|
+
|
|
66
|
+
- a personal guarantee;
|
|
67
|
+
- full repairing terms on a building older than about 20 years;
|
|
68
|
+
- a break with conditions the landlord won't narrow;
|
|
69
|
+
- a lease that excludes statutory renewal protection where the law gives it;
|
|
70
|
+
- a long term (over five years) with open-market reviews.
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Turn a commercial lease's terms into the dates and money a tenant has to plan around. Stdlib only.
|
|
3
|
+
|
|
4
|
+
Reads a terms.json (start, length, rent and how it changes, rent-free months, service charge,
|
|
5
|
+
deposit, break clauses, renewal option, notice periods) and prints:
|
|
6
|
+
- rent year by year through stepped rents, fixed and index-linked reviews (with any cap and
|
|
7
|
+
collar), rent-free months and upward-only reviews
|
|
8
|
+
- what the tenant is committed to pay up to each break date and to the end of the term,
|
|
9
|
+
rent and service charge together
|
|
10
|
+
- every deadline as a date: each break notice, the renewal option's window, notice to leave,
|
|
11
|
+
each rent review, the end of the rent-free period
|
|
12
|
+
- deadlines already passed on the given date, and terms that can't all be true (a break
|
|
13
|
+
after the end of the term, a notice longer than the time left, rent-free longer than the term)
|
|
14
|
+
An open-market review can't be priced in advance; it is shown as unknown from that date on.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
python3 lease_terms.py terms.json [--as-of 2027-03-01] [--json]
|
|
18
|
+
Exit codes: 0 computed, 1 a deadline passed or the terms contradict each other, 2 bad input.
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import calendar
|
|
22
|
+
import datetime as dt
|
|
23
|
+
import json
|
|
24
|
+
import math
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InputError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def day(v, what):
|
|
33
|
+
try:
|
|
34
|
+
return dt.date.fromisoformat(str(v))
|
|
35
|
+
except ValueError:
|
|
36
|
+
raise InputError(f"{what} must be a date as YYYY-MM-DD")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def add_months(d, n):
|
|
40
|
+
y, m = divmod(d.year * 12 + d.month - 1 + n, 12)
|
|
41
|
+
return dt.date(y, m + 1, min(d.day, calendar.monthrange(y, m + 1)[1]))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def num(v, what, lo=0.0):
|
|
45
|
+
if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) or v < lo:
|
|
46
|
+
raise InputError(f"{what} must be a number of at least {lo:g}")
|
|
47
|
+
return float(v)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def rate(v, what):
|
|
51
|
+
v = num(v, what, -100)
|
|
52
|
+
return v / 100 if abs(v) >= 1 else v
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def whole(v, what, lo=0):
|
|
56
|
+
if isinstance(v, bool) or not isinstance(v, int) or v < lo:
|
|
57
|
+
raise InputError(f"{what} must be a whole number of months")
|
|
58
|
+
return v
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load(d):
|
|
62
|
+
if not isinstance(d, dict):
|
|
63
|
+
raise InputError("terms.json must be an object")
|
|
64
|
+
start = day(d.get("start"), "start")
|
|
65
|
+
term = whole(d.get("term_months"), "term_months", 1)
|
|
66
|
+
rent = d.get("rent") or {}
|
|
67
|
+
if not isinstance(rent, dict) or "annual" not in rent:
|
|
68
|
+
raise InputError("`rent` needs `annual`, the starting yearly rent")
|
|
69
|
+
reviews = []
|
|
70
|
+
for i, r in enumerate(rent.get("reviews") or [], 1):
|
|
71
|
+
kind = str(r.get("type", "fixed")).lower()
|
|
72
|
+
if kind not in ("fixed", "index", "cpi", "rpi", "open market", "market"):
|
|
73
|
+
raise InputError(f"review {i}: type must be fixed, index (cpi/rpi) or open market")
|
|
74
|
+
rv = {"month": whole(r.get("month"), f"review {i}: month", 1), "type": kind,
|
|
75
|
+
"upward_only": bool(r.get("upward_only", True))}
|
|
76
|
+
if kind == "fixed":
|
|
77
|
+
rv["increase"] = rate(r.get("increase", 0), f"review {i}: increase")
|
|
78
|
+
elif kind in ("index", "cpi", "rpi"):
|
|
79
|
+
rv["assumed"] = rate(r.get("assumed", 0), f"review {i}: assumed index rise")
|
|
80
|
+
rv["cap"] = rate(r["cap"], f"review {i}: cap") if r.get("cap") is not None else None
|
|
81
|
+
rv["collar"] = rate(r["collar"], f"review {i}: collar") if r.get("collar") is not None else None
|
|
82
|
+
rv["compounded_years"] = whole(r.get("years", 1), f"review {i}: years", 1)
|
|
83
|
+
reviews.append(rv)
|
|
84
|
+
steps = [{"month": whole(s.get("from_month"), "stepped rent: from_month", 1),
|
|
85
|
+
"annual": num(s.get("annual"), "stepped rent: annual")} for s in rent.get("stepped") or []]
|
|
86
|
+
breaks = []
|
|
87
|
+
for i, b in enumerate(d.get("breaks") or [], 1):
|
|
88
|
+
bdate = day(b["date"], f"break {i}: date") if b.get("date") else None
|
|
89
|
+
if bdate is None and b.get("month") is None:
|
|
90
|
+
raise InputError(f"break {i}: give `month` (months from the start) or the lease's own `date`")
|
|
91
|
+
if bdate is not None:
|
|
92
|
+
months = (bdate.year - start.year) * 12 + bdate.month - start.month + (1 if bdate.day >= start.day - 1 else 0)
|
|
93
|
+
b = dict(b, month=b.get("month", max(1, months)))
|
|
94
|
+
breaks.append({"month": whole(b.get("month"), f"break {i}: month", 1), "date": bdate,
|
|
95
|
+
"notice": whole(b.get("notice_months"), f"break {i}: notice_months", 0),
|
|
96
|
+
"party": str(b.get("party", "tenant")),
|
|
97
|
+
"conditions": [str(c) for c in b.get("conditions") or []],
|
|
98
|
+
"penalty_months": num(b.get("penalty_months", 0), f"break {i}: penalty_months")})
|
|
99
|
+
sc = d.get("service_charge") or {}
|
|
100
|
+
dep = d.get("deposit") or {}
|
|
101
|
+
renewal = d.get("renewal_option")
|
|
102
|
+
return {
|
|
103
|
+
"currency": str(d.get("currency", "GBP")), "start": start, "term": term,
|
|
104
|
+
"annual": num(rent["annual"], "rent.annual"), "payable": str(rent.get("payable", "quarterly")).lower(),
|
|
105
|
+
"rent_free": whole(rent.get("rent_free_months", 0), "rent_free_months"),
|
|
106
|
+
"reviews": sorted(reviews, key=lambda r: r["month"]), "steps": sorted(steps, key=lambda s: s["month"]),
|
|
107
|
+
"service": num(sc.get("annual", 0), "service_charge.annual"),
|
|
108
|
+
"service_cap": num(sc["cap"], "service_charge.cap") if sc.get("cap") is not None else None,
|
|
109
|
+
"deposit": num(dep["amount"], "deposit.amount") if dep.get("amount") is not None else (
|
|
110
|
+
num(dep.get("months", 0), "deposit.months") / 12 * num(rent["annual"], "rent.annual")),
|
|
111
|
+
"breaks": sorted(breaks, key=lambda b: b["month"]),
|
|
112
|
+
"renewal": None if not renewal else {
|
|
113
|
+
"notice": whole(renewal.get("notice_months"), "renewal_option.notice_months", 0),
|
|
114
|
+
"earliest": whole(renewal.get("earliest_months_before_end", renewal.get("notice_months", 0)),
|
|
115
|
+
"renewal_option.earliest_months_before_end", 0)},
|
|
116
|
+
"leave_notice": whole(d["notice_to_leave_months"], "notice_to_leave_months")
|
|
117
|
+
if d.get("notice_to_leave_months") is not None else None,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def rent_by_month(t):
|
|
122
|
+
"""Yearly rate in force for each month, and None once an open-market review makes it unknown."""
|
|
123
|
+
rates = []
|
|
124
|
+
current = t["annual"]
|
|
125
|
+
steps = {s["month"]: s["annual"] for s in t["steps"]}
|
|
126
|
+
reviews = {r["month"]: r for r in t["reviews"]}
|
|
127
|
+
notes = []
|
|
128
|
+
for m in range(t["term"]):
|
|
129
|
+
if m in steps and current is not None:
|
|
130
|
+
current = steps[m]
|
|
131
|
+
if m in reviews and m > 0 and current is not None:
|
|
132
|
+
r = reviews[m]
|
|
133
|
+
if r["type"] == "fixed":
|
|
134
|
+
new = current * (1 + r["increase"])
|
|
135
|
+
elif r["type"] in ("index", "cpi", "rpi"):
|
|
136
|
+
rise = (1 + r["assumed"]) ** r["compounded_years"] - 1
|
|
137
|
+
lo = ((1 + r["collar"]) ** r["compounded_years"] - 1) if r["collar"] is not None else None
|
|
138
|
+
hi = ((1 + r["cap"]) ** r["compounded_years"] - 1) if r["cap"] is not None else None
|
|
139
|
+
if hi is not None:
|
|
140
|
+
rise = min(rise, hi)
|
|
141
|
+
if lo is not None:
|
|
142
|
+
rise = max(rise, lo)
|
|
143
|
+
new = current * (1 + rise)
|
|
144
|
+
else:
|
|
145
|
+
new = None
|
|
146
|
+
notes.append(f"open-market review at month {m}: rent unknown from then; budget for an increase")
|
|
147
|
+
if new is not None and r["upward_only"]:
|
|
148
|
+
new = max(new, current)
|
|
149
|
+
current = new
|
|
150
|
+
rates.append(current)
|
|
151
|
+
return rates, notes
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cost(t, rates, months):
|
|
155
|
+
"""Rent (after rent-free) and service charge payable in the first `months` months."""
|
|
156
|
+
rent = 0.0
|
|
157
|
+
unknown = False
|
|
158
|
+
for m in range(min(months, t["term"])):
|
|
159
|
+
if m < t["rent_free"]:
|
|
160
|
+
continue
|
|
161
|
+
if rates[m] is None:
|
|
162
|
+
unknown = True
|
|
163
|
+
continue
|
|
164
|
+
rent += rates[m] / 12
|
|
165
|
+
service = t["service"] * min(months, t["term"]) / 12
|
|
166
|
+
if t["service_cap"] is not None:
|
|
167
|
+
service = min(service, t["service_cap"] * min(months, t["term"]) / 12)
|
|
168
|
+
return rent, service, unknown
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def analyse(t, as_of):
|
|
172
|
+
rates, notes = rent_by_month(t)
|
|
173
|
+
end = add_months(t["start"], t["term"]) - dt.timedelta(days=1) # a 5-year lease from 1 Jan ends on 31 Dec
|
|
174
|
+
fails, warns, deadlines = [], [], []
|
|
175
|
+
if t["rent_free"] >= t["term"]:
|
|
176
|
+
fails.append("the rent-free period is as long as the term")
|
|
177
|
+
if t["rent_free"]:
|
|
178
|
+
deadlines.append(("rent starts", add_months(t["start"], t["rent_free"]), f"after {t['rent_free']} months rent-free"))
|
|
179
|
+
for r in t["reviews"]:
|
|
180
|
+
when = add_months(t["start"], r["month"])
|
|
181
|
+
if r["month"] >= t["term"]:
|
|
182
|
+
fails.append(f"a rent review at month {r['month']} falls after the lease ends")
|
|
183
|
+
deadlines.append(("rent review", when, r["type"] + (", upward only" if r["upward_only"] else "")))
|
|
184
|
+
commitments = []
|
|
185
|
+
for b in t["breaks"]:
|
|
186
|
+
bdate = b["date"] or add_months(t["start"], b["month"])
|
|
187
|
+
notice_by = add_months(bdate, -b["notice"])
|
|
188
|
+
if b["month"] >= t["term"]:
|
|
189
|
+
fails.append(f"a break at month {b['month']} ({bdate}) is after the lease ends ({end})")
|
|
190
|
+
continue
|
|
191
|
+
if notice_by < t["start"]:
|
|
192
|
+
fails.append(f"the break on {bdate} needs {b['notice']} months' notice, which runs back before the lease starts")
|
|
193
|
+
deadlines.append((f"{b['party']} break notice", notice_by, f"for the break on {bdate}"
|
|
194
|
+
+ (f"; conditions: {'; '.join(b['conditions'])}" if b["conditions"] else "")))
|
|
195
|
+
rent, service, unknown = cost(t, rates, b["month"])
|
|
196
|
+
penalty = (rates[b["month"] - 1] or 0) / 12 * b["penalty_months"] if b["penalty_months"] else 0
|
|
197
|
+
commitments.append((f"to the break on {bdate}", rent + penalty, service, unknown, penalty))
|
|
198
|
+
if b["party"].lower() in ("landlord", "mutual", "either"):
|
|
199
|
+
warns.append(f"the landlord can end the lease on {bdate}; plan for having to move then")
|
|
200
|
+
if b["conditions"]:
|
|
201
|
+
warns.append(f"the break on {bdate} is conditional ({'; '.join(b['conditions'])}): one unmet condition, "
|
|
202
|
+
"such as a small unpaid sum, can make it fail")
|
|
203
|
+
rent, service, unknown = cost(t, rates, t["term"])
|
|
204
|
+
commitments.append((f"to the end of the term ({end})", rent, service, unknown, 0))
|
|
205
|
+
if t["renewal"]:
|
|
206
|
+
latest = add_months(end, -t["renewal"]["notice"])
|
|
207
|
+
earliest = add_months(end, -t["renewal"]["earliest"])
|
|
208
|
+
deadlines.append(("renewal option", latest, f"window opens {earliest}" if earliest < latest else "exact date"))
|
|
209
|
+
if t["leave_notice"] is not None:
|
|
210
|
+
deadlines.append(("notice to leave at the end", add_months(end, -t["leave_notice"]), f"for the end on {end}"))
|
|
211
|
+
deadlines.sort(key=lambda d: d[1])
|
|
212
|
+
for what, when, _ in deadlines:
|
|
213
|
+
if as_of and when < as_of and what not in ("rent review", "rent starts"):
|
|
214
|
+
fails.append(f"the {what} deadline ({when}) has already passed")
|
|
215
|
+
years = []
|
|
216
|
+
for y in range(math.ceil(t["term"] / 12)):
|
|
217
|
+
ms = range(12 * y, min(12 * (y + 1), t["term"]))
|
|
218
|
+
known = [rates[m] for m in ms if rates[m] is not None]
|
|
219
|
+
paid = sum((rates[m] or 0) / 12 for m in ms if m >= t["rent_free"])
|
|
220
|
+
years.append({"year": y + 1, "from": add_months(t["start"], 12 * y),
|
|
221
|
+
"rate": known[-1] if len(known) == len(ms) else None, "rent_paid": paid,
|
|
222
|
+
"unknown": len(known) < len(ms)})
|
|
223
|
+
return {"end": end, "years": years, "commitments": commitments, "deadlines": deadlines,
|
|
224
|
+
"notes": notes, "fails": fails, "warns": warns, "deposit": t["deposit"]}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def money(v, cur):
|
|
228
|
+
sym = {"GBP": "£", "USD": "$", "EUR": "€", "INR": "₹", "AUD": "A$", "CAD": "C$"}.get(cur.upper(), "")
|
|
229
|
+
return f"{sym}{v:,.0f}" if sym else f"{v:,.0f} {cur}"
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def report(t, r):
|
|
233
|
+
cur = t["currency"]
|
|
234
|
+
L = ["# Lease: dates and money", "", f"Term: {t['start']} to {r['end']} ({t['term']} months).", "",
|
|
235
|
+
"## Deadlines", "", "| Date | What | Detail |", "|---|---|---|"]
|
|
236
|
+
for what, when, detail in r["deadlines"]:
|
|
237
|
+
L.append(f"| {when} | {what} | {detail} |")
|
|
238
|
+
L += ["", "## Committed cost", "", "| Up to | Rent | Service charge | Total |", "|---|---:|---:|---:|"]
|
|
239
|
+
for label, rent, service, unknown, penalty in r["commitments"]:
|
|
240
|
+
flag = " + unknown open-market rent" if unknown else ""
|
|
241
|
+
pen = f" (incl. {money(penalty, cur)} break penalty)" if penalty else ""
|
|
242
|
+
L.append(f"| {label} | {money(rent, cur)}{pen}{flag} | {money(service, cur)} | {money(rent + service, cur)}{flag} |")
|
|
243
|
+
L += ["", "## Rent by year", "", "| Year | From | Yearly rent | Paid that year |", "|---:|---|---:|---:|"]
|
|
244
|
+
for y in r["years"]:
|
|
245
|
+
rate_s = money(y["rate"], cur) if y["rate"] is not None else "unknown (open market)"
|
|
246
|
+
L.append(f"| {y['year']} | {y['from']} | {rate_s} | {money(y['rent_paid'], cur)}{' + unknown' if y['unknown'] else ''} |")
|
|
247
|
+
if r["deposit"]:
|
|
248
|
+
L += ["", f"Deposit: {money(r['deposit'], cur)}."]
|
|
249
|
+
L += [""] + [f"NOTE {n}" for n in r["notes"]]
|
|
250
|
+
L += [f"FAIL {f}" for f in r["fails"]] + [f"WARN {w}" for w in r["warns"]]
|
|
251
|
+
return "\n".join(L).rstrip() + "\n"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main(argv=None):
|
|
255
|
+
ap = argparse.ArgumentParser(description="A lease's dates and money.")
|
|
256
|
+
ap.add_argument("terms")
|
|
257
|
+
ap.add_argument("--as-of", help="YYYY-MM-DD; deadlines before it are reported as passed")
|
|
258
|
+
ap.add_argument("--json", action="store_true")
|
|
259
|
+
args = ap.parse_args(argv)
|
|
260
|
+
try:
|
|
261
|
+
with open(args.terms, encoding="utf-8-sig") as f:
|
|
262
|
+
t = load(json.load(f))
|
|
263
|
+
as_of = day(args.as_of, "--as-of") if args.as_of else None
|
|
264
|
+
except (OSError, ValueError, UnicodeDecodeError) as e:
|
|
265
|
+
print(f"FAIL cannot read input: {e}")
|
|
266
|
+
return 2
|
|
267
|
+
except InputError as e:
|
|
268
|
+
print(f"FAIL {e}")
|
|
269
|
+
return 2
|
|
270
|
+
r = analyse(t, as_of)
|
|
271
|
+
print(json.dumps(r, indent=2, default=str) if args.json else report(t, r), end="" if not args.json else "\n")
|
|
272
|
+
return 1 if r["fails"] else 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__":
|
|
276
|
+
sys.exit(main())
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"currency": "GBP",
|
|
3
|
+
"start": "2027-01-01",
|
|
4
|
+
"term_months": 60,
|
|
5
|
+
"rent": {
|
|
6
|
+
"annual": 36000,
|
|
7
|
+
"payable": "quarterly",
|
|
8
|
+
"rent_free_months": 3,
|
|
9
|
+
"reviews": [{"month": 36, "type": "cpi", "assumed": 0.04, "cap": 0.04, "collar": 0.01, "years": 3, "upward_only": true}]
|
|
10
|
+
},
|
|
11
|
+
"service_charge": {"annual": 4800},
|
|
12
|
+
"deposit": {"months": 6},
|
|
13
|
+
"breaks": [{"month": 36, "notice_months": 6, "party": "tenant", "conditions": ["all rent paid", "vacant possession"]}],
|
|
14
|
+
"notice_to_leave_months": 6
|
|
15
|
+
}
|