Skip to content

Commit b8ffa16

Browse files
committed
feat: a negotiable listing is buyable at its asking price too
1 parent 063ef34 commit b8ffa16

10 files changed

Lines changed: 113 additions & 72 deletions

File tree

‎CLAUDE.md‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -327,16 +327,16 @@ give it its own doc under `docs/` and link it from here.
327327
*identifiers* (schema/table/column/type/constraint names) stay `snake_case`;
328328
only the stored value strings are kebab. Multi-word: `product-spu`,
329329
`awaiting-buyer-action`, `awaiting-seller-review`.
330-
- **A seller never approves an order; the money creates it.** `price_mode` is the only thing
331-
that differs between the two ways a sale starts. `fixed` is bought from the listing page: the
332-
buyer checks out a draft, pays the item plus the shipping quote, and the order and its shipment
333-
exist as soon as that payment session completes. `negotiable` cannot be checked out — the buyer
334-
opens a negotiation, which is the chat thread the pair already shares, either side revises the
335-
terms, and agreeing opens the same checkout. So the only thing a seller can refuse
336-
is a price, and there is no route that turns paid items into an order: the payment webhook
337-
does, which is why `item.order_id` is nullable and `item_seller_pending_idx` is a retry list
338-
rather than an inbox. The buyer always pays delivery, so a seller is never charged and
339-
`session_kind` has no `seller-confirmation-fee`.
330+
- **A seller never approves an order; the money creates it.** Every listing is buyable from its
331+
page: the buyer checks out a draft that froze the seller's asking price, pays the item plus the
332+
shipping quote, and the order and its shipment exist as soon as that payment session completes.
333+
`price_mode` adds a path rather than replacing one — `negotiable` also lets the buyer open a
334+
negotiation (the chat thread the pair already shares), either side revises the terms, and agreeing
335+
freezes them for the same checkout; `fixed` refuses to be negotiated, which is now the only thing
336+
the mode decides. So the only thing a seller can refuse is a price, and there is no route that
337+
turns paid items into an order: the payment webhook does, which is why `item.order_id` is nullable
338+
and `item_seller_pending_idx` is a retry list rather than an inbox. The buyer always pays
339+
delivery, so a seller is never charged and `session_kind` has no `seller-confirmation-fee`.
340340
- **A claim is taken before the money, and the write is the claim.** A draft is spent
341341
(`WHERE cancelled_at IS NULL`) and an accepted offer moves to `checked-out`
342342
(`WHERE status = 'accepted'`) *before* anything is reserved or a payment session is opened, so a
@@ -353,8 +353,9 @@ give it its own doc under `docs/` and link it from here.
353353
acceptance landed would put terms back on a table that was already agreed, and the buyer holding
354354
a 200 "agreed" would then be told their checkout has nothing to check out. Same rule as
355355
`Version` on an aggregate — a stale read has to lose.
356-
- **Agreeing to a price is not the sale; the buyer's checkout is.** Either party may accept the
357-
terms on the table — whoever does *not* own the standing proposal, since the two sides
356+
- **Agreeing to a price is not the sale; the buyer's checkout is.** (Nor is negotiating the only
357+
way to buy a `negotiable` listing — the asking price is takeable, see above.) Either party may
358+
accept the terms on the table — whoever does *not* own the standing proposal, since the two sides
358359
alternate — and that charges nothing: it freezes the price for `acceptedWindow` (30 minutes,
359360
against `offerWindow`'s 12 hours) and the buyer then presses "create order now", which is the
360361
same checkout a fixed-price listing opens. That split is what makes a *seller* accepting safe:

‎api/openapi.gen.yaml‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2537,7 +2537,7 @@ components:
25372537
25382538
`fixed` is bought straight from the listing page: the buyer checks out, pays the item plus the shipping quote, and the order and its shipment exist as soon as the money lands. The seller confirms nothing.
25392539
2540-
`negotiable` cannot be checked out. The buyer opens a negotiation, which is a thread in chat where either side revises the terms, and accepting them is what starts the same checkout. Either way the seller never approves an order — the only thing they can refuse is a price.
2540+
`negotiable` adds a second way to buy without removing the first: the asking price is still a price a buyer can take outright, and the listing page asks which they want. If they haggle, the negotiation is a thread in chat where either side revises the terms, and agreeing freezes them for the same checkout. Either way the seller never approves an order — the only thing they can refuse is a price.
25412541
enum:
25422542
- fixed
25432543
- negotiable
@@ -6574,7 +6574,7 @@ paths:
65746574
description: |
65756575
Freezes the listing terms so a price the seller edits afterwards cannot be charged. The server snapshots the listing together with every variant — price and package details live on the variant, and those are exactly what must not move under the buyer — and sets `valid_until`.
65766576
6577-
Only a `fixed` listing has one: a `negotiable` price is agreed in an offer, and the accepted offer is what freezes its terms instead. Asking for one is 422.
6577+
Every listing has one, whichever way it is priced. A `negotiable` listing carries the seller's asking price like any other, and a buyer who does not want to haggle takes it: the listing page offers both, and this is the "buy now" half. Negotiating instead freezes the agreed terms in the offer, and `POST /offers/{id}/checkout` is the same checkout from there.
65786578
requestBody:
65796579
content:
65806580
application/json:
@@ -6603,12 +6603,6 @@ paths:
66036603
schema:
66046604
$ref: '#/components/schemas/Error'
66056605
description: Listing not found, deleted, or not active
6606-
"422":
6607-
content:
6608-
application/json:
6609-
schema:
6610-
$ref: '#/components/schemas/Error'
6611-
description: The listing is priced negotiable; open an offer instead
66126606
security:
66136607
- bearerAuth: []
66146608
summary: Open a purchase session for one listing
@@ -6670,7 +6664,7 @@ paths:
66706664
66716665
The session is spent before the money is asked for: the claim is what makes two concurrent checkouts of one draft impossible, and the loser is answered 409 rather than handed a second payment session for the same frozen price.
66726666
6673-
A `negotiable` listing has no draft at all, so there is nothing to check out here — its terms are frozen by the offer both sides agreed on, and `POST /offers/{id}/checkout` is the same checkout from there. The buyer pays delivery either way.
6667+
This is the checkout for terms a draft froze — the seller's own price, whether the listing is `fixed` or `negotiable`. Terms that were *negotiated* are checked out at `POST /offers/{id}/checkout` instead, which is the same flow against the agreed price. The buyer pays delivery either way.
66746668
parameters:
66756669
- in: path
66766670
name: id

‎internal/module/catalog/api/openapi/listing.yaml‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -497,10 +497,11 @@ components:
497497
money lands. The seller confirms nothing.
498498
499499
500-
`negotiable` cannot be checked out. The buyer opens a negotiation, which is a
501-
thread in chat where either side revises the terms, and accepting them is what
502-
starts the same checkout. Either way the seller never approves an order — the only
503-
thing they can refuse is a price.
500+
`negotiable` adds a second way to buy without removing the first: the asking price is
501+
still a price a buyer can take outright, and the listing page asks which they want. If they
502+
haggle, the negotiation is a thread in chat where either side revises the terms, and
503+
agreeing freezes them for the same checkout. Either way the seller never approves an order —
504+
the only thing they can refuse is a price.
504505
enum: [fixed, negotiable]
505506

506507
PublishListingRequest:

‎internal/module/common/uploads/uploads_integration_test.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package uploads_test
55
import (
66
"context"
77
"errors"
8+
"fmt"
89
"os"
910
"strings"
1011
"testing"
@@ -202,8 +203,10 @@ func TestResolveUsesTheStoreEachRowNames(t *testing.T) {
202203
}
203204

204205
// One imported row, held at another origin — what cmd/seed writes. Inserted directly
205-
// because no route can create one: an upload always goes to the write store.
206-
const cdnURL = "https://cdn.example.com/file/imported_tn"
206+
// because no route can create one: an upload always goes to the write store. The key is unique
207+
// per run, since `resource` has one row per (provider, object_key) and these rows outlive a
208+
// test — a fixed key passes once and then collides for ever.
209+
cdnURL := fmt.Sprintf("https://cdn.example.com/file/imported_%d", who)
207210
imported, err := common.NewResource(&who, remote.Name, cdnURL, "image/jpeg", 0, nil, nil)
208211
if err != nil {
209212
t.Fatalf("NewResource: %v", err)

‎internal/module/order/api/openapi/draft-order.yaml‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ paths:
1212
not move under the buyer — and sets `valid_until`.
1313
1414
15-
Only a `fixed` listing has one: a `negotiable` price is agreed in an offer, and the
16-
accepted offer is what freezes its terms instead. Asking for one is 422.
15+
Every listing has one, whichever way it is priced. A `negotiable` listing carries the
16+
seller's asking price like any other, and a buyer who does not want to haggle takes it: the
17+
listing page offers both, and this is the "buy now" half. Negotiating instead freezes the
18+
agreed terms in the offer, and `POST /offers/{id}/checkout` is the same checkout from there.
1719
security: [{ bearerAuth: [] }]
1820
requestBody:
1921
required: true
@@ -33,7 +35,6 @@ paths:
3335
'400': { $ref: '#/components/responses/BadRequest' }
3436
'401': { $ref: '#/components/responses/Unauthorized' }
3537
'404': { description: 'Listing not found, deleted, or not active', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
36-
'422': { description: 'The listing is priced negotiable; open an offer instead', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
3738
get:
3839
tags: [order]
3940
summary: List the caller's purchase sessions
@@ -95,9 +96,10 @@ paths:
9596
than handed a second payment session for the same frozen price.
9697
9798
98-
A `negotiable` listing has no draft at all, so there is nothing to check out here — its
99-
terms are frozen by the offer both sides agreed on, and `POST /offers/{id}/checkout` is the
100-
same checkout from there. The buyer pays delivery either way.
99+
This is the checkout for terms a draft froze — the seller's own price, whether the listing
100+
is `fixed` or `negotiable`. Terms that were *negotiated* are checked out at
101+
`POST /offers/{id}/checkout` instead, which is the same flow against the agreed price. The
102+
buyer pays delivery either way.
101103
security: [{ bearerAuth: [] }]
102104
parameters:
103105
- { name: id, in: path, required: true, schema: { $ref: '#/components/schemas/DraftOrderID' } }

‎internal/module/order/api/openapi/offer.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# order fragment: offer — the negotiation a `negotiable` listing has to go through.
1+
# order fragment: offer — the negotiation a `negotiable` listing offers instead of its asking price.
22
#
33
# It is conducted in the chat thread the two accounts already share (chat has one thread per
44
# pair, so there is nothing to create and nothing to address it by): every route here also

‎internal/module/order/api/openapi/order.yaml‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
# next to this one.
77
#
88
# Two shapes recur:
9-
# - Checkout is PAY-FIRST, and the money is what creates the order. A fixed-price
10-
# listing freezes its terms in a draft; a negotiable one freezes them in the offer
11-
# both sides accepted — either party may agree, which charges nothing and starts a
12-
# short window for the buyer's "create order now". Either way checkout writes the
13-
# items plus one buyer-checkout payment session, and the order appears when that
14-
# session completes. There is no endpoint for it and no seller confirmation: a
15-
# seller can refuse a price, never an order.
9+
# - Checkout is PAY-FIRST, and the money is what creates the order. A draft freezes the
10+
# seller's asking price, which every listing has however it is priced; a negotiation
11+
# freezes agreed terms in the offer instead — either party may agree, which charges
12+
# nothing and starts a short window for the buyer's "create order now". Either way
13+
# checkout writes the items plus one buyer-checkout payment session, and the order
14+
# appears when that session completes. It has no endpoint of its own and no seller
15+
# confirmation anywhere: a seller can refuse a price, never an order.
1616
# - Delivery is the buyer's on both paths, and it is quoted from the carrier at
1717
# checkout rather than sent — the session asks for goods plus that fee, and only the
1818
# goods are ever held for or released to the seller. `POST /shipping-quotes` prices

‎internal/module/order/domain/errors.go‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,12 @@ var (
1313
ErrCartItemNotFound = errx.NewError(http.StatusNotFound, "cart_item_not_found", "cart item not found")
1414

1515
// --- drafts and checkout ---
16-
ErrDraftNotFound = errx.NewError(http.StatusNotFound, "draft_not_found", "purchase session not found")
17-
ErrDraftExpired = errx.NewError(http.StatusConflict, "draft_expired", "this purchase session has expired")
18-
ErrDraftSettled = errx.NewError(http.StatusConflict, "draft_settled", "this purchase session is already cancelled or checked out")
19-
// ErrNegotiableNeedsOffer is a checkout of a listing priced `negotiable`. It cannot be
20-
// bought from the listing page: the price is agreed in a negotiation first.
21-
ErrNegotiableNeedsOffer = errx.NewError(http.StatusUnprocessableEntity, "negotiable_needs_offer", "a negotiable listing is bought through an accepted offer")
22-
ErrPriceMoved = errx.NewError(http.StatusConflict, "price_moved", "the frozen terms no longer match the listing")
23-
ErrCurrencyMismatch = errx.NewError(http.StatusUnprocessableEntity, "currency_mismatch", "the currency does not match the listing's")
24-
ErrVariantNotInDraft = errx.NewError(http.StatusUnprocessableEntity, "variant_not_in_draft", "that variant is not in this purchase session")
16+
ErrDraftNotFound = errx.NewError(http.StatusNotFound, "draft_not_found", "purchase session not found")
17+
ErrDraftExpired = errx.NewError(http.StatusConflict, "draft_expired", "this purchase session has expired")
18+
ErrDraftSettled = errx.NewError(http.StatusConflict, "draft_settled", "this purchase session is already cancelled or checked out")
19+
ErrPriceMoved = errx.NewError(http.StatusConflict, "price_moved", "the frozen terms no longer match the listing")
20+
ErrCurrencyMismatch = errx.NewError(http.StatusUnprocessableEntity, "currency_mismatch", "the currency does not match the listing's")
21+
ErrVariantNotInDraft = errx.NewError(http.StatusUnprocessableEntity, "variant_not_in_draft", "that variant is not in this purchase session")
2522

2623
// --- items ---
2724
ErrItemNotFound = errx.NewError(http.StatusNotFound, "item_not_found", "item not found")

‎internal/module/order/service_draft.go‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,17 @@ import (
1515
"shopnexus/internal/shared/id"
1616
)
1717

18-
// CreateDraft opens a purchase session by freezing the listing's terms. A listing priced
19-
// `negotiable` has no draft: its price is agreed in a negotiation, and there is nothing to
20-
// freeze until somebody accepts.
18+
// CreateDraft opens a purchase session by freezing the listing's terms — for any listing, however
19+
// it is priced. A `negotiable` one carries an asking price like any other, and a buyer who does not
20+
// want to haggle takes it: the listing page asks which they want, and this is the "buy now" half.
21+
// Negotiating instead freezes the agreed terms in the offer, and its own checkout spends those.
2122
func (s *Service) CreateDraft(ctx context.Context, req orderapi.CreateDraftRequest) (orderapi.Draft, error) {
2223
listing, err := s.catalog.GetListing(ctx, catalogapi.GetListingRequest{
2324
ID: req.ListingID, ViewerID: req.ActorID,
2425
})
2526
if err != nil {
2627
return orderapi.Draft{}, fmt.Errorf("get listing: %w", err)
2728
}
28-
if listing.PriceMode != catalogapi.PriceModeFixed {
29-
return orderapi.Draft{}, domain.ErrNegotiableNeedsOffer
30-
}
3129
snapshot := domain.ListingSnapshot{
3230
ListingID: listing.ID.Int64(),
3331
SellerID: listing.Seller.ID.Int64(),

0 commit comments

Comments
 (0)