killbill-uncached
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)
Details
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/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/glue/DefaultInvoiceModule.java b/invoice/src/main/java/org/killbill/billing/invoice/glue/DefaultInvoiceModule.java
index 8f56d81..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
@@ -65,7 +65,6 @@ import com.google.inject.name.Names;
public class DefaultInvoiceModule extends KillBillModule implements InvoiceModule {
-
InvoiceConfig staticInvoiceConfig;
public DefaultInvoiceModule(final KillbillConfigSource configSource) {
@@ -94,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();
}
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 4ad8b4f..51bff88 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/InvoiceDispatcher.java
@@ -223,6 +223,13 @@ public class InvoiceDispatcher {
@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);
}
@@ -233,7 +240,7 @@ public class InvoiceDispatcher {
final InternalCallContext context) throws InvoiceApiException {
boolean parkedAccount = false;
try {
- parkedAccount = parkedAccountsManager.isParked(accountId, context);
+ parkedAccount = parkedAccountsManager.isParked(context);
if (parkedAccount && !isApiCall) {
log.warn("Ignoring invoice generation process for accountId='{}', targetDate='{}', account is parked", accountId.toString(), targetDate);
return null;
@@ -312,16 +319,20 @@ public class InvoiceDispatcher {
} 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);
- try {
- parkedAccountsManager.parkAccount(accountId, context);
- } catch (final TagApiException ignored) {
- log.warn("Unable to park account", ignored);
- }
+ 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);
+ }
+ }
+
private void filterInvoiceItemsForDryRun(final Iterable<UUID> filteredSubscriptionIdsForDryRun, final Invoice invoice) {
if (!filteredSubscriptionIdsForDryRun.iterator().hasNext()) {
return;
diff --git a/invoice/src/main/java/org/killbill/billing/invoice/ParkedAccountsManager.java b/invoice/src/main/java/org/killbill/billing/invoice/ParkedAccountsManager.java
index c03c993..73321ae 100644
--- a/invoice/src/main/java/org/killbill/billing/invoice/ParkedAccountsManager.java
+++ b/invoice/src/main/java/org/killbill/billing/invoice/ParkedAccountsManager.java
@@ -19,101 +19,51 @@ 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.api.TagUserApi;
-import org.killbill.billing.util.cache.Cachable.CacheType;
-import org.killbill.billing.util.cache.CacheControllerDispatcher;
-import org.killbill.billing.util.callcontext.CallContext;
-import org.killbill.billing.util.callcontext.CallOrigin;
-import org.killbill.billing.util.callcontext.InternalCallContextFactory;
-import org.killbill.billing.util.callcontext.UserType;
-import org.killbill.billing.util.dao.NonEntityDao;
import org.killbill.billing.util.tag.Tag;
-import org.killbill.billing.util.tag.dao.TagDefinitionDao;
-import org.killbill.billing.util.tag.dao.TagDefinitionModelDao;
-import org.killbill.clock.Clock;
-import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Predicate;
import com.google.common.collect.Iterables;
import com.google.inject.Inject;
-public class ParkedAccountsManager {
+import static org.killbill.billing.util.tag.dao.SystemTags.PARK_TAG_DEFINITION_ID;
- @VisibleForTesting
- static final String PARK = "__PARK__";
+public class ParkedAccountsManager {
- private final TagUserApi tagUserApi;
- private final TagDefinitionDao tagDefinitionDao;
- private final NonEntityDao nonEntityDao;
- private final CacheControllerDispatcher cacheControllerDispatcher;
- private /* final */ UUID tagDefinitionId;
+ private final TagInternalApi tagApi;
@Inject
- public ParkedAccountsManager(final TagUserApi tagUserApi,
- final TagDefinitionDao tagDefinitionDao,
- final NonEntityDao nonEntityDao,
- final CacheControllerDispatcher cacheControllerDispatcher,
- final Clock clock) throws TagDefinitionApiException {
- this.tagUserApi = tagUserApi;
- this.tagDefinitionDao = tagDefinitionDao;
- this.nonEntityDao = nonEntityDao;
- this.cacheControllerDispatcher = cacheControllerDispatcher;
-
- retrieveOrCreateParkTagDefinition(clock);
+ public ParkedAccountsManager(final TagInternalApi tagApi) throws TagDefinitionApiException {
+ this.tagApi = tagApi;
}
+ // Idempotent
public void parkAccount(final UUID accountId, final InternalCallContext internalCallContext) throws TagApiException {
- final CallContext callContext = createCallContext(internalCallContext);
- tagUserApi.addTag(accountId, ObjectType.ACCOUNT, tagDefinitionId, callContext);
+ 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 {
- final CallContext callContext = createCallContext(internalCallContext);
- tagUserApi.removeTag(accountId, ObjectType.ACCOUNT, tagDefinitionId, callContext);
+ tagApi.removeTag(accountId, ObjectType.ACCOUNT, PARK_TAG_DEFINITION_ID, internalCallContext);
}
- public boolean isParked(final UUID accountId, final InternalCallContext internalCallContext) throws TagApiException {
- final CallContext callContext = createCallContext(internalCallContext);
- return Iterables.<Tag>tryFind(tagUserApi.getTagsForAccount(accountId, false, callContext),
+ 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 tagDefinitionId.equals(input.getTagDefinitionId());
+ return PARK_TAG_DEFINITION_ID.equals(input.getTagDefinitionId());
}
}).orNull() != null;
}
-
- // TODO Consider creating a tag internal API to avoid this
- private CallContext createCallContext(final InternalCallContext internalCallContext) {
- final UUID tenantId = nonEntityDao.retrieveIdFromObject(internalCallContext.getTenantRecordId(),
- ObjectType.TENANT,
- cacheControllerDispatcher.getCacheController(CacheType.OBJECT_ID));
- return internalCallContext.toCallContext(tenantId);
- }
-
- @VisibleForTesting
- void retrieveOrCreateParkTagDefinition(final Clock clock) throws TagDefinitionApiException {
- final InternalCallContext callContext = new InternalCallContext(InternalCallContextFactory.INTERNAL_TENANT_RECORD_ID,
- null,
- null,
- null,
- UUID.randomUUID(),
- ParkedAccountsManager.class.getName(),
- CallOrigin.INTERNAL,
- UserType.SYSTEM,
- null,
- null,
- clock.getUTCNow(),
- clock.getUTCNow());
- // Need to use the DAO directly to bypass validations
- TagDefinitionModelDao tagDefinitionModelDao = tagDefinitionDao.getByName(PARK, callContext);
- if (tagDefinitionModelDao == null) {
- tagDefinitionModelDao = tagDefinitionDao.create(PARK, "Accounts with invalid invoicing state", callContext);
- }
- this.tagDefinitionId = tagDefinitionModelDao.getId();
- }
}
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 e9c6342..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,79 +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;
+ // Parent (enclosing) interval
private final ItemsNodeInterval interval;
- private LinkedList<Item> items;
+ private final LinkedList<Item> items;
- public ItemsInterval(final ItemsNodeInterval interval, final UUID targetInvoiceId) {
- this(interval, targetInvoiceId, null);
+ public ItemsInterval(final ItemsNodeInterval interval) {
+ this(interval, null);
}
- public ItemsInterval(final ItemsNodeInterval 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;
}
/**
@@ -119,26 +133,64 @@ 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 void add(final Item item) {
+ items.add(item);
}
- 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 void cancelItems(final Item item) {
+ Preconditions.checkState(item.getAction() == ItemAction.ADD);
+ Preconditions.checkState(items.size() == 1);
+ Preconditions.checkState(items.get(0).getAction() == ItemAction.CANCEL);
+ items.clear();
}
- public NodeInterval getNodeInterval() {
- return interval;
+ public void remove(final Item item) {
+ items.remove(item);
+ }
+
+ // 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);
+ }
+ }
+
+ // Called on the last node
+ public void buildFromItems(final Collection<Item> output, final boolean mergeMode) {
+ buildForMissingInterval(null, null, null, output, mergeMode);
+ }
+
+ /**
+ * Create a new item based on the existing items and new service period
+ * <p/>
+ * <ul>
+ * <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>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 new item for this service period or null
+ */
+ 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 || 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) {
@@ -146,24 +198,18 @@ public class ItemsInterval {
}
private Item getResulting_CANCEL_Item() {
- Preconditions.checkState(items.size() == 0 || items.size() == 1);
+ Preconditions.checkState(items.size() <= 1, "Too many items=%s", items);
return getResulting_CANCEL_ItemNoChecks();
}
private Item getResulting_CANCEL_ItemNoChecks() {
- return Iterables.tryFind(items, new Predicate<Item>() {
- @Override
- public boolean apply(final Item input) {
- return input.getAction() == ItemAction.CANCEL;
- }
- }).orNull();
+ 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
+ // - 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
@@ -172,12 +218,18 @@ public class ItemsInterval {
//
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;
+ 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>();
- if (items.size() > 1) {
- addItemsCancelled.add(items.get(1).getLinkedId());
+ 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);
@@ -196,100 +248,39 @@ public class ItemsInterval {
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 parentCancelItem = parentItemsInterval.getResulting_CANCEL_ItemNoChecks();
if (parentCancelItem != null) {
+ Preconditions.checkState(parentCancelItem.getLinkedId() != null, "Invalid CANCEL item=%s", parentCancelItem);
addItemsCancelled.add(parentCancelItem.getLinkedId());
}
parentItemsInterval.checkDoubleBilling(addItemsCancelled, addItemsToBeCancelled);
}
- // Just ensure that ADD items precedes CANCEL items
- public void insertSortedItem(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) {
- Preconditions.checkState(item.getAction() == ItemAction.ADD);
- Preconditions.checkState(items.size() == 1);
- Preconditions.checkState(items.get(0).getAction() == ItemAction.CANCEL);
- items.clear();
+ private Item findItem(final ItemAction itemAction) {
+ final Collection<Item> matchingItems = findItems(itemAction);
+ return matchingItems.size() == 1 ? matchingItems.iterator().next() : null;
}
- public void remove(final Item item) {
- items.remove(item);
+ 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;
+ }
+ });
}
- 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();
+ @Override
+ public String toString() {
+ final StringBuilder sb = new StringBuilder("ItemsInterval{");
+ sb.append("items=").append(items);
+ sb.append('}');
+ return sb.toString();
}
-
- 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 int size() {
- return items.size();
- }
-
- /**
- * Creates a new item.
- * <p/>
- * <ul>
- * <li>In normal mode, 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).
- * </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
- */
- private Item createNewItem(final LocalDate startDate, final LocalDate endDate, final boolean mergeMode) {
-
- final Item item = getResultingItem(mergeMode);
- if (item == null) {
- return null;
- }
-
- final InvoiceItem proratedInvoiceItem = item.toProratedInvoiceItem(startDate, endDate);
- if (proratedInvoiceItem == null) {
- return null;
- }
-
- final Item result = new Item(proratedInvoiceItem, targetInvoiceId, item.getAction());
- if (item.getAction() == ItemAction.CANCEL) {
- item.incrementCurrentRepairedAmount(result.getAmount());
- }
- return result;
- }
-
}
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 63d0bd8..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,6 +22,7 @@ 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;
@@ -36,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 ItemsNodeInterval 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
@@ -64,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
@@ -83,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) {
-
- 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);
- }
- }
- );
- }
-
- /**
- * 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;
- }
+ pruneAndValidateTree();
- @Override
- public boolean shouldInsertNode(final NodeInterval insertionNode) {
- // Always want to insert node in the tree when we find the right place.
- return true;
- }
- });
+ build(output, targetInvoiceId, false);
}
/**
- * 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
@@ -188,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
@@ -205,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;
}
}
@@ -223,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) {
@@ -240,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);
@@ -255,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();
}
//
@@ -297,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
@@ -351,7 +305,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.getCancellingItemIfExists(curAddItem.getId());
- if (cancelledItem != null) {
+ if (cancelledItem != null && curAddItem.getId().equals(cancelledItem.getLinkedId())) {
totalRepaired.set(totalRepaired.get().add(cancelledItem.getAmount()));
}
}
@@ -395,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());
}
}
@@ -409,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/TestInvoiceDispatcher.java b/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceDispatcher.java
index f22a72b..36a536b 100644
--- a/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceDispatcher.java
+++ b/invoice/src/test/java/org/killbill/billing/invoice/TestInvoiceDispatcher.java
@@ -27,7 +27,6 @@ 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.DefaultTenantContext;
import org.killbill.billing.callcontext.InternalCallContext;
import org.killbill.billing.catalog.MockPlan;
import org.killbill.billing.catalog.MockPlanPhase;
@@ -56,7 +55,7 @@ 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.TagDefinition;
+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;
@@ -80,8 +79,6 @@ public class TestInvoiceDispatcher extends InvoiceTestSuiteWithEmbeddedDB {
account = invoiceUtil.createAccount(callContext);
subscription = invoiceUtil.createSubscription();
context = internalCallContextFactory.createInternalCallContext(account.getId(), callContext);
- // Tests will delete the database entries, yet ParkedAccountsManager is injected once
- parkedAccountsManager.retrieveOrCreateParkTagDefinition(clock);
}
@Test(groups = "slow")
@@ -151,10 +148,6 @@ public class TestInvoiceDispatcher extends InvoiceTestSuiteWithEmbeddedDB {
internalCallContextFactory, invoiceNotifier, invoicePluginDispatcher, locker, busService.getBus(),
null, invoiceConfig, clock, parkedAccountsManager);
- // Verify the __PARK__ tag definition has been created
- final TagDefinition tagDefinition = tagUserApi.getTagDefinitionForName(ParkedAccountsManager.PARK, new DefaultTenantContext(null));
- Assert.assertNotNull(tagDefinition);
-
// Verify initial tags state for account
Assert.assertTrue(tagUserApi.getTagsForAccount(accountId, true, callContext).isEmpty());
@@ -224,7 +217,7 @@ public class TestInvoiceDispatcher extends InvoiceTestSuiteWithEmbeddedDB {
// 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(), tagDefinition.getId());
+ Assert.assertEquals(tags.get(0).getTagDefinitionId(), SystemTags.PARK_TAG_DEFINITION_ID);
// isApiCall=false
final Invoice nullInvoice1 = dispatcher.processAccount(accountId, target, null, context);
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 853b179..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);
@@ -488,6 +489,28 @@ public class TestSubscriptionItemTree extends InvoiceTestSuiteNoDB {
}
}
+ @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);
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/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/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/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")