fix: invoices charge negotiated price, not list (Stripe/PayPal/Monero/DOGE)

CRITICAL financial defect. Cart UI showed the negotiated $21 total
for an accepted offer (list was $42), but EVERY payment pipeline —
Stripe (cart.py:848), PayPal (cart.py:1164), Monero quote
(crypto.py:269), Dogecoin quote (crypto.py:538) — pulled
invoice.total_in_cents, which summed line items and ignored the
cart_offer / cart_auction override entirely. Buyer's DOGE quote
asked for ~373 DOGE (≈$42 USD) for an offer they negotiated to $21.
Seller would have eaten $21 per accepted offer.

Fix:

- New column mps_invoice.negotiation_override_in_cents (nullable
  BigInteger). Migration 27bc1bfc33dc adds it idempotently.
- Invoice.total_in_cents short-circuits to the override + handling
  when the column is set. Coupons / discount math is bypassed (the
  buyer already negotiated; we don't stack on top).
- Invoice.apply_cart_negotiation(cart) helper copies the cart's
  override onto the invoice in one call. Idempotent. No-op for
  non-negotiated carts.
- Wired into every invoice-from-cart construction site (7 total):
  - views/crypto.py:161 (Monero quote)
  - views/crypto.py:454 (Dogecoin quote — fox's screenshot)
  - views/cart.py:827 (Stripe checkout)
  - views/cart.py:974 (PayPal create-order)
  - views/cart.py:1153 (Adyen sessions)
  - views/cart.py:1258 (PayPal complete-checkout)
  - views/cart.py:1388 (post-PayPal-approval finalize)

Two integration tests cover the model:
- test_negotiation_override_short_circuits_total walks the
  invoice-from-negotiated-cart flow, asserts $42 → $21 transition
  after apply_cart_negotiation, and confirms handling still adds
  on top.
- test_apply_cart_negotiation_noop_when_not_negotiated proves the
  helper is safe to call on plain carts (no negotiation_override
  set, line items sum normally).

Legacy invoices already in the DB have NULL on the new column —
they keep their line-item-summed totals, untouched.
This commit is contained in:
russell@unturf.com 2026-05-13 18:29:50 -04:00
parent 309fae3a61
commit 89156a5d6c
No known key found for this signature in database
5 changed files with 212 additions and 1 deletions

View file

@ -114,6 +114,12 @@ class Invoice(RBase, Base):
# Adyen payment tracking (nullable - only set for Adyen payments)
adyen_psp_reference = Column(Unicode(64), nullable=True)
# MPS-20 + MPS-21: when this invoice was built from a cart with an
# accepted offer or winning auction bid, the negotiated total
# overrides line-item summation. Carries the agreed amount in
# cents; nullable for non-negotiated invoices.
negotiation_override_in_cents = Column(BigInteger, nullable=True)
# one to one.
user = relationship(argument="User", uselist=False, lazy="joined")
@ -193,6 +199,19 @@ class Invoice(RBase, Base):
)
)
def apply_cart_negotiation(self, cart):
"""MPS-20 + MPS-21: if cart is offer- or auction-bound, copy
the negotiated override total onto this invoice so payment
processors charge the agreed price, not the list-price sum
of line items. Idempotent safe to call after invoice
construction at any point. Coupons / gift cards don't stack
on a negotiated price; this short-circuits both.
"""
if cart.is_negotiated:
self.negotiation_override_in_cents = (
cart.auction_offer_override_in_cents
)
def new_coupon_redemption(self, coupon):
"""
given a Coupon, create a coupon redemption for this invoice.
@ -236,7 +255,19 @@ class Invoice(RBase, Base):
@property
def total_in_cents(self):
"""Calculate the total amount in cents for the invoice, including handling fee and discounts."""
"""Calculate the total amount in cents for the invoice, including handling fee and discounts.
MPS-20 + MPS-21: when negotiation_override_in_cents is set, the
invoice was built from a cart with an accepted offer or winning
auction bid. The negotiated price replaces the line-item
summation entirely; coupons and discount stacking do not apply
to a negotiated price (the buyer already negotiated it). Only
handling is added on top.
"""
if self.negotiation_override_in_cents is not None:
handling = self.handling_cost_in_cents or 0
return max(0, self.negotiation_override_in_cents + handling)
subtotal = self.subtotal_in_cents
discount = self.discount_amount_in_cents
handling = self.handling_cost_in_cents or 0

View file

@ -0,0 +1,48 @@
"""invoice negotiation override
Revision ID: 27bc1bfc33dc
Revises: 632878c8f243
Create Date: 2026-05-13 18:23:54.436874
Adds mps_invoice.negotiation_override_in_cents (nullable BigInteger).
When set, Invoice.total_in_cents short-circuits to this override +
handling, bypassing the subtotal/discount math.
Why: carts linked to an accepted offer or winning auction have a
negotiated total that overrides the sum of line items. The cart UI
honored this, but Invoice.total_in_cents summed line items only so
every payment method (Stripe, PayPal, Monero, Dogecoin) was charging
the list price for negotiated carts. This column lets the invoice
inherit the cart's override at construction time.
"""
from alembic import op
import sqlalchemy as sa
revision = '27bc1bfc33dc'
down_revision = '632878c8f243'
branch_labels = None
depends_on = None
def _column_exists(table, column):
conn = op.get_bind()
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
return any(row[1] == column for row in result.fetchall())
def upgrade():
if not _column_exists("mps_invoice", "negotiation_override_in_cents"):
op.add_column(
"mps_invoice",
sa.Column(
"negotiation_override_in_cents",
sa.BigInteger(),
nullable=True,
),
)
def downgrade():
if _column_exists("mps_invoice", "negotiation_override_in_cents"):
op.drop_column("mps_invoice", "negotiation_override_in_cents")

View file

@ -1045,6 +1045,125 @@ class TestStripeUserShopIntegration(DatabaseIntegrationTests):
transaction.commit()
class TestInvoiceNegotiationOverride(DatabaseIntegrationTests):
"""MPS-20 + MPS-21: Invoice.total_in_cents honors the cart's
accepted-offer / winning-auction override so all four payment
methods (Stripe, PayPal, Monero, Dogecoin) charge the negotiated
price, not the line-item sum.
Regression coverage: fox spotted that the Dogecoin quote page
showed Cart Total $42 for an offer accepted at $21. Every payment
pipeline pulled invoice.total_in_cents which summed line items
and ignored the cart_offer override entirely.
"""
def test_negotiation_override_short_circuits_total(self):
from ..models.cart import Cart
from ..models.cart_offer import MpsCartOffer
from ..models.offer import (
MpsOffer, OFFER_STATE_ACCEPTED, now_timestamp,
)
from ..models.invoice import Invoice
from ..models.product import Product
from ..models.price import Price
shop = Shop(
name="Override Shop", phone_number="x",
billing_address="x", description="x",
)
shop.stripe_public_api_key = "pk_test"
shop.stripe_secret_api_key = "sk_test"
shop.domain_name = "override.test"
self.dbsession.add(shop)
product = Product(title="Negotiable", description="...")
product.shop = shop
product.is_physical = False
self.dbsession.add(product)
self.dbsession.flush()
self.dbsession.add(Price(product, 4200)) # $42 list
self.dbsession.flush()
buyer = get_or_create_user_by_email(self.dbsession, "b@ovr.test")
self.dbsession.add(buyer)
self.dbsession.flush()
cart = Cart(user=buyer)
cart.shop = shop
cart.add_product(product)
self.dbsession.add(cart)
offer = MpsOffer(
product=product, shop=shop, buyer=buyer,
amount_in_cents=2100, # $21 accepted
expires_timestamp=now_timestamp() + 86_400_000,
)
offer.state = OFFER_STATE_ACCEPTED
offer.accepted_timestamp = now_timestamp()
self.dbsession.add(offer)
self.dbsession.add(MpsCartOffer(cart=cart, offer=offer))
self.dbsession.flush()
# Build the invoice the way every checkout view does it.
invoice = Invoice(buyer)
invoice.shop = shop
invoice.shop_id = shop.id
invoice.new_line_item(product=product, quantity=1)
# Without applying the cart negotiation, the invoice still sums
# line items — this is what was being passed to the crypto
# quote / Stripe / PayPal.
self.assertEqual(invoice.total_in_cents, 4200)
# The helper writes the override; total flips to the agreed
# amount.
invoice.apply_cart_negotiation(cart)
self.assertEqual(invoice.negotiation_override_in_cents, 2100)
self.assertEqual(invoice.total_in_cents, 2100)
# Handling still adds on top of the override.
invoice.handling_cost_in_cents = 500
self.assertEqual(invoice.total_in_cents, 2600)
def test_apply_cart_negotiation_noop_when_not_negotiated(self):
"""Non-negotiated cart → invoice keeps line-item summation."""
from ..models.cart import Cart
from ..models.invoice import Invoice
from ..models.product import Product
from ..models.price import Price
shop = Shop(
name="Plain Shop", phone_number="x",
billing_address="x", description="x",
)
shop.stripe_public_api_key = "pk_test"
shop.stripe_secret_api_key = "sk_test"
shop.domain_name = "plain.test"
self.dbsession.add(shop)
product = Product(title="Plain", description="...")
product.shop = shop
product.is_physical = False
self.dbsession.add(product)
self.dbsession.flush()
self.dbsession.add(Price(product, 1000))
self.dbsession.flush()
buyer = get_or_create_user_by_email(self.dbsession, "b@plain.test")
self.dbsession.add(buyer)
cart = Cart(user=buyer)
cart.shop = shop
cart.add_product(product)
self.dbsession.add(cart)
self.dbsession.flush()
invoice = Invoice(buyer)
invoice.shop = shop
invoice.shop_id = shop.id
invoice.new_line_item(product=product, quantity=1)
invoice.apply_cart_negotiation(cart)
self.assertIsNone(invoice.negotiation_override_in_cents)
self.assertEqual(invoice.total_in_cents, 1000)
class TestInvoiceDiscountIntegration(DatabaseIntegrationTests):
"""Integration tests for Invoice discount calculations with real coupons."""

View file

@ -824,6 +824,7 @@ def cart_complete_checkout(request):
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
invoice.apply_cart_negotiation(cart)
invoices.append(invoice)
# Attempt payment BEFORE adding invoices to session when Stripe is enabled
@ -970,6 +971,7 @@ def paypal_complete_checkout(request):
continue
invoice.new_coupon_redemption(coupon)
invoice.apply_cart_negotiation(cart)
invoice_map[shop_id] = invoice
invoices_requiring_payment = [inv for inv in invoice_map.values() if inv.requires_payment]
@ -1148,6 +1150,7 @@ def adyen_create_session(request):
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
invoice.apply_cart_negotiation(cart)
invoice_map[shop_id] = invoice
for shop_id, invoice in invoice_map.items():
@ -1252,6 +1255,7 @@ def adyen_complete_checkout(request):
continue
invoice.new_coupon_redemption(coupon)
invoice.apply_cart_negotiation(cart)
invoice_map[shop_id] = invoice
invoices_requiring_payment = [inv for inv in invoice_map.values() if inv.requires_payment]
@ -1381,6 +1385,7 @@ def paypal_create_order(request):
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
invoice.apply_cart_negotiation(cart)
invoice_map[shop_id] = invoice
for shop_id, invoice in invoice_map.items():

View file

@ -156,6 +156,10 @@ def crypto_xmr_start(request):
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
# MPS-20 + MPS-21: inherit the cart's negotiated override so
# the crypto quote charges the agreed price, not list × qty.
invoice.apply_cart_negotiation(cart)
# Check inventory for physical products BEFORE creating payment quote
from ..models.inventory import get_inventory_by_product_and_shop_location
@ -445,6 +449,10 @@ def crypto_doge_start(request):
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
# MPS-20 + MPS-21: inherit the cart's negotiated override so
# the crypto quote charges the agreed price, not list × qty.
invoice.apply_cart_negotiation(cart)
# Check inventory for physical products BEFORE creating payment quote
from ..models.inventory import get_inventory_by_product_and_shop_location