Skip to content

Commit

Permalink
GH-314 - Support for custom AccountancyEntry sub-types.
Browse files Browse the repository at this point in the history
Accountancy now allows handling custom Accountancy entry sub-types. ProductPaymentEntry has been renamed to OrderPaymentEntry.
  • Loading branch information
odrotbohm committed Sep 6, 2023
1 parent f7bf279 commit 8361de4
Show file tree
Hide file tree
Showing 12 changed files with 370 additions and 90 deletions.
73 changes: 60 additions & 13 deletions src/main/asciidoc/salespoint-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -312,24 +312,71 @@ See link:{javadoc}/org/salespointframework/inventory/InventoryOrderEventListener
plantuml::{generated-docs}/module-org.salespointframework.accountancy.puml[,,format=svg,title="Accountancy component"]
include::{generated-docs}/module-org.salespointframework.accountancy.adoc[]

The accountancy package contains functionality supporting book keeping. `AccountancyEntry` is a representation of an accounting entry. `Accountancy` aggregates ``AccountancyEntry``s. Every `AccountancyEntry` is uniquely identified by an ``AccountancyEntryIdentifier``. `AccountancyEntry` extends `AbstractEntity` and serves as persistence entity, while `PersistentAccountancy` implements `Accountancy` and provides transparent access to the JPA layer. `AccountancyEntryIdentifier` is used as primary key attribute, when entities are stored in the database.
The accountancy package contains functionality supporting book keeping.
The `Accountancy` is an application service that is an append-only log of ``AccountancyEntry``s.
This means, that `AccountancyEntry` instances cannot be changed, once they were added to the `Accountancy`.
To correct a wrong entry, one would typically submit a compensating one.
See <<modules.accountancy.events>> for details.

////
[<<Interface>> Accountancy]^-.-[PersistentAccountancy]
[PersistentAccountancy]uses-.->[<<Interface>> AccountancyEntryRepository]
[AccountancyEntry|-id:AccountancyEntryIdentifier]
////
image::accountancy.png[Accountancy, title="Accountancy"]
`AccountancyEntry` is an abstract type.
The only concrete implementation of that which Salespoint knows about is `OrderPaymentEntry`.
Those are <<modules.accountancy.events, automatically created>> during the handling of the lifecycle events triggered in the process of handling orders.

It is common to add custom `AccountancyEntry` types to your application represent other types of incomes and expenses.

[source, java]
----
import org.salespointframework.accountancy.AccountancyEntry;
@Entity
public class MyCustomAccountancyEntry extends AccountancyEntry {
// Custom field declarations
public MyCustomAccountancyEntry(MonetaryAmount amount, String description, …) {
super(amount, description);
// assign other parameters
}
// Getters, *no* setters.
}
----

By implementing and sub-classing `Accountancy`, the notion of different accounts, as known from double-entry bookkeeping, can be realized.
To manage `AccountancyEntry` instances, you can get access to the `Accountancy` service via dependency injection.

