Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion storekit/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.1.0"
__version__ = "1.1.1"
7 changes: 7 additions & 0 deletions storekit/change_log/v1/v1.1.1.md
Original file line number Diff line number Diff line change
@@ -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.
85 changes: 60 additions & 25 deletions storekit/webshop_functions/cart.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,52 @@
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

from storekit.api.shipping import calculate_cost
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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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
Expand Down
Loading