kempo-tax 0.2.0 → 0.2.2

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 CHANGED
@@ -117,10 +117,22 @@ is different: it says "sold here, no tax", and it is reported on a return as an
117
117
  | `rate` | A percentage with up to four decimals: `6`, `7.25`, `6.625` |
118
118
  | `level` | `country`, `state`, `county`, `city`, `district` or `special` — worked out from the place if left blank |
119
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 |
120
121
  | `effectiveFrom` / `effectiveTo` | `YYYY-MM-DD`. `effectiveTo` is exclusive |
121
122
  | `jurisdiction`, `label` | The name a report uses, and the name a receipt may show |
122
123
  | `priority`, `enabled`, `note` | |
123
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
+
124
136
  ### A rate is ended, not edited
125
137
 
126
138
  When a rate changes, **end** the old row and add a new one. A sale quoted last month must still be
@@ -316,22 +328,33 @@ should refuse to complete.
316
328
  and Avalara are the intended extensibility targets — each a new file there, not a change to
317
329
  anything that ever called `calculateTax`.
318
330
 
319
- ### Verifying the Stripe provider
320
-
321
- The Stripe module is written against the installed SDK's own type definitions and its request and
322
- response shaping is covered by tests, but **the default test run never calls Stripe**, and it has
323
- not been exercised against a live account in this repository. Before relying on it:
324
-
325
- 1. Use a **test-mode** key from an account with Stripe Tax switched on, and a head office address.
326
- 2. On **Settings**, save the key, set provider to Stripe, and open the **Where tax is collected**
327
- tab — it should report the account as active.
328
- 3. Add a test registration for a place in the dashboard, then **Try a calculation** for an address
329
- there. Expect `collecting: true` and a rate. For an unregistered place expect `collecting: false`.
330
- 4. Record the sale through the API and confirm the transaction appears under *Tax > Transactions* in
331
- the Stripe dashboard, then reverse it.
332
- 5. Open **Settings** and compare each class name against the category Stripe lists
333
- (`GET /tax/api/tax-codes`). The ids behind `saas` and `saas-business` in particular should be
334
- checked before selling under them.
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.
335
358
 
336
359
  ## Not here
337
360
 
@@ -22,7 +22,7 @@ const CLASSES = ['physical', 'digital', 'saas', 'saas-business'];
22
22
 
23
23
  const blank = () => ({
24
24
  country: 'US', state: '', locality: '', postalPattern: '', taxClass: 'physical', level: '', kind: 'taxable',
25
- rate: '', appliesToShipping: false, jurisdiction: '', label: '', priority: 0, effectiveFrom: '', effectiveTo: '', note: '',
25
+ rate: '', appliesToShipping: false, shippingProportional: false, jurisdiction: '', label: '', priority: 0, effectiveFrom: '', effectiveTo: '', note: '',
26
26
  });
27
27
 
