Skip to main content

Order Tables

Tables for managing orders, customers, and cart abandonment tracking.

orders

Customer orders from all channels.

CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
customer_id UUID REFERENCES customers(id),
order_number TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded')),
subtotal_amount NUMERIC(10,2) NOT NULL,
shipping_amount NUMERIC(10,2) DEFAULT 0,
tax_amount NUMERIC(10,2) DEFAULT 0,
discount_amount NUMERIC(10,2) DEFAULT 0,
total_amount NUMERIC(10,2) NOT NULL,
platform_fee NUMERIC(10,2) DEFAULT 0,
currency TEXT DEFAULT 'USD',
customer_email TEXT,
customer_name TEXT,
shipping_address JSONB,
billing_address JSONB,
shipping_method TEXT,
tracking_number TEXT,
carrier TEXT,
notes TEXT,
channel TEXT DEFAULT 'direct',
channel_order_id TEXT,
stripe_payment_intent_id TEXT,
paid_at TIMESTAMPTZ,
shipped_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_orders_org_id ON orders(org_id);
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_order_number ON orders(order_number);
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
CREATE INDEX idx_orders_channel ON orders(channel);
ColumnTypeDescription
idUUIDPrimary key
org_idUUIDFK to organization
customer_idUUIDFK to customer
order_numberTEXTHuman-readable order ID
statusTEXTOrder lifecycle status
subtotal_amountNUMERICItems total before fees
shipping_amountNUMERICShipping cost
tax_amountNUMERICTax collected
discount_amountNUMERICDiscounts applied
total_amountNUMERICFinal charged amount
platform_feeNUMERICArtbase commission
currencyTEXTISO currency code
customer_emailTEXTCustomer email
customer_nameTEXTCustomer name
shipping_addressJSONBDelivery address
billing_addressJSONBBilling address
shipping_methodTEXTShipping service
tracking_numberTEXTShipment tracking
carrierTEXTShipping carrier
notesTEXTOrder notes
channelTEXTdirect, etsy, gumroad
channel_order_idTEXTExternal order reference
stripe_payment_intent_idTEXTStripe reference

Address JSONB Structure

{
"line1": "123 Main St",
"line2": "Apt 4B",
"city": "Portland",
"state": "OR",
"postal_code": "97201",
"country": "US",
"phone": "+1-555-123-4567"
}

order_items

Line items for each order.

CREATE TABLE order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
org_id UUID REFERENCES organizations(id),
product_id UUID REFERENCES products(id) ON DELETE SET NULL,
variant_id UUID REFERENCES product_variants(id) ON DELETE SET NULL,
title TEXT NOT NULL,
variant_title TEXT,
sku TEXT,
quantity INTEGER NOT NULL DEFAULT 1,
unit_price NUMERIC(10,2) NOT NULL,
total_price NUMERIC(10,2) NOT NULL,
weight_grams INTEGER,
properties JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_org_id ON order_items(org_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);
ColumnTypeDescription
idUUIDPrimary key
order_idUUIDFK to order
org_idUUIDFK to organization
product_idUUIDFK to product
variant_idUUIDFK to variant
titleTEXTProduct title snapshot
variant_titleTEXTVariant name snapshot
skuTEXTSKU snapshot
quantityINTEGERQuantity ordered
unit_priceNUMERICPrice per unit
total_priceNUMERICquantity * unit_price
weight_gramsINTEGERWeight for shipping
propertiesJSONBCustom line properties

customers

Customer records normalized across orders.

CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
first_name TEXT,
last_name TEXT,
phone TEXT,
default_address JSONB,
total_orders INTEGER DEFAULT 0,
total_spent NUMERIC(10,2) DEFAULT 0,
tags TEXT[],
notes TEXT,
accepts_marketing BOOLEAN DEFAULT FALSE,
source TEXT DEFAULT 'direct',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(org_id, email)
);

CREATE INDEX idx_customers_org_id ON customers(org_id);
CREATE INDEX idx_customers_email ON customers(email);
CREATE INDEX idx_customers_created_at ON customers(created_at DESC);
ColumnTypeDescription
idUUIDPrimary key
org_idUUIDFK to organization
emailTEXTCustomer email
first_nameTEXTFirst name
last_nameTEXTLast name
phoneTEXTPhone number
default_addressJSONBPreferred address
total_ordersINTEGERLifetime order count
total_spentNUMERICLifetime spend
tagsTEXT[]Customer tags
notesTEXTInternal notes
accepts_marketingBOOLEANMarketing consent
sourceTEXTAcquisition source

