自研APP积分如何在支付宝APP扫码支付时抵扣现金
结论
自研积分无法直接抵扣用户扫描任意商家支付宝收款码后的付款金额。
街边商家的支付宝收款码、订单金额和资金结算,均由支付宝以及商家的收单关系控制。你的系统既不是这笔交易的收款方,也无权创建订单,因此无法在支付宝付款页面加入自有积分选项,更不能在支付完成后自行减少支付宝的实际扣款金额。
要使用积分抵扣,商家必须接入你的平台,而且你的系统需要参与订单创建。常见流程如下:
- 用户扫描平台提供或管理的二维码。
- 系统识别用户和商家。
- 用户选择是否使用积分。
- 服务端计算抵扣金额并冻结积分。
- 系统只将剩余金额提交给支付宝。
- 收到支付宝异步通知后,系统确认支付结果,正式扣除积分,并与商家结算。
如果积分抵扣后应付金额为零,就不需要调用支付宝,可以直接完成积分支付。
为什么不能直接抵扣普通支付宝收款码
用户扫描普通支付宝商家码时,交易链路通常是:
用户支付宝 → 支付宝收银台 → 商家支付宝账户
你的积分系统不在这条交易链路中,所以无法:
- 获取或修改用户输入的付款金额;
- 在支付宝收银台加入”使用某某积分”选项;
- 在支付宝扣款前锁定或扣除积分;
- 改变支付宝向商家结算的实际金额;
- 可靠处理退款、撤销及积分返还。
即使系统能通过截图、支付通知或人工录入得知支付结果,也只能在付款后奖励或返还积分,不能算作支付时的实时抵扣。
可行的业务模式
方案一:商家接入你的聚合收银系统
这是较为常见,也容易形成完整业务闭环的方案。
商家改用你的收款页面、收银台或二维码。用户扫码后进入支付宝小程序、H5 页面或你的应用页面,服务端先计算积分抵扣,再创建支付宝订单。
例如,订单原价为 20 元,用户使用 600 积分,兑换规则为 100 积分抵 1 元:
积分抵扣金额 = 600 ÷ 100 = 6 元
支付宝支付金额 = 20 - 6 = 14 元
支付宝只处理剩余的 14 元。积分抵扣的 6 元需要由平台按照协议与商家结算,支付宝不会自动把这部分钱补给商家。
完整链路如下:
用户扫码
↓
进入平台收银台并识别用户、商家
↓
服务端查询积分和抵扣规则
↓
创建业务订单并冻结积分
↓
按剩余金额创建支付宝交易
↓
用户在支付宝完成付款
↓
支付宝异步通知平台
↓
平台核验通知并提交积分扣减
↓
订单完成,进入商家结算
采用这种方案,商家必须主动加入你的体系,并更换或增加由平台管理的收款入口。
方案二:用户先在你的系统兑换优惠权益
如果暂时不能接管商家的支付订单,可以让用户用积分兑换:
- 商家优惠券;
- 平台代金券;
- 满减资格;
- 支付后返现;
- 商家储值余额。
支付后返积分或返现相对容易实现,但不属于实时抵扣。用户仍要先按原金额向商家付款,再由你的系统发放奖励。
如果希望优惠直接显示在支付宝收银台,就要接入支付宝相应的营销、优惠券或商家能力,并满足产品准入、签约及接口权限要求。支付宝开放平台的产品政策可能调整,具体能力应以实际申请到的接口权限为准,不能默认任意自建积分都能直接显示在收银台。
方案三:平台作为统一交易和结算参与方
平台统一管理商户、订单、优惠及结算。商家不再单独使用普通个人收款码,改用平台提供的商户码。
这种模式的控制能力更强,但需要重点确认:
- 平台与商家的签约关系;
- 支付产品是否允许这样的交易结构;
- 商户进件和资金结算方式;
- 是否涉及二清、备付金或其他支付合规问题;
- 发票、退款、对账及税务责任。
平台不应先通过自己的普通支付宝账户统一收款,再私下将资金分给大量商户。这种设计可能产生严重的资金和合规风险。应使用支付宝允许的商户、服务商或分账结算能力,具体方案需要支付宝服务商或合规人员确认。
积分系统应该如何设计
积分系统不能只保存一个可以随意修改的余额,至少应包括余额账户、流水、冻结记录和业务订单四类数据。
积分账户
point_account
- user_id
- available_points
- frozen_points
- version
- updated_at
version 可以用于乐观锁,防止用户并发支付时重复使用同一批积分。
积分流水
point_ledger
- id
- user_id
- business_order_no
- change_points
- change_type
- balance_after
- status
- created_at
change_type 可以包括:
EARN 获得积分
FREEZE 冻结积分
DEDUCT 正式扣减
RELEASE 解冻积分
REFUND 退款返还
EXPIRE 积分过期
ADJUST 人工调整
每次积分变动都应生成不可随意覆盖的流水,供审计和对账使用。
业务订单
business_order
- order_no
- merchant_id
- user_id
- original_amount
- point_amount
- cash_amount
- used_points
- status
- alipay_trade_no
- created_at
- paid_at
金额建议以人民币”分”为单位,使用整数保存,避免浮点数精度问题:
original_amount_cent = 2000
point_amount_cent = 600
cash_amount_cent = 1400
数据应始终满足:
original_amount_cent
= point_amount_cent + cash_amount_cent
推荐的支付状态设计
业务订单可以采用以下状态:
CREATED 已创建
POINT_FROZEN 积分已冻结
PAYING 等待支付宝付款
PAID 现金支付成功
COMPLETED 积分已扣减,订单完成
CLOSED 订单关闭
REFUNDING 退款处理中
REFUNDED 已退款
不要在用户点击”立即支付”时直接扣除积分。更稳妥的做法是先冻结积分:
- 支付成功后,将冻结积分转为正式扣减;
- 支付失败或超时后,释放冻结积分;
- 支付结果未知时,继续冻结积分,并主动查询支付宝交易状态;
- 收到退款后,按照退款规则返还相应积分。
服务端计算示例
以下代码只展示积分抵扣和冻结的核心逻辑,不代表支付宝官方 SDK 的完整调用方式。
public PaymentQuote createPaymentQuote(
long userId,
long merchantId,
long originalAmountCent,
long requestedPoints) {
PointRule rule = pointRuleService.getRule(merchantId);
if (!rule.isEnabled()) {
requestedPoints = 0;
}
long availablePoints = pointAccountService.getAvailablePoints(userId);
long usablePoints = Math.min(requestedPoints, availablePoints);
// 示例:100 积分抵扣 100 分,即 1 元。
long pointAmountCent =
usablePoints * rule.getCentPerPoint();
long maxDiscountCent =
originalAmountCent * rule.getMaxDiscountPercent() / 100;
pointAmountCent = Math.min(pointAmountCent, maxDiscountCent);
pointAmountCent = Math.min(pointAmountCent, originalAmountCent);
// 根据实际抵扣金额反算需要使用的积分。
long actualUsedPoints =
pointAmountCent / rule.getCentPerPoint();
pointAmountCent =
actualUsedPoints * rule.getCentPerPoint();
long cashAmountCent =
originalAmountCent - pointAmountCent;
String orderNo = orderNoGenerator.next();
transactionTemplate.executeWithoutResult(status -> {
pointAccountService.freeze(
userId,
orderNo,
actualUsedPoints
);
orderService.create(
orderNo,
merchantId,
userId,
originalAmountCent,
pointAmountCent,
cashAmountCent,
actualUsedPoints
);
});
return new PaymentQuote(
orderNo,
originalAmountCent,
pointAmountCent,
cashAmountCent,
actualUsedPoints
);
}
不能直接信任客户端提交的订单原价、抵扣金额和最终金额。服务端必须按照商品价格、商家规则、用户余额及活动限制重新计算。
创建支付宝支付订单
当 cashAmountCent > 0 时,服务端按照支付宝支付产品的要求,为现金部分创建交易。
伪代码如下:
BusinessOrder order = orderService.getForUpdate(orderNo);
if (order.getStatus() != OrderStatus.POINT_FROZEN) {
throw new IllegalStateException("Invalid order status");
}
if (order.getCashAmountCent() == 0) {
pointAccountService.commitFrozenPoints(
order.getUserId(),
order.getOrderNo()
);
orderService.complete(order.getOrderNo());
return PaymentResult.pointOnly(order.getOrderNo());
}
String amount = BigDecimal
.valueOf(order.getCashAmountCent(), 2)
.setScale(2, RoundingMode.UNNECESSARY)
.toPlainString();
AlipayOrderRequest request = new AlipayOrderRequest();
request.setOutTradeNo(order.getOrderNo());
request.setTotalAmount(amount);
request.setSubject("商户消费");
request.setNotifyUrl(config.getAlipayNotifyUrl());
AlipayOrderResponse response = alipayGateway.createOrder(request);
orderService.markPaying(
order.getOrderNo(),
response.getTradeNo()
);
return PaymentResult.alipay(
order.getOrderNo(),
response.getClientPayPayload()
);
使用哪一个支付宝接口,要看实际支付场景:
- 支付宝小程序内支付;
- H5 或移动网页支付;
- APP 支付;
- 当面付预下单;
- 商家收银设备扫码。
不同产品的准入条件、参数和客户端唤起方式不完全相同。应根据支付宝开放平台中实际签约的产品选择接口,并以对应产品的最新官方文档为准。
支付宝回调处理
不能只根据客户端返回的”支付成功”页面扣除积分。客户端结果可能被伪造,也可能因网络中断而丢失。最终状态应以通过验签的支付宝异步通知或服务端主动查询结果为依据。
@PostMapping("/payment/alipay/notify")
public String handleAlipayNotify(
@RequestParam Map<String, String> parameters) {
if (!alipaySignatureVerifier.verify(parameters)) {
return "failure";
}
String orderNo = parameters.get("out_trade_no");
String tradeNo = parameters.get("trade_no");
String tradeStatus = parameters.get("trade_status");
String totalAmount = parameters.get("total_amount");
String appId = parameters.get("app_id");
String sellerId = parameters.get("seller_id");
BusinessOrder order = orderService.get(orderNo);
paymentVerifier.checkAppId(appId);
paymentVerifier.checkSellerId(sellerId);
paymentVerifier.checkAmount(
totalAmount,
order.getCashAmountCent()
);
if (paymentVerifier.isSuccessful(tradeStatus)) {
orderCompletionService.completeOnce(orderNo, tradeNo);
}
return "success";
}
支付宝可能重复发送同一笔交易的通知,因此 completeOnce 必须具备幂等性:
@Transactional
public void completeOnce(String orderNo, String tradeNo) {
BusinessOrder order = orderRepository.lockByOrderNo(orderNo);
if (order.getStatus() == OrderStatus.COMPLETED) {
return;
}
if (order.getStatus() != OrderStatus.PAYING) {
throw new IllegalStateException("Invalid order status");
}
pointAccountService.commitFrozenPoints(
order.getUserId(),
orderNo
);
orderRepository.markCompleted(orderNo, tradeNo);
}
数据库还应对业务订单号、支付宝交易号和积分业务流水号设置唯一约束。即使代码出现失误或消息重复,数据库也能继续阻止重复扣款。
二维码应该如何设计
不建议把可篡改的金额、商户号和积分抵扣值直接写进二维码。二维码可以只包含一个短期有效的支付入口,例如:
https://pay.example.com/q/8fJ2kL9x
服务端通过随机令牌查询:
- 商户身份;
- 门店身份;
- 二维码是否有效;
- 金额是固定值还是由用户输入;
- 可用的积分规则;
- 收款账户配置。
如果允许用户自行输入金额,服务端还应限制金额上下限,并防止二维码被复制到其他门店使用。
静态二维码适用于”扫码后输入金额”,动态二维码适用于”商家已经确定订单和金额”。从风控和对账角度看,动态二维码更可靠。
退款如何处理
退款时必须同时处理现金和积分,不能只退还支付宝现金。
假设订单为:
原价:20 元
积分抵扣:6 元
支付宝支付:14 元
全额退款时,一般应按以下方式处理:
支付宝退款:14 元
积分返还:600 积分
部分退款需要提前确定规则。常见做法是按照原支付比例分摊:
现金退款额
= 退款总额 × 现金支付额 ÷ 原订单金额
积分退款额
= 退款总额 - 现金退款额
实际计算时,应统一使用”分”和整数积分,并明确舍入规则。最后一次退款可以承担累计的舍入差额,保证现金退款与积分退款之和等于业务退款总额。
积分有效期也需要纳入退款规则。如果消费时使用的积分后来已经过期,需要明确退款后是恢复原有效期、延长有效期,还是发放新的补偿积分。
必须注意的问题
商家必须配合
只要商家继续使用原有的支付宝普通收款码,你的系统就无法实时介入。商家至少要在平台注册、绑定门店,并改用平台管理的支付入口。
积分抵扣部分需要有人承担
用户少付的金额不会凭空产生,必须明确由谁承担:
- 商家让利;
- 平台补贴;
- 用户此前消费形成的权益负债;
- 商家与平台按比例承担。
平台还要建立与商家的对账、结算和争议处理机制。
防止积分超卖
冻结积分时,应使用数据库行锁、乐观锁或原子条件更新。例如:
UPDATE point_account
SET available_points = available_points - :points,
frozen_points = frozen_points + :points,
version = version + 1
WHERE user_id = :userId
AND available_points >= :points
AND version = :version;
只有影响行数为 1,才能确认冻结成功。
支付金额必须校验
处理异步通知时,至少要核对:
out_trade_no;- 支付金额;
- 应用身份;
- 收款方身份;
- 交易状态;
- 签名;
- 订单当前状态。
不能只凭 trade_status 更新订单。
设置超时关闭机制
未支付订单不能一直占用积分。业务超时可以设置为 10~30 分钟,具体时间需要结合所接支付宝产品允许的交易关闭规则确定。订单超时后,应先查询支付宝交易状态,确认尚未支付或已经关闭,再释放积分。
区分”积分”和”现金余额”
普通消费积分通常只能用于兑换权益,不应支持提现或不受限制的转让。如果积分可以充值、提现、跨商户流通,或者承诺固定现金价值,其业务属性和合规要求可能发生变化。上线前需要完成支付、财税及法律评估。
最终建议
如果要让用户在合作包子铺、烟酒店消费时使用积分,较为现实的落地方式是:
- 商家入驻你的平台;
- 每个门店使用平台生成的二维码;
- 用户用支付宝扫码,进入支付宝小程序或平台收银页;
- 服务端计算积分抵扣金额并冻结积分;
- 剩余金额通过已签约的支付宝支付产品收取;
- 支付成功后正式扣除积分;
- 平台按照协议与商家对账结算。
如果商家不接入,二维码也不能更换,同时平台无法创建订单或接收支付结果通知,就只能采用支付后赠送积分、兑换优惠券等间接方式,无法对任意支付宝收款码进行实时积分抵现。
备注:内容仅供参考。
