@@ -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,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 { | |||
@@ -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); | |||
@@ -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, | |||
@@ -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), | |||
@@ -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(); | |||
@@ -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())); | |||
} | |||
@@ -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; | |||
@@ -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), | |||
@@ -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()); | |||
} | |||
} |
@@ -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) | |||
@@ -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())) { | |||
@@ -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 | |||
} | |||
} |
@@ -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 | |||
@@ -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; |
@@ -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": { | |||