|
18 | 18 | package org.killbill.billing.plugin.helloworld; |
19 | 19 |
|
20 | 20 | import java.math.BigDecimal; |
21 | | -import java.util.HashSet; |
| 21 | +import java.math.RoundingMode; |
| 22 | +import java.util.Collection; |
| 23 | +import java.util.EnumSet; |
22 | 24 | import java.util.LinkedList; |
23 | 25 | import java.util.List; |
24 | 26 | import java.util.Set; |
|
39 | 41 | import org.killbill.billing.plugin.api.invoice.PluginInvoicePluginApi; |
40 | 42 | import org.killbill.billing.util.callcontext.TenantContext; |
41 | 43 | import org.killbill.clock.Clock; |
| 44 | +import org.slf4j.Logger; |
| 45 | +import org.slf4j.LoggerFactory; |
42 | 46 |
|
43 | 47 | class HelloWorldInvoicePluginApi extends PluginInvoicePluginApi implements OSGIKillbillEventHandler { |
44 | 48 |
|
| 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 | + |
45 | 65 | public HelloWorldInvoicePluginApi(final OSGIKillbillAPI killbillAPI, final OSGIConfigPropertiesService configProperties, |
46 | 66 | final Clock clock) { |
47 | 67 | super(killbillAPI, configProperties, clock); |
48 | 68 | } |
49 | 69 |
|
50 | 70 | /** |
51 | | - * Returns additional invoice items to be added to invoice |
| 71 | + * Applies a 25% loyalty discount to every 3rd one-time purchase. |
52 | 72 | * <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. |
56 | 77 | * |
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. |
64 | 84 | */ |
65 | 85 | @Override |
66 | 86 | public AdditionalItemsResult getAdditionalInvoiceItems(final Invoice newInvoice, final boolean dryRun, |
67 | 87 | final Iterable<PluginProperty> properties, final InvoiceContext invoiceContext) { |
68 | 88 |
|
69 | 89 | final UUID accountId = newInvoice.getAccountId(); |
70 | 90 | final Account account = getAccount(accountId, invoiceContext); |
71 | | - final Set<Invoice> allInvoices = getAllInvoicesOfAccount(account, newInvoice, invoiceContext); |
72 | 91 | final List<InvoiceItem> additionalItems = new LinkedList<InvoiceItem>(); |
73 | 92 |
|
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++; |
103 | 106 | } |
104 | 107 | } |
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); |
110 | 144 | } |
| 145 | + } |
111 | 146 |
|
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; |
115 | 175 | } |
116 | | - }; |
| 176 | + } |
| 177 | + return hasCharge; |
117 | 178 | } |
118 | 179 |
|
119 | 180 | /** |
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. |
127 | 182 | */ |
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()); |
133 | 185 | } |
134 | 186 |
|
135 | 187 | /** |
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. |
140 | 190 | */ |
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; |
147 | 196 | } |
148 | 197 | } |
149 | | - return adjustmentItemPresent; |
| 198 | + return false; |
150 | 199 | } |
151 | 200 |
|
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 | + }; |
154 | 216 | } |
155 | 217 |
|
156 | 218 | @Override |
157 | 219 | public void handleKillbillEvent(final ExtBusEvent killbillEvent) { |
158 | 220 | } |
159 | | - |
160 | 221 | } |
0 commit comments