From 9904696e76a897fb7a39b0a94a198320c93973ec Mon Sep 17 00:00:00 2001 From: Zhang Chengdong Date: Fri, 25 Sep 2026 15:25:29 +0000 Subject: [PATCH] feat: wave 2 migration (P3, P5, P7 openspec changes) Implements, verifies, and archives the three remaining Wave 2 changes from openspec/MIGRATION-PLAN.md. - add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and one-time admin review, paginated own fund entries, idempotent per-shop weekly/monthly settlement statements with commission rate and one-time payout confirmation. - add-merchant-onboarding (P5): personal/enterprise applications with one live application per user, guarded review with mandatory rejection reason, and transactional shop + owner provisioning returning one-time credentials; mall onboarding/status pages and an admin review console. - add-membership-messaging (P7): platform member levels, append-only growth accrual on order completion with guarded one-way leveling, order/shipment/ refund system messages with unread/read state and soft deletion, plus the mall header unread badge. Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding, membership and messaging modules, event hooks in order/fulfillment/aftersale, and integration suites for each. Shared contract extended and all three frontends updated; code indexes, domain docs, backend guidelines and the migration tracker synced. Verification: cargo test -p vmall-api green twice consecutively; mall, admin and shop-admin builds pass; browser smoke on every new surface; openspec validate --all --strict green (33 passed). The three changes share the @vmall/shared contract, the mall mock adapter and per-app locale/nav files, so they are committed together to keep every commit buildable. --- README.md | 9 +- apps/admin/app.vue | 4 + apps/admin/components/MemberLevelForm.vue | 150 +++ apps/admin/locales-extra.ts | 343 +++++++ apps/admin/pages/member-levels.vue | 421 ++++++++ apps/admin/pages/merchant-applications.vue | 608 +++++++++++ apps/admin/pages/settlements.vue | 718 +++++++++++++ apps/admin/pages/withdrawals.vue | 321 ++++++ apps/api/migrations/0019_wallet.sql | 45 + apps/api/migrations/0020_settlement.sql | 62 ++ .../migrations/0021_merchant_applications.sql | 69 ++ .../0022_storefront_seller_link.sql | 6 + .../migrations/0023_membership_messaging.sql | 73 ++ apps/api/src/models.rs | 76 ++ apps/api/src/modules/account/mod.rs | 2 +- apps/api/src/modules/aftersale/service.rs | 17 +- apps/api/src/modules/fulfillment/service.rs | 35 +- apps/api/src/modules/identity/mod.rs | 2 +- apps/api/src/modules/identity/repo.rs | 19 +- apps/api/src/modules/membership/dto.rs | 59 ++ apps/api/src/modules/membership/handlers.rs | 90 ++ apps/api/src/modules/membership/mod.rs | 6 + apps/api/src/modules/membership/repo.rs | 236 +++++ apps/api/src/modules/membership/service.rs | 189 ++++ .../src/modules/merchant_onboarding/dto.rs | 209 ++++ .../modules/merchant_onboarding/handlers.rs | 111 ++ .../src/modules/merchant_onboarding/mod.rs | 6 + .../src/modules/merchant_onboarding/repo.rs | 194 ++++ .../modules/merchant_onboarding/service.rs | 368 +++++++ apps/api/src/modules/messaging/dto.rs | 50 + apps/api/src/modules/messaging/handlers.rs | 61 ++ apps/api/src/modules/messaging/mod.rs | 6 + apps/api/src/modules/messaging/repo.rs | 130 +++ apps/api/src/modules/messaging/service.rs | 123 +++ apps/api/src/modules/mod.rs | 10 + apps/api/src/modules/order/repo.rs | 50 +- apps/api/src/modules/order/service.rs | 14 +- apps/api/src/modules/settlement/dto.rs | 85 ++ apps/api/src/modules/settlement/handlers.rs | 172 ++++ apps/api/src/modules/settlement/mod.rs | 6 + apps/api/src/modules/settlement/repo.rs | 240 +++++ apps/api/src/modules/settlement/service.rs | 329 ++++++ apps/api/src/modules/shop/service.rs | 11 +- apps/api/src/modules/wallet/dto.rs | 117 +++ apps/api/src/modules/wallet/handlers.rs | 110 ++ apps/api/src/modules/wallet/mod.rs | 6 + apps/api/src/modules/wallet/repo.rs | 157 +++ apps/api/src/modules/wallet/service.rs | 221 ++++ apps/api/tests/membership.rs | 574 +++++++++++ apps/api/tests/merchant_applications.rs | 515 ++++++++++ apps/api/tests/messaging.rs | 413 ++++++++ apps/api/tests/settlement.rs | 556 ++++++++++ apps/api/tests/wallet.rs | 348 +++++++ apps/mall/components/shell/SiteFooter.vue | 5 + apps/mall/components/shell/SiteHeader.vue | 37 + apps/mall/components/shell/TopBar.vue | 2 +- apps/mall/composables/useUnreadMessages.ts | 30 + apps/mall/locales-extra.ts | 21 +- apps/mall/locales/membership.ts | 55 + apps/mall/locales/merchant.ts | 158 +++ apps/mall/locales/messaging.ts | 57 ++ apps/mall/locales/shell.ts | 2 + apps/mall/locales/wallet.ts | 134 +++ apps/mall/mock/api.ts | 963 +++++++++++++++++- apps/mall/mock/data.ts | 2 +- apps/mall/nuxt.config.ts | 4 + apps/mall/pages/login.vue | 10 +- apps/mall/pages/merchant/join.vue | 672 ++++++++++++ apps/mall/pages/merchant/status.vue | 205 ++++ apps/mall/pages/register.vue | 15 +- apps/mall/pages/user.vue | 3 + apps/mall/pages/user/index.vue | 17 + apps/mall/pages/user/membership.vue | 212 ++++ apps/mall/pages/user/messages.vue | 254 +++++ apps/mall/pages/user/wallet.vue | 370 +++++++ apps/mall/plugins/api.ts | 32 +- apps/shop-admin/app.vue | 12 + apps/shop-admin/locales-extra.ts | 163 +++ apps/shop-admin/pages/settlements.vue | 411 ++++++++ apps/shop-admin/pages/shop-account.vue | 329 ++++++ docs/TBD-marketing.md | 5 + docs/backend-guidelines.md | 39 + docs/code_index/consoles.md | 6 + docs/code_index/index.md | 2 +- docs/code_index/mall.md | 12 +- docs/code_index/platform.md | 18 +- docs/domains/platform.md | 75 +- openspec/MIGRATION-PLAN.md | 30 +- .../.openspec.yaml | 0 .../proposal.md | 0 .../specs/frontend-admin/spec.md | 0 .../specs/frontend-mall/spec.md | 0 .../specs/merchant-onboarding/spec.md | 0 .../tasks.md | 38 +- .../proposal.md | 0 .../specs/frontend-admin/spec.md | 0 .../specs/frontend-mall/spec.md | 0 .../specs/frontend-shop-admin/spec.md | 0 .../specs/settlement/spec.md | 0 .../specs/wallet/spec.md | 0 .../tasks.md | 48 +- .../proposal.md | 0 .../specs/frontend-admin/spec.md | 0 .../specs/frontend-mall/spec.md | 0 .../specs/membership/spec.md | 0 .../specs/messaging/spec.md | 0 .../tasks.md | 40 +- openspec/specs/aftersale/spec.md | 5 +- openspec/specs/frontend-admin/spec.md | 65 ++ openspec/specs/frontend-mall/spec.md | 95 ++ openspec/specs/frontend-shop-admin/spec.md | 23 + openspec/specs/membership/spec.md | 73 ++ openspec/specs/merchant-onboarding/spec.md | 80 ++ openspec/specs/messaging/spec.md | 62 ++ openspec/specs/reviews/spec.md | 3 +- openspec/specs/settlement/spec.md | 67 ++ openspec/specs/shipping/spec.md | 4 +- openspec/specs/wallet/spec.md | 60 ++ packages/shared/src/api.ts | 138 +++ packages/shared/src/types.ts | 307 ++++++ 120 files changed, 14097 insertions(+), 125 deletions(-) create mode 100644 apps/admin/components/MemberLevelForm.vue create mode 100644 apps/admin/pages/member-levels.vue create mode 100644 apps/admin/pages/merchant-applications.vue create mode 100644 apps/admin/pages/settlements.vue create mode 100644 apps/admin/pages/withdrawals.vue create mode 100644 apps/api/migrations/0019_wallet.sql create mode 100644 apps/api/migrations/0020_settlement.sql create mode 100644 apps/api/migrations/0021_merchant_applications.sql create mode 100644 apps/api/migrations/0022_storefront_seller_link.sql create mode 100644 apps/api/migrations/0023_membership_messaging.sql create mode 100644 apps/api/src/modules/membership/dto.rs create mode 100644 apps/api/src/modules/membership/handlers.rs create mode 100644 apps/api/src/modules/membership/mod.rs create mode 100644 apps/api/src/modules/membership/repo.rs create mode 100644 apps/api/src/modules/membership/service.rs create mode 100644 apps/api/src/modules/merchant_onboarding/dto.rs create mode 100644 apps/api/src/modules/merchant_onboarding/handlers.rs create mode 100644 apps/api/src/modules/merchant_onboarding/mod.rs create mode 100644 apps/api/src/modules/merchant_onboarding/repo.rs create mode 100644 apps/api/src/modules/merchant_onboarding/service.rs create mode 100644 apps/api/src/modules/messaging/dto.rs create mode 100644 apps/api/src/modules/messaging/handlers.rs create mode 100644 apps/api/src/modules/messaging/mod.rs create mode 100644 apps/api/src/modules/messaging/repo.rs create mode 100644 apps/api/src/modules/messaging/service.rs create mode 100644 apps/api/src/modules/settlement/dto.rs create mode 100644 apps/api/src/modules/settlement/handlers.rs create mode 100644 apps/api/src/modules/settlement/mod.rs create mode 100644 apps/api/src/modules/settlement/repo.rs create mode 100644 apps/api/src/modules/settlement/service.rs create mode 100644 apps/api/src/modules/wallet/dto.rs create mode 100644 apps/api/src/modules/wallet/handlers.rs create mode 100644 apps/api/src/modules/wallet/mod.rs create mode 100644 apps/api/src/modules/wallet/repo.rs create mode 100644 apps/api/src/modules/wallet/service.rs create mode 100644 apps/api/tests/membership.rs create mode 100644 apps/api/tests/merchant_applications.rs create mode 100644 apps/api/tests/messaging.rs create mode 100644 apps/api/tests/settlement.rs create mode 100644 apps/api/tests/wallet.rs create mode 100644 apps/mall/composables/useUnreadMessages.ts create mode 100644 apps/mall/locales/membership.ts create mode 100644 apps/mall/locales/merchant.ts create mode 100644 apps/mall/locales/messaging.ts create mode 100644 apps/mall/locales/wallet.ts create mode 100644 apps/mall/pages/merchant/join.vue create mode 100644 apps/mall/pages/merchant/status.vue create mode 100644 apps/mall/pages/user/membership.vue create mode 100644 apps/mall/pages/user/messages.vue create mode 100644 apps/mall/pages/user/wallet.vue create mode 100644 apps/shop-admin/pages/settlements.vue create mode 100644 apps/shop-admin/pages/shop-account.vue rename openspec/changes/{add-merchant-onboarding => archive/2026-09-24-add-merchant-onboarding}/.openspec.yaml (100%) rename openspec/changes/{add-merchant-onboarding => archive/2026-09-24-add-merchant-onboarding}/proposal.md (100%) rename openspec/changes/{add-merchant-onboarding => archive/2026-09-24-add-merchant-onboarding}/specs/frontend-admin/spec.md (100%) rename openspec/changes/{add-merchant-onboarding => archive/2026-09-24-add-merchant-onboarding}/specs/frontend-mall/spec.md (100%) rename openspec/changes/{add-merchant-onboarding => archive/2026-09-24-add-merchant-onboarding}/specs/merchant-onboarding/spec.md (100%) rename openspec/changes/{add-merchant-onboarding => archive/2026-09-24-add-merchant-onboarding}/tasks.md (68%) rename openspec/changes/{add-wallet-settlement => archive/2026-09-24-add-wallet-settlement}/proposal.md (100%) rename openspec/changes/{add-wallet-settlement => archive/2026-09-24-add-wallet-settlement}/specs/frontend-admin/spec.md (100%) rename openspec/changes/{add-wallet-settlement => archive/2026-09-24-add-wallet-settlement}/specs/frontend-mall/spec.md (100%) rename openspec/changes/{add-wallet-settlement => archive/2026-09-24-add-wallet-settlement}/specs/frontend-shop-admin/spec.md (100%) rename openspec/changes/{add-wallet-settlement => archive/2026-09-24-add-wallet-settlement}/specs/settlement/spec.md (100%) rename openspec/changes/{add-wallet-settlement => archive/2026-09-24-add-wallet-settlement}/specs/wallet/spec.md (100%) rename openspec/changes/{add-wallet-settlement => archive/2026-09-24-add-wallet-settlement}/tasks.md (74%) rename openspec/changes/{add-membership-messaging => archive/2026-09-25-add-membership-messaging}/proposal.md (100%) rename openspec/changes/{add-membership-messaging => archive/2026-09-25-add-membership-messaging}/specs/frontend-admin/spec.md (100%) rename openspec/changes/{add-membership-messaging => archive/2026-09-25-add-membership-messaging}/specs/frontend-mall/spec.md (100%) rename openspec/changes/{add-membership-messaging => archive/2026-09-25-add-membership-messaging}/specs/membership/spec.md (100%) rename openspec/changes/{add-membership-messaging => archive/2026-09-25-add-membership-messaging}/specs/messaging/spec.md (100%) rename openspec/changes/{add-membership-messaging => archive/2026-09-25-add-membership-messaging}/tasks.md (67%) create mode 100644 openspec/specs/membership/spec.md create mode 100644 openspec/specs/merchant-onboarding/spec.md create mode 100644 openspec/specs/messaging/spec.md create mode 100644 openspec/specs/settlement/spec.md create mode 100644 openspec/specs/wallet/spec.md diff --git a/README.md b/README.md index 00a3a64..9c1025a 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,14 @@ openspec validate --all --strict # 规范校验 `apps/mall` 通过 `apps/mall/plugins/api.ts` 的 `liveDomains` 按域选择适配器。当前真实后端域包括 catalog、currency、content、brands、shops、auth、account、cart、orders、shipments、invoices、 -addresses、coupons、points、flashSales、groupBuying、favorites。 +addresses、coupons、points、flashSales、groupBuying、favorites、aftersales、reviews、wallet、 +merchantOnboarding、membership、messaging。 + +`merchantOnboarding` 是商家入驻域:只有 `submitMerchantApplication` 与 +`getMyMerchantApplications` 两个方法走真实后端(admin 侧审核方法由 `apps/admin` 直接调用, +mall 不使用);把该域从 `liveDomains` 移除即回落到 `apps/mall/mock/api.ts` 的确定性 fixture。 +`membership`(`getMembership`/`listGrowthLogs`)与 `messaging`(消息列表、已读、全部已读、删除、 +未读数)同理各自独立可回滚;`messaging` 的未读数由页头徽标消费。 仍由页面直接读取 `~/mock/data` 的能力记录在 `docs/TBD-marketing.md`。营销 fixture 仍由 fixed-data 适配器使用,作为每个已迁移域的回滚实现。 diff --git a/apps/admin/app.vue b/apps/admin/app.vue index 8df6fe9..3225459 100644 --- a/apps/admin/app.vue +++ b/apps/admin/app.vue @@ -22,6 +22,10 @@ const navItems = [ { to: "/currencies", label: "nav.currencies" }, { to: "/points-products", label: "nav.pointsProducts" }, { to: "/points-orders", label: "nav.pointsOrders" }, + { to: "/withdrawals", label: "nav.withdrawals" }, + { to: "/settlements", label: "nav.settlements" }, + { to: "/merchant-applications", label: "nav.merchantApplications" }, + { to: "/member-levels", label: "nav.memberLevels" }, ]; diff --git a/apps/admin/components/MemberLevelForm.vue b/apps/admin/components/MemberLevelForm.vue new file mode 100644 index 0000000..c9c0b51 --- /dev/null +++ b/apps/admin/components/MemberLevelForm.vue @@ -0,0 +1,150 @@ + + + diff --git a/apps/admin/locales-extra.ts b/apps/admin/locales-extra.ts index 827cbc7..599a614 100644 --- a/apps/admin/locales-extra.ts +++ b/apps/admin/locales-extra.ts @@ -12,6 +12,10 @@ export const enExtra = { brands: "Brands", aftersales: "After-sales", reviews: "Reviews", + withdrawals: "Withdrawals", + settlements: "Settlements", + merchantApplications: "Merchant applications", + memberLevels: "Member levels", }, admin: { dashboardTitle: "Platform overview", @@ -149,6 +153,91 @@ export const enExtra = { merchant: "Merchant", platform: "Platform", }, + withdrawalReviewTitle: "Withdrawal applications", + withdrawalId: "Application", + withdrawalBuyer: "Buyer email", + withdrawalUser: "User ID", + withdrawalAmount: "Amount", + withdrawalPayout: "Payout details", + withdrawalMethod: "Channel", + withdrawalAccount: "Account", + withdrawalHolder: "Holder", + withdrawalReviewedAt: "Reviewed", + withdrawalReview: "Review", + withdrawalClose: "Close", + withdrawalNote: "Review note (optional)", + withdrawalNotePlaceholder: "Reason kept with the decision", + withdrawalReviewNote: "Note", + withdrawalApprove: "Approve", + withdrawalReject: "Reject", + withdrawalConfirmApprove: + "Approve this withdrawal? The frozen amount is paid out and cannot be undone.", + withdrawalConfirmReject: + "Reject this withdrawal? The frozen amount returns to the buyer's available balance.", + withdrawalApproved: "Withdrawal approved; the frozen balance is settled.", + withdrawalRejected: "Withdrawal rejected; the amount is back in the buyer's balance.", + withdrawalConflict: "This application was already reviewed; the list was refreshed", + withdrawalStatuses: { + pending: "Pending review", + approved: "Approved", + rejected: "Rejected", + }, + commissionRateTitle: "Commission rate", + commissionRate: "Commission rate (basis points)", + commissionRateHint: "Integer basis points between 0 and 10000; 100 bps = 1%.", + commissionRateDisplay: "{bps} bps ({percent}%)", + commissionRateCurrent: "Effective rate", + commissionRateSave: "Save rate", + commissionRateInvalid: "Enter an integer between 0 and 10000 basis points.", + commissionRateSaved: "Commission rate saved. New statements snapshot it.", + settlementGenerateTitle: "Manual generation", + settlementGenerateHint: + "Generation is idempotent per shop and closed period: an existing statement is returned instead of a duplicate.", + settlementShop: "Shop", + settlementPeriodKind: "Period", + settlementPeriodKinds: { + week: "Week", + month: "Month", + }, + settlementPeriodDate: "Date inside the period", + settlementPeriodDateHint: + "Any date inside the target week/month; the server normalizes it to the period bounds.", + settlementGenerate: "Generate statement", + settlementGenerated: "Statement generated.", + settlementGeneratedExisting: + "An existing statement for this shop and period was returned; nothing was duplicated.", + settlementGeneratedResult: "Returned statement", + settlementPickShop: "Select a shop first.", + settlementPeriodDateInvalid: "Enter a valid date (YYYY-MM-DD).", + settlementPeriodNotClosed: "The period is not closed yet; generation was refused", + settlementListTitle: "Statements", + settlementPeriod: "Period", + settlementOrderCount: "Orders", + settlementGross: "Gross", + settlementRefunds: "Refunds", + settlementCommission: "Commission", + settlementPayable: "Payable", + settlementDetail: "Detail", + settlementClose: "Close", + settlementStatuses: { + pending: "Pending payout", + confirmed: "Confirmed", + }, + settlementDetailTitle: "Statement detail", + settlementSnapshot: "Snapshot", + settlementOrders: "Contributing orders", + settlementNoOrders: "No contributing orders in this period.", + settlementOrderNo: "Order no.", + settlementOrderCurrency: "Order currency", + settlementConfirmedAt: "Confirmed", + settlementUpdated: "Updated", + settlementConfirmPayout: "Confirm payout", + settlementConfirmPayoutHint: + "Confirming credits the payable amount to the shop owner's balance exactly once.", + settlementConfirmDialog: + "Confirm this payout? The shop owner's balance is credited and the confirmation cannot be repeated.", + settlementPayoutConfirmed: "Payout confirmed; the shop owner's ledger is credited.", + settlementConflict: "This statement was already confirmed; the list was refreshed", reviewProduct: "Product", reviewShop: "Shop", reviewBuyer: "Buyer", @@ -167,6 +256,94 @@ export const enExtra = { visible: "Visible", hidden: "Hidden", }, + merchantApplication: "Application", + merchantApplicant: "Applicant email", + merchantEntityType: "Entity type", + merchantEntityName: "Entity name", + merchantSubmittedAt: "Submitted", + merchantUpdated: "Updated", + merchantReviewedAt: "Reviewed", + merchantCreatedShop: "Created shop", + merchantView: "Detail", + merchantClose: "Close", + merchantDetail: "Merchant application detail", + merchantEntityInfo: "Entity information", + merchantRealName: "Real name", + merchantCompanyName: "Company name", + merchantBusinessLicenseNo: "Business license no.", + merchantContact: "Contact", + merchantContactName: "Contact name", + merchantContactPhone: "Phone", + merchantContactEmail: "Contact email", + merchantContactAddress: "Address", + merchantCategories: "Operating categories", + merchantNoCategories: "No categories submitted", + merchantQualification: "Qualification", + merchantIdentityDocument: "Identity document", + merchantBusinessLicense: "Business license", + merchantExtraMaterials: "Extra materials", + merchantNoQualification: "No qualification material provided", + merchantRejectionReason: "Rejection reason", + merchantConfirmApprove: + "Approve this application? A shop and a dedicated shop-owner login are provisioned; the initial password is shown once and cannot be retrieved again.", + merchantConfirmReject: + "Reject this application? The applicant can submit a new application afterwards.", + merchantApprove: "Approve", + merchantReject: "Reject", + merchantRejectReason: "Rejection reason (required)", + merchantRejectReasonPlaceholder: "Tell the applicant why the application was rejected", + merchantRejectReasonRequired: "A rejection reason is required.", + merchantApprovedNotice: "Application approved; the shop and its owner login were provisioned.", + merchantRejectedNotice: "Application rejected.", + merchantOutcomeApproved: "Approved: the shop and owner login were provisioned.", + merchantOutcomeRejected: "Rejected: the applicant can submit a new application.", + merchantConflict: "This application was already reviewed; the list was refreshed", + merchantCredentialsTitle: "One-time initial credentials", + merchantCredentialsWarning: + "Copy these credentials now. The initial password is shown only once and cannot be retrieved again after this dialog is closed.", + merchantCredentialsEmail: "Owner login", + merchantCredentialsPassword: "Initial password", + merchantCredentialsShopSlug: "Shop slug", + merchantCredentialsShopId: "Shop ID", + merchantCredentialsClose: "I have saved the credentials", + merchantStatuses: { + pending: "Pending review", + approved: "Approved", + rejected: "Rejected", + }, + merchantEntityTypes: { + personal: "Personal", + enterprise: "Enterprise", + }, + memberLevelCreateTitle: "Add member level", + memberLevelEditTitle: "Edit member level", + memberLevelHint: + "Levels list in growth order. Both languages are required, and a growth threshold can be used by exactly one level.", + memberLevelNameEn: "Level name (English)", + memberLevelNameZh: "Level name (中文)", + memberLevelIcon: "Icon", + memberLevelGrowth: "Growth threshold", + memberLevelBenefitsEn: "Benefits (English)", + memberLevelBenefitsZh: "Benefits (中文)", + memberLevelCreate: "Add level", + memberLevelCreated: "Member level created.", + memberLevelUpdated: "Member level updated.", + memberLevelUpdatedAt: "Updated", + memberLevelDeleted: "Member level deleted.", + memberLevelRequired: "Both language names and benefits are required.", + memberLevelIconRequired: "An icon is required.", + memberLevelThresholdInvalid: "Use a growth threshold of zero or greater.", + memberLevelThresholdDuplicate: + "Another level already uses that growth threshold.", + memberLevelConflict: "Growth threshold already in use", + memberLevelInvalid: "The level was rejected by the server", + memberLevelConfirmDelete: + 'Delete the level "{name}"? This cannot be undone.', + memberLevelDeleteInUse: + "This level is held by at least one customer, so it cannot be deleted", + memberLevelDiscardEdit: "Discard the unsaved level edits and refresh?", + memberLevelGone: + "That member level no longer exists; the list was refreshed.", }, } as const; @@ -182,6 +359,10 @@ export const zhExtra = { brands: "品牌", aftersales: "售后仲裁", reviews: "评价管理", + withdrawals: "提现审核", + settlements: "结算对账", + merchantApplications: "商家入驻审核", + memberLevels: "会员等级", }, admin: { dashboardTitle: "平台概览", @@ -316,6 +497,85 @@ export const zhExtra = { merchant: "商家", platform: "平台", }, + withdrawalReviewTitle: "提现申请", + withdrawalId: "申请单", + withdrawalBuyer: "买家邮箱", + withdrawalUser: "用户 ID", + withdrawalAmount: "金额", + withdrawalPayout: "收款信息", + withdrawalMethod: "渠道", + withdrawalAccount: "账号", + withdrawalHolder: "户名", + withdrawalReviewedAt: "审核时间", + withdrawalReview: "审核", + withdrawalClose: "收起", + withdrawalNote: "审核备注(可选)", + withdrawalNotePlaceholder: "随审核结果保留的说明", + withdrawalReviewNote: "备注", + withdrawalApprove: "通过", + withdrawalReject: "驳回", + withdrawalConfirmApprove: "确定通过该提现申请吗?冻结金额将打款且不可撤销。", + withdrawalConfirmReject: "确定驳回该提现申请吗?冻结金额将退回买家可用余额。", + withdrawalApproved: "提现已通过,冻结金额已结算。", + withdrawalRejected: "提现已驳回,金额已退回买家余额。", + withdrawalConflict: "该申请已被审核,列表已刷新", + withdrawalStatuses: { + pending: "待审核", + approved: "已通过", + rejected: "已驳回", + }, + commissionRateTitle: "佣金比例", + commissionRate: "佣金比例(基点)", + commissionRateHint: "取 0 到 10000 的整数基点,100 基点 = 1%。", + commissionRateDisplay: "{bps} 基点({percent}%)", + commissionRateCurrent: "当前比例", + commissionRateSave: "保存比例", + commissionRateInvalid: "请输入 0 到 10000 之间的整数基点。", + commissionRateSaved: "佣金比例已保存,之后生成的账单将采用新比例。", + settlementGenerateTitle: "手动生成", + settlementGenerateHint: + "同一店铺同一周期幂等:已存在时返回原账单,不会重复生成。", + settlementShop: "店铺", + settlementPeriodKind: "周期", + settlementPeriodKinds: { + week: "周", + month: "月", + }, + settlementPeriodDate: "周期内日期", + settlementPeriodDateHint: "填写目标周/月内的任意日期,服务端会归一化为周期边界。", + settlementGenerate: "生成账单", + settlementGenerated: "账单已生成。", + settlementGeneratedExisting: "该店铺该周期已存在账单,已返回原账单,未重复生成。", + settlementGeneratedResult: "返回的账单", + settlementPickShop: "请先选择店铺。", + settlementPeriodDateInvalid: "请输入有效日期(YYYY-MM-DD)。", + settlementPeriodNotClosed: "该周期尚未结束,生成被拒绝", + settlementListTitle: "结算账单", + settlementPeriod: "周期", + settlementOrderCount: "订单数", + settlementGross: "交易总额", + settlementRefunds: "退款", + settlementCommission: "佣金", + settlementPayable: "应付", + settlementDetail: "详情", + settlementClose: "收起", + settlementStatuses: { + pending: "待打款", + confirmed: "已确认", + }, + settlementDetailTitle: "账单详情", + settlementSnapshot: "账单快照", + settlementOrders: "关联订单", + settlementNoOrders: "该周期没有关联订单。", + settlementOrderNo: "订单号", + settlementOrderCurrency: "下单币种", + settlementConfirmedAt: "确认时间", + settlementUpdated: "更新时间", + settlementConfirmPayout: "确认打款", + settlementConfirmPayoutHint: "确认后应付金额将一次性计入店主余额。", + settlementConfirmDialog: "确定确认打款吗?店主余额将入账,且不可重复确认。", + settlementPayoutConfirmed: "打款已确认,店主账户已入账。", + settlementConflict: "该账单已确认,列表已刷新", reviewProduct: "商品", reviewShop: "店铺", reviewBuyer: "买家", @@ -334,5 +594,88 @@ export const zhExtra = { visible: "可见", hidden: "已隐藏", }, + merchantApplication: "申请单", + merchantApplicant: "申请人邮箱", + merchantEntityType: "主体类型", + merchantEntityName: "主体名称", + merchantSubmittedAt: "提交时间", + merchantUpdated: "更新时间", + merchantReviewedAt: "审核时间", + merchantCreatedShop: "创建店铺", + merchantView: "详情", + merchantClose: "收起", + merchantDetail: "商家入驻申请详情", + merchantEntityInfo: "主体信息", + merchantRealName: "真实姓名", + merchantCompanyName: "企业名称", + merchantBusinessLicenseNo: "营业执照号", + merchantContact: "联系方式", + merchantContactName: "联系人", + merchantContactPhone: "联系电话", + merchantContactEmail: "联系邮箱", + merchantContactAddress: "联系地址", + merchantCategories: "经营类目", + merchantNoCategories: "未选择类目", + merchantQualification: "资质材料", + merchantIdentityDocument: "身份证明", + merchantBusinessLicense: "营业执照", + merchantExtraMaterials: "补充材料", + merchantNoQualification: "未提供资质材料", + merchantRejectionReason: "驳回原因", + merchantConfirmApprove: + "确定通过该入驻申请吗?将开通店铺并创建专属店主账号,初始密码仅显示一次且无法再次获取。", + merchantConfirmReject: "确定驳回该入驻申请吗?申请人之后可以重新提交。", + merchantApprove: "通过", + merchantReject: "驳回", + merchantRejectReason: "驳回原因(必填)", + merchantRejectReasonPlaceholder: "请说明驳回原因,供申请人查看", + merchantRejectReasonRequired: "驳回原因不能为空。", + merchantApprovedNotice: "申请已通过,店铺与店主账号已开通。", + merchantRejectedNotice: "申请已驳回。", + merchantOutcomeApproved: "已通过:店铺与店主账号已开通。", + merchantOutcomeRejected: "已驳回:申请人可重新提交。", + merchantConflict: "该申请已被审核,列表已刷新", + merchantCredentialsTitle: "一次性初始凭据", + merchantCredentialsWarning: + "请立即保存以下凭据。初始密码仅显示一次,关闭本弹窗后无法再次获取。", + merchantCredentialsEmail: "店主登录账号", + merchantCredentialsPassword: "初始密码", + merchantCredentialsShopSlug: "店铺别名", + merchantCredentialsShopId: "店铺 ID", + merchantCredentialsClose: "我已保存凭据", + merchantStatuses: { + pending: "待审核", + approved: "已通过", + rejected: "已驳回", + }, + merchantEntityTypes: { + personal: "个人", + enterprise: "企业", + }, + memberLevelCreateTitle: "新增会员等级", + memberLevelEditTitle: "编辑会员等级", + memberLevelHint: + "等级按成长值升序排列;中英文均为必填,且同一成长值只能用于一个等级。", + memberLevelNameEn: "等级名称(英文)", + memberLevelNameZh: "等级名称(中文)", + memberLevelIcon: "图标", + memberLevelGrowth: "成长值门槛", + memberLevelBenefitsEn: "等级权益(英文)", + memberLevelBenefitsZh: "等级权益(中文)", + memberLevelCreate: "新增等级", + memberLevelCreated: "会员等级已创建。", + memberLevelUpdated: "会员等级已更新。", + memberLevelUpdatedAt: "更新时间", + memberLevelDeleted: "会员等级已删除。", + memberLevelRequired: "中英文等级名称与权益均为必填项。", + memberLevelIconRequired: "图标为必填项。", + memberLevelThresholdInvalid: "成长值门槛必须为不小于 0 的整数。", + memberLevelThresholdDuplicate: "已有其他等级使用该成长值门槛。", + memberLevelConflict: "成长值门槛已被占用", + memberLevelInvalid: "等级被服务端拒绝", + memberLevelConfirmDelete: "确定删除等级“{name}”吗?此操作不可撤销。", + memberLevelDeleteInUse: "该等级已被至少一位客户持有,无法删除", + memberLevelDiscardEdit: "确定放弃未保存的等级修改并刷新吗?", + memberLevelGone: "该会员等级已不存在,列表已刷新。", }, } as const; diff --git a/apps/admin/pages/member-levels.vue b/apps/admin/pages/member-levels.vue new file mode 100644 index 0000000..60c49dd --- /dev/null +++ b/apps/admin/pages/member-levels.vue @@ -0,0 +1,421 @@ + + + diff --git a/apps/admin/pages/merchant-applications.vue b/apps/admin/pages/merchant-applications.vue new file mode 100644 index 0000000..75fa2e7 --- /dev/null +++ b/apps/admin/pages/merchant-applications.vue @@ -0,0 +1,608 @@ + + + diff --git a/apps/admin/pages/settlements.vue b/apps/admin/pages/settlements.vue new file mode 100644 index 0000000..f02fed4 --- /dev/null +++ b/apps/admin/pages/settlements.vue @@ -0,0 +1,718 @@ + + + diff --git a/apps/admin/pages/withdrawals.vue b/apps/admin/pages/withdrawals.vue new file mode 100644 index 0000000..ab4c6a7 --- /dev/null +++ b/apps/admin/pages/withdrawals.vue @@ -0,0 +1,321 @@ + + + diff --git a/apps/api/migrations/0019_wallet.sql b/apps/api/migrations/0019_wallet.sql new file mode 100644 index 0000000..cef339a --- /dev/null +++ b/apps/api/migrations/0019_wallet.sql @@ -0,0 +1,45 @@ +-- Wallet entry points over the existing customer-account ledger: +-- simulated recharge records and withdrawal applications awaiting review. +-- Balances themselves stay in customer_accounts; these tables are the +-- business records whose lifecycle drives ledger entries. + +CREATE TYPE wallet_recharge_status AS ENUM ('credited', 'failed'); + +CREATE TABLE wallet_recharges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + currency CHAR(3) NOT NULL REFERENCES currencies (code), + amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), + status wallet_recharge_status NOT NULL DEFAULT 'credited', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX wallet_recharges_user_idx + ON wallet_recharges (user_id, created_at DESC); + +CREATE TYPE wallet_withdrawal_status AS ENUM ('pending', 'approved', 'rejected'); + +CREATE TABLE wallet_withdrawals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + currency CHAR(3) NOT NULL REFERENCES currencies (code), + amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), + -- Free-form payout destination captured at application time. + account_details JSONB NOT NULL DEFAULT '{}'::jsonb, + status wallet_withdrawal_status NOT NULL DEFAULT 'pending', + reviewed_by UUID REFERENCES users (id), + reviewed_at TIMESTAMPTZ, + review_note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- A reviewed row always records who and when; a pending row never does. + CONSTRAINT wallet_withdrawals_review_consistent CHECK ( + (status = 'pending' AND reviewed_by IS NULL AND reviewed_at IS NULL) + OR (status <> 'pending' AND reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL) + ) +); + +CREATE INDEX wallet_withdrawals_user_idx + ON wallet_withdrawals (user_id, created_at DESC); +CREATE INDEX wallet_withdrawals_status_idx + ON wallet_withdrawals (status, created_at DESC); diff --git a/apps/api/migrations/0020_settlement.sql b/apps/api/migrations/0020_settlement.sql new file mode 100644 index 0000000..fee85ab --- /dev/null +++ b/apps/api/migrations/0020_settlement.sql @@ -0,0 +1,62 @@ +-- Platform-mediated merchant settlement: per-shop, per-period statements +-- snapshotted from confirmed-received orders minus completed refunds minus a +-- platform commission. Amounts are integer minor units in the platform base +-- currency; the rate is integer basis points and lives in platform settings. + +-- Settlement attributes an order to the period in which it became +-- confirmed-received. Refunds bump orders.updated_at, so that column cannot +-- stand in for the completion instant. +ALTER TABLE orders ADD COLUMN completed_at TIMESTAMPTZ; +UPDATE orders SET completed_at = updated_at + WHERE status = 'completed' AND completed_at IS NULL; +CREATE INDEX orders_shop_completed_idx + ON orders (shop_id, completed_at) WHERE status = 'completed'; + +CREATE TABLE platform_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO platform_settings (key, value) +VALUES ('settlement.commission_rate_bps', '500') +ON CONFLICT (key) DO NOTHING; + +CREATE TYPE settlement_period_kind AS ENUM ('week', 'month'); +CREATE TYPE settlement_status AS ENUM ('pending', 'confirmed'); + +CREATE TABLE settlement_statements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + shop_id UUID NOT NULL REFERENCES shops (id) ON DELETE CASCADE, + -- Statement totals are converted into and snapshotted in this currency. + currency CHAR(3) NOT NULL REFERENCES currencies (code), + period_kind settlement_period_kind NOT NULL, + period_start DATE NOT NULL, + period_end DATE NOT NULL, + order_count INT NOT NULL DEFAULT 0 CHECK (order_count >= 0), + gross_minor BIGINT NOT NULL DEFAULT 0 CHECK (gross_minor >= 0), + refund_minor BIGINT NOT NULL DEFAULT 0 CHECK (refund_minor >= 0), + commission_rate_bps INT NOT NULL + CHECK (commission_rate_bps >= 0 AND commission_rate_bps <= 10000), + commission_minor BIGINT NOT NULL DEFAULT 0 CHECK (commission_minor >= 0), + payable_minor BIGINT NOT NULL DEFAULT 0 CHECK (payable_minor >= 0), + status settlement_status NOT NULL DEFAULT 'pending', + generated_by UUID REFERENCES users (id), + confirmed_by UUID REFERENCES users (id), + confirmed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT settlement_statements_period CHECK (period_end >= period_start), + CONSTRAINT settlement_statements_confirmed_consistent CHECK ( + (status = 'pending' AND confirmed_by IS NULL AND confirmed_at IS NULL) + OR (status = 'confirmed' AND confirmed_by IS NOT NULL AND confirmed_at IS NOT NULL) + ) +); + +-- At most one statement per shop, period kind, and period start. +CREATE UNIQUE INDEX settlement_statements_period_idx + ON settlement_statements (shop_id, period_kind, period_start); +CREATE INDEX settlement_statements_shop_idx + ON settlement_statements (shop_id, created_at DESC); +CREATE INDEX settlement_statements_status_idx + ON settlement_statements (status, created_at DESC); diff --git a/apps/api/migrations/0021_merchant_applications.sql b/apps/api/migrations/0021_merchant_applications.sql new file mode 100644 index 0000000..4a0cc81 --- /dev/null +++ b/apps/api/migrations/0021_merchant_applications.sql @@ -0,0 +1,69 @@ +-- Merchant onboarding: a prospective seller (personal 个人 or enterprise 企业) +-- applies once, a platform admin reviews it, and approval provisions the shop +-- and its dedicated shop_owner account in one transaction. + +CREATE TYPE merchant_entity_type AS ENUM ('personal', 'enterprise'); +CREATE TYPE merchant_application_status AS ENUM ('pending', 'approved', 'rejected'); + +CREATE TABLE merchant_applications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + entity_type merchant_entity_type NOT NULL, + -- Personal-kind entity information. + real_name TEXT, + -- Enterprise-kind entity information. + company_name TEXT, + business_license_no TEXT, + -- Operating categories, one or more rows of the reference category tree. + category_ids UUID[] NOT NULL DEFAULT '{}', + -- Contact details, shared by both kinds. + contact_name TEXT NOT NULL, + contact_phone TEXT NOT NULL, + contact_email TEXT NOT NULL, + contact_address TEXT, + -- Qualification materials are URLs only; no file storage in this MVP. + identity_document_url TEXT, + business_license_url TEXT, + extra_materials JSONB NOT NULL DEFAULT '[]'::jsonb, + status merchant_application_status NOT NULL DEFAULT 'pending', + rejection_reason TEXT, + reviewed_by UUID REFERENCES users (id), + reviewed_at TIMESTAMPTZ, + -- Shop created by approval; null until then. + created_shop_id UUID REFERENCES shops (id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Each kind carries its own required entity + qualification fields. + CONSTRAINT merchant_applications_kind_fields CHECK ( + ( + entity_type = 'personal' + AND real_name IS NOT NULL AND btrim(real_name) <> '' + AND identity_document_url IS NOT NULL AND btrim(identity_document_url) <> '' + ) + OR ( + entity_type = 'enterprise' + AND company_name IS NOT NULL AND btrim(company_name) <> '' + AND business_license_url IS NOT NULL AND btrim(business_license_url) <> '' + ) + ), + -- A pending row is unreviewed; terminal rows record who and when, and a + -- rejection always carries its reason. + CONSTRAINT merchant_applications_review_consistent CHECK ( + (status = 'pending' AND reviewed_by IS NULL AND reviewed_at IS NULL + AND rejection_reason IS NULL AND created_shop_id IS NULL) + OR (status = 'approved' AND reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL + AND rejection_reason IS NULL AND created_shop_id IS NOT NULL) + OR (status = 'rejected' AND reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL + AND rejection_reason IS NOT NULL AND btrim(rejection_reason) <> '' + AND created_shop_id IS NULL) + ), + CONSTRAINT merchant_applications_categories CHECK (cardinality(category_ids) >= 1) +); + +-- At most one live application per user; rejected rows are free to retry. +CREATE UNIQUE INDEX merchant_applications_active_idx + ON merchant_applications (user_id) WHERE status IN ('pending', 'approved'); +CREATE INDEX merchant_applications_user_idx + ON merchant_applications (user_id, created_at DESC); +CREATE INDEX merchant_applications_status_idx + ON merchant_applications (status, created_at DESC); diff --git a/apps/api/migrations/0022_storefront_seller_link.sql b/apps/api/migrations/0022_storefront_seller_link.sql new file mode 100644 index 0000000..a6f846a --- /dev/null +++ b/apps/api/migrations/0022_storefront_seller_link.sql @@ -0,0 +1,6 @@ +-- The "Become a Seller / 入驻商家" storefront quick link now lands on the +-- merchant onboarding form instead of the stores directory. +UPDATE quick_links + SET url = '/merchant/join' + WHERE url = '/stores' + AND label ->> 'en' = 'Become a Seller'; diff --git a/apps/api/migrations/0023_membership_messaging.sql b/apps/api/migrations/0023_membership_messaging.sql new file mode 100644 index 0000000..194a601 --- /dev/null +++ b/apps/api/migrations/0023_membership_messaging.sql @@ -0,0 +1,73 @@ +-- Membership: platform-managed levels, an append-only growth ledger, and the +-- customer's current level on `users`. Messaging: per-customer system messages +-- emitted by order/shipment/refund events with unread state and soft deletion. + +CREATE TABLE member_levels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name JSONB NOT NULL, + icon TEXT NOT NULL, + growth_threshold BIGINT NOT NULL CHECK (growth_threshold >= 0), + benefits JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT member_levels_icon CHECK (btrim(icon) <> '') +); + +-- Thresholds are unique; level order follows the threshold. +CREATE UNIQUE INDEX member_levels_threshold_idx ON member_levels (growth_threshold); + +ALTER TABLE users ADD COLUMN level UUID REFERENCES member_levels (id); +CREATE INDEX users_level_idx ON users (level); + +CREATE TABLE growth_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + -- Growth value in whole base-currency units; 0 only for sub-unit orders. + delta BIGINT NOT NULL CHECK (delta >= 0), + growth_total BIGINT NOT NULL CHECK (growth_total >= 0), + reason TEXT NOT NULL, + reference_type TEXT, + reference_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- One accrual per user and referenced order, so a retried completion is a no-op. +CREATE UNIQUE INDEX growth_logs_reference_idx + ON growth_logs (user_id, reference_type, reference_id) + WHERE reference_id IS NOT NULL; +CREATE INDEX growth_logs_user_idx + ON growth_logs (user_id, created_at DESC, id DESC); + +CREATE TYPE message_kind AS ENUM ('order_paid', 'order_shipped', 'refund_completed'); +CREATE TYPE message_status AS ENUM ('unread', 'read'); + +CREATE TABLE messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + kind message_kind NOT NULL, + title JSONB NOT NULL, + body JSONB NOT NULL, + reference_type TEXT, + reference_id UUID, + status message_status NOT NULL DEFAULT 'unread', + read_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT messages_read_consistent CHECK ( + (status = 'unread' AND read_at IS NULL) + OR (status = 'read' AND read_at IS NOT NULL) + ) +); + +-- One message per customer, kind, and reference: re-running an event handler +-- conflicts instead of duplicating. Soft-deleted rows still occupy the slot so +-- a re-run cannot resurrect a deleted message as a new one. +CREATE UNIQUE INDEX messages_event_idx + ON messages (user_id, kind, reference_type, reference_id) + WHERE reference_id IS NOT NULL; +CREATE INDEX messages_user_idx + ON messages (user_id, created_at DESC, id DESC) + WHERE deleted_at IS NULL; +CREATE INDEX messages_unread_idx + ON messages (user_id) + WHERE deleted_at IS NULL AND status = 'unread'; diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 1f160e4..53a5876 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -539,6 +539,82 @@ pub struct CollectiveGroupMember { pub const COLLECTIVE_GROUP_MEMBER_COLUMNS: &str = "id, group_id, order_id, user_id, joined_at"; +/// Demo recharge outcome. Only `credited` is produced today; the variant keeps +/// the ledger-independent record honest if a failure path is added. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "wallet_recharge_status", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum WalletRechargeStatus { + Credited, + Failed, +} + +/// Withdrawal application lifecycle; reviewed exactly once out of `pending`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "wallet_withdrawal_status", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum WalletWithdrawalStatus { + Pending, + Approved, + Rejected, +} + +/// Settlement statement period granularity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "settlement_period_kind", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum SettlementPeriodKind { + Week, + Month, +} + +/// Payout state machine; `confirmed` is terminal and pays out exactly once. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "settlement_status", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum SettlementStatus { + Pending, + Confirmed, +} + +/// Applicant entity kind for merchant onboarding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "merchant_entity_type", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum MerchantEntityType { + Personal, + Enterprise, +} + +/// Review state machine; `approved` and `rejected` are terminal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "merchant_application_status", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum MerchantApplicationStatus { + Pending, + Approved, + Rejected, +} + +/// System message source event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "message_kind", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum MessageKind { + OrderPaid, + OrderShipped, + RefundCompleted, +} + +/// Unread/read state; soft deletion is a separate marker. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "message_status", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum MessageStatus { + Unread, + Read, +} + #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct AddressBookEntry { pub id: Uuid, diff --git a/apps/api/src/modules/account/mod.rs b/apps/api/src/modules/account/mod.rs index 2cdf8fe..e126496 100644 --- a/apps/api/src/modules/account/mod.rs +++ b/apps/api/src/modules/account/mod.rs @@ -1,6 +1,6 @@ mod dto; mod handlers; -mod repo; +pub mod repo; pub mod service; use axum::Router; diff --git a/apps/api/src/modules/aftersale/service.rs b/apps/api/src/modules/aftersale/service.rs index bf6ac3a..7ddc085 100644 --- a/apps/api/src/modules/aftersale/service.rs +++ b/apps/api/src/modules/aftersale/service.rs @@ -5,8 +5,9 @@ use sqlx::PgConnection; use uuid::Uuid; use crate::error::{ApiError, ApiResult}; -use crate::models::{AftersaleKind, AftersaleStatus, OrderStatus}; +use crate::models::{AftersaleKind, AftersaleStatus, MessageKind, OrderStatus}; use crate::modules::account; +use crate::modules::messaging; use crate::state::AppState; /// Days after the order's last update during which items stay eligible. @@ -510,6 +511,20 @@ async fn complete_refund(tx: &mut PgConnection, id: Uuid) -> ApiResult ApiResult { + let mut tx = state.db.begin().await?; let shipment = sqlx::query_as::<_, Shipment>(&format!( "UPDATE shipments s SET status = 'delivered', delivered_at = now() FROM orders o @@ -112,10 +115,16 @@ pub async fn confirm_delivered( )) .bind(id) .bind(user_id) - .fetch_optional(&state.db) + .fetch_optional(&mut *tx) .await? .ok_or_else(|| ApiError::Conflict("shipment not confirmable".into()))?; - order_repo::maybe_mark_order_completed(&state.db, shipment.order_id).await?; + // Completion accrues growth value and may upgrade the level; the order row + // carries the currency and realized amount for the ledger entry. + if order_repo::maybe_mark_order_completed(&mut tx, shipment.order_id).await? { + let order = order_repo::get_in_tx(&mut tx, shipment.order_id).await?; + membership::service::accrue_for_order(&mut tx, &order).await?; + } + tx.commit().await?; let mut out = views(&state.db, vec![shipment]).await?; Ok(out.remove(0)) } @@ -194,6 +203,7 @@ pub async fn create( } pub async fn mark_shipped(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; let shipment = sqlx::query_as::<_, Shipment>(&format!( "UPDATE shipments s SET status = 'shipped', shipped_at = now() FROM orders o @@ -202,10 +212,25 @@ pub async fn mark_shipped(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResul )) .bind(id) .bind(shop_id) - .fetch_optional(&state.db) + .fetch_optional(&mut *tx) .await? .ok_or_else(|| ApiError::Conflict("shipment not found or not pending".into()))?; - order_repo::maybe_mark_order_shipped(&state.db, shipment.order_id).await?; + let (owner_id, order_no): (Uuid, String) = + sqlx::query_as("SELECT user_id, order_no FROM orders WHERE id = $1") + .bind(shipment.order_id) + .fetch_one(&mut *tx) + .await?; + messaging::service::emit_event( + &mut tx, + owner_id, + MessageKind::OrderShipped, + "order", + shipment.order_id, + &order_no, + ) + .await?; + order_repo::maybe_mark_order_shipped(&mut tx, shipment.order_id).await?; + tx.commit().await?; let mut out = views(&state.db, vec![shipment]).await?; Ok(out.remove(0)) } diff --git a/apps/api/src/modules/identity/mod.rs b/apps/api/src/modules/identity/mod.rs index 1db1fcd..6b22c69 100644 --- a/apps/api/src/modules/identity/mod.rs +++ b/apps/api/src/modules/identity/mod.rs @@ -1,6 +1,6 @@ mod admin; mod handlers; -mod repo; +pub mod repo; pub mod service; use axum::Router; diff --git a/apps/api/src/modules/identity/repo.rs b/apps/api/src/modules/identity/repo.rs index cc44f23..6a3978c 100644 --- a/apps/api/src/modules/identity/repo.rs +++ b/apps/api/src/modules/identity/repo.rs @@ -10,14 +10,29 @@ pub async fn insert_customer<'e, E: PgExecutor<'e>>( email: &str, password_hash: &str, display_name: &str, +) -> Result { + insert_user(exec, email, password_hash, display_name, UserRole::Customer, None).await +} + +/// Create a user with an explicit role/shop. Merchant approval uses this to +/// provision the dedicated `shop_owner` inside its transaction. +pub async fn insert_user<'e, E: PgExecutor<'e>>( + exec: E, + email: &str, + password_hash: &str, + display_name: &str, + role: UserRole, + shop_id: Option, ) -> Result { sqlx::query_as::<_, User>(&format!( - "INSERT INTO users (email, password_hash, display_name, role) - VALUES ($1, $2, $3, 'customer') RETURNING {USER_COLUMNS}" + "INSERT INTO users (email, password_hash, display_name, role, shop_id) + VALUES ($1, $2, $3, $4, $5) RETURNING {USER_COLUMNS}" )) .bind(email) .bind(password_hash) .bind(display_name) + .bind(role) + .bind(shop_id) .fetch_one(exec) .await } diff --git a/apps/api/src/modules/membership/dto.rs b/apps/api/src/modules/membership/dto.rs new file mode 100644 index 0000000..30e83a6 --- /dev/null +++ b/apps/api/src/modules/membership/dto.rs @@ -0,0 +1,59 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +/// A platform-managed member level. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct MemberLevel { + pub id: Uuid, + pub name: Value, + pub icon: String, + pub growth_threshold: i64, + pub benefits: Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub const LEVEL_COLS: &str = "id, name, icon, growth_threshold, benefits, created_at, updated_at"; + +#[derive(Debug, Deserialize)] +pub struct LevelBody { + pub name: Value, + pub icon: String, + pub growth_threshold: i64, + pub benefits: Value, +} + +/// One append-only growth accrual. `growth_total` is the running total after it. +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct GrowthLogEntry { + pub id: Uuid, + pub delta: i64, + pub growth_total: i64, + pub reason: String, + pub reference_type: Option, + pub reference_id: Option, + pub created_at: DateTime, +} + +pub const GROWTH_LOG_COLS: &str = "id, delta, growth_total, reason, reference_type, \ + reference_id, created_at"; + +#[derive(Debug, Serialize)] +pub struct NextLevel { + pub id: Uuid, + pub name: Value, + pub icon: String, + pub growth_threshold: i64, + /// Growth still needed to reach the threshold. + pub remaining: i64, +} + +#[derive(Debug, Serialize)] +pub struct MembershipStatus { + /// Re-derived from the growth total against current thresholds. + pub level: Option, + pub growth_total: i64, + pub next_level: Option, +} diff --git a/apps/api/src/modules/membership/handlers.rs b/apps/api/src/modules/membership/handlers.rs new file mode 100644 index 0000000..8486cdd --- /dev/null +++ b/apps/api/src/modules/membership/handlers.rs @@ -0,0 +1,90 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{delete, get, put}, + Json, Router, +}; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::http::{PageQuery, Paged}; +use crate::state::AppState; + +use super::dto::{GrowthLogEntry, LevelBody, MemberLevel, MembershipStatus}; +use super::service; + +pub fn router() -> Router { + Router::new() + .route("/membership", get(status)) + .route("/membership/growth-logs", get(list_growth_logs)) + .route( + "/admin/member-levels", + get(list_levels).post(create_level), + ) + .route( + "/admin/member-levels/{id}", + put(update_level).delete(delete_level), + ) +} + +// --- customer --- + +async fn status( + State(state): State, + auth: AuthUser, +) -> ApiResult> { + Ok(Json(service::status(&state, auth.id).await?)) +} + +async fn list_growth_logs( + State(state): State, + auth: AuthUser, + Query(page): Query, +) -> ApiResult>> { + Ok(Json( + service::list_growth_logs(&state, auth.id, page.page, page.per_page).await?, + )) +} + +// --- platform admin --- + +async fn list_levels( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(service::list_levels(&state).await?)) +} + +async fn create_level( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + auth.require_admin()?; + Ok(( + StatusCode::CREATED, + Json(service::create_level(&state, body).await?), + )) +} + +async fn update_level( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::update_level(&state, id, body).await?)) +} + +async fn delete_level( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult { + auth.require_admin()?; + service::delete_level(&state, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/api/src/modules/membership/mod.rs b/apps/api/src/modules/membership/mod.rs new file mode 100644 index 0000000..08f7f71 --- /dev/null +++ b/apps/api/src/modules/membership/mod.rs @@ -0,0 +1,6 @@ +pub mod dto; +pub mod handlers; +pub mod repo; +pub mod service; + +pub use handlers::router; diff --git a/apps/api/src/modules/membership/repo.rs b/apps/api/src/modules/membership/repo.rs new file mode 100644 index 0000000..9817026 --- /dev/null +++ b/apps/api/src/modules/membership/repo.rs @@ -0,0 +1,236 @@ +use sqlx::{PgConnection, PgExecutor, PgPool}; +use uuid::Uuid; + +use crate::error::ApiResult; +use crate::models::Currency; + +use super::dto::{GrowthLogEntry, LevelBody, MemberLevel, GROWTH_LOG_COLS, LEVEL_COLS}; + +pub async fn list_levels<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult> { + Ok(sqlx::query_as::<_, MemberLevel>(&format!( + "SELECT {LEVEL_COLS} FROM member_levels ORDER BY growth_threshold, id" + )) + .fetch_all(exec) + .await?) +} + +pub async fn get_level<'e, E: PgExecutor<'e>>( + exec: E, + id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, MemberLevel>(&format!( + "SELECT {LEVEL_COLS} FROM member_levels WHERE id = $1" + )) + .bind(id) + .fetch_optional(exec) + .await?) +} + +/// Insert a level; a duplicate threshold surfaces as a unique violation. +pub async fn insert_level( + tx: &mut PgConnection, + body: &LevelBody, +) -> Result { + sqlx::query_as::<_, MemberLevel>(&format!( + "INSERT INTO member_levels (name, icon, growth_threshold, benefits) + VALUES ($1, $2, $3, $4) RETURNING {LEVEL_COLS}" + )) + .bind(&body.name) + .bind(body.icon.trim()) + .bind(body.growth_threshold) + .bind(&body.benefits) + .fetch_one(&mut *tx) + .await +} + +pub async fn update_level( + tx: &mut PgConnection, + id: Uuid, + body: &LevelBody, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, MemberLevel>(&format!( + "UPDATE member_levels + SET name = $2, icon = $3, growth_threshold = $4, benefits = $5, updated_at = now() + WHERE id = $1 RETURNING {LEVEL_COLS}" + )) + .bind(id) + .bind(&body.name) + .bind(body.icon.trim()) + .bind(body.growth_threshold) + .bind(&body.benefits) + .fetch_optional(&mut *tx) + .await +} + +pub async fn delete_level(tx: &mut PgConnection, id: Uuid) -> ApiResult { + let result = sqlx::query("DELETE FROM member_levels WHERE id = $1") + .bind(id) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected()) +} + +/// How many customers currently hold this level. +pub async fn level_holders<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult { + Ok( + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE level = $1") + .bind(id) + .fetch_one(exec) + .await?, + ) +} + +/// The highest level whose threshold the growth total already meets. +pub async fn level_at_or_below<'e, E: PgExecutor<'e>>( + exec: E, + growth_total: i64, +) -> ApiResult> { + Ok(sqlx::query_as::<_, MemberLevel>(&format!( + "SELECT {LEVEL_COLS} FROM member_levels + WHERE growth_threshold <= $1 + ORDER BY growth_threshold DESC, id LIMIT 1" + )) + .bind(growth_total) + .fetch_optional(exec) + .await?) +} + +/// The lowest level whose threshold is still above the growth total. +pub async fn level_above<'e, E: PgExecutor<'e>>( + exec: E, + growth_total: i64, +) -> ApiResult> { + Ok(sqlx::query_as::<_, MemberLevel>(&format!( + "SELECT {LEVEL_COLS} FROM member_levels + WHERE growth_threshold > $1 + ORDER BY growth_threshold, id LIMIT 1" + )) + .bind(growth_total) + .fetch_optional(exec) + .await?) +} + +pub async fn currency<'e, E: PgExecutor<'e>>( + exec: E, + code: &str, +) -> ApiResult> { + Ok(sqlx::query_as::<_, Currency>( + "SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled + FROM currencies WHERE code = $1", + ) + .bind(code) + .fetch_optional(exec) + .await?) +} + +pub async fn base_currency<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult> { + Ok(sqlx::query_as::<_, Currency>( + "SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled + FROM currencies WHERE is_base LIMIT 1", + ) + .fetch_optional(exec) + .await?) +} + +/// Serialize accruals for one customer before reading or writing their total. +pub async fn lock_user(tx: &mut PgConnection, user_id: Uuid) -> ApiResult<()> { + sqlx::query("SELECT id FROM users WHERE id = $1 FOR UPDATE") + .bind(user_id) + .fetch_one(&mut *tx) + .await?; + Ok(()) +} + +/// Sum of every entry; the ledger is the source of truth for the total. +pub async fn growth_total<'e, E: PgExecutor<'e>>( + exec: E, + user_id: Uuid, +) -> ApiResult { + // SUM(bigint) is NUMERIC; cast so the i64 decode succeeds. + Ok(sqlx::query_scalar( + "SELECT COALESCE(SUM(delta), 0)::bigint FROM growth_logs WHERE user_id = $1", + ) + .bind(user_id) + .fetch_one(exec) + .await?) +} + +/// Append one accrual per user and reference; `None` means it already existed. +pub async fn insert_growth_log( + tx: &mut PgConnection, + user_id: Uuid, + delta: i64, + growth_total: i64, + reason: &str, + reference_type: &str, + reference_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_scalar( + "INSERT INTO growth_logs + (user_id, delta, growth_total, reason, reference_type, reference_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_id, reference_type, reference_id) WHERE reference_id IS NOT NULL + DO NOTHING + RETURNING id", + ) + .bind(user_id) + .bind(delta) + .bind(growth_total) + .bind(reason) + .bind(reference_type) + .bind(reference_id) + .fetch_optional(&mut *tx) + .await?) +} + +/// One-way leveling: only ever move to a strictly higher threshold. +pub async fn upgrade_level( + tx: &mut PgConnection, + user_id: Uuid, + level_id: Uuid, + threshold: i64, +) -> ApiResult { + let result = sqlx::query( + "UPDATE users + SET level = $2 + WHERE id = $1 + AND ( + level IS NULL + OR (SELECT growth_threshold FROM member_levels WHERE id = users.level) < $3 + )", + ) + .bind(user_id) + .bind(level_id) + .bind(threshold) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected()) +} + +pub async fn list_growth_logs( + db: &PgPool, + user_id: Uuid, + limit: i64, + offset: i64, +) -> ApiResult> { + Ok(sqlx::query_as::<_, GrowthLogEntry>(&format!( + "SELECT {GROWTH_LOG_COLS} FROM growth_logs + WHERE user_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2 OFFSET $3" + )) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(db) + .await?) +} + +pub async fn count_growth_logs(db: &PgPool, user_id: Uuid) -> ApiResult { + Ok( + sqlx::query_scalar("SELECT COUNT(*) FROM growth_logs WHERE user_id = $1") + .bind(user_id) + .fetch_one(db) + .await?, + ) +} diff --git a/apps/api/src/modules/membership/service.rs b/apps/api/src/modules/membership/service.rs new file mode 100644 index 0000000..6d7cb39 --- /dev/null +++ b/apps/api/src/modules/membership/service.rs @@ -0,0 +1,189 @@ +use serde_json::Value; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::error::{unique_conflict, ApiError, ApiResult}; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::models::Order; +use crate::money::convert_minor; +use crate::state::AppState; + +use super::dto::{ + GrowthLogEntry, LevelBody, MemberLevel, MembershipStatus, NextLevel, +}; +use super::repo; + +/// Growth ledger reason recorded when an order completes. +pub const REASON_ORDER_COMPLETE: &str = "order_complete"; +/// Reference kind stored on growth entries. +pub const REF_ORDER: &str = "order"; + +// --- platform admin: level catalog --- + +pub async fn list_levels(state: &AppState) -> ApiResult> { + repo::list_levels(&state.db).await +} + +pub async fn create_level(state: &AppState, body: LevelBody) -> ApiResult { + validate(&body)?; + let mut tx = state.db.begin().await?; + let level = repo::insert_level(&mut tx, &body) + .await + .map_err(|e| unique_conflict(e, "growth threshold already exists"))?; + tx.commit().await?; + Ok(level) +} + +pub async fn update_level( + state: &AppState, + id: Uuid, + body: LevelBody, +) -> ApiResult { + validate(&body)?; + let mut tx = state.db.begin().await?; + let level = repo::update_level(&mut tx, id, &body) + .await + .map_err(|e| unique_conflict(e, "growth threshold already exists"))? + .ok_or_else(|| ApiError::NotFound("member level".into()))?; + tx.commit().await?; + Ok(level) +} + +/// Deleting a level that customers hold is refused instead of orphaning them. +pub async fn delete_level(state: &AppState, id: Uuid) -> ApiResult<()> { + let mut tx = state.db.begin().await?; + let holders = repo::level_holders(&mut *tx, id).await?; + if holders > 0 { + return Err(ApiError::Conflict(format!( + "member level is held by {holders} customer(s)" + ))); + } + if repo::delete_level(&mut tx, id).await? == 0 { + return Err(ApiError::NotFound("member level".into())); + } + tx.commit().await?; + Ok(()) +} + +// --- customer: status and growth history --- + +/// Level, total, and next step, re-derived from the growth ledger against the +/// current thresholds so an admin threshold edit is reflected on the next read. +pub async fn status(state: &AppState, user_id: Uuid) -> ApiResult { + let mut conn = state.db.acquire().await?; + let growth_total = repo::growth_total(&mut *conn, user_id).await?; + let level = repo::level_at_or_below(&mut *conn, growth_total).await?; + let next_level = repo::level_above(&mut *conn, growth_total) + .await? + .map(|level| NextLevel { + id: level.id, + name: level.name, + icon: level.icon, + growth_threshold: level.growth_threshold, + remaining: level.growth_threshold - growth_total, + }); + Ok(MembershipStatus { + level, + growth_total, + next_level, + }) +} + +pub async fn list_growth_logs( + state: &AppState, + user_id: Uuid, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let total = repo::count_growth_logs(&state.db, user_id).await?; + let items = + repo::list_growth_logs(&state.db, user_id, per_page, (page - 1) * per_page).await?; + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +// --- event hook: growth accrual on order completion --- + +/// Accrue growth for one completed order inside the caller's transaction. +/// +/// The order's realized paid amount is converted to the base currency and +/// truncated to whole units with integer arithmetic. One ledger entry is +/// appended per order (the partial unique index makes a retry a no-op), and the +/// customer's level is re-derived and moved up in the same transaction. +pub async fn accrue_for_order(tx: &mut PgConnection, order: &Order) -> ApiResult<()> { + let base = repo::base_currency(&mut *tx) + .await? + .ok_or_else(|| ApiError::Conflict("no base currency configured".into()))?; + let from = repo::currency(&mut *tx, &order.currency) + .await? + .ok_or_else(|| ApiError::BadRequest(format!("unknown currency {}", order.currency)))?; + + // Realized paid amount: the order total minus completed refunds. + let realized_minor = (order.total_minor - order.refund_total_minor).max(0); + let base_minor = convert_minor(realized_minor, &from, &base)?; + let scale = 10i64 + .checked_pow(base.exponent.max(0) as u32) + .ok_or_else(|| ApiError::Internal(anyhow::anyhow!("currency exponent out of range")))?; + let delta = if scale == 0 { 0 } else { base_minor / scale }; + + repo::lock_user(tx, order.user_id).await?; + let previous = repo::growth_total(&mut *tx, order.user_id).await?; + let inserted = repo::insert_growth_log( + tx, + order.user_id, + delta, + previous + delta, + REASON_ORDER_COMPLETE, + REF_ORDER, + order.id, + ) + .await?; + if inserted.is_none() { + // Already accrued for this order; nothing to upgrade again. + return Ok(()); + } + + let total = previous + delta; + if let Some(level) = repo::level_at_or_below(&mut *tx, total).await? { + repo::upgrade_level(tx, order.user_id, level.id, level.growth_threshold).await?; + } + Ok(()) +} + +// --- helpers --- + +fn validate(body: &LevelBody) -> ApiResult<()> { + if body.growth_threshold < 0 { + return Err(ApiError::BadRequest( + "growth_threshold must be zero or greater".into(), + )); + } + if body.icon.trim().is_empty() { + return Err(ApiError::BadRequest("icon is required".into())); + } + localized(&body.name, "name")?; + localized(&body.benefits, "benefits")?; + Ok(()) +} + +/// Level content is user-facing JSONB, so both locales must carry text. +fn localized(label: &Value, field: &str) -> ApiResult<()> { + let ok = ["en", "zh"].iter().all(|code| { + label + .get(code) + .and_then(Value::as_str) + .is_some_and(|s| !s.trim().is_empty()) + }); + if !ok { + return Err(ApiError::BadRequest(format!( + "{field} needs non-empty en and zh" + ))); + } + Ok(()) +} diff --git a/apps/api/src/modules/merchant_onboarding/dto.rs b/apps/api/src/modules/merchant_onboarding/dto.rs new file mode 100644 index 0000000..dee06f6 --- /dev/null +++ b/apps/api/src/modules/merchant_onboarding/dto.rs @@ -0,0 +1,209 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::models::{MerchantApplicationStatus, MerchantEntityType}; + +/// Contact block, shared by both entity kinds. Every field is permissive here +/// so missing input becomes a 400 validation error rather than a 422 parse +/// rejection; the service enforces what is required. +#[derive(Debug, Default, Deserialize)] +pub struct ContactBody { + #[serde(default)] + pub name: String, + #[serde(default)] + pub phone: String, + #[serde(default)] + pub email: String, + #[serde(default)] + pub address: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct PersonalQualification { + #[serde(default)] + pub identity_document_url: Option, + #[serde(default)] + pub extra_materials: Vec, +} + +#[derive(Debug, Default, Deserialize)] +pub struct EnterpriseQualification { + #[serde(default)] + pub business_license_url: Option, + #[serde(default)] + pub business_license_no: Option, + #[serde(default)] + pub extra_materials: Vec, +} + +/// Discriminated submission body; `entity_type` picks the kind. +#[derive(Debug, Deserialize)] +#[serde(tag = "entity_type", rename_all = "snake_case")] +pub enum SubmitBody { + Personal { + #[serde(default)] + real_name: Option, + #[serde(default)] + category_ids: Vec, + #[serde(default)] + contact: ContactBody, + #[serde(default)] + qualification: PersonalQualification, + }, + Enterprise { + #[serde(default)] + company_name: Option, + #[serde(default)] + category_ids: Vec, + #[serde(default)] + contact: ContactBody, + #[serde(default)] + qualification: EnterpriseQualification, + }, +} + +#[derive(Debug, Deserialize)] +pub struct RejectBody { + #[serde(default)] + pub reason: String, +} + +/// Raw row as stored, joined with the applicant's login email. +#[derive(Debug, sqlx::FromRow)] +pub struct ApplicationRow { + pub id: Uuid, + pub user_id: Uuid, + pub applicant_email: String, + pub entity_type: MerchantEntityType, + pub real_name: Option, + pub company_name: Option, + pub business_license_no: Option, + pub category_ids: Vec, + pub contact_name: String, + pub contact_phone: String, + pub contact_email: String, + pub contact_address: Option, + pub identity_document_url: Option, + pub business_license_url: Option, + pub extra_materials: Value, + pub status: MerchantApplicationStatus, + pub rejection_reason: Option, + pub reviewed_at: Option>, + pub created_shop_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct CategoryRef { + pub id: Uuid, + pub name: Value, +} + +#[derive(Debug, Serialize)] +pub struct ContactView { + pub name: String, + pub phone: String, + pub email: String, + pub address: Option, +} + +#[derive(Debug, Serialize)] +pub struct QualificationView { + pub identity_document_url: Option, + pub business_license_url: Option, + pub business_license_no: Option, + pub extra_materials: Vec, +} + +/// Public application payload. Never carries credentials. +#[derive(Debug, Serialize)] +pub struct ApplicationView { + pub id: Uuid, + pub user_id: Uuid, + pub applicant_email: String, + pub entity_type: MerchantEntityType, + pub real_name: Option, + pub company_name: Option, + pub business_license_no: Option, + pub category_ids: Vec, + pub categories: Vec, + pub contact: ContactView, + pub qualification: QualificationView, + pub status: MerchantApplicationStatus, + pub rejection_reason: Option, + pub reviewed_at: Option>, + pub created_shop_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ApplicationView { + pub fn from_row(row: ApplicationRow, categories: Vec) -> Self { + let extra_materials = serde_json::from_value::>(row.extra_materials.clone()) + .unwrap_or_default(); + Self { + id: row.id, + user_id: row.user_id, + applicant_email: row.applicant_email, + entity_type: row.entity_type, + real_name: row.real_name, + company_name: row.company_name, + business_license_no: row.business_license_no.clone(), + category_ids: row.category_ids, + categories, + contact: ContactView { + name: row.contact_name, + phone: row.contact_phone, + email: row.contact_email, + address: row.contact_address, + }, + qualification: QualificationView { + identity_document_url: row.identity_document_url, + business_license_url: row.business_license_url, + business_license_no: row.business_license_no.clone(), + extra_materials, + }, + status: row.status, + rejection_reason: row.rejection_reason, + reviewed_at: row.reviewed_at, + created_shop_id: row.created_shop_id, + created_at: row.created_at, + updated_at: row.updated_at, + } + } +} + +/// Validated, normalized insert payload. +pub struct NewApplication { + pub user_id: Uuid, + pub entity_type: MerchantEntityType, + pub real_name: Option, + pub company_name: Option, + pub business_license_no: Option, + pub category_ids: Vec, + pub contact_name: String, + pub contact_phone: String, + pub contact_email: String, + pub contact_address: Option, + pub identity_document_url: Option, + pub business_license_url: Option, + pub extra_materials: Vec, +} + +/// One-time login handed to the approver; present only on the approve response. +#[derive(Debug, Serialize)] +pub struct OwnerCredentials { + pub email: String, + pub initial_password: String, + pub shop_id: Uuid, + pub shop_slug: String, +} + +#[derive(Debug, Serialize)] +pub struct ApprovalResult { + pub application: ApplicationView, + pub credentials: OwnerCredentials, +} diff --git a/apps/api/src/modules/merchant_onboarding/handlers.rs b/apps/api/src/modules/merchant_onboarding/handlers.rs new file mode 100644 index 0000000..47c2154 --- /dev/null +++ b/apps/api/src/modules/merchant_onboarding/handlers.rs @@ -0,0 +1,111 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::http::Paged; +use crate::models::MerchantApplicationStatus; +use crate::state::AppState; + +use super::dto::{ApplicationView, ApprovalResult, RejectBody, SubmitBody}; +use super::service; + +pub fn router() -> Router { + Router::new() + .route( + "/merchant/applications", + post(submit).get(list_mine), + ) + .route( + "/admin/merchant/applications", + get(list_admin), + ) + .route( + "/admin/merchant/applications/{id}", + get(get_application), + ) + .route( + "/admin/merchant/applications/{id}/approve", + post(approve), + ) + .route( + "/admin/merchant/applications/{id}/reject", + post(reject), + ) +} + +#[derive(Deserialize)] +struct AdminListQuery { + status: Option, + page: Option, + per_page: Option, +} + +// --- applicant --- + +async fn submit( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + Ok(( + StatusCode::CREATED, + Json(service::submit(&state, auth.id, body).await?), + )) +} + +async fn list_mine( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + Ok(Json(service::list_mine(&state, auth.id).await?)) +} + +// --- platform admin --- + +async fn list_admin( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json( + service::list_admin(&state, q.status, q.page, q.per_page).await?, + )) +} + +async fn get_application( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::get_admin(&state, id).await?)) +} + +async fn approve( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::approve(&state, auth.id, id).await?)) +} + +async fn reject( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json( + service::reject(&state, auth.id, id, &body.reason).await?, + )) +} diff --git a/apps/api/src/modules/merchant_onboarding/mod.rs b/apps/api/src/modules/merchant_onboarding/mod.rs new file mode 100644 index 0000000..08f7f71 --- /dev/null +++ b/apps/api/src/modules/merchant_onboarding/mod.rs @@ -0,0 +1,6 @@ +pub mod dto; +pub mod handlers; +pub mod repo; +pub mod service; + +pub use handlers::router; diff --git a/apps/api/src/modules/merchant_onboarding/repo.rs b/apps/api/src/modules/merchant_onboarding/repo.rs new file mode 100644 index 0000000..f146f5d --- /dev/null +++ b/apps/api/src/modules/merchant_onboarding/repo.rs @@ -0,0 +1,194 @@ +use sqlx::{PgConnection, PgExecutor, PgPool}; +use uuid::Uuid; + +use crate::error::ApiResult; +use crate::models::MerchantApplicationStatus; + +use super::dto::{ApplicationRow, CategoryRef, NewApplication}; + +const COLS: &str = "a.id, a.user_id, u.email AS applicant_email, a.entity_type, a.real_name, + a.company_name, a.business_license_no, a.category_ids, a.contact_name, a.contact_phone, + a.contact_email, a.contact_address, a.identity_document_url, a.business_license_url, + a.extra_materials, a.status, a.rejection_reason, a.reviewed_at, a.created_shop_id, + a.created_at, a.updated_at"; +const FROM: &str = "merchant_applications a JOIN users u ON u.id = a.user_id"; + +/// Insert a pending application. The partial unique index rejects a second +/// live row, so the caller maps a unique violation to a 409. +pub async fn insert( + tx: &mut PgConnection, + new: &NewApplication, +) -> Result { + let materials = serde_json::Value::from(new.extra_materials.clone()); + sqlx::query_scalar( + "INSERT INTO merchant_applications + (user_id, entity_type, real_name, company_name, business_license_no, category_ids, + contact_name, contact_phone, contact_email, contact_address, + identity_document_url, business_license_url, extra_materials) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING id", + ) + .bind(new.user_id) + .bind(new.entity_type) + .bind(&new.real_name) + .bind(&new.company_name) + .bind(&new.business_license_no) + .bind(&new.category_ids) + .bind(&new.contact_name) + .bind(&new.contact_phone) + .bind(&new.contact_email) + .bind(&new.contact_address) + .bind(&new.identity_document_url) + .bind(&new.business_license_url) + .bind(&materials) + .fetch_one(&mut *tx) + .await +} + +pub async fn get<'e, E: PgExecutor<'e>>(exec: E, id: Uuid) -> ApiResult> { + Ok(sqlx::query_as::<_, ApplicationRow>(&format!( + "SELECT {COLS} FROM {FROM} WHERE a.id = $1" + )) + .bind(id) + .fetch_optional(exec) + .await?) +} + +/// Lock the row so two concurrent reviews serialize on it. +pub async fn get_for_update( + tx: &mut PgConnection, + id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, ApplicationRow>(&format!( + "SELECT {COLS} FROM {FROM} WHERE a.id = $1 FOR UPDATE OF a" + )) + .bind(id) + .fetch_optional(&mut *tx) + .await?) +} + +pub async fn list_for_user<'e, E: PgExecutor<'e>>( + exec: E, + user_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, ApplicationRow>(&format!( + "SELECT {COLS} FROM {FROM} WHERE a.user_id = $1 ORDER BY a.created_at DESC, a.id DESC" + )) + .bind(user_id) + .fetch_all(exec) + .await?) +} + +pub async fn has_active_application<'e, E: PgExecutor<'e>>( + exec: E, + user_id: Uuid, +) -> ApiResult { + Ok(sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM merchant_applications + WHERE user_id = $1 AND status IN ('pending', 'approved') + )", + ) + .bind(user_id) + .fetch_one(exec) + .await?) +} + +pub async fn list_page( + db: &PgPool, + status: Option, + limit: i64, + offset: i64, +) -> ApiResult> { + Ok(sqlx::query_as::<_, ApplicationRow>(&format!( + "SELECT {COLS} FROM {FROM} + WHERE ($1::merchant_application_status IS NULL OR a.status = $1) + ORDER BY a.created_at DESC, a.id DESC + LIMIT $2 OFFSET $3" + )) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(db) + .await?) +} + +pub async fn count(db: &PgPool, status: Option) -> ApiResult { + Ok(sqlx::query_scalar( + "SELECT COUNT(*) FROM merchant_applications a + WHERE ($1::merchant_application_status IS NULL OR a.status = $1)", + ) + .bind(status) + .fetch_one(db) + .await?) +} + +/// Guarded `pending -> approved`; zero rows means it was already reviewed. +pub async fn approve( + tx: &mut PgConnection, + id: Uuid, + admin_id: Uuid, + shop_id: Uuid, +) -> ApiResult { + let result = sqlx::query( + "UPDATE merchant_applications + SET status = 'approved', reviewed_by = $2, reviewed_at = now(), + created_shop_id = $3, updated_at = now() + WHERE id = $1 AND status = 'pending'", + ) + .bind(id) + .bind(admin_id) + .bind(shop_id) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected()) +} + +/// Guarded `pending -> rejected`; the schema CHECK also demands a reason. +pub async fn reject( + tx: &mut PgConnection, + id: Uuid, + admin_id: Uuid, + reason: &str, +) -> ApiResult { + let result = sqlx::query( + "UPDATE merchant_applications + SET status = 'rejected', reviewed_by = $2, reviewed_at = now(), + rejection_reason = $3, updated_at = now() + WHERE id = $1 AND status = 'pending'", + ) + .bind(id) + .bind(admin_id) + .bind(reason) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected()) +} + +/// Resolve submitted ids against the reference category tree. +pub async fn categories<'e, E: PgExecutor<'e>>( + exec: E, + ids: &[Uuid], +) -> ApiResult> { + if ids.is_empty() { + return Ok(Vec::new()); + } + Ok(sqlx::query_as::<_, CategoryRef>( + "SELECT id, name FROM categories WHERE id = ANY($1)", + ) + .bind(ids) + .fetch_all(exec) + .await?) +} + +pub async fn email_exists<'e, E: PgExecutor<'e>>( + exec: E, + email: &str, +) -> ApiResult { + Ok( + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)") + .bind(email) + .fetch_one(exec) + .await?, + ) +} diff --git a/apps/api/src/modules/merchant_onboarding/service.rs b/apps/api/src/modules/merchant_onboarding/service.rs new file mode 100644 index 0000000..031c3f0 --- /dev/null +++ b/apps/api/src/modules/merchant_onboarding/service.rs @@ -0,0 +1,368 @@ +use serde_json::json; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::auth::hash_password; +use crate::error::{unique_conflict, ApiError, ApiResult}; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::models::{MerchantApplicationStatus, MerchantEntityType, UserRole}; +use crate::modules::{account, identity, shop}; +use crate::state::AppState; + +use super::dto::{ + ApplicationRow, ApplicationView, ApprovalResult, CategoryRef, ContactBody, NewApplication, + OwnerCredentials, SubmitBody, +}; +use super::repo; + +/// Submit a merchant onboarding application for review. +pub async fn submit( + state: &AppState, + user_id: Uuid, + body: SubmitBody, +) -> ApiResult { + let new = normalize(user_id, body)?; + + // Operating categories must reference the published category tree. + let known = repo::categories(&state.db, &new.category_ids).await?; + if known.len() != new.category_ids.len() { + return Err(ApiError::BadRequest( + "one or more operating categories do not exist".into(), + )); + } + + let mut tx = state.db.begin().await?; + // Service-level dedupe; the partial unique index is the concurrency backstop. + if repo::has_active_application(&mut *tx, user_id).await? { + return Err(ApiError::Conflict( + "an active merchant application already exists".into(), + )); + } + let id = repo::insert(&mut tx, &new) + .await + .map_err(|e| unique_conflict(e, "an active merchant application already exists"))?; + let view = fetch(&mut tx, id).await?; + tx.commit().await?; + Ok(view) +} + +/// The caller's own application history, newest first. +pub async fn list_mine(state: &AppState, user_id: Uuid) -> ApiResult> { + let mut conn = state.db.acquire().await?; + let rows = repo::list_for_user(&mut *conn, user_id).await?; + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let categories = repo::categories(&mut *conn, &row.category_ids).await?; + out.push(build(row, categories)); + } + Ok(out) +} + +pub async fn list_admin( + state: &AppState, + status: Option, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let total = repo::count(&state.db, status).await?; + let rows = repo::list_page(&state.db, status, per_page, (page - 1) * per_page).await?; + let mut conn = state.db.acquire().await?; + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let categories = repo::categories(&mut *conn, &row.category_ids).await?; + items.push(build(row, categories)); + } + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +pub async fn get_admin(state: &AppState, id: Uuid) -> ApiResult { + let mut conn = state.db.acquire().await?; + fetch(&mut conn, id).await +} + +/// Reject a pending application; the reason is mandatory and recorded. +pub async fn reject( + state: &AppState, + admin_id: Uuid, + id: Uuid, + reason: &str, +) -> ApiResult { + let reason = reason.trim(); + if reason.is_empty() { + return Err(ApiError::BadRequest("rejection reason is required".into())); + } + let mut tx = state.db.begin().await?; + if repo::get(&mut *tx, id).await?.is_none() { + return Err(ApiError::NotFound("merchant application".into())); + } + if repo::reject(&mut tx, id, admin_id, reason).await? == 0 { + return Err(ApiError::Conflict( + "application was already reviewed".into(), + )); + } + let view = fetch(&mut tx, id).await?; + tx.commit().await?; + Ok(view) +} + +/// Approve a pending application: shop + dedicated `shop_owner` account + +/// status flip all commit together, and the initial password is returned once. +pub async fn approve(state: &AppState, admin_id: Uuid, id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + // Lock the row so a concurrent review blocks here and then sees a + // non-pending state instead of provisioning twice. + let row = repo::get_for_update(&mut tx, id) + .await? + .ok_or_else(|| ApiError::NotFound("merchant application".into()))?; + if row.status != MerchantApplicationStatus::Pending { + return Err(ApiError::Conflict( + "application was already reviewed".into(), + )); + } + + let entity_name = row + .company_name + .clone() + .or_else(|| row.real_name.clone()) + .unwrap_or_default(); + let shop = shop::service::create_in_tx( + &mut tx, + json!({ "en": entity_name, "zh": entity_name }), + &slug_for(&entity_name, row.id), + ) + .await?; + + let initial_password = initial_password(); + let password_hash = hash_password(&initial_password)?; + let owner_email = unique_owner_email(&mut tx, &row.contact_email).await?; + let owner = identity::repo::insert_user( + &mut *tx, + &owner_email, + &password_hash, + &entity_name, + UserRole::ShopOwner, + Some(shop.id), + ) + .await + .map_err(|e| unique_conflict(e, "owner email already registered"))?; + account::service::ensure_accounts(&mut tx, owner.id).await?; + + if repo::approve(&mut tx, id, admin_id, shop.id).await? == 0 { + // Rolled back with the provisioning above. + return Err(ApiError::Conflict( + "application was already reviewed".into(), + )); + } + let view = fetch(&mut tx, id).await?; + tx.commit().await?; + + Ok(ApprovalResult { + application: view, + credentials: OwnerCredentials { + email: owner_email, + initial_password, + shop_id: shop.id, + shop_slug: shop.slug, + }, + }) +} + +// --- helpers --- + +async fn fetch(conn: &mut PgConnection, id: Uuid) -> ApiResult { + let row = repo::get(&mut *conn, id) + .await? + .ok_or_else(|| ApiError::NotFound("merchant application".into()))?; + let categories = repo::categories(&mut *conn, &row.category_ids).await?; + Ok(build(row, categories)) +} + +fn build(row: ApplicationRow, categories: Vec) -> ApplicationView { + // Keep the submitted category order. + let mut ordered = Vec::with_capacity(row.category_ids.len()); + for id in &row.category_ids { + if let Some(found) = categories.iter().find(|c| c.id == *id) { + ordered.push(found.clone()); + } + } + ApplicationView::from_row(row, ordered) +} + +/// Validate the discriminated body and normalize it for insert. +fn normalize(user_id: Uuid, body: SubmitBody) -> ApiResult { + match body { + SubmitBody::Personal { + real_name, + category_ids, + contact, + qualification, + } => { + let real_name = required(real_name, "real_name")?; + let identity_document_url = required( + qualification.identity_document_url, + "qualification.identity_document_url", + )?; + check_url(&identity_document_url, "qualification.identity_document_url")?; + let items = check_materials(&qualification.extra_materials)?; + let contact = normalize_contact(contact)?; + Ok(NewApplication { + user_id, + entity_type: MerchantEntityType::Personal, + real_name: Some(real_name), + company_name: None, + business_license_no: None, + category_ids: check_categories(category_ids)?, + contact_name: contact.0, + contact_phone: contact.1, + contact_email: contact.2, + contact_address: contact.3, + identity_document_url: Some(identity_document_url), + business_license_url: None, + extra_materials: items, + }) + } + SubmitBody::Enterprise { + company_name, + category_ids, + contact, + qualification, + } => { + let company_name = required(company_name, "company_name")?; + let business_license_url = required( + qualification.business_license_url, + "qualification.business_license_url", + )?; + check_url(&business_license_url, "qualification.business_license_url")?; + let business_license_no = required( + qualification.business_license_no, + "qualification.business_license_no", + )?; + let items = check_materials(&qualification.extra_materials)?; + let contact = normalize_contact(contact)?; + Ok(NewApplication { + user_id, + entity_type: MerchantEntityType::Enterprise, + real_name: None, + company_name: Some(company_name), + business_license_no: Some(business_license_no), + category_ids: check_categories(category_ids)?, + contact_name: contact.0, + contact_phone: contact.1, + contact_email: contact.2, + contact_address: contact.3, + identity_document_url: None, + business_license_url: Some(business_license_url), + extra_materials: items, + }) + } + } +} + +fn required(value: Option, field: &str) -> ApiResult { + match value.map(|v| v.trim().to_string()) { + Some(v) if !v.is_empty() => Ok(v), + _ => Err(ApiError::BadRequest(format!("{field} is required"))), + } +} + +fn check_categories(ids: Vec) -> ApiResult> { + if ids.is_empty() { + return Err(ApiError::BadRequest( + "at least one operating category is required".into(), + )); + } + Ok(ids) +} + +fn check_url(value: &str, field: &str) -> ApiResult<()> { + let v = value.trim(); + let ok = (v.starts_with("http://") || v.starts_with("https://")) + && v.len() > "https://".len() + && !v.contains(char::is_whitespace); + if !ok { + return Err(ApiError::BadRequest(format!( + "{field} must be an http(s) URL" + ))); + } + Ok(()) +} + +fn check_materials(items: &[String]) -> ApiResult> { + let mut out = Vec::with_capacity(items.len()); + for item in items { + let trimmed = item.trim(); + check_url(trimmed, "qualification.extra_materials")?; + out.push(trimmed.to_string()); + } + Ok(out) +} + +/// (name, phone, email, address) with the shared contact rules applied. +fn normalize_contact(contact: ContactBody) -> ApiResult<(String, String, String, Option)> { + let name = required(Some(contact.name), "contact.name")?; + let phone = required(Some(contact.phone), "contact.phone")?; + let email = required(Some(contact.email), "contact.email")?.to_lowercase(); + if !email.contains('@') || email.starts_with('@') || email.ends_with('@') { + return Err(ApiError::BadRequest("contact.email is invalid".into())); + } + let address = contact + .address + .map(|a| a.trim().to_string()) + .filter(|a| !a.is_empty()); + Ok((name, phone, email, address)) +} + +/// URL-safe shop slug derived from the entity name, suffixed with the +/// application id so it is deterministic and collision-free. +fn slug_for(entity_name: &str, id: Uuid) -> String { + let base: String = entity_name + .trim() + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let base = base + .split('-') + .filter(|part| !part.is_empty()) + .collect::>() + .join("-"); + let base = if base.is_empty() { + "shop".to_string() + } else { + base.chars().take(40).collect() + }; + format!("{base}-{}", &id.simple().to_string()[..6]) +} + +/// 12 hex characters; long enough for the password rule, short enough to read. +fn initial_password() -> String { + Uuid::new_v4().simple().to_string()[..12].to_string() +} + +/// Prefer the submitted contact email; if it is taken (typically because the +/// applicant registered with it), derive a distinct owner address. +async fn unique_owner_email(tx: &mut PgConnection, desired: &str) -> ApiResult { + let base = desired.trim().to_lowercase(); + if !base.is_empty() && !repo::email_exists(&mut *tx, &base).await? { + return Ok(base); + } + let short = &Uuid::new_v4().simple().to_string()[..6]; + match base.split_once('@') { + Some((local, domain)) if !local.is_empty() && !domain.is_empty() => { + let candidate = format!("{local}+owner@{domain}"); + if !repo::email_exists(&mut *tx, &candidate).await? { + Ok(candidate) + } else { + Ok(format!("{local}+owner-{short}@{domain}")) + } + } + _ => Ok(format!("merchant-{short}@vmall.local")), + } +} diff --git a/apps/api/src/modules/messaging/dto.rs b/apps/api/src/modules/messaging/dto.rs new file mode 100644 index 0000000..32899f9 --- /dev/null +++ b/apps/api/src/modules/messaging/dto.rs @@ -0,0 +1,50 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::models::{MessageKind, MessageStatus}; + +/// One system message owned by a customer. +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct MessageRow { + pub id: Uuid, + pub kind: MessageKind, + pub title: Value, + pub body: Value, + pub reference_type: Option, + pub reference_id: Option, + pub status: MessageStatus, + pub read_at: Option>, + pub created_at: DateTime, +} + +pub const MESSAGE_COLS: &str = "id, kind, title, body, reference_type, reference_id, \ + status, read_at, created_at"; + +#[derive(Debug, Default, Deserialize)] +pub struct ListQuery { + #[serde(default)] + pub page: Option, + #[serde(default)] + pub per_page: Option, + #[serde(default)] + pub unread_only: Option, +} + +#[derive(Debug, Serialize)] +pub struct MarkAllResult { + pub updated: u64, +} + +#[derive(Debug, Serialize)] +pub struct UnreadCount { + pub unread: i64, +} + +#[derive(Debug, Serialize)] +pub struct DeleteResult { + pub id: Uuid, + /// Whether this call actually set the soft-delete marker. + pub deleted: bool, +} diff --git a/apps/api/src/modules/messaging/handlers.rs b/apps/api/src/modules/messaging/handlers.rs new file mode 100644 index 0000000..9eab74d --- /dev/null +++ b/apps/api/src/modules/messaging/handlers.rs @@ -0,0 +1,61 @@ +use axum::{ + extract::{Path, Query, State}, + routing::{get, post}, + Json, Router, +}; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::http::Paged; +use crate::state::AppState; + +use super::dto::{DeleteResult, ListQuery, MarkAllResult, MessageRow, UnreadCount}; +use super::service; + +pub fn router() -> Router { + Router::new() + .route("/messages", get(list)) + .route("/messages/unread-count", get(unread_count)) + .route("/messages/read-all", post(mark_all_read)) + .route("/messages/{id}/read", post(mark_read)) + .route("/messages/{id}", axum::routing::delete(delete)) +} + +async fn list( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + Ok(Json(service::list(&state, auth.id, q).await?)) +} + +async fn unread_count( + State(state): State, + auth: AuthUser, +) -> ApiResult> { + Ok(Json(service::unread_count(&state, auth.id).await?)) +} + +async fn mark_read( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + Ok(Json(service::mark_read(&state, auth.id, id).await?)) +} + +async fn mark_all_read( + State(state): State, + auth: AuthUser, +) -> ApiResult> { + Ok(Json(service::mark_all_read(&state, auth.id).await?)) +} + +async fn delete( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + Ok(Json(service::delete(&state, auth.id, id).await?)) +} diff --git a/apps/api/src/modules/messaging/mod.rs b/apps/api/src/modules/messaging/mod.rs new file mode 100644 index 0000000..08f7f71 --- /dev/null +++ b/apps/api/src/modules/messaging/mod.rs @@ -0,0 +1,6 @@ +pub mod dto; +pub mod handlers; +pub mod repo; +pub mod service; + +pub use handlers::router; diff --git a/apps/api/src/modules/messaging/repo.rs b/apps/api/src/modules/messaging/repo.rs new file mode 100644 index 0000000..810da06 --- /dev/null +++ b/apps/api/src/modules/messaging/repo.rs @@ -0,0 +1,130 @@ +use sqlx::{PgConnection, PgExecutor, PgPool}; +use uuid::Uuid; + +use crate::error::ApiResult; +use crate::models::MessageKind; + +use super::dto::{MessageRow, MESSAGE_COLS}; + +/// Emit one message per user, kind, and reference; a repeat is a no-op. +pub async fn insert_event( + tx: &mut PgConnection, + user_id: Uuid, + kind: MessageKind, + title: &serde_json::Value, + body: &serde_json::Value, + reference_type: &str, + reference_id: Uuid, +) -> ApiResult<()> { + sqlx::query( + "INSERT INTO messages (user_id, kind, title, body, reference_type, reference_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_id, kind, reference_type, reference_id) + WHERE reference_id IS NOT NULL + DO NOTHING", + ) + .bind(user_id) + .bind(kind) + .bind(title) + .bind(body) + .bind(reference_type) + .bind(reference_id) + .execute(&mut *tx) + .await?; + Ok(()) +} + +/// One owned message, soft-deleted rows included (deletion is idempotent). +pub async fn get_owned<'e, E: PgExecutor<'e>>( + exec: E, + id: Uuid, + user_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, MessageRow>(&format!( + "SELECT {MESSAGE_COLS} FROM messages WHERE id = $1 AND user_id = $2" + )) + .bind(id) + .bind(user_id) + .fetch_optional(exec) + .await?) +} + +pub async fn list_page( + db: &PgPool, + user_id: Uuid, + unread_only: bool, + limit: i64, + offset: i64, +) -> ApiResult> { + Ok(sqlx::query_as::<_, MessageRow>(&format!( + "SELECT {MESSAGE_COLS} FROM messages + WHERE user_id = $1 AND deleted_at IS NULL AND (NOT $2 OR status = 'unread') + ORDER BY created_at DESC, id DESC + LIMIT $3 OFFSET $4" + )) + .bind(user_id) + .bind(unread_only) + .bind(limit) + .bind(offset) + .fetch_all(db) + .await?) +} + +pub async fn count(db: &PgPool, user_id: Uuid, unread_only: bool) -> ApiResult { + Ok(sqlx::query_scalar( + "SELECT COUNT(*) FROM messages + WHERE user_id = $1 AND deleted_at IS NULL AND (NOT $2 OR status = 'unread')", + ) + .bind(user_id) + .bind(unread_only) + .fetch_one(db) + .await?) +} + +/// Guarded single read: only an `unread`, non-deleted row flips. +pub async fn mark_read(tx: &mut PgConnection, id: Uuid, user_id: Uuid) -> ApiResult { + let result = sqlx::query( + "UPDATE messages SET status = 'read', read_at = now() + WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL AND status = 'unread'", + ) + .bind(id) + .bind(user_id) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected()) +} + +/// Guarded bulk read: touches only unread, non-deleted rows of one customer. +pub async fn mark_all_read(tx: &mut PgConnection, user_id: Uuid) -> ApiResult { + let result = sqlx::query( + "UPDATE messages SET status = 'read', read_at = now() + WHERE user_id = $1 AND deleted_at IS NULL AND status = 'unread'", + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected()) +} + +/// Soft delete; only an undeleted owned row flips, so a repeat is a no-op. +pub async fn soft_delete(tx: &mut PgConnection, id: Uuid, user_id: Uuid) -> ApiResult { + let result = sqlx::query( + "UPDATE messages SET deleted_at = now() + WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL", + ) + .bind(id) + .bind(user_id) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected()) +} + +pub async fn unread_count(db: &PgPool, user_id: Uuid) -> ApiResult { + Ok(sqlx::query_scalar( + "SELECT COUNT(*) FROM messages + WHERE user_id = $1 AND deleted_at IS NULL AND status = 'unread'", + ) + .bind(user_id) + .fetch_one(db) + .await?) +} diff --git a/apps/api/src/modules/messaging/service.rs b/apps/api/src/modules/messaging/service.rs new file mode 100644 index 0000000..db224ca --- /dev/null +++ b/apps/api/src/modules/messaging/service.rs @@ -0,0 +1,123 @@ +use serde_json::json; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::models::MessageKind; +use crate::state::AppState; + +use super::dto::{ + DeleteResult, ListQuery, MarkAllResult, MessageRow, UnreadCount, +}; +use super::repo; + +/// The caller's own messages, newest first, soft-deleted rows excluded. +pub async fn list( + state: &AppState, + user_id: Uuid, + q: ListQuery, +) -> ApiResult> { + let page = clamp_page(q.page); + let per_page = clamp_per_page(q.per_page); + let unread_only = q.unread_only.unwrap_or(false); + let total = repo::count(&state.db, user_id, unread_only).await?; + let items = + repo::list_page(&state.db, user_id, unread_only, per_page, (page - 1) * per_page).await?; + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +/// Mark one owned message read. A repeat is a no-op that still returns the row; +/// another customer's message is a 404. +pub async fn mark_read(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + if repo::get_owned(&mut *tx, id, user_id).await?.is_none() { + return Err(ApiError::NotFound("message".into())); + } + repo::mark_read(&mut tx, id, user_id).await?; + let row = repo::get_owned(&mut *tx, id, user_id) + .await? + .ok_or_else(|| ApiError::NotFound("message".into()))?; + tx.commit().await?; + Ok(row) +} + +/// Mark every unread message of the caller read; returns how many flipped. +pub async fn mark_all_read(state: &AppState, user_id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + let updated = repo::mark_all_read(&mut tx, user_id).await?; + tx.commit().await?; + Ok(MarkAllResult { updated }) +} + +/// Soft-delete one owned message; a repeat reports `deleted: false`. +pub async fn delete(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + if repo::get_owned(&mut *tx, id, user_id).await?.is_none() { + return Err(ApiError::NotFound("message".into())); + } + let deleted = repo::soft_delete(&mut tx, id, user_id).await? > 0; + tx.commit().await?; + Ok(DeleteResult { id, deleted }) +} + +pub async fn unread_count(state: &AppState, user_id: Uuid) -> ApiResult { + Ok(UnreadCount { + unread: repo::unread_count(&state.db, user_id).await?, + }) +} + +/// Emit one system message for an order/shipment/refund event inside the +/// caller's transaction. Idempotent per user, kind, and reference. +pub async fn emit_event( + tx: &mut PgConnection, + user_id: Uuid, + kind: MessageKind, + reference_type: &str, + reference_id: Uuid, + order_no: &str, +) -> ApiResult<()> { + let (title, body) = template(kind, order_no); + repo::insert_event( + tx, + user_id, + kind, + &title, + &body, + reference_type, + reference_id, + ) + .await +} + +/// Bilingual copy naming the affected order. +fn template(kind: MessageKind, order_no: &str) -> (serde_json::Value, serde_json::Value) { + match kind { + MessageKind::OrderPaid => ( + json!({ "en": "Payment received", "zh": "付款成功" }), + json!({ + "en": format!("Order {order_no} is paid and awaiting shipment."), + "zh": format!("订单 {order_no} 已付款,等待发货。"), + }), + ), + MessageKind::OrderShipped => ( + json!({ "en": "Order shipped", "zh": "订单已发货" }), + json!({ + "en": format!("Order {order_no} has been dispatched."), + "zh": format!("订单 {order_no} 已发货。"), + }), + ), + MessageKind::RefundCompleted => ( + json!({ "en": "Refund completed", "zh": "退款完成" }), + json!({ + "en": format!("Your refund for order {order_no} has been issued."), + "zh": format!("订单 {order_no} 的退款已完成。"), + }), + ), + } +} diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index e3d98ee..3fd5764 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -15,11 +15,16 @@ pub mod fulfillment; pub mod group_buying; pub mod health; pub mod identity; +pub mod membership; +pub mod merchant_onboarding; +pub mod messaging; pub mod order; pub mod points; pub mod product; pub mod review; +pub mod settlement; pub mod shop; +pub mod wallet; use axum::Router; @@ -49,4 +54,9 @@ pub fn api_router() -> Router { .merge(review::router()) .merge(fulfillment::router()) .merge(billing::router()) + .merge(wallet::router()) + .merge(settlement::router()) + .merge(merchant_onboarding::router()) + .merge(membership::router()) + .merge(messaging::router()) } diff --git a/apps/api/src/modules/order/repo.rs b/apps/api/src/modules/order/repo.rs index 5f2c2e9..0a80b38 100644 --- a/apps/api/src/modules/order/repo.rs +++ b/apps/api/src/modules/order/repo.rs @@ -281,7 +281,7 @@ pub async fn mark_fulfilling(tx: &mut PgConnection, order_id: Uuid) -> ApiResult Ok(()) } -pub async fn maybe_mark_order_shipped(db: &PgPool, order_id: Uuid) -> ApiResult<()> { +pub async fn maybe_mark_order_shipped(tx: &mut PgConnection, order_id: Uuid) -> ApiResult<()> { let fully_covered: bool = sqlx::query_scalar( "SELECT NOT EXISTS ( SELECT 1 FROM order_items oi @@ -293,7 +293,7 @@ pub async fn maybe_mark_order_shipped(db: &PgPool, order_id: Uuid) -> ApiResult< )", ) .bind(order_id) - .fetch_one(db) + .fetch_one(&mut *tx) .await?; if fully_covered { sqlx::query( @@ -301,21 +301,26 @@ pub async fn maybe_mark_order_shipped(db: &PgPool, order_id: Uuid) -> ApiResult< WHERE id = $1 AND status = 'fulfilling'", ) .bind(order_id) - .execute(db) + .execute(&mut *tx) .await?; } Ok(()) } -pub async fn maybe_mark_order_completed(db: &PgPool, order_id: Uuid) -> ApiResult<()> { +/// Returns whether this call actually completed the order, so the caller can +/// accrue growth exactly once. +pub async fn maybe_mark_order_completed( + tx: &mut PgConnection, + order_id: Uuid, +) -> ApiResult { let open_shipments: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM shipments WHERE order_id = $1 AND status <> 'delivered')", ) .bind(order_id) - .fetch_one(db) + .fetch_one(&mut *tx) .await?; if open_shipments { - return Ok(()); + return Ok(false); } let fully_covered: bool = sqlx::query_scalar( "SELECT NOT EXISTS ( @@ -328,20 +333,33 @@ pub async fn maybe_mark_order_completed(db: &PgPool, order_id: Uuid) -> ApiResul )", ) .bind(order_id) - .fetch_one(db) + .fetch_one(&mut *tx) .await?; - if fully_covered { - sqlx::query( - "UPDATE orders SET status = 'completed', updated_at = now() - WHERE id = $1 AND status IN ('shipped', 'fulfilling')", - ) - .bind(order_id) - .execute(db) - .await?; + if !fully_covered { + return Ok(false); } - Ok(()) + let result = sqlx::query( + "UPDATE orders SET status = 'completed', completed_at = now(), updated_at = now() + WHERE id = $1 AND status IN ('shipped', 'fulfilling')", + ) + .bind(order_id) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected() > 0) } +/// Load one order inside the caller's transaction. +pub async fn get_in_tx(tx: &mut PgConnection, id: Uuid) -> ApiResult { + sqlx::query_as::<_, Order>(&format!( + "SELECT {ORDER_COLS} FROM orders WHERE id = $1" + )) + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("order".into())) +} + + #[derive(sqlx::FromRow)] pub struct CheckoutRow { pub sku_id: Uuid, diff --git a/apps/api/src/modules/order/service.rs b/apps/api/src/modules/order/service.rs index 4eba4e5..1f4175a 100644 --- a/apps/api/src/modules/order/service.rs +++ b/apps/api/src/modules/order/service.rs @@ -4,9 +4,9 @@ use uuid::Uuid; use crate::error::{ApiError, ApiResult}; use crate::http::{clamp_page, clamp_per_page, Paged}; -use crate::models::OrderStatus; +use crate::models::{MessageKind, OrderStatus}; use crate::modules::group_buying::GroupBuyIntent; -use crate::modules::{cart, coupon, flash_sale, freight, group_buying}; +use crate::modules::{cart, coupon, flash_sale, freight, group_buying, messaging}; use crate::money::convert_minor; use crate::state::AppState; @@ -468,6 +468,16 @@ pub async fn pay(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// One contributing confirmed-received order, converted to statement currency. +#[derive(Debug, Serialize)] +pub struct OrderLine { + pub order_id: Uuid, + pub order_no: String, + /// Currency the order was placed in; amounts below are statement currency. + pub order_currency: String, + pub gross_minor: i64, + pub refund_minor: i64, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct StatementDetail { + #[serde(flatten)] + pub statement: StatementRow, + pub orders: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct GenerateBody { + /// Platform admins must name the shop; merchant routes take it from scope. + #[serde(default)] + pub shop_id: Option, + pub period_kind: SettlementPeriodKind, + /// Any date inside the target period; it is normalized to the boundary. + pub period_start: NaiveDate, +} + +#[derive(Debug, Serialize)] +pub struct CommissionRate { + pub commission_rate_bps: i32, +} + +#[derive(Debug, Deserialize)] +pub struct CommissionRateBody { + pub commission_rate_bps: i32, +} + +/// Which statements a caller may see and generate. +#[derive(Debug, Clone, Copy)] +pub enum StatementScope { + /// Platform admin: every shop, optionally narrowed to one. + Admin { shop_id: Option }, + /// Merchant: always the own shop. + Shop(Uuid), +} + +impl StatementScope { + pub fn shop_id(self) -> Option { + match self { + StatementScope::Admin { shop_id } => shop_id, + StatementScope::Shop(id) => Some(id), + } + } +} diff --git a/apps/api/src/modules/settlement/handlers.rs b/apps/api/src/modules/settlement/handlers.rs new file mode 100644 index 0000000..81c84fb --- /dev/null +++ b/apps/api/src/modules/settlement/handlers.rs @@ -0,0 +1,172 @@ +use axum::{ + extract::{Path, Query, State}, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::http::Paged; +use crate::models::SettlementStatus; +use crate::state::AppState; + +use super::dto::{ + CommissionRate, CommissionRateBody, GenerateBody, StatementDetail, StatementRow, + StatementScope, +}; +use super::service; + +pub fn router() -> Router { + Router::new() + .route( + "/shop/settlement/statements", + get(list_shop_statements).post(generate_shop_statement), + ) + .route( + "/shop/settlement/statements/{id}", + get(get_shop_statement), + ) + .route( + "/admin/settlement/commission-rate", + get(get_commission_rate).put(set_commission_rate), + ) + .route( + "/admin/settlement/statements", + get(list_statements).post(generate_statement), + ) + .route( + "/admin/settlement/statements/{id}", + get(get_statement), + ) + .route( + "/admin/settlement/statements/{id}/confirm", + post(confirm_statement), + ) +} + +#[derive(Deserialize)] +struct PageQuery { + page: Option, + per_page: Option, +} + +#[derive(Deserialize)] +struct AdminListQuery { + page: Option, + per_page: Option, + shop_id: Option, + status: Option, +} + +// --- merchant (own shop) --- + +async fn list_shop_statements( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + let shop_id = auth.require_shop()?; + Ok(Json( + service::list( + &state, + StatementScope::Shop(shop_id), + None, + q.page, + q.per_page, + ) + .await?, + )) +} + +async fn get_shop_statement( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json( + service::detail(&state, StatementScope::Shop(shop_id), id).await?, + )) +} + +async fn generate_shop_statement( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json( + service::generate(&state, auth.id, StatementScope::Shop(shop_id), body).await?, + )) +} + +// --- platform admin --- + +async fn get_commission_rate( + State(state): State, + auth: AuthUser, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::commission_rate(&state).await?)) +} + +async fn set_commission_rate( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::set_commission_rate(&state, body).await?)) +} + +async fn list_statements( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json( + service::list( + &state, + StatementScope::Admin { shop_id: q.shop_id }, + q.status, + q.page, + q.per_page, + ) + .await?, + )) +} + +async fn get_statement( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json( + service::detail(&state, StatementScope::Admin { shop_id: None }, id).await?, + )) +} + +async fn generate_statement( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + let scope = StatementScope::Admin { + shop_id: body.shop_id, + }; + Ok(Json(service::generate(&state, auth.id, scope, body).await?)) +} + +async fn confirm_statement( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::confirm(&state, auth.id, id).await?)) +} diff --git a/apps/api/src/modules/settlement/mod.rs b/apps/api/src/modules/settlement/mod.rs new file mode 100644 index 0000000..08f7f71 --- /dev/null +++ b/apps/api/src/modules/settlement/mod.rs @@ -0,0 +1,6 @@ +pub mod dto; +pub mod handlers; +pub mod repo; +pub mod service; + +pub use handlers::router; diff --git a/apps/api/src/modules/settlement/repo.rs b/apps/api/src/modules/settlement/repo.rs new file mode 100644 index 0000000..cf5b84e --- /dev/null +++ b/apps/api/src/modules/settlement/repo.rs @@ -0,0 +1,240 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use sqlx::{PgConnection, PgExecutor, PgPool}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{Currency, SettlementPeriodKind, SettlementStatus}; + +use super::dto::StatementRow; + +pub const SETTING_COMMISSION_RATE: &str = "settlement.commission_rate_bps"; + +const STATEMENT_COLS: &str = "s.id, s.shop_id, sh.name AS shop_name, s.currency, s.period_kind, + s.period_start, s.period_end, s.order_count, s.gross_minor, s.refund_minor, + s.commission_rate_bps, s.commission_minor, s.payable_minor, s.status, s.confirmed_at, + s.created_at, s.updated_at"; +const STATEMENT_FROM: &str = "settlement_statements s JOIN shops sh ON sh.id = s.shop_id"; + +/// A confirmed-received order inside a statement period. +#[derive(Debug, sqlx::FromRow)] +pub struct ContributingOrder { + pub id: Uuid, + pub order_no: String, + pub currency: String, + pub total_minor: i64, + pub refund_total_minor: i64, + pub created_at: DateTime, +} + +pub async fn get_statement<'e, E: PgExecutor<'e>>( + exec: E, + id: Uuid, +) -> ApiResult { + sqlx::query_as::<_, StatementRow>(&format!( + "SELECT {STATEMENT_COLS} FROM {STATEMENT_FROM} WHERE s.id = $1" + )) + .bind(id) + .fetch_optional(exec) + .await? + .ok_or_else(|| ApiError::NotFound("settlement statement".into())) +} + +pub async fn find_statement( + exec: &mut PgConnection, + shop_id: Uuid, + period_kind: SettlementPeriodKind, + period_start: NaiveDate, +) -> ApiResult> { + Ok(sqlx::query_as::<_, StatementRow>(&format!( + "SELECT {STATEMENT_COLS} FROM {STATEMENT_FROM} + WHERE s.shop_id = $1 AND s.period_kind = $2 AND s.period_start = $3" + )) + .bind(shop_id) + .bind(period_kind) + .bind(period_start) + .fetch_optional(&mut *exec) + .await?) +} + +pub struct NewStatement { + pub shop_id: Uuid, + pub currency: String, + pub period_kind: SettlementPeriodKind, + pub period_start: NaiveDate, + pub period_end: NaiveDate, + pub order_count: i32, + pub gross_minor: i64, + pub refund_minor: i64, + pub commission_rate_bps: i32, + pub commission_minor: i64, + pub payable_minor: i64, + pub generated_by: Uuid, +} + +/// Insert unless the shop already has a statement for this period. `None` means +/// a concurrent or repeated request won the race; the caller then reads the +/// existing row. +pub async fn insert_statement( + tx: &mut PgConnection, + new: &NewStatement, +) -> ApiResult> { + Ok(sqlx::query_scalar( + "INSERT INTO settlement_statements + (shop_id, currency, period_kind, period_start, period_end, order_count, + gross_minor, refund_minor, commission_rate_bps, commission_minor, payable_minor, + generated_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (shop_id, period_kind, period_start) DO NOTHING + RETURNING id", + ) + .bind(new.shop_id) + .bind(&new.currency) + .bind(new.period_kind) + .bind(new.period_start) + .bind(new.period_end) + .bind(new.order_count) + .bind(new.gross_minor) + .bind(new.refund_minor) + .bind(new.commission_rate_bps) + .bind(new.commission_minor) + .bind(new.payable_minor) + .bind(new.generated_by) + .fetch_optional(&mut *tx) + .await?) +} + +pub async fn list_statements( + db: &PgPool, + shop_id: Option, + status: Option, + limit: i64, + offset: i64, +) -> ApiResult> { + Ok(sqlx::query_as::<_, StatementRow>(&format!( + "SELECT {STATEMENT_COLS} FROM {STATEMENT_FROM} + WHERE ($1::uuid IS NULL OR s.shop_id = $1) + AND ($2::settlement_status IS NULL OR s.status = $2) + ORDER BY s.created_at DESC, s.id DESC + LIMIT $3 OFFSET $4" + )) + .bind(shop_id) + .bind(status) + .bind(limit) + .bind(offset) + .fetch_all(db) + .await?) +} + +pub async fn count_statements( + db: &PgPool, + shop_id: Option, + status: Option, +) -> ApiResult { + Ok(sqlx::query_scalar( + "SELECT COUNT(*) FROM settlement_statements s + WHERE ($1::uuid IS NULL OR s.shop_id = $1) + AND ($2::settlement_status IS NULL OR s.status = $2)", + ) + .bind(shop_id) + .bind(status) + .fetch_one(db) + .await?) +} + +/// Guarded `pending -> confirmed`; zero rows means already confirmed. +pub async fn confirm_statement( + tx: &mut PgConnection, + id: Uuid, + admin_id: Uuid, +) -> ApiResult { + let result = sqlx::query( + "UPDATE settlement_statements + SET status = 'confirmed', confirmed_by = $2, confirmed_at = now(), updated_at = now() + WHERE id = $1 AND status = 'pending'", + ) + .bind(id) + .bind(admin_id) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected()) +} + +/// Confirmed-received orders whose completion instant falls inside the period. +pub async fn contributing_orders( + tx: &mut PgConnection, + shop_id: Uuid, + period_start: NaiveDate, + period_end: NaiveDate, +) -> ApiResult> { + Ok(sqlx::query_as::<_, ContributingOrder>( + "SELECT id, order_no, currency, total_minor, refund_total_minor, created_at + FROM orders + WHERE shop_id = $1 AND status = 'completed' AND completed_at IS NOT NULL + AND (completed_at AT TIME ZONE 'UTC')::date BETWEEN $2 AND $3 + ORDER BY completed_at, id", + ) + .bind(shop_id) + .bind(period_start) + .bind(period_end) + .fetch_all(&mut *tx) + .await?) +} + +pub async fn all_currencies( + tx: &mut PgConnection, +) -> ApiResult> { + Ok(sqlx::query_as::<_, Currency>( + "SELECT code, name, symbol, exponent, is_base, rate_to_base, enabled FROM currencies", + ) + .fetch_all(&mut *tx) + .await?) +} + +pub async fn shop_exists(tx: &mut PgConnection, shop_id: Uuid) -> ApiResult { + Ok( + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM shops WHERE id = $1)") + .bind(shop_id) + .fetch_one(&mut *tx) + .await?, + ) +} + +/// The user who receives this shop's settlement payout. +pub async fn shop_owner(tx: &mut PgConnection, shop_id: Uuid) -> ApiResult { sqlx::query_scalar( + "SELECT id FROM users + WHERE shop_id = $1 AND role = 'shop_owner' + ORDER BY created_at, id LIMIT 1", + ) + .bind(shop_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::Conflict("shop has no owner to receive the payout".into())) +} + +pub async fn get_setting<'e, E: PgExecutor<'e>>( + exec: E, + key: &str, +) -> ApiResult> { + Ok( + sqlx::query_scalar("SELECT value FROM platform_settings WHERE key = $1") + .bind(key) + .fetch_optional(exec) + .await?, + ) +} + +pub async fn set_setting( + tx: &mut PgConnection, + key: &str, + value: &str, +) -> ApiResult<()> { + sqlx::query( + "INSERT INTO platform_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()", + ) + .bind(key) + .bind(value) + .execute(&mut *tx) + .await?; + Ok(()) +} diff --git a/apps/api/src/modules/settlement/service.rs b/apps/api/src/modules/settlement/service.rs new file mode 100644 index 0000000..3670df3 --- /dev/null +++ b/apps/api/src/modules/settlement/service.rs @@ -0,0 +1,329 @@ +use anyhow::anyhow; +use chrono::{Datelike, Duration, NaiveDate, Utc}; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::models::{AccountKind, Currency, SettlementPeriodKind, SettlementStatus}; +use crate::money::convert_minor; +use crate::modules::account; +use crate::state::AppState; + +use super::dto::{ + CommissionRate, CommissionRateBody, GenerateBody, OrderLine, StatementDetail, StatementRow, + StatementScope, +}; +use super::repo::{self, NewStatement, SETTING_COMMISSION_RATE}; + +/// Ledger reason for a confirmed settlement payout. +pub const REASON_SETTLEMENT_PAYOUT: &str = "settlement_payout"; +/// Reference kind stored on the payout entry. +const REF_STATEMENT: &str = "settlement_statement"; + +const MAX_RATE_BPS: i32 = 10_000; + +// --- platform commission rate --- + +pub async fn commission_rate(state: &AppState) -> ApiResult { + let bps = read_rate(&mut *state.db.acquire().await?).await?; + Ok(CommissionRate { + commission_rate_bps: bps, + }) +} + +pub async fn set_commission_rate( + state: &AppState, + body: CommissionRateBody, +) -> ApiResult { + validate_rate(body.commission_rate_bps)?; + let mut tx = state.db.begin().await?; + repo::set_setting( + &mut tx, + SETTING_COMMISSION_RATE, + &body.commission_rate_bps.to_string(), + ) + .await?; + tx.commit().await?; + Ok(CommissionRate { + commission_rate_bps: body.commission_rate_bps, + }) +} + +// --- statements --- + +/// Idempotent generation for a closed period. A repeat request for the same +/// shop, period kind, and period start returns the existing snapshot untouched. +pub async fn generate( + state: &AppState, + actor_id: Uuid, + scope: StatementScope, + body: GenerateBody, +) -> ApiResult { + let (period_start, period_end) = period_bounds(body.period_kind, body.period_start); + if period_end >= Utc::now().date_naive() { + return Err(ApiError::Conflict("period is not closed".into())); + } + let shop_id = match scope { + StatementScope::Shop(id) => id, + StatementScope::Admin { .. } => body + .shop_id + .ok_or_else(|| ApiError::BadRequest("shop_id is required".into()))?, + }; + + let mut tx = state.db.begin().await?; + if !repo::shop_exists(&mut tx, shop_id).await? { + return Err(ApiError::NotFound("shop".into())); + } + if let Some(existing) = + repo::find_statement(&mut tx, shop_id, body.period_kind, period_start).await? + { + tx.commit().await?; + return Ok(existing); + } + + let currency = account::repo::base_currency(&mut *tx).await?; + let currencies = repo::all_currencies(&mut tx).await?; + let orders = repo::contributing_orders(&mut tx, shop_id, period_start, period_end).await?; + + let mut gross_minor: i64 = 0; + let mut refund_minor: i64 = 0; + for order in &orders { + gross_minor += convert(order.total_minor, &order.currency, ¤cy, ¤cies)?; + refund_minor += convert( + order.refund_total_minor, + &order.currency, + ¤cy, + ¤cies, + )?; + } + + let commission_rate_bps = read_rate(&mut tx).await?; + let net = gross_minor - refund_minor; + // Integer basis points over integer minor units; i128 avoids overflow. + let commission_minor = ((net as i128) * (commission_rate_bps as i128) / 10_000) as i64; + let payable_minor = net - commission_minor; + + let new = NewStatement { + shop_id, + currency, + period_kind: body.period_kind, + period_start, + period_end, + order_count: orders.len() as i32, + gross_minor, + refund_minor, + commission_rate_bps, + commission_minor, + payable_minor, + generated_by: actor_id, + }; + let created = repo::insert_statement(&mut tx, &new).await?; + let row = match created { + Some(id) => repo::get_statement(&mut *tx, id).await?, + // A concurrent request inserted first; return its snapshot. + None => repo::find_statement(&mut tx, shop_id, body.period_kind, period_start) + .await? + .ok_or_else(|| ApiError::Conflict("statement generation raced; retry".into()))?, + }; + tx.commit().await?; + Ok(row) +} + +pub async fn list( + state: &AppState, + scope: StatementScope, + status: Option, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let shop_id = scope.shop_id(); + let total = repo::count_statements(&state.db, shop_id, status).await?; + let items = + repo::list_statements(&state.db, shop_id, status, per_page, (page - 1) * per_page).await?; + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +/// Statement plus its contributing orders, converted to the snapshot currency. +pub async fn detail( + state: &AppState, + scope: StatementScope, + id: Uuid, +) -> ApiResult { + let mut conn = state.db.acquire().await?; + let statement = repo::get_statement(&mut *conn, id).await?; + if let StatementScope::Shop(shop_id) = scope { + if statement.shop_id != shop_id { + return Err(ApiError::NotFound("settlement statement".into())); + } + } + let currencies = repo::all_currencies(&mut conn).await?; + let orders = repo::contributing_orders( + &mut conn, + statement.shop_id, + statement.period_start, + statement.period_end, + ) + .await?; + let lines = orders + .into_iter() + .map(|order| { + Ok(OrderLine { + gross_minor: convert( + order.total_minor, + &order.currency, + &statement.currency, + ¤cies, + )?, + refund_minor: convert( + order.refund_total_minor, + &order.currency, + &statement.currency, + ¤cies, + )?, + order_id: order.id, + order_no: order.order_no, + order_currency: order.currency, + created_at: order.created_at, + }) + }) + .collect::>>()?; + Ok(StatementDetail { + statement, + orders: lines, + }) +} + +/// Confirm payout exactly once; the guarded transition makes a repeat a 409. +/// A positive payable amount credits the shop owner with one ledger entry. +pub async fn confirm(state: &AppState, admin_id: Uuid, id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + let statement = repo::get_statement(&mut *tx, id).await?; + if repo::confirm_statement(&mut tx, id, admin_id).await? == 0 { + return Err(ApiError::Conflict("statement was already confirmed".into())); + } + if statement.payable_minor > 0 { + let owner = repo::shop_owner(&mut tx, statement.shop_id).await?; + account::service::ensure_monetary_account( + &mut tx, + owner, + AccountKind::Available, + &statement.currency, + ) + .await?; + account::service::credit( + &mut tx, + owner, + AccountKind::Available, + Some(&statement.currency), + statement.payable_minor, + REASON_SETTLEMENT_PAYOUT, + Some((REF_STATEMENT, id)), + ) + .await?; + } + let row = repo::get_statement(&mut *tx, id).await?; + tx.commit().await?; + Ok(row) +} + +// --- helpers --- + +/// Normalize any date to the week (Monday-Sunday) or month that contains it, so +/// repeat requests from anywhere inside the period hit the same statement. +fn period_bounds(kind: SettlementPeriodKind, date: NaiveDate) -> (NaiveDate, NaiveDate) { + match kind { + SettlementPeriodKind::Week => { + let offset = date.weekday().num_days_from_monday() as i64; + let start = date - Duration::days(offset); + (start, start + Duration::days(6)) + } + SettlementPeriodKind::Month => { + let start = NaiveDate::from_ymd_opt(date.year(), date.month(), 1) + .expect("valid first-of-month"); + let (year, month) = if date.month() == 12 { + (date.year() + 1, 1) + } else { + (date.year(), date.month() + 1) + }; + let end = NaiveDate::from_ymd_opt(year, month, 1).expect("valid next month") + - Duration::days(1); + (start, end) + } + } +} + +fn convert( + amount_minor: i64, + from_code: &str, + to_code: &str, + currencies: &[Currency], +) -> ApiResult { + let from = currencies + .iter() + .find(|c| c.code == from_code) + .ok_or_else(|| ApiError::BadRequest(format!("unknown currency {from_code}")))?; + let to = currencies + .iter() + .find(|c| c.code == to_code) + .ok_or_else(|| ApiError::BadRequest(format!("unknown currency {to_code}")))?; + convert_minor(amount_minor, from, to) +} + +async fn read_rate(tx: &mut PgConnection) -> ApiResult { + let raw = repo::get_setting(&mut *tx, SETTING_COMMISSION_RATE) + .await? + .ok_or_else(|| ApiError::Internal(anyhow!("{SETTING_COMMISSION_RATE} is not configured")))?; + let bps: i32 = raw + .trim() + .parse() + .map_err(|_| ApiError::Internal(anyhow!("{SETTING_COMMISSION_RATE} is not an integer")))?; + validate_rate(bps)?; + Ok(bps) +} + +fn validate_rate(bps: i32) -> ApiResult<()> { + if !(0..=MAX_RATE_BPS).contains(&bps) { + return Err(ApiError::BadRequest(format!( + "commission_rate_bps must be between 0 and {MAX_RATE_BPS}" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn week_bounds_start_on_monday() { + // 2024-05-15 is a Wednesday. + let date = NaiveDate::from_ymd_opt(2024, 5, 15).unwrap(); + let (start, end) = period_bounds(SettlementPeriodKind::Week, date); + assert_eq!(start, NaiveDate::from_ymd_opt(2024, 5, 13).unwrap()); + assert_eq!(end, NaiveDate::from_ymd_opt(2024, 5, 19).unwrap()); + } + + #[test] + fn month_bounds_cover_december_and_leap_february() { + let (start, end) = period_bounds( + SettlementPeriodKind::Month, + NaiveDate::from_ymd_opt(2024, 12, 20).unwrap(), + ); + assert_eq!(start, NaiveDate::from_ymd_opt(2024, 12, 1).unwrap()); + assert_eq!(end, NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()); + + let (_, feb_end) = period_bounds( + SettlementPeriodKind::Month, + NaiveDate::from_ymd_opt(2024, 2, 10).unwrap(), + ); + assert_eq!(feb_end, NaiveDate::from_ymd_opt(2024, 2, 29).unwrap()); + } +} diff --git a/apps/api/src/modules/shop/service.rs b/apps/api/src/modules/shop/service.rs index e0b1b51..2e4c5bb 100644 --- a/apps/api/src/modules/shop/service.rs +++ b/apps/api/src/modules/shop/service.rs @@ -68,6 +68,15 @@ pub async fn get_active_by_slug(state: &AppState, slug: &str) -> ApiResult ApiResult { + let mut tx = state.db.begin().await?; + let shop = create_in_tx(&mut tx, name, &slug).await?; + tx.commit().await?; + Ok(shop) +} + +/// Create a shop inside the caller's transaction. Merchant approval uses this +/// so the shop, its owner account, and the status flip commit together. +pub async fn create_in_tx(tx: &mut sqlx::PgConnection, name: Value, slug: &str) -> ApiResult { let name_en = name.get("en").and_then(|v| v.as_str()).unwrap_or(""); if name_en.trim().is_empty() { return Err(ApiError::BadRequest("name.en is required".into())); @@ -80,7 +89,7 @@ pub async fn create(state: &AppState, name: Value, slug: String) -> ApiResult, + pub reference_id: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct WalletRecharge { + pub id: Uuid, + pub user_id: Uuid, + pub currency: String, + pub amount_minor: i64, + pub status: WalletRechargeStatus, + pub created_at: DateTime, +} + +/// Recharge response. `demo` is always true: no external payment channel is +/// contacted, and the UI must label the flow as simulated. +#[derive(Debug, Serialize)] +pub struct RechargeResult { + #[serde(flatten)] + pub recharge: WalletRecharge, + pub demo: bool, + /// Available balance after the credit, so the page needs no second read. + pub available_minor: i64, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct WithdrawalRow { + pub id: Uuid, + pub user_id: Uuid, + /// Filled for the platform review queue; joined from `users`. + pub user_email: String, + pub amount_minor: i64, + pub currency: String, + pub account_details: Value, + pub status: WalletWithdrawalStatus, + pub review_note: Option, + pub reviewed_at: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct RechargeBody { + pub amount_minor: i64, +} + +/// Payout destination captured at application time. Kept concrete so the +/// contract stays typed; stored verbatim as JSONB. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WithdrawalDetails { + pub method: String, + pub account: String, + #[serde(default)] + pub holder: Option, +} + +impl WithdrawalDetails { + pub fn validate(&self) -> ApiResult<()> { + let bad = |field: &str| ApiError::BadRequest(format!("{field} is required")); + if self.method.trim().is_empty() { + return Err(bad("account_details.method")); + } + if self.account.trim().is_empty() { + return Err(bad("account_details.account")); + } + if self.method.trim().len() > 40 || self.account.trim().len() > 120 { + return Err(ApiError::BadRequest( + "account_details fields are too long".into(), + )); + } + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +pub struct WithdrawalBody { + pub amount_minor: i64, + pub account_details: WithdrawalDetails, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WithdrawalReviewOutcome { + Approve, + Reject, +} + +#[derive(Debug, Deserialize)] +pub struct ReviewBody { + pub outcome: WithdrawalReviewOutcome, + #[serde(default)] + pub note: Option, +} diff --git a/apps/api/src/modules/wallet/handlers.rs b/apps/api/src/modules/wallet/handlers.rs new file mode 100644 index 0000000..2ca3b29 --- /dev/null +++ b/apps/api/src/modules/wallet/handlers.rs @@ -0,0 +1,110 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::http::{PageQuery, Paged}; +use crate::models::WalletWithdrawalStatus; +use crate::state::AppState; + +use super::dto::{ + RechargeBody, RechargeResult, ReviewBody, WalletEntry, WalletSummary, WithdrawalBody, + WithdrawalRow, +}; +use super::service; + +pub fn router() -> Router { + Router::new() + .route("/wallet", get(get_wallet)) + .route("/wallet/entries", get(list_entries)) + .route("/wallet/recharges", post(recharge)) + .route( + "/wallet/withdrawals", + get(list_my_withdrawals).post(apply_withdrawal), + ) + .route("/admin/wallet/withdrawals", get(list_applications)) + .route( + "/admin/wallet/withdrawals/{id}/review", + post(review_withdrawal), + ) +} + +#[derive(Deserialize)] +struct StatusQuery { + status: Option, +} + +// --- caller's own wallet (customers and shop accounts alike) --- + +async fn get_wallet( + State(state): State, + auth: AuthUser, +) -> ApiResult> { + Ok(Json(service::summary(&state, auth.id).await?)) +} + +async fn list_entries( + State(state): State, + auth: AuthUser, + Query(page): Query, +) -> ApiResult>> { + Ok(Json( + service::list_entries(&state, auth.id, page.page, page.per_page).await?, + )) +} + +async fn recharge( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + Ok(( + StatusCode::CREATED, + Json(service::recharge(&state, auth.id, body).await?), + )) +} + +async fn apply_withdrawal( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + Ok(( + StatusCode::CREATED, + Json(service::apply_withdrawal(&state, auth.id, body).await?), + )) +} + +async fn list_my_withdrawals( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + Ok(Json(service::list_mine(&state, auth.id).await?)) +} + +// --- platform admin review queue --- + +async fn list_applications( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(service::list_for_review(&state, q.status).await?)) +} + +async fn review_withdrawal( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::review(&state, auth.id, id, body).await?)) +} diff --git a/apps/api/src/modules/wallet/mod.rs b/apps/api/src/modules/wallet/mod.rs new file mode 100644 index 0000000..08f7f71 --- /dev/null +++ b/apps/api/src/modules/wallet/mod.rs @@ -0,0 +1,6 @@ +pub mod dto; +pub mod handlers; +pub mod repo; +pub mod service; + +pub use handlers::router; diff --git a/apps/api/src/modules/wallet/repo.rs b/apps/api/src/modules/wallet/repo.rs new file mode 100644 index 0000000..36ec9ec --- /dev/null +++ b/apps/api/src/modules/wallet/repo.rs @@ -0,0 +1,157 @@ +use serde_json::Value; +use sqlx::{PgConnection, PgExecutor, PgPool}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::WalletWithdrawalStatus; + +use super::dto::{WalletEntry, WalletRecharge, WithdrawalRow}; + +const WITHDRAWAL_COLS: &str = "w.id, w.user_id, u.email AS user_email, w.amount_minor, + w.currency, w.account_details, w.status, w.review_note, w.reviewed_at, w.created_at"; +const WITHDRAWAL_FROM: &str = "wallet_withdrawals w JOIN users u ON u.id = w.user_id"; + +const RECHARGE_COLS: &str = + "id, user_id, currency, amount_minor, status, created_at"; + +/// Record a credited demo recharge. Caller owns the transaction so the ledger +/// credit commits with it. +pub async fn insert_recharge( + tx: &mut PgConnection, + user_id: Uuid, + currency: &str, + amount_minor: i64, +) -> ApiResult { + Ok(sqlx::query_as::<_, WalletRecharge>(&format!( + "INSERT INTO wallet_recharges (user_id, currency, amount_minor, status) + VALUES ($1, $2, $3, 'credited') + RETURNING {RECHARGE_COLS}" + )) + .bind(user_id) + .bind(currency) + .bind(amount_minor) + .fetch_one(&mut *tx) + .await?) +} + +/// Insert a pending application; the caller freezes the funds in the same +/// transaction so a failed freeze rolls this row back. +pub async fn insert_withdrawal( + tx: &mut PgConnection, + user_id: Uuid, + currency: &str, + amount_minor: i64, + account_details: &Value, +) -> ApiResult { + Ok(sqlx::query_scalar( + "INSERT INTO wallet_withdrawals (user_id, currency, amount_minor, account_details) + VALUES ($1, $2, $3, $4) + RETURNING id", + ) + .bind(user_id) + .bind(currency) + .bind(amount_minor) + .bind(account_details) + .fetch_one(&mut *tx) + .await?) +} + +pub async fn get_withdrawal<'e, E: PgExecutor<'e>>( + exec: E, + id: Uuid, +) -> ApiResult { + sqlx::query_as::<_, WithdrawalRow>(&format!( + "SELECT {WITHDRAWAL_COLS} FROM {WITHDRAWAL_FROM} WHERE w.id = $1" + )) + .bind(id) + .fetch_optional(exec) + .await? + .ok_or_else(|| ApiError::NotFound("withdrawal application".into())) +} + +pub async fn list_withdrawals_for_user<'e, E: PgExecutor<'e>>( + exec: E, + user_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, WithdrawalRow>(&format!( + "SELECT {WITHDRAWAL_COLS} FROM {WITHDRAWAL_FROM} + WHERE w.user_id = $1 ORDER BY w.created_at DESC, w.id DESC" + )) + .bind(user_id) + .fetch_all(exec) + .await?) +} + +pub async fn list_withdrawals_by_status<'e, E: PgExecutor<'e>>( + exec: E, + status: Option, +) -> ApiResult> { + Ok(sqlx::query_as::<_, WithdrawalRow>(&format!( + "SELECT {WITHDRAWAL_COLS} FROM {WITHDRAWAL_FROM} + WHERE ($1::wallet_withdrawal_status IS NULL OR w.status = $1) + ORDER BY w.created_at DESC, w.id DESC" + )) + .bind(status) + .fetch_all(exec) + .await?) +} + +/// Guarded one-time review: only a `pending` row flips, so a concurrent or +/// repeated review affects zero rows and the caller maps it to 409. +pub async fn review_withdrawal( + tx: &mut PgConnection, + id: Uuid, + status: WalletWithdrawalStatus, + admin_id: Uuid, + note: Option<&str>, +) -> ApiResult { + let result = sqlx::query( + "UPDATE wallet_withdrawals + SET status = $2, reviewed_by = $3, reviewed_at = now(), review_note = $4, + updated_at = now() + WHERE id = $1 AND status = 'pending'", + ) + .bind(id) + .bind(status) + .bind(admin_id) + .bind(note) + .execute(&mut *tx) + .await?; + Ok(result.rows_affected()) +} + +/// The caller's own monetary ledger rows, newest first. Points are excluded: +/// the wallet is the money surface. +pub async fn list_entries( + db: &PgPool, + user_id: Uuid, + limit: i64, + offset: i64, +) -> ApiResult> { + Ok(sqlx::query_as::<_, WalletEntry>( + "SELECT e.id, a.kind AS account_kind, e.delta_minor, e.balance_minor, e.reason, + e.reference_type, e.reference_id, e.created_at + FROM customer_account_entries e + JOIN customer_accounts a ON a.id = e.account_id + WHERE a.user_id = $1 AND a.kind IN ('available', 'frozen') + ORDER BY e.created_at DESC, e.id DESC + LIMIT $2 OFFSET $3", + ) + .bind(user_id) + .bind(limit) + .bind(offset) + .fetch_all(db) + .await?) +} + +pub async fn count_entries(db: &PgPool, user_id: Uuid) -> ApiResult { + Ok(sqlx::query_scalar( + "SELECT COUNT(*) + FROM customer_account_entries e + JOIN customer_accounts a ON a.id = e.account_id + WHERE a.user_id = $1 AND a.kind IN ('available', 'frozen')", + ) + .bind(user_id) + .fetch_one(db) + .await?) +} diff --git a/apps/api/src/modules/wallet/service.rs b/apps/api/src/modules/wallet/service.rs new file mode 100644 index 0000000..e9413a2 --- /dev/null +++ b/apps/api/src/modules/wallet/service.rs @@ -0,0 +1,221 @@ +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::models::{AccountKind, CustomerAccount, WalletWithdrawalStatus}; +use crate::modules::account; +use crate::state::AppState; + +use super::dto::{ + RechargeBody, RechargeResult, ReviewBody, WalletEntry, WalletSummary, WithdrawalBody, + WithdrawalReviewOutcome, WithdrawalRow, +}; +use super::repo; + +/// Ledger reasons for every balance movement the wallet produces. +pub const REASON_RECHARGE: &str = "wallet_recharge"; +pub const REASON_WITHDRAW_FREEZE: &str = "wallet_withdrawal_freeze"; +pub const REASON_WITHDRAW_APPROVED: &str = "wallet_withdrawal_approved"; +pub const REASON_WITHDRAW_REJECTED: &str = "wallet_withdrawal_rejected"; + +/// Reference kind stored on entries that belong to a withdrawal application. +const REF_WITHDRAWAL: &str = "wallet_withdrawal"; + +pub async fn summary(state: &AppState, user_id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + // Defensive: a user created outside registration still gets a wallet. + account::service::ensure_accounts(&mut tx, user_id).await?; + let accounts = account::repo::list_for_user(&mut *tx, user_id).await?; + tx.commit().await?; + summarize(&accounts) +} + +pub async fn list_entries( + state: &AppState, + user_id: Uuid, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let total = repo::count_entries(&state.db, user_id).await?; + let items = repo::list_entries(&state.db, user_id, per_page, (page - 1) * per_page).await?; + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +/// Simulated recharge: one transaction writes the recharge row and credits the +/// available account with its ledger entry. No external channel is contacted. +pub async fn recharge( + state: &AppState, + user_id: Uuid, + body: RechargeBody, +) -> ApiResult { + if body.amount_minor <= 0 { + return Err(ApiError::BadRequest("amount_minor must be positive".into())); + } + let mut tx = state.db.begin().await?; + account::service::ensure_accounts(&mut tx, user_id).await?; + let currency = account::repo::base_currency(&mut *tx).await?; + let recharge = repo::insert_recharge(&mut tx, user_id, ¤cy, body.amount_minor).await?; + let entry = account::service::credit( + &mut tx, + user_id, + AccountKind::Available, + Some(¤cy), + body.amount_minor, + REASON_RECHARGE, + Some(("wallet_recharge", recharge.id)), + ) + .await?; + tx.commit().await?; + Ok(RechargeResult { + recharge, + demo: true, + available_minor: entry.balance_minor, + }) +} + +/// Apply to withdraw: freeze the amount out of available balance in the same +/// transaction that records the application. An uncovered amount fails the +/// guarded debit, which rolls the application row back with it. +pub async fn apply_withdrawal( + state: &AppState, + user_id: Uuid, + body: WithdrawalBody, +) -> ApiResult { + if body.amount_minor <= 0 { + return Err(ApiError::BadRequest("amount_minor must be positive".into())); + } + body.account_details.validate()?; + let details = + serde_json::to_value(&body.account_details).map_err(ApiError::internal)?; + + let mut tx = state.db.begin().await?; + account::service::ensure_accounts(&mut tx, user_id).await?; + let currency = account::repo::base_currency(&mut *tx).await?; + let id = repo::insert_withdrawal(&mut tx, user_id, ¤cy, body.amount_minor, &details).await?; + account::service::freeze( + &mut tx, + user_id, + body.amount_minor, + REASON_WITHDRAW_FREEZE, + Some((REF_WITHDRAWAL, id)), + ) + .await?; + let row = repo::get_withdrawal(&mut *tx, id).await?; + tx.commit().await?; + Ok(row) +} + +pub async fn list_mine(state: &AppState, user_id: Uuid) -> ApiResult> { + repo::list_withdrawals_for_user(&state.db, user_id).await +} + +pub async fn list_for_review( + state: &AppState, + status: Option, +) -> ApiResult> { + repo::list_withdrawals_by_status(&state.db, status).await +} + +/// Approve or reject exactly once. The status guard runs first; a zero-row +/// update means the application was already reviewed (409). Approve consumes +/// the frozen balance, reject returns it to available, each with one entry. +pub async fn review( + state: &AppState, + admin_id: Uuid, + id: Uuid, + body: ReviewBody, +) -> ApiResult { + let note = body + .note + .as_deref() + .map(str::trim) + .filter(|n| !n.is_empty()); + + let mut tx = state.db.begin().await?; + // Load first so an unknown id is a 404 rather than a status conflict. + let application = repo::get_withdrawal(&mut *tx, id).await?; + + let target = match body.outcome { + WithdrawalReviewOutcome::Approve => WalletWithdrawalStatus::Approved, + WithdrawalReviewOutcome::Reject => WalletWithdrawalStatus::Rejected, + }; + if repo::review_withdrawal(&mut tx, id, target, admin_id, note).await? == 0 { + return Err(ApiError::Conflict( + "withdrawal application was already reviewed".into(), + )); + } + + // The freeze created these rows; ensure anyway so a foreign-currency + // application can never leave the ledger entry unwritable. + account::service::ensure_monetary_account( + &mut tx, + application.user_id, + AccountKind::Available, + &application.currency, + ) + .await?; + account::service::ensure_monetary_account( + &mut tx, + application.user_id, + AccountKind::Frozen, + &application.currency, + ) + .await?; + + match body.outcome { + WithdrawalReviewOutcome::Approve => { + account::service::debit( + &mut tx, + application.user_id, + AccountKind::Frozen, + Some(&application.currency), + application.amount_minor, + REASON_WITHDRAW_APPROVED, + Some((REF_WITHDRAWAL, id)), + ) + .await?; + } + WithdrawalReviewOutcome::Reject => { + account::service::release( + &mut tx, + application.user_id, + application.amount_minor, + REASON_WITHDRAW_REJECTED, + Some((REF_WITHDRAWAL, id)), + ) + .await?; + } + } + + let row = repo::get_withdrawal(&mut *tx, id).await?; + tx.commit().await?; + Ok(row) +} + +fn summarize(accounts: &[CustomerAccount]) -> ApiResult { + let available = find(accounts, AccountKind::Available)?; + let frozen = find(accounts, AccountKind::Frozen)?; + let currency = available + .currency + .clone() + .ok_or_else(|| ApiError::Internal(anyhow::anyhow!("available account has no currency")))?; + Ok(WalletSummary { + available_minor: available.balance_minor, + frozen_minor: frozen.balance_minor, + currency, + }) +} + +fn find(accounts: &[CustomerAccount], kind: AccountKind) -> ApiResult<&CustomerAccount> { + accounts + .iter() + .find(|account| account.kind == kind) + .ok_or_else(|| ApiError::NotFound("customer account".into())) +} diff --git a/apps/api/tests/membership.rs b/apps/api/tests/membership.rs new file mode 100644 index 0000000..fbad22e --- /dev/null +++ b/apps/api/tests/membership.rs @@ -0,0 +1,574 @@ +mod common; + +use common::{ + add_to_cart, checkout, client, login_admin, pay, register_customer, setup_sellable, spawn_app, + TestApp, +}; +use serial_test::serial; +use uuid::Uuid; + +// Membership suite: levels have globally unique growth thresholds in a database +// that is never truncated, so every test derives its thresholds from a per-run +// random salt. Base currency is USD (exponent 2): an order of `threshold * 100` +// minor units contributes exactly `threshold` whole growth units. + +/// Random base well above any realistic fixture, unique per test. +fn salt() -> i64 { + 100_000_000 + (Uuid::new_v4().as_u128() % 1_000_000) as i64 +} + +/// Minor-unit price that accrues exactly `growth_units` growth value in USD. +fn price_for(growth_units: i64) -> i64 { + growth_units * 100 +} + +/// member_levels is written only by these tests, so clearing it (and the +/// derived `users.level`) before each test keeps threshold-order assertions +/// deterministic in the shared, never-truncated test database. +async fn reset_levels(app: &TestApp) { + sqlx::query("UPDATE users SET level = NULL") + .execute(&app.db) + .await + .unwrap(); + sqlx::query("DELETE FROM member_levels") + .execute(&app.db) + .await + .unwrap(); +} + +struct OrderFixture { + customer: String, + owner: String, + shop_id: String, + order_id: String, + item_id: String, + qty: i32, + /// Set when the fixture shipped the order, so a retry can target it. + ship_id: Option, +} + +async fn level( + app: &TestApp, + admin: &str, + growth_threshold: i64, + en: &str, + zh: &str, +) -> serde_json::Value { + let res = client() + .post(app.url("/api/admin/member-levels")) + .bearer_auth(admin) + .json(&serde_json::json!({ + "name": { "en": en, "zh": zh }, + "icon": "star", + "growth_threshold": growth_threshold, + "benefits": { "en": format!("{en} benefits"), "zh": format!("{zh}权益") } + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "{:?}", res.text().await); + res.json().await.unwrap() +} + +/// A paid order, optionally shipped + buyer-confirmed (completed). +async fn order_fixture( + app: &TestApp, + label: &str, + price_minor: i64, + qty: i32, + complete: bool, +) -> OrderFixture { + let admin = login_admin(app).await; + let (owner, shop_id, _product, sku_id) = setup_sellable(app, &admin, label, price_minor, 50).await; + let (customer, _) = register_customer(app, label).await; + add_to_cart(app, &customer, &sku_id, qty).await; + let orders = checkout(app, &customer).await; + let order_id = orders[0]["id"].as_str().unwrap().to_string(); + pay(app, &customer, &order_id).await; + + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/shop/orders/{order_id}"))) + .bearer_auth(&owner) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let item_id = detail["items"][0]["id"].as_str().unwrap().to_string(); + + let mut ship_id: Option = None; + if complete { + let res = client() + .post(app.url(&format!("/api/shop/orders/{order_id}/shipments"))) + .bearer_auth(&owner) + .json(&serde_json::json!({ + "carrier": "SF", "tracking_no": "T1", + "items": [{ "order_item_id": item_id, "qty": qty }] + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + assert_eq!( + client() + .post(app.url(&format!("/api/shop/shipments/{id}/ship"))) + .bearer_auth(&owner) + .send() + .await + .unwrap() + .status(), + 200 + ); + let res = client() + .post(app.url(&format!("/api/shipments/{id}/confirm-delivered"))) + .bearer_auth(&customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + ship_id = Some(id); + } + + OrderFixture { + customer, + owner, + shop_id, + order_id, + item_id, + qty, + ship_id, + } +} + +async fn membership(app: &TestApp, token: &str) -> serde_json::Value { + let res = client() + .get(app.url("/api/membership")) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + res.json().await.unwrap() +} + +async fn growth_logs(app: &TestApp, token: &str, page: i64, per_page: i64) -> serde_json::Value { + client() + .get(app.url("/api/membership/growth-logs")) + .query(&[ + ("page", page.to_string()), + ("per_page", per_page.to_string()), + ]) + .bearer_auth(token) + .send() + .await + .unwrap() + .json() + .await + .unwrap() +} + +#[tokio::test] +#[serial] +async fn level_catalog_crud_and_constraints() { + let app = spawn_app().await; + reset_levels(&app).await; + let admin = login_admin(&app).await; + let base = salt(); + + let bronze = level(&app, &admin, base, "Bronze", "青铜").await; + let silver = level(&app, &admin, base + 200, "Silver", "白银").await; + assert_eq!(bronze["growth_threshold"], base); + + // Thresholds order the list. + let listed: Vec = client() + .get(app.url("/api/admin/member-levels")) + .bearer_auth(&admin) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let thresholds: Vec = listed + .iter() + .filter(|l| l["id"] == bronze["id"] || l["id"] == silver["id"]) + .map(|l| l["growth_threshold"].as_i64().unwrap()) + .collect(); + assert_eq!(thresholds, vec![base, base + 200]); + + // Duplicate threshold is a conflict, on create and on edit. + let res = client() + .post(app.url("/api/admin/member-levels")) + .bearer_auth(&admin) + .json(&serde_json::json!({ + "name": { "en": "Copy", "zh": "副本" }, "icon": "star", + "growth_threshold": base, "benefits": { "en": "b", "zh": "b" } + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409, "{:?}", res.text().await); + let res = client() + .put(app.url(&format!( + "/api/admin/member-levels/{}", + silver["id"].as_str().unwrap() + ))) + .bearer_auth(&admin) + .json(&serde_json::json!({ + "name": { "en": "Silver", "zh": "白银" }, "icon": "star", + "growth_threshold": base, "benefits": { "en": "b", "zh": "b" } + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409, "{:?}", res.text().await); + + // Bilingual content is mandatory. + let res = client() + .post(app.url("/api/admin/member-levels")) + .bearer_auth(&admin) + .json(&serde_json::json!({ + "name": { "en": "Only English" }, "icon": "star", + "growth_threshold": base + 900, "benefits": { "en": "b", "zh": "b" } + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400, "{:?}", res.text().await); + + // Editing to a unique threshold persists. + let res = client() + .put(app.url(&format!( + "/api/admin/member-levels/{}", + silver["id"].as_str().unwrap() + ))) + .bearer_auth(&admin) + .json(&serde_json::json!({ + "name": { "en": "Silver", "zh": "白银" }, "icon": "medal", + "growth_threshold": base + 250, "benefits": { "en": "b", "zh": "b" } + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + assert_eq!( + res.json::().await.unwrap()["growth_threshold"], + base + 250 + ); + + // An unused level deletes; a customer-facing one is protected (next test). + let res = client() + .delete(app.url(&format!( + "/api/admin/member-levels/{}", + silver["id"].as_str().unwrap() + ))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 204, "{:?}", res.text().await); + + // Customers cannot manage levels. + let (customer, _) = register_customer(&app, "mb-forbidden").await; + assert_eq!( + client() + .get(app.url("/api/admin/member-levels")) + .bearer_auth(&customer) + .send() + .await + .unwrap() + .status(), + 403 + ); +} + +#[tokio::test] +#[serial] +async fn completion_accrues_growth_and_upgrades_at_the_threshold() { + let app = spawn_app().await; + reset_levels(&app).await; + let admin = login_admin(&app).await; + let base = salt(); + let bronze = level(&app, &admin, base, "Bronze", "青铜").await; + + let f = order_fixture(&app, "mb-exact", price_for(base), 1, true).await; + + let status = membership(&app, &f.customer).await; + assert_eq!(status["growth_total"], base); + assert_eq!(status["level"]["id"], bronze["id"]); + assert_eq!(status["level"]["growth_threshold"], base); + assert!(status["next_level"].is_null()); + + let logs = growth_logs(&app, &f.customer, 1, 20).await; + assert_eq!(logs["total"], 1); + let entry = &logs["items"][0]; + assert_eq!(entry["delta"], base); + assert_eq!(entry["growth_total"], base); + assert_eq!(entry["reason"], "order_complete"); + assert_eq!(entry["reference_type"], "order"); + assert_eq!(entry["reference_id"], f.order_id); + + // A retried completion cannot accrue twice: the guarded delivery transition + // rejects the repeat before the accrual hook can run. + let ship_id = f.ship_id.as_deref().expect("fixture shipped the order"); + let res = client() + .post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered"))) + .bearer_auth(&f.customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409, "{:?}", res.text().await); + assert_eq!(membership(&app, &f.customer).await["growth_total"], base); + assert_eq!(growth_logs(&app, &f.customer, 1, 20).await["total"], 1); + let _ = (f.item_id, f.qty, f.shop_id, f.owner); +} + +#[tokio::test] +#[serial] +async fn jumping_two_thresholds_lands_on_the_highest_level() { + let app = spawn_app().await; + reset_levels(&app).await; + let admin = login_admin(&app).await; + let base = salt(); + let _bronze = level(&app, &admin, base, "Bronze", "青铜").await; + let silver = level(&app, &admin, base + 200, "Silver", "白银").await; + + // Growth clears both thresholds in one accrual. + let f = order_fixture(&app, "mb-jump", price_for(base + 250), 1, true).await; + let status = membership(&app, &f.customer).await; + assert_eq!(status["growth_total"], base + 250); + assert_eq!(status["level"]["id"], silver["id"]); + assert_eq!(status["level"]["growth_threshold"], base + 200); +} + +#[tokio::test] +#[serial] +async fn below_every_threshold_holds_no_level_until_a_later_accrual_qualifies() { + let app = spawn_app().await; + reset_levels(&app).await; + let admin = login_admin(&app).await; + let base = salt(); + let bronze = level(&app, &admin, base, "Bronze", "青铜").await; + + // A sub-unit order truncates to zero growth, which is below every defined + // threshold regardless of other fixtures in the shared database. + let first = order_fixture(&app, "mb-below", 1, 1, true).await; + let status = membership(&app, &first.customer).await; + assert_eq!(status["growth_total"], 0); + assert!(status["level"].is_null()); + assert_eq!(status["next_level"]["id"], bronze["id"]); + assert_eq!(status["next_level"]["remaining"], base); + + // The zero-value accrual is still one immutable ledger entry. + let logs = growth_logs(&app, &first.customer, 1, 20).await; + assert_eq!(logs["total"], 1); + assert_eq!(logs["items"][0]["delta"], 0); + + // A later qualifying accrual assigns the level. + let (owner, _shop, _product, sku_id) = + setup_sellable(&app, &admin, "mb-later", price_for(base), 50).await; + add_to_cart(&app, &first.customer, &sku_id, 1).await; + let orders = checkout(&app, &first.customer).await; + let order_id = orders[0]["id"].as_str().unwrap().to_string(); + pay(&app, &first.customer, &order_id).await; + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/shop/orders/{order_id}"))) + .bearer_auth(&owner) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let item_id = detail["items"][0]["id"].as_str().unwrap().to_string(); + let res = client() + .post(app.url(&format!("/api/shop/orders/{order_id}/shipments"))) + .bearer_auth(&owner) + .json(&serde_json::json!({ + "carrier": "SF", "tracking_no": "T2", + "items": [{ "order_item_id": item_id, "qty": 1 }] + })) + .send() + .await + .unwrap(); + let ship_id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + client() + .post(app.url(&format!("/api/shop/shipments/{ship_id}/ship"))) + .bearer_auth(&owner) + .send() + .await + .unwrap(); + client() + .post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered"))) + .bearer_auth(&first.customer) + .send() + .await + .unwrap(); + + let status = membership(&app, &first.customer).await; + assert_eq!(status["growth_total"], base); + assert_eq!(status["level"]["id"], bronze["id"]); +} + +#[tokio::test] +#[serial] +async fn status_rederives_the_level_against_current_thresholds() { + let app = spawn_app().await; + reset_levels(&app).await; + let admin = login_admin(&app).await; + let base = salt(); + let bronze = level(&app, &admin, base, "Bronze", "青铜").await; + let silver = level(&app, &admin, base + 300, "Silver", "白银").await; + let f = order_fixture(&app, "mb-rederive", price_for(base + 100), 1, true).await; + assert_eq!(membership(&app, &f.customer).await["level"]["id"], bronze["id"]); + + // Lowering a higher level's threshold under the customer's growth makes the + // derived status move even though `users.level` was written at accrual time. + let res = client() + .put(app.url(&format!( + "/api/admin/member-levels/{}", + silver["id"].as_str().unwrap() + ))) + .bearer_auth(&admin) + .json(&serde_json::json!({ + "name": { "en": "Silver", "zh": "白银" }, "icon": "medal", + "growth_threshold": base + 50, "benefits": { "en": "s", "zh": "s" } + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + + let status = membership(&app, &f.customer).await; + assert_eq!(status["growth_total"], base + 100); + assert_eq!(status["level"]["id"], silver["id"]); + assert_eq!(status["level"]["growth_threshold"], base + 50); + + // The stored level is the one accrual assigned; the read re-derives. + let me: serde_json::Value = client() + .get(app.url("/api/auth/me")) + .bearer_auth(&f.customer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let stored: Option = sqlx::query_scalar("SELECT level FROM users WHERE id = $1::uuid") + .bind(me["id"].as_str().unwrap()) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!( + stored, + Some(Uuid::parse_str(bronze["id"].as_str().unwrap()).unwrap()) + ); +} + +#[tokio::test] +#[serial] +async fn deleting_a_held_level_is_rejected() { + let app = spawn_app().await; + reset_levels(&app).await; + let admin = login_admin(&app).await; + let base = salt(); + let bronze = level(&app, &admin, base, "Bronze", "青铜").await; + let f = order_fixture(&app, "mb-held", price_for(base), 1, true).await; + assert_eq!(membership(&app, &f.customer).await["level"]["id"], bronze["id"]); + + let res = client() + .delete(app.url(&format!( + "/api/admin/member-levels/{}", + bronze["id"].as_str().unwrap() + ))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409, "{:?}", res.text().await); + // The level and its member are untouched. + assert_eq!(membership(&app, &f.customer).await["level"]["id"], bronze["id"]); +} + +#[tokio::test] +#[serial] +async fn growth_history_is_own_only_and_paginated() { + let app = spawn_app().await; + reset_levels(&app).await; + let admin = login_admin(&app).await; + + // No levels needed: this only exercises the ledger and its scoping. + let alice = order_fixture(&app, "mb-alice", 5000, 1, true).await; + let bob = order_fixture(&app, "mb-bob", 5000, 1, true).await; + + // A second completed order for Alice. + let (owner, _shop, _product, sku_id) = + setup_sellable(&app, &admin, "mb-alice-2", 5000, 50).await; + add_to_cart(&app, &alice.customer, &sku_id, 1).await; + let orders = checkout(&app, &alice.customer).await; + let order_id = orders[0]["id"].as_str().unwrap().to_string(); + pay(&app, &alice.customer, &order_id).await; + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/shop/orders/{order_id}"))) + .bearer_auth(&owner) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let item_id = detail["items"][0]["id"].as_str().unwrap().to_string(); + let res = client() + .post(app.url(&format!("/api/shop/orders/{order_id}/shipments"))) + .bearer_auth(&owner) + .json(&serde_json::json!({ + "carrier": "SF", "tracking_no": "T3", + "items": [{ "order_item_id": item_id, "qty": 1 }] + })) + .send() + .await + .unwrap(); + let ship_id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + client() + .post(app.url(&format!("/api/shop/shipments/{ship_id}/ship"))) + .bearer_auth(&owner) + .send() + .await + .unwrap(); + client() + .post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered"))) + .bearer_auth(&alice.customer) + .send() + .await + .unwrap(); + + let first = growth_logs(&app, &alice.customer, 1, 1).await; + assert_eq!(first["total"], 2); + assert_eq!(first["items"].as_array().unwrap().len(), 1); + let second = growth_logs(&app, &alice.customer, 2, 1).await; + assert_eq!(second["items"].as_array().unwrap().len(), 1); + assert_ne!(first["items"][0]["id"], second["items"][0]["id"]); + + // Bob only sees his own single entry. + let bob_logs = growth_logs(&app, &bob.customer, 1, 20).await; + assert_eq!(bob_logs["total"], 1); + let alice_ids: Vec<&str> = first["items"] + .as_array() + .unwrap() + .iter() + .chain(second["items"].as_array().unwrap()) + .map(|e| e["id"].as_str().unwrap()) + .collect(); + assert!(!alice_ids.contains(&bob_logs["items"][0]["id"].as_str().unwrap())); +} diff --git a/apps/api/tests/merchant_applications.rs b/apps/api/tests/merchant_applications.rs new file mode 100644 index 0000000..7fcff69 --- /dev/null +++ b/apps/api/tests/merchant_applications.rs @@ -0,0 +1,515 @@ +mod common; + +use common::{ + category_id_by_slug, client, login_admin, register_customer, spawn_app, TestApp, +}; +use serial_test::serial; +use uuid::Uuid; + +// Merchant onboarding suite: each test provisions its own applicant and only +// asserts on rows it created; the shared test database is never truncated. + +fn personal(category_id: &str, email: &str) -> serde_json::Value { + serde_json::json!({ + "entity_type": "personal", + "real_name": "Jane Applicant", + "category_ids": [category_id], + "contact": { + "name": "Jane Applicant", + "phone": "13800000000", + "email": email, + "address": "1 Applicant Way" + }, + "qualification": { + "identity_document_url": "https://example.com/id-card.png", + "extra_materials": ["https://example.com/extra-1.pdf"] + } + }) +} + +fn enterprise(category_id: &str, email: &str) -> serde_json::Value { + serde_json::json!({ + "entity_type": "enterprise", + "company_name": "Acme Trading Co", + "category_ids": [category_id], + "contact": { "name": "Ann Manager", "phone": "13900000000", "email": email }, + "qualification": { + "business_license_url": "https://example.com/license.pdf", + "business_license_no": "91310000MA1FL0XXXX", + "extra_materials": ["https://example.com/tax.pdf"] + } + }) +} + +async fn submit(app: &TestApp, token: &str, body: &serde_json::Value) -> reqwest::Response { + client() + .post(app.url("/api/merchant/applications")) + .bearer_auth(token) + .json(body) + .send() + .await + .unwrap() +} + +async fn mine(app: &TestApp, token: &str) -> Vec { + let res = client() + .get(app.url("/api/merchant/applications")) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + res.json().await.unwrap() +} + +async fn admin_reject( + app: &TestApp, + admin: &str, + id: &str, + reason: &str, +) -> reqwest::Response { + client() + .post(app.url(&format!("/api/admin/merchant/applications/{id}/reject"))) + .bearer_auth(admin) + .json(&serde_json::json!({ "reason": reason })) + .send() + .await + .unwrap() +} + +async fn admin_approve(app: &TestApp, admin: &str, id: &str) -> reqwest::Response { + client() + .post(app.url(&format!("/api/admin/merchant/applications/{id}/approve"))) + .bearer_auth(admin) + .send() + .await + .unwrap() +} + +async fn contact_email(label: &str) -> String { + format!("{label}-{}@biz.test", Uuid::new_v4()) +} + +#[tokio::test] +#[serial] +async fn submissions_are_accepted_for_both_kinds() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let cat = category_id_by_slug(&app, "electronics").await; + + let (seller, _) = register_customer(&app, "mo-enterprise").await; + let mail = contact_email("enterprise").await; + let res = submit(&app, &seller, &enterprise(&cat, &mail)).await; + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let created: serde_json::Value = res.json().await.unwrap(); + assert_eq!(created["entity_type"], "enterprise"); + assert_eq!(created["status"], "pending"); + assert_eq!(created["company_name"], "Acme Trading Co"); + assert_eq!(created["contact"]["email"], mail); + assert_eq!( + created["qualification"]["business_license_no"], + "91310000MA1FL0XXXX" + ); + assert_eq!(created["categories"][0]["id"], cat); + assert_eq!(created["category_ids"][0], cat); + assert!(created["created_shop_id"].is_null()); + assert!(created["rejection_reason"].is_null()); + + let (buyer, _) = register_customer(&app, "mo-personal").await; + let res = submit(&app, &buyer, &personal(&cat, &contact_email("personal").await)).await; + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let personal_row: serde_json::Value = res.json().await.unwrap(); + assert_eq!(personal_row["entity_type"], "personal"); + assert_eq!(personal_row["real_name"], "Jane Applicant"); + assert_eq!( + personal_row["qualification"]["identity_document_url"], + "https://example.com/id-card.png" + ); + assert!(personal_row["company_name"].is_null()); + + // The admin queue filters by status and includes both fresh rows. + let listed: serde_json::Value = client() + .get(app.url("/api/admin/merchant/applications")) + .query(&[("status", "pending"), ("per_page", "100")]) + .bearer_auth(&admin) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let ids: Vec<&str> = listed["items"] + .as_array() + .unwrap() + .iter() + .map(|a| a["id"].as_str().unwrap()) + .collect(); + assert!(ids.contains(&created["id"].as_str().unwrap())); + assert!(ids.contains(&personal_row["id"].as_str().unwrap())); +} + +#[tokio::test] +#[serial] +async fn submission_validation_rejects_bad_input() { + let app = spawn_app().await; + let cat = category_id_by_slug(&app, "electronics").await; + let (seller, _) = register_customer(&app, "mo-invalid").await; + + // Anonymous submissions are refused. + let anon = client() + .post(app.url("/api/merchant/applications")) + .json(&personal(&cat, &contact_email("anon").await)) + .send() + .await + .unwrap(); + assert_eq!(anon.status(), 401); + + // Personal without the identity document. + let mut body = personal(&cat, &contact_email("nodoc").await); + body["qualification"] = serde_json::json!({ "extra_materials": [] }); + assert_eq!(submit(&app, &seller, &body).await.status(), 400); + + // Malformed qualification URL. + let mut body = personal(&cat, &contact_email("badurl").await); + body["qualification"]["identity_document_url"] = serde_json::json!("not-a-url"); + assert_eq!(submit(&app, &seller, &body).await.status(), 400); + + // Enterprise missing its company name. + let mut body = enterprise(&cat, &contact_email("nocname").await); + body["company_name"] = serde_json::json!(" "); + assert_eq!(submit(&app, &seller, &body).await.status(), 400); + + // No operating categories. + let mut body = enterprise(&cat, &contact_email("nocat").await); + body["category_ids"] = serde_json::json!([]); + assert_eq!(submit(&app, &seller, &body).await.status(), 400); + + // Unknown category reference. + let mut body = enterprise(&cat, &contact_email("unknowncat").await); + body["category_ids"] = serde_json::json!([Uuid::new_v4()]); + assert_eq!(submit(&app, &seller, &body).await.status(), 400); + + // Invalid contact email. + let mut body = enterprise(&cat, &contact_email("badmail").await); + body["contact"]["email"] = serde_json::json!("not-an-email"); + assert_eq!(submit(&app, &seller, &body).await.status(), 400); + + // None of the rejected attempts stored a row. + assert!(mine(&app, &seller).await.is_empty()); +} + +#[tokio::test] +#[serial] +async fn one_active_application_per_user_and_reapply_after_rejection() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let cat = category_id_by_slug(&app, "electronics").await; + let (seller, _) = register_customer(&app, "mo-dedupe").await; + let body = enterprise(&cat, &contact_email("dedupe").await); + + let first: serde_json::Value = submit(&app, &seller, &body) + .await + .json() + .await + .unwrap(); + let id = first["id"].as_str().unwrap().to_string(); + + // A second live application is a conflict. + let res = submit(&app, &seller, &body).await; + assert_eq!(res.status(), 409, "{:?}", res.text().await); + assert_eq!(mine(&app, &seller).await.len(), 1); + + // Rejection records the reason, then re-applying is allowed. + let res = admin_reject(&app, &admin, &id, "qualification unreadable").await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let rejected: serde_json::Value = res.json().await.unwrap(); + assert_eq!(rejected["status"], "rejected"); + assert_eq!(rejected["rejection_reason"], "qualification unreadable"); + assert!(rejected["reviewed_at"].is_string()); + + let res = submit(&app, &seller, &body).await; + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let reapplied: serde_json::Value = res.json().await.unwrap(); + assert_ne!(reapplied["id"], first["id"]); + assert_eq!(reapplied["status"], "pending"); + assert_eq!(mine(&app, &seller).await.len(), 2); +} + +#[tokio::test] +#[serial] +async fn reviews_are_guarded_and_rejection_needs_a_reason() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let cat = category_id_by_slug(&app, "fashion").await; + let (seller, _) = register_customer(&app, "mo-guard").await; + let created: serde_json::Value = submit(&app, &seller, &personal(&cat, &contact_email("guard").await)) + .await + .json() + .await + .unwrap(); + let id = created["id"].as_str().unwrap().to_string(); + + // Blank / whitespace reasons never transition. + assert_eq!(admin_reject(&app, &admin, &id, "").await.status(), 400); + assert_eq!(admin_reject(&app, &admin, &id, " ").await.status(), 400); + + assert_eq!( + admin_reject(&app, &admin, &id, "material incomplete") + .await + .status(), + 200 + ); + + // Terminal states are immutable for both actions. + assert_eq!(admin_approve(&app, &admin, &id).await.status(), 409); + assert_eq!( + admin_reject(&app, &admin, &id, "again").await.status(), + 409 + ); +} + +#[tokio::test] +#[serial] +async fn approval_provisions_shop_and_owner_with_one_time_credentials() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let cat = category_id_by_slug(&app, "home-living").await; + let (seller, _) = register_customer(&app, "mo-approve").await; + let mail = contact_email("approve").await; + let created: serde_json::Value = submit(&app, &seller, &enterprise(&cat, &mail)) + .await + .json() + .await + .unwrap(); + let id = created["id"].as_str().unwrap().to_string(); + + let res = admin_approve(&app, &admin, &id).await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let approved: serde_json::Value = res.json().await.unwrap(); + assert_eq!(approved["application"]["status"], "approved"); + let shop_id = approved["application"]["created_shop_id"] + .as_str() + .unwrap() + .to_string(); + assert!(!shop_id.is_empty()); + let credentials = &approved["credentials"]; + assert_eq!(credentials["email"], mail); + assert_eq!(credentials["shop_id"], shop_id); + let password = credentials["initial_password"].as_str().unwrap().to_string(); + assert!(password.len() >= 8); + let slug = credentials["shop_slug"].as_str().unwrap().to_string(); + assert!(slug.starts_with("acme-trading-co-"), "unexpected slug {slug}"); + + // The password is not retrievable from any later read. + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/admin/merchant/applications/{id}"))) + .bearer_auth(&admin) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(detail.get("credentials").is_none()); + assert!(detail["initial_password"].is_null()); + assert_eq!(detail["status"], "approved"); + + // The generated owner can log in and manage the linked shop. + let res = client() + .post(app.url("/api/auth/login")) + .json(&serde_json::json!({ "email": mail, "password": password })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let session: serde_json::Value = res.json().await.unwrap(); + assert_eq!(session["user"]["role"], "shop_owner"); + assert_eq!(session["user"]["shop_id"], shop_id); + let owner_token = session["token"].as_str().unwrap().to_string(); + + let res = client() + .get(app.url("/api/shop/profile")) + .bearer_auth(&owner_token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + assert_eq!(res.json::().await.unwrap()["id"], shop_id); + + // One review only. + assert_eq!(admin_approve(&app, &admin, &id).await.status(), 409); +} + +#[tokio::test] +#[serial] +async fn concurrent_reviews_transition_exactly_once() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let cat = category_id_by_slug(&app, "electronics").await; + let (seller, _) = register_customer(&app, "mo-race").await; + let created: serde_json::Value = submit(&app, &seller, &enterprise(&cat, &contact_email("race").await)) + .await + .json() + .await + .unwrap(); + let id = created["id"].as_str().unwrap().to_string(); + + let (a, b) = tokio::join!( + admin_approve(&app, &admin, &id), + admin_approve(&app, &admin, &id) + ); + let statuses = [a.status().as_u16(), b.status().as_u16()]; + assert!( + statuses.contains(&200) && statuses.contains(&409), + "expected one success and one conflict, got {statuses:?}" + ); + + // Exactly one shop owner was provisioned for the created shop. + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/admin/merchant/applications/{id}"))) + .bearer_auth(&admin) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let shop_id = detail["created_shop_id"].as_str().unwrap().to_string(); + let owners: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM users WHERE shop_id = $1::uuid AND role = 'shop_owner'", + ) + .bind(&shop_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(owners, 1); +} + +#[tokio::test] +#[serial] +async fn concurrent_submissions_create_one_application() { + let app = spawn_app().await; + let cat = category_id_by_slug(&app, "fashion").await; + let (seller, _) = register_customer(&app, "mo-race-submit").await; + let body = personal(&cat, &contact_email("race-submit").await); + + let (a, b) = tokio::join!(submit(&app, &seller, &body), submit(&app, &seller, &body)); + let statuses = [a.status().as_u16(), b.status().as_u16()]; + assert!( + statuses.contains(&201) && statuses.contains(&409), + "expected one success and one conflict, got {statuses:?}" + ); + assert_eq!(mine(&app, &seller).await.len(), 1); +} + +#[tokio::test] +#[serial] +async fn approval_rolls_back_when_provisioning_fails() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let cat = category_id_by_slug(&app, "electronics").await; + let (seller, _) = register_customer(&app, "mo-rollback").await; + let mail = contact_email("rollback").await; + + // A fixed entity name makes the derived slug predictable. + let mut body = enterprise(&cat, &mail); + body["company_name"] = serde_json::json!("Rollback Mart"); + let created: serde_json::Value = submit(&app, &seller, &body) + .await + .json() + .await + .unwrap(); + let id = created["id"].as_str().unwrap().to_string(); + let expected_slug = format!("rollback-mart-{}", &id.replace('-', "")[..6]); + + // Claim the slug so the approval's shop insert fails mid-transaction. + sqlx::query("INSERT INTO shops (name, slug) VALUES ($1, $2)") + .bind(serde_json::json!({"en": "Squatter", "zh": "占位"})) + .bind(&expected_slug) + .execute(&app.db) + .await + .unwrap(); + + let res = admin_approve(&app, &admin, &id).await; + assert_eq!(res.status(), 409, "{:?}", res.text().await); + + // The application stayed pending and nothing else was provisioned. + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/admin/merchant/applications/{id}"))) + .bearer_auth(&admin) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(detail["status"], "pending"); + assert!(detail["created_shop_id"].is_null()); + assert!(detail["reviewed_at"].is_null()); + + let shops: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM shops WHERE slug = $1") + .bind(&expected_slug) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(shops, 1, "only the squatter shop may exist"); + let owners: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = $1") + .bind(&mail) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(owners, 0, "no owner account may have been provisioned"); +} + +#[tokio::test] +#[serial] +async fn applications_are_user_isolated() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let cat = category_id_by_slug(&app, "electronics").await; + let (alice, _) = register_customer(&app, "mo-alice").await; + let (bob, _) = register_customer(&app, "mo-bob").await; + + let created: serde_json::Value = submit(&app, &alice, &personal(&cat, &contact_email("alice").await)) + .await + .json() + .await + .unwrap(); + let id = created["id"].as_str().unwrap().to_string(); + + // Bob sees no trace of Alice's application. + assert!(mine(&app, &bob).await.is_empty()); + assert_eq!(mine(&app, &alice).await.len(), 1); + + // Customer routes expose no cross-user read, and admin routes need the role. + assert_eq!( + client() + .get(app.url(&format!("/api/admin/merchant/applications/{id}"))) + .bearer_auth(&bob) + .send() + .await + .unwrap() + .status(), + 403 + ); + assert_eq!( + client() + .get(app.url("/api/admin/merchant/applications")) + .bearer_auth(&alice) + .send() + .await + .unwrap() + .status(), + 403 + ); + assert_eq!( + client() + .get(app.url(&format!("/api/admin/merchant/applications/{id}"))) + .bearer_auth(&admin) + .send() + .await + .unwrap() + .status(), + 200 + ); +} diff --git a/apps/api/tests/messaging.rs b/apps/api/tests/messaging.rs new file mode 100644 index 0000000..d3ae3bd --- /dev/null +++ b/apps/api/tests/messaging.rs @@ -0,0 +1,413 @@ +mod common; + +use common::{ + add_to_cart, checkout, client, login_admin, pay, register_customer, setup_sellable, spawn_app, + TestApp, +}; +use serial_test::serial; + +// Messaging suite: every test builds its own shop, customer, and orders and +// only asserts on rows it created. + +struct Shop { + owner: String, + customer: String, + sku_id: String, +} + +async fn shop_with_customer(app: &TestApp, label: &str, price_minor: i64) -> Shop { + let admin = login_admin(app).await; + let (owner, _shop_id, _product, sku_id) = setup_sellable(app, &admin, label, price_minor, 50).await; + let (customer, _) = register_customer(app, label).await; + Shop { + owner, + customer, + sku_id, + } +} + +/// Place and pay one order; returns (order_id, order_item_id, order_no). +async fn place_paid_order(app: &TestApp, shop: &Shop, qty: i32) -> (String, String, String) { + add_to_cart(app, &shop.customer, &shop.sku_id, qty).await; + let orders = checkout(app, &shop.customer).await; + let order_id = orders[0]["id"].as_str().unwrap().to_string(); + let order_no = orders[0]["order_no"].as_str().unwrap().to_string(); + pay(app, &shop.customer, &order_id).await; + + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/shop/orders/{order_id}"))) + .bearer_auth(&shop.owner) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let item_id = detail["items"][0]["id"].as_str().unwrap().to_string(); + (order_id, item_id, order_no) +} + +/// Create a shipment and mark it shipped (emits `order_shipped`). +async fn ship_order(app: &TestApp, shop: &Shop, order_id: &str, item_id: &str, qty: i32) -> String { + let res = client() + .post(app.url(&format!("/api/shop/orders/{order_id}/shipments"))) + .bearer_auth(&shop.owner) + .json(&serde_json::json!({ + "carrier": "SF", "tracking_no": "T1", + "items": [{ "order_item_id": item_id, "qty": qty }] + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let ship_id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let res = client() + .post(app.url(&format!("/api/shop/shipments/{ship_id}/ship"))) + .bearer_auth(&shop.owner) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + ship_id +} + +/// Complete an order: ship, then the buyer confirms receipt. +async fn complete_order(app: &TestApp, shop: &Shop, order_id: &str, item_id: &str, qty: i32) { + let ship_id = ship_order(app, shop, order_id, item_id, qty).await; + let res = client() + .post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered"))) + .bearer_auth(&shop.customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); +} + +/// Complete a refund-only after-sale (emits `refund_completed`); returns its id. +async fn refund_order(app: &TestApp, shop: &Shop, item_id: &str, amount_minor: i64) -> String { + let res = client() + .post(app.url("/api/aftersales")) + .bearer_auth(&shop.customer) + .json(&serde_json::json!({ + "order_item_id": item_id, + "kind": "refund_only", + "reason": { "en": "smoke refund", "zh": "冒烟退款" }, + "amount_minor": amount_minor + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + for action in ["approve", "refund"] { + let res = client() + .post(app.url(&format!("/api/shop/aftersales/{id}/{action}"))) + .bearer_auth(&shop.owner) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{action}: {:?}", res.text().await); + } + id +} + +async fn messages( + app: &TestApp, + token: &str, + query: &[(&str, String)], +) -> serde_json::Value { + let res = client() + .get(app.url("/api/messages")) + .query(query) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + res.json().await.unwrap() +} + +async fn unread_count(app: &TestApp, token: &str) -> i64 { + client() + .get(app.url("/api/messages/unread-count")) + .bearer_auth(token) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["unread"] + .as_i64() + .unwrap() +} + +fn of_kind<'a>(page: &'a serde_json::Value, kind: &str) -> Vec<&'a serde_json::Value> { + page["items"] + .as_array() + .unwrap() + .iter() + .filter(|m| m["kind"] == kind) + .collect() +} + +#[tokio::test] +#[serial] +async fn payment_emits_one_order_paid_message() { + let app = spawn_app().await; + let shop = shop_with_customer(&app, "ms-paid", 1000).await; + let (order_id, _item, order_no) = place_paid_order(&app, &shop, 1).await; + + let page = messages(&app, &shop.customer, &[("per_page", "100".into())]).await; + let paid = of_kind(&page, "order_paid"); + assert_eq!(paid.len(), 1); + assert_eq!(paid[0]["reference_type"], "order"); + assert_eq!(paid[0]["reference_id"], order_id); + assert_eq!(paid[0]["status"], "unread"); + assert!(paid[0]["read_at"].is_null()); + assert!(paid[0]["title"]["en"].as_str().unwrap().len() > 0); + assert!(paid[0]["title"]["zh"].as_str().unwrap().len() > 0); + assert!(paid[0]["body"]["en"].as_str().unwrap().contains(&order_no)); + assert!(paid[0]["body"]["zh"].as_str().unwrap().contains(&order_no)); + + // A retried payment conflicts and cannot duplicate the message. + let res = client() + .post(app.url(&format!("/api/orders/{order_id}/pay"))) + .bearer_auth(&shop.customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409, "{:?}", res.text().await); + let page = messages(&app, &shop.customer, &[("per_page", "100".into())]).await; + assert_eq!(of_kind(&page, "order_paid").len(), 1); + assert_eq!(page["total"], 1); +} + +#[tokio::test] +#[serial] +async fn dispatch_and_refund_emit_their_messages() { + let app = spawn_app().await; + let shop = shop_with_customer(&app, "ms-events", 2000).await; + let (order_id, item_id, order_no) = place_paid_order(&app, &shop, 1).await; + + let ship_id = ship_order(&app, &shop, &order_id, &item_id, 1).await; + // Re-shipping the same shipment conflicts; the message stays single. + let res = client() + .post(app.url(&format!("/api/shop/shipments/{ship_id}/ship"))) + .bearer_auth(&shop.owner) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409, "{:?}", res.text().await); + + let aftersale_id = refund_order(&app, &shop, &item_id, 500).await; + + let page = messages(&app, &shop.customer, &[("per_page", "100".into())]).await; + assert_eq!(of_kind(&page, "order_paid").len(), 1); + + let shipped = of_kind(&page, "order_shipped"); + assert_eq!(shipped.len(), 1); + assert_eq!(shipped[0]["reference_type"], "order"); + assert_eq!(shipped[0]["reference_id"], order_id); + assert!(shipped[0]["body"]["en"].as_str().unwrap().contains(&order_no)); + + let refunded = of_kind(&page, "refund_completed"); + assert_eq!(refunded.len(), 1); + assert_eq!(refunded[0]["reference_type"], "aftersale"); + assert_eq!(refunded[0]["reference_id"], aftersale_id); + assert!(refunded[0]["body"]["en"].as_str().unwrap().contains(&order_no)); + assert!(refunded[0]["body"]["zh"].as_str().unwrap().contains(&order_no)); + assert_eq!(page["total"], 3); +} + +#[tokio::test] +#[serial] +async fn read_state_machine_is_guarded_and_idempotent() { + let app = spawn_app().await; + let shop = shop_with_customer(&app, "ms-read", 1000).await; + // Three paid orders -> three unread messages. + for _ in 0..3 { + place_paid_order(&app, &shop, 1).await; + } + let page = messages(&app, &shop.customer, &[("per_page", "100".into())]).await; + assert_eq!(page["total"], 3); + assert_eq!(unread_count(&app, &shop.customer).await, 3); + let first_id = page["items"][0]["id"].as_str().unwrap().to_string(); + + // Mark one read, then repeat: only the first call changes state. + let res = client() + .post(app.url(&format!("/api/messages/{first_id}/read"))) + .bearer_auth(&shop.customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let read: serde_json::Value = res.json().await.unwrap(); + assert_eq!(read["status"], "read"); + assert!(read["read_at"].is_string()); + let read_at = read["read_at"].as_str().unwrap().to_string(); + let res = client() + .post(app.url(&format!("/api/messages/{first_id}/read"))) + .bearer_auth(&shop.customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let again: serde_json::Value = res.json().await.unwrap(); + assert_eq!(again["read_at"], read_at, "repeat marking must not rewrite"); + assert_eq!(unread_count(&app, &shop.customer).await, 2); + + // Mark all read flips exactly the two remaining unread rows. + let res = client() + .post(app.url("/api/messages/read-all")) + .bearer_auth(&shop.customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + assert_eq!( + res.json::().await.unwrap()["updated"], + 2 + ); + assert_eq!(unread_count(&app, &shop.customer).await, 0); + let res = client() + .post(app.url("/api/messages/read-all")) + .bearer_auth(&shop.customer) + .send() + .await + .unwrap(); + assert_eq!( + res.json::().await.unwrap()["updated"], + 0 + ); + + // The unread-only filter empties out. + let unread = messages( + &app, + &shop.customer, + &[("unread_only", "true".into())], + ) + .await; + assert_eq!(unread["total"], 0); +} + +#[tokio::test] +#[serial] +async fn soft_delete_is_idempotent_and_excluded_from_list_and_count() { + let app = spawn_app().await; + let shop = shop_with_customer(&app, "ms-delete", 1000).await; + for _ in 0..3 { + place_paid_order(&app, &shop, 1).await; + } + let page = messages(&app, &shop.customer, &[("per_page", "100".into())]).await; + let id = page["items"][0]["id"].as_str().unwrap().to_string(); + + let res = client() + .delete(app.url(&format!("/api/messages/{id}"))) + .bearer_auth(&shop.customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + assert_eq!(res.json::().await.unwrap()["deleted"], true); + assert_eq!(unread_count(&app, &shop.customer).await, 2); + let page = messages(&app, &shop.customer, &[("per_page", "100".into())]).await; + assert_eq!(page["total"], 2); + assert!(!page["items"] + .as_array() + .unwrap() + .iter() + .any(|m| m["id"] == id)); + + // Soft deletion is idempotent: the row is retained for audit. + let res = client() + .delete(app.url(&format!("/api/messages/{id}"))) + .bearer_auth(&shop.customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + assert_eq!(res.json::().await.unwrap()["deleted"], false); + let retained: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE id = $1::uuid") + .bind(&id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(retained, 1); +} + +#[tokio::test] +#[serial] +async fn unread_only_filter_matches_the_count_endpoint() { + let app = spawn_app().await; + let shop = shop_with_customer(&app, "ms-filter", 1000).await; + for _ in 0..4 { + place_paid_order(&app, &shop, 1).await; + } + let unread = messages(&app, &shop.customer, &[("unread_only", "true".into())]).await; + assert_eq!(unread["total"], 4); + assert_eq!(unread_count(&app, &shop.customer).await, 4); + + client() + .post(app.url("/api/messages/read-all")) + .bearer_auth(&shop.customer) + .send() + .await + .unwrap(); + assert_eq!(unread_count(&app, &shop.customer).await, 0); + let unread = messages(&app, &shop.customer, &[("unread_only", "true".into())]).await; + assert_eq!(unread["total"], 0); + // The full list still shows all four, now read. + let all = messages(&app, &shop.customer, &[("per_page", "100".into())]).await; + assert_eq!(all["total"], 4); +} + +#[tokio::test] +#[serial] +async fn foreign_messages_are_unreachable() { + let app = spawn_app().await; + let alice = shop_with_customer(&app, "ms-alice", 1000).await; + let bob = shop_with_customer(&app, "ms-bob", 1000).await; + let (_order, _item, _no) = place_paid_order(&app, &alice, 1).await; + + let page = messages(&app, &alice.customer, &[("per_page", "100".into())]).await; + let id = page["items"][0]["id"].as_str().unwrap().to_string(); + + // Bob cannot see, read, or delete Alice's message. + let bob_page = messages(&app, &bob.customer, &[("per_page", "100".into())]).await; + assert_eq!(bob_page["total"], 0); + let res = client() + .post(app.url(&format!("/api/messages/{id}/read"))) + .bearer_auth(&bob.customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404, "{:?}", res.text().await); + let res = client() + .delete(app.url(&format!("/api/messages/{id}"))) + .bearer_auth(&bob.customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404, "{:?}", res.text().await); + + // Alice's message is untouched and still unread. + assert_eq!(unread_count(&app, &alice.customer).await, 1); + let still: serde_json::Value = client() + .get(app.url("/api/messages")) + .query(&[("per_page", "100")]) + .bearer_auth(&alice.customer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(still["items"][0]["id"], id); + assert_eq!(still["items"][0]["status"], "unread"); +} diff --git a/apps/api/tests/settlement.rs b/apps/api/tests/settlement.rs new file mode 100644 index 0000000..fa88fd2 --- /dev/null +++ b/apps/api/tests/settlement.rs @@ -0,0 +1,556 @@ +mod common; + +use common::{ + add_to_cart, checkout, client, login_admin, pay, register_customer, setup_sellable, spawn_app, + TestApp, +}; +use serial_test::serial; + +// Settlement suite: each test builds its own shop, product, and completed +// order, then backdates completion into a closed period. Only rows this test +// created are asserted on; the shared test database is never truncated. + +struct Fixture { + admin: String, + owner: String, + customer: String, + shop_id: String, + order_id: String, + item_id: String, + total_minor: i64, +} + +/// A paid, shipped, buyer-confirmed order (status `completed`). +async fn completed_order(app: &TestApp, label: &str, price_minor: i64, qty: i32) -> Fixture { + let admin = login_admin(app).await; + let (owner, shop_id, _product, sku_id) = + setup_sellable(app, &admin, label, price_minor, 50).await; + let (customer, _) = register_customer(app, label).await; + add_to_cart(app, &customer, &sku_id, qty).await; + let orders = checkout(app, &customer).await; + let order_id = orders[0]["id"].as_str().unwrap().to_string(); + pay(app, &customer, &order_id).await; + + let detail: serde_json::Value = client() + .get(app.url(&format!("/api/shop/orders/{order_id}"))) + .bearer_auth(&owner) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let item_id = detail["items"][0]["id"].as_str().unwrap().to_string(); + let total_minor = detail["total_minor"].as_i64().unwrap(); + + let res = client() + .post(app.url(&format!("/api/shop/orders/{order_id}/shipments"))) + .bearer_auth(&owner) + .json(&serde_json::json!({ + "carrier": "SF", "tracking_no": "T1", + "items": [{"order_item_id": item_id, "qty": qty}] + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let ship_id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + assert_eq!( + client() + .post(app.url(&format!("/api/shop/shipments/{ship_id}/ship"))) + .bearer_auth(&owner) + .send() + .await + .unwrap() + .status(), + 200 + ); + let res = client() + .post(app.url(&format!("/api/shipments/{ship_id}/confirm-delivered"))) + .bearer_auth(&customer) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + + Fixture { + admin, + owner, + customer, + shop_id, + order_id, + item_id, + total_minor, + } +} + +/// Move the order's completion instant into a closed period. Refund totals are +/// untouched, so this must run after any after-sale operations. +async fn backdate(app: &TestApp, order_id: &str, date: &str) { + sqlx::query( + "UPDATE orders + SET completed_at = ($2::date + time '12:00') AT TIME ZONE 'UTC', + created_at = ($2::date + time '12:00') AT TIME ZONE 'UTC', + updated_at = ($2::date + time '12:00') AT TIME ZONE 'UTC' + WHERE id = $1::uuid", + ) + .bind(order_id) + .bind(date) + .execute(&app.db) + .await + .unwrap(); +} + +async fn set_rate(app: &TestApp, admin: &str, bps: i64) -> reqwest::Response { + client() + .put(app.url("/api/admin/settlement/commission-rate")) + .bearer_auth(admin) + .json(&serde_json::json!({ "commission_rate_bps": bps })) + .send() + .await + .unwrap() +} + +async fn admin_generate( + app: &TestApp, + admin: &str, + shop_id: &str, + kind: &str, + date: &str, +) -> reqwest::Response { + client() + .post(app.url("/api/admin/settlement/statements")) + .bearer_auth(admin) + .json(&serde_json::json!({ + "shop_id": shop_id, + "period_kind": kind, + "period_start": date, + })) + .send() + .await + .unwrap() +} + +async fn shop_generate(app: &TestApp, token: &str, kind: &str, date: &str) -> reqwest::Response { + client() + .post(app.url("/api/shop/settlement/statements")) + .bearer_auth(token) + .json(&serde_json::json!({ "period_kind": kind, "period_start": date })) + .send() + .await + .unwrap() +} + +async fn statement(app: &TestApp, token: &str, id: &str) -> serde_json::Value { + let res = client() + .get(app.url(&format!("/api/admin/settlement/statements/{id}"))) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + res.json().await.unwrap() +} + +async fn admin_statements( + app: &TestApp, + admin: &str, + shop_id: &str, +) -> serde_json::Value { + let res = client() + .get(app.url("/api/admin/settlement/statements")) + .query(&[("shop_id", shop_id)]) + .bearer_auth(admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + res.json().await.unwrap() +} + +async fn wallet_available(app: &TestApp, token: &str) -> i64 { + client() + .get(app.url("/api/wallet")) + .bearer_auth(token) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["available_minor"] + .as_i64() + .unwrap() +} + +async fn entries(app: &TestApp, token: &str) -> Vec { + client() + .get(app.url("/api/wallet/entries")) + .query(&[("per_page", "100")]) + .bearer_auth(token) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["items"] + .as_array() + .unwrap() + .clone() +} + +#[tokio::test] +#[serial] +async fn generation_is_idempotent_and_normalizes_the_period() { + let app = spawn_app().await; + let f = completed_order(&app, "st-idem", 1000, 1).await; + backdate(&app, &f.order_id, "2024-03-10").await; + assert_eq!(set_rate(&app, &f.admin, 500).await.status(), 200); + + // Any date inside March resolves to the same month period. + let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-03-15").await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let first: serde_json::Value = res.json().await.unwrap(); + assert_eq!(first["period_start"], "2024-03-01"); + assert_eq!(first["period_end"], "2024-03-31"); + assert_eq!(first["order_count"], 1); + assert_eq!(first["gross_minor"], f.total_minor); + assert_eq!(first["status"], "pending"); + let id = first["id"].as_str().unwrap().to_string(); + + let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-03-28").await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let again: serde_json::Value = res.json().await.unwrap(); + assert_eq!(again["id"], first["id"]); + assert_eq!(again["gross_minor"], first["gross_minor"]); + + // Exactly one row exists for the shop and period. + let listed = admin_statements(&app, &f.admin, &f.shop_id).await; + assert_eq!(listed["total"], 1); + assert_eq!(listed["items"][0]["id"], first["id"]); + + // A week period is normalized to its Monday..Sunday bounds. + assert_eq!(set_rate(&app, &f.admin, 500).await.status(), 200); + let res = shop_generate(&app, &f.owner, "week", "2024-05-15").await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let week: serde_json::Value = res.json().await.unwrap(); + assert_eq!(week["period_start"], "2024-05-13"); + assert_eq!(week["period_end"], "2024-05-19"); + assert_eq!(week["order_count"], 0); + + // A period that has not closed cannot be generated. + let today = chrono::Utc::now().date_naive().to_string(); + assert_eq!( + admin_generate(&app, &f.admin, &f.shop_id, "month", &today) + .await + .status(), + 409 + ); + + let _ = id; +} + +#[tokio::test] +#[serial] +async fn refunds_reduce_the_payable_snapshot() { + let app = spawn_app().await; + let f = completed_order(&app, "st-refund", 1000, 2).await; + assert_eq!(set_rate(&app, &f.admin, 500).await.status(), 200); + + // A completed after-sale refund of 500 on the order line. + let res = client() + .post(app.url("/api/aftersales")) + .bearer_auth(&f.customer) + .json(&serde_json::json!({ + "order_item_id": f.item_id, + "kind": "refund_only", + "reason": {"en": "damaged", "zh": "破损"}, + "amount_minor": 500 + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let aftersale_id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + for action in ["approve", "refund"] { + let res = client() + .post(app.url(&format!("/api/shop/aftersales/{aftersale_id}/{action}"))) + .bearer_auth(&f.owner) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{action}: {:?}", res.text().await); + } + + backdate(&app, &f.order_id, "2024-04-10").await; + let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-04-01").await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let st: serde_json::Value = res.json().await.unwrap(); + + assert_eq!(st["gross_minor"], f.total_minor); + assert_eq!(st["refund_minor"], 500); + // Integral basis-point arithmetic, floor division. + let net = st["gross_minor"].as_i64().unwrap() - st["refund_minor"].as_i64().unwrap(); + let expected_commission = net * 500 / 10_000; + assert_eq!(st["commission_rate_bps"], 500); + assert_eq!(st["commission_minor"], expected_commission); + assert_eq!(st["payable_minor"], net - expected_commission); + + let detail = statement(&app, &f.admin, st["id"].as_str().unwrap()).await; + assert_eq!(detail["orders"].as_array().unwrap().len(), 1); + assert_eq!(detail["orders"][0]["order_id"], f.order_id); + assert_eq!(detail["orders"][0]["refund_minor"], 500); + assert_eq!(detail["orders"][0]["gross_minor"], f.total_minor); +} + +#[tokio::test] +#[serial] +async fn commission_rate_change_only_affects_new_statements() { + let app = spawn_app().await; + let f = completed_order(&app, "st-rate", 10000, 1).await; + backdate(&app, &f.order_id, "2024-02-10").await; + + assert_eq!(set_rate(&app, &f.admin, 500).await.status(), 200); + let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-02-05").await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let st: serde_json::Value = res.json().await.unwrap(); + let id = st["id"].as_str().unwrap().to_string(); + // gross 10000, no refunds -> commission 500, payable 9500. + assert_eq!(st["gross_minor"], 10000); + assert_eq!(st["commission_rate_bps"], 500); + assert_eq!(st["commission_minor"], 500); + assert_eq!(st["payable_minor"], 9500); + + // Raising the platform rate leaves the existing snapshot untouched. + assert_eq!(set_rate(&app, &f.admin, 2000).await.status(), 200); + let after = statement(&app, &f.admin, &id).await; + assert_eq!(after["commission_rate_bps"], 500); + assert_eq!(after["commission_minor"], 500); + assert_eq!(after["payable_minor"], 9500); + + // A statement generated afterwards snapshots the new rate. + let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-01-05").await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let later: serde_json::Value = res.json().await.unwrap(); + assert_eq!(later["commission_rate_bps"], 2000); + assert_eq!(later["order_count"], 0); + assert_ne!(later["id"], id); + + // Out-of-range rates are rejected. + assert_eq!(set_rate(&app, &f.admin, 10_001).await.status(), 400); + assert_eq!(set_rate(&app, &f.admin, -1).await.status(), 400); +} + +#[tokio::test] +#[serial] +async fn confirmation_pays_the_owner_exactly_once() { + let app = spawn_app().await; + let f = completed_order(&app, "st-confirm", 8000, 1).await; + backdate(&app, &f.order_id, "2024-06-10").await; + assert_eq!(set_rate(&app, &f.admin, 500).await.status(), 200); + + let res = admin_generate(&app, &f.admin, &f.shop_id, "month", "2024-06-01").await; + let st: serde_json::Value = res.json().await.unwrap(); + let id = st["id"].as_str().unwrap().to_string(); + let payable = st["payable_minor"].as_i64().unwrap(); + assert_eq!(payable, 7600); + + let before = wallet_available(&app, &f.owner).await; + + let res = client() + .post(app.url(&format!("/api/admin/settlement/statements/{id}/confirm"))) + .bearer_auth(&f.admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let confirmed: serde_json::Value = res.json().await.unwrap(); + assert_eq!(confirmed["status"], "confirmed"); + assert!(confirmed["confirmed_at"].is_string()); + + assert_eq!(wallet_available(&app, &f.owner).await, before + payable); + let payout: Vec = entries(&app, &f.owner) + .await + .into_iter() + .filter(|e| e["reason"] == "settlement_payout") + .collect(); + assert_eq!(payout.len(), 1); + assert_eq!(payout[0]["delta_minor"], payable); + assert_eq!(payout[0]["reference_type"], "settlement_statement"); + assert_eq!(payout[0]["reference_id"], id); + + // A repeat confirmation is a 409 and writes no second entry. + let res = client() + .post(app.url(&format!("/api/admin/settlement/statements/{id}/confirm"))) + .bearer_auth(&f.admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409, "{:?}", res.text().await); + assert_eq!(wallet_available(&app, &f.owner).await, before + payable); + let payouts = entries(&app, &f.owner) + .await + .into_iter() + .filter(|e| e["reason"] == "settlement_payout") + .count(); + assert_eq!(payouts, 1); +} + +#[tokio::test] +#[serial] +async fn statements_are_shop_scoped() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let a = completed_order(&app, "st-shop-a", 1000, 1).await; + let b = completed_order(&app, "st-shop-b", 2000, 1).await; + backdate(&app, &a.order_id, "2024-07-10").await; + backdate(&app, &b.order_id, "2024-07-11").await; + assert_eq!(set_rate(&app, &admin, 500).await.status(), 200); + + let a_st: serde_json::Value = admin_generate(&app, &admin, &a.shop_id, "month", "2024-07-01") + .await + .json() + .await + .unwrap(); + let b_st: serde_json::Value = admin_generate(&app, &admin, &b.shop_id, "month", "2024-07-01") + .await + .json() + .await + .unwrap(); + assert_ne!(a_st["id"], b_st["id"]); + assert_eq!(a_st["shop_id"], a.shop_id); + assert_eq!(b_st["shop_id"], b.shop_id); + + // The merchant list only ever shows the own shop. + let listed: serde_json::Value = client() + .get(app.url("/api/shop/settlement/statements")) + .bearer_auth(&a.owner) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(listed["total"], 1); + assert_eq!(listed["items"][0]["id"], a_st["id"]); + assert_eq!(listed["items"][0]["shop_id"], a.shop_id); + + // The other shop's statement is invisible, and its detail is a 404. + let res = client() + .get(app.url(&format!( + "/api/shop/settlement/statements/{}", + b_st["id"].as_str().unwrap() + ))) + .bearer_auth(&a.owner) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404, "{:?}", res.text().await); + + // A merchant generation request is forced onto the own shop. + let res = client() + .post(app.url("/api/shop/settlement/statements")) + .bearer_auth(&a.owner) + .json(&serde_json::json!({ + "shop_id": b.shop_id, + "period_kind": "month", + "period_start": "2024-07-15" + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let own: serde_json::Value = res.json().await.unwrap(); + assert_eq!(own["shop_id"], a.shop_id); + assert_eq!(own["id"], a_st["id"]); + + // Platform admins see both, optionally narrowed to one shop. + let filtered = admin_statements(&app, &admin, &a.shop_id).await; + assert_eq!(filtered["total"], 1); + assert_eq!(filtered["items"][0]["shop_id"], a.shop_id); + assert_eq!(admin_statements(&app, &admin, &b.shop_id).await["total"], 1); + + // Customers hold no shop scope at all. + assert_eq!( + client() + .get(app.url("/api/shop/settlement/statements")) + .bearer_auth(&a.customer) + .send() + .await + .unwrap() + .status(), + 403 + ); +} + +#[tokio::test] +#[serial] +async fn shop_owner_reads_and_withdraws_from_the_shop_account() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let f = completed_order(&app, "st-owner-wallet", 1000, 1).await; + backdate(&app, &f.order_id, "2024-08-10").await; + assert_eq!(set_rate(&app, &admin, 0).await.status(), 200); + + // Zero commission: payable equals gross, paid into the owner's wallet. + let res = admin_generate(&app, &admin, &f.shop_id, "month", "2024-08-01").await; + let st: serde_json::Value = res.json().await.unwrap(); + let payable = st["payable_minor"].as_i64().unwrap(); + assert_eq!(payable, f.total_minor); + let res = client() + .post(app.url(&format!( + "/api/admin/settlement/statements/{}/confirm", + st["id"].as_str().unwrap() + ))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + assert_eq!(wallet_available(&app, &f.owner).await, payable); + + // The shop-owner role (not just customers) can withdraw from that account. + let res = client() + .post(app.url("/api/wallet/withdrawals")) + .bearer_auth(&f.owner) + .json(&serde_json::json!({ + "amount_minor": payable, + "account_details": { "method": "bank", "account": "shop-acct-1" } + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + let wallet: serde_json::Value = client() + .get(app.url("/api/wallet")) + .bearer_auth(&f.owner) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(wallet["available_minor"], 0); + assert_eq!(wallet["frozen_minor"], payable); + + // Platform rejection returns the shop account to available balance. + let res = client() + .post(app.url(&format!("/api/admin/wallet/withdrawals/{id}/review"))) + .bearer_auth(&admin) + .json(&serde_json::json!({ "outcome": "reject" })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + assert_eq!(wallet_available(&app, &f.owner).await, payable); +} diff --git a/apps/api/tests/wallet.rs b/apps/api/tests/wallet.rs new file mode 100644 index 0000000..bf41e77 --- /dev/null +++ b/apps/api/tests/wallet.rs @@ -0,0 +1,348 @@ +mod common; + +use common::{client, login_admin, register_customer, spawn_app, TestApp}; +use serial_test::serial; + +// Wallet suite: every test provisions its own users and only asserts on rows +// it created, because the shared test database is never truncated. + +async fn wallet(app: &TestApp, token: &str) -> serde_json::Value { + let res = client() + .get(app.url("/api/wallet")) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + res.json().await.unwrap() +} + +async fn balances(app: &TestApp, token: &str) -> (i64, i64) { + let body = wallet(app, token).await; + ( + body["available_minor"].as_i64().unwrap(), + body["frozen_minor"].as_i64().unwrap(), + ) +} + +async fn recharge(app: &TestApp, token: &str, amount_minor: i64) -> reqwest::Response { + client() + .post(app.url("/api/wallet/recharges")) + .bearer_auth(token) + .json(&serde_json::json!({ "amount_minor": amount_minor })) + .send() + .await + .unwrap() +} + +async fn withdraw(app: &TestApp, token: &str, amount_minor: i64) -> reqwest::Response { + client() + .post(app.url("/api/wallet/withdrawals")) + .bearer_auth(token) + .json(&serde_json::json!({ + "amount_minor": amount_minor, + "account_details": { "method": "demo", "account": "acct-1", "holder": "Test Holder" } + })) + .send() + .await + .unwrap() +} + +async fn my_withdrawals(app: &TestApp, token: &str) -> Vec { + let res = client() + .get(app.url("/api/wallet/withdrawals")) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + res.json().await.unwrap() +} + +async fn entries(app: &TestApp, token: &str, page: i64, per_page: i64) -> serde_json::Value { + let res = client() + .get(app.url("/api/wallet/entries")) + .query(&[ + ("page", page.to_string()), + ("per_page", per_page.to_string()), + ]) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "{:?}", res.text().await); + res.json().await.unwrap() +} + +async fn review( + app: &TestApp, + admin: &str, + id: &str, + outcome: &str, + note: Option<&str>, +) -> reqwest::Response { + client() + .post(app.url(&format!("/api/admin/wallet/withdrawals/{id}/review"))) + .bearer_auth(admin) + .json(&serde_json::json!({ "outcome": outcome, "note": note })) + .send() + .await + .unwrap() +} + +fn reasons(page: &serde_json::Value) -> Vec { + page["items"] + .as_array() + .unwrap() + .iter() + .map(|e| e["reason"].as_str().unwrap().to_string()) + .collect() +} + +#[tokio::test] +#[serial] +async fn demo_recharge_credits_available_with_one_entry() { + let app = spawn_app().await; + let (token, _) = register_customer(&app, "wl-recharge").await; + + let res = recharge(&app, &token, 5000).await; + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let body: serde_json::Value = res.json().await.unwrap(); + // The payload must label the flow as simulated. + assert_eq!(body["demo"], true); + assert_eq!(body["status"], "credited"); + assert_eq!(body["amount_minor"], 5000); + assert_eq!(body["available_minor"], 5000); + + assert_eq!(balances(&app, &token).await, (5000, 0)); + + let page = entries(&app, &token, 1, 20).await; + assert_eq!(page["total"], 1); + let entry = &page["items"][0]; + assert_eq!(entry["reason"], "wallet_recharge"); + assert_eq!(entry["account_kind"], "available"); + assert_eq!(entry["delta_minor"], 5000); + assert_eq!(entry["balance_minor"], 5000); + assert_eq!(entry["reference_type"], "wallet_recharge"); + assert!(entry["reference_id"].is_string()); +} + +#[tokio::test] +#[serial] +async fn non_positive_amounts_are_rejected() { + let app = spawn_app().await; + let (token, _) = register_customer(&app, "wl-amount").await; + + assert_eq!(recharge(&app, &token, 0).await.status(), 400); + assert_eq!(recharge(&app, &token, -100).await.status(), 400); + assert_eq!(withdraw(&app, &token, 0).await.status(), 400); + + // A malformed payout destination is rejected before any balance moves. + let res = client() + .post(app.url("/api/wallet/withdrawals")) + .bearer_auth(&token) + .json(&serde_json::json!({ + "amount_minor": 100, + "account_details": { "method": " ", "account": "acct" } + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400, "{:?}", res.text().await); + assert_eq!(balances(&app, &token).await, (0, 0)); +} + +#[tokio::test] +#[serial] +async fn insufficient_balance_leaves_no_application_or_balance_change() { + let app = spawn_app().await; + let (token, _) = register_customer(&app, "wl-insufficient").await; + + // Nothing funded: the guarded debit fails and the insert rolls back with it. + assert_eq!(withdraw(&app, &token, 500).await.status(), 409); + assert_eq!(balances(&app, &token).await, (0, 0)); + assert!(my_withdrawals(&app, &token).await.is_empty()); + + assert_eq!(recharge(&app, &token, 100).await.status(), 201); + assert_eq!(withdraw(&app, &token, 200).await.status(), 409); + assert_eq!(balances(&app, &token).await, (100, 0)); + assert!(my_withdrawals(&app, &token).await.is_empty()); +} + +#[tokio::test] +#[serial] +async fn withdrawal_freezes_funds_and_reject_returns_them() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (token, _) = register_customer(&app, "wl-reject").await; + assert_eq!(recharge(&app, &token, 1000).await.status(), 201); + + let res = withdraw(&app, &token, 400).await; + assert_eq!(res.status(), 201, "{:?}", res.text().await); + let created: serde_json::Value = res.json().await.unwrap(); + assert_eq!(created["status"], "pending"); + assert_eq!(created["amount_minor"], 400); + assert_eq!(created["account_details"]["method"], "demo"); + let id = created["id"].as_str().unwrap().to_string(); + + // Freeze moved available -> frozen with a paired entry for each side. + assert_eq!(balances(&app, &token).await, (600, 400)); + let page = entries(&app, &token, 1, 20).await; + let reasons = reasons(&page); + assert_eq!( + reasons + .iter() + .filter(|r| r.as_str() == "wallet_withdrawal_freeze") + .count(), + 2 + ); + // One -400 out of available and one +400 into frozen. + let freeze_deltas: Vec = page["items"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["reason"] == "wallet_withdrawal_freeze") + .map(|e| e["delta_minor"].as_i64().unwrap()) + .collect(); + assert!(freeze_deltas.contains(&-400) && freeze_deltas.contains(&400)); + + // The platform queue sees it as pending; a customer may not review. + let queue: Vec = client() + .get(app.url("/api/admin/wallet/withdrawals")) + .query(&[("status", "pending")]) + .bearer_auth(&admin) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(queue.iter().any(|w| w["id"] == created["id"])); + assert_eq!( + review(&app, &token, &id, "approve", None).await.status(), + 403 + ); + + let res = review(&app, &admin, &id, "reject", Some("no payout channel")).await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + let rejected: serde_json::Value = res.json().await.unwrap(); + assert_eq!(rejected["status"], "rejected"); + assert_eq!(rejected["review_note"], "no payout channel"); + assert!(rejected["reviewed_at"].is_string()); + + // Funds returned to available. + assert_eq!(balances(&app, &token).await, (1000, 0)); + let page = entries(&app, &token, 1, 20).await; + let release_deltas: Vec = page["items"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["reason"] == "wallet_withdrawal_rejected") + .map(|e| e["delta_minor"].as_i64().unwrap()) + .collect(); + assert_eq!(release_deltas.len(), 2); + assert!(release_deltas.contains(&400) && release_deltas.contains(&-400)); +} + +#[tokio::test] +#[serial] +async fn approve_consumes_frozen_funds_and_repeat_review_conflicts() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (token, _) = register_customer(&app, "wl-approve").await; + assert_eq!(recharge(&app, &token, 1000).await.status(), 201); + + let created: serde_json::Value = withdraw(&app, &token, 600).await.json().await.unwrap(); + let id = created["id"].as_str().unwrap().to_string(); + assert_eq!(balances(&app, &token).await, (400, 600)); + + let res = review(&app, &admin, &id, "approve", None).await; + assert_eq!(res.status(), 200, "{:?}", res.text().await); + assert_eq!(res.json::().await.unwrap()["status"], "approved"); + assert_eq!(balances(&app, &token).await, (400, 0)); + + // Exactly one approved entry, and a repeat review changes nothing. + let page = entries(&app, &token, 1, 20).await; + let approved: Vec<&serde_json::Value> = page["items"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["reason"] == "wallet_withdrawal_approved") + .collect(); + assert_eq!(approved.len(), 1); + assert_eq!(approved[0]["account_kind"], "frozen"); + assert_eq!(approved[0]["delta_minor"], -600); + assert_eq!(approved[0]["balance_minor"], 0); + + let before = entries(&app, &token, 1, 20).await["total"] + .as_i64() + .unwrap(); + assert_eq!( + review(&app, &admin, &id, "approve", None).await.status(), + 409 + ); + assert_eq!( + review(&app, &admin, &id, "reject", None).await.status(), + 409 + ); + assert_eq!(balances(&app, &token).await, (400, 0)); + assert_eq!( + entries(&app, &token, 1, 20).await["total"].as_i64().unwrap(), + before + ); +} + +#[tokio::test] +#[serial] +async fn concurrent_withdrawals_cannot_overdraw() { + let app = spawn_app().await; + let (token, _) = register_customer(&app, "wl-race").await; + assert_eq!(recharge(&app, &token, 1000).await.status(), 201); + + let (a, b) = tokio::join!(withdraw(&app, &token, 1000), withdraw(&app, &token, 1000)); + let statuses = [a.status().as_u16(), b.status().as_u16()]; + assert!( + statuses.contains(&201) && statuses.contains(&409), + "expected one success and one conflict, got {statuses:?}" + ); + + // No overdraw: exactly one application, balances still non-negative. + assert_eq!(balances(&app, &token).await, (0, 1000)); + assert_eq!(my_withdrawals(&app, &token).await.len(), 1); +} + +#[tokio::test] +#[serial] +async fn entry_pages_are_paginated_and_user_isolated() { + let app = spawn_app().await; + let (alice, _) = register_customer(&app, "wl-alice").await; + let (bob, _) = register_customer(&app, "wl-bob").await; + + for amount in [100, 200, 300] { + assert_eq!(recharge(&app, &alice, amount).await.status(), 201); + } + assert_eq!(recharge(&app, &bob, 999).await.status(), 201); + + let first = entries(&app, &alice, 1, 2).await; + assert_eq!(first["total"], 3); + assert_eq!(first["items"].as_array().unwrap().len(), 2); + let second = entries(&app, &alice, 2, 2).await; + assert_eq!(second["items"].as_array().unwrap().len(), 1); + + // Newest first: the 300 credit leads. + assert_eq!(first["items"][0]["delta_minor"], 300); + assert_eq!(second["items"][0]["delta_minor"], 100); + + // Bob only ever sees his own single entry. + let bob_page = entries(&app, &bob, 1, 20).await; + assert_eq!(bob_page["total"], 1); + let alice_ids: Vec<&str> = first["items"] + .as_array() + .unwrap() + .iter() + .chain(second["items"].as_array().unwrap()) + .map(|e| e["id"].as_str().unwrap()) + .collect(); + let bob_id = bob_page["items"][0]["id"].as_str().unwrap(); + assert!(!alice_ids.contains(&bob_id)); +} diff --git a/apps/mall/components/shell/SiteFooter.vue b/apps/mall/components/shell/SiteFooter.vue index 2811da5..09fdf72 100644 --- a/apps/mall/components/shell/SiteFooter.vue +++ b/apps/mall/components/shell/SiteFooter.vue @@ -27,6 +27,11 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScroll));
  • {{ t("shell.footer.v2") }}
  • {{ t("shell.footer.v3") }}
  • {{ t("shell.footer.v4") }}
  • +
  • + {{ + t("shell.footer.sellerJoin") + }} +
  • {{ t("shell.footer.copyright") }}

    diff --git a/apps/mall/components/shell/SiteHeader.vue b/apps/mall/components/shell/SiteHeader.vue index 12eee2d..36108c6 100644 --- a/apps/mall/components/shell/SiteHeader.vue +++ b/apps/mall/components/shell/SiteHeader.vue @@ -2,6 +2,8 @@ const { t, locale, locales, setLocale } = useI18n(); const { currency } = usePrefs(); const cart = useCartStore(); +const session = useSessionStore(); +const { unread, refresh: refreshUnread, setCount } = useUnreadMessages(); const route = useRoute(); const router = useRouter(); @@ -28,8 +30,26 @@ onMounted(async () => { /* keep default */ } void cart.refresh(); + if (session.isLoggedIn) void refreshUnread(); }); +// The session is restored from localStorage after hydration. +watch( + () => session.isLoggedIn, + (loggedIn) => { + if (loggedIn) void refreshUnread(); + else setCount(0); + }, +); + +// The shell outlives route changes, so page entry is what re-reads the count. +watch( + () => route.fullPath, + () => { + if (session.isLoggedIn) void refreshUnread(); + }, +); + const menuOpen = ref(false); const isHome = computed(() => route.path === "/"); // The home page pins the category menu in the hero row instead, so the header @@ -86,6 +106,23 @@ const showMenu = computed(() => menuOpen.value && !isHome.value); >{{ cart.count }} + + + + {{ t("messaging.title") }} + {{ unread }} + +
    diff --git a/apps/mall/components/shell/TopBar.vue b/apps/mall/components/shell/TopBar.vue index e821566..460ef0d 100644 --- a/apps/mall/components/shell/TopBar.vue +++ b/apps/mall/components/shell/TopBar.vue @@ -76,7 +76,7 @@ const { t } = useI18n();
  • |
  • - {{ + {{ t("shell.sellerJoin") }}
  • diff --git a/apps/mall/composables/useUnreadMessages.ts b/apps/mall/composables/useUnreadMessages.ts new file mode 100644 index 0000000..152ada2 --- /dev/null +++ b/apps/mall/composables/useUnreadMessages.ts @@ -0,0 +1,30 @@ +/** + * The signed-in shopper's unread message count, shared between the shell badge + * and the message center. Every read/mark-all/delete action refreshes the count + * through `$api.getUnreadCount()` so the badge never drifts from the server. + */ +export function useUnreadMessages() { + const { $api } = useNuxtApp(); + const session = useSessionStore(); + const unread = useState("unread-messages", () => 0); + + /** Anonymous shoppers have no message center, so the count stays at zero. */ + async function refresh(): Promise { + if (!session.isLoggedIn) { + unread.value = 0; + return; + } + try { + unread.value = (await $api.getUnreadCount()).unread; + } catch { + unread.value = 0; + } + } + + /** Let a mutating page push the authoritative number without a second call. */ + function setCount(count: number): void { + unread.value = Math.max(0, count); + } + + return { unread, refresh, setCount }; +} diff --git a/apps/mall/locales-extra.ts b/apps/mall/locales-extra.ts index bf2fcd5..fce2271 100644 --- a/apps/mall/locales-extra.ts +++ b/apps/mall/locales-extra.ts @@ -12,6 +12,10 @@ import auth from "./locales/auth"; import user from "./locales/user"; import stores from "./locales/stores"; import marketing from "./locales/marketing"; +import wallet from "./locales/wallet"; +import merchant from "./locales/merchant"; +import membership from "./locales/membership"; +import messaging from "./locales/messaging"; type Tree = Record; @@ -42,7 +46,22 @@ const legacyZh: Tree = { }, }; -const domains = [shell, home, search, product, cart, checkout, auth, user, stores, marketing]; +const domains = [ + shell, + home, + search, + product, + cart, + checkout, + auth, + user, + stores, + marketing, + wallet, + merchant, + membership, + messaging, +]; export const enExtra: Tree = domains.reduce( (acc, m) => deepMerge(acc, m.en as Tree), diff --git a/apps/mall/locales/membership.ts b/apps/mall/locales/membership.ts new file mode 100644 index 0000000..1436125 --- /dev/null +++ b/apps/mall/locales/membership.ts @@ -0,0 +1,55 @@ +// Buyer membership center: level, growth total, progress and growth history. +export default { + en: { + membership: { + title: "Membership", + entry: "My level", + loadFailed: "Unable to load your membership.", + currentLevel: "Current level", + benefits: "Level benefits", + growthTotal: "Growth total", + growthUnit: "growth", + noLevel: "No level yet", + noLevelHint: "Reach {threshold} {unit} to unlock {level}.", + topLevel: "Top level reached", + topLevelHint: "You are at the highest level — your benefits stay as they are.", + progressTitle: "Progress to {level}", + progressThreshold: "{current} / {threshold} {unit}", + remaining: "{remaining} {unit} to go", + historyTitle: "Growth history", + historyEmpty: "No growth entries yet.", + historyFailed: "Unable to load growth history.", + historyDate: "Time", + historyReason: "Reason", + historyDelta: "Growth", + historyTotal: "Total after", + reason_order_complete: "Order completed", + }, + }, + zh: { + membership: { + title: "会员中心", + entry: "我的等级", + loadFailed: "会员信息加载失败。", + currentLevel: "当前等级", + benefits: "等级权益", + growthTotal: "成长值", + growthUnit: "成长值", + noLevel: "暂无等级", + noLevelHint: "再获得 {threshold} {unit} 即可解锁 {level}。", + topLevel: "已是最高等级", + topLevelHint: "您已达到最高等级,权益保持不变。", + progressTitle: "距离 {level}", + progressThreshold: "{current} / {threshold} {unit}", + remaining: "还差 {remaining} {unit}", + historyTitle: "成长值明细", + historyEmpty: "暂无成长值记录。", + historyFailed: "成长值明细加载失败。", + historyDate: "时间", + historyReason: "原因", + historyDelta: "成长值", + historyTotal: "变动后累计", + reason_order_complete: "订单完成", + }, + }, +}; diff --git a/apps/mall/locales/merchant.ts b/apps/mall/locales/merchant.ts new file mode 100644 index 0000000..8a5c489 --- /dev/null +++ b/apps/mall/locales/merchant.ts @@ -0,0 +1,158 @@ +// Merchant onboarding: application form and applicant status page. +export default { + en: { + merchant: { + joinTitle: "Sell on VMall", + joinSubtitle: + "Submit a merchant onboarding application. The platform team reviews it and provisions your shop and owner account.", + stepKind: "Entity type", + stepEntity: "Entity & categories", + stepContact: "Contact & qualification", + kindPersonal: "Individual", + kindEnterprise: "Enterprise", + kindHint: "Choose the entity type that will own the shop. Required fields change with it.", + entityInfo: "Entity information", + realName: "Legal name", + realNamePlaceholder: "Name shown on the identity document", + companyName: "Company name", + companyNamePlaceholder: "Registered company name", + businessLicenseNo: "Business license number", + businessLicenseNoPlaceholder: "Unified social credit code", + categories: "Operating categories", + categoriesHint: "Select one or more categories from the published category tree.", + categoriesLoadFailed: "Unable to load the category tree.", + contactInfo: "Contact details", + contactName: "Contact name", + contactPhone: "Contact phone", + contactEmail: "Contact email", + contactAddress: "Contact address (optional)", + contactNamePlaceholder: "Who should we contact?", + contactPhonePlaceholder: "Phone number with area code", + contactEmailPlaceholder: "you{'@'}example.com", + contactAddressPlaceholder: "Street, city, region", + qualification: "Qualification materials", + qualificationHint: "Provide public http(s) URLs only; this MVP has no file upload.", + identityDocumentUrl: "Identity document URL", + identityDocumentUrlPlaceholder: "https://example.com/id.jpg", + businessLicenseUrl: "Business license URL", + businessLicenseUrlPlaceholder: "https://example.com/license.jpg", + extraMaterials: "Extra material URLs (optional)", + extraMaterialPlaceholder: "https://example.com/extra.jpg", + extraMaterialAdd: "Add material URL", + extraMaterialRemove: "Remove", + previous: "Back", + next: "Next", + submit: "Submit application", + submitting: "Submitting…", + signInToSubmit: "Sign in or create an account to submit — your answers are kept.", + restoredDraft: "Welcome back. Your saved answers were restored; review them and submit.", + successTitle: "Application submitted", + successBody: + "Your merchant application is now pending review. You can track its state at any time.", + viewStatus: "View application status", + conflict: + "You already have a pending or approved merchant application. Track it on the status page instead of submitting another.", + invalid: "Please check the highlighted fields and try again.", + submitFailed: "Unable to submit the application. Please try again.", + errorRequired: "This field is required.", + errorEmail: "Enter a valid email address.", + errorUrl: "Enter a valid http(s) URL.", + errorCategories: "Select at least one operating category.", + statusTitle: "Merchant application status", + statusSubtitle: "The latest merchant application submitted by your account.", + statusLoadFailed: "Unable to load your merchant applications.", + noApplication: "You have not submitted a merchant application yet.", + startApplication: "Start an application", + status_pending: "Pending review", + status_approved: "Approved", + status_rejected: "Rejected", + applicationId: "Application ID", + entityKind: "Entity type", + submittedAt: "Submitted", + updatedAt: "Last updated", + reviewedAt: "Reviewed", + rejectionReason: "Rejection reason", + createdShop: "Provisioned shop", + reapply: "Apply again", + historyTitle: "Application history", + historyHint: "Earlier applications from this account.", + backToJoin: "Back to the application form", + }, + }, + zh: { + merchant: { + joinTitle: "商家入驻", + joinSubtitle: "提交商家入驻申请,平台审核通过后将为您开通店铺和店主账号。", + stepKind: "主体类型", + stepEntity: "主体与经营类目", + stepContact: "联系人与资质", + kindPersonal: "个人", + kindEnterprise: "企业", + kindHint: "请选择店铺的经营主体类型,选择后需要填写的字段会随之变化。", + entityInfo: "主体信息", + realName: "真实姓名", + realNamePlaceholder: "证件上的姓名", + companyName: "企业名称", + companyNamePlaceholder: "营业执照上的企业名称", + businessLicenseNo: "营业执照号", + businessLicenseNoPlaceholder: "统一社会信用代码", + categories: "经营类目", + categoriesHint: "请从已发布的类目树中选择一个或多个类目。", + categoriesLoadFailed: "类目加载失败。", + contactInfo: "联系信息", + contactName: "联系人姓名", + contactPhone: "联系电话", + contactEmail: "联系邮箱", + contactAddress: "联系地址(选填)", + contactNamePlaceholder: "请填写联系人姓名", + contactPhonePlaceholder: "请填写含区号的电话号码", + contactEmailPlaceholder: "you{'@'}example.com", + contactAddressPlaceholder: "省/市/区详细地址", + qualification: "资质材料", + qualificationHint: "仅支持填写公开的 http(s) 链接,本版本不提供文件上传。", + identityDocumentUrl: "身份证件链接", + identityDocumentUrlPlaceholder: "https://example.com/id.jpg", + businessLicenseUrl: "营业执照链接", + businessLicenseUrlPlaceholder: "https://example.com/license.jpg", + extraMaterials: "其他材料链接(选填)", + extraMaterialPlaceholder: "https://example.com/extra.jpg", + extraMaterialAdd: "添加材料链接", + extraMaterialRemove: "删除", + previous: "上一步", + next: "下一步", + submit: "提交申请", + submitting: "提交中…", + signInToSubmit: "请先登录或注册后提交,已填写的内容会为您保留。", + restoredDraft: "欢迎回来,已恢复您填写的申请内容,请确认后提交。", + successTitle: "申请已提交", + successBody: "您的入驻申请已提交,正在等待平台审核。您可以随时查看审核状态。", + viewStatus: "查看申请状态", + conflict: "您已有一条待审核或已通过的入驻申请,请前往状态页查看,无需重复提交。", + invalid: "请检查标注的字段后重试。", + submitFailed: "申请提交失败,请稍后重试。", + errorRequired: "该项为必填。", + errorEmail: "请填写有效的邮箱地址。", + errorUrl: "请填写有效的 http(s) 链接。", + errorCategories: "请至少选择一个经营类目。", + statusTitle: "入驻申请状态", + statusSubtitle: "您账号下最新提交的入驻申请。", + statusLoadFailed: "入驻申请加载失败。", + noApplication: "您还没有提交过入驻申请。", + startApplication: "开始申请", + status_pending: "待审核", + status_approved: "已通过", + status_rejected: "已拒绝", + applicationId: "申请编号", + entityKind: "主体类型", + submittedAt: "提交时间", + updatedAt: "更新时间", + reviewedAt: "审核时间", + rejectionReason: "拒绝原因", + createdShop: "已开通店铺", + reapply: "重新申请", + historyTitle: "申请记录", + historyHint: "该账号更早的申请记录。", + backToJoin: "返回入驻申请", + }, + }, +}; diff --git a/apps/mall/locales/messaging.ts b/apps/mall/locales/messaging.ts new file mode 100644 index 0000000..3bc81eb --- /dev/null +++ b/apps/mall/locales/messaging.ts @@ -0,0 +1,57 @@ +// Buyer message center: list, filters, read/delete actions and the unread badge. +export default { + en: { + messaging: { + title: "Messages", + loadFailed: "Unable to load your messages.", + listFailed: "Unable to load messages.", + empty: "No messages yet.", + emptyUnread: "No unread messages.", + unreadOnly: "Unread only", + unreadBadge: "{count} unread messages", + markAllRead: "Mark all as read", + markAllSuccess: "Marked {count} message(s) as read.", + markAllFailed: "Unable to mark all messages as read.", + markRead: "Mark as read", + markReadFailed: "Unable to mark the message as read.", + open: "Open", + close: "Close", + delete: "Delete", + deleteConfirm: "Delete this message?", + deleteFailed: "Unable to delete the message.", + createdAt: "Time", + kind_order_paid: "Payment received", + kind_order_shipped: "Order shipped", + kind_refund_completed: "Refund completed", + statusUnread: "Unread", + statusRead: "Read", + }, + }, + zh: { + messaging: { + title: "消息中心", + loadFailed: "消息加载失败。", + listFailed: "消息列表加载失败。", + empty: "暂无消息。", + emptyUnread: "暂无未读消息。", + unreadOnly: "仅看未读", + unreadBadge: "{count} 条未读消息", + markAllRead: "全部标记为已读", + markAllSuccess: "已将 {count} 条消息标记为已读。", + markAllFailed: "全部标记已读失败。", + markRead: "标记已读", + markReadFailed: "标记已读失败。", + open: "展开", + close: "收起", + delete: "删除", + deleteConfirm: "确定删除这条消息吗?", + deleteFailed: "删除消息失败。", + createdAt: "时间", + kind_order_paid: "付款成功", + kind_order_shipped: "订单已发货", + kind_refund_completed: "退款完成", + statusUnread: "未读", + statusRead: "已读", + }, + }, +}; diff --git a/apps/mall/locales/shell.ts b/apps/mall/locales/shell.ts index dd6223a..efc5afd 100644 --- a/apps/mall/locales/shell.ts +++ b/apps/mall/locales/shell.ts @@ -34,6 +34,7 @@ export default { v4: "448 service centers nationwide", copyright: "VMall Demo Mall — all content is fixed mock data.", icp: "Mock ICP 2026-0001", + sellerJoin: "Become a Seller", }, backToTop: "Top", }, @@ -72,6 +73,7 @@ export default { v4: "448家维修网点 全国联保", copyright: "VMall 演示商城 —— 所有内容均为固定 mock 数据。", icp: "模拟备案号 2026-0001", + sellerJoin: "成为商家", }, backToTop: "顶部", }, diff --git a/apps/mall/locales/wallet.ts b/apps/mall/locales/wallet.ts new file mode 100644 index 0000000..edf0895 --- /dev/null +++ b/apps/mall/locales/wallet.ts @@ -0,0 +1,134 @@ +export default { + en: { + wallet: { + title: "My wallet", + available: "Available balance", + frozen: "Frozen funds", + loadFailed: "Unable to load your wallet.", + entriesTitle: "Fund entries", + entriesEmpty: "No fund entries yet.", + entriesFailed: "Unable to load fund entries.", + entryDate: "Date", + entryAccount: "Account", + entryReason: "Reason", + entryDelta: "Change", + entryBalance: "Balance after", + accountAvailable: "Available", + accountFrozen: "Frozen", + rechargeTitle: "Recharge", + rechargeDemoBadge: "Simulated · demo", + rechargeDemoNote: + "This is a demo recharge: no payment provider is contacted and no real money moves.", + rechargeAmount: "Recharge amount ({currency})", + rechargeAmountHint: + "Enter a major-unit amount in {currency}; it is credited to your available balance immediately.", + rechargeSubmit: "Simulate recharge", + rechargeSuccess: "Simulated recharge credited: {amount}.", + rechargeFailed: "Recharge failed. Please try again.", + rechargeConflict: + "The recharge conflicted with the current account state, so it was not applied. Balances have been refreshed.", + withdrawTitle: "Withdraw funds", + withdrawHint: + "A withdrawal leaves your available balance immediately and is held as frozen funds until the platform reviews the request.", + withdrawAmount: "Withdrawal amount ({currency})", + withdrawAmountHint: "It cannot exceed the available balance.", + withdrawMethod: "Payout method", + withdrawMethodBank: "Bank transfer", + withdrawMethodAlipay: "Alipay", + withdrawMethodWechat: "WeChat Pay", + withdrawAccount: "Payout account", + withdrawAccountHint: "Bank account number or payment handle", + withdrawHolder: "Account holder (optional)", + withdrawSubmit: "Submit withdrawal request", + withdrawSuccess: "Withdrawal request submitted; the amount is now frozen and pending review.", + withdrawConflict: + "Insufficient available balance: a withdrawal freezes the full amount, so this request was rejected as a conflict. The balances below show the current server state.", + withdrawFailed: "Unable to submit the withdrawal request.", + validationRequired: "Enter a positive amount, a payout method and a payout account.", + historyTitle: "Withdrawal requests", + historyEmpty: "No withdrawal requests yet.", + historyCreatedAt: "Requested", + historyAmount: "Amount", + historyMethod: "Method", + historyAccount: "Payout account", + historyStatus: "Status", + historyNote: "Review note", + historyReviewedAt: "Reviewed", + status_pending: "Pending review", + status_approved: "Approved", + status_rejected: "Rejected", + reason_wallet_recharge: "Simulated recharge", + reason_wallet_withdrawal_freeze: "Withdrawal held", + reason_wallet_withdrawal_approved: "Withdrawal paid out", + reason_wallet_withdrawal_rejected: "Withdrawal returned", + reason_order_payment: "Order payment", + reason_aftersale_refund: "After-sale refund", + reason_settlement_payout: "Settlement payout", + reason_opening_balance: "Opening balance", + }, + }, + zh: { + wallet: { + title: "我的钱包", + available: "可用余额", + frozen: "冻结资金", + loadFailed: "钱包数据加载失败。", + entriesTitle: "资金明细", + entriesEmpty: "暂无资金明细。", + entriesFailed: "资金明细加载失败。", + entryDate: "时间", + entryAccount: "账户", + entryReason: "原因", + entryDelta: "变动", + entryBalance: "变动后余额", + accountAvailable: "可用", + accountFrozen: "冻结", + rechargeTitle: "充值", + rechargeDemoBadge: "模拟 · 演示", + rechargeDemoNote: "这是模拟充值:不会接入任何支付渠道,也不会发生真实资金流转。", + rechargeAmount: "充值金额({currency})", + rechargeAmountHint: "请输入 {currency} 主单位金额,将立即计入可用余额。", + rechargeSubmit: "模拟充值", + rechargeSuccess: "模拟充值已到账:{amount}。", + rechargeFailed: "充值失败,请重试。", + rechargeConflict: "充值因账户状态冲突未生效,余额已重新加载。", + withdrawTitle: "申请提现", + withdrawHint: "提现会立即从可用余额中扣除并计为冻结资金,等待平台审核。", + withdrawAmount: "提现金额({currency})", + withdrawAmountHint: "不得超过可用余额。", + withdrawMethod: "提现方式", + withdrawMethodBank: "银行转账", + withdrawMethodAlipay: "支付宝", + withdrawMethodWechat: "微信支付", + withdrawAccount: "收款账户", + withdrawAccountHint: "银行卡号或收款账号", + withdrawHolder: "开户人(选填)", + withdrawSubmit: "提交提现申请", + withdrawSuccess: "提现申请已提交,金额已冻结并等待审核。", + withdrawConflict: + "可用余额不足:提现会冻结全额资金,因此本次申请被拒绝(冲突)。下方余额为服务器当前状态。", + withdrawFailed: "提现申请提交失败。", + validationRequired: "请填写正确的金额、提现方式与收款账户。", + historyTitle: "提现记录", + historyEmpty: "暂无提现申请。", + historyCreatedAt: "申请时间", + historyAmount: "金额", + historyMethod: "方式", + historyAccount: "收款账户", + historyStatus: "状态", + historyNote: "审核备注", + historyReviewedAt: "审核时间", + status_pending: "待审核", + status_approved: "已通过", + status_rejected: "已拒绝", + reason_wallet_recharge: "模拟充值", + reason_wallet_withdrawal_freeze: "提现冻结", + reason_wallet_withdrawal_approved: "提现已打款", + reason_wallet_withdrawal_rejected: "提现已退回", + reason_order_payment: "订单支付", + reason_aftersale_refund: "售后退款", + reason_settlement_payout: "结算打款", + reason_opening_balance: "期初余额", + }, + }, +}; diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index 1b52186..b8cdcc6 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -33,13 +33,29 @@ import type { GroupBuyingActivity, GroupBuyingActivityInput, GroupBuyingActivityView, + GrowthLogEntry, HomeContent, IntegralOrder, IntegralProduct, IntegralProductInput, Invoice, InvoiceKind, + MarkAllReadResult, + MemberLevel, + MemberLevelInput, + MembershipStatus, + MerchantApplication, + MerchantApplicationInput, + MerchantApplicationQuery, + MerchantApprovalResult, + MerchantCategoryRef, + Message, + MessageDeleteResult, + MessageKind, + MessageListQuery, + NextMemberLevel, Order, + Paged, Product, PublicFlashSaleSession, RedeemPointsBody, @@ -49,7 +65,15 @@ import type { ReviewSummary, Shipment, ShopProfile, + UnreadCount, User, + WalletAccountKind, + WalletEntry, + WalletRechargeResult, + WalletSummary, + WalletWithdrawal, + WithdrawalAccountDetails, + WithdrawalStatus, } from "@vmall/shared"; import { BASE_CURRENCY, @@ -96,6 +120,17 @@ interface MockState { /** In-memory points catalog and redemptions for the fixed-data path. */ pointsProducts: IntegralProduct[]; redemptions: IntegralOrder[]; + /** Buyer wallet balances, ledger and withdrawal history. */ + walletAvailableMinor: number; + walletFrozenMinor: number; + walletEntries: WalletEntry[]; + walletWithdrawals: WalletWithdrawal[]; + /** Merchant onboarding applications, newest first, for the fixed-adapter path. */ + merchantApplications: MerchantApplication[]; + /** Buyer growth ledger; the level itself is re-derived from these entries. */ + growthLogs: GrowthLogEntry[]; + /** Buyer system messages; soft deletion is a mock-only marker. */ + messages: MockMessage[]; addressSeq: number; favoriteSeq: number; orderSeq: number; @@ -104,9 +139,20 @@ interface MockState { aftersaleSeq: number; aftersaleMessageSeq: number; reviewSeq: number; + walletEntrySeq: number; + walletWithdrawalSeq: number; + merchantApplicationSeq: number; + growthLogSeq: number; + messageSeq: number; } -// v6: customer reviews joined the persisted rollback state. -const STORAGE_KEY = "vmall.mock.state.v6"; + +/** A message plus the soft-delete marker the shared contract never exposes. */ +interface MockMessage extends Message { + deleted_at: string | null; +} + +// v9: membership growth logs and buyer messages joined the persisted rollback state. +const STORAGE_KEY = "vmall.mock.state.v9"; type PersistedState = Pick< MockState, @@ -126,6 +172,18 @@ type PersistedState = Pick< | "aftersaleSeq" | "aftersaleMessageSeq" | "reviewSeq" + | "walletAvailableMinor" + | "walletFrozenMinor" + | "walletEntries" + | "walletWithdrawals" + | "walletEntrySeq" + | "walletWithdrawalSeq" + | "merchantApplications" + | "merchantApplicationSeq" + | "growthLogs" + | "messages" + | "growthLogSeq" + | "messageSeq" >; // Load cart/order session state persisted by a previous page load (client only). @@ -146,6 +204,15 @@ function loadPersisted(): PersistedState | null { if (typeof p.aftersaleSeq !== "number" || typeof p.aftersaleMessageSeq !== "number") return null; if (!Array.isArray(p.reviews) || typeof p.reviewSeq !== "number") return null; + if (typeof p.walletAvailableMinor !== "number" || typeof p.walletFrozenMinor !== "number") + return null; + if (!Array.isArray(p.walletEntries) || !Array.isArray(p.walletWithdrawals)) return null; + if (typeof p.walletEntrySeq !== "number" || typeof p.walletWithdrawalSeq !== "number") + return null; + if (!Array.isArray(p.merchantApplications) || typeof p.merchantApplicationSeq !== "number") + return null; + if (!Array.isArray(p.growthLogs) || typeof p.growthLogSeq !== "number") return null; + if (!Array.isArray(p.messages) || typeof p.messageSeq !== "number") return null; return p as PersistedState; } catch { return null; @@ -483,6 +550,420 @@ function seedReviews(orders: Order[]): Review[] { ]; } +interface WalletSeed { + walletAvailableMinor: number; + walletFrozenMinor: number; + walletEntries: WalletEntry[]; + walletWithdrawals: WalletWithdrawal[]; + walletEntrySeq: number; + walletWithdrawalSeq: number; +} + +/** + * Deterministic opening wallet. Movements replay the same paired-entry shape the + * live backend writes, and end exactly on the fixture balances in USER_STATS. + */ +function seedWallet(): WalletSeed { + const entries: WalletEntry[] = []; + const withdrawals: WalletWithdrawal[] = []; + let available = 0; + let frozen = 0; + let entrySeq = 0; + let withdrawalSeq = 0; + + function move( + account: WalletAccountKind, + deltaMinor: number, + reason: string, + referenceType: string | null, + referenceId: string | null, + createdAt: string, + ): void { + entrySeq += 1; + if (account === "available") available += deltaMinor; + else frozen += deltaMinor; + entries.push({ + id: `we-${entrySeq}`, + account_kind: account, + delta_minor: deltaMinor, + balance_minor: account === "available" ? available : frozen, + reason, + reference_type: referenceType, + reference_id: referenceId, + created_at: createdAt, + }); + } + + function withdrawal( + amountMinor: number, + status: Exclude, + reviewNote: string, + reviewedAt: string, + createdAt: string, + ): WalletWithdrawal { + withdrawalSeq += 1; + const row: WalletWithdrawal = { + id: `wd-${withdrawalSeq}`, + user_id: MOCK_USER.id, + user_email: MOCK_USER.email, + amount_minor: amountMinor, + currency: BASE_CURRENCY, + account_details: { method: "bank", account: "**** 4321", holder: MOCK_USER.display_name }, + status, + review_note: reviewNote, + reviewed_at: reviewedAt, + created_at: createdAt, + }; + withdrawals.push(row); + return row; + } + + move("available", 50000, "opening_balance", null, null, "2026-08-20T09:00:00.000Z"); + const approved = withdrawal( + 20000, + "approved", + "Paid out to the registered bank account.", + "2026-09-03T09:00:00.000Z", + "2026-09-02T09:00:00.000Z", + ); + move( + "available", + -approved.amount_minor, + "wallet_withdrawal_freeze", + "wallet_withdrawal", + approved.id, + "2026-09-02T09:00:05.000Z", + ); + move( + "frozen", + approved.amount_minor, + "wallet_withdrawal_freeze", + "wallet_withdrawal", + approved.id, + "2026-09-02T09:00:05.000Z", + ); + move( + "frozen", + -approved.amount_minor, + "wallet_withdrawal_approved", + "wallet_withdrawal", + approved.id, + "2026-09-03T09:00:00.000Z", + ); + move("available", -19000, "order_payment", "order", "o1", "2026-09-05T10:00:00.000Z"); + const rejected = withdrawal( + 5000, + "rejected", + "Account holder name does not match our records.", + "2026-09-13T09:00:00.000Z", + "2026-09-12T09:00:00.000Z", + ); + move( + "available", + -rejected.amount_minor, + "wallet_withdrawal_freeze", + "wallet_withdrawal", + rejected.id, + "2026-09-12T09:00:05.000Z", + ); + move( + "frozen", + rejected.amount_minor, + "wallet_withdrawal_freeze", + "wallet_withdrawal", + rejected.id, + "2026-09-12T09:00:05.000Z", + ); + move( + "frozen", + -rejected.amount_minor, + "wallet_withdrawal_rejected", + "wallet_withdrawal", + rejected.id, + "2026-09-13T09:00:00.000Z", + ); + move( + "available", + rejected.amount_minor, + "wallet_withdrawal_rejected", + "wallet_withdrawal", + rejected.id, + "2026-09-13T09:00:00.000Z", + ); + move( + "available", + 1800, + "aftersale_refund", + "aftersale", + "as-demo-refunded", + "2026-09-14T09:00:00.000Z", + ); + + return { + walletAvailableMinor: available, + walletFrozenMinor: frozen, + walletEntries: entries, + walletWithdrawals: withdrawals, + walletEntrySeq: entrySeq, + walletWithdrawalSeq: withdrawalSeq, + }; +} + +/** Mirrors the backend's default page size for `/wallet/entries`. */ +const WALLET_ENTRIES_PER_PAGE = 20; + +// ---- membership / messaging fixtures ---- + +/** Deterministic level catalog, as a platform admin would seed it. */ +const MOCK_MEMBER_LEVELS: MemberLevel[] = [ + { + id: "ml-1", + name: { en: "Bronze", zh: "青铜会员" }, + icon: "I", + growth_threshold: 100, + benefits: { en: "Standard customer support.", zh: "标准客户服务。" }, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }, + { + id: "ml-2", + name: { en: "Silver", zh: "白银会员" }, + icon: "II", + growth_threshold: 500, + benefits: { en: "Faster support and member-only coupons.", zh: "优先客服与会员专享优惠券。" }, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }, + { + id: "ml-3", + name: { en: "Gold", zh: "黄金会员" }, + icon: "III", + growth_threshold: 2000, + benefits: { en: "Priority shipping and exclusive offers.", zh: "优先发货与专属活动权益。" }, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }, +]; + +/** Mirrors the backend's default page size for `/membership/growth-logs`. */ +const GROWTH_LOGS_PER_PAGE = 20; + +/** Mirrors the backend's default page size for `/messages`. */ +const MESSAGES_PER_PAGE = 20; + +interface GrowthLogSeed { + growthLogs: GrowthLogEntry[]; + growthLogSeq: number; +} + +interface MessageSeed { + messages: MockMessage[]; + messageSeq: number; +} + +/** The level whose threshold is the highest one at or below the growth total. */ +function levelAtOrBelow(growthTotal: number): MemberLevel | null { + let best: MemberLevel | null = null; + for (const level of MOCK_MEMBER_LEVELS) { + if (level.growth_threshold <= growthTotal) { + if (!best || level.growth_threshold > best.growth_threshold) best = level; + } + } + return best; +} + +/** The lowest threshold above the growth total, i.e. the next upgrade. */ +function levelAbove(growthTotal: number): MemberLevel | null { + let best: MemberLevel | null = null; + for (const level of MOCK_MEMBER_LEVELS) { + if (level.growth_threshold > growthTotal) { + if (!best || level.growth_threshold < best.growth_threshold) best = level; + } + } + return best; +} + +function copyLevel(level: MemberLevel): MemberLevel { + return { ...level, name: { ...level.name }, benefits: { ...level.benefits } }; +} + +/** Strip the mock-only soft-delete marker before it crosses the contract. */ +function toMessage(row: MockMessage): Message { + return { + id: row.id, + kind: row.kind, + title: { ...row.title }, + body: { ...row.body }, + reference_type: row.reference_type, + reference_id: row.reference_id, + status: row.status, + read_at: row.read_at, + created_at: row.created_at, + }; +} + +/** + * Deterministic growth ledger ending at 620 growth, exactly like the live + * backend's one-entry-per-completed-order shape. + */ +function seedGrowthLogs(orders: Order[]): GrowthLogSeed { + const logs: GrowthLogEntry[] = []; + let seq = 0; + let total = 0; + function accrue( + delta: number, + referenceType: string | null, + referenceId: string | null, + createdAt: string, + ): void { + seq += 1; + total += delta; + logs.push({ + id: `ge-${seq}`, + delta, + growth_total: total, + reason: "order_complete", + reference_type: referenceType, + reference_id: referenceId, + created_at: createdAt, + }); + } + accrue(200, "order", orders[0]?.id ?? null, "2026-09-11T10:05:00.000Z"); + accrue(300, "order", orders[1]?.id ?? null, "2026-09-13T16:05:00.000Z"); + accrue(120, null, null, "2026-09-14T09:00:00.000Z"); + return { growthLogs: logs, growthLogSeq: seq }; +} + +/** One system message per fixture event, carrying the same bilingual copy. */ +function seedMessages(orders: Order[]): MessageSeed { + const shippedOrder = orders[0]; + const paidOrder = orders[1] ?? orders[0]; + const rows: MockMessage[] = []; + let seq = 0; + + function copy(kind: MessageKind, orderNo: string): Pick { + if (kind === "order_paid") + return { + title: { en: "Payment received", zh: "付款成功" }, + body: { + en: `Order ${orderNo} is paid and awaiting shipment.`, + zh: `订单 ${orderNo} 已付款,等待发货。`, + }, + }; + if (kind === "order_shipped") + return { + title: { en: "Order shipped", zh: "订单已发货" }, + body: { en: `Order ${orderNo} has been dispatched.`, zh: `订单 ${orderNo} 已发货。` }, + }; + return { + title: { en: "Refund completed", zh: "退款完成" }, + body: { + en: `Your refund for order ${orderNo} has been issued.`, + zh: `订单 ${orderNo} 的退款已完成。`, + }, + }; + } + + function add( + kind: MessageKind, + referenceType: string, + referenceId: string | null, + orderNo: string, + status: Message["status"], + createdAt: string, + ): void { + seq += 1; + rows.push({ + id: `msg-${seq}`, + kind, + ...copy(kind, orderNo), + reference_type: referenceType, + reference_id: referenceId, + status, + read_at: status === "read" ? createdAt : null, + created_at: createdAt, + deleted_at: null, + }); + } + + if (shippedOrder) { + add( + "order_shipped", + "order", + shippedOrder.id, + shippedOrder.order_no, + "read", + "2026-09-10T18:00:00.000Z", + ); + add("order_paid", "order", shippedOrder.id, shippedOrder.order_no, "read", "2026-09-10T10:05:00.000Z"); + } + if (paidOrder) { + add("order_paid", "order", paidOrder.id, paidOrder.order_no, "unread", "2026-09-12T16:00:00.000Z"); + } + add( + "refund_completed", + "aftersale", + "as-demo-refunded", + paidOrder?.order_no ?? "—", + "unread", + "2026-09-14T09:00:00.000Z", + ); + return { messages: rows, messageSeq: seq }; +} + +/** Mirrors `clamp_per_page` for the admin merchant application queue. */ +const MERCHANT_APPLICATIONS_PER_PAGE = 20; + +/** + * Fixed clock for merchant onboarding fixtures: sequence `n` renders as one + * hour after the base instant, so repeated sessions stay deterministic. + */ +const MERCHANT_BASE_MS = Date.parse("2026-09-18T10:00:00.000Z"); + +function merchantTime(seq: number): string { + return new Date(MERCHANT_BASE_MS + seq * 3_600_000).toISOString(); +} + +/** + * Opening merchant fixture: one rejected personal application, so the fixed + * adapter can show the rejection reason and the re-apply action before the + * first submission. A fresh submit then becomes the pending application, and a + * second submit hits the same 409 the live backend returns. + */ +function seedMerchantApplications(): MerchantApplication[] { + const category = MOCK_CATEGORIES[0]; + if (!category) return []; + return [ + { + id: "ma-1", + user_id: MOCK_USER.id, + applicant_email: MOCK_USER.email, + entity_type: "personal", + real_name: MOCK_USER.display_name, + company_name: null, + business_license_no: null, + category_ids: [category.id], + categories: [{ id: category.id, name: { ...category.name } }], + contact: { + name: MOCK_USER.display_name, + phone: "555-0130", + email: MOCK_USER.email, + address: "1 Market Street, San Francisco, CA", + }, + qualification: { + identity_document_url: "https://example.com/merchant/id-demo.jpg", + }, + status: "rejected", + rejection_reason: + "The identity document image is not legible. Please re-apply with a clear scan.", + reviewed_at: merchantTime(1), + created_shop_id: null, + created_at: merchantTime(0), + updated_at: merchantTime(1), + }, + ]; +} + function initialState(): MockState { const persisted = loadPersisted(); // Coupons and points are session-only, so a restored snapshot re-seeds them. @@ -530,6 +1011,10 @@ function initialState(): MockState { aftersaleMessages: seedAftersaleMessages(aftersales), pointsProducts: seedPointsProducts(), redemptions: [], + ...seedWallet(), + ...seedGrowthLogs(seed.orders), + ...seedMessages(seed.orders), + merchantApplications: seedMerchantApplications(), addressSeq: 100, favoriteSeq: 200, orderSeq: 100, @@ -538,6 +1023,7 @@ function initialState(): MockState { aftersaleSeq: 100, reviewSeq: 1, aftersaleMessageSeq: 100, + merchantApplicationSeq: 1, }; } @@ -601,6 +1087,18 @@ export function createMockApi(): ApiClient { aftersaleSeq: state.aftersaleSeq, aftersaleMessageSeq: state.aftersaleMessageSeq, reviewSeq: state.reviewSeq, + walletAvailableMinor: state.walletAvailableMinor, + walletFrozenMinor: state.walletFrozenMinor, + walletEntries: state.walletEntries, + walletWithdrawals: state.walletWithdrawals, + walletEntrySeq: state.walletEntrySeq, + walletWithdrawalSeq: state.walletWithdrawalSeq, + merchantApplications: state.merchantApplications, + merchantApplicationSeq: state.merchantApplicationSeq, + growthLogs: state.growthLogs, + messages: state.messages, + growthLogSeq: state.growthLogSeq, + messageSeq: state.messageSeq, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); } catch { @@ -721,6 +1219,135 @@ export function createMockApi(): ApiClient { ); } + function copyMerchantApplication(row: MerchantApplication): MerchantApplication { + return { + ...row, + category_ids: [...row.category_ids], + categories: row.categories.map((category) => ({ + id: category.id, + name: { ...category.name }, + })), + contact: { ...row.contact }, + qualification: { + ...row.qualification, + ...(row.qualification.extra_materials + ? { extra_materials: [...row.qualification.extra_materials] } + : {}), + }, + }; + } + + function merchantCategoryRefs(ids: string[]): MerchantCategoryRef[] { + return ids.map((id) => { + const category = MOCK_CATEGORIES.find((entry) => entry.id === id); + if (!category) throw new ApiError(400, "BAD_REQUEST", "unknown operating category"); + return { id: category.id, name: { ...category.name } }; + }); + } + + function merchantRequired(value: string | undefined, field: string): string { + const trimmed = (value ?? "").trim(); + if (!trimmed) throw new ApiError(400, "BAD_REQUEST", `${field} is required`); + return trimmed; + } + + function merchantUrl(value: string | undefined, field: string): string { + const trimmed = (value ?? "").trim(); + const ok = + (trimmed.startsWith("http://") || trimmed.startsWith("https://")) && + trimmed.length > "https://".length && + !/\s/.test(trimmed); + if (!ok) throw new ApiError(400, "BAD_REQUEST", `${field} must be an http(s) URL`); + return trimmed; + } + + interface NormalizedMerchantApplication { + entity_type: MerchantApplication["entity_type"]; + real_name: string | null; + company_name: string | null; + business_license_no: string | null; + category_ids: string[]; + categories: MerchantCategoryRef[]; + contact: MerchantApplication["contact"]; + qualification: MerchantApplication["qualification"]; + } + + /** Mirrors the live service's validation before the fixed adapter stores a row. */ + function normalizeMerchantInput(body: MerchantApplicationInput): NormalizedMerchantApplication { + if (body.category_ids.length === 0) { + throw new ApiError(400, "BAD_REQUEST", "at least one operating category is required"); + } + const categoryIds = [...body.category_ids]; + const categories = merchantCategoryRefs(categoryIds); + const contactName = merchantRequired(body.contact.name, "contact.name"); + const contactPhone = merchantRequired(body.contact.phone, "contact.phone"); + const email = merchantRequired(body.contact.email, "contact.email").toLowerCase(); + if (!email.includes("@") || email.startsWith("@") || email.endsWith("@")) { + throw new ApiError(400, "BAD_REQUEST", "contact.email is invalid"); + } + const address = body.contact.address?.trim(); + const contact: MerchantApplication["contact"] = { + name: contactName, + phone: contactPhone, + email, + ...(address ? { address } : {}), + }; + const extra = (body.qualification.extra_materials ?? []).map((url) => + merchantUrl(url, "qualification.extra_materials"), + ); + if (body.entity_type === "personal") { + return { + entity_type: "personal", + real_name: merchantRequired(body.real_name, "real_name"), + company_name: null, + business_license_no: null, + category_ids: categoryIds, + categories, + contact, + qualification: { + identity_document_url: merchantUrl( + body.qualification.identity_document_url, + "qualification.identity_document_url", + ), + ...(extra.length ? { extra_materials: extra } : {}), + }, + }; + } + const businessLicenseNo = merchantRequired( + body.qualification.business_license_no, + "qualification.business_license_no", + ); + return { + entity_type: "enterprise", + real_name: null, + company_name: merchantRequired(body.company_name, "company_name"), + business_license_no: businessLicenseNo, + category_ids: categoryIds, + categories, + contact, + qualification: { + business_license_url: merchantUrl( + body.qualification.business_license_url, + "qualification.business_license_url", + ), + business_license_no: businessLicenseNo, + ...(extra.length ? { extra_materials: extra } : {}), + }, + }; + } + + function findMerchantApplication(id: string): MerchantApplication { + const row = state.merchantApplications.find((entry) => entry.id === id); + if (!row) throw new ApiError(404, "NOT_FOUND", "Merchant application not found"); + return row; + } + + function merchantHistory(): MerchantApplication[] { + return [...state.merchantApplications].sort((a, b) => + b.created_at.localeCompare(a.created_at), + ); + } + return { register: () => Promise.resolve(tokens()), login: () => Promise.resolve(tokens()), @@ -1485,6 +2112,265 @@ export function createMockApi(): ApiClient { return Promise.resolve({ ...order }); }, + getWallet: (): Promise => + Promise.resolve({ + available_minor: state.walletAvailableMinor, + frozen_minor: state.walletFrozenMinor, + currency: BASE_CURRENCY, + }), + + listWalletEntries: (page = 1) => { + const currentPage = clampPage(page); + const ordered = [...state.walletEntries].sort((a, b) => + b.created_at.localeCompare(a.created_at), + ); + const start = (currentPage - 1) * WALLET_ENTRIES_PER_PAGE; + return Promise.resolve({ + items: ordered + .slice(start, start + WALLET_ENTRIES_PER_PAGE) + .map((entry) => ({ ...entry })), + total: ordered.length, + page: currentPage, + per_page: WALLET_ENTRIES_PER_PAGE, + }); + }, + + rechargeWallet: (amountMinor: number): Promise => { + if (!Number.isInteger(amountMinor) || amountMinor <= 0) { + return Promise.reject(new ApiError(400, "BAD_REQUEST", "amount_minor must be positive")); + } + const now = new Date().toISOString(); + state.walletEntrySeq += 1; + const rechargeId = `wr-${state.walletEntrySeq}`; + state.walletAvailableMinor += amountMinor; + state.walletEntries.push({ + id: `we-${state.walletEntrySeq}`, + account_kind: "available", + delta_minor: amountMinor, + balance_minor: state.walletAvailableMinor, + reason: "wallet_recharge", + reference_type: "wallet_recharge", + reference_id: rechargeId, + created_at: now, + }); + persist(); + return Promise.resolve({ + id: rechargeId, + demo: true, + amount_minor: amountMinor, + currency: BASE_CURRENCY, + status: "credited", + available_minor: state.walletAvailableMinor, + created_at: now, + }); + }, + + applyWithdrawal: ( + amountMinor: number, + details: WithdrawalAccountDetails, + ): Promise => { + if (!Number.isInteger(amountMinor) || amountMinor <= 0) { + return Promise.reject(new ApiError(400, "BAD_REQUEST", "amount_minor must be positive")); + } + const method = details.method.trim(); + const account = details.account.trim(); + if (!method || !account) { + return Promise.reject( + new ApiError(400, "BAD_REQUEST", "account_details.method and account are required"), + ); + } + if (amountMinor > state.walletAvailableMinor) { + // The live backend's guarded debit turns an uncovered amount into a 409. + return Promise.reject(new ApiError(409, "CONFLICT", "insufficient available balance")); + } + const holder = details.holder?.trim(); + state.walletWithdrawalSeq += 1; + const now = new Date().toISOString(); + const row: WalletWithdrawal = { + id: `wd-${state.walletWithdrawalSeq}`, + user_id: MOCK_USER.id, + user_email: MOCK_USER.email, + amount_minor: amountMinor, + currency: BASE_CURRENCY, + account_details: { method, account, ...(holder ? { holder } : {}) }, + status: "pending", + review_note: null, + reviewed_at: null, + created_at: now, + }; + state.walletWithdrawals = [row, ...state.walletWithdrawals]; + // Freeze: one paired two-sided movement, exactly like the live service. + state.walletEntrySeq += 1; + state.walletAvailableMinor -= amountMinor; + state.walletEntries.push({ + id: `we-${state.walletEntrySeq}`, + account_kind: "available", + delta_minor: -amountMinor, + balance_minor: state.walletAvailableMinor, + reason: "wallet_withdrawal_freeze", + reference_type: "wallet_withdrawal", + reference_id: row.id, + created_at: now, + }); + state.walletEntrySeq += 1; + state.walletFrozenMinor += amountMinor; + state.walletEntries.push({ + id: `we-${state.walletEntrySeq}`, + account_kind: "frozen", + delta_minor: amountMinor, + balance_minor: state.walletFrozenMinor, + reason: "wallet_withdrawal_freeze", + reference_type: "wallet_withdrawal", + reference_id: row.id, + created_at: now, + }); + persist(); + return Promise.resolve({ ...row, account_details: { ...row.account_details } }); + }, + + listMyWithdrawals: () => + Promise.resolve( + [...state.walletWithdrawals] + .sort((a, b) => b.created_at.localeCompare(a.created_at)) + .map((row) => ({ ...row, account_details: { ...row.account_details } })), + ), + + submitMerchantApplication: (body: MerchantApplicationInput): Promise => { + const normalized = normalizeMerchantInput(body); + const live = state.merchantApplications.some( + (row) => + row.user_id === MOCK_USER.id && (row.status === "pending" || row.status === "approved"), + ); + if (live) { + return Promise.reject( + new ApiError(409, "CONFLICT", "an active merchant application already exists"), + ); + } + state.merchantApplicationSeq += 1; + const now = merchantTime(state.merchantApplicationSeq + 1); + const row: MerchantApplication = { + id: `ma-${state.merchantApplicationSeq}`, + user_id: MOCK_USER.id, + applicant_email: MOCK_USER.email, + entity_type: normalized.entity_type, + real_name: normalized.real_name, + company_name: normalized.company_name, + business_license_no: normalized.business_license_no, + category_ids: normalized.category_ids, + categories: normalized.categories, + contact: normalized.contact, + qualification: normalized.qualification, + status: "pending", + rejection_reason: null, + reviewed_at: null, + created_shop_id: null, + created_at: now, + updated_at: now, + }; + state.merchantApplications = [row, ...state.merchantApplications]; + persist(); + return Promise.resolve(copyMerchantApplication(row)); + }, + + getMyMerchantApplications: (): Promise => + Promise.resolve( + merchantHistory() + .filter((row) => row.user_id === MOCK_USER.id) + .map(copyMerchantApplication), + ), + + getMembership: (): Promise => { + // Re-derived from the ledger on every read, exactly like the live service. + const growthTotal = state.growthLogs.reduce((sum, entry) => sum + entry.delta, 0); + const level = levelAtOrBelow(growthTotal); + const next = levelAbove(growthTotal); + const nextLevel: NextMemberLevel | null = next + ? { + id: next.id, + name: { ...next.name }, + icon: next.icon, + growth_threshold: next.growth_threshold, + remaining: next.growth_threshold - growthTotal, + } + : null; + return Promise.resolve({ + level: level ? copyLevel(level) : null, + growth_total: growthTotal, + next_level: nextLevel, + }); + }, + + listGrowthLogs: (page = 1): Promise> => { + const currentPage = clampPage(page); + const ordered = [...state.growthLogs].sort((a, b) => + b.created_at.localeCompare(a.created_at), + ); + const start = (currentPage - 1) * GROWTH_LOGS_PER_PAGE; + return Promise.resolve({ + items: ordered.slice(start, start + GROWTH_LOGS_PER_PAGE).map((entry) => ({ ...entry })), + total: ordered.length, + page: currentPage, + per_page: GROWTH_LOGS_PER_PAGE, + }); + }, + + listMessages: (q: MessageListQuery = {}): Promise> => { + const currentPage = clampPage(q.page); + const perPage = clampPerPage(q.per_page ?? MESSAGES_PER_PAGE); + const unreadOnly = q.unread_only ?? false; + const visible = state.messages + .filter((row) => row.deleted_at === null && (!unreadOnly || row.status === "unread")) + .sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id)); + const start = (currentPage - 1) * perPage; + return Promise.resolve({ + items: visible.slice(start, start + perPage).map(toMessage), + total: visible.length, + page: currentPage, + per_page: perPage, + }); + }, + + markMessageRead: (id: string): Promise => { + const row = state.messages.find((entry) => entry.id === id); + if (!row) return Promise.reject(new ApiError(404, "NOT_FOUND", "message")); + // Guarded like the live UPDATE: only an unread, non-deleted row flips. + if (row.deleted_at === null && row.status === "unread") { + row.status = "read"; + row.read_at = new Date().toISOString(); + persist(); + } + return Promise.resolve(toMessage(row)); + }, + + markAllMessagesRead: (): Promise => { + const now = new Date().toISOString(); + let updated = 0; + for (const row of state.messages) { + if (row.deleted_at !== null || row.status !== "unread") continue; + row.status = "read"; + row.read_at = now; + updated += 1; + } + if (updated > 0) persist(); + return Promise.resolve({ updated }); + }, + + deleteMessage: (id: string): Promise => { + const row = state.messages.find((entry) => entry.id === id); + if (!row) return Promise.reject(new ApiError(404, "NOT_FOUND", "message")); + // Soft delete is idempotent: a repeat reports that nothing flipped. + if (row.deleted_at !== null) return Promise.resolve({ id, deleted: false }); + row.deleted_at = new Date().toISOString(); + persist(); + return Promise.resolve({ id, deleted: true }); + }, + + getUnreadCount: (): Promise => + Promise.resolve({ + unread: state.messages.filter((row) => row.deleted_at === null && row.status === "unread") + .length, + }), + shop: { getMyShop: () => unsupported(), updateMyProfile: () => unsupported(), @@ -1530,6 +2416,9 @@ export function createMockApi(): ApiClient { deleteFreightTemplate: () => unsupported(), listReviews: (_page?: number) => unsupported(), replyReview: (_id: string, _content: Record) => unsupported(), + listShopSettlementStatements: () => unsupported(), + getShopSettlementStatement: () => unsupported(), + generateShopSettlementStatement: () => unsupported(), }, admin: { listUsers: () => unsupported(), @@ -1559,6 +2448,76 @@ export function createMockApi(): ApiClient { listReviews: (_page?: number) => unsupported(), hideReview: (_id: string) => unsupported(), deleteReview: (_id: string) => unsupported(), + listWithdrawalApplications: () => unsupported(), + reviewWithdrawal: () => unsupported(), + getCommissionRate: () => unsupported(), + setCommissionRate: () => unsupported(), + listSettlementStatements: () => unsupported(), + getSettlementStatement: () => unsupported(), + generateSettlementStatement: () => unsupported(), + confirmSettlementStatement: () => unsupported(), + listMemberLevels: () => unsupported(), + createMemberLevel: (_body: MemberLevelInput) => unsupported(), + updateMemberLevel: (_id: string, _body: MemberLevelInput) => unsupported(), + deleteMemberLevel: (_id: string) => unsupported(), + listMerchantApplications: async ( + q: MerchantApplicationQuery = {}, + ): Promise> => { + const page = clampPage(q.page); + const perPage = clampPerPage(q.per_page ?? MERCHANT_APPLICATIONS_PER_PAGE); + const filtered = merchantHistory().filter((row) => !q.status || row.status === q.status); + const start = (page - 1) * perPage; + return { + items: filtered.slice(start, start + perPage).map(copyMerchantApplication), + total: filtered.length, + page, + per_page: perPage, + }; + }, + getMerchantApplication: async (id: string): Promise => + copyMerchantApplication(findMerchantApplication(id)), + approveMerchantApplication: async (id: string): Promise => { + const row = findMerchantApplication(id); + if (row.status !== "pending") { + throw new ApiError(409, "CONFLICT", "application was already reviewed"); + } + state.merchantApplicationSeq += 1; + const reviewedAt = merchantTime(state.merchantApplicationSeq + 1); + const shopId = `s-merchant-${state.merchantApplicationSeq}`; + row.status = "approved"; + row.reviewed_at = reviewedAt; + row.created_shop_id = shopId; + row.updated_at = reviewedAt; + persist(); + return { + application: copyMerchantApplication(row), + credentials: { + email: `shop-owner+${row.id}@vmall.local`, + initial_password: "Vmall-Owner-2026", + shop_id: shopId, + shop_slug: `merchant-shop-${state.merchantApplicationSeq}`, + }, + }; + }, + rejectMerchantApplication: async ( + id: string, + reason: string, + ): Promise => { + const trimmed = reason.trim(); + if (!trimmed) throw new ApiError(400, "BAD_REQUEST", "rejection reason is required"); + const row = findMerchantApplication(id); + if (row.status !== "pending") { + throw new ApiError(409, "CONFLICT", "application was already reviewed"); + } + state.merchantApplicationSeq += 1; + const reviewedAt = merchantTime(state.merchantApplicationSeq + 1); + row.status = "rejected"; + row.rejection_reason = trimmed; + row.reviewed_at = reviewedAt; + row.updated_at = reviewedAt; + persist(); + return copyMerchantApplication(row); + }, }, }; } diff --git a/apps/mall/mock/data.ts b/apps/mall/mock/data.ts index b5a8baf..a8a1ebd 100644 --- a/apps/mall/mock/data.ts +++ b/apps/mall/mock/data.ts @@ -907,7 +907,7 @@ export const MOCK_QUICK_LINKS: MockQuickLink[] = [ { label: L("Group Buy", "优惠团购"), url: "/collective", glyph: GLYPHS.gift }, { label: L("Flash Sale", "秒杀活动"), url: "/seckill", glyph: GLYPHS.bolt }, { label: L("Notice", "商城公告"), url: "/user", glyph: GLYPHS.bell }, - { label: L("Become a Seller", "入驻商家"), url: "/stores", glyph: GLYPHS.shop }, + { label: L("Become a Seller", "入驻商家"), url: "/merchant/join", glyph: GLYPHS.shop }, ]; export const MOCK_PROMOS: MockPromo[] = [ diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index 0ad57ab..c98c203 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -32,6 +32,10 @@ export default defineNuxtConfig({ "favorites", "aftersales", "reviews", + "wallet", + "membership", + "messaging", + "merchantOnboarding", ], appName: "mall", }, diff --git a/apps/mall/pages/login.vue b/apps/mall/pages/login.vue index ffdb138..2dcdbbd 100644 --- a/apps/mall/pages/login.vue +++ b/apps/mall/pages/login.vue @@ -24,6 +24,12 @@ const redirectTarget = computed((): string => { : "/"; }); +/** Carry the return path into registration so both entry points return here. */ +const registerLink = computed(() => ({ + path: "/register", + query: redirectTarget.value === "/" ? {} : { redirect: redirectTarget.value }, +})); + function validate(): boolean { if (!email.value || !password.value) { errorKey.value = "auth.validationRequired"; @@ -62,7 +68,7 @@ async function submit(): Promise {

    {{ t("auth.loginTab") }}

    - {{ + {{ t("auth.registerTab") }}
    @@ -90,7 +96,7 @@ async function submit(): Promise {
    {{ t("auth.noAccount") }} - {{ + {{ t("auth.registerNow") }} {{ diff --git a/apps/mall/pages/merchant/join.vue b/apps/mall/pages/merchant/join.vue new file mode 100644 index 0000000..344c321 --- /dev/null +++ b/apps/mall/pages/merchant/join.vue @@ -0,0 +1,672 @@ + + + diff --git a/apps/mall/pages/merchant/status.vue b/apps/mall/pages/merchant/status.vue new file mode 100644 index 0000000..0613d2a --- /dev/null +++ b/apps/mall/pages/merchant/status.vue @@ -0,0 +1,205 @@ + + + diff --git a/apps/mall/pages/register.vue b/apps/mall/pages/register.vue index e940e0d..8ce7ff8 100644 --- a/apps/mall/pages/register.vue +++ b/apps/mall/pages/register.vue @@ -5,6 +5,7 @@ const { t } = useI18n(); const { $api } = useNuxtApp(); const session = useSessionStore(); const router = useRouter(); +const route = useRoute(); const displayName = ref(""); const email = ref(""); @@ -13,6 +14,18 @@ const confirmPassword = ref(""); const errorKey = ref(""); const submitting = ref(false); +/** + * Where to go after registering. Same-origin absolute paths only, so a crafted + * query cannot turn registration into an open redirect. + */ +const redirectTarget = computed((): string => { + const raw = route.query.redirect; + const value = Array.isArray(raw) ? raw[0] : raw; + return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") + ? value + : "/"; +}); + /** Mirrors the auth API's rule so a short password fails here, not as a 400. */ const MIN_PASSWORD_LENGTH = 8; @@ -43,7 +56,7 @@ async function submit(): Promise { try { const auth = await $api.register(email.value, password.value, displayName.value); session.setAuth(auth); - await router.push("/"); + await router.push(redirectTarget.value); } catch (error) { errorKey.value = error instanceof ApiError && error.status === 409 ? "auth.emailTaken" : "auth.requestFailed"; diff --git a/apps/mall/pages/user.vue b/apps/mall/pages/user.vue index 7349186..34cf46c 100644 --- a/apps/mall/pages/user.vue +++ b/apps/mall/pages/user.vue @@ -14,12 +14,15 @@ const menuGroups = computed(() => [ { label: t("user.addresses"), to: "/user/addresses" }, { label: t("user.coupons"), to: "/user/coupons" }, { label: t("user.aftersalesTitle"), to: "/user/aftersales" }, + { label: t("messaging.title"), to: "/user/messages" }, ], }, { title: t("user.memberCenter"), items: [ { label: t("user.dashboard"), to: "/user" }, + { label: t("membership.entry"), to: "/user/membership" }, + { label: t("wallet.title"), to: "/user/wallet" }, { label: t("user.favorites"), to: "/user/favorites" }, { label: t("user.invoices"), to: "/user/invoices" }, ], diff --git a/apps/mall/pages/user/index.vue b/apps/mall/pages/user/index.vue index 617d98e..26e4f5f 100644 --- a/apps/mall/pages/user/index.vue +++ b/apps/mall/pages/user/index.vue @@ -134,6 +134,23 @@ const statusLinks = computed(() => [ >{{ item.count }}
    +
    diff --git a/apps/mall/pages/user/membership.vue b/apps/mall/pages/user/membership.vue new file mode 100644 index 0000000..5c7d556 --- /dev/null +++ b/apps/mall/pages/user/membership.vue @@ -0,0 +1,212 @@ + + + diff --git a/apps/mall/pages/user/messages.vue b/apps/mall/pages/user/messages.vue new file mode 100644 index 0000000..31cc7e8 --- /dev/null +++ b/apps/mall/pages/user/messages.vue @@ -0,0 +1,254 @@ + + + diff --git a/apps/mall/pages/user/wallet.vue b/apps/mall/pages/user/wallet.vue new file mode 100644 index 0000000..5048d4c --- /dev/null +++ b/apps/mall/pages/user/wallet.vue @@ -0,0 +1,370 @@ + + + diff --git a/apps/mall/plugins/api.ts b/apps/mall/plugins/api.ts index 483673c..9f47592 100644 --- a/apps/mall/plugins/api.ts +++ b/apps/mall/plugins/api.ts @@ -26,7 +26,11 @@ type LiveDomain = | "groupBuying" | "favorites" | "aftersales" - | "reviews"; + | "reviews" + | "wallet" + | "membership" + | "messaging" + | "merchantOnboarding"; /** * Explicit per-domain method picks rather than a string allowlist: indexing @@ -111,6 +115,28 @@ const LIVE_PICKS = { listReviewableItems: a.listReviewableItems, createReview: a.createReview, }), + wallet: (a: ApiClient) => ({ + getWallet: a.getWallet, + listWalletEntries: a.listWalletEntries, + rechargeWallet: a.rechargeWallet, + applyWithdrawal: a.applyWithdrawal, + listMyWithdrawals: a.listMyWithdrawals, + }), + membership: (a: ApiClient) => ({ + getMembership: a.getMembership, + listGrowthLogs: a.listGrowthLogs, + }), + messaging: (a: ApiClient) => ({ + listMessages: a.listMessages, + markMessageRead: a.markMessageRead, + markAllMessagesRead: a.markAllMessagesRead, + deleteMessage: a.deleteMessage, + getUnreadCount: a.getUnreadCount, + }), + merchantOnboarding: (a: ApiClient) => ({ + submitMerchantApplication: a.submitMerchantApplication, + getMyMerchantApplications: a.getMyMerchantApplications, + }), } satisfies Record Partial>; const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[]; @@ -136,6 +162,10 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [ "favorites", "aftersales", "reviews", + "wallet", + "membership", + "messaging", + "merchantOnboarding", ]; export default defineNuxtPlugin(() => { diff --git a/apps/shop-admin/app.vue b/apps/shop-admin/app.vue index 04009ba..4d55480 100644 --- a/apps/shop-admin/app.vue +++ b/apps/shop-admin/app.vue @@ -78,6 +78,18 @@ watchEffect(() => { class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium" >{{ $t("nav.invoices") }} + {{ $t("nav.settlements") }} + {{ $t("nav.shopAccount") }} +import { ApiError } from "@vmall/shared"; +import type { + Paged, + SettlementPeriodKind, + SettlementStatement, + SettlementStatementDetail, + SettlementStatus, +} from "@vmall/shared"; + +definePageMeta({ middleware: "auth" }); + +const { $api } = useNuxtApp(); +const { locale, t: translate } = useI18n(); +const { load: loadMoney, fmt } = useMoney(); + +const statements = ref | null>(null); +const page = ref(1); +const statusFilter = ref<"" | SettlementStatus>(""); +const loading = ref(true); +const error = ref(""); +const serverError = ref(""); +const notice = ref(""); + +const detailId = ref(""); +const detail = ref(null); +const detailLoading = ref(false); +const detailError = ref(""); + +const periodKind = ref("month"); +const periodDate = ref(defaultPeriodDate()); +const generating = ref(false); +const generated = ref(null); +const generatedExisting = ref(false); + +/** Mid-point of the previous month: safely inside a closed period. */ +function defaultPeriodDate(): string { + const now = new Date(); + const previous = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 15)); + return previous.toISOString().slice(0, 10); +} + +const selectClass = + "border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"; + +function formatDate(value: string): string { + return new Date(value).toLocaleString(locale.value === "zh" ? "zh-CN" : "en-US"); +} + +function statusTone(value: SettlementStatus): "green" | "orange" { + return value === "confirmed" ? "green" : "orange"; +} + +/** Integer basis points to a fixed 2-decimal percentage (no float money math). */ +function formatBps(bps: number): string { + const whole = Math.trunc(bps / 100); + const fraction = Math.abs(bps % 100) + .toString() + .padStart(2, "0"); + return `${whole}.${fraction}%`; +} + +function periodKindLabel(kind: SettlementPeriodKind): string { + return kind === "week" + ? translate("shop.settlement.periodWeek") + : translate("shop.settlement.periodMonth"); +} + +async function loadStatements(): Promise { + loading.value = true; + error.value = ""; + serverError.value = ""; + try { + statements.value = await $api.shop.listShopSettlementStatements({ + page: page.value, + status: statusFilter.value || undefined, + }); + } catch (err: unknown) { + error.value = err instanceof Error ? err.message : translate("common.error"); + } finally { + loading.value = false; + } +} + +function closeDetail(): void { + detailId.value = ""; + detail.value = null; + detailError.value = ""; +} + +async function changePage(next: number): Promise { + if (!statements.value || next < 1) return; + if (next > Math.ceil(statements.value.total / statements.value.per_page)) return; + page.value = next; + closeDetail(); + await loadStatements(); +} + +async function changeStatus(): Promise { + page.value = 1; + closeDetail(); + await loadStatements(); +} + +async function toggleDetail(id: string): Promise { + if (detailId.value === id) { + closeDetail(); + return; + } + detailId.value = id; + detail.value = null; + detailError.value = ""; + detailLoading.value = true; + try { + detail.value = await $api.shop.getShopSettlementStatement(id); + } catch (err: unknown) { + detailError.value = err instanceof Error ? err.message : translate("common.error"); + } finally { + detailLoading.value = false; + } +} + +async function generate(): Promise { + const date = periodDate.value.trim(); + if (!date) { + error.value = translate("shop.settlement.dateRequired"); + return; + } + generating.value = true; + error.value = ""; + serverError.value = ""; + notice.value = ""; + generated.value = null; + generatedExisting.value = false; + try { + // Snapshot the existing statement ids first so an idempotent repeat can be + // reported as "existing" rather than "new". 100 rows covers years of + // weekly statements for a single shop. + const known = await $api.shop.listShopSettlementStatements({ page: 1, per_page: 100 }); + const knownIds = new Set(known.items.map((item) => item.id)); + const statement = await $api.shop.generateShopSettlementStatement({ + period_kind: periodKind.value, + period_start: date, + }); + generated.value = statement; + generatedExisting.value = knownIds.has(statement.id); + notice.value = generatedExisting.value + ? translate("shop.settlement.generatedExisting") + : translate("shop.settlement.generatedNew"); + page.value = 1; + await loadStatements(); + } catch (err: unknown) { + if (err instanceof ApiError && err.status === 409) { + // The period is not closed yet; keep the server message visible too. + error.value = translate("shop.settlement.notClosed"); + serverError.value = err.message; + } else { + error.value = err instanceof Error ? err.message : translate("common.error"); + } + } finally { + generating.value = false; + } +} + +onMounted(() => { + loadMoney(); + loadStatements(); +}); + + + diff --git a/apps/shop-admin/pages/shop-account.vue b/apps/shop-admin/pages/shop-account.vue new file mode 100644 index 0000000..80a8d5e --- /dev/null +++ b/apps/shop-admin/pages/shop-account.vue @@ -0,0 +1,329 @@ + + + diff --git a/docs/TBD-marketing.md b/docs/TBD-marketing.md index 91d9899..03c40cd 100644 --- a/docs/TBD-marketing.md +++ b/docs/TBD-marketing.md @@ -85,6 +85,11 @@ Recorded so the gap is explicit, not because all of them belong in scope: templates (by piece/weight, first+additional fees, free thresholds, region overrides), server-side checkout fee computation with per-item snapshots, and a shipping-company dictionary validated at ship time. +- [x] **Merchant onboarding / 商家入驻** — implemented live as `add-merchant-onboarding`: + the mall `/merchant/join` + `/merchant/status` pages call the real backend through the + `merchantOnboarding` live domain, and the admin review console calls the admin API. No + fixture-driven onboarding surface remains in `apps/mall/mock`, so nothing is listed here + as a holdout. Wallet/withdrawal and settlement holdouts are likewise live (`add-wallet-settlement`). ## Deferred designs from general B2B2C storefronts diff --git a/docs/backend-guidelines.md b/docs/backend-guidelines.md index 06f0d73..b2bc965 100644 --- a/docs/backend-guidelines.md +++ b/docs/backend-guidelines.md @@ -33,8 +33,39 @@ UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2 -- one-time flag (reply, reopen): guard on the empty state UPDATE product_reviews SET reply = $2 WHERE id = $1 AND reply IS NULL + +-- one-time review out of a pending state (wallet withdrawals, payouts) +UPDATE wallet_withdrawals SET status = $2, reviewed_by = $3, reviewed_at = now() + WHERE id = $1 AND status = 'pending' -- 0 rows → ApiError::Conflict ``` +Idempotent generation guarded by a unique index (settlement statements): insert +with `ON CONFLICT () DO NOTHING RETURNING id`; a `None` result means a +repeat or a racing request won, so re-read the existing row and return it +unchanged instead of recomputing. + +"At most one live row per owner" (merchant applications: one `pending`/`approved` +per user) is a **partial unique index** — `UNIQUE (user_id) WHERE status IN (...)` +— checked first in the service for a friendly 409 and relied on as the +concurrency backstop via `unique_conflict`. Transactional provisioning (approval +creating a shop + owner account) reads the row `FOR UPDATE`, provisions, then +runs the guarded status flip last so any failure rolls the whole thing back. + +Event side effects (membership growth, system messages) run **inside the +transition's transaction** and are made idempotent by a partial unique index plus +`ON CONFLICT DO NOTHING`, so a retried handler cannot double-write: + +```sql +-- one message per customer, kind, and reference +INSERT INTO messages (...) VALUES (...) +ON CONFLICT (user_id, kind, reference_type, reference_id) + WHERE reference_id IS NOT NULL DO NOTHING +``` + +A per-customer ledger total (growth) serializes on `SELECT id FROM users WHERE +id = $1 FOR UPDATE`, then reads `COALESCE(SUM(delta),0)::bigint` and appends the +entry carrying the new running total. + Multi-row `FOR UPDATE` must `ORDER BY` primary key. Ledger writes are append-only: change a balance only via `account::service::credit/debit` inside the caller's transaction, never by writing an absolute balance. @@ -74,3 +105,11 @@ endpoints especially. Mind pipefail: `cargo test | grep` hides failures. 3. Add the column to **every** SELECT/RETURNING list for that table. 4. Extend INSERT binds if writable. 5. Mirror in `packages/shared/src/types.ts`. + +Nuance: a column that only one module writes and reads may stay out of the +shared `models.rs` row when adding the field would force every existing +`SELECT`/`RETURNING` list for that table to change. Example: +`orders.completed_at` (settlement period attribution) is written by the order +completion transition and read by `modules/settlement/repo.rs`; `models::Order` +does not carry it. Also remember `updated_at` is not a completion timestamp — +refunds bump it — so period attribution needs a dedicated column. diff --git a/docs/code_index/consoles.md b/docs/code_index/consoles.md index 6c30930..99b21ed 100644 --- a/docs/code_index/consoles.md +++ b/docs/code_index/consoles.md @@ -17,6 +17,8 @@ per page. Navigation is a static list in each `app.vue`. | `pages/invoices.vue` | invoice issuing | | `pages/freight-templates.vue` | freight templates + region rules | | `pages/shop-profile.vue` | own shop profile self-edit | +| `pages/settlements.vue` | own-shop statements: list/detail, idempotent generation | +| `pages/shop-account.vue` | shop-owner wallet summary, withdrawal request/history, fund entries | ## apps/admin (platform console, :3002) @@ -30,3 +32,7 @@ per page. Navigation is a static list in each `app.vue`. | `pages/content.vue`, `pages/brands.vue` | storefront content, brand registry | | `pages/currencies.vue` | currency registry + rates | | `pages/points-products.vue`, `pages/points-orders.vue` | points mall ops | +| `pages/withdrawals.vue` | withdrawal review queue (approve/reject once, 409 feedback) | +| `pages/settlements.vue` | commission rate, statement list/detail, idempotent generation, one-time payout confirmation | +| `pages/merchant-applications.vue` | merchant application review queue, detail, approve/reject, one-time owner credentials | +| `pages/member-levels.vue` + `components/MemberLevelForm.vue` | member level catalog CRUD (bilingual, unique threshold, in-use delete rejection) | diff --git a/docs/code_index/index.md b/docs/code_index/index.md index 8fbb4cf..f8da4f5 100644 --- a/docs/code_index/index.md +++ b/docs/code_index/index.md @@ -10,7 +10,7 @@ files it lists.** | Post-order | aftersales/refunds, reviews | [code_index/post-order.md](post-order.md) | | Catalog | products, SKUs, categories, brands | [code_index/catalog.md](catalog.md) | | Marketing | coupons, flash sales, group buying, points | [code_index/marketing.md](marketing.md) | -| Platform | identity, accounts/ledger, shops, storefront content, currencies, addresses | [code_index/platform.md](platform.md) | +| Platform | identity, accounts/ledger, wallet, settlement, merchant onboarding, membership & messages, shops, storefront content, currencies, addresses | [code_index/platform.md](platform.md) | | Shared contract | types, API client, locales, UI components | [code_index/shared.md](shared.md) | | Storefront app | mall pages, mock adapter, live-domain wiring | [code_index/mall.md](mall.md) | | Console apps | shop-admin, admin pages | [code_index/consoles.md](consoles.md) | diff --git a/docs/code_index/mall.md b/docs/code_index/mall.md index 598750c..ea2473c 100644 --- a/docs/code_index/mall.md +++ b/docs/code_index/mall.md @@ -12,10 +12,16 @@ Guidelines: `docs/frontend-guidelines.md`. | `pages/search.vue`, `pages/goods/[id].vue` | catalog browsing; goods detail has reviews tab | | `pages/cart.vue`, `pages/checkout/*` | cart and checkout (address → quote → submit → pay → success) | | `pages/stores/*` | store directory + store detail | -| `pages/user/*` | buyer center: orders, aftersales, reviews, coupons, addresses, invoices, favorites | +| `pages/user/*` | buyer center: orders, aftersales, reviews, coupons, addresses, invoices, favorites, wallet | +| `pages/user/wallet.vue` | wallet balances, paginated ledger entries, demo recharge, withdrawal request/history | +| `pages/user/membership.vue` | level/benefits, growth total, progress to the next threshold, growth history | +| `pages/user/messages.vue` | system message center: unread filter, mark read, mark-all-read, soft delete | +| `pages/merchant/join.vue` | 商家入驻 multi-step application form (anonymous fill, sign-in gate, draft return) | +| `pages/merchant/status.vue` | applicant's own application status, rejection reason, re-apply | +| `locales/.ts` + `locales-extra.ts` | app-local bilingual strings deep-merged over the shared locales (mall namespaces: shell/home/search/product/cart/checkout/auth/user/stores/marketing/wallet/merchant/membership/messaging) | | `pages/seckill.vue`, `collective.vue`, `integral.vue` | flash sale, group buying, points mall | -| `app.vue` + `components/shell/*` | header/footer shell | -| `composables/` | session/cart stores, `usePrice` (currency conversion display) | +| `app.vue` + `components/shell/*` | header/footer shell; `SiteHeader.vue` carries the unread-message badge | +| `composables/` | session/cart stores, `usePrice` (currency conversion display), `useUnreadMessages` (header badge count) | Remember: mall is the only app with a mock adapter; every new live domain needs mock parity so `NUXT_PUBLIC_LIVE_DOMAINS` rollback keeps working. diff --git a/docs/code_index/platform.md b/docs/code_index/platform.md index f3c1902..086bd81 100644 --- a/docs/code_index/platform.md +++ b/docs/code_index/platform.md @@ -1,8 +1,9 @@ # Index: Platform (identity, accounts, shops, content, currency, addresses) Domain design: `docs/domains/platform.md`. Specs: `openspec/specs/auth/`, -`customer-accounts/`, `store-directory/`, `storefront-content/`, `currency/`, -`address-book/`, `rbac/`. +`customer-accounts/`, `wallet/`, `settlement/`, `merchant-onboarding/`, +`membership/`, `messaging/`, `store-directory/`, `storefront-content/`, +`currency/`, `address-book/`, `rbac/`. ## Backend (`apps/api`) @@ -10,7 +11,12 @@ Domain design: `docs/domains/platform.md`. Specs: `openspec/specs/auth/`, |---|---| | `modules/identity/` | register/login/me; admin user role assignment | | `modules/account/` | customer_accounts ledger; credit/debit/freeze/release, `ensure_monetary_account` | +| `modules/wallet/` | demo recharge, guarded withdrawal freeze + one-time admin review, paginated own fund entries | +| `modules/settlement/` | idempotent per-shop weekly/monthly statements, commission-rate setting, one-time payout confirmation | | `modules/shop/` | shop CRUD/status, profiles, `PUT /shop/profile` merchant self-write | +| `modules/merchant_onboarding/` | merchant applications (personal/enterprise), guarded review, transactional approval provisioning | +| `modules/membership/` | member level catalog, growth accrual ledger, one-way level upgrade, derived status | +| `modules/messaging/` | event-driven system messages, unread/read state, soft delete, unread count | | `modules/content/` | four home-content kinds, whole-list replacement | | `modules/currency/` | currency registry, rates, `convert` endpoint | | `modules/address/` | customer address book | @@ -18,8 +24,10 @@ Domain design: `docs/domains/platform.md`. Specs: `openspec/specs/auth/`, | `src/error.rs` | `ApiError` envelope, `unique_conflict` | | `src/money.rs` | `convert_minor` | -Tests: `tests/auth.rs`, `tests/accounts.rs`, `tests/shops.rs`, -`tests/content.rs`, `tests/addresses.rs`, `tests/catalog.rs` (currency too). +Tests: `tests/auth.rs`, `tests/accounts.rs`, `tests/wallet.rs`, +`tests/settlement.rs`, `tests/merchant_applications.rs`, `tests/membership.rs`, +`tests/messaging.rs`, `tests/shops.rs`, `tests/content.rs`, +`tests/addresses.rs`, `tests/catalog.rs` (currency too). ## Frontends @@ -27,4 +35,4 @@ Tests: `tests/auth.rs`, `tests/accounts.rs`, `tests/shops.rs`, `layouts/`/`app.vue`, `pages/user/addresses.vue` - Shop-admin: `pages/shop-profile.vue` - Admin: `pages/users.vue`, `pages/shops.vue`, `pages/content.vue`, - `pages/brands.vue`, `pages/currencies.vue` + `pages/brands.vue`, `pages/currencies.vue`, `pages/member-levels.vue` diff --git a/docs/domains/platform.md b/docs/domains/platform.md index 2526be4..b93871f 100644 --- a/docs/domains/platform.md +++ b/docs/domains/platform.md @@ -10,8 +10,34 @@ The ledger is the audit trail; balances are never set absolutely. Credits in a currency the customer never held lazily create the zero row (`ensure_monetary_account`). -Planned on this foundation: wallet top-up/withdrawal and merchant settlement -(`openspec/changes/add-wallet-settlement`). +## Wallet (`modules/wallet/`) + +Buyer- and shop-owner-facing entry points over the ledger. `GET /wallet` is the +available/frozen summary in the platform base currency; `GET /wallet/entries` +pages the caller's own `customer_account_entries` (money kinds only) newest +first with signed deltas and resulting balances. `POST /wallet/recharges` is a +**simulated** demo credit (`demo: true` on the payload, no payment provider), +and `POST /wallet/withdrawals` freezes the requested amount out of available +balance through the account module's guarded transfer. Platform admins list and +review pending applications via `/admin/wallet/withdrawals`: approve consumes +the frozen funds, reject returns them to available, and the +`pending -> approved|rejected` transition is guarded so a repeat review is a +409. Every movement pairs with a ledger entry in the same transaction. + +## Settlement (`modules/settlement/`) + +Per-shop, per-period reconciliation statements generated manually for a closed +week or month (`/shop/settlement/*` for the own shop, `/admin/settlement/*` for +the platform). Generation is idempotent per `(shop, period_kind, period_start)` +— enforced by a unique index — and snapshots the contributing confirmed-received +orders (`orders.completed_at` inside the period), their gross and completed +refunds converted into the platform base currency, the platform commission rate +in integer basis points, and `payable = gross - refunds - commission`, all in +integer minor units. The platform rate lives in `platform_settings` +(`settlement.commission_rate_bps`) and only affects statements generated after a +change. Confirmation is a guarded `pending -> confirmed` transition that credits +the payable amount to the shop owner's available account with exactly one +`settlement_payout` ledger entry. ## Storefront content (`modules/content/`) @@ -34,6 +60,51 @@ Email+password register/login, JWT bearer tokens. `AuthUser` carries `require_shop()` for tenant scoping (cross-shop resources return 404). `ensure_accounts` runs inside the registration transaction. +## Merchant onboarding (`modules/merchant_onboarding/`) + +The B2B entry that replaces manual admin shop creation: an authenticated user +submits one application as `personal` or `enterprise` (kind-specific entity +fields, one or more reference categories, contact details, qualification URLs +only). One live application per user is enforced by service validation plus a +partial unique index on `(user_id) WHERE status IN ('pending','approved')`, so +a rejected applicant may re-apply. The review state machine is +`pending -> approved | rejected` through guarded updates; rejection requires a +non-empty reason, and both terminal states are immutable (409 on repeat). +Approval is one transaction — `shop::service::create_in_tx`, a dedicated +`shop_owner` user via `identity::repo::insert_user`, its zero-balance accounts, +and the guarded status flip — and returns the generated initial password +exactly once; any failure rolls the whole provisioning back and leaves the +application `pending`. Applicants only ever read their own history; platform +admins list/filter all applications. + +## Membership (`modules/membership/`) + +Platform-managed `member_levels` (bilingual name and benefits, icon, globally +unique integer growth threshold) plus an append-only `growth_logs` ledger and +`users.level`. When the buyer confirms receipt and the order reaches +`completed`, `membership::service::accrue_for_order` runs inside that +transaction: it converts the order's realized paid amount (total minus completed +refunds) into the base currency with `money::convert_minor`, truncates to whole +units via the base exponent, appends exactly one entry (`ON CONFLICT DO NOTHING` +on the `(user_id, reference_type, reference_id)` partial unique index), and +moves `users.level` with a guarded update that only ever raises the threshold. +Leveling is one-way; the status read re-derives the displayed level against the +current thresholds so an admin edit is reflected without rewriting members. +Growth history is own-only and paginated. + +## Messaging (`modules/messaging/`) + +Per-customer system messages emitted by order events: payment success +(`order_paid`), shipment dispatch (`order_shipped`), and refund completion +(`refund_completed`, referenced by the after-sale row and naming the order). +Each emit is a guarded insert keyed by `(user_id, kind, reference_type, +reference_id)` inside the transition's transaction, so a retried handler cannot +duplicate a message and a soft-deleted row still occupies its slot. Messages are +created `unread`; marking one or all read uses guarded updates that only touch +`unread` rows, deletion is a soft-delete marker that is idempotent and excludes +the row from listing and counting, and a dedicated unread-count endpoint feeds +the mall's top-bar badge. + ## Key files See `docs/code_index/platform.md`. diff --git a/openspec/MIGRATION-PLAN.md b/openspec/MIGRATION-PLAN.md index f848c35..2d1c3b0 100644 --- a/openspec/MIGRATION-PLAN.md +++ b/openspec/MIGRATION-PLAN.md @@ -12,22 +12,34 @@ ## 交接说明(handoff) -**进度**:Wave 1 完成(P6/P0/P2/P1 已归档)。剩 P3、P5、P7(Wave 2)、P4(Wave 3)。 -**下一个建议**:P3 `add-wallet-settlement`(P0 已归档,硬依赖解除)。 +**进度**:Wave 1 完成(P6/P0/P2/P1 已归档)+ P3 + P5 + P7 完成。只剩 P4(Wave 3)。 +**下一个建议**:P4 `add-mobile-h5`——P0/P3 软依赖已全部归档,可直接起步;这也是迁移计划的最后一个 change。 **工作流约定(用户明确要求)**:一次只实现一个 change,完成后停下来等人工 review;可以提议下一个,但不要自动连续实施,除非明确要求连续执行。 **实施要点**: -- 下一个可用迁移号:`0019`(0016 aftersales / 0017 freight / 0018 reviews 已占用)。各 proposal tasks.md 里的迁移号是提议时抢占的(P3 写 0018/0019、P5/P7 写 0016/0017),**实施时以实际顺序重编号**。 +- 下一个可用迁移号:`0024`(0019 wallet / 0020 settlement / 0021 merchant_applications / 0022 storefront seller link / 0023 membership+messaging 已占用)。各 proposal tasks.md 里的迁移号是提议时抢占的(P4 写 0016/0017),**实施时以实际顺序重编号**。 - 文档与索引已就位:改代码前先查 `docs/code_index/index.md`;`docs/backend-guidelines.md` 有并发/状态机模式与 PG 陷阱;`docs/design-guidelines.md` 管 UI;增删文件要同步索引。 -- 前端派发模式已验证:我(主线)做迁移+共享契约+Rust 模块+集成测试,然后按 app 并行派三个子任务(mall/shop-admin/admin)写页面,主线统一构建+smoke+归档。 +- 前端派发模式已验证:我(主线)做迁移+共享契约+Rust 模块+集成测试,然后按 app 并行派子任务写页面(P3 三个 app 并行;P5/P7 只有 mall+admin,shop-admin 无改动但要求照常构建),主线统一构建+smoke+归档。 +- 共享契约的 admin 方法放在 `api.admin.*` 下(去掉任务描述里的 `admin` 前缀),与既有命名一致;mall 的 `LIVE_PICKS` 只需挑客户侧方法。 +- 事件型副作用(消息、成长值)要与业务状态迁移放在**同一个事务**里,并靠部分唯一索引 + `ON CONFLICT DO NOTHING` 保证可重试;为此常需把原先只收 `&PgPool` 的 repo 辅助函数改成 `&mut PgConnection`(P7 改了 `maybe_mark_order_shipped` / `maybe_mark_order_completed`,后者返回 `bool` 表示本次是否真的完成,供调用方决定是否计提)。 **踩过的坑(别再踩)**: - `SUM(bigint)` 在 Postgres 返回 NUMERIC,解码 i64 前必须 `COALESCE(SUM(x),0)::bigint`(售后曾因此 500)。 -- sqlx 运行时解码:`cargo check` 绿不代表 SQL 对——加列要同步所有列清单常量与 INSERT 的列数/参数数。 +- sqlx 运行时解码:`cargo check` 绿不代表 SQL 对——加列要同步所有列清单常量与 INSERT 的列数/参数数;只被单模块读写的列(如 `orders.completed_at`)可不进 `models.rs` 行结构,避免牵动所有列清单。 +- **新增迁移文件后必须强制重编译**:`sqlx::migrate!` 在编译期把迁移列表嵌进二进制,仅新增 `.sql` 文件时 cargo 不一定重编,`cargo run` 会用旧列表“静默跳过”新迁移(P5 的 0022 就这样没生效)。改完跑 `touch apps/api/src/main.rs` 再起服务,并到 `_sqlx_migrations` 确认真应用了。 - 外币订单退款:客户无该币种账户会失败,用 `account::service::ensure_monetary_account` 懒建(已存在)。 -- 共享契约改动要同时改 interface 和 createApi 实现两处;mall 还要 mock 实现 + LIVE_PICKS 精确挑方法(漏挑会静默落回 mock)。 +- 共享契约改动要同时改 interface 和 createApi 实现两处;mall 还要 mock 实现 + LIVE_PICKS 精确挑方法(漏挑会静默落回 mock)。createApi/mock 里已有一批相对 ApiClient 的“多余键”(points/brands/shop profile),`nuxt build` 不做类型检查所以不报——别顺手去改共享契约清理。 +- `orders.updated_at` 不能当“完成时间”:退款会 bump 它;按周期归集订单必须用专用列(P3 加了 `completed_at`,在完成迁移的那条 UPDATE 里写入)。 +- 结算类周期归集:金额跨币种要经 `money::convert_minor` 折回基准币,别直接相加不同币种的最小单位。成长值同理:折回基准币后按基准币 exponent 截断成“整数单位”,再用整数除/乘,别用浮点。 +- 会员等级阈值是**全平台唯一**:共享测试库里 `member_levels` 会跨用例/跨轮次残留,凡是断言“最高等级/下一等级/没有等级”的用例都必须先清空该表(P7 的 `reset_levels`)或用“恰好等于自身阈值”的构造,否则随机碰撞导致 flaky。 +- 等级显示要**按当前阈值重新推导**,`users.level` 只作为“客户持有该等级”的标记(删除在用等级要据此拒绝);升级只允许单向(guarded update 比较阈值)。 +- “一用户一条有效记录”用**部分唯一索引**(`UNIQUE (user_id) WHERE status IN (...)`)+ service 先查一次给友好 409;事务内先 `FOR UPDATE` 锁行、再置备(建店铺/建账号)、最后做 guarded 状态翻转,任何一步失败整笔回滚。 +- 邮箱唯一性:为入驻审批新建店主账号时,申请人的联系邮箱可能已被注册;先查重、退化成 `local+owner@domain`,别直接 INSERT 撞唯一键。 +- mall 登录页的注册链接必须带上 `redirect`,否则“注册后回到已填表单”不成立(P5 修了 login→register 的 redirect 透传与 register 的跳转);表单要 `novalidate`,否则原生校验会拦掉 submit 事件、本地化内联报错永远不触发。 +- `openspec archive` 会写 `## Purpose` 占位(`TBD - created by archiving ...`),`openspec validate --all --strict` 会因此判 fail;归档后必须立刻把新 spec 的 Purpose 写成真句子(Wave 1 的 shipping/aftersale/reviews 也遗留了同样问题,P3 一并修掉)。 - dev server:`pnpm build` 后要 `rm -rf apps//.nuxt` 再 dev;dev 绑 IPv6 localhost;页面卡 Loading 无报错=旧 tab 拿旧 chunk hash,重启+开新 tab。 +- mall 页面用 `playwright-cli` 做登录时别用 `input` 的全局下标取字段(页头搜索框会先匹配),用 placeholder 精确定位;同理别用 `main form input`(VField 不一定包在 form 里),用组件提供的 `data-testid`。 - 测试库迁移校验和冲突(改了已应用的本地迁移):`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`(vmall_test)。 ## 依赖图 @@ -61,11 +73,11 @@ P2 freight ───────────┘(改 checkout/order totals, | P0 | `add-aftersale-refunds` | — | archived | 2026-09-23 | | P1 | `add-product-reviews` | — | archived | 2026-09-24 | | P2 | `add-freight-templates` | —(与 P0 串行) | archived | 2026-09-24 | -| P3 | `add-wallet-settlement` | P0 | proposed | — | +| P3 | `add-wallet-settlement` | P0 | archived | 2026-09-24 | | P4 | `add-mobile-h5` | P0、P3(软) | proposed | — | -| P5 | `add-merchant-onboarding` | — | proposed | — | +| P5 | `add-merchant-onboarding` | — | archived | 2026-09-24 | | P6 | `add-content-admin-ui` | — | archived | 2026-09-23 | -| P7 | `add-membership-messaging` | — | proposed | — | +| P7 | `add-membership-messaging` | — | archived | 2026-09-25 | 状态取值:`proposed` → `implementing` → `verified`(tasks 全勾 + 测试/构建/smoke 通过)→ `archived`。 diff --git a/openspec/changes/add-merchant-onboarding/.openspec.yaml b/openspec/changes/archive/2026-09-24-add-merchant-onboarding/.openspec.yaml similarity index 100% rename from openspec/changes/add-merchant-onboarding/.openspec.yaml rename to openspec/changes/archive/2026-09-24-add-merchant-onboarding/.openspec.yaml diff --git a/openspec/changes/add-merchant-onboarding/proposal.md b/openspec/changes/archive/2026-09-24-add-merchant-onboarding/proposal.md similarity index 100% rename from openspec/changes/add-merchant-onboarding/proposal.md rename to openspec/changes/archive/2026-09-24-add-merchant-onboarding/proposal.md diff --git a/openspec/changes/add-merchant-onboarding/specs/frontend-admin/spec.md b/openspec/changes/archive/2026-09-24-add-merchant-onboarding/specs/frontend-admin/spec.md similarity index 100% rename from openspec/changes/add-merchant-onboarding/specs/frontend-admin/spec.md rename to openspec/changes/archive/2026-09-24-add-merchant-onboarding/specs/frontend-admin/spec.md diff --git a/openspec/changes/add-merchant-onboarding/specs/frontend-mall/spec.md b/openspec/changes/archive/2026-09-24-add-merchant-onboarding/specs/frontend-mall/spec.md similarity index 100% rename from openspec/changes/add-merchant-onboarding/specs/frontend-mall/spec.md rename to openspec/changes/archive/2026-09-24-add-merchant-onboarding/specs/frontend-mall/spec.md diff --git a/openspec/changes/add-merchant-onboarding/specs/merchant-onboarding/spec.md b/openspec/changes/archive/2026-09-24-add-merchant-onboarding/specs/merchant-onboarding/spec.md similarity index 100% rename from openspec/changes/add-merchant-onboarding/specs/merchant-onboarding/spec.md rename to openspec/changes/archive/2026-09-24-add-merchant-onboarding/specs/merchant-onboarding/spec.md diff --git a/openspec/changes/add-merchant-onboarding/tasks.md b/openspec/changes/archive/2026-09-24-add-merchant-onboarding/tasks.md similarity index 68% rename from openspec/changes/add-merchant-onboarding/tasks.md rename to openspec/changes/archive/2026-09-24-add-merchant-onboarding/tasks.md index 600bc64..697174a 100644 --- a/openspec/changes/add-merchant-onboarding/tasks.md +++ b/openspec/changes/archive/2026-09-24-add-merchant-onboarding/tasks.md @@ -1,33 +1,33 @@ ## 1. Persistence and shared contract -- [ ] 1.1 Add migration `0017_merchant_applications.sql` (take the next free number if a sibling change claims it): `merchant_applications` with applicant `user_id` FK, `entity_type` enum (`personal`, `enterprise`), kind-specific entity and contact columns, `category_ids`, qualification URL columns (identity document, business license, extra materials as a JSONB URL array), `status` enum (`pending`, `approved`, `rejected`), `rejection_reason`, `reviewed_by`/`reviewed_at`, `created_shop_id` FK, and a partial unique index on `user_id` `WHERE status IN ('pending', 'approved')`. -- [ ] 1.2 Add shared discriminated application types (submit payloads for both entity kinds, application summary/detail with status and rejection reason, one-time approval credentials) and the `submitMerchantApplication`, `getMyMerchantApplications`, `adminListMerchantApplications`, `adminGetMerchantApplication`, `adminApproveMerchantApplication`, and `adminRejectMerchantApplication` methods to `@vmall/shared`. +- [x] 1.1 Add migration `0021_merchant_applications.sql` (renumbered from the proposed 0017; 0019/0020 were taken by wallet/settlement): `merchant_applications` with applicant `user_id` FK, `entity_type` enum (`personal`, `enterprise`), kind-specific entity and contact columns, `category_ids`, qualification URL columns (identity document, business license, extra materials as a JSONB URL array), `status` enum (`pending`, `approved`, `rejected`), `rejection_reason`, `reviewed_by`/`reviewed_at`, `created_shop_id` FK, and a partial unique index on `user_id` `WHERE status IN ('pending', 'approved')`. +- [x] 1.2 Add shared discriminated application types (submit payloads for both entity kinds, application summary/detail with status and rejection reason, one-time approval credentials) and the `submitMerchantApplication`, `getMyMerchantApplications`, `adminListMerchantApplications`, `adminGetMerchantApplication`, `adminApproveMerchantApplication`, and `adminRejectMerchantApplication` methods to `@vmall/shared`. ## 2. Backend service and behavioral tests -- [ ] 2.1 Implement `apps/api/src/modules/merchant_onboarding/` repository, service, DTO, handlers, and module registration: authenticated customer routes for submit and self status, and `platform_admin`-guarded admin routes for list, detail, approve, and reject. Services return `ApiResult`. -- [ ] 2.2 Enforce submission validation (kind-specific required fields, published category references, qualification URL shape) and duplicate rejection for users holding a `pending` or `approved` application, with the partial unique index as the concurrency backstop. -- [ ] 2.3 Implement guarded status transitions (`UPDATE ... WHERE status = 'pending'`) with mandatory rejection reason and a 409 conflict for already-reviewed applications. -- [ ] 2.4 Implement approval as a single transaction: create the shop, create a dedicated `shop_owner` account scoped to it, flip the application to `approved` with `created_shop_id`, and return the generated initial password exactly once; any failure rolls back the status flip and all provisioning. -- [ ] 2.5 Add isolated API integration coverage in `apps/api/tests/merchant_applications.rs` (reuse the `tests/common/mod.rs` fixtures) for duplicate submission, double-review conflicts, terminal-state immutability, rejection-reason enforcement, and provisioning rollback. +- [x] 2.1 Implement `apps/api/src/modules/merchant_onboarding/` repository, service, DTO, handlers, and module registration: authenticated customer routes for submit and self status, and `platform_admin`-guarded admin routes for list, detail, approve, and reject. Services return `ApiResult`. +- [x] 2.2 Enforce submission validation (kind-specific required fields, published category references, qualification URL shape) and duplicate rejection for users holding a `pending` or `approved` application, with the partial unique index as the concurrency backstop. +- [x] 2.3 Implement guarded status transitions (`UPDATE ... WHERE status = 'pending'`) with mandatory rejection reason and a 409 conflict for already-reviewed applications. +- [x] 2.4 Implement approval as a single transaction: create the shop, create a dedicated `shop_owner` account scoped to it, flip the application to `approved` with `created_shop_id`, and return the generated initial password exactly once; any failure rolls back the status flip and all provisioning. +- [x] 2.5 Add isolated API integration coverage in `apps/api/tests/merchant_applications.rs` (reuse the `tests/common/mod.rs` fixtures) for duplicate submission, double-review conflicts, terminal-state immutability, rejection-reason enforcement, and provisioning rollback. ## 3. Mall onboarding form -- [ ] 3.1 Implement the six merchant-onboarding client methods in `apps/mall/mock/api.ts` with deterministic per-session fixture state mirroring dedupe conflicts, review status, and approval/rejection outcomes. -- [ ] 3.2 Add the `merchant-onboarding` domain and exact shared-client method picks to Mall API selection (`apps/mall/plugins/api.ts` LIVE_PICKS) and enable it in the default live runtime configuration. -- [ ] 3.3 Build the multi-step onboarding page (`/merchant/join`): entity-kind step (personal 个人 / enterprise 企业) switching kind-specific fields, entity information, operating categories from the published category tree, contact details, and qualification URL input fields with no upload controls; allow anonymous filling but gate submission behind registration/sign-in with return to the completed form. -- [ ] 3.4 Build the application status page (`/merchant/status`) showing the applicant's latest application state, submitted entity kind, timestamps, and rejection reason, with a re-apply action after rejection. -- [ ] 3.5 Land the top-bar "商家入驻" (`sellerJoin`) entry and the footer "Become a Seller" link on the onboarding page instead of the stores directory, and add bilingual onboarding, form, and status strings through the existing Mall locale source without per-page hard-coded copy. +- [x] 3.1 Implement the six merchant-onboarding client methods in `apps/mall/mock/api.ts` with deterministic per-session fixture state mirroring dedupe conflicts, review status, and approval/rejection outcomes. +- [x] 3.2 Add the `merchant-onboarding` domain and exact shared-client method picks to Mall API selection (`apps/mall/plugins/api.ts` LIVE_PICKS) and enable it in the default live runtime configuration. +- [x] 3.3 Build the multi-step onboarding page (`/merchant/join`): entity-kind step (personal 个人 / enterprise 企业) switching kind-specific fields, entity information, operating categories from the published category tree, contact details, and qualification URL input fields with no upload controls; allow anonymous filling but gate submission behind registration/sign-in with return to the completed form. +- [x] 3.4 Build the application status page (`/merchant/status`) showing the applicant's latest application state, submitted entity kind, timestamps, and rejection reason, with a re-apply action after rejection. +- [x] 3.5 Land the top-bar "商家入驻" (`sellerJoin`) entry and the footer "Become a Seller" link on the onboarding page instead of the stores directory, and add bilingual onboarding, form, and status strings through the existing Mall locale source without per-page hard-coded copy. ## 4. Admin review console -- [ ] 4.1 Add a platform-admin merchant applications page in `apps/admin` with a status-filtered paginated table and a detail view of entity kind, entity information, operating categories, contact details, and qualification URL fields through the shared contract. -- [ ] 4.2 Add approve/reject actions: rejection requires a non-empty reason, approve shows a one-time initial-credentials dialog with explicit copy that the password cannot be retrieved again, and reviewed rows leave the pending queue immediately. -- [ ] 4.3 Register the merchant applications entry in admin navigation beside existing platform operations and add bilingual console strings through the shared locale source. +- [x] 4.1 Add a platform-admin merchant applications page in `apps/admin` with a status-filtered paginated table and a detail view of entity kind, entity information, operating categories, contact details, and qualification URL fields through the shared contract. +- [x] 4.2 Add approve/reject actions: rejection requires a non-empty reason, approve shows a one-time initial-credentials dialog with explicit copy that the password cannot be retrieved again, and reviewed rows leave the pending queue immediately. +- [x] 4.3 Register the merchant applications entry in admin navigation beside existing platform operations and add bilingual console strings through the shared locale source. ## 5. Verification and tracker cleanup -- [ ] 5.1 Add end-to-end API integration coverage in `apps/api/tests/merchant_applications.rs` (reuse the `tests/common/mod.rs` fixtures) for submit -> approve provisioning (the created `shop_owner` can log in and manage the linked shop) and submit -> reject -> re-apply, then run the focused merchant onboarding integration tests. -- [ ] 5.2 Run the API plus Mall and browser-smoke: anonymous fill -> sign-in gate -> submit, duplicate submission conflict, mall status page after approve and after reject, admin queue filtering and detail, approve one-time credentials display, and reject-reason enforcement. -- [ ] 5.3 Build all three frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/admin build`. -- [ ] 5.4 Update the README mock boundary for the merchant-onboarding adapter fallback, record any remaining fixture-driven onboarding surfaces in `docs/TBD-marketing.md`, check every OpenSpec task, and run `openspec change validate add-merchant-onboarding --strict` plus `openspec validate --all --strict`. +- [x] 5.1 Add end-to-end API integration coverage in `apps/api/tests/merchant_applications.rs` (reuse the `tests/common/mod.rs` fixtures) for submit -> approve provisioning (the created `shop_owner` can log in and manage the linked shop) and submit -> reject -> re-apply, then run the focused merchant onboarding integration tests. +- [x] 5.2 Run the API plus Mall and browser-smoke: anonymous fill -> sign-in gate -> submit, duplicate submission conflict, mall status page after approve and after reject, admin queue filtering and detail, approve one-time credentials display, and reject-reason enforcement. +- [x] 5.3 Build all three frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/admin build`. +- [x] 5.4 Update the README mock boundary for the merchant-onboarding adapter fallback, record any remaining fixture-driven onboarding surfaces in `docs/TBD-marketing.md`, check every OpenSpec task, and run `openspec change validate add-merchant-onboarding --strict` plus `openspec validate --all --strict`. diff --git a/openspec/changes/add-wallet-settlement/proposal.md b/openspec/changes/archive/2026-09-24-add-wallet-settlement/proposal.md similarity index 100% rename from openspec/changes/add-wallet-settlement/proposal.md rename to openspec/changes/archive/2026-09-24-add-wallet-settlement/proposal.md diff --git a/openspec/changes/add-wallet-settlement/specs/frontend-admin/spec.md b/openspec/changes/archive/2026-09-24-add-wallet-settlement/specs/frontend-admin/spec.md similarity index 100% rename from openspec/changes/add-wallet-settlement/specs/frontend-admin/spec.md rename to openspec/changes/archive/2026-09-24-add-wallet-settlement/specs/frontend-admin/spec.md diff --git a/openspec/changes/add-wallet-settlement/specs/frontend-mall/spec.md b/openspec/changes/archive/2026-09-24-add-wallet-settlement/specs/frontend-mall/spec.md similarity index 100% rename from openspec/changes/add-wallet-settlement/specs/frontend-mall/spec.md rename to openspec/changes/archive/2026-09-24-add-wallet-settlement/specs/frontend-mall/spec.md diff --git a/openspec/changes/add-wallet-settlement/specs/frontend-shop-admin/spec.md b/openspec/changes/archive/2026-09-24-add-wallet-settlement/specs/frontend-shop-admin/spec.md similarity index 100% rename from openspec/changes/add-wallet-settlement/specs/frontend-shop-admin/spec.md rename to openspec/changes/archive/2026-09-24-add-wallet-settlement/specs/frontend-shop-admin/spec.md diff --git a/openspec/changes/add-wallet-settlement/specs/settlement/spec.md b/openspec/changes/archive/2026-09-24-add-wallet-settlement/specs/settlement/spec.md similarity index 100% rename from openspec/changes/add-wallet-settlement/specs/settlement/spec.md rename to openspec/changes/archive/2026-09-24-add-wallet-settlement/specs/settlement/spec.md diff --git a/openspec/changes/add-wallet-settlement/specs/wallet/spec.md b/openspec/changes/archive/2026-09-24-add-wallet-settlement/specs/wallet/spec.md similarity index 100% rename from openspec/changes/add-wallet-settlement/specs/wallet/spec.md rename to openspec/changes/archive/2026-09-24-add-wallet-settlement/specs/wallet/spec.md diff --git a/openspec/changes/add-wallet-settlement/tasks.md b/openspec/changes/archive/2026-09-24-add-wallet-settlement/tasks.md similarity index 74% rename from openspec/changes/add-wallet-settlement/tasks.md rename to openspec/changes/archive/2026-09-24-add-wallet-settlement/tasks.md index 0282cf6..b8c6468 100644 --- a/openspec/changes/add-wallet-settlement/tasks.md +++ b/openspec/changes/archive/2026-09-24-add-wallet-settlement/tasks.md @@ -1,38 +1,38 @@ ## 1. Persistence and shared contract -- [ ] 1.1 Add migration `0018_wallet.sql` adapting tigshop's `user_recharge_order` and `user_withdraw_apply`: `wallet_recharges` (user, currency, amount_minor BIGINT, status, timestamps) and `wallet_withdrawals` (user, currency, amount_minor BIGINT, account_details JSONB, status pending/approved/rejected with CHECK, reviewing admin, reviewed_at, review_note, timestamps) with foreign keys, non-negative amount CHECKs, and customer-facing indexes. (Migration numbers 0016/0017 are taken by parallel changes.) -- [ ] 1.2 Add migration `0019_settlement.sql` adapting tigshop's `vendor_settlement_order`: `settlement_statements` (shop, period_kind week/month, period_start, period_end, order_count, gross_minor, refund_minor, commission_rate_bps, commission_minor, payable_minor, status pending/confirmed with CHECK, generated_by, confirmed_by, confirmed_at, timestamps) with a unique index on (shop_id, period_kind, period_start), plus a platform-level settings row for `settlement.commission_rate_bps`. -- [ ] 1.3 Add `@vmall/shared` wallet types and customer-client methods: `getWallet` (available/frozen minor with currency), `listWalletEntries` (paged signed ledger entries), `rechargeWallet` (demo), `applyWithdrawal`, and `listMyWithdrawals`. -- [ ] 1.4 Add `@vmall/shared` settlement and review methods: admin client `listWithdrawalApplications`, `reviewWithdrawal`, `getCommissionRate`, `setCommissionRate`, `listSettlementStatements`, `getSettlementStatement`, `generateSettlementStatement`, `confirmSettlementStatement`; shop client `listShopSettlementStatements`, `getShopSettlementStatement`, `generateShopSettlementStatement`. All money fields are i64 minor units; no floating-point amounts cross the contract. +- [x] 1.1 Add migration `0019_wallet.sql` adapting tigshop's `user_recharge_order` and `user_withdraw_apply`: `wallet_recharges` (user, currency, amount_minor BIGINT, status, timestamps) and `wallet_withdrawals` (user, currency, amount_minor BIGINT, account_details JSONB, status pending/approved/rejected with CHECK, reviewing admin, reviewed_at, review_note, timestamps) with foreign keys, non-negative amount CHECKs, and customer-facing indexes. (Renumbered from the proposed 0018: 0016-0018 were taken by aftersales/freight/reviews.) +- [x] 1.2 Add migration `0020_settlement.sql` adapting tigshop's `vendor_settlement_order`: `settlement_statements` (shop, period_kind week/month, period_start, period_end, order_count, gross_minor, refund_minor, commission_rate_bps, commission_minor, payable_minor, status pending/confirmed with CHECK, generated_by, confirmed_by, confirmed_at, timestamps) with a unique index on (shop_id, period_kind, period_start), plus a platform-level settings row for `settlement.commission_rate_bps`. Also adds `orders.completed_at` (settlement attributes an order to the period it was confirmed received; `refunds` bump `updated_at`) and sets it in the existing completion transition. +- [x] 1.3 Add `@vmall/shared` wallet types and customer-client methods: `getWallet` (available/frozen minor with currency), `listWalletEntries` (paged signed ledger entries), `rechargeWallet` (demo), `applyWithdrawal`, and `listMyWithdrawals`. +- [x] 1.4 Add `@vmall/shared` settlement and review methods: admin client `listWithdrawalApplications`, `reviewWithdrawal`, `getCommissionRate`, `setCommissionRate`, `listSettlementStatements`, `getSettlementStatement`, `generateSettlementStatement`, `confirmSettlementStatement`; shop client `listShopSettlementStatements`, `getShopSettlementStatement`, `generateShopSettlementStatement`. All money fields are i64 minor units; no floating-point amounts cross the contract. ## 2. Wallet backend -- [ ] 2.1 Implement `apps/api/src/modules/wallet/` (repo, service returning `ApiResult`, DTOs, handlers, module registration) with user-scoped `/wallet` routes and `/admin/wallet/withdrawals` review routes declaring the platform-admin role. -- [ ] 2.2 Implement demo recharge: one transaction records a `wallet_recharges` row and credits the available account through the existing `customer-accounts` primitives with a `wallet_recharge` ledger entry; the DTO carries an explicit demo marker. -- [ ] 2.3 Implement the withdrawal lifecycle: application freezes funds via guarded `UPDATE ... WHERE balance_minor >= $amount` moving available to frozen with paired ledger entries; admin approve deducts frozen balance with a ledger entry; admin reject returns frozen balance to available with a ledger entry; each review is guarded by `UPDATE ... WHERE status = 'pending'`, returns 409 on repeat, and records reviewer, timestamp, and note. -- [ ] 2.4 Implement paginated fund-entry listing mapped to `customer_account_entries` for the caller's monetary accounts (newest first; signed delta, resulting balance, reason, reference) with ownership filtering. -- [ ] 2.5 Add `apps/api/tests/wallet.rs` covering concurrent withdrawal overdraw, insufficient-balance no-op, double-review 409, reject unfreeze, approve frozen deduction, recharge ledger pairing, and entry pagination isolation, reusing the `tests/common/mod.rs` fixtures. +- [x] 2.1 Implement `apps/api/src/modules/wallet/` (repo, service returning `ApiResult`, DTOs, handlers, module registration) with user-scoped `/wallet` routes and `/admin/wallet/withdrawals` review routes declaring the platform-admin role. +- [x] 2.2 Implement demo recharge: one transaction records a `wallet_recharges` row and credits the available account through the existing `customer-accounts` primitives with a `wallet_recharge` ledger entry; the DTO carries an explicit demo marker. +- [x] 2.3 Implement the withdrawal lifecycle: application freezes funds via guarded `UPDATE ... WHERE balance_minor >= $amount` moving available to frozen with paired ledger entries; admin approve deducts frozen balance with a ledger entry; admin reject returns frozen balance to available with a ledger entry; each review is guarded by `UPDATE ... WHERE status = 'pending'`, returns 409 on repeat, and records reviewer, timestamp, and note. +- [x] 2.4 Implement paginated fund-entry listing mapped to `customer_account_entries` for the caller's monetary accounts (newest first; signed delta, resulting balance, reason, reference) with ownership filtering. +- [x] 2.5 Add `apps/api/tests/wallet.rs` covering concurrent withdrawal overdraw, insufficient-balance no-op, double-review 409, reject unfreeze, approve frozen deduction, recharge ledger pairing, and entry pagination isolation, reusing the `tests/common/mod.rs` fixtures. ## 3. Settlement backend -- [ ] 3.1 Implement `apps/api/src/modules/settlement/` (repo, service returning `ApiResult`, DTOs, handlers, module registration) with `/shop/settlement/*` routes scoped through `AuthUser::own_shop` and `/admin/settlement/*` routes declaring the platform-admin role. -- [ ] 3.2 Implement idempotent statement generation for a shop and closed week/month period: snapshot order count and gross totals of confirmed-received orders in the period, deduct completed refunds (`aftersales` rows with status `refunded`, per-order totals maintained by `add-aftersale-refunds` — if developed in parallel with P0, merge the P0 `aftersales` migration first), apply the platform commission rate in integer basis points with integer minor-unit arithmetic (payable = gross − refunds − commission), and return the existing statement untouched on repeat generation. -- [ ] 3.3 Implement statement listing and detail with per-order and refund breakdown, with merchant routes restricted to the own shop and platform-admin routes seeing every shop. -- [ ] 3.4 Implement `pending -> confirmed` payout confirmation: guarded `UPDATE ... WHERE status = 'pending'` (409 on repeat) crediting the payable amount to the shop owner's available account with exactly one ledger entry referencing the statement. -- [ ] 3.5 Implement platform commission-rate configuration (integer basis points), applied only to statements generated after a change; generated statements keep their snapshot. -- [ ] 3.6 Add `apps/api/tests/settlement.rs` covering generation idempotency and period uniqueness, refund deduction, commission snapshot immutability after a rate change, double-confirmation 409 with exactly one payout ledger entry, and cross-shop isolation, reusing the `tests/common/mod.rs` fixtures. +- [x] 3.1 Implement `apps/api/src/modules/settlement/` (repo, service returning `ApiResult`, DTOs, handlers, module registration) with `/shop/settlement/*` routes scoped through `AuthUser::own_shop` and `/admin/settlement/*` routes declaring the platform-admin role. +- [x] 3.2 Implement idempotent statement generation for a shop and closed week/month period: snapshot order count and gross totals of confirmed-received orders in the period, deduct completed refunds (`aftersales` rows with status `refunded`, per-order totals maintained by `add-aftersale-refunds` — if developed in parallel with P0, merge the P0 `aftersales` migration first), apply the platform commission rate in integer basis points with integer minor-unit arithmetic (payable = gross − refunds − commission), and return the existing statement untouched on repeat generation. +- [x] 3.3 Implement statement listing and detail with per-order and refund breakdown, with merchant routes restricted to the own shop and platform-admin routes seeing every shop. +- [x] 3.4 Implement `pending -> confirmed` payout confirmation: guarded `UPDATE ... WHERE status = 'pending'` (409 on repeat) crediting the payable amount to the shop owner's available account with exactly one ledger entry referencing the statement. +- [x] 3.5 Implement platform commission-rate configuration (integer basis points), applied only to statements generated after a change; generated statements keep their snapshot. +- [x] 3.6 Add `apps/api/tests/settlement.rs` covering generation idempotency and period uniqueness, refund deduction, commission snapshot immutability after a rate change, double-confirmation 409 with exactly one payout ledger entry, and cross-shop isolation, reusing the `tests/common/mod.rs` fixtures. ## 4. Frontend surfaces -- [ ] 4.1 Add the mall buyer-center wallet page (available/frozen balance, paginated fund entries, clearly labeled demo recharge, withdrawal request and history) through `@vmall/shared`, with bilingual strings in the existing Mall locale source. -- [ ] 4.2 Add the `wallet` domain to the Mall fixed-data adapter (`apps/mall/mock/api.ts`) with matching behavior, and wire `wallet` into the Mall API selection `LIVE_PICKS` and default live domains (`apps/mall/plugins/api.ts`). -- [ ] 4.3 Add admin pages for withdrawal review (approve/reject with outcome and 409 feedback), commission-rate configuration, and settlement statement list/detail with manual generation and one-time payout confirmation. -- [ ] 4.4 Add shop-admin pages for own-shop settlement statement list/detail with manual generation and shop-account summary plus withdrawal request/history. -- [ ] 4.5 Keep all three frontends on the `@vmall/shared` contract only (no page-level fetch of wallet/settlement endpoints) and render page chrome with `@vmall/ui` primitives. +- [x] 4.1 Add the mall buyer-center wallet page (available/frozen balance, paginated fund entries, clearly labeled demo recharge, withdrawal request and history) through `@vmall/shared`, with bilingual strings in the existing Mall locale source. +- [x] 4.2 Add the `wallet` domain to the Mall fixed-data adapter (`apps/mall/mock/api.ts`) with matching behavior, and wire `wallet` into the Mall API selection `LIVE_PICKS` and default live domains (`apps/mall/plugins/api.ts`). +- [x] 4.3 Add admin pages for withdrawal review (approve/reject with outcome and 409 feedback), commission-rate configuration, and settlement statement list/detail with manual generation and one-time payout confirmation. +- [x] 4.4 Add shop-admin pages for own-shop settlement statement list/detail with manual generation and shop-account summary plus withdrawal request/history. +- [x] 4.5 Keep all three frontends on the `@vmall/shared` contract only (no page-level fetch of wallet/settlement endpoints) and render page chrome with `@vmall/ui` primitives. ## 5. Verification and tracker cleanup -- [ ] 5.1 Run the wallet and settlement integration tests in `apps/api/tests/wallet.rs` and `apps/api/tests/settlement.rs` (reusing the `tests/common/mod.rs` fixtures), then `cargo test -p vmall-api` to prove the suite stays green against the shared test database. -- [ ] 5.2 Build all three frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/admin build`, and `pnpm --filter @vmall/shop-admin build`. -- [ ] 5.3 Browser-smoke the API plus frontends: demo recharge updates the wallet; withdrawal freeze, admin reject unfreeze, and admin approve deduction round trip; wallet entries pagination; commission-rate configuration; idempotent statement generation with refund deduction; one-time payout confirmation writing one ledger entry; and shop-admin own-shop statement isolation. -- [ ] 5.4 Check every OpenSpec task, then run `openspec change validate add-wallet-settlement --strict` and `openspec validate --all --strict`. +- [x] 5.1 Run the wallet and settlement integration tests in `apps/api/tests/wallet.rs` and `apps/api/tests/settlement.rs` (reusing the `tests/common/mod.rs` fixtures), then `cargo test -p vmall-api` to prove the suite stays green against the shared test database. +- [x] 5.2 Build all three frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/admin build`, and `pnpm --filter @vmall/shop-admin build`. +- [x] 5.3 Browser-smoke the API plus frontends: demo recharge updates the wallet; withdrawal freeze, admin reject unfreeze, and admin approve deduction round trip; wallet entries pagination; commission-rate configuration; idempotent statement generation with refund deduction; one-time payout confirmation writing one ledger entry; and shop-admin own-shop statement isolation. +- [x] 5.4 Check every OpenSpec task, then run `openspec change validate add-wallet-settlement --strict` and `openspec validate --all --strict`. diff --git a/openspec/changes/add-membership-messaging/proposal.md b/openspec/changes/archive/2026-09-25-add-membership-messaging/proposal.md similarity index 100% rename from openspec/changes/add-membership-messaging/proposal.md rename to openspec/changes/archive/2026-09-25-add-membership-messaging/proposal.md diff --git a/openspec/changes/add-membership-messaging/specs/frontend-admin/spec.md b/openspec/changes/archive/2026-09-25-add-membership-messaging/specs/frontend-admin/spec.md similarity index 100% rename from openspec/changes/add-membership-messaging/specs/frontend-admin/spec.md rename to openspec/changes/archive/2026-09-25-add-membership-messaging/specs/frontend-admin/spec.md diff --git a/openspec/changes/add-membership-messaging/specs/frontend-mall/spec.md b/openspec/changes/archive/2026-09-25-add-membership-messaging/specs/frontend-mall/spec.md similarity index 100% rename from openspec/changes/add-membership-messaging/specs/frontend-mall/spec.md rename to openspec/changes/archive/2026-09-25-add-membership-messaging/specs/frontend-mall/spec.md diff --git a/openspec/changes/add-membership-messaging/specs/membership/spec.md b/openspec/changes/archive/2026-09-25-add-membership-messaging/specs/membership/spec.md similarity index 100% rename from openspec/changes/add-membership-messaging/specs/membership/spec.md rename to openspec/changes/archive/2026-09-25-add-membership-messaging/specs/membership/spec.md diff --git a/openspec/changes/add-membership-messaging/specs/messaging/spec.md b/openspec/changes/archive/2026-09-25-add-membership-messaging/specs/messaging/spec.md similarity index 100% rename from openspec/changes/add-membership-messaging/specs/messaging/spec.md rename to openspec/changes/archive/2026-09-25-add-membership-messaging/specs/messaging/spec.md diff --git a/openspec/changes/add-membership-messaging/tasks.md b/openspec/changes/archive/2026-09-25-add-membership-messaging/tasks.md similarity index 67% rename from openspec/changes/add-membership-messaging/tasks.md rename to openspec/changes/archive/2026-09-25-add-membership-messaging/tasks.md index 6b436d7..559e73f 100644 --- a/openspec/changes/add-membership-messaging/tasks.md +++ b/openspec/changes/archive/2026-09-25-add-membership-messaging/tasks.md @@ -1,34 +1,34 @@ ## 1. Persistence and shared contract -- [ ] 1.1 Add migration `0016_membership_messaging.sql`: `member_levels` (bilingual `name` JSONB `{en, zh}`, `icon`, unique integer `growth_threshold`, bilingual `benefits` JSONB `{en, zh}`), `growth_logs` (append-only `user_id`, `delta`, running `growth_total`, `reason`, `reference_type`/`reference_id` order reference with a partial unique index per user and reference), `messages` (`user_id`, `kind`, bilingual `title`/`body` JSONB `{en, zh}`, `reference_type`/`reference_id`, `status` `unread`/`read`, `deleted_at` soft delete, partial unique index per user/kind/reference), a `users.level` column referencing `member_levels`, and listing/counting indexes. -- [ ] 1.2 Add shared types (`MemberLevel`, `MemberLevelInput`, `MembershipStatus`, `GrowthLogEntry`, `Message`, `MessageListQuery`) and `@vmall/shared` methods `getMembership`, `listGrowthLogs`, `listMessages`, `markMessageRead`, `markAllMessagesRead`, `deleteMessage`, `getUnreadCount`, and `admin.listMemberLevels`, `admin.createMemberLevel`, `admin.updateMemberLevel`, `admin.deleteMemberLevel` with all amounts and growth values as integers and i18n content as `{en, zh}` JSONB. -- [ ] 1.3 Implement `apps/api/src/modules/membership/` repository, service, DTO, handlers, and module registration: admin-role-gated member-level CRUD routes and customer-scoped membership status and growth-ledger routes, services returning `ApiResult`. -- [ ] 1.4 Implement `apps/api/src/modules/messaging/` repository, service, DTO, handlers, and module registration: customer-scoped message list (paginated, optional unread-only filter), mark-read, mark-all-read, soft delete, and unread-count routes returning `ApiResult`. +- [x] 1.1 Add migration `0023_membership_messaging.sql` (renumbered from the proposed 0016; 0019-0022 were taken): `member_levels` (bilingual `name` JSONB `{en, zh}`, `icon`, unique integer `growth_threshold`, bilingual `benefits` JSONB `{en, zh}`), `growth_logs` (append-only `user_id`, `delta`, running `growth_total`, `reason`, `reference_type`/`reference_id` order reference with a partial unique index per user and reference), `messages` (`user_id`, `kind`, bilingual `title`/`body` JSONB `{en, zh}`, `reference_type`/`reference_id`, `status` `unread`/`read`, `deleted_at` soft delete, partial unique index per user/kind/reference), a `users.level` column referencing `member_levels`, and listing/counting indexes. +- [x] 1.2 Add shared types (`MemberLevel`, `MemberLevelInput`, `MembershipStatus`, `GrowthLogEntry`, `Message`, `MessageListQuery`) and `@vmall/shared` methods `getMembership`, `listGrowthLogs`, `listMessages`, `markMessageRead`, `markAllMessagesRead`, `deleteMessage`, `getUnreadCount`, and `admin.listMemberLevels`, `admin.createMemberLevel`, `admin.updateMemberLevel`, `admin.deleteMemberLevel` with all amounts and growth values as integers and i18n content as `{en, zh}` JSONB. +- [x] 1.3 Implement `apps/api/src/modules/membership/` repository, service, DTO, handlers, and module registration: admin-role-gated member-level CRUD routes and customer-scoped membership status and growth-ledger routes, services returning `ApiResult`. +- [x] 1.4 Implement `apps/api/src/modules/messaging/` repository, service, DTO, handlers, and module registration: customer-scoped message list (paginated, optional unread-only filter), mark-read, mark-all-read, soft delete, and unread-count routes returning `ApiResult`. ## 2. Services and behavioral tests -- [ ] 2.1 Implement growth accrual on order completion (customer confirms receipt): convert the order's realized paid amount to the base currency with integer minor-unit arithmetic (truncated whole units, no floating point), append exactly one ledger entry per order, and update `users.level` in the same transaction with a guarded `UPDATE ... WHERE` that only moves the customer to a strictly higher-threshold level. -- [ ] 2.2 Wire idempotent message emission into the order, fulfillment, and refund (after-sale) transitions: order payment success emits `order_paid`, shipment dispatch emits `order_shipped`, and refund completion emits `refund_completed`, each a guarded insert keyed by user, kind, and reference with bilingual `{en, zh}` title and body naming the order. -- [ ] 2.3 Add behavioral coverage in `apps/api/tests/membership.rs` and `apps/api/tests/messaging.rs` (fixtures from `tests/common/mod.rs`) for upgrade boundaries: growth exactly at a threshold upgrades, growth below every threshold holds no level, one accrual jumping two thresholds lands on the highest qualifying level, and repeated completion events accrue and upgrade exactly once. -- [ ] 2.4 Extend the behavioral coverage for event triggers and read semantics: each of the three events creates exactly one correctly referenced message, re-run handlers do not duplicate, mark-read touches only `unread` rows (idempotent single and all-read), soft delete is idempotent and excluded from lists and unread counts, and cross-user message access fails. +- [x] 2.1 Implement growth accrual on order completion (customer confirms receipt): convert the order's realized paid amount to the base currency with integer minor-unit arithmetic (truncated whole units, no floating point), append exactly one ledger entry per order, and update `users.level` in the same transaction with a guarded `UPDATE ... WHERE` that only moves the customer to a strictly higher-threshold level. +- [x] 2.2 Wire idempotent message emission into the order, fulfillment, and refund (after-sale) transitions: order payment success emits `order_paid`, shipment dispatch emits `order_shipped`, and refund completion emits `refund_completed`, each a guarded insert keyed by user, kind, and reference with bilingual `{en, zh}` title and body naming the order. +- [x] 2.3 Add behavioral coverage in `apps/api/tests/membership.rs` and `apps/api/tests/messaging.rs` (fixtures from `tests/common/mod.rs`) for upgrade boundaries: growth exactly at a threshold upgrades, growth below every threshold holds no level, one accrual jumping two thresholds lands on the highest qualifying level, and repeated completion events accrue and upgrade exactly once. +- [x] 2.4 Extend the behavioral coverage for event triggers and read semantics: each of the three events creates exactly one correctly referenced message, re-run handlers do not duplicate, mark-read touches only `unread` rows (idempotent single and all-read), soft delete is idempotent and excluded from lists and unread counts, and cross-user message access fails. ## 3. Mall member and message surfaces -- [ ] 3.1 Implement the `getMembership`, `listGrowthLogs`, `listMessages`, `markMessageRead`, `markAllMessagesRead`, `deleteMessage`, and `getUnreadCount` methods in `apps/mall/mock/api.ts` with deterministic per-session fixture state and the same idempotent read/delete semantics. -- [ ] 3.2 Add the `membership` and `messaging` domains with their exact shared-client method picks to the Mall API selection and enable them in the default live runtime configuration. -- [ ] 3.3 Add or adjust bilingual level, growth, message, badge, and failure strings through the existing Mall locale source without per-page hard-coded copy. -- [ ] 3.4 Build `apps/mall/pages/user/membership.vue`: current level name/icon/benefits, growth total, progress to the next threshold, and paginated growth history from the shared contract, with fixed-adapter parity and no fixture imports. -- [ ] 3.5 Build `apps/mall/pages/user/messages.vue`: paginated message list with unread-only filter, open/mark-read, mark-all-read, and delete actions that persist through the API and refresh list and counts. -- [ ] 3.6 Add the top-bar unread badge on the shell message entry: unread count on page entry and after read/mark-all/delete actions, absent for anonymous shoppers, linking to the message center. +- [x] 3.1 Implement the `getMembership`, `listGrowthLogs`, `listMessages`, `markMessageRead`, `markAllMessagesRead`, `deleteMessage`, and `getUnreadCount` methods in `apps/mall/mock/api.ts` with deterministic per-session fixture state and the same idempotent read/delete semantics. +- [x] 3.2 Add the `membership` and `messaging` domains with their exact shared-client method picks to the Mall API selection and enable them in the default live runtime configuration. +- [x] 3.3 Add or adjust bilingual level, growth, message, badge, and failure strings through the existing Mall locale source without per-page hard-coded copy. +- [x] 3.4 Build `apps/mall/pages/user/membership.vue`: current level name/icon/benefits, growth total, progress to the next threshold, and paginated growth history from the shared contract, with fixed-adapter parity and no fixture imports. +- [x] 3.5 Build `apps/mall/pages/user/messages.vue`: paginated message list with unread-only filter, open/mark-read, mark-all-read, and delete actions that persist through the API and refresh list and counts. +- [x] 3.6 Add the top-bar unread badge on the shell message entry: unread count on page entry and after read/mark-all/delete actions, absent for anonymous shoppers, linking to the message center. ## 4. Admin member-level management -- [ ] 4.1 Build `apps/admin/pages/member-levels.vue`: levels in threshold order with create/edit/delete forms over bilingual name, icon, growth threshold, and benefits through the shared `admin` level methods, surfacing the delete rejection for levels in use. -- [ ] 4.2 Register the member-levels entry in the authenticated admin console navigation beside existing platform operations. +- [x] 4.1 Build `apps/admin/pages/member-levels.vue`: levels in threshold order with create/edit/delete forms over bilingual name, icon, growth threshold, and benefits through the shared `admin` level methods, surfacing the delete rejection for levels in use. +- [x] 4.2 Register the member-levels entry in the authenticated admin console navigation beside existing platform operations. ## 5. Verification -- [ ] 5.1 Run the `apps/api/tests/` integration suites with the `tests/common/mod.rs` fixtures — the new `membership.rs` and `messaging.rs` plus the affected `orders.rs`, `order_service.rs`, and `points.rs` suites — proving upgrade boundaries, event triggers, and read semantics against the shared test database. -- [ ] 5.2 Browser-smoke the running API, Mall, and admin console: confirm receipt upgrades the level and appends the growth entry, the three system events land one message each, message-center read/mark-all/delete update lists and counts, the top-bar badge tracks unread count, and admin level CRUD with in-use delete rejection works end to end. -- [ ] 5.3 Build the affected frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/admin build`, and `pnpm --filter @vmall/shop-admin build`. -- [ ] 5.4 Run `openspec change validate add-membership-messaging --strict` and `openspec validate --all --strict` and fix findings until both pass. \ No newline at end of file +- [x] 5.1 Run the `apps/api/tests/` integration suites with the `tests/common/mod.rs` fixtures — the new `membership.rs` and `messaging.rs` plus the affected `orders.rs`, `order_service.rs`, and `points.rs` suites — proving upgrade boundaries, event triggers, and read semantics against the shared test database. +- [x] 5.2 Browser-smoke the running API, Mall, and admin console: confirm receipt upgrades the level and appends the growth entry, the three system events land one message each, message-center read/mark-all/delete update lists and counts, the top-bar badge tracks unread count, and admin level CRUD with in-use delete rejection works end to end. +- [x] 5.3 Build the affected frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/admin build`, and `pnpm --filter @vmall/shop-admin build`. +- [x] 5.4 Run `openspec change validate add-membership-messaging --strict` and `openspec validate --all --strict` and fix findings until both pass. \ No newline at end of file diff --git a/openspec/specs/aftersale/spec.md b/openspec/specs/aftersale/spec.md index bb95fda..7dc123b 100644 --- a/openspec/specs/aftersale/spec.md +++ b/openspec/specs/aftersale/spec.md @@ -1,7 +1,10 @@ # aftersale Specification ## Purpose -TBD - created by archiving change add-aftersale-refunds. Update Purpose after archive. +Post-purchase after-sale handling: per-line refund-only and return-refund +applications, merchant processing, bilateral messages, and platform +arbitration. A completed refund updates the order's refund total and credits +the customer's available account with a ledger entry in one transaction. ## Requirements ### Requirement: Per-line aftersale application An authenticated customer SHALL apply for after-sale against one owned order item from an order that is paid or shipped and within the configured after-sale window. The application SHALL choose exactly `refund_only` or `return_refund`, include a localized reason, an integer minor-unit refund amount greater than zero and no greater than the line's remaining refundable amount, and zero or more evidence image URLs. The API SHALL reject unavailable, already fully refunded, out-of-window, or cross-customer items. diff --git a/openspec/specs/frontend-admin/spec.md b/openspec/specs/frontend-admin/spec.md index 753be79..396ae6a 100644 --- a/openspec/specs/frontend-admin/spec.md +++ b/openspec/specs/frontend-admin/spec.md @@ -2,7 +2,9 @@ ## Purpose The platform console for managing users, shops and currencies. + ## Requirements + ### Requirement: Platform user and shop management Platform admins SHALL assign user roles (with shop scope), create shops, and suspend/activate shops. Suspended shops' products MUST NOT be purchasable (enforced by API, reflected in UI). @@ -102,3 +104,66 @@ Platform admins SHALL review a paginated moderation list of all reviews with the - **WHEN** an authenticated platform admin opens the admin console - **THEN** a review moderation entry is reachable from the console nav +### Requirement: Withdrawal review and commission configuration +The platform console SHALL list withdrawal applications and approve or reject each pending application through the shared API contract, surfacing review outcomes and conflicts (409 on a repeated review) without silent failure. It SHALL expose the platform commission rate as an integer basis-point setting that admins can read and update. + +#### Scenario: reject returns funds +- **WHEN** an admin rejects a pending withdrawal application +- **THEN** the console shows the rejected status and the buyer's wallet reflects the amount back in available balance + +#### Scenario: approve deducts frozen funds +- **WHEN** an admin approves a pending withdrawal application +- **THEN** the console shows the approved status and the frozen balance decreases by the requested amount + +#### Scenario: set commission rate +- **WHEN** an admin updates the commission rate +- **THEN** settlement statements generated afterwards snapshot the new rate + +### Requirement: Settlement statement confirmation +The platform console SHALL list settlement statements across shops with their amount snapshots and statuses, allow manual generation for a shop and closed period, and confirm payout exactly once per statement through the shared API contract, surfacing a 409 on repeated confirmation. + +#### Scenario: confirm payout +- **WHEN** an admin confirms a pending statement +- **THEN** the console shows the statement confirmed and the shop owner's ledger records the payout + +#### Scenario: manual generation is idempotent +- **WHEN** an admin generates a statement for a shop and period that already has one +- **THEN** the existing statement is shown and no duplicate is created + +### Requirement: Merchant application review console +Platform admins SHALL review merchant onboarding applications in a dedicated console entry beside existing platform operations: a status-filtered paginated list and a detail view showing entity kind, entity information, operating categories, contact details, and qualification URL fields. Approving SHALL provision the shop and `shop_owner` account and display the one-time initial credentials exactly once, with copy stating the password cannot be retrieved again. Rejecting SHALL require a reason. Reviewed applications SHALL leave the pending queue immediately. + +#### Scenario: review an enterprise application +- **WHEN** a platform admin opens a pending enterprise application +- **THEN** the company entity data, operating categories, contact details, and qualification URLs are visible for review + +#### Scenario: approve shows one-time credentials +- **WHEN** a platform admin approves an application +- **THEN** the created shop owner's initial credentials are shown once and the application moves to the approved list + +#### Scenario: reject requires a reason +- **WHEN** a platform admin attempts to reject without entering a reason +- **THEN** the action is blocked until a non-empty reason is provided + +#### Scenario: entry beside platform operations +- **WHEN** an authenticated platform admin opens the admin console +- **THEN** merchant application review is reachable from the console navigation + +### Requirement: Member level management +Platform admins SHALL manage member levels from a dedicated admin console page through the shared API contract: levels listed in growth-threshold order, and create, edit, and delete actions over name, icon, growth threshold, and benefits with both locales editable. Deleting a level in use SHALL surface the API rejection instead of silently succeeding, and member-level management SHALL be reachable from the authenticated console navigation beside existing platform operations. + +#### Scenario: manage a member level +- **WHEN** a platform admin creates a level with bilingual name and benefits, an icon, and a growth threshold +- **THEN** it appears in threshold order and is available for automatic leveling + +#### Scenario: edit a growth threshold +- **WHEN** a platform admin changes a level's growth threshold to a unique value +- **THEN** the level persists with the new threshold and the bilingual content unchanged + +#### Scenario: deleting a level in use fails visibly +- **WHEN** a platform admin deletes a level that customers hold +- **THEN** the console surfaces the rejection and the level and its members remain unchanged + +#### Scenario: level management appears in admin navigation +- **WHEN** an authenticated platform admin opens the admin console +- **THEN** member-level management is reachable from the console nav diff --git a/openspec/specs/frontend-mall/spec.md b/openspec/specs/frontend-mall/spec.md index 4724cc2..b8ab270 100644 --- a/openspec/specs/frontend-mall/spec.md +++ b/openspec/specs/frontend-mall/spec.md @@ -2,7 +2,9 @@ ## Purpose The buyer-facing storefront: shell, home page, discovery, shopping and transaction flows, and the buyer center. + ## Requirements + ### Requirement: Localized storefront The mall SHALL render every UI string and all catalog/store/marketing mock content in en or zh from one switcher, defaulting to en. Switching locale SHALL update the desktop shell and current page without a full reload. @@ -304,3 +306,96 @@ The buyer center SHALL expose a "pending review" entry counting completed order - **WHEN** the reviews domain is configured to fixed data - **THEN** the review area, pending-review entry, and submission flows behave deterministically through the same shared client methods +### Requirement: Buyer wallet surface +The mall SHALL render a buyer-center wallet page driven by the selected API adapter through `@vmall/shared`: available and frozen balance with currency, paginated fund entries, a clearly labeled demo recharge form, and a withdrawal request form. Balances and entries SHALL reflect backend state after each action rather than local-only state. The wallet SHALL be a mall API domain with fixed-adapter fallback methods and `LIVE_PICKS` wiring following the established per-domain adapter pattern. + +#### Scenario: wallet page loads live state +- **WHEN** a signed-in buyer opens the wallet page +- **THEN** balances and the first page of fund entries render from the shared wallet contract + +#### Scenario: demo recharge updates balance +- **WHEN** the buyer submits a demo recharge +- **THEN** the visible available balance reflects the credit without a reload and the form is visibly marked simulated + +#### Scenario: withdrawal freezes visibly +- **WHEN** the buyer submits a withdrawal request +- **THEN** the visible summary shows available decreased and frozen increased by the requested amount, and the request appears in the withdrawal list as pending + +#### Scenario: fixed adapter remains functional +- **WHEN** the wallet domain is configured to fixed data +- **THEN** the wallet page behaves deterministically through the same shared client methods + +### Requirement: Merchant onboarding multi-step form +The mall SHALL expose a merchant onboarding page ("商家入驻") reachable from the top-bar entry, with a multi-step form covering entity kind (personal 个人 / enterprise 企业), kind-specific entity information, operating categories from the published category tree, contact details, and qualification materials as URL input fields with no file-upload controls. The form SHALL be fillable while signed out, but submission SHALL require registration or sign-in and return the applicant to the completed form to submit through the shared selected API adapter. A duplicate-application conflict SHALL be surfaced inline. All copy SHALL come from the mall locale source in en and zh. + +#### Scenario: anonymous fill then sign-in +- **WHEN** a signed-out visitor completes the form and submits +- **THEN** they are sent to register or sign in and, once signed in, returned to the completed form to submit + +#### Scenario: enterprise kind shows company fields +- **WHEN** the applicant selects the enterprise entity kind +- **THEN** the company-specific entity and qualification fields replace the personal ones + +#### Scenario: duplicate application surfaced +- **WHEN** a signed-in user holding a pending or approved application submits the form +- **THEN** the mall shows the conflict instead of silently creating a second application + +#### Scenario: fixed adapter remains functional +- **WHEN** the merchant-onboarding domain is configured to fixed data +- **THEN** the form and status flows behave deterministically through the same shared client methods + +### Requirement: Application status page +The mall SHALL show a signed-in applicant their latest merchant application state — status, submitted entity kind, timestamps, and the rejection reason when rejected — linked from the onboarding page, with a re-apply action after rejection. Anonymous visitors SHALL be sent to sign in first. + +#### Scenario: applicant tracks review +- **WHEN** a signed-in applicant opens the status page while the application is `pending` +- **THEN** the pending state and submission summary are shown + +#### Scenario: rejection explains reason +- **WHEN** a rejected applicant opens the status page +- **THEN** the rejection reason is displayed with an action to apply again + +### Requirement: Member center level page +The buyer center SHALL render a membership level page from the selected API adapter: the current level's name, icon, and benefits, the total growth value, progress toward the next level's threshold, and the customer's growth history from the growth ledger. The page SHALL NOT derive level state from fixtures or local state, and it SHALL behave deterministically when the `membership` domain is configured to fixed data. + +#### Scenario: level page reflects backend state +- **WHEN** a signed-in shopper whose growth qualifies for a level opens the member-center level page +- **THEN** the current level, benefits, growth total, and remaining growth to the next level render from the adapter without per-entry requests + +#### Scenario: growth history lists ledger entries +- **WHEN** a shopper opens the level page +- **THEN** recent growth ledger entries with delta, reason, and time render paginated from the adapter + +#### Scenario: fixed adapter remains functional +- **WHEN** the `membership` domain is configured to fixed data +- **THEN** the level page renders deterministic fixed level and growth data through the same shared client methods + +### Requirement: Message center +The mall SHALL render a user message center from the selected API adapter with a paginated message list and an unread-only filter. Opening or explicitly marking a message read, marking all read, and deleting a message SHALL persist through the API and update the visible list and counts without local-only mutation. Each message SHALL render its title and body in the active locale from the shared bilingual contract. Listing and refresh SHALL happen on entry and on demand; no push transport is required. + +#### Scenario: unread filter and marking +- **WHEN** a shopper filters the message center to unread and marks one message read +- **THEN** the message persists as read and leaves the unread-only view with the unread count reduced + +#### Scenario: mark all read +- **WHEN** a shopper uses mark-all-read in the message center +- **THEN** every unread message becomes read and the unread-only view empties + +#### Scenario: delete a message +- **WHEN** a shopper deletes a message +- **THEN** it disappears from the list and the unread count through the API state + +#### Scenario: fixed adapter remains functional +- **WHEN** the `messaging` domain is configured to fixed data +- **THEN** message listing, read marking, mark-all-read, and deletion behave deterministically through the same shared client methods + +### Requirement: Top-bar unread badge +The mall shell SHALL show the authenticated customer's unread message count as a badge on its message entry, read from the shared unread-count contract. The badge SHALL refresh on page entry and after read, mark-all-read, and delete actions, and SHALL be absent for anonymous shoppers. + +#### Scenario: badge reflects unread count +- **WHEN** a signed-in shopper with three unread messages loads any mall page +- **THEN** the message entry badge shows three + +#### Scenario: badge clears after mark-all-read +- **WHEN** a shopper marks all messages read and returns to the shell +- **THEN** the badge shows no unread count diff --git a/openspec/specs/frontend-shop-admin/spec.md b/openspec/specs/frontend-shop-admin/spec.md index edce0ff..261e116 100644 --- a/openspec/specs/frontend-shop-admin/spec.md +++ b/openspec/specs/frontend-shop-admin/spec.md @@ -2,7 +2,9 @@ ## Purpose The merchant console for managing a shop's products and fulfilling its orders. + ## Requirements + ### Requirement: Merchant product management Shop users SHALL manage only their own shop's products: create/edit bilingual content, manage SKUs, publish/unpublish with immediate effect on the storefront. @@ -131,3 +133,24 @@ Shop users SHALL list only their own shop's reviews in shop-admin through the sh - **WHEN** a merchant opens a review that already carries their shop's reply - **THEN** no reply submission is offered and other shops' reviews are unreachable +### Requirement: Merchant settlement statements +Shop-admin SHALL list and open the signed-in shop's settlement statements — period, amount snapshot, and status — through the shared API contract, and allow manual generation for a closed period of the own shop. Shop-scoped pages SHALL never expose another shop's statements or their order/refund breakdown. + +#### Scenario: statements list and detail +- **WHEN** a merchant opens the settlement page +- **THEN** only their own shop's statements are listed and each opens into its snapshot breakdown of orders, refunds, commission, and payable amount + +#### Scenario: generation is idempotent in the UI +- **WHEN** the merchant generates a statement for a period that already has one +- **THEN** the existing statement appears without duplication + +### Requirement: Shop-account withdrawal +The shop owner SHALL view the shop account summary (available and frozen balance of the shop owner's account) and apply to withdraw from it through the shared API contract, seeing the request as pending until the platform reviews it. + +#### Scenario: shop-account withdrawal freezes funds +- **WHEN** the shop owner submits a withdrawal request from the shop-account page +- **THEN** the summary reflects the frozen amount and the request lists as pending + +#### Scenario: reviewed request reflects outcome +- **WHEN** the platform rejects the shop owner's pending withdrawal +- **THEN** the shop-account summary shows the amount returned to available balance and the request shows as rejected diff --git a/openspec/specs/membership/spec.md b/openspec/specs/membership/spec.md new file mode 100644 index 0000000..486ac0f --- /dev/null +++ b/openspec/specs/membership/spec.md @@ -0,0 +1,73 @@ +# membership Specification + +## Purpose + +A customer's spending converts into status: platform-managed member levels +(bilingual name and benefits, icon, unique integer growth threshold) plus an +append-only growth ledger that accrues the realized paid amount of each +completed order in whole base-currency units. Accrual is idempotent per order +and, inside the same transaction, raises the customer's level with a guarded +one-way update; the status read re-derives the displayed level against current +thresholds and shows progress to the next one. Growth history is own-only. + +## Requirements + +### Requirement: Member level catalog +The platform SHALL manage member levels through admin-only APIs. Each level SHALL carry a bilingual name (`{en, zh}`), an icon key, an integer growth-value threshold, and a bilingual benefits description (`{en, zh}`). Growth thresholds SHALL be unique across levels, and the effective ordering of levels SHALL follow the threshold. Deleting a level that any customer currently holds SHALL be rejected instead of reassigning or orphaning members. + +#### Scenario: create a level +- **WHEN** a platform admin creates a level with name, icon, growth threshold, and benefits in both locales +- **THEN** the level is listed with its bilingual content and threshold ordering + +#### Scenario: duplicate threshold is rejected +- **WHEN** a platform admin creates or edits a level to reuse another level's growth threshold +- **THEN** the request is rejected and the existing levels are unchanged + +#### Scenario: deleting a level in use is rejected +- **WHEN** a platform admin deletes a level that at least one customer holds +- **THEN** the request is rejected and no customer's level changes + +### Requirement: Growth value accrual ledger +When a customer confirms receipt and an order reaches completed, the customer SHALL earn growth value equal to that order's realized paid amount converted to the base currency and truncated to whole units through integer minor-unit arithmetic using the base currency exponent, with no floating-point computation. Accrual SHALL append exactly one entry per order to a growth ledger that, like the points ledger, is append-only and records the delta, running growth total, reason, and order reference. Retried or repeated completion events SHALL NOT create a second entry or a second accrual. + +#### Scenario: confirm receipt accrues growth +- **WHEN** a customer confirms receipt of an order whose realized paid amount converts to 120 whole base-currency units +- **THEN** the growth ledger gains one entry with delta 120 referencing that order and the customer's growth total rises by 120 + +#### Scenario: repeated completion is idempotent +- **WHEN** the completion handling for the same order runs again +- **THEN** no second ledger entry exists and the growth total is unchanged + +#### Scenario: ledger entries are immutable +- **WHEN** any code path handles growth after an entry was written +- **THEN** the entry is only ever appended to, never updated or deleted + +### Requirement: Automatic level upgrade +Inside the growth accrual transaction, the customer's level SHALL be re-derived as the level with the highest growth threshold less than or equal to the customer's growth total and written to `users.level` with a guarded update. Leveling SHALL be one-way: a customer is only ever moved to a level with a strictly higher threshold than the current one, and never demoted automatically. A growth total exactly at a threshold qualifies for that level, and when several thresholds are passed the highest qualifying level wins. A customer below every threshold SHALL hold no level. + +#### Scenario: growth at the threshold upgrades +- **WHEN** an accrual brings a customer's growth total exactly to a level's threshold +- **THEN** the customer's level becomes that level in the same transaction as the ledger entry + +#### Scenario: jumping past intermediate levels +- **WHEN** an accrual passes two levels' thresholds at once +- **THEN** the customer holds the highest qualifying level, not the intermediate one + +#### Scenario: no level below every threshold +- **WHEN** a customer's growth total is below the lowest defined threshold +- **THEN** the customer holds no level and a later qualifying accrual assigns one + +### Requirement: Level benefits display +An authenticated customer SHALL read their own membership status: current level with name, icon, and benefits, total growth value, the next level's threshold and remaining growth to reach it, and a paginated view of their own growth ledger. The status SHALL re-derive the displayed level against current thresholds, and one customer SHALL never read another customer's growth history. + +#### Scenario: progress to the next level +- **WHEN** a customer between two thresholds opens their membership status +- **THEN** the response shows the current level's benefits and the exact growth remaining to the next level + +#### Scenario: top level has no next level +- **WHEN** a customer at or above the highest threshold reads their membership status +- **THEN** the response contains no next level and no remaining growth target + +#### Scenario: growth history is own-only +- **WHEN** a customer requests their growth ledger +- **THEN** only entries of the authenticated customer are returned, newest first and paginated diff --git a/openspec/specs/merchant-onboarding/spec.md b/openspec/specs/merchant-onboarding/spec.md new file mode 100644 index 0000000..813af66 --- /dev/null +++ b/openspec/specs/merchant-onboarding/spec.md @@ -0,0 +1,80 @@ +# merchant-onboarding Specification + +## Purpose + +The B2B entry of the mall: a prospective seller (personal 个人 or enterprise +企业) applies once with entity information, operating categories, contact +details, and qualification URLs; a platform admin reviews the application and, +on approval, the shop and its dedicated `shop_owner` login are provisioned in +one transaction with one-time initial credentials. One live application per +user is enforced by a partial unique index; rejected applicants may re-apply, +and both terminal review states are immutable. + +## Requirements + +### Requirement: Merchant application submission +A prospective seller SHALL submit one merchant onboarding application as either a personal (个人) or an enterprise (企业) entity. Each kind SHALL require its own entity information, one or more operating categories referenced from the published category tree, contact details, and qualification materials submitted as URL fields. The API MUST reject submissions with missing kind-specific fields, unknown categories, or malformed qualification URLs. The form MAY be filled anonymously, but submission SHALL require an authenticated user. + +#### Scenario: enterprise submission accepted +- **WHEN** an authenticated user submits an enterprise application with entity information, operating categories, contact details, and qualification URLs +- **THEN** the application is stored with status `pending` and returned to the applicant + +#### Scenario: personal submission missing identity document +- **WHEN** a user submits a personal application without the required identity document URL +- **THEN** the API returns a validation error and stores no application row + +#### Scenario: anonymous submit requires sign-in +- **WHEN** a signed-out visitor submits the completed form +- **THEN** no application is stored and the visitor must register or sign in before submitting + +### Requirement: One active application per user +Submission SHALL be deduplicated per user: a user holding a `pending` or `approved` application MUST NOT create another one, enforced by service validation backed by a database partial unique index so concurrent submissions cannot both succeed. A user whose application was `rejected` MAY apply again. + +#### Scenario: duplicate pending submission +- **WHEN** a user with a `pending` application submits again +- **THEN** the API rejects the request with a conflict and exactly one application row exists + +#### Scenario: re-apply after rejection +- **WHEN** a user whose application was `rejected` submits a new application +- **THEN** a new `pending` application row is created + +### Requirement: Review state machine +An application SHALL move only from `pending` to `approved` or `rejected`, executed as guarded updates matching the `pending` state so concurrent or repeated reviews of the same application fail with a conflict instead of overwriting each other. Rejecting SHALL require a non-empty reason recorded on the row, and `approved` and `rejected` are terminal states that MUST NOT transition again. + +#### Scenario: double review conflict +- **WHEN** two platform admins review the same `pending` application concurrently +- **THEN** exactly one transition succeeds and the other receives a conflict + +#### Scenario: rejection records a reason +- **WHEN** a platform admin rejects a `pending` application with a reason +- **THEN** the application becomes `rejected` with the reason stored on the row + +#### Scenario: terminal state is immutable +- **WHEN** a platform admin reviews an already `approved` or `rejected` application +- **THEN** the API returns a conflict and the stored state is unchanged + +### Requirement: Transactional approval provisioning +Approving an application SHALL, within a single database transaction, create the shop, create a dedicated `shop_owner` account scoped to that shop, and mark the application `approved` referencing the created shop. The generated initial password SHALL be returned exactly once in the approve response and MUST NOT be retrievable through any later read. If any step fails, the status change and all provisioning SHALL roll back so the application remains `pending`. + +#### Scenario: approval provisions shop and account +- **WHEN** a platform admin approves a `pending` application +- **THEN** one transaction results in an `approved` application, an active shop, and a working `shop_owner` login scoped to that shop + +#### Scenario: credentials shown once +- **WHEN** the approver reads the application again after approval +- **THEN** the initial password is absent from every subsequent response + +#### Scenario: provisioning failure rolls back +- **WHEN** shop or account creation fails during approval +- **THEN** no shop or account persists and the application remains `pending` + +### Requirement: Application visibility +An applicant SHALL read only their own application history, including status, submitted data, timestamps, and rejection reason. Platform admins SHALL list all applications with status filtering and pagination and read any application detail. Other users' applications MUST NOT be readable through customer routes. + +#### Scenario: applicant checks status +- **WHEN** a signed-in applicant requests their application status +- **THEN** their own application state and rejection reason are returned + +#### Scenario: other applicant is hidden +- **WHEN** one authenticated user requests another user's application through customer routes +- **THEN** the API returns no data about the other user's application diff --git a/openspec/specs/messaging/spec.md b/openspec/specs/messaging/spec.md new file mode 100644 index 0000000..32e4b8f --- /dev/null +++ b/openspec/specs/messaging/spec.md @@ -0,0 +1,62 @@ +# messaging Specification + +## Purpose + +An in-site inbox that reaches the customer without a push transport: order +payment, shipment dispatch, and refund completion each emit one bilingual system +message naming the affected order, keyed by customer, kind, and reference so a +retried handler cannot duplicate it. Messages carry unread/read state, support +guarded mark-one and mark-all-read, soft deletion that keeps the row for audit +while hiding it from lists and counts, and a dedicated unread-count endpoint for +the storefront's header badge. + +## Requirements + +### Requirement: System event messages +An order payment success, a shipment dispatch, and a refund completion (including after-sale refunds) SHALL each create one system message for the customer who owns the order. Each message SHALL record its kind, a bilingual title and body (`{en, zh}`) naming the affected order, and the order (and refund) reference it was triggered by. Emission SHALL be idempotent per customer, kind, and reference, so re-running an event handler never writes a duplicate message. + +#### Scenario: paying an order notifies the customer +- **WHEN** a customer's order payment succeeds +- **THEN** an `order_paid` message referencing that order appears in the customer's inbox + +#### Scenario: dispatch notifies the customer +- **WHEN** a shop marks a shipment of the customer's order as shipped +- **THEN** an `order_shipped` message referencing that order appears in the customer's inbox + +#### Scenario: refund completion notifies the customer +- **WHEN** a refund for the customer's order completes +- **THEN** a `refund_completed` message referencing that order and refund appears in the customer's inbox + +#### Scenario: re-run event handler does not duplicate +- **WHEN** the same event handler for the same order, kind, and reference runs twice +- **THEN** exactly one message exists for that customer, kind, and reference + +### Requirement: User message state machine +A message SHALL belong to exactly one customer and move through `unread` and `read` states plus a soft deletion. Messages are created `unread`; marking one read or all read SHALL use a guarded update that touches only `unread` rows, so repeated marking never rewrites already-read rows. Deletion SHALL set a soft-delete marker rather than removing the row, SHALL be idempotent, and SHALL make the message invisible to listing and counting. Customers SHALL only ever read, mark, or delete their own messages. + +#### Scenario: mark one message read +- **WHEN** a customer marks their unread message read twice +- **THEN** the message is read and only the first call changed its state + +#### Scenario: mark all read touches only unread +- **WHEN** a customer with three unread and two read messages marks all read +- **THEN** exactly the three unread rows become read and the two read rows are untouched + +#### Scenario: deleted messages disappear +- **WHEN** a customer deletes a message +- **THEN** it is absent from their list and unread count while its row remains for audit + +#### Scenario: foreign messages are unreachable +- **WHEN** a customer addresses another customer's message for read or delete +- **THEN** the request fails without changing that message + +### Requirement: Unread count endpoint +An authenticated customer SHALL read their unread message count through a dedicated endpoint that excludes soft-deleted messages. The count SHALL reflect read, mark-all-read, and delete actions immediately and SHALL only ever count the requesting customer's messages. + +#### Scenario: count drops after mark-all-read +- **WHEN** a customer with four unread messages marks all read +- **THEN** the unread count endpoint returns zero + +#### Scenario: deleting an unread message drops the count +- **WHEN** a customer deletes one of three unread messages +- **THEN** the unread count endpoint returns two diff --git a/openspec/specs/reviews/spec.md b/openspec/specs/reviews/spec.md index b4268cd..1289647 100644 --- a/openspec/specs/reviews/spec.md +++ b/openspec/specs/reviews/spec.md @@ -1,7 +1,8 @@ # reviews Specification ## Purpose -TBD - created by archiving change add-product-reviews. Update Purpose after archive. +Product reviews written once per completed order line, with aggregated rating +summaries, one-time merchant replies, and platform moderation (hide/delete). ## Requirements ### Requirement: One review per completed order line A signed-in customer SHALL create a review only for a product line of their own completed (received) order that has not been reviewed yet. A review SHALL carry a 1-5 star rating, text content, and optional image URLs, and the rating and content snapshot SHALL never change after creation. The database SHALL enforce at most one review per order line through a unique index on the order line, and creation SHALL validate the order-line precondition before insert. diff --git a/openspec/specs/settlement/spec.md b/openspec/specs/settlement/spec.md new file mode 100644 index 0000000..c2cc98f --- /dev/null +++ b/openspec/specs/settlement/spec.md @@ -0,0 +1,67 @@ +# settlement Specification + +## Purpose +Platform-mediated merchant settlement: per-shop weekly or monthly statements +generated manually for a closed period, snapshotted from confirmed-received +orders minus completed refunds minus a platform commission rate configured in +integer basis points. Amounts are integer minor units in the platform base +currency; generation is idempotent per shop and period; and a platform admin +confirms the payout exactly once, crediting the shop owner's available account +with a single ledger entry. + +## Requirements + +### Requirement: Idempotent periodic statement generation +A settlement statement SHALL be generated manually for one shop, one period kind (week or month), and one closed period. For the same shop, period kind, and period start at most one statement SHALL exist, enforced by the database. A repeated generation request SHALL return the existing statement unchanged instead of recomputing or duplicating it. No scheduled batch job SHALL generate statements. + +#### Scenario: repeat generation is idempotent +- **WHEN** a statement already exists for a shop and period and generation is requested again +- **THEN** the existing statement is returned unchanged and no second row is created + +#### Scenario: generation is manual only +- **WHEN** no generation request is made for a closed period +- **THEN** no statement exists for that period + +### Requirement: Immutable amount snapshot +A generated statement SHALL snapshot the contributing order count, the gross total of confirmed-received orders in the period, the deduction total of completed refunds against those orders (`aftersales` rows with status `refunded`, per-order totals from `add-aftersale-refunds`), the platform commission rate at generation time, the commission amount, and the payable amount where payable = gross − refunds − commission. All amounts SHALL be integer minor units computed with integer arithmetic, and the commission rate SHALL be an integer basis-point value. The snapshot SHALL NOT change after generation even when later orders, refunds, or commission-rate changes occur. + +#### Scenario: refunds reduce the payable amount +- **WHEN** a confirmed-received order in the period has a completed refund of 2000 minor units +- **THEN** the statement's refund deduction includes those 2000 minor units and the payable amount is reduced accordingly + +#### Scenario: commission arithmetic is integral +- **WHEN** the commission rate is 500 basis points and gross minus refunds is 10000 minor units +- **THEN** the commission snapshot is 500 minor units and the payable snapshot is 9500 minor units with no floating-point arithmetic + +#### Scenario: snapshot survives rate changes +- **WHEN** the platform commission rate changes after a statement was generated +- **THEN** the generated statement keeps its snapshotted rate and amounts + +### Requirement: Platform commission rate configuration +The commission rate SHALL be a single platform-level setting stored as integer basis points and readable and editable by platform admins. A rate change SHALL affect only statements generated after the change. + +#### Scenario: new rate applies to new statements +- **WHEN** an admin sets the commission rate and a statement is generated afterwards +- **THEN** that statement snapshots the new rate + +### Requirement: One-time payout confirmation +A statement SHALL transition exactly once from `pending` to `confirmed` through a guarded status transition requiring the `pending` status, triggered by a platform admin confirming the payout; a repeated confirmation SHALL return 409. Confirmation SHALL record the confirming admin and timestamp and SHALL credit the payable amount to the shop owner's available account with exactly one ledger entry referencing the statement. + +#### Scenario: confirm pays out once +- **WHEN** an admin confirms a pending statement +- **THEN** the statement becomes confirmed and the shop owner's available balance increases by the payable amount with one ledger entry referencing the statement + +#### Scenario: double confirmation conflicts +- **WHEN** an admin confirms an already confirmed statement +- **THEN** the API returns 409 and no second ledger entry is written + +### Requirement: Shop-scoped statement visibility +Statements and their breakdown SHALL be scoped to one shop. Through the shop's own-shop scope, shop users SHALL read and generate statements only for their own shop and SHALL never observe another shop's statements, contributing orders, or refund lines; platform admins SHALL observe every shop. + +#### Scenario: merchant sees own shop only +- **WHEN** a shop user lists settlement statements +- **THEN** only statements of their own shop appear + +#### Scenario: detail breakdown is shop-scoped +- **WHEN** a shop user opens a statement detail +- **THEN** the contributing orders and refunded amounts are visible for that shop only diff --git a/openspec/specs/shipping/spec.md b/openspec/specs/shipping/spec.md index 42b8666..8cba6f0 100644 --- a/openspec/specs/shipping/spec.md +++ b/openspec/specs/shipping/spec.md @@ -1,7 +1,9 @@ # shipping Specification ## Purpose -TBD - created by archiving change add-freight-templates. Update Purpose after archive. +Merchant-owned freight templates (by piece or by weight, with region rules and +a default per shop) plus the platform shipping-company dictionary, used by +server-side checkout pricing of per-shop shipping fees. ## Requirements ### Requirement: Merchant freight template management Shop users SHALL manage freight templates for their own shop only, scoped through `own_shop`, and platform administrators SHALL manage none implicitly. A template SHALL have a name, an optional always-free toggle, a pricing method of `by_piece` or `by_weight`, first-unit and additional-unit fees in i64 minor units of the shop currency, integer first/additional unit sizes (pieces for `by_piece`, grams for `by_weight`), and an optional free-shipping threshold in i64 minor units. At most one template per shop SHALL be the default, enforced by the database and preserved under concurrent default changes with guarded updates. Amounts SHALL be i64 minor units and weights integer grams; floating-point money is forbidden. Services SHALL return `ApiResult`. diff --git a/openspec/specs/wallet/spec.md b/openspec/specs/wallet/spec.md new file mode 100644 index 0000000..5a3283e --- /dev/null +++ b/openspec/specs/wallet/spec.md @@ -0,0 +1,60 @@ +# wallet Specification + +## Purpose +The wallet gives the existing customer-account ledger its entry points: a +simulated demo recharge, a withdrawal application that freezes funds until a +platform admin reviews it exactly once (approve consumes the frozen amount, +reject returns it to available), and a paginated view of the caller's own fund +entries. Balances only move through the account module's +credit/debit/freeze/release primitives in the same transaction as the business +record, and every movement pairs with an append-only ledger entry. + +## Requirements + +### Requirement: Demo wallet recharge +An authenticated user SHALL recharge their wallet through a simulated demo flow that records a wallet recharge row and credits the available monetary account with one signed ledger entry in one transaction, without invoking any external payment channel. The API payload and the wallet UI SHALL clearly label the recharge as simulated/demo. Amounts SHALL be positive integer minor units in the account's currency, and monetary arithmetic SHALL NOT use floating point. + +#### Scenario: demo recharge credits balance +- **WHEN** a signed-in buyer submits a demo recharge of 5000 minor units +- **THEN** the available balance increases by exactly 5000 minor units and exactly one ledger entry with reason `wallet_recharge` records the resulting balance + +#### Scenario: flow is labeled as demo +- **WHEN** a buyer opens the recharge flow +- **THEN** it is visibly marked as a simulated recharge and no external payment provider is contacted + +### Requirement: Guarded withdrawal freeze +An authenticated user SHALL apply to withdraw a positive amount in their account currency. The application SHALL atomically move the amount from available to frozen balance through guarded conditional updates that succeed only when available balance covers the amount, and each balance change SHALL be paired with a ledger entry in the same transaction. When available balance is insufficient the request SHALL fail without changing any balance, and concurrent applications SHALL never overdraw or produce a negative balance. + +#### Scenario: withdrawal freezes funds +- **WHEN** a buyer applies to withdraw 1000 minor units +- **THEN** available balance decreases and frozen balance increases by exactly 1000 minor units and two ledger entries record the move + +#### Scenario: concurrent applications cannot overdraw +- **WHEN** two withdrawal applications together exceed the available balance +- **THEN** at most one application succeeds and all balances remain non-negative + +### Requirement: One-time withdrawal review +Platform admins SHALL list pending withdrawal applications and approve or reject each exactly once through a guarded status transition that requires the `pending` status and returns 409 on a repeated review. Approve SHALL deduct the frozen amount with a ledger entry recording that the funds left the platform. Reject SHALL return the frozen amount to available balance with a ledger entry. Every review SHALL record the reviewing admin, timestamp, and optional note. + +#### Scenario: reject returns funds +- **WHEN** an admin rejects a pending withdrawal application +- **THEN** the frozen amount returns to available balance and the application status becomes rejected + +#### Scenario: approve consumes frozen funds +- **WHEN** an admin approves a pending withdrawal application +- **THEN** frozen balance decreases by the requested amount and the application status becomes approved + +#### Scenario: repeated review conflicts +- **WHEN** an admin reviews an application that was already reviewed +- **THEN** the API returns 409 and no balance or ledger row changes + +### Requirement: Paginated fund entries +An authenticated user SHALL page through their own monetary account entries newest first. Each entry SHALL carry the signed delta minor, resulting balance minor, reason, optional business reference, and timestamp, mapped from the existing append-only ledger rows. Entries SHALL never expose or mutate another user's ledger. + +#### Scenario: entries page maps the ledger +- **WHEN** the buyer requests a page of fund entries +- **THEN** only their own available/frozen ledger rows appear with signed deltas and resulting balances + +#### Scenario: entries are user-isolated +- **WHEN** one user requests fund entries +- **THEN** no entry belonging to another user is ever returned diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 16832c7..fc18a5f 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -27,6 +27,7 @@ import type { FlashSaleItemInput, FlashSaleSession, FlashSaleSessionInput, + GenerateSettlementBody, GroupBuyIntent, GroupBuyingActivity, GroupBuyingActivityInput, @@ -38,6 +39,18 @@ import type { Invoice, InvoiceKind, LocalizedText, + MarkAllReadResult, + MemberLevel, + MemberLevelInput, + MembershipStatus, + MerchantApplication, + MerchantApplicationInput, + MerchantApplicationQuery, + MerchantApprovalResult, + Message, + MessageDeleteResult, + MessageListQuery, + GrowthLogEntry, Order, OrderStatus, Paged, @@ -49,8 +62,12 @@ import type { ReviewInput, ReviewSummary, ReviewableItem, + CommissionRate, FreightTemplate, FreightTemplateInput, + SettlementStatement, + SettlementStatementDetail, + SettlementStatementQuery, Shipment, ShippingCompany, ShippingQuote, @@ -60,7 +77,15 @@ import type { ShopProfileInput, ShopProfileSelfInput, Sku, + UnreadCount, User, + WalletEntry, + WalletRechargeResult, + WalletSummary, + WalletWithdrawal, + WithdrawalAccountDetails, + WithdrawalReviewOutcome, + WithdrawalStatus, } from "./types"; export interface ApiClientOptions { @@ -272,6 +297,34 @@ export interface ApiClient { listFlashSales(): Promise; /** Public: active group-buying activities with their open groups. */ listGroupBuyingActivities(): Promise; + /** Available/frozen balances of the caller's monetary account. */ + getWallet(): Promise; + /** The caller's own ledger entries, newest first. */ + listWalletEntries(page?: number): Promise>; + /** Simulated recharge; no external payment channel is involved. */ + rechargeWallet(amountMinor: number): Promise; + /** Freezes the amount in the caller's wallet pending platform review. */ + applyWithdrawal( + amountMinor: number, + details: WithdrawalAccountDetails, + ): Promise; + listMyWithdrawals(): Promise; + /** Merchant onboarding: one live application per user, else a 409. */ + submitMerchantApplication(body: MerchantApplicationInput): Promise; + /** The caller's own application history, newest first. */ + getMyMerchantApplications(): Promise; + /** Membership: derived level, growth total, and the next threshold. */ + getMembership(): Promise; + /** The caller's own growth ledger, newest first. */ + listGrowthLogs(page?: number): Promise>; + /** The caller's own system messages, newest first. */ + listMessages(q?: MessageListQuery): Promise>; + /** Marks one owned message read; a repeat is a no-op. */ + markMessageRead(id: string): Promise; + markAllMessagesRead(): Promise; + /** Soft-deletes one owned message; a repeat is a no-op. */ + deleteMessage(id: string): Promise; + getUnreadCount(): Promise; shop: { getMyShop(): Promise; /** Merchant self-write of the own shop profile; scores stay platform-owned. */ @@ -327,6 +380,11 @@ export interface ApiClient { deleteFreightTemplate(id: string): Promise; listReviews(page?: number): Promise>; replyReview(id: string, content: LocalizedText): Promise; + listShopSettlementStatements( + q?: SettlementStatementQuery, + ): Promise>; + getShopSettlementStatement(id: string): Promise; + generateShopSettlementStatement(body: GenerateSettlementBody): Promise; }; admin: { listUsers(page?: number): Promise>; @@ -351,6 +409,36 @@ export interface ApiClient { listReviews(page?: number): Promise>; hideReview(id: string): Promise; deleteReview(id: string): Promise; + /** Withdrawal applications, optionally filtered by review status. */ + listWithdrawalApplications(status?: WithdrawalStatus): Promise; + /** Reviews a pending application exactly once; a repeat is a 409. */ + reviewWithdrawal( + id: string, + outcome: WithdrawalReviewOutcome, + note?: string | null, + ): Promise; + getCommissionRate(): Promise; + setCommissionRate(commissionRateBps: number): Promise; + listSettlementStatements( + q?: SettlementStatementQuery, + ): Promise>; + getSettlementStatement(id: string): Promise; + /** Idempotent for a shop + period; a repeat returns the existing row. */ + generateSettlementStatement(body: GenerateSettlementBody): Promise; + confirmSettlementStatement(id: string): Promise; + listMerchantApplications( + q?: MerchantApplicationQuery, + ): Promise>; + getMerchantApplication(id: string): Promise; + /** Provisions shop + owner in one transaction; credentials returned once. */ + approveMerchantApplication(id: string): Promise; + rejectMerchantApplication(id: string, reason: string): Promise; + /** Member levels in growth-threshold order. */ + listMemberLevels(): Promise; + createMemberLevel(body: MemberLevelInput): Promise; + updateMemberLevel(id: string, body: MemberLevelInput): Promise; + /** Rejected with a 409 when any customer currently holds the level. */ + deleteMemberLevel(id: string): Promise; }; } @@ -427,6 +515,25 @@ export function createApi(opts: ApiClientOptions): ApiClient { redeemPoints: (body) => r("POST", "/points/redemptions", body), listFlashSales: () => r("GET", "/flash-sales"), listGroupBuyingActivities: () => r("GET", "/group-buying/activities"), + getWallet: () => r("GET", "/wallet"), + listWalletEntries: (page = 1) => r("GET", "/wallet/entries", undefined, { page }), + rechargeWallet: (amountMinor) => + r("POST", "/wallet/recharges", { amount_minor: amountMinor }), + applyWithdrawal: (amountMinor, details) => + r("POST", "/wallet/withdrawals", { + amount_minor: amountMinor, + account_details: details, + }), + listMyWithdrawals: () => r("GET", "/wallet/withdrawals"), + submitMerchantApplication: (body) => r("POST", "/merchant/applications", body), + getMyMerchantApplications: () => r("GET", "/merchant/applications"), + getMembership: () => r("GET", "/membership"), + listGrowthLogs: (page = 1) => r("GET", "/membership/growth-logs", undefined, { page }), + listMessages: (q = {}) => r("GET", "/messages", undefined, { ...q }), + markMessageRead: (id) => r("POST", `/messages/${id}/read`), + markAllMessagesRead: () => r("POST", "/messages/read-all"), + deleteMessage: (id) => r("DELETE", `/messages/${id}`), + getUnreadCount: () => r("GET", "/messages/unread-count"), shop: { getMyShop: () => r("GET", "/shop/profile"), updateMyProfile: (body) => r("PUT", "/shop/profile", body), @@ -480,6 +587,11 @@ export function createApi(opts: ApiClientOptions): ApiClient { deleteFreightTemplate: (id) => r("DELETE", `/shop/freight-templates/${id}`), listReviews: (page = 1) => r("GET", "/shop/reviews", undefined, { page }), replyReview: (id, content) => r("POST", `/shop/reviews/${id}/reply`, { content }), + listShopSettlementStatements: (q = {}) => + r("GET", "/shop/settlement/statements", undefined, { ...q }), + getShopSettlementStatement: (id) => r("GET", `/shop/settlement/statements/${id}`), + generateShopSettlementStatement: (body) => + r("POST", "/shop/settlement/statements", body), }, admin: { listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }), @@ -516,6 +628,32 @@ export function createApi(opts: ApiClientOptions): ApiClient { listPointsRedemptions: (page = 1) => r("GET", "/admin/points/orders", undefined, { page }), fulfillRedemption: (id) => r("POST", `/admin/points/orders/${id}/fulfill`), cancelRedemption: (id) => r("POST", `/admin/points/orders/${id}/cancel`), + listWithdrawalApplications: (status) => + r("GET", "/admin/wallet/withdrawals", undefined, { status }), + reviewWithdrawal: (id, outcome, note) => + r("POST", `/admin/wallet/withdrawals/${id}/review`, { outcome, note: note ?? null }), + getCommissionRate: () => r("GET", "/admin/settlement/commission-rate"), + setCommissionRate: (commissionRateBps) => + r("PUT", "/admin/settlement/commission-rate", { + commission_rate_bps: commissionRateBps, + }), + listSettlementStatements: (q = {}) => + r("GET", "/admin/settlement/statements", undefined, { ...q }), + getSettlementStatement: (id) => r("GET", `/admin/settlement/statements/${id}`), + generateSettlementStatement: (body) => r("POST", "/admin/settlement/statements", body), + confirmSettlementStatement: (id) => + r("POST", `/admin/settlement/statements/${id}/confirm`), + listMerchantApplications: (q = {}) => + r("GET", "/admin/merchant/applications", undefined, { ...q }), + getMerchantApplication: (id) => r("GET", `/admin/merchant/applications/${id}`), + approveMerchantApplication: (id) => + r("POST", `/admin/merchant/applications/${id}/approve`), + rejectMerchantApplication: (id, reason) => + r("POST", `/admin/merchant/applications/${id}/reject`, { reason }), + listMemberLevels: () => r("GET", "/admin/member-levels"), + createMemberLevel: (body) => r("POST", "/admin/member-levels", body), + updateMemberLevel: (id, body) => r("PUT", `/admin/member-levels/${id}`, body), + deleteMemberLevel: (id) => r("DELETE", `/admin/member-levels/${id}`), }, }; } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ff40913..2715c83 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -824,3 +824,310 @@ export interface ReviewInput { content: LocalizedText; images?: string[]; } + +// ---- wallet ---- + +/** Available + frozen minor units of the caller's monetary account currency. */ +export interface WalletSummary { + available_minor: number; + frozen_minor: number; + currency: string; +} + +/** Which balance bucket a ledger entry moved. */ +export type WalletAccountKind = "available" | "frozen"; + +/** One append-only ledger entry mapped for the wallet, newest first. */ +export interface WalletEntry { + id: string; + account_kind: WalletAccountKind; + /** Signed minor units; negative for money leaving the account. */ + delta_minor: number; + /** Balance of that bucket after the movement. */ + balance_minor: number; + reason: string; + reference_type: string | null; + reference_id: string | null; + created_at: string; +} + +/** Payout destination captured with a withdrawal application. */ +export interface WithdrawalAccountDetails { + /** Payout channel, e.g. "bank" or "demo". */ + method: string; + /** Destination identifier (masked account number or handle). */ + account: string; + /** Optional account holder name. */ + holder?: string; +} + +export type WithdrawalStatus = "pending" | "approved" | "rejected"; + +export interface WalletWithdrawal { + id: string; + user_id: string; + /** Populated for the platform review queue; empty on the caller's own rows. */ + user_email: string; + amount_minor: number; + currency: string; + account_details: WithdrawalAccountDetails; + status: WithdrawalStatus; + review_note: string | null; + reviewed_at: string | null; + created_at: string; +} + +/** + * Result of a simulated recharge. `demo` is always true: no external payment + * channel is contacted, and the UI must label the flow as simulated. + */ +export interface WalletRechargeResult { + id: string; + demo: boolean; + amount_minor: number; + currency: string; + status: "credited" | "failed"; + /** Available balance after the credit. */ + available_minor: number; + created_at: string; +} + +export type WithdrawalReviewOutcome = "approve" | "reject"; + +// ---- settlement ---- + +export type SettlementPeriodKind = "week" | "month"; +export type SettlementStatus = "pending" | "confirmed"; + +/** One immutable per-shop, per-period reconciliation snapshot. */ +export interface SettlementStatement { + id: string; + shop_id: string; + shop_name: LocalizedText; + /** Currency the snapshot amounts are expressed in. */ + currency: string; + period_kind: SettlementPeriodKind; + period_start: string; + period_end: string; + order_count: number; + gross_minor: number; + refund_minor: number; + commission_rate_bps: number; + commission_minor: number; + payable_minor: number; + status: SettlementStatus; + confirmed_at: string | null; + created_at: string; + updated_at: string; +} + +/** One contributing confirmed-received order, converted to statement currency. */ +export interface SettlementOrderLine { + order_id: string; + order_no: string; + /** Currency the order was placed in. */ + order_currency: string; + gross_minor: number; + refund_minor: number; + created_at: string; +} + +/** Statement detail: the snapshot plus its contributing orders. */ +export interface SettlementStatementDetail extends SettlementStatement { + orders: SettlementOrderLine[]; +} + +export interface SettlementStatementQuery { + page?: number; + per_page?: number; + shop_id?: string; + status?: SettlementStatus; +} + +/** Generation input; the period is normalized to its containing week/month. */ +export interface GenerateSettlementBody { + shop_id?: string; + period_kind: SettlementPeriodKind; + /** Any date inside the target period (ISO `YYYY-MM-DD`). */ + period_start: string; +} + +export interface CommissionRate { + commission_rate_bps: number; +} + +// ---- merchant onboarding ---- + +export type MerchantEntityType = "personal" | "enterprise"; +export type MerchantApplicationStatus = "pending" | "approved" | "rejected"; + +export interface MerchantContact { + name: string; + phone: string; + email: string; + address?: string; +} + +/** Qualification materials are URLs only; this MVP has no file storage. */ +export interface MerchantQualification { + identity_document_url?: string; + business_license_url?: string; + business_license_no?: string; + extra_materials?: string[]; +} + +interface MerchantApplicationBase { + /** One or more ids from the reference category tree. */ + category_ids: string[]; + contact: MerchantContact; +} + +export interface PersonalMerchantApplicationInput extends MerchantApplicationBase { + entity_type: "personal"; + real_name: string; + qualification: MerchantQualification & { identity_document_url: string }; +} + +export interface EnterpriseMerchantApplicationInput extends MerchantApplicationBase { + entity_type: "enterprise"; + company_name: string; + qualification: MerchantQualification & { + business_license_url: string; + business_license_no: string; + }; +} + +/** Discriminated by `entity_type`; each kind requires its own fields. */ +export type MerchantApplicationInput = + | PersonalMerchantApplicationInput + | EnterpriseMerchantApplicationInput; + +/** A submitted category resolved for display, in submission order. */ +export interface MerchantCategoryRef { + id: string; + name: LocalizedText; +} + +export interface MerchantApplication { + id: string; + user_id: string; + applicant_email: string; + entity_type: MerchantEntityType; + real_name: string | null; + company_name: string | null; + business_license_no: string | null; + category_ids: string[]; + categories: MerchantCategoryRef[]; + contact: MerchantContact; + qualification: MerchantQualification; + status: MerchantApplicationStatus; + rejection_reason: string | null; + reviewed_at: string | null; + /** Shop created by approval; null until then. */ + created_shop_id: string | null; + created_at: string; + updated_at: string; +} + +/** One-time initial login for the shop owner created by approval. */ +export interface MerchantOwnerCredentials { + email: string; + initial_password: string; + shop_id: string; + shop_slug: string; +} + +/** Approval response; the initial password appears here and nowhere else. */ +export interface MerchantApprovalResult { + application: MerchantApplication; + credentials: MerchantOwnerCredentials; +} + +export interface MerchantApplicationQuery { + status?: MerchantApplicationStatus; + page?: number; + per_page?: number; +} + +// ---- membership ---- + +export interface MemberLevel { + id: string; + name: LocalizedText; + icon: string; + /** Growth value in whole base-currency units. */ + growth_threshold: number; + benefits: LocalizedText; + created_at: string; + updated_at: string; +} + +export interface MemberLevelInput { + name: LocalizedText; + icon: string; + growth_threshold: number; + benefits: LocalizedText; +} + +/** One append-only growth accrual, in whole base-currency units. */ +export interface GrowthLogEntry { + id: string; + delta: number; + growth_total: number; + reason: string; + reference_type: string | null; + reference_id: string | null; + created_at: string; +} + +export interface NextMemberLevel { + id: string; + name: LocalizedText; + icon: string; + growth_threshold: number; + /** Growth still needed to reach the threshold. */ + remaining: number; +} + +export interface MembershipStatus { + /** Level re-derived from the growth total against current thresholds. */ + level: MemberLevel | null; + growth_total: number; + next_level: NextMemberLevel | null; +} + +// ---- messaging ---- + +export type MessageKind = "order_paid" | "order_shipped" | "refund_completed"; +export type MessageStatus = "unread" | "read"; + +export interface Message { + id: string; + kind: MessageKind; + title: LocalizedText; + body: LocalizedText; + reference_type: string | null; + reference_id: string | null; + status: MessageStatus; + read_at: string | null; + created_at: string; +} + +export interface MessageListQuery { + page?: number; + per_page?: number; + unread_only?: boolean; +} + +export interface MarkAllReadResult { + updated: number; +} + +export interface MessageDeleteResult { + id: string; + deleted: boolean; +} + +export interface UnreadCount { + unread: number; +}