Files
vmall/apps/mall/mock/api.ts
T
james 9904696e76 feat: wave 2 migration (P3, P5, P7 openspec changes)
Implements, verifies, and archives the three remaining Wave 2 changes from
openspec/MIGRATION-PLAN.md.

- add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and
  one-time admin review, paginated own fund entries, idempotent per-shop
  weekly/monthly settlement statements with commission rate and one-time
  payout confirmation.
- add-merchant-onboarding (P5): personal/enterprise applications with one live
  application per user, guarded review with mandatory rejection reason, and
  transactional shop + owner provisioning returning one-time credentials;
  mall onboarding/status pages and an admin review console.
- add-membership-messaging (P7): platform member levels, append-only growth
  accrual on order completion with guarded one-way leveling, order/shipment/
  refund system messages with unread/read state and soft deletion, plus the
  mall header unread badge.

Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding,
membership and messaging modules, event hooks in order/fulfillment/aftersale,
and integration suites for each. Shared contract extended and all three
frontends updated; code indexes, domain docs, backend guidelines and the
migration tracker synced.

Verification: cargo test -p vmall-api green twice consecutively; mall, admin
and shop-admin builds pass; browser smoke on every new surface; openspec
validate --all --strict green (33 passed).

The three changes share the @vmall/shared contract, the mall mock adapter and
per-app locale/nav files, so they are committed together to keep every commit
buildable.
2026-09-25 15:25:29 +00:00

2526 lines
86 KiB
TypeScript

