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:
@@ -14,6 +14,9 @@ import type {
|
||||
CouponTemplateInput,
|
||||
Currency,
|
||||
HomeContent,
|
||||
IntegralOrder,
|
||||
IntegralProduct,
|
||||
IntegralProductInput,
|
||||
Invoice,
|
||||
InvoiceKind,
|
||||
LocalizedText,
|
||||
@@ -22,6 +25,7 @@ import type {
|
||||
Paged,
|
||||
Product,
|
||||
ProductStatus,
|
||||
RedeemPointsBody,
|
||||
Shipment,
|
||||
Shop,
|
||||
ShopProfile,
|
||||
@@ -200,6 +204,10 @@ export interface ApiClient {
|
||||
listShopCouponTemplates(shopId: string): Promise<CouponTemplate[]>;
|
||||
listMyCoupons(): Promise<Coupon[]>;
|
||||
claimCoupon(templateId: string): Promise<Coupon>;
|
||||
/** Public points catalog: published products only. */
|
||||
listPointsProducts(): Promise<IntegralProduct[]>;
|
||||
listMyRedemptions(): Promise<IntegralOrder[]>;
|
||||
redeemPoints(body: RedeemPointsBody): Promise<IntegralOrder>;
|
||||
shop: {
|
||||
getMyShop(): Promise<Shop>;
|
||||
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
|
||||
@@ -292,6 +300,9 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
listShopCouponTemplates: (shopId) => r("GET", `/shops/${shopId}/coupon-templates`),
|
||||
listMyCoupons: () => r("GET", "/me/coupons"),
|
||||
claimCoupon: (templateId) => r("POST", "/me/coupons", { template_id: templateId }),
|
||||
listPointsProducts: () => r("GET", "/points/products"),
|
||||
listMyRedemptions: () => r("GET", "/points/redemptions"),
|
||||
redeemPoints: (body) => r("POST", "/points/redemptions", body),
|
||||
shop: {
|
||||
getMyShop: () => r("GET", "/shop/profile"),
|
||||
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
|
||||
@@ -337,6 +348,14 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
/** Replaces the whole ordered brand list. */
|
||||
getBrands: () => r("GET", "/brands"),
|
||||
replaceBrands: (items) => r("PUT", "/admin/brands", items),
|
||||
listPointsProducts: () => r("GET", "/admin/points/products"),
|
||||
createPointsProduct: (body) => r("POST", "/admin/points/products", body),
|
||||
updatePointsProduct: (id, body) => r("PUT", `/admin/points/products/${id}`, body),
|
||||
setPointsProductPublished: (id, published) =>
|
||||
r("POST", `/admin/points/products/${id}/${published ? "publish" : "unpublish"}`),
|
||||
listPointsRedemptions: (page = 1) => r("GET", "/admin/points/orders", undefined, { page }),
|
||||
fulfillRedemption: (id) => r("POST", `/admin/points/orders/${id}/fulfill`),
|
||||
cancelRedemption: (id) => r("POST", `/admin/points/orders/${id}/cancel`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -248,6 +248,68 @@ export interface Coupon {
|
||||
redeemed_at: string | null;
|
||||
}
|
||||
|
||||
// ---- points mall ----
|
||||
|
||||
export type IntegralOrderStatus = "pending_fulfillment" | "fulfilled" | "cancelled";
|
||||
|
||||
/** Platform-owned catalog item redeemable for points; never a SKU. */
|
||||
export interface IntegralProduct {
|
||||
id: string;
|
||||
name: LocalizedText;
|
||||
subtitle: LocalizedText | null;
|
||||
content: LocalizedText | null;
|
||||
image: string | null;
|
||||
points_price: number;
|
||||
stock: number;
|
||||
published: boolean;
|
||||
recommend: boolean;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Platform-admin create/update body for a points product. */
|
||||
export interface IntegralProductInput {
|
||||
name: LocalizedText;
|
||||
subtitle?: LocalizedText | null;
|
||||
content?: LocalizedText | null;
|
||||
image?: string | null;
|
||||
points_price: number;
|
||||
stock: number;
|
||||
published?: boolean;
|
||||
recommend?: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface IntegralOrderItem {
|
||||
id: string;
|
||||
order_id: string;
|
||||
/** Null once the catalog item is deleted; the snapshot still stands. */
|
||||
product_id: string | null;
|
||||
name: LocalizedText;
|
||||
image: string | null;
|
||||
points_price: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
/** A redemption order; separate from cash orders and payments. */
|
||||
export interface IntegralOrder {
|
||||
id: string;
|
||||
order_no: string;
|
||||
user_id: string;
|
||||
status: IntegralOrderStatus;
|
||||
total_points: number;
|
||||
shipping_address: Address;
|
||||
items: IntegralOrderItem[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RedeemPointsBody {
|
||||
product_id: string;
|
||||
qty: number;
|
||||
shipping_address: Address;
|
||||
}
|
||||
|
||||
export type ShipmentStatus = "pending" | "shipped" | "delivered";export interface Shipment {
|
||||
id: string;
|
||||
shipment_no: string;
|
||||
|
||||
Reference in New Issue
Block a user