refactor(wallet): remove local balance logic and orphaned domain modules
Delete store/wallet.ts which is no longer referenced anywhere. Remove lib/domain/income.ts, order-machine.ts, resolve-current-shop.ts and their test files — all depended on removed local state machines. Wallet page already uses lib/api/transactions.ts for backend API calls.
This commit is contained in:
@@ -1,35 +0,0 @@
|
|||||||
import type { Shop } from "@/lib/types"
|
|
||||||
|
|
||||||
type ShopCommission = Pick<Shop, "commissionType" | "commissionValue">
|
|
||||||
|
|
||||||
export interface IncomeCalculation {
|
|
||||||
income: number
|
|
||||||
commissionLabel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundCurrency(amount: number) {
|
|
||||||
return Number(amount.toFixed(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateOrderIncome(totalPrice: number, shop?: ShopCommission): IncomeCalculation {
|
|
||||||
if (!shop) {
|
|
||||||
return {
|
|
||||||
income: roundCurrency(totalPrice),
|
|
||||||
commissionLabel: "独立接单无抽成",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commission = Number(shop.commissionValue)
|
|
||||||
|
|
||||||
if (shop.commissionType === "percentage") {
|
|
||||||
return {
|
|
||||||
income: roundCurrency(totalPrice * (1 - commission / 100)),
|
|
||||||
commissionLabel: `扣除${shop.commissionValue}%抽成`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
income: roundCurrency(Math.max(0, totalPrice - commission)),
|
|
||||||
commissionLabel: `扣除¥${shop.commissionValue}固定抽成`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import type { Actor } from "@/lib/actor"
|
|
||||||
import { allow, deny, requireAuth } from "@/lib/decision"
|
|
||||||
import type { ApiDecision } from "@/lib/errors"
|
|
||||||
import type { OrderStatus } from "@/lib/types"
|
|
||||||
|
|
||||||
export type OrderAction =
|
|
||||||
| "PAY"
|
|
||||||
| "ACCEPT"
|
|
||||||
| "REQUEST_CLOSE"
|
|
||||||
| "CONFIRM_CLOSE"
|
|
||||||
| "CANCEL_PRE_ACCEPT"
|
|
||||||
| "OPEN_DISPUTE"
|
|
||||||
| "RESOLVE_DISPUTE"
|
|
||||||
| "SUBMIT_REVIEW"
|
|
||||||
| "AUTO_TIMEOUT_PENDING_ACCEPT"
|
|
||||||
| "AUTO_TIMEOUT_PENDING_CLOSE"
|
|
||||||
| "AUTO_TIMEOUT_PENDING_REVIEW"
|
|
||||||
|
|
||||||
export type OrderTransitionSideEffectType =
|
|
||||||
| "CLEAR_TIMEOUT"
|
|
||||||
| "SCHEDULE_TIMEOUT"
|
|
||||||
| "SYNC_CHAT_SESSION"
|
|
||||||
| "PAYOUT_INCOME"
|
|
||||||
|
|
||||||
export interface OrderTransitionSideEffect {
|
|
||||||
type: OrderTransitionSideEffectType
|
|
||||||
status?: OrderStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TransitionContext {
|
|
||||||
actor?: Actor | null
|
|
||||||
order: {
|
|
||||||
status: OrderStatus
|
|
||||||
}
|
|
||||||
action: OrderAction
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrderTransitionResult {
|
|
||||||
decision: ApiDecision
|
|
||||||
nextStatus?: OrderStatus
|
|
||||||
sideEffects: OrderTransitionSideEffect[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const orderTransitionTable: Record<
|
|
||||||
OrderStatus,
|
|
||||||
Partial<Record<OrderAction, OrderStatus>>
|
|
||||||
> = {
|
|
||||||
pending_payment: {
|
|
||||||
PAY: "pending_accept",
|
|
||||||
},
|
|
||||||
pending_accept: {
|
|
||||||
ACCEPT: "in_progress",
|
|
||||||
CANCEL_PRE_ACCEPT: "cancelled",
|
|
||||||
AUTO_TIMEOUT_PENDING_ACCEPT: "cancelled",
|
|
||||||
},
|
|
||||||
in_progress: {
|
|
||||||
REQUEST_CLOSE: "pending_close",
|
|
||||||
OPEN_DISPUTE: "disputed",
|
|
||||||
},
|
|
||||||
pending_close: {
|
|
||||||
CONFIRM_CLOSE: "pending_review",
|
|
||||||
OPEN_DISPUTE: "disputed",
|
|
||||||
AUTO_TIMEOUT_PENDING_CLOSE: "pending_review",
|
|
||||||
},
|
|
||||||
pending_review: {
|
|
||||||
SUBMIT_REVIEW: "completed",
|
|
||||||
AUTO_TIMEOUT_PENDING_REVIEW: "completed",
|
|
||||||
},
|
|
||||||
disputed: {
|
|
||||||
RESOLVE_DISPUTE: "pending_review",
|
|
||||||
},
|
|
||||||
completed: {},
|
|
||||||
cancelled: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAutoAction(action: OrderAction): boolean {
|
|
||||||
return action.startsWith("AUTO_TIMEOUT_")
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkRolePermission(action: OrderAction, actor?: Actor | null): ApiDecision {
|
|
||||||
if (isAutoAction(action) || (action === "RESOLVE_DISPUTE" && !actor?.userId)) {
|
|
||||||
return allow()
|
|
||||||
}
|
|
||||||
|
|
||||||
const authDecision = requireAuth(actor)
|
|
||||||
if (!authDecision.ok) {
|
|
||||||
return authDecision
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!actor) {
|
|
||||||
return deny(401, "请先登录")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "PAY" || action === "CANCEL_PRE_ACCEPT") {
|
|
||||||
return actor.role === "consumer" ? allow() : deny(403, "仅客户可执行该操作")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "ACCEPT") {
|
|
||||||
return actor.role === "player" || actor.role === "owner"
|
|
||||||
? allow()
|
|
||||||
: deny(403, "仅打手或店主可执行该操作")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === "RESOLVE_DISPUTE") {
|
|
||||||
return actor.role === "owner" ? allow() : deny(403, "仅店主可执行该操作")
|
|
||||||
}
|
|
||||||
|
|
||||||
return actor.role === "consumer" || actor.role === "player"
|
|
||||||
? allow()
|
|
||||||
: deny(403, "当前身份不可执行该操作")
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSideEffects(nextStatus: OrderStatus): OrderTransitionSideEffect[] {
|
|
||||||
const sideEffects: OrderTransitionSideEffect[] = [{ type: "CLEAR_TIMEOUT" }]
|
|
||||||
|
|
||||||
if (
|
|
||||||
nextStatus === "pending_accept" ||
|
|
||||||
nextStatus === "pending_close" ||
|
|
||||||
nextStatus === "pending_review"
|
|
||||||
) {
|
|
||||||
sideEffects.push({ type: "SCHEDULE_TIMEOUT", status: nextStatus })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextStatus !== "pending_payment" && nextStatus !== "cancelled") {
|
|
||||||
sideEffects.push({ type: "SYNC_CHAT_SESSION" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextStatus === "completed") {
|
|
||||||
sideEffects.push({ type: "PAYOUT_INCOME" })
|
|
||||||
}
|
|
||||||
|
|
||||||
return sideEffects
|
|
||||||
}
|
|
||||||
|
|
||||||
export function evaluateOrderTransition(context: TransitionContext): OrderTransitionResult {
|
|
||||||
const roleDecision = checkRolePermission(context.action, context.actor)
|
|
||||||
if (!roleDecision.ok) {
|
|
||||||
return {
|
|
||||||
decision: roleDecision,
|
|
||||||
sideEffects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextStatus = orderTransitionTable[context.order.status][context.action]
|
|
||||||
if (!nextStatus) {
|
|
||||||
return {
|
|
||||||
decision: deny(400, "当前状态不可执行该操作"),
|
|
||||||
sideEffects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextStatus === context.order.status) {
|
|
||||||
return {
|
|
||||||
decision: deny(400, "状态未变化"),
|
|
||||||
sideEffects: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
decision: allow(),
|
|
||||||
nextStatus,
|
|
||||||
sideEffects: buildSideEffects(nextStatus),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { Shop } from "@/lib/types"
|
|
||||||
|
|
||||||
export function resolveOwnerShop(userId: string | undefined, shops: Shop[]): Shop | null {
|
|
||||||
if (!userId) return null
|
|
||||||
return shops.find((shop) => shop.owner.id === userId) ?? null
|
|
||||||
}
|
|
||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
import { calculateOrderIncome } from "@/lib/domain/income"
|
|
||||||
import { generateId } from "@/lib/id"
|
|
||||||
import type { WalletTransaction } from "@/lib/types"
|
|
||||||
import { useShopStore } from "@/store/shops"
|
|
||||||
import { create } from "zustand"
|
|
||||||
|
|
||||||
interface WalletState {
|
|
||||||
balance: number
|
|
||||||
transactions: WalletTransaction[]
|
|
||||||
topUp: (amount: number) => void
|
|
||||||
withdraw: (amount: number) => void
|
|
||||||
deductBalance: (orderId: string, amount: number) => boolean
|
|
||||||
refundPayment: (orderId: string, amount: number) => boolean
|
|
||||||
addIncome: (orderId: string, totalPrice: number, shopId?: string) => void
|
|
||||||
addTransaction: (transaction: WalletTransaction) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useWalletStore = create<WalletState>((set, get) => ({
|
|
||||||
balance: 0,
|
|
||||||
transactions: [],
|
|
||||||
topUp: (amount) => {
|
|
||||||
if (!Number.isFinite(amount) || amount <= 0) return
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
set((state) => ({
|
|
||||||
balance: state.balance + amount,
|
|
||||||
transactions: [
|
|
||||||
{
|
|
||||||
id: generateId("tx"),
|
|
||||||
type: "topup",
|
|
||||||
amount: String(amount),
|
|
||||||
description: "充值",
|
|
||||||
createdAt: now,
|
|
||||||
},
|
|
||||||
...state.transactions,
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
withdraw: (amount) => {
|
|
||||||
if (!Number.isFinite(amount) || amount <= 0) return
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
set((state) => ({
|
|
||||||
transactions: [
|
|
||||||
{
|
|
||||||
id: generateId("tx"),
|
|
||||||
type: "withdrawal",
|
|
||||||
amount: String(-amount),
|
|
||||||
description: "提现到银行卡",
|
|
||||||
createdAt: now,
|
|
||||||
},
|
|
||||||
...state.transactions,
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
deductBalance: (orderId, amount) => {
|
|
||||||
if (!Number.isFinite(amount) || amount <= 0) return false
|
|
||||||
|
|
||||||
const state = get()
|
|
||||||
const paid = state.transactions.some(
|
|
||||||
(transaction) => transaction.type === "payment" && transaction.description.includes(orderId),
|
|
||||||
)
|
|
||||||
if (paid || state.balance < amount) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
set((prev) => ({
|
|
||||||
balance: prev.balance - amount,
|
|
||||||
transactions: [
|
|
||||||
{
|
|
||||||
id: generateId("tx"),
|
|
||||||
type: "payment",
|
|
||||||
amount: String(-amount),
|
|
||||||
description: `支付订单 ${orderId}`,
|
|
||||||
createdAt: now,
|
|
||||||
},
|
|
||||||
...prev.transactions,
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
refundPayment: (orderId, amount) => {
|
|
||||||
if (!Number.isFinite(amount) || amount <= 0) return false
|
|
||||||
|
|
||||||
const state = get()
|
|
||||||
const paid = state.transactions.some(
|
|
||||||
(transaction) => transaction.type === "payment" && transaction.description.includes(orderId),
|
|
||||||
)
|
|
||||||
const refunded = state.transactions.some(
|
|
||||||
(transaction) => transaction.type === "refund" && transaction.description.includes(orderId),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!paid || refunded) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
set((prev) => ({
|
|
||||||
balance: prev.balance + amount,
|
|
||||||
transactions: [
|
|
||||||
{
|
|
||||||
id: generateId("tx"),
|
|
||||||
type: "refund",
|
|
||||||
amount: String(amount),
|
|
||||||
description: `订单 ${orderId} 退款`,
|
|
||||||
createdAt: now,
|
|
||||||
},
|
|
||||||
...prev.transactions,
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
addIncome: (orderId, totalPrice, shopId) => {
|
|
||||||
if (!Number.isFinite(totalPrice) || totalPrice <= 0) return
|
|
||||||
|
|
||||||
const state = get()
|
|
||||||
const exists = state.transactions.some(
|
|
||||||
(transaction) => transaction.type === "income" && transaction.description.includes(orderId),
|
|
||||||
)
|
|
||||||
if (exists) return
|
|
||||||
|
|
||||||
const shop = shopId
|
|
||||||
? useShopStore.getState().shops.find((item) => item.id === shopId)
|
|
||||||
: undefined
|
|
||||||
const { income, commissionLabel } = calculateOrderIncome(totalPrice, shop)
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
set((prev) => ({
|
|
||||||
transactions: [
|
|
||||||
{
|
|
||||||
id: generateId("tx"),
|
|
||||||
type: "income",
|
|
||||||
amount: String(income),
|
|
||||||
description: `订单 ${orderId} 收入(${commissionLabel})`,
|
|
||||||
createdAt: now,
|
|
||||||
},
|
|
||||||
...prev.transactions,
|
|
||||||
],
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
addTransaction: (transaction) => {
|
|
||||||
set((state) => ({
|
|
||||||
transactions: [transaction, ...state.transactions],
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { calculateOrderIncome } from "@/lib/domain/income"
|
|
||||||
import { describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
describe("calculateOrderIncome", () => {
|
|
||||||
it("calculates percentage commission income", () => {
|
|
||||||
const result = calculateOrderIncome(100, {
|
|
||||||
commissionType: "percentage",
|
|
||||||
commissionValue: "12",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
income: 88,
|
|
||||||
commissionLabel: "扣除12%抽成",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("calculates fixed commission income", () => {
|
|
||||||
const result = calculateOrderIncome(60, {
|
|
||||||
commissionType: "fixed",
|
|
||||||
commissionValue: "8",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
income: 52,
|
|
||||||
commissionLabel: "扣除¥8固定抽成",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps full income for independent orders", () => {
|
|
||||||
const result = calculateOrderIncome(90)
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
income: 90,
|
|
||||||
commissionLabel: "独立接单无抽成",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not return negative income for fixed commission", () => {
|
|
||||||
const result = calculateOrderIncome(6, {
|
|
||||||
commissionType: "fixed",
|
|
||||||
commissionValue: "8",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.income).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import type { Actor } from "@/lib/actor"
|
|
||||||
import { evaluateOrderTransition } from "@/lib/domain/order-machine"
|
|
||||||
import type { UserRole } from "@/lib/types"
|
|
||||||
import { describe, expect, it } from "vitest"
|
|
||||||
|
|
||||||
function actor(role: UserRole, userId = "u1"): Actor {
|
|
||||||
return { role, userId }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("evaluateOrderTransition", () => {
|
|
||||||
it("allows valid transition for pay", () => {
|
|
||||||
const result = evaluateOrderTransition({
|
|
||||||
actor: actor("consumer"),
|
|
||||||
order: { status: "pending_payment" },
|
|
||||||
action: "PAY",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.decision.ok).toBe(true)
|
|
||||||
expect(result.nextStatus).toBe("pending_accept")
|
|
||||||
expect(result.sideEffects).toContainEqual({
|
|
||||||
type: "SCHEDULE_TIMEOUT",
|
|
||||||
status: "pending_accept",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("denies invalid status transition", () => {
|
|
||||||
const result = evaluateOrderTransition({
|
|
||||||
actor: actor("consumer"),
|
|
||||||
order: { status: "completed" },
|
|
||||||
action: "PAY",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.decision).toMatchObject({
|
|
||||||
ok: false,
|
|
||||||
error: { code: 400 },
|
|
||||||
})
|
|
||||||
expect(result.nextStatus).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("denies role forbidden actions", () => {
|
|
||||||
const result = evaluateOrderTransition({
|
|
||||||
actor: actor("consumer"),
|
|
||||||
order: { status: "pending_accept" },
|
|
||||||
action: "ACCEPT",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.decision).toMatchObject({
|
|
||||||
ok: false,
|
|
||||||
error: { code: 403 },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows accept for player", () => {
|
|
||||||
const result = evaluateOrderTransition({
|
|
||||||
actor: actor("player"),
|
|
||||||
order: { status: "pending_accept" },
|
|
||||||
action: "ACCEPT",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.decision.ok).toBe(true)
|
|
||||||
expect(result.nextStatus).toBe("in_progress")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows close confirmation to pending_review", () => {
|
|
||||||
const result = evaluateOrderTransition({
|
|
||||||
actor: actor("consumer"),
|
|
||||||
order: { status: "pending_close" },
|
|
||||||
action: "CONFIRM_CLOSE",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.decision.ok).toBe(true)
|
|
||||||
expect(result.nextStatus).toBe("pending_review")
|
|
||||||
expect(result.sideEffects).toContainEqual({
|
|
||||||
type: "SCHEDULE_TIMEOUT",
|
|
||||||
status: "pending_review",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("allows auto timeout actions without actor", () => {
|
|
||||||
const result = evaluateOrderTransition({
|
|
||||||
order: { status: "pending_close" },
|
|
||||||
action: "AUTO_TIMEOUT_PENDING_CLOSE",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.decision.ok).toBe(true)
|
|
||||||
expect(result.nextStatus).toBe("pending_review")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("adds payout side effect when entering completed", () => {
|
|
||||||
const result = evaluateOrderTransition({
|
|
||||||
order: { status: "pending_review" },
|
|
||||||
action: "AUTO_TIMEOUT_PENDING_REVIEW",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.decision.ok).toBe(true)
|
|
||||||
expect(result.nextStatus).toBe("completed")
|
|
||||||
expect(result.sideEffects).toContainEqual({ type: "PAYOUT_INCOME" })
|
|
||||||
})
|
|
||||||
|
|
||||||
it("supports resolving dispute by owner", () => {
|
|
||||||
const result = evaluateOrderTransition({
|
|
||||||
actor: actor("owner"),
|
|
||||||
order: { status: "disputed" },
|
|
||||||
action: "RESOLVE_DISPUTE",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.decision.ok).toBe(true)
|
|
||||||
expect(result.nextStatus).toBe("pending_review")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects dispute resolution by non-owner", () => {
|
|
||||||
const result = evaluateOrderTransition({
|
|
||||||
actor: actor("player"),
|
|
||||||
order: { status: "disputed" },
|
|
||||||
action: "RESOLVE_DISPUTE",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(result.decision).toMatchObject({
|
|
||||||
ok: false,
|
|
||||||
error: { code: 403 },
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user