// Mock API adapter: implements the full @vmall/shared ApiClient surface with
// fixed data + in-memory cart/order state. Pages call $api exactly as they
// would against the live backend, so switching back is a config flip.
import { ApiError } from "@vmall/shared";
import type {
AccountSummary,
Address,
AddressBookEntry,
AddressInput,
Aftersale,
AftersaleApplyBody,
AftersaleDetail,
AftersaleMessage,
AftersaleMessageBody,
AftersaleReturnTrackingBody,
AftersaleStatus,
ApiClient,
AuthTokens,
Cart,
CartItem,
Coupon,
CouponTemplate,
CouponTemplateInput,
Favorite,
FavoriteListQuery,
FavoriteProductSummary,
FlashSaleItem,
FlashSaleItemInput,
FlashSaleSession,
FlashSaleSessionInput,
GroupBuyIntent,
GroupBuyingActivity,
GroupBuyingActivityInput,
GroupBuyingActivityView,
GrowthLogEntry,
HomeContent,
IntegralOrder,
IntegralProduct,
IntegralProductInput,
Invoice,
InvoiceKind,
MarkAllReadResult,
MemberLevel,
MemberLevelInput,
MembershipStatus,
MerchantApplication,
MerchantApplicationInput,
MerchantApplicationQuery,
MerchantApprovalResult,
MerchantCategoryRef,
Message,
MessageDeleteResult,
MessageKind,
MessageListQuery,
NextMemberLevel,
Order,
Paged,
Product,
PublicFlashSaleSession,
RedeemPointsBody,
Review,
ReviewInput,
ReviewableItem,
ReviewSummary,
Shipment,
ShopProfile,
UnreadCount,
User,
WalletAccountKind,
WalletEntry,
WalletRechargeResult,
WalletSummary,
WalletWithdrawal,
WithdrawalAccountDetails,
WithdrawalStatus,
} from "@vmall/shared";
import {
BASE_CURRENCY,
MOCK_BANNERS,
collectiveProducts,
MOCK_BRANDS,
MOCK_CATEGORIES,
MOCK_COUPONS,
MOCK_CURRENCIES,
MOCK_FAVORITES,
INTEGRAL_PRODUCTS,
MOCK_PROMOS,
MOCK_QUICK_LINKS,
MOCK_STORES,
MOCK_USER,
USER_STATS,
MOCK_ADDRESSES,
defaultAddress,
lowestSku,
mockConvertMinor,
productById,
searchMockProducts,
seckillProducts,
seedOrders,
storeById,
} from "./data";
interface MockState {
token: string | null;
cart: CartItem[];
orders: Order[];
shipments: Shipment[];
invoices: Invoice[];
addresses: AddressBookEntry[];
/** In-memory only: claims made during this browser session. */
coupons: Coupon[];
/** Persisted customer favorites for fixed-adapter reload parity. */
favorites: Favorite[];
/** Persisted customer aftersales for fixed-adapter reload parity. */
aftersales: Aftersale[];
aftersaleMessages: AftersaleMessage[];
/** Persisted customer reviews for fixed-adapter reload parity. */
reviews: Review[];
/** In-memory points catalog and redemptions for the fixed-data path. */
pointsProducts: IntegralProduct[];
redemptions: IntegralOrder[];
/** Buyer wallet balances, ledger and withdrawal history. */
walletAvailableMinor: number;
walletFrozenMinor: number;
walletEntries: WalletEntry[];
walletWithdrawals: WalletWithdrawal[];
/** Merchant onboarding applications, newest first, for the fixed-adapter path. */
merchantApplications: MerchantApplication[];
/** Buyer growth ledger; the level itself is re-derived from these entries. */
growthLogs: GrowthLogEntry[];
/** Buyer system messages; soft deletion is a mock-only marker. */
messages: MockMessage[];
addressSeq: number;
favoriteSeq: number;
orderSeq: number;
invoiceSeq: number;
redemptionSeq: number;
aftersaleSeq: number;
aftersaleMessageSeq: number;
reviewSeq: number;
walletEntrySeq: number;
walletWithdrawalSeq: number;
merchantApplicationSeq: number;
growthLogSeq: number;
messageSeq: number;
}
/** A message plus the soft-delete marker the shared contract never exposes. */
interface MockMessage extends Message {
deleted_at: string | null;
}
// v9: membership growth logs and buyer messages joined the persisted rollback state.
const STORAGE_KEY = "vmall.mock.state.v9";
type PersistedState = Pick<
MockState,
| "cart"
| "orders"
| "shipments"
| "invoices"
| "addresses"
| "favorites"
| "aftersales"
| "aftersaleMessages"
| "reviews"
| "orderSeq"
| "invoiceSeq"
| "addressSeq"
| "favoriteSeq"
| "aftersaleSeq"
| "aftersaleMessageSeq"
| "reviewSeq"
| "walletAvailableMinor"
| "walletFrozenMinor"
| "walletEntries"
| "walletWithdrawals"
| "walletEntrySeq"
| "walletWithdrawalSeq"
| "merchantApplications"
| "merchantApplicationSeq"
| "growthLogs"
| "messages"
| "growthLogSeq"
| "messageSeq"
>;
// Load cart/order session state persisted by a previous page load (client only).
function loadPersisted(): PersistedState | null {
if (!import.meta.client) return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return null;
const p = parsed as Partial<PersistedState>;
if (!Array.isArray(p.cart) || !Array.isArray(p.orders)) return null;
if (!Array.isArray(p.shipments) || !Array.isArray(p.invoices)) return null;
if (typeof p.orderSeq !== "number" || typeof p.invoiceSeq !== "number") return null;
if (!Array.isArray(p.addresses) || typeof p.addressSeq !== "number") return null;
if (!Array.isArray(p.favorites) || typeof p.favoriteSeq !== "number") return null;
if (!Array.isArray(p.aftersales) || !Array.isArray(p.aftersaleMessages)) return null;
if (typeof p.aftersaleSeq !== "number" || typeof p.aftersaleMessageSeq !== "number")
return null;
if (!Array.isArray(p.reviews) || typeof p.reviewSeq !== "number") return null;
if (typeof p.walletAvailableMinor !== "number" || typeof p.walletFrozenMinor !== "number")
return null;
if (!Array.isArray(p.walletEntries) || !Array.isArray(p.walletWithdrawals)) return null;
if (typeof p.walletEntrySeq !== "number" || typeof p.walletWithdrawalSeq !== "number")
return null;
if (!Array.isArray(p.merchantApplications) || typeof p.merchantApplicationSeq !== "number")
return null;
if (!Array.isArray(p.growthLogs) || typeof p.growthLogSeq !== "number") return null;
if (!Array.isArray(p.messages) || typeof p.messageSeq !== "number") return null;
return p as PersistedState;
} catch {
return null;
}
}
/** Mock coupons are global fixtures, so any shop id renders them as its own. */
function mockTemplate(t: (typeof MOCK_COUPONS)[number], shopId: string): CouponTemplate {
return {
id: t.id,
shop_id: shopId,
title: t.title,
amount_minor: t.amountMinor,
threshold_minor: t.thresholdMinor,
currency: t.currency,
stock: 100,
enabled: true,
starts_at: "2026-01-01T00:00:00.000Z",
ends_at: `${t.expiresAt}T23:59:59.000Z`,
created_at: "2026-01-01T00:00:00.000Z",
};
}
function mockOwnedCoupon(t: (typeof MOCK_COUPONS)[number]): Coupon {
const template = mockTemplate(t, MOCK_STORES[0].id);
return {
id: `uc-${t.id}`,
user_id: MOCK_USER.id,
template_id: template.id,
shop_id: template.shop_id,
title: template.title,
amount_minor: template.amount_minor,
threshold_minor: template.threshold_minor,
currency: template.currency,
starts_at: template.starts_at,
ends_at: template.ends_at,
status: "claimed",
order_id: null,
claimed_at: "2026-01-01T00:00:00.000Z",
redeemed_at: null,
};
}
function seedCoupons(): Coupon[] {
return MOCK_COUPONS.map(mockOwnedCoupon);
}
function clampPage(page?: number): number {
return Math.max(1, page ?? 1);
}
function clampPerPage(perPage?: number): number {
return Math.min(100, Math.max(1, perPage ?? 20));
}
function productSummary(product: Product): FavoriteProductSummary {
const sku = lowestSku(product);
return {
id: product.id,
shop_id: product.shop_id,
slug: product.slug,
name: product.name,
image: product.images[0] ?? null,
price_minor: sku?.price_minor ?? null,
currency: sku?.currency ?? null,
};
}
function hydrateProductFavorite(id: string, createdAt: string, product: Product): Favorite {
return {
kind: "product",
id,
user_id: MOCK_USER.id,
created_at: createdAt,
product: productSummary(product),
};
}
function hydrateShopFavorite(id: string, createdAt: string, store: MockStoreRecord): Favorite {
return {
kind: "shop",
id,
user_id: MOCK_USER.id,
created_at: createdAt,
shop: toShopProfile(store),
};
}
function seedFavorites(): Favorite[] {
const rows: Favorite[] = [];
for (const item of MOCK_FAVORITES) {
const created = `${item.createdAt}T09:00:00.000Z`;
if (item.kind === "product") {
const product = productById(item.refId);
if (product && product.status === "published") {
rows.push(hydrateProductFavorite(item.id, created, product));
}
} else {
const store = storeById(item.refId);
if (store) rows.push(hydrateShopFavorite(item.id, created, store));
}
}
return rows;
}
function seedPointsProducts(): IntegralProduct[] {
return INTEGRAL_PRODUCTS.map((p) => ({
id: p.id,
name: p.name,
subtitle: null,
content: null,
image: p.image,
points_price: p.points,
stock: p.stock,
published: true,
recommend: false,
position: 0,
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
}));
}
/** One synthesized active session covering the fixed seckill fixtures. */
function mockFlashSales(): PublicFlashSaleSession[] {
const now = Date.now();
const items = seckillProducts().map((entry, index) => {
const sku = lowestSku(entry.product);
const stock = sku?.stock ?? 100;
const sold = Math.round((stock * entry.soldPct) / 100);
return {
id: `fsi${index + 1}`,
session_id: "fs1",
sku_id: sku?.id ?? "",
product_id: entry.product.id,
product_slug: entry.product.slug,
product_name: entry.product.name,
image: entry.product.images[0] ?? null,
sku_code: sku?.sku_code ?? "",
sale_price_minor: entry.seckillPriceMinor,
currency: sku?.currency ?? BASE_CURRENCY,
original_price_minor: sku?.price_minor ?? entry.seckillPriceMinor,
original_currency: sku?.currency ?? BASE_CURRENCY,
reserved_stock: Math.max(stock - sold, 0),
sold_count: sold,
per_customer_limit: 2,
};
});
return [
{
id: "fs1",
shop_id: MOCK_STORES[0].id,
label: { en: "Flash sale", zh: "限时秒杀" },
starts_at: new Date(now - 3_600_000).toISOString(),
ends_at: new Date(now + 6 * 3_600_000).toISOString(),
enabled: true,
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
items,
},
];
}
/** Synthesized activities from the fixed collective fixtures. */
function mockGroupBuying(): GroupBuyingActivityView[] {
const now = Date.now();
return collectiveProducts().map((entry, index) => {
const sku = lowestSku(entry.product);
const price = sku?.price_minor ?? 1000;
return {
id: `gb${index + 1}`,
shop_id: entry.product.shop_id,
sku_id: sku?.id ?? "",
name: { en: `${entry.need}-person group`, zh: `${entry.need}人团` },
description: null,
image: entry.product.images[0] ?? null,
group_price_minor: Math.max(1, Math.round(price * 0.8)),
currency: sku?.currency ?? BASE_CURRENCY,
required_members: entry.need,
starts_at: new Date(now - 3_600_000).toISOString(),
ends_at: new Date(now + 7 * 24 * 3_600_000).toISOString(),
group_lifetime_hours: 24,
enabled: true,
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
sku_code: sku?.sku_code ?? "",
product_id: entry.product.id,
product_slug: entry.product.slug,
product_name: entry.product.name,
original_price_minor: price,
original_currency: sku?.currency ?? BASE_CURRENCY,
open_groups: [
{
id: `g${index + 1}`,
paid_member_count: Math.min(1, entry.need - 1),
required_members: entry.need,
expires_at: new Date(now + 6 * 3_600_000).toISOString(),
},
],
};
});
}
function seedAftersales(orders: Order[]): Aftersale[] {
const item = (orderId: string, itemId: string) => {
const order = orders.find((entry) => entry.id === orderId);
const line = order?.items.find((entry) => entry.id === itemId);
return order && line ? { order, line } : null;
};
const rows: Aftersale[] = [];
const pending = item("o1", "o1-it1");
const approved = item("o1", "o1-it2");
const refunded = item("o2", "o2-it1");
const rejected = item("o2", "o2-it2");
if (pending)
rows.push({
id: "as-demo-pending",
order_id: pending.order.id,
order_item_id: pending.line.id,
shop_id: pending.order.shop_id,
user_id: MOCK_USER.id,
kind: "refund_only",
status: "pending",
reason: { en: "The item arrived with a visible issue.", zh: "商品到货后发现明显问题。" },
amount_minor: Math.min(500, pending.line.unit_price_minor * pending.line.qty),
currency: pending.order.currency,
evidence: ["https://example.com/aftersale/demo-evidence.jpg"],
reopened: false,
return_carrier: null,
return_tracking_no: null,
created_at: "2026-09-10T12:00:00.000Z",
updated_at: "2026-09-10T12:00:00.000Z",
});
if (approved)
rows.push({
id: "as-demo-approved",
order_id: approved.order.id,
order_item_id: approved.line.id,
shop_id: approved.order.shop_id,
user_id: MOCK_USER.id,
kind: "return_refund",
status: "approved",
reason: { en: "The item needs to be returned.", zh: "商品需要退回。" },
amount_minor: approved.line.unit_price_minor * approved.line.qty,
currency: approved.order.currency,
evidence: [],
reopened: false,
return_carrier: null,
return_tracking_no: null,
created_at: "2026-09-10T13:00:00.000Z",
updated_at: "2026-09-11T09:00:00.000Z",
});
if (refunded)
rows.push({
id: "as-demo-refunded",
order_id: refunded.order.id,
order_item_id: refunded.line.id,
shop_id: refunded.order.shop_id,
user_id: MOCK_USER.id,
kind: "refund_only",
status: "refunded",
reason: { en: "The product did not match the listing.", zh: "商品与描述不符。" },
amount_minor: refunded.line.unit_price_minor * refunded.line.qty,
currency: refunded.order.currency,
evidence: [],
reopened: false,
return_carrier: null,
return_tracking_no: null,
created_at: "2026-09-12T17:00:00.000Z",
updated_at: "2026-09-14T09:00:00.000Z",
});
if (rejected)
rows.push({
id: "as-demo-rejected",
order_id: rejected.order.id,
order_item_id: rejected.line.id,
shop_id: rejected.order.shop_id,
user_id: MOCK_USER.id,
kind: "return_refund",
status: "rejected",
reason: { en: "Please review this return request.", zh: "请重新审核本次退货申请。" },
amount_minor: rejected.line.unit_price_minor * rejected.line.qty,
currency: rejected.order.currency,
evidence: [],
reopened: false,
return_carrier: null,
return_tracking_no: null,
created_at: "2026-09-12T18:00:00.000Z",
updated_at: "2026-09-13T09:00:00.000Z",
});
return rows;
}
function seedAftersaleMessages(aftersales: Aftersale[]): AftersaleMessage[] {
const refunded = aftersales.find((row) => row.id === "as-demo-refunded");
return refunded
? [
{
id: "asm-demo-1",
aftersale_id: refunded.id,
author_role: "buyer",
author_id: MOCK_USER.id,
content: { en: "Please help review this refund.", zh: "请帮忙审核退款。" },
evidence: [],
created_at: "2026-09-12T17:30:00.000Z",
},
]
: [];
}
function seedReviews(orders: Order[]): Review[] {
const order = orders.find((entry) => entry.id === "o2");
const item = order?.items.find((entry) => entry.id === "o2-it1");
const product = item ? skuIndex()[item.sku_id]?.product : undefined;
if (!order || !item || !product) return [];
return [
{
id: "rv-demo-1",
order_item_id: item.id,
order_id: order.id,
product_id: product.id,
shop_id: order.shop_id,
user_id: MOCK_USER.id,
rating: 5,
content: {
en: "Excellent quality and a smooth shopping experience.",
zh: "质量很好,购物体验很顺畅。",
},
images: ["https://example.com/reviews/demo-product.jpg"],
reply: { en: "Thank you for your support!", zh: "感谢您的支持!" },
reply_at: "2026-09-14T12:00:00.000Z",
status: "visible",
created_at: "2026-09-14T10:00:00.000Z",
reviewer_name: MOCK_USER.display_name,
},
];
}
interface WalletSeed {
walletAvailableMinor: number;
walletFrozenMinor: number;
walletEntries: WalletEntry[];
walletWithdrawals: WalletWithdrawal[];
walletEntrySeq: number;
walletWithdrawalSeq: number;
}
/**
* Deterministic opening wallet. Movements replay the same paired-entry shape the
* live backend writes, and end exactly on the fixture balances in USER_STATS.
*/
function seedWallet(): WalletSeed {
const entries: WalletEntry[] = [];
const withdrawals: WalletWithdrawal[] = [];
let available = 0;
let frozen = 0;
let entrySeq = 0;
let withdrawalSeq = 0;
function move(
account: WalletAccountKind,
deltaMinor: number,
reason: string,
referenceType: string | null,
referenceId: string | null,
createdAt: string,
): void {
entrySeq += 1;
if (account === "available") available += deltaMinor;
else frozen += deltaMinor;
entries.push({
id: `we-${entrySeq}`,
account_kind: account,
delta_minor: deltaMinor,
balance_minor: account === "available" ? available : frozen,
reason,
reference_type: referenceType,
reference_id: referenceId,
created_at: createdAt,
});
}
function withdrawal(
amountMinor: number,
status: Exclude<WithdrawalStatus, "pending">,
reviewNote: string,
reviewedAt: string,
createdAt: string,
): WalletWithdrawal {
withdrawalSeq += 1;
const row: WalletWithdrawal = {
id: `wd-${withdrawalSeq}`,
user_id: MOCK_USER.id,
user_email: MOCK_USER.email,
amount_minor: amountMinor,
currency: BASE_CURRENCY,
account_details: { method: "bank", account: "**** 4321", holder: MOCK_USER.display_name },
status,
review_note: reviewNote,
reviewed_at: reviewedAt,
created_at: createdAt,
};
withdrawals.push(row);
return row;
}
move("available", 50000, "opening_balance", null, null, "2026-08-20T09:00:00.000Z");
const approved = withdrawal(
20000,
"approved",
"Paid out to the registered bank account.",
"2026-09-03T09:00:00.000Z",
"2026-09-02T09:00:00.000Z",
);
move(
"available",
-approved.amount_minor,
"wallet_withdrawal_freeze",
"wallet_withdrawal",
approved.id,
"2026-09-02T09:00:05.000Z",
);
move(
"frozen",
approved.amount_minor,
"wallet_withdrawal_freeze",
"wallet_withdrawal",
approved.id,
"2026-09-02T09:00:05.000Z",
);
move(
"frozen",
-approved.amount_minor,
"wallet_withdrawal_approved",
"wallet_withdrawal",
approved.id,
"2026-09-03T09:00:00.000Z",
);
move("available", -19000, "order_payment", "order", "o1", "2026-09-05T10:00:00.000Z");
const rejected = withdrawal(
5000,
"rejected",
"Account holder name does not match our records.",
"2026-09-13T09:00:00.000Z",
"2026-09-12T09:00:00.000Z",
);
move(
"available",
-rejected.amount_minor,
"wallet_withdrawal_freeze",
"wallet_withdrawal",
rejected.id,
"2026-09-12T09:00:05.000Z",
);
move(
"frozen",
rejected.amount_minor,
"wallet_withdrawal_freeze",
"wallet_withdrawal",
rejected.id,
"2026-09-12T09:00:05.000Z",
);
move(
"frozen",
-rejected.amount_minor,
"wallet_withdrawal_rejected",
"wallet_withdrawal",
rejected.id,
"2026-09-13T09:00:00.000Z",
);
move(
"available",
rejected.amount_minor,
"wallet_withdrawal_rejected",
"wallet_withdrawal",
rejected.id,
"2026-09-13T09:00:00.000Z",
);
move(
"available",
1800,
"aftersale_refund",
"aftersale",
"as-demo-refunded",
"2026-09-14T09:00:00.000Z",
);
return {
walletAvailableMinor: available,
walletFrozenMinor: frozen,
walletEntries: entries,
walletWithdrawals: withdrawals,
walletEntrySeq: entrySeq,
walletWithdrawalSeq: withdrawalSeq,
};
}
/** Mirrors the backend's default page size for `/wallet/entries`. */
const WALLET_ENTRIES_PER_PAGE = 20;
// ---- membership / messaging fixtures ----
/** Deterministic level catalog, as a platform admin would seed it. */
const MOCK_MEMBER_LEVELS: MemberLevel[] = [
{
id: "ml-1",
name: { en: "Bronze", zh: "青铜会员" },
icon: "I",
growth_threshold: 100,
benefits: { en: "Standard customer support.", zh: "标准客户服务。" },
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
},
{
id: "ml-2",
name: { en: "Silver", zh: "白银会员" },
icon: "II",
growth_threshold: 500,
benefits: { en: "Faster support and member-only coupons.", zh: "优先客服与会员专享优惠券。" },
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
},
{
id: "ml-3",
name: { en: "Gold", zh: "黄金会员" },
icon: "III",
growth_threshold: 2000,
benefits: { en: "Priority shipping and exclusive offers.", zh: "优先发货与专属活动权益。" },
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
},
];
/** Mirrors the backend's default page size for `/membership/growth-logs`. */
const GROWTH_LOGS_PER_PAGE = 20;
/** Mirrors the backend's default page size for `/messages`. */
const MESSAGES_PER_PAGE = 20;
interface GrowthLogSeed {
growthLogs: GrowthLogEntry[];
growthLogSeq: number;
}
interface MessageSeed {
messages: MockMessage[];
messageSeq: number;
}
/** The level whose threshold is the highest one at or below the growth total. */
function levelAtOrBelow(growthTotal: number): MemberLevel | null {
let best: MemberLevel | null = null;
for (const level of MOCK_MEMBER_LEVELS) {
if (level.growth_threshold <= growthTotal) {
if (!best || level.growth_threshold > best.growth_threshold) best = level;
}
}
return best;
}
/** The lowest threshold above the growth total, i.e. the next upgrade. */
function levelAbove(growthTotal: number): MemberLevel | null {
let best: MemberLevel | null = null;
for (const level of MOCK_MEMBER_LEVELS) {
if (level.growth_threshold > growthTotal) {
if (!best || level.growth_threshold < best.growth_threshold) best = level;
}
}
return best;
}
function copyLevel(level: MemberLevel): MemberLevel {
return { ...level, name: { ...level.name }, benefits: { ...level.benefits } };
}
/** Strip the mock-only soft-delete marker before it crosses the contract. */
function toMessage(row: MockMessage): Message {
return {
id: row.id,
kind: row.kind,
title: { ...row.title },
body: { ...row.body },
reference_type: row.reference_type,
reference_id: row.reference_id,
status: row.status,
read_at: row.read_at,
created_at: row.created_at,
};
}
/**
* Deterministic growth ledger ending at 620 growth, exactly like the live
* backend's one-entry-per-completed-order shape.
*/
function seedGrowthLogs(orders: Order[]): GrowthLogSeed {
const logs: GrowthLogEntry[] = [];
let seq = 0;
let total = 0;
function accrue(
delta: number,
referenceType: string | null,
referenceId: string | null,
createdAt: string,
): void {
seq += 1;
total += delta;
logs.push({
id: `ge-${seq}`,
delta,
growth_total: total,
reason: "order_complete",
reference_type: referenceType,
reference_id: referenceId,
created_at: createdAt,
});
}
accrue(200, "order", orders[0]?.id ?? null, "2026-09-11T10:05:00.000Z");
accrue(300, "order", orders[1]?.id ?? null, "2026-09-13T16:05:00.000Z");
accrue(120, null, null, "2026-09-14T09:00:00.000Z");
return { growthLogs: logs, growthLogSeq: seq };
}
/** One system message per fixture event, carrying the same bilingual copy. */
function seedMessages(orders: Order[]): MessageSeed {
const shippedOrder = orders[0];
const paidOrder = orders[1] ?? orders[0];
const rows: MockMessage[] = [];
let seq = 0;
function copy(kind: MessageKind, orderNo: string): Pick<Message, "title" | "body"> {
if (kind === "order_paid")
return {
title: { en: "Payment received", zh: "付款成功" },
body: {
en: `Order ${orderNo} is paid and awaiting shipment.`,
zh: `订单 ${orderNo} 已付款,等待发货。`,
},
};
if (kind === "order_shipped")
return {
title: { en: "Order shipped", zh: "订单已发货" },
body: { en: `Order ${orderNo} has been dispatched.`, zh: `订单 ${orderNo} 已发货。` },
};
return {
title: { en: "Refund completed", zh: "退款完成" },
body: {
en: `Your refund for order ${orderNo} has been issued.`,
zh: `订单 ${orderNo} 的退款已完成。`,
},
};
}
function add(
kind: MessageKind,
referenceType: string,
referenceId: string | null,
orderNo: string,
status: Message["status"],
createdAt: string,
): void {
seq += 1;
rows.push({
id: `msg-${seq}`,
kind,
...copy(kind, orderNo),
reference_type: referenceType,
reference_id: referenceId,
status,
read_at: status === "read" ? createdAt : null,
created_at: createdAt,
deleted_at: null,
});
}
if (shippedOrder) {
add(
"order_shipped",
"order",
shippedOrder.id,
shippedOrder.order_no,
"read",
"2026-09-10T18:00:00.000Z",
);
add("order_paid", "order", shippedOrder.id, shippedOrder.order_no, "read", "2026-09-10T10:05:00.000Z");
}
if (paidOrder) {
add("order_paid", "order", paidOrder.id, paidOrder.order_no, "unread", "2026-09-12T16:00:00.000Z");
}
add(
"refund_completed",
"aftersale",
"as-demo-refunded",
paidOrder?.order_no ?? "—",
"unread",
"2026-09-14T09:00:00.000Z",
);
return { messages: rows, messageSeq: seq };
}
/** Mirrors `clamp_per_page` for the admin merchant application queue. */
const MERCHANT_APPLICATIONS_PER_PAGE = 20;
/**
* Fixed clock for merchant onboarding fixtures: sequence `n` renders as one
* hour after the base instant, so repeated sessions stay deterministic.
*/
const MERCHANT_BASE_MS = Date.parse("2026-09-18T10:00:00.000Z");
function merchantTime(seq: number): string {
return new Date(MERCHANT_BASE_MS + seq * 3_600_000).toISOString();
}
/**
* Opening merchant fixture: one rejected personal application, so the fixed
* adapter can show the rejection reason and the re-apply action before the
* first submission. A fresh submit then becomes the pending application, and a
* second submit hits the same 409 the live backend returns.
*/
function seedMerchantApplications(): MerchantApplication[] {
const category = MOCK_CATEGORIES[0];
if (!category) return [];
return [
{
id: "ma-1",
user_id: MOCK_USER.id,
applicant_email: MOCK_USER.email,
entity_type: "personal",
real_name: MOCK_USER.display_name,
company_name: null,
business_license_no: null,
category_ids: [category.id],
categories: [{ id: category.id, name: { ...category.name } }],
contact: {
name: MOCK_USER.display_name,
phone: "555-0130",
email: MOCK_USER.email,
address: "1 Market Street, San Francisco, CA",
},
qualification: {
identity_document_url: "https://example.com/merchant/id-demo.jpg",
},
status: "rejected",
rejection_reason:
"The identity document image is not legible. Please re-apply with a clear scan.",
reviewed_at: merchantTime(1),
created_shop_id: null,
created_at: merchantTime(0),
updated_at: merchantTime(1),
},
];
}
function initialState(): MockState {
const persisted = loadPersisted();
// Coupons and points are session-only, so a restored snapshot re-seeds them.
if (persisted) {
return {
token: null,
...persisted,
coupons: seedCoupons(),
pointsProducts: seedPointsProducts(),
redemptions: [],
redemptionSeq: 0,
};
}
const seed = seedOrders(MOCK_USER.id);
const aftersales = seedAftersales(seed.orders);
for (const row of aftersales) {
if (row.status !== "refunded") continue;
const order = seed.orders.find((entry) => entry.id === row.order_id);
if (order) order.refund_total_minor += row.amount_minor;
}
const seededAddresses: AddressBookEntry[] = MOCK_ADDRESSES.map((a, i) => ({
id: a.id,
user_id: MOCK_USER.id,
recipient: a.recipient,
phone: a.phone,
country: "US",
region: a.region,
city: a.city,
line1: a.line1,
postal_code: a.postalCode,
is_default: a.isDefault,
created_at: `2026-08-0${i + 1}T09:00:00.000Z`,
}));
return {
token: null,
cart: [],
orders: seed.orders,
shipments: seed.shipments,
invoices: seed.invoices,
addresses: seededAddresses,
coupons: seedCoupons(),
favorites: seedFavorites(),
aftersales,
reviews: seedReviews(seed.orders),
aftersaleMessages: seedAftersaleMessages(aftersales),
pointsProducts: seedPointsProducts(),
redemptions: [],
...seedWallet(),
...seedGrowthLogs(seed.orders),
...seedMessages(seed.orders),
merchantApplications: seedMerchantApplications(),
addressSeq: 100,
favoriteSeq: 200,
orderSeq: 100,
invoiceSeq: 100,
redemptionSeq: 0,
aftersaleSeq: 100,
reviewSeq: 1,
aftersaleMessageSeq: 100,
merchantApplicationSeq: 1,
};
}
function unsupported(): never {
throw new ApiError(
501,
"MOCK_UNSUPPORTED",
"This admin/shop endpoint is not part of the mall mock.",
);
}
type MockStoreRecord = NonNullable<ReturnType<typeof storeById>>;
/** Mock stores carry a nested `rate`; the API exposes flat score fields. */
function toShopProfile(store: MockStoreRecord): ShopProfile {
return {
id: store.id,
slug: store.slug,
name: store.name,
company: store.company,
region: store.region,
address: store.address,
logo: store.logo,
banner: store.banner,
notice: store.notice,
after_sale: store.afterSale,
score_rating: store.rate.score,
score_agreement: store.rate.agree,
score_service: store.rate.service,
score_speed: store.rate.speed,
};
}
function skuIndex(): Record<string, { product: Product; skuId: string }> {
const index: Record<string, { product: Product; skuId: string }> = {};
for (const p of searchMockProducts({ perPage: 1000 }).items) {
for (const s of p.skus ?? []) index[s.id] = { product: p, skuId: s.id };
}
return index;
}
export function createMockApi(): ApiClient {
const state = initialState();
function persist(): void {
if (!import.meta.client) return;
try {
const snapshot: PersistedState = {
cart: state.cart,
orders: state.orders,
shipments: state.shipments,
invoices: state.invoices,
orderSeq: state.orderSeq,
invoiceSeq: state.invoiceSeq,
addresses: state.addresses,
addressSeq: state.addressSeq,
favorites: state.favorites,
favoriteSeq: state.favoriteSeq,
aftersales: state.aftersales,
aftersaleMessages: state.aftersaleMessages,
reviews: state.reviews,
aftersaleSeq: state.aftersaleSeq,
aftersaleMessageSeq: state.aftersaleMessageSeq,
reviewSeq: state.reviewSeq,
walletAvailableMinor: state.walletAvailableMinor,
walletFrozenMinor: state.walletFrozenMinor,
walletEntries: state.walletEntries,
walletWithdrawals: state.walletWithdrawals,
walletEntrySeq: state.walletEntrySeq,
walletWithdrawalSeq: state.walletWithdrawalSeq,
merchantApplications: state.merchantApplications,
merchantApplicationSeq: state.merchantApplicationSeq,
growthLogs: state.growthLogs,
messages: state.messages,
growthLogSeq: state.growthLogSeq,
messageSeq: state.messageSeq,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
/* storage unavailable: keep in-memory only */
}
}
function tokens(): AuthTokens {
state.token = `mock-token-${Date.now()}`;
return { token: state.token, user: MOCK_USER };
}
function cartSnapshot(): Cart {
return { items: state.cart.map((i) => ({ ...i })) };
}
function nextOrderNo(): string {
state.orderSeq += 1;
return `VM20260917${String(state.orderSeq).padStart(3, "0")}`;
}
function findAftersale(id: string): Aftersale {
const row = state.aftersales.find((entry) => entry.id === id && entry.user_id === MOCK_USER.id);
if (!row) throw new ApiError(404, "NOT_FOUND", "Aftersale not found");
return row;
}
function findOrderItem(
orderItemId: string,
): { order: Order; item: Order["items"][number] } | null {
for (const order of state.orders) {
const item = order.items.find((entry) => entry.id === orderItemId);
if (item) return { order, item };
}
return null;
}
function eligibleOrder(order: Order): boolean {
const statusEligible = ["paid", "fulfilling", "shipped", "completed"].includes(order.status);
const windowEnd = Date.parse(order.created_at) + 15 * 24 * 60 * 60 * 1000;
return statusEligible && Date.now() <= windowEnd;
}
function remainingFor(orderItemId: string, lineAmount: number): number {
const refunded = state.aftersales
.filter((row) => row.order_item_id === orderItemId && row.status === "refunded")
.reduce((sum, row) => sum + row.amount_minor, 0);
return Math.max(0, lineAmount - refunded);
}
function copyAftersale(row: Aftersale): Aftersale {
return { ...row, reason: { ...row.reason }, evidence: [...row.evidence] };
}
function aftersaleDetail(id: string): AftersaleDetail {
const row = findAftersale(id);
const found = findOrderItem(row.order_item_id);
if (!found || found.order.id !== row.order_id)
throw new ApiError(404, "NOT_FOUND", "Aftersale item not found");
return {
...copyAftersale(row),
item: {
product_name: { ...found.item.product_name },
sku_code: found.item.sku_code,
image: found.item.image,
unit_price_minor: found.item.unit_price_minor,
qty: found.item.qty,
},
messages: state.aftersaleMessages
.filter((message) => message.aftersale_id === row.id)
.sort((a, b) => a.created_at.localeCompare(b.created_at))
.map((message) => ({
...message,
content: { ...message.content },
evidence: [...message.evidence],
})),
remaining_refundable_minor: remainingFor(
row.order_item_id,
found.item.unit_price_minor * found.item.qty,
),
};
}
function copyReview(row: Review): Review {
return {
...row,
content: { ...row.content },
images: [...row.images],
reply: row.reply ? { ...row.reply } : null,
};
}
function reviewProduct(found: { item: Order["items"][number] }): Product | null {
return skuIndex()[found.item.sku_id]?.product ?? null;
}
function reviewableItems(): ReviewableItem[] {
return state.orders
.filter((order) => order.user_id === MOCK_USER.id && order.status === "completed")
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.flatMap((order) =>
order.items.flatMap((item) => {
if (state.reviews.some((review) => review.order_item_id === item.id)) return [];
const product = reviewProduct({ item });
if (!product) return [];
return [
{
order_item_id: item.id,
order_id: order.id,
order_no: order.order_no,
product_id: product.id,
product_name: { ...item.product_name },
sku_code: item.sku_code,
image: item.image,
created_at: order.created_at,
},
];
}),
);
}
function copyMerchantApplication(row: MerchantApplication): MerchantApplication {
return {
...row,
category_ids: [...row.category_ids],
categories: row.categories.map((category) => ({
id: category.id,
name: { ...category.name },
})),
contact: { ...row.contact },
qualification: {
...row.qualification,
...(row.qualification.extra_materials
? { extra_materials: [...row.qualification.extra_materials] }
: {}),
},
};
}
function merchantCategoryRefs(ids: string[]): MerchantCategoryRef[] {
return ids.map((id) => {
const category = MOCK_CATEGORIES.find((entry) => entry.id === id);
if (!category) throw new ApiError(400, "BAD_REQUEST", "unknown operating category");
return { id: category.id, name: { ...category.name } };
});
}
function merchantRequired(value: string | undefined, field: string): string {
const trimmed = (value ?? "").trim();
if (!trimmed) throw new ApiError(400, "BAD_REQUEST", `${field} is required`);
return trimmed;
}
function merchantUrl(value: string | undefined, field: string): string {
const trimmed = (value ?? "").trim();
const ok =
(trimmed.startsWith("http://") || trimmed.startsWith("https://")) &&
trimmed.length > "https://".length &&
!/\s/.test(trimmed);
if (!ok) throw new ApiError(400, "BAD_REQUEST", `${field} must be an http(s) URL`);
return trimmed;
}
interface NormalizedMerchantApplication {
entity_type: MerchantApplication["entity_type"];
real_name: string | null;
company_name: string | null;
business_license_no: string | null;
category_ids: string[];
categories: MerchantCategoryRef[];
contact: MerchantApplication["contact"];
qualification: MerchantApplication["qualification"];
}
/** Mirrors the live service's validation before the fixed adapter stores a row. */
function normalizeMerchantInput(body: MerchantApplicationInput): NormalizedMerchantApplication {
if (body.category_ids.length === 0) {
throw new ApiError(400, "BAD_REQUEST", "at least one operating category is required");
}
const categoryIds = [...body.category_ids];
const categories = merchantCategoryRefs(categoryIds);
const contactName = merchantRequired(body.contact.name, "contact.name");
const contactPhone = merchantRequired(body.contact.phone, "contact.phone");
const email = merchantRequired(body.contact.email, "contact.email").toLowerCase();
if (!email.includes("@") || email.startsWith("@") || email.endsWith("@")) {
throw new ApiError(400, "BAD_REQUEST", "contact.email is invalid");
}
const address = body.contact.address?.trim();
const contact: MerchantApplication["contact"] = {
name: contactName,
phone: contactPhone,
email,
...(address ? { address } : {}),
};
const extra = (body.qualification.extra_materials ?? []).map((url) =>
merchantUrl(url, "qualification.extra_materials"),
);
if (body.entity_type === "personal") {
return {
entity_type: "personal",
real_name: merchantRequired(body.real_name, "real_name"),
company_name: null,
business_license_no: null,
category_ids: categoryIds,
categories,
contact,
qualification: {
identity_document_url: merchantUrl(
body.qualification.identity_document_url,
"qualification.identity_document_url",
),
...(extra.length ? { extra_materials: extra } : {}),
},
};
}
const businessLicenseNo = merchantRequired(
body.qualification.business_license_no,
"qualification.business_license_no",
);
return {
entity_type: "enterprise",
real_name: null,
company_name: merchantRequired(body.company_name, "company_name"),
business_license_no: businessLicenseNo,
category_ids: categoryIds,
categories,
contact,
qualification: {
business_license_url: merchantUrl(
body.qualification.business_license_url,
"qualification.business_license_url",
),
business_license_no: businessLicenseNo,
...(extra.length ? { extra_materials: extra } : {}),
},
};
}
function findMerchantApplication(id: string): MerchantApplication {
const row = state.merchantApplications.find((entry) => entry.id === id);
if (!row) throw new ApiError(404, "NOT_FOUND", "Merchant application not found");
return row;
}
function merchantHistory(): MerchantApplication[] {
return [...state.merchantApplications].sort((a, b) =>
b.created_at.localeCompare(a.created_at),
);
}
return {
register: () => Promise.resolve(tokens()),
login: () => Promise.resolve(tokens()),
me: (): Promise<User> => Promise.resolve(MOCK_USER),
getAccountSummary: (): Promise<AccountSummary> =>
Promise.resolve({
balance_minor: USER_STATS.balanceMinor,
frozen_minor: USER_STATS.frozenMinor,
currency: BASE_CURRENCY,
points: USER_STATS.points,
}),
listProducts: (q = {}) =>
Promise.resolve(
searchMockProducts({
q: q.q,
categoryId: q.category_id,
brandId: q.brand_id,
shopId: q.shop_id,
sort: q.sort,
order: q.order,
page: q.page,
perPage: q.per_page,
}),
),
getProduct: (idOrSlug) => {
const product = productById(idOrSlug);
if (!product) return Promise.reject(new ApiError(404, "NOT_FOUND", "Product not found"));
return Promise.resolve(product);
},
listCategories: () => Promise.resolve(MOCK_CATEGORIES),
listBrands: () => Promise.resolve(MOCK_BRANDS),
listCurrencies: () => Promise.resolve(MOCK_CURRENCIES),
convert: (amountMinor, from, to) =>
Promise.resolve({ amount_minor: mockConvertMinor(amountMinor, from, to), currency: to }),
getCart: () => Promise.resolve(cartSnapshot()),
addCartItem: (skuId, qty) => {
const entry = skuIndex()[skuId];
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "SKU not found"));
const sku = entry.product.skus?.find((s) => s.id === skuId);
if (!sku || !sku.active)
return Promise.reject(new ApiError(400, "SKU_INACTIVE", "SKU is not available"));
const existing = state.cart.find((i) => i.sku_id === skuId);
const nextQty = (existing?.qty ?? 0) + qty;
if (nextQty > sku.stock)
return Promise.reject(new ApiError(400, "OUT_OF_STOCK", "Not enough stock"));
if (existing) existing.qty = nextQty;
else {
state.cart.push({
sku_id: skuId,
product_id: entry.product.id,
product_name: entry.product.name,
sku_code: sku.sku_code,
image: entry.product.images[0] ?? null,
unit_price_minor: sku.price_minor,
currency: sku.currency,
qty,
// The live cart view carries these too; see replace-mock-api-wave-3.
shop_id: entry.product.shop_id,
shop_name: storeById(entry.product.shop_id)?.name ?? MOCK_STORES[0].name,
stock: sku.stock,
});
}
persist();
return Promise.resolve(cartSnapshot());
},
updateCartItem: (skuId, qty) => {
const item = state.cart.find((i) => i.sku_id === skuId);
if (!item) return Promise.reject(new ApiError(404, "NOT_FOUND", "Cart item not found"));
if (qty <= 0) state.cart = state.cart.filter((i) => i.sku_id !== skuId);
else item.qty = qty;
persist();
return Promise.resolve(cartSnapshot());
},
removeCartItem: (skuId) => {
state.cart = state.cart.filter((i) => i.sku_id !== skuId);
persist();
return Promise.resolve(cartSnapshot());
},
checkout: (
shippingAddress: Address,
currency: string,
couponByShop: Record<string, string> = {},
groupBuy?: GroupBuyIntent,
) => {
if (state.cart.length === 0) {
return Promise.reject(new ApiError(400, "EMPTY_CART", "Cart is empty"));
}
// A group-buy intent repriced exactly one SKU at the activity price.
const activity = groupBuy
? mockGroupBuying().find((entry) => entry.id === groupBuy.activity_id)
: undefined;
if (groupBuy && !activity) {
return Promise.reject(new ApiError(409, "CONFLICT", "Group activity is not available"));
}
const cartItems = state.cart.map((item) =>
activity && groupBuy && item.sku_id === groupBuy.sku_id
? { ...item, unit_price_minor: activity.group_price_minor }
: item,
);
const byShop: Record<string, CartItem[]> = {};
for (const item of cartItems) {
const product = productById(item.product_id);
const shopId = product?.shop_id ?? "unknown";
(byShop[shopId] ??= []).push(item);
}
const created: Order[] = [];
for (const [shopId, items] of Object.entries(byShop)) {
const subtotal = items.reduce((sum, i) => sum + i.unit_price_minor * i.qty, 0);
const groupHere = activity && groupBuy && items.some((i) => i.sku_id === groupBuy.sku_id);
const couponId = couponByShop[shopId];
let discount = 0;
if (couponId && groupHere) {
return Promise.reject(
new ApiError(409, "CONFLICT", "A coupon cannot be combined with group pricing"),
);
}
if (couponId) {
const coupon = state.coupons.find((c) => c.id === couponId);
if (!coupon || coupon.status !== "claimed" || coupon.shop_id !== shopId) {
return Promise.reject(new ApiError(409, "CONFLICT", "Coupon is not redeemable"));
}
if (subtotal < coupon.threshold_minor) {
return Promise.reject(
new ApiError(409, "CONFLICT", "Order does not reach the coupon threshold"),
);
}
discount = Math.min(coupon.amount_minor, subtotal);
}
// Same deterministic rule as quoteShipping: flat fee under the free threshold.
const shippingFee = subtotal - discount >= 20000 ? 0 : 599;
const order: Order = {
id: `o-${Date.now()}-${shopId}`,
order_no: nextOrderNo(),
shop_id: shopId,
user_id: MOCK_USER.id,
status: "pending_payment",
currency,
total_minor: subtotal - discount + shippingFee,
discount_minor: discount,
shipping_fee_minor: shippingFee,
refund_total_minor: 0,
coupon_id: couponId ?? null,
group_activity_id: groupHere ? activity.id : null,
group_id: groupHere ? `g-${Date.now()}` : null,
items: items.map((i, k) => ({
id: `oi-${Date.now()}-${k}`,
sku_id: i.sku_id,
product_name: i.product_name,
sku_code: i.sku_code,
unit_price_minor: i.unit_price_minor,
qty: i.qty,
image: i.image,
flash_sale_item_id: null,
})),
shipping_address: shippingAddress,
created_at: new Date().toISOString(),
};
if (couponId) {
const coupon = state.coupons.find((c) => c.id === couponId);
if (coupon) {
coupon.status = "redeemed";
coupon.order_id = order.id;
coupon.redeemed_at = order.created_at;
}
}
created.push(order);
}
state.orders = [...created, ...state.orders];
state.cart = [];
persist();
return Promise.resolve(created);
},
listMyOrders: (page = 1) => {
const perPage = 10;
const start = (page - 1) * perPage;
return Promise.resolve({
items: state.orders.slice(start, start + perPage),
total: state.orders.length,
page,
per_page: perPage,
});
},
getOrder: (id) => {
const order = state.orders.find((o) => o.id === id || o.order_no === id);
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
return Promise.resolve(order);
},
cancelOrder: (id) => {
const order = state.orders.find((o) => o.id === id);
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
if (order.status !== "pending_payment") {
return Promise.reject(
new ApiError(409, "INVALID_STATE", "Only unpaid orders can be cancelled"),
);
}
order.status = "cancelled";
const coupon = state.coupons.find((c) => c.order_id === order.id);
if (coupon) {
coupon.status = "claimed";
coupon.order_id = null;
coupon.redeemed_at = null;
}
persist();
return Promise.resolve(order);
},
payOrder: (id) => {
const order = state.orders.find((o) => o.id === id);
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
if (order.status !== "pending_payment") {
return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be paid"));
}
order.status = "paid";
persist();
return Promise.resolve(order);
},
confirmDelivered: (shipmentId) => {
const shipment = state.shipments.find((s) => s.id === shipmentId);
if (!shipment) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shipment not found"));
shipment.status = "delivered";
shipment.delivered_at = new Date().toISOString();
const order = state.orders.find((o) => o.id === shipment.order_id);
if (order) order.status = "completed";
persist();
return Promise.resolve(shipment);
},
listMyShipments: () => Promise.resolve(state.shipments.map((s) => ({ ...s }))),
// Deterministic fixture rule: a flat per-shop fee, free at/over 200 major.
quoteShipping: (_address, currency) => {
const byShop = new Map<string, number>();
for (const item of state.cart) {
byShop.set(item.shop_id, (byShop.get(item.shop_id) ?? 0) + item.unit_price_minor * item.qty);
}
const shops = [...byShop.entries()].map(([shop_id, subtotal]) => ({
shop_id,
fee_minor: subtotal >= 20000 ? 0 : 599,
}));
return Promise.resolve({
currency,
shops,
total_minor: shops.reduce((sum, s) => sum + s.fee_minor, 0),
});
},
listShippingCompanies: () =>
Promise.resolve([
{ code: "sf-express", name: { en: "SF Express", zh: "顺丰速运" }, active: true },
{ code: "zto", name: { en: "ZTO Express", zh: "中通快递" }, active: true },
{ code: "ups", name: { en: "UPS", zh: "联合包裹" }, active: true },
]),
requestInvoice: (orderId: string, title: string, taxNo: string | null, kind: InvoiceKind) => {
const order = state.orders.find((o) => o.id === orderId);
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
state.invoiceSeq += 1;
const invoice: Invoice = {
id: `inv-${Date.now()}`,
invoice_no: `INV20260917${String(state.invoiceSeq).padStart(3, "0")}`,
order_id: order.id,
order_no: order.order_no,
title,
tax_no: taxNo,
kind,
amount_minor: order.total_minor,
currency: order.currency,
status: "requested",
issued_at: null,
created_at: new Date().toISOString(),
};
state.invoices = [invoice, ...state.invoices];
persist();
return Promise.resolve(invoice);
},
listMyInvoices: () => Promise.resolve(state.invoices.map((i) => ({ ...i }))),
applyForAftersale: async (body: AftersaleApplyBody): Promise<AftersaleDetail> => {
if (body.kind !== "refund_only" && body.kind !== "return_refund") {
throw new ApiError(400, "BAD_REQUEST", "Invalid aftersale kind");
}
if (!body.reason.en?.trim() || !body.reason.zh?.trim()) {
throw new ApiError(400, "BAD_REQUEST", "reason needs non-empty en and zh");
}
if (!Number.isInteger(body.amount_minor) || body.amount_minor <= 0) {
throw new ApiError(400, "BAD_REQUEST", "amount_minor must be positive");
}
const evidence = (body.evidence ?? []).map((url) => url.trim());
if (evidence.some((url) => !url))
throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
const found = findOrderItem(body.order_item_id);
if (!found || found.order.user_id !== MOCK_USER.id)
throw new ApiError(404, "NOT_FOUND", "order item");
if (!eligibleOrder(found.order))
throw new ApiError(409, "CONFLICT", "order item is not eligible for after-sale");
const active = state.aftersales.some(
(row) =>
row.order_item_id === body.order_item_id &&
["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status),
);
if (active)
throw new ApiError(409, "CONFLICT", "an active aftersale already exists for this item");
const remaining = remainingFor(
body.order_item_id,
found.item.unit_price_minor * found.item.qty,
);
if (body.amount_minor > remaining)
throw new ApiError(409, "CONFLICT", "amount exceeds the remaining refundable balance");
state.aftersaleSeq += 1;
const now = new Date().toISOString();
const row: Aftersale = {
id: `as-${state.aftersaleSeq}`,
order_id: found.order.id,
order_item_id: body.order_item_id,
shop_id: found.order.shop_id,
user_id: MOCK_USER.id,
kind: body.kind,
status: "pending",
reason: { en: body.reason.en.trim(), zh: body.reason.zh.trim() },
amount_minor: body.amount_minor,
currency: found.order.currency,
evidence,
reopened: false,
return_carrier: null,
return_tracking_no: null,
created_at: now,
updated_at: now,
};
state.aftersales = [row, ...state.aftersales];
persist();
return aftersaleDetail(row.id);
},
listMyAftersales: async (): Promise<Aftersale[]> =>
state.aftersales
.filter((row) => row.user_id === MOCK_USER.id)
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.map(copyAftersale),
getAftersale: async (id: string): Promise<AftersaleDetail> => aftersaleDetail(id),
cancelAftersale: async (id: string): Promise<Aftersale> => {
const row = findAftersale(id);
if (!["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status)) {
throw new ApiError(409, "CONFLICT", "aftersale cannot be cancelled");
}
row.status = "cancelled";
row.updated_at = new Date().toISOString();
persist();
return copyAftersale(row);
},
reopenAftersale: async (id: string): Promise<Aftersale> => {
const row = findAftersale(id);
if (row.status !== "rejected" || row.reopened) {
throw new ApiError(409, "CONFLICT", "aftersale cannot be reopened");
}
row.status = "pending";
row.reopened = true;
row.updated_at = new Date().toISOString();
persist();
return copyAftersale(row);
},
submitAftersaleReturnTracking: async (
id: string,
body: AftersaleReturnTrackingBody,
): Promise<Aftersale> => {
const carrier = body.carrier.trim();
const trackingNo = body.tracking_no.trim();
if (!carrier || !trackingNo)
throw new ApiError(400, "BAD_REQUEST", "carrier and tracking_no are required");
const row = findAftersale(id);
if (row.kind !== "return_refund")
throw new ApiError(409, "CONFLICT", "not a return-refund aftersale");
if (row.status !== "approved")
throw new ApiError(409, "CONFLICT", "aftersale is not awaiting return shipping");
row.status = "buyer_shipping";
row.return_carrier = carrier;
row.return_tracking_no = trackingNo;
row.updated_at = new Date().toISOString();
persist();
return copyAftersale(row);
},
addAftersaleMessage: async (
id: string,
body: AftersaleMessageBody,
): Promise<AftersaleMessage> => {
const row = findAftersale(id);
const en = body.content.en?.trim() ?? "";
const zh = body.content.zh?.trim() ?? "";
if (!en && !zh) throw new ApiError(400, "BAD_REQUEST", "content needs text");
const evidence = (body.evidence ?? []).map((url) => url.trim());
if (evidence.some((url) => !url))
throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty");
state.aftersaleMessageSeq += 1;
const now = new Date().toISOString();
const message: AftersaleMessage = {
id: `asm-${state.aftersaleMessageSeq}`,
aftersale_id: row.id,
author_role: "buyer",
author_id: MOCK_USER.id,
content: { en: en || zh, zh: zh || en },
evidence,
created_at: now,
};
state.aftersaleMessages.push(message);
row.updated_at = now;
persist();
return { ...message, content: { ...message.content }, evidence: [...message.evidence] };
},
listProductReviews: (productId: string, page = 1) => {
const visible = state.reviews
.filter((review) => review.product_id === productId && review.status === "visible")
.sort((a, b) => b.created_at.localeCompare(a.created_at));
const currentPage = clampPage(page);
const perPage = clampPerPage();
const start = (currentPage - 1) * perPage;
return Promise.resolve({
items: visible.slice(start, start + perPage).map(copyReview),
total: visible.length,
page: currentPage,
per_page: perPage,
});
},
getProductReviewSummary: (productId: string): Promise<ReviewSummary> => {
const visible = state.reviews.filter(
(review) => review.product_id === productId && review.status === "visible",
);
const distribution: Record<string, number> = {};
let total = 0;
for (const review of visible) {
const key = String(review.rating);
distribution[key] = (distribution[key] ?? 0) + 1;
total += review.rating;
}
return Promise.resolve({
count: visible.length,
avg_rating: visible.length ? Math.round((total / visible.length) * 10) / 10 : 0,
distribution,
});
},
listReviewableItems: (): Promise<ReviewableItem[]> =>
Promise.resolve(reviewableItems()),
createReview: async (body: ReviewInput): Promise<Review> => {
if (!Number.isInteger(body.rating) || body.rating < 1 || body.rating > 5) {
throw new ApiError(400, "BAD_REQUEST", "rating must be between 1 and 5");
}
const en = body.content.en?.trim() ?? "";
const zh = body.content.zh?.trim() ?? "";
if (!en && !zh) {
throw new ApiError(400, "BAD_REQUEST", "content needs text in at least one locale");
}
const found = findOrderItem(body.order_item_id);
if (!found || found.order.user_id !== MOCK_USER.id || found.order.status !== "completed") {
throw new ApiError(409, "CONFLICT", "order line is not reviewable");
}
if (state.reviews.some((review) => review.order_item_id === body.order_item_id)) {
throw new ApiError(409, "CONFLICT", "order line already reviewed");
}
const product = reviewProduct(found);
if (!product) throw new ApiError(409, "CONFLICT", "order line is not reviewable");
state.reviewSeq += 1;
const row: Review = {
id: `rv-${state.reviewSeq}`,
order_item_id: body.order_item_id,
order_id: found.order.id,
product_id: product.id,
shop_id: found.order.shop_id,
user_id: MOCK_USER.id,
rating: body.rating,
content: {
...(body.content.en !== undefined ? { en: body.content.en } : {}),
...(body.content.zh !== undefined ? { zh: body.content.zh } : {}),
},
images: [...(body.images ?? [])],
reply: null,
reply_at: null,
status: "visible",
created_at: new Date().toISOString(),
reviewer_name: MOCK_USER.display_name,
};
state.reviews = [row, ...state.reviews];
persist();
return copyReview(row);
},
// Mirror of the seeded storefront-content rows, so the home page renders
// identically when every domain is configured to fixed data.
getHomeContent: (): Promise<HomeContent> =>
Promise.resolve({
banners: MOCK_BANNERS.map((b, i) => ({
id: `bn${i + 1}`,
image: b.image,
url: b.url,
position: i,
active: true,
})),
promos: MOCK_PROMOS.map((p, i) => ({
id: `pr${i + 1}`,
image: p.image,
url: p.url,
position: i,
active: true,
})),
quick_links: MOCK_QUICK_LINKS.map((q, i) => ({
id: `ql${i + 1}`,
label: q.label,
url: q.url,
glyph: q.glyph,
position: i,
active: true,
})),
floor_adverts: Array.from({ length: 6 }, (_, i) => ({
id: `fa${i + 1}`,
image: `/mock/floor-adv-${i + 1}.svg`,
position: i,
active: true,
})),
}),
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)),
getShop: (slug) => {
const store = storeById(slug);
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found"));
return Promise.resolve(toShopProfile(store));
},
listMyAddresses: () =>
Promise.resolve(
[...state.addresses].sort(
(a, b) =>
Number(b.is_default) - Number(a.is_default) || b.created_at.localeCompare(a.created_at),
),
),
createAddress: (input: AddressInput) => {
const makeDefault = input.is_default === true || state.addresses.length === 0;
if (makeDefault) for (const a of state.addresses) a.is_default = false;
state.addressSeq += 1;
const entry: AddressBookEntry = {
id: `a-${state.addressSeq}`,
user_id: MOCK_USER.id,
recipient: input.recipient,
phone: input.phone,
country: input.country,
region: input.region,
city: input.city,
line1: input.line1,
postal_code: input.postal_code,
is_default: makeDefault,
created_at: new Date().toISOString(),
};
state.addresses.push(entry);
persist();
return Promise.resolve(entry);
},
updateAddress: (id: string, input: AddressInput) => {
const entry = state.addresses.find((a) => a.id === id);
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
if (input.is_default === true && !entry.is_default) {
for (const a of state.addresses) a.is_default = false;
}
entry.recipient = input.recipient;
entry.phone = input.phone;
entry.country = input.country;
entry.region = input.region;
entry.city = input.city;
entry.line1 = input.line1;
entry.postal_code = input.postal_code;
entry.is_default = input.is_default === true || entry.is_default;
persist();
return Promise.resolve({ ...entry });
},
deleteAddress: (id: string) => {
const entry = state.addresses.find((a) => a.id === id);
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
const wasDefault = entry.is_default;
state.addresses = state.addresses.filter((a) => a.id !== id);
if (wasDefault && state.addresses.length > 0) {
const latest = state.addresses.reduce((a, b) => (a.created_at > b.created_at ? a : b));
latest.is_default = true;
}
persist();
return Promise.resolve([...state.addresses]);
},
setDefaultAddress: (id: string) => {
const entry = state.addresses.find((a) => a.id === id);
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
for (const a of state.addresses) a.is_default = a.id === id;
persist();
return Promise.resolve({ ...entry });
},
listFavorites: (q: FavoriteListQuery) => {
const page = clampPage(q.page);
const perPage = clampPerPage(q.per_page);
const visible = state.favorites
.map((row) => {
if (row.kind === "product") {
const product = productById(row.product.id);
if (!product || product.status !== "published") return null;
return hydrateProductFavorite(row.id, row.created_at, product);
}
const store = storeById(row.shop.id);
if (!store) return null;
return hydrateShopFavorite(row.id, row.created_at, store);
})
.filter((row): row is Favorite => row !== null)
.filter((row) => row.kind === q.kind)
.filter((row) => {
if (!q.target_id) return true;
return row.kind === "product"
? row.product.id === q.target_id
: row.shop.id === q.target_id;
})
.sort((a, b) => b.created_at.localeCompare(a.created_at));
const total = visible.length;
const start = (page - 1) * perPage;
return Promise.resolve({
items: visible.slice(start, start + perPage),
total,
page,
per_page: perPage,
});
},
addProductFavorite: (productId: string) => {
const product = productById(productId);
if (!product || product.status !== "published") {
return Promise.reject(new ApiError(404, "NOT_FOUND", "product"));
}
const existing = state.favorites.find(
(row) => row.kind === "product" && row.product.id === productId,
);
if (existing) {
return Promise.resolve(hydrateProductFavorite(existing.id, existing.created_at, product));
}
state.favoriteSeq += 1;
const row = hydrateProductFavorite(
`f-${state.favoriteSeq}`,
new Date().toISOString(),
product,
);
state.favorites.push(row);
persist();
return Promise.resolve(row);
},
removeProductFavorite: (productId: string) => {
state.favorites = state.favorites.filter(
(row) => !(row.kind === "product" && row.product.id === productId),
);
persist();
return Promise.resolve();
},
addShopFavorite: (shopId: string) => {
const store = storeById(shopId);
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "shop"));
const existing = state.favorites.find((row) => row.kind === "shop" && row.shop.id === shopId);
if (existing) {
return Promise.resolve(hydrateShopFavorite(existing.id, existing.created_at, store));
}
state.favoriteSeq += 1;
const row = hydrateShopFavorite(`f-${state.favoriteSeq}`, new Date().toISOString(), store);
state.favorites.push(row);
persist();
return Promise.resolve(row);
},
removeShopFavorite: (shopId: string) => {
state.favorites = state.favorites.filter(
(row) => !(row.kind === "shop" && row.shop.id === shopId),
);
persist();
return Promise.resolve();
},
listShopCouponTemplates: (shopId: string) =>
Promise.resolve(MOCK_COUPONS.map((t) => mockTemplate(t, shopId))),
listMyCoupons: () => Promise.resolve(state.coupons.map((c) => ({ ...c }))),
claimCoupon: (templateId: string) => {
const template = MOCK_COUPONS.find((t) => t.id === templateId);
if (!template) return Promise.reject(new ApiError(404, "NOT_FOUND", "Coupon not found"));
if (state.coupons.some((c) => c.template_id === templateId)) {
return Promise.reject(new ApiError(409, "CONFLICT", "Coupon already claimed"));
}
const coupon = mockOwnedCoupon(template);
state.coupons.push(coupon);
persist();
return Promise.resolve({ ...coupon });
},
listPointsProducts: () =>
Promise.resolve(state.pointsProducts.filter((p) => p.published).map((p) => ({ ...p }))),
listFlashSales: () => Promise.resolve(mockFlashSales()),
listGroupBuyingActivities: () => Promise.resolve(mockGroupBuying()),
listMyRedemptions: () => Promise.resolve(state.redemptions.map((o) => ({ ...o }))),
redeemPoints: (body: RedeemPointsBody) => {
const product = state.pointsProducts.find((p) => p.id === body.product_id && p.published);
if (!product) {
return Promise.reject(new ApiError(404, "NOT_FOUND", "Points product not found"));
}
if (body.qty <= 0 || body.qty > product.stock) {
return Promise.reject(new ApiError(409, "CONFLICT", "Insufficient points stock"));
}
const total = product.points_price * body.qty;
if (total > USER_STATS.points) {
return Promise.reject(new ApiError(409, "CONFLICT", "Insufficient points"));
}
state.redemptionSeq += 1;
const order: IntegralOrder = {
id: `po-${state.redemptionSeq}`,
order_no: `PM${String(state.redemptionSeq).padStart(8, "0")}`,
user_id: MOCK_USER.id,
status: "pending_fulfillment",
total_points: total,
shipping_address: body.shipping_address,
items: [
{
id: `poi-${state.redemptionSeq}`,
order_id: `po-${state.redemptionSeq}`,
product_id: product.id,
name: product.name,
image: product.image,
points_price: product.points_price,
qty: body.qty,
},
],
created_at: new Date().toISOString(),
};
product.stock -= body.qty;
state.redemptions = [order, ...state.redemptions];
persist();
return Promise.resolve({ ...order });
},
getWallet: (): Promise<WalletSummary> =>
Promise.resolve({
available_minor: state.walletAvailableMinor,
frozen_minor: state.walletFrozenMinor,
currency: BASE_CURRENCY,
}),
listWalletEntries: (page = 1) => {
const currentPage = clampPage(page);
const ordered = [...state.walletEntries].sort((a, b) =>
b.created_at.localeCompare(a.created_at),
);
const start = (currentPage - 1) * WALLET_ENTRIES_PER_PAGE;
return Promise.resolve({
items: ordered
.slice(start, start + WALLET_ENTRIES_PER_PAGE)
.map((entry) => ({ ...entry })),
total: ordered.length,
page: currentPage,
per_page: WALLET_ENTRIES_PER_PAGE,
});
},
rechargeWallet: (amountMinor: number): Promise<WalletRechargeResult> => {
if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
return Promise.reject(new ApiError(400, "BAD_REQUEST", "amount_minor must be positive"));
}
const now = new Date().toISOString();
state.walletEntrySeq += 1;
const rechargeId = `wr-${state.walletEntrySeq}`;
state.walletAvailableMinor += amountMinor;
state.walletEntries.push({
id: `we-${state.walletEntrySeq}`,
account_kind: "available",
delta_minor: amountMinor,
balance_minor: state.walletAvailableMinor,
reason: "wallet_recharge",
reference_type: "wallet_recharge",
reference_id: rechargeId,
created_at: now,
});
persist();
return Promise.resolve({
id: rechargeId,
demo: true,
amount_minor: amountMinor,
currency: BASE_CURRENCY,
status: "credited",
available_minor: state.walletAvailableMinor,
created_at: now,
});
},
applyWithdrawal: (
amountMinor: number,
details: WithdrawalAccountDetails,
): Promise<WalletWithdrawal> => {
if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
return Promise.reject(new ApiError(400, "BAD_REQUEST", "amount_minor must be positive"));
}
const method = details.method.trim();
const account = details.account.trim();
if (!method || !account) {
return Promise.reject(
new ApiError(400, "BAD_REQUEST", "account_details.method and account are required"),
);
}
if (amountMinor > state.walletAvailableMinor) {
// The live backend's guarded debit turns an uncovered amount into a 409.
return Promise.reject(new ApiError(409, "CONFLICT", "insufficient available balance"));
}
const holder = details.holder?.trim();
state.walletWithdrawalSeq += 1;
const now = new Date().toISOString();
const row: WalletWithdrawal = {
id: `wd-${state.walletWithdrawalSeq}`,
user_id: MOCK_USER.id,
user_email: MOCK_USER.email,
amount_minor: amountMinor,
currency: BASE_CURRENCY,
account_details: { method, account, ...(holder ? { holder } : {}) },
status: "pending",
review_note: null,
reviewed_at: null,
created_at: now,
};
state.walletWithdrawals = [row, ...state.walletWithdrawals];
// Freeze: one paired two-sided movement, exactly like the live service.
state.walletEntrySeq += 1;
state.walletAvailableMinor -= amountMinor;
state.walletEntries.push({
id: `we-${state.walletEntrySeq}`,
account_kind: "available",
delta_minor: -amountMinor,
balance_minor: state.walletAvailableMinor,
reason: "wallet_withdrawal_freeze",
reference_type: "wallet_withdrawal",
reference_id: row.id,
created_at: now,
});
state.walletEntrySeq += 1;
state.walletFrozenMinor += amountMinor;
state.walletEntries.push({
id: `we-${state.walletEntrySeq}`,
account_kind: "frozen",
delta_minor: amountMinor,
balance_minor: state.walletFrozenMinor,
reason: "wallet_withdrawal_freeze",
reference_type: "wallet_withdrawal",
reference_id: row.id,
created_at: now,
});
persist();
return Promise.resolve({ ...row, account_details: { ...row.account_details } });
},
listMyWithdrawals: () =>
Promise.resolve(
[...state.walletWithdrawals]
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.map((row) => ({ ...row, account_details: { ...row.account_details } })),
),
submitMerchantApplication: (body: MerchantApplicationInput): Promise<MerchantApplication> => {
const normalized = normalizeMerchantInput(body);
const live = state.merchantApplications.some(
(row) =>
row.user_id === MOCK_USER.id && (row.status === "pending" || row.status === "approved"),
);
if (live) {
return Promise.reject(
new ApiError(409, "CONFLICT", "an active merchant application already exists"),
);
}
state.merchantApplicationSeq += 1;
const now = merchantTime(state.merchantApplicationSeq + 1);
const row: MerchantApplication = {
id: `ma-${state.merchantApplicationSeq}`,
user_id: MOCK_USER.id,
applicant_email: MOCK_USER.email,
entity_type: normalized.entity_type,
real_name: normalized.real_name,
company_name: normalized.company_name,
business_license_no: normalized.business_license_no,
category_ids: normalized.category_ids,
categories: normalized.categories,
contact: normalized.contact,
qualification: normalized.qualification,
status: "pending",
rejection_reason: null,
reviewed_at: null,
created_shop_id: null,
created_at: now,
updated_at: now,
};
state.merchantApplications = [row, ...state.merchantApplications];
persist();
return Promise.resolve(copyMerchantApplication(row));
},
getMyMerchantApplications: (): Promise<MerchantApplication[]> =>
Promise.resolve(
merchantHistory()
.filter((row) => row.user_id === MOCK_USER.id)
.map(copyMerchantApplication),
),
getMembership: (): Promise<MembershipStatus> => {
// Re-derived from the ledger on every read, exactly like the live service.
const growthTotal = state.growthLogs.reduce((sum, entry) => sum + entry.delta, 0);
const level = levelAtOrBelow(growthTotal);
const next = levelAbove(growthTotal);
const nextLevel: NextMemberLevel | null = next
? {
id: next.id,
name: { ...next.name },
icon: next.icon,
growth_threshold: next.growth_threshold,
remaining: next.growth_threshold - growthTotal,
}
: null;
return Promise.resolve({
level: level ? copyLevel(level) : null,
growth_total: growthTotal,
next_level: nextLevel,
});
},
listGrowthLogs: (page = 1): Promise<Paged<GrowthLogEntry>> => {
const currentPage = clampPage(page);
const ordered = [...state.growthLogs].sort((a, b) =>
b.created_at.localeCompare(a.created_at),
);
const start = (currentPage - 1) * GROWTH_LOGS_PER_PAGE;
return Promise.resolve({
items: ordered.slice(start, start + GROWTH_LOGS_PER_PAGE).map((entry) => ({ ...entry })),
total: ordered.length,
page: currentPage,
per_page: GROWTH_LOGS_PER_PAGE,
});
},
listMessages: (q: MessageListQuery = {}): Promise<Paged<Message>> => {
const currentPage = clampPage(q.page);
const perPage = clampPerPage(q.per_page ?? MESSAGES_PER_PAGE);
const unreadOnly = q.unread_only ?? false;
const visible = state.messages
.filter((row) => row.deleted_at === null && (!unreadOnly || row.status === "unread"))
.sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id));
const start = (currentPage - 1) * perPage;
return Promise.resolve({
items: visible.slice(start, start + perPage).map(toMessage),
total: visible.length,
page: currentPage,
per_page: perPage,
});
},
markMessageRead: (id: string): Promise<Message> => {
const row = state.messages.find((entry) => entry.id === id);
if (!row) return Promise.reject(new ApiError(404, "NOT_FOUND", "message"));
// Guarded like the live UPDATE: only an unread, non-deleted row flips.
if (row.deleted_at === null && row.status === "unread") {
row.status = "read";
row.read_at = new Date().toISOString();
persist();
}
return Promise.resolve(toMessage(row));
},
markAllMessagesRead: (): Promise<MarkAllReadResult> => {
const now = new Date().toISOString();
let updated = 0;
for (const row of state.messages) {
if (row.deleted_at !== null || row.status !== "unread") continue;
row.status = "read";
row.read_at = now;
updated += 1;
}
if (updated > 0) persist();
return Promise.resolve({ updated });
},
deleteMessage: (id: string): Promise<MessageDeleteResult> => {
const row = state.messages.find((entry) => entry.id === id);
if (!row) return Promise.reject(new ApiError(404, "NOT_FOUND", "message"));
// Soft delete is idempotent: a repeat reports that nothing flipped.
if (row.deleted_at !== null) return Promise.resolve({ id, deleted: false });
row.deleted_at = new Date().toISOString();
persist();
return Promise.resolve({ id, deleted: true });
},
getUnreadCount: (): Promise<UnreadCount> =>
Promise.resolve({
unread: state.messages.filter((row) => row.deleted_at === null && row.status === "unread")
.length,
}),
shop: {
getMyShop: () => unsupported(),
updateMyProfile: () => unsupported(),
listMyProducts: () => unsupported(),
getProduct: () => unsupported(),
createProduct: () => unsupported(),
updateProduct: () => unsupported(),
publish: () => unsupported(),
unpublish: () => unsupported(),
upsertSku: () => unsupported(),
listOrders: () => unsupported(),
getOrder: () => unsupported(),
createShipment: () => unsupported(),
listShipments: () => unsupported(),
markShipped: () => unsupported(),
listInvoices: () => unsupported(),
issueInvoice: () => unsupported(),
listCouponTemplates: () => unsupported(),
createCouponTemplate: (_body: CouponTemplateInput) => unsupported(),
updateCouponTemplate: (_id: string, _body: CouponTemplateInput) => unsupported(),
deleteCouponTemplate: (_id: string) => unsupported(),
listFlashSales: () => unsupported(),
createFlashSale: (_body: FlashSaleSessionInput) => unsupported(),
updateFlashSale: (_id: string, _body: FlashSaleSessionInput) => unsupported(),
deleteFlashSale: (_id: string) => unsupported(),
addFlashSaleItem: (_sessionId: string, _body: FlashSaleItemInput) => unsupported(),
updateFlashSaleItem: (_id: string, _body: FlashSaleItemInput) => unsupported(),
deleteFlashSaleItem: (_id: string) => unsupported(),
listGroupBuyingActivities: () => unsupported(),
createGroupBuyingActivity: (_body: GroupBuyingActivityInput) => unsupported(),
updateGroupBuyingActivity: (_id: string, _body: GroupBuyingActivityInput) => unsupported(),
deleteGroupBuyingActivity: (_id: string) => unsupported(),
listAftersales: (_status?: AftersaleStatus) => unsupported(),
getAftersale: (_id: string) => unsupported(),
approveAftersale: (_id: string) => unsupported(),
rejectAftersale: (_id: string) => unsupported(),
confirmAftersaleReceipt: (_id: string) => unsupported(),
refundAftersale: (_id: string) => unsupported(),
addAftersaleMessage: (_id: string, _body: AftersaleMessageBody) => unsupported(),
listFreightTemplates: () => unsupported(),
createFreightTemplate: () => unsupported(),
updateFreightTemplate: () => unsupported(),
deleteFreightTemplate: () => unsupported(),
listReviews: (_page?: number) => unsupported(),
replyReview: (_id: string, _content: Record<string, string>) => unsupported(),
listShopSettlementStatements: () => unsupported(),
getShopSettlementStatement: () => unsupported(),
generateShopSettlementStatement: () => unsupported(),
},
admin: {
listUsers: () => unsupported(),
setUserRole: () => unsupported(),
listShops: () => unsupported(),
createShop: () => unsupported(),
setShopStatus: () => unsupported(),
listOrders: () => unsupported(),
listCurrencies: () => unsupported(),
upsertCurrency: () => unsupported(),
setRate: () => unsupported(),
getContent: () => unsupported(),
replaceContent: () => unsupported(),
setShopProfile: () => unsupported(),
getBrands: () => unsupported(),
replaceBrands: () => unsupported(),
listPointsProducts: () => unsupported(),
createPointsProduct: (_body: IntegralProductInput) => unsupported(),
updatePointsProduct: (_id: string, _body: IntegralProductInput) => unsupported(),
setPointsProductPublished: (_id: string, _published: boolean) => unsupported(),
listPointsRedemptions: (_page?: number) => unsupported(),
fulfillRedemption: (_id: string) => unsupported(),
cancelRedemption: (_id: string) => unsupported(),
listAftersales: (_status?: AftersaleStatus) => unsupported(),
getAftersale: (_id: string) => unsupported(),
arbitrateAftersale: (_id: string, _outcome: "refund" | "reject") => unsupported(),
listReviews: (_page?: number) => unsupported(),
hideReview: (_id: string) => unsupported(),
deleteReview: (_id: string) => unsupported(),
listWithdrawalApplications: () => unsupported(),
reviewWithdrawal: () => unsupported(),
getCommissionRate: () => unsupported(),
setCommissionRate: () => unsupported(),
listSettlementStatements: () => unsupported(),
getSettlementStatement: () => unsupported(),
generateSettlementStatement: () => unsupported(),
confirmSettlementStatement: () => unsupported(),
listMemberLevels: () => unsupported(),
createMemberLevel: (_body: MemberLevelInput) => unsupported(),
updateMemberLevel: (_id: string, _body: MemberLevelInput) => unsupported(),
deleteMemberLevel: (_id: string) => unsupported(),
listMerchantApplications: async (
q: MerchantApplicationQuery = {},
): Promise<Paged<MerchantApplication>> => {
const page = clampPage(q.page);
const perPage = clampPerPage(q.per_page ?? MERCHANT_APPLICATIONS_PER_PAGE);
const filtered = merchantHistory().filter((row) => !q.status || row.status === q.status);
const start = (page - 1) * perPage;
return {
items: filtered.slice(start, start + perPage).map(copyMerchantApplication),
total: filtered.length,
page,
per_page: perPage,
};
},
getMerchantApplication: async (id: string): Promise<MerchantApplication> =>
copyMerchantApplication(findMerchantApplication(id)),
approveMerchantApplication: async (id: string): Promise<MerchantApprovalResult> => {
const row = findMerchantApplication(id);
if (row.status !== "pending") {
throw new ApiError(409, "CONFLICT", "application was already reviewed");
}
state.merchantApplicationSeq += 1;
const reviewedAt = merchantTime(state.merchantApplicationSeq + 1);
const shopId = `s-merchant-${state.merchantApplicationSeq}`;
row.status = "approved";
row.reviewed_at = reviewedAt;
row.created_shop_id = shopId;
row.updated_at = reviewedAt;
persist();
return {
application: copyMerchantApplication(row),
credentials: {
email: `shop-owner+${row.id}@vmall.local`,
initial_password: "Vmall-Owner-2026",
shop_id: shopId,
shop_slug: `merchant-shop-${state.merchantApplicationSeq}`,
},
};
},
rejectMerchantApplication: async (
id: string,
reason: string,
): Promise<MerchantApplication> => {
const trimmed = reason.trim();
if (!trimmed) throw new ApiError(400, "BAD_REQUEST", "rejection reason is required");
const row = findMerchantApplication(id);
if (row.status !== "pending") {
throw new ApiError(409, "CONFLICT", "application was already reviewed");
}
state.merchantApplicationSeq += 1;
const reviewedAt = merchantTime(state.merchantApplicationSeq + 1);
row.status = "rejected";
row.rejection_reason = trimmed;
row.reviewed_at = reviewedAt;
row.updated_at = reviewedAt;
persist();
return copyMerchantApplication(row);
},
},
};
}
export { defaultAddress };