Customers
| Features | CrestApps.OrchardCore.Customers, CrestApps.OrchardCore.Customers.CheckoutDetails (Checkout details), CrestApps.OrchardCore.Customers.StoreCredit (Store Credit), CrestApps.OrchardCore.Customers.Loyalty (Loyalty Points), CrestApps.OrchardCore.Customers.Referrals (Referrals), CrestApps.OrchardCore.Customers.Api (Customers API) |
| Core | CrestApps.OrchardCore.Customers.Core |
| Category | Commerce |
| Depends on | Commerce, OrchardCore.Users |
The Customers feature keeps a record of everyone who buys from the store: a person or a business, with an account or as a guest. Orders, transactions and subscriptions can point at one customer, whatever later happens to the account.
Customers and users
A customer is not a user. Accounts stay with Orchard Core Users, which handles registration, sign-in, password resets, email confirmation and external logins. A customer with an account is linked to its user by Customer.UserId. The customer record keeps only what the store needs: how to address the customer, their company and tax id, and how they want to be contacted.
A guest customer has no user. Customers builds no registration of its own; a guest who wants an account registers through Orchard Core.
The customer record
Customer is a CatalogItem stored in its own YesSql collection (Customers), so it carries the usual extensible properties for other modules.
| Property | |
|---|---|
Kind | Person or Business. |
Status | Active, Disabled (the store stopped doing business with them; their history stays) or Anonymized (their personal data was erased). |
UserId | The Orchard Core user, or null for a guest. |
DisplayName, FirstName, LastName | How the store addresses the customer. GetName() returns the display name, then a business's company, then the person's name, then the email. |
CompanyName, TaxId | For a business, such as a VAT number. |
IsTaxExempt, TaxExemptionReason | A customer who pays no tax, such as a reseller, and why, such as a certificate number. A signed-in exempt customer pays no tax at checkout; see Taxation at checkout. |
Email, Phone | For a customer with an account, Email is the account's email when the record was made. |
AcceptsMarketing | Whether the customer agreed to news and offers. Order and payment messages are always sent. |
ExternalIds | Ids at outside services, such as a payment provider's customer id, by service name. |
CreatedUtc, ModifiedUtc, LastActivityUtc | LastActivityUtc is when the customer last bought or changed something. |
Addresses, DefaultBillingAddressId, DefaultShippingAddressId | The address book, below. |
CustomerIndex indexes the user id, the email, name and company in upper case for searching, the kind, the status, whether the customer has an account, and the dates.
Address book
A customer keeps up to 20 CustomerAddress entries on the record itself. Each has an AddressId, a Label (such as Home) and an Address: the shared, immutable value from the Addresses module, so a customer's address is the same type an order, a tax rule or a content field holds.
GetDefaultBillingAddress()andGetDefaultShippingAddress()return the defaults, ornull.RemoveAddress(addressId)also clears a default that named the address.ToAddress()returns a copy of theAddress. An order stores that copy, so editing or deleting an address never changes a past order.
The address pages use the Addresses module's editor (IAddressEditorService and the AddressEditor shape, posted under the Address prefix), so they lay the form out for the chosen country and check the address against its rules: the parts the country requires, its postal code pattern, a region from its list, and a postal code within that region. The address book also requires the recipient's name and offers a phone, and a new address starts in the country of the customer's other addresses. Addresses are listed and printed through IAddressFormatter, the way each country writes them. Customers depends on the Addresses feature for all of this.
Services
ICustomerStorefinds customers by id or user id and pages them with aCustomerQuery. A search matches the start of the name, company or email, ignoring case. Saving checks concurrency, so two editors never overwrite each other silently.ICustomerService.GetOrCreateForUserAsync(userId)returns a user's customer, making it from the account the first time. A distributed lock keyed by the user makes two requests from the same user share one customer.TouchAsyncrecords activity.ICustomerOwnerNotifier(DefaultCustomerOwnerNotifier, registered byAddCustomersCore()) sends a buyer a receipt, reminder or notice: a user throughINotificationService, a guest by email at the contactICustomerContactResolverresolves.ICustomerContactResolveris replaced byCustomerRecordContactResolver. A customer with an account is addressed by the name on their customer record and the account's email, so statements, receipts and reminders read the same everywhere. A guest is still addressed from the contact kept at purchase.
Orders
With Customers enabled, an order placed by a signed-in user belongs to their customer: Order.CustomerId is set at checkout, and the customer's record is made on their first order. Orders keep their own copy of the customer's name and email, so an order reads the same after the customer changes.
The checkout's Contact step
With Orders enabled, Customers adds a Contact step to every order's checkout (CustomersConstants.ContactStepKey, right after the hidden order step and before payment). CustomerContactCheckoutHandler adds it, and CustomerContactCheckoutFlowDisplayDriver renders it as the CustomerContactStep_Edit shape.
- What it asks: a name and an email (required), a phone, and a billing address as
CustomerCheckoutSettings.BillingAddresssays:Optional(the default),RequiredorOff, under Settings > Commerce > Customers with the Manage customers permission. The checkout keeps the setting it started with, stored on the session, so changing it never strands a checkout. A signed-in buyer never sees the step when the setting isOff. - The billing address uses the Addresses module's
AddressEditorunder the step's prefix, without a recipient name (the buyer named above is the recipient) and with the buyer's phone. A signed-in customer can pick one of their saved addresses and ask for a new one to be saved. - What it keeps: the entries as
CustomerContactStepin the session's saved steps, and aCheckoutContactInfo(name, email, phone, billing address) on the session. The order copies that contact and billing address when it is paid, and Pay Later puts the guest's name and email on its transaction, so receipts and reminders reach them. - When the order is paid (
CompletingAsync, inside the completion's transaction): the order is linked to the signed-in buyer's customer, or to a new guest customer made withICustomerService.CreateGuestAsync; each guest checkout makes its own, since an email typed at checkout proves nothing. A guest's billing address goes into their address book as the default billing address; a signed-in customer's new address goes in when they asked, never twice.
Checkout details
The Checkout details feature (CrestApps.OrchardCore.Customers.CheckoutDetails, depends on Customers, Orders, Contents and Content Types) creates a CheckoutDetails content type with no fields. Its fields are the store's own checkout fields:
CheckoutDetailsCheckoutFlowDisplayDriverrenders the type's editor on the Contact step with theCheckoutDetailsprefix, throughIContentItemDisplayManager, so every field validates as it does in the admin. A valid post stores the item's parts onOrder.CheckoutDetails(aJsonObject).CheckoutDetailsCheckoutHandlerkeeps the Contact step visible (Conceal = false) when the type has fields, after every handler has initialized the checkout.OrderCheckoutDetailsDisplayDrivershows the values, each with its field's name, on the order's admin and customer pages (OrderConstants.DisplayTypes.DetailAdminandDetail).Order.Anonymizeremoves them.
Storefront account pages
account/details (AccountDetails) and account/addresses (AccountAddresses, with .../add, .../{addressId} and .../{addressId}/delete) edit the signed-in buyer's customer record and address book on the storefront, through CustomerAccountPages, the same validation as My Account. With their features on, account/store-credit, account/rewards and account/refer show the buyer's store credit, points and referral link; each is in the account menu with Manage own customer profile and addresses.
Sections on the customer's page
The customer's admin page (CustomersAdminController.Edit) builds the customer's DetailAdmin display through IDisplayManager<Customer> and renders it below the orders, as CustomerEditViewModel.Sections. A module adds a section with a DisplayDriver<Customer> that answers only context.DisplayType == "DetailAdmin" and places its shape in the Content zone; the Customer.DetailAdmin template renders that zone. Store credit (Content:5) and loyalty points (Content:6) use it.
Store credit
The Store Credit feature (CrestApps.OrchardCore.Customers.StoreCredit, depends on Customers and Orders) keeps money the store owes a customer, given by hand or as a refund, and lets it pay toward the signed-in customer's checkouts as a tender, after tax.
- The account. A
StoreCreditAccount(aCatalogItemin theCustomerscollection,StoreCreditAccountIndexon customer, currency and balance,StoreCreditMigrations) holds one customer'sBalancein oneCurrency, withActivity(StoreCreditActivity:CreatedUtc,Amount,Balanceafter,Reason,SessionId,Actor).ChangeBalancenever goes below zero and always writes the activity.IStoreCreditStore(StoreCreditStore, with concurrency checks) addsFindAsync(customerId, currency)andListByCustomerAsync. - Changing it.
StoreCreditService.ChangeAsync(customerId, currency, amount, reason, actor, sessionId)makes the account on a first credit, and otherwise changes it underCheckoutTenderLocks.GetKey(StoreCreditService.TenderKey, accountId), the lock the checkout's ledger takes. It returnsnullwhen the lock is busy, or when there's nothing to take credit from. - Paying with it.
StoreCreditTenderProvideris theICheckoutTenderProviderwith the keystore-credit. Store credit belongs to an account, soFindForCheckoutAsyncresolves it only from the session'sOwnerId, through the user's customer, in the invoice's currency, and its lines pay only from the buyer's own account, whatever the selection names. The line's description is Store credit.StoreCreditCheckoutFlowDisplayDriverrendersCheckoutStoreCreditatAside:7for a signed-in buyer with credit, on a checkout that doesn't settle an existing balance or save a payment method. Its button postsapply-tender:store-credit(StoreCreditCheckoutFlowDisplayDriver.ApplyAction), and Remove postsremove-tender:store-credit:and the account id. - Refunds as credit. The provider is also the
IOrderStoreCreditRefunderfor the Orders feature:CanRefundAsyncis true when the order'sUserIdhas a customer, andRefundAsyncadds the amount in the order's currency with the reason Refund of order … and the order's checkout session id. - Admin.
CustomerStoreCreditDisplayDriveradds theCustomerStoreCredit_DetailAdminsection: the balances, the latest 20 changes, and for Manage customers a form that posts toStoreCreditAdminController.Change(Admin/customers/{customerId}/store-credit, withamount,currencyand a requiredreason). A guest customer can't be given credit. - Storefront.
account/store-credit(AccountStoreCredit,StoreCreditAccountController) shows the signed-in customer's credit and the latest 50 changes, andStoreCreditAccountMenuadds Store credit to the account menu.
Loyalty points
The Loyalty Points feature (CrestApps.OrchardCore.Customers.Loyalty, depends on Customers and Orders) gives a signed-in customer points for what they buy, and lets them spend the points toward later checkouts as a tender.
- The account. A
LoyaltyAccount(in theCustomerscollection,LoyaltyAccountIndexon the customer,LoyaltyMigrations) holds wholePointsandActivity(LoyaltyActivity:Pointschanged,Balance,Kind,Reason,OrderId,SessionId,Actor).LoyaltyActivityKindisEarned,Reversed,Spent,ReturnedorAdjusted.ChangePointsnever goes below zero and records the change that actually happened.GetEarnedFor(orderId)is what an order earned and hasn't had taken back. - Settings.
LoyaltySettings(Settings > Commerce > Rewards, grouployalty,CustomersConstants.LoyaltySettingsGroupId, permission Manage customers):ProgramName(blank shows Rewards),PointsPerUnit(1, 0 or more),PointValue(0.01, more than zero) andMinimumPointsToRedeem(100, 1 or more).LoyaltyService.GetSettingsAsyncfills in the name. - Earning.
LoyaltyOrderEventHandler.PaidAsyncgives the order's signed-in buyerLoyaltyService.GetPointsFor(order, settings), once per order: the floor ofPointsPerUnittimes the order'sSubtotal, less its discounts whoseOrderDiscount.Targetisn'tDiscountTarget.Shipping, less what points paid (Order.Tenderswith the keyloyalty-points). Shipping and tax earn nothing. It adds an order note. - Taking back.
CanceledAsynctakes back everything the order still has earned.RefundedAsynctakes back the share of the points the order earned that the refund is of the order's total, rounded, never more than it still has earned. Both areReversedchanges. - Spending.
LoyaltyTenderProvideris theICheckoutTenderProviderwith the keyloyalty-points, for the session owner's customer only, described by the program's name. It offers nothing belowMinimumPointsToRedeem. A balance is worthLoyaltyService.GetValue(points, settings, currency), the points timesPointValueto the cent below.DebitAsyncspendsGetPointsWorth(amount), the points that pay the amount rounded up, never more than the customer has;CreditAsyncgives back the points an amount is worth, asReturned.LoyaltyCheckoutFlowDisplayDriverrendersCheckoutLoyaltyatAside:8, postingapply-tender:loyalty-pointsandremove-tender:loyalty-points:and the account id. - Changing points by hand.
LoyaltyService.ChangeAsync(customerId, points, kind, reason, actor, orderId)works like store credit, underCheckoutTenderLocks.GetKey(LoyaltyService.TenderKey, accountId).CustomerLoyaltyDisplayDriveraddsCustomerLoyalty_DetailAdmin, with a form that posts toLoyaltyAdminController.Change(Admin/customers/{customerId}/loyalty,pointsand a requiredreason, asAdjusted). - Storefront.
account/rewards(AccountRewards,LoyaltyAccountController) shows the points, their value, how they're earned and spent, and the latest 50 changes.LoyaltyAccountMenuadds Rewards to the account menu, andLoyaltySettingsAdminMenuthe settings page.
Referrals
The Referrals feature (CrestApps.OrchardCore.Customers.Referrals, depends on Customers and Orders) gives each signed-in customer a referral link. A friend who opens it can get a single-use discount, and the customer is rewarded with store credit or points for the friend's first order.
- The profile. A
ReferralProfile(in theCustomerscollection,ReferralProfileIndexon customer and code,ReferralMigrations) holds the customer'sCodeandReferrals: oneReferralRecordper rewarded friend (FriendEmailin lower case,FriendName, the friend's first name,OrderId,OrderNumber,Reward, the reward's description ornull, andRewardKind,RewardAmount,RewardCurrencyandRewardPoints, what was given, which a canceled order takes back whatever the settings say by then,CreatedUtc).ReferralService.GetOrCreateAsync(customerId)makes it the first time the customer's account page is opened, with an 8-character code from the alphabet without 0, O, 1 or I. - Settings.
ReferralSettings(Settings > Commerce > Referrals, groupreferrals, permission Manage customers):FriendCouponId(a coupon that isn't automatic, ornull),RewardKind(None,StoreCreditorPoints),RewardAmount(10),RewardPoints(500) andMinimumOrderAmount(0). - The link.
GET refer/{code}(ReferralController) stores the code in theorchard-referralcookie (ReferralService.CookieName; HTTP-only, essential, the tenant's path, 30 days) and redirects to the home page with a message. When the store offers a coupon that is redeemable now, and this browser hasn't opened this link before, it generates one single-use code of it with the prefixREF-(ICouponStore.GenerateCodesAsync(coupon, 1, "REF-", usesPerCode: 1)) and stores it in the discount link cookie (CheckoutConstants.DiscountLinkCookieName) for 30 days, so it is applied to the friend's checkouts. Generating codes is rate limited per address (IPaymentAttemptLimiter, scopereferral-link). A code that matches no profile says the link isn't valid; a signed-in customer opening their own link is told so, and gets no code. - Remembering it.
ReferralCheckoutHandler.ActivatingAsyncputs the cookie's code on each new checkout as aCheckoutReferral, except one that settles an existing balance. - The reward.
ReferralOrderEventHandler.PaidAsyncreads the paid order's checkout session. It rewards the profile's customer once, and only when the order has an email; the friend isn't the customer (by email or user); no record of the profile names the friend's email; the friend has no other paid order (byUserId, or for a guest by email, throughIOrderStore.PageAsync); and the order'sSubtotal, less its discounts that didn't come off shipping, reachesMinimumOrderAmount. The reward isRewardAmountof store credit in the order's currency throughStoreCreditService, orRewardPointsthroughLoyaltyService(asAdjusted), when the referrer has an account and that feature is on; the record is kept either way, and an order note names the referrer and the reward. - Taking it back.
CanceledAsyncremoves the friend's record, so they can be referred again, and takes backRewardAmountorRewardPointsfor the currentRewardKindwhen a reward was given. A refund doesn't. - Storefront.
account/refer(AccountReferrals,ReferralAccountController) shows the link ({scheme}://{host}{pathBase}/refer/{code}), what a friend gets (the coupon's description), what the customer gets, and the friends rewarded.ReferralAccountMenuadds Refer a friend to the account menu, andReferralSettingsAdminMenuthe settings page.
Merging guests into an account
ICustomerMergeService.MergeVerifiedGuestsAsync(userId) joins a guest's history to their account. It runs when a user signs in (ILoginFormEvent.LoggedInAsync), registers (IRegistrationFormEvents.RegisteredAsync) or confirms their email (IUserEventHandler.ConfirmedAsync), and does nothing unless the user's EmailConfirmed is true. Under a per-user distributed lock it takes every active guest customer with the user's email (ICustomerStore.ListGuestsByEmailAsync) and, for each:
- calls every
ICustomerMergeHandler. With Orders on,OrderCustomerMergeHandlermoves the guest's orders to the account's customer (settingUserIdtoo, so they appear under My Orders) and, with Transactions on, turns the guest transactions paying for them into the user's; - moves the guest's addresses into the account's book, skipping duplicates, up to 20;
- marks the guest
MergedwithMergedIntoCustomerIdandMergedUtc, and adds it to the account customer'sMergedCustomerIds.
The event handlers resolve the merge service when they run: Orchard Core's user store and user service create them, and the merge service needs the user service, so injecting it would be a circular dependency. A failed merge is logged and tried again on the next sign-in.
Privacy
ICustomerPrivacyService answers access and erasure requests and applies the retention windows.
EraseAsync(customer, by)calls everyICustomerPrivacyHandler.ErasingAsync, thenCustomer.Anonymize: names, company, tax id, email, phone, addresses, outside ids and marketing consent are cleared, the account is unlinked (UserId), and the status becomesAnonymized. With Orders on,OrderCustomerPrivacyHandleranonymizes the customer's orders (byCustomerId, and byUserIdfor an account holder's older orders) throughOrderAnonymizer:Order.Anonymizeclears the name, email and phone and keeps only the billing address's country and region, and the guest contact of the transactions paying for the order is cleared. Every checkout session behind the order (the one it names and any earlier attempt for it) and behind those transactions is anonymized throughICheckoutSessionAnonymizer, which clears the owner, contact, IP address, user agent and saved steps; see Checkout. Handlers see the account before it is unlinked (CustomerPrivacyContext.UserId), and run byICustomerPrivacyHandler.Order(lower first, then in registration order). Erasing an anonymized customer does nothing.ExportAsync(customer, by)returns aJsonObjectwith the customer and their addresses; each handler'sExportingAsyncadds its own section (orders, each with itstransactions).CommercePersonalDataCustomerPrivacyHandlercovers what the other commerce modules keep, throughICommercePersonalDataService: the customer is identified by their account (when they have one) and their email. Erasing anonymizes their checkout sessions, transactions, subscriptions, installment plans, coupon redemptions and Stripe customers, clearing names the same way the customer record is cleared; exporting adds acommercesection with those records and the payments and refunds of their checkouts. It runs before the other handlers (Orderis-100), because they erase the checkout emails and owners it follows to the customer's guest transactions, subscriptions and plans. See Personal data requests.ExpireAsync(customer)erases as the retention task.
Each export and erasure appends a CustomerPrivacyEntry (Utc, Action: Exported, Erased or Expired, By) to Customer.PrivacyLog.
Retention. CustomerRetentionBackgroundTask runs daily at 03:30 under the background-task lock and reads CustomerPrivacySettings (Settings › Commerce › Customers; zero turns a window off):
InactiveGuestMonths(24): guests (HasAccount = false, merged ones included) whoseLastActivityUtcis older are expired. Customers with an account are not, since the account holds the same data; erase them on request.GuestOrderMonths(12): orders placed without an account (OrderQuery.GuestsOnly),PaidorCanceled, created before the window, are anonymized, with the checkout sessions behind them. Orders awaiting payment keep their contact.
Each run handles up to 500 of each, and queries leave out what is already anonymized (CustomerQuery.NotAnonymized, OrderQuery.NotAnonymized on the new OrderIndex.AnonymizedUtc, upgrade step 4), so a backlog is worked off over a few days.
Admin. POST customers/{customerId}/erase and GET customers/{customerId}/export (a customer-{id}.json download) need Manage customers.
Recipes
The Customers recipe step adds or updates guest and business customers, for sample stores and test sites:
{
"name": "Customers",
"Customers": [
{
"Kind": "Business",
"CompanyName": "Contoso Supplies",
"TaxId": "123456789RT0001",
"Email": "purchasing@contoso.example",
"Addresses": [
{
"AddressId": "contoso-office",
"Label": "Office",
"Address": { "Company": "Contoso Supplies", "AddressLine1": "100 King Street West", "City": "Toronto", "Region": "ON", "PostalCode": "M5X 1C7", "Country": "CA" }
}
],
"DefaultBillingAddressId": "contoso-office",
"DefaultShippingAddressId": "contoso-office"
}
]
}
A customer is matched by ItemId, then by the email of a guest, so running a recipe again updates rather than duplicates. Customers with an account are made from their user, and erased or merged customers are left alone. Customers are personal data, so there is no deployment step that exports them. The Product listing: online store sample recipe uses this step for its two sample customers.
API
The Customers API feature (CrestApps.OrchardCore.Customers.Api) maps into the commerce JSON API at api/commerce/v1:
meandme/addresses, the signed-in buyer's account and address book, throughCustomerAccountPagesandCustomerAddressBookwith Manage own customer profile and addresses, held to the same rules as My Account.customersandcustomers/{customerId}, read-only, with View customers.- With Orders and the Checkout API on,
PUT checkout/{id}/contact, the Contact step, throughCustomerContactStepService. - With Store Credit or Loyalty Points and the Checkout API on,
checkout/{id}/store-creditandcheckout/{id}/points, for signed-in buyers.
See Commerce API for the endpoints, the conventions they share, and how apps sign in, and Headless commerce API in the User Manual for setting it up in the admin.
Admin
Commerce > Customers lists customers, the most recently active first, with a search by name, company or email and filters for Active, With an account, Guests, Businesses, Disabled and All customers. Add customer creates one by hand, for example for an order taken by phone. Each customer opens an editor for their details, with their account, when they became a customer and when they were last active, and their address book (customers/{customerId}/addresses/...). An anonymized customer's addresses can't be edited.
My Account (my-account) is the signed-in user's own page: their customer details and address book. It is an admin-area page marked [CustomerAdminPage], so a customer reaches it without Access admin panel. The first visit makes the user's customer record with GetOrCreateForUserAsync; a disabled or anonymized customer can't edit it.
Permissions
| Permission | Allows |
|---|---|
| Manage customers | Adding, editing, exporting and erasing customers, and the Customers settings. Giving and taking store credit and points, and the Rewards and Referrals settings. Implies View customers. |
| View customers | Listing and opening customers, read-only. |
| Manage own customer profile and addresses | Using My Account. Granted to the Authenticated role by default. |
Administrators have the first two by default.