Homepage was rendering 71MB of HTML because every uploaded image (gallery, products, proofs, custom requests, design previews, invoices, receipts) was stored as base64 in Postgres and double-embedded via RSC hydration payloads. Adds src/lib/storage.ts (sharp-based compression, disk storage under public/uploads/) and a new /api/design-uploads endpoint for images added inside the design tool, migrates existing rows, and drops the now-unused base64 columns and the dead ProductImage table. Also fixes a systemic bug from the Next.js 16 upgrade where dynamic pages across the app still destructured params/searchParams synchronously instead of awaiting them as Promises, which was silently breaking filters/params and crashing /products/[slug] outright. Updates README.md and SETUP_AND_BUILD.md to reflect Postgres + Next.js 16 and document the new image storage architecture and backup requirements. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
574 lines
27 KiB
Plaintext
574 lines
27 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.
|
|
// Categories can be hierarchical: parentId links to a parent category.
|
|
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
|
|
parentId String? // parent category ID for hierarchical organization (e.g., "Apparel - Adults" has parent "Apparel")
|
|
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
|
|
children Category[] @relation("CategoryHierarchy")
|
|
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
|
|
plainPrice Int? // cents — used for plain/blank items
|
|
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)
|
|
weightGrams Int? // weight in grams (used for Royal Mail postage calculation via API)
|
|
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
|
|
showAsPlain Boolean @default(false) // list on /products/plain — blank/unprinted items
|
|
showAsReadyMade Boolean @default(false) // list on /products/ready-made — pre-finished items
|
|
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[]
|
|
reviews Review[]
|
|
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[]
|
|
}
|
|
|
|
// 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
|
|
invoiceUrl String? // disk path under /uploads/invoices/...
|
|
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
|
|
receiptUrl String? // disk path under /uploads/receipts/...
|
|
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
|
|
imageUrl String // disk path under /uploads/proofs/...
|
|
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
|
|
isRead Boolean @default(false)
|
|
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
|
|
expectedDeliveryDate DateTime? // admin sets this when approving the design
|
|
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
|
|
shippingCountry String? // ISO 2-letter country code (e.g., 'GB', 'US') for Royal Mail shipping
|
|
status String @default("PENDING") // PENDING | PAID | PRINTING | PRINTED | SHIPPED | DELIVERED | FAILED
|
|
archived Boolean @default(false)
|
|
total Int // cents (includes shipping cost)
|
|
shippingCost Int @default(0) // cents — Royal Mail postage cost
|
|
|
|
// Shipping & tracking info
|
|
shippingService String? // e.g. "Royal Mail 24®", "International Signed" — selected by customer at checkout
|
|
carrier String? @default("Royal Mail") // e.g. "Royal Mail", "DPD", "Courier"
|
|
trackingNumber String? // tracking number from carrier
|
|
estimatedDeliveryDate DateTime? // estimated delivery date
|
|
collectedByCustomer Boolean @default(false) // true if order was collected by customer (no shipping)
|
|
isCollectionPickup Boolean @default(false) // true if customer selected collection at checkout
|
|
readyForCollection Boolean @default(false) // true when admin marks order as ready for pickup
|
|
|
|
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
|
|
phoneNumber String? // phone number for WhatsApp notifications (collection 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?
|
|
phone String? // delivery phone number
|
|
addressLine1 String? // street address
|
|
addressLine2 String? // apartment, suite, etc.
|
|
city String?
|
|
postalCode String?
|
|
country String? // ISO 2-letter country code, e.g., "GB"
|
|
createdAt DateTime @default(now())
|
|
|
|
orders Order[]
|
|
passwordResetToken PasswordResetToken?
|
|
customRequests CustomRequest[]
|
|
manualSales ManualSale[]
|
|
designApprovals DesignApproval[]
|
|
reviews Review[]
|
|
}
|
|
|
|
// 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?
|
|
imageUrl String // disk path under /uploads/custom-requests/...
|
|
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())
|
|
imageUrl String // disk path under /uploads/gallery/...
|
|
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
|
|
}
|
|
|
|
// Admin user account — stores admin credentials (can override env vars if present)
|
|
model AdminUser {
|
|
id String @id @default(cuid())
|
|
username String @unique
|
|
passwordHash String
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Customer reviews for products
|
|
model Review {
|
|
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], onDelete: Cascade)
|
|
rating Int // 1-5 stars
|
|
title String // short headline
|
|
comment String // full review text
|
|
approved Boolean @default(false) // must be approved by admin before showing publicly
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
// Shipping rates configuration — managed from admin panel
|
|
// Allows easy updates to Royal Mail pricing without code changes
|
|
model ShippingRate {
|
|
id String @id @default(cuid())
|
|
service String @unique // e.g., "Royal Mail 24®", "International Signed"
|
|
basePrice Int // cents — base price for this service
|
|
pricePerKg Int @default(0) // cents — additional cost per kg (mainly for international)
|
|
estimatedDays Int // estimated delivery days
|
|
isInternational Boolean @default(false)
|
|
active Boolean @default(true)
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|