feat(api): points mall with atomic redemption and admin fulfillment
Platform-owned points products and a redemption order lifecycle kept separate from cash orders. Redeeming locks the published product, reserves stock, creates the order, debits points through the archived customer-accounts ledger with the order as reference, and snapshots the line in one transaction; a failure leaves no order, no stock change, and no ledger entry. Fulfilment moves only from pending_fulfillment, and customers see only their own redemptions. Demo seeding credits the demo customer through the same guarded credit path with reason seed, once, so no balance is ever written absolutely. Surfaces (admin console, mall points page) and product seeding follow.
This commit is contained in:
+95
-2
@@ -16,10 +16,14 @@ import type {
|
||||
CouponTemplate,
|
||||
CouponTemplateInput,
|
||||
HomeContent,
|
||||
IntegralOrder,
|
||||
IntegralProduct,
|
||||
IntegralProductInput,
|
||||
Invoice,
|
||||
InvoiceKind,
|
||||
Order,
|
||||
Product,
|
||||
RedeemPointsBody,
|
||||
Shipment,
|
||||
ShopProfile,
|
||||
User,
|
||||
@@ -31,6 +35,7 @@ import {
|
||||
MOCK_CATEGORIES,
|
||||
MOCK_COUPONS,
|
||||
MOCK_CURRENCIES,
|
||||
INTEGRAL_PRODUCTS,
|
||||
MOCK_PROMOS,
|
||||
MOCK_QUICK_LINKS,
|
||||
MOCK_STORES,
|
||||
@@ -54,9 +59,13 @@ interface MockState {
|
||||
addresses: AddressBookEntry[];
|
||||
/** In-memory only: claims made during this browser session. */
|
||||
coupons: Coupon[];
|
||||
/** In-memory points catalog and redemptions for the fixed-data path. */
|
||||
pointsProducts: IntegralProduct[];
|
||||
redemptions: IntegralOrder[];
|
||||
addressSeq: number;
|
||||
orderSeq: number;
|
||||
invoiceSeq: number;
|
||||
redemptionSeq: number;
|
||||
}
|
||||
|
||||
// v3: address book joined the persisted state.
|
||||
@@ -124,10 +133,36 @@ function seedCoupons(): Coupon[] {
|
||||
return MOCK_COUPONS.map(mockOwnedCoupon);
|
||||
}
|
||||
|
||||
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",
|
||||
}));
|
||||
}
|
||||
|
||||
function initialState(): MockState {
|
||||
const persisted = loadPersisted();
|
||||
// Coupons are session-only, so a restored snapshot re-seeds them.
|
||||
if (persisted) return { token: null, ...persisted, coupons: seedCoupons() };
|
||||
// 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 seededAddresses: AddressBookEntry[] = MOCK_ADDRESSES.map((a, i) => ({
|
||||
id: a.id,
|
||||
@@ -150,9 +185,12 @@ function initialState(): MockState {
|
||||
invoices: seed.invoices,
|
||||
addresses: seededAddresses,
|
||||
coupons: seedCoupons(),
|
||||
pointsProducts: seedPointsProducts(),
|
||||
redemptions: [],
|
||||
addressSeq: 100,
|
||||
orderSeq: 100,
|
||||
invoiceSeq: 100,
|
||||
redemptionSeq: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -587,6 +625,54 @@ export function createMockApi(): ApiClient {
|
||||
return Promise.resolve({ ...coupon });
|
||||
},
|
||||
|
||||
listPointsProducts: () =>
|
||||
Promise.resolve(
|
||||
state.pointsProducts.filter((p) => p.published).map((p) => ({ ...p })),
|
||||
),
|
||||
|
||||
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 });
|
||||
},
|
||||
|
||||
shop: {
|
||||
getMyShop: () => unsupported(),
|
||||
listMyProducts: () => unsupported(),
|
||||
@@ -623,6 +709,13 @@ export function createMockApi(): ApiClient {
|
||||
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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineNuxtConfig({
|
||||
// Domains served by the live backend; every other domain stays on the
|
||||
// fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'.
|
||||
// See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3.
|
||||
liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons"],
|
||||
liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points"],
|
||||
appName: "mall",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -20,7 +20,8 @@ type LiveDomain =
|
||||
| "shipments"
|
||||
| "invoices"
|
||||
| "addresses"
|
||||
| "coupons";
|
||||
| "coupons"
|
||||
| "points";
|
||||
|
||||
/**
|
||||
* Explicit per-domain method picks rather than a string allowlist: indexing
|
||||
@@ -72,6 +73,11 @@ const LIVE_PICKS = {
|
||||
listMyCoupons: a.listMyCoupons,
|
||||
claimCoupon: a.claimCoupon,
|
||||
}),
|
||||
points: (a: ApiClient) => ({
|
||||
listPointsProducts: a.listPointsProducts,
|
||||
listMyRedemptions: a.listMyRedemptions,
|
||||
redeemPoints: a.redeemPoints,
|
||||
}),
|
||||
} satisfies Record<LiveDomain, (a: ApiClient) => Partial<ApiClient>>;
|
||||
|
||||
const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[];
|
||||
@@ -91,6 +97,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
|
||||
"invoices",
|
||||
"addresses",
|
||||
"coupons",
|
||||
"points",
|
||||
];
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
|
||||
Reference in New Issue
Block a user