From 2917c9e2f290fee37f4f40738de0ae3fcf7a952c Mon Sep 17 00:00:00 2001 From: Mateusz Czeladka Date: Mon, 16 Dec 2024 16:10:20 +0100 Subject: [PATCH] feat: serialize API3 reports and send to the blockchain --- .../config/TransactionSubmissionConfig.java | 18 +- .../core/API1BlockchainTransactions.java | 12 ++ .../core/API3BlockchainTransaction.java | 8 + .../domain/core/BlockchainTransactions.java | 12 -- .../core/SerializedCardanoL1Transaction.java | 5 + .../job/TransactionDispatcherJob.java | 6 +- ...tor.java => API1L1TransactionCreator.java} | 54 +++--- .../service/API3L1TransactionCreator.java | 146 +++++++++++++++ .../service/API3MetadataSerialiser.java | 168 +++++++++++++++++ .../service/ReportSerializer.java | 18 -- .../dispatch/BlockchainReportsDispatcher.java | 60 +++++-- .../BlockchainTransactionsDispatcher.java | 16 +- .../service/API3MetadataSerialiserTest.java | 170 ++++++++++++++++++ 13 files changed, 600 insertions(+), 93 deletions(-) create mode 100644 blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/API1BlockchainTransactions.java create mode 100644 blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/API3BlockchainTransaction.java delete mode 100644 blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/BlockchainTransactions.java create mode 100644 blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/SerializedCardanoL1Transaction.java rename blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/{L1TransactionCreator.java => API1L1TransactionCreator.java} (83%) create mode 100644 blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3L1TransactionCreator.java create mode 100644 blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3MetadataSerialiser.java delete mode 100644 blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/ReportSerializer.java create mode 100644 blockchain_publisher/src/test/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3MetadataSerialiserTest.java diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/config/TransactionSubmissionConfig.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/config/TransactionSubmissionConfig.java index e3a86213..a5c625e5 100644 --- a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/config/TransactionSubmissionConfig.java +++ b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/config/TransactionSubmissionConfig.java @@ -3,7 +3,7 @@ import com.bloxbean.cardano.client.account.Account; import com.bloxbean.cardano.client.backend.api.BackendService; import org.cardanofoundation.lob.app.blockchain_common.service_assistance.MetadataChecker; -import org.cardanofoundation.lob.app.blockchain_publisher.service.L1TransactionCreator; +import org.cardanofoundation.lob.app.blockchain_publisher.service.API1L1TransactionCreator; import org.cardanofoundation.lob.app.blockchain_publisher.service.API1MetadataSerialiser; import org.cardanofoundation.lob.app.blockchain_publisher.service.transation_submit.*; import org.cardanofoundation.lob.app.blockchain_reader.BlockchainReaderPublicApiIF; @@ -38,15 +38,15 @@ public TransactionSubmissionService transactionSubmissionService( } @Bean - public L1TransactionCreator l1TransactionCreator(@Qualifier("yaci_blockfrost") BackendService backendService, - API1MetadataSerialiser API1MetadataSerialiser, - BlockchainReaderPublicApiIF blockchainReaderPublicApi, - MetadataChecker metadataChecker, - Account organiserAccount, - @Value("${l1.transaction.metadata_label:1447}") int metadataLabel, - @Value("${l1.transaction.debug_store_output_tx:false}") boolean debugStoreOutputTx + public API1L1TransactionCreator l1TransactionCreator(@Qualifier("yaci_blockfrost") BackendService backendService, + API1MetadataSerialiser API1MetadataSerialiser, + BlockchainReaderPublicApiIF blockchainReaderPublicApi, + MetadataChecker metadataChecker, + Account organiserAccount, + @Value("${l1.transaction.metadata_label:1447}") int metadataLabel, + @Value("${l1.transaction.debug_store_output_tx:false}") boolean debugStoreOutputTx ) { - return new L1TransactionCreator(backendService, + return new API1L1TransactionCreator(backendService, API1MetadataSerialiser, blockchainReaderPublicApi, metadataChecker, diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/API1BlockchainTransactions.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/API1BlockchainTransactions.java new file mode 100644 index 00000000..6cd94da6 --- /dev/null +++ b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/API1BlockchainTransactions.java @@ -0,0 +1,12 @@ +package org.cardanofoundation.lob.app.blockchain_publisher.domain.core; + +import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.txs.TransactionEntity; + +import java.util.Set; + +public record API1BlockchainTransactions(String organisationId, + Set submittedTransactions, + Set remainingTransactions, + long creationSlot, + byte[] serialisedTxData) { +} diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/API3BlockchainTransaction.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/API3BlockchainTransaction.java new file mode 100644 index 00000000..787338e9 --- /dev/null +++ b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/API3BlockchainTransaction.java @@ -0,0 +1,8 @@ +package org.cardanofoundation.lob.app.blockchain_publisher.domain.core; + +import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.reports.ReportEntity; + +public record API3BlockchainTransaction(ReportEntity report, + long creationSlot, + byte[] serialisedTxData) { +} diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/BlockchainTransactions.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/BlockchainTransactions.java deleted file mode 100644 index fc9bb71e..00000000 --- a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/BlockchainTransactions.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.cardanofoundation.lob.app.blockchain_publisher.domain.core; - -import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.txs.TransactionEntity; - -import java.util.Set; - -public record BlockchainTransactions(String organisationId, - Set submittedTransactions, - Set remainingTransactions, - long creationSlot, - byte[] serialisedTxData) { -} diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/SerializedCardanoL1Transaction.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/SerializedCardanoL1Transaction.java new file mode 100644 index 00000000..25548bfe --- /dev/null +++ b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/domain/core/SerializedCardanoL1Transaction.java @@ -0,0 +1,5 @@ +package org.cardanofoundation.lob.app.blockchain_publisher.domain.core; + +public record SerializedCardanoL1Transaction(byte[] txBytes, + byte[] metadataCbor, + String metadataJson) { } diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/job/TransactionDispatcherJob.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/job/TransactionDispatcherJob.java index 2de628e1..49f8caaf 100644 --- a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/job/TransactionDispatcherJob.java +++ b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/job/TransactionDispatcherJob.java @@ -11,7 +11,7 @@ @Service("blockchain_publisher.TransactionDispatcherJob") @Slf4j @RequiredArgsConstructor -@ConditionalOnProperty(value = "lob.blockchain_publisher.dispatcher.enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnProperty(value = "lob.blockchain_publisher.dispatcher.txs.enabled", havingValue = "true", matchIfMissing = true) public class TransactionDispatcherJob { private final BlockchainTransactionsDispatcher blockchainTransactionsDispatcher; @@ -22,8 +22,8 @@ public void init() { } @Scheduled( - fixedDelayString = "${lob.blockchain_publisher.dispatcher.fixed_delay:PT10S}", - initialDelayString = "${lob.blockchain_publisher.dispatcher.initial_delay:PT1M}") + fixedDelayString = "${lob.blockchain_publisher.dispatcher.txs.fixed_delay:PT10S}", + initialDelayString = "${lob.blockchain_publisher.dispatcher.txs.initial_delay:PT1M}") public void execute() { log.info("Pooling for blockchain transactions to be send to the blockchain..."); diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/L1TransactionCreator.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API1L1TransactionCreator.java similarity index 83% rename from blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/L1TransactionCreator.java rename to blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API1L1TransactionCreator.java index e235ba52..39c7f2fc 100644 --- a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/L1TransactionCreator.java +++ b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API1L1TransactionCreator.java @@ -20,9 +20,11 @@ import lombok.extern.slf4j.Slf4j; import lombok.val; import org.cardanofoundation.lob.app.blockchain_common.service_assistance.MetadataChecker; -import org.cardanofoundation.lob.app.blockchain_publisher.domain.core.BlockchainTransactions; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.core.API1BlockchainTransactions; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.core.SerializedCardanoL1Transaction; import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.txs.TransactionEntity; import org.cardanofoundation.lob.app.blockchain_reader.BlockchainReaderPublicApiIF; +import org.springframework.stereotype.Service; import org.zalando.problem.Problem; import java.io.IOException; @@ -39,9 +41,10 @@ import static org.apache.commons.collections4.iterators.PeekingIterator.peekingIterator; import static org.zalando.problem.Status.INTERNAL_SERVER_ERROR; +@Service @Slf4j @RequiredArgsConstructor -public class L1TransactionCreator { +public class API1L1TransactionCreator { private static final int CARDANO_MAX_TRANSACTION_SIZE_BYTES = 16384; @@ -58,24 +61,24 @@ public class L1TransactionCreator { @PostConstruct public void init() { - log.info("L1TransactionCreator::metadata label: {}", metadataLabel); - log.info("L1TransactionCreator::debug store output tx: {}", debugStoreOutputTx); + log.info("API1L1TransactionCreator::metadata label: {}", metadataLabel); + log.info("API1L1TransactionCreator::debug store output tx: {}", debugStoreOutputTx); runId = UUID.randomUUID().toString(); - log.info("L1TransactionCreator::runId: {}", runId); + log.info("API1L1TransactionCreator::runId: {}", runId); - log.info("L1TransactionCreator is initialised."); + log.info("API1L1TransactionCreator is initialised."); } - public Either> pullBlockchainTransaction(String organisationId, - Set txs) { + public Either> pullBlockchainTransaction(String organisationId, + Set txs) { return blockchainReaderPublicApi.getChainTip() .flatMap(chainTip -> handleTransactionCreation(organisationId, txs, chainTip.getAbsoluteSlot())); } - private Either> handleTransactionCreation(String organisationId, - Set transactions, - long creationSlot) { + private Either> handleTransactionCreation(String organisationId, + Set transactions, + long creationSlot) { try { return createTransaction(organisationId, transactions, creationSlot); } catch (IOException e) { @@ -90,9 +93,9 @@ private Either> handleTransactionCreat } // error or transactions to process or no more transactions to process in case of blockchain transaction creation - private Either> createTransaction(String organisationId, - Set transactions, - long creationSlot) throws IOException { + private Either> createTransaction(String organisationId, + Set transactions, + long creationSlot) throws IOException { log.info("Splitting {} passedTransactions into blockchain passedTransactions", transactions.size()); val transactionsBatch = new LinkedHashSet(); @@ -110,7 +113,7 @@ private Either> createTransaction(Stri } val serializedTransaction = serializedTransactionsE.get(); - val txBytes = serializedTransaction.txBytes; + val txBytes = serializedTransaction.txBytes(); val transactionLinePeek = it.peek(); if (transactionLinePeek == null) { // next one is last element @@ -125,7 +128,7 @@ private Either> createTransaction(Stri return Either.left(newChunkTxBytesE.getLeft()); } val newSerializedTransaction = newChunkTxBytesE.get(); - val newChunkTxBytes = newSerializedTransaction.txBytes; + val newChunkTxBytes = newSerializedTransaction.txBytes(); if (newChunkTxBytes.length >= CARDANO_MAX_TRANSACTION_SIZE_BYTES) { log.info("Blockchain transaction created, id:{}", TransactionUtil.getTxHash(txBytes)); @@ -135,7 +138,7 @@ private Either> createTransaction(Stri val remainingTxs = calculateRemainingTransactions(transactions, transactionsBatch); - return Either.right(Optional.of(new BlockchainTransactions(organisationId, transactionsBatch, remainingTxs, creationSlot, txBytes))); + return Either.right(Optional.of(new API1BlockchainTransactions(organisationId, transactionsBatch, remainingTxs, creationSlot, txBytes))); } } @@ -152,15 +155,16 @@ private Either> createTransaction(Stri } val serTx = serializedTxE.get(); - log.info("Blockchain transaction created, id:{}, debugTxOutput:{}", TransactionUtil.getTxHash(serTx.txBytes), this.debugStoreOutputTx); + val txBytes = serTx.txBytes(); + log.info("Blockchain transaction created, id:{}, debugTxOutput:{}", TransactionUtil.getTxHash(txBytes), this.debugStoreOutputTx); + potentiallyStoreTxs(creationSlot, serTx); - val txBytes = serTx.txBytes; log.info("Transaction size: {}", txBytes.length); val remaining = calculateRemainingTransactions(transactions, transactionsBatch); - return Either.right(Optional.of(new BlockchainTransactions(organisationId, transactionsBatch, remaining, creationSlot, txBytes))); + return Either.right(Optional.of(new API1BlockchainTransactions(organisationId, transactionsBatch, remaining, creationSlot, txBytes))); } // no transactions to process @@ -171,15 +175,15 @@ private Either> createTransaction(Stri private void potentiallyStoreTxs(long creationSlot, SerializedCardanoL1Transaction tx) throws IOException { if (debugStoreOutputTx) { val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.now()); - val name = STR."lob-txs-metadata-\{runId}-\{timestamp}-\{creationSlot}"; + val name = STR."lob-txs-api1-metadata-\{runId}-\{timestamp}-\{creationSlot}"; val tmpJsonTxFile = Files.createTempFile(name, ".json"); val tmpCborFile = Files.createTempFile(name, ".cbor"); log.info("DebugStoreTx enabled, storing JSON tx metadata to file: {}", tmpJsonTxFile); - Files.writeString(tmpJsonTxFile, tx.metadataJson); + Files.writeString(tmpJsonTxFile, tx.metadataJson()); log.info("DebugStoreTx enabled, storing CBOR tx metadata to file: {}", tmpCborFile); - Files.write(tmpCborFile, tx.metadataCbor); + Files.write(tmpCborFile, tx.metadataCbor()); } } @@ -249,8 +253,4 @@ protected byte[] serialiseTransaction(Metadata metadata) throws CborSerializatio .serialize(); } - public record SerializedCardanoL1Transaction(byte[] txBytes, - byte[] metadataCbor, - String metadataJson) { } - } diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3L1TransactionCreator.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3L1TransactionCreator.java new file mode 100644 index 00000000..a47c923c --- /dev/null +++ b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3L1TransactionCreator.java @@ -0,0 +1,146 @@ +package org.cardanofoundation.lob.app.blockchain_publisher.service; + +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.api.model.Amount; +import com.bloxbean.cardano.client.backend.api.BackendService; +import com.bloxbean.cardano.client.common.cbor.CborSerializationUtil; +import com.bloxbean.cardano.client.exception.CborSerializationException; +import com.bloxbean.cardano.client.function.helper.SignerProviders; +import com.bloxbean.cardano.client.metadata.Metadata; +import com.bloxbean.cardano.client.metadata.MetadataBuilder; +import com.bloxbean.cardano.client.metadata.cbor.CBORMetadataMap; +import com.bloxbean.cardano.client.metadata.helper.MetadataToJsonNoSchemaConverter; +import com.bloxbean.cardano.client.quicktx.QuickTxBuilder; +import com.bloxbean.cardano.client.quicktx.Tx; +import io.vavr.control.Either; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.core.API3BlockchainTransaction; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.core.SerializedCardanoL1Transaction; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.reports.ReportEntity; +import org.cardanofoundation.lob.app.blockchain_reader.BlockchainReaderPublicApiIF; +import org.springframework.stereotype.Service; +import org.zalando.problem.Problem; + +import java.io.IOException; +import java.nio.file.Files; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.UUID; + +import static org.zalando.problem.Status.INTERNAL_SERVER_ERROR; + +@Service +@RequiredArgsConstructor +@Slf4j +public class API3L1TransactionCreator { + + private final BackendService backendService; + private final API3MetadataSerialiser api3MetadataSerialiser; + private final BlockchainReaderPublicApiIF blockchainReaderPublicApi; +// @Qualifier("api3JsonSchemaMetadataChecker") +// private final MetadataChecker jsonSchemaMetadataChecker; + private final Account organiserAccount; + + private final int metadataLabel; + private final boolean debugStoreOutputTx; + + private String runId; + + @PostConstruct + public void init() { + log.info("API3L1TransactionCreator::metadata label: {}", metadataLabel); + log.info("API3L1TransactionCreator::debug store output tx: {}", debugStoreOutputTx); + + runId = UUID.randomUUID().toString(); + log.info("API3L1TransactionCreator::runId: {}", runId); + + log.info("API3L1TransactionCreator is initialised."); + } + + public Either pullBlockchainTransaction(ReportEntity reportEntity) { + return blockchainReaderPublicApi.getChainTip() + .flatMap(chainTip -> handleTransactionCreation(reportEntity, chainTip.getAbsoluteSlot())); + } + + private Either handleTransactionCreation(ReportEntity reportEntity, + long creationSlot) { + try { + val metadataMap = + api3MetadataSerialiser.serialiseToMetadataMap(reportEntity, creationSlot); + + val data = metadataMap.getMap(); + val bytes = CborSerializationUtil.serialize(data); + + // we use json only for validation with json schema and for debugging (storing to a tmp file) + val json = MetadataToJsonNoSchemaConverter.cborBytesToJson(bytes); + + val metadata = MetadataBuilder.createMetadata(); + val cborMetadataMap = new CBORMetadataMap(data); + + metadata.put(metadataLabel, cborMetadataMap); + +// val isValid = jsonSchemaMetadataChecker.checkTransactionMetadata(json); +// +// if (!isValid) { +// return Either.left(Problem.builder() +// .withTitle("INVALID_TRANSACTION_METADATA") +// .withDetail("Metadata is not valid according to the transaction schema, we will not create a transaction!") +// .withStatus(INTERNAL_SERVER_ERROR) +// .build() +// ); +// } +// +// log.info("Metadata for tx validated, gonna serialise tx now..."); + + val serialisedTxBytes = serialiseTransaction(metadata); + + val serializedTx = new SerializedCardanoL1Transaction(serialisedTxBytes, bytes, json); + + potentiallyStoreTxs(creationSlot, serializedTx); + + return Either.right(new API3BlockchainTransaction(reportEntity, creationSlot, serialisedTxBytes)); + } catch (Exception e) { + log.error("Error serialising metadata to cbor", e); + return Either.left(Problem.builder() + .withTitle("ERROR_SERIALISING_METADATA") + .withDetail("Error serialising metadata to cbor") + .withStatus(INTERNAL_SERVER_ERROR) + .build() + ); + } + } + + // for debug and inspection only + private void potentiallyStoreTxs(long creationSlot, SerializedCardanoL1Transaction tx) throws IOException { + if (debugStoreOutputTx) { + val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.now()); + val name = STR."lob-txs-api3-metadata-\{runId}-\{timestamp}-\{creationSlot}"; + val tmpJsonTxFile = Files.createTempFile(name, ".json"); + val tmpCborFile = Files.createTempFile(name, ".cbor"); + + log.info("DebugStoreTx enabled, storing JSON tx metadata to file: {}", tmpJsonTxFile); + Files.writeString(tmpJsonTxFile, tx.metadataJson()); + + log.info("DebugStoreTx enabled, storing CBOR tx metadata to file: {}", tmpCborFile); + Files.write(tmpCborFile, tx.metadataCbor()); + } + } + + protected byte[] serialiseTransaction(Metadata metadata) throws CborSerializationException { + val quickTxBuilder = new QuickTxBuilder(backendService); + + val tx = new Tx() + .payToAddress(organiserAccount.baseAddress(), Amount.ada(2.0)) + .attachMetadata(metadata) + .from(organiserAccount.baseAddress()); + + return quickTxBuilder.compose(tx) + .withSigner(SignerProviders.signerFrom(organiserAccount)) + .buildAndSign() + .serialize(); + } + +} diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3MetadataSerialiser.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3MetadataSerialiser.java new file mode 100644 index 00000000..b06ddc0d --- /dev/null +++ b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3MetadataSerialiser.java @@ -0,0 +1,168 @@ +package org.cardanofoundation.lob.app.blockchain_publisher.service; + + +import com.bloxbean.cardano.client.metadata.MetadataBuilder; +import com.bloxbean.cardano.client.metadata.MetadataMap; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.reports.BalanceSheetData; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.reports.IncomeStatementData; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.reports.ReportEntity; +import org.springframework.stereotype.Service; + +import java.math.BigInteger; +import java.time.Clock; +import java.time.Instant; +import java.time.format.DateTimeFormatter; + +@Service +@RequiredArgsConstructor +public class API3MetadataSerialiser { + + public static final String VERSION = "1.0"; + private final Clock clock; + + public MetadataMap serialiseToMetadataMap(ReportEntity reportEntity, + long creationSlot) { + val globalMetadataMap = MetadataBuilder.createMap(); + + // Metadata Section + globalMetadataMap.put("metadata", createMetadataSection(creationSlot)); + + // Organisation Section + val organisation = reportEntity.getOrganisation(); + globalMetadataMap.put("org", serialiseOrganisation(organisation)); + + // Report Data Section + globalMetadataMap.put("type", "REPORT"); + globalMetadataMap.put("subType", reportEntity.getType().name()); + globalMetadataMap.put("interval", reportEntity.getIntervalType().name()); + globalMetadataMap.put("year", reportEntity.getYear().toString()); + globalMetadataMap.put("mode", reportEntity.getMode().name()); + + // Data Section + switch (reportEntity.getType()) { + case BALANCE_SHEET -> globalMetadataMap.put("data", serialiseBalanceSheetData( + reportEntity.getBalanceSheetReportData().orElseThrow())); + case INCOME_STATEMENT -> globalMetadataMap.put("data", serialiseIncomeStatementData( + reportEntity.getIncomeStatementReportData().orElseThrow())); + default -> throw new IllegalArgumentException("Unsupported report type: " + reportEntity.getType()); + } + + return globalMetadataMap; + } + + private MetadataMap createMetadataSection(long creationSlot) { + val metadataMap = MetadataBuilder.createMap(); + val now = Instant.now(clock); + + metadataMap.put("creation_slot", BigInteger.valueOf(creationSlot)); + metadataMap.put("timestamp", DateTimeFormatter.ISO_INSTANT.format(now)); + metadataMap.put("version", VERSION); + + return metadataMap; + } + + private static MetadataMap serialiseOrganisation(org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.txs.Organisation organisation) { + val orgMap = MetadataBuilder.createMap(); + + orgMap.put("id", organisation.getId()); + orgMap.put("name", organisation.getName()); + orgMap.put("tax_id_number", organisation.getTaxIdNumber()); + orgMap.put("currency_id", organisation.getCurrencyId()); + orgMap.put("country_code", organisation.getCountryCode()); + + return orgMap; + } + + private static MetadataMap serialiseBalanceSheetData(BalanceSheetData balanceSheetData) { + val dataMap = MetadataBuilder.createMap(); + + // Assets + val assetsMap = MetadataBuilder.createMap(); + balanceSheetData.getAssets().ifPresent(assets -> { + assets.getNonCurrentAssets().ifPresent(nca -> { + val nonCurrentAssetsMap = MetadataBuilder.createMap(); + nca.getPropertyPlantEquipment().ifPresent(value -> nonCurrentAssetsMap.put("property_plant_equipment", value.toString())); + nca.getIntangibleAssets().ifPresent(value -> nonCurrentAssetsMap.put("intangible_assets", value.toString())); + nca.getInvestments().ifPresent(value -> nonCurrentAssetsMap.put("investments", value.toString())); + nca.getFinancialAssets().ifPresent(value -> nonCurrentAssetsMap.put("financial_assets", value.toString())); + assetsMap.put("non_current_assets", nonCurrentAssetsMap); + }); + + assets.getCurrentAssets().ifPresent(ca -> { + val currentAssetsMap = MetadataBuilder.createMap(); + ca.getPrepaymentsAndOtherShortTermAssets().ifPresent(value -> currentAssetsMap.put("prepayments_and_other_short_term_assets", value.toString())); + ca.getOtherReceivables().ifPresent(value -> currentAssetsMap.put("other_receivables", value.toString())); + ca.getCryptoAssets().ifPresent(value -> currentAssetsMap.put("crypto_assets", value.toString())); + ca.getCashAndCashEquivalents().ifPresent(value -> currentAssetsMap.put("cash_and_cash_equivalents", value.toString())); + assetsMap.put("current_assets", currentAssetsMap); + }); + + dataMap.put("assets", assetsMap); + }); + + // Liabilities + val liabilitiesMap = MetadataBuilder.createMap(); + balanceSheetData.getLiabilities().ifPresent(liabilities -> { + liabilities.getNonCurrentLiabilities().ifPresent(ncl -> { + val nonCurrentLiabilitiesMap = MetadataBuilder.createMap(); + ncl.getProvisions().ifPresent(value -> nonCurrentLiabilitiesMap.put("provisions", value.toString())); + liabilitiesMap.put("non_current_liabilities", nonCurrentLiabilitiesMap); + }); + + liabilities.getCurrentLiabilities().ifPresent(cl -> { + val currentLiabilitiesMap = MetadataBuilder.createMap(); + cl.getTradeAccountsPayables().ifPresent(value -> currentLiabilitiesMap.put("trade_accounts_payables", value.toString())); + cl.getOtherCurrentLiabilities().ifPresent(value -> currentLiabilitiesMap.put("other_current_liabilities", value.toString())); + cl.getAccrualsAndShortTermProvisions().ifPresent(value -> currentLiabilitiesMap.put("accruals_and_short_term_provisions", value.toString())); + liabilitiesMap.put("current_liabilities", currentLiabilitiesMap); + }); + + dataMap.put("liabilities", liabilitiesMap); + }); + + // Capital + val capitalMap = MetadataBuilder.createMap(); + balanceSheetData.getCapital().ifPresent(capital -> { + capital.getCapital().ifPresent(value -> capitalMap.put("capital", value.toString())); + capital.getResultsCarriedForward().ifPresent(value -> capitalMap.put("results_carried_forward", value.toString())); + capital.getProfitForTheYear().ifPresent(value -> capitalMap.put("profit_for_the_year", value.toString())); + }); + dataMap.put("capital", capitalMap); + + return dataMap; + } + + private static MetadataMap serialiseIncomeStatementData(IncomeStatementData incomeStatementData) { + val dataMap = MetadataBuilder.createMap(); + + incomeStatementData.getRevenues().ifPresent(revenues -> { + val revenuesMap = MetadataBuilder.createMap(); + revenues.getOtherIncome().ifPresent(value -> revenuesMap.put("other_income", value.toString())); + revenues.getBuildOfLongTermProvision().ifPresent(value -> revenuesMap.put("build_of_long_term_provision", value.toString())); + dataMap.put("revenues", revenuesMap); + }); + + incomeStatementData.getCostOfGoodsAndServices().ifPresent(cogs -> { + val cogsMap = MetadataBuilder.createMap(); + cogs.getCostOfProvidingServices().ifPresent(value -> cogsMap.put("cost_of_providing_services", value.toString())); + dataMap.put("cost_of_goods_and_services", cogsMap); + }); + + incomeStatementData.getOperatingExpenses().ifPresent(opex -> { + val opexMap = MetadataBuilder.createMap(); + opex.getPersonnelExpenses().ifPresent(value -> opexMap.put("personnel_expenses", value.toString())); + opex.getGeneralAndAdministrativeExpenses().ifPresent(value -> opexMap.put("general_and_administrative_expenses", value.toString())); + opex.getDepreciationAndImpairmentLossesOnTangibleAssets().ifPresent(value -> opexMap.put("depreciation_and_impairment_losses_on_tangible_assets", value.toString())); + opex.getAmortizationOnIntangibleAssets().ifPresent(value -> opexMap.put("amortization_on_intangible_assets", value.toString())); + opex.getRentExpenses().ifPresent(value -> opexMap.put("rent_expenses", value.toString())); + dataMap.put("operating_expenses", opexMap); + }); + + incomeStatementData.getProfitForTheYear().ifPresent(value -> dataMap.put("profit_for_the_year", value.toString())); + + return dataMap; + } + +} diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/ReportSerializer.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/ReportSerializer.java deleted file mode 100644 index 90ba8227..00000000 --- a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/ReportSerializer.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.cardanofoundation.lob.app.blockchain_publisher.service; - -import lombok.RequiredArgsConstructor; -import org.cardanofoundation.lob.app.accounting_reporting_core.service.internal.ReportService; -import org.springframework.stereotype.Service; - -@RequiredArgsConstructor -@Service -public class ReportSerializer { - - private final ReportService reportService; - - public byte[] serialize() { - // mock / fake for now - return new byte[0]; - } - -} diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/dispatch/BlockchainReportsDispatcher.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/dispatch/BlockchainReportsDispatcher.java index ece069e2..ead7b6c2 100644 --- a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/dispatch/BlockchainReportsDispatcher.java +++ b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/dispatch/BlockchainReportsDispatcher.java @@ -4,10 +4,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.core.API3BlockchainTransaction; import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.reports.ReportEntity; import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.txs.L1SubmissionData; import org.cardanofoundation.lob.app.blockchain_publisher.repository.ReportEntityRepositoryGateway; -import org.cardanofoundation.lob.app.blockchain_publisher.service.ReportSerializer; +import org.cardanofoundation.lob.app.blockchain_publisher.service.API3L1TransactionCreator; import org.cardanofoundation.lob.app.blockchain_publisher.service.event_publish.LedgerUpdatedEventPublisher; import org.cardanofoundation.lob.app.blockchain_publisher.service.transation_submit.TransactionSubmissionService; import org.cardanofoundation.lob.app.organisation.OrganisationPublicApi; @@ -28,7 +29,7 @@ public class BlockchainReportsDispatcher { private final OrganisationPublicApi organisationPublicApi; private final ReportEntityRepositoryGateway reportEntityRepositoryGateway; private final DispatchingStrategy dispatchingStrategy = new ImmediateDispatchingStrategy<>(); - private final ReportSerializer reportSerializer; + private final API3L1TransactionCreator api3L1TransactionCreator; private final TransactionSubmissionService transactionSubmissionService; private final LedgerUpdatedEventPublisher ledgerUpdatedEventPublisher; @@ -71,41 +72,66 @@ protected void dispatchReports(String organisationId, public void dispatchReport(String organisationId, ReportEntity reportEntity) { log.info("Dispatching report for organisation: {}", organisationId); + val api3BlockchainTransactionE = createAndSendBlockchainTransactions(reportEntity); + if (api3BlockchainTransactionE.isEmpty()) { + log.info("No more reports to dispatch for organisationId, success or error?, organisationId: {}", organisationId); + } + } + + @Transactional + private Optional createAndSendBlockchainTransactions(ReportEntity reportEntity) { + log.info("Creating and sending blockchain transactions for report:{}", reportEntity.getReportId()); + + val serialisedTxE = api3L1TransactionCreator.pullBlockchainTransaction(reportEntity); + + if (serialisedTxE.isLeft()) { + val problem = serialisedTxE.getLeft(); + + log.error("Error pulling blockchain transaction, problem: {}", problem); + + return Optional.empty(); + } + + val serialisedTx = serialisedTxE.get(); try { - sendReportOnChainAndUpdateDb(reportEntity); + sendTransactionOnChainAndUpdateDb(serialisedTx); + + return Optional.of(serialisedTx); } catch (InterruptedException | ApiException e) { - log.error("Error sending report on chain and / or updating db", e); + log.error("Error sending transaction on chain and / or updating db", e); } + + return Optional.empty(); } @Transactional - private void sendReportOnChainAndUpdateDb(ReportEntity reportEntity) throws InterruptedException, ApiException { - log.info("Sending report on chain and updating db, reportId:{}", reportEntity.getReportId()); + private void sendTransactionOnChainAndUpdateDb(API3BlockchainTransaction api3BlockchainTransaction) throws InterruptedException, ApiException { + val reportTxData = api3BlockchainTransaction.serialisedTxData(); - val reportTxData = reportSerializer.serialize(); - if (reportTxData.length > 0) { - val l1SubmissionData = transactionSubmissionService.submitTransactionWithPossibleConfirmation(reportTxData); + val l1SubmissionData = transactionSubmissionService.submitTransactionWithPossibleConfirmation(reportTxData); - val txHash = l1SubmissionData.txHash(); - val txAbsoluteSlotM = l1SubmissionData.absoluteSlot(); + val txHash = l1SubmissionData.txHash(); + val txAbsoluteSlotM = l1SubmissionData.absoluteSlot(); - updateTransactionStatuses(txHash, txAbsoluteSlotM, reportEntity); - ledgerUpdatedEventPublisher.sendReportLedgerUpdatedEvents(reportEntity.getOrganisation().getId(), Set.of(reportEntity)); + val report = api3BlockchainTransaction.report(); + val creationSlot = api3BlockchainTransaction.creationSlot(); - log.info("Blockchain transaction submitted (report), l1SubmissionData:{}", l1SubmissionData); - } + updateTransactionStatuses(txHash, txAbsoluteSlotM, creationSlot, report); + ledgerUpdatedEventPublisher.sendReportLedgerUpdatedEvents(report.getOrganisation().getId(), Set.of(report)); - log.info("No report data to send to blockchain, since tx building failed."); + log.info("Blockchain transaction submitted (report), l1SubmissionData:{}", l1SubmissionData); } @Transactional private void updateTransactionStatuses(String txHash, Optional absoluteSlot, + long creationSlot, ReportEntity reportEntity) { + reportEntity.setL1SubmissionData(Optional.of(L1SubmissionData.builder() .transactionHash(txHash) .absoluteSlot(absoluteSlot.orElse(null)) // if tx is not confirmed yet, slot will not be available - .creationSlot(1L) // TODO find out the right creation slot + .creationSlot(creationSlot) .publishStatus(SUBMITTED) .build()) ); diff --git a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/dispatch/BlockchainTransactionsDispatcher.java b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/dispatch/BlockchainTransactionsDispatcher.java index 6b236902..c44de009 100644 --- a/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/dispatch/BlockchainTransactionsDispatcher.java +++ b/blockchain_publisher/src/main/java/org/cardanofoundation/lob/app/blockchain_publisher/service/dispatch/BlockchainTransactionsDispatcher.java @@ -5,11 +5,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import lombok.val; -import org.cardanofoundation.lob.app.blockchain_publisher.domain.core.BlockchainTransactions; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.core.API1BlockchainTransactions; import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.txs.L1SubmissionData; import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.txs.TransactionEntity; import org.cardanofoundation.lob.app.blockchain_publisher.repository.TransactionEntityRepositoryGateway; -import org.cardanofoundation.lob.app.blockchain_publisher.service.L1TransactionCreator; +import org.cardanofoundation.lob.app.blockchain_publisher.service.API1L1TransactionCreator; import org.cardanofoundation.lob.app.blockchain_publisher.service.event_publish.LedgerUpdatedEventPublisher; import org.cardanofoundation.lob.app.blockchain_publisher.service.transation_submit.TransactionSubmissionService; import org.cardanofoundation.lob.app.organisation.OrganisationPublicApi; @@ -29,7 +29,7 @@ public class BlockchainTransactionsDispatcher { private final TransactionEntityRepositoryGateway transactionEntityRepositoryGateway; private final OrganisationPublicApi organisationPublicApi; - private final L1TransactionCreator l1TransactionCreator; + private final API1L1TransactionCreator l1TransactionCreator; private final TransactionSubmissionService transactionSubmissionService; private final LedgerUpdatedEventPublisher ledgerUpdatedEventPublisher; private final DispatchingStrategy dispatchingStrategy; @@ -81,8 +81,8 @@ protected void dispatchTransactionsBatch(String organisationId, } @Transactional - private Optional createAndSendBlockchainTransactions(String organisationId, - Set transactions) { + private Optional createAndSendBlockchainTransactions(String organisationId, + Set transactions) { log.info("Processing passedTransactions for organisation:{}, remaining size:{}", organisationId, transactions.size()); if (transactions.isEmpty()) { @@ -109,6 +109,8 @@ private Optional createAndSendBlockchainTransactions(Str val serialisedTx = serialisedTxM.orElseThrow(); try { sendTransactionOnChainAndUpdateDb(serialisedTx); + + return Optional.of(serialisedTx); } catch (InterruptedException | ApiException e) { log.error("Error sending transaction on chain and / or updating db", e); } @@ -117,7 +119,7 @@ private Optional createAndSendBlockchainTransactions(Str } @Transactional - private void sendTransactionOnChainAndUpdateDb(BlockchainTransactions blockchainTransactions) throws InterruptedException, ApiException { + private void sendTransactionOnChainAndUpdateDb(API1BlockchainTransactions blockchainTransactions) throws InterruptedException, ApiException { val txData = blockchainTransactions.serialisedTxData(); val l1SubmissionData = transactionSubmissionService.submitTransactionWithPossibleConfirmation(txData); val organisationId = blockchainTransactions.organisationId(); @@ -136,7 +138,7 @@ private void sendTransactionOnChainAndUpdateDb(BlockchainTransactions blockchain @Transactional private void updateTransactionStatuses(String txHash, Optional absoluteSlot, - BlockchainTransactions blockchainTransactions) { + API1BlockchainTransactions blockchainTransactions) { for (val txEntity : blockchainTransactions.submittedTransactions()) { txEntity.setL1SubmissionData(Optional.of(L1SubmissionData.builder() .transactionHash(txHash) diff --git a/blockchain_publisher/src/test/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3MetadataSerialiserTest.java b/blockchain_publisher/src/test/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3MetadataSerialiserTest.java new file mode 100644 index 00000000..7baa0eaf --- /dev/null +++ b/blockchain_publisher/src/test/java/org/cardanofoundation/lob/app/blockchain_publisher/service/API3MetadataSerialiserTest.java @@ -0,0 +1,170 @@ +package org.cardanofoundation.lob.app.blockchain_publisher.service; + +import com.bloxbean.cardano.client.metadata.MetadataMap; +import lombok.val; +import org.cardanofoundation.lob.app.accounting_reporting_core.domain.core.report.IntervalType; +import org.cardanofoundation.lob.app.accounting_reporting_core.domain.core.report.ReportType; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.reports.BalanceSheetData; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.reports.IncomeStatementData; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.reports.ReportEntity; +import org.cardanofoundation.lob.app.blockchain_publisher.domain.entity.txs.Organisation; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; + +class API3MetadataSerialiserTest { + + private API3MetadataSerialiser serialiser; + + private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2024-06-01T10:15:30Z"), ZoneId.of("UTC")); + private static final long CREATION_SLOT = 123456L; + + @BeforeEach + void setUp() { + serialiser = new API3MetadataSerialiser(FIXED_CLOCK); + } + + @Test + void serialiseToMetadataMap_whenBalanceSheet_shouldReturnAllFieldsCorrectly() { + // Arrange + val organisation = new Organisation("org-123", "Cardano Foundation", "CH", "CHE-123456", "ISO_4217:CHF"); + + val balanceSheetData = BalanceSheetData.builder() + .assets(BalanceSheetData.Assets.builder() + .nonCurrentAssets(BalanceSheetData.Assets.NonCurrentAssets.builder() + .propertyPlantEquipment(BigDecimal.valueOf(10000)) + .intangibleAssets(BigDecimal.valueOf(5000)) + .investments(BigDecimal.valueOf(20000)) + .financialAssets(BigDecimal.valueOf(30000)) + .build()) + .currentAssets(BalanceSheetData.Assets.CurrentAssets.builder() + .prepaymentsAndOtherShortTermAssets(BigDecimal.valueOf(1500)) + .otherReceivables(BigDecimal.valueOf(2500)) + .cryptoAssets(BigDecimal.valueOf(3500)) + .cashAndCashEquivalents(BigDecimal.valueOf(4500)) + .build()) + .build()) + .liabilities(BalanceSheetData.Liabilities.builder() + .nonCurrentLiabilities(BalanceSheetData.Liabilities.NonCurrentLiabilities.builder() + .provisions(BigDecimal.valueOf(5000)) + .build()) + .currentLiabilities(BalanceSheetData.Liabilities.CurrentLiabilities.builder() + .tradeAccountsPayables(BigDecimal.valueOf(1500)) + .otherCurrentLiabilities(BigDecimal.valueOf(2000)) + .accrualsAndShortTermProvisions(BigDecimal.valueOf(2500)) + .build()) + .build()) + .capital(BalanceSheetData.Capital.builder() + .capital(BigDecimal.valueOf(1000)) + .resultsCarriedForward(BigDecimal.valueOf(2000)) + .profitForTheYear(BigDecimal.valueOf(3000)) + .build()) + .build(); + + val reportEntity = new ReportEntity(); + reportEntity.setType(ReportType.BALANCE_SHEET); + reportEntity.setIntervalType(IntervalType.YEAR); + reportEntity.setYear((short) 2024); + reportEntity.setMode(org.cardanofoundation.lob.app.accounting_reporting_core.domain.core.report.ReportMode.USER); + reportEntity.setOrganisation(organisation); + reportEntity.setBalanceSheetReportData(java.util.Optional.of(balanceSheetData)); + + // Act + MetadataMap metadataMap = serialiser.serialiseToMetadataMap(reportEntity, CREATION_SLOT); + + // Assert + assertThat(metadataMap).isNotNull(); + assertThat(metadataMap.get("metadata")).isNotNull(); + assertThat(metadataMap.get("org")).isNotNull(); + assertThat(metadataMap.get("type")).isEqualTo("REPORT"); + assertThat(metadataMap.get("subType")).isEqualTo("BALANCE_SHEET"); + assertThat(metadataMap.get("data")).isNotNull(); + + // Data Section Validation + val data = (MetadataMap) metadataMap.get("data"); + + val assets = (MetadataMap) data.get("assets"); + assertThat(assets).isNotNull(); + + val nonCurrentAssets = (MetadataMap) assets.get("non_current_assets"); + assertThat(nonCurrentAssets.get("property_plant_equipment")).isEqualTo("10000"); + assertThat(nonCurrentAssets.get("intangible_assets")).isEqualTo("5000"); + assertThat(nonCurrentAssets.get("investments")).isEqualTo("20000"); + assertThat(nonCurrentAssets.get("financial_assets")).isEqualTo("30000"); + + val currentAssets = (MetadataMap) assets.get("current_assets"); + assertThat(currentAssets.get("prepayments_and_other_short_term_assets")).isEqualTo("1500"); + assertThat(currentAssets.get("other_receivables")).isEqualTo("2500"); + assertThat(currentAssets.get("crypto_assets")).isEqualTo("3500"); + assertThat(currentAssets.get("cash_and_cash_equivalents")).isEqualTo("4500"); + + val liabilities = (MetadataMap) data.get("liabilities"); + val nonCurrentLiabilities = (MetadataMap) liabilities.get("non_current_liabilities"); + assertThat(nonCurrentLiabilities.get("provisions")).isEqualTo("5000"); + + val currentLiabilities = (MetadataMap) liabilities.get("current_liabilities"); + assertThat(currentLiabilities.get("trade_accounts_payables")).isEqualTo("1500"); + assertThat(currentLiabilities.get("other_current_liabilities")).isEqualTo("2000"); + assertThat(currentLiabilities.get("accruals_and_short_term_provisions")).isEqualTo("2500"); + + val capital = (MetadataMap) data.get("capital"); + assertThat(capital.get("capital")).isEqualTo("1000"); + assertThat(capital.get("results_carried_forward")).isEqualTo("2000"); + assertThat(capital.get("profit_for_the_year")).isEqualTo("3000"); + } + + @Test + void serialiseToMetadataMap_whenIncomeStatement_shouldReturnAllFieldsCorrectly() { + // Arrange + val organisation = new Organisation("org-456", "Cardano Foundation", "CH", "CHE-654321", "ISO_4217:USD"); + + val incomeStatementData = IncomeStatementData.builder() + .revenues(IncomeStatementData.Revenues.builder() + .otherIncome(BigDecimal.valueOf(10000)) + .buildOfLongTermProvision(BigDecimal.valueOf(5000)) + .build()) + .costOfGoodsAndServices(IncomeStatementData.CostOfGoodsAndServices.builder() + .costOfProvidingServices(BigDecimal.valueOf(2000)) + .build()) + .profitForTheYear(BigDecimal.valueOf(7000)) + .build(); + + val reportEntity = new ReportEntity(); + reportEntity.setType(ReportType.INCOME_STATEMENT); + reportEntity.setIntervalType(IntervalType.YEAR); + reportEntity.setYear((short) 2024); + reportEntity.setMode(org.cardanofoundation.lob.app.accounting_reporting_core.domain.core.report.ReportMode.SYSTEM); + reportEntity.setOrganisation(organisation); + reportEntity.setIncomeStatementReportData(java.util.Optional.of(incomeStatementData)); + + // Act + MetadataMap metadataMap = serialiser.serialiseToMetadataMap(reportEntity, CREATION_SLOT); + + // Assert + assertThat(metadataMap).isNotNull(); + assertThat(metadataMap.get("metadata")).isNotNull(); + assertThat(metadataMap.get("org")).isNotNull(); + assertThat(metadataMap.get("type")).isEqualTo("REPORT"); + assertThat(metadataMap.get("subType")).isEqualTo("INCOME_STATEMENT"); + assertThat(metadataMap.get("data")).isNotNull(); + + // Data Section Validation + val data = (MetadataMap) metadataMap.get("data"); + + val revenues = (MetadataMap) data.get("revenues"); + assertThat(revenues.get("other_income")).isEqualTo("10000"); + assertThat(revenues.get("build_of_long_term_provision")).isEqualTo("5000"); + + val costOfGoodsAndServices = (MetadataMap) data.get("cost_of_goods_and_services"); + assertThat(costOfGoodsAndServices.get("cost_of_providing_services")).isEqualTo("2000"); + + assertThat(data.get("profit_for_the_year")).isEqualTo("17000"); + } + +}