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.

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:
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:

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

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:

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:

$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.