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
+112 -6
View File
@@ -12,6 +12,9 @@ import type {
AuthTokens,
Cart,
CartItem,
Coupon,
CouponTemplate,
CouponTemplateInput,
HomeContent,
Invoice,
InvoiceKind,
@@ -26,6 +29,7 @@ import {
MOCK_BANNERS,
MOCK_BRANDS,
MOCK_CATEGORIES,
MOCK_COUPONS,
MOCK_CURRENCIES,
MOCK_PROMOS,
MOCK_QUICK_LINKS,
@@ -48,6 +52,8 @@ interface MockState {
shipments: Shipment[];
invoices: Invoice[];
addresses: AddressBookEntry[];
/** In-memory only: claims made during this browser session. */
coupons: Coupon[];
addressSeq: number;
orderSeq: number;
invoiceSeq: number;
@@ -77,9 +83,51 @@ function loadPersisted(): PersistedState | null {
}
}
/** Mock coupons are global fixtures, so any shop id renders them as its own. */
function mockTemplate(t: (typeof MOCK_COUPONS)[number], shopId: string): CouponTemplate {
return {
id: t.id,
shop_id: shopId,
title: t.title,
amount_minor: t.amountMinor,
threshold_minor: t.thresholdMinor,
currency: t.currency,
stock: 100,
enabled: true,
starts_at: "2026-01-01T00:00:00.000Z",
ends_at: `${t.expiresAt}T23:59:59.000Z`,
created_at: "2026-01-01T00:00:00.000Z",
};
}
function mockOwnedCoupon(t: (typeof MOCK_COUPONS)[number]): Coupon {
const template = mockTemplate(t, MOCK_STORES[0].id);
return {
id: `uc-${t.id}`,
user_id: MOCK_USER.id,
template_id: template.id,
shop_id: template.shop_id,
title: template.title,
amount_minor: template.amount_minor,
threshold_minor: template.threshold_minor,
currency: template.currency,
starts_at: template.starts_at,
ends_at: template.ends_at,
status: "claimed",
order_id: null,
claimed_at: "2026-01-01T00:00:00.000Z",
redeemed_at: null,
};
}
function seedCoupons(): Coupon[] {
return MOCK_COUPONS.map(mockOwnedCoupon);
}
function initialState(): MockState {
const persisted = loadPersisted();
if (persisted) return { token: null, ...persisted };
// Coupons are session-only, so a restored snapshot re-seeds them.
if (persisted) return { token: null, ...persisted, coupons: seedCoupons() };
const seed = seedOrders(MOCK_USER.id);
const seededAddresses: AddressBookEntry[] = MOCK_ADDRESSES.map((a, i) => ({
id: a.id,
@@ -101,6 +149,7 @@ function initialState(): MockState {
shipments: seed.shipments,
invoices: seed.invoices,
addresses: seededAddresses,
coupons: seedCoupons(),
addressSeq: 100,
orderSeq: 100,
invoiceSeq: 100,
@@ -261,7 +310,11 @@ export function createMockApi(): ApiClient {
return Promise.resolve(cartSnapshot());
},
checkout: (shippingAddress: Address, currency: string) => {
checkout: (
shippingAddress: Address,
currency: string,
couponByShop: Record<string, string> = {},
) => {
if (state.cart.length === 0) {
return Promise.reject(new ApiError(400, "EMPTY_CART", "Cart is empty"));
}
@@ -271,7 +324,23 @@ export function createMockApi(): ApiClient {
const shopId = product?.shop_id ?? "unknown";
(byShop[shopId] ??= []).push(item);
}
const created: Order[] = Object.entries(byShop).map(([shopId, items]) => {
const created: Order[] = [];
for (const [shopId, items] of Object.entries(byShop)) {
const subtotal = items.reduce((sum, i) => sum + i.unit_price_minor * i.qty, 0);
const couponId = couponByShop[shopId];
let discount = 0;
if (couponId) {
const coupon = state.coupons.find((c) => c.id === couponId);
if (!coupon || coupon.status !== "claimed" || coupon.shop_id !== shopId) {
return Promise.reject(new ApiError(409, "CONFLICT", "Coupon is not redeemable"));
}
if (subtotal < coupon.threshold_minor) {
return Promise.reject(
new ApiError(409, "CONFLICT", "Order does not reach the coupon threshold"),
);
}
discount = Math.min(coupon.amount_minor, subtotal);
}
const order: Order = {
id: `o-${Date.now()}-${shopId}`,
order_no: nextOrderNo(),
@@ -279,7 +348,9 @@ export function createMockApi(): ApiClient {
user_id: MOCK_USER.id,
status: "pending_payment",
currency,
total_minor: items.reduce((sum, i) => sum + i.unit_price_minor * i.qty, 0),
total_minor: subtotal - discount,
discount_minor: discount,
coupon_id: couponId ?? null,
items: items.map((i, k) => ({
id: `oi-${Date.now()}-${k}`,
sku_id: i.sku_id,
@@ -292,8 +363,16 @@ export function createMockApi(): ApiClient {
shipping_address: shippingAddress,
created_at: new Date().toISOString(),
};
return order;
});
if (couponId) {
const coupon = state.coupons.find((c) => c.id === couponId);
if (coupon) {
coupon.status = "redeemed";
coupon.order_id = order.id;
coupon.redeemed_at = order.created_at;
}
}
created.push(order);
}
state.orders = [...created, ...state.orders];
state.cart = [];
persist();
@@ -324,6 +403,12 @@ export function createMockApi(): ApiClient {
return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be cancelled"));
}
order.status = "cancelled";
const coupon = state.coupons.find((c) => c.order_id === order.id);
if (coupon) {
coupon.status = "claimed";
coupon.order_id = null;
coupon.redeemed_at = null;
}
persist();
return Promise.resolve(order);
},
@@ -485,6 +570,23 @@ export function createMockApi(): ApiClient {
return Promise.resolve({ ...entry });
},
listShopCouponTemplates: (shopId: string) =>
Promise.resolve(MOCK_COUPONS.map((t) => mockTemplate(t, shopId))),
listMyCoupons: () => Promise.resolve(state.coupons.map((c) => ({ ...c }))),
claimCoupon: (templateId: string) => {
const template = MOCK_COUPONS.find((t) => t.id === templateId);
if (!template) return Promise.reject(new ApiError(404, "NOT_FOUND", "Coupon not found"));
if (state.coupons.some((c) => c.template_id === templateId)) {
return Promise.reject(new ApiError(409, "CONFLICT", "Coupon already claimed"));
}
const coupon = mockOwnedCoupon(template);
state.coupons.push(coupon);
persist();
return Promise.resolve({ ...coupon });
},
shop: {
getMyShop: () => unsupported(),
listMyProducts: () => unsupported(),
@@ -501,6 +603,10 @@ export function createMockApi(): ApiClient {
markShipped: () => unsupported(),
listInvoices: () => unsupported(),
issueInvoice: () => unsupported(),
listCouponTemplates: () => unsupported(),
createCouponTemplate: (_body: CouponTemplateInput) => unsupported(),
updateCouponTemplate: (_id: string, _body: CouponTemplateInput) => unsupported(),
deleteCouponTemplate: (_id: string) => unsupported(),
},
admin: {
listUsers: () => unsupported(),