Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

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 owns the login form, forgot-password, magic links, two-factor and remember-me. KahunaCart authenticates nobody.

    BASH
    bin/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.

The KahunaCart database holds everything commercial: customers, orders, addresses, download grants, invoices. With account.db_identity on, it also holds storefront customers' passwords, 2FA secrets and sign-in tokens.

Important

KahunaCart never writes to a user account YAML (user/accounts/*.yaml) — not an address, not an order, not a preference, not a total. With account.db_identity on, no account file is created for a customer at all.


Store accounts

Turn on store accounts

  1. Set account.db_identity to true. This is the default.
  2. Set account.checkout_registration to true to offer guests an account at checkout.
  3. Set account.invite_expiry_days to the number of days a set-password link stays clickable. The default is 7.
  4. Run bin/plugin kahunacart migrate if your database is not on the auto-migrate policy. This adds the kahunacart_customer_auth table.
YAML
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 a username that looks like an email address, and it applies the groups and access from its own configuration. Turning it on gives you YAML customer accounts again, silently.

Registration at checkout

A guest who is not signed in, and whose email address does not already have an account, 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 choice is acted on at order completion, not at submit, so a declined card or an abandoned cart leaves no account behind.

At completion the account is opened with no password and 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 set-password link is a Login plugin password-reset link, pointing at the same /reset_password route and validated against system.pwd_regex.

The account cannot be signed into until the link is used, because Grav refuses a login when no password hash is stored. If the link expires, the ordinary forgot-password form regenerates it.

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, while 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 every order is filed under. 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.

  1. Open Customers and select a customer.
  2. Read the Sign-in account panel for their current state.
  3. 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 sit behind 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 — for a customer who lost their phone. The secret is destroyed, never shown. They sign in with their password and enroll a new device from the profile page.
  • Disable / enable — the account switch. 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 a store account for that address could never be signed into. Neither path creates one.

  • 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. account.claim_by_email then 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.

  1. $grav['accounts'] is no longer a FlexCollectionInterface on a site running Flex accounts, so a third-party plugin checking that interface changes path.
  2. $grav['accounts'] becomes iterable on a site running file accounts, so code branching on is_iterable() changes path. The proxy's iterator yields Grav accounts first and store customers after.
  3. /api/users/{email} finds customers. Three defences stack: a store account is denied every action outside site., so admin.login, admin.super, api.access and api.super are unreachable; the API gate refuses a user without those permissions; and a write through a user object drops access, groups, username and email.

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 under their email address and no user_id on it, so resolution runs in two steps.

  1. By usernameuser_id equals the logged-in username. Once a claim exists, this is the only lookup that runs.
  2. By email — the account's email matches a customer record that no other account has claimed. On a match, user_id is stamped and the claim is permanent.

Step 2 is governed by account.claim_by_email, which is on by default. A record another account already holds is never reassigned. An account with no customer record yet is not an error, and every account page shows an empty state for it.

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 when your registration verifies email addresses. Turn on send_activation_email and set_user_disabled in the Login plugin's registration options, and leave account.claim_by_email on.

Warning

With verification off, anyone who knows a customer's email address can register an account using it and inherit that customer's guest order history: order totals, delivery addresses, and any download links still live on those orders. If your registration does not verify email addresses, set account.claim_by_email to false.

With the claim off, existing customers keep their history only from the point they buy something while logged in.

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.


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}/order/{hash}/invoice Printable invoice

Sign-in, sign-out, forgot password, reset, magic link and the profile page are the Login plugin's routes. The account navigation links to its profile page ("Password and security") and its logout task ("Sign out"), and both disappear when the 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 at that point and authorized is not. An anonymous visitor is sent to the Login plugin's route with a return-to, written to its redirect_after_login session property.

Orders

The order history shows number, date, total, and all three status axes: payment status, order status and fulfillment status, rendered from the same translation keys the receipt uses. Every row links to the receipt at {base}/order/{hash} and to that order's invoice.

The receipt is unchanged and is still reachable by anyone holding the hash, because a guest who bought without an account still needs the link from their confirmation email.

Invoices

{base}/order/{hash}/invoice renders a printable invoice: your store block, the order number as the invoice number, the line items, the adjustments with the same included-tax handling the receipt uses, the total, and the payment status. No PDF is generated; the browser's print dialog is the export.

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.

Note

Access to an invoice is the receipt's rule: possession of the hash, so a customer can forward the link to whoever pays their bills. The hash is 32 hex characters of random_bytes.

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, so it hands straight to checkout with no translation.

Each customer has at most one default billing address and at most one default shipping address. Promoting an address demotes the previous holder in the same transaction. Deleting a default clears the flag and promotes nothing in its place.

An address can be added before the first purchase, which creates the customer record in KahunaCart's database.

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, so a customer editing the form mid-flow is never overruled by their address book.

"Save this address for next time"

Logged-in customers get a checkbox on the checkout form, checked by default and nonce-protected like every other storefront POST. Guests see the account opt-in instead, when store accounts are on.

The checkbox is recorded on the cart and acted on at order completion, not at submit, so no address is filed for an order that never happened. The save overwrites the existing default rather than adding a row, so ordering fifty times leaves one billing default and one delivery default.

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 item by item, 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, which fires once per successful browser login and after any two-factor challenge. The cart and account routes repeat the check lazily, 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.

TWIG
{% 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.

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.

TWIG
{% 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.invite_expiry_days 7 How long a set-password link stays clickable.
account.claim_by_email true Whether a Grav account with no history may adopt a guest customer record sharing its email. 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.