Browse Source

WIP: create forward bill so we can notify if payment is due

pull/41/head
Jonathan Cobb 4 years ago
parent
commit
35c03fe392
16 changed files with 260 additions and 103 deletions
  1. +5
    -0
      bubble-server/src/main/java/bubble/cloud/NoopCloud.java
  2. +86
    -26
      bubble-server/src/main/java/bubble/cloud/payment/PaymentDriverBase.java
  3. +2
    -0
      bubble-server/src/main/java/bubble/cloud/payment/PaymentServiceDriver.java
  4. +11
    -0
      bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java
  5. +7
    -0
      bubble-server/src/main/java/bubble/dao/bill/AccountPaymentDAO.java
  6. +9
    -0
      bubble-server/src/main/java/bubble/dao/bill/AccountPlanDAO.java
  7. +20
    -5
      bubble-server/src/main/java/bubble/dao/bill/BillDAO.java
  8. +19
    -0
      bubble-server/src/main/java/bubble/model/bill/Bill.java
  9. +1
    -0
      bubble-server/src/main/java/bubble/model/cloud/notify/NotificationType.java
  10. +18
    -0
      bubble-server/src/main/java/bubble/notify/payment/NotificationHandler_payment_driver_payment_due.java
  11. +9
    -3
      bubble-server/src/main/java/bubble/resources/bill/BillsResource.java
  12. +47
    -25
      bubble-server/src/main/java/bubble/service/bill/BillingService.java
  13. +0
    -42
      bubble-server/src/main/java/bubble/service/bill/FirstBillNoticeService.java
  14. +22
    -1
      bubble-server/src/main/java/bubble/service/bill/PromotionService.java
  15. +3
    -0
      bubble-server/src/main/resources/db/migration/V2020080701__add_bill_notified.sql
  16. +1
    -1
      bubble-server/src/test/resources/models/tests/payment/recurring_billing.json

+ 5
- 0
bubble-server/src/main/java/bubble/cloud/NoopCloud.java View File

@@ -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;


+ 86
- 26
bubble-server/src/main/java/bubble/cloud/payment/PaymentDriverBase.java View File

@@ -86,6 +86,44 @@ public abstract class PaymentDriverBase<T> extends CloudServiceDriverBase<T> 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<AccountPayment> 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<T> extends CloudServiceDriverBase<T> 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<AccountPaymentMethod> accountPaymentMethods = paymentMethodDAO.findByAccountAndPromoAndNotDeleted(accountPlan.getAccount());
final List<Promotion> 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<T> extends CloudServiceDriverBase<T> 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<AccountPaymentMethod> accountPaymentMethods = paymentMethodDAO.findByAccountAndPromoAndNotDeleted(accountPlan.getAccount());
final List<Promotion> 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<T> extends CloudServiceDriverBase<T> imp
}

// if there are no unpaid bills, we can (re-)enable the plan
final List<Bill> unpaidBills = billDAO.findUnpaidByAccountPlan(accountPlanUuid);
final List<Bill> unpaidBills = billDAO.findUnpaidAndDueByAccountPlan(plan, accountPlanUuid);
if (unpaidBills.isEmpty()) {
accountPlanDAO.update(accountPlan.setEnabled(true));
} else {


+ 2
- 0
bubble-server/src/main/java/bubble/cloud/payment/PaymentServiceDriver.java View File

@@ -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);


+ 11
- 0
bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java View File

@@ -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,


+ 7
- 0
bubble-server/src/main/java/bubble/dao/bill/AccountPaymentDAO.java View File

@@ -55,6 +55,13 @@ public class AccountPaymentDAO extends AccountOwnedEntityDAO<AccountPayment> {
"status", AccountPaymentStatus.success);
}

public List<AccountPayment> findByAccountAndAccountPlanAndPaymentSuccess(String accountUuid, String accountPlanUuid) {
return findByFields("account", accountUuid,
"accountPlan", accountPlanUuid,
"type", AccountPaymentType.payment,
"status", AccountPaymentStatus.success);
}

