diff --git a/finnow-api/source/util/validation/common.d b/finnow-api/source/util/validation/common.d new file mode 100644 index 0000000..e688398 --- /dev/null +++ b/finnow-api/source/util/validation/common.d @@ -0,0 +1,46 @@ +module util.validation.common; + +import handy_http_primitives.optional; +import std.datetime; + +struct ValidationError { + string field; + string message; +} + +interface ValidationRule(T) { + Optional!ValidationError validate(in T payload); +} + +ValidationError[] applyValidationRules(T)(ValidationRule!(T)[] rules, in T payload) { + import std.array; + auto app = appender!(ValidationError[]); + foreach (rule; rules) { + auto result = rule.validate(payload); + if (result) app ~= result.value; + } + return app[]; +} + +// Helper functions: + +void validateTags(in string[] tags) { + import std.regex; + import handy_http_primitives: HttpStatus, HttpStatusException; + foreach (tag; tags) { + import std.regex; + auto r = ctRegex!(`^[a-z0-9-_]{3,32}$`); + if (!matchFirst(tag, r)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid tag: \"" ~ tag ~ "\"."); + } + } +} + +SysTime validateTimestampFormat(string timestampStr) { + import handy_http_primitives: HttpStatus, HttpStatusException; + try { + return SysTime.fromISOExtString(timestampStr, UTC()); + } catch (TimeException e) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp format. Expected ISO-8601 datetime."); + } +} diff --git a/finnow-api/source/util/validation/draft.d b/finnow-api/source/util/validation/draft.d new file mode 100644 index 0000000..4cabe79 --- /dev/null +++ b/finnow-api/source/util/validation/draft.d @@ -0,0 +1,59 @@ +module util.validation.draft; + +import handy_http_primitives; +import std.datetime; + +import transaction.data; +import transaction.dto; +import util.validation.common; +import account.data; + +void validateDraftPayload( + TransactionVendorRepository vendorRepo, + TransactionCategoryRepository categoryRepo, + AccountRepository accountRepo, + in TransactionDraftPayload payload +) { + if (payload.amount && !payload.currencyCode) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Currency is required when saving an amount."); + } + if (payload.amount && payload.amount.value == 0) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0."); + } + if (payload.timestamp) { + validateTimestampFormat(payload.timestamp.value); + } + if (payload.vendorId && !vendorRepo.existsById(payload.vendorId.value)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist."); + } + if (payload.categoryId && !categoryRepo.existsById(payload.categoryId.value)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist."); + } + if (payload.creditedAccountId && !accountRepo.existsById(payload.creditedAccountId.value)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist."); + } + if (payload.debitedAccountId && !accountRepo.existsById(payload.debitedAccountId.value)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist."); + } + validateTags(payload.tags); + if (payload.lineItems.length > 0) { + long lineItemsTotal = 0; + foreach (lineItem; payload.lineItems) { + if (lineItem.categoryId && !categoryRepo.existsById(lineItem.categoryId.value)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist."); + } + if (lineItem.quantity == 0) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's quantity should greater than zero."); + } + for (ulong i = 0; i < lineItem.quantity; i++) { + lineItemsTotal += lineItem.valuePerItem; + } + } + if (payload.amount && lineItemsTotal != payload.amount.value) { + throw new HttpStatusException( + HttpStatus.BAD_REQUEST, + "Total of all line items doesn't equal the transaction's total." + ); + } + } +} \ No newline at end of file diff --git a/finnow-api/source/util/validation/transaction.d b/finnow-api/source/util/validation/transaction.d new file mode 100644 index 0000000..f9fa566 --- /dev/null +++ b/finnow-api/source/util/validation/transaction.d @@ -0,0 +1,78 @@ +module util.validation.transaction; + +import handy_http_primitives; +import std.array; +import std.datetime; + +import util.validation.common; +import transaction.dto; +import transaction.data; +import account.data; + +// class AtLeastOneLinkedAccountRule : ValidationRule!AddTransactionPayload { +// Optional!ValidationError validate(in AddTransactionPayload payload) { +// if (!payload.creditedAccountId && !payload.debitedAccountId) { +// return ValidationError("creditedAccountId", "At least one account must be linked.").toOptional(); +// } +// return Optional!ValidationError.empty(); +// } +// } + +void validateTransactionPayload( + TransactionVendorRepository vendorRepo, + TransactionCategoryRepository categoryRepo, + AccountRepository accountRepo, + in AddTransactionPayload payload +) { + if (!payload.creditedAccountId && !payload.debitedAccountId) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked."); + } + if ( + payload.creditedAccountId && + payload.debitedAccountId && + payload.creditedAccountId.value == payload.debitedAccountId.value + ) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit."); + } + if (payload.amount == 0) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0."); + } + SysTime now = Clock.currTime(UTC()); + SysTime timestamp = validateTimestampFormat(payload.timestamp); + if (timestamp > now) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future."); + } + if (payload.vendorId && !vendorRepo.existsById(payload.vendorId.value)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist."); + } + if (payload.categoryId && !categoryRepo.existsById(payload.categoryId.value)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist."); + } + if (payload.creditedAccountId && !accountRepo.existsById(payload.creditedAccountId.value)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist."); + } + if (payload.debitedAccountId && !accountRepo.existsById(payload.debitedAccountId.value)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist."); + } + validateTags(payload.tags); + if (payload.lineItems.length > 0) { + long lineItemsTotal = 0; + foreach (lineItem; payload.lineItems) { + if (lineItem.categoryId && !categoryRepo.existsById(lineItem.categoryId.value)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist."); + } + if (lineItem.quantity == 0) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's quantity should greater than zero."); + } + for (ulong i = 0; i < lineItem.quantity; i++) { + lineItemsTotal += lineItem.valuePerItem; + } + } + if (lineItemsTotal != payload.amount) { + throw new HttpStatusException( + HttpStatus.BAD_REQUEST, + "Total of all line items doesn't equal the transaction's total." + ); + } + } +}