Bug Fix: Vouchers of the Same Type Erroneously Grouped — Must Display Individually by Code
Last Updated: 14/07/2026
Type: Bug — Voucher Deduplication Logic Error
Screen: My Vouchers (VoucherScreen) — "Unused" Tab
Severity: 🔴 High — User cannot see or use all of their vouchers
Feedback Screenshot:
1. Bug Description
The user has 2 vouchers of "Voucher 50K Healing World":
- Voucher 1: Existing voucher (code:
HW50-260702-3MQYC5, Expiry: 1/8/2026) - Voucher 2: Just received from Lucky Spin — has a different code and/or expiry date.
The "My Vouchers" screen → "Unused" tab displays only 1 card, completely hiding the second voucher from the user interface.
Impact: The user is unaware they have 2 vouchers and cannot use the second one.
2. Root Cause
The frontend code performs a grouping/deduplication based on the name/type (voucherType or templateId) and only renders one card representing that group. This logic is business-incorrect because each voucher is a unique entity with:
- A unique code (e.g.,
HW50-260702-3MQYC5vs another code) - Different validity/expiration dates (marketing spins usually have shorter expiration periods)
- Different origins (point redemptions vs lucky spin prizes)
3. Correct Business Logic
✅ Display SEPARATELY when:
- Same name/type but different unique codes → 2 separate cards
- Same name/type but different validity start/end dates → 2 separate cards
- Same name/type but from different sources → 2 separate cards
✅ GROUP and show quantity badge ONLY WHEN:
All of the following conditions are met simultaneously:
- ✅ Same name/type (
templateIdorvoucherTypeCode) - ✅ Same validity start date (
validFrom) - ✅ Same expiration date (
expiredAt/validTo) - ✅ Same terms of use (discount value, applicable services...)
When grouped: Render 1 card with a quantity badge (e.g.,
x2) in the top corner of the card.
❌ NEVER group when:
- Codes are different (even if they are of the same template type)
- Expiry dates are different (even if they share the same template ID)
4. Code Adjustments
4.1 Fix the voucher list rendering logic
// ❌ WRONG — Current logic (grouping solely by name/type)
const groupedVouchers = vouchers.reduce((acc, v) => {
const key = v.voucherTypeName; // Group by name -> WRONG
if (!acc[key]) acc[key] = v;
return acc;
}, {});
// ✅ CORRECT — Each unique code is its own entity.
// Only group when templateId + validFrom + expiredAt are identical.
const groupVouchers = (vouchers: Voucher[]): VoucherDisplayItem[] => {
const groups = new Map<string, VoucherDisplayItem>();
vouchers.forEach((v) => {
// Group key = templateId + start date + end date
const groupKey = `${v.templateId}__${v.validFrom}__${v.expiredAt}`;
if (groups.has(groupKey)) {
// Same group -> increment quantity
groups.get(groupKey)!.quantity += 1;
// Store the code (to allow the user to select which code to use upon tap)
groups.get(groupKey)!.codes.push(v.code);
} else {
// New group -> create separate card
groups.set(groupKey, {
...v,
quantity: 1,
codes: [v.code],
});
}
});
return Array.from(groups.values());
};
4.2 Display quantity badge when quantity > 1
const VoucherCard = ({ voucher }: { voucher: VoucherDisplayItem }) => (
<TouchableOpacity style={styles.card}>
{/* Quantity Badge — only render when quantity > 1 */}
{voucher.quantity > 1 && (
<View style={styles.quantityBadge}>
<Text style={styles.quantityText}>x{voucher.quantity}</Text>
</View>
)}
{/* Main Card Content */}
<VoucherCardContent voucher={voucher} />
{/* Expiration date */}
<Text style={styles.expiry}>Expiry: {formatDate(voucher.expiredAt)}</Text>
</TouchableOpacity>
);
4.3 Detail screen for grouped vouchers
When a user taps a card with quantity > 1, show the list of individual codes to choose from:
// Within VoucherDetailScreen or bottom sheet
{voucher.codes.length > 1 && (
<View>
<Text style={styles.selectCodeLabel}>Select code to use:</Text>
{voucher.codes.map((code, index) => (
<TouchableOpacity key={code} style={styles.codeItem}
onPress={() => selectCode(code)}>
<Text style={styles.codeText}>Code {index + 1}: {code}</Text>
</TouchableOpacity>
))}
</View>
)}
5. Examples
Current User Bug Case (Needs Correction):
Voucher A: "50K Healing World" | Code: HW50-260702-3MQYC5 | Expiry: 1/8/2026
Voucher B: "50K Healing World" | Code: HW50-260714-XXXXX | Expiry: 14/8/2026 (from Lucky Spin)
→ DIFFERENT EXPIRY DATES → Display 2 separate cards ✅
Groupable Case:
Voucher A: "50K Healing World" | Code: HW50-AA | Expiry: 1/8/2026 | validFrom: 1/7/2026
Voucher B: "50K Healing World" | Code: HW50-BB | Expiry: 1/8/2026 | validFrom: 1/7/2026
→ Same type + same expiry + same start date → Group into 1 card with "x2" badge ✅
Non-groupable Case with Identical Expiry:
Voucher A: "50K Healing World" | Expiry: 1/8/2026 | Origin: Point Redemption
Voucher B: "50K Healing World" | Expiry: 1/8/2026 | Origin: Lucky Spin (Different template ID)
→ Different template ID → Display 2 separate cards ✅
Technical Checklist
- Locate and edit the grouping logic in
VoucherScreen.tsxor its selector/hook. - Apply
groupVouchers()with the composite keytemplateId + validFrom + expiredAt. - Display
x{quantity}badge on cards wherequantity > 1. - Implement the code selector in the detail screen for grouped cards.
- Verify the API response: ensure the backend returns distinct voucher entities (no grouping on the server side).
- Test case: Create 2 vouchers of the same type with different expiry dates → verify 2 cards are shown.
- Test case: Create 2 vouchers of the same type, same expiry, and same
validFrom→ verify 1 card with a "x2" badge. - Test case: Use 1 of the grouped vouchers → verify quantity updates to 1 (or moves to the used tab).