Case 27: After-sales refund & exchange eligibility
This case corresponds to scenario 27 “After-sales refund / return / exchange eligibility determination” in the JQuick-Java repository USE-CASES, in the e-commerce retail domain. For each after-sales request, the system automatically decides whether to process a return-and-refund, an exchange, or a rejection, and provides the refund ratio, the freight payer and a customer-facing review conclusion. The decision priority is: quality issue → category no-return list → exchange → seven-day no-questions-asked.
Industry scenario
Section titled “Industry scenario”After signing for goods, a buyer may file an after-sales request due to quality issues, wrong size, description mismatch, or simply having ordered too much. Platform customer service faces a large volume of requests every day and needs a unified adjudication caliber based on days since receipt, product condition, evidence, responsibility attribution and product category, avoiding contradictory conclusions from different agents.
Quality issues (request type QUALITY, or product condition is DAMAGED on arrival) have the highest priority: within the 15-day dispute window after receipt, and with quality evidence provided by the buyer or responsibility acknowledged by the merchant, a full return-and-refund is processed (RETURN_REFUND, refund ratio 1.0) with return freight borne by the merchant (MERCHANT); overdue or insufficient evidence leads to rejection.
Non-quality requests first pass the category gate: CUSTOM, VIRTUAL and FRESH categories do not support seven-day no-questions-asked returns (except fresh goods with quality issues), and are rejected directly with the corresponding category text. Wrong size (SIZE) goes through the exchange channel, limited to within 15 days of receipt, with exchange freight borne by the buyer; description mismatch (MISMATCH) is the merchant’s responsibility, limited to within 30 days, with round-trip freight borne by the merchant. All remaining requests fall into seven-day no-questions-asked: all four conditions — within 7 days, unused, seal intact and not affecting resale — must hold simultaneously for a full refund with freight paid by the buyer (BUYER); any single failure leads to rejection.
The decision output is AfterSalesDecision: action (RETURN_REFUND/EXCHANGE/REJECT), refund ratio, freight payer (MERCHANT/BUYER/NONE), and itemized review explanations (reasons), which can be returned directly to the customer-service workbench and the buyer’s after-sales detail page.
Business pain points
Section titled “Business pain points”- After-sales policies adjust frequently with consumer-protection regulations and platform rules (e.g. quality dispute window, exchange deadlines for size/description mismatch, category no-return lists). Hard-coding deadlines and copy in the system means a release for every policy update.
- The quality, exchange and no-questions-asked channels have different deadline calibers (15/30/7 days), intertwined with category no-return lists and evidence/responsibility judgments in one large block of branching code — new maintainers can easily introduce holes.
- The freight payer (merchant/buyer/not applicable) varies with the request reason; finance and merchant settlement both require auditability, and a black-box decision error directly triggers merchant complaints.
- Forks such as fresh goods “returnable for quality, rejected for no-questions-asked” within the same category, and responsibility forks like “full refund without evidence if the merchant acknowledges fault”, need explicit priority in the decision chain with traceable reasons.
- Many legacy e-commerce systems still run on JDK 8 and domestic-innovation JDKs, making it hard to introduce heavy rule platforms that require standalone deployment.
- Customer service and buyers need readable adjudication reasons (e.g. “seal broken”, “beyond the 15-day dispute window”); a true/false rule result alone cannot be explained to customers.
Solution architecture
Section titled “Solution architecture”This case uses the six-part structure “input model + decision model + rule interface + XML rule script + Service orchestration + JUnit tests”: four rule methods encapsulate the eligibility boolean judgments and freight mapping respectively, all deadline thresholds are externalized in XML, and the Service handles request routing, short-circuit returns and copy assembly.
| Layer | Responsibility | Files |
|---|---|---|
| Domain model layer | Carries after-sales request elements; outputs action, refund ratio, freight payer and explanations | AfterSalesRequest.java, AfterSalesDecision.java |
| Rule interface layer | Declares the three eligibility judgments and the freight-payer method, bound by name via @Param |
AfterSalesRules.java |
| Rule script layer | 7/15/30-day deadline gates and freight mapping externalized; supports hot update | after-sales.xml |
| Orchestration & test layer | Priority routing of quality → category → exchange → no-questions-asked, short-circuit returns, copy assembly; regression over 6 profiles | AfterSalesService.java, AfterSalesServiceTest.java |
Rule method breakdown
Section titled “Rule method breakdown”| Rule method | Caliber | Score/output |
|---|---|---|
| sevenDayEligible | Received within 7 days, unused, seal intact, not affecting resale — all four conditions must hold | true/false |
| qualityEligible | Within the 15-day dispute window after receipt, and either quality evidence or merchant acknowledgment of responsibility | true/false |
| exchangeEligible | Reason SIZE limited to 15 days; reason MISMATCH limited to 30 days; any other reason is always false | true/false |
| freightPayer | QUALITY and MISMATCH return MERCHANT; SIZE, SEVEN_DAY and other reasons return BUYER | MERCHANT/BUYER |
Code implementation (complete & runnable)
Section titled “Code implementation (complete & runnable)”Place the following files into the JQuick-Java test project under their original packages: the two models into src/test/java/com/github/paohaijiao/demo/model/, the rule interface and test class into src/test/java/com/github/paohaijiao/demo/, the Service into src/test/java/com/github/paohaijiao/demo/service/, and the XML script into src/test/resources/demo/. Run mvn test -Dtest=AfterSalesServiceTest at the project root to execute.
AfterSalesRequest.java
Section titled “AfterSalesRequest.java”package com.github.paohaijiao.demo.model;
import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;
/** * 售后申请要素 */@Data@NoArgsConstructor@AllArgsConstructorpublic class AfterSalesRequest {
/** * 订单号 */ private String orderId;
/** * 商品编码 */ private String sku;
/** * 商品类目:STANDARD 普通 / FRESH 生鲜 / CUSTOM 定制 / VIRTUAL 虚拟 */ private String productCategory;
/** * 签收后天数 */ private int daysSinceReceived;
/** * 商品现状:UNUSED 未使用 / USED 已使用 / DAMAGED 到货破损 */ private String productCondition;
/** * 申请类型:QUALITY 质量问题 / SEVEN_DAY 七天无理由 / SIZE 尺码不符 / MISMATCH 描述不符 / CHANGE_MIND 多拍不想要 */ private String reasonType;
/** * 是否提供质量问题凭证 */ private boolean qualityProof;
/** * 是否商家责任(如发错货) */ private boolean shopResponsible;
/** * 商品密封/包装是否完好 */ private boolean sealedIntact;
/** * 是否已产生影响二次销售的使用痕迹 */ private boolean impactResale;}AfterSalesDecision.java
Section titled “AfterSalesDecision.java”package com.github.paohaijiao.demo.model;
import lombok.Data;
import java.util.ArrayList;import java.util.List;
/** * 售后资格决策 */@Datapublic class AfterSalesDecision {
/** * 订单号 */ private String orderId;
/** * 处置动作:RETURN_REFUND 退货退款 / EXCHANGE 换货 / REJECT 驳回 */ private String action;
/** * 退款比例 */ private double refundRatio;
/** * 运费承担方:MERCHANT 商家 / BUYER 买家 / NONE 不涉及 */ private String freightPayer;
/** * 审核结论说明 */ private List<String> reasons = new ArrayList<String>();
public void addReason(String reason) { this.reasons.add(reason); }}AfterSalesRules.java
Section titled “AfterSalesRules.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.xml.param.Param;
/** * 场景 27:售后退款/退货/换货资格规则 */public interface AfterSalesRules {
/** * 七天无理由退货资格:7 天内、未使用、密封完好、不影响二次销售 */ boolean sevenDayEligible(@Param("days") int days, @Param("unused") boolean unused, @Param("sealed") boolean sealed, @Param("impact") boolean impact);
/** * 质量问题退货资格:15 天异议期内,且有凭证或商家认责 */ boolean qualityEligible(@Param("days") int days, @Param("proof") boolean proof, @Param("shopResponsible") boolean shopResponsible);
/** * 换货资格:尺码问题 15 天内,描述不符(商家责任)30 天内 */ boolean exchangeEligible(@Param("reason") String reason, @Param("days") int days);
/** * 运费承担方判定 */ String freightPayer(@Param("reason") String reason);}after-sales.xml
Section titled “after-sales.xml”<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE javas PUBLIC "-//PAOHAIJIAO//DTD API JAVA 1.0//EN" "classpath:paohaijiao/dtd/Jquick-java.dtd"><javas namespace="com.github.paohaijiao.demo.AfterSalesRules">
<java name="sevenDayEligible" returnClass="boolean"> <![CDATA[ boolean def sevenDayEligible(int:days, boolean:unused, boolean:sealed, boolean:impact) { if (days <= 7) { if (unused) { if (sealed) { if (impact) { return false; } else { return true; } } else { return false; } } else { return false; } } else { return false; } } ]]> </java>
<java name="qualityEligible" returnClass="boolean"> <![CDATA[ boolean def qualityEligible(int:days, boolean:proof, boolean:shopResponsible) { if (days <= 15) { if (proof) { return true; } else { if (shopResponsible) { return true; } else { return false; } } } else { return false; } } ]]> </java>
<java name="exchangeEligible" returnClass="boolean"> <![CDATA[ boolean def exchangeEligible(str:reason, int:days) { if (reason == "SIZE") { if (days <= 15) { return true; } else { return false; } } else if (reason == "MISMATCH") { if (days <= 30) { return true; } else { return false; } } else { return false; } } ]]> </java>
<java name="freightPayer" returnClass="java.lang.String"> <![CDATA[ str def freightPayer(str:reason) { if (reason == "QUALITY") { return "MERCHANT"; } else if (reason == "MISMATCH") { return "MERCHANT"; } else if (reason == "SIZE") { return "BUYER"; } else if (reason == "SEVEN_DAY") { return "BUYER"; } else { return "BUYER"; } } ]]> </java>
</javas>AfterSalesService.java
Section titled “AfterSalesService.java”package com.github.paohaijiao.demo.service;
import com.github.paohaijiao.demo.AfterSalesRules;import com.github.paohaijiao.demo.model.AfterSalesDecision;import com.github.paohaijiao.demo.model.AfterSalesRequest;
/** * 场景 27:售后退款/退货/换货资格判定 * <p> * 判定优先级:质量问题 → 类目禁退 → 换货 → 七天无理由。 */public class AfterSalesService {
private final AfterSalesRules rules;
public AfterSalesService(AfterSalesRules rules) { this.rules = rules; }
public AfterSalesDecision review(AfterSalesRequest req) { AfterSalesDecision d = new AfterSalesDecision(); d.setOrderId(req.getOrderId());
boolean qualityClaim = "QUALITY".equals(req.getReasonType()) || "DAMAGED".equals(req.getProductCondition());
if (qualityClaim) { handleQuality(req, d); return d; }
if ("CUSTOM".equals(req.getProductCategory()) || "VIRTUAL".equals(req.getProductCategory()) || "FRESH".equals(req.getProductCategory())) { reject(d, categoryText(req.getProductCategory())); return d; }
if ("SIZE".equals(req.getReasonType()) || "MISMATCH".equals(req.getReasonType())) { handleExchange(req, d); return d; }
handleSevenDay(req, d); return d; }
private void handleQuality(AfterSalesRequest req, AfterSalesDecision d) { boolean eligible = rules.qualityEligible(req.getDaysSinceReceived(), req.isQualityProof(), req.isShopResponsible()); if (!eligible) { d.setAction("REJECT"); d.setRefundRatio(0.0); d.setFreightPayer("NONE"); d.addReason("超出 15 天质量异议期或举证不足,不予受理质量退货"); return; } d.setAction("RETURN_REFUND"); d.setRefundRatio(1.0); d.setFreightPayer("MERCHANT"); if (req.isShopResponsible()) { d.addReason("商家责任认定成立,办理全额退货退款,退货运费由商家承担"); } else { d.addReason("质量问题凭证核验通过,办理全额退货退款,退货运费由商家承担"); } }
private String categoryText(String category) { if ("FRESH".equals(category)) { return "生鲜易腐商品不支持七天无理由退货(质量问题除外)"; } if ("CUSTOM".equals(category)) { return "定制商品不支持七天无理由退货"; } if ("VIRTUAL".equals(category)) { return "虚拟商品一经交付不支持退货"; } return "该商品类目不支持无理由退货"; }
private void reject(AfterSalesDecision d, String categoryText) { d.setAction("REJECT"); d.setRefundRatio(0.0); d.setFreightPayer("NONE"); d.addReason(categoryText); }
private void handleExchange(AfterSalesRequest req, AfterSalesDecision d) { boolean eligible = rules.exchangeEligible(req.getReasonType(), req.getDaysSinceReceived()); if (!eligible) { d.setAction("REJECT"); d.setRefundRatio(0.0); d.setFreightPayer("NONE"); d.addReason("已超过换货申请期限(尺码问题 15 天、描述不符 30 天)"); return; } d.setAction("EXCHANGE"); d.setRefundRatio(0.0); d.setFreightPayer(rules.freightPayer(req.getReasonType())); if ("MISMATCH".equals(req.getReasonType())) { d.addReason("商品与描述不符,30 天内支持换货,往返运费由商家承担"); } else { d.addReason("尺码不符 15 天内支持换货,换货运费由买家承担"); } }
private void handleSevenDay(AfterSalesRequest req, AfterSalesDecision d) { boolean eligible = rules.sevenDayEligible(req.getDaysSinceReceived(), "UNUSED".equals(req.getProductCondition()), req.isSealedIntact(), req.isImpactResale()); if (!eligible) { d.setAction("REJECT"); d.setRefundRatio(0.0); d.setFreightPayer("NONE"); d.addReason("不符合七天无理由退货条件(超期、已使用、密封破损或影响二次销售)"); return; } d.setAction("RETURN_REFUND"); d.setRefundRatio(1.0); d.setFreightPayer("BUYER"); d.addReason("符合七天无理由退货条件,办理全额退款,退货运费由买家承担"); }}AfterSalesServiceTest.java
Section titled “AfterSalesServiceTest.java”package com.github.paohaijiao.demo;
import com.github.paohaijiao.demo.model.AfterSalesDecision;import com.github.paohaijiao.demo.model.AfterSalesRequest;import com.github.paohaijiao.demo.service.AfterSalesService;import com.github.paohaijiao.xml.JQuickJava;import org.junit.Assert;import org.junit.Before;import org.junit.Test;
/** * 场景 27:售后退款/退货/换货资格判定 * <p> * 覆盖 6 组售后形态:七天无理由退货、质量退货、尺码换货、 * 无理由条件不符双驳回、生鲜类目质量/无理由分叉、超期无凭证与商家认责分叉。 */public class AfterSalesServiceTest {
private AfterSalesService service;
@Before public void setUp() { AfterSalesRules rules = JQuickJava.create() .createApi(AfterSalesRules.class, "demo/after-sales.xml"); this.service = new AfterSalesService(rules); }
/** * 普通商品签收 3 天、未使用、密封完好 → 七天无理由退货退款,买家承担运费 */ @Test public void shouldApproveSevenDayReturn() { AfterSalesRequest req = base("AS001", "STANDARD", "SEVEN_DAY"); req.setDaysSinceReceived(3); req.setProductCondition("UNUSED"); req.setSealedIntact(true); req.setImpactResale(false);
AfterSalesDecision d = service.review(req);
Assert.assertEquals("RETURN_REFUND", d.getAction()); Assert.assertEquals(1.0, d.getRefundRatio(), 0.0001); Assert.assertEquals("BUYER", d.getFreightPayer()); }
/** * 到货破损 10 天、凭证齐全 → 质量退货,商家承担运费 */ @Test public void shouldApproveQualityReturnWithProof() { AfterSalesRequest req = base("AS002", "STANDARD", "QUALITY"); req.setDaysSinceReceived(10); req.setProductCondition("DAMAGED"); req.setQualityProof(true);
AfterSalesDecision d = service.review(req);
Assert.assertEquals("RETURN_REFUND", d.getAction()); Assert.assertEquals(1.0, d.getRefundRatio(), 0.0001); Assert.assertEquals("MERCHANT", d.getFreightPayer()); Assert.assertTrue(d.getReasons().contains( "质量问题凭证核验通过,办理全额退货退款,退货运费由商家承担")); }
/** * 已穿用但 8 天内尺码不符 → 换货,买家承担换货运费 */ @Test public void shouldApproveSizeExchange() { AfterSalesRequest req = base("AS003", "STANDARD", "SIZE"); req.setDaysSinceReceived(8); req.setProductCondition("USED");
AfterSalesDecision d = service.review(req);
Assert.assertEquals("EXCHANGE", d.getAction()); Assert.assertEquals(0.0, d.getRefundRatio(), 0.0001); Assert.assertEquals("BUYER", d.getFreightPayer()); Assert.assertTrue(d.getReasons().contains("尺码不符 15 天内支持换货,换货运费由买家承担")); }
/** * 同样 5 天无理由:有使用痕迹或密封破损 → 均驳回 */ @Test public void shouldRejectSevenDayWhenConditionNotMet() { AfterSalesRequest used = base("AS004", "STANDARD", "SEVEN_DAY"); used.setDaysSinceReceived(5); used.setProductCondition("UNUSED"); used.setSealedIntact(true); used.setImpactResale(true); AfterSalesDecision d1 = service.review(used); Assert.assertEquals("REJECT", d1.getAction()); Assert.assertTrue(d1.getReasons().get(0).contains("不符合七天无理由退货条件"));
AfterSalesRequest unsealed = base("AS005", "STANDARD", "SEVEN_DAY"); unsealed.setDaysSinceReceived(5); unsealed.setProductCondition("UNUSED"); unsealed.setSealedIntact(false); unsealed.setImpactResale(false); AfterSalesDecision d2 = service.review(unsealed); Assert.assertEquals("REJECT", d2.getAction()); }
/** * 生鲜:质量问题可退;无理由拒退 */ @Test public void shouldSplitFreshQualityAndSevenDay() { AfterSalesRequest quality = base("AS006", "FRESH", "QUALITY"); quality.setDaysSinceReceived(2); quality.setProductCondition("DAMAGED"); quality.setQualityProof(true); AfterSalesDecision d1 = service.review(quality); Assert.assertEquals("RETURN_REFUND", d1.getAction()); Assert.assertEquals("MERCHANT", d1.getFreightPayer());
AfterSalesRequest sevenDay = base("AS007", "FRESH", "SEVEN_DAY"); sevenDay.setDaysSinceReceived(2); sevenDay.setProductCondition("UNUSED"); sevenDay.setSealedIntact(true); AfterSalesDecision d2 = service.review(sevenDay); Assert.assertEquals("REJECT", d2.getAction()); Assert.assertTrue(d2.getReasons().contains("生鲜易腐商品不支持七天无理由退货(质量问题除外)")); }
/** * 超期无凭证驳回;异议期内商家认责即便无凭证也全退 */ @Test public void shouldHandleOverdueProofMissingAndShopResponsible() { AfterSalesRequest overdue = base("AS008", "STANDARD", "QUALITY"); overdue.setDaysSinceReceived(20); overdue.setProductCondition("DAMAGED"); overdue.setQualityProof(false); overdue.setShopResponsible(false); AfterSalesDecision d1 = service.review(overdue); Assert.assertEquals("REJECT", d1.getAction()); Assert.assertEquals("NONE", d1.getFreightPayer());
AfterSalesRequest shopFault = base("AS009", "STANDARD", "QUALITY"); shopFault.setDaysSinceReceived(10); shopFault.setProductCondition("DAMAGED"); shopFault.setQualityProof(false); shopFault.setShopResponsible(true); AfterSalesDecision d2 = service.review(shopFault); Assert.assertEquals("RETURN_REFUND", d2.getAction()); Assert.assertEquals("MERCHANT", d2.getFreightPayer()); Assert.assertTrue(d2.getReasons().contains( "商家责任认定成立,办理全额退货退款,退货运费由商家承担")); }
private AfterSalesRequest base(String orderId, String category, String reason) { AfterSalesRequest r = new AfterSalesRequest(); r.setOrderId(orderId); r.setSku("SKU100"); r.setProductCategory(category); r.setDaysSinceReceived(0); r.setProductCondition("UNUSED"); r.setReasonType(reason); r.setQualityProof(false); r.setShopResponsible(false); r.setSealedIntact(true); r.setImpactResale(false); return r; }}Run & verified results
Section titled “Run & verified results”Execute at the project root:
mvn test -Dtest=AfterSalesServiceTestTest coverage (6 @Test methods in total):
| Test method | Business profile | Key assertions / expected decision |
|---|---|---|
| shouldApproveSevenDayReturn | Standard goods received 3 days ago, UNUSED, seal intact, resale unaffected | action=RETURN_REFUND, refundRatio=1.0, freightPayer=BUYER |
| shouldApproveQualityReturnWithProof | Damaged on arrival 10 days ago, QUALITY, full evidence | RETURN_REFUND, 1.0, MERCHANT; reason contains “质量问题凭证核验通过,办理全额退货退款,退货运费由商家承担” |
| shouldApproveSizeExchange | Worn (USED) but size mismatch within 8 days | action=EXCHANGE, refundRatio=0.0, freightPayer=BUYER; contains “尺码不符 15 天内支持换货,换货运费由买家承担” |
| shouldRejectSevenDayWhenConditionNotMet | Both 5 days after receipt, no-questions-asked: one affects resale, one has broken seal | Both REJECT; d1’s first reason contains “不符合七天无理由退货条件” |
| shouldSplitFreshQualityAndSevenDay | Two FRESH-category orders: 2-day damaged with evidence via quality; 2-day unused via no-questions-asked | Quality order RETURN_REFUND + MERCHANT; no-questions-asked order REJECT, containing “生鲜易腐商品不支持七天无理由退货(质量问题除外)” |
| shouldHandleOverdueProofMissingAndShopResponsible | 20-day damaged, no evidence, no acknowledgment vs 10-day damaged, no evidence but merchant acknowledges | Former REJECT + NONE; latter RETURN_REFUND + MERCHANT, containing “商家责任认定成立,办理全额退货退款,退货运费由商家承担” |
Technical highlights
Section titled “Technical highlights”- Externalized deadline and category calibers with hot update: the 7/15/30-day gates and the reason-to-freight-payer mapping all live in after-sales.xml; after-sales policy changes only touch the XML, not the customer-service main system.
- One method per caliber: the four rule methods answer respectively “is no-questions-asked valid”, “is quality valid”, “is exchange valid” and “who pays the freight”; every adjudication can be located to a specific rule method, making auditing and evidence presentation clear.
- Service priority short-circuit: the order quality → category no-return → exchange → no-questions-asked is exactly the business priority, returning on first hit; the fresh-goods “returnable for quality, rejected for no-questions-asked” fork and the evidence/acknowledgment fork are guaranteed by the routing order.
- Positive returns avoid negation: the eligibility scripts in XML uniformly use nested
ifwith “positive condition returns true, otherwise returns false” to express multi-condition AND logic, without the!operator, making deadline boundaries (≤) obvious at a glance. - Decisions carry readable copy: rules output only booleans and enums; the Service assembles customer-facing reasons, separating rule stability from copy flexibility.
- Lightweight with no heavy dependencies: the interface is implemented via an ASM dynamic proxy, runs on Java 8 and above, and can be embedded directly into JDK 8 legacy e-commerce systems and domestic-innovation environments without a standalone rule server.