Skip to content

Commit

Permalink
Merge pull request #42 from killbill/tax-rate-in-item-details
Browse files Browse the repository at this point in the history
Make taxRate part of item details for all tax items added to the invoice
  • Loading branch information
vlaskhilkevich authored Jul 26, 2024
2 parents 0a0faf6 + 89d86d3 commit f482962
Show file tree
Hide file tree
Showing 2 changed files with 139 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package org.killbill.billing.plugin.vertex;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
Expand All @@ -29,6 +30,7 @@
import java.util.Set;
import java.util.UUID;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import org.joda.time.LocalDate;
Expand All @@ -38,6 +40,8 @@
import org.killbill.billing.osgi.libs.killbill.OSGIKillbillAPI;
import org.killbill.billing.payment.api.PluginProperty;
import org.killbill.billing.plugin.api.PluginProperties;
import org.killbill.billing.plugin.api.invoice.PluginInvoiceItem;
import org.killbill.billing.plugin.api.invoice.PluginInvoiceItem.Builder;
import org.killbill.billing.plugin.api.invoice.PluginTaxCalculator;
import org.killbill.billing.plugin.vertex.dao.VertexDao;
import org.killbill.billing.plugin.vertex.gen.ApiException;
Expand All @@ -64,10 +68,13 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Multimap;

