kempo-commerce 0.0.0-stage → 0.1.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 +497 -2
- package/admin/carts/index.page.html +20 -0
- package/admin/components/Carts.js +78 -0
- package/admin/components/OrderDetail.js +319 -0
- package/admin/components/Orders.js +127 -0
- package/admin/components/Refund.js +247 -0
- package/admin/components/Settings.js +121 -0
- package/admin/icons/receipt.svg +1 -0
- package/admin/index.page.html +17 -0
- package/admin/nav.global.html +9 -0
- package/admin/order/index.page.html +16 -0
- package/admin/refund/index.page.html +11 -0
- package/admin/settings/index.page.html +22 -0
- package/emails/order-cancelled.page.html +21 -0
- package/emails/order-completed.page.html +22 -0
- package/emails/order-failed.page.html +22 -0
- package/emails/order-note.page.html +13 -0
- package/emails/order-on-hold.page.html +22 -0
- package/emails/order-partial-refund.page.html +13 -0
- package/emails/order-processing.page.html +23 -0
- package/emails/order-refunded.page.html +22 -0
- package/emails/packing-slip.page.html +27 -0
- package/emails/set-password.page.html +14 -0
- package/hooks/email-verified.js +6 -0
- package/hooks/payment-canceled.js +6 -0
- package/hooks/payment-disputed.js +6 -0
- package/hooks/payment-failed.js +6 -0
- package/hooks/payment-refunded.js +6 -0
- package/hooks/payment-succeeded.js +6 -0
- package/install.js +56 -0
- package/jobs/sweep.js +6 -0
- package/kempo-config.json +257 -0
- package/package.json +72 -4
- package/public/account/orders/[id]/index.page.html +13 -0
- package/public/account/orders/index.page.html +14 -0
- package/public/account.global.html +4 -0
- package/public/api/cart/DELETE.js +12 -0
- package/public/api/cart/GET.js +20 -0
- package/public/api/cart/lines/POST.js +16 -0
- package/public/api/cart/lines/[id]/DELETE.js +12 -0
- package/public/api/cart/lines/[id]/PATCH.js +12 -0
- package/public/api/carts/GET.js +13 -0
- package/public/api/checkout/GET.js +20 -0
- package/public/api/checkout/orders/[id]/confirm/POST.js +31 -0
- package/public/api/checkout/place/POST.js +33 -0
- package/public/api/checkout/validate/POST.js +17 -0
- package/public/api/config/GET.js +11 -0
- package/public/api/emails/preview/GET.js +12 -0
- package/public/api/my/orders/GET.js +19 -0
- package/public/api/my/orders/[id]/GET.js +16 -0
- package/public/api/my/orders/[id]/cancel/POST.js +22 -0
- package/public/api/orders/GET.js +16 -0
- package/public/api/orders/[id]/GET.js +14 -0
- package/public/api/orders/[id]/cancel/POST.js +16 -0
- package/public/api/orders/[id]/contact/POST.js +13 -0
- package/public/api/orders/[id]/events/GET.js +14 -0
- package/public/api/orders/[id]/guest/GET.js +13 -0
- package/public/api/orders/[id]/guest/cancel/POST.js +16 -0
- package/public/api/orders/[id]/lines/[lineId]/PATCH.js +15 -0
- package/public/api/orders/[id]/mark-paid/POST.js +16 -0
- package/public/api/orders/[id]/notes/POST.js +16 -0
- package/public/api/orders/[id]/packing-slip/GET.js +15 -0
- package/public/api/orders/[id]/refund/POST.js +26 -0
- package/public/api/orders/[id]/status/PATCH.js +16 -0
- package/public/api/refunds/[id]/finish/POST.js +12 -0
- package/public/api/refunds/preview/POST.js +13 -0
- package/public/api/regions/GET.js +7 -0
- package/public/api/settings/GET.js +19 -0
- package/public/api/settings/PUT.js +25 -0
- package/public/api/summary/GET.js +27 -0
- package/public/cart/index.page.html +13 -0
- package/public/cart.menu-item.html +5 -0
- package/public/checkout/index.page.html +14 -0
- package/public/components/AddQty.js +14 -0
- package/public/components/AddToCart.js +166 -0
- package/public/components/Cart.js +175 -0
- package/public/components/CartButton.js +139 -0
- package/public/components/CartQty.js +25 -0
- package/public/components/Checkout.js +669 -0
- package/public/components/CommerceWidget.js +42 -0
- package/public/components/MiniCart.js +198 -0
- package/public/components/Order.js +202 -0
- package/public/components/Orders.js +85 -0
- package/public/components/QtyPicker.js +162 -0
- package/public/components/Storefront.js +22 -0
- package/public/components/checkoutSections.js +76 -0
- package/public/icons/add_shopping_cart.svg +1 -0
- package/public/icons/ballot.svg +1 -0
- package/public/icons/remove.svg +1 -0
- package/public/icons/remove_shopping_cart.svg +1 -0
- package/public/icons/shopping_cart.svg +1 -0
- package/public/icons/shopping_cart_checkout.svg +1 -0
- package/public/order/[id]/received/index.page.html +11 -0
- package/public/product-actions.global.html +9 -0
- package/public/sdk.js +130 -0
- package/public/storefront.global.html +3 -0
- package/public/utils/amount.js +24 -0
- package/public/utils/format.js +74 -0
- package/public/utils/regions.js +18 -0
- package/sdk.js +66 -0
- package/server/data/regions.json +1 -0
- package/server/db/indexes.js +70 -0
- package/server/db/schema.js +246 -0
- package/server/http/gate.js +115 -0
- package/server/utils/carts/cart.js +369 -0
- package/server/utils/carts/catalog.js +107 -0
- package/server/utils/carts/lineKey.js +18 -0
- package/server/utils/checkout/abuse.js +100 -0
- package/server/utils/checkout/account.js +26 -0
- package/server/utils/checkout/billing.js +54 -0
- package/server/utils/checkout/complete.js +161 -0
- package/server/utils/checkout/credits.js +24 -0
- package/server/utils/checkout/payments.js +122 -0
- package/server/utils/checkout/place.js +246 -0
- package/server/utils/checkout/prefill.js +40 -0
- package/server/utils/checkout/quote.js +71 -0
- package/server/utils/checkout/regions.js +54 -0
- package/server/utils/checkout/stock.js +35 -0
- package/server/utils/checkout/tax.js +115 -0
- package/server/utils/checkout/tenders.js +255 -0
- package/server/utils/checkout/unwind.js +81 -0
- package/server/utils/config/declared.js +15 -0
- package/server/utils/config/settings.js +139 -0
- package/server/utils/config/validate.js +90 -0
- package/server/utils/discounts.js +126 -0
- package/server/utils/events.js +52 -0
- package/server/utils/hooks.js +29 -0
- package/server/utils/ids.js +19 -0
- package/server/utils/optional.js +31 -0
- package/server/utils/orders/contact.js +39 -0
- package/server/utils/orders/data.js +43 -0
- package/server/utils/orders/emails.js +134 -0
- package/server/utils/orders/events.js +37 -0
- package/server/utils/orders/fulfillment.js +67 -0
- package/server/utils/orders/link.js +32 -0
- package/server/utils/orders/links.js +19 -0
- package/server/utils/orders/notes.js +42 -0
- package/server/utils/orders/number.js +39 -0
- package/server/utils/orders/packingSlip.js +47 -0
- package/server/utils/orders/previewEmail.js +61 -0
- package/server/utils/orders/read.js +97 -0
- package/server/utils/orders/status.js +102 -0
- package/server/utils/refunds/issue.js +256 -0
- package/server/utils/refunds/preview.js +149 -0
- package/server/utils/sweeps.js +111 -0
- package/server/utils/totals.js +71 -0
- package/uninstall.js +27 -0
- package/update.js +11 -0
package/README.md
CHANGED
|
@@ -1,3 +1,498 @@
|
|
|
1
|
-
#
|
|
1
|
+
# kempo-commerce
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Carts, orders, checkout and refunds for [kempo](https://github.com/dustinpoissant/kempo). It sells what
|
|
4
|
+
[kempo-products](https://github.com/dustinpoissant/kempo-products) lists, takes payment through
|
|
5
|
+
[kempo-payments](https://github.com/dustinpoissant/kempo-payments), and uses
|
|
6
|
+
[kempo-tax](https://github.com/dustinpoissant/kempo-tax),
|
|
7
|
+
[kempo-store-credit](https://github.com/dustinpoissant/kempo-store-credit) and
|
|
8
|
+
[kempo-credits](https://github.com/dustinpoissant/kempo-credits) when they are installed. The model is
|
|
9
|
+
WooCommerce's core: where this document does not say otherwise, it does what WooCommerce does.
|
|
10
|
+
|
|
11
|
+
It is the store and nothing else: the catalogue (products, options, prices, quantity, categories, search),
|
|
12
|
+
discounts and coupons, shipping, subscriptions and reviews are other extensions, and this one is built
|
|
13
|
+
so they can plug in. [PLAN.md](./PLAN.md) is the design, and the decisions behind it.
|
|
14
|
+
|
|
15
|
+
## Contents
|
|
16
|
+
|
|
17
|
+
1. [Install](#install)
|
|
18
|
+
2. [What it does](#what-it-does)
|
|
19
|
+
3. [Orders and their statuses](#orders-and-their-statuses)
|
|
20
|
+
4. [Guests and accounts](#guests-and-accounts)
|
|
21
|
+
5. [Paying: tenders](#paying-tenders)
|
|
22
|
+
6. [Refunds](#refunds)
|
|
23
|
+
7. [Emails](#emails)
|
|
24
|
+
8. [Building on it: the SDK](#building-on-it-the-sdk)
|
|
25
|
+
9. [Hooks](#hooks)
|
|
26
|
+
10. [Components, pages and addresses](#components-pages-and-addresses)
|
|
27
|
+
11. [HTTP API](#http-api)
|
|
28
|
+
12. [Permissions and groups](#permissions-and-groups)
|
|
29
|
+
13. [Settings](#settings)
|
|
30
|
+
14. [Abuse protection and concurrency](#abuse-protection-and-concurrency)
|
|
31
|
+
15. [Admin](#admin)
|
|
32
|
+
16. [Development and tests](#development-and-tests)
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install kempo-commerce
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Install it from the admin's **Extensions** page. It needs **kempo-products** and **kempo-payments**
|
|
41
|
+
installed and enabled first, and a payment processor set up under Payments. kempo-tax, kempo-store-credit
|
|
42
|
+
and kempo-credits are optional and found when they are installed and enabled.
|
|
43
|
+
|
|
44
|
+
Set the **site address** under Commerce Settings, so the links in emails work. Existing sites need
|
|
45
|
+
`npx drizzle-kit push` to add kempo's `scheduledJob` table: the expiry sweep needs it, and without it
|
|
46
|
+
unpaid orders are only expired when something else notices them.
|
|
47
|
+
|
|
48
|
+
Needs a kempo with scheduled jobs, extension email templates, address aliases and the
|
|
49
|
+
`user:email_verified` hook, and a kempo-products with quantity holds.
|
|
50
|
+
|
|
51
|
+
## What it does
|
|
52
|
+
|
|
53
|
+
- A **cart** for visitors (a random token in a cookie, stored hashed) and for signed-in customers,
|
|
54
|
+
which survives signing in. Every read prices it again from the catalogue and reports what is wrong
|
|
55
|
+
(a price changed, less quantity than asked for, a product no longer for sale) instead of fixing it
|
|
56
|
+
silently. Nothing is reserved by a cart.
|
|
57
|
+
- A **checkout**, for guests and signed-in customers, that prices on the server, adds tax, uses store
|
|
58
|
+
credit and credits, takes a card payment for the rest and creates an **order**.
|
|
59
|
+
- **Orders** with a status, a history of everything that happened, notes, and fulfilment per line.
|
|
60
|
+
- **Order history** for customers, who can cancel an order that is not yet paid.
|
|
61
|
+
- **Refunds**, issued by an administrator, with a warning about credits the customer already spent.
|
|
62
|
+
- **Emails** and administrator notifications, a **mini-cart**, and the pages and components for all
|
|
63
|
+
of it.
|
|
64
|
+
|
|
65
|
+
The browser names a product and a quantity and never an amount. Every price and total comes from the
|
|
66
|
+
server, every write is idempotent, and every step that takes something records how to give it back.
|
|
67
|
+
|
|
68
|
+
## Orders and their statuses
|
|
69
|
+
|
|
70
|
+
WooCommerce's seven statuses:
|
|
71
|
+
|
|
72
|
+
| Status | Means |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `pending_payment` | The order exists, its quantity is held and credit spent, payment has not completed. Expires after `pending_expiry_minutes`, then becomes `failed` and everything is given back |
|
|
75
|
+
| `processing` | Paid, with something still to fulfil |
|
|
76
|
+
| `completed` | Paid and every line is fulfilled or needs nothing. A credit pack goes straight here |
|
|
77
|
+
| `on_hold` | Paused by an administrator or an extension |
|
|
78
|
+
| `cancelled` | Cancelled before it was paid |
|
|
79
|
+
| `refunded` | Refunded in full. A **partial refund keeps the status**, as in WooCommerce |
|
|
80
|
+
| `failed` | Payment failed or expired. Everything it took was given back |
|
|
81
|
+
|
|
82
|
+
Extensions cannot add statuses (every screen and hook listener depends on the list); they can keep their
|
|
83
|
+
own sub-state in `order.data`. A status changes only through the SDK, which checks the move, writes the
|
|
84
|
+
event and fires the hooks. An order's number is sequential (`#1042`; the prefix, padding and first
|
|
85
|
+
number are settings), and its id, not its number, is what addresses and keys use.
|
|
86
|
+
|
|
87
|
+
Each order keeps **what the customer was shown** (`shown`: lines, prices, tax, total, the refund policy
|
|
88
|
+
text) because that answers a dispute and settings change.
|
|
89
|
+
|
|
90
|
+
## Guests and accounts
|
|
91
|
+
|
|
92
|
+
Guest checkout is **on by default**, as in WooCommerce. A guest is not a kempo user: the cart is a cookie,
|
|
93
|
+
the order holds the email and an `orderKey`, and the guest reaches their order at
|
|
94
|
+
`/commerce/order/<id>/received/?key=<orderKey>` (also emailed). A wrong key looks exactly like no
|
|
95
|
+
order.
|
|
96
|
+
|
|
97
|
+
The checkout can **create an account** for a guest, which is **required when the cart has credits for
|
|
98
|
+
sale** (a guest cannot be given credits): it is made with a password nobody knows and the customer gets
|
|
99
|
+
a link to set their own. A guest's earlier orders become theirs when their email is **verified**, never
|
|
100
|
+
on a typed address alone. Guests pay by card only: store credit and credits belong to an account.
|
|
101
|
+
|
|
102
|
+
## Paying: tenders
|
|
103
|
+
|
|
104
|
+
A **tender** is a way an order is paid. Credits, store credit and the card are all the same thing with
|
|
105
|
+
the same life (apply, reverse before the order is finished, refund after), so a failed payment's cleanup
|
|
106
|
+
and a refund are one loop each, and a gift card or wallet extension is a new kind of tender, not a change
|
|
107
|
+
here.
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
import { registerTender } from 'kempo-commerce/sdk';
|
|
111
|
+
|
|
112
|
+
registerTender({
|
|
113
|
+
kind: 'gift-card',
|
|
114
|
+
label: 'Gift card',
|
|
115
|
+
priority: 30, // credits 10, store credit 20, card 1000
|
|
116
|
+
available: async ({ userId, cart, due, currency }) => ({ max: 5000, detail: {} }), // or null
|
|
117
|
+
apply: async ({ order, tender, userId }) => [null, { reference }],
|
|
118
|
+
reverse: async ({ order, tender, userId }) => [null, true],
|
|
119
|
+
refund: async ({ order, tender, amount, refundId, userId }) => [null, { reference }],
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Tenders are applied in `priority` order against what is still due and the card takes the rest. A
|
|
124
|
+
specific amount the customer chose that is no longer available (their balance moved) is refused with a
|
|
125
|
+
409 so they can look again, instead of quietly being charged a different mix.
|
|
126
|
+
|
|
127
|
+
**Placing an order** (`placeOrder`, idempotent on a `checkoutKey`) checks the limits, prices again and
|
|
128
|
+
refuses if the total is not the one the customer saw, creates the account if one was asked for, **takes
|
|
129
|
+
the quantity** (an entry in kempo-products' ledger, `order:<id>`; if there is not enough, no order exists at all), creates the
|
|
130
|
+
order, applies credits then store credit, and creates the card payment for the rest. A failure at any step
|
|
131
|
+
undoes the earlier ones in reverse and fails the order. When nothing is left for a card, the order is
|
|
132
|
+
complete straight away: **this works with no payment processor configured**, which is how a store
|
|
133
|
+
owner tests a checkout (give yourself store credit and buy something).
|
|
134
|
+
|
|
135
|
+
**Completing** happens from the processor's signed webhook (`payment:succeeded`), never the browser's
|
|
136
|
+
word; the browser asks the server to check, and the server asks the processor. It claims the order as paid
|
|
137
|
+
(only one run can, and not an order that already failed or was cancelled and had its quantity returned), grants any credits
|
|
138
|
+
the products give (once, under a key made from the order), marks tenders applied, records
|
|
139
|
+
the tax, decides each line's fulfilment, moves the status and fires `order:paid`. It is safe to run any
|
|
140
|
+
number of times at once. A payment that arrives after the order expired is **refunded in full
|
|
141
|
+
automatically** and the shop is told.
|
|
142
|
+
|
|
143
|
+
A customer who starts a new checkout from the same cart replaces their earlier unpaid one. There is no
|
|
144
|
+
advisory locking: every race is settled by conditional updates (the cart is claimed while an order is
|
|
145
|
+
placed, a status changes only from the status that was read, a refund can only take what is left).
|
|
146
|
+
|
|
147
|
+
## Refunds
|
|
148
|
+
|
|
149
|
+
Refunds are **issued by an administrator** from the order's page, as in WooCommerce core; there is no
|
|
150
|
+
refund request for a customer to file (one can be built as an extension on the `refund:*` hooks).
|
|
151
|
+
|
|
152
|
+
- By **lines and quantities** or by **amount**, never more than was paid and not yet returned. A line's
|
|
153
|
+
share is exact: the last unit takes the rounding.
|
|
154
|
+
- Money goes back **tender by tender**, in proportion to what each still holds: card money to the card,
|
|
155
|
+
store credit to store credit, credits as credits. An administrator can send the card part to **store
|
|
156
|
+
credit** instead (accounts only).
|
|
157
|
+
- **Spent credits are never refunded as money.** If the products gave credits and the customer has
|
|
158
|
+
already spent some, the screen says how many, links to their credits history, and makes the
|
|
159
|
+
administrator choose: take what they hold and leave them at **zero**, or take all of it and leave them
|
|
160
|
+
**owing** the rest. The choice is recorded on the refund and the order's history.
|
|
161
|
+
- **Restock** per refund (default from `restock_on_refund`). Refunding the whole order and restocking all
|
|
162
|
+
of it adds one ledger entry that undoes the order's, which also returns materials to kempo-inventory.
|
|
163
|
+
- Tax is reversed in proportion.
|
|
164
|
+
- If a step fails after an earlier one succeeded the refund is left **partial** with exactly what was
|
|
165
|
+
done and what was not, administrators are notified, and **Try the rest** carries on from there. It is
|
|
166
|
+
never silently half-applied.
|
|
167
|
+
|
|
168
|
+
A refund made in the processor's own dashboard, or a chargeback, is recorded on the order and flagged;
|
|
169
|
+
nothing is changed automatically.
|
|
170
|
+
|
|
171
|
+
## Emails
|
|
172
|
+
|
|
173
|
+
Commerce supplies templates and data; **kempo logs and sends them** (`sendEmailFromTemplate`: kempo
|
|
174
|
+
keeps a log of every email, and an email extension such as kempo-resend or kempo-smtp sends it). WooCommerce's set: processing (received and paid), completed, on hold, failed,
|
|
175
|
+
cancelled, refunded, part refunded, a customer note, and the link to set a password for an account made
|
|
176
|
+
at checkout. A site rewords any of them with `templates/emails/kempo-commerce/<name>.page.html`, and they
|
|
177
|
+
use the site's email layout. Other extensions add to a template's data through
|
|
178
|
+
`kempo-commerce:order:email_context`. A failed send is recorded on the order and never fails it.
|
|
179
|
+
Administrators get kempo **notifications** (new order, refund left partial, a payment for an expired order,
|
|
180
|
+
a flood of refused checkouts), not email.
|
|
181
|
+
|
|
182
|
+
**Editing.** The **Emails** tab of Commerce Settings (`/admin/extension/kempo-commerce/settings/`) lists every template, including the packing slip. **Edit** opens one in a code editor; saving writes the site's copy to `templates/emails/kempo-commerce/` (used from the next email, kept through updates) and **Reset to default** removes it. **Preview** opens a template as it would be sent, in one tab that each preview replaces, using your most recent order (or a made-up one when there are none) and sample wording for a note, a refund and a password link; it shows the saved template, not what is being typed. `GET /commerce/api/emails/preview?template=` (`commerce:settings:manage`). The editor is kempo's `<k-email-templates owner="kempo-commerce" preview-url="/commerce/api/emails/preview">`.
|
|
183
|
+
|
|
184
|
+
**Styling.** Kempo runs every email template through `kempo-css/shake`, which keeps only the kempo-css rules the template uses and embeds them in the email, so the templates are written with kempo-css elements and classes (`<table>`, `<a class="btn primary">`, `.ta-center`, `.ta-right`, `.tc-muted`) and no inline styles. Do the same in a template you override or add.
|
|
185
|
+
|
|
186
|
+
**Packing slip.** The **Packing slip** button on the admin order page opens a page to print: the shop name, order number and date, where it is going, and the items to pack with their quantities and options (no prices, so it can go in a gift). It is an email template, `emails/packing-slip.page.html`, drawn by kempo's email engine and returned as HTML instead of being sent, so it is changed like any email: put your own at `templates/emails/kempo-commerce/packing-slip.page.html`, and it uses the site's email layout. It is drawn from the order each time (nothing is stored), so it always has the address as it is now. Its data is the email data plus `placedDate`, `shipTo` (lines of the address), `hasShipTo`, `packItems` (`name`, `options`, `hasOptions`, `quantity`) and `itemCount`; other extensions add to it through `kempo-commerce:order:email_context`, which gets `template: 'packing-slip'`. `GET /commerce/api/orders/:id/packing-slip` (`commerce:orders:view`).
|
|
187
|
+
|
|
188
|
+
**Emailing a customer.** **Email the customer** on the Customer tab opens a dialog with a subject and a message, sent in the `order-note` template. A sent email is a private line in the order's history ("Emailed the customer: subject", with who sent it); one that could not be sent is recorded as "Email not sent" with the reason.
|
|
189
|
+
|
|
190
|
+
## Building on it: the SDK
|
|
191
|
+
|
|
192
|
+
```javascript
|
|
193
|
+
import { getCart, addToCart, placeOrder, getOrder, setLineFulfillment, refundOrder, registerTender } from 'kempo-commerce/sdk';
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Every function returns `[error, data]`, none throws, and none checks permissions: the routes decide who
|
|
197
|
+
may do what. Anything that changes an order takes an optional `{ actor }` (your extension's name), which
|
|
198
|
+
is written to the order's history.
|
|
199
|
+
|
|
200
|
+
| Function | Purpose |
|
|
201
|
+
|---|---|
|
|
202
|
+
| `resolveCart({ token, userId })`, `getCart({ cartId })` | Find or make the cart, and read it priced with its notices and totals |
|
|
203
|
+
| `addToCart`, `updateCartLine`, `removeCartLine`, `clearCart`, `mergeCarts`, `setCartData`, `listCarts` | Change a cart |
|
|
204
|
+
| `computeTotals`, `collectAdjustments` | The one totals function (the cart, the mini-cart, checkout and the order all use it) |
|
|
205
|
+
| `startCheckout({ cartId, userId, billing, tenders })` | The quote: totals, ways to pay and the split, fields, account rules |
|
|
206
|
+
| `placeOrder({ cartId, userId, checkoutKey, billing, data, tenders, account, expectedTotal, ip })` | Place the order (idempotent) |
|
|
207
|
+
| `completeOrder(id)`, `markPaid(id, { actor, note })` | Finish a paid order; record money taken another way |
|
|
208
|
+
| `failOrder(id, reason)`, `cancelUnpaidOrder(id, { actor })` | Undo an unpaid order |
|
|
209
|
+
| `getOrder(idOrNumber)`, `getOrderByKey(id, key)`, `listOrders({ userId, status, search, from, to })`, `forCustomer(order)` | Read orders |
|
|
210
|
+
| `setOrderStatus(id, status, { actor, note })` | Pause, resume, processing, completed |
|
|
211
|
+
| `setLineFulfillment(id, lineId, 'pending' \| 'done', { actor, data })` | What shipping and delivery call; the order completes when no line waits |
|
|
212
|
+
| `addOrderNote`, `getOrderEvents`, `linkGuestOrders`, `sendOrderEmail` | Notes, history, guest linking, mail |
|
|
213
|
+
| `previewRefund`, `refundOrder`, `finishRefund`, `getRefund` | Refunds |
|
|
214
|
+
| `registerTender`, `unregisterTender`, `listTenders`, `getTender` | Ways to pay |
|
|
215
|
+
| `readConfig`, `runSweep`, `EVENTS`, `orderStatuses` | Settings, the recovery sweep, the hook names, the statuses |
|
|
216
|
+
|
|
217
|
+
### What shipping (and similar) needs
|
|
218
|
+
|
|
219
|
+
| To... | Use |
|
|
220
|
+
|---|---|
|
|
221
|
+
| Collect a delivery address | `checkout:fields` and `checkout:validate`; kept in `order.data` |
|
|
222
|
+
| Offer methods and add the cost | `totals:fees`: a fee adjustment with `source` and `taxable` |
|
|
223
|
+
| Say which lines need sending | `order:line_fulfillment` |
|
|
224
|
+
| Mark things sent | `setLineFulfillment(order, line, 'done', { data: { shipmentId } })`, one call per shipment |
|
|
225
|
+
| Show tracking | the `commerce-account-order-panels` fragment (customer) and a tab on the admin order page (staff, see below) |
|
|
226
|
+
| Change a part of an order later | `setOrderData(orderId, key, value, { actor, summary })`: changes only `order.data[key]` and writes a private history entry |
|
|
227
|
+
| Add a line to the history | `addOrderEvent(orderId, type, { actor, visible, data: { summary } })` |
|
|
228
|
+
| React to a refund | `refund:issued`, and `refund:before_issue` to warn that it already shipped |
|
|
229
|
+
|
|
230
|
+
Nothing here stores a carrier, a tracking number or a rate.
|
|
231
|
+
|
|
232
|
+
### Tabs on the admin order page
|
|
233
|
+
|
|
234
|
+
The admin order page is a set of tabs: **Items**, **Payment**, the tabs other extensions add (kempo-shipping's **Shipping** comes next), **Customer** (billing details, and a form to email the customer) and **History**. An extension adds a tab by putting a `k-commerce-order-tab` in the `commerce-admin-order-tabs` location (and loading its component from `commerce-admin-order-scripts`, outside any template):
|
|
235
|
+
|
|
236
|
+
```html
|
|
237
|
+
<k-commerce-order-tab name="shipping" label="Shipping" position="300"><k-shipping-order></k-shipping-order></k-commerce-order-tab>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`position` sorts the tabs (Items 100, Payment 200, Customer 400, extensions 600 unless set, History 1000). The tab's component loads the order itself with `getOrder` from the `id` in the page address.
|
|
241
|
+
|
|
242
|
+
## Discounts
|
|
243
|
+
|
|
244
|
+
Commerce creates no discounts; extensions (kempo-discounts, kempo-coupons) push them through `totals:discounts`, and commerce
|
|
245
|
+
combines them so every extension follows one rule. A discount says what it is, `{ kind, value, stackable }`, with the line it is on:
|
|
246
|
+
|
|
247
|
+
- the ones that can be combined (`stackable`) all apply together: every **fixed** amount comes off the unit price first, then the **percents are added up** and taken off what is left (a $50 car with $10 off, then 25% and 10%, is $26);
|
|
248
|
+
- one that cannot be combined applies alone, and the line gets whichever saves more, that or the combination;
|
|
249
|
+
- the unit price never goes below the product's **minimum price** (kempo-products' `minimumPrice`), or zero without one; percents round down, and the rows add up to the whole;
|
|
250
|
+
- when the minimum price held a discount back, its rows carry `data.limited` and the cart shows the `price_floor_notice` setting.
|
|
251
|
+
|
|
252
|
+
The cart shows one row per kind of discount, adding up the lines. A discount stays against its line on the order, so tax is worked out on what the customer pays, and **a refund of a line returns what was paid for it**, not its full price. `resolveUnitDiscounts` and `resolveDiscountRows` are exported for an extension that wants the same answer for a storefront price.
|
|
253
|
+
|
|
254
|
+
## Hooks
|
|
255
|
+
|
|
256
|
+
Fired through kempo's `triggerHook`, named `kempo-commerce:<resource>:<event>`. **Guards** run before something
|
|
257
|
+
happens and may change the payload's `draft` or add to a collection; they refuse by throwing
|
|
258
|
+
`{ code, msg }`, which reaches the caller exactly. **Notes** run after the change is committed; a handler
|
|
259
|
+
that throws is logged and stops nothing. The totals hooks run in a fixed order (discounts, fees, tax).
|
|
260
|
+
Every payload carries `actor`: the extension that made the change, or `''` for a person.
|
|
261
|
+
|
|
262
|
+
| Event | Kind | Payload | Lets you |
|
|
263
|
+
|---|---|---|---|
|
|
264
|
+
| `kempo-commerce:cart:before_add` | guard | `{ cart, draft: { productId, quantity, selections, data }, userId, actor }` | change or refuse an add (a minimum, "one per customer", an age-gated product) |
|
|
265
|
+
| `kempo-commerce:cart:item_added` | note | `{ cart, line, actor }` | react |
|
|
266
|
+
| `kempo-commerce:cart:before_update` | guard | `{ cart, line, changes, actor }` | refuse or clamp a quantity change |
|
|
267
|
+
| `kempo-commerce:cart:item_updated` | note | `{ cart, line, previous, actor }` | |
|
|
268
|
+
| `kempo-commerce:cart:item_removed` | note | `{ cart, line, actor }` | |
|
|
269
|
+
| `kempo-commerce:cart:cleared` | note | `{ cart, actor }` | |
|
|
270
|
+
| `kempo-commerce:cart:merged` | note | `{ from, into, userId }` | carry over your own cart data |
|
|
271
|
+
| `kempo-commerce:cart:validate` | guard | `{ cart, notices }` | **add notices** (set `blocking`) for a regional restriction or a mixed-cart rule |
|
|
272
|
+
| `kempo-commerce:cart:expired` | note | `{ cart }` | abandoned-cart emails |
|
|
273
|
+
| `kempo-commerce:totals:discounts` | note | `{ cart or order, lines, adjustments, currency }` | push `{ type: 'discount', code, label, lineId, taxable?, source, data: { kind: 'fixed' | 'percent', value, stackable } }` and **leave out `amount`**: commerce works out every row's amount per line, together (see Discounts). A row that already has an `amount` and no `kind` is used as it is |
|
|
274
|
+
| `kempo-commerce:totals:fees` | note | same, plus `checkout` (`{ billing, data }`) at checkout only | push `{ type: 'fee', ... }`: **shipping lands here**. Read `checkout.data` for what the customer entered; the cart and mini-cart do not get `checkout` |
|
|
275
|
+
| `kempo-commerce:totals:tax` | note | `{ cart or order, taxable, address, tax }` | replace `tax` (kempo-tax has already filled it in) |
|
|
276
|
+
| `kempo-commerce:totals:computed` | note | `{ cart or order, totals }` | read the result |
|
|
277
|
+
| `kempo-commerce:checkout:fields` | guard | `{ cart, fields }` | **add fields to the checkout form** (a delivery address, a gift message, a VAT number): `{ key, label, type, required, hint }` |
|
|
278
|
+
| `kempo-commerce:checkout:validate` | guard | `{ cart, billing, data, errors }` | push `{ field, msg }` to `errors`, or throw `{ code, msg }` |
|
|
279
|
+
| `kempo-commerce:checkout:tenders` | guard | `{ cart, userId, tenders, due }` | offer, limit or remove ways to pay |
|
|
280
|
+
| `kempo-commerce:checkout:before_place` | guard | `{ cart, draft }` | last chance to refuse or change the order draft (`draft.data`, `draft.plan`) |
|
|
281
|
+
| `kempo-commerce:checkout:abandoned` | note | `{ cart, order? }` | reserved |
|
|
282
|
+
| `kempo-commerce:order:before_create` | guard | `{ draft }` | reserved |
|
|
283
|
+
| `kempo-commerce:order:created` | note | `{ order, actor }` | |
|
|
284
|
+
| `kempo-commerce:order:paid` | note | `{ order, tenders, actor }` | **the moment to do the work**: fulfil, license, grant |
|
|
285
|
+
| `kempo-commerce:order:line_fulfillment` | guard | `{ order, line, fulfillment: { value, data } }` | decide whether a line needs fulfilling: set `fulfillment.value` to `none`, `pending` or `done` |
|
|
286
|
+
| `kempo-commerce:order:fulfillment_changed` | note | `{ order, line, previous, actor }` | |
|
|
287
|
+
| `kempo-commerce:order:before_status` | guard | `{ order, from, to, actor }` | refuse a transition |
|
|
288
|
+
| `kempo-commerce:order:status_changed` | note | `{ order, from, to, actor }` | everything else |
|
|
289
|
+
| `kempo-commerce:order:note_added` | note | `{ order, note }` | |
|
|
290
|
+
| `kempo-commerce:order:email_context` | guard | `{ order, template, context }` | add to or alter the data of a customer email (a tracking number, a download link) |
|
|
291
|
+
| `kempo-commerce:order:failed` | note | `{ order, reason }` | |
|
|
292
|
+
| `kempo-commerce:order:cancelled` | note | `{ order, actor, reason }` | |
|
|
293
|
+
| `kempo-commerce:refund:before_issue` | guard | `{ order, refund, preview }` | **add warnings** (`preview.warnings.push(text)`) or **block** (throw `{ code, msg }`) a refund: a fraud signal, a parcel already shipped. Runs for the preview too |
|
|
294
|
+
| `kempo-commerce:refund:issued` | note | `{ order, refund, actor }` | **put things back**: licences, shipments, entitlements |
|
|
295
|
+
| `kempo-commerce:refund:partial` | note | `{ order, refund, done, failed }` | alert: a refund was only partly issued |
|
|
296
|
+
|
|
297
|
+
Hooks this extension listens to: `payment:succeeded`, `payment:failed`, `payment:canceled`,
|
|
298
|
+
`payment:refunded` and `payment:disputed` (kempo-payments) and `user:email_verified` (kempo).
|
|
299
|
+
|
|
300
|
+
## Components, pages and addresses
|
|
301
|
+
|
|
302
|
+
| Element | Module | |
|
|
303
|
+
|---|---|---|
|
|
304
|
+
| `<k-commerce-add-to-cart>` | `/commerce/components/AddToCart.js` | Quantity and the button; registered with kempo-products so every product card and page shows one. Disables with the reason |
|
|
305
|
+
| `<k-commerce-cart-button>` | `CartButton.js` | The cart icon with a count, for the inside of a link to the cart page (`<a href="/cart/">`). A plain click opens the mini-cart; Ctrl, Cmd and middle clicks, and a page with no mini-cart, follow the link. On its own it draws its own link |
|
|
306
|
+
| `<k-commerce-mini-cart>` | `MiniCart.js` | A `k-aside` cart; `open()`, `close()`, `toggle()`, `refresh()`; slots `before-lines`, `after-lines`, `footer`; fires `mini-cart-opened` and `mini-cart-closed` |
|
|
307
|
+
| `<k-commerce-cart-qty>`, `<k-commerce-add-qty>` | `CartQty.js`, `AddQty.js` | The quantity pickers: one bordered control with a "-" button, a number you can type into (so 100 is not 99 clicks) and a "+" button, a whole number from 1 with no maximum, firing `change` (`detail.value`). In the cart the "-" turns into the remove-from-cart icon at 1 and fires `qty-remove` (typing 0 does too); the add-to-cart one just disables it at 1 |
|
|
308
|
+
| `<k-commerce-cart>` | `Cart.js` | The cart page's body; slots `before` and `after` |
|
|
309
|
+
| `<k-commerce-checkout>` | `Checkout.js` | The checkout page. **Light DOM**, because Stripe's fields do not mount inside a shadow root |
|
|
310
|
+
| `<k-commerce-orders>`, `<k-commerce-order>` | `Orders.js`, `Order.js` | Order history, and one order (`received` waits for the payment to be confirmed) |
|
|
311
|
+
|
|
312
|
+
Events (bubbling, composed): `cart-changed`, `cart-line-added`, `mini-cart-opened`, `mini-cart-closed`. Methods
|
|
313
|
+
cause actions; events only say something happened. The browser client is `/commerce/sdk.js`.
|
|
314
|
+
|
|
315
|
+
**The cart button is a menu item.** On kempo's **Menus** page (Admin → Content → Menus), click **Add item** on the
|
|
316
|
+
place that is your navigation and choose **Cart** under kempo-commerce. It is placed, ordered, hidden and removed
|
|
317
|
+
like any other link, and it leaves the site if this extension is disabled. It is a real link, `<a href="/cart/"><k-commerce-cart-button></k-commerce-cart-button></a>`,
|
|
318
|
+
so a nav styles it exactly like its other links (padding, theme link colour, hover) and it goes to the cart page
|
|
319
|
+
even before any script has loaded. A site with no menu places that link itself.
|
|
320
|
+
|
|
321
|
+
By default (`mini_cart_inject`) a script on every public page puts the mini-cart on the page. A site that wants to
|
|
322
|
+
place the mini-cart itself turns that off and uses the element.
|
|
323
|
+
|
|
324
|
+
**"Add to cart" on products** is injected into kempo-products' product cards and product page by
|
|
325
|
+
`public/product-actions.global.html`, which fills that extension's `products-card-actions` and `products-detail-actions`
|
|
326
|
+
locations with `<k-commerce-add-to-cart>` and loads its script (`products-scripts`) on those pages only. The element
|
|
327
|
+
finds its card with `closest('k-products-card')` or its page with `closest('k-products-detail')`. A page of your own that
|
|
328
|
+
embeds a `<k-products-list>` includes `<fragment name="products-card-template" />` once to get the button on its cards.
|
|
329
|
+
|
|
330
|
+
| Page | Address | Short address |
|
|
331
|
+
|---|---|---|
|
|
332
|
+
| Cart | `/commerce/cart/` | `/cart/` |
|
|
333
|
+
| Checkout | `/commerce/checkout/` | `/checkout/` |
|
|
334
|
+
| Order received | `/commerce/order/<id>/received/` | |
|
|
335
|
+
| Order history | `/commerce/account/orders/` | `/account/orders/` |
|
|
336
|
+
| One order | `/commerce/account/orders/<id>/` | `/account/orders/<id>/` |
|
|
337
|
+
|
|
338
|
+
The short addresses are kempo `aliases`: a page the site has of its own at that address always wins.
|
|
339
|
+
Fragments other extensions can fill: `commerce-cart-before`, `commerce-cart-after`,
|
|
340
|
+
`commerce-order-received-extra`, `commerce-account-order-panels`, `commerce-account-order-actions`,
|
|
341
|
+
`commerce-admin-order-panels` (under the Items tab), `commerce-admin-order-actions`, `commerce-admin-order-tabs` and `commerce-admin-order-scripts`.
|
|
342
|
+
|
|
343
|
+
## HTTP API
|
|
344
|
+
|
|
345
|
+
Under `/commerce/api`. `/my/` routes use the session only.
|
|
346
|
+
|
|
347
|
+
| Route | Who |
|
|
348
|
+
|---|---|
|
|
349
|
+
| `GET /cart`, `POST /cart/lines`, `PATCH /cart/lines/:id`, `DELETE /cart/lines/:id`, `DELETE /cart` | anyone (the cart cookie) |
|
|
350
|
+
| `GET /config` | anyone: what a page needs to render |
|
|
351
|
+
| `GET /checkout`, `POST /checkout/validate`, `POST /checkout/place` | anyone with a cart (`commerce:checkout` when signed in). `GET /checkout` also returns `prefill` for a signed-in customer: the name and email of the account, and the phone and address of their last order, which the checkout form starts with |
|
|
352
|
+
| `POST /checkout/orders/:id/confirm` | the order's owner, or a guest with its key: asks the processor |
|
|
353
|
+
| `GET /my/orders`, `GET /my/orders/:id`, `POST /my/orders/:id/cancel` | signed in, their own |
|
|
354
|
+
| `GET /orders/:id/guest?key=`, `POST /orders/:id/guest/cancel` | a guest with the key |
|
|
355
|
+
| `GET /orders`, `GET /orders/:id`, `GET /orders/:id/events`, `GET /orders/:id/packing-slip` (a page to print), `GET /carts`, `GET /summary` | `commerce:orders:view` |
|
|
356
|
+
| `POST /orders/:id/contact` (`{ subject, body }`: emails the customer and notes it in the history), `PATCH /orders/:id/status`, `POST /orders/:id/notes`, `PATCH /orders/:id/lines/:lineId`, `POST /orders/:id/cancel`, `POST /orders/:id/mark-paid` | `commerce:orders:manage` |
|
|
357
|
+
| `POST /refunds/preview`, `POST /orders/:id/refund`, `POST /refunds/:id/finish` | `commerce:refunds:issue` (and `payments:refund` for card money) |
|
|
358
|
+
| `GET /settings`, `PUT /settings` | `commerce:settings:manage` |
|
|
359
|
+
|
|
360
|
+
There is **no route that spends store credit or credits**: checkout does, on the server, inside `placeOrder`.
|
|
361
|
+
|
|
362
|
+
## Permissions and groups
|
|
363
|
+
|
|
364
|
+
| Permission | Lets you |
|
|
365
|
+
|---|---|
|
|
366
|
+
| `commerce:orders:view` | See orders, their history, and open and abandoned carts |
|
|
367
|
+
| `commerce:orders:manage` | Change an order's status, add notes, mark lines fulfilled and cancel orders |
|
|
368
|
+
| `commerce:refunds:issue` | Refund orders. Returning card money also needs payments:refund |
|
|
369
|
+
| `commerce:settings:manage` | Change the commerce settings |
|
|
370
|
+
| `commerce:checkout` | Check out while signed in. Held by every user from the first install, so a site can narrow it |
|
|
371
|
+
|
|
372
|
+
Groups: `kempo-commerce:support` (see orders and carts), `kempo-commerce:manager` (also manage orders and
|
|
373
|
+
refund) and `kempo-commerce:administrator` (everything). `commerce:checkout` is given to `system:Users` on
|
|
374
|
+
the first install only, so a site can narrow it and a reinstall will not hand it back. A guest is not a
|
|
375
|
+
user and needs no permission; `guest_checkout` governs guests.
|
|
376
|
+
|
|
377
|
+
## Settings
|
|
378
|
+
|
|
379
|
+
| Setting | Default | |
|
|
380
|
+
|---|---|---|
|
|
381
|
+
| `site_url` | `http://localhost:3000` | The address of this site, used for the links in emails, such as http://localhost:3000 |
|
|
382
|
+
| `guest_checkout` | `true` | Allow people to check out without an account. A cart with credits for sale in it always needs one |
|
|
383
|
+
| `allow_account_creation` | `true` | Offer to create an account on the checkout form. Always offered, and required, when the cart has credits for sale in it |
|
|
384
|
+
| `cart_expiry_days` | `30` | Days without a change before an open cart counts as abandoned |
|
|
385
|
+
| `cart_purge_days` | `90` | Days after a cart was abandoned before it is deleted |
|
|
386
|
+
| `max_lines_per_cart` | `100` | The most different lines one cart can have |
|
|
387
|
+
| `max_quantity_per_line` | `99` | The most of one product a customer can put on one line |
|
|
388
|
+
| `pending_expiry_minutes` | `60` | How long an order waiting for its payment holds its quantity and any credit spent on it, before it fails and everything is given back |
|
|
389
|
+
| `order_number_prefix` | `#` | Shown before every order number, such as # or INV- |
|
|
390
|
+
| `order_number_padding` | `0` | Pad order numbers with zeros to at least this many digits. 0 does not pad |
|
|
391
|
+
| `order_number_start` | `1000` | The number of the first order. Changing it later only affects orders placed after |
|
|
392
|
+
| `order_number_random` | `false` | Give each order a random 8-digit number instead of 1001, 1002 and so on, so nobody can tell how many orders the shop has had or guess another order's number. The prefix still applies; the padding and first number do not |
|
|
393
|
+
| `restock_on_refund` | `true` | Whether refunded products go back into quantity by default. It can be changed for each refund |
|
|
394
|
+
| `refund_policy` | `(blank)` | Shown to the customer at checkout, and kept with the order as what they agreed to |
|
|
395
|
+
| `mini_cart_side` | `right` | Which side of the page the mini-cart opens from: left or right |
|
|
396
|
+
| `mini_cart_open_on_add` | `false` | Open the mini-cart when something is added to the cart. Off by default: the "Added to your cart" toast confirms an add, and the cart is one click away |
|
|
397
|
+
| `mini_cart_inject` | `true` | Put the mini-cart on every public page. Turn off to place <k-commerce-mini-cart> yourself. The cart button is not placed for you: add the **Cart** item to a menu |
|
|
398
|
+
| `price_floor_notice` | A discount on this order was limited by the minimum price of a product. | Shown in the cart under the totals when a product's minimum price (kempo-products) held a discount back |
|
|
399
|
+
| `add_to_cart_toast_action` | `mini-cart` | The **View cart** button on the confirmation shown after adding to the cart: `mini-cart` opens the mini-cart (the cart page when the page has none), `cart-page` goes to the cart page, `none` shows no button |
|
|
400
|
+
| `mini_cart_hide_on_cart_pages` | `true` | Do not show the mini-cart on the cart page itself, which shows the same thing. It stays available on the checkout page, so an item can be changed or added without leaving |
|
|
401
|
+
| `purchase_limit_per_user` | `10` | Checkout attempts one account can make in an hour. 0 turns the limit off |
|
|
402
|
+
| `purchase_limit_per_ip` | `20` | Checkout attempts one address can make in an hour. 0 turns the limit off |
|
|
403
|
+
| `purchase_limit_global` | `500` | Checkout attempts the whole site accepts in an hour. 0 turns the limit off |
|
|
404
|
+
| `failed_payment_limit` | `3` | Declined payments from one account or address, within the cool-down, before further attempts are refused. 0 turns it off |
|
|
405
|
+
| `failed_payment_cooldown_minutes` | `60` | How long declines are counted for, and how long attempts are refused once the limit is reached |
|
|
406
|
+
| `trust_proxy` | `false` | Take the visitor's address from X-Forwarded-For. Turn on only when the site is behind a proxy you run: with none, the header is whatever the visitor sent |
|
|
407
|
+
|
|
408
|
+
## Abuse protection and concurrency
|
|
409
|
+
|
|
410
|
+
Selling cheap things to strangers invites card testing, and guest checkout removes "signed-in buyers only" as a
|
|
411
|
+
defence, so: **rate limits** per account, per address and site-wide per hour; a **decline cool-down**
|
|
412
|
+
(after N declines from one account or address, further attempts are refused until it passes, and the payment
|
|
413
|
+
that hit the limit is cancelled at the processor); a log of every refused attempt and a notification
|
|
414
|
+
as the hour's count reaches 1, 10, 100. `trust_proxy` takes the address from `X-Forwarded-For`, only behind a
|
|
415
|
+
proxy you run. Declines are counted from `payment:failed`.
|
|
416
|
+
|
|
417
|
+
Placing an order, completing it, refunding it and every status change are conditional updates, so two
|
|
418
|
+
tabs, a redelivered webhook, the sweep and an administrator can all act at once and each thing happens
|
|
419
|
+
once. A recovery sweep runs every minute (kempo's scheduler): it fails unpaid orders that ran out of time
|
|
420
|
+
(asking the processor first), finishes paid ones that never completed, records tax that was missed,
|
|
421
|
+
abandons and eventually deletes old carts, and releases expired holds.
|
|
422
|
+
|
|
423
|
+
## Admin
|
|
424
|
+
|
|
425
|
+
Under **Commerce** in the admin: **Orders** (search by number, name or email; filter by status and date),
|
|
426
|
+
the **order** page (everything and everything that happened; mark lines sent, notes private or shown to the
|
|
427
|
+
customer, pause, cancel, record a payment taken another way, refund), the **refund** page, **Carts** (open
|
|
428
|
+
and abandoned, read-only), and **Commerce Settings**, last in the Commerce group. A dashboard widget shows orders and
|
|
429
|
+
revenue today and this week.
|
|
430
|
+
|
|
431
|
+
## The checkout page, and adding to it
|
|
432
|
+
|
|
433
|
+
One page and one button. Down the left is an accordion, one panel open at a time: **Contact** (name, email, an account when one is wanted, and the extra fields extensions ask for), **Billing address** (country, address, and the tax it sets), **Store credit and credits** (when the customer has either), and **Payment** (the card for whatever is left, shown only when something is left to pay). On the right: the order, the refund policy, anything an extension puts under the summary, and **Place order**. Pressing it makes the order, makes the card payment and charges the card in the same step; a declined card leaves the order saved and the form in front of the customer to try again. The card fields are `<k-pay-form deferred>` from kempo-payments (needs `kempo-payments >=0.3.0`).
|
|
434
|
+
|
|
435
|
+
Another extension (a shipping address, a coupon box, a sales banner) adds to the page with `registerCheckoutSection`:
|
|
436
|
+
|
|
437
|
+
```javascript
|
|
438
|
+
import { registerCheckoutSection } from '/commerce/components/checkoutSections.js';
|
|
439
|
+
|
|
440
|
+
const TAG = 'k-shipping-address';
|
|
441
|
+
customElements.define(TAG, ShippingAddress);
|
|
442
|
+
registerCheckoutSection(TAG, { title: 'Shipping address', priority: 300 });
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
| Option | Meaning |
|
|
446
|
+
|---|---|
|
|
447
|
+
| `title` | The panel's heading. Required for a panel |
|
|
448
|
+
| `priority` | Where the panel goes, highest first. Contact is 500, billing 400, credits 200 and payment 100, so 300 lands between billing and credits |
|
|
449
|
+
| `id` | Names the panel, default the tag. `contact`, `billing`, `credits` and `payment` are reserved |
|
|
450
|
+
| `where` | `panel` (default) for an accordion panel, or `summary` for an element under the order summary, above the button |
|
|
451
|
+
|
|
452
|
+
The element is handed `checkout`, the page: `checkout.quote` (the server's cart, totals, tenders and plan),
|
|
453
|
+
`checkout.form` (the contact and billing so far), `checkout.reprice()` (ask the server for the totals again,
|
|
454
|
+
after something changed that they depend on), and `checkout.setData(key, value)` (a value sent with the
|
|
455
|
+
order as `data`, which the `checkout:validate` guard receives). What a section does to the price goes
|
|
456
|
+
through the cart's adjustments on the server, not through the browser.
|
|
457
|
+
|
|
458
|
+
**Country and state** are pickers, filled from `server/data/regions.json` (ISO 3166-1 countries and the first level of ISO 3166-2, from the MIT-licensed [iso3166-2-db](https://www.npmjs.com/package/iso3166-2-db)). To rebuild it: `npm pack iso3166-2-db`, unpack `i18n/en.json`, and run `node scripts/build-regions.js path/to/en.json`. The state is stored as its code (`VT`); a country with no states listed takes free text. `GET /commerce/api/regions` serves the lists, and the server refuses a country or state that is not on them.
|
|
459
|
+
|
|
460
|
+
**What tax asks of the address.** With kempo-tax on, the country is required, plus a state or province for the United States and Canada and a postal code for the United States (`needsFor` in `server/utils/checkout/tax.js`: add a country there when its tax turns out to depend on more). The page marks those required, the tax is not asked for until they are filled in, and **Place order** stays disabled until every required field, and the card, is complete.
|
|
461
|
+
|
|
462
|
+
**The account page.** The customer's **Order history** (`/account/orders/`, with each order a click away) is linked from their account page. That works because the account page has two places an extension can add to, `account-details` (lines such as a balance) and `account-links` (buttons), which kempo's starter account page has and any site's copy of it can be given with `<location name="account-links" />`.
|
|
463
|
+
|
|
464
|
+
**Leaving the checkout** is never blocked: there is a **Back to cart** button, the whole site navigation stays, and the side cart opens there so an item can be changed or added without leaving (the checkout follows it).
|
|
465
|
+
|
|
466
|
+
## Where an extension goes in the admin menu
|
|
467
|
+
|
|
468
|
+
The menu is ordered by each entry's `priority` (higher first), and a group sits where its first entry does, so
|
|
469
|
+
the four commerce groups are bands of priority numbers. An extension joins a group by naming it and picks a
|
|
470
|
+
number in its band:
|
|
471
|
+
|
|
472
|
+
| Group | Priorities | In order |
|
|
473
|
+
|---|---|---|
|
|
474
|
+
| Commerce | 95-99 | Orders 99, Carts 98, Shipping 97 (kempo-shipping), Tax 96, Commerce Settings 95 |
|
|
475
|
+
| Products | 85-89 | Products 89, Category 88, Inventory 87, Connect Products to Inventory 86, Product Settings 85 |
|
|
476
|
+
| Payments | 74-79 | Payments 79, Credits 78, Store Credits 77, Payouts 76, Disputes 75 (kempo-payments), Transaction History 74 |
|
|
477
|
+
| Marketing | 66-69 | Sales and Discounts 69 (kempo-discounts), Coupons 68 (kempo-coupons), Affiliates 67 (kempo-affiliates), Promotional Credit Links 66 |
|
|
478
|
+
|
|
479
|
+
An entry sets `group="Payments"` and, on at least one entry of a group, `group-icon="credit_card"` for the
|
|
480
|
+
menu's icon (kempo's own groups get theirs from the aside).
|
|
481
|
+
|
|
482
|
+
## Development and tests
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
npm install
|
|
486
|
+
npm run link:local # links the sibling checkouts (kempo, kempo-products, kempo-payments, ...) into node_modules
|
|
487
|
+
npm test # the unit tier: an in-memory Postgres (PGlite), nothing else needed
|
|
488
|
+
docker compose up -d && npx drizzle-kit push --force # the integration tier's database (port 5453)
|
|
489
|
+
DATABASE_URL=postgres://kempo:kempo@localhost:5453/kempo_commerce_test npm run test:integration
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
To look at it in a browser, install it in kempo-demo (the one site every extension is tried on).
|
|
493
|
+
|
|
494
|
+
The suites cover the cart, checkout (the zero-total path, credits, store credit, a card, expiry, a late
|
|
495
|
+
payment, concurrency), tax, refunds (the credits decisions, a refund that fails halfway), the HTTP layer and
|
|
496
|
+
the permission contract for every audience, emails, the lifecycle and the extension points from the outside.
|
|
497
|
+
The card in the unit tier is a stand-in for contacting Stripe and nothing else; `stripe.node-test.js` runs the
|
|
498
|
+
real thing and **says it was skipped** unless Stripe test-mode keys are set.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
locked: true
|
|
3
|
+
-->
|
|
4
|
+
<page title="Carts - Admin">
|
|
5
|
+
<content>
|
|
6
|
+
<script type="module" src="/kempo-ui/components/Icon.js"></script>
|
|
7
|
+
<script type="module" src="/admin/extension/kempo-commerce/components/Carts.js"></script>
|
|
8
|
+
|
|
9
|
+
<div class="d-f mb" style="align-items: center; gap: var(--spacer);">
|
|
10
|
+
<a href="/admin/extension/kempo-commerce/" class="btn"><k-icon name="arrow" direction="left"></k-icon> Orders</a>
|
|
11
|
+
<h1 class="flex mb0">Carts</h1>
|
|
12
|
+
</div>
|
|
13
|
+
<p class="tc-muted">
|
|
14
|
+
Open carts are what people are shopping with right now. Abandoned ones were left alone for longer than
|
|
15
|
+
the cart expiry setting. Nothing here can be changed.
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<k-commerce-admin-carts></k-commerce-admin-carts>
|
|
19
|
+
</content>
|
|
20
|
+
</page>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import ShadowComponent from '/kempo-ui/components/ShadowComponent.js';
|
|
2
|
+
import { html, nothing } from '/kempo-ui/lit-all.min.js';
|
|
3
|
+
import '/kempo-ui/components/Spinner.js';
|
|
4
|
+
import { listCarts } from '/commerce/sdk.js';
|
|
5
|
+
import { formatDate } from '/commerce/utils/format.js';
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Open and abandoned carts, read-only, served at /admin/extension/kempo-commerce/components/Carts.js.
|
|
9
|
+
Abandoned carts are what a later "abandoned cart" extension would email about.
|
|
10
|
+
*/
|
|
11
|
+
export default class AdminCarts extends ShadowComponent {
|
|
12
|
+
static properties = {
|
|
13
|
+
status: { state: true },
|
|
14
|
+
items: { state: true },
|
|
15
|
+
total: { state: true },
|
|
16
|
+
loading: { state: true },
|
|
17
|
+
error: { state: true },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
constructor(){
|
|
21
|
+
super();
|
|
22
|
+
this.status = 'open';
|
|
23
|
+
this.items = [];
|
|
24
|
+
this.total = 0;
|
|
25
|
+
this.loading = true;
|
|
26
|
+
this.error = '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
connectedCallback(){
|
|
30
|
+
super.connectedCallback();
|
|
31
|
+
this.load();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
load = async () => {
|
|
35
|
+
this.loading = true;
|
|
36
|
+
const [error, data] = await listCarts({ status: this.status, limit: 50 });
|
|
37
|
+
this.loading = false;
|
|
38
|
+
if(error){
|
|
39
|
+
this.error = error.msg;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.error = '';
|
|
43
|
+
this.items = data.items;
|
|
44
|
+
this.total = data.total;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
show = status => {
|
|
48
|
+
this.status = status;
|
|
49
|
+
this.load();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
render(){
|
|
53
|
+
return html`
|
|
54
|
+
<div class="btn-grp mb">
|
|
55
|
+
<button class=${this.status === 'open' ? 'primary' : ''} @click=${() => this.show('open')}>Open</button>
|
|
56
|
+
<button class=${this.status === 'abandoned' ? 'primary' : ''} @click=${() => this.show('abandoned')}>Abandoned</button>
|
|
57
|
+
</div>
|
|
58
|
+
${this.error ? html`<p class="tc-danger" role="alert">${this.error}</p>` : nothing}
|
|
59
|
+
${this.loading ? html`<k-spinner></k-spinner>` : nothing}
|
|
60
|
+
${!this.loading && !this.items.length ? html`<p class="tc-muted">No ${this.status} carts.</p>` : nothing}
|
|
61
|
+
${this.items.length ? html`
|
|
62
|
+
<div class="table-wrapper">
|
|
63
|
+
<table>
|
|
64
|
+
<thead><tr><th>Who</th><th>Items</th><th>Last touched</th></tr></thead>
|
|
65
|
+
<tbody>${this.items.map(cart => html`<tr>
|
|
66
|
+
<td>${cart.userId ? html`<code>${cart.userId}</code>` : html`<span class="tc-muted">Visitor</span>`}</td>
|
|
67
|
+
<td>${cart.lines.reduce((sum, line) => sum + line.quantity, 0)} item${cart.lines.reduce((sum, line) => sum + line.quantity, 0) === 1 ? '' : 's'} in ${cart.lines.length} line${cart.lines.length === 1 ? '' : 's'}</td>
|
|
68
|
+
<td>${formatDate(cart.updatedAt)}</td>
|
|
69
|
+
</tr>`)}</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
</div>
|
|
72
|
+
<p class="tc-muted small mt">${this.total} ${this.status} cart${this.total === 1 ? '' : 's'}.</p>
|
|
73
|
+
` : nothing}
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
customElements.define('k-commerce-admin-carts', AdminCarts);
|