Skip to content

Commit

Permalink
Implement invoice config support and new bus events to notify of upco…
Browse files Browse the repository at this point in the history
…ming invoices. See #308
  • Loading branch information
sbrossie committed Apr 2, 2015
1 parent 401a9ed commit c6c3a60
Show file tree
Hide file tree
Showing 19 changed files with 459 additions and 15 deletions.
Expand Up @@ -35,6 +35,7 @@ public enum BusInternalEventType {
ENTITLEMENT_TRANSITION, ENTITLEMENT_TRANSITION,
INVOICE_ADJUSTMENT, INVOICE_ADJUSTMENT,
INVOICE_CREATION, INVOICE_CREATION,
INVOICE_NOTIFICATION,
INVOICE_EMPTY, INVOICE_EMPTY,
OVERDUE_CHANGE, OVERDUE_CHANGE,
PAYMENT_ERROR, PAYMENT_ERROR,
Expand Down
@@ -0,0 +1,34 @@
/*
* Copyright 2014-2015 Groupon, Inc
* Copyright 2014-2015 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.events;

import java.math.BigDecimal;
import java.util.UUID;

import org.joda.time.DateTime;
import org.killbill.billing.catalog.api.Currency;

public interface InvoiceNotificationInternalEvent extends InvoiceInternalEvent {

public BigDecimal getAmountOwed();

This comment has been minimized.

Copy link
@pierre

pierre Apr 2, 2015

Member

Nit: I would have named it balance to make it obvious it's Invoice#getBalance, which is computed in a very specific way by convention (see InvoiceCalculatorUtils).


public Currency getCurrency();

public DateTime getTargetDate();

}
Expand Up @@ -38,6 +38,7 @@
import org.killbill.billing.events.EntitlementInternalEvent; import org.killbill.billing.events.EntitlementInternalEvent;
import org.killbill.billing.events.InvoiceAdjustmentInternalEvent; import org.killbill.billing.events.InvoiceAdjustmentInternalEvent;
import org.killbill.billing.events.InvoiceCreationInternalEvent; import org.killbill.billing.events.InvoiceCreationInternalEvent;
import org.killbill.billing.events.InvoiceNotificationInternalEvent;
import org.killbill.billing.events.OverdueChangeInternalEvent; import org.killbill.billing.events.OverdueChangeInternalEvent;
import org.killbill.billing.events.PaymentErrorInternalEvent; import org.killbill.billing.events.PaymentErrorInternalEvent;
import org.killbill.billing.events.PaymentInfoInternalEvent; import org.killbill.billing.events.PaymentInfoInternalEvent;
Expand Down Expand Up @@ -159,6 +160,15 @@ private BusEvent computeExtBusEventEntryFromBusInternalEvent(final BusInternalEv
eventBusType = ExtBusEventType.INVOICE_CREATION; eventBusType = ExtBusEventType.INVOICE_CREATION;
break; break;


case INVOICE_NOTIFICATION:
final InvoiceNotificationInternalEvent realEventInvNotification = (InvoiceNotificationInternalEvent) event;
objectType = ObjectType.INVOICE;
objectId = null;
accountId = realEventInvNotification.getAccountId(); // has to be set here because objectId is null with a dryRun Invoice
eventBusType = ExtBusEventType.INVOICE_NOTIFICATION;
break;


case INVOICE_ADJUSTMENT: case INVOICE_ADJUSTMENT:
final InvoiceAdjustmentInternalEvent realEventInvAdj = (InvoiceAdjustmentInternalEvent) event; final InvoiceAdjustmentInternalEvent realEventInvAdj = (InvoiceAdjustmentInternalEvent) event;
objectType = ObjectType.INVOICE; objectType = ObjectType.INVOICE;
Expand Down
@@ -0,0 +1,64 @@
/*
* Copyright 2014-2015 Groupon, Inc
* Copyright 2014-2015 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 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.catalog.api.BillingPeriod;
import org.killbill.billing.catalog.api.ProductCategory;
import org.killbill.billing.entitlement.api.DefaultEntitlement;
import org.killbill.billing.platform.api.KillbillConfigSource;
import org.testng.annotations.Test;

import com.google.common.collect.ImmutableMap;

public class TestInvoiceNotifications extends TestIntegrationBase {

@Override
protected KillbillConfigSource getConfigSource() {
ImmutableMap additionalProperties = new ImmutableMap.Builder()
.put("org.killbill.invoice.dryRunNotificationSchedule", "7d")
.build();
return getConfigSource("/beatrix.properties", additionalProperties);
}

@Test(groups = "slow")
public void testInvoiceNotificationBasic() throws Exception {

final AccountData accountData = getAccountData(1);
final Account account = createAccountWithNonOsgiPaymentMethod(accountData);
accountChecker.checkAccount(account.getId(), accountData, callContext);

// 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 DefaultEntitlement bpSubscription = createBaseEntitlementAndCheckForCompletion(account.getId(), "bundleKey", "Shotgun", ProductCategory.BASE, BillingPeriod.MONTHLY, NextEvent.CREATE, NextEvent.INVOICE);

// Move to end of trial => 2012, 5, 1
addDaysAndCheckForCompletion(30, NextEvent.PHASE, NextEvent.INVOICE, NextEvent.PAYMENT);

// Next invoice is scheduled for 2012, 6, 1 so we should have a NOTIFICATION event 7 days before, on 2012, 5, 25
addDaysAndCheckForCompletion(24, NextEvent.INVOICE_NOTIFICATION);

// And then verify the invoice is correctly generated
addDaysAndCheckForCompletion(7, NextEvent.INVOICE, NextEvent.PAYMENT);
}
}
Expand Up @@ -38,14 +38,19 @@
import org.killbill.billing.account.api.AccountInternalApi; import org.killbill.billing.account.api.AccountInternalApi;
import org.killbill.billing.callcontext.InternalCallContext; import org.killbill.billing.callcontext.InternalCallContext;
import org.killbill.billing.callcontext.InternalTenantContext; import org.killbill.billing.callcontext.InternalTenantContext;
import org.killbill.billing.catalog.api.BillingActionPolicy;
import org.killbill.billing.catalog.api.BillingMode; import org.killbill.billing.catalog.api.BillingMode;
import org.killbill.billing.catalog.api.CatalogApiException; import org.killbill.billing.catalog.api.CatalogApiException;
import org.killbill.billing.catalog.api.Currency; import org.killbill.billing.catalog.api.Currency;
import org.killbill.billing.catalog.api.PlanPhasePriceOverride;
import org.killbill.billing.catalog.api.PlanPhaseSpecifier;
import org.killbill.billing.catalog.api.Usage; import org.killbill.billing.catalog.api.Usage;
import org.killbill.billing.entitlement.api.SubscriptionEventType;
import org.killbill.billing.events.BusInternalEvent; import org.killbill.billing.events.BusInternalEvent;
import org.killbill.billing.events.EffectiveSubscriptionInternalEvent; import org.killbill.billing.events.EffectiveSubscriptionInternalEvent;
import org.killbill.billing.events.InvoiceAdjustmentInternalEvent; import org.killbill.billing.events.InvoiceAdjustmentInternalEvent;
import org.killbill.billing.events.InvoiceInternalEvent; import org.killbill.billing.events.InvoiceInternalEvent;
import org.killbill.billing.events.InvoiceNotificationInternalEvent;
import org.killbill.billing.invoice.api.DryRunArguments; import org.killbill.billing.invoice.api.DryRunArguments;
import org.killbill.billing.invoice.api.Invoice; import org.killbill.billing.invoice.api.Invoice;
import org.killbill.billing.invoice.api.InvoiceApiException; import org.killbill.billing.invoice.api.InvoiceApiException;
Expand All @@ -54,6 +59,7 @@
import org.killbill.billing.invoice.api.InvoiceNotifier; import org.killbill.billing.invoice.api.InvoiceNotifier;
import org.killbill.billing.invoice.api.user.DefaultInvoiceAdjustmentEvent; import org.killbill.billing.invoice.api.user.DefaultInvoiceAdjustmentEvent;
import org.killbill.billing.invoice.api.user.DefaultInvoiceCreationEvent; import org.killbill.billing.invoice.api.user.DefaultInvoiceCreationEvent;
import org.killbill.billing.invoice.api.user.DefaultInvoiceNotificationInternalEvent;
import org.killbill.billing.invoice.api.user.DefaultNullInvoiceEvent; import org.killbill.billing.invoice.api.user.DefaultNullInvoiceEvent;
import org.killbill.billing.invoice.dao.InvoiceDao; import org.killbill.billing.invoice.dao.InvoiceDao;
import org.killbill.billing.invoice.dao.InvoiceItemModelDao; import org.killbill.billing.invoice.dao.InvoiceItemModelDao;
Expand Down Expand Up @@ -93,6 +99,8 @@ public class InvoiceDispatcher {
private static final Logger log = LoggerFactory.getLogger(InvoiceDispatcher.class); private static final Logger log = LoggerFactory.getLogger(InvoiceDispatcher.class);
private static final int NB_LOCK_TRY = 5; private static final int NB_LOCK_TRY = 5;


private static final NullDryRunArguments NULL_DRY_RUN_ARGUMENTS = new NullDryRunArguments();

private final InvoiceGenerator generator; private final InvoiceGenerator generator;
private final BillingInternalApi billingApi; private final BillingInternalApi billingApi;
private final AccountInternalApi accountApi; private final AccountInternalApi accountApi;
Expand Down Expand Up @@ -130,27 +138,49 @@ public InvoiceDispatcher(final InvoiceGenerator generator,
this.clock = clock; this.clock = clock;
} }


public void processSubscription(final EffectiveSubscriptionInternalEvent transition, public void processSubscriptionForInvoiceGeneration(final EffectiveSubscriptionInternalEvent transition,
final InternalCallContext context) throws InvoiceApiException { final InternalCallContext context) throws InvoiceApiException {
final UUID subscriptionId = transition.getSubscriptionId(); final UUID subscriptionId = transition.getSubscriptionId();
final DateTime targetDate = transition.getEffectiveTransitionTime(); final DateTime targetDate = transition.getEffectiveTransitionTime();
processSubscription(subscriptionId, targetDate, context); processSubscriptionForInvoiceGeneration(subscriptionId, targetDate, context);
}

public void processSubscriptionForInvoiceGeneration(final UUID subscriptionId, final DateTime targetDate, final InternalCallContext context) throws InvoiceApiException {
processSubscriptionInternal(subscriptionId, targetDate, false, context);
}

public void processSubscriptionForInvoiceNotification(final UUID subscriptionId, final DateTime targetDate, final InternalCallContext context) throws InvoiceApiException {
final Invoice dryRunInvoice = processSubscriptionInternal(subscriptionId, targetDate, true, context);
if (dryRunInvoice != null && dryRunInvoice.getBalance().compareTo(BigDecimal.ZERO) > 0) {
final InvoiceNotificationInternalEvent event = new DefaultInvoiceNotificationInternalEvent(dryRunInvoice.getAccountId(), dryRunInvoice.getBalance(), dryRunInvoice.getCurrency(),
targetDate, context.getAccountRecordId(), context.getTenantRecordId(), context.getUserToken());
try {
eventBus.post(event);
} catch (EventBusException e) {
log.error("Failed to post event " + event, e);
}
}
} }


public void processSubscription(final UUID subscriptionId, final DateTime targetDate, final InternalCallContext context) throws InvoiceApiException {
private Invoice processSubscriptionInternal(final UUID subscriptionId, final DateTime targetDate, final boolean dryRunForNotification, final InternalCallContext context) throws InvoiceApiException {
try { try {
if (subscriptionId == null) { if (subscriptionId == null) {
log.error("Failed handling SubscriptionBase change.", new InvoiceApiException(ErrorCode.INVOICE_INVALID_TRANSITION)); log.error("Failed handling SubscriptionBase change.", new InvoiceApiException(ErrorCode.INVOICE_INVALID_TRANSITION));
return; return null;
} }
final UUID accountId = subscriptionApi.getAccountIdFromSubscriptionId(subscriptionId, context); final UUID accountId = subscriptionApi.getAccountIdFromSubscriptionId(subscriptionId, context);
processAccount(accountId, targetDate, null, context); final DryRunArguments dryRunArguments = dryRunForNotification ? NULL_DRY_RUN_ARGUMENTS : null;

return processAccount(accountId, targetDate, dryRunArguments, context);
} catch (final SubscriptionBaseApiException e) { } catch (final SubscriptionBaseApiException e) {
log.error("Failed handling SubscriptionBase change.", log.error("Failed handling SubscriptionBase change.",
new InvoiceApiException(ErrorCode.INVOICE_NO_ACCOUNT_ID_FOR_SUBSCRIPTION_ID, subscriptionId.toString())); new InvoiceApiException(ErrorCode.INVOICE_NO_ACCOUNT_ID_FOR_SUBSCRIPTION_ID, subscriptionId.toString()));
return null;
} }
} }



public Invoice processAccount(final UUID accountId, final DateTime targetDate, public Invoice processAccount(final UUID accountId, final DateTime targetDate,
@Nullable final DryRunArguments dryRunArguments, final InternalCallContext context) throws InvoiceApiException { @Nullable final DryRunArguments dryRunArguments, final InternalCallContext context) throws InvoiceApiException {
GlobalLock lock = null; GlobalLock lock = null;
Expand Down Expand Up @@ -426,4 +456,35 @@ private void addInvoiceItemsToChargeThroughDates(final DateAndTimeZoneContext da
} }
} }
} }

private final static class NullDryRunArguments implements DryRunArguments {
@Override
public PlanPhaseSpecifier getPlanPhaseSpecifier() {
return null;
}
@Override
public SubscriptionEventType getAction() {
return null;
}
@Override
public UUID getSubscriptionId() {
return null;
}
@Override
public DateTime getEffectiveDate() {
return null;
}
@Override
public UUID getBundleId() {
return null;
}
@Override
public BillingActionPolicy getBillingActionPolicy() {
return null;
}
@Override
public List<PlanPhasePriceOverride> getPlanPhasePriceoverrides() {
return null;

This comment has been minimized.

Copy link
@pierre

pierre Apr 2, 2015

Member

Return empty list instead.

This comment has been minimized.

Copy link
@sbrossie

sbrossie Apr 2, 2015

Author Member

in that specific case, the definition of that 'NullDryRunArguments' is precisely to have all its fields set to null (which indicates that this is a dryRun with targetDate and not dryRun with subscription info

This comment has been minimized.

Copy link
@pierre

pierre Apr 2, 2015

Member

Right, but does getPlanPhasePriceoverrides == null vs getPlanPhasePriceoverrides.isEmpty() matter? If not, it's just to avoid an NPE down the line in foreach loops.

}
}
} }
Expand Up @@ -83,7 +83,7 @@ public void handleSubscriptionTransition(final EffectiveSubscriptionInternalEven
return; return;
} }
final InternalCallContext context = internalCallContextFactory.createInternalCallContext(event.getSearchKey2(), event.getSearchKey1(), "SubscriptionBaseTransition", CallOrigin.INTERNAL, UserType.SYSTEM, event.getUserToken()); final InternalCallContext context = internalCallContextFactory.createInternalCallContext(event.getSearchKey2(), event.getSearchKey1(), "SubscriptionBaseTransition", CallOrigin.INTERNAL, UserType.SYSTEM, event.getUserToken());
dispatcher.processSubscription(event, context); dispatcher.processSubscriptionForInvoiceGeneration(event, context);
} catch (InvoiceApiException e) { } catch (InvoiceApiException e) {
log.error(e.getMessage()); log.error(e.getMessage());
} }
Expand Down Expand Up @@ -122,7 +122,16 @@ public void handleBlockingStateTransition(final BlockingTransitionInternalEvent
public void handleNextBillingDateEvent(final UUID subscriptionId, final DateTime eventDateTime, final UUID userToken, final Long accountRecordId, final Long tenantRecordId) { public void handleNextBillingDateEvent(final UUID subscriptionId, final DateTime eventDateTime, final UUID userToken, final Long accountRecordId, final Long tenantRecordId) {
try { try {
final InternalCallContext context = internalCallContextFactory.createInternalCallContext(tenantRecordId, accountRecordId, "Next Billing Date", CallOrigin.INTERNAL, UserType.SYSTEM, userToken); final InternalCallContext context = internalCallContextFactory.createInternalCallContext(tenantRecordId, accountRecordId, "Next Billing Date", CallOrigin.INTERNAL, UserType.SYSTEM, userToken);
dispatcher.processSubscription(subscriptionId, eventDateTime, context); dispatcher.processSubscriptionForInvoiceGeneration(subscriptionId, eventDateTime, context);
} catch (InvoiceApiException e) {
log.error(e.getMessage());
}
}

public void handleEventForInvoiceNotification(final UUID subscriptionId, final DateTime eventDateTime, final UUID userToken, final Long accountRecordId, final Long tenantRecordId) {
try {
final InternalCallContext context = internalCallContextFactory.createInternalCallContext(tenantRecordId, accountRecordId, "Next Billing Date", CallOrigin.INTERNAL, UserType.SYSTEM, userToken);
dispatcher.processSubscriptionForInvoiceNotification(subscriptionId, eventDateTime, context);
} catch (InvoiceApiException e) { } catch (InvoiceApiException e) {
log.error(e.getMessage()); log.error(e.getMessage());
} }
Expand Down

0 comments on commit c6c3a60

Please sign in to comment.