feat(api): shop coupons with per-shop checkout redemption
Templates belong to a shop; claiming copies their terms into a customer-owned snapshot so a later edit or disable cannot rewrite a held coupon. Claim stock is taken with a guarded decrement after locking the template, and a unique (user, template) index makes a duplicate claim a 409. Deleting a template leaves claimed snapshots standing via ON DELETE SET NULL. Checkout accepts at most one owned coupon per generated shop order, locks the selected coupons by primary key after the SKU locks, and resolves eligibility and the discount server-side (ownership, shop, status, window, converted threshold). The realized discount and coupon id land on the order, and a pending-payment cancellation restores the coupon in the same transaction as stock. The shared contract gains the coupon types, claim/list/manage methods, and the checkout coupon map; the fixed-data adapter implements the same surface. Surfaces (shop-admin management, mall coupon pages, checkout selection) and seeding still follow in tasks 3.1-4.2.
This commit is contained in:
@@ -9,6 +9,9 @@ import type {
|
||||
Category,
|
||||
ContentInputByKind,
|
||||
ContentKind,
|
||||
Coupon,
|
||||
CouponTemplate,
|
||||
CouponTemplateInput,
|
||||
Currency,
|
||||
HomeContent,
|
||||
Invoice,
|
||||
@@ -164,7 +167,12 @@ export interface ApiClient {
|
||||
addCartItem(skuId: string, qty: number): Promise<Cart>;
|
||||
updateCartItem(skuId: string, qty: number): Promise<Cart>;
|
||||
removeCartItem(skuId: string): Promise<Cart>;
|
||||
checkout(shippingAddress: Address, currency: string): Promise<Order[]>;
|
||||
checkout(
|
||||
shippingAddress: Address,
|
||||
currency: string,
|
||||
/** At most one owned coupon per generated shop order. */
|
||||
couponByShop?: Record<string, string>,
|
||||
): Promise<Order[]>;
|
||||
listMyOrders(page?: number): Promise<Paged<Order>>;
|
||||
getOrder(id: string): Promise<Order>;
|
||||
cancelOrder(id: string): Promise<Order>;
|
||||
@@ -188,6 +196,10 @@ export interface ApiClient {
|
||||
updateAddress(id: string, address: AddressInput): Promise<AddressBookEntry>;
|
||||
deleteAddress(id: string): Promise<AddressBookEntry[]>;
|
||||
setDefaultAddress(id: string): Promise<AddressBookEntry>;
|
||||
/** Public: coupons a shopper could claim from this shop right now. */
|
||||
listShopCouponTemplates(shopId: string): Promise<CouponTemplate[]>;
|
||||
listMyCoupons(): Promise<Coupon[]>;
|
||||
claimCoupon(templateId: string): Promise<Coupon>;
|
||||
shop: {
|
||||
getMyShop(): Promise<Shop>;
|
||||
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
|
||||
@@ -209,6 +221,10 @@ export interface ApiClient {
|
||||
markShipped(id: string): Promise<Shipment>;
|
||||
listInvoices(): Promise<Invoice[]>;
|
||||
issueInvoice(id: string): Promise<Invoice>;
|
||||
listCouponTemplates(): Promise<CouponTemplate[]>;
|
||||
createCouponTemplate(body: CouponTemplateInput): Promise<CouponTemplate>;
|
||||
updateCouponTemplate(id: string, body: CouponTemplateInput): Promise<CouponTemplate>;
|
||||
deleteCouponTemplate(id: string): Promise<void>;
|
||||
};
|
||||
admin: {
|
||||
listUsers(page?: number): Promise<Paged<User>>;
|
||||
@@ -250,8 +266,12 @@ 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) =>
|
||||
r("POST", "/orders/checkout", { shipping_address: shippingAddress, currency }),
|
||||
checkout: (shippingAddress, currency, couponByShop = {}) =>
|
||||
r("POST", "/orders/checkout", {
|
||||
shipping_address: shippingAddress,
|
||||
currency,
|
||||
coupon_by_shop: couponByShop,
|
||||
}),
|
||||
listMyOrders: (page = 1) => r("GET", "/orders", undefined, { page }),
|
||||
getOrder: (id) => r("GET", `/orders/${id}`),
|
||||
cancelOrder: (id) => r("POST", `/orders/${id}/cancel`),
|
||||
@@ -269,6 +289,9 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
updateAddress: (id, address) => r("PUT", `/addresses/${id}`, address),
|
||||
deleteAddress: (id) => r("DELETE", `/addresses/${id}`),
|
||||
setDefaultAddress: (id) => r("POST", `/addresses/${id}/default`),
|
||||
listShopCouponTemplates: (shopId) => r("GET", `/shops/${shopId}/coupon-templates`),
|
||||
listMyCoupons: () => r("GET", "/me/coupons"),
|
||||
claimCoupon: (templateId) => r("POST", "/me/coupons", { template_id: templateId }),
|
||||
shop: {
|
||||
getMyShop: () => r("GET", "/shop/profile"),
|
||||
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
|
||||
@@ -290,6 +313,10 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
markShipped: (id) => r("POST", `/shop/shipments/${id}/ship`),
|
||||
listInvoices: () => r("GET", "/shop/invoices"),
|
||||
issueInvoice: (id) => r("POST", `/shop/invoices/${id}/issue`),
|
||||
listCouponTemplates: () => r("GET", "/shop/coupon-templates"),
|
||||
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}`),
|
||||
},
|
||||
admin: {
|
||||
listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }),
|
||||
|
||||
@@ -155,6 +155,10 @@ export interface Order {
|
||||
status: OrderStatus;
|
||||
currency: string;
|
||||
total_minor: number;
|
||||
/** Server-calculated coupon discount, already applied to `total_minor`. */
|
||||
discount_minor: number;
|
||||
/** The coupon this order redeemed, or null. */
|
||||
coupon_id: string | null;
|
||||
items: OrderItem[];
|
||||
shipping_address: Address;
|
||||
created_at: string;
|
||||
@@ -195,9 +199,56 @@ export interface AddressBookEntry extends Address {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type ShipmentStatus = "pending" | "shipped" | "delivered";
|
||||
export type CouponStatus = "claimed" | "redeemed" | "expired";
|
||||
|
||||
export interface Shipment {
|
||||
/** Shop-issued coupon definition. Editing one never rewrites existing claims. */
|
||||
export interface CouponTemplate {
|
||||
id: string;
|
||||
shop_id: string;
|
||||
title: LocalizedText;
|
||||
amount_minor: number;
|
||||
threshold_minor: number;
|
||||
currency: string;
|
||||
/** Remaining claim stock. */
|
||||
stock: number;
|
||||
enabled: boolean;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Create/update body for a shop coupon template. */
|
||||
export interface CouponTemplateInput {
|
||||
title: LocalizedText;
|
||||
amount_minor: number;
|
||||
threshold_minor: number;
|
||||
currency: string;
|
||||
stock: number;
|
||||
enabled?: boolean;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
}
|
||||
|
||||
/** A customer-owned snapshot of the terms at claim time. */
|
||||
export interface Coupon {
|
||||
id: string;
|
||||
user_id: string;
|
||||
/** Null once the issuing template is deleted; the snapshot still stands. */
|
||||
template_id: string | null;
|
||||
shop_id: string;
|
||||
title: LocalizedText;
|
||||
amount_minor: number;
|
||||
threshold_minor: number;
|
||||
currency: string;
|
||||
starts_at: string;
|
||||
ends_at: string;
|
||||
status: CouponStatus;
|
||||
order_id: string | null;
|
||||
claimed_at: string;
|
||||
redeemed_at: string | null;
|
||||
}
|
||||
|
||||
export type ShipmentStatus = "pending" | "shipped" | "delivered";export interface Shipment {
|
||||
id: string;
|
||||
shipment_no: string;
|
||||
order_id: string;
|
||||
|
||||
Reference in New Issue
Block a user