- Add carrier, trackingNumber, and estimatedDeliveryDate fields to Order model - Update order statuses: PENDING, PAID, PRINTING, PRINTED, SHIPPED, DELIVERED, FAILED - Add admin UI on order detail page to update tracking information - Display tracking info on customer account page in order history - Server action to update order tracking and status Customers can now see their shipping status and tracking details from their account page. Admins can update order status and tracking info from the order detail page.
525 lines
24 KiB
Plaintext
525 lines
24 KiB
Plaintext
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
// Categories are their own table so you can add/rename/remove them from the admin
|
|
// panel. Product.category stays a plain string (matching Category.slug) rather
|
|
// than a foreign key, so nothing breaks if a category is renamed after the fact.
|
|
model Category {
|
|
id String @id @default(cuid())
|
|
name String // display label, e.g. "Phone Cases"
|
|
slug String @unique // stored key, e.g. "PHONE_CASE" — matches Product.category
|
|
showOnPersonalised Boolean @default(true) // appears as a filter on /made-to-order
|
|
showOnProducts Boolean @default(true) // appears as a filter on /products
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
model Product {
|
|
id String @id @default(cuid())
|
|
slug String @unique
|
|
name String
|
|
category String
|
|
description String
|
|
basePrice Int // cents — used for ready-made items on /products
|
|
personalisedPrice Int? // cents — used for made-to-order items on /made-to-order; falls back to basePrice if not set
|
|
colors String // JSON array of hex strings, e.g. ["#17181C","#FBF9F5"]
|
|
colorPhotos String? // JSON object mapping hex -> photo data URL, e.g. {"#17181C":"data:..."}.
|
|
colorPhotosBack String? // same idea, for the back view of each color
|
|
// Lets a product show the real photo for whichever color is selected, instead of
|
|
// tinting one photo. Falls back to imageUrl/photoRecolorable/mockup for any color
|
|
// that doesn't have its own photo here.
|
|
sizes String? // JSON array of size labels, e.g. ["S","M","L","XL"] — leave null for products with no sizing (mugs, phone cases)
|
|
mockup String // which SVG mockup component to render, e.g. "tshirt" | "hoodie" | "mug" | "phonecase"
|
|
printArea String // JSON: {"xPct":..,"yPct":..,"wPct":..,"hPct":..}
|
|
printAreaBack String? // same shape, for the back print area — only used when imageUrlBack is set
|
|
printAreaWidthMm Int @default(200) // width of the printable area in millimeters
|
|
printAreaHeightMm Int @default(280) // height of the printable area in millimeters
|
|
referenceWidthCm Int? // width of garment/object for ruler reference (cm)
|
|
referenceHeightCm Int? // height of garment/object for ruler reference (cm)
|
|
imageUrl String? // optional real product photo — shown instead of the illustration when set
|
|
imageUrlBack String? // optional back-view photo, toggled alongside the front photo
|
|
photoRecolorable Boolean @default(false) // if true, the photo is treated as a grayscale
|
|
// "shading map" and multiplied by the selected color swatch live in the browser,
|
|
// instead of being shown as a fixed image. Only looks right for black/white/gray
|
|
// source photos — recoloring an already-colored photo produces muddy results.
|
|
showOnPersonalised Boolean @default(true) // list on /made-to-order
|
|
showOnProducts Boolean @default(false) // list on /products — same product/photo can show on both
|
|
createdAt DateTime @default(now())
|
|
|
|
salePrice Int? // cents — set alongside a date window below to put this product on sale
|
|
saleStartsAt DateTime? // leave null to start the sale immediately
|
|
saleEndsAt DateTime? // leave null for an open-ended sale
|
|
|
|
orderItems OrderItem[]
|
|
proofs DesignProof[]
|
|
customRequests CustomRequest[]
|
|
designApprovals DesignApproval[]
|
|
promotions Promotion[] // only relevant to promotions scoped to specific items, not "all items"
|
|
collections Collection[] // occasion/recipient tags, e.g. "Christmas", "For Teachers"
|
|
manualSaleItems ManualSaleItem[]
|
|
stockLevels StockLevel[]
|
|
purchaseItems PurchaseItem[]
|
|
productImages ProductImage[]
|
|
}
|
|
|
|
// Bulk-uploaded product images with auto-detected colors. Supports any product type
|
|
// (tees, hoodies, mugs, etc.). User uploads a folder, colors are extracted, then saved
|
|
// as color-swatch references for the product's color palette.
|
|
model ProductImage {
|
|
id String @id @default(cuid())
|
|
productId String
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
color String // hex color detected from the image, e.g. "#1EA7E0"
|
|
imageDataUrl String // the uploaded image itself as a data URL
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([productId, color])
|
|
}
|
|
|
|
// Quantity on hand for one product/colour/size combination. Purchases increase
|
|
// it, paid website orders and manual sales decrease it. Info-only: counts can
|
|
// go negative (shown highlighted in admin as "your records are out of sync")
|
|
// and never block anything on the shop side.
|
|
model StockLevel {
|
|
id String @id @default(cuid())
|
|
productId String
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
color String
|
|
size String @default("") // '' = product has no sizes — a real empty value, not NULL, so the unique index below actually dedupes (SQLite treats NULLs as distinct)
|
|
quantity Int @default(0)
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([productId, color, size])
|
|
}
|
|
|
|
// Stock coming in: one supplier invoice, entered by hand with the invoice
|
|
// file (PDF or photo) attached for record-keeping. Product lines add to
|
|
// StockLevel; material lines (ink, vinyl, packaging) are just logged — they
|
|
// have no running count, and will feed the expenses list in a later stage.
|
|
// Every Purchase auto-creates an Expense on creation.
|
|
model Purchase {
|
|
id String @id @default(cuid())
|
|
supplierName String
|
|
purchasedAt DateTime @default(now()) // invoice date, editable — not entry time
|
|
invoiceDataUrl String? // the uploaded invoice itself, as a data URL
|
|
invoiceFileName String?
|
|
total Int // pence — sum of line totals, recomputed server-side
|
|
notes String?
|
|
createdAt DateTime @default(now())
|
|
|
|
items PurchaseItem[]
|
|
expense Expense? // auto-created on Purchase creation; cascade delete if Purchase is deleted
|
|
}
|
|
|
|
model PurchaseItem {
|
|
id String @id @default(cuid())
|
|
purchaseId String
|
|
purchase Purchase @relation(fields: [purchaseId], references: [id], onDelete: Cascade)
|
|
productId String? // null = loose material line (no stock tracking)
|
|
product Product? @relation(fields: [productId], references: [id])
|
|
description String // product name snapshot, or the material description
|
|
color String?
|
|
size String?
|
|
quantity Int
|
|
unitCost Int // pence
|
|
}
|
|
|
|
// Expenses: everything that goes out. Auto-created from purchases (category =
|
|
// "Stock purchases"), plus manual entries for postage, utilities, equipment, etc.
|
|
// Receipt can be the invoice file or a standalone attachment. Pence storage like
|
|
// everything else.
|
|
model Expense {
|
|
id String @id @default(cuid())
|
|
date DateTime @default(now())
|
|
description String
|
|
category String @default("Stock purchases") // "Stock purchases", "Postage", others as user enters them
|
|
amount Int // pence
|
|
receiptDataUrl String? // uploaded receipt as data URL
|
|
receiptFileName String?
|
|
notes String?
|
|
purchaseId String? @unique // null = standalone expense; set = auto-created from a Purchase (one-to-one)
|
|
purchase Purchase? @relation(fields: [purchaseId], references: [id], onDelete: SetNull)
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
// A design you (the shop owner) upload for one specific customer to review and buy —
|
|
// separate from the self-serve design tool. You create one of these per custom job,
|
|
// send the customer the link, and they approve it straight into their cart.
|
|
model DesignProof {
|
|
id String @id @default(cuid())
|
|
productId String
|
|
product Product @relation(fields: [productId], references: [id])
|
|
customerName String?
|
|
customerEmail String
|
|
color String
|
|
quantity Int @default(1)
|
|
unitPrice Int // cents — defaults to the product's base price if not overridden
|
|
imageDataUrl String // the finished design image you uploaded, shown as-is to the customer
|
|
note String? // optional message shown alongside the design
|
|
status String @default("PENDING") // PENDING | APPROVED
|
|
createdAt DateTime @default(now())
|
|
|
|
messages Message[]
|
|
}
|
|
|
|
// One chat thread per design proof — simple polling-based chat, no websockets needed.
|
|
model Message {
|
|
id String @id @default(cuid())
|
|
proofId String
|
|
proof DesignProof @relation(fields: [proofId], references: [id], onDelete: Cascade)
|
|
sender String // "ADMIN" | "CUSTOMER"
|
|
body String
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
// Customer-submitted designs pending admin approval before checkout
|
|
model DesignApproval {
|
|
id String @id @default(cuid())
|
|
customerId String? // null for guest customers
|
|
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
|
customerEmail String
|
|
customerName String?
|
|
productId String
|
|
product Product @relation(fields: [productId], references: [id])
|
|
designJson String // serialized {front:[...], back:[...]} design elements
|
|
designPreviewUrl String // base64 PNG data URL — front design, transparent, print-ready
|
|
designPreviewUrlBack String? // same, for the back design if there is one
|
|
placementPreviewUrl String? // front design on product photo
|
|
placementPreviewUrlBack String? // back design on product photo
|
|
designWidthCm Float? // width of design in centimeters (legacy, for backward compatibility)
|
|
designHeightCm Float? // height of design in centimeters (legacy, for backward compatibility)
|
|
designImageDimensions String? // JSON array of {imageId, widthCm, heightCm} for each uploaded image
|
|
color String
|
|
size String?
|
|
status String @default("PENDING") // PENDING | IN_REVIEW | APPROVED | REJECTED
|
|
messages DesignApprovalMessage[]
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
// Chat messages for design approval discussions
|
|
model DesignApprovalMessage {
|
|
id String @id @default(cuid())
|
|
designId String
|
|
design DesignApproval @relation(fields: [designId], references: [id], onDelete: Cascade)
|
|
sender String // "ADMIN" | "CUSTOMER"
|
|
body String
|
|
messageType String @default("MESSAGE") // MESSAGE | CHANGE_REQUEST
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
model Order {
|
|
id String @id @default(cuid())
|
|
stripeSessionId String? @unique
|
|
email String?
|
|
shippingAddress String? // JSON — collected by Stripe Checkout, saved once payment completes
|
|
status String @default("PENDING") // PENDING | PAID | PRINTING | PRINTED | SHIPPED | DELIVERED | FAILED
|
|
archived Boolean @default(false)
|
|
total Int // cents
|
|
|
|
// Shipping & tracking info
|
|
carrier String? // e.g. "Royal Mail", "DPD", "Courier"
|
|
trackingNumber String? // tracking number from carrier
|
|
estimatedDeliveryDate DateTime? // estimated delivery date
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
customerId String? // set when the buyer was logged in at checkout — null for guest orders
|
|
customer Customer? @relation(fields: [customerId], references: [id])
|
|
|
|
guestName String? // full name for guest orders
|
|
guestPhone String? // contact phone for guest orders
|
|
|
|
items OrderItem[]
|
|
}
|
|
|
|
// A customer's own account — separate from the ADMIN_USER/ADMIN_PASSWORD admin login.
|
|
// Optional: checkout still works as a guest with no Customer record at all.
|
|
model Customer {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
passwordHash String
|
|
name String?
|
|
createdAt DateTime @default(now())
|
|
|
|
orders Order[]
|
|
passwordResetToken PasswordResetToken?
|
|
customRequests CustomRequest[]
|
|
manualSales ManualSale[]
|
|
designApprovals DesignApproval[]
|
|
}
|
|
|
|
// A sale that didn't go through the website checkout — cash at a fair, a bank
|
|
// transfer arranged over Facebook, etc. Recorded by the admin from
|
|
// /admin/accounts so every sale (web + offline) lives in one ledger.
|
|
model ManualSale {
|
|
id String @id @default(cuid())
|
|
soldAt DateTime @default(now()) // when the sale actually happened (editable, not just entry time)
|
|
buyerName String? // free text — a cash buyer doesn't need an account
|
|
buyerEmail String?
|
|
customerId String? // auto-linked when buyerEmail matches a Customer account
|
|
customer Customer? @relation(fields: [customerId], references: [id])
|
|
paymentMethod String // CASH | BANK_TRANSFER
|
|
total Int // pence — sum of line totals, computed server-side on save
|
|
notes String?
|
|
createdAt DateTime @default(now())
|
|
|
|
items ManualSaleItem[]
|
|
}
|
|
|
|
model ManualSaleItem {
|
|
id String @id @default(cuid())
|
|
saleId String
|
|
sale ManualSale @relation(fields: [saleId], references: [id], onDelete: Cascade)
|
|
productId String? // null for free-text one-offs (e.g. "custom commission")
|
|
product Product? @relation(fields: [productId], references: [id])
|
|
description String // product name snapshot, or the free text itself
|
|
color String?
|
|
size String?
|
|
quantity Int
|
|
unitPrice Int // pence
|
|
}
|
|
|
|
// One active reset token per customer at a time — requesting a new one replaces it.
|
|
model PasswordResetToken {
|
|
id String @id @default(cuid())
|
|
customerId String @unique
|
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
|
token String @unique
|
|
expiresAt DateTime
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
// A customer's own photo, submitted for the shop to design something with —
|
|
// separate from the self-serve design tool and from DesignProof (which is the
|
|
// finished result you send back once you've worked on it). No FK link between
|
|
// the two: turning a request into a proof is a one-time action, not a tracked relation.
|
|
model CustomRequest {
|
|
id String @id @default(cuid())
|
|
customerId String?
|
|
customer Customer? @relation(fields: [customerId], references: [id])
|
|
productId String
|
|
product Product @relation(fields: [productId], references: [id])
|
|
customerName String
|
|
customerEmail String
|
|
customerPhone String?
|
|
imageDataUrl String // the photo they want us to work with
|
|
note String?
|
|
status String @default("NEW") // NEW | DONE
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
// A promotion: shown as a banner on every page while its date window is active,
|
|
// AND actually discounts prices — either every product ("ALL") or a hand-picked
|
|
// set ("SPECIFIC", via the products relation below). Kept as a list rather than
|
|
// a single settings row so past promotions aren't destroyed when you create a
|
|
// new one. Price discounts stack with a product's own manual sale price by
|
|
// taking whichever is lower — see getEffectivePrice in src/lib/pricing.ts.
|
|
model Promotion {
|
|
id String @id @default(cuid())
|
|
message String
|
|
discountPercent Int // 1-100
|
|
scope String @default("ALL") // ALL | SPECIFIC
|
|
startsAt DateTime? // leave null to start immediately
|
|
endsAt DateTime? // leave null for an open-ended promotion
|
|
createdAt DateTime @default(now())
|
|
|
|
products Product[] // only consulted when scope = SPECIFIC
|
|
}
|
|
|
|
// Occasion ("Christmas", "Birthdays") and recipient ("Teachers", "School leavers") tags
|
|
// for the two nav dropdowns of the same name. One model with a group discriminator
|
|
// (rather than two models) so there's a single set of admin pages — split into
|
|
// sections by group instead. A product can carry any number of both, hence the
|
|
// many-to-many (unlike Product.category, which is a single string).
|
|
model Collection {
|
|
id String @id @default(cuid())
|
|
name String
|
|
slug String @unique
|
|
group String // "OCCASION" | "RECIPIENT"
|
|
createdAt DateTime @default(now())
|
|
products Product[]
|
|
}
|
|
|
|
// Unified mailing list — fed by three separate sources (customer registration,
|
|
// the footer newsletter signup, and guest checkout emails) so a single admin
|
|
// broadcast can reach all of them without querying three different shapes.
|
|
// `subscribed` is the admin's opt-out switch and is deliberately never
|
|
// overwritten by a later upsert from any source, so it always wins.
|
|
model MailingListEntry {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
name String?
|
|
source String // "CUSTOMER" | "NEWSLETTER" | "GUEST" — where this address first came from
|
|
subscribed Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
// Backs a small DB-based rate limiter (src/lib/rateLimit.ts) — chosen over an
|
|
// in-memory counter because that would silently reset on every serverless
|
|
// cold start and not share state across instances. Rows older than 24h are
|
|
// opportunistically pruned by the limiter itself, no separate cron needed.
|
|
model RateLimitAttempt {
|
|
id String @id @default(cuid())
|
|
key String // e.g. "login:customer:<ip>", "checkout:<ip>"
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([key, createdAt])
|
|
}
|
|
|
|
// Site-wide settings — a single row (fixed id "singleton"). Currently just the
|
|
// maintenance-mode toggle: when on, customers see a "we're updating" page while
|
|
// logged-in admins still see the real site. Read on every page render (cheap
|
|
// single-row lookup), written only from the admin maintenance page.
|
|
model SiteSetting {
|
|
id String @id @default("singleton")
|
|
maintenanceMode Boolean @default(false)
|
|
maintenanceMessage String?
|
|
}
|
|
|
|
// Footer email signup — just captures addresses for you to use once you pick an
|
|
// email tool (Mailchimp, Klaviyo, etc.). Not wired up to send anything itself.
|
|
model NewsletterSubscriber {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
// Photo gallery of completed work, shown on the public /gallery page. Photos are
|
|
// uploaded manually in the admin (base64 data URLs, same as products/proofs).
|
|
// Categories here are their own thing, separate from product Category — a photo's
|
|
// category is optional. (Future: an official Facebook Graph API import could add a
|
|
// nullable source/attribution column here — not built yet.)
|
|
model GalleryCategory {
|
|
id String @id @default(cuid())
|
|
name String
|
|
slug String @unique
|
|
createdAt DateTime @default(now())
|
|
photos GalleryPhoto[]
|
|
}
|
|
|
|
model GalleryPhoto {
|
|
id String @id @default(cuid())
|
|
imageDataUrl String // base64 data URL
|
|
caption String?
|
|
categoryId String? // nullable — a photo can be uncategorized
|
|
category GalleryCategory? @relation(fields: [categoryId], references: [id])
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
model OrderItem {
|
|
id String @id @default(cuid())
|
|
orderId String
|
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
|
productId String
|
|
product Product @relation(fields: [productId], references: [id])
|
|
productName String // snapshot at time of order, in case the product changes later
|
|
quantity Int
|
|
color String
|
|
size String?
|
|
unitPrice Int // cents
|
|
designJson String // serialized {front:[...], back:[...]} design elements
|
|
designPreviewUrl String // base64 PNG data URL — front design, transparent, print-ready
|
|
designPreviewUrlBack String? // same, for the back design if there is one
|
|
placementPreviewUrl String? // front design shown composited on the actual product photo, for reference — where does this go when printing
|
|
placementPreviewUrlBack String? // same, for the back
|
|
designWidthCm Float? // width of design in centimeters (legacy)
|
|
designHeightCm Float? // height of design in centimeters (legacy)
|
|
designImageDimensions String? // JSON array of {imageId, widthCm, heightCm} for each uploaded image
|
|
}
|
|
|
|
// Audit log for admin logins — tracks when the admin panel is accessed
|
|
model AdminLoginLog {
|
|
id String @id @default(cuid())
|
|
loginAt DateTime @default(now())
|
|
logoutAt DateTime? // null = still logged in (or session expired without explicit logout)
|
|
ipAddress String?
|
|
userAgent String?
|
|
success Boolean @default(true) // false for failed login attempts
|
|
}
|
|
|
|
// Audit log for customer logins — tracks customer account access
|
|
model CustomerLoginLog {
|
|
id String @id @default(cuid())
|
|
customerId String?
|
|
email String // captured for failed attempts where customer doesn't exist
|
|
loginAt DateTime @default(now())
|
|
ipAddress String?
|
|
userAgent String?
|
|
success Boolean @default(true) // false for failed login attempts
|
|
}
|
|
|
|
// Audit log for financial transactions — tracks all changes to financial data
|
|
model FinancialAuditLog {
|
|
id String @id @default(cuid())
|
|
action String // CREATE, UPDATE, DELETE
|
|
entityType String // "ORDER", "MANUAL_SALE", "PURCHASE", "EXPENSE"
|
|
entityId String // ID of the order/sale/purchase/expense
|
|
changes String // JSON: {field: {old: X, new: Y}, ...}
|
|
amount Int? // cents, for quick filtering of high-value changes
|
|
changedAt DateTime @default(now())
|
|
changedBy String? // "SYSTEM" or IP address if admin
|
|
reason String? // why was this changed
|
|
}
|
|
|
|
// Bank statement uploaded for reconciliation
|
|
model BankStatement {
|
|
id String @id @default(cuid())
|
|
filename String // original filename
|
|
bankName String? // e.g., "HSBC", "Barclays"
|
|
accountNumber String? // last 4 digits for safety
|
|
statementMonth DateTime // the month this statement covers
|
|
startDate DateTime // first transaction date in statement
|
|
endDate DateTime // last transaction date in statement
|
|
totalTransactions Int // count of lines in statement
|
|
totalAmount Int? // cents, total of all transactions (for verification)
|
|
status String @default("PENDING") // PENDING, IN_PROGRESS, RECONCILED, ARCHIVED
|
|
uploadedAt DateTime @default(now())
|
|
reconciliedAt DateTime? // when reconciliation was completed
|
|
lines BankStatementLine[]
|
|
reconciliations BankReconciliation[]
|
|
}
|
|
|
|
// Individual transaction line from a bank statement
|
|
model BankStatementLine {
|
|
id String @id @default(cuid())
|
|
statementId String
|
|
statement BankStatement @relation(fields: [statementId], references: [id], onDelete: Cascade)
|
|
date DateTime // transaction date
|
|
description String // e.g., "STRIPE DEPOSIT", "TRANSFER TO SAVINGS"
|
|
amount Int // cents, positive for credit, negative for debit
|
|
balance Int? // account balance after this transaction (if provided)
|
|
reference String? // transaction reference from bank
|
|
matched Boolean @default(false) // whether this line has been reconciled
|
|
reconciliation BankReconciliation? // the matched transaction, if any
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
// Link between system transaction (order/sale/expense) and bank statement line
|
|
model BankReconciliation {
|
|
id String @id @default(cuid())
|
|
statementId String
|
|
statement BankStatement @relation(fields: [statementId], references: [id], onDelete: Cascade)
|
|
bankLineId String @unique
|
|
bankLine BankStatementLine @relation(fields: [bankLineId], references: [id], onDelete: Cascade)
|
|
|
|
// Which system transaction this was matched to
|
|
transactionType String // "ORDER", "MANUAL_SALE", "REFUND", "EXPENSE", "TRANSFER_OUT", "FEE", "OTHER"
|
|
transactionId String? // ID of the order/sale/expense if applicable
|
|
description String // what the match was (e.g., "Order #12345", "Stripe fee")
|
|
systemAmount Int // cents, amount in our system
|
|
bankAmount Int // cents, amount on bank statement
|
|
amountMatches Boolean // whether system and bank amounts are equal
|
|
|
|
matchedAt DateTime @default(now())
|
|
matchedBy String? // IP/admin who manually matched this (null if auto-matched)
|
|
confidence Int @default(100) // 0-100, how confident the match is (100=exact amount+date, <100=fuzzy match)
|
|
notes String? // manual notes about the match
|
|
}
|