feat(marketing): add threshold promotions (满减)
Shop-scoped threshold rules with bilingual names, integer threshold/reduction (reduction < threshold), active windows and display order. Checkout applies the best eligible rule per shop with currency conversion; coupon/threshold/activity pricing are mutually exclusive per shop order. Reduction persists in orders.discount_minor + threshold_promotion_id and flows into payment totals; checkout idempotency covers the threshold selection. Delete archives referenced rules, hard-deletes unreferenced ones. - migration 0026: threshold_promotions + orders.threshold_promotion_id + coupon/threshold exclusivity CHECK - api: modules/promotion (CRUD, preview, checkout integration) - shared: ThresholdPromotion types, shop CRUD + preview client methods, checkout threshold_by_shop - shop-admin: promotions page + nav + en/zh locales - mall: exclusive promotion radio on checkout, live preview wiring, threshold label on order detail - tests: promotions.rs (scoping, validation, best-rule, exclusivity, activity-priced 409, totals, replay, archive-vs-delete) - openspec: archived 2026-10-19-add-threshold-promotions; spec threshold-promotions created, order/frontend specs updated
This commit is contained in:
@@ -22,6 +22,9 @@ import type {
|
||||
Coupon,
|
||||
CouponTemplate,
|
||||
CouponTemplateInput,
|
||||
ShopThresholdPreview,
|
||||
ThresholdPromotion,
|
||||
ThresholdPromotionInput,
|
||||
CreatePaymentAttemptBody,
|
||||
Currency,
|
||||
EligibleMethod,
|
||||
@@ -264,6 +267,7 @@ export interface ApiClient {
|
||||
shippingAddress: Address,
|
||||
currency: string,
|
||||
couponByShop: Record<string, string> | undefined,
|
||||
thresholdByShop: Record<string, string> | undefined,
|
||||
groupBuy: GroupBuyIntent | undefined,
|
||||
idempotencyKey: string,
|
||||
): Promise<CheckoutResult>;
|
||||
@@ -312,6 +316,8 @@ export interface ApiClient {
|
||||
removeShopFavorite(shopId: string): Promise<void>;
|
||||
/** Public: coupons a shopper could claim from this shop right now. */
|
||||
listShopCouponTemplates(shopId: string): Promise<CouponTemplate[]>;
|
||||
/** Per-shop best eligible threshold rule for the current cart; display-only. */
|
||||
previewCheckoutThresholds(currency: string): Promise<ShopThresholdPreview[]>;
|
||||
listMyCoupons(): Promise<Coupon[]>;
|
||||
claimCoupon(templateId: string): Promise<Coupon>;
|
||||
applyForAftersale(body: AftersaleApplyBody): Promise<AftersaleDetail>;
|
||||
@@ -393,6 +399,11 @@ export interface ApiClient {
|
||||
createCouponTemplate(body: CouponTemplateInput): Promise<CouponTemplate>;
|
||||
updateCouponTemplate(id: string, body: CouponTemplateInput): Promise<CouponTemplate>;
|
||||
deleteCouponTemplate(id: string): Promise<void>;
|
||||
listThresholdPromotions(): Promise<ThresholdPromotion[]>;
|
||||
createThresholdPromotion(body: ThresholdPromotionInput): Promise<ThresholdPromotion>;
|
||||
updateThresholdPromotion(id: string, body: ThresholdPromotionInput): Promise<ThresholdPromotion>;
|
||||
/** Referenced rules are archived, not deleted; both return 204. */
|
||||
deleteThresholdPromotion(id: string): Promise<void>;
|
||||
listFlashSales(): Promise<ShopFlashSaleSession[]>;
|
||||
createFlashSale(body: FlashSaleSessionInput): Promise<FlashSaleSession>;
|
||||
updateFlashSale(id: string, body: FlashSaleSessionInput): Promise<FlashSaleSession>;
|
||||
@@ -532,7 +543,7 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
addCartItem: (skuId, qty) => r("POST", "/cart/items", { sku_id: skuId, qty }),
|
||||
updateCartItem: (skuId, qty) => r("PUT", `/cart/items/${skuId}`, { qty }),
|
||||
removeCartItem: (skuId) => r("DELETE", `/cart/items/${skuId}`),
|
||||
checkout: (shippingAddress, currency, couponByShop, groupBuy, idempotencyKey) =>
|
||||
checkout: (shippingAddress, currency, couponByShop, thresholdByShop, groupBuy, idempotencyKey) =>
|
||||
r(
|
||||
"POST",
|
||||
"/orders/checkout",
|
||||
@@ -540,6 +551,7 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
shipping_address: shippingAddress,
|
||||
currency,
|
||||
coupon_by_shop: couponByShop ?? {},
|
||||
threshold_by_shop: thresholdByShop ?? {},
|
||||
group_buy: groupBuy ?? null,
|
||||
},
|
||||
undefined,
|
||||
@@ -576,6 +588,8 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
addShopFavorite: (shopId) => r("PUT", `/favorites/shops/${shopId}`),
|
||||
removeShopFavorite: (shopId) => r("DELETE", `/favorites/shops/${shopId}`),
|
||||
listShopCouponTemplates: (shopId) => r("GET", `/shops/${shopId}/coupon-templates`),
|
||||
previewCheckoutThresholds: (currency) =>
|
||||
r("GET", "/checkout/threshold-preview", undefined, { currency }),
|
||||
listMyCoupons: () => r("GET", "/me/coupons"),
|
||||
claimCoupon: (templateId) => r("POST", "/me/coupons", { template_id: templateId }),
|
||||
applyForAftersale: (body) => r("POST", "/aftersales", body),
|
||||
@@ -642,6 +656,10 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
createCouponTemplate: (body) => r("POST", "/shop/coupon-templates", body),
|
||||
updateCouponTemplate: (id, body) => r("PUT", `/shop/coupon-templates/${id}`, body),
|
||||
deleteCouponTemplate: (id) => r("DELETE", `/shop/coupon-templates/${id}`),
|
||||
listThresholdPromotions: () => r("GET", "/shop/threshold-promotions"),
|
||||
createThresholdPromotion: (body) => r("POST", "/shop/threshold-promotions", body),
|
||||
updateThresholdPromotion: (id, body) => r("PUT", `/shop/threshold-promotions/${id}`, body),
|
||||
deleteThresholdPromotion: (id) => r("DELETE", `/shop/threshold-promotions/${id}`),
|
||||
listFlashSales: () => r("GET", "/shop/flash-sales"),
|
||||
createFlashSale: (body) => r("POST", "/shop/flash-sales", body),
|
||||
updateFlashSale: (id, body) => r("PUT", `/shop/flash-sales/${id}`, body),
|
||||
|
||||
@@ -159,7 +159,7 @@ export interface Order {
|
||||
status: OrderStatus;
|
||||
currency: string;
|
||||
total_minor: number;
|
||||
/** Server-calculated coupon discount, already applied to `total_minor`. */
|
||||
/** Server-calculated promotion discount (coupon or threshold), already applied to `total_minor`. */
|
||||
discount_minor: number;
|
||||
/** Authoritative sum of completed refunds, integer minor units. */
|
||||
refund_total_minor: number;
|
||||
@@ -167,6 +167,8 @@ export interface Order {
|
||||
shipping_fee_minor: number;
|
||||
/** The coupon this order redeemed, or null. */
|
||||
coupon_id: string | null;
|
||||
/** The threshold promotion this order applied, or null. Never set together with `coupon_id`. */
|
||||
threshold_promotion_id: string | null;
|
||||
/** The group-buying activity this order joined, or null. */
|
||||
group_activity_id: string | null;
|
||||
/** The concrete group this order belongs to, or null. */
|
||||
@@ -241,6 +243,55 @@ export interface CouponTemplateInput {
|
||||
ends_at: string;
|
||||
}
|
||||
|
||||
/** Shop-owned automatic threshold reduction (满减); priced at checkout, never claimed. */
|
||||
export interface ThresholdPromotion {
|
||||
id: string;
|
||||
shop_id: string;
|
||||
name: LocalizedText;
|
||||
currency: string;
|
||||
threshold_minor: number;
|
||||
reduction_minor: number;
|
||||
enabled: boolean;
|
||||
archived: boolean;
|
||||
display_order: number;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Create/update body for a shop threshold promotion. */
|
||||
export interface ThresholdPromotionInput {
|
||||
name: LocalizedText;
|
||||
currency: string;
|
||||
threshold_minor: number;
|
||||
reduction_minor: number;
|
||||
enabled: boolean;
|
||||
display_order?: number;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
}
|
||||
|
||||
/** One eligible rule in the buyer's currency, for checkout preview display. */
|
||||
export interface ThresholdRuleView {
|
||||
id: string;
|
||||
shop_id: string;
|
||||
name: LocalizedText;
|
||||
currency: string;
|
||||
threshold_minor: number;
|
||||
reduction_minor: number;
|
||||
converted_threshold_minor: number;
|
||||
converted_reduction_minor: number;
|
||||
ends_at: string;
|
||||
}
|
||||
|
||||
/** Per-shop read-only threshold preview for the current cart. */
|
||||
export interface ShopThresholdPreview {
|
||||
shop_id: string;
|
||||
subtotal_minor: number;
|
||||
best: ThresholdRuleView | null;
|
||||
}
|
||||
|
||||
/** A customer-owned snapshot of the terms at claim time. */
|
||||
export interface Coupon {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user