From b1ceed77908a646103211c9051ab27877d9ff70c Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 21 Sep 2026 11:55:35 +0200 Subject: [PATCH] Added the catalogue models and seed to support phase 3 of the POC prototype in the Persistence package. --- Packages/Persistence/README.md | 12 +- .../Internal/Migrations/AddTradeColumns.swift | 29 + .../Internal/Migrations/CreateTrade.swift | 82 +++ .../Internal/Models/ConversationRecord.swift | 61 ++ .../Internal/Models/ListingRecord.swift | 6 +- .../Internal/Models/MessageRecord.swift | 58 ++ .../Sources/Internal/Models/OfferRecord.swift | 82 +++ .../Sources/Internal/Models/SaleRecord.swift | 116 ++++ .../Sources/Internal/Models/ShopRecord.swift | 3 + .../Sources/Internal/Seeds/Seed.swift | 9 + .../Public/Enumerations/TradeError.swift | 19 + .../Sources/Public/Methods/PrepareDB.swift | 2 + .../Sources/Public/Models/Conversation.swift | 131 ++++ .../Sources/Public/Models/Listing.swift | 20 +- .../Sources/Public/Models/ListingFilter.swift | 6 +- .../Sources/Public/Models/Offer.swift | 87 +++ .../Sources/Public/Models/Sale.swift | 224 ++++++ .../Public/Repositories/TradeRepository.swift | 637 ++++++++++++++++++ .../Tests/Cases/Public/Models/SaleTests.swift | 118 ++++ .../Repositories/TradeRepositoryTests.swift | 341 ++++++++++ 20 files changed, 2035 insertions(+), 8 deletions(-) create mode 100644 Packages/Persistence/Sources/Internal/Migrations/AddTradeColumns.swift create mode 100644 Packages/Persistence/Sources/Internal/Migrations/CreateTrade.swift create mode 100644 Packages/Persistence/Sources/Internal/Models/ConversationRecord.swift create mode 100644 Packages/Persistence/Sources/Internal/Models/MessageRecord.swift create mode 100644 Packages/Persistence/Sources/Internal/Models/OfferRecord.swift create mode 100644 Packages/Persistence/Sources/Internal/Models/SaleRecord.swift create mode 100644 Packages/Persistence/Sources/Public/Enumerations/TradeError.swift create mode 100644 Packages/Persistence/Sources/Public/Models/Conversation.swift create mode 100644 Packages/Persistence/Sources/Public/Models/Offer.swift create mode 100644 Packages/Persistence/Sources/Public/Models/Sale.swift create mode 100644 Packages/Persistence/Sources/Public/Repositories/TradeRepository.swift create mode 100644 Packages/Persistence/Tests/Cases/Public/Models/SaleTests.swift create mode 100644 Packages/Persistence/Tests/Cases/Public/Repositories/TradeRepositoryTests.swift diff --git a/Packages/Persistence/README.md b/Packages/Persistence/README.md index b45defa..2c6ce70 100644 --- a/Packages/Persistence/README.md +++ b/Packages/Persistence/README.md @@ -9,6 +9,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da | Migrations | `PrepareDB`, the single registrar declaring every migration, in order | | Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline | | Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot; `CreateUser` seeds the prototype's demo accounts | +| Trade | `TradeRepository`, which reads and writes `Conversation`, `Offer`, and `Sale` snapshots and enforces who may do what, throwing a `TradeError` otherwise; `Sale` carries the five-step handshake's rules as pure functions | | Catalogue | `CatalogueRepository`, returning `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data | ## Design rules @@ -16,6 +17,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da - **One default database.** `Service` registers the selected backend as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which driver is in use. - **Migrations are declared once, and append-only.** `PrepareDB` registers every migration in the order it must run; alter the schema by adding a migration, never by editing one that has already run. Registering does not apply them: the in-memory backend is migrated at launch, while a shared PostgreSQL database is migrated out of band (the executable's migrate-and-exit mode), so booting instances never race. - **Models never cross a concurrency boundary.** FluentKit models are mutable reference types, so they stay internal to the package and repositories return `Sendable` value-type snapshots (e.g. `Listing`) instead. +- **Registration order is not creation order.** `AddTradeColumns` is registered ahead of `SeedCatalogue`, whose models write its columns; Fluent tracks migrations by name, so a database already past the seed still gains them. - **Prototype shortcuts.** `CatalogueRepository` filters in memory, the catalogue fields (series, issue, publisher) are folded into the listing, and the demo data ships as a migration. All three go once real data arrives. - **Readiness never throws, and never hangs.** `Probe` runs a schema-independent `SELECT 1`, maps every failure to `false`, and races the query against a deadline (2 seconds by default) — a hanging database yields a prompt "not ready" instead of a stalled endpoint. - **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop, so every query reaches the same in-memory database instead of each pooled connection getting a private one. @@ -28,14 +30,14 @@ Sources are split by visibility, then by kind, one type per file: ``` Sources/ ├── Public/ -│ ├── Enumerations/ Driver, TLS +│ ├── Enumerations/ Driver, TLS, TradeError │ ├── Methods/ Service, PrepareDB, Probe -│ ├── Models/ Shop, Listing, ListingFilter, User -│ ├── Repositories/ CatalogueRepository, AccountRepository +│ ├── Models/ Shop, Listing, ListingFilter, User, Conversation, Offer, Sale +│ ├── Repositories/ CatalogueRepository, AccountRepository, TradeRepository │ └── Types/ Configuration └── Internal/ - ├── Migrations/ CreateShop, CreateListing, SeedCatalogue, CreateUser - ├── Models/ ShopRecord, ListingRecord, UserRecord + ├── Migrations/ CreateShop, CreateListing, AddTradeColumns, SeedCatalogue, CreateUser, CreateTrade + ├── Models/ ShopRecord, ListingRecord, UserRecord, ConversationRecord, MessageRecord, OfferRecord, SaleRecord └── Seeds/ Seed Tests/ ├── Cases/ the test suites, mirroring the Sources/ layout diff --git a/Packages/Persistence/Sources/Internal/Migrations/AddTradeColumns.swift b/Packages/Persistence/Sources/Internal/Migrations/AddTradeColumns.swift new file mode 100644 index 0000000..da82b04 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/AddTradeColumns.swift @@ -0,0 +1,29 @@ +import FluentKit + +/// Adds an owner to shops and a sold date to listings. +/// +/// Registered ahead of ``SeedCatalogue``, whose models write both columns. The owner carries no foreign key: on a fresh database the +/// `users` table does not exist yet at this point. +struct AddTradeColumns: AsyncMigration { + + // MARK: Methods + + func prepare(on database: Database) async throws { + try await database.schema(ShopRecord.schema) + .field("owner_id", .uuid) + .update() + try await database.schema(ListingRecord.schema) + .field("sold_at", .datetime) + .update() + } + + func revert(on database: Database) async throws { + try await database.schema(ListingRecord.schema) + .deleteField("sold_at") + .update() + try await database.schema(ShopRecord.schema) + .deleteField("owner_id") + .update() + } + +} diff --git a/Packages/Persistence/Sources/Internal/Migrations/CreateTrade.swift b/Packages/Persistence/Sources/Internal/Migrations/CreateTrade.swift new file mode 100644 index 0000000..17e4667 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/CreateTrade.swift @@ -0,0 +1,82 @@ +import FluentKit + +/// Creates the conversations, offers, sales, and messages tables, and hands the demo shops to seller accounts, created here when missing. +struct CreateTrade: AsyncMigration { + + // MARK: Methods + + func prepare(on database: Database) async throws { + try await database.schema(ConversationRecord.schema) + .id() + .field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id")) + .field("shop_id", .uuid, .required, .references(ShopRecord.schema, "id")) + .field("listing_id", .uuid, .references(ListingRecord.schema, "id")) + .field("created_at", .datetime, .required) + .field("buyer_read_at", .datetime, .required) + .field("seller_read_at", .datetime, .required) + .create() + try await database.schema(OfferRecord.schema) + .id() + .field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id")) + .field("listing_id", .uuid, .required, .references(ListingRecord.schema, "id")) + .field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id")) + .field("amount_cents", .int, .required) + .field("status", .string, .required) + .field("created_at", .datetime, .required) + .field("expires_at", .datetime, .required) + .create() + try await database.schema(SaleRecord.schema) + .id() + .field("number", .int, .required) + .field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id")) + .field("listing_id", .uuid, .required, .references(ListingRecord.schema, "id")) + .field("buyer_id", .uuid, .required, .references(UserRecord.schema, "id")) + .field("seller_id", .uuid, .required, .references(UserRecord.schema, "id")) + .field("price_cents", .int, .required) + .field("asked_cents", .int, .required) + .field("shipping_cents", .int, .required) + .field("declared_grade", .string, .required) + .field("declared_page_quality", .string) + .field("declared_printing", .string, .required) + .field("declared_notes", .string, .required) + .field("agreed_at", .datetime, .required) + .field("paid_at", .datetime) + .field("payment_note", .string) + .field("sent_at", .datetime) + .field("shipping_note", .string) + .field("received_at", .datetime) + .field("buyer_rating", .int) + .field("seller_rating", .int) + .unique(on: "number") + .create() + try await database.schema(MessageRecord.schema) + .id() + .field("conversation_id", .uuid, .required, .references(ConversationRecord.schema, "id")) + .field("sender_id", .uuid, .required, .references(UserRecord.schema, "id")) + .field("kind", .string, .required) + .field("body", .string, .required) + .field("offer_id", .uuid, .references(OfferRecord.schema, "id")) + .field("sale_number", .int) + .field("created_at", .datetime, .required) + .create() + + for (slug, seller) in Seed.owners { + let owner = try await UserRecord.query(on: database) + .filter(\.$email == seller.email) + .first() ?? UserRecord(email: seller.email, displayName: seller.displayName) + + try await owner.save(on: database) + try await ShopRecord.query(on: database) + .filter(\.$slug == slug) + .set(\.$owner.$id, to: try owner.requireID()) + .update() + } + } + + func revert(on database: Database) async throws { + for schema in [MessageRecord.schema, SaleRecord.schema, OfferRecord.schema, ConversationRecord.schema] { + try await database.schema(schema).delete() + } + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/ConversationRecord.swift b/Packages/Persistence/Sources/Internal/Models/ConversationRecord.swift new file mode 100644 index 0000000..7c2d740 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/ConversationRecord.swift @@ -0,0 +1,61 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `conversations` row: a buyer and a shop, talking about a listing or the shop at large. +final class ConversationRecord: Model, @unchecked Sendable { + + // MARK: Properties + + static let schema = "conversations" + + @ID(key: .id) + var id: UUID? + + @Parent(key: "buyer_id") + var buyer: UserRecord + + @Parent(key: "shop_id") + var shop: ShopRecord + + @OptionalParent(key: "listing_id") + var listing: ListingRecord? + + @Field(key: "created_at") + var createdAt: Date + + /// When the buyer last opened the conversation; what came later is unread to them. + @Field(key: "buyer_read_at") + var buyerReadAt: Date + + /// When the seller last opened the conversation; what came later is unread to them. + @Field(key: "seller_read_at") + var sellerReadAt: Date + + @Children(for: \.$conversation) + var messages: [MessageRecord] + + @Children(for: \.$conversation) + var offers: [OfferRecord] + + @Children(for: \.$conversation) + var sales: [SaleRecord] + + // MARK: Initializers + + init() {} + + init( + buyerID: UUID, + shopID: UUID, + listingID: UUID?, + at date: Date + ) { + self.$buyer.id = buyerID + self.$shop.id = shopID + self.$listing.id = listingID + self.createdAt = date + self.buyerReadAt = date + self.sellerReadAt = date + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/ListingRecord.swift b/Packages/Persistence/Sources/Internal/Models/ListingRecord.swift index e3f66d5..b576d3f 100644 --- a/Packages/Persistence/Sources/Internal/Models/ListingRecord.swift +++ b/Packages/Persistence/Sources/Internal/Models/ListingRecord.swift @@ -91,6 +91,9 @@ final class ListingRecord: Model, @unchecked Sendable { @Field(key: "listed_at") var listedAt: Date + @OptionalField(key: "sold_at") + var soldAt: Date? + // MARK: Initializers init() {} @@ -169,7 +172,8 @@ extension ListingRecord { imprint: coverImprint, number: coverNumber ), - listedAt: listedAt + listedAt: listedAt, + soldAt: soldAt ) } diff --git a/Packages/Persistence/Sources/Internal/Models/MessageRecord.swift b/Packages/Persistence/Sources/Internal/Models/MessageRecord.swift new file mode 100644 index 0000000..05d23ca --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/MessageRecord.swift @@ -0,0 +1,58 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `messages` row: something said, an offer made, or an event stamped into a conversation. +final class MessageRecord: Model, @unchecked Sendable { + + // MARK: Properties + + static let schema = "messages" + + @ID(key: .id) + var id: UUID? + + @Parent(key: "conversation_id") + var conversation: ConversationRecord + + /// Who wrote the message, made the offer, or caused the event. + @Parent(key: "sender_id") + var sender: UserRecord + + @Field(key: "kind") + var kind: String + + @Field(key: "body") + var body: String + + @OptionalParent(key: "offer_id") + var offer: OfferRecord? + + @OptionalField(key: "sale_number") + var saleNumber: Int? + + @Field(key: "created_at") + var createdAt: Date + + // MARK: Initializers + + init() {} + + init( + conversationID: UUID, + senderID: UUID, + kind: Conversation.Entry.Kind, + body: String, + offerID: UUID? = nil, + saleNumber: Int? = nil, + at date: Date + ) { + self.$conversation.id = conversationID + self.$sender.id = senderID + self.kind = kind.rawValue + self.body = body + self.$offer.id = offerID + self.saleNumber = saleNumber + self.createdAt = date + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/OfferRecord.swift b/Packages/Persistence/Sources/Internal/Models/OfferRecord.swift new file mode 100644 index 0000000..214e56d --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/OfferRecord.swift @@ -0,0 +1,82 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `offers` row. +final class OfferRecord: Model, @unchecked Sendable { + + // MARK: Properties + + static let schema = "offers" + + @ID(key: .id) + var id: UUID? + + @Parent(key: "conversation_id") + var conversation: ConversationRecord + + @Parent(key: "listing_id") + var listing: ListingRecord + + @Parent(key: "buyer_id") + var buyer: UserRecord + + @Field(key: "amount_cents") + var amountCents: Int + + @Field(key: "status") + var status: String + + @Field(key: "created_at") + var createdAt: Date + + @Field(key: "expires_at") + var expiresAt: Date + + // MARK: Initializers + + init() {} + + init( + conversationID: UUID, + listingID: UUID, + buyerID: UUID, + amountCents: Int, + at date: Date + ) { + self.$conversation.id = conversationID + self.$listing.id = listingID + self.$buyer.id = buyerID + self.amountCents = amountCents + self.status = Offer.Status.open.rawValue + self.createdAt = date + self.expiresAt = date.addingTimeInterval(Offer.lifetime) + } + +} + +// MARK: - Extensions + +extension OfferRecord { + + // MARK: Methods + + /// The `Sendable` snapshot of the saved record; its buyer, and its listing with its shop, must be eager-loaded. + /// - Parameter now: the moment an open offer's expiry is judged at. + func snapshot( + at now: Date + ) throws -> Offer { + let stored = Offer.Status(rawValue: status) ?? .declined + + return .init( + id: try requireID(), + conversationID: $conversation.id, + listing: listing.snapshot, + buyer: try buyer.snapshot, + amountCents: amountCents, + status: stored == .open && expiresAt <= now ? .expired : stored, + createdAt: createdAt, + expiresAt: expiresAt + ) + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/SaleRecord.swift b/Packages/Persistence/Sources/Internal/Models/SaleRecord.swift new file mode 100644 index 0000000..a31ea1b --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/SaleRecord.swift @@ -0,0 +1,116 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `sales` row: the record of an agreement and of its handshake. +final class SaleRecord: Model, @unchecked Sendable { + + // MARK: Properties + + static let schema = "sales" + + @ID(key: .id) + var id: UUID? + + @Field(key: "number") + var number: Int + + @Parent(key: "conversation_id") + var conversation: ConversationRecord + + @Parent(key: "listing_id") + var listing: ListingRecord + + @Parent(key: "buyer_id") + var buyer: UserRecord + + @Parent(key: "seller_id") + var seller: UserRecord + + @Field(key: "price_cents") + var priceCents: Int + + @Field(key: "asked_cents") + var askedCents: Int + + @Field(key: "shipping_cents") + var shippingCents: Int + + @Field(key: "declared_grade") + var declaredGrade: String + + @OptionalField(key: "declared_page_quality") + var declaredPageQuality: String? + + @Field(key: "declared_printing") + var declaredPrinting: String + + @Field(key: "declared_notes") + var declaredNotes: String + + @Field(key: "agreed_at") + var agreedAt: Date + + @OptionalField(key: "paid_at") + var paidAt: Date? + + @OptionalField(key: "payment_note") + var paymentNote: String? + + @OptionalField(key: "sent_at") + var sentAt: Date? + + @OptionalField(key: "shipping_note") + var shippingNote: String? + + @OptionalField(key: "received_at") + var receivedAt: Date? + + @OptionalField(key: "buyer_rating") + var buyerRating: Int? + + @OptionalField(key: "seller_rating") + var sellerRating: Int? + + // MARK: Initializers + + init() {} + +} + +// MARK: - Extensions + +extension SaleRecord { + + // MARK: Computed + + /// The `Sendable` snapshot of the saved record; its buyer, its seller, and its listing with its shop must be eager-loaded. + var snapshot: Sale { + get throws { + .init( + number: number, + conversationID: $conversation.id, + listing: listing.snapshot, + buyer: try buyer.snapshot, + seller: try seller.snapshot, + priceCents: priceCents, + askedCents: askedCents, + shippingCents: shippingCents, + declared: .init( + grade: declaredGrade, + pageQuality: declaredPageQuality, + printing: declaredPrinting, + notes: declaredNotes + ), + agreedAt: agreedAt, + paidAt: paidAt, + paymentNote: paymentNote, + sentAt: sentAt, + shippingNote: shippingNote, + receivedAt: receivedAt, + buyerRating: buyerRating, + sellerRating: sellerRating + ) + } + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/ShopRecord.swift b/Packages/Persistence/Sources/Internal/Models/ShopRecord.swift index d60af04..2b94955 100644 --- a/Packages/Persistence/Sources/Internal/Models/ShopRecord.swift +++ b/Packages/Persistence/Sources/Internal/Models/ShopRecord.swift @@ -50,6 +50,9 @@ final class ShopRecord: Model, @unchecked Sendable { @Field(key: "about") var about: String + @OptionalParent(key: "owner_id") + var owner: UserRecord? + @Children(for: \.$shop) var listings: [ListingRecord] diff --git a/Packages/Persistence/Sources/Internal/Seeds/Seed.swift b/Packages/Persistence/Sources/Internal/Seeds/Seed.swift index 9273f24..56e7ba2 100644 --- a/Packages/Persistence/Sources/Internal/Seeds/Seed.swift +++ b/Packages/Persistence/Sources/Internal/Seeds/Seed.swift @@ -11,6 +11,15 @@ enum Seed { ("joost@kestrelrow.example", "Joost de Wit"), ] + /// The seller account owning each demo shop, by the shop's slug. + static let owners: [(shop: String, seller: (email: String, displayName: String))] = [ + ("kestrel-row-comics", accounts[1]), + ("third-panel-supply", ("hello@thirdpanel.example", "Noor Janssens")), + ("longbox-atlantic", ("sales@longboxatlantic.example", "Owen Pryce")), + ("silverfish-comics", ("shop@silverfish.example", "Fenna Bos")), + ("inkwell-and-staple", ("post@inkwellstaple.example", "Lukas Brandt")), + ] + /// The demo listings, newest first. static let listings: [Listing] = [ single( diff --git a/Packages/Persistence/Sources/Public/Enumerations/TradeError.swift b/Packages/Persistence/Sources/Public/Enumerations/TradeError.swift new file mode 100644 index 0000000..92eb039 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Enumerations/TradeError.swift @@ -0,0 +1,19 @@ +/// Why a step towards a sale was turned down. +public enum TradeError: Error, Equatable, Sendable { + /// The amount is not a positive number of cents at or under the asking price. + case invalidAmount + /// The listing does not exist, or is sold. + case listingUnavailable + /// The account is not a party to the conversation, offer, or sale — or the step is not theirs to take now. + case notAllowed + /// The buyer already has an open offer on the listing. + case offerAlreadyOpen + /// The offer is under the floor the seller hears offers from, in euro cents. + case offerTooLow(floorCents: Int) + /// The listing's seller takes no offers. + case offersNotAccepted + /// The listing, shop, conversation, or sale belongs to the account itself. + case ownListing + /// The shop has no owner to sell on its behalf. + case shopUnattended +} diff --git a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift index 6c7a33b..d648de8 100644 --- a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift +++ b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift @@ -24,8 +24,10 @@ public struct PrepareDB: Sendable { await fluent.migrations.add([ CreateShop(), CreateListing(), + AddTradeColumns(), SeedCatalogue(), CreateUser(), + CreateTrade(), ]) } diff --git a/Packages/Persistence/Sources/Public/Models/Conversation.swift b/Packages/Persistence/Sources/Public/Models/Conversation.swift new file mode 100644 index 0000000..f38ae0f --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/Conversation.swift @@ -0,0 +1,131 @@ +import Foundation + +/// A `Sendable` snapshot of a conversation between a buyer and a shop, as one of them sees it. +public struct Conversation: Sendable, Equatable { + + // MARK: Properties + + /// The conversation's identifier. + public let id: UUID + /// The account buying. + public let buyer: User + /// The shop selling. + public let shop: Shop + /// The listing the conversation is about, or `nil` when it is about the shop at large. + public let listing: Listing? + /// Which side of the conversation the viewer is on. + public let side: Side + /// What was said, offered, and stamped, oldest first. + public let entries: [Entry] + /// The number of entries from the other side the viewer has not seen yet. + public let unreadCount: Int + /// The number of the sale agreed in the conversation, or `nil` when none was. + public let saleNumber: Int? + + // MARK: Initializers + + public init( + id: UUID, + buyer: User, + shop: Shop, + listing: Listing?, + side: Side, + entries: [Entry], + unreadCount: Int, + saleNumber: Int? + ) { + self.id = id + self.buyer = buyer + self.shop = shop + self.listing = listing + self.side = side + self.entries = entries + self.unreadCount = unreadCount + self.saleNumber = saleNumber + } + +} + +// MARK: - Structs + +public extension Conversation { + /// One thing in a conversation: something said, an offer made, or an event stamped. + struct Entry: Sendable, Equatable { + + // MARK: Properties + + /// What the entry is. + public let kind: Kind + /// Who wrote the message, made the offer, or caused the event. + public let sender: User + /// The message, the offer's note, or the event in a sentence. + public let body: String + /// The offer made, for an offer entry. + public let offer: Offer? + /// The number of the sale an event opened, or `nil`. + public let saleNumber: Int? + /// When the entry was made. + public let createdAt: Date + + // MARK: Initializers + + public init( + kind: Kind, + sender: User, + body: String, + offer: Offer? = nil, + saleNumber: Int? = nil, + createdAt: Date + ) { + self.kind = kind + self.sender = sender + self.body = body + self.offer = offer + self.saleNumber = saleNumber + self.createdAt = createdAt + } + + // MARK: Enumerations + + /// What a conversation entry is. + public enum Kind: String, Sendable { + /// Something a party wrote. + case text + /// An offer, with its optional note. + case offer + /// Something that happened: an offer answered, a sale agreed, a step of the handshake taken. + case event + } + + } +} + +// MARK: - Enumerations + +public extension Conversation { + /// A side of a conversation. + enum Side: String, Sendable { + case buying + case selling + } +} + +// MARK: - Extensions + +public extension Conversation { + + // MARK: Computed + + /// The latest entry, or `nil` in a conversation nothing was said in yet. + var latest: Entry? { + entries.last + } + + /// The offer still waiting for an answer, or `nil` when there is none. + var openOffer: Offer? { + entries + .compactMap(\.offer) + .last { $0.status == .open } + } + +} diff --git a/Packages/Persistence/Sources/Public/Models/Listing.swift b/Packages/Persistence/Sources/Public/Models/Listing.swift index 7960e9b..1290b3f 100644 --- a/Packages/Persistence/Sources/Public/Models/Listing.swift +++ b/Packages/Persistence/Sources/Public/Models/Listing.swift @@ -51,6 +51,8 @@ public struct Listing: Sendable, Equatable { public let cover: Cover /// When the copy was listed. public let listedAt: Date + /// When a sale of the copy was agreed, or `nil` while it is for sale. + public let soldAt: Date? // MARK: Initializers @@ -77,7 +79,8 @@ public struct Listing: Sendable, Equatable { shippingCents: Int, acceptsOffers: Bool, cover: Cover, - listedAt: Date + listedAt: Date, + soldAt: Date? = nil ) { self.slug = slug self.shop = shop @@ -102,6 +105,7 @@ public struct Listing: Sendable, Equatable { self.acceptsOffers = acceptsOffers self.cover = cover self.listedAt = listedAt + self.soldAt = soldAt } } @@ -112,6 +116,20 @@ public extension Listing { // MARK: Computed + /// The grade or condition in a few words: `CGC 9.8` for a slab, `Raw · NM` for a raw issue, the condition alone for a collected edition. + var gradeLabel: String { + if let grader, let grade { + return "\(grader) \(String(format: "%.1f", grade))" + } + + return format == "single-issues" ? "Raw · \(condition)" : condition + } + + /// Whether the copy is still for sale. + var isAvailable: Bool { + soldAt == nil + } + /// Whether the issue is a key. var isKey: Bool { keyNotes != nil diff --git a/Packages/Persistence/Sources/Public/Models/ListingFilter.swift b/Packages/Persistence/Sources/Public/Models/ListingFilter.swift index 1cca243..44b5fd8 100644 --- a/Packages/Persistence/Sources/Public/Models/ListingFilter.swift +++ b/Packages/Persistence/Sources/Public/Models/ListingFilter.swift @@ -1,4 +1,4 @@ -/// The criteria a listing search is narrowed and ordered by; the default matches every listing, newest first. +/// The criteria a listing search is narrowed and ordered by; the default matches every listing for sale, newest first. public struct ListingFilter: Sendable, Equatable { // MARK: Properties @@ -109,6 +109,10 @@ extension ListingFilter { func matches( _ listing: Listing ) -> Bool { + guard listing.isAvailable else { + return false + } + if let shop, listing.shop.slug != shop { return false } diff --git a/Packages/Persistence/Sources/Public/Models/Offer.swift b/Packages/Persistence/Sources/Public/Models/Offer.swift new file mode 100644 index 0000000..02723b5 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/Offer.swift @@ -0,0 +1,87 @@ +import Foundation + +/// A `Sendable` snapshot of a buyer's offer on a listing. +public struct Offer: Sendable, Equatable { + + // MARK: Constants + + /// The share of the asking price under which an offer is declined outright. + public static let floorShare = 0.8 + + /// How long an offer binds its buyer: 48 hours. + public static let lifetime: TimeInterval = 172_800 + + // MARK: Properties + + /// The offer's identifier. + public let id: UUID + /// The identifier of the conversation the offer was made in. + public let conversationID: UUID + /// The listing offered on. + public let listing: Listing + /// The account offering. + public let buyer: User + /// The amount offered, in euro cents. + public let amountCents: Int + /// Where the offer stands; an open offer past its expiry reads as expired. + public let status: Status + /// When the offer was made. + public let createdAt: Date + /// When the offer stops binding its buyer. + public let expiresAt: Date + + // MARK: Initializers + + public init( + id: UUID, + conversationID: UUID, + listing: Listing, + buyer: User, + amountCents: Int, + status: Status, + createdAt: Date, + expiresAt: Date + ) { + self.id = id + self.conversationID = conversationID + self.listing = listing + self.buyer = buyer + self.amountCents = amountCents + self.status = status + self.createdAt = createdAt + self.expiresAt = expiresAt + } + +} + +// MARK: - Enumerations + +public extension Offer { + /// Where an offer stands. + enum Status: String, Sendable { + /// Waiting for the seller's answer. + case open + /// Accepted: a sale record was opened. + case accepted + /// Declined by the seller. + case declined + /// Left unanswered past its expiry, or overtaken by a sale of the listing. + case expired + } +} + +// MARK: - Extensions + +public extension Offer { + + // MARK: Methods + + /// The lowest offer a listing's seller hears, in euro cents: ``floorShare`` of the asking price, rounded up to a whole euro. + /// - Parameter askingCents: the listing's asking price, in euro cents. + static func floor( + forAsking askingCents: Int + ) -> Int { + Int((Double(askingCents) * floorShare / 100).rounded(.up)) * 100 + } + +} diff --git a/Packages/Persistence/Sources/Public/Models/Sale.swift b/Packages/Persistence/Sources/Public/Models/Sale.swift new file mode 100644 index 0000000..0750ea4 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/Sale.swift @@ -0,0 +1,224 @@ +import Foundation + +/// A `Sendable` snapshot of a sale record: what was agreed, and how far its handshake has come. +/// +/// The handshake has five steps — agreed, paid, sent, received, rated — each stamped by the person who took it. Payment is settled between +/// buyer and seller off-platform; the record keeps the amounts and the stamps. +public struct Sale: Sendable, Equatable { + + // MARK: Properties + + /// The record's number, shown as `CCN-48213`. + public let number: Int + /// The identifier of the conversation the sale was agreed in. + public let conversationID: UUID + /// The listing sold. + public let listing: Listing + /// The account buying. + public let buyer: User + /// The account selling: the owner of the listing's shop. + public let seller: User + /// The agreed price, in euro cents. + public let priceCents: Int + /// The asking price at the moment of agreement, in euro cents. + public let askedCents: Int + /// The agreed shipping cost, in euro cents. + public let shippingCents: Int + /// The condition the seller declared, frozen at the moment of agreement. + public let declared: Declaration + /// When the sale was agreed. + public let agreedAt: Date + /// When the buyer marked the sale paid. + public let paidAt: Date? + /// How the buyer says they paid. + public let paymentNote: String? + /// When the seller marked the copy sent. + public let sentAt: Date? + /// The carrier and tracking the seller gave. + public let shippingNote: String? + /// When the buyer marked the copy received. + public let receivedAt: Date? + /// The stars the buyer gave the seller, out of five. + public let buyerRating: Int? + /// The stars the seller gave the buyer, out of five. + public let sellerRating: Int? + + // MARK: Initializers + + public init( + number: Int, + conversationID: UUID, + listing: Listing, + buyer: User, + seller: User, + priceCents: Int, + askedCents: Int, + shippingCents: Int, + declared: Declaration, + agreedAt: Date, + paidAt: Date? = nil, + paymentNote: String? = nil, + sentAt: Date? = nil, + shippingNote: String? = nil, + receivedAt: Date? = nil, + buyerRating: Int? = nil, + sellerRating: Int? = nil + ) { + self.number = number + self.conversationID = conversationID + self.listing = listing + self.buyer = buyer + self.seller = seller + self.priceCents = priceCents + self.askedCents = askedCents + self.shippingCents = shippingCents + self.declared = declared + self.agreedAt = agreedAt + self.paidAt = paidAt + self.paymentNote = paymentNote + self.sentAt = sentAt + self.shippingNote = shippingNote + self.receivedAt = receivedAt + self.buyerRating = buyerRating + self.sellerRating = sellerRating + } + +} + +// MARK: - Structs + +public extension Sale { + /// The condition a seller declared, frozen when the sale was agreed: a problem report is only ever for what is not in it. + struct Declaration: Sendable, Equatable { + /// The grade or condition, e.g. `CGC 9.8`. + public let grade: String + /// The page quality, or `nil` when not stated. + public let pageQuality: String? + /// The printing in full. + public let printing: String + /// The seller's notes on the copy. + public let notes: String + + public init( + grade: String, + pageQuality: String?, + printing: String, + notes: String + ) { + self.grade = grade + self.pageQuality = pageQuality + self.printing = printing + self.notes = notes + } + } +} + +// MARK: - Enumerations + +public extension Sale { + /// Something a party does to move the handshake on. + enum Action: Sendable, Equatable { + /// The buyer says they paid, and how. + case markPaid(note: String) + /// The seller says the copy is on its way, and how. + case markSent(note: String) + /// The buyer says the copy arrived. + case markReceived + /// Either party rates the other, out of five. + case rate(stars: Int) + } + + /// A party to a sale. + enum Role: Sendable { + case buyer + case seller + } + + /// A step of the handshake, in order. + enum Step: Int, CaseIterable, Comparable, Sendable { + case agreed = 1 + case paid + case sent + case received + case rated + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + } +} + +// MARK: - Extensions + +public extension Sale { + + // MARK: Computed + + /// The last step taken. Rated counts once both parties have rated. + var completed: Step { + if buyerRating != nil, sellerRating != nil { + return .rated + } + + if receivedAt != nil { + return .received + } + + if sentAt != nil { + return .sent + } + + return paidAt != nil ? .paid : .agreed + } + + /// The step waited on, or `nil` once the handshake is done. + var next: Step? { + .init(rawValue: completed.rawValue + 1) + } + + /// The agreed price and shipping together, in euro cents. + var totalCents: Int { + priceCents + shippingCents + } + + // MARK: Methods + + /// Whether a party may take an action now. + /// + /// Each step waits for the one before it and is taken once, by the party it belongs to: the buyer pays and receives, the seller sends. + /// Ratings unlock only after the copy is received, so every rating maps to a finished sale. + /// - Parameters: + /// - action: the action to take. + /// - role: the party taking it. + func allows( + _ action: Action, + by role: Role + ) -> Bool { + switch action { + case .markPaid: role == .buyer && completed == .agreed + case .markSent: role == .seller && completed == .paid + case .markReceived: role == .buyer && completed == .sent + case .rate(let stars): receivedAt != nil && (1...5).contains(stars) && rating(by: role) == nil + } + } + + /// The stars a party gave, or `nil` when they have not rated yet. + func rating( + by role: Role + ) -> Int? { + role == .buyer ? buyerRating : sellerRating + } + + /// The party an account is to the sale, or `nil` when it is neither. + /// - Parameter userID: the account's identifier. + func role( + of userID: UUID + ) -> Role? { + if userID == buyer.id { + return .buyer + } + + return userID == seller.id ? .seller : nil + } + +} diff --git a/Packages/Persistence/Sources/Public/Repositories/TradeRepository.swift b/Packages/Persistence/Sources/Public/Repositories/TradeRepository.swift new file mode 100644 index 0000000..baabd6e --- /dev/null +++ b/Packages/Persistence/Sources/Public/Repositories/TradeRepository.swift @@ -0,0 +1,637 @@ +import FluentKit +import Foundation +import HummingbirdFluent + +/// Reads and writes what leads to a sale: conversations, offers, and the sale records with their handshake. +/// +/// Every write checks that the account may make it, and throws a ``TradeError`` when not. A write touching several rows runs in a transaction. +public struct TradeRepository: Sendable { + + // MARK: Constants + + /// The number before the first sale record's: the prototype's records count up from `CCN-48213`. + private static let firstNumber = 48_212 + + // MARK: Properties + + private let fluent: Fluent + + /// The clock entries are stamped and expiries judged by. + private let now: @Sendable () -> Date + + // MARK: Initializers + + /// Creates a repository backed by the given `Fluent` service. + /// - Parameters: + /// - fluent: the service whose default database the repository operates on. + /// - now: the clock entries are stamped and expiries judged by; the system's by default. + public init( + fluent: Fluent, + now: @escaping @Sendable () -> Date = { .init() } + ) { + self.fluent = fluent + self.now = now + } + +} + +// MARK: - Conversations + +public extension TradeRepository { + + /// Fetches a conversation as one of its parties sees it, marking it read by them. + /// - Returns: the conversation, or `nil` when there is none the account is a party to. + func conversation( + id: UUID, + for user: User + ) async throws -> Conversation? { + guard + let record = try await conversations(on: fluent.db()) + .filter(\.$id == id) + .first(), + let side = side(of: user.id, in: record) + else { + return nil + } + + let conversation = try snapshot(of: record, as: side) + + if side == .buying { + record.buyerReadAt = now() + } else { + record.sellerReadAt = now() + } + + try await record.save(on: fluent.db()) + + return conversation + } + + /// Fetches every conversation an account is a party to, the latest spoken in first. + func conversations( + for user: User + ) async throws -> [Conversation] { + try await conversations(on: fluent.db()) + .all() + .compactMap { record in + try side(of: user.id, in: record).map { try snapshot(of: record, as: $0) } + } + .sorted { ($0.latest?.createdAt ?? .distantPast) > ($1.latest?.createdAt ?? .distantPast) } + } + + /// Finds the buyer's conversation about a listing, starting it when there is none. + /// - Returns: the conversation's identifier. + func conversation( + aboutListing slug: String, + buyer: User + ) async throws -> UUID { + try await fluent.db().transaction { database in + let listing = try await listing(slug: slug, on: database) + + return try await conversation( + between: buyer, + and: listing.shop, + about: listing, + on: database + ).requireID() + } + } + + /// Finds the buyer's conversation with a shop at large, starting it when there is none. + /// - Returns: the conversation's identifier. + func conversation( + withShop slug: String, + buyer: User + ) async throws -> UUID { + try await fluent.db().transaction { database in + guard let shop = try await ShopRecord.query(on: database) + .filter(\.$slug == slug) + .with(\.$owner) + .first() + else { + throw TradeError.listingUnavailable + } + + return try await conversation( + between: buyer, + and: shop, + about: nil, + on: database + ).requireID() + } + } + + /// Adds a message to a conversation; one that is blank is dropped. + func post( + _ body: String, + to id: UUID, + from user: User + ) async throws { + let body = body.trimmingCharacters(in: .whitespacesAndNewlines) + + guard + let record = try await conversations(on: fluent.db()) + .filter(\.$id == id) + .first(), + side(of: user.id, in: record) != nil + else { + throw TradeError.notAllowed + } + + guard !body.isEmpty else { + return + } + + try await MessageRecord( + conversationID: id, + senderID: user.id, + kind: .text, + body: String(body.prefix(2_000)), + at: now() + ).save(on: fluent.db()) + } + + /// The number of conversations holding something an account has not seen yet. + func unreadCount( + for user: User + ) async throws -> Int { + try await conversations(for: user) + .count { $0.unreadCount > 0 } + } + +} + +// MARK: - Offers + +public extension TradeRepository { + + /// Makes a buyer's offer on a listing, in their conversation about it. + /// + /// The offer binds the buyer for ``Offer/lifetime``. One under the listing's floor is turned down outright, and a buyer holds one open + /// offer per listing. + /// - Returns: the identifier of the conversation the offer was made in. + func makeOffer( + onListing slug: String, + buyer: User, + amountCents: Int, + note: String + ) async throws -> UUID { + try await fluent.db().transaction { database in + let listing = try await listing(slug: slug, on: database) + + guard listing.acceptsOffers else { + throw TradeError.offersNotAccepted + } + + guard amountCents > 0, amountCents <= listing.priceCents else { + throw TradeError.invalidAmount + } + + let floor = Offer.floor(forAsking: listing.priceCents) + + guard amountCents >= floor else { + throw TradeError.offerTooLow(floorCents: floor) + } + + let listingID = try listing.requireID() + let isOpen = try await OfferRecord.query(on: database) + .filter(\.$listing.$id == listingID) + .filter(\.$buyer.$id == buyer.id) + .filter(\.$status == Offer.Status.open.rawValue) + .filter(\.$expiresAt > now()) + .first() != nil + + guard !isOpen else { + throw TradeError.offerAlreadyOpen + } + + let conversationID = try await conversation( + between: buyer, + and: listing.shop, + about: listing, + on: database + ).requireID() + let offer = OfferRecord( + conversationID: conversationID, + listingID: listingID, + buyerID: buyer.id, + amountCents: amountCents, + at: now() + ) + + try await offer.save(on: database) + try await MessageRecord( + conversationID: conversationID, + senderID: buyer.id, + kind: .offer, + body: String(note.trimmingCharacters(in: .whitespacesAndNewlines).prefix(500)), + offerID: try offer.requireID(), + at: now() + ).save(on: database) + + return conversationID + } + } + + /// Fetches the offers waiting for a seller's answer, the soonest to expire first. + func openOffers( + for seller: User + ) async throws -> [Offer] { + try await OfferRecord.query(on: fluent.db()) + .filter(\.$status == Offer.Status.open.rawValue) + .filter(\.$expiresAt > now()) + .sort(\.$expiresAt) + .with(\.$buyer) + .with(\.$listing) { $0.with(\.$shop) { $0.with(\.$owner) } } + .all() + .filter { $0.listing.shop.$owner.id == seller.id } + .map { try $0.snapshot(at: now()) } + } + + /// Answers an offer on the seller's behalf. Accepting it opens a sale record. + /// - Returns: the number of the sale opened, or `nil` when the offer was declined. + @discardableResult + func respond( + toOffer id: UUID, + accepting: Bool, + by seller: User + ) async throws -> Int? { + try await fluent.db().transaction { database in + guard + let offer = try await OfferRecord.query(on: database) + .filter(\.$id == id) + .with(\.$listing, { $0.with(\.$shop) }) + .first(), + offer.listing.shop.$owner.id == seller.id, + offer.status == Offer.Status.open.rawValue, + offer.expiresAt > now() + else { + throw TradeError.notAllowed + } + + guard accepting else { + offer.status = Offer.Status.declined.rawValue + + try await offer.save(on: database) + try await MessageRecord( + conversationID: offer.$conversation.id, + senderID: seller.id, + kind: .event, + body: "declined the offer", + at: now() + ).save(on: database) + + return nil + } + + guard offer.listing.soldAt == nil else { + throw TradeError.listingUnavailable + } + + offer.status = Offer.Status.accepted.rawValue + + try await offer.save(on: database) + + return try await agree( + on: offer.listing, + conversationID: offer.$conversation.id, + buyerID: offer.$buyer.id, + sellerID: seller.id, + priceCents: offer.amountCents, + event: (seller.id, "accepted the offer"), + on: database + ) + } + } + +} + +// MARK: - Sales + +public extension TradeRepository { + + /// Buys a listing outright, at its asking price, opening a sale record. + /// - Returns: the number of the sale opened. + func buy( + listing slug: String, + buyer: User + ) async throws -> Int { + try await fluent.db().transaction { database in + let listing = try await listing(slug: slug, on: database) + + guard let sellerID = listing.shop.$owner.id else { + throw TradeError.shopUnattended + } + + let conversation = try await conversation( + between: buyer, + and: listing.shop, + about: listing, + on: database + ) + + return try await agree( + on: listing, + conversationID: try conversation.requireID(), + buyerID: buyer.id, + sellerID: sellerID, + priceCents: listing.priceCents, + event: (buyer.id, "bought the copy at the asking price"), + on: database + ) + } + } + + /// Takes a step of a sale's handshake, stamping it into the sale's conversation. + /// - Returns: the sale after the step. + @discardableResult + func perform( + _ action: Sale.Action, + onSale number: Int, + by user: User + ) async throws -> Sale { + try await fluent.db().transaction { database in + guard + let record = try await sales(on: database) + .filter(\.$number == number) + .first(), + let role = try record.snapshot.role(of: user.id), + try record.snapshot.allows(action, by: role) + else { + throw TradeError.notAllowed + } + + let event: String + + switch action { + case .markPaid(let note): + record.paidAt = now() + record.paymentNote = Self.note(note) + event = "marked the sale paid" + case .markSent(let note): + record.sentAt = now() + record.shippingNote = Self.note(note) + event = "marked the copy sent" + case .markReceived: + record.receivedAt = now() + event = "marked the copy received" + case .rate(let stars): + if role == .buyer { + record.buyerRating = stars + } else { + record.sellerRating = stars + } + + event = "left a rating" + } + + try await record.save(on: database) + try await MessageRecord( + conversationID: record.$conversation.id, + senderID: user.id, + kind: .event, + body: event, + saleNumber: number, + at: now() + ).save(on: database) + + return try record.snapshot + } + } + + /// Fetches a sale record as one of its parties. + /// - Returns: the sale, or `nil` when there is none the account is a party to. + func sale( + number: Int, + for user: User + ) async throws -> Sale? { + try await sales(on: fluent.db()) + .filter(\.$number == number) + .first() + .map { try $0.snapshot } + .flatMap { $0.role(of: user.id) == nil ? nil : $0 } + } + + /// Fetches every sale an account is a party to, the latest agreed first. + func sales( + for user: User + ) async throws -> [Sale] { + try await sales(on: fluent.db()) + .group(.or) { + $0.filter(\.$buyer.$id == user.id) + $0.filter(\.$seller.$id == user.id) + } + .sort(\.$agreedAt, .descending) + .all() + .map { try $0.snapshot } + } + + /// Fetches the shop an account owns. + /// - Returns: the shop, or `nil` when the account owns none. + func shop( + ownedBy user: User + ) async throws -> Shop? { + try await ShopRecord.query(on: fluent.db()) + .filter(\.$owner.$id == user.id) + .first()? + .snapshot + } + +} + +// MARK: - Helpers + +private extension TradeRepository { + + // MARK: Methods + + /// Opens the sale record of an agreement, takes the listing off sale, lapses the other offers on it, and stamps the event. + /// - Returns: the number of the sale opened. + func agree( + on listing: ListingRecord, + conversationID: UUID, + buyerID: UUID, + sellerID: UUID, + priceCents: Int, + event: (actor: UUID, body: String), + on database: any Database + ) async throws -> Int { + let listingID = try listing.requireID() + let number = (try await SaleRecord.query(on: database).max(\.$number) ?? Self.firstNumber) + 1 + let declared = listing.snapshot + let sale = SaleRecord() + + sale.number = number + sale.$conversation.id = conversationID + sale.$listing.id = listingID + sale.$buyer.id = buyerID + sale.$seller.id = sellerID + sale.priceCents = priceCents + sale.askedCents = listing.priceCents + sale.shippingCents = listing.shippingCents + sale.declaredGrade = declared.gradeLabel + (declared.certNumber.map { " · cert \($0)" } ?? "") + sale.declaredPageQuality = declared.pageQuality + sale.declaredPrinting = declared.printingDetail + sale.declaredNotes = declared.sellerNotes + sale.agreedAt = now() + + listing.soldAt = now() + + try await sale.save(on: database) + try await listing.save(on: database) + try await OfferRecord.query(on: database) + .filter(\.$listing.$id == listingID) + .filter(\.$status == Offer.Status.open.rawValue) + .set(\.$status, to: Offer.Status.expired.rawValue) + .update() + try await MessageRecord( + conversationID: conversationID, + senderID: event.actor, + kind: .event, + body: event.body, + saleNumber: number, + at: now() + ).save(on: database) + + return number + } + + /// Finds the conversation between a buyer and a shop about a listing — or about the shop at large — starting it when there is none. + func conversation( + between buyer: User, + and shop: ShopRecord, + about listing: ListingRecord?, + on database: any Database + ) async throws -> ConversationRecord { + guard let ownerID = shop.$owner.id else { + throw TradeError.shopUnattended + } + + guard ownerID != buyer.id else { + throw TradeError.ownListing + } + + let shopID = try shop.requireID() + let listingID = try listing?.requireID() + let existing = try await ConversationRecord.query(on: database) + .filter(\.$buyer.$id == buyer.id) + .filter(\.$shop.$id == shopID) + .all() + .first { $0.$listing.id == listingID } + + if let existing { + return existing + } + + let conversation = ConversationRecord( + buyerID: buyer.id, + shopID: shopID, + listingID: listingID, + at: now() + ) + + try await conversation.save(on: database) + + return conversation + } + + /// The conversations query, with everything a snapshot reads eager-loaded. + func conversations( + on database: any Database + ) -> QueryBuilder { + ConversationRecord.query(on: database) + .with(\.$buyer) + .with(\.$shop) + .with(\.$listing) { $0.with(\.$shop) } + .with(\.$sales) + .with(\.$messages) { message in + message + .with(\.$sender) + .with(\.$offer) { offer in + offer + .with(\.$buyer) + .with(\.$listing) { $0.with(\.$shop) } + } + } + } + + /// Fetches a listing still for sale, with its shop. + func listing( + slug: String, + on database: any Database + ) async throws -> ListingRecord { + guard + let listing = try await ListingRecord.query(on: database) + .filter(\.$slug == slug) + .with(\.$shop) + .first(), + listing.soldAt == nil + else { + throw TradeError.listingUnavailable + } + + return listing + } + + /// A party's note, trimmed and capped, or `nil` when blank. + static func note( + _ note: String + ) -> String? { + let note = note.trimmingCharacters(in: .whitespacesAndNewlines) + + return note.isEmpty ? nil : String(note.prefix(200)) + } + + /// The sales query, with everything a snapshot reads eager-loaded. + func sales( + on database: any Database + ) -> QueryBuilder { + SaleRecord.query(on: database) + .with(\.$buyer) + .with(\.$seller) + .with(\.$listing) { $0.with(\.$shop) } + } + + /// The side of a conversation an account is on, or `nil` when it is a party to neither. + func side( + of userID: UUID, + in record: ConversationRecord + ) -> Conversation.Side? { + if record.$buyer.id == userID { + return .buying + } + + return record.shop.$owner.id == userID ? .selling : nil + } + + /// The snapshot of a conversation, as the given side sees it. + func snapshot( + of record: ConversationRecord, + as side: Conversation.Side + ) throws -> Conversation { + let viewerID = side == .buying ? record.$buyer.id : record.shop.$owner.id + let readAt = side == .buying ? record.buyerReadAt : record.sellerReadAt + let messages = record.messages.sorted { $0.createdAt < $1.createdAt } + + return .init( + id: try record.requireID(), + buyer: try record.buyer.snapshot, + shop: record.shop.snapshot, + listing: record.listing?.snapshot, + side: side, + entries: try messages.map { message in + .init( + kind: .init(rawValue: message.kind) ?? .text, + sender: try message.sender.snapshot, + body: message.body, + offer: try message.offer?.snapshot(at: now()), + saleNumber: message.saleNumber, + createdAt: message.createdAt + ) + }, + unreadCount: messages.count { $0.createdAt > readAt && $0.$sender.id != viewerID }, + saleNumber: record.sales.first?.number + ) + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Models/SaleTests.swift b/Packages/Persistence/Tests/Cases/Public/Models/SaleTests.swift new file mode 100644 index 0000000..b6e366e --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Models/SaleTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing + +@testable import Persistence + +@Suite( + "Sale model", + .tags(.model) +) +struct SaleTests { + + // MARK: Computed tests + + @Test + func `walks the handshake one step at a time`() { + #expect(sale().completed == .agreed) + #expect(sale().next == .paid) + #expect(sale(paid: true).completed == .paid) + #expect(sale(paid: true, sent: true).completed == .sent) + #expect(sale(paid: true, sent: true, received: true).completed == .received) + #expect(sale(paid: true, sent: true, received: true, buyerRating: 5).completed == .received) + #expect(sale(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4).completed == .rated) + #expect(sale(paid: true, sent: true, received: true, buyerRating: 5, sellerRating: 4).next == nil) + } + + @Test + func `adds the shipping to the price`() { + #expect(sale().totalCents == 22_100) + } + + // MARK: Methods tests + + @Test + func `gives each step to its party, once, in order`() { + #expect(sale().allows(.markPaid(note: ""), by: .buyer)) + #expect(!sale().allows(.markPaid(note: ""), by: .seller)) + #expect(!sale(paid: true).allows(.markPaid(note: ""), by: .buyer)) + + #expect(!sale().allows(.markSent(note: ""), by: .seller)) + #expect(sale(paid: true).allows(.markSent(note: ""), by: .seller)) + #expect(!sale(paid: true).allows(.markSent(note: ""), by: .buyer)) + + #expect(!sale(paid: true).allows(.markReceived, by: .buyer)) + #expect(sale(paid: true, sent: true).allows(.markReceived, by: .buyer)) + #expect(!sale(paid: true, sent: true).allows(.markReceived, by: .seller)) + } + + @Test + func `unlocks ratings only once the copy is received, one per party, out of five`() { + let received = sale(paid: true, sent: true, received: true) + + #expect(!sale(paid: true, sent: true).allows(.rate(stars: 5), by: .buyer)) + #expect(received.allows(.rate(stars: 5), by: .buyer)) + #expect(received.allows(.rate(stars: 1), by: .seller)) + #expect(!received.allows(.rate(stars: 0), by: .buyer)) + #expect(!received.allows(.rate(stars: 6), by: .buyer)) + #expect(!sale(paid: true, sent: true, received: true, buyerRating: 5).allows(.rate(stars: 4), by: .buyer)) + #expect(sale(paid: true, sent: true, received: true, buyerRating: 5).allows(.rate(stars: 4), by: .seller)) + } + + @Test + func `knows its parties`() { + let sale = sale() + + #expect(sale.role(of: sale.buyer.id) == .buyer) + #expect(sale.role(of: sale.seller.id) == .seller) + #expect(sale.role(of: .init()) == nil) + } + + // MARK: Offer tests + + @Test(arguments: zip( + [24_000, 1_400, 4_500, 100], + [19_200, 1_200, 3_600, 100] + )) + func `floors offers at four fifths of the asking price, in whole euros`( + asking: Int, + expects floor: Int + ) { + #expect(Offer.floor(forAsking: asking) == floor) + } + +} + +// MARK: - Helpers + +private extension SaleTests { + + /// A sale of the first demo listing at €215 + €6, as far along as told. + func sale( + paid: Bool = false, + sent: Bool = false, + received: Bool = false, + buyerRating: Int? = nil, + sellerRating: Int? = nil + ) -> Sale { + let stamp = Date(timeIntervalSince1970: 0) + + return .init( + number: 48_213, + conversationID: .init(), + listing: Seed.listings[0], + buyer: .init(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser"), + seller: .init(id: .init(), email: "joost@kestrelrow.example", displayName: "Joost de Wit"), + priceCents: 21_500, + askedCents: 24_000, + shippingCents: 600, + declared: .init(grade: "CGC 9.8", pageQuality: "White", printing: "First printing", notes: ""), + agreedAt: stamp, + paidAt: paid ? stamp : nil, + sentAt: sent ? stamp : nil, + receivedAt: received ? stamp : nil, + buyerRating: buyerRating, + sellerRating: sellerRating + ) + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Repositories/TradeRepositoryTests.swift b/Packages/Persistence/Tests/Cases/Public/Repositories/TradeRepositoryTests.swift new file mode 100644 index 0000000..6cb8d7f --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Repositories/TradeRepositoryTests.swift @@ -0,0 +1,341 @@ +import Foundation +import HummingbirdFluent +import Testing + +@testable import Persistence + +@Suite( + "TradeRepository repository", + .tags(.repository) +) +struct TradeRepositoryTests { + + // MARK: Constants + + /// The first demo listing: Kestrel Row's Nightward #1, asking €240, open to offers. + private let slug = "nightward-1-cgc-9-8" + + // MARK: Offers tests + + @Test + func `makes an offer in the buyer's conversation about the listing`() async throws { + try await withTrade { trade, mara, joost, _ in + let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: " Would you take €215? ") + let buying = try #require(try await trade.conversation(id: id, for: mara)) + let inbox = try await trade.openOffers(for: joost) + + #expect(buying.side == .buying) + #expect(buying.listing?.slug == slug) + #expect(buying.entries.map(\.kind) == [.offer]) + #expect(buying.entries[0].body == "Would you take €215?") + #expect(buying.openOffer?.amountCents == 21_500) + #expect(inbox.map(\.amountCents) == [21_500]) + #expect(inbox[0].buyer == mara) + } + } + + @Test + func `turns down offers that break the rules`() async throws { + try await withTrade { trade, mara, joost, _ in + await #expect(throws: TradeError.offerTooLow(floorCents: 19_200)) { + try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 19_100, note: "") + } + await #expect(throws: TradeError.invalidAmount) { + try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 24_100, note: "") + } + await #expect(throws: TradeError.invalidAmount) { + try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 0, note: "") + } + await #expect(throws: TradeError.offersNotAccepted) { + try await trade.makeOffer(onListing: "echo-country-3", buyer: mara, amountCents: 1_300, note: "") + } + await #expect(throws: TradeError.ownListing) { + try await trade.makeOffer(onListing: slug, buyer: joost, amountCents: 21_500, note: "") + } + await #expect(throws: TradeError.listingUnavailable) { + try await trade.makeOffer(onListing: "missing", buyer: mara, amountCents: 21_500, note: "") + } + + _ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "") + + await #expect(throws: TradeError.offerAlreadyOpen) { + try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 22_000, note: "") + } + } + } + + @Test + func `lapses an offer left unanswered for 48 hours`() async throws { + try await withTrade { trade, mara, joost, clock in + let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "") + let offer = try #require(try await trade.openOffers(for: joost).first) + + clock.advance(by: Offer.lifetime) + + let conversation = try #require(try await trade.conversation(id: id, for: mara)) + let inbox = try await trade.openOffers(for: joost) + + #expect(conversation.entries[0].offer?.status == .expired) + #expect(conversation.openOffer == nil) + #expect(inbox.isEmpty) + + await #expect(throws: TradeError.notAllowed) { + try await trade.respond(toOffer: offer.id, accepting: true, by: joost) + } + + // The listing is free for a new offer. + _ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 22_000, note: "") + } + } + + @Test + func `declines an offer, leaving the listing for sale`() async throws { + try await withTrade { trade, mara, joost, _ in + let id = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "") + let offer = try #require(try await trade.openOffers(for: joost).first) + let number = try await trade.respond(toOffer: offer.id, accepting: false, by: joost) + let conversation = try #require(try await trade.conversation(id: id, for: mara)) + + #expect(number == nil) + #expect(conversation.entries.map(\.kind) == [.offer, .event]) + #expect(conversation.entries[0].offer?.status == .declined) + #expect(conversation.entries[1].body == "declined the offer") + #expect(conversation.saleNumber == nil) + } + } + + @Test + func `lets only the shop's owner answer an offer`() async throws { + try await withTrade { trade, mara, joost, _ in + _ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "") + + let offer = try #require(try await trade.openOffers(for: joost).first) + + await #expect(throws: TradeError.notAllowed) { + try await trade.respond(toOffer: offer.id, accepting: true, by: mara) + } + } + } + + // MARK: Sales tests + + @Test + func `accepting an offer opens a sale, takes the listing off sale, and lapses the other offers`() async throws { + try await withDatabase { fluent in + let clock = Clock() + let trade = TradeRepository(fluent: fluent, now: clock.now) + let accounts = AccountRepository(fluent: fluent) + let mara = try await accounts.account(email: "mara.v@example.com") + let sam = try await accounts.account(email: "sam@example.com") + let joost = try await accounts.account(email: "joost@kestrelrow.example") + + _ = try await trade.makeOffer(onListing: slug, buyer: mara, amountCents: 21_500, note: "") + + let other = try await trade.makeOffer(onListing: slug, buyer: sam, amountCents: 20_000, note: "") + let offer = try #require(try await trade.openOffers(for: joost).first { $0.buyer == mara }) + let number = try #require(try await trade.respond(toOffer: offer.id, accepting: true, by: joost)) + let sale = try #require(try await trade.sale(number: number, for: mara)) + let listing = try await CatalogueRepository(fluent: fluent).listing(slug: slug) + let forSale = try await CatalogueRepository(fluent: fluent).listings() + let lapsed = try #require(try await trade.conversation(id: other, for: sam)) + let inbox = try await trade.openOffers(for: joost) + + #expect(number == 48_213) + #expect(sale.priceCents == 21_500) + #expect(sale.askedCents == 24_000) + #expect(sale.shippingCents == 600) + #expect(sale.declared.grade == "CGC 9.8 · cert 4312087-002") + #expect(sale.buyer == mara) + #expect(sale.seller == joost) + #expect(sale.completed == .agreed) + #expect(listing?.isAvailable == false) + #expect(!forSale.contains { $0.slug == slug }) + #expect(lapsed.entries[0].offer?.status == .expired) + #expect(inbox.isEmpty) + + await #expect(throws: TradeError.listingUnavailable) { + try await trade.buy(listing: slug, buyer: sam) + } + } + } + + @Test + func `buys at the asking price, numbering the sales in order`() async throws { + try await withTrade { trade, mara, _, _ in + let first = try await trade.buy(listing: slug, buyer: mara) + let second = try await trade.buy(listing: "echo-country-3", buyer: mara) + let sale = try #require(try await trade.sale(number: second, for: mara)) + let sales = try await trade.sales(for: mara) + + #expect(first == 48_213) + #expect(second == 48_214) + #expect(sale.priceCents == sale.askedCents) + #expect(sales.count == 2) + } + } + + @Test + func `walks the handshake, stamping each step into the conversation`() async throws { + try await withTrade { trade, mara, joost, _ in + let number = try await trade.buy(listing: slug, buyer: mara) + + await #expect(throws: TradeError.notAllowed) { + try await trade.perform(.markSent(note: ""), onSale: number, by: joost) + } + await #expect(throws: TradeError.notAllowed) { + try await trade.perform(.markPaid(note: ""), onSale: number, by: joost) + } + + try await trade.perform(.markPaid(note: " iDEAL "), onSale: number, by: mara) + try await trade.perform(.markSent(note: "DHL 3S123"), onSale: number, by: joost) + try await trade.perform(.markReceived, onSale: number, by: mara) + try await trade.perform(.rate(stars: 5), onSale: number, by: mara) + + let sale = try await trade.perform(.rate(stars: 4), onSale: number, by: joost) + let conversation = try #require(try await trade.conversation(id: sale.conversationID, for: joost)) + + #expect(sale.completed == .rated) + #expect(sale.paymentNote == "iDEAL") + #expect(sale.shippingNote == "DHL 3S123") + #expect(sale.buyerRating == 5) + #expect(sale.sellerRating == 4) + #expect(conversation.saleNumber == number) + #expect(conversation.entries.map(\.body) == [ + "bought the copy at the asking price", "marked the sale paid", "marked the copy sent", + "marked the copy received", "left a rating", "left a rating", + ]) + } + } + + @Test + func `shows a sale to its parties alone`() async throws { + try await withDatabase { fluent in + let trade = TradeRepository(fluent: fluent) + let accounts = AccountRepository(fluent: fluent) + let mara = try await accounts.account(email: "mara.v@example.com") + let sam = try await accounts.account(email: "sam@example.com") + let number = try await trade.buy(listing: slug, buyer: mara) + let strangers = try await trade.sale(number: number, for: sam) + let none = try await trade.sales(for: sam) + + #expect(strangers == nil) + #expect(none.isEmpty) + } + } + + // MARK: Conversations tests + + @Test + func `keeps one conversation per buyer and listing, and one with the shop at large`() async throws { + try await withTrade { trade, mara, _, _ in + let first = try await trade.conversation(aboutListing: slug, buyer: mara) + let again = try await trade.conversation(aboutListing: slug, buyer: mara) + let shop = try await trade.conversation(withShop: "kestrel-row-comics", buyer: mara) + let shopAgain = try await trade.conversation(withShop: "kestrel-row-comics", buyer: mara) + + #expect(first == again) + #expect(shop == shopAgain) + #expect(first != shop) + } + } + + @Test + func `counts what the other side said as unread until the conversation is opened`() async throws { + try await withTrade { trade, mara, joost, clock in + let id = try await trade.conversation(aboutListing: slug, buyer: mara) + + clock.advance(by: 60) + try await trade.post("Is the case clean?", to: id, from: mara) + try await trade.post(" ", to: id, from: mara) + + let before = try await trade.unreadCount(for: joost) + let own = try await trade.unreadCount(for: mara) + + clock.advance(by: 60) + + let opened = try #require(try await trade.conversation(id: id, for: joost)) + let after = try await trade.unreadCount(for: joost) + + #expect(before == 1) + #expect(own == 0) + #expect(opened.side == .selling) + #expect(opened.unreadCount == 1) + #expect(opened.entries.map(\.body) == ["Is the case clean?"]) + #expect(after == 0) + } + } + + @Test + func `keeps strangers out of a conversation`() async throws { + try await withDatabase { fluent in + let trade = TradeRepository(fluent: fluent) + let accounts = AccountRepository(fluent: fluent) + let mara = try await accounts.account(email: "mara.v@example.com") + let sam = try await accounts.account(email: "sam@example.com") + let id = try await trade.conversation(aboutListing: slug, buyer: mara) + let peek = try await trade.conversation(id: id, for: sam) + + #expect(peek == nil) + + await #expect(throws: TradeError.notAllowed) { + try await trade.post("Hello", to: id, from: sam) + } + } + } + + @Test + func `finds the shop an account owns`() async throws { + try await withTrade { trade, mara, joost, _ in + let owned = try await trade.shop(ownedBy: joost) + let none = try await trade.shop(ownedBy: mara) + + #expect(owned?.slug == "kestrel-row-comics") + #expect(none == nil) + } + } + +} + +// MARK: - Helpers + +/// A clock the tests move by hand. +final class Clock: @unchecked Sendable { + + private let lock = NSLock() + private var date = Date(timeIntervalSince1970: 1_790_000_000) + + /// The clock's reading. + @Sendable + func now() -> Date { + lock.withLock { date } + } + + /// Moves the clock forward. + func advance( + by interval: TimeInterval + ) { + lock.withLock { date.addTimeInterval(interval) } + } + +} + +private extension TradeRepositoryTests { + + /// Runs the body against a seeded database, with the demo buyer, the first demo listing's seller, and the clock the repository reads. + func withTrade( + _ body: (TradeRepository, User, User, Clock) async throws -> Void + ) async throws { + try await withDatabase { fluent in + let accounts = AccountRepository(fluent: fluent) + let clock = Clock() + + try await body( + .init(fluent: fluent, now: clock.now), + try await accounts.account(email: "mara.v@example.com"), + try await accounts.account(email: "joost@kestrelrow.example"), + clock + ) + } + } + +}