# EDU-021 — Import legacy tenant payment accounts into native Pay - **Status:** done — bounded single-account import, audit, and native Pay UI entry implemented and verified - **Type:** legacy data bridge / payment security / tenant isolation - **Phase:** 4 / tenant operations - **Blockers:** EDU-020, native Pay App/Channel runtime, PostgreSQL Flyway ## Problem The legacy `tenant_payment_accounts` and `app_private.tenant_secrets` records cannot be copied directly into native Pay. Provider aliases, collection modes, channel variants, callback ownership, credential shapes, and status values are not one-to-one. Guessing any of them can route money or callbacks to the wrong party. The migration also needs durable evidence without creating an Education payment shadow model or persisting a second plaintext credential copy. ## Delivered contract - The bridge is Pay-owned and creates native `pay_app` and `pay_channel` rows through `PayAppService` and `PayChannelService`; Education owns neither a payment account nor a credential table. - `POST /pay/legacy-account-import/import` imports exactly one explicitly reviewed manifest. It requires both `pay:app:create` and `pay:channel:create`. - `GET /pay/legacy-account-import/page` exposes tenant-filtered audit history and requires both Pay App and Channel query permissions. - The existing `pay/app/index` Vben page adds a **迁移旧支付账号** action and JSON manifest modal. The button uses explicit AND permission visibility, matching the controller. - Import request-body logging is disabled for database access logs, non-production request logs, and unexpected-error logs so the manifest does not become a plaintext logging side channel. - Only `tenant_collect` is accepted. `platform_collect` and `service_provider` fail closed because their settlement and merchant ownership semantics are not equivalent. - Historical WeChat and Alipay aliases normalize to `wechat_pay` or `alipay`. Non-equivalent providers such as XPay/Xunhu fail closed. - Operators must explicitly select a native channel such as `wx_lite`, `wx_pub`, or an Alipay variant. Provider family and channel family must match; the importer never guesses a WeChat client type. - WeChat V3 and Alipay public-key configurations map into native Pay configuration objects. Multiple rotating WeChat platform keys require an explicit choice. Alipay accepts only the native production or sandbox official gateway. - Old provider callbacks are not reused. The manifest must provide new business order/refund callbacks and may provide a transfer callback. - `active` maps to enabled. `disabled` and `pending` map to disabled with an audit note. - Within the current target tenant, `sourceAccountId` is the idempotency key. A replay with the same source SHA-256 returns the existing mapping; a different checksum is rejected rather than overwriting it. ## Audit and database contract V4330 creates tenant-scoped `pay_legacy_account_import` with source identifiers/checksum, normalized provider/config digest, target App/Channel IDs, mapping notes, operator, and timestamp. It deliberately has no `config_public`, `secret_json`, raw config, or secret-value column. Composite foreign keys `(tenant_id,target_app_id)` and `(tenant_id,target_channel_id)` prevent an audit row from pointing across tenants. A target tenant may import the same legacy UUID independently, while duplicate active source IDs inside one tenant are rejected. An existing global audit table without `tenant_id` causes migration failure and requires explicit disposition. Credentials still enter native `pay_channel.config` using Pay's existing configuration storage. EDU-021 prevents an extra audit copy; it does not introduce generic encryption or key rotation for native Pay credentials. ## Verification - Nine focused importer tests pass for WeChat/Alipay mapping, aliases, disabled-state mapping, replay, checksum conflict, unsupported modes/providers, channel mismatch, unsafe endpoints/rotating keys, and audit redaction. A controller contract verifies dual write permission and request-body logging suppression. - The combined Pay selection passes 29 tests, including the prior tenant App/Channel contracts. - All 45 PostgreSQL Flyway integration tests pass through V4380. V4330 coverage verifies tenant-independent legacy UUID reuse, no raw credential columns, cross-tenant composite-FK rejection, mode constraints, migration history, and fail-closed global-table adoption. - The Vben `@vben/web-antd` typecheck passes with the import API, modal, and explicit dual-permission button. ## Explicitly open - Audit history currently has a backend/Vben API contract but no dedicated history table in the account-import modal. EDU-023 provides its own recent transaction-import history table on the native order page. - A controlled export job from the legacy database and operator runbook are still required before production bulk migration. The UI template contains placeholders and must never be submitted unchanged. - Concurrent first imports of the same source account rely on the database unique constraint and transaction rollback; a friendly concurrent-replay response is not claimed. - Platform/service-provider settlement, XPay/Xunhu replacement, generic credential encryption/rotation, and tenant PNVS require separate decisions. - EDU-022 activates tenant-scoped native Pay order, refund, and notification ledgers; EDU-023 adds bounded terminal legacy transaction import; EDU-024 activates empty native Transfer/Wallet ledgers. Production bulk tooling and reviewed opening-balance migration remain open. - EDU-025 delivers native Mall Product activation. Explicit legacy product import, Promotion/Trade, coupon import/redemption, purchase fulfillment, refunds-to-entitlement revocation, and reconciliation remain separate commercialization work.