To create a new account, `AccountancyEntry` has to be sub-classed. Every object of such a class belongs to the same account. Accessing per-account entries is facilitated by specifiying the desired class type when calling `get()` or `find()` methods of `Accountancy`.
[source, java]
----
@Service
class MyApplicationComponent {
private final Accountancy accountancy;
// Getting access to Accountancy via dependency injection.
MyApplicationComponent(Accountancy accountancy) {
Assert.notNull(accountancy, "Accountancy must not be null!"
this.accountancy = accountancy;
}
void someMethod() {
var amount = Money.of(50, Currencies.EURO);
// Add the custom entry to the accountancy
var entry = accountancy.add(new MyCustomAccountancyEntry(amount, "Some description."));
// Obtains only the custom accountancy entries
var entries = accountancy.findAll(MyCustomAccountancyEntry.class);
}
}
----

[[modules.accountancy.events]]
=== Handling OrderPaid events
=== Accountancy handling of order-related events

The accountancy subsystem handles `OrderPaid` events by creating a `ProductPaymentEntry` for the order.
Reversely, it will create a compensating `ProductPaymentEntry` on `OrderCanceled` if theres a revenue `ProductPaymentEntry` available for `Order`.
The accountancy subsystem handles `OrderPaid` events by creating a `OrderPaymentEntry` for the order.
Reversely, it will create a compensating `OrderPaymentEntry` on `OrderCanceled` if theres a revenue `OrderPaymentEntry` available for `Order`.
See link:{javadoc}/org/salespointframework/accountancy/AccountancyOrderEventListener.html[the Javadoc of the event listener] for details.

[[modules.payment]]
Expand Down Expand Up @@ -780,7 +827,7 @@ Read more about that in the updated section of <<modules.order>>.

==== New order canceled event and fixes in event handling in general
https://github.com/st-tu-dresden/salespoint/issues/230[#230] -- The creation of a `PaymentAccountancyEntry` has been properly attached to the `OrderPaid` event now.
An `OrderCanceled` event is now published on order cancellation which triggers a rollback of the inventory updates as well as a compensating `ProductPaymentEntry`.
An `OrderCanceled` event is now published on order cancellation which triggers a rollback of the inventory updates as well as a compensating `OrderPaymentEntry`.
Read more on that in the updated section <<modules.order.lifecycle>>.

[[new-and-noteworthy.whats-new-in-6.1]]
Expand Down
81 changes: 65 additions & 16 deletions src/main/java/org/salespointframework/accountancy/Accountancy.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@
public interface Accountancy {

/**
* Adds a new {@link AccountancyEntry} to this {@code Accountancy}. The {@link AccountancyEntry}'s date will be set
* to the value returned by {@link BusinessTime#getTime()} in case it is not set already.
* Adds a new {@link AccountancyEntry} to this {@code Accountancy}. The {@link AccountancyEntry}'s date will be set to
* the value returned by {@link BusinessTime#getTime()} in case it is not set already.
*
* @param accountancyEntry entry to be added to the accountancy, must not be {@literal null}.
* @param entry entry to be added to the accountancy, must not be {@literal null}.
* @return the added {@link AccountancyEntry}.
*/
<T extends AccountancyEntry> T add(T accountancyEntry);
<T extends AccountancyEntry> T add(T entry);

/**
* Returns all {@link AccountancyEntry}s previously added to the accountancy.
Expand All @@ -53,15 +53,38 @@ public interface Accountancy {
*/
Streamable<AccountancyEntry> findAll();

/**
* Returns all {@link AccountancyEntry} instances of the given types, and the given types only. This means the asking
* for intermediate types in an inheritance hierarchy is not sufficient. All leaf types of the hierarchy would have to
* be passed in.
*
* @param <T> the concrete subtype of {@link AccountancyEntry}.
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
* @since 9.0
*/
<T extends AccountancyEntry> Streamable<T> findAll(Class<T> type);

/**
* Returns the {@link AccountancyEntry} identified by the given {@link AccountancyEntryIdentifier}, if it exists.
*
* @param accountancyEntryIdentifier the {@link AccountancyEntryIdentifier} of the entry to be returned, must not be
* {@literal null}.
* @return an {@code Optional} with a matching {@link AccountancyEntry}, or an empty {@code Optional} if no match
* was found.
* @param identifier the {@link AccountancyEntryIdentifier} of the entry to be returned, must not be {@literal null}.
* @return an {@code Optional} with a matching {@link AccountancyEntry}, or an empty {@code Optional} if no match was
* found.
*/
Optional<AccountancyEntry> get(AccountancyEntryIdentifier accountancyEntryIdentifier);
Optional<AccountancyEntry> get(AccountancyEntryIdentifier identifier);

/**
* Returns the {@link AccountancyEntry} identified by the given {@link AccountancyEntryIdentifier} of the given type,
* if it exists.
*
* @param identifier the {@link AccountancyEntryIdentifier} of the entry to be returned, must not be {@literal null}.
* @param type the concrete {@link AccountancyEntry} type, must not be {@literal null}.
* @return an {@code Optional} with a matching {@link AccountancyEntry}, or an empty {@code Optional} if no match was
* found.
* @since 9.0
*/
<T extends AccountancyEntry> Optional<T> get(AccountancyEntryIdentifier identifier, Class<T> type);

/**
* Returns all {@link AccountancyEntry}s that have a {@code date} within the given {@link Interval}.
Expand All @@ -71,11 +94,22 @@ public interface Accountancy {
*/
Streamable<AccountancyEntry> find(Interval interval);

/**
* Returns all {@link AccountancyEntry}s of the given type for the given {@link Interval}.
*
* @param <T>
* @param interval must not be {@literal null}.
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
* @since 9.0
*/
<T extends AccountancyEntry> Streamable<T> find(Interval interval, Class<T> type);

/**
* Returns all {@link AccountancyEntry}s within the given interval, grouped by sub-intervals of the given duration.
* These sub-intervals are used as the keys of the returned {@link Map}. Note that the last sub-interval may be
* shorter than the given {@code duration}.
*
*
* @param interval the interval within which we want to find {@link AccountancyEntry}s, must not be {@literal null}.
* @param duration the duration of the sub-intervals, must not be {@literal null}.
* @return a {@link Map} containing a {@link Streamable} with zero or more {@link AccountancyEntry}s for each
Expand All @@ -84,15 +118,30 @@ public interface Accountancy {
Map<Interval, Streamable<AccountancyEntry>> find(Interval interval, TemporalAmount duration);

/**
* Computes the sales volume, i.e., the sum of {@link AccountancyEntry#getValue()}, for all
* {@link AccountancyEntry}s within the given interval, grouped by sub-intervals of the given duration. If a
* sub-interval doesn't contain an {@link AccountancyEntry}, its sales volume is zero. Note that the last
* Returns all {@link AccountancyEntry}s of the given type within the given interval, grouped by sub-intervals of the
* given duration. These sub-intervals are used as the keys of the returned {@link Map}. Note that the last
* sub-interval may be shorter than the given {@code duration}.
*
*
* @param interval the interval within which we want to find {@link AccountancyEntry}s, must not be {@literal null}.
* @param duration the duration of the sub-intervals, must not be {@literal null}.
* @param type
* @return a {@link Map} containing a {@link Streamable} with zero or more {@link AccountancyEntry}s for each
* sub-interval.
* @since 9.0
*/
<T extends AccountancyEntry> Map<Interval, Streamable<T>> find(Interval interval, TemporalAmount duration,
Class<T> type);

/**
* Computes the sales volume, i.e., the sum of {@link AccountancyEntry#getValue()}, for all {@link AccountancyEntry}s
* within the given interval, grouped by sub-intervals of the given duration. If a sub-interval doesn't contain an
* {@link AccountancyEntry}, its sales volume is zero. Note that the last sub-interval may be shorter than the given
* {@code duration}.
*
* @param interval the interval within which we want to find {@link AccountancyEntry}s, must not be {@literal null}.
* @param duration the duration of the sub-intervals that are used to group the summation, must not be
* {@literal null}.
* @return a {@link Map} containing the summated {@link MonetaryAmount} for each sub-interval.
* {@literal null}.
* @return a {@link Map} containing the summed up {@link MonetaryAmount} for each sub-interval.
*/
Map<Interval, MonetaryAmount> salesVolume(Interval interval, TemporalAmount duration);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package org.salespointframework.accountancy;

import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.Embeddable;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
Expand Down Expand Up @@ -42,14 +43,15 @@

/**
* This class represents an accountancy entry. It is advisable to sub-class it, to define specific entry types for an
* accountancy, for example a {@link ProductPaymentEntry}.
* accountancy, for example a {@link OrderPaymentEntry}.
*
* @author Hannes Weisbach
* @author Oliver Drotbohm
*/
@Entity
@ToString
@NoArgsConstructor(force = true, access = AccessLevel.PROTECTED, onConstructor = @__(@Deprecated))
@DiscriminatorColumn(length = 100)
public class AccountancyEntry extends AbstractEntity<AccountancyEntryIdentifier> {

private @EmbeddedId AccountancyEntryIdentifier accountancyEntryIdentifier = AccountancyEntryIdentifier
Expand Down Expand Up @@ -94,7 +96,7 @@ public boolean hasDate() {

/**
* Returns the date this entry was posted.
*
*
* @return the date when this entry was posted, or an empty {@code Optional} if no date is set.
*/
public Optional<LocalDateTime> getDate() {
Expand All @@ -103,7 +105,7 @@ public Optional<LocalDateTime> getDate() {

/**
* Returns the unique identifier of this {@link AccountancyEntry}.
*
*
* @return will never be {@literal null}
*/
@Override
Expand All @@ -114,7 +116,7 @@ public AccountancyEntryIdentifier getId() {
/**
* Returns whether the entry is considered revenue, i.e. its value is zero or positive.
*
* @return
* @return whether the entry is considered revenue, i.e. its value is zero or positive.
* @since 7.1
*/
public boolean isRevenue() {
Expand All @@ -124,7 +126,7 @@ public boolean isRevenue() {
/**
* Returns whether the entry is considered expense, i.e. its value is negative.
*
* @return
* @return whether the entry is considered expense, i.e. its value is negative.
* @since 7.1
*/
public boolean isExpense() {
Expand All @@ -143,8 +145,8 @@ void verifyConstraints() {

/**
* {@link AccountancyEntryIdentifier} serves as an identifier type and primary key for {@link AccountancyEntry}
* objects. The main reason for its existence is type safety for identifiers across the Salespoint framework.
* However, it can also be used as a key for non-persistent, {@link Map}-based implementations.
* objects. The main reason for its existence is type safety for identifiers across the Salespoint framework. However,
* it can also be used as a key for non-persistent, {@link Map}-based implementations.
*
* @author Hannes Weisbach
* @author Oliver Drotbohm
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,35 +20,100 @@
import org.salespointframework.accountancy.AccountancyEntry.AccountancyEntryIdentifier;
import org.salespointframework.core.SalespointRepository;
import org.salespointframework.time.Interval;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.data.util.Streamable;
import org.springframework.util.Assert;

/**
* Repository for {@link AccountancyEntry}s.
*
* @author Oliver Gierke
* @author Oliver Drotbohm
*/
interface AccountancyEntryRepository extends SalespointRepository<AccountancyEntry, AccountancyEntryIdentifier> {

/**
* Finds all {@link AccountancyEntry} of the given type.
*
* @param <T> the actual {@link AccountancyEntry} type
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
*/
@SuppressWarnings("unchecked")
default <T extends AccountancyEntry> Streamable<T> findAll(Class<T> type) {

Assert.notNull(type, "Type must not be null!");

return AccountancyEntry.class.equals(type) ? (Streamable<T>) findAll() : findAllTyped(type);
}

/**
* Finds all {@link AccountancyEntry} of the given type created between the given start and end date.
*
* @param <T> the actual {@link AccountancyEntry} type
* @param from must not be {@literal null}.
* @param to must not be {@literal null}.
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
*/
@Query("select ae from AccountancyEntry ae where ae.date > :from and ae.date < :to and type(ae) = :type")
<T extends AccountancyEntry> Streamable<T> findByDateBetween(LocalDateTime from, LocalDateTime to,
@Param("type") Class<T> type);

/**
* Returns all {@link AccountancyEntry}s in the given time frame.
*
* @param from
* @param to
* @return
* @param from must not be {@literal null}.
* @param to must not be {@literal null}.
* @return will never be {@literal null}.
*/
Streamable<AccountancyEntry> findByDateBetween(LocalDateTime from, LocalDateTime to);
default Streamable<AccountancyEntry> findByDateBetween(LocalDateTime from, LocalDateTime to) {

Assert.notNull(from, "Start date must not be null!");
Assert.notNull(to, "End date must not be null!");

return findByDateBetween(from, to, AccountancyEntry.class);
}

/**
* Returns all {@link AccountancyEntry}s within the given {@link Interval}.
*
* @param interval must not be {@literal null}.
* @return
* @return will never be {@literal null}.
*/
default Streamable<AccountancyEntry> findByDateIn(Interval interval) {

Assert.notNull(interval, "Interval must not be null!");

return findByDateBetween(interval.getStart(), interval.getEnd());
}

/**
* Finds all {@link AccountancyEntry}s of the given type within the given {@link Interval}.
*
* @param <T> the actual {@link AccountancyEntry} type
* @param interval must not be {@literal null}.
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
*/
@SuppressWarnings("unchecked")
default <T extends AccountancyEntry> Streamable<T> findByDateIn(Interval interval, Class<T> type) {

Assert.notNull(interval, "Interval must not be null!");
Assert.notNull(type, "Type must not be null!");

return AccountancyEntry.class.equals(type)
? (Streamable<T>) findByDateBetween(interval.getStart(), interval.getEnd())
: findByDateBetween(interval.getStart(), interval.getEnd(), type);
}

/**
* Finds all {@link AccountancyEntry}s of the given type.
*
* @param <T> the actual {@link AccountancyEntry} type.
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
*/
@Query("select ae from AccountancyEntry ae where type(ae) = :type")
<T extends AccountancyEntry> Streamable<T> findAllTyped(@Param("type") Class<T> type);
}
Loading

0 comments on commit 8361de4

Please sign in to comment.