localgreenchain/lib/marketplace/offerService.ts
Claude b3c2af51bf
Implement marketplace foundation (Agent 9)
Add comprehensive plant trading marketplace with:
- Prisma schema with marketplace models (Listing, Offer, SellerProfile, WishlistItem)
- Service layer for listings, offers, search, and matching
- API endpoints for CRUD operations, search, and recommendations
- Marketplace pages: home, listing detail, create, my-listings, my-offers
- Reusable UI components: ListingCard, ListingGrid, OfferForm, SearchFilters, etc.

Features:
- Browse and search listings by category, price, tags
- Create and manage listings (draft, active, sold, cancelled)
- Make and manage offers on listings
- Seller and buyer views with statistics
- Featured and recommended listings
- In-memory store (ready for database migration via Agent 2)
2025-11-23 03:58:08 +00:00

291 lines
7.2 KiB
TypeScript

// Offer Service for Marketplace
// Handles offer management for marketplace listings
import {
Offer,
OfferStatus,
Listing,
ListingStatus,
CreateOfferInput,
} from './types';
import { offerStore, listingStore, generateId } from './store';
export class OfferService {
/**
* Create a new offer on a listing
*/
async createOffer(
buyerId: string,
buyerName: string,
input: CreateOfferInput
): Promise<Offer> {
const listing = listingStore.getById(input.listingId);
if (!listing) {
throw new Error('Listing not found');
}
if (listing.status !== ListingStatus.ACTIVE) {
throw new Error('Cannot make offers on inactive listings');
}
if (listing.sellerId === buyerId) {
throw new Error('Cannot make an offer on your own listing');
}
// Check for existing pending offer from this buyer
const existingOffer = offerStore.getByListingId(input.listingId)
.find(o => o.buyerId === buyerId && o.status === OfferStatus.PENDING);
if (existingOffer) {
throw new Error('You already have a pending offer on this listing');
}
const offer: Offer = {
id: generateId(),
listingId: input.listingId,
buyerId,
buyerName,
amount: input.amount,
message: input.message,
status: OfferStatus.PENDING,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
};
return offerStore.create(offer);
}
/**
* Get an offer by ID
*/
async getOfferById(id: string): Promise<Offer | null> {
return offerStore.getById(id) || null;
}
/**
* Get all offers for a listing
*/
async getOffersForListing(listingId: string): Promise<Offer[]> {
return offerStore.getByListingId(listingId);
}
/**
* Get all offers made by a buyer
*/
async getOffersByBuyer(buyerId: string): Promise<Offer[]> {
return offerStore.getByBuyerId(buyerId);
}
/**
* Get all offers for a seller's listings
*/
async getOffersForSeller(sellerId: string): Promise<(Offer & { listing?: Listing })[]> {
const sellerListings = listingStore.getBySellerId(sellerId);
const listingIds = new Set(sellerListings.map(l => l.id));
const offers = offerStore.getAll().filter(o => listingIds.has(o.listingId));
return offers.map(offer => ({
...offer,
listing: listingStore.getById(offer.listingId),
}));
}
/**
* Accept an offer
*/
async acceptOffer(offerId: string, sellerId: string): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
const listing = listingStore.getById(offer.listingId);
if (!listing) {
throw new Error('Listing not found');
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only accept pending offers');
}
// Accept this offer
const acceptedOffer = offerStore.update(offerId, {
status: OfferStatus.ACCEPTED
});
// Reject all other pending offers for this listing
const otherOffers = offerStore.getByListingId(offer.listingId)
.filter(o => o.id !== offerId && o.status === OfferStatus.PENDING);
for (const otherOffer of otherOffers) {
offerStore.update(otherOffer.id, { status: OfferStatus.REJECTED });
}
// Mark listing as sold
listingStore.update(offer.listingId, {
status: ListingStatus.SOLD,
quantity: 0
});
if (!acceptedOffer) {
throw new Error('Failed to accept offer');
}
return acceptedOffer;
}
/**
* Reject an offer
*/
async rejectOffer(offerId: string, sellerId: string): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
const listing = listingStore.getById(offer.listingId);
if (!listing) {
throw new Error('Listing not found');
}
if (listing.sellerId !== sellerId) {
throw new Error('Unauthorized: You do not own this listing');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only reject pending offers');
}
const rejectedOffer = offerStore.update(offerId, {
status: OfferStatus.REJECTED
});
if (!rejectedOffer) {
throw new Error('Failed to reject offer');
}
return rejectedOffer;
}
/**
* Withdraw an offer (buyer action)
*/
async withdrawOffer(offerId: string, buyerId: string): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
if (offer.buyerId !== buyerId) {
throw new Error('Unauthorized: This is not your offer');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only withdraw pending offers');
}
const withdrawnOffer = offerStore.update(offerId, {
status: OfferStatus.WITHDRAWN
});
if (!withdrawnOffer) {
throw new Error('Failed to withdraw offer');
}
return withdrawnOffer;
}
/**
* Counter offer (update amount)
*/
async updateOfferAmount(
offerId: string,
buyerId: string,
newAmount: number
): Promise<Offer> {
const offer = offerStore.getById(offerId);
if (!offer) {
throw new Error('Offer not found');
}
if (offer.buyerId !== buyerId) {
throw new Error('Unauthorized: This is not your offer');
}
if (offer.status !== OfferStatus.PENDING) {
throw new Error('Can only update pending offers');
}
const updatedOffer = offerStore.update(offerId, { amount: newAmount });
if (!updatedOffer) {
throw new Error('Failed to update offer');
}
return updatedOffer;
}
/**
* Get offer statistics
*/
async getOfferStats(userId: string, role: 'buyer' | 'seller'): Promise<{
totalOffers: number;
pendingOffers: number;
acceptedOffers: number;
rejectedOffers: number;
}> {
let offers: Offer[];
if (role === 'buyer') {
offers = offerStore.getByBuyerId(userId);
} else {
const sellerListings = listingStore.getBySellerId(userId);
const listingIds = new Set(sellerListings.map(l => l.id));
offers = offerStore.getAll().filter(o => listingIds.has(o.listingId));
}
return {
totalOffers: offers.length,
pendingOffers: offers.filter(o => o.status === OfferStatus.PENDING).length,
acceptedOffers: offers.filter(o => o.status === OfferStatus.ACCEPTED).length,
rejectedOffers: offers.filter(o => o.status === OfferStatus.REJECTED).length,
};
}
/**
* Expire old pending offers
*/
async expireOldOffers(): Promise<number> {
const now = new Date();
let expiredCount = 0;
const pendingOffers = offerStore.getAll().filter(
o => o.status === OfferStatus.PENDING && o.expiresAt
);
for (const offer of pendingOffers) {
if (offer.expiresAt && offer.expiresAt < now) {
offerStore.update(offer.id, { status: OfferStatus.EXPIRED });
expiredCount++;
}
}
return expiredCount;
}
}
// Export singleton instance
export const offerService = new OfferService();