diff --git a/make_post_sell/lib/mail.py b/make_post_sell/lib/mail.py index 1154454..a561225 100644 --- a/make_post_sell/lib/mail.py +++ b/make_post_sell/lib/mail.py @@ -22,6 +22,14 @@ from make_post_sell.lib.mail_messages import ( OFFER_RECEIVED_HTML, OFFER_ACCEPTED_TEXT, OFFER_ACCEPTED_HTML, + OFFER_COUNTERED_TEXT, + OFFER_COUNTERED_HTML, + OFFER_DECLINED_TEXT, + OFFER_DECLINED_HTML, + OFFER_WITHDRAWN_TEXT, + OFFER_WITHDRAWN_HTML, + OFFER_BUYER_CANCELLED_TEXT, + OFFER_BUYER_CANCELLED_HTML, ) import dkim @@ -660,3 +668,75 @@ def send_offer_accepted_email(request, to_email, offer): offer_url=offer_url, ) send_pyramid_email(request, to_email, subject, text, html) + + +def send_offer_countered_email(request, to_email, offer): + """Notify the OTHER party that their counterpart countered. Caller + decides 'other party' — buyer gets it when seller counters, seller + gets it when buyer counters back.""" + offer_url = f"{request.host_url}/o/{offer.uuid_str}" + subject = f'Counter offer on "{offer.product.title}"' + fmt = dict( + amount=f"{offer.current_amount:.2f}", + title=offer.product.title, + offer_url=offer_url, + ) + send_pyramid_email( + request, to_email, subject, + OFFER_COUNTERED_TEXT.format(**fmt), + OFFER_COUNTERED_HTML.format(**fmt), + ) + + +def send_offer_declined_email(request, to_email, offer): + """Notify the buyer that the seller manually declined their offer. + Auto-decline (sub-threshold) is intentionally silent — the flash on + submit already explains it inline.""" + offer_url = f"{request.host_url}/o/{offer.uuid_str}" + subject = f'Your offer for "{offer.product.title}" was declined' + fmt = dict( + amount=f"{offer.current_amount:.2f}", + title=offer.product.title, + offer_url=offer_url, + ) + send_pyramid_email( + request, to_email, subject, + OFFER_DECLINED_TEXT.format(**fmt), + OFFER_DECLINED_HTML.format(**fmt), + ) + + +def send_offer_withdrawn_email(request, to_email, offer): + """Notify the seller that the buyer withdrew their offer before + the seller responded. (Distinct from buyer-cancel-after-accept, + which has its own helper.)""" + offer_url = f"{request.host_url}/o/{offer.uuid_str}" + subject = f'Offer withdrawn on "{offer.product.title}"' + fmt = dict( + amount=f"{offer.current_amount:.2f}", + title=offer.product.title, + offer_url=offer_url, + ) + send_pyramid_email( + request, to_email, subject, + OFFER_WITHDRAWN_TEXT.format(**fmt), + OFFER_WITHDRAWN_HTML.format(**fmt), + ) + + +def send_offer_buyer_cancelled_email(request, to_email, offer): + """Notify the seller that the buyer cancelled an *already-accepted* + offer before paying. The seller is owed the explicit notice — + otherwise they'd keep waiting for a payment that's never coming.""" + offer_url = f"{request.host_url}/o/{offer.uuid_str}" + subject = f'Buyer cancelled accepted offer on "{offer.product.title}"' + fmt = dict( + amount=f"{offer.current_amount:.2f}", + title=offer.product.title, + offer_url=offer_url, + ) + send_pyramid_email( + request, to_email, subject, + OFFER_BUYER_CANCELLED_TEXT.format(**fmt), + OFFER_BUYER_CANCELLED_HTML.format(**fmt), + ) diff --git a/make_post_sell/lib/mail_messages.py b/make_post_sell/lib/mail_messages.py index 0f1f0c2..e4cb6c3 100644 --- a/make_post_sell/lib/mail_messages.py +++ b/make_post_sell/lib/mail_messages.py @@ -405,3 +405,85 @@ OFFER_ACCEPTED_HTML = """ """ + + +OFFER_COUNTERED_TEXT = """ +The other party countered with ${amount} for "{title}". + +Accept, counter, or decline: + +{offer_url} +""" + +OFFER_COUNTERED_HTML = """ + + + +

Counter offer received

+

The other party countered with ${amount} for {title}.

+

Review and respond

+ + +""" + + +OFFER_DECLINED_TEXT = """ +Your offer of ${amount} for "{title}" was declined. + +View details: + +{offer_url} +""" + +OFFER_DECLINED_HTML = """ + + + +

Offer declined

+

Your offer of ${amount} for {title} was declined.

+

View details

+ + +""" + + +OFFER_WITHDRAWN_TEXT = """ +The buyer withdrew their offer of ${amount} for "{title}". + +View details: + +{offer_url} +""" + +OFFER_WITHDRAWN_HTML = """ + + + +

Offer withdrawn

+

The buyer withdrew their offer of ${amount} for {title}.

+

View details

+ + +""" + + +OFFER_BUYER_CANCELLED_TEXT = """ +The buyer cancelled their accepted offer of ${amount} for "{title}". + +You accepted this offer earlier but the buyer backed out before paying. +View details: + +{offer_url} +""" + +OFFER_BUYER_CANCELLED_HTML = """ + + + +

Buyer cancelled accepted offer

+

The buyer cancelled their accepted offer of ${amount} for {title}.

+

You accepted this offer earlier but the buyer backed out before paying.

+

View details

+ + +""" diff --git a/make_post_sell/views/offer.py b/make_post_sell/views/offer.py index 6cd7646..8103ca7 100644 --- a/make_post_sell/views/offer.py +++ b/make_post_sell/views/offer.py @@ -31,6 +31,10 @@ from ..lib.offer import ( from ..lib.mail import ( send_offer_received_email, send_offer_accepted_email, + send_offer_countered_email, + send_offer_declined_email, + send_offer_withdrawn_email, + send_offer_buyer_cancelled_email, ) from ..lib.currency import cents_to_dollars from ..models.offer import ( @@ -379,10 +383,64 @@ def _offer_action(request, action_fn, message_required=False): return HTTPFound(offer_url) +def _safe_email(label, fn): + """Wrap a transactional-email send so a delivery failure can't + abort an offer-state HTTP response. All offer emails are + informational; the source of truth is the offer page.""" + try: + fn() + except Exception: + logging.getLogger(__name__).exception( + "%s email failed (non-fatal)", label + ) + + +def _shop_owner_emails(offer): + """Set of distinct owner emails for this offer's shop. Used for + notifications that target the seller side.""" + return {o.email for o in offer.shop.owners if o.email} + + @view_config(route_name="offer_counter", request_method="POST", renderer="json") @user_required(flash_msg="Please log in to act on this offer.") def offer_counter(request): - return _offer_action(request, counter_offer) + # Snapshot the round count so we only notify on an actual transition + # (not a retried POST that already succeeded). + offer_before = get_offer_by_id( + request.dbsession, request.matchdict["offer_id"] + ) + rounds_before = offer_before.round_count if offer_before else None + actor_party_before, _ = _user_party(request, offer_before) if offer_before else (None, False) + + result = _offer_action(request, counter_offer) + + offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"]) + if ( + offer is not None + and rounds_before is not None + and offer.round_count > rounds_before + and actor_party_before is not None + ): + # Whoever wasn't the actor needs the email. counter_offer + # flipped current_party to the OTHER side, but the simpler + # path is: actor != buyer → notify buyer; actor == buyer → + # notify shop owners. + if actor_party_before == OFFER_PARTY_BUYER: + for owner_email in _shop_owner_emails(offer): + _safe_email( + "offer counter", + lambda em=owner_email: send_offer_countered_email( + request, em, offer + ), + ) + else: + _safe_email( + "offer counter", + lambda: send_offer_countered_email( + request, offer.buyer.email, offer + ), + ) + return result @view_config(route_name="offer_accept", request_method="POST", renderer="json") @@ -399,25 +457,74 @@ def offer_accept(request): # repeated request against an already-accepted offer). offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"]) if offer is not None and offer.is_accepted and not was_accepted: - try: - send_offer_accepted_email(request, offer.buyer.email, offer) - except Exception: - logging.getLogger(__name__).exception( - "offer accepted email failed (non-fatal)" - ) + _safe_email( + "offer accepted", + lambda: send_offer_accepted_email( + request, offer.buyer.email, offer + ), + ) return result @view_config(route_name="offer_decline", request_method="POST", renderer="json") @user_required(flash_msg="Please log in to act on this offer.") def offer_decline(request): - return _offer_action(request, decline_offer) + offer_before = get_offer_by_id( + request.dbsession, request.matchdict["offer_id"] + ) + was_declined = ( + offer_before is not None and offer_before.state == OFFER_STATE_DECLINED + ) + actor_party_before, _ = _user_party(request, offer_before) if offer_before else (None, False) + + result = _offer_action(request, decline_offer) + + # Only notify on the actual transition into DECLINED, and only + # when the seller did it manually — auto-decline (sub-threshold) + # is intentionally silent. + offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"]) + if ( + offer is not None + and offer.state == OFFER_STATE_DECLINED + and not was_declined + and actor_party_before == OFFER_PARTY_SELLER + ): + _safe_email( + "offer declined", + lambda: send_offer_declined_email( + request, offer.buyer.email, offer + ), + ) + return result @view_config(route_name="offer_withdraw", request_method="POST", renderer="json") @user_required(flash_msg="Please log in to act on this offer.") def offer_withdraw(request): - return _offer_action(request, withdraw_offer) + offer_before = get_offer_by_id( + request.dbsession, request.matchdict["offer_id"] + ) + was_withdrawn = ( + offer_before is not None + and offer_before.state == OFFER_STATE_WITHDRAWN + ) + + result = _offer_action(request, withdraw_offer) + + offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"]) + if ( + offer is not None + and offer.state == OFFER_STATE_WITHDRAWN + and not was_withdrawn + ): + for owner_email in _shop_owner_emails(offer): + _safe_email( + "offer withdrawn", + lambda em=owner_email: send_offer_withdrawn_email( + request, em, offer + ), + ) + return result @view_config( @@ -431,7 +538,32 @@ def offer_cancel_after_accept_view(request): state than withdraw so the seller's inbox can distinguish "they pulled it mid-negotiation" from "they ghosted after I accepted." """ - return _offer_action(request, cancel_offer_after_accept) + from ..models.offer import OFFER_STATE_BUYER_CANCELLED + + offer_before = get_offer_by_id( + request.dbsession, request.matchdict["offer_id"] + ) + was_cancelled = ( + offer_before is not None + and offer_before.state == OFFER_STATE_BUYER_CANCELLED + ) + + result = _offer_action(request, cancel_offer_after_accept) + + offer = get_offer_by_id(request.dbsession, request.matchdict["offer_id"]) + if ( + offer is not None + and offer.state == OFFER_STATE_BUYER_CANCELLED + and not was_cancelled + ): + for owner_email in _shop_owner_emails(offer): + _safe_email( + "offer buyer-cancelled", + lambda em=owner_email: send_offer_buyer_cancelled_email( + request, em, offer + ), + ) + return result @view_config(route_name="offer_checkout", request_method="POST")