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.
Files changed (47) hide show
  1. package/PLAN.md +20 -3
  2. package/README.md +257 -85
  3. package/admin/components/TaxRates.js +351 -0
  4. package/admin/components/TaxRegistrations.js +20 -10
  5. package/admin/components/TaxReports.js +224 -0
  6. package/admin/components/TaxSettings.js +65 -30
  7. package/admin/components/TaxTry.js +2 -2
  8. package/admin/index.page.html +19 -3
  9. package/install.js +53 -12
  10. package/kempo-config.json +80 -16
  11. package/package.json +9 -6
  12. package/public/api/ledger/GET.js +18 -0
  13. package/public/api/ledger-export/GET.js +24 -0
  14. package/public/api/rate/[id]/DELETE.js +17 -0
  15. package/public/api/rate/[id]/PUT.js +18 -0
  16. package/public/api/rate-export/GET.js +23 -0
  17. package/public/api/rate-import/POST.js +25 -0
  18. package/public/api/rate-quickstart/POST.js +40 -0
  19. package/public/api/rates/GET.js +16 -0
  20. package/public/api/rates/POST.js +17 -0
  21. package/public/api/report-export/GET.js +36 -0
  22. package/public/api/reports/GET.js +26 -0
  23. package/public/api/settings/PUT.js +31 -8
  24. package/public/sdk.js +39 -0
  25. package/sdk.js +18 -4
  26. package/server/db/schema.js +144 -0
  27. package/server/providers/index.js +10 -5
  28. package/server/providers/rates.js +114 -0
  29. package/server/utils/config/settings.js +46 -17
  30. package/server/utils/ledger/csv.js +74 -0
  31. package/server/utils/ledger/entries.js +129 -0
  32. package/server/utils/ledger/exports.js +56 -0
  33. package/server/utils/ledger/money.js +21 -0
  34. package/server/utils/ledger/period.js +36 -0
  35. package/server/utils/ledger/quotes.js +69 -0
  36. package/server/utils/ledger/report.js +175 -0
  37. package/server/utils/ledger/store.js +150 -0
  38. package/server/utils/rates/csv.js +54 -0
  39. package/server/utils/rates/quickstart.js +100 -0
  40. package/server/utils/rates/store.js +128 -0
  41. package/server/utils/rates/validate.js +112 -0
  42. package/server/utils/tax/calculate.js +25 -8
  43. package/server/utils/tax/percent.js +98 -0
  44. package/server/utils/tax/rates.js +315 -0
  45. package/server/utils/tax/shape.js +101 -32
  46. package/server/utils/tax/transactions.js +164 -20
  47. 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: **v0.1 exists** (Stripe Tax lookup, see README). This file scopes what comes next. The
4
- > existing code is the reference for style; read `server/providers/index.js` and
5
- > `server/utils/tax/shape.js` before changing anything.
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 — without this site keeping a table of rates.
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
- [Stripe Tax](https://stripe.com/tax) is the service that ships. Rates, rules and taxability differ by
7
- place and by what is sold, and they change constantly, so none of that is stored here: every
8
- calculation is asked of Stripe, which keeps them current.
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 a
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: 'CA', postalCode: '94103' },
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 is registered here and so will actually charge it
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 and:
66
+ Install it from **Admin > Extensions**, then open **Tax** in the admin menu.
52
67
 
53
- 1. Save a Stripe **secret key**. If kempo-payments is installed and connected, this one is optional —
54
- its key is used when this extension has none of its own, and the screen says so.
55
- 2. Switch on Stripe Tax in the Stripe dashboard if it is not already (the screen tells you what is
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
- `SETTINGS_ENCRYPTION_KEY` must be set on the server, because the key is stored encrypted. Without it
60
- kempo silently cannot create the setting, so the install says so loudly.
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
- This is the thing most worth understanding before relying on it.
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
- Stripe will calculate a rate for any address. It only *collects* where your account is **registered**.
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
- So `quote.collecting` distinguishes "this is a 0% rate" from "you are not registered here". A checkout
71
- that shows a tax line should show it when `collecting` is true and `tax` is non-zero, and it is why
72
- the **Where tax is collected** tab leads with a plain-words banner stating which situation the site is
73
- in rather than an empty table.
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
- > **This extension looks tax up. It does not register you with, or file returns to, any tax
76
- > authority.** Whether you owe tax, where, and from when depends on where you are based, where your
77
- > customers are, how much you sell and what you sell. Talk to an accountant, and use Stripe's
78
- > registration and filing tools.
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; Stripe decides whether it is taxable and at what rate in each
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
- Anything else is a Stripe tax code (`txcd_…`), passed as `taxClass` directly. The **Settings** tab
95
- lists Stripe's full set. A line that names no class uses the **default tax category** from settings,
96
- or Stripe's own default from the dashboard if that is blank.
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** on
101
- for VAT-style pricing, where the sticker price is what the customer pays and the tax is inside it. A
102
- single line can override either way with `taxBehavior: 'inclusive' | 'exclusive'`.
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 *inside*
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
- ## Business customers
197
+ ## Recording, reversing and the ledger
108
198
 
109
- Pass `taxIds: [{ type: 'eu_vat', value: 'DE123456789' }]` and Stripe may treat the sale as B2B, which
110
- can mean reverse-charge and no tax. Stripe checks that an id is well-formed, **not that it is valid** —
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
- ## Recording and reversing
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
- A calculation is a quote. It is safe to ask as often as the page changes, and nothing is reported
116
- anywhere. It only *counts* — appears in Stripe's tax reports and advances a registration threshold —
117
- once you record it.
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
- ```js
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
- // on a refund — whole, or part of the tax-inclusive total:
124
- await reverseTransaction({ transactionId: sale.transactionId, reference: `${order.id}-refund-1` });
125
- await reverseTransaction({ transactionId: sale.transactionId, reference: `${order.id}-refund-2`, amount: 1000 });
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
- Both are idempotent on `reference`, so a webhook delivered twice records once. A calculation expires
129
- after 90 days; a sale completing later than that must be calculated again first.
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 })` | Make a finished sale reportable |
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
- | `TAX_CLASSES`, `resolveTaxCode`, `parseAddress` | The class names and validation, for callers that want to check first |
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 are
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'` — which kind of address that was |
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 (±31 days) |
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
- A line with an `amount` of `0` is kept in the answer, at zero tax, and is never sent. A cart of nothing
156
- but free items is answered without calling Stripe at all.
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 is
161
- deliberately no anonymous endpoint: each calculation is a call to Stripe, and an open one would let
162
- anyone spend them. A storefront shows its customers a tax figure by asking from its own server.
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
- | `POST /transactions/reverse` | `tax:record` |
169
- | `GET /status`, `GET /registrations`, `GET /tax-codes` | `tax:view` |
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
- Browser client: `import { calculateTax } from '/tax/sdk.js'`.
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 loud
178
- failure is fixed the same afternoon. Decide in the caller what to do with one — most checkouts should
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
- Only Stripe Tax ships, behind a small provider interface from the first commit
184
- (`server/providers/index.js` documents it). TaxJar, Avalara and a plain rate table for a seller with
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, and registering.** Stripe's dashboard and its filing partners.
361
+ - **Filing returns, registering, and paying.**
191
362
  - **An address book.** The caller owns addresses; this takes a plain object.
192
- - **Shipping rates.** A separate concern — this works out tax *on* a shipping charge, not what to charge.
193
- - **A rate table of its own.** Deliberately. See above.
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
- The tests use an in-memory database and never call Stripe. A symlinked `kempo` cannot see this
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`.