# Huifu mini-program payment integration ## Implemented server flow 1. The talent application calls `uni.login` and the WeChat phone-number authorization API during sign-in. 2. The server exchanges those short-lived codes with WeChat, verifies the pre-enrolled mobile number, binds the returned `openid`, and issues a talent access session. 3. The talent application creates a payment for a pending benefit order with that access session. 4. The server validates the order, price version, expiry time, owner, session and WeChat `openid`. 5. The server records a payment transaction with an idempotency key and calls Huifu standard API `POST /v3/trade/payment/jspay` with `trade_type=T_MINIAPP`. The `huifu_id` is the collecting sub-merchant fixed on the order, never the platform merchant. 6. The server parses Huifu `pay_info`; the client receives its `appId`, `timeStamp`, `nonceStr`, `package`, `signType`, and `paySign` fields and calls `uni.requestPayment`. 7. Huifu posts `resp_data` and `sign` to the callback URL. The server verifies the original `resp_data` with `SHA256WithRSA`. 8. Only a verified successful callback whose request number, merchant number, order reference and amount match the order may mark the payment transaction and order as paid. Repeated callbacks are idempotent. ## Application endpoints - `POST /api/app/pending-orders/{orderNo}/payment` - Requires `Authorization: Bearer {talentAccessToken}` and requires the session talent to equal the body `talentId`. - Header: `X-Idempotency-Key` (required, 1-128 characters) - Body: `{ "talentId": 1, "priceVersion": 1 }` - Response: `paymentNo`, `paymentStatus`, and provider `paymentParams`. - `GET /api/app/pending-orders/{orderNo}/payment?talentId={id}&paymentNo={paymentNo}` - Returns `NOT_STARTED`, `PAYMENT_PENDING`, `PAYMENT_CREATE_FAILED`, or `PAID`. - `POST /api/payments/huifu/notify` - Huifu callback endpoint. It only accepts `application/x-www-form-urlencoded` data with `resp_data` and `sign`. - A successful processing response is `RECV_ORD_ID_{req_seq_id}`. - `POST /api/app/auth/login` - Body: `{ "wechatCode": "...", "phoneCode": "...", "agreementVersion": "v1", "agreedAt": 0 }`. - Exchanges WeChat codes server-side, binds the authenticated OpenID to the existing talent record matched by mobile number, and returns `talentId`, `accessToken`, `refreshToken`, and `expireAt`. - `POST /api/app/auth/refresh` - Rotates the talent access and refresh tokens. The legacy `/pay` endpoint now rejects calls so a client cannot convert an order to paid state without a verified payment result. ## Deployment configuration Store these values in the deployment secret manager and expose them as environment variables. Do not add keys to `application.yml`. ```text HUIFU_ENABLED=true HUIFU_ENVIRONMENT=test|prod HUIFU_SYSTEM_ID=... # sys_id from Huifu provisioning HUIFU_PRODUCT_ID=... # product_id from Huifu provisioning HUIFU_MERCHANT_ID=... HUIFU_MINI_PROGRAM_APP_ID=... HUIFU_PRIVATE_KEY=... HUIFU_PLATFORM_PUBLIC_KEY=... HUIFU_NOTIFY_URL=https://api.example.com/api/payments/huifu/notify HUIFU_DELAY_ACCOUNT=false # Enable only after delayed-transaction confirmation is implemented. WECHAT_MINI_PROGRAM_APP_ID=... # Usually the same app id used by the Huifu mini-program product. WECHAT_MINI_PROGRAM_APP_SECRET=... # WeChat Mini Program AppSecret, from the WeChat admin console. ``` The callback address must be publicly reachable over HTTPS. Configure the same address with Huifu. Enable the provider only after the Huifu product, merchant, mini-program relationship, signing keys and callback reachability have been verified in the sandbox. ## Manual test procedure 1. In the WeChat Mini Program backend, copy the AppSecret from **Development > Development Management > Development Settings**. Set it as `WECHAT_MINI_PROGRAM_APP_SECRET` on the server process. Do not put it in the client or commit it to source control. 2. Restart the service. The startup migration creates `talent_session`; verify `GET /actuator/health` is `UP`. 3. Compile the talent application in HBuilderX and log in with a WeChat account whose authorized mobile number already exists in `talent_user`. 4. Open **Usage Records > Pending Payment**, choose an unpaid order, and tap payment. The service creates one `payment_transaction` and the app opens the native WeChat payment sheet. 5. Complete payment in the Huifu sandbox. The order must remain pending until the verified callback arrives, then the client status query redirects to payment success. 6. Verify server state without exposing customer data: ```sql select payment_no, status, amount, provider_transaction_no, paid_at from payment_transaction order by id desc; select provider_request_no, signature_valid, process_status, received_at from payment_callback_event order by id desc; ``` Expected successful values: transaction `SUCCEEDED`; callback `signature_valid = 1`, `process_status = PROCESSED`; corresponding `benefit_order.order_status = 1`. ## Order-fixed collecting sub-merchant An order that needs payment is bound to exactly one collecting sub-merchant when it is created, so the payment request, the callback, the reconciliation query and the refund can never drift to another merchant or to the platform account. | Column | Meaning | | --- | --- | | `benefit_order.collect_onboarding_id` | Approved onboarding application that collects the money | | `benefit_order.collect_store_id` | Store of that onboarding | | `benefit_order.collect_huifu_id` | Huifu merchant number (`huifu_id`) that receives the money | | `payment_transaction.provider_huifu_id` | Sub-merchant the prepayment was created against | | `order_refund.provider_huifu_id` | Sub-merchant the refund is requested from | All three order-creation paths (`AppOrderPortalService.create`, `ManualConsumptionService.create` and `OrderService.create`) resolve the target through `MerchantPaymentReadinessService.requireCollectingMerchant` and store it; a merchant whose onboarding is not approved with a non-blank `provider_huifu_id` cannot create a paid order at all. Free orders carry no collecting merchant. `HuifuMiniProgramPaymentGateway`, `HuifuRefundGateway` and the reconciliation query all send the stored `huifu_id`, and a payment whose order has no binding is rejected with a 409 instead of calling Huifu. ## Payment confirmation rules A verified notification (or a successful provider query) is handed to `PaymentSettlementService`, which locks the payment transaction and compares the provider statement with local state before anything changes: | Check | Accepted | Otherwise | | --- | --- | --- | | `trans_stat` | `S`/`SUCCESS`/`PAID`, `F`/`FAILED`, `P`/`PROCESSING`/`PENDING` | `UNKNOWN_STATUS`, no state change | | Request number (order) | `req_seq_id` matches a local `payment_no` whose `order_id`/`order_no` identify the order to settle | `UNMATCHED` | | Merchant number | `huifu_id` equals `payment_transaction.provider_huifu_id` (or the order's `collect_huifu_id`) | `PAYMENT_MERCHANT_MISMATCH`, or `PAYMENT_MERCHANT_UNVERIFIED` when it is absent | | Amount | `trans_amt` (yuan) equals `payment_transaction.amount` (cents) | `PAYMENT_AMOUNT_MISMATCH` | | Payment state | not `CLOSED` and the order is still pending | `PAYMENT_ORDER_CANCELLED` | The order check is the request number itself: `req_seq_id` is the payment number this server generated for exactly one order, the callback is resolved through `payment_transaction.payment_no`, and only that transaction's order can change. A statement naming no local payment changes nothing (`UNMATCHED`), and no other order is ever touched. The amount and merchant number are compared with the local record before any state changes, and a success statement that carries no merchant number is refused rather than trusted. A rejected statement never marks the order paid; it is recorded as an open `payment_reconciliation_issue` row for the management console. An accepted one sets the transaction to `SUCCEEDED`, the order to paid, and `STORE_CONSUME.verify_status = 1`, whether it arrived through the callback or through a reconciliation query. Duplicate notifications are idempotent at two levels: an identical payload for an already processed request is acknowledged without processing (`received_at` still updates), and a repeated success with a different payload only converges the already settled state. A payload may carry the business fields at the top level or nested under `data` (object or JSON string). ## Refunds of already collected payments A refund is requested from the merchant that collected the payment. New payments carry that sub-merchant in `payment_transaction.provider_huifu_id` and `order_refund.provider_huifu_id`. Payments made before the collecting sub-merchant was recorded were collected by the configured platform merchant (`HUIFU_MERCHANT_ID`), and that fact is not stored anywhere, so those refunds fall back to the platform merchant exactly as before. Migration `V131` therefore backfills only in-flight pending orders and never invents a collecting sub-merchant for money that has already moved. ## Order fund boundary (verification, refund, withdrawal lock) `benefit_order` carries the money state of one order: `order_status` (paid or not), `verify_status` (consumed by the merchant or not), `refund_status` (`0` none, `1` refund in flight, `2` refunded) and `withdrawal_status` (`0` not in a withdrawal record, `1` locked into one). `OrderFundBoundaryService` owns the rules and the writes for the last two: every verification path, the refund path (request, provider query *and* the asynchronous refund callback) and the daily aggregation lock the same `benefit_order` row with `SELECT ... FOR UPDATE` first, so verification, refund and withdrawal locking are serialized per order instead of racing. The rules it enforces: - An order can only be verified when it is paid, not yet verified, has no effective refund, and is not in a withdrawal record. `verify_record` additionally has a generated column plus unique key (`uk_verify_record_active_order`), so one order can never hold two active verification records. - A paid, unverified order can be refunded. Accepting the refund immediately sets `refund_status = 1`, which blocks verification until the provider answers; a successful refund (local response, provider query or asynchronous callback) sets it to `2`, which excludes the whole order from withdrawal aggregation (a partial refund also excludes the order, by the agreed policy). A refund that the provider declares failed returns the order to `refund_status = 0`, but a late failure notification never reopens an order whose refund already succeeded. - `lockIntoWithdrawal` (called by the daily aggregation of ticket 04) only accepts an order that is paid, verified, unrefunded and not yet in another withdrawal record. Once locked, the order can neither be revoked nor refunded. Migration `V132` adds the two columns, backfills `refund_status` from `order_refund`, and marks duplicate historical verification records as revoked (keeping the earliest one) before creating the unique key. ## Automatic settlement The platform no longer aggregates daily orders and no longer calls Huifu encashment. Delayed account plus transaction confirmation already keep unverified money away from the merchant, and payout is left to Huifu: - onboarding opens automatic settlement for every merchant (settle_status=1, cycle D1, batch settlement, no minimum and no retained amount), so a one-cent settlement is possible; - after verification confirms the delayed transaction, the money sits in the merchant's basic account and Huifu settles it to the bound settlement card on the next natural day; - the platform stores no withdrawal rows. The admin **结算记录** page queries Huifu settlement records (/v2/merchant/basicdata/settlement/query) to monitor how much was settled each day. Migration V140 drops merchant_withdrawal, merchant_withdrawal_order and the order's withdrawal_status. ## Reconciliation and exception handling The server records verified callback audit events, then uses the provider query API as a second confirmation path for payments and refunds that remain pending. It never converts an order to paid from a client request or a local timer result alone. The payment query runs against the order's bound sub-merchant (`coalesce(payment_transaction.provider_huifu_id, benefit_order.collect_huifu_id)`) and its result goes through the same settlement rules as a callback, so a late callback and a successful query produce the same order state. Set these values only after sandbox verification. The scheduler is deliberately off by default so a local startup does not call the real provider: ```text HUIFU_RECONCILIATION_ENABLED=true HUIFU_RECONCILIATION_FIXED_DELAY_MS=300000 HUIFU_RECONCILIATION_PENDING_AFTER_MINUTES=5 ``` The scheduler checks up to 100 old pending payments and 100 old pending refunds per run. It writes non-sensitive exceptions to `payment_reconciliation_issue`: pending timeout, provider-declared failure, query failure, and the mismatch types listed above. It does not store callback bodies, signatures, OpenID values, card numbers, or keys. Administrators can use **Payment reconciliation** in the management console to inspect open issues, run a one-off check, and mark a confirmed issue as resolved or ignored. Marking a row resolved does not alter payment or refund state; it is an audit action only. ## Deployment and acceptance checklist 1. Rotate the Huifu RSA private key and WeChat AppSecret if either was ever committed, pasted into chat, copied into a log, or otherwise exposed. Put the replacement values only in the deployment secret store as the environment variables listed above. 2. Configure `HUIFU_NOTIFY_URL` as a public HTTPS URL in both deployment configuration and the Huifu console. Confirm that the ingress/WAF permits `POST /api/payments/huifu/notify` with form data and does not rewrite `resp_data` or `sign`. 3. Confirm the Huifu merchant, product, mini-program AppID, callback signing public key, and the WeChat mini-program AppID/AppSecret belong to the same target environment (sandbox or production). 4. In the WeChat console, configure the server request domain and complete the production domain verification. The mini-program must use the same AppID that is configured in Huifu. 5. Run a successful payment and verify `payment_transaction.status = SUCCEEDED`, a callback event with `signature_valid = 1`, and `benefit_order.order_status = 1`. Confirm the sandbox callback really carries `huifu_id` and `trans_amt`: those fields are required for the payment to settle, and a sandbox callback that omits them leaves the order pending with an open reconciliation issue. 6. Send a duplicate callback from the provider test facility. The status and paid amount must remain unchanged while the callback audit timestamp updates. 7. Send a callback with another merchant number or a different `trans_amt`. The order must stay pending and `payment_reconciliation_issue` must gain a `PAYMENT_MERCHANT_MISMATCH` or `PAYMENT_AMOUNT_MISMATCH` row. 8. Temporarily block or delay the callback in sandbox, wait past the configured threshold, then run reconciliation. The provider query must either settle the local transaction or create an open reconciliation issue. 9. Run a refund, repeat the delayed-callback test, and confirm that only a verified callback or provider query can mark `order_refund.refund_status = 2`. 10. Test invalid callback signatures, provider timeouts, duplicate payment button taps, expired orders, refunded orders, and provider-declared failures. No case may expose payment parameters, OpenID, keys, signatures, or full callback payloads in logs or API responses. ## Merchant payment readiness (settlement and encashment capability) A merchant may only take paid orders once its sub-merchant configuration is confirmed. The server reads that configuration with the Huifu `POST /v2/merchant/basicdata/query` interface (`v2.merchant.basicdata.query`) and stores the result on the onboarding application: | Stored column | Provider field | Ready value | | --- | --- | --- | | `merchant_stat` | `mer_stat` | `NORMAL` (any other value blocks payment) | | `auto_settle_status` | `qry_settle_config_list[].settle_status` | `0` (automatic settlement off) | | `cash_switch_status` | `qry_cash_config_list[].switch_state` | `1` (encashment available) | | `cash_type` | `qry_cash_config_list[].cash_type` | recorded for reconciliation (`D0`/`D1`/`T1`/`DM`) | | `capability_verified_at` | query timestamp | set only after a successful query | Rules enforced by `MerchantPaymentReadinessService`: 1. The newest approved onboarding of the merchant (optionally scoped to a store) must exist. 2. That onboarding must own an approved settlement bank card with a provider `token_no`. Because a merchant may replace its settlement card, an approved card for the same store also satisfies this rule. 3. `merchant_stat` must be `NORMAL`. 4. Automatic settlement must be off; the provider console is the place to switch it off, and the next capability refresh confirms it. 5. `qry_cash_config_list[].switch_state` must be `1`. When the snapshot has never been confirmed the service queries the provider on demand. That query always runs outside any surrounding database transaction, so a slow Huifu call never holds payment or onboarding row locks; a failed query writes no snapshot and leaves the merchant blocked. When `app.payment.huifu.enabled=false` (local development and tests) no outbound provider call is made: only rules 1 and 2 are enforced, plus rules 4 and 5 for whatever the stored snapshot already says. `MerchantPaymentCapabilityScheduler` refreshes approved sub-merchants whose snapshot is older than `app.payment.huifu.capability-refresh.stale-hours` (default 6 hours), in its own transaction per merchant so one failing merchant never affects the others. Administrators can force a refresh from the management console. When onboarding completes, the snapshot refresh is registered as an `afterCommit` synchronization. The onboarding and business-open callbacks reach that point while their own transaction still holds the application row, and the snapshot ends up written from a second connection (`REQUIRES_NEW` for the read, `PROPAGATION_NOT_SUPPORTED` for the provider query and the update), so refreshing inside the transaction waited for its own row lock and failed with `CannotAcquireLockException` after `innodb_lock_wait_timeout` instead of filling the snapshot — an approved merchant that can never collect money. For the same reason the refresh path reads the application without `for update`: locking that row cannot serialize a refresh that happens on another connection, it only collides with the caller's lock. Migrations `V137` and `V138` add the onboarding columns the submission path writes but the earlier migrations never created (`registered_province_code`/`registered_city_code`/ `registered_district_code`, `license_validity_type`/`license_begin_date`/`license_end_date`, `subject_cert_end_date`); without them the very first onboarding submission failed with `Unknown column ... in 'field list'`. `MerchantOnboardingSchemaInitializer` adds the same columns for deployments that run without Flyway. ### Management endpoints - `GET /api/admin/management/merchant-settlement/onboardings/{id}/payment-capability` - Returns `applicationId`, `huifuId`, `merchantStatus`, `autoSettleDisabled`, `autoSettleStatus`, `cashAvailable`, `cashSwitchStatus`, `verifiedAt`. It queries the provider when the snapshot has never been confirmed. - `POST /api/admin/management/merchant-settlement/onboardings/{id}/payment-capability/refresh` - Re-reads the provider configuration, stores a fresh snapshot and returns the same shape. ### Configuration ```text HUIFU_MERCHANT_CAPABILITY_REQUIRED=true # require provider confirmation (defaults to the enabled flag) HUIFU_CAPABILITY_REFRESH_ENABLED=true HUIFU_CAPABILITY_STALE_HOURS=6 HUIFU_CAPABILITY_REFRESH_FIXED_DELAY_MS=1800000 ``` ### Running the server tests Several migrations use MySQL-only syntax that the in-memory H2 default of `application-test.yml` cannot parse, so the Spring tests only load when pointed at MySQL. Run them through the script, which sets the variables, keeps one Maven build at a time and writes `target/server-tests.log`: ```powershell cd talent-card-server .\scripts\run-server-tests.ps1 # whole suite .\scripts\run-server-tests.ps1 -Test PaymentSettlementTests # one class .\scripts\run-server-tests.ps1 -Reset # rebuild the schema first ``` The equivalent manual invocation, when a raw Maven run is needed: ```powershell $env:TEST_DATASOURCE_URL='jdbc:mysql://127.0.0.1:8134/talent_card_test?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false&createDatabaseIfNotExist=true' $env:TEST_DATASOURCE_USERNAME='root' $env:TEST_DATASOURCE_PASSWORD='123456' $env:TEST_DATASOURCE_DRIVER='com.mysql.cj.jdbc.Driver' .\mvnw.cmd -o test ``` Schema state, the migration append-only rule and the known baseline failures are documented in `docs/agents/testing.md`. `MerchantPaymentReadinessServiceTests`, `MerchantSettlementCallbackIdempotencyTests` and `AppOrderPortalServiceTests` cover the readiness snapshot, the payment gate for paid orders and callback idempotency. They stub `HuifuMerchantCapabilityGateway`, so they never call Huifu. `PaymentSettlementTests` covers the payment closure: the order-fixed collecting sub-merchant, the payment request and the refund using that sub-merchant, callback amount/merchant/order validation, duplicate notifications, unknown request numbers and statuses, cancellation after a late success, nested callback payloads, and query compensation. It stubs `HuifuSignatureService`, `HuifuMiniProgramPaymentGateway` and `HuifuRefundGateway`, so it never signs, pays, refunds or queries the real provider. ## Remaining provider-dependent work - Delayed-account confirmation must only be enabled after the corresponding Huifu product and confirmation API are provisioned and tested. ## Settlement records HuifuSettlementRecordGateway queries Huifu settlement records for one sub-merchant over a date range. The admin endpoint GET /api/admin/management/merchant-settlement/settlements asks every approved onboarding for the range (default: last 30 days), merges the rows and returns them newest first. A merchant whose query fails is reported in ailures instead of failing the whole page.