Added the catalogue models and seed to support phase 3 of the POC prototype in the Persistence package.
This commit is contained in:
1 parent
43dcc03da6
commit
b1ceed7790
20 files changed
+2035
-8
No files matched your search
@@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user