Files
raisfast/docs/order-design.md
T
2026-05-13 10:38:14 +08:00

14 KiB

Order System Design

Positioning

SaaS order system — supports both digital goods (auto-delivery) and physical goods (shipping). Initially focused on digital products (subscriptions, memberships, credits, content paywalls, license keys). Physical goods shipping ready when needed.

The relationship with payment:

orders (business)              payment_orders (payment intent)
  买了什么、多少钱               怎么付的、渠道回调
  1 ──────────────── N          (一次下单可能多次支付尝试)
  │                              │
  └── order_id ──────────────────┘

Database Schema

orders

CREATE TABLE IF NOT EXISTS orders (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    document_id TEXT NOT NULL UNIQUE,

    user_id INTEGER NOT NULL REFERENCES users(id),
    order_no TEXT NOT NULL UNIQUE,             -- business order number: ORD-20260513-xxxx

    -- pricing (smallest unit)
    subtotal INTEGER NOT NULL DEFAULT 0,       -- sum of items unit_price * quantity
    discount_amount INTEGER NOT NULL DEFAULT 0,
    shipping_amount INTEGER NOT NULL DEFAULT 0,
    total_amount INTEGER NOT NULL CHECK(total_amount >= 0),

    currency TEXT NOT NULL DEFAULT 'USD',

    -- status
    status TEXT NOT NULL DEFAULT 'pending',

    -- buyer info
    buyer_name TEXT,
    buyer_phone TEXT,
    buyer_email TEXT,
    shipping_address TEXT,                    -- JSON: { country, province, city, district, street, zip, name, phone }

    -- shipping (physical goods)
    tracking_no TEXT,
    carrier TEXT,                             -- e.g. "sf_express", "yto", "fedex"

    -- remark
    remark TEXT,
    admin_remark TEXT,

    -- delivery (for digital goods, e.g. license key, download link)
    delivery_data TEXT,                        -- JSON: auto-populated after paid

    -- timestamps
    paid_at TEXT,
    completed_at TEXT,
    cancelled_at TEXT,
    refunding_at TEXT,
    refunded_at TEXT,
    expired_at TEXT,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_order_no ON orders(order_no);

products

CREATE TABLE IF NOT EXISTS products (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    document_id TEXT NOT NULL UNIQUE,

    category_id INTEGER REFERENCES categories(id),  -- product classification
    title TEXT NOT NULL,
    description TEXT,
    cover_url TEXT,

    -- type & delivery
    product_type TEXT NOT NULL DEFAULT 'custom',  -- virtual_credit/membership/content_paywall/license/download/physical/custom
    fulfillment_type TEXT NOT NULL DEFAULT 'digital', -- digital/physical
    delivery_hook TEXT,                            -- plugin hook name for digital delivery (e.g. "deliver_license")

    -- physical goods
    weight INTEGER,                               -- grams
    shipping_template_id INTEGER,                 -- reference to shipping template (future table)

    -- pricing (smallest unit)
    price INTEGER NOT NULL CHECK(price >= 0),     -- 0 = free
    currency TEXT NOT NULL DEFAULT 'USD',

    -- status
    status TEXT NOT NULL DEFAULT 'draft',          -- draft/active/archived

    -- extensible
    attributes TEXT,                               -- JSON: arbitrary product-specific data (duration, access_level, etc.)

    sort_order INTEGER NOT NULL DEFAULT 0,
    version INTEGER NOT NULL DEFAULT 1,
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_products_status ON products(status);
CREATE INDEX IF NOT EXISTS idx_products_type ON products(product_type);

order_items

CREATE TABLE IF NOT EXISTS order_items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    document_id TEXT NOT NULL UNIQUE,

    order_id INTEGER NOT NULL REFERENCES orders(id),

    -- what was bought (snapshot, immutable once order is paid)
    product_id INTEGER REFERENCES products(id),  -- NULL if product deleted
    title TEXT NOT NULL,                          -- snapshot at purchase time
    description TEXT,

    -- pricing (smallest unit)
    unit_price INTEGER NOT NULL CHECK(unit_price >= 0),
    quantity INTEGER NOT NULL CHECK(quantity > 0),
    subtotal INTEGER NOT NULL,                 -- unit_price * quantity

    -- snapshot
    cover_url TEXT,                            -- product image
    attributes TEXT,                           -- JSON: variant info (color, size, etc.)

    created_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id);

State Machine

┌──────────┐
│ pending  │ ← create order
└────┬──┬──┘
     │  │
     │  └──────────────→ cancelled (buyer cancel or expire)
     ▼
┌──────────┐
│   paid   │ ← payment callback confirmed
└────┬──┬──┘
     │  │
     │  └──────────────→ refunding → refunded
     │
     ├──→ completed (digital: auto)
     │
     ▼
┌───────────┐
│  shipped  │ ← admin marks shipped (physical only)
└─────┬─────┘
      │
      ▼
┌───────────┐
│ completed  │ ← buyer confirms receipt (or auto after timeout)
└───────────┘
define_enum!(
    OrderStatus {
        Pending = "pending",
        Paid = "paid",
        Shipped = "shipped",
        Completed = "completed",
        Cancelled = "cancelled",
        Refunding = "refunding",
        Refunded = "refunded",
        Expired = "expired",
    }
);

define_enum!(
    ProductType {
        VirtualCredit = "virtual_credit",
        Membership = "membership",
        ContentPaywall = "content_paywall",
        License = "license",
        Download = "download",
        Physical = "physical",
        Custom = "custom",
    }
);

define_enum!(
    FulfillmentType {
        Digital = "digital",
        Physical = "physical",
    }
);

define_enum!(
    ProductStatus {
        Draft = "draft",
        Active = "active",
        Archived = "archived",
    }
);

Sequence: Order → Payment → Delivery

Buyer            Order Service       Payment Service      Provider        Wallet
  │                   │                    │                  │              │
  │ POST /orders      │                    │                  │              │
  │──────────────────>│                    │                  │              │
  │                   │ INSERT order       │                  │              │
  │                   │ status=pending     │                  │              │
  │                   │ INSERT order_items │                  │              │
  │<──────────────────│ {order_id}         │                  │              │
  │                   │                    │                  │              │
  │ POST /payment/orders                   │                  │              │
  │  {order_id, amount, channel}           │                  │              │
  │───────────────────────────────────────>│                  │              │
  │                   │                    │ create payment   │              │
  │                   │                    │─────────────────>│              │
  │  {redirect_url}   │                    │<────────────────│              │
  │<───────────────────────────────────────│                  │              │
  │                   │                    │                  │              │
  │ ... buyer pays on provider page ...    │                  │              │
  │                   │                    │                  │              │
  │                   │                    │  webhook callback│              │
  │                   │                    │<─────────────────│              │
  │                   │                    │                  │              │
  │                   │                    │ ── atomic tx ──────────────────>│
  │                   │                    │ 1. payment_order=paid          │
  │                   │                    │ 2. payment_tx INSERT           │
  │                   │                    │ 3. wallet credit               │
  │                   │                    │ ── commit ─────────────────────>│
  │                   │                    │                  │              │
  │                   │ order paid         │                  │              │
  │                   │<───────────────────│                  │              │
  │                   │ UPDATE orders      │                  │              │
  │                   │ status=paid        │                  │              │
  │                   │ paid_at=now        │                  │              │
  │                   │                    │                  │              │
  │                   │ deliver digital goods (auto-complete) │              │
  │                   │ UPDATE orders      │                  │              │
  │                   │ status=completed   │                  │              │
  │                   │ delivery_data=...  │                  │              │
  │<──────────────────│ {delivery_data}    │                  │              │
  │                   │                    │                  │              │

Order ↔ Payment Connection

Payment callback, after its own atomic transaction, notifies the order system:

// In payment callback handler, after payment + wallet atomic commit:
if let Some(order_id) = &payment_order.order_id {
    crate::services::order::mark_paid(pool, order_id).await?;
}
// src/services/order.rs
pub async fn mark_paid(pool: &Pool, order_doc_id: &str) -> AppResult<()> {
    let order = model::find_by_doc_id(pool, order_doc_id).await?
        .ok_or_else(|| AppError::not_found("order"))?;

    if order.status != OrderStatus::Pending {
        return Ok(()); // idempotent
    }

    model::update_status(pool, order.id, OrderStatus::Paid, Some("paid_at")).await?;

    // Digital: auto-deliver → auto-complete
    // Physical: stay at paid, wait for admin to ship
    if order.is_digital() {
        deliver_and_complete(pool, order.id).await?;
    }

    Ok(())
}

async fn deliver_and_complete(pool: &Pool, order_id: i64) -> AppResult<()> {
    // Plugin hook: on-order-paid → generate license key / grant access / send download link
    let delivery = crate::services::delivery::process(pool, order_id).await?;

    model::update_delivery(pool, order_id, &delivery).await?;
    model::update_status(pool, order_id, OrderStatus::Completed, Some("completed_at")).await?;
    Ok(())
}

Payment does not know order internals. Order does not know which provider was used. They communicate through payment_orders.order_id → orders.document_id.

API

# Public (authenticated)
GET    /api/v1/products                            # List active products
GET    /api/v1/products/:id                        # Product detail
POST   /api/v1/orders                              # Create order + items
GET    /api/v1/orders                              # My orders (paginated)
GET    /api/v1/orders/:id                          # Order detail + items
POST   /api/v1/orders/:id/cancel                   # Cancel pending order
POST   /api/v1/orders/:id/confirm                  # Buyer confirms receipt (shipped → completed)

# Admin
GET/POST/PUT/DELETE  /api/v1/admin/products[/:id]  # CRUD products
GET                  /api/v1/admin/orders[/:id]    # All orders (paginated + filter)
POST                 /api/v1/admin/orders/:id/ship  # Mark shipped (physical only)
POST                 /api/v1/admin/orders/:id/cancel
POST                 /api/v1/admin/orders/:id/refund
PUT                  /api/v1/admin/orders/:id/admin-remark
GET                  /api/v1/admin/orders/stats

Digital Delivery

After order is paid, digital goods auto-deliver based on product type:

Product Type Delivery Action delivery_data Example
virtual_credit Credit wallet {"wallet_tx_id": "..."}
membership Activate subscription {"plan": "premium", "expires_at": "2027-05-13"}
content_paywall Grant access {"content_ids": ["post-123"]}
license Generate key {"license_key": "XXXX-XXXX-XXXX"}
download Generate signed URL {"download_url": "https://..."}
custom Plugin delivery_hook depends on plugin

Plugin registers on-order-paid hook (or product's delivery_hook), receives order data, returns delivery result. Core order system just stores the result in delivery_data and marks completed.

File Structure

src/order/
  mod.rs              -- pub mod
  model.rs            -- DB queries (products + orders + order_items)
  service.rs          -- product CRUD / create order / cancel / mark_paid / deliver
  handler.rs          -- HTTP handlers
  dto.rs              -- request/response types

No provider trait needed — order service is pure business logic, payment integration is the thin bridge layer.