forked from StellarSplit/StellarSplit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsplit.validator.ts
More file actions
312 lines (274 loc) · 10.2 KB
/
Copy pathsplit.validator.ts
File metadata and controls
312 lines (274 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
import { BadRequestException } from '@nestjs/common';
import {
CalculateSplitDto,
SplitType,
ItemDto,
PercentageSplitDto,
CustomSplitDto,
} from '../dto/calculate-split.dto';
/**
* Validator class for split calculation requests
* Ensures data integrity and business rules are followed
*/
export class SplitValidator {
/**
* Main validation method that routes to specific validators based on split type
*/
static validate(dto: CalculateSplitDto): void {
// Validate basic requirements
this.validateBasicRequirements(dto);
// Validate specific split type
switch (dto.splitType) {
case SplitType.EQUAL:
this.validateEqualSplit(dto);
break;
case SplitType.ITEMIZED:
this.validateItemizedSplit(dto);
break;
case SplitType.PERCENTAGE:
this.validatePercentageSplit(dto);
break;
case SplitType.CUSTOM:
this.validateCustomSplit(dto);
break;
default:
throw new BadRequestException('Invalid split type');
}
}
/**
* Validates basic requirements common to all split types
*/
private static validateBasicRequirements(dto: CalculateSplitDto): void {
// Ensure there are participants
if (!dto.participantIds || dto.participantIds.length === 0) {
throw new BadRequestException('At least one participant is required');
}
// Check for duplicate participant IDs
const uniqueParticipants = new Set(dto.participantIds);
if (uniqueParticipants.size !== dto.participantIds.length) {
throw new BadRequestException('Duplicate participant IDs are not allowed');
}
// Validate monetary values
if (dto.subtotal < 0) {
throw new BadRequestException('Subtotal cannot be negative');
}
if (dto.tax !== undefined && dto.tax < 0) {
throw new BadRequestException('Tax cannot be negative');
}
if (dto.tip !== undefined && dto.tip < 0) {
throw new BadRequestException('Tip cannot be negative');
}
// Validate total is reasonable (at least subtotal)
const total = dto.subtotal + (dto.tax || 0) + (dto.tip || 0);
if (total <= 0 && dto.participantIds.length > 0) {
throw new BadRequestException('Total amount must be greater than zero');
}
}
/**
* Validates equal split requirements
*/
private static validateEqualSplit(dto: CalculateSplitDto): void {
// No additional validation needed for equal split
// Just ensure no extra data is provided
if (dto.items || dto.percentages || dto.customAmounts) {
throw new BadRequestException(
'Equal split should not include items, percentages, or custom amounts',
);
}
}
/**
* Validates itemized split requirements
*/
private static validateItemizedSplit(dto: CalculateSplitDto): void {
if (!dto.items || dto.items.length === 0) {
throw new BadRequestException('Itemized split requires at least one item');
}
// Ensure items don't include percentages or custom amounts
if (dto.percentages || dto.customAmounts) {
throw new BadRequestException(
'Itemized split should not include percentages or custom amounts',
);
}
// Validate each item
dto.items.forEach((item: ItemDto, index: number) => {
// Ensure item has a name
if (!item.name || item.name.trim() === '') {
throw new BadRequestException(`Item at index ${index} must have a name`);
}
// Ensure item has a valid price
if (item.price < 0) {
throw new BadRequestException(
`Item "${item.name}" has invalid price: ${item.price}`,
);
}
// Ensure item has participants
if (!item.participantIds || item.participantIds.length === 0) {
throw new BadRequestException(
`Item "${item.name}" must have at least one participant`,
);
}
// Ensure all item participants are in the main participant list
item.participantIds.forEach((participantId: string) => {
if (!dto.participantIds.includes(participantId)) {
throw new BadRequestException(
`Item "${item.name}" has participant "${participantId}" who is not in the main participant list`,
);
}
});
// Check for duplicate participants in item
const uniqueItemParticipants = new Set(item.participantIds);
if (uniqueItemParticipants.size !== item.participantIds.length) {
throw new BadRequestException(
`Item "${item.name}" has duplicate participants`,
);
}
});
// Validate that total of items matches subtotal (with small tolerance for rounding)
const itemsTotal = dto.items.reduce((sum, item) => sum + item.price, 0);
const tolerance = 0.01; // 1 cent tolerance
if (Math.abs(itemsTotal - dto.subtotal) > tolerance) {
throw new BadRequestException(
`Sum of item prices (${itemsTotal.toFixed(2)}) does not match subtotal (${dto.subtotal.toFixed(2)})`,
);
}
}
/**
* Validates percentage split requirements
*/
private static validatePercentageSplit(dto: CalculateSplitDto): void {
if (!dto.percentages || dto.percentages.length === 0) {
throw new BadRequestException(
'Percentage split requires percentage configuration',
);
}
// Ensure percentages don't include items or custom amounts
if (dto.items || dto.customAmounts) {
throw new BadRequestException(
'Percentage split should not include items or custom amounts',
);
}
// Validate each percentage entry
const percentageMap = new Map<string, number>();
dto.percentages.forEach((pct: PercentageSplitDto) => {
// Ensure participant is in main list
if (!dto.participantIds.includes(pct.participantId)) {
throw new BadRequestException(
`Percentage split includes participant "${pct.participantId}" who is not in the main participant list`,
);
}
// Check for duplicate participants
if (percentageMap.has(pct.participantId)) {
throw new BadRequestException(
`Duplicate percentage entry for participant "${pct.participantId}"`,
);
}
// Validate percentage range
if (pct.percentage < 0 || pct.percentage > 100) {
throw new BadRequestException(
`Invalid percentage ${pct.percentage} for participant "${pct.participantId}". Must be between 0 and 100`,
);
}
percentageMap.set(pct.participantId, pct.percentage);
});
// Calculate total percentage
const totalPercentage = Array.from(percentageMap.values()).reduce(
(sum, pct) => sum + pct,
0,
);
// Allow small tolerance for floating point errors
const tolerance = 0.01;
if (Math.abs(totalPercentage - 100) > tolerance) {
throw new BadRequestException(
`Total percentage (${totalPercentage.toFixed(2)}%) must equal 100%`,
);
}
// Ensure all participants have a percentage
dto.participantIds.forEach((participantId) => {
if (!percentageMap.has(participantId)) {
throw new BadRequestException(
`Participant "${participantId}" is missing a percentage assignment`,
);
}
});
}
/**
* Validates custom split requirements
*/
private static validateCustomSplit(dto: CalculateSplitDto): void {
if (!dto.customAmounts || dto.customAmounts.length === 0) {
throw new BadRequestException('Custom split requires custom amount configuration');
}
// Ensure custom amounts don't include items or percentages
if (dto.items || dto.percentages) {
throw new BadRequestException(
'Custom split should not include items or percentages',
);
}
// Validate each custom amount entry
const amountMap = new Map<string, number>();
dto.customAmounts.forEach((custom: CustomSplitDto) => {
// Ensure participant is in main list
if (!dto.participantIds.includes(custom.participantId)) {
throw new BadRequestException(
`Custom split includes participant "${custom.participantId}" who is not in the main participant list`,
);
}
// Check for duplicate participants
if (amountMap.has(custom.participantId)) {
throw new BadRequestException(
`Duplicate custom amount entry for participant "${custom.participantId}"`,
);
}
// Validate amount
if (custom.amount < 0) {
throw new BadRequestException(
`Invalid amount ${custom.amount} for participant "${custom.participantId}". Must be non-negative`,
);
}
amountMap.set(custom.participantId, custom.amount);
});
// Calculate total custom amounts
const totalCustom = Array.from(amountMap.values()).reduce(
(sum, amt) => sum + amt,
0,
);
// Validate that total matches subtotal (with small tolerance for rounding)
const tolerance = 0.01; // 1 cent tolerance
if (Math.abs(totalCustom - dto.subtotal) > tolerance) {
throw new BadRequestException(
`Sum of custom amounts (${totalCustom.toFixed(2)}) does not match subtotal (${dto.subtotal.toFixed(2)})`,
);
}
// Ensure all participants have a custom amount
dto.participantIds.forEach((participantId) => {
if (!amountMap.has(participantId)) {
throw new BadRequestException(
`Participant "${participantId}" is missing a custom amount assignment`,
);
}
});
}
/**
* Validates the result to ensure totals match and no rounding errors exist beyond tolerance
*/
static validateResult(
dto: CalculateSplitDto,
calculatedTotal: number,
roundingAdjustment: number,
): void {
const expectedTotal = dto.subtotal + (dto.tax || 0) + (dto.tip || 0);
const tolerance = 0.01; // 1 cent tolerance
if (Math.abs(calculatedTotal - expectedTotal) > tolerance) {
throw new BadRequestException(
`Calculated total (${calculatedTotal.toFixed(2)}) does not match expected total (${expectedTotal.toFixed(2)})`,
);
}
// Ensure rounding adjustment is reasonable (within 1 cent per participant)
const maxAdjustment = dto.participantIds.length * 0.01;
if (Math.abs(roundingAdjustment) > maxAdjustment) {
throw new BadRequestException(
`Rounding adjustment (${roundingAdjustment.toFixed(2)}) exceeds reasonable threshold`,
);
}
}
}