diff --git a/bubble-server/src/main/java/bubble/cloud/NoopCloud.java b/bubble-server/src/main/java/bubble/cloud/NoopCloud.java index f6a7de16..3aea8156 100644 --- a/bubble-server/src/main/java/bubble/cloud/NoopCloud.java +++ b/bubble-server/src/main/java/bubble/cloud/NoopCloud.java @@ -152,6 +152,11 @@ public class NoopCloud implements return null; } + @Override public boolean paymentDue(String accountPlanUuid, String billUuid, String paymentMethodUuid) { + if (log.isDebugEnabled()) log.debug("paymentDue(accountPlanUuid=" + accountPlanUuid + ")"); + return false; + } + @Override public boolean authorize(BubblePlan plan, String accountPlanUuid, String billUuid, AccountPaymentMethod paymentMethod) { if (log.isDebugEnabled()) log.debug("authorize(plan=" + plan + ")"); return false; diff --git a/bubble-server/src/main/java/bubble/cloud/payment/PaymentDriverBase.java b/bubble-server/src/main/java/bubble/cloud/payment/PaymentDriverBase.java index a2051391..010d9f84 100644 --- a/bubble-server/src/main/java/bubble/cloud/payment/PaymentDriverBase.java +++ b/bubble-server/src/main/java/bubble/cloud/payment/PaymentDriverBase.java @@ -86,6 +86,44 @@ public abstract class PaymentDriverBase extends CloudServiceDriverBase imp return true; } + @Override public boolean paymentDue(String accountPlanUuid, String billUuid, String paymentMethodUuid) { + final AccountPlan accountPlan = getAccountPlan(accountPlanUuid); + final BubblePlan plan = getBubblePlan(accountPlan); + final AccountPaymentMethod paymentMethod = getPaymentMethod(accountPlan, paymentMethodUuid); + final Bill bill = getBill(billUuid, plan.getPrice(), plan.getCurrency(), accountPlan); + + if (!paymentMethod.getAccount().equals(accountPlan.getAccount()) || !paymentMethod.getAccount().equals(bill.getAccount())) { + throw invalidEx("err.purchase.billNotFound"); + } + + // has this already been paid? + if (bill.paid()) { + log.warn("paymentDue: existing Bill was already paid (returning false): " + bill.getUuid()); + return false; + } + + final List successfulPayments = accountPaymentDAO.findByAccountAndAccountPlanAndBillAndPaid(accountPlan.getAccount(), accountPlanUuid, bill.getUuid()); + final int totalPayments = totalPayments(successfulPayments); + if (totalPayments >= bill.getTotal()) { + log.warn("paymentDue: sufficient successful AccountPayments found (marking Bill "+bill.getUuid()+" as paid and returning false): "+json(successfulPayments, COMPACT_MAPPER)); + billDAO.update(bill.setStatus(BillStatus.paid)); + return false; + } + + // If the current PaymentDriver is not for a promotional credit, + // then check for AccountPaymentMethods associated with promotional credits + // If we have one, use that payment driver instead. It may apply a partial payment. + long chargeAmount = bill.getTotal() - totalPayments; + if (getPaymentMethodType() != promotional_credit) { + chargeAmount = checkChargeAmount(accountPlan, plan, paymentMethod, bill, chargeAmount); + if (chargeAmount <= 0) { + log.info("paymentDue: chargeAmount="+chargeAmount+" after applying promotions, returning false"); + return false; + } + } + return true; + } + @Override public boolean purchase(String accountPlanUuid, String paymentMethodUuid, String billUuid) { final AccountPlan accountPlan = getAccountPlan(accountPlanUuid); @@ -116,30 +154,11 @@ public abstract class PaymentDriverBase extends CloudServiceDriverBase imp // If we have one, use that payment driver instead. It may apply a partial payment. long chargeAmount = bill.getTotal() - totalPayments; if (getPaymentMethodType() != promotional_credit) { - final List accountPaymentMethods = paymentMethodDAO.findByAccountAndPromoAndNotDeleted(accountPlan.getAccount()); - final List promos = new ArrayList<>(); - for (AccountPaymentMethod apm : accountPaymentMethods) { - if (!apm.hasPromotion()) { - log.warn("purchase: AccountPaymentMethod "+apm.getUuid()+" has type "+promotional_credit+" but promotion was null, skipping"); - continue; - } - final Promotion promo = promotionDAO.findByUuid(apm.getPromotion()); - if (promo == null) { - log.warn("purchase: AccountPaymentMethod "+apm.getUuid()+": promotion "+apm.getPromotion()+" does not exist"); - continue; - } - if (promo.inactive()) { - log.warn("purchase: AccountPaymentMethod "+apm.getUuid()+": promotion "+apm.getPromotion()+" is not active"); - continue; - } - promos.add(promo.setPaymentMethod(apm)); - } - if (!promos.isEmpty()) { - chargeAmount = promoService.usePromotions(plan, accountPlan, bill, paymentMethod, this, promos, chargeAmount); - if (chargeAmount <= 0) { - log.info("purchase: chargeAmount="+chargeAmount+" after applying promotions, done"); - return true; - } + chargeAmount = getChargeAmount(accountPlan, plan, paymentMethod, bill, chargeAmount); + if (chargeAmount <= 0) { + log.info("purchase: chargeAmount="+chargeAmount+" after applying promotions (marking Bill "+bill.getUuid()+" as paid and returning true)"); + billDAO.update(bill.setStatus(BillStatus.paid)); + return true; } } @@ -162,11 +181,52 @@ public abstract class PaymentDriverBase extends CloudServiceDriverBase imp .setInfo(paymentMethod.getPaymentInfo())); throw e; } - recordPayment(bill, accountPlan, paymentMethod, chargeResult); + recordPayment(bill, plan, accountPlan, paymentMethod, chargeResult); return true; } + public long getChargeAmount(AccountPlan accountPlan, BubblePlan plan, AccountPaymentMethod paymentMethod, Bill bill, long chargeAmount) { + return _getChargeAmount(accountPlan, plan, paymentMethod, bill, chargeAmount, false); + } + + public long checkChargeAmount(AccountPlan accountPlan, BubblePlan plan, AccountPaymentMethod paymentMethod, Bill bill, long chargeAmount) { + return _getChargeAmount(accountPlan, plan, paymentMethod, bill, chargeAmount, true); + } + + public long _getChargeAmount(AccountPlan accountPlan, + BubblePlan plan, + AccountPaymentMethod paymentMethod, + Bill bill, + long chargeAmount, + boolean checkOnly) { + final List accountPaymentMethods = paymentMethodDAO.findByAccountAndPromoAndNotDeleted(accountPlan.getAccount()); + final List promos = new ArrayList<>(); + for (AccountPaymentMethod apm : accountPaymentMethods) { + if (!apm.hasPromotion()) { + log.warn("purchase: AccountPaymentMethod "+apm.getUuid()+" has type "+promotional_credit+" but promotion was null, skipping"); + continue; + } + final Promotion promo = promotionDAO.findByUuid(apm.getPromotion()); + if (promo == null) { + log.warn("purchase: AccountPaymentMethod "+apm.getUuid()+": promotion "+apm.getPromotion()+" does not exist"); + continue; + } + if (promo.inactive()) { + log.warn("purchase: AccountPaymentMethod "+apm.getUuid()+": promotion "+apm.getPromotion()+" is not active"); + continue; + } + promos.add(promo.setPaymentMethod(apm)); + } + if (!promos.isEmpty()) { + chargeAmount = checkOnly + ? promoService.checkPromotions(plan, accountPlan, bill, paymentMethod, this, promos, chargeAmount) + : promoService.usePromotions(plan, accountPlan, bill, paymentMethod, this, promos, chargeAmount); + } + return chargeAmount; + } + public AccountPayment recordPayment(Bill bill, + BubblePlan plan, AccountPlan accountPlan, AccountPaymentMethod paymentMethod, ChargeResult chargeResult) { @@ -196,7 +256,7 @@ public abstract class PaymentDriverBase extends CloudServiceDriverBase imp } // if there are no unpaid bills, we can (re-)enable the plan - final List unpaidBills = billDAO.findUnpaidByAccountPlan(accountPlanUuid); + final List unpaidBills = billDAO.findUnpaidAndDueByAccountPlan(plan, accountPlanUuid); if (unpaidBills.isEmpty()) { accountPlanDAO.update(accountPlan.setEnabled(true)); } else { diff --git a/bubble-server/src/main/java/bubble/cloud/payment/PaymentServiceDriver.java b/bubble-server/src/main/java/bubble/cloud/payment/PaymentServiceDriver.java index 4285983e..a52fb84a 100644 --- a/bubble-server/src/main/java/bubble/cloud/payment/PaymentServiceDriver.java +++ b/bubble-server/src/main/java/bubble/cloud/payment/PaymentServiceDriver.java @@ -22,6 +22,8 @@ public interface PaymentServiceDriver extends CloudServiceDriver { default PaymentValidationResult claim(AccountPaymentMethod paymentMethod) { return notSupported("claim"); } default PaymentValidationResult claim(AccountPlan accountPlan) { return notSupported("claim"); } + boolean paymentDue(String accountPlanUuid, String billUuid, String paymentMethodUuid); + boolean authorize(BubblePlan plan, String accountPlanUuid, String billUuid, AccountPaymentMethod paymentMethod); boolean cancelAuthorization(BubblePlan plan, String accountPlanUuid, AccountPaymentMethod paymentMethod); diff --git a/bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java b/bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java index cecd7cbc..423490cf 100644 --- a/bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java +++ b/bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java @@ -57,6 +57,17 @@ public class DelegatedPaymentDriver extends DelegatedCloudServiceDriverBase impl new PaymentMethodClaimNotification(cloud.getName(), accountPlan)); } + @Override public boolean paymentDue(String accountPlanUuid, String billUuid, String paymentMethodUuid) { + final BubbleNode delegate = getDelegateNode(); + final PaymentResult result = notificationService.notifySync(delegate, payment_driver_payment_due, + new PaymentNotification() + .setCloud(cloud.getName()) + .setAccountPlanUuid(accountPlanUuid) + .setBillUuid(billUuid) + .setPaymentMethodUuid(paymentMethodUuid)); + return processResult(result); + } + @Override public boolean authorize(BubblePlan plan, String accountPlanUuid, String billUuid, AccountPaymentMethod paymentMethod) { final BubbleNode delegate = getDelegateNode(); final PaymentResult result = notificationService.notifySync(delegate, payment_driver_authorize, diff --git a/bubble-server/src/main/java/bubble/dao/bill/AccountPaymentDAO.java b/bubble-server/src/main/java/bubble/dao/bill/AccountPaymentDAO.java index 2ea5889f..c4dde652 100644 --- a/bubble-server/src/main/java/bubble/dao/bill/AccountPaymentDAO.java +++ b/bubble-server/src/main/java/bubble/dao/bill/AccountPaymentDAO.java @@ -55,6 +55,13 @@ public class AccountPaymentDAO extends AccountOwnedEntityDAO { "status", AccountPaymentStatus.success); } + public List findByAccountAndAccountPlanAndPaymentSuccess(String accountUuid, String accountPlanUuid) { + return findByFields("account", accountUuid, + "accountPlan", accountPlanUuid, + "type", AccountPaymentType.payment, + "status", AccountPaymentStatus.success); + } + public List findByAccountAndAccountPlanAndBillAndPaid(String accountUuid, String accountPlanUuid, String billUuid) { return list(criteria().add(and( eq("account", accountUuid), diff --git a/bubble-server/src/main/java/bubble/dao/bill/AccountPlanDAO.java b/bubble-server/src/main/java/bubble/dao/bill/AccountPlanDAO.java index 231d1d17..b78bbbc4 100644 --- a/bubble-server/src/main/java/bubble/dao/bill/AccountPlanDAO.java +++ b/bubble-server/src/main/java/bubble/dao/bill/AccountPlanDAO.java @@ -92,6 +92,15 @@ public class AccountPlanDAO extends AccountOwnedEntityDAO { ))); } + public List findBillableAccountPlans(long startTime, long endTime) { + return list(criteria().add(and( + isNull("deleted"), + eq("closed", false), + ge("nextBill", startTime), + lt("nextBill", endTime) + ))); + } + public boolean isNotDeleted(String networkUuid) { final AccountPlan accountPlan = findByNetwork(networkUuid); return accountPlan != null && accountPlan.notDeleting() && accountPlan.notDeleted(); diff --git a/bubble-server/src/main/java/bubble/dao/bill/BillDAO.java b/bubble-server/src/main/java/bubble/dao/bill/BillDAO.java index 803634a6..58614b3d 100644 --- a/bubble-server/src/main/java/bubble/dao/bill/BillDAO.java +++ b/bubble-server/src/main/java/bubble/dao/bill/BillDAO.java @@ -10,7 +10,9 @@ import org.hibernate.criterion.Order; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.stream.Collectors; +import static org.cobbzilla.util.daemon.ZillaRuntime.now; import static org.hibernate.criterion.Restrictions.*; @Repository @@ -19,6 +21,10 @@ public class BillDAO extends AccountOwnedEntityDAO { // newest first @Override public Order getDefaultSortOrder() { return ORDER_CTIME_DESC; } + @Override public Object preCreate(Bill bill) { + return super.preCreate(bill.setNotified(false)); + } + // todo: make this more efficient, use "COUNT" public int countByAccount(String accountUuid) { return findByAccount(accountUuid).size(); } @@ -31,6 +37,11 @@ public class BillDAO extends AccountOwnedEntityDAO { return bills.isEmpty() ? null : bills.get(0); } + public Bill findMostRecentPaidBillForAccountPlan(String accountPlanUuid) { + final List bills = findByFields("accountPlan", accountPlanUuid, "status", BillStatus.paid); + return bills.isEmpty() ? null : bills.get(0); + } + public List findUnpaidByAccountPlan(String accountPlanUuid) { return list(criteria().add(and( eq("accountPlan", accountPlanUuid), @@ -38,17 +49,21 @@ public class BillDAO extends AccountOwnedEntityDAO { .addOrder(ORDER_CTIME_ASC)); } + public List findUnpaidAndDueByAccountPlan(BubblePlan plan, String accountPlanUuid) { + final long now = now(); + return list(criteria().add(and( + eq("accountPlan", accountPlanUuid), + ne("status", BillStatus.paid)))).stream() + .filter(b -> b.isDue(plan, now)) + .collect(Collectors.toList()); + } + public List findUnpaidByAccount(String accountUuid) { return list(criteria().add(and( eq("account", accountUuid), ne("status", BillStatus.paid)))); } - public Bill findOldestUnpaidBillByAccountPlan(String accountPlanUuid) { - final List unpaid = findUnpaidByAccountPlan(accountPlanUuid); - return unpaid.isEmpty() ? null : unpaid.get(unpaid.size()-1); - } - public Bill createFirstBill(BubblePlan plan, AccountPlan accountPlan) { return create(newBill(plan, accountPlan, accountPlan.getCtime())); } diff --git a/bubble-server/src/main/java/bubble/model/bill/Bill.java b/bubble-server/src/main/java/bubble/model/bill/Bill.java index cb434f9d..9adc29ed 100644 --- a/bubble-server/src/main/java/bubble/model/bill/Bill.java +++ b/bubble-server/src/main/java/bubble/model/bill/Bill.java @@ -18,6 +18,9 @@ import org.hibernate.annotations.Type; import javax.persistence.*; import java.util.List; +import static bubble.service.bill.BillingService.ADVANCE_BILLING; +import static org.cobbzilla.util.daemon.ZillaRuntime.bool; +import static org.cobbzilla.util.daemon.ZillaRuntime.now; import static org.cobbzilla.wizard.model.crypto.EncryptedTypes.ENCRYPTED_LONG; import static org.cobbzilla.wizard.model.crypto.EncryptedTypes.ENC_LONG; import static org.cobbzilla.wizard.model.entityconfig.annotations.ECForeignKeySearchDepth.shallow; @@ -68,6 +71,12 @@ public class Bill extends IdentifiableBase implements HasAccountNoName { public int daysInPeriod () { return BillPeriod.daysInPeriod(periodStart, periodEnd); } + public boolean isDue (BubblePlan plan) { return isDue(plan, now()); } + + public boolean isDue (BubblePlan plan, long now) { + return plan.getPeriod().periodMillis(getPeriodStart()) < now; + } + @ECSearchable @ECField(index=80) @Type(type=ENCRYPTED_LONG) @Column(updatable=false, columnDefinition="varchar("+(ENC_LONG)+") NOT NULL") @Getter @Setter private Long total = 0L; @@ -81,6 +90,16 @@ public class Bill extends IdentifiableBase implements HasAccountNoName { @Getter @Setter private Long refundedAmount = 0L; public boolean hasRefundedAmount () { return refundedAmount != null && refundedAmount > 0L; } + @ECSearchable @ECField(index=110) + @Column(nullable=false) + @Getter @Setter private Boolean notified; + public boolean notified () { return bool(notified); } + + public boolean shouldNotify (BubblePlan plan) { + final long now = now(); + return !isDue(plan, now) && isDue(plan, now+ADVANCE_BILLING); + } + @Transient @Getter @Setter private transient BubblePlan planObject; @Transient @Getter @Setter private transient List payments; diff --git a/bubble-server/src/main/java/bubble/model/cloud/notify/NotificationType.java b/bubble-server/src/main/java/bubble/model/cloud/notify/NotificationType.java index 6d6f488c..fdaab0c0 100644 --- a/bubble-server/src/main/java/bubble/model/cloud/notify/NotificationType.java +++ b/bubble-server/src/main/java/bubble/model/cloud/notify/NotificationType.java @@ -104,6 +104,7 @@ public enum NotificationType { // delegated payment driver notifications payment_driver_validate (PaymentValidationResult.class), payment_driver_claim (PaymentValidationResult.class), + payment_driver_payment_due (PaymentResult.class), payment_driver_authorize (PaymentResult.class), payment_driver_cancel_authorization (PaymentResult.class), payment_driver_purchase (PaymentResult.class), diff --git a/bubble-server/src/main/java/bubble/notify/payment/NotificationHandler_payment_driver_payment_due.java b/bubble-server/src/main/java/bubble/notify/payment/NotificationHandler_payment_driver_payment_due.java new file mode 100644 index 00000000..9ca2bc8f --- /dev/null +++ b/bubble-server/src/main/java/bubble/notify/payment/NotificationHandler_payment_driver_payment_due.java @@ -0,0 +1,18 @@ +/** + * Copyright (c) 2020 Bubble, Inc. All rights reserved. + * For personal (non-commercial) use, see license: https://getbubblenow.com/bubble-license/ + */ +package bubble.notify.payment; + +import bubble.model.cloud.CloudService; + +public class NotificationHandler_payment_driver_payment_due extends NotificationHandler_payment_driver { + + @Override public boolean handlePaymentRequest(PaymentNotification paymentNotification, CloudService paymentService) { + return paymentService.getPaymentDriver(configuration).paymentDue( + paymentNotification.getAccountPlanUuid(), + paymentNotification.getBillUuid(), + paymentNotification.getPaymentMethodUuid()); + } + +} diff --git a/bubble-server/src/main/java/bubble/resources/bill/BillsResource.java b/bubble-server/src/main/java/bubble/resources/bill/BillsResource.java index c2aafa65..417dea09 100644 --- a/bubble-server/src/main/java/bubble/resources/bill/BillsResource.java +++ b/bubble-server/src/main/java/bubble/resources/bill/BillsResource.java @@ -25,9 +25,11 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import static bubble.ApiConstants.EP_PAY; import static bubble.ApiConstants.EP_PAYMENTS; +import static org.cobbzilla.util.daemon.ZillaRuntime.now; import static org.cobbzilla.util.http.URIUtil.queryParams; import static org.cobbzilla.wizard.resources.ResourceUtil.*; @@ -68,21 +70,25 @@ public class BillsResource extends ReadOnlyAccountOwnedResource { return bill; } - private Map paymentMethodCache = new ExpirationMap<>(ExpirationEvictionPolicy.atime); + private final Map paymentMethodCache = new ExpirationMap<>(ExpirationEvictionPolicy.atime); private AccountPaymentMethod findPaymentMethod(String paymentMethodUuid) { return paymentMethodCache.computeIfAbsent(paymentMethodUuid, k -> paymentMethodDAO.findByUuid(k)); } @Override protected List list(ContainerRequest ctx) { if (accountPlan == null) return super.list(ctx); - return getDao().findByAccountAndAccountPlan(getAccountUuid(ctx), accountPlan.getUuid()); + final long now = now(); + // don't show bills for future service + return getDao().findByAccountAndAccountPlan(getAccountUuid(ctx), accountPlan.getUuid()).stream() + .filter(b -> findPlan(b.getPlan()).getPeriod().periodMillis(b.getPeriodStart()) < now) + .collect(Collectors.toList()); } @Override protected Bill populate(ContainerRequest ctx, Bill bill) { return super.populate(ctx, bill.setPlanObject(findPlan(bill.getPlan()))); } - private Map planCache = new ExpirationMap<>(ExpirationEvictionPolicy.atime); + private final Map planCache = new ExpirationMap<>(ExpirationEvictionPolicy.atime); private BubblePlan findPlan(String planUuid) { return planCache.computeIfAbsent(planUuid, k -> planDAO.findByUuid(k)); } @Path("/{id}"+EP_PAYMENTS) diff --git a/bubble-server/src/main/java/bubble/service/bill/BillingService.java b/bubble-server/src/main/java/bubble/service/bill/BillingService.java index 04483073..a9a0a435 100644 --- a/bubble-server/src/main/java/bubble/service/bill/BillingService.java +++ b/bubble-server/src/main/java/bubble/service/bill/BillingService.java @@ -24,6 +24,8 @@ import bubble.model.cloud.CloudService; import bubble.server.BubbleConfiguration; import bubble.service.cloud.NetworkService; import lombok.extern.slf4j.Slf4j; +import org.cobbzilla.util.collection.ExpirationEvictionPolicy; +import org.cobbzilla.util.collection.ExpirationMap; import org.cobbzilla.util.daemon.SimpleDaemon; import org.joda.time.DateTime; import org.joda.time.Days; @@ -35,6 +37,7 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; +import static java.util.concurrent.TimeUnit.DAYS; import static java.util.concurrent.TimeUnit.HOURS; import static org.cobbzilla.util.daemon.ZillaRuntime.die; import static org.cobbzilla.util.daemon.ZillaRuntime.now; @@ -46,6 +49,7 @@ public class BillingService extends SimpleDaemon { private static final long BILLING_CHECK_INTERVAL = HOURS.toMillis(6); private static final int MAX_UNPAID_DAYS_BEFORE_STOP = 7; + public static final long ADVANCE_BILLING = DAYS.toMillis(3); @Autowired private AccountDAO accountDAO; @Autowired private AccountPlanDAO accountPlanDAO; @@ -64,23 +68,18 @@ public class BillingService extends SimpleDaemon { @Override protected boolean canInterruptSleep() { return true; } + private final Map planCache = new ExpirationMap<>(ExpirationEvictionPolicy.atime); + private BubblePlan findPlan(String planUuid) { return planCache.computeIfAbsent(planUuid, k -> planDAO.findByUuid(k)); } + @Override protected void process() { // sort plans by Account ctime, newer Accounts are billed before older Accounts - final List plansToBill = accountPlanDAO.findBillableAccountPlans(now()); - final Map> plansByAccount = new TreeMap<>(CTIME_DESC); - for (AccountPlan accountPlan : plansToBill) { - final Account account = accountDAO.findByUuid(accountPlan.getAccount()); - if (account == null) { - reportError("process: account "+accountPlan.getAccount()+" not found for AccountPlan="+accountPlan.getUuid()); - } else { - plansByAccount.computeIfAbsent(account, a -> new ArrayList<>()).add(accountPlan); - } - } + final List plansToBill = accountPlanDAO.findBillableAccountPlans(now()+ADVANCE_BILLING); + final Map> plansByAccount = plansByAccount(plansToBill, accountDAO); for (Account account : plansByAccount.keySet()) { for (AccountPlan accountPlan : plansByAccount.get(account)) { - final BubblePlan plan = planDAO.findByUuid(accountPlan.getPlan()); + final BubblePlan plan = findPlan(accountPlan.getPlan()); if (plan == null) { final String msg = "process: plan not found (" + accountPlan.getPlan() + ") for accountPlan: " + accountPlan.getUuid(); log.error(msg); @@ -151,6 +150,19 @@ public class BillingService extends SimpleDaemon { } } + public static Map> plansByAccount(List plansToBill, AccountDAO accountDAO) { + final Map> plansByAccount = new TreeMap<>(CTIME_DESC); + for (AccountPlan accountPlan : plansToBill) { + final Account account = accountDAO.findByUuid(accountPlan.getAccount()); + if (account == null) { + reportError("process: account "+accountPlan.getAccount()+" not found for AccountPlan="+accountPlan.getUuid()); + } else { + plansByAccount.computeIfAbsent(account, a -> new ArrayList<>()).add(accountPlan); + } + } + return plansByAccount; + } + private List billPlan(BubblePlan plan, AccountPlan accountPlan) { final Bill recentBill = billDAO.findMostRecentBillForAccountPlan(accountPlan.getUuid()); if (recentBill == null) return die("billPlan: no recent bill found for accountPlan: "+accountPlan.getUuid()); @@ -159,25 +171,19 @@ public class BillingService extends SimpleDaemon { final List bills = billDAO.findUnpaidByAccountPlan(accountPlan.getUuid()); final BillPeriod period = plan.getPeriod(); - // create bills for the past, until a bill has a periodEnd beyond the AccountPlan.nextBill date + // create bills for the past, until a bill has a periodStart in the future Bill bill = recentBill; - while (true) { - final long nextBillMillis = period.periodMillis(bill.getPeriodEnd()); + while (period.periodMillis(bill.getPeriodStart()) < now()) { + long nextBillMillis = period.periodMillis(bill.getPeriodEnd()); final Bill nextBill = billDAO.newBill(plan, accountPlan, nextBillMillis); - if (nextBillMillis <= now()) { - bill = billDAO.create(nextBill); - bills.add(bill); - } else { + bill = billDAO.create(nextBill); + bills.add(bill); + if (nextBillMillis > now()) { accountPlan.setNextBill(nextBillMillis); accountPlan.setNextBillDate(); accountPlanDAO.update(accountPlan); - break; } } - - if (bills.size() > 1) { - log.warn("billPlan: "+bills.size()+" bills found for accountPlan: "+accountPlan.getUuid()); - } return bills; } @@ -190,9 +196,25 @@ public class BillingService extends SimpleDaemon { final PaymentServiceDriver paymentDriver = paymentService.getPaymentDriver(configuration); for (Bill bill : bills) { + final long billStart = plan.getPeriod().periodMillis(bill.getPeriodStart()); + if (billStart > now()) { + if (bill.shouldNotify(plan)) { + if (paymentDriver.paymentDue(accountPlan.getUuid(), bill.getUuid(), paymentMethod.getUuid())) { + // send notification + billDAO.update(bill.setNotified(true)); + } + } + log.info("payBills: skipping bill not yet due: "+bill.getUuid()); + continue; + } + if (paymentDriver.getPaymentMethodType().requiresAuth()) { - if (!paymentDriver.authorize(plan, accountPlan.getUuid(), bill.getUuid(), paymentMethod)) { - return die("payBills: paymentDriver.authorized returned false for accountPlan="+accountPlan.getUuid()+", paymentMethod="+paymentMethod.getUuid()+", bill="+bill.getUuid()); + if (!paymentDriver.paymentDue(accountPlan.getUuid(), bill.getUuid(), paymentMethod.getUuid())) { + log.info("payBills: No amount due, skipping authorization step for accountPlan="+accountPlan.getUuid()+", paymentMethod="+paymentMethod.getUuid()+", bill="+bill.getUuid()); + } else { + if (!paymentDriver.authorize(plan, accountPlan.getUuid(), bill.getUuid(), paymentMethod)) { + return die("payBills: paymentDriver.authorized returned false for accountPlan=" + accountPlan.getUuid() + ", paymentMethod=" + paymentMethod.getUuid() + ", bill=" + bill.getUuid()); + } } } if (!paymentDriver.purchase(accountPlan.getUuid(), paymentMethod.getUuid(), bill.getUuid())) { diff --git a/bubble-server/src/main/java/bubble/service/bill/FirstBillNoticeService.java b/bubble-server/src/main/java/bubble/service/bill/FirstBillNoticeService.java deleted file mode 100644 index 604949a3..00000000 --- a/bubble-server/src/main/java/bubble/service/bill/FirstBillNoticeService.java +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright (c) 2020 Bubble, Inc. All rights reserved. - * For personal (non-commercial) use, see license: https://getbubblenow.com/bubble-license/ - */ -package bubble.service.bill; - -import bubble.dao.bill.AccountPlanDAO; -import bubble.dao.bill.BillDAO; -import bubble.dao.bill.BubblePlanDAO; -import bubble.model.bill.AccountPlan; -import lombok.extern.slf4j.Slf4j; -import org.cobbzilla.util.daemon.SimpleDaemon; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; - -import java.util.List; - -import static java.util.concurrent.TimeUnit.DAYS; -import static java.util.concurrent.TimeUnit.HOURS; -import static org.cobbzilla.util.daemon.ZillaRuntime.now; - -@Service @Slf4j -public class FirstBillNoticeService extends SimpleDaemon { - - private static final long BILLING_CHECK_INTERVAL = HOURS.toMillis(6); - @Override protected long getSleepTime() { return BILLING_CHECK_INTERVAL; } - - @Autowired private AccountPlanDAO accountPlanDAO; - @Autowired private BubblePlanDAO planDAO; - @Autowired private BillDAO billDAO; - - @Override protected void process() { - - // sort plans by Account ctime, newer Accounts are billed before older Accounts - final List plansToBillSoon = accountPlanDAO.findBillableAccountPlans(now()+DAYS.toMillis(3)); - - // iterate plans, find plans that have no promotions to apply and will thus actually be charged - // for each such plan, send an email to the account owner telling them: - // when they will be billed, what the amount will be, how it will appear on their statement, and how to cancel - - } -} diff --git a/bubble-server/src/main/java/bubble/service/bill/PromotionService.java b/bubble-server/src/main/java/bubble/service/bill/PromotionService.java index 2b6a3c35..200371f4 100644 --- a/bubble-server/src/main/java/bubble/service/bill/PromotionService.java +++ b/bubble-server/src/main/java/bubble/service/bill/PromotionService.java @@ -153,6 +153,16 @@ public class PromotionService { return null; } + public long checkPromotions(BubblePlan plan, + AccountPlan accountPlan, + Bill bill, + AccountPaymentMethod paymentMethod, + PaymentServiceDriver paymentDriver, + List promos, + long chargeAmount) { + return _usePromotions(plan, accountPlan, bill, paymentMethod, paymentDriver, promos, chargeAmount, true); + } + public long usePromotions(BubblePlan plan, AccountPlan accountPlan, Bill bill, @@ -160,6 +170,17 @@ public class PromotionService { PaymentServiceDriver paymentDriver, List promos, long chargeAmount) { + return _usePromotions(plan, accountPlan, bill, paymentMethod, paymentDriver, promos, chargeAmount, false); + } + + public long _usePromotions(BubblePlan plan, + AccountPlan accountPlan, + Bill bill, + AccountPaymentMethod paymentMethod, + PaymentServiceDriver paymentDriver, + List promos, + long chargeAmount, + boolean checkOnly) { if (configuration.promoCodesDisabled()) { log.warn("usePromotions: promo codes are disabled, not using"); return chargeAmount; @@ -202,7 +223,7 @@ public class PromotionService { log.warn("purchase: Promotion "+promo.getName()+" cannot currently be used for accountPlan "+ accountPlanUuid); continue; } - promoDriver.purchase(accountPlanUuid, apm.getUuid(), bill.getUuid()); + if (!checkOnly) promoDriver.purchase(accountPlanUuid, apm.getUuid(), bill.getUuid()); used.add(promo); // verify AccountPayments exists for new payment with promo diff --git a/bubble-server/src/main/resources/db/migration/V2020080701__add_bill_notified.sql b/bubble-server/src/main/resources/db/migration/V2020080701__add_bill_notified.sql new file mode 100644 index 00000000..d5fb9a3f --- /dev/null +++ b/bubble-server/src/main/resources/db/migration/V2020080701__add_bill_notified.sql @@ -0,0 +1,3 @@ +ALTER TABLE ONLY bill ADD COLUMN notified boolean; +UPDATE bill SET notified = false; +ALTER TABLE ONLY bill ALTER COLUMN notified SET NOT NULL; diff --git a/bubble-server/src/test/resources/models/tests/payment/recurring_billing.json b/bubble-server/src/test/resources/models/tests/payment/recurring_billing.json index 307dd469..c02106a6 100644 --- a/bubble-server/src/test/resources/models/tests/payment/recurring_billing.json +++ b/bubble-server/src/test/resources/models/tests/payment/recurring_billing.json @@ -262,7 +262,7 @@ }, { - "before": "fast_forward_and_bill 62d 20s", + "before": "fast_forward_and_bill 66d 60s", "comment": "3rd fast-forward: fast-forward even more, +66 days, we have missed a billing cycle, so two new bills should be created", "request": { "uri": "me/plans/{{accountPlan.uuid}}/bills" }, "response": {