public List<AccountPayment> findByAccountAndAccountPlanAndBillAndPaid(String accountUuid, String accountPlanUuid, String billUuid) {
return list(criteria().add(and(
eq("account", accountUuid),


+ 9
- 0
bubble-server/src/main/java/bubble/dao/bill/AccountPlanDAO.java View File

@@ -92,6 +92,15 @@ public class AccountPlanDAO extends AccountOwnedEntityDAO<AccountPlan> {
)));
}

public List<AccountPlan> 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();


+ 20
- 5
bubble-server/src/main/java/bubble/dao/bill/BillDAO.java View File

@@ -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<Bill> {
// 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<Bill> {
return bills.isEmpty() ? null : bills.get(0);
}

public Bill findMostRecentPaidBillForAccountPlan(String accountPlanUuid) {
final List<Bill> bills = findByFields("accountPlan", accountPlanUuid, "status", BillStatus.paid);
return bills.isEmpty() ? null : bills.get(0);
}

public List<Bill> findUnpaidByAccountPlan(String accountPlanUuid) {
return list(criteria().add(and(
eq("accountPlan", accountPlanUuid),
@@ -38,17 +49,21 @@ public class BillDAO extends AccountOwnedEntityDAO<Bill> {
.addOrder(ORDER_CTIME_ASC));
}

public List<Bill> 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<Bill> findUnpaidByAccount(String accountUuid) {
return list(criteria().add(and(
eq("account", accountUuid),
ne("status", BillStatus.paid))));
}

public Bill findOldestUnpaidBillByAccountPlan(String accountPlanUuid) {
final List<Bill> 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()));
}


+ 19
- 0
bubble-server/src/main/java/bubble/model/bill/Bill.java View File

@@ -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<AccountPayment> payments;



+ 1
- 0
bubble-server/src/main/java/bubble/model/cloud/notify/NotificationType.java View File

@@ -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),


+ 18
- 0
bubble-server/src/main/java/bubble/notify/payment/NotificationHandler_payment_driver_payment_due.java View File

@@ -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());
}

}

+ 9
- 3
bubble-server/src/main/java/bubble/resources/bill/BillsResource.java View File

@@ -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<Bill, BillDAO> {
return bill;
}

private Map<String, AccountPaymentMethod> paymentMethodCache = new ExpirationMap<>(ExpirationEvictionPolicy.atime);
private final Map<String, AccountPaymentMethod> paymentMethodCache = new ExpirationMap<>(ExpirationEvictionPolicy.atime);
private AccountPaymentMethod findPaymentMethod(String paymentMethodUuid) {
return paymentMethodCache.computeIfAbsent(paymentMethodUuid, k -> paymentMethodDAO.findByUuid(k));
}

@Override protected List<Bill> 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<String, BubblePlan> planCache = new ExpirationMap<>(ExpirationEvictionPolicy.atime);
private final Map<String, BubblePlan> planCache = new ExpirationMap<>(ExpirationEvictionPolicy.atime);
private BubblePlan findPlan(String planUuid) { return planCache.computeIfAbsent(planUuid, k -> planDAO.findByUuid(k)); }

@Path("/{id}"+EP_PAYMENTS)


+ 47
- 25
bubble-server/src/main/java/bubble/service/bill/BillingService.java View File

@@ -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<String, BubblePlan> 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<AccountPlan> plansToBill = accountPlanDAO.findBillableAccountPlans(now());
final Map<Account, List<AccountPlan>> 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<AccountPlan> plansToBill = accountPlanDAO.findBillableAccountPlans(now()+ADVANCE_BILLING);
final Map<Account, List<AccountPlan>> 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<Account, List<AccountPlan>> plansByAccount(List<AccountPlan> plansToBill, AccountDAO accountDAO) {
final Map<Account, List<AccountPlan>> 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<Bill> 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<Bill> 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())) {


+ 0
- 42
bubble-server/src/main/java/bubble/service/bill/FirstBillNoticeService.java View File

@@ -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<AccountPlan> 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

}
}

+ 22
- 1
bubble-server/src/main/java/bubble/service/bill/PromotionService.java View File

@@ -153,6 +153,16 @@ public class PromotionService {
return null;
}

public long checkPromotions(BubblePlan plan,
AccountPlan accountPlan,
Bill bill,
AccountPaymentMethod paymentMethod,
PaymentServiceDriver paymentDriver,
List<Promotion> 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<Promotion> 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<Promotion> 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


+ 3
- 0
bubble-server/src/main/resources/db/migration/V2020080701__add_bill_notified.sql View File

@@ -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;

+ 1
- 1
bubble-server/src/test/resources/models/tests/payment/recurring_billing.json View File

@@ -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": {


Loading…
Cancel
Save