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:
2026-09-18 12:03:00 +00:00
parent 39d0158f29
commit 23955434c6
20 changed files with 1363 additions and 46 deletions
+30 -3
View File
@@ -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 }),
+53 -2
View File
@@ -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;