killbill-aplcache

subscription: Enforce bundle externalKey at the database

9/23/2017 12:24:20 AM

Changes

Details

diff --git a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegration.java b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegration.java
index 89507b5..b9f6047 100644
--- a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegration.java
+++ b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegration.java
@@ -510,40 +510,7 @@ public class TestIntegration extends TestIntegrationBase {
         checkNoMoreInvoiceToGenerate(account);
     }
 
-    @Test(groups = "slow")
-    public void testCreateMultipleBPWithSameExternalKey() throws Exception {
-        final DateTime initialDate = new DateTime(2012, 4, 25, 0, 13, 42, 0, testTimeZone);
-        clock.setDeltaFromReality(initialDate.getMillis() - clock.getUTCNow().getMillis());
-
-        final Account account = createAccountWithNonOsgiPaymentMethod(getAccountData(25));
-        assertNotNull(account);
-
-        final String productName = "Shotgun";
-        final BillingPeriod term = BillingPeriod.MONTHLY;
-
-        final DefaultEntitlement baseEntitlement = createBaseEntitlementAndCheckForCompletion(account.getId(), "bundleKey", productName, ProductCategory.BASE, term, NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE);
-        final SubscriptionBundle initialBundle = subscriptionApi.getActiveSubscriptionBundleForExternalKey("bundleKey", callContext);
-
-        busHandler.pushExpectedEvents(NextEvent.BLOCK, NextEvent.CANCEL, NextEvent.NULL_INVOICE);
-        baseEntitlement.cancelEntitlementWithPolicy(EntitlementActionPolicy.IMMEDIATE, ImmutableList.<PluginProperty>of(), callContext);
-        assertListenerStatus();
-
-        final String newProductName = "Pistol";
-        final DefaultEntitlement newBaseEntitlement = createBaseEntitlementAndCheckForCompletion(account.getId(), "bundleKey", newProductName, ProductCategory.BASE, term, NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE);
-
-        final SubscriptionBundle newBundle = subscriptionApi.getActiveSubscriptionBundleForExternalKey("bundleKey", callContext);
-
-        assertNotEquals(initialBundle.getId(), newBundle.getId());
-        assertEquals(initialBundle.getAccountId(), newBundle.getAccountId());
-        assertEquals(initialBundle.getExternalKey(), newBundle.getExternalKey());
-
-        final Entitlement refreshedBseEntitlement = entitlementApi.getEntitlementForId(baseEntitlement.getId(), callContext);
-
-        assertEquals(refreshedBseEntitlement.getState(), EntitlementState.CANCELLED);
-        assertEquals(newBaseEntitlement.getState(), EntitlementState.ACTIVE);
-
-        checkNoMoreInvoiceToGenerate(account);
-    }
+   
 
     @Test(groups = "slow")
     public void testWithPauseResume() throws Exception {
diff --git a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationParentInvoice.java b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationParentInvoice.java
index a8fd0c1..324e747 100644
--- a/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationParentInvoice.java
+++ b/beatrix/src/test/java/org/killbill/billing/beatrix/integration/TestIntegrationParentInvoice.java
@@ -918,7 +918,7 @@ public class TestIntegrationParentInvoice extends TestIntegrationBase {
         clock.setTime(date);
         assertListenerStatus();
 
-        createBaseEntitlementAndCheckForCompletion(childAccount.getId(), "bundleKey1", "Shotgun", ProductCategory.BASE, BillingPeriod.MONTHLY, NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE);
+        createBaseEntitlementAndCheckForCompletion(childAccount.getId(), "bundleKey2", "Shotgun", ProductCategory.BASE, BillingPeriod.MONTHLY, NextEvent.CREATE, NextEvent.BLOCK, NextEvent.INVOICE);
 
         // Move through time and verify new parent Invoice.
         busHandler.pushExpectedEvents(NextEvent.PHASE, NextEvent.INVOICE, NextEvent.INVOICE);
diff --git a/entitlement/src/main/java/org/killbill/billing/entitlement/api/DefaultSubscriptionApi.java b/entitlement/src/main/java/org/killbill/billing/entitlement/api/DefaultSubscriptionApi.java
index f8b59ef..a9416a7 100644
--- a/entitlement/src/main/java/org/killbill/billing/entitlement/api/DefaultSubscriptionApi.java
+++ b/entitlement/src/main/java/org/killbill/billing/entitlement/api/DefaultSubscriptionApi.java
@@ -268,7 +268,7 @@ public class DefaultSubscriptionApi implements SubscriptionApi {
 
         logUpdateExternalKey(log, bundleId, newExternalKey);
 
-        final InternalCallContext internalCallContext = internalCallContextFactory.createInternalCallContextWithoutAccountRecordId(callContext);
+        final InternalCallContext internalCallContext = internalCallContextFactory.createInternalCallContext(bundleId, ObjectType.BUNDLE, callContext);
 
         final SubscriptionBaseBundle bundle;
         final ImmutableAccountData account;
diff --git a/entitlement/src/test/java/org/killbill/billing/entitlement/api/TestDefaultSubscriptionApi.java b/entitlement/src/test/java/org/killbill/billing/entitlement/api/TestDefaultSubscriptionApi.java
index c73dcdb..9c719bf 100644
--- a/entitlement/src/test/java/org/killbill/billing/entitlement/api/TestDefaultSubscriptionApi.java
+++ b/entitlement/src/test/java/org/killbill/billing/entitlement/api/TestDefaultSubscriptionApi.java
@@ -134,6 +134,9 @@ public class TestDefaultSubscriptionApi extends EntitlementTestSuiteWithEmbedded
         entitlement.cancelEntitlementWithDate(new LocalDate(clock.getUTCNow(), account.getTimeZone()), true, ImmutableList.<PluginProperty>of(), callContext);
         assertListenerStatus();
 
+        // Update the bundle externalKey associated with previously cancelled  subscription, so we can reuse it
+        subscriptionApi.updateExternalKey(bundles.get(0).getId(), "kbtest-123:" + externalKey, callContext);
+
         try {
             subscriptionApi.getActiveSubscriptionBundleForExternalKey(externalKey, callContext);
             Assert.fail("Expected getActiveSubscriptionBundleForExternalKey to fail after cancellation");
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/api/svcs/DefaultSubscriptionInternalApi.java b/subscription/src/main/java/org/killbill/billing/subscription/api/svcs/DefaultSubscriptionInternalApi.java
index a3a15cc..140d1a1 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/api/svcs/DefaultSubscriptionInternalApi.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/api/svcs/DefaultSubscriptionInternalApi.java
@@ -46,7 +46,6 @@ import org.killbill.billing.catalog.api.PlanPhase;
 import org.killbill.billing.catalog.api.PlanPhasePriceOverride;
 import org.killbill.billing.catalog.api.PlanPhasePriceOverridesWithCallContext;
 import org.killbill.billing.catalog.api.PlanPhaseSpecifier;
-import org.killbill.billing.catalog.api.PlanSpecifier;
 import org.killbill.billing.catalog.api.ProductCategory;
 import org.killbill.billing.entitlement.api.BaseEntitlementWithAddOnsSpecifier;
 import org.killbill.billing.entitlement.api.Entitlement.EntitlementState;
@@ -399,7 +398,8 @@ public class DefaultSubscriptionInternalApi extends SubscriptionApiBase implemen
 
     @Override
     public List<SubscriptionBaseBundle> getBundlesForAccountAndKey(final UUID accountId, final String bundleKey, final InternalTenantContext context) throws SubscriptionBaseApiException {
-        return dao.getSubscriptionBundlesForAccountAndKey(accountId, bundleKey, context);
+        final SubscriptionBaseBundle subscriptionBundlesForAccountAndKey = dao.getSubscriptionBundlesForAccountAndKey(accountId, bundleKey, context);
+        return subscriptionBundlesForAccountAndKey != null ? ImmutableList.of(subscriptionBundlesForAccountAndKey) : ImmutableList.<SubscriptionBaseBundle>of();
     }
 
     @Override
@@ -424,7 +424,7 @@ public class DefaultSubscriptionInternalApi extends SubscriptionApiBase implemen
                                               new Function<SubscriptionBundleModelDao, SubscriptionBaseBundle>() {
                                                   @Override
                                                   public SubscriptionBaseBundle apply(final SubscriptionBundleModelDao bundleModelDao) {
-                                                      return SubscriptionBundleModelDao.toSubscriptionbundle(bundleModelDao);
+                                                      return SubscriptionBundleModelDao.toSubscriptionBundle(bundleModelDao);
                                                   }
                                               }
                                              );
@@ -442,7 +442,7 @@ public class DefaultSubscriptionInternalApi extends SubscriptionApiBase implemen
                                               new Function<SubscriptionBundleModelDao, SubscriptionBaseBundle>() {
                                                   @Override
                                                   public SubscriptionBaseBundle apply(final SubscriptionBundleModelDao bundleModelDao) {
-                                                      return SubscriptionBundleModelDao.toSubscriptionbundle(bundleModelDao);
+                                                      return SubscriptionBundleModelDao.toSubscriptionBundle(bundleModelDao);
                                                   }
                                               }
                                              );
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/api/transfer/DefaultSubscriptionBaseTransferApi.java b/subscription/src/main/java/org/killbill/billing/subscription/api/transfer/DefaultSubscriptionBaseTransferApi.java
index 61f34e9..f93883e 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/api/transfer/DefaultSubscriptionBaseTransferApi.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/api/transfer/DefaultSubscriptionBaseTransferApi.java
@@ -193,12 +193,13 @@ public class DefaultSubscriptionBaseTransferApi extends SubscriptionApiBase impl
                 throw new SubscriptionBaseTransferApiException(ErrorCode.SUB_TRANSFER_INVALID_EFF_DATE, effectiveTransferDate);
             }
 
-            final List<SubscriptionBaseBundle> bundlesForAccountAndKey = dao.getSubscriptionBundlesForAccountAndKey(sourceAccountId, bundleKey, fromInternalCallContext);
-            final SubscriptionBaseBundle bundle = DefaultSubscriptionInternalApi.getActiveBundleForKeyNotException(bundlesForAccountAndKey, dao, clock, catalog, fromInternalCallContext);
+            final SubscriptionBaseBundle bundleForAccountAndKey = dao.getSubscriptionBundlesForAccountAndKey(sourceAccountId, bundleKey, fromInternalCallContext);
+            final SubscriptionBaseBundle bundle = DefaultSubscriptionInternalApi.getActiveBundleForKeyNotException(ImmutableList.of(bundleForAccountAndKey), dao, clock, catalog, fromInternalCallContext);
             if (bundle == null) {
                 throw new SubscriptionBaseTransferApiException(ErrorCode.SUB_CREATE_NO_BUNDLE, bundleKey);
             }
 
+
             // Get the bundle timeline for the old account
             final BundleBaseTimeline bundleBaseTimeline = timelineApi.getBundleTimeline(bundle, context);
 
@@ -260,7 +261,7 @@ public class DefaultSubscriptionBaseTransferApi extends SubscriptionApiBase impl
                 final SubscriptionTransferData curData = new SubscriptionTransferData(defaultSubscriptionBase, events, null);
                 subscriptionTransferDataList.add(curData);
             }