28
28
  export default class TaxRates extends ShadowComponent {
@@ -43,7 +43,7 @@ export default class TaxRates extends ShadowComponent {
43
43
  this.rates = [];
44
44
  this.loaded = false;
45
45
  this.editing = null;
46
- this.quick = { country: 'US', state: '', stateRate: '', jurisdiction: '', taxable: ['physical'], exempt: ['nontaxable'], shippingTaxable: false, locals: '' };
46
+ this.quick = { country: 'US', state: '', stateRate: '', jurisdiction: '', taxable: ['physical'], exempt: ['nontaxable'], shipping: 'none', locals: '' };
47
47
  this.importing = false;
48
48
  this.csv = '';
49
49
  this.error = '';
@@ -109,7 +109,7 @@ export default class TaxRates extends ShadowComponent {
109
109
  };
110
110
 
111
111
  runQuickStart = async () => {
112
- const { country, state, stateRate, jurisdiction, taxable, exempt, shippingTaxable, locals } = this.quick;
112
+ const { country, state, stateRate, jurisdiction, taxable, exempt, shipping, locals } = this.quick;
113
113
 
114
114
  const localRates = locals.split('\n').map(line => line.trim()).filter(Boolean).map(line => {
115
115
  const [locality, rate] = line.split('=').map(part => part.trim());
@@ -118,7 +118,7 @@ export default class TaxRates extends ShadowComponent {
118
118
 
119
119
  this.busy = true;
120
120
  const [error, data] = await quickStart({
121
- country, state, stateRate, jurisdiction, taxableClasses: taxable, exemptClasses: exempt, shippingTaxable, localRates,
121
+ country, state, stateRate, jurisdiction, taxableClasses: taxable, exemptClasses: exempt, shipping, localRates,
122
122
  });
123
123
  this.busy = false;
124
124
 
@@ -207,6 +207,12 @@ export default class TaxRates extends ShadowComponent {
207
207
  <input type="checkbox" .checked=${!!rate.appliesToShipping} @change=${event => this.set('appliesToShipping', event.target.checked)} />
208
208
  This row applies to shipping charges
209
209
  </label>
210
+ ${rate.appliesToShipping ? html`
211
+ <label class="field">
212
+ <input type="checkbox" .checked=${!!rate.shippingProportional} @change=${event => this.set('shippingProportional', event.target.checked)} />
213
+ …and only in proportion to the taxable goods in the order
214
+ </label>
215
+ ` : ''}
210
216
  <button class="primary" ?disabled=${this.busy} @click=${this.save}>${this.busy ? 'Saving…' : 'Save'}</button>
211
217
  <button class="secondary" @click=${() => { this.editing = null; }}>Cancel</button>
212
218
  </div>
@@ -219,7 +225,7 @@ export default class TaxRates extends ShadowComponent {
219
225
  return html`
220
226
  <div class="card">
221
227
  <h2>Quick start</h2>
222
- <p class="small tc-muted">
228
+ <p class="tc-muted">
223
229
  Answer five questions and the matching rows are created for you — real rows, which you can then edit.
224
230
  <strong>Get the rate from your tax authority</strong>; this does not look it up or guess it.
225
231
  </p>
@@ -244,8 +250,16 @@ export default class TaxRates extends ShadowComponent {
244
250
  `)}
245
251
 
246
252
  <label class="field">
247
- <input type="checkbox" .checked=${quick.shippingTaxable} @change=${event => this.setQuick('shippingTaxable', event.target.checked)} />
248
- Shipping is taxed here
253
+ <span class="d-b">Is shipping taxed here?</span>
254
+ <span class="tc-muted d-b">
255
+ <em>In proportion</em> taxes shipping only for the share of the order that is taxable — a $60 order with $50 of
256
+ taxable goods taxes five-sixths of the shipping. Your tax authority's instructions say which applies.
257
+ </span>
258
+ <select .value=${quick.shipping} @change=${event => this.setQuick('shipping', event.target.value)}>
259
+ <option value="none" ?selected=${quick.shipping === 'none'}>No</option>
260
+ <option value="full" ?selected=${quick.shipping === 'full'}>Yes, in full</option>
261
+ <option value="proportional" ?selected=${quick.shipping === 'proportional'}>Yes, in proportion to the taxable goods</option>
262
+ </select>
249
263
  </label>
250
264
 
251
265
  <label class="field">
@@ -263,13 +277,13 @@ export default class TaxRates extends ShadowComponent {
263
277
 
264
278
  return html`
265
279
  ${this.error ? html`<p class="tc-danger">${this.error}</p>` : ''}
266
- ${this.notice ? html`<p class="tc-success small">${this.notice}</p>` : ''}
280
+ ${this.notice ? html`<p class="tc-success">${this.notice}</p>` : ''}
267
281
 
268
282
  ${this.editing ? this.#renderEditor() : ''}
269
283
  ${!this.rates.length && !this.editing ? this.#renderQuickStart() : ''}
270
284
 
271
285
  ${this.rates.length ? html`
272
- <p class="small tc-muted">
286
+ <p class="tc-muted">
273
287
  Rates stack by level: the most specific row at each level applies, and the levels add. To change a rate going
274
288
  forward, <strong>end</strong> the old row and add a new one — a sale quoted last month keeps last month's rate.
275
289
  </p>
@@ -285,12 +299,12 @@ export default class TaxRates extends ShadowComponent {
285
299
  <td>${rate.level}</td>
286
300
  <td>${rate.kind === 'exempt' ? html`<em>exempt</em>` : `${rate.rate}%`}</td>
287
301
  <td>${rate.appliesToShipping ? 'yes' : ''}</td>
288
- <td class="small">${rate.effectiveFrom || '—'} → ${rate.effectiveTo || 'now'}</td>
302
+ <td >${rate.effectiveFrom || '—'} → ${rate.effectiveTo || 'now'}</td>
289
303
  <td>${rate.jurisdiction}</td>
290
304
  <td>
291
- <button class="link small" @click=${() => { this.editing = { ...rate }; }}>Edit</button>
292
- ${rate.effectiveTo ? '' : html`<button class="link small" @click=${() => this.end(rate)}>End</button>`}
293
- <button class="link small tc-danger" @click=${() => this.remove(rate)}>Delete</button>
305
+ <button class="link" @click=${() => { this.editing = { ...rate }; }}>Edit</button>
306
+ ${rate.effectiveTo ? '' : html`<button class="link" @click=${() => this.end(rate)}>End</button>`}
307
+ <button class="link tc-danger" @click=${() => this.remove(rate)}>Delete</button>
294
308
  </td>
295
309
  </tr>
296
310
  `)}
@@ -306,7 +320,7 @@ export default class TaxRates extends ShadowComponent {
306
320
 
307
321
  ${this.importing ? html`
308
322
  <div class="card">
309
- <p class="small tc-muted">
323
+ <p class="tc-muted">
310
324
  Columns: country, state, locality, postalPattern, taxClass, level, kind, rate, appliesToShipping, taxType,
311
325
  jurisdiction, label, priority, enabled, effectiveFrom, effectiveTo, note. Only country and rate are required.
312
326
  It only adds rows, and if any row is wrong nothing is added.
@@ -318,7 +332,7 @@ export default class TaxRates extends ShadowComponent {
318
332
 
319
333
  ${this.rates.length && !this.editing ? html`
320
334
  <details>
321
- <summary class="small tc-muted">Run the quick start again</summary>
335
+ <summary class="tc-muted">Run the quick start again</summary>
322
336
  ${this.#renderQuickStart()}
323
337
  </details>
324
338
  ` : ''}
@@ -78,7 +78,7 @@ export default class TaxRegistrations extends ShadowComponent {
78
78
  return html`
79
79
  <div class="card banner tc-warning">
80
80
  <strong>Not connected.</strong>
81
- <p class="small">No tax can be looked up until a Stripe secret key is saved under Settings.</p>
81
+ <p >No tax can be looked up until a Stripe secret key is saved under Settings.</p>
82
82
  </div>
83
83
  `;
84
84
  }
@@ -87,7 +87,7 @@ export default class TaxRegistrations extends ShadowComponent {
87
87
  return html`
88
88
  <div class="card banner tc-danger">
89
89
  <strong>Stripe would not talk to this key.</strong>
90
- <p class="small">${this.accountError}</p>
90
+ <p >${this.accountError}</p>
91
91
  </div>
92
92
  `;
93
93
  }
@@ -96,7 +96,7 @@ export default class TaxRegistrations extends ShadowComponent {
96
96
  return html`
97
97
  <div class="card banner tc-warning">
98
98
  <strong>Stripe Tax is not switched on for this account yet.</strong>
99
- <p class="small">
99
+ <p >
100
100
  Open Tax in the Stripe dashboard and finish the setup${this.account?.missingFields?.length
101
101
  ? html` — it still needs: <span class="ff-mono">${this.account.missingFields.join(', ')}</span>`
102
102
  : ''}.
@@ -109,7 +109,7 @@ export default class TaxRegistrations extends ShadowComponent {
109
109
  return html`
110
110
  <div class="card banner tc-warning">
111
111
  <strong>The rate table is empty, so no tax is being collected anywhere.</strong>
112
- <p class="small">Use the quick start on the Rates tab to add the rate for the place you collect in.</p>
112
+ <p >Use the quick start on the Rates tab to add the rate for the place you collect in.</p>
113
113
  </div>
114
114
  `;
115
115
  }
@@ -118,7 +118,7 @@ export default class TaxRegistrations extends ShadowComponent {
118
118
  return html`
119
119
  <div class="card banner tc-warning">
120
120
  <strong>Connected, but registered nowhere — so no tax is being collected.</strong>
121
- <p class="small">
121
+ <p >
122
122
  That is correct until a sales threshold is crossed or an obligation begins. Calculations
123
123
  still return the rate that <em>would</em> apply, marked as not collecting. Add a
124
124
  registration in the Stripe dashboard when you need to charge tax somewhere.
@@ -128,7 +128,7 @@ export default class TaxRegistrations extends ShadowComponent {
128
128
  }
129
129
 
130
130
  return html`
131
- <div class="card banner ${this.status.livemode ? 'tc-danger' : 'tc-success'}">
131
+ <div class="card banner ${this.status.livemode && this.status.provider !== 'rates' ? 'tc-danger' : 'tc-success'}">
132
132
  <strong>
133
133
  ${this.status.livemode
134
134
  ? (this.status.provider === 'rates' ? 'Collecting from the rate table.' : 'Connected in live mode — tax is collected for real.')
@@ -164,7 +164,7 @@ export default class TaxRegistrations extends ShadowComponent {
164
164
  </table>
165
165
  ` : ''}
166
166
 
167
- <p class="small tc-muted">
167
+ <p class="tc-muted">
168
168
  ${this.status?.provider === 'rates'
169
169
  ? html`These are the places that have a rate in your rate table.`
170
170
  : html`Registrations are managed in the <a href="https://dashboard.stripe.com/tax/registrations" target="_blank" rel="noopener">Stripe dashboard</a>.`}
@@ -106,15 +106,15 @@ export default class TaxReports extends ShadowComponent {
106
106
  return html`
107
107
  <h2>${currency.toUpperCase()}</h2>
108
108
  <div class="cards">
109
- <div class="card"><span class="small tc-muted d-b">Tax collected</span><strong>${money(t.taxCollected)}</strong></div>
110
- <div class="card"><span class="small tc-muted d-b">Tax reversed (refunds)</span><strong>${money(t.taxReversed)}</strong></div>
111
- <div class="card"><span class="small tc-muted d-b">Net tax owed</span><strong>${money(t.taxNet)}</strong></div>
112
- <div class="card"><span class="small tc-muted d-b">Sales</span><strong>${money(t.sales)}</strong></div>
113
- <div class="card"><span class="small tc-muted d-b">Taxable sales</span><strong>${money(t.taxable)}</strong></div>
114
- <div class="card"><span class="small tc-muted d-b">Exempt sales</span><strong>${money(t.exempt)}</strong></div>
115
- <div class="card"><span class="small tc-muted d-b">Not collected</span><strong>${money(t.uncollected)}</strong></div>
109
+ <div class="card"><span class="tc-muted d-b">Tax collected</span><strong>${money(t.taxCollected)}</strong></div>
110
+ <div class="card"><span class="tc-muted d-b">Tax reversed (refunds)</span><strong>${money(t.taxReversed)}</strong></div>
111
+ <div class="card"><span class="tc-muted d-b">Net tax owed</span><strong>${money(t.taxNet)}</strong></div>
112
+ <div class="card"><span class="tc-muted d-b">Sales</span><strong>${money(t.sales)}</strong></div>
113
+ <div class="card"><span class="tc-muted d-b">Taxable sales</span><strong>${money(t.taxable)}</strong></div>
114
+ <div class="card"><span class="tc-muted d-b">Exempt sales</span><strong>${money(t.exempt)}</strong></div>
115
+ <div class="card"><span class="tc-muted d-b">Not collected</span><strong>${money(t.uncollected)}</strong></div>
116
116
  </div>
117
- <p class="small tc-muted">${t.salesCount} sale${t.salesCount === 1 ? '' : 's'}, ${t.reversalsCount} refund${t.reversalsCount === 1 ? '' : 's'}.
117
+ <p class="tc-muted">${t.salesCount} sale${t.salesCount === 1 ? '' : 's'}, ${t.reversalsCount} refund${t.reversalsCount === 1 ? '' : 's'}.
118
118
  <em>Not collected</em> is sold where this site has no rate — it appears on no return.</p>
119
119
 
120
120
  ${data.jurisdictions.length ? html`
@@ -123,7 +123,7 @@ export default class TaxReports extends ShadowComponent {
123
123
  <tbody>
124
124
  ${data.jurisdictions.map(row => html`
125
125
  <tr>
126
- <td>${row.jurisdiction}${row.state ? ` (${row.state})` : ''}</td>
126
+ <td>${row.jurisdiction}${row.state && row.state !== row.jurisdiction ? ` (${row.state})` : ''}</td>
127
127
  <td>${row.level}</td>
128
128
  <td>${row.rate ?? ''}%</td>
129
129
  <td>${money(row.taxableAmount)}</td>
@@ -166,12 +166,12 @@ export default class TaxReports extends ShadowComponent {
166
166
  ` : html`
167
167
  <input type="date" .value=${this.from} @input=${event => { this.from = event.target.value; }} />
168
168
  <input type="date" .value=${this.to} @input=${event => { this.to = event.target.value; }} />
169
- <span class="small tc-muted">to is exclusive</span>
169
+ <span class="tc-muted">to is exclusive</span>
170
170
  `}
171
171
  <button class="primary" ?disabled=${this.loading} @click=${this.run}>${this.loading ? 'Loading…' : 'Run report'}</button>
172
172
  </div>
173
173
 
174
- <p class="small tc-muted">
174
+ <p class="tc-muted">
175
175
  A record of what this site collected — <strong>not a tax return</strong>. File and pay on your tax authority's own
176
176
  portal. A refund counts in the period it happened in.
177
177
  </p>
@@ -183,15 +183,16 @@ export default class TaxReports extends ShadowComponent {
183
183
  ${currencies.map(([currency, data]) => this.#renderCurrency(currency, data))}
184
184
 
185
185
  ${this.entries.length ? html`
186
- <h2>Ledger <span class="small tc-muted">(${this.entries.length} of ${this.total})</span></h2>
186
+ <h2>Ledger <span class="tc-muted">(${this.entries.length} of ${this.total})</span></h2>
187
187
  <table>
188
- <thead><tr><th>When</th><th>Kind</th><th>Reference</th><th>Place</th><th>Sales</th><th>Tax</th></tr></thead>
188
+ <thead><tr><th>When</th><th>Kind</th><th>Reference</th><th>Provider</th><th>Place</th><th>Sales</th><th>Tax</th></tr></thead>
189
189
  <tbody>
190
190
  ${this.entries.map(entry => html`
191
191
  <tr>
192
- <td class="small">${new Date(entry.occurredAt).toLocaleString()}</td>
192
+ <td >${new Date(entry.occurredAt).toLocaleString()}</td>
193
193
  <td>${entry.kind}</td>
194
- <td class="ff-mono small">${entry.reference}</td>
194
+ <td class="ff-mono">${entry.reference}</td>
195
+ <td>${entry.provider}</td>
195
196
  <td>${[entry.country, entry.state].filter(Boolean).join(' ')}</td>
196
197
  <td>${this.#money(entry.salesAmount, entry.currency)}</td>
197
198
  <td>${this.#money(entry.taxAmount, entry.currency)}</td>
@@ -204,7 +205,7 @@ export default class TaxReports extends ShadowComponent {
204
205
  <a class="button secondary" href=${exportUrls.ledger(this.report ? { from: this.report.from || '', to: this.report.to || '' } : {})}>Export this period's ledger (CSV)</a>
205
206
  <a class="button secondary" href=${exportUrls.ledger({})}>Export the whole ledger</a>
206
207
  </p>
207
- <p class="small tc-muted">
208
+ <p class="tc-muted">
208
209
  The ledger is your legal record of tax collected. It is deleted if this extension is uninstalled —
209
210
  export it first.
210
211
  </p>
@@ -141,7 +141,7 @@ export default class TaxSettings extends ShadowComponent {
141
141
  return html`
142
142
  <div class="card banner tc-danger">
143
143
  <strong>SETTINGS_ENCRYPTION_KEY is not set on this server.</strong>
144
- <p class="small">
144
+ <p >
145
145
  The Stripe key is encrypted with it, so none can be stored until it exists. Generate one with
146
146
  <code class="ff-mono">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"</code>,
147
147
  put it in the site’s environment, and restart. Back it up — losing it makes every stored secret unrecoverable.
@@ -154,7 +154,7 @@ export default class TaxSettings extends ShadowComponent {
154
154
  return html`
155
155
  <div class="card banner tc-success">
156
156
  <strong>Using the rate table.</strong>
157
- <p class="small">No external service is involved. Tax comes from the rows on the Rates tab, so it is only as current as you keep them.</p>
157
+ <p >No external service is involved. Tax comes from the rows on the Rates tab, so it is only as current as you keep them.</p>
158
158
  </div>
159
159
  `;
160
160
  }
@@ -163,7 +163,7 @@ export default class TaxSettings extends ShadowComponent {
163
163
  return html`
164
164
  <div class="card banner tc-warning">
165
165
  <strong>Not connected.</strong>
166
- <p class="small">Save a Stripe secret key below, or install kempo-payments and connect Stripe there.</p>
166
+ <p >Save a Stripe secret key below, or install kempo-payments and connect Stripe there.</p>
167
167
  </div>
168
168
  `;
169
169
  }
@@ -173,7 +173,7 @@ export default class TaxSettings extends ShadowComponent {
173
173
  <strong>
174
174
  ${this.status.livemode ? 'Using a live key — calculations are real.' : 'Using a test key. No real tax is reported.'}
175
175
  </strong>
176
- <p class="small">
176
+ <p >
177
177
  ${this.status.keySource === 'payments'
178
178
  ? 'The key is borrowed from kempo-payments. Save one below to give tax its own.'
179
179
  : 'The key is stored on this screen.'}
@@ -189,8 +189,8 @@ export default class TaxSettings extends ShadowComponent {
189
189
 
190
190
  return html`
191
191
  <label class="field">
192
- <span class="d-b">${field.label} ${isSet ? html`<span class="small tc-success">· set</span>` : ''}</span>
193
- <span class="small tc-muted d-b">${field.help}</span>
192
+ <span class="d-b">${field.label} ${isSet ? html`<span class="tc-success">· set</span>` : ''}</span>
193
+ <span class="tc-muted d-b">${field.help}</span>
194
194
  <input
195
195
  type="password"
196
196
  autocomplete="off"
@@ -198,7 +198,7 @@ export default class TaxSettings extends ShadowComponent {
198
198
  .value=${typeof value === 'string' ? value : ''}
199
199
  @input=${event => this.edit(field.name, event.target.value)}
200
200
  />
201
- ${isSet ? html`<button class="link small" ?disabled=${this.saving} @click=${() => this.clear(field.name)}>Clear this key</button>` : ''}
201
+ ${isSet ? html`<button class="link" ?disabled=${this.saving} @click=${() => this.clear(field.name)}>Clear this key</button>` : ''}
202
202
  </label>
203
203
  `;
204
204
  });
@@ -211,11 +211,11 @@ export default class TaxSettings extends ShadowComponent {
211
211
  return html`
212
212
  ${this.#renderBanner()}
213
213
  ${this.error ? html`<p class="tc-danger">${this.error}</p>` : ''}
214
- ${this.notice ? html`<p class="tc-success small">${this.notice}</p>` : ''}
214
+ ${this.notice ? html`<p class="tc-success">${this.notice}</p>` : ''}
215
215
 
216
216
  <h2>Provider</h2>
217
217
  <label class="field">
218
- <span class="small tc-muted d-b">What works out the tax</span>
218
+ <span class="tc-muted d-b">What works out the tax</span>
219
219
  <select .value=${this.values.provider} @change=${event => this.set('provider', event.target.value)}>
220
220
  ${this.providers.map(entry => html`<option value=${entry.name} ?selected=${entry.name === this.values.provider}>${entry.label}</option>`)}
221
221
  </select>
@@ -224,7 +224,7 @@ export default class TaxSettings extends ShadowComponent {
224
224
  ${this.#renderCredentials()}
225
225
 
226
226
  <h2>Where you are based</h2>
227
- <p class="small tc-muted">
227
+ <p class="tc-muted">
228
228
  Used to warn you when a sale at home is recorded with no tax collected — the case that makes a return short without
229
229
  anything looking wrong. Use the same codes your checkout sends for the address (such as VT).
230
230
  </p>
@@ -238,7 +238,7 @@ export default class TaxSettings extends ShadowComponent {
238
238
  <h2>Defaults</h2>
239
239
  <label class="field">
240
240
  <span class="d-b">Currency</span>
241
- <span class="small tc-muted d-b">Used when whatever asks for a calculation does not name one</span>
241
+ <span class="tc-muted d-b">Used when whatever asks for a calculation does not name one</span>
242
242
  <input type="text" maxlength="3" .value=${this.values.currency} @input=${event => this.set('currency', event.target.value)} />
243
243
  </label>
244
244
 
@@ -247,7 +247,7 @@ export default class TaxSettings extends ShadowComponent {
247
247
  <input type="checkbox" .checked=${!!this.values.includesTax} @change=${event => this.set('includesTax', event.target.checked)} />
248
248
  Prices already include tax
249
249
  </span>
250
- <span class="small tc-muted d-b">
250
+ <span class="tc-muted d-b">
251
251
  Turn on for VAT-style pricing where the sticker price is what is paid. Leave off for US-style
252
252
  pricing where tax is added at checkout. A caller can still say otherwise per line.
253
253
  </span>
@@ -255,7 +255,7 @@ export default class TaxSettings extends ShadowComponent {
255
255
 
256
256
  <label class="field">
257
257
  <span class="d-b">Default tax class</span>
258
- <span class="small tc-muted d-b">
258
+ <span class="tc-muted d-b">
259
259
  For an item whose caller names none — a class name such as <span class="ff-mono">physical</span>, or a Stripe
260
260
  category id. Whether it is taxable, and at what rate, is decided per place. Shipping uses its own class.
261
261
  </span>
@@ -268,7 +268,7 @@ export default class TaxSettings extends ShadowComponent {
268
268
 
269
269
  <label class="field">
270
270
  <span class="d-b">Rounding</span>
271
- <span class="small tc-muted d-b">
271
+ <span class="tc-muted d-b">
272
272
  <em>Each line</em> rounds the tax on every line. <em>Whole invoice</em> rounds once per tax layer and spreads it back
273
273
  over the lines. Use whichever your tax authority's instructions describe (rate table only).
274
274
  </span>
@@ -88,7 +88,7 @@ export default class TaxTry extends ShadowComponent {
88
88
 
89
89
  render(){
90
90
  return html`
91
- <p class="small tc-muted">A quote only — nothing is recorded or reported.</p>
91
+ <p class="tc-muted">A quote only — nothing is recorded or reported.</p>
92
92
 
93
93
  <div class="grid">
94
94
  ${this.#field('Country', 'country', { placeholder: 'US' })}
@@ -128,7 +128,7 @@ export default class TaxTry extends ShadowComponent {
128
128
  <td>${entry.rate === null ? '—' : `${entry.rate}%`}</td>
129
129
  <td>${this.#money(entry.taxableAmount)}</td>
130
130
  <td>${this.#money(entry.amount)}</td>
131
- <td class="small">${entry.reason}</td>
131
+ <td >${entry.reason}</td>
132
132
  </tr>
133
133
  `)}
134
134
  </tbody>
package/install.js CHANGED
@@ -17,6 +17,15 @@ import db from 'kempo/server/db/index.js';
17
17
  the rate table never needs it, so this says what it affects instead of insisting.
18
18
  */
19
19
 
20
+ /*
21
+ Columns added since 0.2.0. `CREATE TABLE IF NOT EXISTS` leaves an existing table alone, so a site
22
+ that installed an earlier version needs these applied, and `IF NOT EXISTS` makes them safe to run
23
+ on every install and update.
24
+ */
25
+ export const COLUMNS = [
26
+ sql`ALTER TABLE "kempoTaxRate" ADD COLUMN IF NOT EXISTS "shippingProportional" boolean NOT NULL DEFAULT false`,
27
+ ];
28
+
20
29
  export const INDEXES = [
21
30
  sql`CREATE UNIQUE INDEX IF NOT EXISTS "kempoTaxEntry_reference_idx" ON "kempoTaxEntry" ("kind", "reference")`,
22
31
  sql`CREATE INDEX IF NOT EXISTS "kempoTaxEntry_occurred_idx" ON "kempoTaxEntry" ("occurredAt")`,
@@ -26,6 +35,14 @@ export const INDEXES = [
26
35
  ];
27
36
 
28
37
  export default async () => {
38
+ for(const statement of COLUMNS){
39
+ try {
40
+ await db.execute(statement);
41
+ } catch(error) {
42
+ console.warn(`[kempo-tax] Could not add a column: ${error.message}`);
43
+ }
44
+ }
45
+
29
46
  for(const statement of INDEXES){
30
47
  try {
31
48
  await db.execute(statement);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kempo-tax",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Sales tax, VAT and GST for kempo — look up what to charge for an address and items, with Stripe Tax built in",
5
5
  "license": "MIT",
6
6
  "author": "Dustin Poissant",
@@ -35,6 +35,13 @@ export const kempoTaxRate = pgTable('kempoTaxRate', {
35
35
  kind: text('kind').notNull().default('taxable'),
36
36
  rateMicro: integer('rateMicro').notNull().default(0),
37
37
  appliesToShipping: boolean('appliesToShipping').notNull().default(false),
38
+
39
+ /*
40
+ How shipping is taxed by a row that applies to it. Off, shipping is taxed in full. On, it is
41
+ taxed in proportion to the goods that are taxable: $50 of taxable goods and $10 of exempt ones
42
+ tax five-sixths of the shipping charge. Some states work this way, and Stripe does.
43
+ */
44
+ shippingProportional: boolean('shippingProportional').notNull().default(false),
38
45
  taxType: text('taxType').notNull().default('sales_tax'),
39
46
 
40
47
  /*
@@ -65,7 +65,7 @@ export const entryFromResult = (result, { address = {}, reference, occurredAt, q
65
65
  by largest remainder. So a reversal always satisfies `sales + tax = -amount` exactly, which is
66
66
  what lets "how much of this sale is left to reverse" be answered with plain subtraction.
67
67
  */
68
- export const reversalFromEntry = (original, { amount, reference, occurredAt, note }) => {
68
+ export const reversalFromEntry = (original, { amount, reference, occurredAt, note, providerRef }) => {
69
69
  const total = original.salesAmount + original.taxAmount;
70
70
  const whole = amount === undefined || amount === null;
71
71
 
@@ -106,7 +106,7 @@ export const reversalFromEntry = (original, { amount, reference, occurredAt, not
106
106
  reference,
107
107
  originalEntryId: original.id,
108
108
  provider: original.provider,
109
- providerRef: null,
109
+ providerRef: providerRef || null,
110
110
  quoteId: original.quoteId,
111
111
  livemode: original.livemode,
112
112
  currency: original.currency,
@@ -1,3 +1,5 @@
1
+ import { formatPercent, parsePercent } from '../tax/percent.js';
2
+
1
3
  /*
2
4
  The numbers a return asks for, built from ledger entries. Pure.
3
5
 
@@ -12,6 +14,29 @@
12
14
  Figures are grouped by currency first, because adding 100 JPY to 100 GBP produces 200 of nothing.
13
15
  */
14
16
 
17
+ /*
18
+ A ledger row is permanent, and the code that wrote it may be older than the code reading it. So a
19
+ jurisdiction is read defensively: an entry recorded before Stripe's breakdown was named by state
20
+ has no jurisdiction and a rate of "7.0", and it must land on the same report row as the "VT" / "7"
21
+ that the same sale would produce today, or one place appears twice.
22
+ */
23
+ /*
24
+ The entry itself records where the sale went, so a jurisdiction row with no place in it at all —
25
+ the earliest Stripe entries — still lands on the right one rather than on a blank.
26
+ */
27
+ const stateOf = (part, entry) => part.state || entry.state || '';
28
+
29
+ const countryOf = (part, entry) => part.country || entry.country || '';
30
+
31
+ const placeOf = (part, entry) => part.jurisdiction || stateOf(part, entry) || countryOf(part, entry) || '—';
32
+
33
+ const levelOf = (part, entry) => part.level || (stateOf(part, entry) ? 'state' : countryOf(part, entry) ? 'country' : '');
34
+
35
+ const rateOf = part => {
36
+ const micro = parsePercent(String(part.rate ?? ''));
37
+ return micro === null ? (part.rate ?? '') : formatPercent(micro);
38
+ };
39
+
15
40
  const empty = () => ({
16
41
  sales: 0,
17
42
  taxable: 0,
@@ -63,14 +88,19 @@ export const buildReport = (entries, { from = null, to = null } = {}) => {
63
88
  apply(bucket.months.get(month));
64
89
 
65
90
  for(const part of entry.jurisdictions || []){
66
- const key = [part.country, part.state, part.level, part.jurisdiction, part.rate].join('|');
91
+ const jurisdiction = placeOf(part, entry);
92
+ const level = levelOf(part, entry);
93
+ const rate = rateOf(part);
94
+ const country = countryOf(part, entry);
95
+ const state = stateOf(part, entry);
96
+ const key = [country, state, level, jurisdiction, rate].join('|');
67
97
  if(!bucket.jurisdictions.has(key)){
68
98
  bucket.jurisdictions.set(key, {
69
- country: part.country,
70
- state: part.state,
71
- level: part.level,
72
- jurisdiction: part.jurisdiction,
73
- rate: part.rate,
99
+ country,
100
+ state,
101
+ level,
102
+ jurisdiction,
103
+ rate,
74
104
  taxableAmount: 0,
75
105
  taxAmount: 0,
76
106
  });
@@ -15,6 +15,7 @@ export const RATE_COLUMNS = [
15
15
  { key: 'kind' },
16
16
  { key: 'rate' },
17
17
  { key: 'appliesToShipping' },
18
+ { key: 'shippingProportional' },
18
19
  { key: 'taxType' },
19
20
  { key: 'jurisdiction' },
20
21
  { key: 'label' },
@@ -65,8 +65,18 @@ export const quickstartRows = input => {
65
65
 
66
66
  for(const taxClass of exempt) add({ taxClass, kind: 'exempt' });
67
67
 
68
- if(input.shippingTaxable){
69
- add({ taxClass: 'shipping', kind: 'taxable', rate: input.stateRate, appliesToShipping: true }, 'shipping');
68
+ /*
69
+ `shippingTaxable` is the older boolean. `shipping` is one of none, full, proportional: whether
70
+ delivery charges are taxed at all, in full, or only in proportion to the taxable goods.
71
+ */
72
+ const shipping = input.shipping || (input.shippingTaxable ? 'full' : 'none');
73
+ if(!['none', 'full', 'proportional'].includes(shipping)){
74
+ return [{ code: 400, msg: 'Shipping must be none, full or proportional' }, null];
75
+ }
76
+ const proportional = shipping === 'proportional';
77
+
78
+ if(shipping !== 'none'){
79
+ add({ taxClass: 'shipping', kind: 'taxable', rate: input.stateRate, appliesToShipping: true, shippingProportional: proportional }, 'shipping');
70
80
  for(const local of locals){
71
81
  add({
72
82
  taxClass: 'shipping',
@@ -76,6 +86,7 @@ export const quickstartRows = input => {
76
86
  locality: local.locality,
77
87
  jurisdiction: local.jurisdiction || `${local.locality} local option`,
78
88
  appliesToShipping: true,
89
+ shippingProportional: proportional,
79
90
  }, 'shipping, local option');
80
91
  }
81
92
  }
@@ -74,6 +74,7 @@ export const cleanRate = input => {
74
74
  kind,
75
75
  rateMicro,
76
76
  appliesToShipping: input.appliesToShipping === true || input.appliesToShipping === 'true' || input.appliesToShipping === '1' || input.appliesToShipping === 1,
77
+ shippingProportional: input.shippingProportional === true || input.shippingProportional === 'true' || input.shippingProportional === '1' || input.shippingProportional === 1,
77
78
  taxType: text(input.taxType || 'sales_tax', 40) || 'sales_tax',
78
79
  jurisdiction,
79
80
  label: text(input.label),
@@ -99,6 +100,7 @@ export const presentRate = row => ({
99
100
  kind: row.kind,
100
101
  rate: row.kind === 'exempt' ? '0' : formatPercent(row.rateMicro),
101
102
  appliesToShipping: row.appliesToShipping,
103
+ shippingProportional: !!row.shippingProportional,
102
104
  taxType: row.taxType,
103
105
  jurisdiction: row.jurisdiction,
104
106
  label: row.label,
@@ -171,19 +171,42 @@ export const calculateWithRates = (request, rows, options = {}) => {
171
171
  return { item, layers, taxable, totalMicro: taxable.reduce((sum, row) => sum + row.rateMicro, 0) };
172
172
  });
173
173
 
174
+ /*
175
+ How much of the goods is taxable, for a shipping row that taxes shipping in proportion. Free
176
+ lines are not goods here, and shipping is never part of its own share.
177
+ */
178
+ const goods = resolved.filter(entry => !entry.item.isShipping && entry.item.amount > 0);
179
+ const goodsTotal = goods.reduce((sum, entry) => sum + entry.item.amount, 0);
180
+ const goodsTaxable = goods.filter(entry => entry.taxable.length).reduce((sum, entry) => sum + entry.item.amount, 0);
181
+
174
182
  // 2. The tax for each (item, layer).
175
183
  const shares = resolved.map(entry => entry.taxable.map(row => {
176
184
  const inclusive = entry.item.taxBehavior === 'inclusive';
177
- return {
178
- row,
179
- inclusive,
180
- exact: inclusive
181
- ? { numerator: BigInt(entry.item.amount) * BigInt(row.rateMicro), denominator: BigInt(ONE + entry.totalMicro) }
182
- : { numerator: BigInt(entry.item.amount) * BigInt(row.rateMicro), denominator: BigInt(ONE) },
183
- tax: inclusive
184
- ? taxInclusive(entry.item.amount, row.rateMicro, entry.totalMicro)
185
- : taxExclusive(entry.item.amount, row.rateMicro),
186
- };
185
+
186
+ /*
187
+ Proportional shipping applies to tax added on top. A tax-inclusive shipping charge is taxed
188
+ in full, because the share of tax already inside a price cannot be split by what else was
189
+ bought; the README says so.
190
+ */
191
+ const proportional = entry.item.isShipping && row.shippingProportional && !inclusive;
192
+
193
+ let exact;
194
+ let tax;
195
+ if(inclusive){
196
+ exact = { numerator: BigInt(entry.item.amount) * BigInt(row.rateMicro), denominator: BigInt(ONE + entry.totalMicro) };
197
+ tax = taxInclusive(entry.item.amount, row.rateMicro, entry.totalMicro);
198
+ } else if(proportional){
199
+ exact = {
200
+ numerator: BigInt(entry.item.amount) * BigInt(row.rateMicro) * BigInt(goodsTaxable),
201
+ denominator: BigInt(ONE) * BigInt(Math.max(goodsTotal, 1)),
202
+ };
203
+ tax = roundDiv(exact.numerator, exact.denominator);
204
+ } else {
205
+ exact = { numerator: BigInt(entry.item.amount) * BigInt(row.rateMicro), denominator: BigInt(ONE) };
206
+ tax = taxExclusive(entry.item.amount, row.rateMicro);
207
+ }
208
+
209
+ return { row, inclusive, exact, tax };
187
210
  }));
188
211
 
189
212
  // 3. Invoice rounding: one rounded figure per layer, spread back over the lines that used it.
@@ -1,3 +1,5 @@
1
+ import { formatPercent, parsePercent } from './percent.js';
2
+
1
3
  /*
2
4
  Turning what a caller hands in into what a provider needs, and what a provider answers into one
3
5
  vocabulary — with no network and no database anywhere in this file.
@@ -305,14 +307,34 @@ export const toStripeParams = request => ({
305
307
  */
306
308
  const NOT_COLLECTING = new Set(['not_collecting', 'not_supported']);
307
309
 
310
+ /*
311
+ What Stripe actually returns for a breakdown, found by running it against a live account rather
312
+ than from the type definitions: a calculation names a place only by `state` and `country` inside
313
+ `tax_rate_details`, there is no jurisdiction object and no per-line breakdown, and the layers of
314
+ a place are **combined** into one row — Vermont's 6% and Burlington's 1% arrive as a single 7.0.
315
+
316
+ So the jurisdiction is the state (or the country, where there is none), at level `state` or
317
+ `country`, and the rate is the combined one. A per-layer breakdown is what the rate table gives;
318
+ with Stripe a return can only be broken down as far as Stripe says. The fields are read
319
+ defensively, so a version that does send a jurisdiction object is used as sent.
320
+ */
321
+ const place = entry => entry.jurisdiction?.state || entry.tax_rate_details?.state || entry.jurisdiction?.country || entry.tax_rate_details?.country || null;
322
+
323
+ const normalisedRate = value => {
324
+ const micro = parsePercent(String(value ?? ''));
325
+ return micro === null ? (value ?? null) : formatPercent(micro);
326
+ };
327
+
308
328
  const breakdownFrom = entry => ({
309
- jurisdiction: entry.jurisdiction?.display_name || null,
310
- level: entry.jurisdiction?.level || null,
311
- country: entry.jurisdiction?.country || null,
312
- state: entry.jurisdiction?.state || null,
329
+ jurisdiction: entry.jurisdiction?.display_name || place(entry),
330
+ level: entry.jurisdiction?.level
331
+ || (entry.tax_rate_details?.state || entry.jurisdiction?.state ? 'state' : place(entry) ? 'country' : null),
332
+ country: entry.jurisdiction?.country || entry.tax_rate_details?.country || null,
333
+ state: entry.jurisdiction?.state || entry.tax_rate_details?.state || null,
313
334
  taxType: entry.tax_rate_details?.tax_type || null,
314
- name: entry.tax_rate_details?.display_name || null,
315
- rate: entry.tax_rate_details?.percentage_decimal ?? null,
335
+ name: entry.tax_rate_details?.display_name
336
+ || [place(entry), entry.tax_rate_details?.tax_type?.replace(/_/g, ' ')].filter(Boolean).join(' ') || null,
337
+ rate: normalisedRate(entry.tax_rate_details?.percentage_decimal),
316
338
  taxableAmount: entry.taxable_amount ?? 0,
317
339
  amount: entry.amount ?? 0,
318
340
  inclusive: !!entry.inclusive,
@@ -178,16 +178,18 @@ export const reverseTransaction = async ({ transactionId, reference, amount, occ
178
178
  */
179
179
  const reverseAmount = amount || (reversed > 0 ? remaining : undefined);
180
180
 
181
+ let providerReversalRef = null;
181
182
  if(original.provider !== 'rates' && original.providerRef){
182
183
  const [providerError, provider] = getProvider(original.provider);
183
184
  if(providerError) return [providerError, null];
184
185
 
185
- const [error] = await provider.reverseTransaction({
186
+ const [error, reversal] = await provider.reverseTransaction({
186
187
  transactionId: original.providerRef,
187
188
  reference: cleaned,
188
189
  amount: reverseAmount,
189
190
  });
190
191
  if(error) return [error, null];
192
+ providerReversalRef = reversal.transactionId;
191
193
  }
192
194
 
193
195
  const entry = reversalFromEntry(original, {
@@ -195,6 +197,7 @@ export const reverseTransaction = async ({ transactionId, reference, amount, occ
195
197
  reference: cleaned,
196
198
  occurredAt: occurredAt ? new Date(occurredAt) : new Date(),
197
199
  note,
200
+ providerRef: providerReversalRef,
198
201
  });
199
202
 
200
203
  const [insertError, saved] = await insertEntry(entry);