domma-cms 0.84.0 → 0.85.0
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 +4 -4
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/js/lib/themes.js +1 -1
- package/package.json +2 -2
- package/plugins/free-tier.lock.json +53 -5
- package/plugins/security/plugin.js +10 -0
- package/plugins/security/plugin.json +1 -1
- package/plugins/security/server/facts.js +3 -1
- package/plugins/shopping-cart/CLAUDE.md +93 -0
- package/plugins/shopping-cart/admin/css/index.css +224 -0
- package/plugins/shopping-cart/admin/lib/kit.js +190 -0
- package/plugins/shopping-cart/admin/templates/shop.html +1 -0
- package/plugins/shopping-cart/admin/views/orders.js +365 -0
- package/plugins/shopping-cart/admin/views/overview.js +214 -0
- package/plugins/shopping-cart/admin/views/products.js +547 -0
- package/plugins/shopping-cart/admin/views/settings.js +247 -0
- package/plugins/shopping-cart/admin/views/shop.js +130 -0
- package/plugins/shopping-cart/collections/shop-orders.json +20 -0
- package/plugins/shopping-cart/collections/shop-products.json +24 -0
- package/plugins/shopping-cart/config.js +52 -0
- package/plugins/shopping-cart/inject/body.html +2 -0
- package/plugins/shopping-cart/lib/extensions.js +96 -0
- package/plugins/shopping-cart/lib/payments.js +281 -0
- package/plugins/shopping-cart/lib/pricing.js +232 -0
- package/plugins/shopping-cart/lib/render.js +282 -0
- package/plugins/shopping-cart/lib/samples.js +34 -0
- package/plugins/shopping-cart/lib/shop.js +436 -0
- package/plugins/shopping-cart/lib/summary.js +127 -0
- package/plugins/shopping-cart/plugin.js +559 -0
- package/plugins/shopping-cart/plugin.json +52 -0
- package/plugins/shopping-cart/plugin.public.js +144 -0
- package/plugins/shopping-cart/public/cart.js +715 -0
- package/plugins/shopping-cart/public/lib/money.js +105 -0
- package/plugins/shopping-cart/public/lib/orders.js +122 -0
- package/plugins/shopping-cart/public/lib/products.js +235 -0
- package/plugins/shopping-cart/public/samples/candle-2.svg +16 -0
- package/plugins/shopping-cart/public/samples/candle.svg +16 -0
- package/plugins/shopping-cart/public/samples/mug-2.svg +14 -0
- package/plugins/shopping-cart/public/samples/mug.svg +14 -0
- package/plugins/shopping-cart/public/samples/notebook-2.svg +15 -0
- package/plugins/shopping-cart/public/samples/notebook.svg +15 -0
- package/plugins/shopping-cart/public/samples/print-2.svg +14 -0
- package/plugins/shopping-cart/public/samples/print.svg +14 -0
- package/plugins/shopping-cart/public/samples/tee-2.svg +12 -0
- package/plugins/shopping-cart/public/samples/tee.svg +12 -0
- package/plugins/shopping-cart/public/samples/tote-2.svg +13 -0
- package/plugins/shopping-cart/public/samples/tote.svg +13 -0
- package/plugins/shopping-cart/public/shop.css +415 -0
- package/plugins/shopping-cart/templates/shop.html +4 -0
- package/plugins/shopping-cart/tests/api.test.js +144 -0
- package/plugins/shopping-cart/tests/pure.test.js +210 -0
- package/scripts/setup.js +1 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stripe and PayPal, spoken to over their REST APIs with `fetch` - no SDK to
|
|
3
|
+
* install, nothing to keep in step. Both are HOSTED checkouts: the shopper
|
|
4
|
+
* types card details on Stripe's or PayPal's page, never on ours, so no card
|
|
5
|
+
* number ever reaches this server.
|
|
6
|
+
*
|
|
7
|
+
* Stripe: create a Checkout Session -> shopper pays there -> back to the
|
|
8
|
+
* receipt page, which asks Stripe whether the session is paid.
|
|
9
|
+
* The webhook (checkout.session.completed) catches a shopper who
|
|
10
|
+
* closed the tab before coming back.
|
|
11
|
+
* PayPal: create an order -> shopper approves there -> back to our return
|
|
12
|
+
* route, which CAPTURES it (that is when the money moves).
|
|
13
|
+
*
|
|
14
|
+
* Either way an order is only marked paid on the provider's word, for the
|
|
15
|
+
* order's own amount and currency - never on a query string.
|
|
16
|
+
*
|
|
17
|
+
* @module shopping-cart/lib/payments
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {createHmac, timingSafeEqual} from 'crypto';
|
|
21
|
+
import {fromMinor} from '../public/lib/money.js';
|
|
22
|
+
|
|
23
|
+
const TIMEOUT = 15000;
|
|
24
|
+
|
|
25
|
+
export class PaymentError extends Error {
|
|
26
|
+
constructor(message, {status = 502, provider = ''} = {}) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.provider = provider;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A provider's error body, as one readable line. */
|
|
34
|
+
function reason(body, fallback) {
|
|
35
|
+
return body?.error?.message || body?.message || body?.details?.[0]?.description || body?.error_description
|
|
36
|
+
|| body?.name || fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function call(url, init, provider) {
|
|
40
|
+
let res;
|
|
41
|
+
try {
|
|
42
|
+
res = await fetch(url, {...init, signal: AbortSignal.timeout(TIMEOUT)});
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new PaymentError(`${provider} could not be reached (${err.name === 'TimeoutError' ? 'timed out' : err.message}).`, {provider});
|
|
45
|
+
}
|
|
46
|
+
let body = null;
|
|
47
|
+
try { body = await res.json(); } catch { /* an empty or HTML answer */ }
|
|
48
|
+
if (!res.ok) throw Object.assign(new PaymentError(`${provider}: ${reason(body, `HTTP ${res.status}`)}`, {provider, status: res.status >= 500 ? 502 : 400}), {body, httpStatus: res.status});
|
|
49
|
+
return body;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- Stripe ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const STRIPE = 'https://api.stripe.com/v1';
|
|
55
|
+
|
|
56
|
+
/** Stripe's form encoding, nested keys and arrays included: a[b][0][c]=d. */
|
|
57
|
+
export function stripeForm(obj, prefix = '', out = new URLSearchParams()) {
|
|
58
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
59
|
+
if (v === undefined || v === null) continue;
|
|
60
|
+
const key = prefix ? `${prefix}[${k}]` : k;
|
|
61
|
+
if (typeof v === 'object') stripeForm(v, key, out);
|
|
62
|
+
else out.append(key, String(v));
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function stripeInit(secretKey, method = 'GET', form = null) {
|
|
68
|
+
return {
|
|
69
|
+
method,
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${secretKey}`,
|
|
72
|
+
...(form ? {'Content-Type': 'application/x-www-form-urlencoded'} : {}),
|
|
73
|
+
'Stripe-Version': '2024-06-20'
|
|
74
|
+
},
|
|
75
|
+
...(form ? {body: form.toString()} : {})
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** "sk_test_…" or "sk_live_…" / restricted "rk_…": which mode a key is for. */
|
|
80
|
+
export const stripeMode = (key) => (/^(sk|rk)_live_/.test(key || '') ? 'live' : /^(sk|rk)_test_/.test(key || '') ? 'test' : '');
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The Checkout Session's line items. Itemised when the lines add up to the
|
|
84
|
+
* total on their own (plus delivery and added tax); one "Order #n" line when a
|
|
85
|
+
* discount is in play, since Stripe only discounts through its own coupons.
|
|
86
|
+
*/
|
|
87
|
+
export function stripeLineItems(order) {
|
|
88
|
+
const cur = String(order.currency).toLowerCase();
|
|
89
|
+
const item = (name, amount, qty = 1, description = '', image = '') => ({
|
|
90
|
+
quantity: qty,
|
|
91
|
+
price_data: {
|
|
92
|
+
currency: cur,
|
|
93
|
+
unit_amount: amount,
|
|
94
|
+
product_data: {name: name.slice(0, 250), ...(description ? {description: description.slice(0, 500)} : {}),
|
|
95
|
+
...(image ? {images: {0: image}} : {})}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
const discount = order.adjustments.some(a => a.stage === 'discount' && a.amount < 0);
|
|
99
|
+
const odd = order.adjustments.some(a => !a.included && a.amount < 0 && a.stage !== 'discount');
|
|
100
|
+
if (discount || odd) {
|
|
101
|
+
const what = order.lines.map(l => `${l.qty} × ${l.name}${l.variantName ? ` (${l.variantName})` : ''}`).join(', ');
|
|
102
|
+
return [item(`Order #${order.number}`, order.total, 1, what)];
|
|
103
|
+
}
|
|
104
|
+
const items = order.lines.map(l => item(l.variantName ? `${l.name} (${l.variantName})` : l.name, l.unitPrice, l.qty, '',
|
|
105
|
+
/^https:\/\//.test(l.image || '') ? l.image : ''));
|
|
106
|
+
for (const a of order.adjustments) {
|
|
107
|
+
if (a.included || a.amount <= 0) continue;
|
|
108
|
+
items.push(item(a.label, a.amount));
|
|
109
|
+
}
|
|
110
|
+
return items;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Start a Stripe Checkout Session for an order.
|
|
115
|
+
*
|
|
116
|
+
* @returns {Promise<{id: string, url: string}>}
|
|
117
|
+
*/
|
|
118
|
+
export async function stripeCreateSession({secretKey, order, successUrl, cancelUrl, shopName = ''}) {
|
|
119
|
+
const items = stripeLineItems(order);
|
|
120
|
+
const form = stripeForm({
|
|
121
|
+
mode: 'payment',
|
|
122
|
+
success_url: successUrl,
|
|
123
|
+
cancel_url: cancelUrl,
|
|
124
|
+
client_reference_id: order.id,
|
|
125
|
+
customer_email: order.customer?.email || undefined,
|
|
126
|
+
metadata: {orderId: order.id, orderNumber: order.number},
|
|
127
|
+
payment_intent_data: {metadata: {orderId: order.id, orderNumber: order.number},
|
|
128
|
+
description: `${shopName ? `${shopName} ` : ''}order #${order.number}`.slice(0, 1000)},
|
|
129
|
+
line_items: Object.fromEntries(items.map((x, i) => [i, x]))
|
|
130
|
+
});
|
|
131
|
+
const s = await call(`${STRIPE}/checkout/sessions`, stripeInit(secretKey, 'POST', form), 'Stripe');
|
|
132
|
+
if (!s?.url) throw new PaymentError('Stripe did not return a payment page.', {provider: 'Stripe'});
|
|
133
|
+
return {id: s.id, url: s.url};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A session as Stripe has it now. */
|
|
137
|
+
export async function stripeGetSession({secretKey, sessionId}) {
|
|
138
|
+
if (!/^cs_[A-Za-z0-9_]{8,200}$/.test(String(sessionId))) throw new PaymentError('Not a Stripe session.', {status: 400, provider: 'Stripe'});
|
|
139
|
+
return call(`${STRIPE}/checkout/sessions/${sessionId}`, stripeInit(secretKey), 'Stripe');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Did this session pay for this order, in full? Returns the payment reference
|
|
144
|
+
* when it did, null when it has not (yet).
|
|
145
|
+
*/
|
|
146
|
+
export function stripeSessionPaid(session, order) {
|
|
147
|
+
if (!session || session.payment_status !== 'paid') return null;
|
|
148
|
+
if (session.client_reference_id !== order.id && session.metadata?.orderId !== order.id) return null;
|
|
149
|
+
if (String(session.currency).toUpperCase() !== order.currency || Number(session.amount_total) !== order.total) return null;
|
|
150
|
+
return {ref: session.id, payment: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id || ''};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function stripeRefund({secretKey, paymentIntent, amount}) {
|
|
154
|
+
const form = stripeForm({payment_intent: paymentIntent, ...(amount ? {amount} : {})});
|
|
155
|
+
return call(`${STRIPE}/refunds`, stripeInit(secretKey, 'POST', form), 'Stripe');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Can this key talk to Stripe? */
|
|
159
|
+
export async function stripeTest({secretKey}) {
|
|
160
|
+
await call(`${STRIPE}/balance`, stripeInit(secretKey), 'Stripe');
|
|
161
|
+
return {ok: true, mode: stripeMode(secretKey)};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Verify a webhook's `Stripe-Signature` header over the raw body.
|
|
166
|
+
*
|
|
167
|
+
* @param {Buffer|string} raw
|
|
168
|
+
* @param {string} header - "t=…,v1=…[,v1=…]"
|
|
169
|
+
* @param {string} secret - "whsec_…"
|
|
170
|
+
* @param {number} [toleranceSec]
|
|
171
|
+
* @param {number} [nowSec]
|
|
172
|
+
*/
|
|
173
|
+
export function stripeVerify(raw, header, secret, toleranceSec = 300, nowSec = Math.floor(Date.now() / 1000)) {
|
|
174
|
+
if (!secret || !header) return false;
|
|
175
|
+
const parts = String(header).split(',').map(s => s.split('='));
|
|
176
|
+
const t = parts.find(([k]) => k === 't')?.[1];
|
|
177
|
+
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
|
|
178
|
+
if (!t || !sigs.length || !/^\d+$/.test(t)) return false;
|
|
179
|
+
if (Math.abs(nowSec - Number(t)) > toleranceSec) return false;
|
|
180
|
+
const expected = createHmac('sha256', secret).update(`${t}.`).update(raw).digest();
|
|
181
|
+
return sigs.some(s => {
|
|
182
|
+
const got = Buffer.from(s, 'hex');
|
|
183
|
+
return got.length === expected.length && timingSafeEqual(got, expected);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- PayPal ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
const PAYPAL = {sandbox: 'https://api-m.sandbox.paypal.com', live: 'https://api-m.paypal.com'};
|
|
190
|
+
const tokens = new Map();
|
|
191
|
+
|
|
192
|
+
const paypalBase = (mode) => PAYPAL[mode === 'live' ? 'live' : 'sandbox'];
|
|
193
|
+
|
|
194
|
+
async function paypalToken({clientId, secret, mode}) {
|
|
195
|
+
const key = `${mode}:${clientId}`;
|
|
196
|
+
const had = tokens.get(key);
|
|
197
|
+
if (had && had.until > Date.now()) return had.token;
|
|
198
|
+
const body = await call(`${paypalBase(mode)}/v1/oauth2/token`, {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: {
|
|
201
|
+
Authorization: `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`,
|
|
202
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
203
|
+
},
|
|
204
|
+
body: 'grant_type=client_credentials'
|
|
205
|
+
}, 'PayPal');
|
|
206
|
+
tokens.set(key, {token: body.access_token, until: Date.now() + Math.max(60, (body.expires_in || 300) - 60) * 1000});
|
|
207
|
+
return body.access_token;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function paypalCall(creds, method, path, payload) {
|
|
211
|
+
const token = await paypalToken(creds);
|
|
212
|
+
return call(`${paypalBase(creds.mode)}${path}`, {
|
|
213
|
+
method,
|
|
214
|
+
headers: {Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Prefer: 'return=representation'},
|
|
215
|
+
...(payload ? {body: JSON.stringify(payload)} : {})
|
|
216
|
+
}, 'PayPal');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Create a PayPal order for ours; the shopper approves it at `url`.
|
|
221
|
+
*
|
|
222
|
+
* @returns {Promise<{id: string, url: string}>}
|
|
223
|
+
*/
|
|
224
|
+
export async function paypalCreateOrder({creds, order, returnUrl, cancelUrl, shopName = ''}) {
|
|
225
|
+
const o = await paypalCall(creds, 'POST', '/v2/checkout/orders', {
|
|
226
|
+
intent: 'CAPTURE',
|
|
227
|
+
purchase_units: [{
|
|
228
|
+
reference_id: order.id,
|
|
229
|
+
custom_id: order.id,
|
|
230
|
+
description: `${shopName ? `${shopName} ` : ''}order #${order.number}`.slice(0, 127),
|
|
231
|
+
amount: {currency_code: order.currency, value: fromMinor(order.total, order.currency)}
|
|
232
|
+
}],
|
|
233
|
+
payment_source: {paypal: {experience_context: {
|
|
234
|
+
return_url: returnUrl,
|
|
235
|
+
cancel_url: cancelUrl,
|
|
236
|
+
user_action: 'PAY_NOW',
|
|
237
|
+
shipping_preference: 'NO_SHIPPING',
|
|
238
|
+
...(shopName ? {brand_name: shopName.slice(0, 127)} : {})
|
|
239
|
+
}}}
|
|
240
|
+
});
|
|
241
|
+
const url = (o.links || []).find(l => l.rel === 'payer-action' || l.rel === 'approve')?.href;
|
|
242
|
+
if (!url) throw new PaymentError('PayPal did not return a payment page.', {provider: 'PayPal'});
|
|
243
|
+
return {id: o.id, url};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Capture an approved PayPal order - this is when the money moves. Already
|
|
248
|
+
* captured (a refresh, a second tab) reads the order instead of failing.
|
|
249
|
+
*/
|
|
250
|
+
export async function paypalCapture({creds, paypalOrderId}) {
|
|
251
|
+
if (!/^[A-Z0-9]{8,40}$/.test(String(paypalOrderId))) throw new PaymentError('Not a PayPal order.', {status: 400, provider: 'PayPal'});
|
|
252
|
+
try {
|
|
253
|
+
return await paypalCall(creds, 'POST', `/v2/checkout/orders/${paypalOrderId}/capture`, {});
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (err.httpStatus === 422 && JSON.stringify(err.body || {}).includes('ORDER_ALREADY_CAPTURED')) {
|
|
256
|
+
return paypalCall(creds, 'GET', `/v2/checkout/orders/${paypalOrderId}`);
|
|
257
|
+
}
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Did this PayPal order pay for ours, in full? {ref, payment} or null. */
|
|
263
|
+
export function paypalPaid(pp, order) {
|
|
264
|
+
if (!pp || pp.status !== 'COMPLETED') return null;
|
|
265
|
+
const unit = (pp.purchase_units || [])[0] || {};
|
|
266
|
+
if (unit.reference_id !== order.id && unit.custom_id !== order.id) return null;
|
|
267
|
+
const capture = (unit.payments?.captures || []).find(c => c.status === 'COMPLETED');
|
|
268
|
+
if (!capture) return null;
|
|
269
|
+
if (capture.amount?.currency_code !== order.currency || capture.amount?.value !== fromMinor(order.total, order.currency)) return null;
|
|
270
|
+
return {ref: pp.id, payment: capture.id};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function paypalRefund({creds, captureId}) {
|
|
274
|
+
return paypalCall(creds, 'POST', `/v2/payments/captures/${encodeURIComponent(captureId)}/refund`, {});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function paypalTest({creds}) {
|
|
278
|
+
tokens.delete(`${creds.mode}:${creds.clientId}`);
|
|
279
|
+
await paypalToken(creds);
|
|
280
|
+
return {ok: true, mode: creds.mode === 'live' ? 'live' : 'sandbox'};
|
|
281
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The price of a basket - the ONE place a total is worked out. The storefront
|
|
3
|
+
* shows what this returns and checkout charges what this returns; the prices a
|
|
4
|
+
* browser sends are never read, only product ids, options and quantities.
|
|
5
|
+
*
|
|
6
|
+
* subtotal (lines at today's prices, capped at the stock there is)
|
|
7
|
+
* -> discount -> shipping -> tax (the stages)
|
|
8
|
+
* -> total
|
|
9
|
+
*
|
|
10
|
+
* Each stage has a built-in handler (none / a flat rate / a flat tax rate,
|
|
11
|
+
* from the Cart's settings) that an extension can replace - Shopping Cart Pro
|
|
12
|
+
* brings discount codes, shipping zones and tax rules this way (see
|
|
13
|
+
* extensions.js). A handler gets the basket so far and returns adjustments:
|
|
14
|
+
* `{label, amount}` in minor units, negative for a discount; `included: true`
|
|
15
|
+
* for tax already inside the prices (shown, not added).
|
|
16
|
+
*
|
|
17
|
+
* @module shopping-cart/lib/pricing
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {includedTax, percentOf} from '../public/lib/money.js';
|
|
21
|
+
import {available, unitPrice, variantOf} from '../public/lib/products.js';
|
|
22
|
+
|
|
23
|
+
export const STAGES = ['discount', 'shipping', 'tax'];
|
|
24
|
+
export const MAX_LINES = 100;
|
|
25
|
+
export const MAX_QTY = 999;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a browser sent, as lines: `[{productId, variantId, qty}]`, the same
|
|
29
|
+
* product and option merged, junk dropped.
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} raw
|
|
32
|
+
* @returns {{productId: string, variantId: string, qty: number}[]}
|
|
33
|
+
*/
|
|
34
|
+
export function cleanLines(raw) {
|
|
35
|
+
const out = new Map();
|
|
36
|
+
for (const l of Array.isArray(raw) ? raw : []) {
|
|
37
|
+
const productId = String(l?.productId ?? l?.id ?? '').slice(0, 64);
|
|
38
|
+
const variantId = String(l?.variantId ?? '').slice(0, 32);
|
|
39
|
+
const qty = Math.floor(Number(l?.qty));
|
|
40
|
+
if (!/^[\w-]{1,64}$/.test(productId) || !(qty > 0)) continue;
|
|
41
|
+
const key = `${productId}:${variantId}`;
|
|
42
|
+
const had = out.get(key);
|
|
43
|
+
out.set(key, {productId, variantId, qty: Math.min(MAX_QTY, (had?.qty || 0) + qty)});
|
|
44
|
+
if (out.size >= MAX_LINES) break;
|
|
45
|
+
}
|
|
46
|
+
return [...out.values()];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- The built-in handlers -------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** No discounts in the free Cart: Pro's codes replace this. */
|
|
52
|
+
export function builtInDiscount() {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One delivery rate, free over a threshold, and collection when offered.
|
|
58
|
+
* Nothing to ship (every line a download or a service) -> no delivery line.
|
|
59
|
+
*/
|
|
60
|
+
export function builtInShipping({requiresShipping, settings, input, runningTotal}) {
|
|
61
|
+
if (!requiresShipping) return [];
|
|
62
|
+
if (settings.allowCollection && input.delivery === 'collect') {
|
|
63
|
+
return [{label: settings.collectionLabel || 'Collection', amount: 0}];
|
|
64
|
+
}
|
|
65
|
+
const over = Number(settings.freeShippingOver) || 0;
|
|
66
|
+
if (over > 0 && runningTotal >= over) return [{label: 'Free delivery', amount: 0}];
|
|
67
|
+
const rate = Number(settings.shippingFlat) || 0;
|
|
68
|
+
return [{label: settings.shippingLabel || 'Delivery', amount: rate}];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One tax rate for everything. Inclusive prices (UK VAT, most of Europe) show
|
|
73
|
+
* the tax held inside the total; exclusive prices (US sales tax) add it.
|
|
74
|
+
*/
|
|
75
|
+
export function builtInTax({settings, runningTotal, adjustments}) {
|
|
76
|
+
const rate = Number(settings.taxRate) || 0;
|
|
77
|
+
if (rate <= 0) return [];
|
|
78
|
+
const shipping = adjustments.filter(a => a.stage === 'shipping').reduce((s, a) => s + a.amount, 0);
|
|
79
|
+
const base = Math.max(0, settings.taxShipping ? runningTotal : runningTotal - shipping);
|
|
80
|
+
const label = settings.taxLabel || 'Tax';
|
|
81
|
+
if (settings.pricesIncludeTax) {
|
|
82
|
+
return [{label: `Includes ${label} (${rate}%)`, amount: includedTax(base, rate), included: true}];
|
|
83
|
+
}
|
|
84
|
+
return [{label: `${label} (${rate}%)`, amount: percentOf(base, rate)}];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const BUILT_IN = {discount: builtInDiscount, shipping: builtInShipping, tax: builtInTax};
|
|
88
|
+
|
|
89
|
+
/** A handler's answer, trusted no further than this. */
|
|
90
|
+
function cleanAdjustments(result, stage) {
|
|
91
|
+
const list = Array.isArray(result) ? result : Array.isArray(result?.adjustments) ? result.adjustments : [];
|
|
92
|
+
return list
|
|
93
|
+
.filter(a => a && Number.isFinite(Number(a.amount)))
|
|
94
|
+
.slice(0, 20)
|
|
95
|
+
.map(a => ({
|
|
96
|
+
stage,
|
|
97
|
+
label: String(a.label || stage).slice(0, 120),
|
|
98
|
+
amount: Math.round(Number(a.amount)),
|
|
99
|
+
...(a.included ? {included: true} : {}),
|
|
100
|
+
...(a.code ? {code: String(a.code).slice(0, 60)} : {}),
|
|
101
|
+
...(a.ref ? {ref: String(a.ref).slice(0, 60)} : {})
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Price a basket.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} args
|
|
109
|
+
* @param {{productId, variantId, qty}[]} args.lines - cleanLines() output
|
|
110
|
+
* @param {Map<string, {id, data}>} args.products - by id, every product the lines name
|
|
111
|
+
* @param {object} args.settings - the Cart's live settings
|
|
112
|
+
* @param {object} [args.input] - {code, country, region, postcode, delivery}
|
|
113
|
+
* @param {Object<string, Function>} [args.handlers] - stage -> handler; missing = built in
|
|
114
|
+
* @returns {Promise<object>} the quote
|
|
115
|
+
*/
|
|
116
|
+
export async function priceBasket({lines, products, settings, input = {}, handlers = {}}) {
|
|
117
|
+
const currency = settings.currency || 'GBP';
|
|
118
|
+
const out = [];
|
|
119
|
+
const problems = [];
|
|
120
|
+
|
|
121
|
+
for (const l of lines) {
|
|
122
|
+
const entry = products.get(l.productId);
|
|
123
|
+
const p = entry?.data;
|
|
124
|
+
if (!p || p.status !== 'active') {
|
|
125
|
+
problems.push({productId: l.productId, variantId: l.variantId, message: 'No longer on sale - taken out.'});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const v = variantOf(p, l.variantId);
|
|
129
|
+
if (!v) {
|
|
130
|
+
problems.push({productId: l.productId, variantId: l.variantId, message: `${p.name}: that option is gone - taken out.`});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const can = available(p, l.variantId);
|
|
134
|
+
let qty = l.qty;
|
|
135
|
+
if (can <= 0) {
|
|
136
|
+
problems.push({productId: l.productId, variantId: l.variantId, message: `${p.name}${v.name ? ` (${v.name})` : ''} is sold out.`});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (qty > can) {
|
|
140
|
+
problems.push({productId: l.productId, variantId: l.variantId, message: `Only ${can} of ${p.name}${v.name ? ` (${v.name})` : ''} left - quantity lowered.`});
|
|
141
|
+
qty = can;
|
|
142
|
+
}
|
|
143
|
+
const unit = unitPrice(p, l.variantId);
|
|
144
|
+
out.push({
|
|
145
|
+
productId: l.productId,
|
|
146
|
+
variantId: l.variantId,
|
|
147
|
+
name: p.name,
|
|
148
|
+
variantName: v.name || '',
|
|
149
|
+
optionName: p.optionName || '',
|
|
150
|
+
slug: p.slug,
|
|
151
|
+
image: p.images?.[0] || '',
|
|
152
|
+
sku: v.sku || p.sku || '',
|
|
153
|
+
unitPrice: unit,
|
|
154
|
+
qty,
|
|
155
|
+
lineTotal: unit * qty,
|
|
156
|
+
available: can === Infinity ? null : can,
|
|
157
|
+
shippable: p.shippable !== false,
|
|
158
|
+
weight: Number.isInteger(p.weight) ? p.weight : null,
|
|
159
|
+
taxClass: p.taxClass || 'standard',
|
|
160
|
+
category: p.category || ''
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const subtotal = out.reduce((s, l) => s + l.lineTotal, 0);
|
|
165
|
+
const requiresShipping = out.some(l => l.shippable);
|
|
166
|
+
const weight = out.reduce((s, l) => s + (l.weight || 0) * l.qty, 0);
|
|
167
|
+
const adjustments = [];
|
|
168
|
+
const notes = [];
|
|
169
|
+
let shippingChoices = [];
|
|
170
|
+
let runningTotal = subtotal;
|
|
171
|
+
|
|
172
|
+
for (const stage of STAGES) {
|
|
173
|
+
const handler = typeof handlers[stage] === 'function' ? handlers[stage] : BUILT_IN[stage];
|
|
174
|
+
let result;
|
|
175
|
+
try {
|
|
176
|
+
result = await handler({
|
|
177
|
+
stage, lines: out, subtotal, runningTotal, currency, settings, input,
|
|
178
|
+
adjustments: [...adjustments], requiresShipping, weight
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// An extension that throws must not take checkout down with it:
|
|
182
|
+
// fall back to the built-in rule for this stage and say so.
|
|
183
|
+
result = await BUILT_IN[stage]({stage, lines: out, subtotal, runningTotal, currency, settings, input,
|
|
184
|
+
adjustments: [...adjustments], requiresShipping, weight});
|
|
185
|
+
notes.push({stage, message: `The ${stage} rules failed (${err?.message || err}); the standard ${stage} was used.`, internal: true});
|
|
186
|
+
}
|
|
187
|
+
let adds = cleanAdjustments(result, stage);
|
|
188
|
+
// A discount can take the basket to nothing, never below it.
|
|
189
|
+
if (stage === 'discount') {
|
|
190
|
+
let room = runningTotal;
|
|
191
|
+
adds = adds.map(a => {
|
|
192
|
+
const amount = Math.max(-room, Math.min(0, a.amount));
|
|
193
|
+
room += amount;
|
|
194
|
+
return {...a, amount};
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
for (const a of adds) {
|
|
198
|
+
adjustments.push(a);
|
|
199
|
+
if (!a.included) runningTotal += a.amount;
|
|
200
|
+
}
|
|
201
|
+
for (const m of result?.problems || []) problems.push({stage, message: String(m?.message || m).slice(0, 200)});
|
|
202
|
+
for (const m of result?.notes || []) notes.push({stage, message: String(m?.message || m).slice(0, 200)});
|
|
203
|
+
// A shipping handler may offer delivery services to choose between: [{id, label, amount}].
|
|
204
|
+
if (stage === 'shipping' && Array.isArray(result?.choices)) {
|
|
205
|
+
shippingChoices = result.choices.slice(0, 10)
|
|
206
|
+
.filter(c => c && c.id && Number.isFinite(Number(c.amount)))
|
|
207
|
+
.map(c => ({id: String(c.id).slice(0, 40), label: String(c.label || c.id).slice(0, 80), amount: Math.round(Number(c.amount))}));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const total = Math.max(0, runningTotal);
|
|
212
|
+
const tax = adjustments.filter(a => a.stage === 'tax').reduce((s, a) => s + a.amount, 0);
|
|
213
|
+
return {
|
|
214
|
+
currency,
|
|
215
|
+
lines: out,
|
|
216
|
+
problems,
|
|
217
|
+
notes: notes.filter(n => !n.internal),
|
|
218
|
+
internalNotes: notes.filter(n => n.internal),
|
|
219
|
+
subtotal,
|
|
220
|
+
adjustments,
|
|
221
|
+
discount: adjustments.filter(a => a.stage === 'discount').reduce((s, a) => s + a.amount, 0),
|
|
222
|
+
shipping: adjustments.filter(a => a.stage === 'shipping').reduce((s, a) => s + a.amount, 0),
|
|
223
|
+
tax,
|
|
224
|
+
taxIncluded: adjustments.some(a => a.stage === 'tax' && a.included),
|
|
225
|
+
total,
|
|
226
|
+
requiresShipping,
|
|
227
|
+
shippingChoices,
|
|
228
|
+
shippingChoice: adjustments.find(a => a.stage === 'shipping')?.ref || '',
|
|
229
|
+
weight,
|
|
230
|
+
itemCount: out.reduce((s, l) => s + l.qty, 0)
|
|
231
|
+
};
|
|
232
|
+
}
|