-            BundleTransferData bundleTransferData = new BundleTransferData(subscriptionBundleData, subscriptionTransferDataList);
+            final BundleTransferData bundleTransferData = new BundleTransferData(subscriptionBundleData, subscriptionTransferDataList);
 
             // Atomically cancelWithRequestedDate all subscription on old account and create new bundle, subscriptions, events for new account
             dao.transfer(sourceAccountId, destAccountId, bundleTransferData, transferCancelDataList, catalog, fromInternalCallContext, toInternalCallContext);
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/BundleSqlDao.java b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/BundleSqlDao.java
index bc8f9f2..79a0d28 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/BundleSqlDao.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/BundleSqlDao.java
@@ -19,11 +19,6 @@ package org.killbill.billing.subscription.engine.dao;
 import java.util.Date;
 import java.util.List;
 
-import org.skife.jdbi.v2.sqlobject.Bind;
-import org.killbill.commons.jdbi.binder.SmartBindBean;
-import org.skife.jdbi.v2.sqlobject.SqlQuery;
-import org.skife.jdbi.v2.sqlobject.SqlUpdate;
-
 import org.killbill.billing.callcontext.InternalCallContext;
 import org.killbill.billing.callcontext.InternalTenantContext;
 import org.killbill.billing.subscription.api.user.SubscriptionBaseBundle;
