Skip to content

Commit d0c27c1

Browse files
committed
Implement user story:
As a returning customer I want the billing engine to automatically apply a 25% discount to every third purchase So that I am rewarded for continued loyalty without needing to enter a promo code. Signed-off-by: Pierre-Alexandre Meyer <pierre@mouraf.org>
1 parent 9df4d94 commit d0c27c1

File tree

3 files changed

+538
-77
lines changed

3 files changed

+538
-77
lines changed

pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@
140140
<scope>provided</scope>
141141
</dependency>
142142

143+
<dependency>
144+
<groupId>org.mockito</groupId>
145+
<artifactId>mockito-core</artifactId>
146+
<scope>test</scope>
147+
</dependency>
143148
<dependency>
144149
<groupId>org.testng</groupId>
145150
<artifactId>testng</artifactId>

src/main/java/org/killbill/billing/plugin/helloworld/HelloWorldInvoicePluginApi.java

Lines changed: 138 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
package org.killbill.billing.plugin.helloworld;
1919

2020
import java.math.BigDecimal;
21-
import java.util.HashSet;
21+
import java.math.RoundingMode;
22+
import java.util.Collection;
23+
import java.util.EnumSet;
2224
import java.util.LinkedList;
2325
import java.util.List;
2426
import java.util.Set;
@@ -39,122 +41,181 @@
3941
import org.killbill.billing.plugin.api.invoice.PluginInvoicePluginApi;
4042
import org.killbill.billing.util.callcontext.TenantContext;
4143
import org.killbill.clock.Clock;
44+
import org.slf4j.Logger;
45+
import org.slf4j.LoggerFactory;
4246

