kempo-commerce 0.1.0 → 0.1.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.
@@ -1,115 +1,115 @@
1
- import { loadTax } from '../optional.js';
2
- import { EVENTS, notify } from '../hooks.js';
3
- import { subdivisionsOf } from './regions.js';
4
-
5
- /*
6
- Tax comes from kempo-tax when it is installed and enabled, and from nothing otherwise: a store
7
- without it charges no tax and says nothing about it.
8
-
9
- kempo-tax's `prices_include_tax` setting decides whether the catalog's prices already contain tax. Commerce
10
- never decides that itself and never adds tax twice: with inclusive prices the tax is reported and the
11
- total is the subtotal; with exclusive prices the tax is added.
12
-
13
- A tax provider that is installed but cannot calculate (not connected, a key expired) is an error, not
14
- a silent zero. A checkout that quietly stopped charging tax for a week is a liability that grows with
15
- every order; a loud one is fixed the same afternoon.
16
- */
17
-
18
- const FIELDS = ['line1', 'line2', 'city', 'state', 'postalCode', 'country'];
19
-
20
- export const addressOf = billing => {
21
- const address = {};
22
- for(const field of FIELDS) if(typeof billing?.[field] === 'string' && billing[field].trim()) address[field] = billing[field].trim();
23
- if(address.country) address.country = address.country.toUpperCase();
24
- return address;
25
- };
26
-
27
- /* True when kempo-tax is on, which is what makes an address worth asking for. */
28
- export const taxEnabled = async () => Boolean(await loadTax());
29
-
30
- /*
31
- What the tax needs of an address, by country, beyond the country itself. A state or province where the tax
32
- is set by it (the United States and Canada), and a postal code where the provider refuses to calculate
33
- without one (the United States). Anywhere else the country is enough. Add a country here when its tax turns
34
- out to depend on more.
35
- */
36
- const STATE_FOR_TAX = new Set(['US', 'CA']);
37
- const POSTAL_CODE_FOR_TAX = new Set(['US']);
38
-
39
- export const needsFor = country => ({
40
- country: true,
41
- state: STATE_FOR_TAX.has(country) && Boolean(subdivisionsOf(country)),
42
- postalCode: POSTAL_CODE_FOR_TAX.has(country),
43
- });
44
-
45
- /* What the address must have for tax to be calculated, as { country, state, postalCode } booleans; all false when tax is off. */
46
- export const addressRequirements = async billing => {
47
- if(!(await taxEnabled())) return { country: false, state: false, postalCode: false };
48
- return needsFor(addressOf(billing).country);
49
- };
50
-
51
- /* True when the address has everything tax needs. */
52
- const addressReady = address => {
53
- if(!address.country) return false;
54
- const needs = needsFor(address.country);
55
- return (!needs.state || Boolean(address.state)) && (!needs.postalCode || Boolean(address.postalCode));
56
- };
57
-
58
- /*
59
- Works out the tax on `lines` (each `{ id, lineTotal }`) plus any taxable fees, for an address.
60
- Returns [null, null] when there is nothing to tax with yet (tax is off, or the address is not complete
61
- enough: a provider asked early only answers with a complaint), or
62
- [null, { tax, total, calculationId, perLine, pricesIncludeTax }].
63
-
64
- The `totals:tax` notification lets another extension replace or add to what kempo-tax said.
65
- */
66
- export const quoteTax = async ({ lines, adjustments = [], billing, currency, cart, order }) => {
67
- const sdk = await loadTax();
68
- if(!sdk) return [null, null];
69
-
70
- const address = addressOf(billing);
71
- if(!addressReady(address)) return [null, null];
72
-
73
- /* A line-level discount lowers what is taxed on that line; one on the whole order is not spread here. */
74
- const taxLines = lines.map(line => ({
75
- reference: line.id,
76
- /* What kind of product it is, so kempo-tax can tax it as its Settings say that type is (credits are not taxed like a mug). */
77
- productType: line.productType,
78
- amount: Math.max(0, line.lineTotal + adjustments.filter(adjustment => adjustment.lineId === line.id).reduce((sum, adjustment) => sum + adjustment.amount, 0)),
79
- })).filter(line => line.amount > 0);
80
- const shipping = adjustments.filter(adjustment => adjustment.type === 'fee' && adjustment.taxable).reduce((sum, adjustment) => sum + adjustment.amount, 0);
81
- if(!taxLines.length && shipping <= 0) return [null, null];
82
-
83
- const [error, result] = await sdk.calculateTax({ address, currency, lines: taxLines, ...(shipping > 0 ? { shipping: { amount: shipping } } : {}) });
84
- if(error) return [{ code: error.code === 409 ? 409 : 502, msg: `Tax could not be calculated: ${error.msg}` }, null];
85
-
86
- const quote = {
87
- tax: result.tax,
88
- total: result.total,
89
- calculationId: result.calculationId ?? null,
90
- perLine: Object.fromEntries((result.lines ?? []).map(line => [line.reference, line.tax])),
91
- pricesIncludeTax: (result.lines?.[0]?.taxBehavior ?? 'exclusive') === 'inclusive',
92
- };
93
-
94
- const payload = { cart, order, taxable: taxLines, address, tax: quote };
95
- await notify(EVENTS.totalsTax, payload);
96
- return [null, payload.tax];
97
- };
98
-
99
- /* Records the sale with kempo-tax once the payment has succeeded. Returns the transaction id, or null. */
100
- export const recordSale = async ({ calculationId, reference }) => {
101
- const sdk = await loadTax();
102
- if(!sdk || !calculationId) return [null, null];
103
- const [error, sale] = await sdk.recordTransaction({ calculationId, reference });
104
- if(error) return [error, null];
105
- return [null, sale.transactionId];
106
- };
107
-
108
- /* Reverses part or all of a recorded sale, for a refund. */
109
- export const reverseSale = async ({ transactionId, reference, amount }) => {
110
- const sdk = await loadTax();
111
- if(!sdk || !transactionId) return [null, null];
112
- const [error, reversal] = await sdk.reverseTransaction({ transactionId, reference, ...(amount ? { amount } : {}) });
113
- if(error) return [error, null];
114
- return [null, reversal];
115
- };
1
+ import { loadTax } from '../optional.js';
2
+ import { EVENTS, notify } from '../hooks.js';
3
+ import { subdivisionsOf } from './regions.js';
4
+
5
+ /*
6
+ Tax comes from kempo-tax when it is installed and enabled, and from nothing otherwise: a store
7
+ without it charges no tax and says nothing about it.
8
+
9
+ kempo-tax's `prices_include_tax` setting decides whether the catalog's prices already contain tax. Commerce
10
+ never decides that itself and never adds tax twice: with inclusive prices the tax is reported and the
11
+ total is the subtotal; with exclusive prices the tax is added.
12
+
13
+ A tax provider that is installed but cannot calculate (not connected, a key expired) is an error, not
14
+ a silent zero. A checkout that quietly stopped charging tax for a week is a liability that grows with
15
+ every order; a loud one is fixed the same afternoon.
16
+ */
17
+
18
+ const FIELDS = ['line1', 'line2', 'city', 'state', 'postalCode', 'country'];
19
+
20
+ export const addressOf = billing => {
21
+ const address = {};
22
+ for(const field of FIELDS) if(typeof billing?.[field] === 'string' && billing[field].trim()) address[field] = billing[field].trim();
23
+ if(address.country) address.country = address.country.toUpperCase();
24
+ return address;
25
+ };
26
+
27
+ /* True when kempo-tax is on, which is what makes an address worth asking for. */
28
+ export const taxEnabled = async () => Boolean(await loadTax());
29
+
30
+ /*
31
+ What the tax needs of an address, by country, beyond the country itself. A state or province where the tax
32
+ is set by it (the United States and Canada), and a postal code where the provider refuses to calculate
33
+ without one (the United States). Anywhere else the country is enough. Add a country here when its tax turns
34
+ out to depend on more.
35
+ */
36
+ const STATE_FOR_TAX = new Set(['US', 'CA']);
37
+ const POSTAL_CODE_FOR_TAX = new Set(['US']);
38
+
39
+ export const needsFor = country => ({
40
+ country: true,
41
+ state: STATE_FOR_TAX.has(country) && Boolean(subdivisionsOf(country)),
42
+ postalCode: POSTAL_CODE_FOR_TAX.has(country),
43
+ });
44
+
45
+ /* What the address must have for tax to be calculated, as { country, state, postalCode } booleans; all false when tax is off. */
46
+ export const addressRequirements = async billing => {
47
+ if(!(await taxEnabled())) return { country: false, state: false, postalCode: false };
48
+ return needsFor(addressOf(billing).country);
49
+ };
50
+
51
+ /* True when the address has everything tax needs. */
52
+ const addressReady = address => {
53
+ if(!address.country) return false;
54
+ const needs = needsFor(address.country);
55
+ return (!needs.state || Boolean(address.state)) && (!needs.postalCode || Boolean(address.postalCode));
56
+ };
57
+
58
+ /*
59
+ Works out the tax on `lines` (each `{ id, lineTotal }`) plus any taxable fees, for an address.
60
+ Returns [null, null] when there is nothing to tax with yet (tax is off, or the address is not complete
61
+ enough: a provider asked early only answers with a complaint), or
62
+ [null, { tax, total, calculationId, perLine, pricesIncludeTax }].
63
+
64
+ The `totals:tax` notification lets another extension replace or add to what kempo-tax said.
65
+ */
66
+ export const quoteTax = async ({ lines, adjustments = [], billing, currency, cart, order }) => {
67
+ const sdk = await loadTax();
68
+ if(!sdk) return [null, null];
69
+
70
+ const address = addressOf(billing);
71
+ if(!addressReady(address)) return [null, null];
72
+
73
+ /* A line-level discount lowers what is taxed on that line; one on the whole order is not spread here. */
74
+ const taxLines = lines.map(line => ({
75
+ reference: line.id,
76
+ /* What kind of product it is, so kempo-tax can tax it as its Settings say that type is (credits are not taxed like a mug). */
77
+ productType: line.productType,
78
+ amount: Math.max(0, line.lineTotal + adjustments.filter(adjustment => adjustment.lineId === line.id).reduce((sum, adjustment) => sum + adjustment.amount, 0)),
79
+ })).filter(line => line.amount > 0);
80
+ const shipping = adjustments.filter(adjustment => adjustment.type === 'fee' && adjustment.taxable).reduce((sum, adjustment) => sum + adjustment.amount, 0);
81
+ if(!taxLines.length && shipping <= 0) return [null, null];
82
+
83
+ const [error, result] = await sdk.calculateTax({ address, currency, lines: taxLines, ...(shipping > 0 ? { shipping: { amount: shipping } } : {}) });
84
+ if(error) return [{ code: error.code === 409 ? 409 : 502, msg: `Tax could not be calculated: ${error.msg}` }, null];
85
+
86
+ const quote = {
87
+ tax: result.tax,
88
+ total: result.total,
89
+ calculationId: result.calculationId ?? null,
90
+ perLine: Object.fromEntries((result.lines ?? []).map(line => [line.reference, line.tax])),
91
+ pricesIncludeTax: (result.lines?.[0]?.taxBehavior ?? 'exclusive') === 'inclusive',
92
+ };
93
+
94
+ const payload = { cart, order, taxable: taxLines, address, tax: quote };
95
+ await notify(EVENTS.totalsTax, payload);
96
+ return [null, payload.tax];
97
+ };
98
+
99
+ /* Records the sale with kempo-tax once the payment has succeeded. Returns the transaction id, or null. */
100
+ export const recordSale = async ({ calculationId, reference }) => {
101
+ const sdk = await loadTax();
102
+ if(!sdk || !calculationId) return [null, null];
103
+ const [error, sale] = await sdk.recordTransaction({ calculationId, reference });
104
+ if(error) return [error, null];
105
+ return [null, sale.transactionId];
106
+ };
107
+
108
+ /* Reverses part or all of a recorded sale, for a refund. */
109
+ export const reverseSale = async ({ transactionId, reference, amount }) => {
110
+ const sdk = await loadTax();
111
+ if(!sdk || !transactionId) return [null, null];
112
+ const [error, reversal] = await sdk.reverseTransaction({ transactionId, reference, ...(amount ? { amount } : {}) });
113
+ if(error) return [error, null];
114
+ return [null, reversal];
115
+ };