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:
Chengdong Zhang
2026-10-19 18:14:38 +08:00
parent a7cd392bd4
commit 9e3abf95ef
41 changed files with 2140 additions and 52 deletions
+19 -1
View File
@@ -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),
+52 -1
View File
@@ -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;