@@ -31,7 +26,12 @@ import org.killbill.billing.subscription.engine.dao.model.SubscriptionBundleMode
 import org.killbill.billing.util.audit.ChangeType;
 import org.killbill.billing.util.entity.dao.Audited;
 import org.killbill.billing.util.entity.dao.EntitySqlDao;
+import org.killbill.commons.jdbi.binder.SmartBindBean;
 import org.killbill.commons.jdbi.template.KillBillSqlDaoStringTemplate;
+import org.skife.jdbi.v2.sqlobject.Bind;
+import org.skife.jdbi.v2.sqlobject.SqlQuery;
+import org.skife.jdbi.v2.sqlobject.SqlUpdate;
+import org.skife.jdbi.v2.sqlobject.customizers.Define;
 
 @KillBillSqlDaoStringTemplate
 public interface BundleSqlDao extends EntitySqlDao<SubscriptionBundleModelDao, SubscriptionBaseBundle> {
@@ -44,14 +44,20 @@ public interface BundleSqlDao extends EntitySqlDao<SubscriptionBundleModelDao, S
 
     @SqlUpdate
     @Audited(ChangeType.UPDATE)
+    public void renameBundleExternalKey(@Bind("externalKey") String externalKey,
+                                        @Define("prefix") final String prefix,
+                                        @SmartBindBean final InternalCallContext context);
+
+    @SqlUpdate
+    @Audited(ChangeType.UPDATE)
     public void updateBundleLastSysTime(@Bind("id") String id,
                                         @Bind("lastSysUpdateDate") Date lastSysUpdate,
                                         @SmartBindBean final InternalCallContext context);
 
     @SqlQuery
-    public List<SubscriptionBundleModelDao> getBundlesFromAccountAndKey(@Bind("accountId") String accountId,
-                                                                        @Bind("externalKey") String externalKey,
-                                                                        @SmartBindBean final InternalTenantContext context);
+    public SubscriptionBundleModelDao getBundlesFromAccountAndKey(@Bind("accountId") String accountId,
+                                                                  @Bind("externalKey") String externalKey,
+                                                                  @SmartBindBean final InternalTenantContext context);
 
     @SqlQuery
     public List<SubscriptionBundleModelDao> getBundleFromAccount(@Bind("accountId") String accountId,
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/DefaultSubscriptionDao.java b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/DefaultSubscriptionDao.java
index ac018a3..67bdfb1 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/DefaultSubscriptionDao.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/DefaultSubscriptionDao.java
@@ -35,12 +35,10 @@ import javax.inject.Inject;
 
 import org.joda.time.DateTime;
 import org.killbill.billing.ErrorCode;
-import org.killbill.billing.ObjectType;
 import org.killbill.billing.callcontext.InternalCallContext;
 import org.killbill.billing.callcontext.InternalTenantContext;
 import org.killbill.billing.catalog.api.Catalog;
 import org.killbill.billing.catalog.api.CatalogApiException;
-import org.killbill.billing.catalog.api.CatalogInternalApi;
 import org.killbill.billing.catalog.api.Plan;
 import org.killbill.billing.catalog.api.ProductCategory;
 import org.killbill.billing.entitlement.api.Entitlement.EntitlementState;
@@ -140,17 +138,12 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
     }
 
     @Override
-    public List<SubscriptionBaseBundle> getSubscriptionBundlesForAccountAndKey(final UUID accountId, final String bundleKey, final InternalTenantContext context) {
-        return transactionalSqlDao.execute(new EntitySqlDaoTransactionWrapper<List<SubscriptionBaseBundle>>() {
+    public SubscriptionBaseBundle getSubscriptionBundlesForAccountAndKey(final UUID accountId, final String bundleKey, final InternalTenantContext context) {
+        return transactionalSqlDao.execute(new EntitySqlDaoTransactionWrapper<SubscriptionBaseBundle>() {
             @Override
-            public List<SubscriptionBaseBundle> inTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory) throws Exception {
-                final List<SubscriptionBundleModelDao> models = entitySqlDaoWrapperFactory.become(BundleSqlDao.class).getBundlesFromAccountAndKey(accountId.toString(), bundleKey, context);
-                return new ArrayList<SubscriptionBaseBundle>(Collections2.transform(models, new Function<SubscriptionBundleModelDao, SubscriptionBaseBundle>() {
-                    @Override
-                    public SubscriptionBaseBundle apply(@Nullable final SubscriptionBundleModelDao input) {
-                        return SubscriptionBundleModelDao.toSubscriptionbundle(input);
-                    }
-                }));
+            public SubscriptionBaseBundle inTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory) throws Exception {
+                final SubscriptionBundleModelDao input = entitySqlDaoWrapperFactory.become(BundleSqlDao.class).getBundlesFromAccountAndKey(accountId.toString(), bundleKey, context);
+                return SubscriptionBundleModelDao.toSubscriptionBundle(input);
             }
         });
     }
@@ -165,7 +158,7 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
                 return new ArrayList<SubscriptionBaseBundle>(Collections2.transform(models, new Function<SubscriptionBundleModelDao, SubscriptionBaseBundle>() {
                     @Override
                     public SubscriptionBaseBundle apply(@Nullable final SubscriptionBundleModelDao input) {
-                        return SubscriptionBundleModelDao.toSubscriptionbundle(input);
+                        return SubscriptionBundleModelDao.toSubscriptionBundle(input);
                     }
                 }));
             }
@@ -178,13 +171,14 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
             @Override
             public SubscriptionBaseBundle inTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory) throws Exception {
                 final SubscriptionBundleModelDao model = entitySqlDaoWrapperFactory.become(BundleSqlDao.class).getById(bundleId.toString(), context);
-                return SubscriptionBundleModelDao.toSubscriptionbundle(model);
+                return SubscriptionBundleModelDao.toSubscriptionBundle(model);
             }
         });
     }
 
     @Override
     public List<SubscriptionBaseBundle> getSubscriptionBundlesForKey(final String bundleKey, final InternalTenantContext context) {
+
         return transactionalSqlDao.execute(new EntitySqlDaoTransactionWrapper<List<SubscriptionBaseBundle>>() {
             @Override
             public List<SubscriptionBaseBundle> inTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory) throws Exception {
@@ -192,7 +186,7 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
                 return new ArrayList<SubscriptionBaseBundle>(Collections2.transform(models, new Function<SubscriptionBundleModelDao, SubscriptionBaseBundle>() {
                     @Override
                     public SubscriptionBaseBundle apply(@Nullable final SubscriptionBundleModelDao input) {
-                        return SubscriptionBundleModelDao.toSubscriptionbundle(input);
+                        return SubscriptionBundleModelDao.toSubscriptionBundle(input);
                     }
                 }));
             }
@@ -273,7 +267,9 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
                 final SubscriptionBundleModelDao existingBundleForAccount = Iterables.tryFind(existingBundles, new Predicate<SubscriptionBundleModelDao>() {
                     @Override
                     public boolean apply(final SubscriptionBundleModelDao input) {
-                        return input.getAccountId().equals(bundle.getAccountId());
+                        return input.getAccountId().equals(bundle.getAccountId()) &&
+                               // We look for strict equality ignoring tsf items with keys 'kbtsf-343453:'
+                               bundle.getExternalKey().equals(input.getExternalKey());
                     }
                 }).orNull();
 
@@ -287,7 +283,7 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
                             return input.getBundleId().equals(existingBundleForAccount.getId());
                         }
                     })) {
-                        return SubscriptionBundleModelDao.toSubscriptionbundle(existingBundleForAccount);
+                        return SubscriptionBundleModelDao.toSubscriptionBundle(existingBundleForAccount);
                     }
                 }
                 return null;