abandoned_carts

Cart abandonment tracking for recovery emails.

CREATE TABLE abandoned_carts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
cart_items JSONB NOT NULL,
cart_total NUMERIC(10,2) NOT NULL,
recovery_email_sent_at TIMESTAMPTZ,
recovered_at TIMESTAMPTZ,
order_id UUID REFERENCES orders(id),
session_id TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_abandoned_carts_org_id ON abandoned_carts(org_id);
CREATE INDEX idx_abandoned_carts_email ON abandoned_carts(email);
CREATE INDEX idx_abandoned_carts_created_at ON abandoned_carts(created_at);
CREATE INDEX idx_abandoned_carts_pending ON abandoned_carts(org_id, created_at)
WHERE recovery_email_sent_at IS NULL AND recovered_at IS NULL;
ColumnTypeDescription
idUUIDPrimary key
org_idUUIDFK to organization
emailTEXTCustomer email
cart_itemsJSONBCart contents
cart_totalNUMERICCart value
recovery_email_sent_atTIMESTAMPTZWhen email was sent
recovered_atTIMESTAMPTZWhen cart was purchased
order_idUUIDResulting order (if any)
session_idTEXTBrowser session ID

Cart Items Structure

[
{
"product_id": "uuid",
"variant_id": "uuid",
"title": "Ceramic Vase",
"variant_title": "Large / White",
"quantity": 1,
"price": 85.00,
"image_url": "https://..."
}
]

channel_orders

Raw order data from external channels (audit trail).

CREATE TABLE channel_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_account_id UUID NOT NULL REFERENCES channel_accounts(id) ON DELETE CASCADE,
channel_order_id TEXT NOT NULL,
order_id UUID REFERENCES orders(id),
raw_data JSONB NOT NULL,
synced_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_account_id, channel_order_id)
);

CREATE INDEX idx_channel_orders_account ON channel_orders(channel_account_id);
CREATE INDEX idx_channel_orders_order_id ON channel_orders(order_id);
ColumnTypeDescription
idUUIDPrimary key
channel_account_idUUIDFK to channel account
channel_order_idTEXTExternal order ID
order_idUUIDFK to normalized order
raw_dataJSONBOriginal API response
synced_atTIMESTAMPTZSync timestamp

RLS Policies

-- Orders: Organization access
CREATE POLICY "Members view org orders"
ON orders FOR SELECT
USING (org_id IN (
SELECT org_id FROM organization_members
WHERE user_id = auth.uid()
));

-- Order items: Through order relationship
CREATE POLICY "Members view order items"
ON order_items FOR SELECT
USING (order_id IN (
SELECT id FROM orders
WHERE org_id IN (
SELECT org_id FROM organization_members
WHERE user_id = auth.uid()
)
));

-- Customers: Organization access
CREATE POLICY "Members view org customers"
ON customers FOR SELECT
USING (org_id IN (
SELECT org_id FROM organization_members
WHERE user_id = auth.uid()
));

-- Abandoned carts: Organization access
CREATE POLICY "Members view org abandoned carts"
ON abandoned_carts FOR SELECT
USING (org_id IN (
SELECT org_id FROM organization_members
WHERE user_id = auth.uid()
));

Common Queries

Get Orders with Items

SELECT
o.*,
COALESCE(json_agg(oi.*) FILTER (WHERE oi.id IS NOT NULL), '[]') as items
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
WHERE o.org_id = $1
GROUP BY o.id
ORDER BY o.created_at DESC
LIMIT 50;

Get Customer Order History

SELECT o.*
FROM orders o
WHERE o.customer_id = $1
ORDER BY o.created_at DESC;

Abandoned Carts Ready for Recovery

SELECT *
FROM abandoned_carts
WHERE org_id = $1
AND recovery_email_sent_at IS NULL
AND recovered_at IS NULL
AND created_at < NOW() - INTERVAL '1 hour'
ORDER BY created_at;

Update Order Status with Trigger

-- Trigger function for shipping notifications
CREATE OR REPLACE FUNCTION notify_order_shipped()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status = 'shipped' AND OLD.status != 'shipped' THEN
PERFORM pg_notify('order_shipped', json_build_object(
'order_id', NEW.id,
'org_id', NEW.org_id,
'customer_email', NEW.customer_email,
'tracking_number', NEW.tracking_number
)::text);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER on_order_shipped
AFTER UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION notify_order_shipped();