Customer accounts
KahunaCart adds a customer account area at {base}/account with order history, printable invoices, download links and an address book. This page is for store administrators setting it up and deciding where customer identities live.
Before you begin
-
Install and enable the Login plugin. It handles the login form, forgot-password, magic links, two-factor and remember-me. KahunaCart relies on it for authentication.
BASHbin/gpm install login -
Nothing else is required. The account routes appear as soon as the Login plugin is enabled.
Account models
A customer can have an account in one of two ways. account.db_identity decides which.
Store accounts (account.db_identity: true, the default) |
Grav accounts (account.db_identity: false) |
|
|---|---|---|
| Where credentials live | kahunacart_customer_auth, in the store database |
user/accounts/<username>.yaml |
| Who creates them | A checkout opt-in, or the Customers screen in the admin | The Login plugin's registration form, or you |
| Username | Their email address, lowercased | Whatever the Login plugin allows |
| Where you manage them | KahunaCart's Customers screen | Grav's admin Users screen |
| How commerce history is joined | The auth row is keyed to the customer row | kahunacart_customers.user_id holds the Grav username |
Both models use the Login plugin's front end and all of its features. Your admin accounts are unaffected either way: a Grav account wins every lookup, and a store account is denied every permission outside the site. namespace. With account.db_identity on, the store database also holds customers' passwords, 2FA secrets and sign-in tokens.
Important
KahunaCart never writes to a user account YAML (user/accounts/*.yaml). With account.db_identity on, no account file is created for a customer at all.
Store accounts
Turn on store accounts
- Set
account.db_identitytotrue. This is the default. - Set
account.checkout_registrationtotrueto offer guests an account at checkout. - Set
account.invite_expiry_daysto the number of days a set-password link stays clickable. The default is7. - Run
bin/plugin kahunacart migrateif your database is not on the auto-migrate policy. This adds thekahunacart_customer_authtable.
account:
db_identity: true
checkout_registration: true
invite_expiry_days: 7
Customers can now sign in against the store database.
Important
Leave the Login plugin's user_registration.enabled off. Its registration form validates usernames against system.username_regex, whose default ^[a-z0-9_-]{3,16}$ rejects an email address, and it applies its own groups and access. Turning it on gives you YAML customer accounts again.
Registration at checkout
A guest whose email address has no account yet gets one checkbox on the checkout form:
☐ Create an account so I can see my orders and check out faster next time
The checkbox ships unchecked, and there is no password field. The account is opened at order completion, not at submit, so a declined card or an abandoned cart leaves no account behind. At completion an email goes out:
Your account is ready — Choose a password and your orders, invoices and downloads will all be in one place next time you visit.
The email is queued like every other order email, and the worker sends it (bin/plugin kahunacart work, or the scheduler).
The offer on the order page
account.order_page_offer (default true) repeats the offer for a guest who did not take the checkout opt-in. A panel under their order invites them to create an account with the address on it, and both order emails carry the same line, linking to {base}/order/{hash}#kahunacart-account-offer.
Pressing the button opens the account and sends the same set-password email. No password is asked for, and no order is moved: the account is keyed to the customer who already owns every order and download grant under that address.
The panel is shown only to a visitor who is not signed in and whose customer has no account yet. A customer who already registered gets a one-line "Sign in to manage your purchases" instead. An address a Grav account already holds is refused, as at checkout.
The offer is limited to three requests an hour per order, in the account_offer bucket. Change it under security.rate_limits.account_offer. See Hardening.
Set account.order_page_offer to false to remove the panel, the sign-in line and the line in both emails. It needs account.db_identity, as the checkout opt-in does.
Important
If your theme overrides kahunacart-order.html.twig, port the block across and keep its id="kahunacart-account-offer". That anchor is what the order emails link to.
The set-password link
The set-password link opens KahunaCart's own "Choose your password" page at {base}/password/{token}. It needs no Login plugin page. The password is validated against system.pwd_regex, and saving it signs the customer in through the Login plugin. Opening the page spends nothing; the token is spent by the save.
The account cannot be signed into until the link is used. If the link expires, the ordinary forgot-password form regenerates it, and so does Send set-password link in the admin.
What the customer can do
Everything the Login plugin offers, with no configuration beyond enabling each feature in the Login plugin itself.
| Feature | Works | Notes |
|---|---|---|
| Password sign-in | Yes | The email address is the username. |
| Forgot password | Yes | The Login plugin's own form, route and email. |
| Magic / email-link sign-in | Yes | Needs plugins.login.magic_link.enabled. |
| Remember me | Yes | Tokens live in user-data://rememberme/. |
| Two-factor | Yes | Enrolled from the Login plugin's profile page, with plugins.login.twofa_enabled on. |
| Profile edit | Partly | The Login plugin's /user_profile page. See below. |
| Registration form | No | Registration is checkout's, or the admin's. |
| Invitations | No | The Login plugin's invitation flow creates YAML accounts. |
On the profile page, the full name goes to the customer record; the language, the password and the 2FA fields go to the account row. The email address is not editable there, because it is the username. Writes to email, username, access and groups are dropped.
Managing accounts in the admin
Store accounts never appear in Grav's admin Users list. Manage them in KahunaCart's Customers section.
- Open Customers and select a customer.
- Read the Sign-in account panel for their current state.
- Use one of the three actions to change it.
| State | Meaning |
|---|---|
| No account | A guest. They have bought, but cannot sign in. |
| Invited | The account exists and has no password yet. The set-password link has not been used. |
| Active | They can sign in. |
| 2FA on | Active, with an authenticator enrolled. |
| Disabled | Sign-in refused. An open session ends on their next page load. |
The three actions need the kahunacart.customers.manage permission, which is separate from kahunacart.customers.view.
- Send set-password link opens the account if there is none, then queues the same email the checkout opt-in queues. Use it for an invitation that expired or went to spam, or for a guest who now wants an account. A second link invalidates the first.
- Clear 2FA destroys the secret for a customer who lost their phone. They sign in with their password and enroll a new device from the profile page.
- Disable / enable switches sign-in off and on. Orders, invoices and download links are untouched.
One address, one identity
A Grav account and a customer record can share an email address. The Grav account then wins every lookup, so no store account is ever created for that address.
- At checkout, the opt-in is ignored when the address already belongs to a Grav account. The order completes normally and a line goes into the log.
- In the admin, Send set-password link answers "A Grav user account already uses this email address. That account signs in instead."
If both identities are the same person, sign in with the Grav account. Turn on account.claim_by_email (it is off by default) and it hands them their history.
How store accounts work
KahunaCart decorates the $grav['accounts'] container service with a proxy that resolves Grav and Flex accounts first and falls through to the store database only when nothing answers. Every credential path in the Login plugin reaches its user through that one service. Three side effects follow.
$grav['accounts']is no longer aFlexCollectionInterfaceon a site running Flex accounts, so a third-party plugin checking that interface changes path.$grav['accounts']becomes iterable on a site running file accounts, so code branching onis_iterable()changes path. The proxy's iterator yields Grav accounts first and store customers after./api/users/{email}finds customers. Three defences stack: a store account is denied every action outsidesite., soadmin.login,admin.super,api.accessandapi.superare unreachable; the API gate refuses a user without those permissions; and a write through a user object dropsaccess,groups,usernameandemail.
Turning account.db_identity off removes the decoration. The site reverts to Grav accounts, and the customer rows sit unused.
Grav accounts and the email claim
This applies to Grav accounts: staff who also buy, customers from before account.db_identity, and any store that turns it off. A customer who checked out as a guest and registered afterwards has order history with no user_id on it, so resolution runs in two steps.
- By username:
user_idequals the logged-in username. Once a claim exists, this is the only lookup that runs. - By email: the account's email matches a customer record that no other account has claimed, and that has no store sign-in of its own. On a match,
user_idis stamped and the claim is permanent.
Step 2 is governed by account.claim_by_email, which is off by default. With it off, a Grav account sees the orders it places while signed in, and checkout ties it to the record for its own address. What it doesn't get is the history of a guest record that was there before. A record another account already holds is never reassigned, and neither is a record with a store sign-in. An account with no customer record yet sees an empty state on every account page.
Email claim trade-off
The email claim trusts that the email address on a Grav account belongs to the person holding it. That is true only when both halves are checked. Turn on send_activation_email and set_user_disabled in the Login plugin's registration options, so an account only exists once someone has clicked a link in that mailbox. But Grav also lets a signed-in user change the email on their profile without verifying the new one. Until your site checks both, the address on a Grav account proves nothing. That is why account.claim_by_email is off by default.
Warning
With the claim on and the address unproven, anyone who knows a customer's email address can register an account with it, or change their profile to it, and inherit that customer's guest order history: order totals, delivery addresses, and any download links still live on those orders.
With the claim off, existing customers keep their history only from the point they buy something while logged in.
If your registration and your profile changes both verify the address, set account.claim_by_email to true, or turn it on under Settings → Accounts. A store that relied on Grav accounts picking up their guest history needs to turn it back on after updating.
Typing an address at checkout never takes over a record. The order goes to the customer the typed address names, the same as for a guest. But a signed-in account is tied to that customer only when the address typed is the account's own. An account that types somebody else's address claims nothing, so that person's orders, addresses, downloads and licence keys never show up in its account area. For the account's own address, an existing guest record is claimed at checkout only when claim_by_email is on.
account.claim_by_email does not apply to store accounts. Their auth row is keyed to their customer row, and the address is proved by the set-password link.
Grav user groups decide a customer group
customers.group_map maps a Grav user group to a KahunaCart customer group, so a store whose memberships already live in Grav keeps one list rather than two. It is read at sign-in, after the customer record has been resolved, and the customer is added to the group of every entry whose Grav group the user belongs to.
A sign-in never takes a group away, and an account that has never ordered gets its group on the first sign-in after a customer record exists. The map is empty by default. See Map a Grav user group to a customer group.
Pages only a group can see
Every customer group a customer is in is also a permission: site.customer_group.<code>. Put it in a page's access: header and only customers in that group can open the page.
---
title: Member downloads
access:
site.customer_group.members: true
---
In Admin Next, pick it from the page's Security → Access picker instead, where every group is listed under Customer groups. It needs KahunaCart 1.3.5 or later.
The Login plugin does the checking, the same way it checks any access: header. A visitor who isn't signed in is sent to the login page, and a signed-in customer outside the group gets its access-denied page.
This is how a page becomes subscriber-only. Pick the group as a plan's Customer group while subscribed in the Subscriptions add-on, and subscribers are in it for as long as their subscription is live. Subscriptions → Access for subscribers walks through a private Downloads Pro page, including blocking direct access to its files.
Some details to keep in mind:
- It follows the groups a customer is actually in, ticked on their record or put there by an add-on. The store's default group gives no permission. A customer in no group is priced as the default, but if the default counted here, a store with a default group would open every "members" page to every signed-in customer.
- It's worked out on every request. Put a customer in a group and the page opens on their next click. Take them out, or let their subscription lapse, and it closes on the next one. Nothing about it is stored on the account.
- It never reaches past
site.A store account is refusedadmin.*andapi.*before any group is looked at, whatever group it's in. - Grav accounts are managed with Grav's own groups. Staff who buy with their Grav account, and every customer on a store with
account.db_identityoff, are Grav accounts, and KahunaCart never writes to those. To let one in, give a Grav user group the same permission (site.customer_group.members: truein its access) and put the account in that group. Super users see every page anyway.
Articles with a teaser
Everyone can open an article, and everyone sees its headline and opening paragraphs. Anyone who isn't a subscriber sees a subscribe panel where the rest would be. A subscriber sees the whole article at the same address. This is how a newspaper, or a paid newsletter with a website, works. It needs KahunaCart 1.3.8 or later.
The rule behind all of it: a visitor who can't read the article never receives its body. It isn't in the HTML and hidden with CSS, it isn't in the feed, and it isn't in a search snippet. The full text is never sent.
Whole-page lock or teaser
There are two ways to put a page behind a group, and they do different things.
access:locks the whole page. A guest is sent to the sign-in page and a signed-in customer outside the group gets access denied. Nothing of the page is visible. See Pages only a group can see.paywall:shows a teaser. The page stays open to everyone, and the group decides how much of it a visitor gets.
A page can carry both. The Login plugin checks access: first, so a visitor it turns away never reaches the paywall.
Marking a page
Put the group's code in the page's paywall: header and cut the article with a === line where the free part ends.
---
title: Apple skips high-end M6 chips
paywall: premium
---
Opening paragraphs everyone reads.
===
The rest, for subscribers only.
In Admin Next, the page editor has a Paywall field beside Access in the Security tab. Pick one or more customer groups there and it writes the header for you. A store with no customer groups yet sees a note with a link to create one, instead of an empty picker.
A list means any of them: paywall: [premium, trade] opens the article to a customer in either group. A group counts the same whether it's ticked on the customer's record or granted while a subscription is live, so an article opens the moment a subscription starts and closes when it stops. Nothing is stored on the account.
A page can carry its own settings by using the long form.
paywall:
groups: [premium]
subscribe: /shop/product/premium-membership # where the panel's Subscribe button goes, instead of the plans
teaser: 3 # how many blocks to show when there is no === line
meter: false # never a free read, whatever the store's meter says
schema_type: NewsArticle # the structured data type; Article by default
A group code the store doesn't have is ignored and noted in the Grav log. A page that names only groups that don't exist can be read by editors and nobody else, since giving the article away over a typo is the worse mistake.
What each visitor sees
A visitor who can read the article gets the page exactly as their theme draws it, on the page's own template.
Everyone else gets the page's headline, the teaser and the panel, drawn by the kahunacart-paywall template. KahunaCart switches the page to that template for that visitor on that request. It works whatever template the page names, so a theme that has never heard of the paywall still can't print the body to a visitor who shouldn't have it.
Editors see everything, so they can preview an article. That means anyone with admin.super, api.super or admin.pages, and anyone in Admin Next who holds both api.access and api.pages.write.
Where the teaser ends
The teaser is the part of the page above the === line. It's cut from the HTML Grav already built, so there's no second pass over the Markdown.
With no === line, the teaser is the first two blocks (paywall.teaser_paragraphs, or the page's own teaser). A block is one paragraph, or one list, quote or table, so nothing is cut in half. Headings ride along with the block after them and never end the teaser. An article with no more blocks than the limit still keeps its last block back, since a paywall that hides nothing is a mistake.
Write the free part so it works alone. It's what a search engine, a feed reader and a shared link show.
The subscribe panel
The panel says why the rest is locked, lists what sells access, and gives an existing subscriber a way in. It shows:
- a heading and one line of copy,
- a Subscribe button for each plan that grants one of the page's groups, with its price,
- Already a subscriber? Sign in.
The plans come from add-ons. KahunaCart asks them with the onKahunaCartGroupOffers event, once per page per request and only when the panel is going to be shown. The Subscriptions add-on answers with every published subscription product whose plan grants one of the page's groups, priced the way its product page prices it. A page's own paywall.subscribe link replaces the list, and the store's paywall.subscribe_url is the fallback when nothing answers. With none of those, the panel shows only the sign-in link, and the Grav log says so.
The Subscribe links carry ?from= and the article's route. KahunaCart keeps it in the session only when it names a paywalled page on this site, checked the same way ?return= is. When the customer's order is paid, the receipt shows Continue reading: the article's title once. If the subscription hasn't started yet because the payment's webhook is still on its way, the link still goes to the article, and it opens as soon as the group is granted. KahunaCart starts no session and sets no cookie for a visitor who only reads a teaser.
The sign-in link goes to the Login plugin's page, which remembers the article the visitor came from and sends them back to it. The store's own sign-in-by-email page can only return to a storefront page.
Free articles (the meter)
A store can let visitors who aren't subscribers read a few articles in full each month before the teaser applies. It's off by default, and with it off nothing is written and no cookie is set.
paywall:
meter:
free: 3 # articles per period; 0 is off, 50 is the most
period: month # month or week, by the calendar in the store's timezone
who: everyone # everyone, or accounts
In Admin Next these are under Settings → Accounts → Articles with a teaser.
- A read is one article in the period. Opening the same one again costs nothing.
- Only visitors who can't already read the article are counted.
- A metered read shows the whole article with a slim bar above it: "2 free articles left this month. Subscribe". That's on the page's HTML; a
.jsonor other format gets the article without the bar. When the reads run out, the article falls back to the teaser and the panel says "You've read your 3 free articles this month." - Signed-in customers are counted in the store's database, so the count follows them to another device. That holds for an ordinary Grav account too, as long as it belongs to a customer (the same match the account area uses). A signed-in account that no customer record belongs to has nowhere to keep a count, so it's counted in the cookie like a guest.
- Guests are counted in a signed cookie named
kc_meter. There's no session. A cookie that has been edited or can't be read counts as used up, never as empty. The cookie grows with every free read, and a browser drops one over 4 KB, sofreeis capped at 50. A guest who clears their cookies gets fresh reads, which is the limit of counting guests anywhere. If that matters to you, usewho: accounts. who: accountsgives guests no free reads. The panel offers Create a free account alongside the plans, and the account link comes frompaywall.meter.register_url, or the Login plugin's registration page when that's switched on. KahunaCart doesn't sign people up itself.- Search and social crawlers always get the teaser, whatever they have left. Serving a crawler less than a person is never cloaking.
- A page can opt out with
meter: false, for articles that are always for subscribers.
The worker deletes counted reads older than two periods, and a customer's are erased with the customer. If you use the Consent plugin, the kc_meter cookie is listed for you while the meter is on.
Caching
Every paywalled page is sent with Cache-Control: private, no-cache and no Expires, for every visitor. The browser checks again each time, so someone who has just subscribed gets the full article on their next visit, and a shared computer never shows the article to the next person. A page without paywall: is served exactly as before.
Grav's own cache of the page's content holds the whole article once, and the choice between the teaser and the whole thing is made for each request after that. KahunaCart changes the request's copy of the page's content only after the cache is written: a reader's copy gets its wrapper, and everyone else's becomes the teaser. So nothing one visitor sees can be served to another, and a guest opening the article first never leaves the teaser in the cache for a subscriber.
Keep the paywall out of the page's own content. A Twig check inside a page with process: twig runs on content Grav has already cached, which is the wrong place for it. The template decides.
Structured data
Every paywalled page gets a JSON-LD block in the head saying the article isn't free (isAccessibleForFree: false), with the @type from paywall.schema_type (Article unless the page says otherwise). A reader's copy wraps everything after the teaser in <div class="kahunacart-paywalled">, which is the selector the block points at. Search crawlers get the teaser like any guest, so they see exactly what a visitor with no access sees and there's nothing to read as cloaking. The cost is that the article ranks on its teaser. If the SEO Magic plugin also writes an Article block, both stay and Google merges them.
Feeds and search
- The feed plugin (1.10.4 and later) asks KahunaCart what to print for each item through
onFeedItemContent. A paywalled page prints its teaser and a Read the full article link, unshortened, in RSS, Atom and JSON, the same for whoever asks. An older feed plugin ignores the event and prints the start of the body, so update it. - YetiSearch Pro builds each search document through
onYetisearchBuildDocument. For a paywalled page, KahunaCart keeps the title, tags, taxonomy and URL, replaces the content and excerpt with the teaser, and drops the per-section chunks, so a snippet can only quote the free part. The article is still found. That includes a page saved in the admin, from YetiSearch Pro 2.1.2 on. Before that, a saved page put the whole article in the index until the next full index run replaced it with the teaser version. - Other search plugins (SimpleSearch, TNTSearch) read
page.contentthemselves. They get what the visitor or process asking can read: a search by a visitor who can't read the article, or an index run from the command line, sees the teaser only. An index run from the admin (for example a reindex button) sees the whole article, because KahunaCart stays out of the admin. If you run one that way, leave paywalled pages out of it, for example with the plugin's own ignore option. - A listing that prints each child's body (
child.content, orchild.summarywith summaries switched off) prints a paywalled child's teaser to a visitor who can't read it and the whole article to one who can, because the page'scontentitself changes for the request. To lay a teaser out yourself,kahunacart_paywall(child).teaseris the same text, and it's the same for whoever asks.
What a theme changes
Nothing, to be safe: the kahunacart-paywall.html.twig template does the right thing in any theme. A theme that wants the teaser to look like its own article layout copies these into its templates/ folder.
| Template | What it draws |
|---|---|
kahunacart-paywall.html.twig |
The locked page: title, teaser and panel. Keep the kahunacart_paywall(page) call and the panel include, and never print page.content here. |
partials/kahunacart-paywall-panel.html.twig |
The subscribe panel. |
partials/kahunacart-paywall-meter.html.twig |
The bar above an article on a free read. KahunaCart places it at the top of the article for you. |
partials/kahunacart-continue-reading.html.twig |
The receipt's Continue reading line. A theme that overrides kahunacart-order.html.twig keeps it with {% include 'partials/kahunacart-continue-reading.html.twig' with {paywall_continue: paywall_continue\|default(null)} only %}. |
For a template that wants to branch itself, kahunacart_paywall(page) returns everything it needs and never the body. See Storefront → Twig functions.
The panel and the bar are styled by the storefront stylesheet using its colour and radius variables, so they follow the theme in light and dark. KahunaCart adds that stylesheet to paywalled pages that are outside the shop, unless builtin_css is off.
Routes
| Method | Route | What it does |
|---|---|---|
GET |
{base}/account |
Overview — recent orders, default addresses, links to each section |
GET |
{base}/account/orders |
Full order history |
GET |
{base}/account/downloads |
Every live download grant across every order |
GET |
{base}/account/addresses |
The address book |
POST |
{base}/account/addresses |
Add, edit, delete, set default |
GET |
{base}/account/wishlist |
The customer's saved lists. See Wishlists |
POST |
{base}/account/wishlist |
Every list action: create, rename, share, delete, remove an item, change a quantity, move to the cart, estimate delivery |
GET |
{base}/wishlist/{token} |
A list its owner has shared, read-only. Outside the account area and open to anybody holding the link |
GET POST |
{base}/account/profile |
The Login plugin's password and two-factor form, in KahunaCart's account chrome |
GET |
{base}/order/{hash}/invoice |
Printable invoice, reached by a signed link or by its logged-in owner |
GET |
{base}/order/{hash}/invoice.pdf |
The same invoice as a downloadable PDF, under the same access rule |
GET |
{base}/order/{hash}/pay |
Pay an order the store built, for an invoice sent by hand |
The three order routes carry a ?t= token when the link was minted for somebody who is not logged in. A logged-in customer whose id is on the order needs none, and neither does an admin holding kahunacart.orders.view. See Hardening.
account.profile_slug (default profile) names the sub-page under {base}/account that carries the password and two-factor screen. It must not clash with orders, downloads, addresses, wishlist, or a tab another plugin registered.
The section ids overview, orders, downloads, addresses and wishlist are reserved, so a plugin registering a tab of its own on onKahunaCartAccountSections cannot shadow a built-in page.
Sign-in, sign-out, forgot password, reset and magic link are the Login plugin's routes. The account navigation links to the profile page ("Password and security") and to the Login plugin's logout task ("Sign out"). The profile tab needs the Form plugin, and the logout link disappears when the Login plugin is not installed.
Everything under {base}/account requires a logged-in, fully authorized user. A customer part-way through a two-factor challenge does not count (authenticated is true, authorized is not). An anonymous visitor is sent to the Login plugin's route with a return-to in its redirect_after_login session property.
Hide empty tabs
A customer sees only the account tabs that have something in them. With Hide Empty Tabs on (Accounts tab, account.hide_empty_tabs, on by default), the Downloads and Stock alerts tabs show only for a customer who has a download or an alert. The same goes for any add-on tab that says how to tell, so a software store's customers never see an empty Bookings tab.
Each page still opens at its address, and the tab shows while the customer is on it. Turn the setting off to show every tab to everyone.
Tell the account when your tab is empty
An add-on registers its tab on onKahunaCartAccountSections. After registering it, call $registry->showsWhen($id, fn (array $customer): bool => …) with a check that answers whether this customer has anything there: any booking, any license key, any subscription.
- While Hide Empty Tabs is on, a tab whose check says no is left out of that customer's navigation.
- A tab with no check is always shown, and so is one whose check throws.
- The check runs on every account page, so keep it to one indexed lookup.
- Call it only where it exists,
if (method_exists($registry, 'showsWhen')). On a base older than 1.6.0 the tab shows as it always has.
Orders
The order history shows number, date, total, and all three status axes: payment status, order status and fulfillment status. Every row links to the receipt at {base}/order/{hash}, to that order's invoice, and to the invoice PDF. A row with shipments carries their tracking links underneath. See Orders and the admin.
The account area's own links carry no token and never expire. A logged-in customer is authorized by their session. A guest reaches the same pages through the signed link in their confirmation email, and that one expires. See Hardening.
Invoices
{base}/order/{hash}/invoice renders a printable invoice: your store block, the invoice number with the order number beneath it, the line items, the adjustments with the same included-tax handling the receipt uses, the total, and the payment status.
An order placed before invoice numbers existed has none, and prints its order number in that slot.
{base}/order/{hash}/invoice.pdf is the same document as a file, rendered server-side by dompdf from templates/kahunacart-invoice-pdf.html.twig. The printable page and every row of the account order history link to it. The browser's print dialog still works on the HTML invoice.
The shipped stylesheet's @media print block hides everything except the invoice, and anything that should not go on paper carries the kahunacart-no-print class. If you set builtin_css to false, you own the print rules too.
| Key | Default | Effect |
|---|---|---|
invoice.store_name |
'' |
Printed at the top. Empty falls back to the site title. |
invoice.store_address |
'' |
Your business address, one line per line. |
invoice.tax_id |
'' |
VAT or tax registration number. Omitted entirely when empty. |
invoice.footer |
'' |
Markdown at the foot: payment terms, a thank-you, whatever your accountant asks for. |
invoice.reverse_charge_text |
'' |
The reverse-charge line on an order entered with "Don't charge tax". Empty prints the translated default. See Tax → Orders entered with "Don't charge tax". |
Three more decide what the invoice number itself looks like: invoice.sequence_start, invoice.format and invoice.reset_yearly. See Configuration.
Access is a signed link, its logged-in owner, or an admin. The link expires after security.invoice_link_days (30 days by default). Consider setting it longer than the receipt, since customers forward invoices to whoever pays their bills. A lapsed link lands on a page offering to email a fresh one. See Hardening.
Downloads
{base}/account/downloads collects every live download grant the customer holds across every order. Each row shows the file name, the product it came from, downloads remaining or unlimited, the expiry date if there is one, and a link back to the order.
The links are the same tokenized {base}/download/{token} URLs the receipt uses, counted the same way. Revoked grants are left out.
See Digital products for how grants, limits and expiry work.
The address book
Saved addresses live in kahunacart_addresses, one row per address, keyed to the customer. Each address is stored whole as JSON in the same spelling checkout writes onto an order.
Each customer has at most one default billing address and at most one default shipping address. Promoting an address demotes the previous holder. Deleting a default promotes nothing in its place.
An address can be added before the first purchase, which creates the customer record.
You can also keep the book for a customer from the admin, for someone who orders by phone and never opens their account page. See Customers → A customer's address book.
Checkout prefill
A logged-in customer's checkout arrives filled in. Email and name come from their customer record, falling back to the email on their account for a first purchase, and billing and delivery addresses come from their defaults. Prefill only fills blanks.
"Save this address for next time"
Logged-in customers get a checkbox on the checkout form, checked by default. Guests see the account opt-in instead, when store accounts are on.
The save happens at order completion, not at submit, so no address is filed for an order that never happened. It overwrites the existing default rather than adding a row.
Wishlists
Wishlist is a page of the account area, at {base}/account/wishlist. A customer keeps up to 20 named lists of up to 500 items each.
From that page a customer can create a list, rename it, share or unshare it, delete it, change a quantity, remove an item, move one or all of its items into the cart, and price a delivery for the whole list. Every one of those is a nonce-protected POST to {base}/account/wishlist.
A shared list is readable at {base}/wishlist/{token} by anybody holding the link. That page is outside the account area and needs no login, so it is served with Cache-Control: no-store. A private list is invisible even to somebody holding the token.
An anonymous list is merged into the customer's account when they sign in. A product on both lists stays one row, and the account's own quantity and note win. A list the account did not already have is claimed instead, keeping its id and token, so a shared link keeps working.
The account overview carries a wishlist summary panel, shown only when the customer has at least one list.
Set wishlists.enabled to false to remove the page, the panel and both routes.
Carts and logging in
A customer who logs in on a session with no cart, and who has exactly one unfinished cart on record, gets it back. Adoption never merges two carts, never touches a session that already has a cart, and does nothing when there are two or more unfinished carts.
It runs on the Login plugin's onUserLoginAuthorized event, after any two-factor challenge. The cart and account routes repeat the check, so remember-me logins and older sessions are covered too.
Note
A cart only carries a customer id once checkout has been submitted at least once, which is when setCustomerInfo() runs. A cart abandoned before reaching checkout belongs to nobody and cannot be handed back.
Theme integration
KahunaCart ships the Twig functions a theme needs for a header account link.
{% set customer = kahunacart_customer() %}
{% if customer %}
<a href="{{ kahunacart_account_url() }}">Your account</a>
<a href="{{ kahunacart_logout_url() }}">Sign out</a>
{% else %}
<a href="{{ kahunacart_login_url() }}">Sign in</a>
{% endif %}
kahunacart_customer() returns the resolved customer array, or null for guests and for logged-in accounts that have never bought anything. kahunacart_account_url() is the account route, honouring your configured base. kahunacart_login_url() and kahunacart_logout_url() are the Login plugin's routes, including the nonce its logout task expects, and both return an empty string when that plugin is not installed. See Storefront → Twig functions.
kahunacart_avatar(size) returns the signed-in customer's picture at the size you ask for: their own picture if the store has one, otherwise a generated initials circle. account.gravatar (default false) lets the fallback come from gravatar.com instead, which sends a hash of the customer's email address to a third party on every page view.
Every account template is overridable in your theme. See the template table in Storefront.
Important
If your theme overrides kahunacart-checkout.html.twig, port the account opt-in across when you enable store accounts. It sits beside the existing save_address checkbox and reads two variables the plugin sets.
{% if create_account_offered|default(false) %}
<label class="kahunacart-checkbox"><input type="checkbox" name="save_account" value="1" {{ create_account|default(false) ? 'checked' }}> {{ 'PLUGIN_KAHUNACART.CREATE_ACCOUNT'|t }}</label>
<p class="kahunacart-hint">{{ 'PLUGIN_KAHUNACART.CREATE_ACCOUNT_HELP'|t }}</p>
{% endif %}
Configuration
| Key | Default | Effect |
|---|---|---|
account.db_identity |
true |
Storefront customers sign in against the store database rather than a Grav account file. Turn off to fall back to Grav accounts. See Store accounts. |
account.checkout_registration |
true |
Offer guests an account at checkout. Requires account.db_identity. |
account.order_page_offer |
true |
Offer guests an account on their order page and in its emails, after the fact. Requires account.db_identity. See The offer on the order page. |
account.profile_slug |
profile |
The sub-page under {base}/account carrying the password and two-factor screen. Must not clash with orders, downloads, addresses, or another plugin's tab. |
account.hide_empty_tabs |
true |
Leave out the account tabs a customer has nothing in: Downloads, Stock alerts and any add-on tab that says how to tell. The page still opens at its address. See Hide empty tabs. |
account.gravatar |
false |
Fall back to gravatar.com when a customer has no picture of their own. Off by default because it sends a hash of their email address to a third party on every page view that draws their avatar. |
account.invite_expiry_days |
7 |
How long a set-password link stays clickable. |
account.claim_by_email |
false |
Whether a Grav account with no history may adopt a guest customer record sharing its email. Off by default, because the address on a Grav account isn't proven until your site verifies both registration and profile changes. Does not apply to store accounts. |
invoice.store_name |
'' |
Store name on invoices. Empty uses the site title. |
invoice.store_address |
'' |
Business address on invoices. |
invoice.tax_id |
'' |
Tax or VAT number on invoices. |
invoice.footer |
'' |
Markdown footer on invoices. |
invoice.reverse_charge_text |
'' |
Reverse-charge line on exempt orders; empty uses the translated default. |
invoice.sequence_start |
1 |
The number given to the first invoice. Later invoices count up from it. |
invoice.format |
'{number}' |
How the invoice number is printed. See Configuration. |
invoice.reset_yearly |
false |
Start again at invoice.sequence_start every January (UTC). Put {year} in the format as well, or numbers repeat. |
security.invoice_link_days |
30 |
How long a signed link to an invoice or its PDF keeps working. |