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:
parent
309fae3a61
commit
89156a5d6c
5 changed files with 212 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue