001package io.konik.validation;
002
003import com.google.common.base.Predicate;
004import com.google.common.collect.Collections2;
005import com.google.common.collect.Iterables;
006import io.konik.validator.annotation.Basic;
007import io.konik.zugferd.Invoice;
008import io.konik.zugferd.entity.GrossPrice;
009import io.konik.zugferd.entity.PaymentMeans;
010import io.konik.zugferd.entity.trade.MonetarySummation;
011import io.konik.zugferd.entity.trade.Settlement;
012import io.konik.zugferd.entity.trade.Trade;
013import io.konik.zugferd.entity.trade.item.Item;
014import io.konik.zugferd.entity.trade.item.SpecifiedMonetarySummation;
015import io.konik.zugferd.entity.trade.item.SpecifiedSettlement;
016import io.konik.zugferd.profile.ConformanceLevel;
017import io.konik.zugferd.unqualified.Amount;
018import org.apache.bval.jsr.util.PathImpl;
019import org.slf4j.Logger;
020import org.slf4j.LoggerFactory;
021
022import javax.annotation.Nullable;
023import javax.validation.*;
024import javax.validation.metadata.ConstraintDescriptor;
025import java.lang.annotation.Annotation;
026import java.math.BigDecimal;
027import java.math.RoundingMode;
028import java.util.*;
029
030/**
031 * Validates {@link Invoice}'s {@link MonetarySummation} by comparing values after recalculating MonetarySummation
032 * for the invoice and all line position items.
033 */
034public class MonetarySummationValidator {
035
036        private static Logger log = LoggerFactory.getLogger(MonetarySummationValidator.class);
037
038        private final MessageInterpolator messageInterpolator;
039
040        /**
041         * @param messageInterpolator
042         */
043        public MonetarySummationValidator(MessageInterpolator messageInterpolator) {
044                this.messageInterpolator = messageInterpolator;
045        }
046
047        /**
048         * Checks if given method belongs to the validation groups profile.
049         *
050         * @param clazz
051         * @param methodName
052         * @param validationGroups
053         * @return true if the method belongs to this validation group 
054         * 
055         */
056        public static boolean belongsToProfile(final Class<?> clazz, final String methodName, final List<Class<?>> validationGroups) {
057                try {
058                        Annotation[] annotations  = clazz.getMethod(methodName).getAnnotations();
059                        List<Annotation> profileAnnotationsOnly = new LinkedList<Annotation>(Collections2.filter(Arrays.asList(annotations), new Predicate<Annotation>() {
060                                @Override
061                                public boolean apply(Annotation annotation) {
062                                        return ConformanceLevel.getAnnotations().contains(annotation.annotationType());
063                                }
064                        }));
065
066                        if (profileAnnotationsOnly.isEmpty()) {
067                                return true;
068                        }
069
070                        if (profileAnnotationsOnly.size() == 1 && profileAnnotationsOnly.get(0).annotationType().equals(Basic.class)) {
071                                return true;
072                        }
073
074                        return Iterables.any(profileAnnotationsOnly, new Predicate<Annotation>() {
075                                @Override
076                                public boolean apply(@Nullable Annotation annotation) {
077                                        return validationGroups.contains(annotation.annotationType());
078                                }
079                        });
080
081                } catch (Exception e) {
082                        log.warn("{} caught while checking if method {} from class {} belongs to validation groups: {}", e.getClass().getSimpleName(), methodName, clazz, e.getMessage());
083                }
084
085                return false;
086        }
087
088        public Set<ConstraintViolation<Invoice>> validate(final Invoice invoice, final Class<?>[] validationGroups) {
089                if (invoice == null) {
090                        throw new IllegalArgumentException("Invoice cannot be null");
091                }
092
093                Set<ConstraintViolation<Invoice>> violations = new HashSet<ConstraintViolation<Invoice>>();
094                Trade trade = invoice.getTrade();
095
096                if (trade != null) {
097                        Settlement settlement = trade.getSettlement();
098
099                        List<Class<?>> validationGroupsList = Arrays.asList(validationGroups);
100
101                        if (settlement.getMonetarySummation() != null) {
102                                log.debug("Validating invoice monetary summation...");
103
104                                MonetarySummation monetarySummation = settlement.getMonetarySummation();
105                                MonetarySummation calculatedMonetarySummation = AmountCalculator.recalculate(invoice).getMonetarySummation();
106
107                                Class<?> clazz = MonetarySummation.class;
108
109                                if (belongsToProfile(clazz, "getGrandTotal", validationGroupsList) &&
110                                                !areEqual(monetarySummation.getGrandTotal(), calculatedMonetarySummation.getGrandTotal())) {
111                                        String message = message(monetarySummation.getGrandTotal(), calculatedMonetarySummation.getGrandTotal());
112                                        violations.add(new Violation(invoice, message, "monetarySummation.grandTotal.error", "trade.settlement.monetarySummation.grandTotal", monetarySummation.getGrandTotal() != null ? monetarySummation.getGrandTotal().getValue() : null));
113                                }
114
115                                if (belongsToProfile(clazz, "getTaxBasisTotal", validationGroupsList) &&
116                                                !areEqual(monetarySummation.getTaxBasisTotal(), calculatedMonetarySummation.getTaxBasisTotal())) {
117                                        String message = message(monetarySummation.getTaxBasisTotal(), calculatedMonetarySummation.getTaxBasisTotal());
118                                        violations.add(new Violation(invoice, message, "monetarySummation.taxBasisTotal.error", "trade.settlement.monetarySummation.taxBasisTotal", monetarySummation.getTaxBasisTotal() != null ? monetarySummation.getTaxBasisTotal().getValue() : null));
119                                }
120
121                                if (belongsToProfile(clazz, "getChargeTotal", validationGroupsList) &&
122                                                !areEqual(monetarySummation.getChargeTotal(), calculatedMonetarySummation.getChargeTotal())) {
123                                        String message = message(monetarySummation.getChargeTotal(), calculatedMonetarySummation.getChargeTotal());
124                                        violations.add(new Violation(invoice, message, "monetarySummation.chargeTotal.error", "trade.settlement.monetarySummation.chargeTotal", monetarySummation.getChargeTotal() != null ? monetarySummation.getChargeTotal().getValue() : null));
125                                }
126
127                                if (belongsToProfile(clazz, "getAllowanceTotal", validationGroupsList) &&
128                                                !areEqual(monetarySummation.getAllowanceTotal(), calculatedMonetarySummation.getAllowanceTotal())) {
129                                        String message = message(monetarySummation.getAllowanceTotal(), calculatedMonetarySummation.getAllowanceTotal());
130                                        violations.add(new Violation(invoice, message, "monetarySummation.allowanceTotal.error", "trade.settlement.monetarySummation.allowanceTotal", monetarySummation.getAllowanceTotal() != null ? monetarySummation.getAllowanceTotal().getValue() : null));
131                                }
132
133                                boolean expectDuePayable = monetarySummation.getTotalPrepaid() != null && !isEqualZero(monetarySummation.getTotalPrepaid());
134                                if (settlement.getPaymentMeans() != null) {
135                                        for (PaymentMeans paymentMeans : settlement.getPaymentMeans()) {
136                                                expectDuePayable = expectDuePayable || paymentMeans.getCode() != null;
137                                        }
138                                }
139
140                                if (belongsToProfile(clazz, "getDuePayable", validationGroupsList) &&
141                                                expectDuePayable &&
142                                                !areEqual(monetarySummation.getDuePayable(), calculatedMonetarySummation.getDuePayable())) {
143                                        String message = message(monetarySummation.getDuePayable(), calculatedMonetarySummation.getDuePayable());
144                                        violations.add(new Violation(invoice, message, "monetarySummation.duePayable.error", "trade.settlement.monetarySummation.duePayable", monetarySummation.getDuePayable() != null ? monetarySummation.getDuePayable().getValue() : null));
145                                }
146
147                                if (belongsToProfile(clazz, "getLineTotal", validationGroupsList) &&
148                                                !areEqual(monetarySummation.getLineTotal(), calculatedMonetarySummation.getLineTotal())) {
149                                        String message = message(monetarySummation.getLineTotal(), calculatedMonetarySummation.getLineTotal());
150                                        violations.add(new Violation(invoice, message, "monetarySummation.lineTotal.error", "trade.settlement.monetarySummation.lineTotal", monetarySummation.getLineTotal() != null ? monetarySummation.getLineTotal().getValue() : null));
151                                }
152
153                                if (belongsToProfile(clazz, "getTaxTotal", validationGroupsList) &&
154                                                !areEqual(monetarySummation.getTaxTotal(), calculatedMonetarySummation.getTaxTotal())) {
155                                        String message = message(monetarySummation.getTaxTotal(), calculatedMonetarySummation.getTaxTotal());
156                                        violations.add(new Violation(invoice, message, "monetarySummation.taxTotal.error", "trade.settlement.monetarySummation.taxTotal", monetarySummation.getTaxTotal() != null ? monetarySummation.getTaxTotal().getValue() : null));
157                                }
158
159                                if (belongsToProfile(clazz, "getTotalPrepaid", validationGroupsList) &&
160                                                !areEqual(monetarySummation.getTotalPrepaid(), calculatedMonetarySummation.getTotalPrepaid())) {
161                                        String message = message(monetarySummation.getTotalPrepaid(), calculatedMonetarySummation.getTotalPrepaid());
162                                        violations.add(new Violation(invoice, message, "monetarySummation.totalPrepaid.error", "trade.settlement.monetarySummation.totalPrepaid", monetarySummation.getTotalPrepaid() != null ? monetarySummation.getTotalPrepaid().getValue() : null));
163                                }
164                        }
165
166                        log.debug("Validating item's specified monetary summations...");
167
168                        if (trade.getItems() != null) {
169                                for (int i = 0; i < trade.getItems().size(); i++) {
170                                        Item item = trade.getItems().get(i);
171
172                                        if (item.getSettlement() != null) {
173                                                SpecifiedSettlement specifiedSettlement = item.getSettlement();
174
175                                                if (specifiedSettlement.getMonetarySummation() != null) {
176                                                        SpecifiedMonetarySummation monetarySummation = specifiedSettlement.getMonetarySummation();
177                                                        SpecifiedMonetarySummation calculatedMonetarySummation = AmountCalculator.calculateSpecifiedMonetarySummation(item);
178
179                                                        if (belongsToProfile(SpecifiedMonetarySummation.class, "getLineTotal", validationGroupsList) &&
180                                                                        !areEqual(monetarySummation.getLineTotal(), calculatedMonetarySummation.getLineTotal())) {
181                                                                String message = message(monetarySummation.getLineTotal(), calculatedMonetarySummation.getLineTotal());
182                                                                violations.add(new Violation(invoice, message, "item.monetarySummation.lineTotal.error", "trade.items["+i+"].settlement.monetarySummation.lineTotal", monetarySummation.getLineTotal() != null ? monetarySummation.getLineTotal().getValue() : null));
183                                                        }
184                                                        if (belongsToProfile(SpecifiedMonetarySummation.class, "getTotalAllowanceCharge", validationGroupsList) &&
185                                                                        ((grossPriceIncludesCharges(item) && monetarySummation.getTotalAllowanceCharge() == null) || !areEqual(monetarySummation.getTotalAllowanceCharge(), calculatedMonetarySummation.getTotalAllowanceCharge()))) {
186                                                                String message = message(monetarySummation.getTotalAllowanceCharge(), calculatedMonetarySummation.getTotalAllowanceCharge());
187                                                                violations.add(new Violation(invoice, message, "item.monetarySummation.totalAllowanceCharge.error", "trade.items["+i+"].settlement.monetarySummation.totalAllowanceCharge", monetarySummation.getTotalAllowanceCharge() != null ? monetarySummation.getTotalAllowanceCharge().getValue() : null));
188                                                        }
189                                                }
190                                        }
191                                }
192                        }
193                }
194
195                return violations;
196        }
197
198        private static boolean grossPriceIncludesCharges(final Item item) {
199                boolean result = false;
200
201                if (item != null && item.getAgreement() != null && item.getAgreement().getGrossPrice() != null) {
202                        GrossPrice grossPrice = item.getAgreement().getGrossPrice();
203
204                        if (grossPrice.getAllowanceCharges() != null) {
205                                return !grossPrice.getAllowanceCharges().isEmpty();
206                        }
207                }
208
209                return result;
210        }
211
212        private static boolean isEqualZero(final Amount amount) {
213                if (amount == null || amount.getValue() == null) {
214                        return false;
215                }
216
217                return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP).equals(amount.getValue().setScale(2, RoundingMode.HALF_UP));
218        }
219
220        private String message(final Amount current, final Amount expected) {
221                Object currentValue = current != null ? current.getValue() : "null";
222                Object expectedValue = expected != null ? expected.getValue() : "null";
223
224                return messageInterpolator.interpolate("{io.konik.validation.amount.calculation.error}", new Violation.Context(currentValue, expectedValue));
225        }
226
227        private static boolean areEqual(final Amount first, final Amount second) {
228                if (first == null && second == null) {
229                        return true;
230                }
231
232                if (zeroEqualsNull(first, second) || zeroEqualsNull(second, first)) {
233                        return true;
234                }
235
236                if (first == null || second == null) {
237                        return false;
238                }
239
240                if (first.getCurrency() != null && second.getCurrency() != null) {
241                        if (!first.getCurrency().getCurrency().equals(second.getCurrency().getCurrency())) {
242                                return false;
243                        }
244                }
245
246                if (first.getValue() != null && second.getValue() != null) {
247                        return first.getValue()
248                                        .setScale(2, RoundingMode.HALF_UP)
249                                        .equals(second.getValue()
250                                                        .setScale(2, RoundingMode.HALF_UP)
251                                        );
252                }
253
254                return false;
255        }
256
257        private static boolean zeroEqualsNull(final Amount first, final Amount second) {
258                return first == null &&
259                                second != null &&
260                                second.getValue() != null &&
261                                second.getValue().setScale(2, RoundingMode.HALF_UP).equals(BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP));
262        }
263
264        private static class Violation implements ConstraintViolation<Invoice> {
265
266                private final Invoice invoice;
267                private final String message;
268                private final String messageTemplate;
269                private final String propertyPath;
270                private final Object invalidValue;
271
272                public Violation(Invoice invoice, String message, String messageTemplate, String propertyPath, Object invalidValue) {
273                        this.invoice = invoice;
274                        this.message = message;
275                        this.messageTemplate = messageTemplate;
276                        this.propertyPath = propertyPath;
277                        this.invalidValue = invalidValue;
278                }
279
280                @Override
281                public String getMessage() {
282                        return message;
283                }
284
285                @Override
286                public String getMessageTemplate() {
287                        return messageTemplate;
288                }
289
290                @Override
291                public Invoice getRootBean() {
292                        return invoice;
293                }
294
295                @Override
296                public Class<Invoice> getRootBeanClass() {
297                        return Invoice.class;
298                }
299
300                @Override
301                public Object getLeafBean() {
302                        return null;
303                }
304
305                @Override
306                public Object[] getExecutableParameters() {
307                        return new Object[0];
308                }
309
310                @Override
311                public Object getExecutableReturnValue() {
312                        return null;
313                }
314
315                @Override
316                public Path getPropertyPath() {
317                        return PathImpl.createPathFromString(propertyPath);
318                }
319
320                @Override
321                public Object getInvalidValue() {
322                        return invalidValue;
323                }
324
325                @Override
326                public ConstraintDescriptor<?> getConstraintDescriptor() {
327                        return null;
328                }
329
330                @Override
331                public <U> U unwrap(Class<U> type) {
332                        return null;
333                }
334
335                static class Context implements MessageInterpolator.Context {
336
337                        private final Object currentValue;
338                        private final Object expectedValue;
339
340                        public Context(Object currentValue, Object expectedValue) {
341                                this.currentValue = currentValue;
342                                this.expectedValue = expectedValue;
343                        }
344
345                        @Override
346                        public ConstraintDescriptor<?> getConstraintDescriptor() {
347                                return new ConstraintDescriptor<Annotation>() {
348                                        @Override
349                                        public Annotation getAnnotation() {
350                                                return null;
351                                        }
352
353                                        @Override
354                                        public String getMessageTemplate() {
355                                                return "{io.konik.validation.amount.calculation.error}";
356                                        }
357
358                                        @Override
359                                        public Set<Class<?>> getGroups() {
360                                                return null;
361                                        }
362
363                                        @Override
364                                        public Set<Class<? extends Payload>> getPayload() {
365                                                return null;
366                                        }
367
368                                        @Override
369                                        public ConstraintTarget getValidationAppliesTo() {
370                                                return null;
371                                        }
372
373                                        @Override
374                                        public List<Class<? extends ConstraintValidator<Annotation, ?>>> getConstraintValidatorClasses() {
375                                                return new LinkedList<Class<? extends ConstraintValidator<Annotation, ?>>>();
376                                        }
377
378                                        @Override
379                                        public Map<String, Object> getAttributes() {
380                                                Map<String,Object> map =  new HashMap<String, Object>();
381                                                map.put("currentValue", currentValue);
382                                                map.put("expectedValue", expectedValue);
383                                                return map;
384                                        }
385
386                                        @Override
387                                        public Set<ConstraintDescriptor<?>> getComposingConstraints() {
388                                                return new HashSet<ConstraintDescriptor<?>>();
389                                        }
390
391                                        @Override
392                                        public boolean isReportAsSingleViolation() {
393                                                return false;
394                                        }
395                                };
396                        }
397
398                        @Override
399                        public Object getValidatedValue() {
400                                return currentValue;
401                        }
402
403                        @Override
404                        public <T> T unwrap(Class<T> type) {
405                                return null;
406                        }
407                }
408        }
409}