4347
class HelloWorldInvoicePluginApi extends PluginInvoicePluginApi implements OSGIKillbillEventHandler {
4448

49+
private static final Logger logger = LoggerFactory.getLogger(HelloWorldInvoicePluginApi.class);
50+
51+
static final String LOYALTY_DISCOUNT_DESC = "Loyalty discount \u2013 25% off (every 3rd purchase)";
52+
static final BigDecimal DISCOUNT_RATE = new BigDecimal("0.25");
53+
static final int PURCHASE_CYCLE = 3;
54+
55+
/**
56+
* Invoice item types that represent a customer charge (one-time or otherwise).
57+
*/
58+
private static final Set<InvoiceItemType> CHARGEABLE_TYPES = EnumSet.of(
59+
InvoiceItemType.FIXED,
60+
InvoiceItemType.EXTERNAL_CHARGE,
61+
InvoiceItemType.USAGE,
62+
InvoiceItemType.RECURRING
63+
);
64+
4565
public HelloWorldInvoicePluginApi(final OSGIKillbillAPI killbillAPI, final OSGIConfigPropertiesService configProperties,
4666
final Clock clock) {
4767
super(killbillAPI, configProperties, clock);
4868
}
4969

5070
/**
51-
* Returns additional invoice items to be added to invoice
71+
* Applies a 25% loyalty discount to every 3rd one-time purchase.
5272
* <p>
53-
* This method produces two types of invoice items a. Tax Item on Invoice Item
54-
* b. Adjustment Item on only the very first Historical Invoice Tax Item
55-
* automatically ( just for Demo purpose )
73+
* During invoice generation Kill Bill calls this method before the invoice is
74+
* committed. The plugin counts past one-time-purchase invoices for the account
75+
* and, when the current invoice is the 3rd (6th, 9th, …) purchase, returns
76+
* negative {@code ITEM_ADJ} items equal to 25% of each charge line item.
5677
*
57-
* @param newInvoice The invoice that is being created.
58-
* @param dryRun Whether it is dryRun or not
59-
* @param properties Any user-specified plugin properties, coming straight out
60-
* of the API request that has triggered this code to run.
61-
* @param callCtx The context in which this code is running.
62-
* @return A new immutable list of new tax items, or adjustments on existing tax
63-
* items.
78+
* @param newInvoice The invoice that is being created.
79+
* @param dryRun Whether it is a dry-run preview.
80+
* @param properties Any user-specified plugin properties.
81+
* @param invoiceContext The context in which this code is running.
82+
* @return Additional adjustment items representing the loyalty discount, or an
83+
* empty list when the discount does not apply.
6484
*/
6585
@Override
6686
public AdditionalItemsResult getAdditionalInvoiceItems(final Invoice newInvoice, final boolean dryRun,
6787
final Iterable<PluginProperty> properties, final InvoiceContext invoiceContext) {
6888

6989
final UUID accountId = newInvoice.getAccountId();
7090
final Account account = getAccount(accountId, invoiceContext);
71-
final Set<Invoice> allInvoices = getAllInvoicesOfAccount(account, newInvoice, invoiceContext);
7291
final List<InvoiceItem> additionalItems = new LinkedList<InvoiceItem>();
7392

74-
// Creating tax item for first Item of new Invoice
75-
final List<InvoiceItem> newInvoiceItems = newInvoice.getInvoiceItems();
76-
final InvoiceItem newInvoiceItem = newInvoiceItems.get(0);
77-
BigDecimal charge = new BigDecimal("80");
78-
final InvoiceItem taxItem = PluginInvoiceItem.createTaxItem(newInvoiceItem, newInvoiceItem.getInvoiceId(),
79-
newInvoice.getInvoiceDate(), null, charge, "Tax Item");
80-
additionalItems.add(taxItem);
81-
82-
// Creating External Charge for first Item of new Invoice
83-
final InvoiceItem externalItem = PluginInvoiceItem.create(newInvoiceItem, newInvoiceItem.getInvoiceId(),
84-
newInvoice.getInvoiceDate(), null, charge, "External Item", InvoiceItemType.EXTERNAL_CHARGE);
85-
additionalItems.add(externalItem);
86-
87-
// Adding adjustment invoice item to the first historical invoice, if it does not have the adjustment item
88-
for (final Invoice invoice : allInvoices) {
89-
if (!invoice.getId().equals(newInvoice.getId())) {
90-
final List<InvoiceItem> invoiceItems = invoice.getInvoiceItems();
91-
// Check for if any adjustment item exists for Historical Invoice
92-
if (checkforAdjustmentItem(invoiceItems)) {
93-
break;
94-
}
95-
for (final InvoiceItem item : invoiceItems) {
96-
charge = new BigDecimal("-30");
97-
final InvoiceItem adjItem = PluginInvoiceItem.createAdjustmentItem(item, item.getInvoiceId(),
98-
newInvoice.getInvoiceDate(), newInvoice.getInvoiceDate(), charge, "Adjustment Item");
99-
additionalItems.add(adjItem);
100-
break;
101-
}
102-
break;
93+
// --- Idempotency guard: skip if discount items were already added --------
94+
if (hasExistingLoyaltyDiscount(newInvoice)) {
95+
logger.info("Loyalty discount already present on invoice {} for account {} – skipping",
96+
newInvoice.getId(), accountId);
97+
return buildResult(additionalItems);
98+
}
99+
100+
// --- Count past one-time-purchase invoices (excluding the current one) ---
101+
final Collection<Invoice> pastInvoices = getInvoicesByAccountId(account.getId(), invoiceContext);
102+
int pastPurchaseCount = 0;
103+
for (final Invoice invoice : pastInvoices) {
104+
if (!invoice.getId().equals(newInvoice.getId()) && isOneTimePurchaseInvoice(invoice)) {
105+
pastPurchaseCount++;
103106
}
104107
}
105-
106-
return new AdditionalItemsResult() {
107-
@Override
108-
public List<InvoiceItem> getAdditionalItems() {
109-
return additionalItems;
108+
109+
// The current invoice is purchase number (pastPurchaseCount + 1)
110+
// but only if this invoice itself qualifies as a one-time purchase.
111+
if (!isOneTimePurchaseInvoice(newInvoice)) {
112+
logger.debug("Invoice {} for account {} is not a one-time purchase – no loyalty discount",
113+
newInvoice.getId(), accountId);
114+
return buildResult(additionalItems);
115+
}
116+
117+
final int currentPurchaseNumber = pastPurchaseCount + 1;
118+
logger.info("Account {}: this is one-time purchase #{}", accountId, currentPurchaseNumber);
119+
120+
if (currentPurchaseNumber % PURCHASE_CYCLE != 0) {
121+
logger.info("Account {}: purchase #{} is not a multiple of {} – no discount",
122+
accountId, currentPurchaseNumber, PURCHASE_CYCLE);
123+
return buildResult(additionalItems);
124+
}
125+
126+
// --- Apply 25% discount to every chargeable item on this invoice ---------
127+
BigDecimal totalDiscount = BigDecimal.ZERO;
128+
for (final InvoiceItem item : newInvoice.getInvoiceItems()) {
129+
if (isChargeableItem(item) && item.getAmount() != null && item.getAmount().compareTo(BigDecimal.ZERO) > 0) {
130+
final BigDecimal discountAmount = item.getAmount()
131+
.multiply(DISCOUNT_RATE)
132+
.setScale(2, RoundingMode.HALF_UP)
133+
.negate();
134+
135+
final InvoiceItem adjItem = PluginInvoiceItem.createAdjustmentItem(
136+
item,
137+
item.getInvoiceId(),
138+
newInvoice.getInvoiceDate(),
139+
newInvoice.getInvoiceDate(),
140+
discountAmount,
141+
LOYALTY_DISCOUNT_DESC);
142+
additionalItems.add(adjItem);
143+
totalDiscount = totalDiscount.add(discountAmount);
110144
}
145+
}
111146

112-
@Override
113-
public Iterable<PluginProperty> getAdjustedPluginProperties() {
114-
return null;
147+
if (dryRun) {
148+
logger.info("Account {}: dry-run – loyalty discount of {} would be applied on purchase #{}",
149+
accountId, totalDiscount, currentPurchaseNumber);
150+
} else {
151+
logger.info("Account {}: applying loyalty discount of {} on purchase #{}",
152+
accountId, totalDiscount, currentPurchaseNumber);
153+
}
154+
155+
return buildResult(additionalItems);
156+
}
157+
158+
// -------------------------------------------------------------------------
159+
// Helper methods
160+
// -------------------------------------------------------------------------
161+
162+
/**
163+
* Determines whether an invoice represents a one-time purchase.
164+
* An invoice qualifies when it contains at least one chargeable item and
165+
* does <b>not</b> contain any {@link InvoiceItemType#RECURRING} items.
166+
*/
167+
boolean isOneTimePurchaseInvoice(final Invoice invoice) {
168+
boolean hasCharge = false;
169+
for (final InvoiceItem item : invoice.getInvoiceItems()) {
170+
if (item.getInvoiceItemType() == InvoiceItemType.RECURRING) {
171+
return false;
172+
}
173+
if (isChargeableItem(item)) {
174+
hasCharge = true;
115175
}
116-
};
176+
}
177+
return hasCharge;
117178
}
118179

119180
/**
120-
* This method returns all invoices of account
121-
*
122-
* @param account The account to consider.
123-
* @param newInvoice New Invoice Item to be added to existing invoices of the
124-
* account
125-
* @param tenantCtx
126-
* @return All invoices of account
181+
* Returns {@code true} when the item type represents a customer charge.
127182
*/
128-
private Set<Invoice> getAllInvoicesOfAccount(final Account account, final Invoice newInvoice, final TenantContext tenantCtx) {
129-
final Set<Invoice> invoices = new HashSet<Invoice>();
130-
invoices.addAll(getInvoicesByAccountId(account.getId(), tenantCtx));
131-
invoices.add(newInvoice);
132-
return invoices;
183+
private boolean isChargeableItem(final InvoiceItem item) {
184+
return CHARGEABLE_TYPES.contains(item.getInvoiceItemType());
133185
}
134186

135187
/**
136-
* Check whether adjustment item is already present in invoice Item of Invoice
137-
*
138-
* @param invoiceItems
139-
* @return
188+
* Idempotency check: returns {@code true} if the invoice already contains
189+
* an adjustment item with the loyalty-discount description.
140190
*/
141-
private boolean checkforAdjustmentItem(final List<InvoiceItem> invoiceItems) {
142-
boolean adjustmentItemPresent = false;
143-
for (final InvoiceItem invoiceItem : invoiceItems) {
144-
if (invoiceItem.getInvoiceItemType().equals(InvoiceItemType.ITEM_ADJ)) {
145-
adjustmentItemPresent = true;
146-
break;
191+
private boolean hasExistingLoyaltyDiscount(final Invoice invoice) {
192+
for (final InvoiceItem item : invoice.getInvoiceItems()) {
193+
if (InvoiceItemType.ITEM_ADJ.equals(item.getInvoiceItemType())
194+
&& LOYALTY_DISCOUNT_DESC.equals(item.getDescription())) {
195+
return true;
147196
}
148197
}
149-
return adjustmentItemPresent;
198+
return false;
150199
}
151200

152-
protected boolean isTaxItem(final InvoiceItem invoiceItem) {
153-
return InvoiceItemType.TAX.equals(invoiceItem.getInvoiceItemType());
201+
/**
202+
* Wraps the list of additional items into an {@link AdditionalItemsResult}.
203+
*/
204+
private AdditionalItemsResult buildResult(final List<InvoiceItem> additionalItems) {
205+
return new AdditionalItemsResult() {
206+
@Override
207+
public List<InvoiceItem> getAdditionalItems() {
208+
return additionalItems;
209+
}
210+
211+
@Override
212+
public Iterable<PluginProperty> getAdjustedPluginProperties() {
213+
return null;
214+
}
215+
};
154216
}
155217

156218
@Override
157219
public void handleKillbillEvent(final ExtBusEvent killbillEvent) {
158220
}
159-
160221
}

0 commit comments

Comments
 (0)