/* * Copyright 2010-2013 Ning, Inc. * 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.api.svcs; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.UUID; import javax.annotation.Nullable; import org.joda.time.DateTime; import org.joda.time.LocalDate; import org.killbill.billing.ErrorCode; import org.killbill.billing.callcontext.InternalCallContext; import org.killbill.billing.callcontext.InternalTenantContext; import org.killbill.billing.catalog.api.BillingActionPolicy; import org.killbill.billing.catalog.api.BillingAlignment; import org.killbill.billing.catalog.api.Catalog; import org.killbill.billing.catalog.api.CatalogApiException; import org.killbill.billing.catalog.api.CatalogService; import org.killbill.billing.catalog.api.Plan; import org.killbill.billing.catalog.api.PlanChangeResult; 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; import org.killbill.billing.entitlement.api.EntitlementAOStatusDryRun; import org.killbill.billing.entitlement.api.EntitlementAOStatusDryRun.DryRunChangeReason; import org.killbill.billing.entitlement.api.EntitlementSpecifier; import org.killbill.billing.events.EffectiveSubscriptionInternalEvent; import org.killbill.billing.invoice.api.DryRunArguments; import org.killbill.billing.subscription.api.SubscriptionApiBase; import org.killbill.billing.subscription.api.SubscriptionBase; import org.killbill.billing.subscription.api.SubscriptionBaseApiService; import org.killbill.billing.subscription.api.SubscriptionBaseInternalApi; import org.killbill.billing.subscription.api.SubscriptionBaseTransitionType; import org.killbill.billing.subscription.api.SubscriptionBaseWithAddOns; import org.killbill.billing.subscription.api.user.DefaultEffectiveSubscriptionEvent; import org.killbill.billing.subscription.api.user.DefaultSubscriptionBase; import org.killbill.billing.subscription.api.user.DefaultSubscriptionBaseBundle; import org.killbill.billing.subscription.api.user.DefaultSubscriptionStatusDryRun; import org.killbill.billing.subscription.api.user.SubscriptionAndAddOnsSpecifier; import org.killbill.billing.subscription.api.user.SubscriptionBaseApiException; import org.killbill.billing.subscription.api.user.SubscriptionBaseBundle; import org.killbill.billing.subscription.api.user.SubscriptionBaseTransition; import org.killbill.billing.subscription.api.user.SubscriptionBaseTransitionData; import org.killbill.billing.subscription.api.user.SubscriptionBuilder; import org.killbill.billing.subscription.api.user.SubscriptionSpecifier; import org.killbill.billing.subscription.engine.addon.AddonUtils; import org.killbill.billing.subscription.engine.core.DefaultSubscriptionBaseService; import org.killbill.billing.subscription.engine.dao.SubscriptionDao; import org.killbill.billing.subscription.engine.dao.model.SubscriptionBundleModelDao; import org.killbill.billing.subscription.events.SubscriptionBaseEvent; import org.killbill.billing.subscription.events.bcd.BCDEvent; import org.killbill.billing.subscription.events.bcd.BCDEventData; import org.killbill.billing.subscription.exceptions.SubscriptionBaseError; import org.killbill.billing.util.UUIDs; import org.killbill.billing.util.bcd.BillCycleDayCalculator; import org.killbill.billing.util.callcontext.CallContext; import org.killbill.billing.util.callcontext.InternalCallContextFactory; import org.killbill.billing.util.callcontext.TenantContext; import org.killbill.billing.util.entity.Entity; import org.killbill.billing.util.entity.Pagination; import org.killbill.billing.util.entity.dao.DefaultPaginationHelper.SourcePaginationBuilder; import org.killbill.clock.Clock; import org.killbill.clock.DefaultClock; import org.killbill.notificationq.api.NotificationEvent; import org.killbill.notificationq.api.NotificationEventWithMetadata; import org.killbill.notificationq.api.NotificationQueue; import org.killbill.notificationq.api.NotificationQueueService; import org.killbill.notificationq.api.NotificationQueueService.NoSuchNotificationQueue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Function; import com.google.common.base.Predicate; import com.google.common.collect.Collections2; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; import com.google.inject.Inject; import static org.killbill.billing.util.entity.dao.DefaultPaginationHelper.getEntityPaginationNoException; public class DefaultSubscriptionInternalApi extends SubscriptionApiBase implements SubscriptionBaseInternalApi { private static final Logger log = LoggerFactory.getLogger(DefaultSubscriptionInternalApi.class); private final AddonUtils addonUtils; private final InternalCallContextFactory internalCallContextFactory; private final NotificationQueueService notificationQueueService; public static final Comparator<SubscriptionBase> SUBSCRIPTIONS_COMPARATOR = new Comparator<SubscriptionBase>() { @Override public int compare(final SubscriptionBase o1, final SubscriptionBase o2) { if (o1.getCategory() == ProductCategory.BASE) { return -1; } else if (o2.getCategory() == ProductCategory.BASE) { return 1; } else { return ((DefaultSubscriptionBase) o1).getAlignStartDate().compareTo(((DefaultSubscriptionBase) o2).getAlignStartDate()); } } }; @Inject public DefaultSubscriptionInternalApi(final SubscriptionDao dao, final SubscriptionBaseApiService apiService, final NotificationQueueService notificationQueueService, final Clock clock, final CatalogService catalogService, final AddonUtils addonUtils, final InternalCallContextFactory internalCallContextFactory) { super(dao, apiService, clock, catalogService); this.addonUtils = addonUtils; this.internalCallContextFactory = internalCallContextFactory; this.notificationQueueService = notificationQueueService; } @Override public SubscriptionBase createSubscription(final UUID bundleId, final PlanPhaseSpecifier spec, final List<PlanPhasePriceOverride> overrides, final DateTime requestedDateWithMs, final boolean isMigrated, final InternalCallContext context) throws SubscriptionBaseApiException { try { final DateTime now = clock.getUTCNow(); final DateTime effectiveDate = (requestedDateWithMs != null) ? DefaultClock.truncateMs(requestedDateWithMs) : now; /* if (requestedDate.isAfter(now)) { throw new SubscriptionBaseApiException(ErrorCode.SUB_INVALID_REQUESTED_DATE, now.toString(), requestedDate.toString()); } */ final CallContext callContext = internalCallContextFactory.createCallContext(context); final Catalog catalog = catalogService.getFullCatalog(true, true, context); final PlanPhasePriceOverridesWithCallContext overridesWithContext = new DefaultPlanPhasePriceOverridesWithCallContext(overrides, callContext); final Plan plan = catalog.createOrFindPlan(spec, overridesWithContext, effectiveDate); final PlanPhase phase = plan.getAllPhases()[0]; if (phase == null) { throw new SubscriptionBaseError(String.format("No initial PlanPhase for Product %s, term %s and set %s does not exist in the catalog", spec.getProductName(), spec.getBillingPeriod().toString(), plan.getPriceListName())); } final SubscriptionBaseBundle bundle = dao.getSubscriptionBundleFromId(bundleId, context); if (bundle == null) { throw new SubscriptionBaseApiException(ErrorCode.SUB_CREATE_NO_BUNDLE, bundleId); } final DefaultSubscriptionBase baseSubscription = (DefaultSubscriptionBase) dao.getBaseSubscription(bundleId, context); // verify the number of subscriptions (of the same kind) allowed per bundle if (ProductCategory.ADD_ON.toString().equalsIgnoreCase(plan.getProduct().getCategory().toString())) { if (plan.getPlansAllowedInBundle() != -1 && plan.getPlansAllowedInBundle() > 0 && addonUtils.countExistingAddOnsWithSamePlanName(getSubscriptionsForBundle(bundleId, null, context), plan.getName()) >= plan.getPlansAllowedInBundle()) { // a new ADD_ON subscription of the same plan can't be added because it has reached its limit by bundle throw new SubscriptionBaseApiException(ErrorCode.SUB_CREATE_AO_MAX_PLAN_ALLOWED_BY_BUNDLE, plan.getName()); } } final DateTime bundleStartDate = getBundleStartDateWithSanity(bundleId, baseSubscription, plan, effectiveDate, context); return apiService.createPlan(new SubscriptionBuilder() .setId(UUIDs.randomUUID()) .setBundleId(bundleId) .setBundleExternalKey(bundle.getExternalKey()) .setCategory(plan.getProduct().getCategory()) .setBundleStartDate(bundleStartDate) .setAlignStartDate(effectiveDate) .setMigrated(isMigrated), plan, spec.getPhaseType(), plan.getPriceListName(), effectiveDate, now, callContext); } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } } private List<SubscriptionSpecifier> verifyAndBuildSubscriptionSpecifiers(final UUID bundleId, final String externalKey, final Iterable<EntitlementSpecifier> entitlements, final boolean isMigrated, final InternalCallContext context, final DateTime now, final DateTime effectiveDate, final Catalog catalog, final CallContext callContext) throws SubscriptionBaseApiException, CatalogApiException { final List<SubscriptionSpecifier> subscriptions = new ArrayList<SubscriptionSpecifier>(); final List<SubscriptionBase> subscriptionsForBundle = getSubscriptionsForBundle(bundleId, null, context); for (final EntitlementSpecifier entitlement : entitlements) { final PlanPhaseSpecifier spec = entitlement.getPlanPhaseSpecifier(); if (spec == null) { // BP already exists continue; } final PlanPhasePriceOverridesWithCallContext overridesWithContext = new DefaultPlanPhasePriceOverridesWithCallContext(entitlement.getOverrides(), callContext); final Plan plan = catalog.createOrFindPlan(spec, overridesWithContext, effectiveDate); final PlanPhase phase = plan.getAllPhases()[0]; if (phase == null) { throw new SubscriptionBaseError(String.format("No initial PlanPhase for Product %s, term %s and set %s does not exist in the catalog", spec.getProductName(), spec.getBillingPeriod().toString(), plan.getPriceListName())); } // verify the number of subscriptions (of the same kind) allowed per bundle and the existing ones if (ProductCategory.ADD_ON.toString().equalsIgnoreCase(plan.getProduct().getCategory().toString())) { if (plan.getPlansAllowedInBundle() != -1 && plan.getPlansAllowedInBundle() > 0) { final int existingAddOnsWithSamePlanName = addonUtils.countExistingAddOnsWithSamePlanName(subscriptionsForBundle, plan.getName()); final int currentAddOnsWithSamePlanName = countCurrentAddOnsWithSamePlanName(entitlements, catalog, plan.getName(), effectiveDate, callContext); if ((existingAddOnsWithSamePlanName + currentAddOnsWithSamePlanName) > plan.getPlansAllowedInBundle()) { // a new ADD_ON subscription of the same plan can't be added because it has reached its limit by bundle throw new SubscriptionBaseApiException(ErrorCode.SUB_CREATE_AO_MAX_PLAN_ALLOWED_BY_BUNDLE, plan.getName()); } } } final SubscriptionSpecifier subscription = new SubscriptionSpecifier(); subscription.setRealPriceList(plan.getPriceListName()); subscription.setEffectiveDate(effectiveDate); subscription.setProcessedDate(now); subscription.setPlan(plan); subscription.setInitialPhase(spec.getPhaseType()); subscription.setBuilder(new SubscriptionBuilder() .setId(UUIDs.randomUUID()) .setBundleId(bundleId) .setBundleExternalKey(externalKey) .setCategory(plan.getProduct().getCategory()) .setBundleStartDate(effectiveDate) .setAlignStartDate(effectiveDate) .setMigrated(isMigrated)); subscriptions.add(subscription); } return subscriptions; } private boolean sanityAndReorderBPSpecFirst(final Catalog catalog, final BaseEntitlementWithAddOnsSpecifier entitlementWithAddOnsSpecifier, final DateTime effectiveDate, final List<EntitlementSpecifier> outputEntitlementSpecifier) throws SubscriptionBaseApiException { EntitlementSpecifier basePlanSpecifier = null; final List<EntitlementSpecifier> addOnSpecifiers = new ArrayList<EntitlementSpecifier>(); try { for (final EntitlementSpecifier cur : entitlementWithAddOnsSpecifier.getEntitlementSpecifier()) { final Plan inputPlan = catalog.createOrFindPlan(cur.getPlanPhaseSpecifier(), null, effectiveDate); final boolean isBaseSpecifier = inputPlan.getProduct().getCategory() == ProductCategory.BASE; if (isBaseSpecifier) { if (basePlanSpecifier == null) { basePlanSpecifier = cur; } else { throw new SubscriptionBaseApiException(ErrorCode.SUB_CREATE_INVALID_ENTITLEMENT_SPECIFIER); } } else { addOnSpecifiers.add(cur); } } } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } if (basePlanSpecifier != null) { outputEntitlementSpecifier.add(basePlanSpecifier); } outputEntitlementSpecifier.addAll(addOnSpecifiers); return basePlanSpecifier != null; } @Override public List<SubscriptionBaseWithAddOns> createBaseSubscriptionsWithAddOns(final UUID accountId, final Iterable<BaseEntitlementWithAddOnsSpecifier> baseEntitlementWithAddOnsSpecifier, final InternalCallContext context) throws SubscriptionBaseApiException { try { final Catalog catalog = catalogService.getFullCatalog(true, true, context); final CallContext callContext = internalCallContextFactory.createCallContext(context); final DateTime now = clock.getUTCNow(); final Collection<SubscriptionAndAddOnsSpecifier> subscriptionAndAddOns = new ArrayList<SubscriptionAndAddOnsSpecifier>(); for (final BaseEntitlementWithAddOnsSpecifier entitlementWithAddOnsSpecifier : baseEntitlementWithAddOnsSpecifier) { final DateTime effectiveDate = (entitlementWithAddOnsSpecifier.getBillingEffectiveDate() != null) ? DefaultClock.truncateMs(entitlementWithAddOnsSpecifier.getBillingEffectiveDate().toDateTimeAtStartOfDay()) : now; final List<EntitlementSpecifier> reorderedSpecifiers = new ArrayList<EntitlementSpecifier>(); final boolean isBaseSpecifierExists = sanityAndReorderBPSpecFirst(catalog, entitlementWithAddOnsSpecifier, effectiveDate, reorderedSpecifiers); final SubscriptionBaseBundle bundle; if (isBaseSpecifierExists) { bundle = createBundleForAccount(accountId, entitlementWithAddOnsSpecifier.getExternalKey(), context); } else { final List<SubscriptionBaseBundle> existingBundles = dao.getSubscriptionBundlesForKey(entitlementWithAddOnsSpecifier.getExternalKey(), context); final SubscriptionBaseBundle tmp = getActiveBundleForKeyNotException(existingBundles, dao, clock, context); if (tmp == null) { throw new SubscriptionBaseApiException(ErrorCode.SUB_CREATE_NO_BP, entitlementWithAddOnsSpecifier.getExternalKey()); } else if (!tmp.getAccountId().equals(accountId)) { throw new SubscriptionBaseApiException(ErrorCode.SUB_CREATE_ACTIVE_BUNDLE_KEY_EXISTS, entitlementWithAddOnsSpecifier.getExternalKey()); } else { bundle = tmp; } } final SubscriptionAndAddOnsSpecifier subscriptionAndAddOnsSpecifier = new SubscriptionAndAddOnsSpecifier( bundle.getId(), effectiveDate, verifyAndBuildSubscriptionSpecifiers(bundle.getId(), bundle.getExternalKey(), reorderedSpecifiers, entitlementWithAddOnsSpecifier.isMigrated(), context, now, effectiveDate, catalog, callContext) ); subscriptionAndAddOns.add(subscriptionAndAddOnsSpecifier); } return apiService.createPlansWithAddOns(accountId, subscriptionAndAddOns, callContext); } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } } private int countCurrentAddOnsWithSamePlanName(final Iterable<EntitlementSpecifier> entitlements, final Catalog catalog, final String planName, final DateTime effectiveDate, final CallContext callContext) throws CatalogApiException { int countCurrentAddOns = 0; for (final EntitlementSpecifier entitlement : entitlements) { final PlanPhaseSpecifier spec = entitlement.getPlanPhaseSpecifier(); final PlanPhasePriceOverridesWithCallContext overridesWithContext = new DefaultPlanPhasePriceOverridesWithCallContext(entitlement.getOverrides(), callContext); final Plan plan = catalog.createOrFindPlan(spec, overridesWithContext, effectiveDate); if (plan.getName().equalsIgnoreCase(planName) && plan.getProduct().getCategory() != null && ProductCategory.ADD_ON.equals(plan.getProduct().getCategory())) { countCurrentAddOns++; } } return countCurrentAddOns; } @Override public void cancelBaseSubscriptions(final Iterable<SubscriptionBase> subscriptions, final BillingActionPolicy policy, int accountBillCycleDayLocal, final InternalCallContext context) throws SubscriptionBaseApiException { apiService.cancelWithPolicyNoValidation(Iterables.<SubscriptionBase, DefaultSubscriptionBase>transform(subscriptions, new Function<SubscriptionBase, DefaultSubscriptionBase>() { @Override public DefaultSubscriptionBase apply(final SubscriptionBase subscriptionBase) { try { return getDefaultSubscriptionBase(subscriptionBase, context); } catch (final CatalogApiException e) { throw new RuntimeException(e); } } }), policy, accountBillCycleDayLocal, context); } @Override public SubscriptionBaseBundle createBundleForAccount(final UUID accountId, final String bundleKey, final InternalCallContext context) throws SubscriptionBaseApiException { final List<SubscriptionBaseBundle> existingBundles = dao.getSubscriptionBundlesForKey(bundleKey, context); // // Because the creation of the SubscriptionBundle is not atomic (with creation of Subscription/SubscriptionEvent), we verify if we were left // with an empty SubscriptionBaseBundle form a past failing operation (See #684). We only allow reuse if such SubscriptionBaseBundle is fully // empty (and don't allow use case where all Subscription are cancelled, which is the condition for that key to be re-used) // Such condition should have been checked upstream (to decide whether that key is valid or not) // final SubscriptionBaseBundle existingBundleForAccount = Iterables.tryFind(existingBundles, new Predicate<SubscriptionBaseBundle>() { @Override public boolean apply(final SubscriptionBaseBundle input) { return input.getAccountId().equals(accountId); } }).orNull(); // If Bundle already exists, and there is 0 Subscription, we reuse if (existingBundleForAccount != null) { try { final Map<UUID, List<SubscriptionBase>> accountSubscriptions = dao.getSubscriptionsForAccount(context); final List<SubscriptionBase> subscriptions = accountSubscriptions.get(existingBundleForAccount.getId()); if (subscriptions == null || subscriptions.size() == 0) { return existingBundleForAccount; } } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } } final DateTime now = clock.getUTCNow(); final DateTime originalCreatedDate = !existingBundles.isEmpty() ? existingBundles.get(0).getCreatedDate() : now; final DefaultSubscriptionBaseBundle bundle = new DefaultSubscriptionBaseBundle(bundleKey, accountId, now, originalCreatedDate, now, now); if (null != bundleKey && bundleKey.length() > 255) { throw new SubscriptionBaseApiException(ErrorCode.EXTERNAL_KEY_LIMIT_EXCEEDED); } return dao.createSubscriptionBundle(bundle, context); } @Override public List<SubscriptionBaseBundle> getBundlesForAccountAndKey(final UUID accountId, final String bundleKey, final InternalTenantContext context) throws SubscriptionBaseApiException { return dao.getSubscriptionBundlesForAccountAndKey(accountId, bundleKey, context); } @Override public List<SubscriptionBaseBundle> getBundlesForAccount(final UUID accountId, final InternalTenantContext context) { return dao.getSubscriptionBundleForAccount(accountId, context); } @Override public List<SubscriptionBaseBundle> getBundlesForKey(final String bundleKey, final InternalTenantContext context) { return dao.getSubscriptionBundlesForKey(bundleKey, context); } @Override public Pagination<SubscriptionBaseBundle> getBundles(final Long offset, final Long limit, final InternalTenantContext context) { return getEntityPaginationNoException(limit, new SourcePaginationBuilder<SubscriptionBundleModelDao, SubscriptionBaseApiException>() { @Override public Pagination<SubscriptionBundleModelDao> build() { return dao.get(offset, limit, context); } }, new Function<SubscriptionBundleModelDao, SubscriptionBaseBundle>() { @Override public SubscriptionBaseBundle apply(final SubscriptionBundleModelDao bundleModelDao) { return SubscriptionBundleModelDao.toSubscriptionbundle(bundleModelDao); } } ); } @Override public Pagination<SubscriptionBaseBundle> searchBundles(final String searchKey, final Long offset, final Long limit, final InternalTenantContext context) { return getEntityPaginationNoException(limit, new SourcePaginationBuilder<SubscriptionBundleModelDao, SubscriptionBaseApiException>() { @Override public Pagination<SubscriptionBundleModelDao> build() { return dao.searchSubscriptionBundles(searchKey, offset, limit, context); } }, new Function<SubscriptionBundleModelDao, SubscriptionBaseBundle>() { @Override public SubscriptionBaseBundle apply(final SubscriptionBundleModelDao bundleModelDao) { return SubscriptionBundleModelDao.toSubscriptionbundle(bundleModelDao); } } ); } @Override public Iterable<UUID> getNonAOSubscriptionIdsForKey(final String bundleKey, final InternalTenantContext context) { return dao.getNonAOSubscriptionIdsForKey(bundleKey, context); } public static SubscriptionBaseBundle getActiveBundleForKeyNotException(final Iterable<SubscriptionBaseBundle> existingBundles, final SubscriptionDao dao, final Clock clock, final InternalTenantContext context) { for (final SubscriptionBaseBundle cur : existingBundles) { final List<SubscriptionBase> subscriptions; try { subscriptions = dao.getSubscriptions(cur.getId(), ImmutableList.<SubscriptionBaseEvent>of(), context); for (final SubscriptionBase s : subscriptions) { if (s.getCategory() == ProductCategory.ADD_ON) { continue; } if (s.getEndDate() == null || s.getEndDate().compareTo(clock.getUTCNow()) > 0) { return cur; } } } catch (final CatalogApiException e) { log.warn("Failed to get subscriptions for bundleId='{}'", cur.getId(), e); return null; } } return null; } @Override public List<SubscriptionBase> getSubscriptionsForBundle(final UUID bundleId, @Nullable final DryRunArguments dryRunArguments, final InternalTenantContext context) throws SubscriptionBaseApiException { try { final List<SubscriptionBaseEvent> outputDryRunEvents = new ArrayList<SubscriptionBaseEvent>(); final List<SubscriptionBase> outputSubscriptions = new ArrayList<SubscriptionBase>(); populateDryRunEvents(bundleId, dryRunArguments, outputDryRunEvents, outputSubscriptions, context); final List<SubscriptionBase> result; result = dao.getSubscriptions(bundleId, outputDryRunEvents, context); if (result != null && !result.isEmpty()) { outputSubscriptions.addAll(result); } Collections.sort(outputSubscriptions, DefaultSubscriptionInternalApi.SUBSCRIPTIONS_COMPARATOR); return createSubscriptionsForApiUse(outputSubscriptions); } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } } @Override public Map<UUID, List<SubscriptionBase>> getSubscriptionsForAccount(final InternalTenantContext context) throws SubscriptionBaseApiException { try { final Map<UUID, List<SubscriptionBase>> internalSubscriptions = dao.getSubscriptionsForAccount(context); final Map<UUID, List<SubscriptionBase>> result = new HashMap<UUID, List<SubscriptionBase>>(); for (final UUID bundleId : internalSubscriptions.keySet()) { result.put(bundleId, createSubscriptionsForApiUse(internalSubscriptions.get(bundleId))); } return result; } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } } @Override public SubscriptionBase getBaseSubscription(final UUID bundleId, final InternalTenantContext context) throws SubscriptionBaseApiException { try { final SubscriptionBase result = dao.getBaseSubscription(bundleId, context); if (result == null) { throw new SubscriptionBaseApiException(ErrorCode.SUB_GET_NO_SUCH_BASE_SUBSCRIPTION, bundleId); } return createSubscriptionForApiUse(result); } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } } @Override public SubscriptionBase getSubscriptionFromId(final UUID id, final InternalTenantContext context) throws SubscriptionBaseApiException { try { final SubscriptionBase result = dao.getSubscriptionFromId(id, context); if (result == null) { throw new SubscriptionBaseApiException(ErrorCode.SUB_INVALID_SUBSCRIPTION_ID, id); } return createSubscriptionForApiUse(result); } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } } @Override public SubscriptionBaseBundle getBundleFromId(final UUID id, final InternalTenantContext context) throws SubscriptionBaseApiException { final SubscriptionBaseBundle result = dao.getSubscriptionBundleFromId(id, context); if (result == null) { throw new SubscriptionBaseApiException(ErrorCode.SUB_GET_INVALID_BUNDLE_ID, id.toString()); } return result; } @Override public UUID getAccountIdFromSubscriptionId(final UUID subscriptionId, final InternalTenantContext context) throws SubscriptionBaseApiException { return dao.getAccountIdFromSubscriptionId(subscriptionId, context); } @Override public void setChargedThroughDate(final UUID subscriptionId, final DateTime chargedThruDate, final InternalCallContext context) throws SubscriptionBaseApiException { try { final DefaultSubscriptionBase subscription = (DefaultSubscriptionBase) dao.getSubscriptionFromId(subscriptionId, context); final SubscriptionBuilder builder = new SubscriptionBuilder(subscription) .setChargedThroughDate(chargedThruDate); dao.updateChargedThroughDate(new DefaultSubscriptionBase(builder), context); } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } } @Override public List<EffectiveSubscriptionInternalEvent> getAllTransitions(final SubscriptionBase subscription, final InternalTenantContext context) { final List<SubscriptionBaseTransition> transitions = subscription.getAllTransitions(); return convertEffectiveSubscriptionInternalEventFromSubscriptionTransitions(subscription, context, transitions); } @Override public List<EffectiveSubscriptionInternalEvent> getBillingTransitions(final SubscriptionBase subscription, final InternalTenantContext context) { final List<SubscriptionBaseTransition> transitions = ((DefaultSubscriptionBase) subscription).getBillingTransitions(); return convertEffectiveSubscriptionInternalEventFromSubscriptionTransitions(subscription, context, transitions); } @Override public DateTime getDryRunChangePlanEffectiveDate(final SubscriptionBase subscription, final PlanSpecifier spec, final DateTime requestedDateWithMs, final BillingActionPolicy requestedPolicy, final List<PlanPhasePriceOverride> overrides, final InternalCallContext context) throws SubscriptionBaseApiException, CatalogApiException { final TenantContext tenantContext = internalCallContextFactory.createTenantContext(context); final CallContext callContext = internalCallContextFactory.createCallContext(context); // verify the number of subscriptions (of the same kind) allowed per bundle final Catalog catalog = catalogService.getFullCatalog(true, true, context); final DateTime now = clock.getUTCNow(); final DateTime effectiveDate = (requestedDateWithMs != null) ? DefaultClock.truncateMs(requestedDateWithMs) : null; final DateTime effectiveCatalogDate = effectiveDate != null? effectiveDate : now; final PlanPhasePriceOverridesWithCallContext overridesWithContext = new DefaultPlanPhasePriceOverridesWithCallContext(overrides, callContext); final Plan plan = catalog.createOrFindPlan(spec, overridesWithContext, effectiveCatalogDate); if (ProductCategory.ADD_ON.toString().equalsIgnoreCase(plan.getProduct().getCategory().toString())) { if (plan.getPlansAllowedInBundle() != -1 && plan.getPlansAllowedInBundle() > 0 && addonUtils.countExistingAddOnsWithSamePlanName(getSubscriptionsForBundle(subscription.getBundleId(), null, context), plan.getName()) >= plan.getPlansAllowedInBundle()) { // the plan can be changed to the new value, because it has reached its limit by bundle throw new SubscriptionBaseApiException(ErrorCode.SUB_CHANGE_AO_MAX_PLAN_ALLOWED_BY_BUNDLE, plan.getName()); } } return apiService.dryRunChangePlan((DefaultSubscriptionBase) subscription, spec, effectiveDate, requestedPolicy, tenantContext); } @Override public List<EntitlementAOStatusDryRun> getDryRunChangePlanStatus(final UUID subscriptionId, @Nullable final String baseProductName, final DateTime requestedDate, final InternalTenantContext context) throws SubscriptionBaseApiException { try { final SubscriptionBase subscription = dao.getSubscriptionFromId(subscriptionId, context); if (subscription == null) { throw new SubscriptionBaseApiException(ErrorCode.SUB_INVALID_SUBSCRIPTION_ID, subscriptionId); } if (subscription.getCategory() != ProductCategory.BASE) { throw new SubscriptionBaseApiException(ErrorCode.SUB_CHANGE_DRY_RUN_NOT_BP); } final List<EntitlementAOStatusDryRun> result = new LinkedList<EntitlementAOStatusDryRun>(); final List<SubscriptionBase> bundleSubscriptions = dao.getSubscriptions(subscription.getBundleId(), ImmutableList.<SubscriptionBaseEvent>of(), context); for (final SubscriptionBase cur : bundleSubscriptions) { if (cur.getId().equals(subscriptionId)) { continue; } // If ADDON is cancelled, skip if (cur.getState() == EntitlementState.CANCELLED) { continue; } final DryRunChangeReason reason; // If baseProductName is null, it's a cancellation dry-run. In this case, return all addons, so they are cancelled if (baseProductName != null && addonUtils.isAddonIncludedFromProdName(baseProductName, cur.getCurrentPlan(), requestedDate, context)) { reason = DryRunChangeReason.AO_INCLUDED_IN_NEW_PLAN; } else if (baseProductName != null && addonUtils.isAddonAvailableFromProdName(baseProductName, cur.getCurrentPlan(), requestedDate, context)) { reason = DryRunChangeReason.AO_AVAILABLE_IN_NEW_PLAN; } else { reason = DryRunChangeReason.AO_NOT_AVAILABLE_IN_NEW_PLAN; } final EntitlementAOStatusDryRun status = new DefaultSubscriptionStatusDryRun(cur.getId(), cur.getCurrentPlan().getProduct().getName(), cur.getCurrentPhase().getPhaseType(), cur.getCurrentPlan().getRecurringBillingPeriod(), cur.getCurrentPriceList().getName(), reason); result.add(status); } return result; } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } } @Override public void updateExternalKey(final UUID bundleId, final String newExternalKey, final InternalCallContext context) { dao.updateBundleExternalKey(bundleId, newExternalKey, context); } private void populateDryRunEvents(@Nullable final UUID bundleId, @Nullable final DryRunArguments dryRunArguments, final Collection<SubscriptionBaseEvent> outputDryRunEvents, final Collection<SubscriptionBase> outputSubscriptions, final InternalTenantContext context) throws SubscriptionBaseApiException { if (dryRunArguments == null || dryRunArguments.getAction() == null) { return; } final DateTime utcNow = clock.getUTCNow(); List<SubscriptionBaseEvent> dryRunEvents = null; try { final PlanPhaseSpecifier inputSpec = dryRunArguments.getPlanPhaseSpecifier(); final boolean isInputSpecNullOrEmpty = inputSpec == null || (inputSpec.getPlanName() == null && inputSpec.getProductName() == null && inputSpec.getBillingPeriod() == null); final Catalog catalog = catalogService.getFullCatalog(true, true, context); // Create an overridesWithContext with a null context to indicate this is dryRun and no price overriden plan should be created. final PlanPhasePriceOverridesWithCallContext overridesWithContext = new DefaultPlanPhasePriceOverridesWithCallContext(dryRunArguments.getPlanPhasePriceOverrides(), null); final Plan plan = isInputSpecNullOrEmpty ? null : catalog.createOrFindPlan(inputSpec, overridesWithContext, utcNow); final TenantContext tenantContext = internalCallContextFactory.createTenantContext(context); switch (dryRunArguments.getAction()) { case START_BILLING: final DefaultSubscriptionBase baseSubscription = (DefaultSubscriptionBase) dao.getBaseSubscription(bundleId, context); final DateTime startEffectiveDate = dryRunArguments.getEffectiveDate() != null ? context.toUTCDateTime(dryRunArguments.getEffectiveDate()) : utcNow; final DateTime bundleStartDate = getBundleStartDateWithSanity(bundleId, baseSubscription, plan, startEffectiveDate, context); final UUID subscriptionId = UUIDs.randomUUID(); dryRunEvents = apiService.getEventsOnCreation(bundleId, subscriptionId, startEffectiveDate, bundleStartDate, plan, inputSpec.getPhaseType(), plan.getPriceListName(), startEffectiveDate, utcNow, context); final SubscriptionBuilder builder = new SubscriptionBuilder() .setId(subscriptionId) .setBundleId(bundleId) .setBundleExternalKey(null) .setCategory(plan.getProduct().getCategory()) .setBundleStartDate(bundleStartDate) .setAlignStartDate(startEffectiveDate); final DefaultSubscriptionBase newSubscription = new DefaultSubscriptionBase(builder, apiService, clock); newSubscription.rebuildTransitions(dryRunEvents, catalog); outputSubscriptions.add(newSubscription); break; case CHANGE: final DefaultSubscriptionBase subscriptionForChange = (DefaultSubscriptionBase) dao.getSubscriptionFromId(dryRunArguments.getSubscriptionId(), context); DateTime changeEffectiveDate = getDryRunEffectiveDate(dryRunArguments.getEffectiveDate(), subscriptionForChange, context); if (changeEffectiveDate == null) { BillingActionPolicy policy = dryRunArguments.getBillingActionPolicy(); if (policy == null) { final PlanChangeResult planChangeResult = apiService.getPlanChangeResult(subscriptionForChange, inputSpec, utcNow, tenantContext); policy = planChangeResult.getPolicy(); } // We pass null for billingAlignment, accountTimezone, account BCD because this is not available which means that dryRun with START_OF_TERM BillingPolicy will fail changeEffectiveDate = subscriptionForChange.getPlanChangeEffectiveDate(policy, null, -1, context); } dryRunEvents = apiService.getEventsOnChangePlan(subscriptionForChange, plan, plan.getPriceListName(), changeEffectiveDate, utcNow, true, context); break; case STOP_BILLING: final DefaultSubscriptionBase subscriptionForCancellation = (DefaultSubscriptionBase) dao.getSubscriptionFromId(dryRunArguments.getSubscriptionId(), context); DateTime cancelEffectiveDate = getDryRunEffectiveDate(dryRunArguments.getEffectiveDate(), subscriptionForCancellation, context); if (dryRunArguments.getEffectiveDate() == null) { BillingActionPolicy policy = dryRunArguments.getBillingActionPolicy(); if (policy == null) { final Plan currentPlan = subscriptionForCancellation.getCurrentPlan(); final PlanPhaseSpecifier spec = new PlanPhaseSpecifier(currentPlan.getName(), subscriptionForCancellation.getCurrentPhase().getPhaseType()); policy = catalogService.getFullCatalog(true, true, context).planCancelPolicy(spec, utcNow); } // We pass null for billingAlignment, accountTimezone, account BCD because this is not available which means that dryRun with START_OF_TERM BillingPolicy will fail cancelEffectiveDate = subscriptionForCancellation.getPlanChangeEffectiveDate(policy, null, -1, context); } dryRunEvents = apiService.getEventsOnCancelPlan(subscriptionForCancellation, cancelEffectiveDate, utcNow, true, context); break; default: throw new IllegalArgumentException("Unexpected dryRunArguments action " + dryRunArguments.getAction()); } } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } if (dryRunEvents != null && !dryRunEvents.isEmpty()) { outputDryRunEvents.addAll(dryRunEvents); } } private DateTime getDryRunEffectiveDate(@Nullable final LocalDate inputDate, final DefaultSubscriptionBase subscription, final InternalTenantContext context) { if (inputDate == null) { return null; } // We first use context account reference time to get a candidate) final DateTime tmp = context.toUTCDateTime(inputDate); // If we realize that the candidate is on the same LocalDate boundary as the subscription startDate but a bit prior we correct it to avoid weird things down the line if (inputDate.compareTo(context.toLocalDate(subscription.getStartDate())) == 0 && tmp.compareTo(subscription.getStartDate()) < 0) { return subscription.getStartDate(); } else { return tmp; } } @Override public Iterable<DateTime> getFutureNotificationsForAccount(final InternalCallContext internalCallContext) { try { final NotificationQueue notificationQueue = notificationQueueService.getNotificationQueue(DefaultSubscriptionBaseService.SUBSCRIPTION_SERVICE_NAME, DefaultSubscriptionBaseService.NOTIFICATION_QUEUE_NAME); final Iterable<NotificationEventWithMetadata<NotificationEvent>> futureNotifications = notificationQueue.getFutureNotificationForSearchKeys(internalCallContext.getAccountRecordId(), internalCallContext.getTenantRecordId()); return Iterables.transform(futureNotifications, new Function<NotificationEventWithMetadata<NotificationEvent>, DateTime>() { @Nullable @Override public DateTime apply(final NotificationEventWithMetadata<NotificationEvent> input) { return input.getEffectiveDate(); } }); } catch (final NoSuchNotificationQueue noSuchNotificationQueue) { throw new IllegalStateException(noSuchNotificationQueue); } } @Override public Map<UUID, DateTime> getNextFutureEventForSubscriptions(final SubscriptionBaseTransitionType eventType, final InternalCallContext internalCallContext) { final Iterable<SubscriptionBaseEvent> events = dao.getFutureEventsForAccount(internalCallContext); final Iterable<SubscriptionBaseEvent> filteredEvents = Iterables.filter(events, new Predicate<SubscriptionBaseEvent>() { @Override public boolean apply(final SubscriptionBaseEvent input) { switch (input.getType()) { case PHASE: return eventType == SubscriptionBaseTransitionType.PHASE; case BCD_UPDATE: return eventType == SubscriptionBaseTransitionType.BCD_CHANGE; case API_USER: default: return true; } } }); final Map<UUID, DateTime> result = filteredEvents.iterator().hasNext() ? new HashMap<UUID, DateTime>() : ImmutableMap.<UUID, DateTime>of(); for (final SubscriptionBaseEvent cur : filteredEvents) { final DateTime targetDate = result.get(cur.getSubscriptionId()); if (targetDate == null || targetDate.compareTo(cur.getEffectiveDate()) > 0) { result.put(cur.getSubscriptionId(), cur.getEffectiveDate()); } } return result; } @Override public void updateBCD(final UUID subscriptionId, final int bcd, @Nullable final LocalDate effectiveFromDate, final InternalCallContext internalCallContext) throws SubscriptionBaseApiException { final DefaultSubscriptionBase subscription = (DefaultSubscriptionBase) getSubscriptionFromId(subscriptionId, internalCallContext); final DateTime effectiveDate = getEffectiveDateForNewBCD(bcd, effectiveFromDate, internalCallContext); final BCDEvent bcdEvent = BCDEventData.createBCDEvent(subscription, effectiveDate, bcd); dao.createBCDChangeEvent(subscription, bcdEvent, internalCallContext); } @Override public int getDefaultBillCycleDayLocal(final Map<UUID, Integer> bcdCache, final SubscriptionBase subscription, final SubscriptionBase baseSubscription, final PlanPhaseSpecifier planPhaseSpecifier, final int accountBillCycleDayLocal, final DateTime effectiveDate, final InternalTenantContext context) throws SubscriptionBaseApiException { try { final Catalog catalog = catalogService.getFullCatalog(true, true, context); final BillingAlignment alignment = catalog.billingAlignment(planPhaseSpecifier, effectiveDate); return BillCycleDayCalculator.calculateBcdForAlignment(bcdCache, subscription, baseSubscription, alignment, context, accountBillCycleDayLocal); } catch (final CatalogApiException e) { throw new SubscriptionBaseApiException(e); } } @VisibleForTesting DateTime getEffectiveDateForNewBCD(final int bcd, @Nullable final LocalDate effectiveFromDate, final InternalCallContext internalCallContext) { if (internalCallContext.getAccountRecordId() == null) { throw new IllegalStateException("Need to have a valid context with accountRecordId"); } // Today as seen by this account final LocalDate startDate = effectiveFromDate != null ? effectiveFromDate : internalCallContext.toLocalDate(clock.getUTCNow()); // We want to compute a LocalDate in account TZ which maps to the provided 'bcd' and then compute an effectiveDate for when that BCD_CHANGE event needs to be triggered // // There is a bit of complexity to make sure the date we chose exists (e.g: a BCD of 31 in a february month would not make sense). final int currentDay = startDate.getDayOfMonth(); final int lastDayOfMonth = startDate.dayOfMonth().getMaximumValue(); final LocalDate requestedDate; if (bcd < currentDay) { final LocalDate startDatePlusOneMonth = startDate.plusMonths(1); final int lastDayOfNextMonth = startDatePlusOneMonth.dayOfMonth().getMaximumValue(); final int originalBCDORLastDayOfMonth = bcd <= lastDayOfNextMonth ? bcd : lastDayOfNextMonth; requestedDate = new LocalDate(startDatePlusOneMonth.getYear(), startDatePlusOneMonth.getMonthOfYear(), originalBCDORLastDayOfMonth); } else if (bcd == currentDay && effectiveFromDate == null) { // will default to immediate event requestedDate = null; } else if (bcd <= lastDayOfMonth) { requestedDate = new LocalDate(startDate.getYear(), startDate.getMonthOfYear(), bcd); } else /* bcd > lastDayOfMonth && bcd > currentDay */ { requestedDate = new LocalDate(startDate.getYear(), startDate.getMonthOfYear(), lastDayOfMonth); } return requestedDate == null ? clock.getUTCNow() : internalCallContext.toUTCDateTime(requestedDate); } private DateTime getBundleStartDateWithSanity(final UUID bundleId, @Nullable final DefaultSubscriptionBase baseSubscription, final Plan plan, final DateTime effectiveDate, final InternalTenantContext context) throws SubscriptionBaseApiException, CatalogApiException { switch (plan.getProduct().getCategory()) { case BASE: if (baseSubscription != null && (baseSubscription.getState() == EntitlementState.ACTIVE || baseSubscription.getState() == EntitlementState.PENDING)) { throw new SubscriptionBaseApiException(ErrorCode.SUB_CREATE_BP_EXISTS, bundleId); } return effectiveDate; case ADD_ON: if (baseSubscription == null) { throw new SubscriptionBaseApiException(ErrorCode.SUB_CREATE_NO_BP, bundleId); } if (effectiveDate.isBefore(baseSubscription.getStartDate())) { throw new SubscriptionBaseApiException(ErrorCode.SUB_INVALID_REQUESTED_DATE, effectiveDate.toString(), baseSubscription.getStartDate().toString()); } addonUtils.checkAddonCreationRights(baseSubscription, plan, effectiveDate, context); return baseSubscription.getStartDate(); case STANDALONE: if (baseSubscription != null) { throw new SubscriptionBaseApiException(ErrorCode.SUB_CREATE_BP_EXISTS, bundleId); } // Not really but we don't care, there is no alignment for STANDALONE subscriptions return effectiveDate; default: throw new SubscriptionBaseError(String.format("Can't create subscription of type %s", plan.getProduct().getCategory().toString())); } } private List<EffectiveSubscriptionInternalEvent> convertEffectiveSubscriptionInternalEventFromSubscriptionTransitions(final SubscriptionBase subscription, final InternalTenantContext context, final Collection<SubscriptionBaseTransition> transitions) { return ImmutableList.<EffectiveSubscriptionInternalEvent>copyOf(Collections2.transform(transitions, new Function<SubscriptionBaseTransition, EffectiveSubscriptionInternalEvent>() { @Override @Nullable public EffectiveSubscriptionInternalEvent apply(@Nullable final SubscriptionBaseTransition input) { return new DefaultEffectiveSubscriptionEvent((SubscriptionBaseTransitionData) input, ((DefaultSubscriptionBase) subscription).getAlignStartDate(), null, context.getAccountRecordId(), context.getTenantRecordId()); } })); } // For forward-compatibility private DefaultSubscriptionBase getDefaultSubscriptionBase(final Entity subscriptionBase, final InternalTenantContext context) throws CatalogApiException { if (subscriptionBase instanceof DefaultSubscriptionBase) { return (DefaultSubscriptionBase) subscriptionBase; } else { // Safe cast, see above return (DefaultSubscriptionBase) dao.getSubscriptionFromId(subscriptionBase.getId(), context); } } }