Commit 31a17684 authored by seykron's avatar seykron

Issue #23: adds credit notes to invalidate existing transactions

parent 2c6527a5
......@@ -2,35 +2,13 @@ package be.rlab.domino.application
import be.rlab.domino.application.model.BalanceDTO
import be.rlab.domino.application.model.BillDTO
import be.rlab.domino.application.model.BillingMode
import be.rlab.domino.application.model.MemorySlots.SERVICE_CONFIGURATIONS
import be.rlab.domino.application.model.ServiceConfig
import be.rlab.domino.domain.model.Balance
import be.rlab.domino.domain.model.Bill
import be.rlab.domino.domain.model.TransactionType
import be.rlab.domino.domain.model.Value
import be.rlab.domino.domain.model.Value.Companion.ZERO
import be.rlab.tehanu.domain.Memory
import org.joda.time.DateTime
import java.util.*
class BalanceFactory(memory: Memory) {
private val serviceConfigurations: List<ServiceConfig> by memory.slot(SERVICE_CONFIGURATIONS, emptyList<ServiceConfig>())
data class BillInfo(
val bill: Bill,
val serviceConfig: ServiceConfig
) {
val billingMode: BillingMode = serviceConfig.billingMode
val creditorId: UUID = bill.creditor().id
val creditorUserName: String = bill.creditor().userName
val pending: Boolean = bill.pending
val debt: Value = bill.credit.debt
val total: Value = bill.debt.value
val dueDate: DateTime? = bill.dueDate
}
class BalanceFactory {
fun create(
previousBalance: Balance,
balance: Balance
......@@ -43,11 +21,11 @@ class BalanceFactory(memory: Memory) {
return BalanceDTO(
previousBalance = -previousTotal,
bills = resolveSingleBills(balance.bills).map { bill ->
bills = resolveValidBills(balance.bills).map { bill ->
BillDTO.new(
serviceName = bill.creditorUserName,
debt = bill.debt,
total = bill.total,
serviceName = bill.creditor().userName,
debt = bill.credit.debt,
total = bill.debt.value,
dueDate = bill.dueDate,
pending = bill.pending
)
......@@ -71,11 +49,11 @@ class BalanceFactory(memory: Memory) {
}
private fun calculatePendingDebt(balance: Balance): Value {
val bills: List<BillInfo> = resolveSingleBills(balance.bills)
val bills: List<Bill> = resolveValidBills(balance.bills)
return bills.fold(ZERO) { pendingDebt, billInfo ->
if (billInfo.pending) {
pendingDebt + billInfo.debt
pendingDebt + billInfo.credit.debt
} else {
pendingDebt
}
......@@ -98,37 +76,11 @@ class BalanceFactory(memory: Memory) {
}
}
private fun resolveSingleBills(
private fun resolveValidBills(
bills: List<Bill>
): List<BillInfo> {
return createBillsInfos(bills).fold(listOf()) { pendingBills, bill ->
if (!billExists(pendingBills, bill)) {
pendingBills + bill
} else {
pendingBills
}
}
}
private fun billExists(
pendingBills: List<BillInfo>,
bill: BillInfo
): Boolean {
return when (bill.billingMode) {
BillingMode.SINGLE -> pendingBills.any { pendingBill ->
bill.creditorId == pendingBill.creditorId
}
BillingMode.MULTIPLE -> false
}
}
private fun createBillsInfos(bills: List<Bill>): List<BillInfo> {
return bills.map { bill ->
val serviceConfig: ServiceConfig = serviceConfigurations.find { serviceConfig ->
serviceConfig.id == bill.creditor().id
} ?: throw RuntimeException("Cannot find service config for bill $bill")
BillInfo(bill, serviceConfig)
): List<Bill> {
return bills.filter { bill ->
!bill.cancelled()
}
}
}
......@@ -21,6 +21,7 @@ object DomainBeans {
bean { BlockSignatures }
bean { Transactions }
bean { Balances }
bean { CreditNotes }
// DAOs
bean<AccountDAO>()
......@@ -30,6 +31,7 @@ object DomainBeans {
bean<CreditDAO>()
bean<TransactionDAO>()
bean<BalanceDAO>()
bean<CreditNoteDAO>()
// Services
bean<AgentService>()
......
......@@ -40,7 +40,7 @@ class BalanceService(
val from = DateTime(year, month, 1, 0, 0, DateTimeZone.UTC)
val to = from.plusMonths(1)
val transactions: List<Transaction> = transactionDAO.findByPeriod(
val transactions: List<Transaction> = transactionDAO.findValidByPeriod(
treasuryConfig.group, from, to
)
val bills: List<Bill> = billDAO.findByPeriod(
......
package be.rlab.domino.domain
import be.rlab.domino.domain.model.*
import be.rlab.domino.domain.persistence.AccountDAO
import be.rlab.domino.domain.persistence.BlockSignatureDAO
import be.rlab.domino.domain.persistence.CreditDAO
import be.rlab.domino.domain.persistence.TransactionDAO
import be.rlab.domino.domain.persistence.*
import be.rlab.domino.util.persistence.TransactionSupport
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
......@@ -16,6 +13,7 @@ class TransactionService(
private val accountDAO: AccountDAO,
private val transactionDAO: TransactionDAO,
private val creditDAO: CreditDAO,
private val creditNoteDAO: CreditNoteDAO,
private val blockSignatureDAO: BlockSignatureDAO
): TransactionSupport() {
......@@ -117,6 +115,27 @@ class TransactionService(
transactionDAO.saveOrUpdate(transaction.ack())
}
/** Creates a credit note and invalidates the specified transaction.
* @param transaction Transaction to invalidate.
* @param reason Reason why the transaction is being invalidated.
* @return the invalid transaction.
*/
fun invalidate(
transaction: Transaction,
reason: String
): Transaction {
creditNoteDAO.saveOrUpdate(
CreditNote.new(
transaction = transaction,
description = reason
)
)
return transactionDAO.saveOrUpdate(
transaction.invalidate()
)
}
private fun lastSignature(accountId: UUID): String =
blockSignatureDAO.lastSignature(accountId)
}
\ No newline at end of file
......@@ -160,21 +160,23 @@ class TreasuryService(
}
private fun cancelBill(bill: Bill) {
val chargedAccount: Account = treasuryAccount()
val service: Agent = bill.creditor()
val creditorAccount: Account = bill.credit.account
transactionService.newDeposit(
account = chargedAccount,
description = "Cancelación de factura ${bill.shortId()} del servicio: ${service.userName}",
value = bill.debt.value
val invalidDebt: Transaction = transactionService.invalidate(
transaction = bill.debt,
reason = "Cancelación de factura ${bill.id} del servicio: ${service.userName}"
)
transactionService.newDebt(
account = creditorAccount,
description = "Nota de crédito por factura ${bill.shortId()}",
value = bill.debt.value
val invalidCredit: Credit = bill.credit.invalidate(
transactionService.invalidate(
transaction = bill.credit.income,
reason = "Cancelación de factura ${bill.id} del usuario ${bill.account.owner.userName}"
)
)
billDAO.saveOrUpdate(bill.asCreditNote())
billDAO.saveOrUpdate(bill.invalidate(
invalidDebt = invalidDebt,
invalidCredit = invalidCredit
))
}
private fun treasuryAccount(): Account =
......
......@@ -6,6 +6,13 @@ import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import java.util.*
/** Represents a service bill.
*
* A bill is associated to a [Credit] in the service account and a debt in the
* treasury account.
*
* In order to cancel a bill, the underlying debt and credit must be invalidated.
*/
data class Bill(
override val id: UUID,
override val signature: BlockSignature,
......@@ -88,14 +95,29 @@ data class Bill(
}
/** Creates a credit note for this bill.
*
* @param invalidDebt Invalidated transaction.
* @param invalidCredit Invalidated credit.
*
* @return the cancelled bill.
*/
fun asCreditNote(): Bill = copy(
credit = credit.invalidate(),
fun invalidate(
invalidDebt: Transaction,
invalidCredit: Credit
): Bill = copy(
credit = invalidCredit,
debt = invalidDebt,
pending = false
)
/** Indicates if this bill is cancelled.
*
* If a bill is cancelled, it shouldn't be used to calculate balances.
*
* @return true if cancelled, false otherwise.
*/
fun cancelled(): Boolean = debt.invalid
override fun blockId(): String = buildSignature(
fields = listOf(credit.signature, debt.signature)
)
......
......@@ -64,10 +64,11 @@ data class Credit(
/** Invalidates this credit.
* It cancels the debt and marks the credit as no longer pending.
* @param invalidIncome Invalidated transaction.
*/
fun invalidate(): Credit = copy(
debt = Value.ZERO,
pending = false
fun invalidate(invalidIncome: Transaction): Credit = copy(
pending = false,
income = invalidIncome
)
override fun blockId(): String = buildSignature(
......
package be.rlab.domino.domain.model
import be.rlab.domino.util.SignatureUtils.buildSignature
import be.rlab.domino.util.SignatureUtils.calculateSignature
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
import java.util.*
/** A credit note is used to invalidate an existing [Transaction].
*/
data class CreditNote(
override val id: UUID,
override val signature: BlockSignature,
override val creationDate: DateTime,
override val account: Account,
override val description: String,
val transaction: Transaction
) : Activity {
companion object {
fun new(
transaction: Transaction,
description: String
): CreditNote {
val creationDate: DateTime = DateTime.now().withZone(DateTimeZone.UTC)
val id: UUID = UUID.randomUUID()
return CreditNote(
id = id,
signature = calculateSignature(
transaction.signature.signature, id, creationDate, transaction.account.id,
description, transaction.id
),
creationDate = creationDate,
account = transaction.account,
description = description,
transaction = transaction
)
}
}
override fun blockId(): String = buildSignature(
fields = listOf(transaction.id)
)
}
......@@ -22,8 +22,9 @@ data class Transaction(
override val account: Account,
override val description: String,
val type: TransactionType,
val value: Value,
val ack: Boolean,
val value: Value
val invalid: Boolean
) : Activity {
companion object {
......@@ -80,7 +81,8 @@ data class Transaction(
description = description,
type = transactionType,
value = value,
ack = false
ack = false,
invalid = false
)
}
}
......@@ -98,4 +100,18 @@ data class Transaction(
ack = true
)
}
/** Invalidates this transaction.
*
* There must exist am underlying [CreditNote] related to this transaction
* explaining why it is invalidated.
*
* It throws an error if the transaction is already invalid.
*/
fun invalidate(): Transaction {
require(!invalid)
return copy(
invalid = true
)
}
}
package be.rlab.domino.domain.persistence
import be.rlab.domino.domain.model.CreditNote
import be.rlab.domino.util.persistence.AbstractEntityClass
import org.jetbrains.exposed.dao.EntityID
import java.util.*
object CreditNotes : Activities("treasury_credit_notes")
class CreditNoteEntity(id: EntityID<UUID>) : ActivityEntity<CreditNote>(
table = CreditNotes,
id = id,
type = CreditNote::class
) {
companion object : AbstractEntityClass<CreditNote, CreditNoteEntity>(CreditNotes)
}
/** DAO to manage [CreditNote]s.
*/
class CreditNoteDAO : ActivityDAO<CreditNote, CreditNoteEntity>(
table = CreditNotes,
entity = CreditNoteEntity
)
package be.rlab.domino.domain.persistence
import be.rlab.domino.domain.model.Transaction
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.greaterEq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.lessEq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select
import org.joda.time.DateTime
/** DAO to manage [Transaction]s.
*/
class TransactionDAO : ActivityDAO<Transaction, TransactionEntity>(
table = Transactions,
entity = TransactionEntity
) {
fun findValidByPeriod(
group: String,
from: DateTime,
to: DateTime
): List<Transaction> {
return TransactionEntity.wrapRows(
(Transactions innerJoin Accounts).select {
(Accounts.group eq group) and
(Transactions.creationDate greaterEq from) and
(Transactions.creationDate lessEq to) and
(Transactions.invalid eq false)
}
).map { activityEntity ->
activityEntity.toDomainType()
}
}
}
......@@ -2,10 +2,13 @@ package be.rlab.domino.domain.persistence
import be.rlab.domino.domain.model.Transaction
import be.rlab.domino.util.persistence.AbstractEntityClass
import be.rlab.domino.util.persistence.EntitySerialization.serialize
import org.jetbrains.exposed.dao.EntityID
import java.util.*
object Transactions : Activities("treasury_transactions")
object Transactions : Activities("treasury_transactions") {
val invalid = bool("is_invalid").default(false)
}
class TransactionEntity(id: EntityID<UUID>) : ActivityEntity<Transaction>(
table = Transactions,
......@@ -13,11 +16,18 @@ class TransactionEntity(id: EntityID<UUID>) : ActivityEntity<Transaction>(
type = Transaction::class
) {
companion object : AbstractEntityClass<Transaction, TransactionEntity>(Transactions)
}
/** DAO to manage [Transaction]s.
*/
class TransactionDAO : ActivityDAO<Transaction, TransactionEntity>(
table = Transactions,
entity = TransactionEntity
)
var invalid: Boolean by Transactions.invalid
override fun create(source: Transaction): ActivityEntity<Transaction> {
invalid = source.invalid
return super.create(source)
}
override fun update(source: Transaction): ActivityEntity<Transaction> {
invalid = source.invalid
data = serialize(source)
return super.update(source)
}
}
......@@ -3,10 +3,7 @@ package be.rlab.domino.domain
import be.rlab.domino.domain.model.*
import be.rlab.domino.domain.model.Value.Companion.valueOf
import be.rlab.domino.util.TestDataSource.initTransaction
import be.rlab.domino.util.mock.TestAccountDAO
import be.rlab.domino.util.mock.TestBlockSignatureDAO
import be.rlab.domino.util.mock.TestCreditDAO
import be.rlab.domino.util.mock.TestTransactionDAO
import be.rlab.domino.util.mock.*
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.mock
import org.joda.time.DateTime
......@@ -17,6 +14,7 @@ class TransactionServiceTest {
private val accountDAO: TestAccountDAO = TestAccountDAO()
private val transactionDAO: TestTransactionDAO = TestTransactionDAO()
private val creditDAO: TestCreditDAO = TestCreditDAO()
private val creditNoteDAO: TestCreditNoteDAO = TestCreditNoteDAO()
private val blockSignatureDAO: TestBlockSignatureDAO = TestBlockSignatureDAO()
@Test
......@@ -39,7 +37,8 @@ class TransactionServiceTest {
creditDAO = creditDAO.instance,
blockSignatureDAO = blockSignatureDAO
.lastSignature(account.id, BlockSignature.INITIAL_SIGNATURE)
.instance
.instance,
creditNoteDAO = creditNoteDAO.instance
))
val deposit: Transaction = transactionService.newDeposit(
......@@ -87,7 +86,8 @@ class TransactionServiceTest {
blockSignatureDAO = blockSignatureDAO
.lastSignature(sourceAccount.id, BlockSignature.INITIAL_SIGNATURE)
.lastSignature(destinationAccount.id, BlockSignature.INITIAL_SIGNATURE)
.instance
.instance,
creditNoteDAO = creditNoteDAO.instance
))
val transfer: Transaction = transactionService.newTransfer(
......@@ -140,7 +140,8 @@ class TransactionServiceTest {
.instance,
blockSignatureDAO = blockSignatureDAO
.lastSignature(account.id, BlockSignature.INITIAL_SIGNATURE)
.instance
.instance,
creditNoteDAO = creditNoteDAO.instance
))
val dueDate: DateTime = DateTime.now().plusDays(45)
......@@ -168,4 +169,36 @@ class TransactionServiceTest {
assert(capturedCredit.debt == valueOf(Currency.ARS, 60.0))
assert(capturedCredit.description == "testing credit")
}
@Test
fun invalidate() {
val debt: Transaction = TestTransaction().new()
val result: Transaction = mock()
val transactionService = initTransaction(TransactionService(
accountDAO = accountDAO.instance,
transactionDAO = transactionDAO
.saveOrUpdate(result)
.instance,
creditDAO = creditDAO.instance,
blockSignatureDAO = blockSignatureDAO.instance,
creditNoteDAO = creditNoteDAO
.saveOrUpdate()
.instance
))
assert(transactionService.invalidate(
transaction = debt,
reason = "cancelación de factura"
) == result)
transactionDAO.verifyAll()
creditNoteDAO.verifyAll()
val savedCreditNote: CreditNote = creditNoteDAO.capturedValue("saveOrUpdate")
assert(savedCreditNote.transaction == debt)
assert(savedCreditNote.description == "cancelación de factura")
val savedDebt: Transaction = transactionDAO.capturedValue("saveOrUpdate")
assert(savedDebt == debt.invalidate())
}
}
......@@ -192,17 +192,15 @@ class TreasuryServiceTest {
.findDefaultByAgent(creditor.id, creditorAccount)
.instance,
transactionService = transactionService
.newDeposit(
account = chargedAccount,
description = "Cancelación de factura ${pendingBill.shortId()} del servicio: ${creditor.userName}",
value = pendingBill.debt.value,
result = mock()
.invalidate(
transaction = pendingBill.debt,
reason = "Cancelación de factura ${pendingBill.id} del servicio: ${pendingBill.creditor().userName}",
result = pendingBill.debt.invalidate()
)
.newDebt(
account = creditorAccount,
description = "Nota de crédito por factura ${pendingBill.shortId()}",
value = pendingBill.debt.value,
result = mock()
.invalidate(
transaction = pendingBill.credit.income,
reason = "Cancelación de factura ${pendingBill.id} del usuario ${chargedAccount.owner.userName}",
result = pendingBill.credit.income.invalidate()
)
.newDebt(
chargedAccount,
......@@ -231,7 +229,10 @@ class TreasuryServiceTest {
transactionService.verifyAll()
val capturedCancelledBill: Bill = billDAO.capturedValues<Bill>("saveOrUpdate")[0]
assert(capturedCancelledBill == pendingBill.asCreditNote())
assert(capturedCancelledBill == pendingBill.invalidate(
invalidDebt = pendingBill.debt.invalidate(),
invalidCredit = pendingBill.credit.invalidate(pendingBill.credit.income.invalidate())
))
val capturedBill: Bill = billDAO.capturedValues<Bill>("saveOrUpdate")[1]
assert(capturedBill.pending)
......
package be.rlab.domino.domain.persistence
import be.rlab.domino.domain.model.*
import be.rlab.domino.domain.model.Value.Companion.valueOf
import be.rlab.domino.util.ActivityDAOTestSupport
import be.rlab.domino.util.TestDataSource.initTransaction
import org.junit.Test
class CreditNoteDAOTest : ActivityDAOTestSupport() {
private val transactionDAO: TransactionDAO by lazy {
initTransaction(TransactionDAO())
}
private val creditNoteDAO: CreditNoteDAO by lazy {
initTransaction(CreditNoteDAO())
}
@Test
fun saveOrUpdate() {
val account: Account = createAccount("rlyeh")
val debt: Transaction = transactionDAO.saveOrUpdate(
Transaction.debt(
previousSignature = BlockSignature.INITIAL_SIGNATURE,
account = account,
description = "factura de luz",
value = valueOf(Currency.ARS, 100.0)
)
)
val creditNote: CreditNote = CreditNote.new(
description = "depósito",
transaction = debt
)
assert(creditNoteDAO.saveOrUpdate(creditNote) == creditNote)
}
}
......@@ -3,6 +3,9 @@ package be.rlab.domino.domain.persistence
import be.rlab.domino.domain.model.*
import be.rlab.domino.util.ActivityDAOTestSupport
import be.rlab.domino.util.TestDataSource.initTransaction
import be.rlab.domino.util.TestDataSource.transaction
import org.jetbrains.exposed.sql.deleteAll
import org.joda.time.DateTime
import org.junit.Test
import java.math.BigDecimal
......@@ -56,4 +59,45 @@ class TransactionDAOTest : ActivityDAOTestSupport() {
)
assert(transactionDAO.saveOrUpdate(credit) == credit)
}
@Test
fun invalidate() {
val debt: Transaction = Transaction.debt(
previousSignature = BlockSignature.INITIAL_SIGNATURE,
account = createAccount("rlyeh"),
description = "nueva factura",
value = Value.ZERO
)
assert(transactionDAO.saveOrUpdate(debt) == debt)
assert(transactionDAO.saveOrUpdate(debt.invalidate()) == debt.invalidate())
}
@Test
fun findValidByPeriod() = transaction {
Transactions.deleteAll()
val debt1: Transaction = Transaction.debt(
previousSignature = BlockSignature.INITIAL_SIGNATURE,
account = createAccount("rlyeh"),
description = "nueva factura 1",
value = Value.ZERO
).invalidate()
val debt2: Transaction = Transaction.debt(
previousSignature = BlockSignature.INITIAL_SIGNATURE,
account = createAccount("rlyeh"),
description = "nueva factura 2",
value = Value.ZERO
)
transactionDAO.saveOrUpdate(debt1)
transactionDAO.saveOrUpdate(debt2)
val transactions: List<Transaction> = transactionDAO.findValidByPeriod(
group = "rlyeh",
from = DateTime.now().minusDays(1),
to = DateTime.now()
)
assert(transactions.size == 1)
assert(transactions[0] == debt2)
}
}
......@@ -34,7 +34,8 @@ object TestDataSource {
val initializer = DataSourceInitializer(
config = config,
tables = listOf(Accounts, Agents, Credits, Bills,
BlockSignatures, Transactions, TestActivities, Balances)
BlockSignatures, Transactions, TestActivities, Balances,
CreditNotes)
)
val configField: Field = initializer.javaClass.superclass.getDeclaredField("config")
configField.isAccessible = true
......
package be.rlab.domino.util.mock
import be.rlab.domino.domain.model.CreditNote
import be.rlab.domino.domain.persistence.CreditNoteDAO
import be.rlab.domino.util.VerifySupport
import com.nhaarman.mockitokotlin2.*