Integrating WeChat Pay for Education Mini‑Programs: Automated Deposit and Final‑Payment Settlement
This article walks through the complete WeChat Pay integration for an education‑focused mini‑program, covering the business flow, database schema, Java backend implementation for order creation, callback handling, automatic refunds, scheduled tasks, common pitfalls, and practical tips for reliable payment processing.
1. Clarify the Business Process
The typical education‑center trial‑class flow includes:
Parent books a class and pays a 49 CNY deposit.
If the class is attended, the deposit is refunded within 1‑3 business days.
If the parent no‑shows or cancels late, the deposit is retained as compensation.
If the appointment is cancelled within two hours, the deposit is fully refunded automatically.
Technical points involved:
WeChat Pay JSAPI order creation
Payment result notification (callback)
Refund API (original‑path refund)
Scheduled tasks + state‑machine to manage order lifecycle
2. Database Design (Payment‑Related Tables)
2.1 Payment Order Table
CREATE TABLE `payment_order` (
`id` bigint PRIMARY KEY AUTO_INCREMENT,
`out_trade_no` varchar(32) NOT NULL COMMENT 'Merchant order number (unique)',
`transaction_id` varchar(64) COMMENT 'WeChat Pay transaction ID',
`appointment_id` bigint NOT NULL COMMENT 'Associated appointment ID',
`user_id` bigint NOT NULL,
`amount` int NOT NULL COMMENT 'Amount (cents)',
`status` tinyint DEFAULT 0 COMMENT '0‑Pending, 1‑Success, 2‑Refunded, 3‑Failed, 4‑Closed',
`pay_time` datetime COMMENT 'Payment completion time',
`refund_time` datetime COMMENT 'Refund time',
`refund_id` varchar(64) COMMENT 'WeChat refund ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`expire_time` datetime COMMENT 'Order expiration (usually 30 min)'
);2.2 Refund Record Table
CREATE TABLE `refund_record` (
`id` bigint PRIMARY KEY AUTO_INCREMENT,
`out_trade_no` varchar(32) NOT NULL,
`out_refund_no` varchar(32) NOT NULL COMMENT 'Merchant refund number',
`refund_amount` int NOT NULL COMMENT 'Refund amount (cents)',
`status` tinyint DEFAULT 0 COMMENT '0‑Processing, 1‑Success, 2‑Failed',
`reason` varchar(200) COMMENT 'Refund reason',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP
);2.3 Extend Appointment Table
ALTER TABLE `appointment`
ADD COLUMN `deposit_paid` tinyint DEFAULT 0 COMMENT '0‑Unpaid, 1‑Paid',
ADD COLUMN `deposit_refunded` tinyint DEFAULT 0 COMMENT '0‑Not refunded, 1‑Refunded',
ADD COLUMN `pay_expire_time` datetime COMMENT 'Payment deadline';3. WeChat Pay Integration (Java Backend)
3.1 Maven Dependency
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-java</artifactId>
<version>0.2.11</version>
</dependency>3.2 Configuration Class (WeChat Pay Parameters)
@Configuration
@ConfigurationProperties(prefix = "wechat.pay")
@Data
public class WechatPayConfig {
private String appId; // Mini‑program AppID
private String mchId; // Merchant ID
private String apiV3Key; // APIv3 key
private String privateKeyPath; // Merchant private key path
private String mchSerialNo; // Merchant certificate serial number
private String notifyUrl; // Payment callback URL
@Bean
public Config wechatPayConfig() throws Exception {
PrivateKey privateKey = PemUtil.loadPrivateKey(new FileInputStream(privateKeyPath));
return new RSAAutoCertificateConfig.Builder()
.merchantId(mchId)
.privateKey(privateKey)
.merchantSerialNumber(mchSerialNo)
.apiV3Key(apiV3Key)
.build();
}
}3.3 Core Service: Create Payment Order
@Service
public class PaymentService {
@Autowired
private Config wechatPayConfig;
@Autowired
private PaymentOrderMapper paymentOrderMapper;
@Autowired
private AppointmentMapper appointmentMapper;
/** Create a payment order and return parameters required by the mini‑program */
public Map<String, String> createPayment(Long appointmentId, String openid) {
// 1. Validate appointment
Appointment appointment = appointmentMapper.selectById(appointmentId);
if (appointment == null || appointment.getStatus() != 1) {
throw new BusinessException("Appointment does not exist or status is invalid");
}
if (appointment.getDepositPaid() == 1) {
throw new BusinessException("Order already paid, do not repeat");
}
// 2. Generate merchant order number: P + timestamp + 4‑digit random
String outTradeNo = "P" + System.currentTimeMillis() + RandomUtil.randomNumbers(4);
// 3. Persist payment order (status: pending)
PaymentOrder paymentOrder = new PaymentOrder();
paymentOrder.setOutTradeNo(outTradeNo);
paymentOrder.setAppointmentId(appointmentId);
paymentOrder.setUserId(appointment.getUserId());
paymentOrder.setAmount(4900); // 49 CNY = 4900 cents
paymentOrder.setStatus(0);
paymentOrder.setExpireTime(DateUtil.offsetMinute(new Date(), 30));
paymentOrderMapper.insert(paymentOrder);
// 4. Call WeChat Pay unified order API
HttpUrlBuilder urlBuilder = HttpUrlBuilder.instance();
String url = urlBuilder.build("/v3/pay/transactions/jsapi");
Map<String, Object> bodyMap = new HashMap<>();
bodyMap.put("appid", wechatPayConfig.getAppId());
bodyMap.put("mchid", wechatPayConfig.getMchId());
bodyMap.put("description", "Programming trial class deposit");
bodyMap.put("out_trade_no", outTradeNo);
bodyMap.put("notify_url", wechatPayConfig.getNotifyUrl());
bodyMap.put("amount", Map.of("total", 4900, "currency", "CNY"));
bodyMap.put("payer", Map.of("openid", openid));
HttpRequest request = HttpRequest.post(url)
.body(JSON.toJSONString(bodyMap))
.header("Content-Type", "application/json");
// 5. Add WeChat Pay signature
String authorization = WechatPayUtil.buildAuthorization(
"POST",
"/v3/pay/transactions/jsapi",
JSON.toJSONString(bodyMap),
wechatPayConfig);
request.header("Authorization", authorization);
HttpResponse response = request.execute();
String result = response.body();
// 6. Parse prepay_id
JSONObject jsonObject = JSON.parseObject(result);
String prepayId = jsonObject.getString("prepay_id");
// 7. Build parameters for mini‑program payment
return buildPayParams(prepayId);
}
/** Generate signature parameters for mini‑program payment */
private Map<String, String> buildPayParams(String prepayId) {
String nonceStr = RandomUtil.randomString(32);
long timestamp = System.currentTimeMillis() / 1000;
String packageStr = "prepay_id=" + prepayId;
String signStr = wechatPayConfig.getAppId() + "
" +
timestamp + "
" +
nonceStr + "
" +
packageStr + "
";
String paySign = WechatPayUtil.sign(signStr, wechatPayConfig);
Map<String, String> params = new HashMap<>();
params.put("timeStamp", String.valueOf(timestamp));
params.put("nonceStr", nonceStr);
params.put("package", packageStr);
params.put("signType", "RSA");
params.put("paySign", paySign);
return params;
}
}3.4 Payment Result Callback (Critical Part)
@PostMapping("/wechat/pay/notify")
public String payNotify(HttpServletRequest request) {
try {
// 1. Read callback payload
String body = request.getReader().lines().collect(Collectors.joining());
// 2. Verify signature
WechatPayValidator validator = new WechatPayValidator(request, body, wechatPayConfig);
if (!validator.isValid()) {
return buildNotifyResponse("FAIL", "Signature verification failed");
}
// 3. Parse encrypted data
JSONObject resource = JSON.parseObject(body).getJSONObject("resource");
String ciphertext = resource.getString("ciphertext");
String associatedData = resource.getString("associated_data");
String nonce = resource.getString("nonce");
// 4. Decrypt
String plainText = WechatPayUtil.decrypt(ciphertext, associatedData, nonce, wechatPayConfig.getApiV3Key());
JSONObject payResult = JSON.parseObject(plainText);
String outTradeNo = payResult.getString("out_trade_no");
String transactionId = payResult.getString("transaction_id");
String tradeState = payResult.getString("trade_state");
String successTime = payResult.getString("success_time");
// 5. Idempotency: check if order already processed
PaymentOrder paymentOrder = paymentOrderMapper.selectByOutTradeNo(outTradeNo);
if (paymentOrder == null) {
return buildNotifyResponse("FAIL", "Order not found");
}
if (paymentOrder.getStatus() == 1) {
return buildNotifyResponse("SUCCESS", "OK"); // already processed
}
// 6. Update order status on success
if ("SUCCESS".equals(tradeState)) {
paymentOrder.setTransactionId(transactionId);
paymentOrder.setStatus(1);
paymentOrder.setPayTime(DateUtil.parse(successTime));
paymentOrderMapper.updateById(paymentOrder);
// 7. Mark deposit as paid in appointment
Appointment appointment = new Appointment();
appointment.setId(paymentOrder.getAppointmentId());
appointment.setDepositPaid(1);
appointmentMapper.updateById(appointment);
// 8. Optional: send template message to parent
sendPaySuccessNotice(paymentOrder.getUserId());
}
return buildNotifyResponse("SUCCESS", "OK");
} catch (Exception e) {
log.error("Payment callback handling failed", e);
return buildNotifyResponse("FAIL", e.getMessage());
}
}
private String buildNotifyResponse(String code, String message) {
return String.format("{\"code\":\"%s\",\"message\":\"%s\"}", code, message);
}4. Automatic Refund Logic (After Course Completion)
4.1 Scenario 1 – Normal Attendance → Automatic Refund
@Service
public class RefundService {
/** Refund deposit after class ends */
@Scheduled(cron = "0 0 10 * * ?") // every day at 10 am
public void autoRefundForCompletedClasses() {
List<Appointment> completedList = appointmentMapper.selectCompletedUnrefunded();
for (Appointment ap : completedList) {
try {
refundDeposit(ap.getId(), "Course completed, refunding deposit");
} catch (Exception e) {
log.error("Automatic refund failed, appointment ID:{}", ap.getId(), e);
}
}
}
/** Core refund logic */
public void refundDeposit(Long appointmentId, String reason) {
// 1. Find paid order
PaymentOrder paymentOrder = paymentOrderMapper.selectByAppointmentId(appointmentId);
if (paymentOrder == null || paymentOrder.getStatus() != 1) {
throw new BusinessException("Paid order not found");
}
if (paymentOrder.getStatus() == 2) {
log.info("Order already refunded, skip");
return;
}
// 2. Generate refund number
String outRefundNo = "R" + System.currentTimeMillis() + RandomUtil.randomNumbers(4);
// 3. Call WeChat refund API
String url = "https://api.mch.weixin.qq.com/v3/refund/domestic/refunds";
Map<String, Object> bodyMap = new HashMap<>();
bodyMap.put("out_trade_no", paymentOrder.getOutTradeNo());
bodyMap.put("out_refund_no", outRefundNo);
bodyMap.put("amount", Map.of(
"refund", paymentOrder.getAmount(),
"total", paymentOrder.getAmount(),
"currency", "CNY"));
bodyMap.put("reason", reason);
String result = wechatPayRequest(url, "POST", bodyMap);
JSONObject resultJson = JSON.parseObject(result);
// 4. Save refund record
RefundRecord refundRecord = new RefundRecord();
refundRecord.setOutTradeNo(paymentOrder.getOutTradeNo());
refundRecord.setOutRefundNo(outRefundNo);
refundRecord.setRefundAmount(paymentOrder.getAmount());
refundRecord.setStatus(1); // mark success (actual async callback may update later)
refundRecord.setReason(reason);
refundRecordMapper.insert(refundRecord);
// 5. Update original order status
paymentOrder.setStatus(2);
paymentOrder.setRefundTime(new Date());
paymentOrder.setRefundId(resultJson.getString("refund_id"));
paymentOrderMapper.updateById(paymentOrder);
// 6. Update appointment record
Appointment appointment = new Appointment();
appointment.setId(appointmentId);
appointment.setDepositRefunded(1);
appointmentMapper.updateById(appointment);
}
}4.2 Scenario 2 – No‑Show / Late Cancellation → Deposit Retained
// In the no‑show scheduled task from the previous article
if (missedAppointment.getDepositPaid() == 1 && missedAppointment.getDepositRefunded() == 0) {
// Deposit is not refunded, mark as institution revenue
sendNoShowNotice(user.getOpenid());
// Insert income record for reconciliation
incomeRecordMapper.insert(ap.getId(), paymentOrder.getAmount(), "No‑show charge");
}5. Mini‑Program Front‑End: Trigger Payment
// pages/appointment/pay.js
Page({
data: {
appointmentId: null,
payParams: null
},
onLoad(options) {
this.setData({ appointmentId: options.id });
},
// Click "Pay Now"
handlePay() {
wx.showLoading({ title: 'Creating order...' });
wx.request({
url: 'https://api.yourdomain.com/payment/create',
method: 'POST',
data: { appointmentId: this.data.appointmentId },
success: (res) => {
wx.hideLoading();
if (res.data.code === 200) {
const payParams = res.data.data;
wx.requestPayment({
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonceStr,
package: payParams.package,
signType: payParams.signType,
paySign: payParams.paySign,
success: () => {
wx.showToast({ title: 'Payment successful' });
wx.redirectTo({ url: '/pages/appointment/success?id=' + this.data.appointmentId });
},
fail: (err) => {
console.error('Payment failed', err);
wx.showModal({ title: 'Notice', content: 'Payment failed, please retry', showCancel: false });
}
});
} else {
wx.showToast({ title: res.data.msg, icon: 'none' });
}
}
});
}
});6. Common Pitfalls and Solutions
Pitfall 1 – Callback Not Received or Received Multiple Times
Symptoms: User pays but database status is not updated.
Callback URL must be a publicly reachable HTTPS address (no localhost, no HTTP).
Callback may be delayed by seconds or minutes; WeChat retries (15 s, 30 s, 1 min, …).
Solutions:
Make the callback endpoint idempotent – processing the same order multiple times must not duplicate updates.
Use out_trade_no with a unique index to prevent duplicate inserts.
After a successful payment, actively query the WeChat order status as a fallback.
Pitfall 2 – Refund Amount Exceeds Paid Amount
Symptoms: Refund API returns INVALID_REQUEST.
Cause: An order can be refunded only once, or the requested amount is larger than the original payment.
Solution: Before refunding, query the original order amount and strictly validate. For education deposits, a full‑amount refund is simplest; partial refunds require precise calculation.
Pitfall 3 – Refund Arrival Time
Symptoms: Users ask why the refund hasn't arrived.
Cause: WeChat refunds are not instantaneous; they take 0‑3 business days.
Solutions:
Clearly inform users that refunds are returned to the original path within 1‑3 business days.
Add a refund_status field to the appointment record (e.g., "Refunding", "Refunded", "Failed") so users can query progress.
If a refund fails (e.g., user has disabled WeChat Pay), create an offline transfer record and flag it for manual handling.
Pitfall 4 – Expired Orders Not Closed
Symptoms: Users can still pay after the 30‑minute window.
Cause: No scheduled task closes expired orders.
Solution: Implement a periodic job that queries unpaid orders whose expire_time has passed, calls WeChat’s close‑order API, and updates the status to "Closed" (4). Optionally release the reserved appointment slot.
@Scheduled(cron = "0 */5 * * * ?") // every 5 minutes
public void closeExpiredOrders() {
List<PaymentOrder> expiredList = paymentOrderMapper.selectExpiredUnpaid();
for (PaymentOrder order : expiredList) {
closeWechatOrder(order.getOutTradeNo()); // call WeChat close API
order.setStatus(4);
paymentOrderMapper.updateById(order);
// Optional: release appointment quota
}
}7. Full Textual Sequence Diagram
Parent Mini‑Program Java Backend WeChat Pay Scheduled Tasks
| | | |
|--Click Pay--------->| | |
| |--Unified Order API---->| |
| |<--prepay_id-----------| |
|<--Return payment params---------------| |
|--Trigger payment---------------------->| |
| | | |
|--Enter password----------------------->| |
| |<--Payment callback-------------------|
| |--Update order status| |
|<--Show success------------------------| |
| | | |
| | |--Course ends-->|
| | | |--Auto refund-->
| |--Call refund API--------------------|
|<--Refund received (1‑3 days)------------------------|8. Final Thoughts
Integrating WeChat Pay itself is straightforward; the real challenge lies in managing the business state machine – appointment, payment, attendance, refund, and no‑show – each step requires clear status transitions and compensation mechanisms.
Recommendations:
Start with WeChat’s sandbox environment for testing; avoid real money during early development.
Prepare a daily reconciliation script to compare WeChat merchant reports with your local database.
Set up monitoring alerts (e.g., Feishu/DingTalk) for payment callback failures or refund failures exceeding a threshold.
Education‑center payment scenarios test business understanding more than raw concurrency tricks; a well‑designed state machine is essential.
Next time we will explore how education mini‑programs can implement referral‑based distribution, automatically calculate commissions, and support WeChat auto‑withdrawal to users’ wallets.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Coder Trainee
Experienced in Java and Python, we share and learn together. For submissions or collaborations, DM us.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
