kempo-tax 0.1.0 → 0.2.1
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/PLAN.md +20 -3
- package/README.md +257 -85
- package/admin/components/TaxRates.js +351 -0
- package/admin/components/TaxRegistrations.js +20 -10
- package/admin/components/TaxReports.js +224 -0
- package/admin/components/TaxSettings.js +65 -30
- package/admin/components/TaxTry.js +2 -2
- package/admin/index.page.html +19 -3
- package/install.js +53 -12
- package/kempo-config.json +80 -16
- package/package.json +9 -6
- package/public/api/ledger/GET.js +18 -0
- package/public/api/ledger-export/GET.js +24 -0
- package/public/api/rate/[id]/DELETE.js +17 -0
- package/public/api/rate/[id]/PUT.js +18 -0
- package/public/api/rate-export/GET.js +23 -0
- package/public/api/rate-import/POST.js +25 -0
- package/public/api/rate-quickstart/POST.js +40 -0
- package/public/api/rates/GET.js +16 -0
- package/public/api/rates/POST.js +17 -0
- package/public/api/report-export/GET.js +36 -0
- package/public/api/reports/GET.js +26 -0
- package/public/api/settings/PUT.js +31 -8
- package/public/sdk.js +39 -0
- package/sdk.js +18 -4
- package/server/db/schema.js +144 -0
- package/server/providers/index.js +10 -5
- package/server/providers/rates.js +114 -0
- package/server/utils/config/settings.js +46 -17
- package/server/utils/ledger/csv.js +74 -0
- package/server/utils/ledger/entries.js +129 -0
- package/server/utils/ledger/exports.js +56 -0
- package/server/utils/ledger/money.js +21 -0
- package/server/utils/ledger/period.js +36 -0
- package/server/utils/ledger/quotes.js +69 -0
- package/server/utils/ledger/report.js +175 -0
- package/server/utils/ledger/store.js +150 -0
- package/server/utils/rates/csv.js +54 -0
- package/server/utils/rates/quickstart.js +100 -0
- package/server/utils/rates/store.js +128 -0
- package/server/utils/rates/validate.js +112 -0
- package/server/utils/tax/calculate.js +25 -8
- package/server/utils/tax/percent.js +98 -0
- package/server/utils/tax/rates.js +315 -0
- package/server/utils/tax/shape.js +101 -32
- package/server/utils/tax/transactions.js +164 -20
- package/uninstall.js +33 -0
package/PLAN.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# kempo-tax — PLAN: rate table provider, ledger and reports
|
|
2
2
|
|
|
3
|
-
> Status: **
|
|
4
|
-
>
|
|
5
|
-
>
|
|
3
|
+
> Status: **built (this plan is implemented).** The rate table provider, the ledger, reports, the
|
|
4
|
+
> home-state quick start, rate CSV import/export, the home-sale warning and the uninstall warning
|
|
5
|
+
> are all in. Two items remain and are listed under "Outstanding" at the end of this file. The text
|
|
6
|
+
> below is kept as the design record — read it before changing the behaviour it describes.
|
|
6
7
|
|
|
7
8
|
> **Upstream changes are allowed.** Every dependency with `kempo` in its name (kempo, kempo-ui,
|
|
8
9
|
> kempo-css, kempo-server, kempo-payments, kempo-media, and the rest) is owned by the same person, and
|
|
@@ -156,3 +157,19 @@ Follow `~/dev/kempo/AGENTS.md` and `~/dev/kempo-payments`. Specifically:
|
|
|
156
157
|
- Uninstall warns and export works.
|
|
157
158
|
- Home wizard and rate editor loaded and checked in a browser.
|
|
158
159
|
- README and CHANGELOG updated. Published as a **minor** version.
|
|
160
|
+
|
|
161
|
+
## Outstanding
|
|
162
|
+
|
|
163
|
+
Not done, and not possible from a development checkout:
|
|
164
|
+
|
|
165
|
+
- **Run the Stripe provider against a live test-mode account.** It has only been written against
|
|
166
|
+
the SDK's types and tested for request/response shaping. The manual procedure is in the README.
|
|
167
|
+
- **Verify every `txcd_` id behind the class names** (especially `saas` and `saas-business`)
|
|
168
|
+
against Stripe's own list at `/tax/api/tax-codes`. They were written from memory.
|
|
169
|
+
|
|
170
|
+
Deliberately left for later:
|
|
171
|
+
|
|
172
|
+
- Exemption certificates and resale handling.
|
|
173
|
+
- A third provider (TaxJar, Avalara).
|
|
174
|
+
- An origin-sourcing rule richer than "the seller's own state, from the seller's own address".
|
|
175
|
+
- Fiscal-year periods as a first-class choice (a custom range does the job today).
|
package/README.md
CHANGED
|
@@ -1,27 +1,39 @@
|
|
|
1
1
|
# kempo-tax
|
|
2
2
|
|
|
3
3
|
Sales tax, VAT and GST for the [kempo](https://github.com/dustinpoissant/kempo) CMS. Hand it an
|
|
4
|
-
address and some items, get back what tax to charge —
|
|
4
|
+
address and some items, get back what tax to charge — and keep a record of what you collected, in
|
|
5
|
+
the shape a return asks for.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
Two ways to get the rates, behind one interface:
|
|
8
|
+
|
|
9
|
+
- **Rate table** — rows you maintain: a state rate, a local option, which things are exempt. No
|
|
10
|
+
external service, no per-transaction fee, works with any payment processor. The right choice for
|
|
11
|
+
a seller in one place, or a few.
|
|
12
|
+
- **Stripe Tax** — [Stripe](https://stripe.com/tax) works out rates and rules for you, which is the
|
|
13
|
+
right choice once you sell into enough places that keeping a table is the problem.
|
|
14
|
+
|
|
15
|
+
Switching between them is a setting. Callers do not change.
|
|
9
16
|
|
|
10
17
|
This kempo (CMS) extension depends on no other kempo extension. It has no catalog, no cart and no
|
|
11
|
-
idea what a "product" is — the caller supplies the classification of each line, which is what lets
|
|
12
|
-
subscription, a donation or an invoice use it exactly as a store checkout does. It sits beside
|
|
18
|
+
idea what a "product" is — the caller supplies the classification of each line, which is what lets
|
|
19
|
+
a subscription, a donation or an invoice use it exactly as a store checkout does. It sits beside
|
|
13
20
|
[kempo-payments](https://github.com/dustinpoissant/kempo-payments), not on top of it: one works out
|
|
14
21
|
what is owed, the other moves the money, and either can be swapped without touching the other.
|
|
15
22
|
|
|
23
|
+
> **This extension works out tax and keeps a record. It does not register you with, or file returns
|
|
24
|
+
> to, any tax authority, and it does not pay anything to anyone.** Whether you owe tax, where, and
|
|
25
|
+
> from when depends on where you are based, where your customers are, how much you sell and what you
|
|
26
|
+
> sell. Talk to an accountant, or to your tax authority.
|
|
27
|
+
|
|
16
28
|
## What it is for
|
|
17
29
|
|
|
18
30
|
Other kempo (CMS) extensions.
|
|
19
31
|
|
|
20
32
|
```js
|
|
21
|
-
import { calculateTax, recordTransaction } from 'kempo-tax/sdk';
|
|
33
|
+
import { calculateTax, recordTransaction, reverseTransaction } from 'kempo-tax/sdk';
|
|
22
34
|
|
|
23
35
|
const [error, quote] = await calculateTax({
|
|
24
|
-
address: { country: 'US', state: '
|
|
36
|
+
address: { country: 'US', state: 'VT', postalCode: '05401', city: 'Burlington' },
|
|
25
37
|
lines: [
|
|
26
38
|
{ amount: 4999, reference: 'sku-1', taxClass: 'physical' },
|
|
27
39
|
{ amount: 1500, reference: 'sku-2', quantity: 3, taxClass: 'digital' },
|
|
@@ -31,12 +43,15 @@ const [error, quote] = await calculateTax({
|
|
|
31
43
|
|
|
32
44
|
// quote.tax what is owed
|
|
33
45
|
// quote.total what to charge — hand this to createPayment
|
|
34
|
-
// quote.collecting true when this site
|
|
46
|
+
// quote.collecting true when this site collects tax where the customer is
|
|
35
47
|
// quote.lines[] the tax on each line, matched back by your reference
|
|
36
|
-
// quote.breakdown[] per jurisdiction: name, rate, taxable amount, amount
|
|
48
|
+
// quote.breakdown[] per jurisdiction: name, level, rate, taxable amount, amount
|
|
37
49
|
|
|
38
50
|
// …and once the payment has *succeeded*, not before:
|
|
39
|
-
await recordTransaction({ calculationId: quote.calculationId, reference: order.id });
|
|
51
|
+
const [, sale] = await recordTransaction({ calculationId: quote.calculationId, reference: order.id });
|
|
52
|
+
|
|
53
|
+
// on a refund, whole or part:
|
|
54
|
+
await reverseTransaction({ transactionId: sale.transactionId, reference: `${order.id}-refund-1`, amount: 1000 });
|
|
40
55
|
```
|
|
41
56
|
|
|
42
57
|
All amounts are integers in the currency's smallest unit — `4999` is $49.99 — the same as
|
|
@@ -48,39 +63,114 @@ kempo-payments, so `quote.total` goes straight into `createPayment`.
|
|
|
48
63
|
npm install kempo-tax
|
|
49
64
|
```
|
|
50
65
|
|
|
51
|
-
Install it from **Admin > Extensions**, then open **Tax** in the admin menu
|
|
66
|
+
Install it from **Admin > Extensions**, then open **Tax** in the admin menu.
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
still missing).
|
|
57
|
-
3. Register in the places you are required to collect — also in the Stripe dashboard.
|
|
68
|
+
**For the rate table**, open the **Rates** tab and use the quick start: your country and state, the
|
|
69
|
+
rate, which classes are taxable and which exempt, whether shipping is taxed, and any local option
|
|
70
|
+
taxes. It writes ordinary rows you can then edit.
|
|
58
71
|
|
|
59
|
-
|
|
60
|
-
|
|
72
|
+
**For Stripe Tax**, save a Stripe secret key on the **Settings** tab (or install kempo-payments and
|
|
73
|
+
connect Stripe there, and this extension borrows its key), switch on Stripe Tax in the Stripe
|
|
74
|
+
dashboard, and register where you are required to collect. `SETTINGS_ENCRYPTION_KEY` must be set on
|
|
75
|
+
the server for a key to be stored.
|
|
61
76
|
|
|
62
77
|
## Connected is not the same as collecting
|
|
63
78
|
|
|
64
|
-
|
|
79
|
+
Both providers answer "what would the tax be" and "are we collecting it here" separately.
|
|
80
|
+
|
|
81
|
+
- **Rate table:** there is a rate row for that place and class, or there is not.
|
|
82
|
+
- **Stripe Tax:** Stripe calculates a rate for any address and only *collects* where your account
|
|
83
|
+
is registered. Everywhere else the tax is `0` with the reason `not_collecting`.
|
|
84
|
+
|
|
85
|
+
So `quote.collecting` distinguishes "this is a 0% rate" from "this site does not collect here". That
|
|
86
|
+
is not a fault: a small seller under every threshold owes nothing in most places. It matters because
|
|
87
|
+
a sale made where you *are* based, with no rate, is the one that makes a return short without
|
|
88
|
+
anything looking wrong — see [Sales at home](#sales-at-home-with-no-tax-collected).
|
|
89
|
+
|
|
90
|
+
## The rate table
|
|
91
|
+
|
|
92
|
+
### Rows are layers
|
|
93
|
+
|
|
94
|
+
A place is taxed in layers that stack — a country, a state, a county, a city, a district. Vermont's
|
|
95
|
+
6% state rate plus a 1% local option in some towns is **two rows**, and a customer in such a town
|
|
96
|
+
pays both.
|
|
97
|
+
|
|
98
|
+
For one line, the rows that apply are chosen like this:
|
|
99
|
+
|
|
100
|
+
1. Rows are filtered to those matching the destination (country, then state, town, postal pattern —
|
|
101
|
+
each blank meaning "any"), the line's class (or `*`), and the date.
|
|
102
|
+
2. Within one **level**, the most specific row wins: a postal-code row beats a town row beats a
|
|
103
|
+
state-wide row. At equal specificity a row for a named class beats a `*` row, then higher
|
|
104
|
+
`priority`, then the later `effectiveFrom`, then the newer row.
|
|
105
|
+
3. The winners, one per level, are the layers that apply, and their rates add.
|
|
106
|
+
|
|
107
|
+
A line with **no matching row at any level** is `not_collecting`. An **exempt** row (kind `exempt`)
|
|
108
|
+
is different: it says "sold here, no tax", and it is reported on a return as an exempt sale.
|
|
109
|
+
|
|
110
|
+
### A row
|
|
111
|
+
|
|
112
|
+
| Field | |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `country`, `state`, `locality`, `postalPattern` | Where. Blank means any. State uses the same code your checkout sends (`VT`). A postal pattern is a comma list of exact codes, prefixes (`054*`) and ranges (`05400-05499`). |
|
|
115
|
+
| `taxClass` | `physical`, `digital`, `saas`, `saas-business`, `nontaxable`, `shipping`, a `txcd_…` code, or `*` for every class |
|
|
116
|
+
| `kind` | `taxable` or `exempt` |
|
|
117
|
+
| `rate` | A percentage with up to four decimals: `6`, `7.25`, `6.625` |
|
|
118
|
+
| `level` | `country`, `state`, `county`, `city`, `district` or `special` — worked out from the place if left blank |
|
|
119
|
+
| `appliesToShipping` | Whether this row taxes shipping charges. Shipping is only taxed by rows that say so |
|
|
120
|
+
| `shippingProportional` | With `appliesToShipping`: tax shipping only in proportion to the taxable goods. See below |
|
|
121
|
+
| `effectiveFrom` / `effectiveTo` | `YYYY-MM-DD`. `effectiveTo` is exclusive |
|
|
122
|
+
| `jurisdiction`, `label` | The name a report uses, and the name a receipt may show |
|
|
123
|
+
| `priority`, `enabled`, `note` | |
|
|
124
|
+
|
|
125
|
+
### Shipping
|
|
126
|
+
|
|
127
|
+
A row with `appliesToShipping` taxes the shipping charge **in full**. Add `shippingProportional` and
|
|
128
|
+
it taxes only the share of shipping that matches the taxable goods in the order: with $49.99 of
|
|
129
|
+
taxable goods, $10.00 of exempt goods and $8.00 of shipping, five-sixths of the shipping is taxed.
|
|
130
|
+
Which one applies is your tax authority's rule, and some states work proportionally. Stripe does for
|
|
131
|
+
Vermont, which is why this exists: the two providers agree to the cent on the same cart.
|
|
132
|
+
|
|
133
|
+
Proportional applies to tax added on top. A tax-inclusive shipping charge is taxed in full, because
|
|
134
|
+
the tax already inside a price cannot be split by what else was bought.
|
|
135
|
+
|
|
136
|
+
### A rate is ended, not edited
|
|
137
|
+
|
|
138
|
+
When a rate changes, **end** the old row and add a new one. A sale quoted last month must still be
|
|
139
|
+
taxed at last month's rate, and a report has to be reproducible. Edit a row only to fix a mistake.
|
|
140
|
+
|
|
141
|
+
### Rates are not floats
|
|
142
|
+
|
|
143
|
+
A rate is held as an integer count of millionths of a percent (6.625% is `66250`), and tax is
|
|
144
|
+
computed in `BigInt` and rounded half-up. A tax return that does not reconcile by a cent is one
|
|
145
|
+
somebody has to explain.
|
|
65
146
|
|
|
66
|
-
|
|
67
|
-
Everywhere else the tax comes back as `0` with the reason `not_collecting` — which is correct and not
|
|
68
|
-
a fault: a small seller under every threshold owes nothing anywhere.
|
|
147
|
+
### Rounding
|
|
69
148
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
149
|
+
Per line by default: each line's tax is rounded, then they are added. `rounding: invoice` rounds
|
|
150
|
+
once per tax layer for the whole sale and spreads the result back over the lines (largest
|
|
151
|
+
remainder), so every line still shows its own share and they sum to the total. Tax-inclusive lines
|
|
152
|
+
are always rounded by themselves. Use whichever your tax authority's instructions describe.
|
|
74
153
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
154
|
+
### Sourcing
|
|
155
|
+
|
|
156
|
+
Destination sourcing — tax by where the goods go — is the default. For a state that taxes an
|
|
157
|
+
in-state sale by where the *seller* is, list it under `origin_sourcing_states` and set your home
|
|
158
|
+
city and postal code: a sale to your own state is then taxed by your address, and everything else
|
|
159
|
+
stays destination-based.
|
|
160
|
+
|
|
161
|
+
### Import and export
|
|
162
|
+
|
|
163
|
+
Export the table as CSV, edit it in a spreadsheet, import it back. An import **only adds** rows,
|
|
164
|
+
and if any row is wrong nothing is added and the error names the row. A misspelt column is an error,
|
|
165
|
+
not silently dropped.
|
|
166
|
+
|
|
167
|
+
> Vermont's local option taxes and what it exempts are not shipped in this package. Rates change,
|
|
168
|
+
> and stale ones are worse than none. Take them from the Vermont Department of Taxes' own published
|
|
169
|
+
> list and enter or import them.
|
|
79
170
|
|
|
80
171
|
## Tax classes
|
|
81
172
|
|
|
82
|
-
Say what *kind* of thing a line is
|
|
83
|
-
place.
|
|
173
|
+
Say what *kind* of thing a line is.
|
|
84
174
|
|
|
85
175
|
| Class | Meaning |
|
|
86
176
|
|---|---|
|
|
@@ -91,106 +181,188 @@ place.
|
|
|
91
181
|
| `nontaxable` | Never taxed |
|
|
92
182
|
| `shipping` | Shipping and delivery charges |
|
|
93
183
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
184
|
+
A rate-table site matches rows by class name. Stripe uses the category behind the name. A line that
|
|
185
|
+
names no class uses the **default tax class** setting — a class name or a Stripe category id — and
|
|
186
|
+
shipping always uses `shipping`.
|
|
97
187
|
|
|
98
188
|
## Prices that already include tax
|
|
99
189
|
|
|
100
|
-
Off by default, which is US-style pricing: tax is added on top. Turn **Prices already include tax**
|
|
101
|
-
for VAT-style pricing, where the sticker price is what the customer pays
|
|
102
|
-
|
|
190
|
+
Off by default, which is US-style pricing: tax is added on top. Turn **Prices already include tax**
|
|
191
|
+
on for VAT-style pricing, where the sticker price is what the customer pays. A single line can
|
|
192
|
+
override with `taxBehavior: 'inclusive' | 'exclusive'`.
|
|
103
193
|
|
|
104
|
-
In the result, `taxAmountExclusive` is tax to *add* and `taxAmountInclusive` is tax already
|
|
105
|
-
the amounts. `tax` is their sum and `total` is what is actually charged.
|
|
194
|
+
In the result, `taxAmountExclusive` is tax to *add* and `taxAmountInclusive` is tax already
|
|
195
|
+
*inside* the amounts. `tax` is their sum and `total` is what is actually charged.
|
|
106
196
|
|
|
107
|
-
##
|
|
197
|
+
## Recording, reversing and the ledger
|
|
108
198
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if you need it verified, do that before asking.
|
|
199
|
+
A calculation is a quote. It is safe to ask as often as the page changes, and nothing is reported
|
|
200
|
+
anywhere. It only *counts* once you record it.
|
|
112
201
|
|
|
113
|
-
|
|
202
|
+
Recording writes a row to this site's **ledger** — for every provider, so a return can be produced
|
|
203
|
+
without depending on any third party, and so history survives a change of provider. With Stripe it
|
|
204
|
+
also creates the Stripe transaction that appears in their reports.
|
|
114
205
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
206
|
+
- **The figures come from the stored quote, never from the caller.** You pass a `calculationId` and
|
|
207
|
+
your own `reference`; you cannot pass an amount.
|
|
208
|
+
- **Idempotent on `reference`.** Recording the same order twice — a webhook delivered twice, a retry
|
|
209
|
+
after a timeout — returns the first entry. This is also enforced by a unique index.
|
|
210
|
+
- **One calculation records one sale.** The same `calculationId` under a second reference is refused.
|
|
211
|
+
- **Reversals** take the sale's `transactionId`, a `reference` of their own, and optionally an
|
|
212
|
+
`amount` (of the tax-inclusive total). Omit it to reverse whatever is left. They are stored as
|
|
213
|
+
negative entries, always satisfy `sales + tax = −amount` exactly, and cannot exceed what is left.
|
|
214
|
+
- **Entries are immutable.** A mistake is corrected by a reversal and a new sale.
|
|
118
215
|
|
|
119
|
-
|
|
120
|
-
// when the payment has succeeded:
|
|
121
|
-
const [, sale] = await recordTransaction({ calculationId: quote.calculationId, reference: order.id });
|
|
216
|
+
A quote is kept for 90 days, then pruned — it holds a customer's address.
|
|
122
217
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
218
|
+
### Hooks
|
|
219
|
+
|
|
220
|
+
Fired in notification mode through kempo's `triggerHook`. Register handlers in your own
|
|
221
|
+
`kempo-config.json`.
|
|
222
|
+
|
|
223
|
+
| Hook | Data |
|
|
224
|
+
|---|---|
|
|
225
|
+
| `tax:recorded` | `{ entry }` — a sale was recorded (not on a repeat) |
|
|
226
|
+
| `tax:reversed` | `{ entry, original }` — a reversal was recorded |
|
|
227
|
+
| `tax:uncollected-home-sale` | `{ entry }` — see below |
|
|
127
228
|
|
|
128
|
-
|
|
129
|
-
|
|
229
|
+
## Reports
|
|
230
|
+
|
|
231
|
+
**Admin > Tax > Reports** builds totals for a quarter or any date range, by currency and by
|
|
232
|
+
jurisdiction, and exports them as CSV. The screen and the CSV are built from one report object, so
|
|
233
|
+
they cannot disagree.
|
|
234
|
+
|
|
235
|
+
| Figure | Meaning |
|
|
236
|
+
|---|---|
|
|
237
|
+
| Sales | Amounts, without tax |
|
|
238
|
+
| Taxable | Sales that tax was charged on |
|
|
239
|
+
| Exempt | Sold where you collect, carrying no tax — **still reported** |
|
|
240
|
+
| Not collected | Sold where you have no rate — appears on **no** return |
|
|
241
|
+
| Tax collected / reversed / net | Sales and refunds, net |
|
|
242
|
+
|
|
243
|
+
- **A refund counts in the period it happened in**, not the period of the original sale.
|
|
244
|
+
- **Sales in different currencies are never added together.**
|
|
245
|
+
- **It is a record, not a filing.** The screen says so.
|
|
246
|
+
- Quarters are calendar quarters in UTC. For a fiscal calendar use a custom range (`to` is
|
|
247
|
+
exclusive).
|
|
248
|
+
|
|
249
|
+
### Sales at home with no tax collected
|
|
250
|
+
|
|
251
|
+
If you set the country and state you are based in, then a sale **to there** that is recorded with no
|
|
252
|
+
tax collected fires `tax:uncollected-home-sale` and logs a warning. That is the case that makes a
|
|
253
|
+
return short silently: the rate was never entered, or the registration was never added. It cannot
|
|
254
|
+
catch a sale that was never recorded.
|
|
255
|
+
|
|
256
|
+
## Uninstalling
|
|
257
|
+
|
|
258
|
+
**The ledger is a legal record.** kempo drops this extension's tables on uninstall, and reinstalling
|
|
259
|
+
does not bring the rows back. Uninstall prints how many entries and rates are about to be lost and
|
|
260
|
+
points at the export. Export the ledger first: **Admin > Tax > Reports > Export the whole ledger**
|
|
261
|
+
(or `GET /tax/api/ledger-export`).
|
|
130
262
|
|
|
131
263
|
## SDK
|
|
132
264
|
|
|
133
265
|
| Function | Does |
|
|
134
266
|
|---|---|
|
|
135
267
|
| `calculateTax(request)` | A quote for an address and lines |
|
|
136
|
-
| `recordTransaction({ calculationId, reference })` |
|
|
268
|
+
| `recordTransaction({ calculationId, reference })` | Record a finished sale in the ledger |
|
|
137
269
|
| `reverseTransaction({ transactionId, reference, amount? })` | Reverse some or all of a recorded sale |
|
|
138
|
-
| `
|
|
270
|
+
| `listEntries`, `entriesInPeriod`, `buildReport`, `quarterRange` | Read the ledger and build reports |
|
|
271
|
+
| `listRates`, `createRate`, `updateRate`, `deleteRate`, `cleanRate` | Manage the rate table from code |
|
|
272
|
+
| `TAX_CLASSES`, `resolveTaxClass`, `parseAddress`, `parsePercent` | Validation, for callers that want to check first |
|
|
139
273
|
|
|
140
|
-
Everything returns `[error, data]` with `error` as `{ code, msg }`, like the rest of kempo. These
|
|
141
|
-
the data operations with no permission checks of their own — the routes enforce who may do what.
|
|
274
|
+
Everything returns `[error, data]` with `error` as `{ code, msg }`, like the rest of kempo. These
|
|
275
|
+
are the data operations with no permission checks of their own — the routes enforce who may do what.
|
|
142
276
|
|
|
143
277
|
### Request
|
|
144
278
|
|
|
145
279
|
| Field | Notes |
|
|
146
280
|
|---|---|
|
|
147
281
|
| `address` | `{ country (required, 2 letters), state, postalCode, city, line1 }` |
|
|
148
|
-
| `addressSource` | `'shipping'` (default) or `'billing'`
|
|
282
|
+
| `addressSource` | `'shipping'` (default) or `'billing'` |
|
|
149
283
|
| `lines[]` | `{ amount, reference?, quantity?, taxClass?, taxBehavior? }` — `amount` is the line's **total** |
|
|
150
284
|
| `shipping` | `{ amount, taxClass?, taxBehavior? }` |
|
|
151
285
|
| `currency` | Defaults to the setting |
|
|
152
|
-
| `taxIds` | `[{ type, value }]` |
|
|
153
|
-
| `taxDate` | Unix seconds — use rules in force on that day
|
|
286
|
+
| `taxIds` | `[{ type, value }]` — Stripe only; may make a sale B2B / reverse-charge |
|
|
287
|
+
| `taxDate` | Unix seconds — use the rules in force on that day |
|
|
288
|
+
|
|
289
|
+
A line with an `amount` of `0` is kept in the answer, at zero tax, and is never sent. A cart of
|
|
290
|
+
nothing but free items is answered without calling anything, and has no `calculationId` — there is
|
|
291
|
+
nothing to record.
|
|
154
292
|
|
|
155
|
-
|
|
156
|
-
|
|
293
|
+
### Result
|
|
294
|
+
|
|
295
|
+
`calculationId` (record from this), `tax`, `total`, `collecting`, `lines[]` (each with `tax`,
|
|
296
|
+
`netAmount`, `status`: `taxed` / `exempt` / `not_collecting` / `zero`), `shipping`, `breakdown[]`,
|
|
297
|
+
`currency`, `provider`, `expiresAt`.
|
|
157
298
|
|
|
158
299
|
## HTTP API
|
|
159
300
|
|
|
160
|
-
Served under `/tax/api/`, and **every route needs a signed-in user holding the permission**. There
|
|
161
|
-
deliberately no anonymous endpoint: each calculation is
|
|
162
|
-
anyone spend them
|
|
301
|
+
Served under `/tax/api/`, and **every route needs a signed-in user holding the permission**. There
|
|
302
|
+
is deliberately no anonymous endpoint: with Stripe each calculation is an API call and an open one
|
|
303
|
+
would let anyone spend them, and with either provider it would let anyone fill the quote table. A
|
|
304
|
+
storefront shows its customers a tax figure by asking from its own server through the SDK.
|
|
163
305
|
|
|
164
306
|
| Route | Permission |
|
|
165
307
|
|---|---|
|
|
166
308
|
| `POST /calculate` | `tax:calculate` |
|
|
167
|
-
| `POST /transactions/record` | `tax:record` |
|
|
168
|
-
| `
|
|
169
|
-
| `
|
|
309
|
+
| `POST /transactions/record`, `POST /transactions/reverse` | `tax:record` |
|
|
310
|
+
| `GET /rates`, `GET /rate-export`, `GET /status`, `GET /registrations`, `GET /tax-codes` | `tax:view` |
|
|
311
|
+
| `POST /rates`, `PUT/DELETE /rate/:id`, `POST /rate-import`, `POST /rate-quickstart` | `tax:rates` |
|
|
312
|
+
| `GET /ledger`, `GET /ledger-export`, `GET /reports`, `GET /report-export` | `tax:reports` |
|
|
170
313
|
| `GET /settings`, `PUT /settings` | `tax:settings` |
|
|
171
314
|
|
|
172
|
-
|
|
315
|
+
Groups: `kempo-tax:viewer`, `kempo-tax:bookkeeper` (what an order system or accountant acts as) and
|
|
316
|
+
`kempo-tax:administrator`. Browser client: `import { calculateTax } from '/tax/sdk.js'`.
|
|
173
317
|
|
|
174
318
|
## Failing loudly
|
|
175
319
|
|
|
176
320
|
Not connected, or Stripe unreachable, is an **error**, never a quiet zero. A checkout that silently
|
|
177
|
-
stops charging tax for a week because a key expired is a liability that grows with every order; a
|
|
178
|
-
failure is fixed the same afternoon. Decide in the caller what to do with one — most checkouts
|
|
179
|
-
refuse to complete.
|
|
321
|
+
stops charging tax for a week because a key expired is a liability that grows with every order; a
|
|
322
|
+
loud failure is fixed the same afternoon. Decide in the caller what to do with one — most checkouts
|
|
323
|
+
should refuse to complete.
|
|
180
324
|
|
|
181
325
|
## Providers
|
|
182
326
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
one jurisdiction are the intended extensibility targets — each a new file there, not a change to
|
|
327
|
+
`rates` and `stripe`, behind a small interface (`server/providers/index.js` documents it). TaxJar
|
|
328
|
+
and Avalara are the intended extensibility targets — each a new file there, not a change to
|
|
186
329
|
anything that ever called `calculateTax`.
|
|
187
330
|
|
|
331
|
+
### Stripe Tax, as it actually behaves
|
|
332
|
+
|
|
333
|
+
Verified against a live **test-mode** Stripe account on 2026-10-14: a calculation for a registered
|
|
334
|
+
state, an unregistered one, recording a sale and finding the transaction at Stripe under the same
|
|
335
|
+
reference, idempotent re-recording, a partial reversal and the remainder (each found at Stripe,
|
|
336
|
+
pointing at the sale), a refused over-reversal, and a report that nets to zero. The default test run
|
|
337
|
+
never calls Stripe; to repeat the check yourself, use a test-mode key from an account with Stripe Tax
|
|
338
|
+
switched on and a head office address, add a test registration, and run a calculation, a record and
|
|
339
|
+
a reversal through the API.
|
|
340
|
+
|
|
341
|
+
Things that only a live run showed, and that are worth knowing:
|
|
342
|
+
|
|
343
|
+
- **Stripe combines the layers of a place into one rate.** Vermont's 6% and a town's 1% arrive as a
|
|
344
|
+
single `7.0`, with no jurisdiction names, so a Stripe-backed report groups by state (`VT`, rate
|
|
345
|
+
`7`). The rate table is what gives a per-layer breakdown.
|
|
346
|
+
- **Stripe taxes shipping in proportion to the goods.** With $49.99 taxable and $10 exempt, only
|
|
347
|
+
that share of an $8 shipping charge is taxed. Give the rate table's shipping rows
|
|
348
|
+
`shippingProportional` and the two agree: a head-to-head of 21 carts across Burlington,
|
|
349
|
+
Montpelier and Rutland (goods, mixed carts, digital, SaaS, odd cents, shipping) matched Stripe to
|
|
350
|
+
the cent once the table held the same rows. Where they differed it was data — a local-option town
|
|
351
|
+
that was not in the table — not arithmetic.
|
|
352
|
+
- **Stripe returns a rate for any address and `not_collecting` outside your registrations**, which
|
|
353
|
+
is what `collecting` reports.
|
|
354
|
+
- **The six class names resolve to the right categories.** Each id was checked against Stripe's own
|
|
355
|
+
list of 677 codes: `txcd_99999999` General - Tangible Goods, `txcd_10000000` Electronically
|
|
356
|
+
Supplied Services, `txcd_10103000` / `txcd_10103001` SaaS personal / business use,
|
|
357
|
+
`txcd_00000000` Nontaxable, `txcd_92010001` Shipping.
|
|
358
|
+
|
|
188
359
|
## Not here
|
|
189
360
|
|
|
190
|
-
- **Filing returns,
|
|
361
|
+
- **Filing returns, registering, and paying.**
|
|
191
362
|
- **An address book.** The caller owns addresses; this takes a plain object.
|
|
192
|
-
- **Shipping rates.**
|
|
193
|
-
- **
|
|
363
|
+
- **Shipping rates.** This works out tax *on* a shipping charge, not what to charge.
|
|
364
|
+
- **Exemption certificates and resale handling.**
|
|
365
|
+
- **A bundled rate dataset.** Deliberately — see above.
|
|
194
366
|
|
|
195
367
|
## Development
|
|
196
368
|
|
|
@@ -200,6 +372,6 @@ npm run link:local # symlink sibling kempo checkouts; never commit a file: spe
|
|
|
200
372
|
npm test
|
|
201
373
|
```
|
|
202
374
|
|
|
203
|
-
|
|
204
|
-
package's test dependencies; to run them as CI does, install the registry copies instead:
|
|
375
|
+
Tests run against an in-memory Postgres (PGlite) and never call Stripe. A symlinked `kempo` cannot
|
|
376
|
+
see this package's test dependencies; to run them as CI does, install the registry copies instead:
|
|
205
377
|
`npm install --no-save --no-legacy-peer-deps --force kempo@latest kempo-ui@latest kempo-server@latest`.
|