public class VertexTaxCalculator extends PluginTaxCalculator {
Expand Down Expand Up @@ -99,6 +106,7 @@ public class VertexTaxCalculator extends PluginTaxCalculator {
private final VertexApiConfigurationHandler vertexApiConfigurationHandler;
private final VertexDao dao;
private final Clock clock;
private final ObjectMapper objectMapper;

public VertexTaxCalculator(final VertexApiConfigurationHandler vertexApiConfigurationHandler,
final VertexDao dao,
Expand All @@ -108,6 +116,7 @@ public VertexTaxCalculator(final VertexApiConfigurationHandler vertexApiConfigur
this.vertexApiConfigurationHandler = vertexApiConfigurationHandler;
this.clock = clock;
this.dao = dao;
this.objectMapper = new ObjectMapper();
}

public List<InvoiceItem> compute(final Account account,
Expand Down Expand Up @@ -296,7 +305,7 @@ private Collection<InvoiceItem> toInvoiceItems(final UUID invoiceId,
for (final TaxesType transactionLineDetailModel : transactionLineModel.getTaxes()) {
final String description = getTaxDescription(transactionLineDetailModel);
final BigDecimal calculatedTax = transactionLineDetailModel.getCalculatedTax() != null ? BigDecimal.valueOf(transactionLineDetailModel.getCalculatedTax()) : null;
final InvoiceItem taxItem = buildTaxItem(taxableItem, invoiceId, adjustmentItem, calculatedTax, description);
final InvoiceItem taxItem = createTaxInvoiceItem(taxableItem, invoiceId, adjustmentItem, calculatedTax, description, transactionLineDetailModel.getEffectiveRate());
if (taxItem != null) {
invoiceItems.add(taxItem);
}
Expand All @@ -305,6 +314,62 @@ private Collection<InvoiceItem> toInvoiceItems(final UUID invoiceId,
}
}

private InvoiceItem createTaxInvoiceItem(final InvoiceItem taxableItem, final UUID invoiceId, @Nullable final InvoiceItem adjustmentItem, final BigDecimal calculatedTax, @Nullable final String description, @Nullable Double taxRate) {
final InvoiceItem taxItem = buildTaxItem(taxableItem, invoiceId, adjustmentItem, calculatedTax, description);
if (taxItem == null) {
return null;
}

if (taxRate == null) {
logger.warn("The tax rate is not provided in the Vertex response for the tax item with ID: {} and calculated tax: {}", taxItem.getId(), calculatedTax);
taxRate = calculatedTax.divide(taxableItem.getAmount(), 5, RoundingMode.FLOOR).doubleValue();
}

final String taxItemDetails = createTaxItemDetails(ImmutableMap.of("taxRate", taxRate));
if (taxItemDetails == null) {
return taxItem;
}

return new PluginInvoiceItem(new Builder<>()
.withId(taxItem.getId())
.withInvoiceItemType(taxItem.getInvoiceItemType())
.withInvoiceId(taxItem.getInvoiceId())
.withAccountId(taxItem.getAccountId())
.withChildAccountId(taxItem.getChildAccountId())
.withStartDate(taxItem.getStartDate())
.withEndDate(taxItem.getEndDate())
.withAmount(taxItem.getAmount())
.withCurrency(taxItem.getCurrency())
.withDescription(taxItem.getDescription())
.withSubscriptionId(taxItem.getSubscriptionId())
.withBundleId(taxItem.getBundleId())
.withCatalogEffectiveDate(taxItem.getCatalogEffectiveDate())
.withProductName(taxItem.getProductName())
.withPrettyProductName(taxItem.getPrettyProductName())
.withPlanName(taxItem.getPlanName())
.withPrettyPlanName(taxItem.getPrettyPlanName())
.withPhaseName(taxItem.getPhaseName())
.withPrettyPhaseName(taxItem.getPrettyPhaseName())
.withRate(taxItem.getRate())
.withLinkedItemId(taxItem.getLinkedItemId())
.withUsageName(taxItem.getUsageName())
.withPrettyUsageName(taxItem.getPrettyUsageName())
.withQuantity(taxItem.getQuantity())
.withItemDetails(taxItemDetails)
.withCreatedDate(taxItem.getCreatedDate())
.withUpdatedDate(taxItem.getUpdatedDate())
.validate().build());
}

private String createTaxItemDetails(@Nonnull final Map<String, Object> taxItemDetails) {
try {
return objectMapper.writeValueAsString(taxItemDetails);
} catch (JsonProcessingException exception) {
logger.error("Couldn't serialize the tax item details: {}", taxItemDetails, exception);
return null;
}
}

private String getTaxDescription(final TaxesType transactionLineDetailModel) {
final Jurisdiction jurisdiction = transactionLineDetailModel.getJurisdiction();
return jurisdiction != null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,21 +92,32 @@ public class VertexTaxCalculatorTest {
private InvoiceItem taxableInvoiceItem;
@Mock
private InvoiceItem adjustment;
@Mock
private TaxesType taxesType;

@InjectMocks
private VertexTaxCalculator vertexTaxCalculator;

@BeforeClass(groups = "fast")
public void setUp() throws Exception {
public void beforeClass() {
MockitoAnnotations.openMocks(this);
}

@BeforeMethod(groups = "fast")
public void beforeMethod() throws Exception {

Mockito.clearInvocations(vertexDao);
Mockito.clearInvocations(vertexApiClient);

given(vertexApiConfigurationHandler.getConfigurable(any(UUID.class))).willReturn(vertexApiClient);
given(tenantContext.getTenantId()).willReturn(UUID.randomUUID());
given(invoice.getId()).willReturn(INVOICE_ID);
given(invoice.getInvoiceDate()).willReturn(INVOICE_DATE);
given(invoice.getCurrency()).willReturn(Currency.USD);
given(account.getExternalKey()).willReturn("externalKey");
given(invoice.getInvoiceItems()).willReturn(Collections.singletonList(taxableInvoiceItem));
given(clock.getUTCNow()).willReturn(new DateTime(DateTimeZone.UTC));

given(account.getId()).willReturn(UUID.randomUUID());

given(taxableInvoiceItem.getAmount()).willReturn(new BigDecimal(1));
Expand All @@ -125,10 +136,15 @@ public void setUp() throws Exception {
given(adjustment.getEndDate()).willReturn(INVOICE_DATE.plusMonths(1));

given(vertexDao.getSuccessfulResponses(any(UUID.class), any(UUID.class))).willReturn(Collections.emptyList());
given(vertexApiClient.calculateTaxes(any(SaleRequestType.class))).willReturn(taxResponse);

given(responseLineItem.getTaxes()).willReturn(Collections.singletonList(taxesType));
given(taxesType.getCalculatedTax()).willReturn(MOCK_TAX_AMOUNT_1_01);

given(responseLineItem.getLineItemId()).willReturn(TAX_ITEM_ID.toString());
given(responseLineItem.getTotalTax()).willReturn(MOCK_TAX_AMOUNT_1_01);

given(vertexApiClient.calculateTaxes(any(SaleRequestType.class))).willReturn(taxResponse);
given(taxResponse.getData()).willReturn(apiResponseData);
given(apiResponseData.getLineItems()).willReturn(Collections.singletonList(responseLineItem));
}

Expand All @@ -155,9 +171,6 @@ public void testComputeReturnsEmptyIfNoDataInApiResponse() throws Exception {
@Test(groups = "fast")
public void testCompute() throws Exception {
//given
given(taxResponse.getData()).willReturn(apiResponseData);
given(invoice.getInvoiceItems()).willReturn(Collections.singletonList(taxableInvoiceItem));

final boolean isDryRun = false;

//when
Expand All @@ -181,8 +194,6 @@ public void testCompute() throws Exception {
@Test(groups = "fast")
public void testComputeDryRun() throws Exception {
//given
given(taxResponse.getData()).willReturn(apiResponseData);
given(invoice.getInvoiceItems()).willReturn(Collections.singletonList(taxableInvoiceItem));
final boolean isDryRun = true;

//when
Expand All @@ -198,8 +209,6 @@ public void testComputeDryRun() throws Exception {
public void testTaxDescription() throws Exception {
//given
final boolean isDryRun = false;
given(invoice.getInvoiceItems()).willReturn(Collections.singletonList(taxableInvoiceItem));
given(taxResponse.getData()).willReturn(apiResponseData);
given(responseLineItem.getTaxes()).willReturn(null);

String expectedTaxDescription = "Tax"; //The case when taxes retrieved from total tax field (taxes object is missing in line item)
Expand Down Expand Up @@ -231,11 +240,65 @@ public void testTaxDescription() throws Exception {
assertEquals("CA STATE TAX", result.get(0).getDescription());
}

@Test(groups = "fast")
public void testTaxEffectiveRate() throws Exception {
//given
given(taxesType.getEffectiveRate()).willReturn(0.09975d);

//then it persisted in item details invoice item field
List<InvoiceItem> result = vertexTaxCalculator.compute(account, invoice, true, Collections.emptyList(), tenantContext);
assertEquals("{\"taxRate\":0.09975}", result.get(0).getItemDetails());
checkTaxItemFields(result.get(0));
}

@Test(groups = "fast")
public void testComputeWhenTaxEffectiveRateIsNotPresentedByVertex() throws Exception {
//given
final BigDecimal taxableAmount = BigDecimal.valueOf(0.97d);
given(taxableInvoiceItem.getAmount()).willReturn(taxableAmount);

final BigDecimal expectedTaxRate = BigDecimal.valueOf(0.09975);
final TaxesType taxesType = new TaxesType();
taxesType.setCalculatedTax(taxableAmount.multiply(expectedTaxRate).doubleValue());
given(responseLineItem.getTaxes()).willReturn(Collections.singletonList(taxesType));

taxesType.setEffectiveRate(null);

//when tax rate is calculated and provided in item details in the result
List<InvoiceItem> result = vertexTaxCalculator.compute(account, invoice, true, Collections.emptyList(), tenantContext);

//then
assertEquals(1, result.size());
assertEquals("{\"taxRate\":" + expectedTaxRate + "}", result.get(0).getItemDetails());
checkTaxItemFields(result.get(0));
}

@Test(groups = "fast")
public void testComputeWhenEffectiveTaxRateZero() throws Exception {
//given
given(taxesType.getEffectiveRate()).willReturn(0d);

//when calculated tax amount is positive tax item with tax rate is added to the result
List<InvoiceItem> result = vertexTaxCalculator.compute(account, invoice, true, Collections.emptyList(), tenantContext);

//then
assertEquals(1, result.size());
assertEquals("{\"taxRate\":0.0}", result.get(0).getItemDetails());
checkTaxItemFields(result.get(0));
}

private void checkTaxItemFields(final InvoiceItem taxItem) {
assertEquals(InvoiceItemType.TAX, taxItem.getInvoiceItemType());
assertEquals("Tax", taxItem.getDescription());
assertEquals(INVOICE_ID, taxItem.getInvoiceId());
assertEquals(INVOICE_DATE, taxItem.getStartDate());
assertEquals(INVOICE_DATE.plusMonths(1), taxItem.getEndDate());
}

@Test(groups = "fast", expectedExceptions = {IllegalStateException.class})
public void testComputeWithAnomalousAdjustmentsException() throws Exception {
//given
given(invoice.getInvoiceItems()).willReturn(Arrays.asList(taxableInvoiceItem, adjustment));
given(taxResponse.getData()).willReturn(apiResponseData);
final boolean isDryRun = false;

//IllegalStateException is thrown when previous invoice id is missing for adjustments and skipAnomalousAdjustments property is not set
Expand All @@ -246,7 +309,6 @@ public void testComputeWithAnomalousAdjustmentsException() throws Exception {
public void testComputeWithAnomalousAdjustmentsSkipIfPropertyTrue() throws Exception {
//given
given(invoice.getInvoiceItems()).willReturn(Arrays.asList(taxableInvoiceItem, adjustment));
given(taxResponse.getData()).willReturn(apiResponseData);
final boolean isDryRun = false;
given(vertexApiClient.shouldSkipAnomalousAdjustments()).willReturn(true); //vertex-plugin will skip adjustment items if previousInvoiceId is missing

Expand Down

0 comments on commit f482962

Please sign in to comment.