feat: email both parties on every offer state transition
Previously the only emails on the offer state machine were:
- PENDING → seller (offer received)
- auto-accept / seller-accept → buyer (offer accepted)
- cart paid → buyer + seller (purchase / sale)
Every other transition was silent — buyer countered, seller
countered, seller manually declined, buyer withdrew before accept,
buyer cancelled after accept. The buyer-cancelled-after-accept case
stung most: the seller had accepted and was awaiting payment that
was never coming, with no notice except by polling /s/<shop>/offers.
Five gap-plugs:
1. Buyer counters back → seller(s) emailed (all shop owners).
2. Seller counters → buyer emailed.
3. Seller manually declines → buyer emailed. (auto-decline stays
silent — the submit flash already conveys it inline.)
4. Buyer withdraws (pre-accept) → seller(s) emailed.
5. Buyer cancels (post-accept) → seller(s) emailed with explicit
"buyer cancelled accepted offer" copy so the seller stops
expecting payment.
Templates: OFFER_COUNTERED_{TEXT,HTML}, OFFER_DECLINED_{TEXT,HTML},
OFFER_WITHDRAWN_{TEXT,HTML}, OFFER_BUYER_CANCELLED_{TEXT,HTML} in
lib/mail_messages.py.
Helpers: send_offer_countered_email, send_offer_declined_email,
send_offer_withdrawn_email, send_offer_buyer_cancelled_email in
lib/mail.py — same pattern as send_offer_received_email +
send_offer_accepted_email.
Each handler in views/offer.py snapshots the pre-action state, runs
the action, and only emails on the actual transition (so a retried
POST against an already-terminal offer doesn't re-fire the email).
All sends go through _safe_email which logs + swallows exceptions:
mail failure cannot 500 an offer-state HTTP response.
This commit is contained in:
parent
7870c9425e
commit
9dc6b7be81
3 changed files with 304 additions and 10 deletions
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -405,3 +405,85 @@ OFFER_ACCEPTED_HTML = """
|
|||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
OFFER_COUNTERED_TEXT = """
|
||||
The other party countered with ${amount} for "{title}".
|
||||
|
||||
Accept, counter, or decline:
|
||||
|
||||
{offer_url}
|
||||
"""
|
||||
|
||||
OFFER_COUNTERED_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h2>Counter offer received</h2>
|
||||
<p>The other party countered with <strong>${amount}</strong> for <strong>{title}</strong>.</p>
|
||||
<p><a href="{offer_url}" style="font-weight: bold;">Review and respond</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
OFFER_DECLINED_TEXT = """
|
||||
Your offer of ${amount} for "{title}" was declined.
|
||||
|
||||
View details:
|
||||
|
||||
{offer_url}
|
||||
"""
|
||||
|
||||
OFFER_DECLINED_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h2>Offer declined</h2>
|
||||
<p>Your offer of <strong>${amount}</strong> for <strong>{title}</strong> was declined.</p>
|
||||
<p><a href="{offer_url}" style="font-weight: bold;">View details</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
OFFER_WITHDRAWN_TEXT = """
|
||||
The buyer withdrew their offer of ${amount} for "{title}".
|
||||
|
||||
View details:
|
||||
|
||||
{offer_url}
|
||||
"""
|
||||
|
||||
OFFER_WITHDRAWN_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h2>Offer withdrawn</h2>
|
||||
<p>The buyer withdrew their offer of <strong>${amount}</strong> for <strong>{title}</strong>.</p>
|
||||
<p><a href="{offer_url}" style="font-weight: bold;">View details</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
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 = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h2>Buyer cancelled accepted offer</h2>
|
||||
<p>The buyer cancelled their accepted offer of <strong>${amount}</strong> for <strong>{title}</strong>.</p>
|
||||
<p>You accepted this offer earlier but the buyer backed out before paying.</p>
|
||||
<p><a href="{offer_url}" style="font-weight: bold;">View details</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue