kempo-tax 0.2.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/README.md +39 -16
- package/admin/components/TaxRates.js +29 -15
- package/admin/components/TaxRegistrations.js +7 -7
- package/admin/components/TaxReports.js +17 -16
- package/admin/components/TaxSettings.js +14 -14
- package/admin/components/TaxTry.js +2 -2
- package/install.js +17 -0
- package/package.json +1 -1
- package/server/db/schema.js +7 -0
- package/server/utils/ledger/entries.js +2 -2
- package/server/utils/ledger/report.js +24 -4
- package/server/utils/rates/csv.js +1 -0
- package/server/utils/rates/quickstart.js +13 -2
- package/server/utils/rates/validate.js +2 -0
- package/server/utils/tax/rates.js +33 -10
- package/server/utils/tax/shape.js +28 -6
- package/server/utils/tax/transactions.js +4 -1
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
|
-
###
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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'],
|
|
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,
|
|
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,
|
|
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="
|
|
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
|
-
<
|
|
248
|
-
|
|
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
|
|
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="
|
|
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
|
|
302
|
+
<td >${rate.effectiveFrom || '—'} → ${rate.effectiveTo || 'now'}</td>
|
|
289
303
|
<td>${rate.jurisdiction}</td>
|
|
290
304
|
<td>
|
|
291
|
-
<button class="link
|
|
292
|
-
${rate.effectiveTo ? '' : html`<button class="link
|
|
293
|
-
<button class="link
|
|
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="
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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="
|
|
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="
|
|
110
|
-
<div class="card"><span class="
|
|
111
|
-
<div class="card"><span class="
|
|
112
|
-
<div class="card"><span class="
|
|
113
|
-
<div class="card"><span class="
|
|
114
|
-
<div class="card"><span class="
|
|
115
|
-
<div class="card"><span class="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
|
192
|
+
<td >${new Date(entry.occurredAt).toLocaleString()}</td>
|
|
193
193
|
<td>${entry.kind}</td>
|
|
194
|
-
<td class="ff-mono
|
|
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="
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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="
|
|
193
|
-
<span class="
|
|
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
|
|
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
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
|
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
package/server/db/schema.js
CHANGED
|
@@ -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,21 @@
|
|
|
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
|
+
const placeOf = part => part.jurisdiction || part.state || part.country || '—';
|
|
24
|
+
|
|
25
|
+
const levelOf = part => part.level || (part.state ? 'state' : part.country ? 'country' : '');
|
|
26
|
+
|
|
27
|
+
const rateOf = part => {
|
|
28
|
+
const micro = parsePercent(String(part.rate ?? ''));
|
|
29
|
+
return micro === null ? (part.rate ?? '') : formatPercent(micro);
|
|
30
|
+
};
|
|
31
|
+
|
|
15
32
|
const empty = () => ({
|
|
16
33
|
sales: 0,
|
|
17
34
|
taxable: 0,
|
|
@@ -63,14 +80,17 @@ export const buildReport = (entries, { from = null, to = null } = {}) => {
|
|
|
63
80
|
apply(bucket.months.get(month));
|
|
64
81
|
|
|
65
82
|
for(const part of entry.jurisdictions || []){
|
|
66
|
-
const
|
|
83
|
+
const jurisdiction = placeOf(part);
|
|
84
|
+
const level = levelOf(part);
|
|
85
|
+
const rate = rateOf(part);
|
|
86
|
+
const key = [part.country, part.state, level, jurisdiction, rate].join('|');
|
|
67
87
|
if(!bucket.jurisdictions.has(key)){
|
|
68
88
|
bucket.jurisdictions.set(key, {
|
|
69
89
|
country: part.country,
|
|
70
90
|
state: part.state,
|
|
71
|
-
level
|
|
72
|
-
jurisdiction
|
|
73
|
-
rate
|
|
91
|
+
level,
|
|
92
|
+
jurisdiction,
|
|
93
|
+
rate,
|
|
74
94
|
taxableAmount: 0,
|
|
75
95
|
taxAmount: 0,
|
|
76
96
|
});
|
|
@@ -65,8 +65,18 @@ export const quickstartRows = input => {
|
|
|
65
65
|
|
|
66
66
|
for(const taxClass of exempt) add({ taxClass, kind: 'exempt' });
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
inclusive
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 ||
|
|
310
|
-
level: entry.jurisdiction?.level
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
315
|
-
|
|
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);
|