From 89156a5d6c3e55a4d2ab011903da43d3f2f9eebb Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Wed, 13 May 2026 18:29:50 -0400 Subject: [PATCH] fix: invoices charge negotiated price, not list (Stripe/PayPal/Monero/DOGE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- make_post_sell/models/invoice.py | 33 ++++- ...bc1bfc33dc_invoice_negotiation_override.py | 48 +++++++ make_post_sell/tests/test_integration.py | 119 ++++++++++++++++++ make_post_sell/views/cart.py | 5 + make_post_sell/views/crypto.py | 8 ++ 5 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 make_post_sell/scripts/alembic/versions/27bc1bfc33dc_invoice_negotiation_override.py diff --git a/make_post_sell/models/invoice.py b/make_post_sell/models/invoice.py index e65d385..70d2e05 100644 --- a/make_post_sell/models/invoice.py +++ b/make_post_sell/models/invoice.py @@ -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 diff --git a/make_post_sell/scripts/alembic/versions/27bc1bfc33dc_invoice_negotiation_override.py b/make_post_sell/scripts/alembic/versions/27bc1bfc33dc_invoice_negotiation_override.py new file mode 100644 index 0000000..ccfe384 --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/27bc1bfc33dc_invoice_negotiation_override.py @@ -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") diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py index 06c5b89..3900cee 100644 --- a/make_post_sell/tests/test_integration.py +++ b/make_post_sell/tests/test_integration.py @@ -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.""" diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py index a35c4b5..2756701 100644 --- a/make_post_sell/views/cart.py +++ b/make_post_sell/views/cart.py @@ -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(): diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py index 471f5d7..607204d 100644 --- a/make_post_sell/views/crypto.py +++ b/make_post_sell/views/crypto.py @@ -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