diff --git a/admin/src/views/commissions/index.vue b/admin/src/views/commissions/index.vue index bb4f8be..b988811 100644 --- a/admin/src/views/commissions/index.vue +++ b/admin/src/views/commissions/index.vue @@ -2,15 +2,23 @@
- +
-
¥{{ stats.totalCommissionPaid }}
-
累计佣金支出
+
¥{{ stats.totalCommissionPaidRmb || stats.totalCommissionPaid }}
+
累计佣金支出(人民币)
- + + +
+
₱{{ stats.totalCommissionPaidPeso || '0.00' }}
+
累计佣金支出(比索)
+
+
+
+
{{ stats.totalCommissionCount }}
@@ -18,10 +26,11 @@
- +
-
¥{{ stats.pendingWithdrawal }}
+
¥{{ stats.pendingWithdrawalRmb || stats.pendingWithdrawal }}
+
₱{{ stats.pendingWithdrawalPeso || '0.00' }}
待提现金额
@@ -93,13 +102,20 @@ + + + @@ -271,6 +287,15 @@ onMounted(() => { font-size: 28px; font-weight: bold; color: #409eff; + + &.rmb { + color: #e6a23c; + } + + &.peso { + color: #67c23a; + font-size: 20px; + } } .stat-label { @@ -304,6 +329,10 @@ onMounted(() => { .payment-amount { color: #e6a23c; + + &.peso { + color: #67c23a; + } } .commission-amount { diff --git a/admin/src/views/payment-orders/index.vue b/admin/src/views/payment-orders/index.vue index 1ab208c..aded873 100644 --- a/admin/src/views/payment-orders/index.vue +++ b/admin/src/views/payment-orders/index.vue @@ -78,9 +78,18 @@ {{ formatDate(row.paymentTime) }} - + @@ -189,17 +198,35 @@ - 佣金信息 - - {{ currentOrder.commission.inviter?.nickname }} (UID: {{ currentOrder.commission.inviter?.uid }}) - {{ currentOrder.commission.commissionRate }} - ¥{{ currentOrder.commission.commissionAmount }} - - - {{ currentOrder.commission.status === 'credited' ? '已入账' : '已取消' }} - - - + 佣金信息 +
+ + + + {{ comm.currency === 'PHP' ? '比索 (PHP)' : '人民币 (RMB)' }} + + + + + {{ comm.status === 'credited' ? '已入账' : '已取消' }} + + + {{ comm.inviter?.nickname || '-' }} (UID: {{ comm.inviter?.uid || '-' }}) + {{ comm.commissionRate }} + + + {{ comm.currency === 'PHP' ? '₱' : '¥' }}{{ comm.commissionAmount }} + + + {{ formatDate(comm.createdAt) }} + +
@@ -370,8 +397,14 @@ async function handleCreate() { createDialogVisible.value = false fetchOrders() - if (response.data.data.commission) { - ElMessage.info(`已生成佣金 ¥${response.data.data.commission.commissionAmount}`) + // Show commission info for all created commissions + const commissions = response.data.data.commissions + if (commissions && commissions.length > 0) { + const commissionMessages = commissions.map(c => { + const currencySymbol = c.currency === 'PHP' ? '₱' : '¥' + return `${currencySymbol}${c.commissionAmount}` + }) + ElMessage.info(`已生成佣金: ${commissionMessages.join(', ')}`) } } else { ElMessage.error(response.data.error?.message || '创建失败') @@ -510,6 +543,24 @@ onMounted(() => { font-weight: bold; } + .commissions-info { + display: flex; + flex-direction: column; + gap: 4px; + } + + .commission-item { + font-weight: bold; + + &.peso { + color: #409eff; + } + + &.rmb { + color: #e6a23c; + } + } + .no-commission { color: #909399; } @@ -551,5 +602,21 @@ onMounted(() => { .cancel-reason { color: #f56c6c; } + + .commission-section { + margin-top: 16px; + } + + .peso-amount { + color: #409eff; + font-weight: bold; + font-size: 16px; + } + + .rmb-amount { + color: #e6a23c; + font-weight: bold; + font-size: 16px; + } } diff --git a/backend/src/scripts/fixDualCurrencyCommissions.js b/backend/src/scripts/fixDualCurrencyCommissions.js new file mode 100644 index 0000000..5ff71e9 --- /dev/null +++ b/backend/src/scripts/fixDualCurrencyCommissions.js @@ -0,0 +1,175 @@ +/** + * Script to fix dual-currency commissions for existing payment orders + * + * This script finds payment orders that have both amountRmb and amountPeso, + * but only have one commission record, and creates the missing commission. + * + * Usage: node src/scripts/fixDualCurrencyCommissions.js [--dry-run] + */ + +const { sequelize } = require('../config/database'); +const PaymentOrder = require('../models/PaymentOrder'); +const Commission = require('../models/Commission'); +const User = require('../models/User'); +const commissionConfigService = require('../services/commissionConfigService'); + +const isDryRun = process.argv.includes('--dry-run'); + +async function fixDualCurrencyCommissions() { + console.log('=== Fix Dual Currency Commissions ==='); + console.log(`Mode: ${isDryRun ? 'DRY RUN (no changes will be made)' : 'LIVE'}`); + console.log(''); + + try { + // Find all active payment orders with both currencies + const orders = await PaymentOrder.findAll({ + where: { + status: 'active', + }, + include: [ + { + model: User, + as: 'user', + attributes: ['id', 'invitedBy'], + }, + ], + }); + + console.log(`Found ${orders.length} active payment orders`); + + let fixedCount = 0; + let skippedCount = 0; + + for (const order of orders) { + const amountRmb = parseFloat(order.amountRmb || 0); + const amountPeso = parseFloat(order.amountPeso || 0); + const legacyAmount = parseFloat(order.amount || 0); + + // Check if order has both currencies + const hasRmb = amountRmb > 0 || legacyAmount > 0; + const hasPeso = amountPeso > 0; + + if (!hasRmb && !hasPeso) { + continue; // No amounts, skip + } + + // Check if user was invited + if (!order.user || !order.user.invitedBy) { + continue; // No inviter, skip + } + + // Get existing commissions for this order + const existingCommissions = await Commission.findAll({ + where: { paymentOrderId: order.id }, + }); + + const existingCurrencies = existingCommissions.map(c => c.currency || 'RMB'); + const hasRmbCommission = existingCurrencies.includes('RMB'); + const hasPhpCommission = existingCurrencies.includes('PHP'); + + // Check if we need to create missing commissions + const needsRmbCommission = hasRmb && !hasRmbCommission; + const needsPhpCommission = hasPeso && !hasPhpCommission; + + if (!needsRmbCommission && !needsPhpCommission) { + continue; // All commissions exist, skip + } + + console.log(`\nOrder ${order.orderNo}:`); + console.log(` - amountRmb: ${amountRmb}, amountPeso: ${amountPeso}, amount: ${legacyAmount}`); + console.log(` - Existing commissions: ${existingCurrencies.join(', ') || 'none'}`); + console.log(` - Needs RMB commission: ${needsRmbCommission}`); + console.log(` - Needs PHP commission: ${needsPhpCommission}`); + + if (isDryRun) { + console.log(` - [DRY RUN] Would create missing commissions`); + fixedCount++; + continue; + } + + // Get commission rate (use existing rate if available, otherwise get current rate) + let commissionRate; + if (existingCommissions.length > 0) { + commissionRate = parseFloat(existingCommissions[0].commissionRate); + } else { + commissionRate = await commissionConfigService.getCommissionRate(); + } + + const transaction = await sequelize.transaction(); + + try { + const inviter = await User.findByPk(order.user.invitedBy, { transaction }); + + if (needsRmbCommission) { + const rmbPaymentAmount = amountRmb > 0 ? amountRmb : legacyAmount; + const rmbCommissionAmount = rmbPaymentAmount * commissionRate; + + await Commission.create({ + inviterId: order.user.invitedBy, + inviteeId: order.user.id, + paymentOrderId: order.id, + paymentAmount: rmbPaymentAmount, + commissionRate: commissionRate, + commissionAmount: rmbCommissionAmount, + currency: 'RMB', + status: 'credited', + }, { transaction }); + + if (inviter) { + inviter.balance = parseFloat(inviter.balance || 0) + rmbCommissionAmount; + } + + console.log(` - Created RMB commission: ¥${rmbCommissionAmount.toFixed(2)}`); + } + + if (needsPhpCommission) { + const phpCommissionAmount = amountPeso * commissionRate; + + await Commission.create({ + inviterId: order.user.invitedBy, + inviteeId: order.user.id, + paymentOrderId: order.id, + paymentAmount: amountPeso, + commissionRate: commissionRate, + commissionAmount: phpCommissionAmount, + currency: 'PHP', + status: 'credited', + }, { transaction }); + + if (inviter) { + inviter.balancePeso = parseFloat(inviter.balancePeso || 0) + phpCommissionAmount; + } + + console.log(` - Created PHP commission: ₱${phpCommissionAmount.toFixed(2)}`); + } + + if (inviter) { + await inviter.save({ transaction }); + } + + await transaction.commit(); + fixedCount++; + console.log(` - ✓ Fixed successfully`); + } catch (error) { + await transaction.rollback(); + console.error(` - ✗ Error: ${error.message}`); + skippedCount++; + } + } + + console.log('\n=== Summary ==='); + console.log(`Fixed: ${fixedCount}`); + console.log(`Skipped/Errors: ${skippedCount}`); + + if (isDryRun) { + console.log('\nThis was a dry run. Run without --dry-run to apply changes.'); + } + + } catch (error) { + console.error('Error:', error); + } finally { + await sequelize.close(); + } +} + +fixDualCurrencyCommissions(); diff --git a/backend/src/services/commissionService.js b/backend/src/services/commissionService.js index 61afe38..a16d4b5 100644 --- a/backend/src/services/commissionService.js +++ b/backend/src/services/commissionService.js @@ -10,9 +10,10 @@ const commissionConfigService = require('./commissionConfigService'); */ /** - * Calculate and record commission for a payment order + * Calculate and record commissions for a payment order + * Creates separate commission records for each currency present (RMB and/or PHP) * @param {string} paymentOrderId - Payment order ID - * @returns {Object|null} Commission record or null if no commission generated + * @returns {Array|null} Array of commission records or null if no commission generated */ const calculateCommission = async (paymentOrderId) => { // Get payment order with user info @@ -37,57 +38,70 @@ const calculateCommission = async (paymentOrderId) => { // Get current commission rate const commissionRate = await commissionConfigService.getCommissionRate(); - // Calculate commission amount - prioritize RMB, then Peso, then legacy amount - // Commission is calculated based on whichever currency amount is available - let paymentAmount = 0; - let currency = 'RMB'; + // Collect all currency amounts to process + const currencyAmounts = []; - if (paymentOrder.amountRmb && parseFloat(paymentOrder.amountRmb) > 0) { - paymentAmount = parseFloat(paymentOrder.amountRmb); - currency = 'RMB'; - } else if (paymentOrder.amountPeso && parseFloat(paymentOrder.amountPeso) > 0) { - paymentAmount = parseFloat(paymentOrder.amountPeso); - currency = 'PHP'; - } else if (paymentOrder.amount && parseFloat(paymentOrder.amount) > 0) { - paymentAmount = parseFloat(paymentOrder.amount); - currency = 'RMB'; + // Check RMB amount (including legacy 'amount' field) + const rmbAmount = parseFloat(paymentOrder.amountRmb || 0) || parseFloat(paymentOrder.amount || 0); + if (rmbAmount > 0) { + currencyAmounts.push({ currency: 'RMB', paymentAmount: rmbAmount }); } - if (paymentAmount <= 0) { - return null; // No valid payment amount + // Check Peso amount + const pesoAmount = parseFloat(paymentOrder.amountPeso || 0); + if (pesoAmount > 0) { + currencyAmounts.push({ currency: 'PHP', paymentAmount: pesoAmount }); + } + + if (currencyAmounts.length === 0) { + return null; // No valid payment amounts } - const commissionAmount = paymentAmount * commissionRate; // Start transaction const transaction = await sequelize.transaction(); try { - // Create commission record with currency - const commission = await Commission.create({ - inviterId: inviteeUser.invitedBy, - inviteeId: inviteeUser.id, - paymentOrderId: paymentOrder.id, - paymentAmount: paymentAmount, - commissionRate: commissionRate, - commissionAmount: commissionAmount, - currency: currency, - status: 'credited', - }, { transaction }); - - // Update inviter's balance based on currency + const commissions = []; + + // Get inviter for balance updates const inviter = await User.findByPk(inviteeUser.invitedBy, { transaction }); - if (inviter) { - if (currency === 'PHP') { - inviter.balancePeso = parseFloat(inviter.balancePeso || 0) + commissionAmount; - } else { - inviter.balance = parseFloat(inviter.balance) + commissionAmount; + + // Create commission record for each currency + for (const { currency, paymentAmount } of currencyAmounts) { + const commissionAmount = paymentAmount * commissionRate; + + // Create commission record + const commission = await Commission.create({ + inviterId: inviteeUser.invitedBy, + inviteeId: inviteeUser.id, + paymentOrderId: paymentOrder.id, + paymentAmount: paymentAmount, + commissionRate: commissionRate, + commissionAmount: commissionAmount, + currency: currency, + status: 'credited', + }, { transaction }); + + commissions.push(commission); + + // Update inviter's balance based on currency + if (inviter) { + if (currency === 'PHP') { + inviter.balancePeso = parseFloat(inviter.balancePeso || 0) + commissionAmount; + } else { + inviter.balance = parseFloat(inviter.balance || 0) + commissionAmount; + } } + } + + // Save inviter balance updates + if (inviter) { await inviter.save({ transaction }); } await transaction.commit(); - return commission; + return commissions; } catch (error) { await transaction.rollback(); throw error; @@ -376,6 +390,7 @@ const getAllCommissions = async (options = {}) => { paymentAmount: parseFloat(commission.paymentAmount).toFixed(2), commissionRate: `${(parseFloat(commission.commissionRate) * 100).toFixed(2)}%`, commissionAmount: parseFloat(commission.commissionAmount).toFixed(2), + currency: commission.currency || 'RMB', status: commission.status, createdAt: commission.createdAt, })); @@ -396,34 +411,52 @@ const getAllCommissions = async (options = {}) => { * @returns {Object} Platform commission statistics */ const getPlatformCommissionStats = async () => { - // Total commission paid - const totalPaid = await Commission.sum('commissionAmount', { + const Op = require('sequelize').Op; + + // Total commission paid by currency + const commissions = await Commission.findAll({ where: { status: 'credited' }, - }) || 0; + attributes: ['commissionAmount', 'currency'], + raw: true, + }); + + let totalPaidRmb = 0; + let totalPaidPeso = 0; + commissions.forEach(c => { + const amount = parseFloat(c.commissionAmount) || 0; + if (c.currency === 'PHP') { + totalPaidPeso += amount; + } else { + totalPaidRmb += amount; + } + }); // Total commission count const totalCount = await Commission.count({ where: { status: 'credited' }, }); - // Get user balance statistics (pending withdrawal) + // Get user balance statistics (pending withdrawal) - dual currency const users = await User.findAll({ - where: sequelize.where( - sequelize.cast(sequelize.col('balance'), 'DECIMAL(10,2)'), - { [require('sequelize').Op.gt]: 0 } - ), - attributes: ['balance'], + attributes: ['balance', 'balancePeso'], + raw: true, }); - const pendingWithdrawal = users.reduce( - (sum, u) => sum + parseFloat(u.balance), - 0 - ); + let pendingWithdrawalRmb = 0; + let pendingWithdrawalPeso = 0; + users.forEach(u => { + pendingWithdrawalRmb += parseFloat(u.balance) || 0; + pendingWithdrawalPeso += parseFloat(u.balancePeso) || 0; + }); return { - totalCommissionPaid: parseFloat(totalPaid).toFixed(2), + totalCommissionPaid: parseFloat(totalPaidRmb).toFixed(2), + totalCommissionPaidRmb: parseFloat(totalPaidRmb).toFixed(2), + totalCommissionPaidPeso: parseFloat(totalPaidPeso).toFixed(2), totalCommissionCount: totalCount, - pendingWithdrawal: pendingWithdrawal.toFixed(2), + pendingWithdrawal: pendingWithdrawalRmb.toFixed(2), + pendingWithdrawalRmb: pendingWithdrawalRmb.toFixed(2), + pendingWithdrawalPeso: pendingWithdrawalPeso.toFixed(2), }; }; diff --git a/backend/src/services/paymentOrderService.js b/backend/src/services/paymentOrderService.js index 444257b..326b231 100644 --- a/backend/src/services/paymentOrderService.js +++ b/backend/src/services/paymentOrderService.js @@ -117,16 +117,28 @@ const createPaymentOrder = async (data, adminId) => { }); // Calculate commission (if applicable, for any currency) - let commission = null; + // commissionService.calculateCommission now returns an array of commissions + let commissions = null; if (totalAmountForCommission > 0) { try { - commission = await commissionService.calculateCommission(paymentOrder.id); + commissions = await commissionService.calculateCommission(paymentOrder.id); } catch (error) { // Log error but don't fail the order creation console.error('Commission calculation error:', error.message); } } + // Format commissions array for response + const formattedCommissions = commissions && Array.isArray(commissions) && commissions.length > 0 + ? commissions.map(c => ({ + id: c.id, + inviterId: c.inviterId, + commissionAmount: parseFloat(c.commissionAmount).toFixed(2), + commissionRate: `${(parseFloat(c.commissionRate) * 100).toFixed(2)}%`, + currency: c.currency || 'RMB', + })) + : null; + return { paymentOrder: { id: paymentOrder.id, @@ -142,12 +154,9 @@ const createPaymentOrder = async (data, adminId) => { status: paymentOrder.status, createdAt: paymentOrder.createdAt, }, - commission: commission ? { - id: commission.id, - inviterId: commission.inviterId, - commissionAmount: parseFloat(commission.commissionAmount).toFixed(2), - commissionRate: `${(parseFloat(commission.commissionRate) * 100).toFixed(2)}%`, - } : null, + commissions: formattedCommissions, + // Keep backward compatibility with single commission field + commission: formattedCommissions && formattedCommissions.length > 0 ? formattedCommissions[0] : null, }; }; @@ -232,37 +241,50 @@ const getPaymentOrders = async (options = {}) => { totalPeso: parseFloat(statisticsResult?.totalPeso || 0).toFixed(2), }; - // Get commission status for each order + // Get all commissions for each order (dual-currency support) const Commission = require('../models/Commission'); const orderIds = rows.map(o => o.id); const commissions = await Commission.findAll({ where: { paymentOrderId: orderIds }, - attributes: ['paymentOrderId', 'commissionAmount', 'status'], + attributes: ['paymentOrderId', 'commissionAmount', 'status', 'currency'], }); - const commissionMap = {}; + + // Build a map of orderId -> array of commissions + const commissionsMap = {}; commissions.forEach(c => { - commissionMap[c.paymentOrderId] = { + const orderId = c.paymentOrderId; + if (!commissionsMap[orderId]) { + commissionsMap[orderId] = []; + } + commissionsMap[orderId].push({ amount: parseFloat(c.commissionAmount).toFixed(2), status: c.status, - }; + currency: c.currency || 'RMB', + }); }); - const records = rows.map(order => ({ - id: order.id, - orderNo: order.orderNo, - user: order.user, - appointment: order.appointment, - amount: order.amount ? parseFloat(order.amount).toFixed(2) : null, - amountPeso: order.amountPeso ? parseFloat(order.amountPeso).toFixed(2) : null, - amountRmb: order.amountRmb ? parseFloat(order.amountRmb).toFixed(2) : null, - serviceContent: order.serviceContent, - paymentTime: order.paymentTime, - notes: order.notes, - status: order.status, - creator: order.creator, - commission: commissionMap[order.id] || null, - createdAt: order.createdAt, - })); + const records = rows.map(order => { + const orderCommissions = commissionsMap[order.id] || []; + return { + id: order.id, + orderNo: order.orderNo, + user: order.user, + appointment: order.appointment, + amount: order.amount ? parseFloat(order.amount).toFixed(2) : null, + amountPeso: order.amountPeso ? parseFloat(order.amountPeso).toFixed(2) : null, + amountRmb: order.amountRmb ? parseFloat(order.amountRmb).toFixed(2) : null, + serviceContent: order.serviceContent, + paymentTime: order.paymentTime, + notes: order.notes, + status: order.status, + creator: order.creator, + // Return array of all commissions for dual-currency support + commissions: orderCommissions.length > 0 ? orderCommissions : null, + // Keep backward compatibility with single commission field (first commission) + commission: orderCommissions.length > 0 ? orderCommissions[0] : null, + createdAt: order.createdAt, + }; + }); return { records, @@ -306,9 +328,9 @@ const getPaymentOrderById = async (orderId) => { throw new Error('Payment order not found'); } - // Get commission info + // Get all commission info (dual-currency support) const Commission = require('../models/Commission'); - const commission = await Commission.findOne({ + const commissions = await Commission.findAll({ where: { paymentOrderId: orderId }, include: [ { @@ -319,6 +341,19 @@ const getPaymentOrderById = async (orderId) => { ], }); + // Format commissions array + const formattedCommissions = commissions.length > 0 + ? commissions.map(c => ({ + id: c.id, + inviter: c.inviter, + commissionAmount: parseFloat(c.commissionAmount).toFixed(2), + commissionRate: `${(parseFloat(c.commissionRate) * 100).toFixed(2)}%`, + currency: c.currency || 'RMB', + status: c.status, + createdAt: c.createdAt, + })) + : null; + return { id: order.id, orderNo: order.orderNo, @@ -333,14 +368,10 @@ const getPaymentOrderById = async (orderId) => { status: order.status, cancelReason: order.cancelReason, creator: order.creator, - commission: commission ? { - id: commission.id, - inviter: commission.inviter, - commissionAmount: parseFloat(commission.commissionAmount).toFixed(2), - commissionRate: `${(parseFloat(commission.commissionRate) * 100).toFixed(2)}%`, - status: commission.status, - createdAt: commission.createdAt, - } : null, + // Return array of all commissions for dual-currency support + commissions: formattedCommissions, + // Keep backward compatibility with single commission field (first commission) + commission: formattedCommissions && formattedCommissions.length > 0 ? formattedCommissions[0] : null, createdAt: order.createdAt, updatedAt: order.updatedAt, }; @@ -375,25 +406,59 @@ const cancelPaymentOrder = async (orderId, cancelReason) => { order.cancelReason = cancelReason.trim(); await order.save({ transaction }); - // Cancel related commission and revert balance + // Find ALL related commissions (dual-currency support) const Commission = require('../models/Commission'); - const commission = await Commission.findOne({ + const commissions = await Commission.findAll({ where: { paymentOrderId: orderId, status: 'credited' }, transaction, }); - if (commission) { - // Revert inviter's balance - const inviter = await User.findByPk(commission.inviterId, { transaction }); - if (inviter) { - inviter.balance = parseFloat(inviter.balance) - parseFloat(commission.commissionAmount); - if (inviter.balance < 0) inviter.balance = 0; - await inviter.save({ transaction }); - } + let commissionsCancelled = 0; - // Cancel commission - commission.status = 'cancelled'; - await commission.save({ transaction }); + if (commissions.length > 0) { + // Group commissions by inviter to batch balance updates + const inviterBalanceUpdates = {}; + + for (const commission of commissions) { + const inviterId = commission.inviterId; + const currency = commission.currency || 'RMB'; + const amount = parseFloat(commission.commissionAmount); + + if (!inviterBalanceUpdates[inviterId]) { + inviterBalanceUpdates[inviterId] = { rmbDeduction: 0, pesoDeduction: 0 }; + } + + if (currency === 'PHP') { + inviterBalanceUpdates[inviterId].pesoDeduction += amount; + } else { + inviterBalanceUpdates[inviterId].rmbDeduction += amount; + } + + // Cancel commission + commission.status = 'cancelled'; + await commission.save({ transaction }); + commissionsCancelled++; + } + + // Update inviter balances + for (const [inviterId, deductions] of Object.entries(inviterBalanceUpdates)) { + const inviter = await User.findByPk(inviterId, { transaction }); + if (inviter) { + // Deduct RMB balance (floor at zero) + if (deductions.rmbDeduction > 0) { + inviter.balance = parseFloat(inviter.balance || 0) - deductions.rmbDeduction; + if (inviter.balance < 0) inviter.balance = 0; + } + + // Deduct Peso balance (floor at zero) + if (deductions.pesoDeduction > 0) { + inviter.balancePeso = parseFloat(inviter.balancePeso || 0) - deductions.pesoDeduction; + if (inviter.balancePeso < 0) inviter.balancePeso = 0; + } + + await inviter.save({ transaction }); + } + } } await transaction.commit(); @@ -403,7 +468,8 @@ const cancelPaymentOrder = async (orderId, cancelReason) => { orderNo: order.orderNo, status: order.status, cancelReason: order.cancelReason, - commissionCancelled: !!commission, + commissionCancelled: commissionsCancelled > 0, + commissionsCancelledCount: commissionsCancelled, }; } catch (error) { await transaction.rollback(); diff --git a/backend/src/tests/dualCurrencyCommission.property.test.js b/backend/src/tests/dualCurrencyCommission.property.test.js new file mode 100644 index 0000000..5bd900c --- /dev/null +++ b/backend/src/tests/dualCurrencyCommission.property.test.js @@ -0,0 +1,1637 @@ +/** + * Dual Currency Commission Property Tests + * Tests for dual-currency commission calculation correctness + * + * **Feature: dual-currency-commission** + */ + +const fc = require('fast-check'); + +// Mock database first +const mockTransaction = { + commit: jest.fn(), + rollback: jest.fn(), +}; + +jest.mock('../config/database', () => ({ + sequelize: { + define: jest.fn(() => ({})), + transaction: jest.fn(() => Promise.resolve(mockTransaction)), + fn: jest.fn((fnName, ...args) => ({ fn: fnName, args })), + col: jest.fn((colName) => ({ col: colName })), + }, +})); + +// Mock models +jest.mock('../models/User', () => ({ + findByPk: jest.fn(), + count: jest.fn(), + findAll: jest.fn(), +})); + +jest.mock('../models/Commission', () => ({ + create: jest.fn(), + findAll: jest.fn(), + findAndCountAll: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), + sum: jest.fn(), +})); + +jest.mock('../models/PaymentOrder', () => ({ + findByPk: jest.fn(), +})); + +jest.mock('../services/commissionConfigService', () => ({ + getCommissionRate: jest.fn(), +})); + +const User = require('../models/User'); +const Commission = require('../models/Commission'); +const PaymentOrder = require('../models/PaymentOrder'); +const { sequelize } = require('../config/database'); +const commissionConfigService = require('../services/commissionConfigService'); +const commissionService = require('../services/commissionService'); + +describe('Dual Currency Commission - Property Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockTransaction.commit.mockClear(); + mockTransaction.rollback.mockClear(); + sequelize.transaction.mockResolvedValue(mockTransaction); + }); + + /** + * **Feature: dual-currency-commission, Property 1: Commission records match currency presence** + * *For any* payment order with invited user, the number of commission records created SHALL equal + * the number of non-zero currency amounts (amountRmb > 0 adds one RMB commission, amountPeso > 0 adds one PHP commission) + * **Validates: Requirements 1.1, 1.2, 1.3** + */ + describe('Property 1: Commission records match currency presence', () => { + it('should create commission records matching non-zero currency amounts', async () => { + await fc.assert( + fc.asyncProperty( + // Generate optional RMB amount (0 means no RMB) + fc.oneof( + fc.constant(0), + fc.double({ min: 0.01, max: 100000, noNaN: true }) + ), + // Generate optional Peso amount (0 means no Peso) + fc.oneof( + fc.constant(0), + fc.double({ min: 0.01, max: 100000, noNaN: true }) + ), + fc.double({ min: 0.001, max: 0.5, noNaN: true }), // commission rate + async (amountRmb, amountPeso, commissionRate) => { + // Skip if both amounts are zero (no commission expected) + if (amountRmb === 0 && amountPeso === 0) return; + + // Arrange + const inviterId = 'inviter-uuid'; + const inviteeId = 'invitee-uuid'; + const paymentOrderId = 'order-uuid'; + + PaymentOrder.findByPk.mockResolvedValue({ + id: paymentOrderId, + amountRmb: amountRmb > 0 ? amountRmb : null, + amountPeso: amountPeso > 0 ? amountPeso : null, + amount: null, // Legacy field + status: 'active', + user: { + id: inviteeId, + invitedBy: inviterId, + }, + }); + + commissionConfigService.getCommissionRate.mockResolvedValue(commissionRate); + + const createdCommissions = []; + Commission.create.mockImplementation((data) => { + const commission = { ...data, id: `commission-uuid-${createdCommissions.length}` }; + createdCommissions.push(commission); + return Promise.resolve(commission); + }); + + User.findByPk.mockResolvedValue({ + id: inviterId, + balance: 0, + balancePeso: 0, + save: jest.fn(), + }); + + // Act + const result = await commissionService.calculateCommission(paymentOrderId); + + // Calculate expected number of commissions + let expectedCount = 0; + if (amountRmb > 0) expectedCount++; + if (amountPeso > 0) expectedCount++; + + // Assert: Number of commission records should match non-zero currency count + expect(result).not.toBeNull(); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(expectedCount); + expect(createdCommissions.length).toBe(expectedCount); + + // Verify currency types + const currencies = createdCommissions.map(c => c.currency); + if (amountRmb > 0) { + expect(currencies).toContain('RMB'); + } + if (amountPeso > 0) { + expect(currencies).toContain('PHP'); + } + } + ), + { numRuns: 100 } + ); + }); + + it('should create only RMB commission when only RMB amount is present', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 0.01, max: 100000, noNaN: true }), // RMB amount + async (amountRmb) => { + // Arrange + const inviterId = 'inviter-uuid'; + const inviteeId = 'invitee-uuid'; + const paymentOrderId = 'order-uuid'; + + PaymentOrder.findByPk.mockResolvedValue({ + id: paymentOrderId, + amountRmb: amountRmb, + amountPeso: null, + amount: null, + status: 'active', + user: { + id: inviteeId, + invitedBy: inviterId, + }, + }); + + commissionConfigService.getCommissionRate.mockResolvedValue(0.02); + + const createdCommissions = []; + Commission.create.mockImplementation((data) => { + const commission = { ...data, id: `commission-uuid-${createdCommissions.length}` }; + createdCommissions.push(commission); + return Promise.resolve(commission); + }); + + User.findByPk.mockResolvedValue({ + id: inviterId, + balance: 0, + balancePeso: 0, + save: jest.fn(), + }); + + // Act + const result = await commissionService.calculateCommission(paymentOrderId); + + // Assert: Only one RMB commission should be created + expect(result.length).toBe(1); + expect(createdCommissions.length).toBe(1); + expect(createdCommissions[0].currency).toBe('RMB'); + expect(createdCommissions[0].paymentAmount).toBeCloseTo(amountRmb, 8); + } + ), + { numRuns: 100 } + ); + }); + + it('should create only PHP commission when only Peso amount is present', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 0.01, max: 100000, noNaN: true }), // Peso amount + async (amountPeso) => { + // Arrange + const inviterId = 'inviter-uuid'; + const inviteeId = 'invitee-uuid'; + const paymentOrderId = 'order-uuid'; + + PaymentOrder.findByPk.mockResolvedValue({ + id: paymentOrderId, + amountRmb: null, + amountPeso: amountPeso, + amount: null, + status: 'active', + user: { + id: inviteeId, + invitedBy: inviterId, + }, + }); + + commissionConfigService.getCommissionRate.mockResolvedValue(0.02); + + const createdCommissions = []; + Commission.create.mockImplementation((data) => { + const commission = { ...data, id: `commission-uuid-${createdCommissions.length}` }; + createdCommissions.push(commission); + return Promise.resolve(commission); + }); + + User.findByPk.mockResolvedValue({ + id: inviterId, + balance: 0, + balancePeso: 0, + save: jest.fn(), + }); + + // Act + const result = await commissionService.calculateCommission(paymentOrderId); + + // Assert: Only one PHP commission should be created + expect(result.length).toBe(1); + expect(createdCommissions.length).toBe(1); + expect(createdCommissions[0].currency).toBe('PHP'); + expect(createdCommissions[0].paymentAmount).toBeCloseTo(amountPeso, 8); + } + ), + { numRuns: 100 } + ); + }); + + it('should create two commissions when both RMB and Peso amounts are present', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 0.01, max: 100000, noNaN: true }), // RMB amount + fc.double({ min: 0.01, max: 100000, noNaN: true }), // Peso amount + async (amountRmb, amountPeso) => { + // Arrange + const inviterId = 'inviter-uuid'; + const inviteeId = 'invitee-uuid'; + const paymentOrderId = 'order-uuid'; + + PaymentOrder.findByPk.mockResolvedValue({ + id: paymentOrderId, + amountRmb: amountRmb, + amountPeso: amountPeso, + amount: null, + status: 'active', + user: { + id: inviteeId, + invitedBy: inviterId, + }, + }); + + commissionConfigService.getCommissionRate.mockResolvedValue(0.02); + + const createdCommissions = []; + Commission.create.mockImplementation((data) => { + const commission = { ...data, id: `commission-uuid-${createdCommissions.length}` }; + createdCommissions.push(commission); + return Promise.resolve(commission); + }); + + User.findByPk.mockResolvedValue({ + id: inviterId, + balance: 0, + balancePeso: 0, + save: jest.fn(), + }); + + // Act + const result = await commissionService.calculateCommission(paymentOrderId); + + // Assert: Two commissions should be created (one RMB, one PHP) + expect(result.length).toBe(2); + expect(createdCommissions.length).toBe(2); + + const currencies = createdCommissions.map(c => c.currency); + expect(currencies).toContain('RMB'); + expect(currencies).toContain('PHP'); + + // Verify amounts match + const rmbCommission = createdCommissions.find(c => c.currency === 'RMB'); + const phpCommission = createdCommissions.find(c => c.currency === 'PHP'); + expect(rmbCommission.paymentAmount).toBeCloseTo(amountRmb, 8); + expect(phpCommission.paymentAmount).toBeCloseTo(amountPeso, 8); + } + ), + { numRuns: 100 } + ); + }); + }); +}); + + + /** + * **Feature: dual-currency-commission, Property 2: Balance updates match commission currencies** + * *For any* commission creation, the inviter's balance SHALL increase by the RMB commission amount + * and balancePeso SHALL increase by the PHP commission amount + * **Validates: Requirements 1.4** + */ + describe('Property 2: Balance updates match commission currencies', () => { + it('should update RMB balance when RMB commission is created', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 0.01, max: 100000, noNaN: true }), // RMB amount + fc.double({ min: 0, max: 10000, noNaN: true }), // initial RMB balance + fc.double({ min: 0.001, max: 0.5, noNaN: true }), // commission rate + async (amountRmb, initialBalance, commissionRate) => { + // Arrange + const inviterId = 'inviter-uuid'; + const inviteeId = 'invitee-uuid'; + const paymentOrderId = 'order-uuid'; + + PaymentOrder.findByPk.mockResolvedValue({ + id: paymentOrderId, + amountRmb: amountRmb, + amountPeso: null, + amount: null, + status: 'active', + user: { + id: inviteeId, + invitedBy: inviterId, + }, + }); + + commissionConfigService.getCommissionRate.mockResolvedValue(commissionRate); + + Commission.create.mockImplementation((data) => { + return Promise.resolve({ ...data, id: 'commission-uuid' }); + }); + + let updatedBalance = null; + let updatedBalancePeso = null; + const mockInviter = { + id: inviterId, + balance: initialBalance, + balancePeso: 0, + save: jest.fn().mockImplementation(function() { + updatedBalance = this.balance; + updatedBalancePeso = this.balancePeso; + return Promise.resolve(); + }), + }; + User.findByPk.mockResolvedValue(mockInviter); + + // Act + await commissionService.calculateCommission(paymentOrderId); + + // Assert: RMB balance should increase by commission amount + const expectedCommission = amountRmb * commissionRate; + const expectedBalance = initialBalance + expectedCommission; + expect(updatedBalance).toBeCloseTo(expectedBalance, 8); + // Peso balance should remain unchanged + expect(updatedBalancePeso).toBe(0); + } + ), + { numRuns: 100 } + ); + }); + + it('should update Peso balance when PHP commission is created', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 0.01, max: 100000, noNaN: true }), // Peso amount + fc.double({ min: 0, max: 10000, noNaN: true }), // initial Peso balance + fc.double({ min: 0.001, max: 0.5, noNaN: true }), // commission rate + async (amountPeso, initialBalancePeso, commissionRate) => { + // Arrange + const inviterId = 'inviter-uuid'; + const inviteeId = 'invitee-uuid'; + const paymentOrderId = 'order-uuid'; + + PaymentOrder.findByPk.mockResolvedValue({ + id: paymentOrderId, + amountRmb: null, + amountPeso: amountPeso, + amount: null, + status: 'active', + user: { + id: inviteeId, + invitedBy: inviterId, + }, + }); + + commissionConfigService.getCommissionRate.mockResolvedValue(commissionRate); + + Commission.create.mockImplementation((data) => { + return Promise.resolve({ ...data, id: 'commission-uuid' }); + }); + + let updatedBalance = null; + let updatedBalancePeso = null; + const mockInviter = { + id: inviterId, + balance: 0, + balancePeso: initialBalancePeso, + save: jest.fn().mockImplementation(function() { + updatedBalance = this.balance; + updatedBalancePeso = this.balancePeso; + return Promise.resolve(); + }), + }; + User.findByPk.mockResolvedValue(mockInviter); + + // Act + await commissionService.calculateCommission(paymentOrderId); + + // Assert: Peso balance should increase by commission amount + const expectedCommission = amountPeso * commissionRate; + const expectedBalancePeso = initialBalancePeso + expectedCommission; + expect(updatedBalancePeso).toBeCloseTo(expectedBalancePeso, 8); + // RMB balance should remain unchanged + expect(updatedBalance).toBe(0); + } + ), + { numRuns: 100 } + ); + }); + + it('should update both balances when dual-currency commission is created', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 0.01, max: 100000, noNaN: true }), // RMB amount + fc.double({ min: 0.01, max: 100000, noNaN: true }), // Peso amount + fc.double({ min: 0, max: 10000, noNaN: true }), // initial RMB balance + fc.double({ min: 0, max: 10000, noNaN: true }), // initial Peso balance + fc.double({ min: 0.001, max: 0.5, noNaN: true }), // commission rate + async (amountRmb, amountPeso, initialBalance, initialBalancePeso, commissionRate) => { + // Arrange + const inviterId = 'inviter-uuid'; + const inviteeId = 'invitee-uuid'; + const paymentOrderId = 'order-uuid'; + + PaymentOrder.findByPk.mockResolvedValue({ + id: paymentOrderId, + amountRmb: amountRmb, + amountPeso: amountPeso, + amount: null, + status: 'active', + user: { + id: inviteeId, + invitedBy: inviterId, + }, + }); + + commissionConfigService.getCommissionRate.mockResolvedValue(commissionRate); + + Commission.create.mockImplementation((data) => { + return Promise.resolve({ ...data, id: 'commission-uuid' }); + }); + + let updatedBalance = null; + let updatedBalancePeso = null; + const mockInviter = { + id: inviterId, + balance: initialBalance, + balancePeso: initialBalancePeso, + save: jest.fn().mockImplementation(function() { + updatedBalance = this.balance; + updatedBalancePeso = this.balancePeso; + return Promise.resolve(); + }), + }; + User.findByPk.mockResolvedValue(mockInviter); + + // Act + await commissionService.calculateCommission(paymentOrderId); + + // Assert: Both balances should increase by their respective commission amounts + const expectedRmbCommission = amountRmb * commissionRate; + const expectedPesoCommission = amountPeso * commissionRate; + const expectedBalance = initialBalance + expectedRmbCommission; + const expectedBalancePeso = initialBalancePeso + expectedPesoCommission; + + expect(updatedBalance).toBeCloseTo(expectedBalance, 8); + expect(updatedBalancePeso).toBeCloseTo(expectedBalancePeso, 8); + } + ), + { numRuns: 100 } + ); + }); + }); + + +/** + * Property 3: Cancellation reverses all commissions + * Tests for payment order cancellation with dual-currency commission handling + */ +describe('Property 3: Cancellation reverses all commissions', () => { + /** + * **Feature: dual-currency-commission, Property 3: Cancellation reverses all commissions** + * *For any* payment order cancellation, all associated commission records SHALL be cancelled + * and the inviter's balances SHALL be decremented by the respective commission amounts (with floor at zero) + * **Validates: Requirements 3.1, 3.2, 3.3** + */ + + // We need to mock paymentOrderService for cancellation tests + const mockPaymentOrderTransaction = { + commit: jest.fn(), + rollback: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockPaymentOrderTransaction.commit.mockClear(); + mockPaymentOrderTransaction.rollback.mockClear(); + sequelize.transaction.mockResolvedValue(mockPaymentOrderTransaction); + }); + + it('should cancel all commission records when order is cancelled', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 0.01, max: 100000, noNaN: true }), // RMB commission amount + fc.double({ min: 0.01, max: 100000, noNaN: true }), // Peso commission amount + async (rmbCommissionAmount, pesoCommissionAmount) => { + // Arrange + const orderId = 'order-uuid'; + const inviterId = 'inviter-uuid'; + + // Mock PaymentOrder.findByPk for cancellation + const mockOrder = { + id: orderId, + status: 'active', + cancelReason: null, + save: jest.fn().mockResolvedValue(true), + }; + PaymentOrder.findByPk.mockResolvedValue(mockOrder); + + // Mock commissions (dual-currency) + const mockCommissions = [ + { + id: 'commission-rmb-uuid', + inviterId: inviterId, + paymentOrderId: orderId, + commissionAmount: rmbCommissionAmount, + currency: 'RMB', + status: 'credited', + save: jest.fn().mockResolvedValue(true), + }, + { + id: 'commission-php-uuid', + inviterId: inviterId, + paymentOrderId: orderId, + commissionAmount: pesoCommissionAmount, + currency: 'PHP', + status: 'credited', + save: jest.fn().mockResolvedValue(true), + }, + ]; + Commission.findAll.mockResolvedValue(mockCommissions); + + // Mock inviter + const mockInviter = { + id: inviterId, + balance: rmbCommissionAmount + 100, // Ensure enough balance + balancePeso: pesoCommissionAmount + 100, + save: jest.fn().mockResolvedValue(true), + }; + User.findByPk.mockResolvedValue(mockInviter); + + // Act - Import and call cancelPaymentOrder + const paymentOrderService = require('../services/paymentOrderService'); + const result = await paymentOrderService.cancelPaymentOrder(orderId, 'Test cancellation'); + + // Assert: All commissions should be cancelled + expect(result.commissionsCancelledCount).toBe(2); + expect(mockCommissions[0].status).toBe('cancelled'); + expect(mockCommissions[1].status).toBe('cancelled'); + expect(mockCommissions[0].save).toHaveBeenCalled(); + expect(mockCommissions[1].save).toHaveBeenCalled(); + } + ), + { numRuns: 50 } + ); + }); + + it('should deduct correct amounts from respective currency balances', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 0.01, max: 1000, noNaN: true }), // RMB commission amount + fc.double({ min: 0.01, max: 1000, noNaN: true }), // Peso commission amount + fc.double({ min: 1000, max: 10000, noNaN: true }), // Initial RMB balance (high enough) + fc.double({ min: 1000, max: 10000, noNaN: true }), // Initial Peso balance (high enough) + async (rmbCommission, pesoCommission, initialRmbBalance, initialPesoBalance) => { + // Arrange + const orderId = 'order-uuid'; + const inviterId = 'inviter-uuid'; + + const mockOrder = { + id: orderId, + status: 'active', + cancelReason: null, + save: jest.fn().mockResolvedValue(true), + }; + PaymentOrder.findByPk.mockResolvedValue(mockOrder); + + const mockCommissions = [ + { + id: 'commission-rmb-uuid', + inviterId: inviterId, + commissionAmount: rmbCommission, + currency: 'RMB', + status: 'credited', + save: jest.fn().mockResolvedValue(true), + }, + { + id: 'commission-php-uuid', + inviterId: inviterId, + commissionAmount: pesoCommission, + currency: 'PHP', + status: 'credited', + save: jest.fn().mockResolvedValue(true), + }, + ]; + Commission.findAll.mockResolvedValue(mockCommissions); + + let finalRmbBalance = null; + let finalPesoBalance = null; + const mockInviter = { + id: inviterId, + balance: initialRmbBalance, + balancePeso: initialPesoBalance, + save: jest.fn().mockImplementation(function() { + finalRmbBalance = this.balance; + finalPesoBalance = this.balancePeso; + return Promise.resolve(); + }), + }; + User.findByPk.mockResolvedValue(mockInviter); + + // Act + const paymentOrderService = require('../services/paymentOrderService'); + await paymentOrderService.cancelPaymentOrder(orderId, 'Test cancellation'); + + // Assert: Balances should be decremented correctly + const expectedRmbBalance = initialRmbBalance - rmbCommission; + const expectedPesoBalance = initialPesoBalance - pesoCommission; + + expect(finalRmbBalance).toBeCloseTo(expectedRmbBalance, 8); + expect(finalPesoBalance).toBeCloseTo(expectedPesoBalance, 8); + } + ), + { numRuns: 50 } + ); + }); + + it('should floor balance at zero when deduction exceeds balance', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 100, max: 1000, noNaN: true }), // RMB commission (larger than balance) + fc.double({ min: 100, max: 1000, noNaN: true }), // Peso commission (larger than balance) + fc.double({ min: 0, max: 50, noNaN: true }), // Initial RMB balance (smaller than commission) + fc.double({ min: 0, max: 50, noNaN: true }), // Initial Peso balance (smaller than commission) + async (rmbCommission, pesoCommission, initialRmbBalance, initialPesoBalance) => { + // Arrange + const orderId = 'order-uuid'; + const inviterId = 'inviter-uuid'; + + const mockOrder = { + id: orderId, + status: 'active', + cancelReason: null, + save: jest.fn().mockResolvedValue(true), + }; + PaymentOrder.findByPk.mockResolvedValue(mockOrder); + + const mockCommissions = [ + { + id: 'commission-rmb-uuid', + inviterId: inviterId, + commissionAmount: rmbCommission, + currency: 'RMB', + status: 'credited', + save: jest.fn().mockResolvedValue(true), + }, + { + id: 'commission-php-uuid', + inviterId: inviterId, + commissionAmount: pesoCommission, + currency: 'PHP', + status: 'credited', + save: jest.fn().mockResolvedValue(true), + }, + ]; + Commission.findAll.mockResolvedValue(mockCommissions); + + let finalRmbBalance = null; + let finalPesoBalance = null; + const mockInviter = { + id: inviterId, + balance: initialRmbBalance, + balancePeso: initialPesoBalance, + save: jest.fn().mockImplementation(function() { + finalRmbBalance = this.balance; + finalPesoBalance = this.balancePeso; + return Promise.resolve(); + }), + }; + User.findByPk.mockResolvedValue(mockInviter); + + // Act + const paymentOrderService = require('../services/paymentOrderService'); + await paymentOrderService.cancelPaymentOrder(orderId, 'Test cancellation'); + + // Assert: Balances should be floored at zero + expect(finalRmbBalance).toBe(0); + expect(finalPesoBalance).toBe(0); + } + ), + { numRuns: 50 } + ); + }); + + it('should handle single currency commission cancellation correctly', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 0.01, max: 1000, noNaN: true }), // Commission amount + fc.constantFrom('RMB', 'PHP'), // Currency type + fc.double({ min: 1000, max: 10000, noNaN: true }), // Initial balance + async (commissionAmount, currency, initialBalance) => { + // Arrange + const orderId = 'order-uuid'; + const inviterId = 'inviter-uuid'; + + const mockOrder = { + id: orderId, + status: 'active', + cancelReason: null, + save: jest.fn().mockResolvedValue(true), + }; + PaymentOrder.findByPk.mockResolvedValue(mockOrder); + + // Single commission + const mockCommissions = [ + { + id: 'commission-uuid', + inviterId: inviterId, + commissionAmount: commissionAmount, + currency: currency, + status: 'credited', + save: jest.fn().mockResolvedValue(true), + }, + ]; + Commission.findAll.mockResolvedValue(mockCommissions); + + let finalRmbBalance = null; + let finalPesoBalance = null; + const mockInviter = { + id: inviterId, + balance: currency === 'RMB' ? initialBalance : 0, + balancePeso: currency === 'PHP' ? initialBalance : 0, + save: jest.fn().mockImplementation(function() { + finalRmbBalance = this.balance; + finalPesoBalance = this.balancePeso; + return Promise.resolve(); + }), + }; + User.findByPk.mockResolvedValue(mockInviter); + + // Act + const paymentOrderService = require('../services/paymentOrderService'); + const result = await paymentOrderService.cancelPaymentOrder(orderId, 'Test cancellation'); + + // Assert + expect(result.commissionsCancelledCount).toBe(1); + + if (currency === 'RMB') { + expect(finalRmbBalance).toBeCloseTo(initialBalance - commissionAmount, 8); + expect(finalPesoBalance).toBe(0); // Unchanged + } else { + expect(finalRmbBalance).toBe(0); // Unchanged + expect(finalPesoBalance).toBeCloseTo(initialBalance - commissionAmount, 8); + } + } + ), + { numRuns: 50 } + ); + }); +}); + + +/** + * Property 4: API returns all commissions for order + * Tests for payment order query methods returning all commissions + */ +describe('Property 4: API returns all commissions for order', () => { + /** + * **Feature: dual-currency-commission, Property 4: API returns all commissions for order** + * *For any* payment order with multiple commissions, the API response SHALL include all commission records + * with their respective currencies + * **Validates: Requirements 2.1, 2.3** + */ + + // Additional mocks needed for query methods + jest.mock('../models/Appointment', () => ({ + findByPk: jest.fn(), + findOne: jest.fn(), + })); + + jest.mock('../models/Admin', () => ({ + findByPk: jest.fn(), + })); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getPaymentOrderById returns all commissions', () => { + it('should return all commission records for dual-currency orders', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 0.01, max: 1000, noNaN: true }), // RMB commission amount + fc.double({ min: 0.01, max: 1000, noNaN: true }), // Peso commission amount + fc.double({ min: 0.001, max: 0.5, noNaN: true }), // commission rate + async (rmbCommissionAmount, pesoCommissionAmount, commissionRate) => { + // Arrange + const orderId = 'order-uuid'; + const inviterId = 'inviter-uuid'; + const inviteeId = 'invitee-uuid'; + + // Mock PaymentOrder.findByPk for getPaymentOrderById + PaymentOrder.findByPk.mockResolvedValue({ + id: orderId, + orderNo: 'PO123456789', + userId: inviteeId, + appointmentId: null, + amount: null, + amountRmb: rmbCommissionAmount / commissionRate, // Derive from commission + amountPeso: pesoCommissionAmount / commissionRate, + serviceContent: 'Test service', + paymentTime: new Date(), + notes: null, + status: 'active', + cancelReason: null, + createdAt: new Date(), + updatedAt: new Date(), + user: { + id: inviteeId, + uid: '123456', + nickname: 'Test User', + phone: '1234567890', + invitedBy: inviterId, + }, + appointment: null, + creator: { + id: 'admin-uuid', + username: 'admin', + email: 'admin@test.com', + }, + }); + + // Mock Commission.findAll to return dual-currency commissions + const mockCommissions = [ + { + id: 'commission-rmb-uuid', + inviterId: inviterId, + inviteeId: inviteeId, + paymentOrderId: orderId, + paymentAmount: rmbCommissionAmount / commissionRate, + commissionRate: commissionRate, + commissionAmount: rmbCommissionAmount, + currency: 'RMB', + status: 'credited', + createdAt: new Date(), + inviter: { + id: inviterId, + uid: '654321', + nickname: 'Inviter User', + }, + }, + { + id: 'commission-php-uuid', + inviterId: inviterId, + inviteeId: inviteeId, + paymentOrderId: orderId, + paymentAmount: pesoCommissionAmount / commissionRate, + commissionRate: commissionRate, + commissionAmount: pesoCommissionAmount, + currency: 'PHP', + status: 'credited', + createdAt: new Date(), + inviter: { + id: inviterId, + uid: '654321', + nickname: 'Inviter User', + }, + }, + ]; + Commission.findAll.mockResolvedValue(mockCommissions); + + // Act + const paymentOrderService = require('../services/paymentOrderService'); + const result = await paymentOrderService.getPaymentOrderById(orderId); + + // Assert: Response should include commissions array with all commissions + expect(result.commissions).not.toBeNull(); + expect(Array.isArray(result.commissions)).toBe(true); + expect(result.commissions.length).toBe(2); + + // Verify both currencies are present + const currencies = result.commissions.map(c => c.currency); + expect(currencies).toContain('RMB'); + expect(currencies).toContain('PHP'); + + // Verify commission amounts + const rmbCommission = result.commissions.find(c => c.currency === 'RMB'); + const phpCommission = result.commissions.find(c => c.currency === 'PHP'); + expect(parseFloat(rmbCommission.commissionAmount)).toBeCloseTo(rmbCommissionAmount, 2); + expect(parseFloat(phpCommission.commissionAmount)).toBeCloseTo(pesoCommissionAmount, 2); + + // Verify backward compatibility - commission field should have first commission + expect(result.commission).not.toBeNull(); + expect(result.commission.currency).toBe('RMB'); // First commission + } + ), + { numRuns: 100 } + ); + }); + + it('should return single commission for single-currency orders', async () => { + await fc.assert( + fc.asyncProperty( + fc.double({ min: 0.01, max: 1000, noNaN: true }), // commission amount + fc.constantFrom('RMB', 'PHP'), // currency type + fc.double({ min: 0.001, max: 0.5, noNaN: true }), // commission rate + async (commissionAmount, currency, commissionRate) => { + // Arrange + const orderId = 'order-uuid'; + const inviterId = 'inviter-uuid'; + const inviteeId = 'invitee-uuid'; + + PaymentOrder.findByPk.mockResolvedValue({ + id: orderId, + orderNo: 'PO123456789', + userId: inviteeId, + appointmentId: null, + amount: null, + amountRmb: currency === 'RMB' ? commissionAmount / commissionRate : null, + amountPeso: currency === 'PHP' ? commissionAmount / commissionRate : null, + serviceContent: 'Test service', + paymentTime: new Date(), + notes: null, + status: 'active', + cancelReason: null, + createdAt: new Date(), + updatedAt: new Date(), + user: { + id: inviteeId, + uid: '123456', + nickname: 'Test User', + phone: '1234567890', + invitedBy: inviterId, + }, + appointment: null, + creator: { + id: 'admin-uuid', + username: 'admin', + email: 'admin@test.com', + }, + }); + + // Single commission + const mockCommissions = [ + { + id: 'commission-uuid', + inviterId: inviterId, + inviteeId: inviteeId, + paymentOrderId: orderId, + paymentAmount: commissionAmount / commissionRate, + commissionRate: commissionRate, + commissionAmount: commissionAmount, + currency: currency, + status: 'credited', + createdAt: new Date(), + inviter: { + id: inviterId, + uid: '654321', + nickname: 'Inviter User', + }, + }, + ]; + Commission.findAll.mockResolvedValue(mockCommissions); + + // Act + const paymentOrderService = require('../services/paymentOrderService'); + const result = await paymentOrderService.getPaymentOrderById(orderId); + + // Assert: Response should include single commission in array + expect(result.commissions).not.toBeNull(); + expect(Array.isArray(result.commissions)).toBe(true); + expect(result.commissions.length).toBe(1); + expect(result.commissions[0].currency).toBe(currency); + expect(parseFloat(result.commissions[0].commissionAmount)).toBeCloseTo(commissionAmount, 2); + + // Backward compatibility + expect(result.commission).not.toBeNull(); + expect(result.commission.currency).toBe(currency); + } + ), + { numRuns: 100 } + ); + }); + + it('should return null commissions for orders without commissions', async () => { + // Arrange + const orderId = 'order-uuid'; + const inviteeId = 'invitee-uuid'; + + PaymentOrder.findByPk.mockResolvedValue({ + id: orderId, + orderNo: 'PO123456789', + userId: inviteeId, + appointmentId: null, + amount: null, + amountRmb: 100, + amountPeso: null, + serviceContent: 'Test service', + paymentTime: new Date(), + notes: null, + status: 'active', + cancelReason: null, + createdAt: new Date(), + updatedAt: new Date(), + user: { + id: inviteeId, + uid: '123456', + nickname: 'Test User', + phone: '1234567890', + invitedBy: null, // No inviter + }, + appointment: null, + creator: { + id: 'admin-uuid', + username: 'admin', + email: 'admin@test.com', + }, + }); + + // No commissions + Commission.findAll.mockResolvedValue([]); + + // Act + const paymentOrderService = require('../services/paymentOrderService'); + const result = await paymentOrderService.getPaymentOrderById(orderId); + + // Assert: Both commissions and commission should be null + expect(result.commissions).toBeNull(); + expect(result.commission).toBeNull(); + }); + }); + + describe('getPaymentOrders returns all commissions per order', () => { + it('should return commissions array for each order in list', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 5 }), // number of orders + fc.double({ min: 0.01, max: 1000, noNaN: true }), // RMB commission amount + fc.double({ min: 0.01, max: 1000, noNaN: true }), // Peso commission amount + async (numOrders, rmbCommissionAmount, pesoCommissionAmount) => { + // Arrange + const orders = []; + const allCommissions = []; + + for (let i = 0; i < numOrders; i++) { + const orderId = `order-uuid-${i}`; + orders.push({ + id: orderId, + orderNo: `PO12345678${i}`, + userId: `user-uuid-${i}`, + appointmentId: null, + amount: null, + amountRmb: 100, + amountPeso: 200, + serviceContent: 'Test service', + paymentTime: new Date(), + notes: null, + status: 'active', + createdAt: new Date(), + user: { + id: `user-uuid-${i}`, + uid: `12345${i}`, + nickname: `User ${i}`, + phone: '1234567890', + }, + appointment: null, + creator: { + id: 'admin-uuid', + username: 'admin', + email: 'admin@test.com', + }, + }); + + // Add dual-currency commissions for each order + allCommissions.push({ + paymentOrderId: orderId, + commissionAmount: rmbCommissionAmount, + status: 'credited', + currency: 'RMB', + }); + allCommissions.push({ + paymentOrderId: orderId, + commissionAmount: pesoCommissionAmount, + status: 'credited', + currency: 'PHP', + }); + } + + // Mock PaymentOrder.findAndCountAll + PaymentOrder.findAndCountAll = jest.fn().mockResolvedValue({ + count: numOrders, + rows: orders, + }); + + // Mock PaymentOrder.findOne for statistics + PaymentOrder.findOne = jest.fn().mockResolvedValue({ + totalRmb: 100 * numOrders, + totalPeso: 200 * numOrders, + }); + + // Mock Commission.findAll for all orders + Commission.findAll.mockResolvedValue(allCommissions); + + // Act + const paymentOrderService = require('../services/paymentOrderService'); + const result = await paymentOrderService.getPaymentOrders({ page: 1, limit: 20 }); + + // Assert: Each order should have commissions array + expect(result.records.length).toBe(numOrders); + + for (const record of result.records) { + expect(record.commissions).not.toBeNull(); + expect(Array.isArray(record.commissions)).toBe(true); + expect(record.commissions.length).toBe(2); + + // Verify both currencies present + const currencies = record.commissions.map(c => c.currency); + expect(currencies).toContain('RMB'); + expect(currencies).toContain('PHP'); + + // Verify backward compatibility + expect(record.commission).not.toBeNull(); + } + } + ), + { numRuns: 50 } + ); + }); + + it('should return null commissions for orders without commissions in list', async () => { + // Arrange + const orders = [ + { + id: 'order-uuid-1', + orderNo: 'PO123456781', + userId: 'user-uuid-1', + appointmentId: null, + amount: null, + amountRmb: 100, + amountPeso: null, + serviceContent: 'Test service', + paymentTime: new Date(), + notes: null, + status: 'active', + createdAt: new Date(), + user: { + id: 'user-uuid-1', + uid: '123451', + nickname: 'User 1', + phone: '1234567890', + }, + appointment: null, + creator: { + id: 'admin-uuid', + username: 'admin', + email: 'admin@test.com', + }, + }, + ]; + + PaymentOrder.findAndCountAll = jest.fn().mockResolvedValue({ + count: 1, + rows: orders, + }); + + PaymentOrder.findOne = jest.fn().mockResolvedValue({ + totalRmb: 100, + totalPeso: 0, + }); + + // No commissions + Commission.findAll.mockResolvedValue([]); + + // Act + const paymentOrderService = require('../services/paymentOrderService'); + const result = await paymentOrderService.getPaymentOrders({ page: 1, limit: 20 }); + + // Assert + expect(result.records.length).toBe(1); + expect(result.records[0].commissions).toBeNull(); + expect(result.records[0].commission).toBeNull(); + }); + }); +}); + + +/** + * Property 5: Statistics aggregate by currency + * Tests for commission statistics aggregation by currency + */ +describe('Property 5: Statistics aggregate by currency', () => { + /** + * **Feature: dual-currency-commission, Property 5: Statistics aggregate by currency** + * *For any* commission statistics query, the totals SHALL be correctly separated by currency + * (totalCommissionRmb, totalCommissionPeso) + * **Validates: Requirements 4.1, 4.2, 4.3** + */ + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getCommissionStats aggregates by currency', () => { + it('should correctly aggregate commission totals by currency', async () => { + await fc.assert( + fc.asyncProperty( + // Generate array of commissions with random amounts and currencies + fc.array( + fc.record({ + commissionAmount: fc.double({ min: 0.01, max: 1000, noNaN: true }), + currency: fc.constantFrom('RMB', 'PHP'), + }), + { minLength: 1, maxLength: 20 } + ), + fc.double({ min: 0, max: 10000, noNaN: true }), // initial RMB balance + fc.double({ min: 0, max: 10000, noNaN: true }), // initial Peso balance + async (commissions, initialBalance, initialBalancePeso) => { + // Arrange + const inviterId = 'inviter-uuid'; + + // Calculate expected totals + let expectedRmbTotal = 0; + let expectedPesoTotal = 0; + commissions.forEach(c => { + if (c.currency === 'PHP') { + expectedPesoTotal += c.commissionAmount; + } else { + expectedRmbTotal += c.commissionAmount; + } + }); + + // Mock User.findByPk + User.findByPk.mockResolvedValue({ + id: inviterId, + balance: initialBalance, + balancePeso: initialBalancePeso, + invitationCode: 'ABC123', + update: jest.fn().mockResolvedValue(true), + }); + + // Mock User.count for total invites + User.count.mockResolvedValue(5); + + // Mock Commission.findAll to return the generated commissions + Commission.findAll.mockResolvedValue( + commissions.map((c, i) => ({ + id: `commission-uuid-${i}`, + commissionAmount: c.commissionAmount, + currency: c.currency, + status: 'credited', + })) + ); + + // Mock Commission.count for paid invites + Commission.count.mockResolvedValue(3); + + // Act + const result = await commissionService.getCommissionStats(inviterId); + + // Assert: Totals should be correctly separated by currency + expect(parseFloat(result.totalCommissionRmb)).toBeCloseTo(expectedRmbTotal, 2); + expect(parseFloat(result.totalCommissionPeso)).toBeCloseTo(expectedPesoTotal, 2); + + // Verify balances are returned correctly + expect(parseFloat(result.availableBalanceRmb)).toBeCloseTo(initialBalance, 2); + expect(parseFloat(result.availableBalancePeso)).toBeCloseTo(initialBalancePeso, 2); + } + ), + { numRuns: 100 } + ); + }); + + it('should return zero totals when no commissions exist', async () => { + // Arrange + const inviterId = 'inviter-uuid'; + + User.findByPk.mockResolvedValue({ + id: inviterId, + balance: 100, + balancePeso: 200, + invitationCode: 'ABC123', + update: jest.fn().mockResolvedValue(true), + }); + + User.count.mockResolvedValue(0); + Commission.findAll.mockResolvedValue([]); + Commission.count.mockResolvedValue(0); + + // Act + const result = await commissionService.getCommissionStats(inviterId); + + // Assert + expect(parseFloat(result.totalCommissionRmb)).toBe(0); + expect(parseFloat(result.totalCommissionPeso)).toBe(0); + }); + + it('should handle only RMB commissions correctly', async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.double({ min: 0.01, max: 1000, noNaN: true }), + { minLength: 1, maxLength: 10 } + ), + async (rmbAmounts) => { + // Arrange + const inviterId = 'inviter-uuid'; + const expectedRmbTotal = rmbAmounts.reduce((sum, amt) => sum + amt, 0); + + User.findByPk.mockResolvedValue({ + id: inviterId, + balance: 100, + balancePeso: 0, + invitationCode: 'ABC123', + update: jest.fn().mockResolvedValue(true), + }); + + User.count.mockResolvedValue(5); + Commission.findAll.mockResolvedValue( + rmbAmounts.map((amt, i) => ({ + id: `commission-uuid-${i}`, + commissionAmount: amt, + currency: 'RMB', + status: 'credited', + })) + ); + Commission.count.mockResolvedValue(3); + + // Act + const result = await commissionService.getCommissionStats(inviterId); + + // Assert + expect(parseFloat(result.totalCommissionRmb)).toBeCloseTo(expectedRmbTotal, 2); + expect(parseFloat(result.totalCommissionPeso)).toBe(0); + } + ), + { numRuns: 50 } + ); + }); + + it('should handle only PHP commissions correctly', async () => { + await fc.assert( + fc.asyncProperty( + fc.array( + fc.double({ min: 0.01, max: 1000, noNaN: true }), + { minLength: 1, maxLength: 10 } + ), + async (pesoAmounts) => { + // Arrange + const inviterId = 'inviter-uuid'; + const expectedPesoTotal = pesoAmounts.reduce((sum, amt) => sum + amt, 0); + + User.findByPk.mockResolvedValue({ + id: inviterId, + balance: 0, + balancePeso: 100, + invitationCode: 'ABC123', + update: jest.fn().mockResolvedValue(true), + }); + + User.count.mockResolvedValue(5); + Commission.findAll.mockResolvedValue( + pesoAmounts.map((amt, i) => ({ + id: `commission-uuid-${i}`, + commissionAmount: amt, + currency: 'PHP', + status: 'credited', + })) + ); + Commission.count.mockResolvedValue(3); + + // Act + const result = await commissionService.getCommissionStats(inviterId); + + // Assert + expect(parseFloat(result.totalCommissionRmb)).toBe(0); + expect(parseFloat(result.totalCommissionPeso)).toBeCloseTo(expectedPesoTotal, 2); + } + ), + { numRuns: 50 } + ); + }); + }); + + describe('getInvitedUsersWithCommissions aggregates by currency per invitee', () => { + it('should correctly aggregate commission totals by currency for each invitee', async () => { + await fc.assert( + fc.asyncProperty( + // Generate array of commissions with random amounts and currencies + fc.array( + fc.record({ + commissionAmount: fc.double({ min: 0.01, max: 1000, noNaN: true }), + paymentAmount: fc.double({ min: 1, max: 10000, noNaN: true }), + currency: fc.constantFrom('RMB', 'PHP'), + }), + { minLength: 1, maxLength: 10 } + ), + async (commissions) => { + // Arrange + const inviterId = 'inviter-uuid'; + const inviteeId = 'invitee-uuid'; + + // Calculate expected totals + let expectedRmbCommission = 0; + let expectedPesoCommission = 0; + let expectedRmbPayment = 0; + let expectedPesoPayment = 0; + + commissions.forEach(c => { + if (c.currency === 'PHP') { + expectedPesoCommission += c.commissionAmount; + expectedPesoPayment += c.paymentAmount; + } else { + expectedRmbCommission += c.commissionAmount; + expectedRmbPayment += c.paymentAmount; + } + }); + + // Mock User.findAndCountAll for invited users + User.findAndCountAll = jest.fn().mockResolvedValue({ + count: 1, + rows: [{ + id: inviteeId, + uid: '123456', + nickname: 'Test Invitee', + avatar: null, + createdAt: new Date(), + }], + }); + + // Mock Commission.findAll for the invitee's commissions + Commission.findAll.mockResolvedValue( + commissions.map((c, i) => ({ + id: `commission-uuid-${i}`, + inviterId: inviterId, + inviteeId: inviteeId, + commissionAmount: c.commissionAmount, + paymentAmount: c.paymentAmount, + currency: c.currency, + status: 'credited', + createdAt: new Date(), + paymentOrder: { + id: `order-uuid-${i}`, + orderNo: `PO12345678${i}`, + serviceContent: 'Test service', + paymentTime: new Date(), + amountRmb: c.currency === 'RMB' ? c.paymentAmount : null, + amountPeso: c.currency === 'PHP' ? c.paymentAmount : null, + }, + })) + ); + + // Act + const result = await commissionService.getInvitedUsersWithCommissions(inviterId, { page: 1, limit: 20 }); + + // Assert: Totals should be correctly separated by currency + expect(result.records.length).toBe(1); + const inviteeRecord = result.records[0]; + + expect(parseFloat(inviteeRecord.totalCommissionRmb)).toBeCloseTo(expectedRmbCommission, 2); + expect(parseFloat(inviteeRecord.totalCommissionPeso)).toBeCloseTo(expectedPesoCommission, 2); + expect(parseFloat(inviteeRecord.totalPaymentRmb)).toBeCloseTo(expectedRmbPayment, 2); + expect(parseFloat(inviteeRecord.totalPaymentPeso)).toBeCloseTo(expectedPesoPayment, 2); + + // Verify order count + expect(inviteeRecord.orderCount).toBe(commissions.length); + + // Verify each order has correct currency + inviteeRecord.orders.forEach((order, i) => { + expect(order.currency).toBe(commissions[i].currency); + }); + } + ), + { numRuns: 50 } + ); + }); + + it('should return zero totals for invitee with no commissions', async () => { + // Arrange + const inviterId = 'inviter-uuid'; + const inviteeId = 'invitee-uuid'; + + User.findAndCountAll = jest.fn().mockResolvedValue({ + count: 1, + rows: [{ + id: inviteeId, + uid: '123456', + nickname: 'Test Invitee', + avatar: null, + createdAt: new Date(), + }], + }); + + Commission.findAll.mockResolvedValue([]); + + // Act + const result = await commissionService.getInvitedUsersWithCommissions(inviterId, { page: 1, limit: 20 }); + + // Assert + expect(result.records.length).toBe(1); + const inviteeRecord = result.records[0]; + + expect(parseFloat(inviteeRecord.totalCommissionRmb)).toBe(0); + expect(parseFloat(inviteeRecord.totalCommissionPeso)).toBe(0); + expect(parseFloat(inviteeRecord.totalPaymentRmb)).toBe(0); + expect(parseFloat(inviteeRecord.totalPaymentPeso)).toBe(0); + expect(inviteeRecord.orderCount).toBe(0); + }); + + it('should handle multiple invitees with different currency distributions', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 2, max: 5 }), // number of invitees + async (numInvitees) => { + // Arrange + const inviterId = 'inviter-uuid'; + const invitees = []; + const commissionsByInvitee = new Map(); + + for (let i = 0; i < numInvitees; i++) { + const inviteeId = `invitee-uuid-${i}`; + invitees.push({ + id: inviteeId, + uid: `12345${i}`, + nickname: `Invitee ${i}`, + avatar: null, + createdAt: new Date(), + }); + + // Generate random commissions for this invitee + const numCommissions = Math.floor(Math.random() * 5) + 1; + const commissions = []; + for (let j = 0; j < numCommissions; j++) { + commissions.push({ + id: `commission-uuid-${i}-${j}`, + inviterId: inviterId, + inviteeId: inviteeId, + commissionAmount: Math.random() * 100, + paymentAmount: Math.random() * 1000, + currency: Math.random() > 0.5 ? 'RMB' : 'PHP', + status: 'credited', + createdAt: new Date(), + paymentOrder: { + id: `order-uuid-${i}-${j}`, + orderNo: `PO${i}${j}`, + serviceContent: 'Test service', + paymentTime: new Date(), + }, + }); + } + commissionsByInvitee.set(inviteeId, commissions); + } + + User.findAndCountAll = jest.fn().mockResolvedValue({ + count: numInvitees, + rows: invitees, + }); + + // Mock Commission.findAll to return commissions for the specific invitee + Commission.findAll.mockImplementation(({ where }) => { + const inviteeId = where.inviteeId; + return Promise.resolve(commissionsByInvitee.get(inviteeId) || []); + }); + + // Act + const result = await commissionService.getInvitedUsersWithCommissions(inviterId, { page: 1, limit: 20 }); + + // Assert: Each invitee should have correct totals + expect(result.records.length).toBe(numInvitees); + + for (let i = 0; i < numInvitees; i++) { + const inviteeRecord = result.records[i]; + const inviteeId = invitees[i].id; + const commissions = commissionsByInvitee.get(inviteeId); + + // Calculate expected totals + let expectedRmbCommission = 0; + let expectedPesoCommission = 0; + commissions.forEach(c => { + if (c.currency === 'PHP') { + expectedPesoCommission += c.commissionAmount; + } else { + expectedRmbCommission += c.commissionAmount; + } + }); + + expect(parseFloat(inviteeRecord.totalCommissionRmb)).toBeCloseTo(expectedRmbCommission, 2); + expect(parseFloat(inviteeRecord.totalCommissionPeso)).toBeCloseTo(expectedPesoCommission, 2); + } + } + ), + { numRuns: 30 } + ); + }); + }); +});