From e11d32d8abad42c7f469c1ccb066767ce238cbb4 Mon Sep 17 00:00:00 2001 From: Md Omar Faruk Date: Mon, 12 Oct 2026 10:53:10 +0600 Subject: [PATCH] fix(checkout): guest signup leaves no order history and breaks later orders Guest checkout's place_order() created the new Customer without a Portal User row, so the storefront order-history page (which finds the Customer via Portal User, not Contact) showed no orders for a customer who had just signed up - not even their own first one. create_party() now adds the Portal User row, matching what the standalone /register flow already did. Separately, Frappe auto-creates a Contact for every new User via a background job that can race storekit's own Contact creation during checkout, leaving two Contact rows for one email - one linked to the new Customer, one not. get_party() had no way to prefer the right one, so a later visit could land on the unlinked Contact and silently spin up a second, empty Customer whose address didn't match the one saved at signup - surfacing as "Something went wrong" on the next order. Added _resolve_contact_for_email() to prefer a linked Contact over an unlinked one, and used it in get_party(), create_contact() and _get_cart_quotation(). Release 1.1.1. --- storekit/__init__.py | 2 +- storekit/change_log/v1/v1.1.1.md | 7 +++ storekit/webshop_functions/cart.py | 85 +++++++++++++++++++++--------- 3 files changed, 68 insertions(+), 26 deletions(-) create mode 100644 storekit/change_log/v1/v1.1.1.md diff --git a/storekit/__init__.py b/storekit/__init__.py index 6849410..a82b376 100644 --- a/storekit/__init__.py +++ b/storekit/__init__.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.1.1" diff --git a/storekit/change_log/v1/v1.1.1.md b/storekit/change_log/v1/v1.1.1.md new file mode 100644 index 0000000..a85e15e --- /dev/null +++ b/storekit/change_log/v1/v1.1.1.md @@ -0,0 +1,7 @@ +# Changelog + +## [1.1.1] - 2026-10-12 + +### 🐞 Fixed +- Guest checkout signup (`storekit.webshop_functions.cart.place_order`) created the new Customer without a Portal User row, so `/profile` showed no order history for a customer who had just signed up and ordered — not then, not ever, since nothing ever added the row later. `create_party()` now links the new Customer to the signing-up user the same way `storekit.api.register.register` already did. +- The same checkout signup could leave two Contact records for one email: Frappe auto-creates a bare Contact for every new User via a background job (`frappe.core.doctype.user.user.create_contact`, enqueued after commit), which could race storekit's own Contact creation in `create_contact()`. `get_contact_name()` has no defined order between the two rows, so `get_party()` sometimes resolved the unlinked one on a later visit, silently creating a second, empty Customer. That Customer's address didn't match the one saved at signup, so placing a second order while logged in failed with "Billing Address does not belong to <customer>" (surfaced to the customer as a generic "Something went wrong"). Added `_resolve_contact_for_email()`, which prefers whichever Contact for an email is actually linked to a party, and used it in `get_party()`, `create_contact()` and `_get_cart_quotation()` instead of trusting `get_contact_name()`'s arbitrary pick. diff --git a/storekit/webshop_functions/cart.py b/storekit/webshop_functions/cart.py index f797b3e..4d6a76a 100644 --- a/storekit/webshop_functions/cart.py +++ b/storekit/webshop_functions/cart.py @@ -4,7 +4,6 @@ from erpnext.selling.doctype.quotation.quotation import _make_sales_order from frappe import _ from frappe.contacts.doctype.address.address import get_address_display -from frappe.contacts.doctype.contact.contact import get_contact_name from frappe.utils import cint, cstr, flt, get_fullname from frappe.utils.nestedset import get_root_of @@ -12,6 +11,45 @@ from storekit.webshop_functions.checkout_auth import user_exists +def _resolve_contact_for_email(email): + """ + Find the Contact for this email, preferring one that's actually linked to a party. + + frappe.core.doctype.user.user.create_contact() auto-creates a bare Contact (no Customer + link) for every new User, but via frappe.enqueue(..., enqueue_after_commit=True) - a + background job, not something that has run by the time create_user() returns in the same + request. That job can still race storekit's own create_contact() below (e.g. the worker + picks up the job before our own insert has committed), leaving two Contact rows for the + same email: one linked to the new Customer, one not. get_contact_name() has no defined + order between them, so landing on the unlinked one made get_party() (used on every later + visit) believe this customer had no Customer/address history yet, creating a second, empty + Customer each time and hiding the billing address the signup itself had just saved. Scan + every Contact for this email and prefer one that already has a party link. + + Args: + email (str): The email address to look up. + + Returns: + Optional[frappe.model.document.Document]: The best-matching Contact, or None. + """ + if not email: + return None + + linked, unlinked = None, None + for contact_name in frappe.get_all( + "Contact Email", filters={"email_id": email, "parenttype": "Contact"}, pluck="parent" + ): + if not frappe.db.exists("Contact", contact_name): + continue + contact = frappe.get_doc("Contact", contact_name) + if contact.links: + linked = contact + break + unlinked = unlinked or contact + + return linked or unlinked + + def _get_cart_quotation(party=None, contact=None): """ Retrieve or create a "Shopping Cart" Quotation for the given party and contact. @@ -61,11 +99,7 @@ def _get_cart_quotation(party=None, contact=None): ) if not contact: - contact_name = get_contact_name(frappe.session.user) - if contact_name: - contact = frappe.get_doc("Contact", contact_name) - else: - contact = None + contact = _resolve_contact_for_email(frappe.session.user) if contact: contact_name = getattr(contact, "name", contact) @@ -183,14 +217,12 @@ def get_party(user=None): if not user: user = frappe.session.user - contact_name = get_contact_name(user) + contact = _resolve_contact_for_email(user) party = None - if contact_name: - contact = frappe.get_doc("Contact", contact_name) - if contact.links: - party_doctype = contact.links[0].link_doctype - party = contact.links[0].link_name + if contact and contact.links: + party_doctype = contact.links[0].link_doctype + party = contact.links[0].link_name if party: doc = frappe.get_doc(party_doctype, party) @@ -602,7 +634,7 @@ def place_order(doc=None, cart_items=None): "customer_email_address": doc.email, } - party = create_party(doc=customer) + party = create_party(doc=customer, user=doc.get("email_id")) if party: contact = create_contact(doc, party.name) quotation = _get_cart_quotation(party=party, contact=contact) @@ -725,18 +757,26 @@ def place_order(doc=None, cart_items=None): } -def create_party(doc): +def create_party(doc, user=None): """ Create and save a new Customer party based on the provided details. Args: doc (dict): The customer details to create the party. + user (Optional[str]): The portal user (email) to link to this Customer, so + get_party() can find it by Portal User lookup on later visits and the + storefront's order-history page (which filters Customers by Portal User, + not by Contact) can find it right away. Without this, a customer created + during guest checkout signup has no Portal User row at all, and /profile + shows no orders even immediately after the signup's own first order. Returns: frappe.model.document.Document: The created Customer document. """ doc.update({"doctype": "Customer"}) party = frappe.get_doc(doc) + if user: + party.append("portal_users", {"user": user}) party.save(ignore_permissions=True) return party @@ -762,14 +802,10 @@ def create_contact(doc, customer_name): """ Link the Customer to the Contact for this email, creating one only if none exists. - frappe.core.doctype.user.user.create_contact() already auto-creates a bare Contact (no - Customer link) for every new User, right when create_user() above calls user.insert(). Blindly - inserting another Contact here left two Contact rows sharing the same email - one linked to the - new Customer, one not - and get_contact_name() (used by get_party() on every later visit) has no - defined order between them. Landing on the unlinked one made get_party() believe this customer - had no Customer/address history yet, creating a second, empty Customer each time and hiding the - billing address the signup itself had just saved. Reuse whatever Contact already exists for this - email instead of creating a second one. + See _resolve_contact_for_email() for why more than one Contact can exist for the same + email (a race with frappe's own background User->Contact job), and why picking the wrong + one breaks every later visit for this customer. Reuse the best-matching Contact instead of + blindly creating a second one. Args: doc (dict): The contact details (full name, email, and phone). @@ -779,10 +815,9 @@ def create_contact(doc, customer_name): frappe.model.document.Document: The (possibly pre-existing) Contact document. """ email = doc.get("email_id") - existing_name = email and get_contact_name(email) + contact_doc = _resolve_contact_for_email(email) - if existing_name: - contact_doc = frappe.get_doc("Contact", existing_name) + if contact_doc: if not any( link.link_doctype == "Customer" and link.link_name == customer_name for link in contact_doc.links