killbill-memoizeit
Changes
beatrix/src/test/java/org/killbill/billing/beatrix/integration/BeatrixIntegrationModule.java 21(+20 -1)
beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestInvoiceSystemDisabling.java 113(+113 -0)
entitlement/src/main/java/org/killbill/billing/entitlement/api/svcs/DefaultEntitlementInternalApi.java 28(+27 -1)
invoice/src/test/java/org/killbill/billing/invoice/generator/TestDefaultInvoiceGenerator.java 62(+0 -62)
invoice/src/test/java/org/killbill/billing/invoice/generator/TestFixedAndRecurringInvoiceItemGenerator.java 338(+338 -0)
invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModuleWithEmbeddedDb.java 2(+2 -0)
subscription/src/main/java/org/killbill/billing/subscription/api/SubscriptionBaseApiService.java 5(+3 -2)
subscription/src/main/java/org/killbill/billing/subscription/api/svcs/DefaultSubscriptionInternalApi.java 10(+7 -3)
subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBase.java 45(+42 -3)
subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBaseApiService.java 31(+23 -8)
subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCancel.java 60(+51 -9)
Details
diff --git a/api/src/main/java/org/killbill/billing/subscription/api/SubscriptionBase.java b/api/src/main/java/org/killbill/billing/subscription/api/SubscriptionBase.java
index 5cda4c4..5d2e050 100644
--- a/api/src/main/java/org/killbill/billing/subscription/api/SubscriptionBase.java
+++ b/api/src/main/java/org/killbill/billing/subscription/api/SubscriptionBase.java
@@ -21,6 +21,7 @@ import java.util.UUID;
import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
import org.killbill.billing.catalog.api.BillingActionPolicy;
import org.killbill.billing.catalog.api.BillingPeriod;
import org.killbill.billing.catalog.api.Plan;
@@ -46,7 +47,7 @@ public interface SubscriptionBase extends Entity, Blockable {
public boolean cancelWithDate(final DateTime requestedDate, final CallContext context)
throws SubscriptionBaseApiException;
- public boolean cancelWithPolicy(final BillingActionPolicy policy, final CallContext context)
+ public boolean cancelWithPolicy(final BillingActionPolicy policy, final DateTimeZone accountTimeZone, int accountBillCycleDayLocal, final CallContext context)
throws SubscriptionBaseApiException;
public boolean uncancel(final CallContext context)
diff --git a/api/src/main/java/org/killbill/billing/subscription/api/SubscriptionBaseInternalApi.java b/api/src/main/java/org/killbill/billing/subscription/api/SubscriptionBaseInternalApi.java
index 617049b..9530310 100644
--- a/api/src/main/java/org/killbill/billing/subscription/api/SubscriptionBaseInternalApi.java
+++ b/api/src/main/java/org/killbill/billing/subscription/api/SubscriptionBaseInternalApi.java
@@ -50,7 +50,7 @@ public interface SubscriptionBaseInternalApi {
public List<SubscriptionBaseWithAddOns> createBaseSubscriptionsWithAddOns(UUID accountId, Iterable<BaseEntitlementWithAddOnsSpecifier> baseEntitlementWithAddOnsSpecifier,
InternalCallContext contextWithValidAccountRecordId) throws SubscriptionBaseApiException;
- public void cancelBaseSubscriptions(Iterable<SubscriptionBase> subscriptions, BillingActionPolicy policy, InternalCallContext context) throws SubscriptionBaseApiException;
+ public void cancelBaseSubscriptions(Iterable<SubscriptionBase> subscriptions, BillingActionPolicy policy, DateTimeZone accountTimeZone, int accountBillCycleDayLocal, InternalCallContext context) throws SubscriptionBaseApiException;
public SubscriptionBaseBundle createBundleForAccount(UUID accountId, String bundleName, InternalCallContext context)
throws SubscriptionBaseApiException;
diff --git a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/BeatrixIntegrationModule.java b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/BeatrixIntegrationModule.java
index 6c9b9b0..5b501ad 100644
--- a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/BeatrixIntegrationModule.java
+++ b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/BeatrixIntegrationModule.java
@@ -18,6 +18,8 @@
package org.killbill.billing.beatrix.integration;
+import javax.annotation.Nullable;
+
import org.killbill.billing.GuicyKillbillTestWithEmbeddedDBModule;
import org.killbill.billing.account.glue.DefaultAccountModule;
import org.killbill.billing.api.TestApiListener;
@@ -42,6 +44,7 @@ import org.killbill.billing.platform.api.KillbillConfigSource;
import org.killbill.billing.subscription.glue.DefaultSubscriptionModule;
import org.killbill.billing.tenant.glue.DefaultTenantModule;
import org.killbill.billing.usage.glue.UsageModule;
+import org.killbill.billing.util.config.definition.InvoiceConfig;
import org.killbill.billing.util.config.definition.PaymentConfig;
import org.killbill.billing.util.email.EmailModule;
import org.killbill.billing.util.email.templates.TemplateModule;
@@ -68,8 +71,15 @@ public class BeatrixIntegrationModule extends KillBillModule {
// Same name the osgi-payment-test plugin uses to register its service
public static final String OSGI_PLUGIN_NAME = "osgi-payment-plugin";
+ private final InvoiceConfig invoiceConfig;
+
public BeatrixIntegrationModule(final KillbillConfigSource configSource) {
+ this(configSource, null);
+ }
+
+ public BeatrixIntegrationModule(final KillbillConfigSource configSource, @Nullable final InvoiceConfig invoiceConfig) {
super(configSource);
+ this.invoiceConfig = invoiceConfig;
}
@Override
@@ -113,7 +123,7 @@ public class BeatrixIntegrationModule extends KillBillModule {
bind(TestApiListener.class).asEagerSingleton();
}
- private static final class DefaultInvoiceModuleWithSwitchRepairLogic extends DefaultInvoiceModule {
+ private final class DefaultInvoiceModuleWithSwitchRepairLogic extends DefaultInvoiceModule {
private DefaultInvoiceModuleWithSwitchRepairLogic(final KillbillConfigSource configSource) {
super(configSource);
@@ -122,6 +132,15 @@ public class BeatrixIntegrationModule extends KillBillModule {
protected void installInvoiceGenerator() {
bind(InvoiceGenerator.class).to(DefaultInvoiceGenerator.class).asEagerSingleton();
}
+
+ @Override
+ protected void installConfig() {
+ if (invoiceConfig != null) {
+ super.installConfig(invoiceConfig);
+ } else {
+ super.installConfig();
+ }
+ }
}
private static final class PaymentPluginMockModule extends PaymentModule {
diff --git a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationBase.java b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationBase.java
index 7697a94..5ed3690 100644
--- a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationBase.java
+++ b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationBase.java
@@ -48,6 +48,7 @@ import org.killbill.billing.beatrix.util.InvoiceChecker;
import org.killbill.billing.beatrix.util.PaymentChecker;
import org.killbill.billing.beatrix.util.RefundChecker;
import org.killbill.billing.beatrix.util.SubscriptionChecker;
+import org.killbill.billing.callcontext.InternalTenantContext;
import org.killbill.billing.catalog.api.BillingActionPolicy;
import org.killbill.billing.catalog.api.BillingPeriod;
import org.killbill.billing.catalog.api.Currency;
@@ -65,6 +66,7 @@ import org.killbill.billing.entitlement.api.EntitlementApi;
import org.killbill.billing.entitlement.api.EntitlementApiException;
import org.killbill.billing.entitlement.api.SubscriptionApi;
import org.killbill.billing.entitlement.api.SubscriptionEventType;
+import org.killbill.billing.invoice.ParkedAccountsManager;
import org.killbill.billing.invoice.api.DryRunArguments;
import org.killbill.billing.invoice.api.DryRunType;
import org.killbill.billing.invoice.api.Invoice;
@@ -111,11 +113,14 @@ import org.killbill.billing.util.api.TagApiException;
import org.killbill.billing.util.api.TagDefinitionApiException;
import org.killbill.billing.util.api.TagUserApi;
import org.killbill.billing.util.cache.CacheControllerDispatcher;
+import org.killbill.billing.util.config.definition.InvoiceConfig;
import org.killbill.billing.util.dao.NonEntityDao;
import org.killbill.billing.util.nodes.KillbillNodesApi;
import org.killbill.billing.util.tag.ControlTagType;
import org.killbill.billing.util.tag.Tag;
import org.killbill.bus.api.PersistentBus;
+import org.skife.config.ConfigurationObjectFactory;
+import org.skife.config.TimeSpan;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testng.Assert;
@@ -283,13 +288,20 @@ public class TestIntegrationBase extends BeatrixTestSuiteWithEmbeddedDB {
@Inject
protected CacheControllerDispatcher controllerDispatcher;
+ @Inject
+ protected ParkedAccountsManager parkedAccountsManager;
+
+ protected ConfigurableInvoiceConfig invoiceConfig;
+
protected void assertListenerStatus() {
busHandler.assertListenerStatus();
}
@BeforeClass(groups = "slow")
public void beforeClass() throws Exception {
- final Injector g = Guice.createInjector(Stage.PRODUCTION, new BeatrixIntegrationModule(configSource));
+ final InvoiceConfig defaultInvoiceConfig = new ConfigurationObjectFactory(skifeConfigSource).build(InvoiceConfig.class);
+ invoiceConfig = new ConfigurableInvoiceConfig(defaultInvoiceConfig);
+ final Injector g = Guice.createInjector(Stage.PRODUCTION, new BeatrixIntegrationModule(configSource, invoiceConfig));
g.injectMembers(this);
}
@@ -902,4 +914,90 @@ public class TestIntegrationBase extends BeatrixTestSuiteWithEmbeddedDB {
return res;
}
}
+
+ static class ConfigurableInvoiceConfig implements InvoiceConfig {
+
+ private final InvoiceConfig defaultInvoiceConfig;
+
+ private boolean isInvoicingSystemEnabled;
+
+ public ConfigurableInvoiceConfig(final InvoiceConfig defaultInvoiceConfig) {
+ this.defaultInvoiceConfig = defaultInvoiceConfig;
+ isInvoicingSystemEnabled = defaultInvoiceConfig.isInvoicingSystemEnabled();
+ }
+
+ @Override
+ public int getNumberOfMonthsInFuture() {
+ return defaultInvoiceConfig.getNumberOfMonthsInFuture();
+ }
+
+ @Override
+ public int getNumberOfMonthsInFuture(final InternalTenantContext tenantContext) {
+ return defaultInvoiceConfig.getNumberOfMonthsInFuture();
+ }
+
+ @Override
+ public boolean isSanitySafetyBoundEnabled() {
+ return defaultInvoiceConfig.isSanitySafetyBoundEnabled();
+ }
+
+ @Override
+ public boolean isSanitySafetyBoundEnabled(final InternalTenantContext tenantContext) {
+ return defaultInvoiceConfig.isSanitySafetyBoundEnabled();
+ }
+
+ @Override
+ public int getMaxDailyNumberOfItemsSafetyBound() {
+ return defaultInvoiceConfig.getMaxDailyNumberOfItemsSafetyBound();
+ }
+
+ @Override
+ public int getMaxDailyNumberOfItemsSafetyBound(final InternalTenantContext tenantContext) {
+ return defaultInvoiceConfig.getMaxDailyNumberOfItemsSafetyBound();
+ }
+
+ @Override
+ public TimeSpan getDryRunNotificationSchedule() {
+ return defaultInvoiceConfig.getDryRunNotificationSchedule();
+ }
+
+ @Override
+ public TimeSpan getDryRunNotificationSchedule(final InternalTenantContext tenantContext) {
+ return defaultInvoiceConfig.getDryRunNotificationSchedule();
+ }
+
+ @Override
+ public int getMaxRawUsagePreviousPeriod() {
+ return defaultInvoiceConfig.getMaxRawUsagePreviousPeriod();
+ }
+
+ @Override
+ public int getMaxRawUsagePreviousPeriod(final InternalTenantContext tenantContext) {
+ return defaultInvoiceConfig.getMaxRawUsagePreviousPeriod();
+ }
+
+ @Override
+ public int getMaxGlobalLockRetries() {
+ return defaultInvoiceConfig.getMaxGlobalLockRetries();
+ }
+
+ @Override
+ public boolean isEmailNotificationsEnabled() {
+ return defaultInvoiceConfig.isEmailNotificationsEnabled();
+ }
+
+ @Override
+ public boolean isInvoicingSystemEnabled() {
+ return isInvoicingSystemEnabled;
+ }
+
+ @Override
+ public boolean isInvoicingSystemEnabled(final InternalTenantContext tenantContext) {
+ return isInvoicingSystemEnabled();
+ }
+
+ public void setInvoicingSystemEnabled(final boolean invoicingSystemEnabled) {
+ isInvoicingSystemEnabled = invoicingSystemEnabled;
+ }
+ }
}
diff --git a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestInvoiceSystemDisabling.java b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestInvoiceSystemDisabling.java
new file mode 100644
index 0000000..cc57eae
--- /dev/null
+++ b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestInvoiceSystemDisabling.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2014-2016 Groupon, Inc
+ * Copyright 2014-2016 The Billing Project, LLC
+ *
+ * The Billing Project licenses this file to you under the Apache License, version 2.0
+ * (the "License"); you may not use this file except in compliance with the
+ * License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.killbill.billing.beatrix.integration;
+
+import java.math.BigDecimal;
+import java.util.Collection;
+
+import org.joda.time.LocalDate;
+import org.killbill.billing.account.api.Account;
+import org.killbill.billing.account.api.AccountData;
+import org.killbill.billing.api.TestApiListener.NextEvent;
+import org.killbill.billing.beatrix.util.InvoiceChecker.ExpectedInvoiceItemCheck;
+import org.killbill.billing.catalog.api.BillingPeriod;
+import org.killbill.billing.catalog.api.ProductCategory;
+import org.killbill.billing.entitlement.api.DefaultEntitlement;
+import org.killbill.billing.invoice.api.DryRunType;
+import org.killbill.billing.invoice.api.Invoice;
+import org.killbill.billing.invoice.api.InvoiceItemType;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.google.common.collect.ImmutableList;
+
+import static org.testng.Assert.assertEquals;
+
+public class TestInvoiceSystemDisabling extends TestIntegrationBase {
+
+ @Test(groups = "slow")
+ public void testInvoiceSystemDisablingBasic() throws Exception {
+ // We take april as it has 30 days (easier to play with BCD)
+ // Set clock to the initial start date - we implicitly assume here that the account timezone is UTC
+ clock.setDay(new LocalDate(2012, 4, 1));
+
+ final AccountData accountData = getAccountData(1);
+ final Account account = createAccountWithNonOsgiPaymentMethod(accountData);
+ accountChecker.checkAccount(account.getId(), accountData, callContext);
+
+ Assert.assertFalse(parkedAccountsManager.isParked(internalCallContext));
+
+ // Stop invoicing system
+ invoiceConfig.setInvoicingSystemEnabled(false);
+
+ final DefaultEntitlement bpSubscription = createBaseEntitlementAndCheckForCompletion(account.getId(),
+ "bundleKey",
+ "Shotgun",
+ ProductCategory.BASE,
+ BillingPeriod.MONTHLY,
+ NextEvent.CREATE,
+ NextEvent.BLOCK,
+ NextEvent.TAG);
+
+ Assert.assertTrue(parkedAccountsManager.isParked(internalCallContext));
+ Collection<Invoice> invoices = invoiceUserApi.getInvoicesByAccount(account.getId(), false, callContext);
+ assertEquals(invoices.size(), 0);
+
+ // Move to end of trial => 2012, 5, 1
+ addDaysAndCheckForCompletion(30, NextEvent.PHASE);
+
+ Assert.assertTrue(parkedAccountsManager.isParked(internalCallContext));
+ invoices = invoiceUserApi.getInvoicesByAccount(account.getId(), false, callContext);
+ assertEquals(invoices.size(), 0);
+
+ // Dry-run generation
+ Invoice invoice = invoiceUserApi.triggerInvoiceGeneration(account.getId(), clock.getUTCToday(), new TestDryRunArguments(DryRunType.TARGET_DATE), callContext);
+ assertListenerStatus();
+ final ImmutableList<ExpectedInvoiceItemCheck> expected = ImmutableList.<ExpectedInvoiceItemCheck>of(new ExpectedInvoiceItemCheck(new LocalDate(2012, 4, 1), null, InvoiceItemType.FIXED, BigDecimal.ZERO),
+ new ExpectedInvoiceItemCheck(new LocalDate(2012, 5, 1), new LocalDate(2012, 6, 1), InvoiceItemType.RECURRING, new BigDecimal("249.95")));
+ invoiceChecker.checkInvoiceNoAudits(invoice, callContext, expected);
+
+ // Still parked
+ Assert.assertTrue(parkedAccountsManager.isParked(internalCallContext));
+ invoices = invoiceUserApi.getInvoicesByAccount(account.getId(), false, callContext);
+ assertEquals(invoices.size(), 0);
+
+ // Non dry-run generation
+ busHandler.pushExpectedEvents(NextEvent.TAG, NextEvent.INVOICE, NextEvent.PAYMENT, NextEvent.INVOICE_PAYMENT);
+ invoice = invoiceUserApi.triggerInvoiceGeneration(account.getId(), clock.getUTCToday(), null, callContext);
+ assertListenerStatus();
+
+ // Now unparked
+ Assert.assertFalse(parkedAccountsManager.isParked(internalCallContext));
+ invoiceChecker.checkInvoice(invoice, callContext, expected);
+ invoices = invoiceUserApi.getInvoicesByAccount(account.getId(), false, callContext);
+ assertEquals(invoices.size(), 1);
+ invoiceChecker.checkInvoice(account.getId(), 1, callContext, expected);
+
+ // Restart invoicing system and verify next notification
+ invoiceConfig.setInvoicingSystemEnabled(true);
+ addDaysAndCheckForCompletion(31, NextEvent.INVOICE, NextEvent.PAYMENT, NextEvent.INVOICE_PAYMENT);
+
+ invoices = invoiceUserApi.getInvoicesByAccount(account.getId(), false, callContext);
+ assertEquals(invoices.size(), 2);
+ invoiceChecker.checkInvoice(account.getId(),
+ 2,
+ callContext,
+ new ExpectedInvoiceItemCheck(new LocalDate(2012, 6, 1), new LocalDate(2012, 7, 1), InvoiceItemType.RECURRING, new BigDecimal("249.95")));
+ }
+}
diff --git a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestSubscription.java b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestSubscription.java
index 8b5ca40..54788f8 100644
--- a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestSubscription.java
+++ b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestSubscription.java
@@ -42,6 +42,7 @@ import org.killbill.billing.entitlement.api.Entitlement.EntitlementActionPolicy;
import org.killbill.billing.entitlement.api.Entitlement.EntitlementState;
import org.killbill.billing.entitlement.api.EntitlementApiException;
import org.killbill.billing.entitlement.api.EntitlementSpecifier;
+import org.killbill.billing.entitlement.api.Subscription;
import org.killbill.billing.entitlement.api.SubscriptionEventType;
import org.killbill.billing.invoice.api.DryRunType;
import org.killbill.billing.invoice.api.Invoice;
@@ -479,4 +480,74 @@ public class TestSubscription extends TestIntegrationBase {
clock.addMonths(1);
assertListenerStatus();
}
+
+
+
+ @Test(groups = "slow")
+ public void testCancelSubscriptionInTrialWith_START_OF_TERM() throws Exception {
+ final LocalDate initialDate = new LocalDate(2015, 9, 1);
+ clock.setDay(initialDate);
+
+ final Account account = createAccountWithNonOsgiPaymentMethod(getAccountData(1));
+
+ final PlanPhaseSpecifier spec = new PlanPhaseSpecifier("Shotgun", BillingPeriod.ANNUAL, PriceListSet.DEFAULT_PRICELIST_NAME, null);
+
+ busHandler.pushExpectedEvents(NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE);
+ final Entitlement createdEntitlement = entitlementApi.createBaseEntitlement(account.getId(), spec, account.getExternalKey(), null, initialDate, initialDate, false, ImmutableList.<PluginProperty>of(), callContext);
+ assertEquals(createdEntitlement.getEffectiveStartDate().compareTo(initialDate), 0);
+ assertEquals(createdEntitlement.getEffectiveEndDate(), null);
+ assertListenerStatus();
+
+ // Move clock a bit to make sure START_OF_TERM brings us back to initialDate
+ clock.addDays(5);
+
+ busHandler.pushExpectedEvents(NextEvent.CANCEL, NextEvent.BLOCK, NextEvent.NULL_INVOICE);
+ final Entitlement cancelledEntitlement = createdEntitlement.cancelEntitlementWithPolicyOverrideBillingPolicy(EntitlementActionPolicy.IMMEDIATE, BillingActionPolicy.START_OF_TERM, null, callContext);
+ assertListenerStatus();
+
+ final Subscription subscription = subscriptionApi.getSubscriptionForEntitlementId(cancelledEntitlement.getId(), callContext);
+
+ assertEquals(subscription.getEffectiveEndDate().compareTo(new LocalDate(2015, 9, 6)), 0);
+ assertEquals(subscription.getBillingEndDate().compareTo(initialDate), 0);
+
+ }
+
+ @Test(groups = "slow")
+ public void testCancelSubscriptionAfterTrialWith_START_OF_TERM() throws Exception {
+ final LocalDate initialDate = new LocalDate(2015, 8, 1);
+ clock.setDay(initialDate);
+
+ Account account = createAccountWithNonOsgiPaymentMethod(getAccountData(0));
+
+ final PlanPhaseSpecifier spec = new PlanPhaseSpecifier("Shotgun", BillingPeriod.ANNUAL, PriceListSet.DEFAULT_PRICELIST_NAME, null);
+
+ busHandler.pushExpectedEvents(NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE);
+ final Entitlement createdEntitlement = entitlementApi.createBaseEntitlement(account.getId(), spec, account.getExternalKey(), null, initialDate, initialDate, false, ImmutableList.<PluginProperty>of(), callContext);
+ assertEquals(createdEntitlement.getEffectiveStartDate().compareTo(initialDate), 0);
+ assertEquals(createdEntitlement.getEffectiveEndDate(), null);
+ assertListenerStatus();
+
+ // Move out of trial : 2015-8-31
+ busHandler.pushExpectedEvents(NextEvent.PHASE, NextEvent.INVOICE, NextEvent.PAYMENT, NextEvent.INVOICE_PAYMENT);
+ clock.addDays(30);
+ assertListenerStatus();
+
+ account = accountUserApi.getAccountById(account.getId(), callContext);
+ Assert.assertEquals(account.getBillCycleDayLocal().intValue(), 31);
+
+
+ // Move clock a bit to make sure START_OF_TERM brings us back to last Phase date : 2015-9-5
+ clock.addDays(5);
+
+ busHandler.pushExpectedEvents(NextEvent.CANCEL, NextEvent.BLOCK, NextEvent.INVOICE);
+ final Entitlement cancelledEntitlement = createdEntitlement.cancelEntitlementWithPolicyOverrideBillingPolicy(EntitlementActionPolicy.IMMEDIATE, BillingActionPolicy.START_OF_TERM, null, callContext);
+ assertListenerStatus();
+
+ final Subscription subscription = subscriptionApi.getSubscriptionForEntitlementId(cancelledEntitlement.getId(), callContext);
+
+ assertEquals(subscription.getEffectiveEndDate().compareTo(new LocalDate(2015, 9, 5)), 0);
+ assertEquals(subscription.getBillingEndDate().compareTo(new LocalDate(2015, 8, 31)), 0);
+
+ }
+
}
diff --git a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestWithBCDUpdate.java b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestWithBCDUpdate.java
index fcc3d52..8ba0800 100644
--- a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestWithBCDUpdate.java
+++ b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestWithBCDUpdate.java
@@ -28,11 +28,15 @@ import org.joda.time.LocalDate;
import org.killbill.billing.account.api.Account;
import org.killbill.billing.api.TestApiListener.NextEvent;
import org.killbill.billing.beatrix.util.InvoiceChecker.ExpectedInvoiceItemCheck;
+import org.killbill.billing.catalog.api.BillingActionPolicy;
import org.killbill.billing.catalog.api.BillingPeriod;
import org.killbill.billing.catalog.api.ProductCategory;
import org.killbill.billing.entitlement.api.BlockingState;
import org.killbill.billing.entitlement.api.BlockingStateType;
import org.killbill.billing.entitlement.api.DefaultEntitlement;
+import org.killbill.billing.entitlement.api.Entitlement;
+import org.killbill.billing.entitlement.api.Entitlement.EntitlementActionPolicy;
+import org.killbill.billing.entitlement.api.Subscription;
import org.killbill.billing.invoice.api.Invoice;
import org.killbill.billing.invoice.api.InvoiceItemType;
import org.killbill.billing.junction.DefaultBlockingState;
@@ -42,6 +46,7 @@ import org.testng.annotations.Test;
import com.google.common.collect.ImmutableList;
+import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotNull;
public class TestWithBCDUpdate extends TestIntegrationBase {
@@ -99,6 +104,17 @@ public class TestWithBCDUpdate extends TestIntegrationBase {
invoices = invoiceUserApi.getInvoicesByAccount(account.getId(), false, callContext);
invoiceChecker.checkInvoice(invoices.get(2).getId(), callContext, expectedInvoices);
expectedInvoices.clear();
+
+ // Add cancellation with START_OF_TERM to verify BCD update is correctly interpreted
+ clock.addDays(3);
+
+ busHandler.pushExpectedEvents(NextEvent.CANCEL, NextEvent.BLOCK, NextEvent.INVOICE);
+ final Entitlement cancelledEntitlement = baseEntitlement.cancelEntitlementWithPolicyOverrideBillingPolicy(EntitlementActionPolicy.IMMEDIATE, BillingActionPolicy.START_OF_TERM, null, callContext);
+ assertListenerStatus();
+
+ final Subscription subscription = subscriptionApi.getSubscriptionForEntitlementId(cancelledEntitlement.getId(), callContext);
+ assertEquals(subscription.getEffectiveEndDate().compareTo(new LocalDate(2016, 5, 18)), 0);
+ assertEquals(subscription.getBillingEndDate().compareTo(new LocalDate(2016, 5, 15)), 0);
}
@@ -169,6 +185,16 @@ public class TestWithBCDUpdate extends TestIntegrationBase {
expectedInvoices.add(new ExpectedInvoiceItemCheck(new LocalDate(2016, 7, 10), new LocalDate(2016, 7, 15), InvoiceItemType.REPAIR_ADJ, new BigDecimal("-41.66")));
invoiceChecker.checkInvoice(invoices.get(4).getId(), callContext, expectedInvoices);
expectedInvoices.clear();
+
+ clock.addDays(3);
+ busHandler.pushExpectedEvents(NextEvent.CANCEL, NextEvent.BLOCK, NextEvent.INVOICE);
+ final Entitlement cancelledEntitlement = baseEntitlement.cancelEntitlementWithPolicyOverrideBillingPolicy(EntitlementActionPolicy.IMMEDIATE, BillingActionPolicy.START_OF_TERM, null, callContext);
+ assertListenerStatus();
+
+ final Subscription subscription = subscriptionApi.getSubscriptionForEntitlementId(cancelledEntitlement.getId(), callContext);
+ assertEquals(subscription.getEffectiveEndDate().compareTo(new LocalDate(2016, 7, 13)), 0);
+ assertEquals(subscription.getBillingEndDate().compareTo(new LocalDate(2016, 7, 10)), 0);
+
}
diff --git a/catalog/src/main/java/org/killbill/billing/catalog/rules/DefaultCaseCancelPolicy.java b/catalog/src/main/java/org/killbill/billing/catalog/rules/DefaultCaseCancelPolicy.java
index 11a3399..2d21e94 100644
--- a/catalog/src/main/java/org/killbill/billing/catalog/rules/DefaultCaseCancelPolicy.java
+++ b/catalog/src/main/java/org/killbill/billing/catalog/rules/DefaultCaseCancelPolicy.java
@@ -18,9 +18,12 @@ package org.killbill.billing.catalog.rules;
import javax.xml.bind.annotation.XmlElement;
+import org.killbill.billing.catalog.StandaloneCatalog;
import org.killbill.billing.catalog.api.BillingActionPolicy;
import org.killbill.billing.catalog.api.PhaseType;
import org.killbill.billing.catalog.api.rules.CaseCancelPolicy;
+import org.killbill.xmlloader.ValidationError;
+import org.killbill.xmlloader.ValidationErrors;
public class DefaultCaseCancelPolicy extends DefaultCasePhase<BillingActionPolicy> implements CaseCancelPolicy {
@@ -38,6 +41,16 @@ public class DefaultCaseCancelPolicy extends DefaultCasePhase<BillingActionPolic
}
@Override
+ public ValidationErrors validate(final StandaloneCatalog catalog, final ValidationErrors errors) {
+ if (policy == BillingActionPolicy.START_OF_TERM) {
+ errors.add(new ValidationError("Default catalog START_OF_TERM has not been implemented, such policy can be used during cancellation by overriding policy",
+ catalog.getCatalogURI(), DefaultCaseCancelPolicy.class, ""));
+ }
+ return errors;
+ }
+
+
+ @Override
public BillingActionPolicy getBillingActionPolicy() {
return policy;
}
diff --git a/catalog/src/main/java/org/killbill/billing/catalog/rules/DefaultPlanRules.java b/catalog/src/main/java/org/killbill/billing/catalog/rules/DefaultPlanRules.java
index bc7f4ae..827aee9 100644
--- a/catalog/src/main/java/org/killbill/billing/catalog/rules/DefaultPlanRules.java
+++ b/catalog/src/main/java/org/killbill/billing/catalog/rules/DefaultPlanRules.java
@@ -192,6 +192,7 @@ public class DefaultPlanRules extends ValidatingConfig<StandaloneCatalog> implem
cur.getToPriceList() == null) {
foundDefaultCase = true;
}
+ cur.validate(catalog, errors);
}
if (!foundDefaultCase) {
errors.add(new ValidationError("Missing default rule case for plan change", catalog.getCatalogURI(), DefaultPlanRules.class, ""));
@@ -212,6 +213,7 @@ public class DefaultPlanRules extends ValidatingConfig<StandaloneCatalog> implem
cur.getPriceList() == null) {
foundDefaultCase = true;
}
+ cur.validate(catalog, errors);
}
if (!foundDefaultCase) {
errors.add(new ValidationError("Missing default rule case for plan cancellation", catalog.getCatalogURI(), DefaultPlanRules.class, ""));
@@ -225,6 +227,7 @@ public class DefaultPlanRules extends ValidatingConfig<StandaloneCatalog> implem
} else {
caseChangePlanAlignmentsSet.add(cur);
}
+ cur.validate(catalog, errors);
}
final HashSet<DefaultCaseCreateAlignment> caseCreateAlignmentsSet = new HashSet<DefaultCaseCreateAlignment>();
@@ -234,6 +237,7 @@ public class DefaultPlanRules extends ValidatingConfig<StandaloneCatalog> implem
} else {
caseCreateAlignmentsSet.add(cur);
}
+ cur.validate(catalog, errors);
}
final HashSet<DefaultCaseBillingAlignment> caseBillingAlignmentsSet = new HashSet<DefaultCaseBillingAlignment>();
@@ -243,6 +247,7 @@ public class DefaultPlanRules extends ValidatingConfig<StandaloneCatalog> implem
} else {
caseBillingAlignmentsSet.add(cur);
}
+ cur.validate(catalog, errors);
}
final HashSet<DefaultCasePriceList> casePriceListsSet = new HashSet<DefaultCasePriceList>();
@@ -252,6 +257,7 @@ public class DefaultPlanRules extends ValidatingConfig<StandaloneCatalog> implem
} else {
casePriceListsSet.add(cur);
}
+ cur.validate(catalog, errors);
}
return errors;
}
diff --git a/entitlement/src/main/java/org/killbill/billing/entitlement/api/DefaultEntitlement.java b/entitlement/src/main/java/org/killbill/billing/entitlement/api/DefaultEntitlement.java
index 2cf1acc..9680a76 100644
--- a/entitlement/src/main/java/org/killbill/billing/entitlement/api/DefaultEntitlement.java
+++ b/entitlement/src/main/java/org/killbill/billing/entitlement/api/DefaultEntitlement.java
@@ -500,7 +500,7 @@ public class DefaultEntitlement extends EntityBase implements Entitlement {
try {
// Cancel subscription base first, to correctly compute the add-ons entitlements we need to cancel (see below)
- getSubscriptionBase().cancelWithPolicy(billingPolicy, callContext);
+ getSubscriptionBase().cancelWithPolicy(billingPolicy, eventsStream.getAccountTimeZone(), eventsStream.getDefaultBillCycleDayLocal(), callContext);
} catch (final SubscriptionBaseApiException e) {
throw new EntitlementApiException(e);
}
@@ -523,6 +523,7 @@ public class DefaultEntitlement extends EntityBase implements Entitlement {
}
private LocalDate getLocalDateFromEntitlementPolicy(final EntitlementActionPolicy entitlementPolicy) {
+
final LocalDate cancellationDate;
switch (entitlementPolicy) {
case IMMEDIATE:
@@ -541,6 +542,7 @@ public class DefaultEntitlement extends EntityBase implements Entitlement {
return (cancellationDate.compareTo(getEffectiveStartDate()) < 0) ? getEffectiveStartDate() : cancellationDate;
}
+
@Override
public Entitlement changePlan(final PlanSpecifier spec, final List<PlanPhasePriceOverride> overrides, final Iterable<PluginProperty> properties, final CallContext callContext) throws EntitlementApiException {
diff --git a/entitlement/src/main/java/org/killbill/billing/entitlement/api/svcs/DefaultEntitlementInternalApi.java b/entitlement/src/main/java/org/killbill/billing/entitlement/api/svcs/DefaultEntitlementInternalApi.java
index 2f7ad68..982b932 100644
--- a/entitlement/src/main/java/org/killbill/billing/entitlement/api/svcs/DefaultEntitlementInternalApi.java
+++ b/entitlement/src/main/java/org/killbill/billing/entitlement/api/svcs/DefaultEntitlementInternalApi.java
@@ -32,7 +32,9 @@ import javax.annotation.Nullable;
import javax.inject.Inject;
import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
import org.joda.time.LocalDate;
+import org.killbill.billing.account.api.AccountApiException;
import org.killbill.billing.account.api.AccountInternalApi;
import org.killbill.billing.callcontext.InternalCallContext;
import org.killbill.billing.callcontext.InternalTenantContext;
@@ -75,6 +77,7 @@ import org.killbill.notificationq.api.NotificationQueueService;
import org.killbill.notificationq.api.NotificationQueueService.NoSuchNotificationQueue;
import com.google.common.base.Optional;
+import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
public class DefaultEntitlementInternalApi extends DefaultEntitlementApiBase implements EntitlementInternalApi {
@@ -95,6 +98,21 @@ public class DefaultEntitlementInternalApi extends DefaultEntitlementApiBase imp
@Override
public void cancel(final Iterable<Entitlement> entitlements, @Nullable final LocalDate effectiveDate, final BillingActionPolicy billingPolicy, final Iterable<PluginProperty> properties, final InternalCallContext internalCallContext) throws EntitlementApiException {
+
+ if (!entitlements.iterator().hasNext()) {
+ return;
+ }
+
+ int bcd = 0;
+ DateTimeZone accountTimeZone = null;
+ try {
+ bcd = accountApi.getBCD(entitlements.iterator().next().getAccountId(), internalCallContext);
+ accountTimeZone = accountApi.getImmutableAccountDataByRecordId(internalCallContext. getAccountRecordId(), internalCallContext).getTimeZone();
+ } catch (final AccountApiException e) {
+ throw new EntitlementApiException(e);
+ }
+ Preconditions.checkState(bcd > 0 && accountTimeZone != null, "Unexpected condition where account info could not be retrieved");
+
final CallContext callContext = internalCallContextFactory.createCallContext(internalCallContext);
final ImmutableMap.Builder<BlockingState, Optional<UUID>> blockingStates = new ImmutableMap.Builder<BlockingState, Optional<UUID>>();
@@ -141,6 +159,8 @@ public class DefaultEntitlementInternalApi extends DefaultEntitlementApiBase imp
final Callable<Void> preCallbacksCallback = new BulkSubscriptionBaseCancellation(subscriptions,
billingPolicy,
+ accountTimeZone,
+ bcd,
internalCallContext);
pluginExecution.executeWithPlugin(preCallbacksCallback, callbacks, pluginContexts);
@@ -182,20 +202,26 @@ public class DefaultEntitlementInternalApi extends DefaultEntitlementApiBase imp
private final Iterable<SubscriptionBase> subscriptions;
private final BillingActionPolicy billingPolicy;
+ private final DateTimeZone accountTimeZone;
+ private final int accountBillCycleDayLocal;
private final InternalCallContext callContext;
public BulkSubscriptionBaseCancellation(final Iterable<SubscriptionBase> subscriptions,
final BillingActionPolicy billingPolicy,
+ final DateTimeZone accountTimeZone,
+ final int accountBillCycleDayLocal,
final InternalCallContext callContext) {
this.subscriptions = subscriptions;
this.billingPolicy = billingPolicy;
+ this.accountTimeZone = accountTimeZone;
+ this.accountBillCycleDayLocal = accountBillCycleDayLocal;
this.callContext = callContext;
}
@Override
public Void call() throws Exception {
try {
- subscriptionInternalApi.cancelBaseSubscriptions(subscriptions, billingPolicy, callContext);
+ subscriptionInternalApi.cancelBaseSubscriptions(subscriptions, billingPolicy, accountTimeZone, accountBillCycleDayLocal, callContext);
} catch (final SubscriptionBaseApiException e) {
throw new EntitlementApiException(e);
}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/api/user/DefaultInvoiceUserApi.java b/invoice/src/main/java/org/killbill/billing/invoice/api/user/DefaultInvoiceUserApi.java
index 0baf924..3cad92b 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/api/user/DefaultInvoiceUserApi.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/api/user/DefaultInvoiceUserApi.java
@@ -214,7 +214,7 @@ public class DefaultInvoiceUserApi implements InvoiceUserApi {
final CallContext context) throws InvoiceApiException {
final InternalCallContext internalContext = internalCallContextFactory.createInternalCallContext(accountId, context);
- final Invoice result = dispatcher.processAccount(accountId, targetDate, dryRunArguments, internalContext);
+ final Invoice result = dispatcher.processAccount(true, accountId, targetDate, dryRunArguments, internalContext);
if (result == null) {
throw new InvoiceApiException(ErrorCode.INVOICE_NOTHING_TO_DO, accountId, targetDate != null ? targetDate : "null");
} else {
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/config/MultiTenantInvoiceConfig.java b/invoice/src/main/java/org/killbill/billing/invoice/config/MultiTenantInvoiceConfig.java
index d519cc0..45de5f6 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/config/MultiTenantInvoiceConfig.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/config/MultiTenantInvoiceConfig.java
@@ -119,6 +119,20 @@ public class MultiTenantInvoiceConfig extends MultiTenantConfigBase implements I
}
@Override
+ public boolean isInvoicingSystemEnabled() {
+ return staticConfig.isInvoicingSystemEnabled();
+ }
+
+ @Override
+ public boolean isInvoicingSystemEnabled(final InternalTenantContext tenantContext) {
+ final String result = getStringTenantConfig("isInvoicingSystemEnabled", tenantContext);
+ if (result != null) {
+ return Boolean.parseBoolean(result);
+ }
+ return isInvoicingSystemEnabled();
+ }
+
+ @Override
protected Class<? extends KillbillConfig> getConfigClass() {
return InvoiceConfig.class;
}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/generator/BillingIntervalDetail.java b/invoice/src/main/java/org/killbill/billing/invoice/generator/BillingIntervalDetail.java
index 8a75ef0..ea26fcc 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/generator/BillingIntervalDetail.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/generator/BillingIntervalDetail.java
@@ -21,8 +21,7 @@ package org.killbill.billing.invoice.generator;
import org.joda.time.LocalDate;
import org.killbill.billing.catalog.api.BillingMode;
import org.killbill.billing.catalog.api.BillingPeriod;
-
-import com.google.common.annotations.VisibleForTesting;
+import org.killbill.billing.util.bcd.BillCycleDayCalculator;
public class BillingIntervalDetail {
@@ -32,7 +31,6 @@ public class BillingIntervalDetail {
private final int billingCycleDay;
private final BillingPeriod billingPeriod;
private final BillingMode billingMode;
- private final boolean isMonthBased;
// First date after the startDate aligned with the BCD
private LocalDate firstBillingCycleDate;
// Date up to which we should bill
@@ -56,7 +54,6 @@ public class BillingIntervalDetail {
}
this.billingPeriod = billingPeriod;
this.billingMode = billingMode;
- this.isMonthBased = (billingPeriod.getPeriod().getMonths() | billingPeriod.getPeriod().getYears()) > 0;
computeAll();
}
@@ -70,7 +67,7 @@ public class BillingIntervalDetail {
public LocalDate getFutureBillingDateFor(final int nbPeriod) {
final LocalDate proposedDate = InvoiceDateUtils.advanceByNPeriods(firstBillingCycleDate, billingPeriod, nbPeriod);
- return alignProposedBillCycleDate(proposedDate, billingCycleDay, isMonthBased);
+ return BillCycleDayCalculator.alignProposedBillCycleDate(proposedDate, billingCycleDay, billingPeriod);
}
public LocalDate getLastBillingCycleDate() {
@@ -79,7 +76,7 @@ public class BillingIntervalDetail {
public LocalDate getNextBillingCycleDate() {
final LocalDate proposedDate = lastBillingCycleDate != null ? lastBillingCycleDate.plus(billingPeriod.getPeriod()) : firstBillingCycleDate;
- final LocalDate nextBillingCycleDate = alignProposedBillCycleDate(proposedDate, billingCycleDay, isMonthBased);
+ final LocalDate nextBillingCycleDate = BillCycleDayCalculator.alignProposedBillCycleDate(proposedDate, billingCycleDay, billingPeriod);
return nextBillingCycleDate;
}
@@ -109,7 +106,7 @@ public class BillingIntervalDetail {
while (proposedDate.isBefore(startDate)) {
proposedDate = proposedDate.plus(billingPeriod.getPeriod());
}
- firstBillingCycleDate = alignProposedBillCycleDate(proposedDate, billingCycleDay, isMonthBased);
+ firstBillingCycleDate = BillCycleDayCalculator.alignProposedBillCycleDate(proposedDate, billingCycleDay, billingPeriod);
}
private void calculateEffectiveEndDate() {
@@ -141,7 +138,7 @@ public class BillingIntervalDetail {
nextProposedDate = InvoiceDateUtils.advanceByNPeriods(firstBillingCycleDate, billingPeriod, numberOfPeriods);
numberOfPeriods += 1;
}
- proposedDate = alignProposedBillCycleDate(proposedDate, billingCycleDay, isMonthBased);
+ proposedDate = BillCycleDayCalculator.alignProposedBillCycleDate(proposedDate, billingCycleDay, billingPeriod);
// We honor the endDate as long as it does not go beyond our targetDate (by construction this cannot be after the nextProposedDate neither.
if (endDate != null && !endDate.isAfter(targetDate)) {
@@ -171,7 +168,7 @@ public class BillingIntervalDetail {
proposedDate = InvoiceDateUtils.advanceByNPeriods(firstBillingCycleDate, billingPeriod, numberOfPeriods);
numberOfPeriods += 1;
}
- proposedDate = alignProposedBillCycleDate(proposedDate, billingCycleDay, isMonthBased);
+ proposedDate = BillCycleDayCalculator.alignProposedBillCycleDate(proposedDate, billingCycleDay, billingPeriod);
// The proposedDate is greater to our endDate => return it
if (endDate != null && endDate.isBefore(proposedDate)) {
@@ -199,7 +196,7 @@ public class BillingIntervalDetail {
// Our proposed date is billingCycleDate prior to the effectiveEndDate
proposedDate = proposedDate.minus(billingPeriod.getPeriod());
- proposedDate = alignProposedBillCycleDate(proposedDate, billingCycleDay, isMonthBased);
+ proposedDate = BillCycleDayCalculator.alignProposedBillCycleDate(proposedDate, billingCycleDay, billingPeriod);
if (proposedDate.isBefore(firstBillingCycleDate)) {
// Make sure not to go too far in the past
@@ -208,20 +205,4 @@ public class BillingIntervalDetail {
lastBillingCycleDate = proposedDate;
}
}
-
- //
- // We start from a billCycleDate
- //
- private static LocalDate alignProposedBillCycleDate(final LocalDate proposedDate, final int billingCycleDay, final boolean isMonthBased) {
- // billingCycleDay alignment only makes sense for month based BillingPeriod (MONTHLY, QUARTERLY, BIANNUAL, ANNUAL)
- if (!isMonthBased) {
- return proposedDate;
- }
- final int lastDayOfMonth = proposedDate.dayOfMonth().getMaximumValue();
- int proposedBillCycleDate = proposedDate.getDayOfMonth();
- if (proposedBillCycleDate < billingCycleDay && billingCycleDay <= lastDayOfMonth) {
- proposedBillCycleDate = billingCycleDay;
- }
- return new LocalDate(proposedDate.getYear(), proposedDate.getMonthOfYear(), proposedBillCycleDate, proposedDate.getChronology());
- }
}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceModule.java b/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceModule.java
index a9aa2dc..d400a3f 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceModule.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceModule.java
@@ -22,6 +22,7 @@ import org.killbill.billing.glue.InvoiceModule;
import org.killbill.billing.invoice.InvoiceDispatcher;
import org.killbill.billing.invoice.InvoiceListener;
import org.killbill.billing.invoice.InvoiceTagHandler;
+import org.killbill.billing.invoice.ParkedAccountsManager;
import org.killbill.billing.invoice.api.DefaultInvoiceService;
import org.killbill.billing.invoice.api.InvoiceApiHelper;
import org.killbill.billing.invoice.api.InvoiceInternalApi;
@@ -64,7 +65,6 @@ import com.google.inject.name.Names;
public class DefaultInvoiceModule extends KillBillModule implements InvoiceModule {
-
InvoiceConfig staticInvoiceConfig;
public DefaultInvoiceModule(final KillbillConfigSource configSource) {
@@ -93,7 +93,11 @@ public class DefaultInvoiceModule extends KillBillModule implements InvoiceModul
}
protected void installConfig() {
- staticInvoiceConfig = new ConfigurationObjectFactory(skifeConfigSource).build(InvoiceConfig.class);
+ installConfig(new ConfigurationObjectFactory(skifeConfigSource).build(InvoiceConfig.class));
+ }
+
+ protected void installConfig(final InvoiceConfig staticInvoiceConfig) {
+ this.staticInvoiceConfig = staticInvoiceConfig;
bind(InvoiceConfig.class).annotatedWith(Names.named(STATIC_CONFIG)).toInstance(staticInvoiceConfig);
bind(InvoiceConfig.class).to(MultiTenantInvoiceConfig.class).asEagerSingleton();
}
@@ -164,5 +168,6 @@ public class DefaultInvoiceModule extends KillBillModule implements InvoiceModul
installResourceBundleFactory();
bind(RawUsageOptimizer.class).asEagerSingleton();
bind(InvoiceApiHelper.class).asEagerSingleton();
+ bind(ParkedAccountsManager.class).asEagerSingleton();
}
}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java b/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java
index cd2c7da..51bff88 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java
@@ -93,6 +93,7 @@ import org.killbill.billing.subscription.api.SubscriptionBaseInternalApi;
import org.killbill.billing.subscription.api.SubscriptionBaseTransitionType;
import org.killbill.billing.subscription.api.user.SubscriptionBaseApiException;
import org.killbill.billing.util.UUIDs;
+import org.killbill.billing.util.api.TagApiException;
import org.killbill.billing.util.callcontext.CallContext;
import org.killbill.billing.util.callcontext.InternalCallContextFactory;
import org.killbill.billing.util.callcontext.TenantContext;
@@ -144,6 +145,7 @@ public class InvoiceDispatcher {
private final Clock clock;
private final NotificationQueueService notificationQueueService;
private final InvoiceConfig invoiceConfig;
+ private final ParkedAccountsManager parkedAccountsManager;
@Inject
public InvoiceDispatcher(final InvoiceGenerator generator,
@@ -158,7 +160,8 @@ public class InvoiceDispatcher {
final PersistentBus eventBus,
final NotificationQueueService notificationQueueService,
final InvoiceConfig invoiceConfig,
- final Clock clock) {
+ final Clock clock,
+ final ParkedAccountsManager parkedAccountsManager) {
this.generator = generator;
this.billingApi = billingApi;
this.subscriptionApi = SubscriptionApi;
@@ -172,6 +175,7 @@ public class InvoiceDispatcher {
this.clock = clock;
this.notificationQueueService = notificationQueueService;
this.invoiceConfig = invoiceConfig;
+ this.parkedAccountsManager = parkedAccountsManager;
}
public void processSubscriptionForInvoiceGeneration(final EffectiveSubscriptionInternalEvent transition,
@@ -215,13 +219,41 @@ public class InvoiceDispatcher {
}
}
- public Invoice processAccount(final UUID accountId, @Nullable final LocalDate targetDate,
- @Nullable final DryRunArguments dryRunArguments, final InternalCallContext context) throws InvoiceApiException {
+ public Invoice processAccount(final UUID accountId,
+ @Nullable final LocalDate targetDate,
+ @Nullable final DryRunArguments dryRunArguments,
+ final InternalCallContext context) throws InvoiceApiException {
+ // Note that all API calls (dryRun or not) will bypass this (see processAccount below)
+ if (!invoiceConfig.isInvoicingSystemEnabled(context)) {
+ log.warn("Invoicing system is off, parking accountId='{}'", accountId);
+ parkAccount(accountId, context);
+ return null;
+ }
+
+ return processAccount(false, accountId, targetDate, dryRunArguments, context);
+ }
+
+ public Invoice processAccount(final boolean isApiCall,
+ final UUID accountId,
+ @Nullable final LocalDate targetDate,
+ @Nullable final DryRunArguments dryRunArguments,
+ final InternalCallContext context) throws InvoiceApiException {
+ boolean parkedAccount = false;
+ try {
+ parkedAccount = parkedAccountsManager.isParked(context);
+ if (parkedAccount && !isApiCall) {
+ log.warn("Ignoring invoice generation process for accountId='{}', targetDate='{}', account is parked", accountId.toString(), targetDate);
+ return null;
+ }
+ } catch (final TagApiException e) {
+ log.warn("Unable to determine parking state for accountId='{}'", accountId);
+ }
+
GlobalLock lock = null;
try {
lock = locker.lockWithNumberOfTries(LockerType.ACCNT_INV_PAY.toString(), accountId.toString(), invoiceConfig.getMaxGlobalLockRetries());
- return processAccountWithLock(accountId, targetDate, dryRunArguments, context);
+ return processAccountWithLock(parkedAccount, accountId, targetDate, dryRunArguments, context);
} catch (final LockFailedException e) {
log.warn("Failed to process invoice for accountId='{}', targetDate='{}'", accountId.toString(), targetDate, e);
} finally {
@@ -232,8 +264,11 @@ public class InvoiceDispatcher {
return null;
}
- private Invoice processAccountWithLock(final UUID accountId, @Nullable final LocalDate inputTargetDateMaybeNull,
- @Nullable final DryRunArguments dryRunArguments, final InternalCallContext context) throws InvoiceApiException {
+ private Invoice processAccountWithLock(final boolean parkedAccount,
+ final UUID accountId,
+ @Nullable final LocalDate inputTargetDateMaybeNull,
+ @Nullable final DryRunArguments dryRunArguments,
+ final InternalCallContext context) throws InvoiceApiException {
final boolean isDryRun = dryRunArguments != null;
final boolean upcomingInvoiceDryRun = isDryRun && DryRunType.UPCOMING_INVOICE.equals(dryRunArguments.getDryRunType());
@@ -258,6 +293,16 @@ public class InvoiceDispatcher {
final Invoice invoice = processAccountWithLockAndInputTargetDate(accountId, curTargetDate, billingEvents, isDryRun, context);
if (invoice != null) {
filterInvoiceItemsForDryRun(filteredSubscriptionIdsForDryRun, invoice);
+
+ if (!isDryRun && parkedAccount) {
+ try {
+ log.info("Illegal invoicing state fixed for accountId='{}', unparking account", accountId);
+ parkedAccountsManager.unparkAccount(accountId, context);
+ } catch (final TagApiException ignored) {
+ log.warn("Unable to unpark account", ignored);
+ }
+ }
+
return invoice;
}
}
@@ -271,6 +316,20 @@ public class InvoiceDispatcher {
} catch (final SubscriptionBaseApiException e) {
log.warn("Failed to retrieve BillingEvents for accountId='{}', dryRunArguments='{}'", accountId, dryRunArguments, e);
return null;
+ } catch (final InvoiceApiException e) {
+ if (e.getCode() == ErrorCode.UNEXPECTED_ERROR.getCode() && !isDryRun) {
+ log.warn("Illegal invoicing state detected for accountId='{}', dryRunArguments='{}', parking account", accountId, dryRunArguments, e);
+ parkAccount(accountId, context);
+ }
+ throw e;
+ }
+ }
+
+ private void parkAccount(final UUID accountId, final InternalCallContext context) {
+ try {
+ parkedAccountsManager.parkAccount(accountId, context);
+ } catch (final TagApiException ignored) {
+ log.warn("Unable to park account", ignored);
}
}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/ParkedAccountsManager.java b/invoice/src/main/java/org/killbill/billing/invoice/ParkedAccountsManager.java
new file mode 100644
index 0000000..73321ae
--- /dev/null
+++ b/invoice/src/main/java/org/killbill/billing/invoice/ParkedAccountsManager.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2014-2016 Groupon, Inc
+ * Copyright 2014-2016 The Billing Project, LLC
+ *
+ * The Billing Project licenses this file to you under the Apache License, version 2.0
+ * (the "License"); you may not use this file except in compliance with the
+ * License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.killbill.billing.invoice;
+
+import java.util.UUID;
+
+import org.killbill.billing.ErrorCode;
+import org.killbill.billing.ObjectType;
+import org.killbill.billing.callcontext.InternalCallContext;
+import org.killbill.billing.tag.TagInternalApi;
+import org.killbill.billing.util.api.TagApiException;
+import org.killbill.billing.util.api.TagDefinitionApiException;
+import org.killbill.billing.util.tag.Tag;
+
+import com.google.common.base.Predicate;
+import com.google.common.collect.Iterables;
+import com.google.inject.Inject;
+
+import static org.killbill.billing.util.tag.dao.SystemTags.PARK_TAG_DEFINITION_ID;
+
+public class ParkedAccountsManager {
+
+ private final TagInternalApi tagApi;
+
+ @Inject
+ public ParkedAccountsManager(final TagInternalApi tagApi) throws TagDefinitionApiException {
+ this.tagApi = tagApi;
+ }
+
+ // Idempotent
+ public void parkAccount(final UUID accountId, final InternalCallContext internalCallContext) throws TagApiException {
+ try {
+ tagApi.addTag(accountId, ObjectType.ACCOUNT, PARK_TAG_DEFINITION_ID, internalCallContext);
+ } catch (final TagApiException e) {
+ if (ErrorCode.TAG_ALREADY_EXISTS.getCode() != e.getCode()) {
+ throw e;
+ }
+ }
+ }
+
+ public void unparkAccount(final UUID accountId, final InternalCallContext internalCallContext) throws TagApiException {
+ tagApi.removeTag(accountId, ObjectType.ACCOUNT, PARK_TAG_DEFINITION_ID, internalCallContext);
+ }
+
+ public boolean isParked(final InternalCallContext internalCallContext) throws TagApiException {
+ return Iterables.<Tag>tryFind(tagApi.getTagsForAccountType(ObjectType.ACCOUNT, false, internalCallContext),
+ new Predicate<Tag>() {
+ @Override
+ public boolean apply(final Tag input) {
+ return PARK_TAG_DEFINITION_ID.equals(input.getTagDefinitionId());
+ }
+ }).orNull() != null;
+ }
+}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/tree/Item.java b/invoice/src/main/java/org/killbill/billing/invoice/tree/Item.java
index 8c31b4b..aaec4eb 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/tree/Item.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/tree/Item.java
@@ -24,14 +24,13 @@ import java.util.UUID;
import org.joda.time.DateTime;
import org.joda.time.Days;
import org.joda.time.LocalDate;
-
import org.killbill.billing.catalog.api.Currency;
import org.killbill.billing.invoice.api.InvoiceItem;
import org.killbill.billing.invoice.generator.InvoiceDateUtils;
import org.killbill.billing.invoice.model.RecurringInvoiceItem;
import org.killbill.billing.invoice.model.RepairAdjInvoiceItem;
-import org.killbill.billing.util.currency.KillBillMoney;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
@@ -135,8 +134,7 @@ public class Item {
return new RecurringInvoiceItem(id, createdDate, invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, newStartDate, newEndDate, positiveAmount, rate, currency);
} else {
// We first compute the maximum amount after adjustment and that sets the amount limit of how much can be repaired.
- final BigDecimal maxAvailableAmountAfterAdj = amount.subtract(adjustedAmount);
- final BigDecimal maxAvailableAmountForRepair = maxAvailableAmountAfterAdj.subtract(currentRepairedAmount);
+ final BigDecimal maxAvailableAmountForRepair = getNetAmount();
final BigDecimal positiveAmountForRepair = positiveAmount.compareTo(maxAvailableAmountForRepair) <= 0 ? positiveAmount : maxAvailableAmountForRepair;
return positiveAmountForRepair.compareTo(BigDecimal.ZERO) > 0 ? new RepairAdjInvoiceItem(targetInvoiceId, accountId, newStartDate, newEndDate, positiveAmountForRepair.negate(), currency, linkedId) : null;
}
@@ -152,6 +150,11 @@ public class Item {
currentRepairedAmount = currentRepairedAmount.add(increment);
}
+ @JsonIgnore
+ public BigDecimal getNetAmount() {
+ return amount.subtract(adjustedAmount).subtract(currentRepairedAmount);
+ }
+
public ItemAction getAction() {
return action;
}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsInterval.java b/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsInterval.java
index 9140329..6403b91 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsInterval.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsInterval.java
@@ -19,77 +19,93 @@
package org.killbill.billing.invoice.tree;
import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
+import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
+import java.util.Set;
import java.util.UUID;
+import javax.annotation.Nullable;
+
import org.joda.time.LocalDate;
import org.killbill.billing.invoice.api.InvoiceItem;
import org.killbill.billing.invoice.tree.Item.ItemAction;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
+import com.google.common.collect.Collections2;
import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
/**
- * Keeps track of all the items existing on a specified interval.
+ * Keeps track of all the items existing on a specified ItemsNodeInterval
*/
public class ItemsInterval {
- private final UUID targetInvoiceId;
- private final NodeInterval interval;
- private LinkedList<Item> items;
+ // Parent (enclosing) interval
+ private final ItemsNodeInterval interval;
+ private final LinkedList<Item> items;
- public ItemsInterval(final NodeInterval interval, final UUID targetInvoiceId) {
- this(interval, targetInvoiceId, null);
+ public ItemsInterval(final ItemsNodeInterval interval) {
+ this(interval, null);
}
- public ItemsInterval(final NodeInterval interval, final UUID targetInvoiceId, final Item initialItem) {
+ public ItemsInterval(final ItemsNodeInterval interval, final Item initialItem) {
this.interval = interval;
- this.targetInvoiceId = targetInvoiceId;
this.items = Lists.newLinkedList();
if (initialItem != null) {
items.add(initialItem);
}
}
- public Item findItem(final UUID targetId) {
- return Iterables.tryFind(items, new Predicate<Item>() {
- @Override
- public boolean apply(final Item input) {
- return input.getId().equals(targetId);
- }
- }).orNull();
- }
-
public List<Item> getItems() {
return items;
}
- public void buildForMissingInterval(final LocalDate startDate, final LocalDate endDate, final List<Item> output, final boolean addRepair) {
- final Item item = createNewItem(startDate, endDate, addRepair);
- if (item != null) {
- output.add(item);
- }
+ public Iterable<Item> get_ADD_items() {
+ return findItems(ItemAction.ADD);
}
- /**
- * Determines what is left based on the mergeMode and the action for each item.
- *
- * @param output
- * @param mergeMode
- * @return whether or not the parent should ignore the interval covered by the child interval
- */
- public void buildFromItems(final List<Item> output, final boolean mergeMode) {
- final Item item = getResultingItem(mergeMode);
- if (item != null) {
- output.add(item);
- }
+ public Iterable<Item> get_CANCEL_items() {
+ return findItems(ItemAction.CANCEL);
+ }
+
+ public Item getCancellingItemIfExists(final UUID targetId) {
+ return Iterables.tryFind(items,
+ new Predicate<Item>() {
+ @Override
+ public boolean apply(final Item input) {
+ return input.getAction() == ItemAction.CANCEL && input.getLinkedId().equals(targetId);
+ }
+ }).orNull();
+ }
+
+ public Item getCancelledItemIfExists(final UUID linkedId) {
+ return Iterables.tryFind(items,
+ new Predicate<Item>() {
+ @Override
+ public boolean apply(final Item input) {
+ return input.getAction() == ItemAction.ADD && input.getId().equals(linkedId);
+ }
+ }).orNull();
+ }
+
+ public NodeInterval getNodeInterval() {
+ return interval;
+ }
+
+ public Item findItem(final UUID targetId) {
+ final Collection<Item> matchingItems = Collections2.<Item>filter(items,
+ new Predicate<Item>() {
+ @Override
+ public boolean apply(final Item input) {
+ return input.getId().equals(targetId);
+ }
+ });
+ Preconditions.checkState(matchingItems.size() < 2, "Too many items matching id='%s' among items='%s'", targetId, items);
+ return matchingItems.size() == 1 ? matchingItems.iterator().next() : null;
}
/**
@@ -117,74 +133,8 @@ public class ItemsInterval {
return items.isEmpty();
}
- public Iterable<Item> get_ADD_items() {
- return Iterables.filter(items, new Predicate<Item>() {
- @Override
- public boolean apply(final Item input) {
- return input.getAction() == ItemAction.ADD;
- }
- });
- }
-
- public Iterable<Item> get_CANCEL_items() {
- return Iterables.filter(items, new Predicate<Item>() {
- @Override
- public boolean apply(final Item input) {
- return input.getAction() == ItemAction.CANCEL;
- }
- });
- }
-
- public NodeInterval getNodeInterval() {
- return interval;
- }
-
- private Item getResultingItem(final boolean mergeMode) {
- return mergeMode ? getResulting_CANCEL_Item() : getResulting_ADD_Item();
- }
-
- private Item getResulting_CANCEL_Item() {
- Preconditions.checkState(items.size() == 0 || items.size() == 1);
- return Iterables.tryFind(items, new Predicate<Item>() {
- @Override
- public boolean apply(final Item input) {
- return input.getAction() == ItemAction.CANCEL;
- }
- }).orNull();
- }
-
- private Item getResulting_ADD_Item() {
-
- //
- // At this point we pruned the items so that we can have either:
- // - 2 items (ADD + CANCEL, where CANCEL does NOT point to ADD item-- otherwise this is a cancelling pair that
- // would have been removed in mergeCancellingPairs logic)
- // - 1 ADD item, simple enough we return it
- // - 1 CANCEL, there is nothing to return but the period will be ignored by the parent
- // - Nothing at all; this valid, this just means its original items got removed during mergeCancellingPairs logic,
- // but its NodeInterval has children so it could not be deleted.
- //
- Preconditions.checkState(items.size() <= 2, "Double billing detected: %s", items);
-
- final Item item = items.size() > 0 && items.get(0).getAction() == ItemAction.ADD ? items.get(0) : null;
- return item;
- }
-
- // Just ensure that ADD items precedes CANCEL items
- public void insertSortedItem(final Item item) {
+ public void add(final Item item) {
items.add(item);
- Collections.sort(items, new Comparator<Item>() {
- @Override
- public int compare(final Item o1, final Item o2) {
- if (o1.getAction() == ItemAction.ADD && o2.getAction() == ItemAction.CANCEL) {
- return -1;
- } else if (o1.getAction() == ItemAction.CANCEL && o2.getAction() == ItemAction.ADD) {
- return 1;
- } else {
- return 0;
- }
- }
- });
}
public void cancelItems(final Item item) {
@@ -198,61 +148,139 @@ public class ItemsInterval {
items.remove(item);
}
- public Item getCancellingItemIfExists(final UUID targetId) {
- return Iterables.tryFind(items,
- new Predicate<Item>() {
- @Override
- public boolean apply(final Item input) {
- return input.getAction() == ItemAction.CANCEL && input.getLinkedId().equals(targetId);
- }
- }).orNull();
- }
-
- public Item getCancelledItemIfExists(final UUID linkedId) {
- return Iterables.tryFind(items,
- new Predicate<Item>() {
- @Override
- public boolean apply(final Item input) {
- return input.getAction() == ItemAction.ADD && input.getId().equals(linkedId);
- }
- }).orNull();
+ // Called for missing service periods
+ public void buildForMissingInterval(@Nullable final LocalDate startDate, @Nullable final LocalDate endDate, @Nullable final UUID targetInvoiceId, final Collection<Item> output, final boolean addRepair) {
+ final Item item = createNewItem(startDate, endDate, targetInvoiceId, addRepair);
+ if (item != null) {
+ output.add(item);
+ }
}
- public int size() {
- return items.size();
+ // Called on the last node
+ public void buildFromItems(final Collection<Item> output, final boolean mergeMode) {
+ buildForMissingInterval(null, null, null, output, mergeMode);
}
/**
- * Creates a new item.
+ * Create a new item based on the existing items and new service period
* <p/>
* <ul>
- * <li>In normal mode, we only consider ADD items. This happens when for instance an existing item was partially repaired
+ * <li>During the build phase, we only consider ADD items. This happens when for instance an existing item was partially repaired
* and there is a need to create a new item which represents the part left -- that was not repaired.
- * <li>In mergeMode, we allow to create new items that are the missing repaired items (CANCEL).
+ * <li>During the merge phase, we create new items that are the missing repaired items (CANCEL).
* </ul>
*
* @param startDate start date of the new item to create
* @param endDate end date of the new item to create
* @param mergeMode mode to consider.
- * @return
+ * @return new item for this service period or null
*/
- private Item createNewItem(final LocalDate startDate, final LocalDate endDate, final boolean mergeMode) {
-
+ private Item createNewItem(@Nullable final LocalDate startDate, @Nullable final LocalDate endDate, @Nullable final UUID targetInvoiceId, final boolean mergeMode) {
+ // Find the ADD (build phase) or CANCEL (merge phase) item of this interval
final Item item = getResultingItem(mergeMode);
- if (item == null) {
- return null;
+ if (item == null || startDate == null || endDate == null || targetInvoiceId == null) {
+ return item;
}
+ // Prorate (build phase) or repair (merge phase) this item, as needed
final InvoiceItem proratedInvoiceItem = item.toProratedInvoiceItem(startDate, endDate);
if (proratedInvoiceItem == null) {
return null;
+ } else {
+ // Keep track of the repaired amount for this item
+ item.incrementCurrentRepairedAmount(proratedInvoiceItem.getAmount().abs());
+ return new Item(proratedInvoiceItem, targetInvoiceId, item.getAction());
+ }
+ }
+
+ private Item getResultingItem(final boolean mergeMode) {
+ return mergeMode ? getResulting_CANCEL_Item() : getResulting_ADD_Item();
+ }
+
+ private Item getResulting_CANCEL_Item() {
+ Preconditions.checkState(items.size() <= 1, "Too many items=%s", items);
+ return getResulting_CANCEL_ItemNoChecks();
+ }
+
+ private Item getResulting_CANCEL_ItemNoChecks() {
+ return findItem(ItemAction.CANCEL);
+ }
+
+ private Item getResulting_ADD_Item() {
+ //
+ // At this point we pruned the items so that we can have either:
+ // - 2 items (ADD + CANCEL, where CANCEL does NOT point to ADD item -- otherwise this is a cancelling pair that
+ // would have been removed in mergeCancellingPairs logic)
+ // - 1 ADD item, simple enough we return it
+ // - 1 CANCEL, there is nothing to return but the period will be ignored by the parent
+ // - Nothing at all; this valid, this just means its original items got removed during mergeCancellingPairs logic,
+ // but its NodeInterval has children so it could not be deleted.
+ //
+ Preconditions.checkState(items.size() <= 2, "Double billing detected: %s", items);
+
+ final Collection<Item> addItems = findItems(ItemAction.ADD);
+ Preconditions.checkState(addItems.size() <= 1, "Double billing detected: %s", items);
+
+ final Item item = findItem(ItemAction.ADD);
+
+ // Double billing sanity check across nodes
+ if (item != null) {
+ final Set<UUID> addItemsCancelled = new HashSet<UUID>();
+ final Item cancelItem = findItem(ItemAction.CANCEL);
+ if (cancelItem != null) {
+ Preconditions.checkState(cancelItem.getLinkedId() != null, "Invalid CANCEL item=%s", cancelItem);
+ addItemsCancelled.add(cancelItem.getLinkedId());
+ }
+ final Set<UUID> addItemsToBeCancelled = new HashSet<UUID>();
+ checkDoubleBilling(addItemsCancelled, addItemsToBeCancelled);
+ }
+
+ return item;
+ }
+
+ private void checkDoubleBilling(final Set<UUID> addItemsCancelled, final Set<UUID> addItemsToBeCancelled) {
+ final ItemsNodeInterval parentNodeInterval = (ItemsNodeInterval) interval.getParent();
+ if (parentNodeInterval == null) {
+ Preconditions.checkState(addItemsCancelled.equals(addItemsToBeCancelled), "Double billing detected: addItemsCancelled=%s, addItemsToBeCancelled=%s", addItemsCancelled, addItemsToBeCancelled);
+ return;
+ }
+ final ItemsInterval parentItemsInterval = parentNodeInterval.getItemsInterval();
+
+ final Item parentAddItem = parentItemsInterval.getResulting_ADD_Item();
+ if (parentAddItem != null) {
+ Preconditions.checkState(parentAddItem.getId() != null, "Invalid ADD item=%s", parentAddItem);
+ addItemsToBeCancelled.add(parentAddItem.getId());
}
- final Item result = new Item(proratedInvoiceItem, targetInvoiceId, item.getAction());
- if (item.getAction() == ItemAction.CANCEL) {
- item.incrementCurrentRepairedAmount(result.getAmount());
+ final Item parentCancelItem = parentItemsInterval.getResulting_CANCEL_ItemNoChecks();
+ if (parentCancelItem != null) {
+ Preconditions.checkState(parentCancelItem.getLinkedId() != null, "Invalid CANCEL item=%s", parentCancelItem);
+ addItemsCancelled.add(parentCancelItem.getLinkedId());
}
- return result;
+
+ parentItemsInterval.checkDoubleBilling(addItemsCancelled, addItemsToBeCancelled);
}
+ private Item findItem(final ItemAction itemAction) {
+ final Collection<Item> matchingItems = findItems(itemAction);
+ return matchingItems.size() == 1 ? matchingItems.iterator().next() : null;
+ }
+
+ private Collection<Item> findItems(final ItemAction itemAction) {
+ return Collections2.<Item>filter(items,
+ new Predicate<Item>() {
+ @Override
+ public boolean apply(final Item input) {
+ return input.getAction() == itemAction;
+ }
+ });
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder("ItemsInterval{");
+ sb.append("items=").append(items);
+ sb.append('}');
+ return sb.toString();
+ }
}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsNodeInterval.java b/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsNodeInterval.java
index 51b2b26..515b88a 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsNodeInterval.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/tree/ItemsNodeInterval.java
@@ -22,11 +22,13 @@ import java.io.IOException;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
import org.joda.time.LocalDate;
import org.killbill.billing.invoice.api.InvoiceItem;
@@ -35,22 +37,23 @@ import org.killbill.billing.util.jackson.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.core.JsonGenerator;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
+/**
+ * Node in the SubscriptionItemTree
+ */
public class ItemsNodeInterval extends NodeInterval {
- private final UUID targetInvoiceId;
- private ItemsInterval items;
+ private final ItemsInterval items;
- public ItemsNodeInterval(final UUID targetInvoiceId) {
- this.items = new ItemsInterval(this, targetInvoiceId);
- this.targetInvoiceId = targetInvoiceId;
+ public ItemsNodeInterval() {
+ this.items = new ItemsInterval(this);
}
- public ItemsNodeInterval(final NodeInterval parent, final UUID targetInvoiceId, final Item item) {
+ public ItemsNodeInterval(final ItemsNodeInterval parent, final Item item) {
super(parent, item.getStartDate(), item.getEndDate());
- this.items = new ItemsInterval(this, targetInvoiceId, item);
- this.targetInvoiceId = targetInvoiceId;
+ this.items = new ItemsInterval(this, item);
}
@JsonIgnore
@@ -63,6 +66,33 @@ public class ItemsNodeInterval extends NodeInterval {
}
/**
+ * Add existing item into the tree
+ *
+ * @param newNode an existing item
+ */
+ public void addExistingItem(final ItemsNodeInterval newNode) {
+ Preconditions.checkState(newNode.getItems().size() == 1, "Invalid node=%s", newNode);
+ final Item item = newNode.getItems().get(0);
+
+ addNode(newNode,
+ new AddNodeCallback() {
+ @Override
+ public boolean onExistingNode(final NodeInterval existingNode) {
+ final ItemsInterval existingOrNewNodeItems = ((ItemsNodeInterval) existingNode).getItemsInterval();
+ existingOrNewNodeItems.add(item);
+ // There is no new node added but instead we just populated the list of items for the already existing node
+ return false;
+ }
+
+ @Override
+ public boolean shouldInsertNode(final NodeInterval insertionNode) {
+ // Always want to insert node in the tree when we find the right place.
+ return true;
+ }
+ });
+ }
+
+ /**
* <p/>
* There is no limit in the depth of the tree,
* and the build strategy is to first consider the lowest child for a given period
@@ -82,103 +112,24 @@ public class ItemsNodeInterval extends NodeInterval {
* and the goal is to generate the repair items; @see addProposedItem
*
* @param output result list of built items
+ * @param targetInvoiceId
*/
- public void buildForExistingItems(final List<Item> output) {
-
+ public void buildForExistingItems(final Collection<Item> output, final UUID targetInvoiceId) {
// We start by pruning useless entries to simplify the build phase.
- pruneTree();
-
- build(new BuildNodeCallback() {
- @Override
- public void onMissingInterval(final NodeInterval curNode, final LocalDate startDate, final LocalDate endDate) {
- final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
- items.buildForMissingInterval(startDate, endDate, output, false);
- }
-
- @Override
- public void onLastNode(final NodeInterval curNode) {
- final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
- items.buildFromItems(output, false);
- }
- });
- }
-
-
- /**
- * The merge tree is initially constructed by flattening all the existing items and reversing them (CANCEL node).
- * <p/>
- * That means that if we were to not merge any new proposed items, we would end up with only those reversed existing
- * items, and they would all end up repaired-- which is what we want.
- * <p/>
- * However, if there are new proposed items, then we look to see if they are children one our existing reverse items
- * so that we can generate the repair pieces missing. For e.g, below is one scenario among so many:
- * <p/>
- * <pre>
- * D1 D2
- * |---------------------------------------------------| (existing reversed (CANCEL) item
- * D1' D2'
- * |---------------| (proposed same plan)
- * </pre>
- * In that case we want to generated a repair for [D1, D1') and [D2',D2)
- * <p/>
- * Note that this tree is never very deep, only 3 levels max, with exiting at the first level
- * and proposed that are the for the exact same plan but for different dates below.
- *
- * @param output result list of built items
- */
- public void mergeExistingAndProposed(final List<Item> output) {
+ pruneAndValidateTree();
- build(new BuildNodeCallback() {
- @Override
- public void onMissingInterval(final NodeInterval curNode, final LocalDate startDate, final LocalDate endDate) {
- final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
- items.buildForMissingInterval(startDate, endDate, output, true);
- }
-
- @Override
- public void onLastNode(final NodeInterval curNode) {
- final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
- items.buildFromItems(output, true);
- }
- }
- );
+ build(output, targetInvoiceId, false);
}
/**
- * Add existing item into the tree.
- *
- * @param newNode an existing item
- */
- public boolean addExistingItem(final ItemsNodeInterval newNode) {
-
- return addNode(newNode, new AddNodeCallback() {
- @Override
- public boolean onExistingNode(final NodeInterval existingNode) {
- if (!existingNode.isRoot() && newNode.getStart().compareTo(existingNode.getStart()) == 0 && newNode.getEnd().compareTo(existingNode.getEnd()) == 0) {
- final Item item = newNode.getItems().get(0);
- final ItemsInterval existingOrNewNodeItems = ((ItemsNodeInterval) existingNode).getItemsInterval();
- existingOrNewNodeItems.insertSortedItem(item);
- }
- // There is no new node added but instead we just populated the list of items for the already existing node.
- return false;
- }
-
- @Override
- public boolean shouldInsertNode(final NodeInterval insertionNode) {
- // Always want to insert node in the tree when we find the right place.
- return true;
- }
- });
- }
-
- /**
- * Add proposed item into the (flattened and reversed) tree.
+ * Add proposed item into the (flattened and reversed) tree
*
* @param newNode a new proposed item
- * @return true if the item was merged and will trigger a repair or false if the proposed item should be kept as such
- * and no repair generated.
+ * @return true if the item was merged and will trigger a repair or false if the proposed item should be kept as such and no repair generated
*/
public boolean addProposedItem(final ItemsNodeInterval newNode) {
+ Preconditions.checkState(newNode.getItems().size() == 1, "Invalid node=%s", newNode);
+ final Item item = newNode.getItems().get(0);
return addNode(newNode, new AddNodeCallback() {
@Override
@@ -187,8 +138,6 @@ public class ItemsNodeInterval extends NodeInterval {
return false;
}
- Preconditions.checkState(newNode.getStart().compareTo(existingNode.getStart()) == 0 && newNode.getEnd().compareTo(existingNode.getEnd()) == 0);
- final Item item = newNode.getItems().get(0);
final ItemsInterval existingOrNewNodeItems = ((ItemsNodeInterval) existingNode).getItemsInterval();
existingOrNewNodeItems.cancelItems(item);
// In the merge logic, whether we really insert the node or find an existing node on which to insert items should be seen
@@ -204,17 +153,16 @@ public class ItemsNodeInterval extends NodeInterval {
return false;
}
- final ItemsInterval insertionNodeItems = ((ItemsNodeInterval) insertionNode).getItemsInterval();
- Preconditions.checkState(insertionNodeItems.getItems().size() == 1, "Expected existing node to have only one item");
- final Item insertionNodeItem = insertionNodeItems.getItems().get(0);
- final Item newNodeItem = newNode.getItems().get(0);
+ final List<Item> insertionNodeItems = ((ItemsNodeInterval) insertionNode).getItems();
+ Preconditions.checkState(insertionNodeItems.size() == 1, "Expected existing node to have only one item");
+ final Item insertionNodeItem = insertionNodeItems.get(0);
// If we receive a new proposed that is the same kind as the reversed existing we want to insert it to generate
// a piece of repair
- if (insertionNodeItem.isSameKind(newNodeItem)) {
+ if (insertionNodeItem.isSameKind(item)) {
return true;
- // If not, then keep the proposed outside of the tree.
} else {
+ // If not, then keep the proposed outside of the tree.
return false;
}
}
@@ -222,14 +170,39 @@ public class ItemsNodeInterval extends NodeInterval {
}
/**
+ * The merge tree is initially constructed by flattening all the existing items and reversing them (CANCEL node).
+ * <p/>
+ * That means that if we were to not merge any new proposed items, we would end up with only those reversed existing
+ * items, and they would all end up repaired-- which is what we want.
+ * <p/>
+ * However, if there are new proposed items, then we look to see if they are children one our existing reverse items
+ * so that we can generate the repair pieces missing. For e.g, below is one scenario among so many:
+ * <p/>
+ * <pre>
+ * D1 D2
+ * |---------------------------------------------------| (existing reversed (CANCEL) item
+ * D1' D2'
+ * |---------------| (proposed same plan)
+ * </pre>
+ * In that case we want to generated a repair for [D1, D1') and [D2',D2)
+ * <p/>
+ * Note that this tree is never very deep, only 3 levels max, with exiting at the first level
+ * and proposed that are the for the exact same plan but for different dates below.
+ *
+ * @param output result list of built items
+ */
+ public void mergeExistingAndProposed(final Collection<Item> output, final UUID targetInvoiceId) {
+ build(output, targetInvoiceId, true);
+ }
+
+ /**
* Add the adjustment amount on the item specified by the targetId.
*
* @return linked item if fully adjusted, null otherwise
*/
- public Item addAdjustment(final InvoiceItem item) {
+ public Item addAdjustment(final InvoiceItem item, final UUID targetInvoiceId) {
final UUID targetId = item.getLinkedItemId();
- // TODO we should really be using findNode(adjustmentDate, callback) instead but wrong dates in test creates panic.
final NodeInterval node = findNode(new SearchCallback() {
@Override
public boolean isMatch(final NodeInterval curNode) {
@@ -239,14 +212,13 @@ public class ItemsNodeInterval extends NodeInterval {
Preconditions.checkNotNull(node, "Unable to find item interval for id='%s', tree=%s", targetId, this);
final ItemsInterval targetItemsInterval = ((ItemsNodeInterval) node).getItemsInterval();
- final List<Item> targetItems = targetItemsInterval.getItems();
final Item targetItem = targetItemsInterval.findItem(targetId);
- Preconditions.checkNotNull(targetItem, "Unable to find item with id='%s', items=%s", targetId, targetItems);
+ Preconditions.checkNotNull(targetItem, "Unable to find item with id='%s', itemsInterval=%s", targetId, targetItemsInterval);
final BigDecimal adjustmentAmount = item.getAmount().negate();
if (targetItem.getAmount().compareTo(adjustmentAmount) == 0) {
// Full item adjustment - treat it like a repair
- addExistingItem(new ItemsNodeInterval(this, targetInvoiceId, new Item(item, targetItem.getStartDate(), targetItem.getEndDate(), targetInvoiceId, ItemAction.CANCEL)));
+ addExistingItem(new ItemsNodeInterval(this, new Item(item, targetItem.getStartDate(), targetItem.getEndDate(), targetInvoiceId, ItemAction.CANCEL)));
return targetItem;
} else {
targetItem.incrementAdjustedAmount(adjustmentAmount);
@@ -254,37 +226,20 @@ public class ItemsNodeInterval extends NodeInterval {
}
}
- public void jsonSerializeTree(final ObjectMapper mapper, final OutputStream output) throws IOException {
-
- final JsonGenerator generator = mapper.getFactory().createJsonGenerator(output);
- generator.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false);
-
- walkTree(new WalkCallback() {
-
- private int curDepth = 0;
-
+ private void build(final Collection<Item> output, final UUID targetInvoiceId, final boolean mergeMode) {
+ build(new BuildNodeCallback() {
@Override
- public void onCurrentNode(final int depth, final NodeInterval curNode, final NodeInterval parent) {
- final ItemsNodeInterval node = (ItemsNodeInterval) curNode;
- if (node.isRoot()) {
- return;
- }
+ public void onMissingInterval(final NodeInterval curNode, final LocalDate startDate, final LocalDate endDate) {
+ final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
+ items.buildForMissingInterval(startDate, endDate, targetInvoiceId, output, mergeMode);
+ }
- try {
- if (curDepth < depth) {
- generator.writeStartArray();
- curDepth = depth;
- } else if (curDepth > depth) {
- generator.writeEndArray();
- curDepth = depth;
- }
- generator.writeObject(node);
- } catch (IOException e) {
- throw new RuntimeException("Failed to deserialize tree", e);
- }
+ @Override
+ public void onLastNode(final NodeInterval curNode) {
+ final ItemsInterval items = ((ItemsNodeInterval) curNode).getItemsInterval();
+ items.buildFromItems(output, mergeMode);
}
});
- generator.close();
}
//
@@ -296,7 +251,7 @@ public class ItemsNodeInterval extends NodeInterval {
// whose children completely map the interval (isPartitionedByChildren) and where each child will have a CANCEL item pointing to the ADD.
// When we detect such nodes, we delete both the ADD in the parent interval and the CANCEL in the children (and cleanup the interval if it does not have items)
//
- private void pruneTree() {
+ private void pruneAndValidateTree() {
final NodeInterval root = this;
walkTree(new WalkCallback() {
@Override
@@ -323,9 +278,7 @@ public class ItemsNodeInterval extends NodeInterval {
public void onCurrentNode(final int depth, final NodeInterval curNode, final NodeInterval parent) {
final ItemsInterval curChildItems = ((ItemsNodeInterval) curNode).getItemsInterval();
final Item cancelledItem = curChildItems.getCancelledItemIfExists(curCancelItem.getLinkedId());
- if (cancelledItem != null) {
- throw new IllegalStateException(String.format("Invalid cancelledItem=%s for cancelItem=%s", cancelledItem, curCancelItem));
- }
+ Preconditions.checkState(cancelledItem == null, "Invalid cancelledItem=%s for cancelItem=%s", cancelledItem, curCancelItem);
}
});
}
@@ -339,8 +292,25 @@ public class ItemsNodeInterval extends NodeInterval {
return cancelledItem != null;
}
});
- if (nodeIntervalForCancelledItem == null) {
- throw new IllegalStateException(String.format("Missing cancelledItem for cancelItem=%s", curCancelItem));
+ Preconditions.checkState(nodeIntervalForCancelledItem != null, "Missing cancelledItem for cancelItem=%s", curCancelItem);
+ }
+
+ for (final Item curAddItem : curNodeItems.get_ADD_items()) {
+ // Sanity: verify the item hasn't been adjusted too much
+ if (curNode.getLeftChild() != null) {
+ final AtomicReference<BigDecimal> totalRepaired = new AtomicReference<BigDecimal>(BigDecimal.ZERO);
+ curNode.getLeftChild()
+ .walkTree(new WalkCallback() {
+ @Override
+ public void onCurrentNode(final int depth, final NodeInterval curNode, final NodeInterval parent) {
+ final ItemsInterval curChildItems = ((ItemsNodeInterval) curNode).getItemsInterval();
+ final Item cancelledItem = curChildItems.getCancellingItemIfExists(curAddItem.getId());
+ if (cancelledItem != null && curAddItem.getId().equals(cancelledItem.getLinkedId())) {
+ totalRepaired.set(totalRepaired.get().add(cancelledItem.getAmount()));
+ }
+ }
+ });
+ Preconditions.checkState(curAddItem.getNetAmount().compareTo(totalRepaired.get()) >= 0, "Item %s overly repaired", curAddItem);
}
}
@@ -379,7 +349,7 @@ public class ItemsNodeInterval extends NodeInterval {
if (foundFullRepairByParts) {
for (ItemsInterval curItemsInterval : childrenCancellingToBeRemoved.keySet()) {
curItemsInterval.remove(childrenCancellingToBeRemoved.get(curItemsInterval));
- if (curItemsInterval.size() == 0) {
+ if (curItemsInterval.getItems().isEmpty()) {
curNode.removeChild(curItemsInterval.getNodeInterval());
}
}
@@ -393,4 +363,45 @@ public class ItemsNodeInterval extends NodeInterval {
}
});
}
+
+ @VisibleForTesting
+ public void jsonSerializeTree(final ObjectMapper mapper, final OutputStream output) throws IOException {
+ final JsonGenerator generator = mapper.getFactory().createGenerator(output);
+ generator.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false);
+
+ walkTree(new WalkCallback() {
+
+ private int curDepth = 0;
+
+ @Override
+ public void onCurrentNode(final int depth, final NodeInterval curNode, final NodeInterval parent) {
+ final ItemsNodeInterval node = (ItemsNodeInterval) curNode;
+ if (node.isRoot()) {
+ return;
+ }
+
+ try {
+ if (curDepth < depth) {
+ generator.writeStartArray();
+ curDepth = depth;
+ } else if (curDepth > depth) {
+ generator.writeEndArray();
+ curDepth = depth;
+ }
+ generator.writeObject(node);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to deserialize tree", e);
+ }
+ }
+ });
+ generator.close();
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder("ItemsNodeInterval{");
+ sb.append("items=").append(items);
+ sb.append('}');
+ return sb.toString();
+ }
}
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/tree/SubscriptionItemTree.java b/invoice/src/main/java/org/killbill/billing/invoice/tree/SubscriptionItemTree.java
index 70effcf..9c90462 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/tree/SubscriptionItemTree.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/tree/SubscriptionItemTree.java
@@ -38,21 +38,22 @@ import com.google.common.collect.Iterables;
import com.google.common.collect.Ordering;
/**
- * Tree of invoice items for a given subscription.
+ * Tree of invoice items for a given subscription
*/
public class SubscriptionItemTree {
+ private final List<Item> items = new LinkedList<Item>();
+ private final List<Item> existingFullyAdjustedItems = new LinkedList<Item>();
+ private final List<InvoiceItem> existingFixedItems = new LinkedList<InvoiceItem>();
+ private final Map<LocalDate, InvoiceItem> remainingFixedItems = new HashMap<LocalDate, InvoiceItem>();
+ private final List<InvoiceItem> pendingItemAdj = new LinkedList<InvoiceItem>();
+
private final UUID targetInvoiceId;
private final UUID subscriptionId;
- private ItemsNodeInterval root;
- private boolean isBuilt;
- private boolean isMerged;
- private List<Item> items;
- private List<Item> existingFullyAdjustedItems;
- private List<InvoiceItem> existingFixedItems;
- private Map<LocalDate, InvoiceItem> remainingFixedItems;
- private List<InvoiceItem> pendingItemAdj;
+ private ItemsNodeInterval root =new ItemsNodeInterval();
+ private boolean isBuilt = false;
+ private boolean isMerged = false;
private static final Comparator<InvoiceItem> INVOICE_ITEM_COMPARATOR = new Comparator<InvoiceItem>() {
@Override
@@ -74,32 +75,57 @@ public class SubscriptionItemTree {
}
};
+ // targetInvoiceId is the new invoice id being generated
public SubscriptionItemTree(final UUID subscriptionId, final UUID targetInvoiceId) {
this.subscriptionId = subscriptionId;
this.targetInvoiceId = targetInvoiceId;
- this.root = new ItemsNodeInterval(targetInvoiceId);
- this.items = new LinkedList<Item>();
- this.existingFullyAdjustedItems = new LinkedList<Item>();
- this.existingFixedItems = new LinkedList<InvoiceItem>();
- this.remainingFixedItems = new HashMap<LocalDate, InvoiceItem>();
- this.pendingItemAdj = new LinkedList<InvoiceItem>();
- this.isBuilt = false;
}
/**
- * Build the tree to return the list of existing items.
+ * Add an existing item in the tree. A new node is inserted or an existing one updated, if one for the same period already exists.
+ *
+ * @param invoiceItem new existing invoice item on disk.
+ */
+ public void addItem(final InvoiceItem invoiceItem) {
+ Preconditions.checkState(!isBuilt, "Tree already built, unable to add new invoiceItem=%s", invoiceItem);
+
+ switch (invoiceItem.getInvoiceItemType()) {
+ case RECURRING:
+ root.addExistingItem(new ItemsNodeInterval(root, new Item(invoiceItem, targetInvoiceId, ItemAction.ADD)));
+ break;
+
+ case REPAIR_ADJ:
+ root.addExistingItem(new ItemsNodeInterval(root, new Item(invoiceItem, targetInvoiceId, ItemAction.CANCEL)));
+ break;
+
+ case FIXED:
+ existingFixedItems.add(invoiceItem);
+ break;
+
+ case ITEM_ADJ:
+ pendingItemAdj.add(invoiceItem);
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Build the tree and process adjustments
*/
public void build() {
Preconditions.checkState(!isBuilt);
- for (InvoiceItem item : pendingItemAdj) {
- final Item fullyAdjustedItem = root.addAdjustment(item);
+ for (final InvoiceItem item : pendingItemAdj) {
+ final Item fullyAdjustedItem = root.addAdjustment(item, targetInvoiceId);
if (fullyAdjustedItem != null) {
existingFullyAdjustedItems.add(fullyAdjustedItem);
}
}
pendingItemAdj.clear();
- root.buildForExistingItems(items);
+
+ root.buildForExistingItems(items, targetInvoiceId);
isBuilt = true;
}
@@ -110,68 +136,32 @@ public class SubscriptionItemTree {
*
* @param reverse whether to reverse the existing items (recurring items now show up as CANCEL instead of ADD)
*/
- public void flatten(boolean reverse) {
+ public void flatten(final boolean reverse) {
if (!isBuilt) {
build();
}
- root = new ItemsNodeInterval(targetInvoiceId);
- for (Item item : items) {
+
+ root = new ItemsNodeInterval();
+ for (final Item item : items) {
Preconditions.checkState(item.getAction() == ItemAction.ADD);
- root.addExistingItem(new ItemsNodeInterval(root, targetInvoiceId, new Item(item, reverse ? ItemAction.CANCEL : ItemAction.ADD)));
+ root.addExistingItem(new ItemsNodeInterval(root, new Item(item, reverse ? ItemAction.CANCEL : ItemAction.ADD)));
}
items.clear();
isBuilt = false;
}
- public void buildForMerge() {
- Preconditions.checkState(!isBuilt);
- root.mergeExistingAndProposed(items);
- isBuilt = true;
- isMerged = true;
- }
-
/**
- * Add an existing item in the tree.
- *
- * @param invoiceItem new existing invoice item on disk.
- */
- public void addItem(final InvoiceItem invoiceItem) {
-
- Preconditions.checkState(!isBuilt);
- switch (invoiceItem.getInvoiceItemType()) {
- case RECURRING:
- root.addExistingItem(new ItemsNodeInterval(root, targetInvoiceId, new Item(invoiceItem, targetInvoiceId, ItemAction.ADD)));
- break;
-
- case REPAIR_ADJ:
- root.addExistingItem(new ItemsNodeInterval(root, targetInvoiceId, new Item(invoiceItem, targetInvoiceId, ItemAction.CANCEL)));
- break;
-
- case FIXED:
- existingFixedItems.add(invoiceItem);
- break;
-
- case ITEM_ADJ:
- pendingItemAdj.add(invoiceItem);
- break;
-
- default:
- break;
- }
- }
-
- /**
- * Merge a new proposed ietm in the tree.
+ * Merge a new proposed item in the tree.
*
* @param invoiceItem new proposed item that should be merged in the existing tree
*/
public void mergeProposedItem(final InvoiceItem invoiceItem) {
+ Preconditions.checkState(!isBuilt, "Tree already built, unable to add new invoiceItem=%s", invoiceItem);
- Preconditions.checkState(!isBuilt);
switch (invoiceItem.getInvoiceItemType()) {
case RECURRING:
- final boolean result = root.addProposedItem(new ItemsNodeInterval(root, targetInvoiceId, new Item(invoiceItem, targetInvoiceId, ItemAction.ADD)));
- if (!result) {
+ final boolean merged = root.addProposedItem(new ItemsNodeInterval(root, new Item(invoiceItem, targetInvoiceId, ItemAction.ADD)));
+ if (!merged) {
items.add(new Item(invoiceItem, targetInvoiceId, ItemAction.ADD));
}
break;
@@ -191,7 +181,14 @@ public class SubscriptionItemTree {
default:
Preconditions.checkState(false, "Unexpected proposed item " + invoiceItem);
}
+ }
+ // Build tree post merge
+ public void buildForMerge() {
+ Preconditions.checkState(!isBuilt, "Tree already built");
+ root.mergeExistingAndProposed(items, targetInvoiceId);
+ isBuilt = true;
+ isMerged = true;
}
/**
@@ -200,6 +197,7 @@ public class SubscriptionItemTree {
* <li>When called prior, the merge this gives a flat view of the existing items on disk
* <li>When called after the merge with proposed items, this gives the list of items that should now be written to disk -- new fixed, recurring and repair.
* </ul>
+ *
* @return a flat view of the items in the tree.
*/
public List<InvoiceItem> getView() {
@@ -269,10 +267,6 @@ public class SubscriptionItemTree {
}
}
- public UUID getSubscriptionId() {
- return subscriptionId;
- }
-
@Override
public String toString() {
final StringBuilder sb = new StringBuilder("SubscriptionItemTree{");
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/generator/TestDefaultInvoiceGenerator.java b/invoice/src/test/java/org/killbill/billing/invoice/generator/TestDefaultInvoiceGenerator.java
index e6eb41f..bdf3a39 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/generator/TestDefaultInvoiceGenerator.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/generator/TestDefaultInvoiceGenerator.java
@@ -110,68 +110,6 @@ public class TestDefaultInvoiceGenerator extends InvoiceTestSuiteNoDB {
protected void beforeClass() throws Exception {
super.beforeClass();
final Clock clock = new DefaultClock();
- final InvoiceConfig invoiceConfig = new InvoiceConfig() {
-
- @Override
- public int getNumberOfMonthsInFuture() {
- return 36;
- }
-
- @Override
- public int getNumberOfMonthsInFuture(final InternalTenantContext context) {
- return getNumberOfMonthsInFuture();
- }
-
- @Override
- public boolean isSanitySafetyBoundEnabled() {
- return true;
- }
-
- @Override
- public boolean isSanitySafetyBoundEnabled(final InternalTenantContext tenantContext) {
- return true;
- }
-
- @Override
- public int getMaxDailyNumberOfItemsSafetyBound() {
- return 10;
- }
-
- @Override
- public int getMaxDailyNumberOfItemsSafetyBound(final InternalTenantContext tenantContext) {
- return 10;
- }
-
- @Override
- public boolean isEmailNotificationsEnabled() {
- return false;
- }
-
- @Override
- public TimeSpan getDryRunNotificationSchedule() {
- return new TimeSpan("0s");
- }
-
- @Override
- public TimeSpan getDryRunNotificationSchedule(final InternalTenantContext context) {
- return getDryRunNotificationSchedule();
- }
-
- @Override
- public int getMaxRawUsagePreviousPeriod() {
- return -1;
- }
-
- @Override
- public int getMaxRawUsagePreviousPeriod(final InternalTenantContext context) {
- return getMaxRawUsagePreviousPeriod();
- }
-
- @Override
- public int getMaxGlobalLockRetries() {
- return 10;
- }
- };
this.account = new MockAccountBuilder().name(UUID.randomUUID().toString().substring(1, 8))
.firstNameLength(6)
.email(UUID.randomUUID().toString().substring(1, 8))
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/generator/TestFixedAndRecurringInvoiceItemGenerator.java b/invoice/src/test/java/org/killbill/billing/invoice/generator/TestFixedAndRecurringInvoiceItemGenerator.java
index 26d30ff..e340aab 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/generator/TestFixedAndRecurringInvoiceItemGenerator.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/generator/TestFixedAndRecurringInvoiceItemGenerator.java
@@ -561,6 +561,87 @@ public class TestFixedAndRecurringInvoiceItemGenerator extends InvoiceTestSuiteN
}
@Test(groups = "fast", description = "https://github.com/killbill/killbill/issues/664")
+ public void testOverlappingExistingItems() throws InvoiceApiException {
+ final LocalDate startDate = new LocalDate("2016-01-01");
+
+ final BillingEventSet events = new MockBillingEventSet();
+ final BigDecimal amount = BigDecimal.TEN;
+ final MockInternationalPrice price = new MockInternationalPrice(new DefaultPrice(amount, account.getCurrency()));
+ final Plan plan = new MockPlan("my-plan");
+ final PlanPhase planPhase = new MockPlanPhase(price, null, BillingPeriod.MONTHLY, PhaseType.EVERGREEN);
+ final BillingEvent event = invoiceUtil.createMockBillingEvent(account,
+ subscription,
+ startDate.toDateTimeAtStartOfDay(),
+ plan,
+ planPhase,
+ null,
+ amount,
+ account.getCurrency(),
+ BillingPeriod.MONTHLY,
+ 1,
+ BillingMode.IN_ADVANCE,
+ "Billing Event Desc",
+ 1L,
+ SubscriptionBaseTransitionType.CREATE);
+ events.add(event);
+
+ // Simulate a previous mis-bill: existing item is for [2016-01-01,2016-01-30], proposed will be for [2016-01-01,2016-02-01]
+ final List<Invoice> existingInvoices = new LinkedList<Invoice>();
+ final Invoice invoice = new DefaultInvoice(account.getId(), clock.getUTCToday(), startDate, account.getCurrency());
+ invoice.addInvoiceItem(new RecurringInvoiceItem(UUID.randomUUID(),
+ startDate.toDateTimeAtStartOfDay(),
+ invoice.getId(),
+ account.getId(),
+ subscription.getBundleId(),
+ subscription.getId(),
+ event.getPlan().getName(),
+ event.getPlanPhase().getName(),
+ startDate,
+ startDate.plusDays(29),
+ amount,
+ amount,
+ account.getCurrency()));
+ // Correct one already generated
+ invoice.addInvoiceItem(new RecurringInvoiceItem(UUID.randomUUID(),
+ startDate.toDateTimeAtStartOfDay(),
+ invoice.getId(),
+ account.getId(),
+ subscription.getBundleId(),
+ subscription.getId(),
+ event.getPlan().getName(),
+ event.getPlanPhase().getName(),
+ startDate,
+ startDate.plusMonths(1),
+ amount,
+ amount,
+ account.getCurrency()));
+ existingInvoices.add(invoice);
+
+ try {
+ // There will be one proposed item but the tree will refuse the merge because of the bad state on disk
+ final List<InvoiceItem> generatedItems = fixedAndRecurringInvoiceItemGenerator.generateItems(account,
+ UUID.randomUUID(),
+ events,
+ existingInvoices,
+ startDate,
+ account.getCurrency(),
+ new HashMap<UUID, SubscriptionFutureNotificationDates>(),
+ internalCallContext);
+
+ // Maybe we could auto-fix-it one day?
+ // assertEquals(generatedItems.size(), 1);
+ // assertTrue(generatedItems.get(0) instanceof RepairAdjInvoiceItem);
+ // assertEquals(generatedItems.get(0).getAmount().compareTo(amount.negate()), 0);
+ // assertEquals(generatedItems.get(0).getLinkedItemId(), invoice.getInvoiceItems().get(0).getId());
+
+ fail();
+ } catch (final InvoiceApiException e) {
+ assertEquals(e.getCode(), ErrorCode.UNEXPECTED_ERROR.getCode());
+ assertTrue(e.getCause().getMessage().startsWith("Double billing detected"));
+ }
+ }
+
+ @Test(groups = "fast", description = "https://github.com/killbill/killbill/issues/664")
public void testOverlappingItemsWithRepair() throws InvoiceApiException {
final LocalDate startDate = new LocalDate("2016-01-01");
@@ -904,6 +985,263 @@ public class TestFixedAndRecurringInvoiceItemGenerator extends InvoiceTestSuiteN
}
}
+ @Test(groups = "fast", description = "https://github.com/killbill/killbill/issues/664")
+ public void testItemPartiallyRepairedAndPartiallyAdjusted() throws InvoiceApiException {
+ final LocalDate startDate = new LocalDate("2016-01-01");
+
+ final BillingEventSet events = new MockBillingEventSet();
+ final BigDecimal amount = BigDecimal.TEN;
+ final MockInternationalPrice price = new MockInternationalPrice(new DefaultPrice(amount, account.getCurrency()));
+ final Plan plan = new MockPlan("my-plan");
+ final PlanPhase planPhase = new MockPlanPhase(price, null, BillingPeriod.MONTHLY, PhaseType.EVERGREEN);
+ final BillingEvent event1 = invoiceUtil.createMockBillingEvent(account,
+ subscription,
+ startDate.toDateTimeAtStartOfDay(),
+ plan,
+ planPhase,
+ null,
+ amount,
+ account.getCurrency(),
+ BillingPeriod.MONTHLY,
+ 1,
+ BillingMode.IN_ADVANCE,
+ "Billing Event Desc",
+ 1L,
+ SubscriptionBaseTransitionType.CREATE);
+ events.add(event1);
+ final BillingEvent event2 = invoiceUtil.createMockBillingEvent(account,
+ subscription,
+ startDate.plusDays(1).toDateTimeAtStartOfDay(),
+ plan,
+ planPhase,
+ null,
+ null,
+ Currency.USD,
+ BillingPeriod.NO_BILLING_PERIOD,
+ 1,
+ BillingMode.IN_ADVANCE,
+ "Billing Event Desc",
+ 2L,
+ SubscriptionBaseTransitionType.CANCEL);
+ events.add(event2);
+
+ // Subscription incorrectly invoiced
+ final List<Invoice> existingInvoices = new LinkedList<Invoice>();
+ final Invoice invoice = new DefaultInvoice(account.getId(), clock.getUTCToday(), startDate, account.getCurrency());
+ invoice.addInvoiceItem(new RecurringInvoiceItem(UUID.randomUUID(),
+ startDate.toDateTimeAtStartOfDay(),
+ invoice.getId(),
+ account.getId(),
+ subscription.getBundleId(),
+ subscription.getId(),
+ plan.getName(),
+ planPhase.getName(),
+ startDate,
+ startDate.plusMonths(1),
+ amount,
+ amount,
+ account.getCurrency()));
+ // Repaired by the system
+ invoice.addInvoiceItem(new RepairAdjInvoiceItem(UUID.randomUUID(),
+ startDate.toDateTimeAtStartOfDay(),
+ invoice.getId(),
+ account.getId(),
+ startDate.plusDays(1),
+ startDate.plusMonths(1),
+ new BigDecimal("9.68").negate(),
+ account.getCurrency(),
+ invoice.getInvoiceItems().get(0).getId()));
+ // Item adjust the remaining
+ invoice.addInvoiceItem(new ItemAdjInvoiceItem(invoice.getInvoiceItems().get(0),
+ startDate,
+ new BigDecimal("0.32").negate(),
+ account.getCurrency()));
+ existingInvoices.add(invoice);
+
+ final List<InvoiceItem> generatedItems = fixedAndRecurringInvoiceItemGenerator.generateItems(account,
+ UUID.randomUUID(),
+ events,
+ existingInvoices,
+ startDate,
+ account.getCurrency(),
+ new HashMap<UUID, SubscriptionFutureNotificationDates>(),
+ internalCallContext);
+ assertTrue(generatedItems.isEmpty());
+ }
+
+ @Test(groups = "fast", description = "https://github.com/killbill/killbill/issues/664")
+ public void testItemPartiallyRepairedAndPartiallyAdjustedV2() throws InvoiceApiException {
+ final LocalDate startDate = new LocalDate("2016-01-01");
+
+ final BillingEventSet events = new MockBillingEventSet();
+ final BigDecimal amount = BigDecimal.TEN;
+ final MockInternationalPrice price = new MockInternationalPrice(new DefaultPrice(amount, account.getCurrency()));
+ final Plan plan = new MockPlan("my-plan");
+ final PlanPhase planPhase = new MockPlanPhase(price, null, BillingPeriod.MONTHLY, PhaseType.EVERGREEN);
+ final BillingEvent event1 = invoiceUtil.createMockBillingEvent(account,
+ subscription,
+ startDate.toDateTimeAtStartOfDay(),
+ plan,
+ planPhase,
+ null,
+ amount,
+ account.getCurrency(),
+ BillingPeriod.MONTHLY,
+ 1,
+ BillingMode.IN_ADVANCE,
+ "Billing Event Desc",
+ 1L,
+ SubscriptionBaseTransitionType.CREATE);
+ events.add(event1);
+ final BillingEvent event2 = invoiceUtil.createMockBillingEvent(account,
+ subscription,
+ startDate.plusDays(1).toDateTimeAtStartOfDay(),
+ plan,
+ planPhase,
+ null,
+ null,
+ Currency.USD,
+ BillingPeriod.NO_BILLING_PERIOD,
+ 1,
+ BillingMode.IN_ADVANCE,
+ "Billing Event Desc",
+ 2L,
+ SubscriptionBaseTransitionType.CANCEL);
+ events.add(event2);
+
+ // Subscription incorrectly invoiced
+ final List<Invoice> existingInvoices = new LinkedList<Invoice>();
+ final Invoice invoice = new DefaultInvoice(account.getId(), clock.getUTCToday(), startDate, account.getCurrency());
+ invoice.addInvoiceItem(new RecurringInvoiceItem(UUID.randomUUID(),
+ startDate.toDateTimeAtStartOfDay(),
+ invoice.getId(),
+ account.getId(),
+ subscription.getBundleId(),
+ subscription.getId(),
+ plan.getName(),
+ planPhase.getName(),
+ startDate,
+ startDate.plusMonths(1),
+ amount,
+ amount,
+ account.getCurrency()));
+ // Item adjust the remaining
+ invoice.addInvoiceItem(new ItemAdjInvoiceItem(invoice.getInvoiceItems().get(0),
+ startDate,
+ BigDecimal.ONE.negate(),
+ account.getCurrency()));
+ // Repaired by the system (the system would have consumed all the remaining amount available)
+ invoice.addInvoiceItem(new RepairAdjInvoiceItem(UUID.randomUUID(),
+ startDate.toDateTimeAtStartOfDay(),
+ invoice.getId(),
+ account.getId(),
+ startDate.plusDays(1),
+ startDate.plusMonths(1),
+ new BigDecimal("9").negate(),
+ account.getCurrency(),
+ invoice.getInvoiceItems().get(0).getId()));
+ existingInvoices.add(invoice);
+
+ final List<InvoiceItem> generatedItems = fixedAndRecurringInvoiceItemGenerator.generateItems(account,
+ UUID.randomUUID(),
+ events,
+ existingInvoices,
+ startDate,
+ account.getCurrency(),
+ new HashMap<UUID, SubscriptionFutureNotificationDates>(),
+ internalCallContext);
+ assertTrue(generatedItems.isEmpty());
+ }
+
+ @Test(groups = "fast", description = "https://github.com/killbill/killbill/issues/664")
+ public void testItemPartiallyRepairedAndInvalidAdjustment() throws InvoiceApiException {
+ final LocalDate startDate = new LocalDate("2016-01-01");
+
+ final BillingEventSet events = new MockBillingEventSet();
+ final BigDecimal amount = BigDecimal.TEN;
+ final MockInternationalPrice price = new MockInternationalPrice(new DefaultPrice(amount, account.getCurrency()));
+ final Plan plan = new MockPlan("my-plan");
+ final PlanPhase planPhase = new MockPlanPhase(price, null, BillingPeriod.MONTHLY, PhaseType.EVERGREEN);
+ final BillingEvent event1 = invoiceUtil.createMockBillingEvent(account,
+ subscription,
+ startDate.toDateTimeAtStartOfDay(),
+ plan,
+ planPhase,
+ null,
+ amount,
+ account.getCurrency(),
+ BillingPeriod.MONTHLY,
+ 1,
+ BillingMode.IN_ADVANCE,
+ "Billing Event Desc",
+ 1L,
+ SubscriptionBaseTransitionType.CREATE);
+ events.add(event1);
+ final BillingEvent event2 = invoiceUtil.createMockBillingEvent(account,
+ subscription,
+ startDate.plusDays(1).toDateTimeAtStartOfDay(),
+ plan,
+ planPhase,
+ null,
+ null,
+ Currency.USD,
+ BillingPeriod.NO_BILLING_PERIOD,
+ 1,
+ BillingMode.IN_ADVANCE,
+ "Billing Event Desc",
+ 2L,
+ SubscriptionBaseTransitionType.CANCEL);
+ events.add(event2);
+
+ // Subscription incorrectly invoiced
+ final List<Invoice> existingInvoices = new LinkedList<Invoice>();
+ final Invoice invoice = new DefaultInvoice(account.getId(), clock.getUTCToday(), startDate, account.getCurrency());
+ invoice.addInvoiceItem(new RecurringInvoiceItem(UUID.randomUUID(),
+ startDate.toDateTimeAtStartOfDay(),
+ invoice.getId(),
+ account.getId(),
+ subscription.getBundleId(),
+ subscription.getId(),
+ plan.getName(),
+ planPhase.getName(),
+ startDate,
+ startDate.plusMonths(1),
+ amount,
+ amount,
+ account.getCurrency()));
+ // Repaired by the system
+ invoice.addInvoiceItem(new RepairAdjInvoiceItem(UUID.randomUUID(),
+ startDate.toDateTimeAtStartOfDay(),
+ invoice.getId(),
+ account.getId(),
+ startDate.plusDays(1),
+ startDate.plusMonths(1),
+ new BigDecimal("9.68").negate(),
+ account.getCurrency(),
+ invoice.getInvoiceItems().get(0).getId()));
+ // Invalid adjustment (too much)
+ invoice.addInvoiceItem(new ItemAdjInvoiceItem(invoice.getInvoiceItems().get(0),
+ startDate,
+ new BigDecimal("9.68").negate(),
+ account.getCurrency()));
+ existingInvoices.add(invoice);
+
+ try {
+ final List<InvoiceItem> generatedItems = fixedAndRecurringInvoiceItemGenerator.generateItems(account,
+ UUID.randomUUID(),
+ events,
+ existingInvoices,
+ startDate,
+ account.getCurrency(),
+ new HashMap<UUID, SubscriptionFutureNotificationDates>(),
+ internalCallContext);
+ fail();
+ } catch (final InvoiceApiException e) {
+ assertEquals(e.getCode(), ErrorCode.UNEXPECTED_ERROR.getCode());
+ assertTrue(e.getCause().getMessage().endsWith("overly repaired"));
+ }
+ }
+
// Simulate a bug in the generator where two fixed items for the same day and subscription end up in the resulting items
@Test(groups = "fast", description = "https://github.com/killbill/killbill/issues/664")
public void testTooManyFixedInvoiceItemsForGivenSubscriptionAndStartDatePostMerge() throws InvoiceApiException {
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModule.java b/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModule.java
index 049446e..bc4ddeb 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModule.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModule.java
@@ -60,7 +60,6 @@ public class TestInvoiceModule extends DefaultInvoiceModule {
install(new MockTenantModule(configSource));
- install(new TagStoreModule(configSource));
install(new CustomFieldModule(configSource));
install(new UsageModule(configSource));
installExternalApis();
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModuleNoDB.java b/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModuleNoDB.java
index cf36e03..278a808 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModuleNoDB.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModuleNoDB.java
@@ -37,6 +37,7 @@ import org.killbill.billing.invoice.dao.MockInvoiceDao;
import org.killbill.billing.mock.api.MockAccountUserApi;
import org.killbill.billing.mock.glue.MockAccountModule;
import org.killbill.billing.mock.glue.MockNonEntityDaoModule;
+import org.killbill.billing.mock.glue.MockTagModule;
import org.killbill.billing.platform.api.KillbillConfigSource;
import org.mockito.Mockito;
@@ -55,7 +56,7 @@ public class TestInvoiceModuleNoDB extends TestInvoiceModule {
super.configure();
install(new GuicyKillbillTestNoDBModule(configSource));
install(new MockNonEntityDaoModule(configSource));
-
+ install(new MockTagModule(configSource));
install(new MockAccountModule(configSource));
installCurrencyConversionApi();
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModuleWithEmbeddedDb.java b/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModuleWithEmbeddedDb.java
index 0e161e0..626a257 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModuleWithEmbeddedDb.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/glue/TestInvoiceModuleWithEmbeddedDb.java
@@ -25,6 +25,7 @@ import org.killbill.billing.invoice.InvoiceListener;
import org.killbill.billing.invoice.TestInvoiceNotificationQListener;
import org.killbill.billing.platform.api.KillbillConfigSource;
import org.killbill.billing.util.glue.NonEntityDaoModule;
+import org.killbill.billing.util.glue.TagStoreModule;
import org.mockito.Mockito;
public class TestInvoiceModuleWithEmbeddedDb extends TestInvoiceModule {
@@ -45,6 +46,7 @@ public class TestInvoiceModuleWithEmbeddedDb extends TestInvoiceModule {
install(new DefaultAccountModule(configSource));
install(new GuicyKillbillTestWithEmbeddedDBModule(configSource));
install(new NonEntityDaoModule(configSource));
+ install(new TagStoreModule(configSource));
bind(CurrencyConversionApi.class).toInstance(Mockito.mock(CurrencyConversionApi.class));
}
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/InvoiceTestSuiteWithEmbeddedDB.java b/invoice/src/test/java/org/killbill/billing/invoice/InvoiceTestSuiteWithEmbeddedDB.java
index 487fd07..f5eceaa 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/InvoiceTestSuiteWithEmbeddedDB.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/InvoiceTestSuiteWithEmbeddedDB.java
@@ -109,6 +109,8 @@ public abstract class InvoiceTestSuiteWithEmbeddedDB extends GuicyKillbillTestSu
protected InvoicePluginDispatcher invoicePluginDispatcher;
@Inject
protected InvoiceConfig invoiceConfig;
+ @Inject
+ protected ParkedAccountsManager parkedAccountsManager;
@Override
protected KillbillConfigSource getConfigSource() {
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceDispatcher.java b/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceDispatcher.java
index cfedd3c..36a536b 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceDispatcher.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceDispatcher.java
@@ -24,6 +24,7 @@ import java.util.UUID;
import org.joda.time.DateTime;
import org.joda.time.LocalDate;
+import org.killbill.billing.ErrorCode;
import org.killbill.billing.account.api.Account;
import org.killbill.billing.account.api.AccountApiException;
import org.killbill.billing.callcontext.InternalCallContext;
@@ -36,6 +37,8 @@ import org.killbill.billing.catalog.api.Currency;
import org.killbill.billing.catalog.api.PhaseType;
import org.killbill.billing.catalog.api.Plan;
import org.killbill.billing.catalog.api.PlanPhase;
+import org.killbill.billing.invoice.InvoiceDispatcher.FutureAccountNotifications;
+import org.killbill.billing.invoice.InvoiceDispatcher.FutureAccountNotifications.SubscriptionNotification;
import org.killbill.billing.invoice.TestInvoiceHelper.DryRunFutureDateArguments;
import org.killbill.billing.invoice.api.DryRunArguments;
import org.killbill.billing.invoice.api.Invoice;
@@ -43,17 +46,26 @@ import org.killbill.billing.invoice.api.InvoiceApiException;
import org.killbill.billing.invoice.api.InvoiceItem;
import org.killbill.billing.invoice.api.InvoiceItemType;
import org.killbill.billing.invoice.api.InvoiceNotifier;
+import org.killbill.billing.invoice.dao.InvoiceItemModelDao;
import org.killbill.billing.invoice.dao.InvoiceModelDao;
import org.killbill.billing.invoice.notification.NullInvoiceNotifier;
import org.killbill.billing.junction.BillingEventSet;
import org.killbill.billing.subscription.api.SubscriptionBase;
import org.killbill.billing.subscription.api.SubscriptionBaseTransitionType;
import org.killbill.billing.subscription.api.user.SubscriptionBaseApiException;
+import org.killbill.billing.util.api.TagDefinitionApiException;
+import org.killbill.billing.util.tag.Tag;
+import org.killbill.billing.util.tag.dao.SystemTags;
import org.mockito.Mockito;
+import org.skife.jdbi.v2.Handle;
+import org.skife.jdbi.v2.tweak.HandleCallback;
import org.testng.Assert;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
public class TestInvoiceDispatcher extends InvoiceTestSuiteWithEmbeddedDB {
private Account account;
@@ -90,7 +102,7 @@ public class TestInvoiceDispatcher extends InvoiceTestSuiteWithEmbeddedDB {
final InvoiceNotifier invoiceNotifier = new NullInvoiceNotifier();
final InvoiceDispatcher dispatcher = new InvoiceDispatcher(generator, accountApi, billingApi, subscriptionApi, invoiceDao,
internalCallContextFactory, invoiceNotifier, invoicePluginDispatcher, locker, busService.getBus(),
- null, invoiceConfig, clock);
+ null, invoiceConfig, clock, parkedAccountsManager);
Invoice invoice = dispatcher.processAccount(accountId, target, new DryRunFutureDateArguments(), context);
Assert.assertNotNull(invoice);
@@ -114,6 +126,146 @@ public class TestInvoiceDispatcher extends InvoiceTestSuiteWithEmbeddedDB {
}
@Test(groups = "slow")
+ public void testWithParking() throws InvoiceApiException, AccountApiException, CatalogApiException, SubscriptionBaseApiException, TagDefinitionApiException {
+ final UUID accountId = account.getId();
+
+ final BillingEventSet events = new MockBillingEventSet();
+ final Plan plan = MockPlan.createBicycleNoTrialEvergreen1USD();
+ final PlanPhase planPhase = MockPlanPhase.create1USDMonthlyEvergreen();
+ final DateTime effectiveDate = clock.getUTCNow().minusDays(1);
+ final Currency currency = Currency.USD;
+ final BigDecimal fixedPrice = null;
+ events.add(invoiceUtil.createMockBillingEvent(account, subscription, effectiveDate, plan, planPhase,
+ fixedPrice, BigDecimal.ONE, currency, BillingPeriod.MONTHLY, 1,
+ BillingMode.IN_ADVANCE, "", 1L, SubscriptionBaseTransitionType.CREATE));
+
+ Mockito.when(billingApi.getBillingEventsForAccountAndUpdateAccountBCD(Mockito.<UUID>any(), Mockito.<DryRunArguments>any(), Mockito.<InternalCallContext>any())).thenReturn(events);
+
+ final LocalDate target = internalCallContext.toLocalDate(effectiveDate);
+
+ final InvoiceNotifier invoiceNotifier = new NullInvoiceNotifier();
+ final InvoiceDispatcher dispatcher = new InvoiceDispatcher(generator, accountApi, billingApi, subscriptionApi, invoiceDao,
+ internalCallContextFactory, invoiceNotifier, invoicePluginDispatcher, locker, busService.getBus(),
+ null, invoiceConfig, clock, parkedAccountsManager);
+
+ // Verify initial tags state for account
+ Assert.assertTrue(tagUserApi.getTagsForAccount(accountId, true, callContext).isEmpty());
+
+ // Create chaos on disk
+ final InvoiceModelDao invoiceModelDao = new InvoiceModelDao(accountId,
+ target,
+ target,
+ currency,
+ false);
+ final InvoiceItemModelDao invoiceItemModelDao1 = new InvoiceItemModelDao(clock.getUTCNow(),
+ InvoiceItemType.RECURRING,
+ invoiceModelDao.getId(),
+ accountId,
+ subscription.getBundleId(),
+ subscription.getId(),
+ "Bad data",
+ plan.getName(),
+ planPhase.getName(),
+ null,
+ effectiveDate.toLocalDate(),
+ effectiveDate.plusMonths(1).toLocalDate(),
+ BigDecimal.TEN,
+ BigDecimal.ONE,
+ currency,
+ null);
+ final InvoiceItemModelDao invoiceItemModelDao2 = new InvoiceItemModelDao(clock.getUTCNow(),
+ InvoiceItemType.RECURRING,
+ invoiceModelDao.getId(),
+ accountId,
+ subscription.getBundleId(),
+ subscription.getId(),
+ "Bad data",
+ plan.getName(),
+ planPhase.getName(),
+ null,
+ effectiveDate.plusDays(1).toLocalDate(),
+ effectiveDate.plusMonths(1).toLocalDate(),
+ BigDecimal.TEN,
+ BigDecimal.ONE,
+ currency,
+ null);
+ invoiceDao.createInvoice(invoiceModelDao,
+ ImmutableList.<InvoiceItemModelDao>of(invoiceItemModelDao1, invoiceItemModelDao2),
+ true,
+ new FutureAccountNotifications(ImmutableMap.<UUID, List<SubscriptionNotification>>of()),
+ context);
+
+ try {
+ dispatcher.processAccount(accountId, target, new DryRunFutureDateArguments(), context);
+ Assert.fail();
+ } catch (final InvoiceApiException e) {
+ Assert.assertEquals(e.getCode(), ErrorCode.UNEXPECTED_ERROR.getCode());
+ Assert.assertTrue(e.getCause().getMessage().startsWith("Double billing detected"));
+ }
+ // Dry-run: no side effect on disk
+ Assert.assertEquals(invoiceDao.getInvoicesByAccount(context).size(), 1);
+ Assert.assertTrue(tagUserApi.getTagsForAccount(accountId, true, callContext).isEmpty());
+
+ try {
+ dispatcher.processAccount(accountId, target, null, context);
+ Assert.fail();
+ } catch (final InvoiceApiException e) {
+ Assert.assertEquals(e.getCode(), ErrorCode.UNEXPECTED_ERROR.getCode());
+ Assert.assertTrue(e.getCause().getMessage().startsWith("Double billing detected"));
+ }
+ Assert.assertEquals(invoiceDao.getInvoicesByAccount(context).size(), 1);
+ // No dry-run: account is parked
+ final List<Tag> tags = tagUserApi.getTagsForAccount(accountId, false, callContext);
+ Assert.assertEquals(tags.size(), 1);
+ Assert.assertEquals(tags.get(0).getTagDefinitionId(), SystemTags.PARK_TAG_DEFINITION_ID);
+
+ // isApiCall=false
+ final Invoice nullInvoice1 = dispatcher.processAccount(accountId, target, null, context);
+ Assert.assertNull(nullInvoice1);
+
+ // No dry-run and isApiCall=true
+ try {
+ dispatcher.processAccount(true, accountId, target, null, context);
+ Assert.fail();
+ } catch (final InvoiceApiException e) {
+ Assert.assertEquals(e.getCode(), ErrorCode.UNEXPECTED_ERROR.getCode());
+ Assert.assertTrue(e.getCause().getMessage().startsWith("Double billing detected"));
+ }
+ // Idempotency
+ Assert.assertEquals(invoiceDao.getInvoicesByAccount(context).size(), 1);
+ Assert.assertEquals(tagUserApi.getTagsForAccount(accountId, false, callContext), tags);
+
+ // Fix state
+ dbi.withHandle(new HandleCallback<Void>() {
+ @Override
+ public Void withHandle(final Handle handle) throws Exception {
+ handle.execute("delete from invoices");
+ handle.execute("delete from invoice_items");
+ return null;
+ }
+ });
+
+ // Dry-run and isApiCall=false: still parked
+ final Invoice nullInvoice2 = dispatcher.processAccount(accountId, target, new DryRunFutureDateArguments(), context);
+ Assert.assertNull(nullInvoice2);
+
+ // Dry-run and isApiCall=true: call goes through
+ final Invoice invoice1 = dispatcher.processAccount(true, accountId, target, new DryRunFutureDateArguments(), context);
+ Assert.assertNotNull(invoice1);
+ Assert.assertEquals(invoiceDao.getInvoicesByAccount(context).size(), 0);
+ // Dry-run: still parked
+ Assert.assertEquals(tagUserApi.getTagsForAccount(accountId, false, callContext).size(), 1);
+
+ // No dry-run and isApiCall=true: call goes through
+ final Invoice invoice2 = dispatcher.processAccount(true, accountId, target, null, context);
+ Assert.assertNotNull(invoice2);
+ Assert.assertEquals(invoiceDao.getInvoicesByAccount(context).size(), 1);
+ // No dry-run: now unparked
+ Assert.assertEquals(tagUserApi.getTagsForAccount(accountId, false, callContext).size(), 0);
+ Assert.assertEquals(tagUserApi.getTagsForAccount(accountId, true, callContext).size(), 1);
+ }
+
+ @Test(groups = "slow")
public void testWithOverdueEvents() throws Exception {
final BillingEventSet events = new MockBillingEventSet();
@@ -143,7 +295,7 @@ public class TestInvoiceDispatcher extends InvoiceTestSuiteWithEmbeddedDB {
final InvoiceNotifier invoiceNotifier = new NullInvoiceNotifier();
final InvoiceDispatcher dispatcher = new InvoiceDispatcher(generator, accountApi, billingApi, subscriptionApi, invoiceDao,
internalCallContextFactory, invoiceNotifier, invoicePluginDispatcher, locker, busService.getBus(),
- null, invoiceConfig, clock);
+ null, invoiceConfig, clock, parkedAccountsManager);
final Invoice invoice = dispatcher.processAccount(account.getId(), new LocalDate("2012-07-30"), null, context);
Assert.assertNotNull(invoice);
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceHelper.java b/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceHelper.java
index 8e22146..a7ec5de 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceHelper.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceHelper.java
@@ -162,6 +162,7 @@ public class TestInvoiceHelper {
private final GlobalLocker locker;
private final Clock clock;
private final NonEntityDao nonEntityDao;
+ private final ParkedAccountsManager parkedAccountsManager;
private final MutableInternalCallContext internalCallContext;
private final InternalCallContextFactory internalCallContextFactory;
private final InvoiceConfig invoiceConfig;
@@ -174,7 +175,7 @@ public class TestInvoiceHelper {
public TestInvoiceHelper(final InvoiceGenerator generator, final IDBI dbi,
final BillingInternalApi billingApi, final AccountInternalApi accountApi, final ImmutableAccountInternalApi immutableAccountApi, final InvoicePluginDispatcher invoicePluginDispatcher, final AccountUserApi accountUserApi, final SubscriptionBaseInternalApi subscriptionApi, final BusService busService,
final InvoiceDao invoiceDao, final GlobalLocker locker, final Clock clock, final NonEntityDao nonEntityDao, final CacheControllerDispatcher cacheControllerDispatcher, final MutableInternalCallContext internalCallContext, final InvoiceConfig invoiceConfig,
- final InternalCallContextFactory internalCallContextFactory) {
+ final ParkedAccountsManager parkedAccountsManager, final InternalCallContextFactory internalCallContextFactory) {
this.generator = generator;
this.billingApi = billingApi;
this.accountApi = accountApi;
@@ -187,6 +188,7 @@ public class TestInvoiceHelper {
this.locker = locker;
this.clock = clock;
this.nonEntityDao = nonEntityDao;
+ this.parkedAccountsManager = parkedAccountsManager;
this.internalCallContext = internalCallContext;
this.internalCallContextFactory = internalCallContextFactory;
this.invoiceItemSqlDao = dbi.onDemand(InvoiceItemSqlDao.class);
@@ -213,7 +215,7 @@ public class TestInvoiceHelper {
final InvoiceNotifier invoiceNotifier = new NullInvoiceNotifier();
final InvoiceDispatcher dispatcher = new InvoiceDispatcher(generator, accountApi, billingApi, subscriptionApi,
invoiceDao, internalCallContextFactory, invoiceNotifier, invoicePluginDispatcher, locker, busService.getBus(),
- null, invoiceConfig, clock);
+ null, invoiceConfig, clock, parkedAccountsManager);
Invoice invoice = dispatcher.processAccount(account.getId(), targetDate, new DryRunFutureDateArguments(), internalCallContext);
Assert.assertNotNull(invoice);
diff --git a/invoice/src/test/java/org/killbill/billing/invoice/tree/TestSubscriptionItemTree.java b/invoice/src/test/java/org/killbill/billing/invoice/tree/TestSubscriptionItemTree.java
index 4c4688c..9f592b3 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/tree/TestSubscriptionItemTree.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/tree/TestSubscriptionItemTree.java
@@ -100,6 +100,7 @@ public class TestSubscriptionItemTree extends InvoiceTestSuiteNoDB {
// Stage II: Try again.. with existing items
existingItems.addAll(tree.getView());
+ assertEquals(existingItems.size(), 5);
tree = new SubscriptionItemTree(subscriptionId, invoiceId);
for (InvoiceItem e : existingItems) {
tree.addItem(e);
@@ -235,7 +236,7 @@ public class TestSubscriptionItemTree extends InvoiceTestSuiteNoDB {
final InvoiceItem repair1 = new RepairAdjInvoiceItem(invoiceId, accountId, repairDate1, endDate, amount1.negate(), currency, initial.getId());
final InvoiceItem newItem2 = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, repairDate2, endDate, amount3, rate3, currency);
- final InvoiceItem repair2 = new RepairAdjInvoiceItem(invoiceId, accountId, repairDate2, endDate, amount2.negate(), currency, initial.getId());
+ final InvoiceItem repair2 = new RepairAdjInvoiceItem(invoiceId, accountId, repairDate2, endDate, amount2.negate(), currency, newItem1.getId());
final List<InvoiceItem> expectedResult = Lists.newLinkedList();
final InvoiceItem expected1 = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, startDate, repairDate1, new BigDecimal("8.52"), rate1, currency);
@@ -465,6 +466,124 @@ public class TestSubscriptionItemTree extends InvoiceTestSuiteNoDB {
verifyResult(tree.getView(), expectedResult);
}
+ @Test(groups = "fast", description = "https://github.com/killbill/killbill/issues/664")
+ public void testOverlappingRecurring() {
+ final LocalDate startDate1 = new LocalDate(2012, 5, 1);
+ final LocalDate startDate2 = new LocalDate(2012, 5, 2);
+ final LocalDate endDate = new LocalDate(2012, 6, 1);
+
+ final BigDecimal rate = BigDecimal.TEN;
+ final BigDecimal amount = rate;
+
+ final InvoiceItem recurring1 = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, startDate1, endDate, amount, rate, currency);
+ final InvoiceItem recurring2 = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, startDate2, endDate, amount, rate, currency);
+
+ final SubscriptionItemTree tree = new SubscriptionItemTree(subscriptionId, invoiceId);
+ tree.addItem(recurring1);
+ tree.addItem(recurring2);
+
+ try {
+ tree.build();
+ fail();
+ } catch (final IllegalStateException e) {
+ }
+ }
+
+ @Test(groups = "fast", description = "https://github.com/killbill/killbill/issues/664")
+ public void testDoubleBillingOnDifferentInvoices() {
+ final LocalDate startDate1 = new LocalDate(2012, 5, 1);
+ final LocalDate endDate = new LocalDate(2012, 6, 1);
+
+ final BigDecimal rate = BigDecimal.TEN;
+ final BigDecimal amount = rate;
+
+ final InvoiceItem recurring1 = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, startDate1, endDate, amount, rate, currency);
+ final InvoiceItem recurring2 = new RecurringInvoiceItem(UUID.randomUUID(), accountId, bundleId, subscriptionId, planName, phaseName, startDate1, endDate, amount, rate, currency);
+
+ final SubscriptionItemTree tree = new SubscriptionItemTree(subscriptionId, invoiceId);
+ tree.addItem(recurring1);
+ tree.addItem(recurring2);
+
+ try {
+ tree.build();
+ fail();
+ } catch (final IllegalStateException e) {
+ }
+ }
+
+ @Test(groups = "fast")
+ public void testInvalidRepairCausingOverlappingRecurring() {
+ final LocalDate startDate = new LocalDate(2014, 1, 1);
+ final LocalDate endDate = new LocalDate(2014, 2, 1);
+
+ final LocalDate repairDate1 = new LocalDate(2014, 1, 23);
+
+ final LocalDate repairDate2 = new LocalDate(2014, 1, 26);
+
+ final BigDecimal rate1 = new BigDecimal("12.00");
+ final BigDecimal amount1 = rate1;
+
+ final BigDecimal rate2 = new BigDecimal("14.85");
+ final BigDecimal amount2 = rate2;
+
+ final BigDecimal rate3 = new BigDecimal("19.23");
+ final BigDecimal amount3 = rate3;
+
+ final InvoiceItem initial = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, startDate, endDate, amount1, rate1, currency);
+ final InvoiceItem newItem1 = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, repairDate1, endDate, amount2, rate2, currency);
+ final InvoiceItem repair1 = new RepairAdjInvoiceItem(invoiceId, accountId, repairDate1, endDate, amount1.negate(), currency, initial.getId());
+
+ final InvoiceItem newItem2 = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, repairDate2, endDate, amount3, rate3, currency);
+ // This repair should point to newItem1 instead
+ final InvoiceItem repair2 = new RepairAdjInvoiceItem(invoiceId, accountId, repairDate2, endDate, amount2.negate(), currency, initial.getId());
+
+ // Out-of-order insertion to show ordering doesn't matter
+ final SubscriptionItemTree tree = new SubscriptionItemTree(subscriptionId, invoiceId);
+ tree.addItem(repair1);
+ tree.addItem(repair2);
+ tree.addItem(initial);
+ tree.addItem(newItem1);
+ tree.addItem(newItem2);
+
+ try {
+ tree.build();
+ fail();
+ } catch (final IllegalStateException e) {
+ }
+ }
+
+ @Test(groups = "fast")
+ public void testInvalidRepairCausingOverlappingRecurringV2() {
+ final LocalDate startDate = new LocalDate(2014, 1, 1);
+ final LocalDate endDate = new LocalDate(2014, 2, 1);
+
+ final LocalDate repairDate1 = new LocalDate(2014, 1, 23);
+
+ final LocalDate repairDate2 = new LocalDate(2014, 1, 26);
+
+ final BigDecimal rate1 = new BigDecimal("12.00");
+ final BigDecimal amount1 = rate1;
+
+ final BigDecimal rate2 = new BigDecimal("14.85");
+ final BigDecimal amount2 = rate2;
+
+ final InvoiceItem initial = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, startDate, endDate, amount1, rate1, currency);
+ final InvoiceItem newItem1 = new RecurringInvoiceItem(invoiceId, accountId, bundleId, subscriptionId, planName, phaseName, repairDate1, endDate, amount2, rate2, currency);
+ final InvoiceItem repair1 = new RepairAdjInvoiceItem(invoiceId, accountId, repairDate2, endDate, amount1.negate(), currency, initial.getId());
+
+ // Out-of-order insertion to show ordering doesn't matter
+ final SubscriptionItemTree tree = new SubscriptionItemTree(subscriptionId, invoiceId);
+ tree.addItem(repair1);
+ tree.addItem(initial);
+ tree.addItem(newItem1);
+
+ try {
+ tree.build();
+ fail();
+ } catch (final IllegalStateException e) {
+ }
+ }
+
// The test that first repair (repair1) and new Item (newItem1) end up being ignored.
@Test(groups = "fast")
public void testOverlappingRepair() {
diff --git a/jaxrs/src/main/java/org/killbill/billing/jaxrs/resources/AdminResource.java b/jaxrs/src/main/java/org/killbill/billing/jaxrs/resources/AdminResource.java
index c964e67..056ce07 100644
--- a/jaxrs/src/main/java/org/killbill/billing/jaxrs/resources/AdminResource.java
+++ b/jaxrs/src/main/java/org/killbill/billing/jaxrs/resources/AdminResource.java
@@ -17,13 +17,16 @@
package org.killbill.billing.jaxrs.resources;
-import java.util.List;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URI;
import java.util.UUID;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
+import javax.ws.rs.DefaultValue;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
@@ -31,11 +34,16 @@ import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
+import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
+import javax.ws.rs.core.StreamingOutput;
+import org.killbill.billing.ErrorCode;
import org.killbill.billing.ObjectType;
import org.killbill.billing.account.api.AccountUserApi;
+import org.killbill.billing.invoice.api.InvoiceApiException;
+import org.killbill.billing.invoice.api.InvoiceUserApi;
import org.killbill.billing.jaxrs.json.AdminPaymentJson;
import org.killbill.billing.jaxrs.util.Context;
import org.killbill.billing.jaxrs.util.JaxrsUriBuilder;
@@ -56,10 +64,15 @@ import org.killbill.billing.util.api.TagUserApi;
import org.killbill.billing.util.cache.Cachable.CacheType;
import org.killbill.billing.util.callcontext.CallContext;
import org.killbill.billing.util.callcontext.TenantContext;
+import org.killbill.billing.util.entity.Pagination;
+import org.killbill.billing.util.tag.Tag;
+import org.killbill.billing.util.tag.dao.SystemTags;
import org.killbill.clock.Clock;
+import com.fasterxml.jackson.core.JsonGenerator;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.inject.Singleton;
import io.swagger.annotations.Api;
@@ -77,20 +90,21 @@ import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
public class AdminResource extends JaxRsResourceBase {
private final AdminPaymentApi adminPaymentApi;
+ private final InvoiceUserApi invoiceUserApi;
private final TenantUserApi tenantApi;
private final CacheManager cacheManager;
private final RecordIdApi recordIdApi;
@Inject
- public AdminResource(final JaxrsUriBuilder uriBuilder, final TagUserApi tagUserApi, final CustomFieldUserApi customFieldUserApi, final AuditUserApi auditUserApi, final AccountUserApi accountUserApi, final PaymentApi paymentApi, final AdminPaymentApi adminPaymentApi, final CacheManager cacheManager, final TenantUserApi tenantApi, final RecordIdApi recordIdApi, final Clock clock, final Context context) {
+ public AdminResource(final JaxrsUriBuilder uriBuilder, final TagUserApi tagUserApi, final CustomFieldUserApi customFieldUserApi, final AuditUserApi auditUserApi, final AccountUserApi accountUserApi, final PaymentApi paymentApi, final AdminPaymentApi adminPaymentApi, final InvoiceUserApi invoiceUserApi, final CacheManager cacheManager, final TenantUserApi tenantApi, final RecordIdApi recordIdApi, final Clock clock, final Context context) {
super(uriBuilder, tagUserApi, customFieldUserApi, auditUserApi, accountUserApi, paymentApi, null, clock, context);
this.adminPaymentApi = adminPaymentApi;
+ this.invoiceUserApi = invoiceUserApi;
this.tenantApi = tenantApi;
this.recordIdApi = recordIdApi;
this.cacheManager = cacheManager;
}
-
@PUT
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
@@ -123,13 +137,71 @@ public class AdminResource extends JaxRsResourceBase {
return Response.status(Status.OK).build();
}
+ @POST
+ @Consumes(APPLICATION_JSON)
+ @Produces(APPLICATION_JSON)
+ @Path("/invoices")
+ @ApiOperation(value = "Trigger an invoice generation for all parked accounts")
+ @ApiResponses(value = {})
+ public Response triggerInvoiceGenerationForParkedAccounts(@QueryParam(QUERY_SEARCH_OFFSET) @DefaultValue("0") final Long offset,
+ @QueryParam(QUERY_SEARCH_LIMIT) @DefaultValue("100") final Long limit,
+ @HeaderParam(HDR_CREATED_BY) final String createdBy,
+ @HeaderParam(HDR_REASON) final String reason,
+ @HeaderParam(HDR_COMMENT) final String comment,
+ @javax.ws.rs.core.Context final HttpServletRequest request) {
+ final CallContext callContext = context.createContext(createdBy, reason, comment, request);
+
+ // TODO Consider adding a real invoice API post 0.18.x
+ final Pagination<Tag> tags = tagUserApi.searchTags(SystemTags.PARK_TAG_DEFINITION_NAME, offset, limit, callContext);
+
+ // Return the accounts still parked
+ final StreamingOutput json = new StreamingOutput() {
+ @Override
+ public void write(final OutputStream output) throws IOException, WebApplicationException {
+ final JsonGenerator generator = mapper.getFactory().createGenerator(output);
+ generator.configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false);
+
+ generator.writeStartArray();
+ for (final Tag tag : tags) {
+ final UUID accountId = tag.getObjectId();
+ try {
+ invoiceUserApi.triggerInvoiceGeneration(accountId, clock.getUTCToday(), null, callContext);
+ } catch (final InvoiceApiException e) {
+ if (e.getCode() == ErrorCode.UNEXPECTED_ERROR.getCode()) {
+ generator.writeString(accountId.toString());
+ }
+ if (e.getCode() != ErrorCode.INVOICE_NOTHING_TO_DO.getCode()) {
+ log.warn("Unable to trigger invoice generation for accountId='{}'", accountId);
+ }
+ }
+ }
+ generator.writeEndArray();
+ generator.close();
+ }
+ };
+
+ final URI nextPageUri = uriBuilder.nextPage(AdminResource.class,
+ "triggerInvoiceGenerationForParkedAccounts",
+ tags.getNextOffset(),
+ limit,
+ ImmutableMap.<String, String>of());
+ return Response.status(Status.OK)
+ .entity(json)
+ .header(HDR_PAGINATION_CURRENT_OFFSET, tags.getCurrentOffset())
+ .header(HDR_PAGINATION_NEXT_OFFSET, tags.getNextOffset())
+ .header(HDR_PAGINATION_TOTAL_NB_RECORDS, tags.getTotalNbRecords())
+ .header(HDR_PAGINATION_MAX_NB_RECORDS, tags.getMaxNbRecords())
+ .header(HDR_PAGINATION_NEXT_PAGE_URI, nextPageUri)
+ .build();
+ }
+
@DELETE
@Path("/" + CACHE)
@Produces(APPLICATION_JSON)
@ApiOperation(value = "Invalidates the given Cache if specified, otherwise invalidates all caches")
@ApiResponses(value = {@ApiResponse(code = 400, message = "Cache name does not exist or is not alive")})
public Response invalidatesCache(@QueryParam("cacheName") final String cacheName,
- @javax.ws.rs.core.Context final HttpServletRequest request) {
+ @javax.ws.rs.core.Context final HttpServletRequest request) {
if (null != cacheName && !cacheName.isEmpty()) {
final Ehcache cache = cacheManager.getEhcache(cacheName);
// check if cache is null
@@ -139,8 +211,7 @@ public class AdminResource extends JaxRsResourceBase {
}
// Clear given cache
cache.removeAll();
- }
- else {
+ } else {
// if not given a specific cacheName, clear all
cacheManager.clearAll();
}
@@ -176,7 +247,7 @@ public class AdminResource extends JaxRsResourceBase {
@ApiOperation(value = "Invalidates Caches per tenant level")
@ApiResponses(value = {})
public Response invalidatesCacheByTenant(@QueryParam("tenantApiKey") final String tenantApiKey,
- @javax.ws.rs.core.Context final HttpServletRequest request) throws TenantApiException {
+ @javax.ws.rs.core.Context final HttpServletRequest request) throws TenantApiException {
// creating Tenant Context from Request
TenantContext tenantContext = context.createContext(request);
diff --git a/payment/src/test/java/org/killbill/billing/payment/core/sm/TestPluginOperation.java b/payment/src/test/java/org/killbill/billing/payment/core/sm/TestPluginOperation.java
index 405d5be..ea77ad2 100644
--- a/payment/src/test/java/org/killbill/billing/payment/core/sm/TestPluginOperation.java
+++ b/payment/src/test/java/org/killbill/billing/payment/core/sm/TestPluginOperation.java
@@ -42,6 +42,8 @@ import org.killbill.billing.payment.dispatcher.PluginDispatcher;
import org.killbill.billing.payment.dispatcher.PluginDispatcher.PluginDispatcherReturnType;
import org.killbill.billing.payment.plugin.api.PaymentPluginApiException;
import org.killbill.billing.payment.plugin.api.PaymentTransactionInfoPlugin;
+import org.killbill.billing.payment.provider.MockPaymentProviderPlugin;
+import org.killbill.billing.platform.api.KillbillConfigSource;
import org.killbill.billing.util.config.definition.PaymentConfig;
import org.killbill.commons.locker.GlobalLocker;
import org.killbill.commons.locker.memory.MemoryGlobalLocker;
@@ -51,6 +53,7 @@ import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import com.jayway.awaitility.Awaitility;
public class TestPluginOperation extends PaymentTestSuiteNoDB {
@@ -62,6 +65,15 @@ public class TestPluginOperation extends PaymentTestSuiteNoDB {
private final GlobalLocker locker = new MemoryGlobalLocker();
private final Account account = Mockito.mock(Account.class);
+ @Override
+ protected KillbillConfigSource getConfigSource() {
+ return getConfigSource("/payment.properties",
+ ImmutableMap.<String, String>of("org.killbill.payment.provider.default", MockPaymentProviderPlugin.PLUGIN_NAME,
+ "killbill.payment.engine.events.off", "false",
+ "org.killbill.payment.globalLock.retries", "1"));
+
+ }
+
@BeforeMethod(groups = "fast")
public void beforeMethod() throws Exception {
super.beforeMethod();
diff --git a/profiles/killbill/src/test/java/org/killbill/billing/jaxrs/TestAdmin.java b/profiles/killbill/src/test/java/org/killbill/billing/jaxrs/TestAdmin.java
index 288e8bd..59d24be 100644
--- a/profiles/killbill/src/test/java/org/killbill/billing/jaxrs/TestAdmin.java
+++ b/profiles/killbill/src/test/java/org/killbill/billing/jaxrs/TestAdmin.java
@@ -18,21 +18,40 @@
package org.killbill.billing.jaxrs;
import java.math.BigDecimal;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
import java.util.UUID;
+import org.joda.time.DateTime;
+import org.killbill.billing.catalog.api.BillingPeriod;
+import org.killbill.billing.catalog.api.ProductCategory;
+import org.killbill.billing.client.JaxrsResource;
import org.killbill.billing.client.KillBillClientException;
import org.killbill.billing.client.KillBillHttpClient;
+import org.killbill.billing.client.RequestOptions;
import org.killbill.billing.client.model.Account;
+import org.killbill.billing.client.model.Invoice;
import org.killbill.billing.client.model.Payment;
import org.killbill.billing.client.model.PaymentTransaction;
import org.killbill.billing.jaxrs.json.AdminPaymentJson;
import org.killbill.billing.payment.api.TransactionStatus;
+import org.killbill.billing.util.api.AuditLevel;
+import org.killbill.billing.util.jackson.ObjectMapper;
import org.testng.Assert;
import org.testng.annotations.Test;
+import com.ning.http.client.Response;
+
import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.Multimap;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+
public class TestAdmin extends TestJaxrsBase {
@Test(groups = "slow")
@@ -77,6 +96,70 @@ public class TestAdmin extends TestJaxrsBase {
doCapture(updatedPayment2, true);
}
+ @Test(groups = "slow")
+ public void testAdminInvoiceEndpoint() throws Exception {
+ final DateTime initialDate = new DateTime(2012, 4, 25, 0, 3, 42, 0);
+ clock.setDeltaFromReality(initialDate.getMillis() - clock.getUTCNow().getMillis());
+
+ final Collection<UUID> accounts = new HashSet<UUID>();
+ for (int i = 0; i < 5; i++) {
+ final Account accountJson = createAccountWithDefaultPaymentMethod();
+ assertNotNull(accountJson);
+ accounts.add(accountJson.getAccountId());
+
+ createEntitlement(accountJson.getAccountId(),
+ UUID.randomUUID().toString(),
+ "Shotgun",
+ ProductCategory.BASE,
+ BillingPeriod.MONTHLY,
+ true);
+ clock.addDays(2);
+ crappyWaitForLackOfProperSynchonization();
+
+ Assert.assertEquals(killBillClient.getInvoices(requestOptions).getPaginationMaxNbRecords(), i + 1);
+ final List<Invoice> invoices = killBillClient.getInvoicesForAccount(accountJson.getAccountId(), false, false, false, AuditLevel.NONE, requestOptions);
+ assertEquals(invoices.size(), 1);
+ }
+
+ // Trigger first non-trial invoice
+ clock.addDays(32);
+ crappyWaitForLackOfProperSynchonization();
+
+ Assert.assertEquals(killBillClient.getInvoices(requestOptions).getPaginationMaxNbRecords(), 10);
+ for (final UUID accountId : accounts) {
+ final List<Invoice> invoices = killBillClient.getInvoicesForAccount(accountId, false, false, false, AuditLevel.NONE, requestOptions);
+ assertEquals(invoices.size(), 2);
+ }
+
+ // Upload the config
+ final ObjectMapper mapper = new ObjectMapper();
+ final Map<String, String> perTenantProperties = new HashMap<String, String>();
+ perTenantProperties.put("org.killbill.invoice.enabled", "false");
+ final String perTenantConfig = mapper.writeValueAsString(perTenantProperties);
+ killBillClient.postConfigurationPropertiesForTenant(perTenantConfig, requestOptions);
+ crappyWaitForLackOfProperSynchonization();
+
+ // Verify the second invoice isn't generated
+ clock.addDays(32);
+ crappyWaitForLackOfProperSynchonization();
+
+ Assert.assertEquals(killBillClient.getInvoices(requestOptions).getPaginationMaxNbRecords(), 10);
+ for (final UUID accountId : accounts) {
+ final List<Invoice> invoices = killBillClient.getInvoicesForAccount(accountId, false, false, false, AuditLevel.NONE, requestOptions);
+ assertEquals(invoices.size(), 2);
+ }
+
+ // Fix one account
+ final Response response = triggerInvoiceGenerationForParkedAccounts(1);
+ Assert.assertEquals(response.getResponseBody(), "[]");
+ Assert.assertEquals(killBillClient.getInvoices(requestOptions).getPaginationMaxNbRecords(), 11);
+
+ // Fix all accounts
+ final Response response2 = triggerInvoiceGenerationForParkedAccounts(5);
+ Assert.assertEquals(response2.getResponseBody(), "[]");
+ Assert.assertEquals(killBillClient.getInvoices(requestOptions).getPaginationMaxNbRecords(), 15);
+ }
+
private void doCapture(final Payment payment, final boolean expectException) throws KillBillClientException {
// Payment object does not export state, this is purely internal, so to verify that we indeed changed to Failed, we can attempt
// a capture, which should fail
@@ -100,7 +183,6 @@ public class TestAdmin extends TestJaxrsBase {
}
-
private void fixPaymentState(final Payment payment, final String lastSuccessPaymentState, final String currentPaymentStateName, final TransactionStatus transactionStatus) throws KillBillClientException {
//
// We do not expose the endpoint in the client API on purpose since this should only be accessed using special permission ADMIN_CAN_FIX_DATA
@@ -115,4 +197,15 @@ public class TestAdmin extends TestJaxrsBase {
result.put(KillBillHttpClient.AUDIT_OPTION_COMMENT, comment);
killBillHttpClient.doPut(uri, body, result);
}
+
+ private Response triggerInvoiceGenerationForParkedAccounts(final int limit) throws KillBillClientException {
+ final String uri = "/1.0/kb/admin/invoices";
+
+ final RequestOptions requestOptions = RequestOptions.builder()
+ .withQueryParams(ImmutableMultimap.<String, String>of(JaxrsResource.QUERY_SEARCH_LIMIT, String.valueOf(limit)))
+ .withCreatedBy(createdBy)
+ .withReason(reason)
+ .withComment(comment).build();
+ return killBillHttpClient.doPost(uri, null, requestOptions);
+ }
}
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/api/SubscriptionBaseApiService.java b/subscription/src/main/java/org/killbill/billing/subscription/api/SubscriptionBaseApiService.java
index e3ad715..83c8117 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/api/SubscriptionBaseApiService.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/api/SubscriptionBaseApiService.java
@@ -22,6 +22,7 @@ import java.util.List;
import java.util.UUID;
import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
import org.killbill.billing.callcontext.InternalCallContext;
import org.killbill.billing.callcontext.InternalTenantContext;
import org.killbill.billing.catalog.api.BillingActionPolicy;
@@ -56,10 +57,10 @@ public interface SubscriptionBaseApiService {
public boolean cancelWithRequestedDate(DefaultSubscriptionBase subscription, DateTime requestedDate, CallContext context)
throws SubscriptionBaseApiException;
- public boolean cancelWithPolicy(DefaultSubscriptionBase subscription, BillingActionPolicy policy, CallContext context)
+ public boolean cancelWithPolicy(DefaultSubscriptionBase subscription, BillingActionPolicy policy, DateTimeZone accountTimeZone, int accountBillCycleDayLocal, CallContext context)
throws SubscriptionBaseApiException;
- public boolean cancelWithPolicyNoValidation(Iterable<DefaultSubscriptionBase> subscriptions, BillingActionPolicy policy, InternalCallContext context)
+ public boolean cancelWithPolicyNoValidation(Iterable<DefaultSubscriptionBase> subscriptions, BillingActionPolicy policy, DateTimeZone accountTimeZone, int accountBillCycleDayLocal, InternalCallContext context)
throws SubscriptionBaseApiException;
public boolean uncancel(DefaultSubscriptionBase subscription, CallContext context)
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/api/svcs/DefaultSubscriptionInternalApi.java b/subscription/src/main/java/org/killbill/billing/subscription/api/svcs/DefaultSubscriptionInternalApi.java
index d8bcd5c..798a653 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/api/svcs/DefaultSubscriptionInternalApi.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/api/svcs/DefaultSubscriptionInternalApi.java
@@ -313,7 +313,7 @@ public class DefaultSubscriptionInternalApi extends SubscriptionApiBase implemen
}
@Override
- public void cancelBaseSubscriptions(final Iterable<SubscriptionBase> subscriptions, final BillingActionPolicy policy, final InternalCallContext context) throws SubscriptionBaseApiException {
+ public void cancelBaseSubscriptions(final Iterable<SubscriptionBase> subscriptions, final BillingActionPolicy policy, final DateTimeZone accountTimeZone, int accountBillCycleDayLocal, final InternalCallContext context) throws SubscriptionBaseApiException {
apiService.cancelWithPolicyNoValidation(Iterables.<SubscriptionBase, DefaultSubscriptionBase>transform(subscriptions,
new Function<SubscriptionBase, DefaultSubscriptionBase>() {
@Override
@@ -326,6 +326,8 @@ public class DefaultSubscriptionInternalApi extends SubscriptionApiBase implemen
}
}),
policy,
+ accountTimeZone,
+ accountBillCycleDayLocal,
context);
}
@@ -656,7 +658,8 @@ public class DefaultSubscriptionInternalApi extends SubscriptionApiBase implemen
final PlanChangeResult planChangeResult = apiService.getPlanChangeResult(subscriptionForChange, inputSpec, utcNow, tenantContext);
policy = planChangeResult.getPolicy();
}
- changeEffectiveDate = subscriptionForChange.getPlanChangeEffectiveDate(policy);
+ // We pass null for billingAlignment, accountTimezone, account BCD because this is not available which means that dryRun with START_OF_TERM BillingPolicy will fail
+ changeEffectiveDate = subscriptionForChange.getPlanChangeEffectiveDate(policy, null, null, -1, context);
}
dryRunEvents = apiService.getEventsOnChangePlan(subscriptionForChange, plan, plan.getPriceListName(), changeEffectiveDate, utcNow, true, context);
break;
@@ -673,7 +676,8 @@ public class DefaultSubscriptionInternalApi extends SubscriptionApiBase implemen
subscriptionForCancellation.getCurrentPhase().getPhaseType());
policy = catalogService.getFullCatalog(true, true, context).planCancelPolicy(spec, utcNow);
}
- cancelEffectiveDate = subscriptionForCancellation.getPlanChangeEffectiveDate(policy);
+ // We pass null for billingAlignment, accountTimezone, account BCD because this is not available which means that dryRun with START_OF_TERM BillingPolicy will fail
+ cancelEffectiveDate = subscriptionForCancellation.getPlanChangeEffectiveDate(policy, null, null, -1, context);
}
dryRunEvents = apiService.getEventsOnCancelPlan(subscriptionForCancellation, cancelEffectiveDate, utcNow, true, context);
break;
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBase.java b/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBase.java
index 90a581e..91acbb2 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBase.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBase.java
@@ -29,7 +29,12 @@ import java.util.UUID;
import javax.annotation.Nullable;
import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.joda.time.LocalDate;
+import org.killbill.billing.callcontext.InternalCallContext;
+import org.killbill.billing.callcontext.InternalTenantContext;
import org.killbill.billing.catalog.api.BillingActionPolicy;
+import org.killbill.billing.catalog.api.BillingAlignment;
import org.killbill.billing.catalog.api.BillingPeriod;
import org.killbill.billing.catalog.api.Catalog;
import org.killbill.billing.catalog.api.CatalogApiException;
@@ -57,11 +62,13 @@ import org.killbill.billing.subscription.events.phase.PhaseEvent;
import org.killbill.billing.subscription.events.user.ApiEvent;
import org.killbill.billing.subscription.events.user.ApiEventType;
import org.killbill.billing.subscription.exceptions.SubscriptionBaseError;
+import org.killbill.billing.util.bcd.BillCycleDayCalculator;
import org.killbill.billing.util.callcontext.CallContext;
import org.killbill.clock.Clock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.collect.Iterables;
@@ -235,8 +242,8 @@ public class DefaultSubscriptionBase extends EntityBase implements SubscriptionB
}
@Override
- public boolean cancelWithPolicy(final BillingActionPolicy policy, final CallContext context) throws SubscriptionBaseApiException {
- return apiService.cancelWithPolicy(this, policy, context);
+ public boolean cancelWithPolicy(final BillingActionPolicy policy, final DateTimeZone accountTimeZone, int accountBillCycleDayLocal, final CallContext context) throws SubscriptionBaseApiException {
+ return apiService.cancelWithPolicy(this, policy, accountTimeZone, accountBillCycleDayLocal, context);
}
@Override
@@ -526,13 +533,45 @@ public class DefaultSubscriptionBase extends EntityBase implements SubscriptionB
return getFutureEndDate() != null;
}
- public DateTime getPlanChangeEffectiveDate(final BillingActionPolicy policy) {
+ public DateTime getPlanChangeEffectiveDate(final BillingActionPolicy policy, @Nullable final BillingAlignment alignment, @Nullable final DateTimeZone accountTimeZone, @Nullable final Integer accountBillCycleDayLocal, final InternalTenantContext context) {
final DateTime candidateResult;
switch (policy) {
case IMMEDIATE:
candidateResult = clock.getUTCNow();
break;
+ case START_OF_TERM:
+ if (chargedThroughDate == null) {
+ candidateResult = getStartDate();
+ // Will take care of billing IN_ARREAR or subscriptions that are not invoiced up to date
+ } else if (!chargedThroughDate.isAfter(clock.getUTCNow())) {
+ candidateResult = chargedThroughDate;
+ } else {
+
+ // In certain path (dryRun, or default catalog START_OF_TERM policy), the info is not easily available and as a result, such policy is not implemented
+ Preconditions.checkState(alignment != null && accountTimeZone != null && accountBillCycleDayLocal != null, "START_OF_TERM not implemented in dryRun use case");
+
+ Preconditions.checkState(alignment != BillingAlignment.BUNDLE || category != ProductCategory.ADD_ON, "START_OF_TERM not implemented for AO configured with a BUNDLE billing alignment");
+
+ // If BCD was overriden at the subscription level, we take its latest value (it should also be reflected in the chargedThroughDate) but still required for
+ // alignment purpose
+ Integer bcd = getBillCycleDayLocal();
+ if (bcd == null) {
+ bcd = BillCycleDayCalculator.calculateBcdForAlignment(null, this, this, alignment, accountTimeZone, accountBillCycleDayLocal);
+ }
+
+ final BillingPeriod billingPeriod = getLastActivePlan().getRecurringBillingPeriod();
+ DateTime proposedDate = chargedThroughDate;
+ while (proposedDate.isAfter(clock.getUTCNow())) {
+ proposedDate = proposedDate.minus(billingPeriod.getPeriod());
+ }
+
+ final LocalDate resultingLocalDate = BillCycleDayCalculator.alignProposedBillCycleDate(proposedDate, bcd, billingPeriod, accountTimeZone);
+ candidateResult = context.toUTCDateTime(resultingLocalDate);
+ }
+
+ break;
+
case END_OF_TERM:
//
// If we have a chargedThroughDate that is 'up to date' we use it, if not default to now
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBaseApiService.java b/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBaseApiService.java
index 49efce2..19731b9 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBaseApiService.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/api/user/DefaultSubscriptionBaseApiService.java
@@ -29,12 +29,17 @@ import java.util.UUID;
import javax.annotation.Nullable;
import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
import org.joda.time.ReadableInstant;
import org.killbill.billing.ErrorCode;
import org.killbill.billing.ObjectType;
+import org.killbill.billing.account.api.AccountApiException;
+import org.killbill.billing.account.api.AccountInternalApi;
+import org.killbill.billing.account.api.ImmutableAccountInternalApi;
import org.killbill.billing.callcontext.InternalCallContext;
import org.killbill.billing.callcontext.InternalTenantContext;
import org.killbill.billing.catalog.api.BillingActionPolicy;
+import org.killbill.billing.catalog.api.BillingAlignment;
import org.killbill.billing.catalog.api.Catalog;
import org.killbill.billing.catalog.api.CatalogApiException;
import org.killbill.billing.catalog.api.CatalogEntity;
@@ -71,6 +76,7 @@ import org.killbill.billing.util.callcontext.TenantContext;
import org.killbill.clock.Clock;
import org.killbill.clock.DefaultClock;
+import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
@@ -204,7 +210,10 @@ public class DefaultSubscriptionBaseApiService implements SubscriptionBaseApiSer
try {
final InternalCallContext internalCallContext = createCallContextFromBundleId(subscription.getBundleId(), context);
final BillingActionPolicy policy = catalogService.getFullCatalog(true, true, internalCallContext).planCancelPolicy(planPhase, now);
- final DateTime effectiveDate = subscription.getPlanChangeEffectiveDate(policy);
+
+ Preconditions.checkState(policy != BillingActionPolicy.START_OF_TERM, "A default START_OF_TERM policy is not availaible");
+
+ final DateTime effectiveDate = subscription.getPlanChangeEffectiveDate(policy, null, null, -1, null);
return doCancelPlan(ImmutableMap.<DefaultSubscriptionBase, DateTime>of(subscription, effectiveDate), now, internalCallContext);
} catch (final CatalogApiException e) {
@@ -226,24 +235,30 @@ public class DefaultSubscriptionBaseApiService implements SubscriptionBaseApiSer
}
@Override
- public boolean cancelWithPolicy(final DefaultSubscriptionBase subscription, final BillingActionPolicy policy, final CallContext context) throws SubscriptionBaseApiException {
+ public boolean cancelWithPolicy(final DefaultSubscriptionBase subscription, final BillingActionPolicy policy, final DateTimeZone accountTimeZone, int accountBillCycleDayLocal, final CallContext context) throws SubscriptionBaseApiException {
final EntitlementState currentState = subscription.getState();
if (currentState == EntitlementState.CANCELLED) {
throw new SubscriptionBaseApiException(ErrorCode.SUB_CANCEL_BAD_STATE, subscription.getId(), currentState);
}
final InternalCallContext internalCallContext = createCallContextFromBundleId(subscription.getBundleId(), context);
- return cancelWithPolicyNoValidation(ImmutableList.<DefaultSubscriptionBase>of(subscription), policy, internalCallContext);
+ return cancelWithPolicyNoValidation(ImmutableList.<DefaultSubscriptionBase>of(subscription), policy, accountTimeZone, accountBillCycleDayLocal, internalCallContext);
}
@Override
- public boolean cancelWithPolicyNoValidation(final Iterable<DefaultSubscriptionBase> subscriptions, final BillingActionPolicy policy, final InternalCallContext context) throws SubscriptionBaseApiException {
+ public boolean cancelWithPolicyNoValidation(final Iterable<DefaultSubscriptionBase> subscriptions, final BillingActionPolicy policy, final DateTimeZone accountTimeZone, final int accountBillCycleDayLocal, final InternalCallContext context) throws SubscriptionBaseApiException {
final Map<DefaultSubscriptionBase, DateTime> subscriptionsWithEffectiveDate = new HashMap<DefaultSubscriptionBase, DateTime>();
final DateTime now = clock.getUTCNow();
- for (final DefaultSubscriptionBase subscription : subscriptions) {
- final DateTime effectiveDate = subscription.getPlanChangeEffectiveDate(policy);
- subscriptionsWithEffectiveDate.put(subscription, effectiveDate);
+ try {
+
+ for (final DefaultSubscriptionBase subscription : subscriptions) {
+ final BillingAlignment billingAlignment = (subscription.getState() == EntitlementState.PENDING ? null : catalogService.getFullCatalog(true, true, context).billingAlignment(new PlanPhaseSpecifier(subscription.getLastActivePlan().getName(), subscription.getLastActivePhase().getPhaseType()), clock.getUTCNow()));
+ final DateTime effectiveDate = subscription.getPlanChangeEffectiveDate(policy, billingAlignment, accountTimeZone, accountBillCycleDayLocal, context);
+ subscriptionsWithEffectiveDate.put(subscription, effectiveDate);
+ }
+ } catch (final CatalogApiException e) {
+ throw new SubscriptionBaseApiException(e);
}
return doCancelPlan(subscriptionsWithEffectiveDate, now, context);
@@ -330,7 +345,7 @@ public class DefaultSubscriptionBaseApiService implements SubscriptionBaseApiSer
}
if (policyMaybeNull != null) {
- return subscription.getPlanChangeEffectiveDate(policyMaybeNull);
+ return subscription.getPlanChangeEffectiveDate(policyMaybeNull, null, null, -1, null);
} else if (requestedDateWithMs != null) {
return DefaultClock.truncateMs(requestedDateWithMs);
} else {
diff --git a/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCancel.java b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCancel.java
index 056208c..af4ddbb 100644
--- a/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCancel.java
+++ b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCancel.java
@@ -20,16 +20,9 @@ import java.util.UUID;
import org.joda.time.DateTime;
import org.joda.time.Interval;
-import org.killbill.billing.entitlement.api.Entitlement.EntitlementState;
-import org.killbill.billing.entity.EntityPersistenceException;
-import org.killbill.billing.subscription.engine.dao.SubscriptionEventSqlDao;
-import org.killbill.billing.subscription.engine.dao.model.SubscriptionEventModelDao;
-import org.killbill.billing.subscription.events.SubscriptionBaseEvent;
-import org.skife.jdbi.v2.Handle;
-import org.testng.Assert;
-import org.testng.annotations.Test;
-
+import org.joda.time.LocalDate;
import org.killbill.billing.api.TestApiListener.NextEvent;
+import org.killbill.billing.catalog.api.BillingActionPolicy;
import org.killbill.billing.catalog.api.BillingPeriod;
import org.killbill.billing.catalog.api.Duration;
import org.killbill.billing.catalog.api.PhaseType;
@@ -37,8 +30,17 @@ import org.killbill.billing.catalog.api.Plan;
import org.killbill.billing.catalog.api.PlanPhase;
import org.killbill.billing.catalog.api.PriceListSet;
import org.killbill.billing.catalog.api.ProductCategory;
+import org.killbill.billing.entitlement.api.Entitlement.EntitlementState;
+import org.killbill.billing.entity.EntityPersistenceException;
import org.killbill.billing.subscription.SubscriptionTestSuiteWithEmbeddedDB;
+import org.killbill.billing.subscription.api.SubscriptionBaseTransitionType;
import org.killbill.billing.subscription.api.SubscriptionBillingApiException;
+import org.killbill.billing.subscription.engine.dao.SubscriptionEventSqlDao;
+import org.killbill.billing.subscription.engine.dao.model.SubscriptionEventModelDao;
+import org.killbill.billing.subscription.events.SubscriptionBaseEvent;
+import org.skife.jdbi.v2.Handle;
+import org.testng.Assert;
+import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNull;
@@ -344,6 +346,46 @@ public class TestUserApiCancel extends SubscriptionTestSuiteWithEmbeddedDB {
final SubscriptionBaseTransition previousTransition = subscription.getPreviousTransition();
Assert.assertEquals(previousTransition.getPreviousState(), EntitlementState.ACTIVE);
Assert.assertNotNull(previousTransition.getPreviousPlan());
+ }
+
+ @Test(groups = "slow")
+ public void testCancelSubscription_START_OF_TERM() throws SubscriptionBaseApiException {
+
+ // Set date in such a way that Phase align with the first of the month (and so matches our hardcoded accountData account BCD)
+ final DateTime testStartDate = new DateTime(2016, 11, 1, 0, 3, 42, 0);
+ clock.setDeltaFromReality(testStartDate.getMillis() - clock.getUTCNow().getMillis());
+
+ final String prod = "Shotgun";
+ final BillingPeriod term = BillingPeriod.MONTHLY;
+ final String planSet = PriceListSet.DEFAULT_PRICELIST_NAME;
+
+ // CREATE
+ DefaultSubscriptionBase subscription = testUtil.createSubscription(bundle, prod, term, planSet);
+ PlanPhase currentPhase = subscription.getCurrentPhase();
+ assertEquals(currentPhase.getPhaseType(), PhaseType.TRIAL);
+
+ // Move out of TRIAL
+ testListener.pushExpectedEvent(NextEvent.PHASE);
+ clock.addDays(30);
+ assertListenerStatus();
+
+ // Artificially set the CTD
+ final Duration ctd = testUtil.getDurationMonth(1);
+ final DateTime newChargedThroughDate = TestSubscriptionHelper.addDuration(clock.getUTCNow(), ctd);
+ subscriptionInternalApi.setChargedThroughDate(subscription.getId(), newChargedThroughDate, internalCallContext);
+ subscription = (DefaultSubscriptionBase) subscriptionInternalApi.getSubscriptionFromId(subscription.getId(), internalCallContext);
+
+ // Move ahead a bit abd cancel START_OF_TERM
+ clock.addDays(5);
+ testListener.pushExpectedEvent(NextEvent.CANCEL);
+ subscription.cancelWithPolicy(BillingActionPolicy.START_OF_TERM, accountData.getTimeZone(), accountData.getBillCycleDayLocal(), callContext);
+ assertListenerStatus();
+
+ subscription = (DefaultSubscriptionBase) subscriptionInternalApi.getSubscriptionFromId(subscription.getId(), internalCallContext);
+ Assert.assertEquals(subscription.getAllTransitions().get(subscription.getAllTransitions().size() - 1).getTransitionType(), SubscriptionBaseTransitionType.CANCEL);
+ Assert.assertEquals(new LocalDate(subscription.getAllTransitions().get(subscription.getAllTransitions().size() - 1).getEffectiveTransitionTime(), accountData.getTimeZone()), new LocalDate(2016, 12, 1));
}
+
+
}
diff --git a/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiError.java b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiError.java
index 0c2e6c5..cd663dd 100644
--- a/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiError.java
+++ b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiError.java
@@ -151,7 +151,7 @@ public class TestUserApiError extends SubscriptionTestSuiteNoDB {
subscription = subscriptionInternalApi.getSubscriptionFromId(subscription.getId(), internalCallContext);
- subscription.cancelWithPolicy(BillingActionPolicy.END_OF_TERM, callContext);
+ subscription.cancelWithPolicy(BillingActionPolicy.END_OF_TERM, null, -1, callContext);
try {
subscription.changePlanWithDate(new PlanSpecifier("Pistol", BillingPeriod.MONTHLY, PriceListSet.DEFAULT_PRICELIST_NAME), null, clock.getUTCNow(), callContext);
} catch (final SubscriptionBaseApiException e) {
diff --git a/util/src/main/java/org/killbill/billing/util/bcd/BillCycleDayCalculator.java b/util/src/main/java/org/killbill/billing/util/bcd/BillCycleDayCalculator.java
index 51a8e01..80fda69 100644
--- a/util/src/main/java/org/killbill/billing/util/bcd/BillCycleDayCalculator.java
+++ b/util/src/main/java/org/killbill/billing/util/bcd/BillCycleDayCalculator.java
@@ -20,9 +20,13 @@ package org.killbill.billing.util.bcd;
import java.util.Map;
import java.util.UUID;
+import javax.annotation.Nullable;
+
import org.joda.time.DateTime;
import org.joda.time.DateTimeZone;
+import org.joda.time.LocalDate;
import org.killbill.billing.catalog.api.BillingAlignment;
+import org.killbill.billing.catalog.api.BillingPeriod;
import org.killbill.billing.subscription.api.SubscriptionBase;
import org.killbill.clock.ClockUtil;
import org.slf4j.Logger;
@@ -32,7 +36,7 @@ public abstract class BillCycleDayCalculator {
private static final Logger log = LoggerFactory.getLogger(BillCycleDayCalculator.class);
- public static int calculateBcdForAlignment(final Map<UUID, Integer> bcdCache, final SubscriptionBase subscription, final SubscriptionBase baseSubscription, final BillingAlignment alignment, final DateTimeZone accountTimeZone, final int accountBillCycleDayLocal) {
+ public static int calculateBcdForAlignment(@Nullable final Map<UUID, Integer> bcdCache, final SubscriptionBase subscription, final SubscriptionBase baseSubscription, final BillingAlignment alignment, final DateTimeZone accountTimeZone, final int accountBillCycleDayLocal) {
int result = 0;
switch (alignment) {
case ACCOUNT:
@@ -48,11 +52,33 @@ public abstract class BillCycleDayCalculator {
return result;
}
- private static int calculateOrRetrieveBcdFromSubscription(final Map<UUID, Integer> bcdCache, final SubscriptionBase subscription, final DateTimeZone accountTimeZone) {
- Integer result = bcdCache.get(subscription.getId());
+ public static LocalDate alignProposedBillCycleDate(final LocalDate proposedDate, final int billingCycleDay, final BillingPeriod billingPeriod) {
+ // billingCycleDay alignment only makes sense for month based BillingPeriod (MONTHLY, QUARTERLY, BIANNUAL, ANNUAL)
+ final boolean isMonthBased = (billingPeriod.getPeriod().getMonths() | billingPeriod.getPeriod().getYears()) > 0;
+ if (!isMonthBased) {
+ return proposedDate;
+ }
+ final int lastDayOfMonth = proposedDate.dayOfMonth().getMaximumValue();
+ int proposedBillCycleDate = proposedDate.getDayOfMonth();
+ if (proposedBillCycleDate < billingCycleDay && billingCycleDay <= lastDayOfMonth) {
+ proposedBillCycleDate = billingCycleDay;
+ }
+ return new LocalDate(proposedDate.getYear(), proposedDate.getMonthOfYear(), proposedBillCycleDate, proposedDate.getChronology());
+ }
+
+ public static LocalDate alignProposedBillCycleDate(final DateTime proposedDate, final int billingCycleDay, final BillingPeriod billingPeriod, final DateTimeZone accountTimeZone) {
+ final LocalDate proposedLocalDate = ClockUtil.toLocalDate(proposedDate, accountTimeZone);
+ final LocalDate resultingLocalDate = alignProposedBillCycleDate(proposedLocalDate, billingCycleDay, billingPeriod);
+ return resultingLocalDate;
+ }
+
+ private static int calculateOrRetrieveBcdFromSubscription(@Nullable final Map<UUID, Integer> bcdCache, final SubscriptionBase subscription, final DateTimeZone accountTimeZone) {
+ Integer result = bcdCache != null ? bcdCache.get(subscription.getId()) : null;
if (result == null) {
result = calculateBcdFromSubscription(subscription, accountTimeZone);
- bcdCache.put(subscription.getId(), result);
+ if (bcdCache != null) {
+ bcdCache.put(subscription.getId(), result);
+ }
}
return result;
}
diff --git a/util/src/main/java/org/killbill/billing/util/config/definition/InvoiceConfig.java b/util/src/main/java/org/killbill/billing/util/config/definition/InvoiceConfig.java
index b2ac2b0..8170bd6 100644
--- a/util/src/main/java/org/killbill/billing/util/config/definition/InvoiceConfig.java
+++ b/util/src/main/java/org/killbill/billing/util/config/definition/InvoiceConfig.java
@@ -85,4 +85,14 @@ public interface InvoiceConfig extends KillbillConfig {
@Default("false")
@Description("Whether to send email notifications on invoice creation (for configured accounts)")
boolean isEmailNotificationsEnabled();
+
+ @Config("org.killbill.invoice.enabled")
+ @Default("true")
+ @Description("Whether the invoicing system is enabled")
+ boolean isInvoicingSystemEnabled();
+
+ @Config("org.killbill.invoice.enabled")
+ @Default("true")
+ @Description("Whether the invoicing system is enabled")
+ boolean isInvoicingSystemEnabled(@Param("dummy") final InternalTenantContext tenantContext);
}
diff --git a/util/src/main/java/org/killbill/billing/util/tag/dao/DefaultTagDao.java b/util/src/main/java/org/killbill/billing/util/tag/dao/DefaultTagDao.java
index ed2d38c..1b4c9ec 100644
--- a/util/src/main/java/org/killbill/billing/util/tag/dao/DefaultTagDao.java
+++ b/util/src/main/java/org/killbill/billing/util/tag/dao/DefaultTagDao.java
@@ -166,13 +166,7 @@ public class DefaultTagDao extends EntityDaoBase<TagModelDao, Tag, TagApiExcepti
}
private TagDefinitionModelDao getTagDefinitionFromTransaction(final UUID tagDefinitionId, final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory, final InternalTenantContext context) throws TagApiException {
- TagDefinitionModelDao tagDefintion = null;
- for (final ControlTagType t : ControlTagType.values()) {
- if (t.getId().equals(tagDefinitionId)) {
- tagDefintion = new TagDefinitionModelDao(t);
- break;
- }
- }
+ TagDefinitionModelDao tagDefintion = SystemTags.lookup(tagDefinitionId);
if (tagDefintion == null) {
final TagDefinitionSqlDao transTagDefintionSqlDao = entitySqlDaoWrapperFactory.become(TagDefinitionSqlDao.class);
tagDefintion = transTagDefintionSqlDao.getById(tagDefinitionId.toString(), context);
diff --git a/util/src/main/java/org/killbill/billing/util/tag/dao/DefaultTagDefinitionDao.java b/util/src/main/java/org/killbill/billing/util/tag/dao/DefaultTagDefinitionDao.java
index 46128ce..e406380 100644
--- a/util/src/main/java/org/killbill/billing/util/tag/dao/DefaultTagDefinitionDao.java
+++ b/util/src/main/java/org/killbill/billing/util/tag/dao/DefaultTagDefinitionDao.java
@@ -24,30 +24,28 @@ import java.util.LinkedList;
import java.util.List;
import java.util.UUID;
-import org.killbill.billing.util.callcontext.InternalCallContextFactory;
-import org.skife.jdbi.v2.IDBI;
-import org.skife.jdbi.v2.exceptions.TransactionFailedException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
import org.killbill.billing.BillingExceptionBase;
import org.killbill.billing.ErrorCode;
-import org.killbill.bus.api.PersistentBus;
import org.killbill.billing.callcontext.InternalCallContext;
import org.killbill.billing.callcontext.InternalTenantContext;
-import org.killbill.clock.Clock;
import org.killbill.billing.events.TagDefinitionInternalEvent;
import org.killbill.billing.util.api.TagDefinitionApiException;
import org.killbill.billing.util.audit.ChangeType;
import org.killbill.billing.util.cache.CacheControllerDispatcher;
+import org.killbill.billing.util.callcontext.InternalCallContextFactory;
import org.killbill.billing.util.dao.NonEntityDao;
import org.killbill.billing.util.entity.dao.EntityDaoBase;
import org.killbill.billing.util.entity.dao.EntitySqlDaoTransactionWrapper;
import org.killbill.billing.util.entity.dao.EntitySqlDaoTransactionalJdbiWrapper;
import org.killbill.billing.util.entity.dao.EntitySqlDaoWrapperFactory;
-import org.killbill.billing.util.tag.ControlTagType;
import org.killbill.billing.util.tag.TagDefinition;
import org.killbill.billing.util.tag.api.user.TagEventBuilder;
+import org.killbill.bus.api.PersistentBus;
+import org.killbill.clock.Clock;
+import org.skife.jdbi.v2.IDBI;
+import org.skife.jdbi.v2.exceptions.TransactionFailedException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import com.google.common.base.Function;
import com.google.common.collect.Collections2;
@@ -81,9 +79,7 @@ public class DefaultTagDefinitionDao extends EntityDaoBase<TagDefinitionModelDao
Iterators.addAll(definitionList, all);
// Add invoice tag definitions
- for (final ControlTagType controlTag : ControlTagType.values()) {
- definitionList.add(new TagDefinitionModelDao(controlTag));
- }
+ definitionList.addAll(SystemTags.all());
return definitionList;
}
});
@@ -94,12 +90,8 @@ public class DefaultTagDefinitionDao extends EntityDaoBase<TagDefinitionModelDao
return transactionalSqlDao.execute(new EntitySqlDaoTransactionWrapper<TagDefinitionModelDao>() {
@Override
public TagDefinitionModelDao inTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory) throws Exception {
- for (final ControlTagType controlTag : ControlTagType.values()) {
- if (controlTag.name().equals(definitionName)) {
- return new TagDefinitionModelDao(controlTag);
- }
- }
- return entitySqlDaoWrapperFactory.become(TagDefinitionSqlDao.class).getByName(definitionName, context);
+ final TagDefinitionModelDao tagDefinitionModelDao = SystemTags.lookup(definitionName);
+ return tagDefinitionModelDao != null ? tagDefinitionModelDao : entitySqlDaoWrapperFactory.become(TagDefinitionSqlDao.class).getByName(definitionName, context);
}
});
}
@@ -109,12 +101,8 @@ public class DefaultTagDefinitionDao extends EntityDaoBase<TagDefinitionModelDao
return transactionalSqlDao.execute(new EntitySqlDaoTransactionWrapper<TagDefinitionModelDao>() {
@Override
public TagDefinitionModelDao inTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory) throws Exception {
- for (final ControlTagType controlTag : ControlTagType.values()) {
- if (controlTag.getId().equals(definitionId)) {
- return new TagDefinitionModelDao(controlTag);
- }
- }
- return entitySqlDaoWrapperFactory.become(TagDefinitionSqlDao.class).getById(definitionId.toString(), context);
+ final TagDefinitionModelDao tagDefinitionModelDao = SystemTags.lookup(definitionId);
+ return tagDefinitionModelDao != null ? tagDefinitionModelDao : entitySqlDaoWrapperFactory.become(TagDefinitionSqlDao.class).getById(definitionId.toString(), context);
}
});
}
@@ -126,11 +114,9 @@ public class DefaultTagDefinitionDao extends EntityDaoBase<TagDefinitionModelDao
public List<TagDefinitionModelDao> inTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory) throws Exception {
final List<TagDefinitionModelDao> result = new LinkedList<TagDefinitionModelDao>();
for (final UUID cur : definitionIds) {
- for (final ControlTagType controlTag : ControlTagType.values()) {
- if (controlTag.getId().equals(cur)) {
- result.add(new TagDefinitionModelDao(controlTag));
- break;
- }
+ final TagDefinitionModelDao tagDefinitionModelDao = SystemTags.lookup(cur);
+ if (tagDefinitionModelDao != null) {
+ result.add(tagDefinitionModelDao);
}
}
if (definitionIds.size() > 0) {
diff --git a/util/src/main/java/org/killbill/billing/util/tag/dao/SystemTags.java b/util/src/main/java/org/killbill/billing/util/tag/dao/SystemTags.java
new file mode 100644
index 0000000..adede89
--- /dev/null
+++ b/util/src/main/java/org/killbill/billing/util/tag/dao/SystemTags.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2014-2016 Groupon, Inc
+ * Copyright 2014-2016 The Billing Project, LLC
+ *
+ * The Billing Project licenses this file to you under the Apache License, version 2.0
+ * (the "License"); you may not use this file except in compliance with the
+ * License. You may obtain a copy of the License at:
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.killbill.billing.util.tag.dao;
+
+import java.util.Collection;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.UUID;
+
+import org.killbill.billing.util.tag.ControlTagType;
+
+import com.google.common.collect.ImmutableList;
+
+public class SystemTags {
+
+ // Invoice
+ public static final UUID PARK_TAG_DEFINITION_ID = new UUID(1, 1);
+ public static final String PARK_TAG_DEFINITION_NAME = "__PARK__";
+
+ // Note! TagSqlDao.sql.stg needs to be kept in sync (see userAndSystemTagDefinitions)
+ private static final List<TagDefinitionModelDao> SYSTEM_DEFINED_TAG_DEFINITIONS = ImmutableList.<TagDefinitionModelDao>of(new TagDefinitionModelDao(PARK_TAG_DEFINITION_ID, null, null, PARK_TAG_DEFINITION_NAME, "Accounts with invalid invoicing state"));
+
+ public static Collection<TagDefinitionModelDao> all() {
+ final Collection<TagDefinitionModelDao> all = new LinkedList<TagDefinitionModelDao>(SYSTEM_DEFINED_TAG_DEFINITIONS);
+ for (final ControlTagType controlTag : ControlTagType.values()) {
+ all.add(new TagDefinitionModelDao(controlTag));
+ }
+ return all;
+ }
+
+ public static TagDefinitionModelDao lookup(final String tagDefinitionName) {
+ for (final ControlTagType t : ControlTagType.values()) {
+ if (t.name().equals(tagDefinitionName)) {
+ return new TagDefinitionModelDao(t);
+ }
+ }
+
+ for (final TagDefinitionModelDao t : SYSTEM_DEFINED_TAG_DEFINITIONS) {
+ if (t.getName().equals(tagDefinitionName)) {
+ return t;
+ }
+ }
+
+ return null;
+ }
+
+ public static TagDefinitionModelDao lookup(final UUID tagDefinitionId) {
+ for (final ControlTagType t : ControlTagType.values()) {
+ if (t.getId().equals(tagDefinitionId)) {
+ return new TagDefinitionModelDao(t);
+ }
+ }
+
+ for (final TagDefinitionModelDao t : SYSTEM_DEFINED_TAG_DEFINITIONS) {
+ if (t.getId().equals(tagDefinitionId)) {
+ return t;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/util/src/main/resources/org/killbill/billing/util/tag/dao/TagSqlDao.sql.stg b/util/src/main/resources/org/killbill/billing/util/tag/dao/TagSqlDao.sql.stg
index 00c7bbe..3240d91 100644
--- a/util/src/main/resources/org/killbill/billing/util/tag/dao/TagSqlDao.sql.stg
+++ b/util/src/main/resources/org/killbill/billing/util/tag/dao/TagSqlDao.sql.stg
@@ -100,6 +100,11 @@ userAndSystemTagDefinitions() ::= <<
\'00000000-0000-0000-0000-000000000007\' id
, \'PARTNER\' as name
, \'Indicates that this is a partner account\' description
+ union
+ select
+ \'00000000-0000-0001-0000-000000000001\' id
+ , \'__PARK__\' as name
+ , \'Accounts with invalid invoicing state\' description
>>
searchQuery(tagAlias, tagDefinitionAlias) ::= <<
diff --git a/util/src/test/java/org/killbill/billing/mock/MockSubscription.java b/util/src/test/java/org/killbill/billing/mock/MockSubscription.java
index c3287d1..5475050 100644
--- a/util/src/test/java/org/killbill/billing/mock/MockSubscription.java
+++ b/util/src/test/java/org/killbill/billing/mock/MockSubscription.java
@@ -20,6 +20,7 @@ import java.util.List;
import java.util.UUID;
import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
import org.killbill.billing.catalog.api.PlanPhasePriceOverride;
import org.killbill.billing.catalog.api.PlanSpecifier;
import org.mockito.Mockito;
@@ -71,9 +72,9 @@ public class MockSubscription implements SubscriptionBase {
}
@Override
- public boolean cancelWithPolicy(BillingActionPolicy policy, CallContext context)
+ public boolean cancelWithPolicy(BillingActionPolicy policy, final DateTimeZone accountTimeZone, int accountBillCycleDayLocal, CallContext context)
throws SubscriptionBaseApiException {
- return sub.cancelWithPolicy(policy, context);
+ return sub.cancelWithPolicy(policy, accountTimeZone, accountBillCycleDayLocal, context);
}
@Override
diff --git a/util/src/test/java/org/killbill/billing/util/tag/dao/TestDefaultTagDao.java b/util/src/test/java/org/killbill/billing/util/tag/dao/TestDefaultTagDao.java
index fd16051..b110370 100644
--- a/util/src/test/java/org/killbill/billing/util/tag/dao/TestDefaultTagDao.java
+++ b/util/src/test/java/org/killbill/billing/util/tag/dao/TestDefaultTagDao.java
@@ -69,7 +69,7 @@ public class TestDefaultTagDao extends UtilTestSuiteWithEmbeddedDB {
assertEquals(result.size(), 4);
result = tagDefinitionDao.getTagDefinitions(internalCallContext);
- assertEquals(result.size(), 3 + ControlTagType.values().length);
+ assertEquals(result.size(), 3 + SystemTags.all().size());
}
@Test(groups = "slow")