@@ -330,7 +326,7 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
                 }
                 final BundleSqlDao bundleSqlDao = entitySqlDaoWrapperFactory.become(BundleSqlDao.class);
                 final SubscriptionBundleModelDao result = createAndRefresh(bundleSqlDao, model, context);
-                return SubscriptionBundleModelDao.toSubscriptionbundle(result);
+                return SubscriptionBundleModelDao.toSubscriptionBundle(result);
             }
         });
     }
@@ -1043,13 +1039,18 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
         transactionalSqlDao.execute(new EntitySqlDaoTransactionWrapper<Void>() {
             @Override
             public Void inTransaction(final EntitySqlDaoWrapperFactory entitySqlDaoWrapperFactory) throws Exception {
-                final SubscriptionEventSqlDao transactional = entitySqlDaoWrapperFactory.become(SubscriptionEventSqlDao.class);
 
                 // Cancel the subscriptions for the old bundle
                 for (final TransferCancelData cancel : transferCancelData) {
                     cancelSubscriptionFromTransaction(cancel.getSubscription(), cancel.getCancelEvent(), entitySqlDaoWrapperFactory, catalog, fromContext, 0);
                 }
 
+
+                // Rename externalKey from source bundle
+                final BundleSqlDao bundleSqlDao = entitySqlDaoWrapperFactory.become(BundleSqlDao.class);
+                bundleSqlDao.renameBundleExternalKey(bundleTransferData.getData().getExternalKey(), "tsf", fromContext);
+
+                final SubscriptionEventSqlDao transactional = entitySqlDaoWrapperFactory.become(SubscriptionEventSqlDao.class);
                 transferBundleDataFromTransaction(bundleTransferData, transactional, entitySqlDaoWrapperFactory, toContext);
                 return null;
             }
@@ -1181,8 +1182,8 @@ public class DefaultSubscriptionDao extends EntityDaoBase<SubscriptionBundleMode
 
         final DefaultSubscriptionBaseBundle bundleData = bundleTransferData.getData();
 
-        final List<SubscriptionBundleModelDao> existingBundleModels = transBundleDao.getBundlesFromAccountAndKey(bundleData.getAccountId().toString(), bundleData.getExternalKey(), context);
-        if (!existingBundleModels.isEmpty()) {
+        final SubscriptionBundleModelDao existingBundleForAccount = transBundleDao.getBundlesFromAccountAndKey(bundleData.getAccountId().toString(), bundleData.getExternalKey(), context);
+        if (existingBundleForAccount != null) {
             log.warn("Bundle already exists for accountId='{}', bundleExternalKey='{}'", bundleData.getAccountId(), bundleData.getExternalKey());
             return;
         }
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/model/SubscriptionBundleModelDao.java b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/model/SubscriptionBundleModelDao.java
index 2c9489f..e5938dc 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/model/SubscriptionBundleModelDao.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/model/SubscriptionBundleModelDao.java
@@ -17,6 +17,8 @@
 package org.killbill.billing.subscription.engine.dao.model;
 
 import java.util.UUID;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import org.joda.time.DateTime;
 import org.killbill.billing.subscription.api.user.DefaultSubscriptionBaseBundle;
@@ -29,6 +31,11 @@ import com.google.common.base.MoreObjects;
 
 public class SubscriptionBundleModelDao extends EntityModelDaoBase implements EntityModelDao<SubscriptionBaseBundle> {
 
+    // Any key that starts with kb<some_prefix>-<some_number>:<something> is interpreted as a  <something> key that got renamed for internal purpose
+    // KB core currently only use the prefix 'tsf' for renaming such keys during bundle transfer
+    //
+    private static Pattern BUNDLE_KEY_PATTERN  = Pattern.compile("kb(?:\\w+)-\\d+:(.*)");
+
     private String externalKey;
     private UUID accountId;
     private DateTime lastSysUpdateDate;
@@ -81,11 +88,15 @@ public class SubscriptionBundleModelDao extends EntityModelDaoBase implements En
         this.originalCreatedDate = originalCreatedDate;
     }
 
-    public static SubscriptionBaseBundle toSubscriptionbundle(final SubscriptionBundleModelDao src) {
+    public static SubscriptionBaseBundle toSubscriptionBundle(final SubscriptionBundleModelDao src) {
         if (src == null) {
             return null;
         }
-        return new DefaultSubscriptionBaseBundle(src.getId(), src.getExternalKey(), src.getAccountId(), src.getLastSysUpdateDate(), src.getOriginalCreatedDate(), src.getCreatedDate(), src.getUpdatedDate());
+
+        // Fix externalKey to remove internal prefix used for tsf
+        final Matcher m = BUNDLE_KEY_PATTERN.matcher(src.getExternalKey());
+        final String externalKey = m.matches() ? m.group(1) : src.getExternalKey();
+        return new DefaultSubscriptionBaseBundle(src.getId(), externalKey, src.getAccountId(), src.getLastSysUpdateDate(), src.getOriginalCreatedDate(), src.getCreatedDate(), src.getUpdatedDate());
     }
 
     @Override
diff --git a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/SubscriptionDao.java b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/SubscriptionDao.java
index ac499bf..accffd1 100644
--- a/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/SubscriptionDao.java
+++ b/subscription/src/main/java/org/killbill/billing/subscription/engine/dao/SubscriptionDao.java
@@ -31,7 +31,6 @@ import org.killbill.billing.subscription.api.transfer.BundleTransferData;
 import org.killbill.billing.subscription.api.transfer.TransferCancelData;
 import org.killbill.billing.subscription.api.user.DefaultSubscriptionBase;
 import org.killbill.billing.subscription.api.user.DefaultSubscriptionBaseBundle;
-import org.killbill.billing.subscription.api.user.DefaultSubscriptionBaseWithAddOns;
 import org.killbill.billing.subscription.api.user.SubscriptionBaseApiException;
 import org.killbill.billing.subscription.api.user.SubscriptionBaseBundle;
 import org.killbill.billing.subscription.engine.dao.model.SubscriptionBundleModelDao;
@@ -50,7 +49,7 @@ public interface SubscriptionDao extends EntityDao<SubscriptionBundleModelDao, S
 
     public Iterable<UUID> getNonAOSubscriptionIdsForKey(String bundleKey, InternalTenantContext context);
 
-    public List<SubscriptionBaseBundle> getSubscriptionBundlesForAccountAndKey(UUID accountId, String bundleKey, InternalTenantContext context);
+    public SubscriptionBaseBundle getSubscriptionBundlesForAccountAndKey(UUID accountId, String bundleKey, InternalTenantContext context);
 
     public SubscriptionBaseBundle getSubscriptionBundleFromId(UUID bundleId, InternalTenantContext context);
 
diff --git a/subscription/src/main/resources/org/killbill/billing/subscription/ddl.sql b/subscription/src/main/resources/org/killbill/billing/subscription/ddl.sql
index ac3b021..0807094 100644
--- a/subscription/src/main/resources/org/killbill/billing/subscription/ddl.sql
+++ b/subscription/src/main/resources/org/killbill/billing/subscription/ddl.sql
@@ -65,7 +65,7 @@ CREATE TABLE bundles (
     PRIMARY KEY(record_id)
 ) /*! CHARACTER SET utf8 COLLATE utf8_bin */;
 CREATE UNIQUE INDEX bundles_id ON bundles(id);
-CREATE INDEX bundles_key ON bundles(external_key);
+CREATE UNIQUE INDEX bundles_external_key ON bundles(external_key, tenant_record_id);
 CREATE INDEX bundles_account ON bundles(account_id);
 CREATE INDEX bundles_tenant_account_record_id ON bundles(tenant_record_id, account_record_id);
 
diff --git a/subscription/src/main/resources/org/killbill/billing/subscription/engine/dao/BundleSqlDao.sql.stg b/subscription/src/main/resources/org/killbill/billing/subscription/engine/dao/BundleSqlDao.sql.stg
index 9a5337c..874e2e7 100644
--- a/subscription/src/main/resources/org/killbill/billing/subscription/engine/dao/BundleSqlDao.sql.stg
+++ b/subscription/src/main/resources/org/killbill/billing/subscription/engine/dao/BundleSqlDao.sql.stg
@@ -47,11 +47,26 @@ where id = :id
 ;
 >>
 
+
+renameBundleExternalKey(prefix)  ::= <<
+update bundles b
+join (select
+     record_id
+     , external_key
+     from
+     bundles
+     where external_key = :externalKey <AND_CHECK_TENANT("")>) t
+on b.record_id = t.record_id
+set b.external_key = concat('kb', '<prefix>', '-', t.record_id, ':', t.external_key)
+;
+>>
+
 getBundlesForKey() ::= <<
 select <allTableFields("")>
 from bundles
 where
 external_key = :externalKey
+or external_key like concat('kb%-%:', :externalKey)
 <AND_CHECK_TENANT("")>
 <defaultOrderBy("")>
 ;
diff --git a/subscription/src/main/resources/org/killbill/billing/subscription/migration/V20170920200757__bundle_external_key.sql b/subscription/src/main/resources/org/killbill/billing/subscription/migration/V20170920200757__bundle_external_key.sql
new file mode 100644
index 0000000..c175f86
--- /dev/null
+++ b/subscription/src/main/resources/org/killbill/billing/subscription/migration/V20170920200757__bundle_external_key.sql
@@ -0,0 +1,2 @@
+drop index bundles_key on bundles;
+create unique index bundles_external_key on bundles(external_key, tenant_record_id);
\ No newline at end of file
diff --git a/subscription/src/test/java/org/killbill/billing/subscription/api/transfer/TestTransfer.java b/subscription/src/test/java/org/killbill/billing/subscription/api/transfer/TestTransfer.java
index 3a0b350..ea1b9fa 100644
--- a/subscription/src/test/java/org/killbill/billing/subscription/api/transfer/TestTransfer.java
+++ b/subscription/src/test/java/org/killbill/billing/subscription/api/transfer/TestTransfer.java
@@ -64,7 +64,7 @@ public class TestTransfer extends SubscriptionTestSuiteWithEmbeddedDB {
         final Account account2 = createAccount(accountData2);
         finalNewAccountId = account2.getId();
 
-        // internal context will be configured for newAccountId
+        // internal context will be configured for accountId
         final AccountData accountData = subscriptionTestInitializer.initAccountData();
         final Account account = createAccount(accountData);
         newAccountId = account.getId();
diff --git a/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCreate.java b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCreate.java
index 36167c8..065ae2e 100644
--- a/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCreate.java
+++ b/subscription/src/test/java/org/killbill/billing/subscription/api/user/TestUserApiCreate.java
@@ -76,6 +76,7 @@ public class TestUserApiCreate extends SubscriptionTestSuiteWithEmbeddedDB {
         subscription.cancelWithDate(clock.getUTCNow(), callContext);
         assertListenerStatus();
 
+        /*
         final SubscriptionBaseBundle newBundle = subscriptionInternalApi.createBundleForAccount(bundle.getAccountId(), DefaultSubscriptionTestInitializer.DEFAULT_BUNDLE_KEY, internalCallContext);
         assertNotNull(newBundle);
         assertEquals(newBundle.getOriginalCreatedDate().compareTo(bundle.getCreatedDate()), 0);
@@ -92,6 +93,7 @@ public class TestUserApiCreate extends SubscriptionTestSuiteWithEmbeddedDB {
 
         assertListenerStatus();
         assertNotNull(newSubscription);
+        */
     }
 
     @Test(groups = "slow")
diff --git a/subscription/src/test/java/org/killbill/billing/subscription/engine/dao/MockSubscriptionDaoMemory.java b/subscription/src/test/java/org/killbill/billing/subscription/engine/dao/MockSubscriptionDaoMemory.java
index cbcb9a5..0e94c99 100644
--- a/subscription/src/test/java/org/killbill/billing/subscription/engine/dao/MockSubscriptionDaoMemory.java
+++ b/subscription/src/test/java/org/killbill/billing/subscription/engine/dao/MockSubscriptionDaoMemory.java
@@ -163,14 +163,14 @@ public class MockSubscriptionDaoMemory extends MockEntityDaoBase<SubscriptionBun
     }
 
     @Override
-    public List<SubscriptionBaseBundle> getSubscriptionBundlesForAccountAndKey(final UUID accountId, final String bundleKey, final InternalTenantContext context) {
+    public SubscriptionBaseBundle getSubscriptionBundlesForAccountAndKey(final UUID accountId, final String bundleKey, final InternalTenantContext context) {
         final List<SubscriptionBaseBundle> results = new ArrayList<SubscriptionBaseBundle>();
         for (final SubscriptionBaseBundle cur : bundles) {
             if (cur.getExternalKey().equals(bundleKey) && cur.getAccountId().equals(accountId)) {
-                results.add(cur);
+                return cur;
             }
         }
-        return results;
+        return null;
     }
 
     @Override
diff --git a/subscription/src/test/java/org/killbill/billing/subscription/engine/dao/TestSubscriptionDao.java b/subscription/src/test/java/org/killbill/billing/subscription/engine/dao/TestSubscriptionDao.java
new file mode 100644
index 0000000..cfe002b
--- /dev/null
+++ b/subscription/src/test/java/org/killbill/billing/subscription/engine/dao/TestSubscriptionDao.java
@@ -0,0 +1,166 @@
+/*
+ * Copyright 2014-2017 Groupon, Inc
+ * Copyright 2014-2017 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.subscription.engine.dao;
+
+import java.util.List;
+import java.util.UUID;
+
+import org.joda.time.DateTime;
+import org.killbill.billing.ErrorCode;
+import org.killbill.billing.account.api.Account;
+import org.killbill.billing.account.api.AccountData;
+import org.killbill.billing.catalog.DefaultPriceListSet;
+import org.killbill.billing.catalog.api.ProductCategory;
+import org.killbill.billing.subscription.SubscriptionTestSuiteWithEmbeddedDB;
+import org.killbill.billing.subscription.api.user.DefaultSubscriptionBase;
+import org.killbill.billing.subscription.api.user.DefaultSubscriptionBaseBundle;
+import org.killbill.billing.subscription.api.user.SubscriptionBaseApiException;
+import org.killbill.billing.subscription.api.user.SubscriptionBaseBundle;
+import org.killbill.billing.subscription.api.user.SubscriptionBuilder;
+import org.killbill.billing.subscription.events.SubscriptionBaseEvent;
+import org.killbill.billing.subscription.events.user.ApiEventBuilder;
+import org.killbill.billing.subscription.events.user.ApiEventCreate;
+import org.killbill.billing.util.UUIDs;
+import org.testng.Assert;
+import org.testng.annotations.AfterMethod;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.Test;
+
+import com.google.common.collect.ImmutableList;
+
+import static org.testng.Assert.assertEquals;
+
+public class TestSubscriptionDao extends SubscriptionTestSuiteWithEmbeddedDB {
+
+    protected UUID accountId;
+
+    @Override
+    @BeforeMethod(groups = "slow")
+    public void beforeMethod() throws Exception {
+        // Note: this will cleanup all tables
+        super.beforeMethod();
+
+        // internal context will be configured for accountId
+        final AccountData accountData = subscriptionTestInitializer.initAccountData();
+        final Account account = createAccount(accountData);
+        accountId = account.getId();
+    }
+
+    @Override // to ignore events
+    @AfterMethod(groups = "slow")
+    public void afterMethod() throws Exception {
+        subscriptionTestInitializer.stopTestFramework(testListener, busService, subscriptionBaseService);
+    }
+
+    @Test(groups = "slow")
+    public void testBundleExternalKeyReused() throws Exception {
+
+        final String externalKey = "12345";
+        final DateTime startDate = clock.getUTCNow();
+        final DateTime createdDate = startDate.plusSeconds(10);
+
+        final DefaultSubscriptionBaseBundle bundleDef = new DefaultSubscriptionBaseBundle(externalKey, accountId, startDate, startDate, createdDate, createdDate);
+        final SubscriptionBaseBundle bundle = dao.createSubscriptionBundle(bundleDef, catalog, internalCallContext);
+
+        final List<SubscriptionBaseBundle> result = dao.getSubscriptionBundlesForKey(externalKey, internalCallContext);
+        assertEquals(result.size(), 1);
+        assertEquals(result.get(0).getExternalKey(), bundle.getExternalKey());
+
+        // Operation succeeds but nothing new got created because bundle is empty
+        dao.createSubscriptionBundle(bundleDef, catalog, internalCallContext);
+        final List<SubscriptionBaseBundle> result2 = dao.getSubscriptionBundlesForKey(externalKey, internalCallContext);
+        assertEquals(result2.size(), 1);
+
+        // Create a subscription and this time operation should fail
+        final SubscriptionBuilder builder = new SubscriptionBuilder()
+                .setId(UUIDs.randomUUID())
+                .setBundleId(bundle.getId())
+                .setBundleExternalKey(bundle.getExternalKey())
+                .setCategory(ProductCategory.BASE)
+                .setBundleStartDate(startDate)
+                .setAlignStartDate(startDate)
+                .setMigrated(false);
+
+        final ApiEventBuilder createBuilder = new ApiEventBuilder()
+                .setSubscriptionId(builder.getId())
+                .setEventPlan("shotgun-monthly")
+                .setEventPlanPhase("shotgun-monthly-trial")
+                .setEventPriceList(DefaultPriceListSet.DEFAULT_PRICELIST_NAME)
+                .setEffectiveDate(startDate)
+                .setFromDisk(true);
+        final SubscriptionBaseEvent creationEvent = new ApiEventCreate(createBuilder);
+
+        final DefaultSubscriptionBase subscription = new DefaultSubscriptionBase(builder);
+        dao.createSubscription(subscription, ImmutableList.of(creationEvent), catalog, internalCallContext);
+
+        // Operation Should now fail
+        try {
+            dao.createSubscriptionBundle(bundleDef, catalog, internalCallContext);
+            Assert.fail("Should fail to create new subscription bundle with existing key");
+        } catch (SubscriptionBaseApiException e) {
+            assertEquals(ErrorCode.SUB_CREATE_ACTIVE_BUNDLE_KEY_EXISTS.getCode(), e.getCode());
+        }
+    }
+
+    @Test(groups = "slow")
+    public void testBundleExternalKeyTransferred() throws Exception {
+
+        final String externalKey = "2534125sdfsd";
+        final DateTime startDate = clock.getUTCNow();
+        final DateTime createdDate = startDate.plusSeconds(10);
+
+        final DefaultSubscriptionBaseBundle bundleDef = new DefaultSubscriptionBaseBundle(externalKey, accountId, startDate, startDate, createdDate, createdDate);
+        final SubscriptionBaseBundle bundle = dao.createSubscriptionBundle(bundleDef, catalog, internalCallContext);
+
+        final List<SubscriptionBaseBundle> result = dao.getSubscriptionBundlesForKey(externalKey, internalCallContext);
+        assertEquals(result.size(), 1);
+        assertEquals(result.get(0).getExternalKey(), bundle.getExternalKey());
+
+        // Update key to 'internal KB value 'kbtsf-12345:'
+        dao.updateBundleExternalKey(bundle.getId(), "kbtsf-12345:" + bundle.getExternalKey(), internalCallContext);
+        final List<SubscriptionBaseBundle> result2 = dao.getSubscriptionBundlesForKey(externalKey, internalCallContext);
+        assertEquals(result2.size(), 1);
+
+        // Create new bundle with original key, verify all results show original key, stripping down internal prefix
+        final DefaultSubscriptionBaseBundle bundleDef2 = new DefaultSubscriptionBaseBundle(externalKey, accountId, startDate, startDate, createdDate, createdDate);
+        final SubscriptionBaseBundle bundle2 = dao.createSubscriptionBundle(bundleDef2, catalog, internalCallContext);
+        final List<SubscriptionBaseBundle> result3 = dao.getSubscriptionBundlesForKey(externalKey, internalCallContext);
+        assertEquals(result3.size(), 2);
+        assertEquals(result3.get(0).getExternalKey(), bundle2.getExternalKey());
+        assertEquals(result3.get(1).getExternalKey(), bundle2.getExternalKey());
+
+        // This time we call the lower SqlDao to rename the bundle automatically and verify we still get same # results,
+        // with original key
+        dbi.onDemand(BundleSqlDao.class).renameBundleExternalKey(externalKey, "foo", internalCallContext);
+        final List<SubscriptionBaseBundle> result4 = dao.getSubscriptionBundlesForKey(externalKey, internalCallContext);
+        assertEquals(result4.size(), 2);
+        assertEquals(result4.get(0).getExternalKey(), bundle2.getExternalKey());
+        assertEquals(result4.get(1).getExternalKey(), bundle2.getExternalKey());
+
+        // Create bundle one more time
+        final DefaultSubscriptionBaseBundle bundleDef3 = new DefaultSubscriptionBaseBundle(externalKey, accountId, startDate, startDate, createdDate, createdDate);
+        final SubscriptionBaseBundle bundle3 = dao.createSubscriptionBundle(bundleDef3, catalog, internalCallContext);
+        final List<SubscriptionBaseBundle> result5 = dao.getSubscriptionBundlesForKey(externalKey, internalCallContext);
+        assertEquals(result5.size(), 3);
+        assertEquals(result5.get(0).getExternalKey(), bundle2.getExternalKey());
+        assertEquals(result5.get(1).getExternalKey(), bundle2.getExternalKey());
+        assertEquals(result5.get(2).getExternalKey(), bundle2.getExternalKey());
+
+
+    }
+}
diff --git a/subscription/src/test/java/org/killbill/billing/subscription/engine/dao/TestSubscriptionModelDao.java b/subscription/src/test/java/org/killbill/billing/subscription/engine/dao/TestSubscriptionModelDao.java
new file mode 100644
index 0000000..2f6bf69
--- /dev/null
+++ b/subscription/src/test/java/org/killbill/billing/subscription/engine/dao/TestSubscriptionModelDao.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2014-2017 Groupon, Inc
+ * Copyright 2014-2017 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.subscription.engine.dao;
+
+import org.killbill.billing.subscription.SubscriptionTestSuiteNoDB;
+import org.killbill.billing.subscription.engine.dao.model.SubscriptionBundleModelDao;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+
+public class TestSubscriptionModelDao extends SubscriptionTestSuiteNoDB {
+
+    @Test(groups = "fast")
+    public void testBundleExternalKeyPattern1() throws Exception {
+        final SubscriptionBundleModelDao b = new SubscriptionBundleModelDao();
+        b.setExternalKey("1235");
+
+        assertEquals(SubscriptionBundleModelDao.toSubscriptionBundle(b).getExternalKey(), "1235");
+    }
+
+
+    @Test(groups = "fast")
+    public void testBundleExternalKeyPattern2() throws Exception {
+        final SubscriptionBundleModelDao b = new SubscriptionBundleModelDao();
+        b.setExternalKey("kbtsf-343453:1235");
+        assertEquals(SubscriptionBundleModelDao.toSubscriptionBundle(b).getExternalKey(), "1235");
+    }
+
+    @Test(groups = "fast")
+    public void testBundleExternalKeyPattern3() throws Exception {
+        final SubscriptionBundleModelDao b = new SubscriptionBundleModelDao();
+        b.setExternalKey("kbXXXX-343453:1235");
+        assertEquals(SubscriptionBundleModelDao.toSubscriptionBundle(b).getExternalKey(), "1235");
+    }
+
+}