@@ -7,6 +7,8 @@ import bubble.model.bill.*; | |||
import lombok.extern.slf4j.Slf4j; | |||
import org.springframework.beans.factory.annotation.Autowired; | |||
import java.util.List; | |||
import static org.cobbzilla.wizard.resources.ResourceUtil.invalidEx; | |||
@Slf4j | |||
@@ -60,7 +62,7 @@ public abstract class PaymentDriverBase<T> extends CloudServiceDriverBase<T> imp | |||
return bill; | |||
} | |||
@Override public boolean authorize(BubblePlan plan, AccountPaymentMethod paymentMethod) { | |||
@Override public boolean authorize(BubblePlan plan, String accountPlanUuid, AccountPaymentMethod paymentMethod) { | |||
return true; | |||
} | |||
@@ -131,7 +133,14 @@ public abstract class PaymentDriverBase<T> extends CloudServiceDriverBase<T> imp | |||
// mark the bill as paid, enable the plan | |||
billDAO.update(bill.setPayment(accountPayment.getUuid()).setStatus(BillStatus.paid)); | |||
accountPlanDAO.update(accountPlan.setEnabled(true)); | |||
// if there are no unpaid bills, we can (re-)enable the plan | |||
final List<Bill> unpaidBills = billDAO.findUnpaidByAccountPlan(accountPlan.getUuid()); | |||
if (unpaidBills.isEmpty()) { | |||
accountPlanDAO.update(accountPlan.setEnabled(true)); | |||
} else { | |||
accountPlanDAO.update(accountPlan.setEnabled(false)); | |||
} | |||
return accountPayment; | |||
} | |||
@@ -181,7 +190,11 @@ public abstract class PaymentDriverBase<T> extends CloudServiceDriverBase<T> imp | |||
} | |||
// Determine how much to refund | |||
final long refundAmount = plan.getPeriod().calculateRefund(accountPlan.getCtime(), bill.getPeriod(), bill.getTotal()); | |||
final long refundAmount = plan.getPeriod().calculateRefund(bill, accountPlan); | |||
if (refundAmount == 0) { | |||
log.warn("refund: no refund to issue, refundAmount == 0"); | |||
return true; | |||
} | |||
final String refundInfo; | |||
try { | |||
@@ -17,7 +17,7 @@ public interface PaymentServiceDriver { | |||
default PaymentValidationResult claim(AccountPaymentMethod paymentMethod) { return notSupported("claim"); } | |||
default PaymentValidationResult claim(AccountPlan accountPlan) { return notSupported("claim"); } | |||
boolean authorize(BubblePlan plan, AccountPaymentMethod paymentMethod); | |||
boolean authorize(BubblePlan plan, String accountPlanUuid, AccountPaymentMethod paymentMethod); | |||
boolean purchase(String accountPlanUuid, String paymentMethodUuid, String billUuid); | |||
@@ -52,12 +52,13 @@ public class DelegatedPaymentDriver extends DelegatedCloudServiceDriverBase impl | |||
new PaymentMethodClaimNotification(cloud.getName(), accountPlan)); | |||
} | |||
@Override public boolean authorize(BubblePlan plan, AccountPaymentMethod paymentMethod) { | |||
@Override public boolean authorize(BubblePlan plan, String accountPlanUuid, AccountPaymentMethod paymentMethod) { | |||
final BubbleNode delegate = getDelegateNode(); | |||
final PaymentResult result = notificationService.notifySync(delegate, payment_driver_authorize, | |||
new PaymentNotification() | |||
.setCloud(cloud.getName()) | |||
.setPlanUuid(plan.getUuid()) | |||
.setAccountPlanUuid(accountPlanUuid) | |||
.setPaymentMethodUuid(paymentMethod.getUuid())); | |||
return processResult(result); | |||
} | |||
@@ -89,7 +90,7 @@ public class DelegatedPaymentDriver extends DelegatedCloudServiceDriverBase impl | |||
if (result.hasViolations()) { | |||
throw invalidEx(result.violationList()); | |||
} | |||
if (result.hasError()) return die("authorize: "+result.getError()); | |||
if (result.hasError()) return die("processResult: "+result.getError()); | |||
return false; | |||
} | |||
@@ -126,6 +126,7 @@ public class StripePaymentDriver extends PaymentDriverBase<StripePaymentDriverCo | |||
} | |||
@Override public boolean authorize(BubblePlan plan, | |||
String accountPlanUuid, | |||
AccountPaymentMethod paymentMethod) { | |||
final String planUuid = plan.getUuid(); | |||
final String paymentMethodUuid = paymentMethod.getUuid(); | |||
@@ -141,7 +142,7 @@ public class StripePaymentDriver extends PaymentDriverBase<StripePaymentDriverCo | |||
chargeParams.put("statement_descriptor", plan.chargeDescription()); | |||
chargeParams.put("capture", false); | |||
final String chargeJson = json(chargeParams, COMPACT_MAPPER); | |||
final String authCacheKey = getAuthCacheKey(planUuid, paymentMethodUuid); | |||
final String authCacheKey = getAuthCacheKey(accountPlanUuid, paymentMethodUuid); | |||
final String chargeId = authCache.get(authCacheKey); | |||
if (chargeId != null) { | |||
log.warn("authorize: already authorized: "+authCacheKey); | |||
@@ -208,7 +209,7 @@ public class StripePaymentDriver extends PaymentDriverBase<StripePaymentDriverCo | |||
final RedisService authCache = getAuthCache(); | |||
final RedisService chargeCache = getChargeCache(); | |||
final String authCacheKey = getAuthCacheKey(plan.getUuid(), paymentMethodUuid); | |||
final String authCacheKey = getAuthCacheKey(accountPlanUuid, paymentMethodUuid); | |||
try { | |||
final String charged = chargeCache.get(billUuid); | |||
if (charged != null) { | |||
@@ -18,8 +18,10 @@ import java.util.List; | |||
import static java.util.concurrent.TimeUnit.SECONDS; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.background; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.now; | |||
import static org.cobbzilla.util.system.Sleep.sleep; | |||
import static org.cobbzilla.wizard.resources.ResourceUtil.invalidEx; | |||
import static org.hibernate.criterion.Restrictions.*; | |||
@Repository | |||
public class AccountPlanDAO extends AccountOwnedEntityDAO<AccountPlan> { | |||
@@ -36,11 +38,18 @@ public class AccountPlanDAO extends AccountOwnedEntityDAO<AccountPlan> { | |||
} | |||
public List<AccountPlan> findByAccountAndNotDeleted(String account) { | |||
return findByFields("account", account, "deleted", false); | |||
return findByFields("account", account, "deleted", null); | |||
} | |||
public List<AccountPlan> findByAccountAndPaymentMethodAndNotDeleted(String account, String paymentMethod) { | |||
return findByFields("account", account, "paymentMethod", paymentMethod, "deleted", false); | |||
return findByFields("account", account, "paymentMethod", paymentMethod, "deleted", null); | |||
} | |||
public List<AccountPlan> findByDeletedAndNotClosed() { | |||
return list(criteria().add(and( | |||
isNotNull("deleted"), | |||
eq("closed", false) | |||
))); | |||
} | |||
@Override public Object preCreate(AccountPlan accountPlan) { | |||
@@ -62,7 +71,8 @@ public class AccountPlanDAO extends AccountOwnedEntityDAO<AccountPlan> { | |||
} | |||
if (paymentDriver.getPaymentMethodType().requiresAuth()) { | |||
final BubblePlan plan = planDAO.findByUuid(accountPlan.getPlan()); | |||
paymentDriver.authorize(plan, accountPlan.getPaymentMethodObject()); | |||
accountPlan.beforeCreate(); // ensure uuid exists | |||
paymentDriver.authorize(plan, accountPlan.getUuid(), accountPlan.getPaymentMethodObject()); | |||
} | |||
accountPlan.setPaymentMethod(accountPlan.getPaymentMethodObject().getUuid()); | |||
} | |||
@@ -81,6 +91,8 @@ public class AccountPlanDAO extends AccountOwnedEntityDAO<AccountPlan> { | |||
.setPrice(plan.getPrice()) | |||
.setCurrency(plan.getCurrency()) | |||
.setPeriod(plan.getPeriod().currentPeriod()) | |||
.setPeriodStart(plan.getPeriod().getFirstPeriodStart()) | |||
.setPeriodEnd(plan.getPeriod().getFirstPeriodEnd()) | |||
.setQuantity(1L) | |||
.setType(BillItemType.compute) | |||
.setStatus(BillStatus.unpaid)); | |||
@@ -106,7 +118,7 @@ public class AccountPlanDAO extends AccountOwnedEntityDAO<AccountPlan> { | |||
if (network != null && network.getState() != BubbleNetworkState.stopped) { | |||
throw invalidEx("err.accountPlan.stopNetworkBeforeDeleting"); | |||
} | |||
update(accountPlan.setDeleted(true).setEnabled(false)); | |||
update(accountPlan.setDeleted(now()).setEnabled(false)); | |||
if (configuration.paymentsEnabled()) { | |||
refundService.processRefunds(); | |||
} | |||
@@ -2,6 +2,7 @@ package bubble.dao.bill; | |||
import bubble.dao.account.AccountOwnedEntityDAO; | |||
import bubble.model.bill.Bill; | |||
import bubble.model.bill.BillStatus; | |||
import org.hibernate.criterion.Order; | |||
import org.springframework.stereotype.Repository; | |||
@@ -22,4 +23,8 @@ public class BillDAO extends AccountOwnedEntityDAO<Bill> { | |||
return bills.isEmpty() ? null : bills.get(0); | |||
} | |||
public List<Bill> findUnpaidByAccountPlan(String accountPlanUuid) { | |||
return findByFields("accountPlan", accountPlanUuid, "status", BillStatus.unpaid); | |||
} | |||
} |
@@ -38,6 +38,8 @@ public class AccountPlan extends IdentifiableBase implements HasAccount { | |||
@SuppressWarnings("unused") | |||
public AccountPlan (AccountPlan other) { copy(this, other, CREATE_FIELDS); } | |||
@Override public void beforeCreate() { if (!hasUuid()) initUuid(); } | |||
// mirrors network name | |||
@Size(max=100, message="err.name.length") | |||
@Column(length=100, nullable=false) | |||
@@ -67,13 +69,12 @@ public class AccountPlan extends IdentifiableBase implements HasAccount { | |||
@Getter @Setter private Boolean enabled = false; | |||
public boolean enabled() { return enabled != null && enabled; } | |||
@Column(nullable=false) | |||
@Getter @Setter private Boolean deleted = false; | |||
public boolean deleted() { return deleted != null && deleted; } | |||
@ECIndex @Getter @Setter private Long deleted; | |||
public boolean deleted() { return deleted != null; } | |||
public boolean notDeleted() { return !deleted(); } | |||
@Column(nullable=false) | |||
@Getter @Setter private Boolean closed = false; | |||
@ECIndex @Getter @Setter private Boolean closed = false; | |||
public boolean closed() { return closed != null && closed; } | |||
public boolean notClosed() { return !closed(); } | |||
@@ -51,9 +51,17 @@ public class Bill extends IdentifiableBase implements HasAccountNoName { | |||
@Column(nullable=false, updatable=false, length=20) | |||
@Getter @Setter private BillItemType type; | |||
@Column(nullable=false, updatable=false, length=50) | |||
@Column(nullable=false, updatable=false, length=20) | |||
@ECIndex @Getter @Setter private String period; | |||
@Column(nullable=false, updatable=false, length=20) | |||
@Getter @Setter private String periodStart; | |||
@Column(nullable=false, updatable=false, length=20) | |||
@Getter @Setter private String periodEnd; | |||
public int daysInPeriod () { return BillPeriod.daysInPeriod(periodStart, periodEnd); } | |||
@Type(type=ENCRYPTED_LONG) @Column(updatable=false, columnDefinition="varchar("+(ENC_LONG)+") NOT NULL") | |||
@Getter @Setter private Long quantity = 0L; | |||
@@ -5,6 +5,9 @@ import com.fasterxml.jackson.annotation.JsonCreator; | |||
import lombok.AllArgsConstructor; | |||
import lombok.Getter; | |||
import org.joda.time.DateTime; | |||
import org.joda.time.Days; | |||
import org.joda.time.DurationFieldType; | |||
import org.joda.time.format.DateTimeFormat; | |||
import org.joda.time.format.DateTimeFormatter; | |||
import static bubble.ApiConstants.enumFromString; | |||
@@ -17,17 +20,30 @@ public enum BillPeriod { | |||
monthly (DATE_FORMAT_YYYY_MM); | |||
public static final DateTimeFormatter BILL_START_END_FORMAT = DateTimeFormat.forPattern("yyyy-MM-dd"); | |||
@Getter private DateTimeFormatter formatter; | |||
@Getter(lazy=true) private final BillPeriodDriver driver = instantiate(getClass().getName()+"_"+name()); | |||
@Getter(lazy=true) private final BillPeriodDriver driver = instantiate(BillPeriodDriver.class.getName()+"_"+name()); | |||
@JsonCreator public static BillPeriod fromString (String v) { return enumFromString(BillPeriod.class, v); } | |||
public static int daysInPeriod(String periodStart, String periodEnd) { | |||
final DateTime start = new DateTime(BILL_START_END_FORMAT.parseMillis(periodStart)); | |||
final DateTime end = new DateTime(BILL_START_END_FORMAT.parseMillis(periodEnd)); | |||
return Days.daysBetween(start.withTimeAtStartOfDay(), end.withTimeAtStartOfDay()).getDays(); | |||
} | |||
public String currentPeriod() { return formatter.print(now()); } | |||
public long calculateRefund(long planStart, String period, long total) { | |||
return getDriver().calculateRefund(planStart, period, total); | |||
public long calculateRefund(Bill bill, AccountPlan accountPlan) { | |||
return getDriver().calculateRefund(bill, accountPlan); | |||
} | |||
public DateTime nextPeriod() { return getDriver().nextPeriod(); } | |||
public String getFirstPeriodStart() { return BILL_START_END_FORMAT.print(now()); } | |||
public String getFirstPeriodEnd() { | |||
final DurationFieldType fieldType = getDriver().getDurationFieldType(); | |||
return BILL_START_END_FORMAT.print(new DateTime(now()).withTimeAtStartOfDay().withFieldAdded(fieldType, 1)); | |||
} | |||
} |
@@ -1,11 +1,13 @@ | |||
package bubble.model.bill.period; | |||
import org.joda.time.DateTime; | |||
import bubble.model.bill.AccountPlan; | |||
import bubble.model.bill.Bill; | |||
import org.joda.time.DurationFieldType; | |||
public interface BillPeriodDriver { | |||
long calculateRefund(long planStart, String period, long total); | |||
long calculateRefund(Bill bill, AccountPlan plan); | |||
DateTime nextPeriod(); | |||
DurationFieldType getDurationFieldType(); | |||
} |
@@ -1,52 +1,37 @@ | |||
package bubble.model.bill.period; | |||
import bubble.model.bill.BillPeriod; | |||
import bubble.model.bill.AccountPlan; | |||
import bubble.model.bill.Bill; | |||
import lombok.extern.slf4j.Slf4j; | |||
import org.joda.time.DateTime; | |||
import org.joda.time.Days; | |||
import org.joda.time.LocalDate; | |||
import org.joda.time.DurationFieldType; | |||
import java.math.RoundingMode; | |||
import static bubble.model.bill.BillPeriod.BILL_START_END_FORMAT; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.big; | |||
import static org.cobbzilla.util.daemon.ZillaRuntime.now; | |||
import static org.cobbzilla.wizard.resources.ResourceUtil.invalidEx; | |||
@Slf4j | |||
public class BillPeriodDriver_monthly implements BillPeriodDriver { | |||
@Override public long calculateRefund(long planStart, String period, long total) { | |||
final DateTime currentTime = new DateTime(now()).withTimeAtStartOfDay(); | |||
final DateTime periodStart = BillPeriod.monthly.getFormatter().parseDateTime(period); | |||
final DateTime nextPeriodStart = nextPeriod(); | |||
@Override public DurationFieldType getDurationFieldType() { return DurationFieldType.months(); } | |||
if (currentTime.getMillis() < periodStart.getMillis()) { | |||
// this should never happen | |||
log.error("calculateRefund: currentTime + 1 day is BEFORE periodStart, invalid"); | |||
throw invalidEx("err.refund.periodError"); | |||
} | |||
if (currentTime.getMillis() > nextPeriodStart.getMillis()) { | |||
log.warn("calculateRefund: currentTime + 1 day is after nextPeriodStart, no refund"); | |||
return 0L; | |||
} | |||
@Override public long calculateRefund(Bill bill, AccountPlan plan) { | |||
final DateTime endTime = new DateTime(plan.getDeleted()).plusDays(1).withTimeAtStartOfDay(); | |||
final DateTime startPeriod = BILL_START_END_FORMAT.parseDateTime(bill.getPeriodStart()); | |||
final DateTime endPeriod = BILL_START_END_FORMAT.parseDateTime(bill.getPeriodEnd()); | |||
final int daysInPeriod = Days.daysBetween(periodStart, nextPeriodStart).getDays(); | |||
final int daysUsed = Days.daysBetween(periodStart, currentTime).getDays(); | |||
if (daysUsed == 0) { | |||
log.warn("calculateRefund: no days used, assuming 1 day used"); | |||
return prorated(total, daysInPeriod - 1, daysInPeriod); | |||
} else { | |||
return prorated(total, daysInPeriod - daysUsed, daysInPeriod); | |||
final int daysInPeriod = Days.daysBetween(startPeriod, endPeriod).getDays(); | |||
final int daysRemaining = Days.daysBetween(endTime, endPeriod).getDays(); | |||
if (daysRemaining == 0) { | |||
log.info("calculateRefund: no days remaining in period, no refund to issue"); | |||
return 0L; | |||
} | |||
return big(bill.getTotal()) | |||
.multiply(big(daysRemaining)) | |||
.divide(big(daysInPeriod), RoundingMode.HALF_EVEN) | |||
.longValue(); | |||
} | |||
private long prorated(long total, int daysUsed, int daysInPeriod) { | |||
return big(total) | |||
.multiply(big(daysUsed)) | |||
.divide(big(daysInPeriod), RoundingMode.HALF_EVEN).longValue(); | |||
} | |||
@Override public DateTime nextPeriod() { | |||
return new LocalDate(now()).plusMonths(1).withDayOfMonth(1).toDateTimeAtStartOfDay(); | |||
} | |||
} |
@@ -1,7 +1,7 @@ | |||
package bubble.notify.payment; | |||
import bubble.cloud.payment.PaymentServiceDriver; | |||
import bubble.dao.bill.AccountPaymentMethodDAO; | |||
import bubble.dao.bill.AccountPlanDAO; | |||
import bubble.dao.bill.BubblePlanDAO; | |||
import bubble.model.bill.AccountPaymentMethod; | |||
import bubble.model.bill.BubblePlan; | |||
@@ -12,12 +12,12 @@ public class NotificationHandler_payment_driver_authorize extends NotificationHa | |||
@Autowired private BubblePlanDAO planDAO; | |||
@Autowired private AccountPaymentMethodDAO paymentMethodDAO; | |||
@Autowired private AccountPlanDAO accountPlanDAO; | |||
@Override public boolean handlePaymentRequest(PaymentNotification paymentNotification, CloudService paymentService) { | |||
final BubblePlan plan = planDAO.findByUuid(paymentNotification.getPlanUuid()); | |||
final AccountPaymentMethod paymentMethod = paymentMethodDAO.findByUuid(paymentNotification.getPaymentMethodUuid()); | |||
return paymentService.getPaymentDriver(configuration).authorize(plan, paymentMethod); | |||
final PaymentServiceDriver paymentDriver = paymentService.getPaymentDriver(configuration); | |||
return paymentDriver.authorize(plan, paymentNotification.getAccountPlanUuid(), paymentMethod); | |||
} | |||
} |
@@ -47,11 +47,6 @@ public class AccountPlansResource extends AccountOwnedResource<AccountPlan, Acco | |||
public AccountPlansResource(Account account) { super(account); } | |||
@Override protected AccountPlan find(ContainerRequest ctx, String id) { | |||
final AccountPlan accountPlan = super.find(ctx, id); | |||
return accountPlan == null || accountPlan.deleted() ? null : accountPlan; | |||
} | |||
@Override protected List<AccountPlan> list(ContainerRequest ctx) { | |||
return getDao().findByAccountAndNotDeleted(account.getUuid()); | |||
} | |||
@@ -9,6 +9,7 @@ import bubble.dao.cloud.CloudServiceDAO; | |||
import bubble.model.cloud.BubbleNode; | |||
import bubble.model.cloud.BubbleNodeKey; | |||
import bubble.server.BubbleConfiguration; | |||
import bubble.service.bill.RefundService; | |||
import bubble.service.boot.SageHelloService; | |||
import lombok.extern.slf4j.Slf4j; | |||
import org.cobbzilla.util.string.StringUtil; | |||
@@ -109,6 +110,12 @@ public class NodeInitializerListener extends RestServerLifecycleListenerBase<Bub | |||
c.getBean(SageHelloService.class).start(); | |||
} | |||
// start RefundService if payments are enabled and this is a SageLauncher | |||
if (c.paymentsEnabled() && c.isSageLauncher()) { | |||
log.info("onStart: starting RefundService"); | |||
c.getBean(RefundService.class).start(); | |||
} | |||
return true; | |||
} | |||
} |
@@ -1,16 +1,51 @@ | |||
package bubble.service.bill; | |||
import bubble.cloud.payment.PaymentServiceDriver; | |||
import bubble.dao.bill.AccountPaymentMethodDAO; | |||
import bubble.dao.bill.AccountPlanDAO; | |||
import bubble.dao.cloud.CloudServiceDAO; | |||
import bubble.model.bill.AccountPaymentMethod; | |||
import bubble.model.bill.AccountPlan; | |||
import bubble.model.cloud.CloudService; | |||
import bubble.server.BubbleConfiguration; | |||
import lombok.extern.slf4j.Slf4j; | |||
import org.cobbzilla.util.daemon.SimpleDaemon; | |||
import org.springframework.beans.factory.annotation.Autowired; | |||
import org.springframework.stereotype.Service; | |||
@Service | |||
public class RefundService { | |||
import java.util.List; | |||
import static java.util.concurrent.TimeUnit.HOURS; | |||
@Service @Slf4j | |||
public class RefundService extends SimpleDaemon { | |||
private static final long REFUND_CHECK_INTERVAL = HOURS.toMillis(6); | |||
@Autowired private AccountPlanDAO accountPlanDAO; | |||
@Autowired private CloudServiceDAO cloudDAO; | |||
@Autowired private AccountPaymentMethodDAO paymentMethodDAO; | |||
@Autowired private BubbleConfiguration configuration; | |||
public void processRefunds () { interrupt(); } | |||
@Override protected long getSleepTime() { return REFUND_CHECK_INTERVAL; } | |||
@Override protected boolean canInterruptSleep() { return true; } | |||
public void processRefunds () { | |||
// todo: wake up background job to look for AccountPlans that have been deleted but not refunded | |||
@Override protected void process() { | |||
// iterate over all account plans that have been deleted but not yet closed | |||
final List<AccountPlan> pendingPlans = accountPlanDAO.findByDeletedAndNotClosed(); | |||
for (AccountPlan accountPlan : pendingPlans) { | |||
try { | |||
final AccountPaymentMethod paymentMethod = paymentMethodDAO.findByUuid(accountPlan.getPaymentMethod()); | |||
final CloudService paymentCloud = cloudDAO.findByUuid(paymentMethod.getCloud()); | |||
final PaymentServiceDriver paymentDriver = paymentCloud.getPaymentDriver(configuration); | |||
paymentDriver.refund(accountPlan.getUuid()); | |||
} catch (Exception e) { | |||
log.error("process: error processing refund for AccountPlan: "+accountPlan.getUuid()); | |||
} | |||
} | |||
} | |||
} |
@@ -30,8 +30,6 @@ import static org.cobbzilla.util.system.CommandShell.loadShellExportsOrDie; | |||
@Slf4j | |||
public abstract class BubbleModelTestBase extends ApiModelTestBase<BubbleConfiguration, BubbleServer> { | |||
public static final String[] SQL_POST_SCRIPTS = {"models/constraints.sql"}; | |||
public static final List<RestServerLifecycleListener> TEST_LIFECYCLE_LISTENERS = asList(new RestServerLifecycleListener[] { | |||
new NodeInitializerListener() | |||
}); | |||
@@ -1,6 +1,7 @@ | |||
package bubble.test; | |||
import bubble.server.BubbleConfiguration; | |||
import bubble.service.bill.RefundService; | |||
import lombok.extern.slf4j.Slf4j; | |||
import org.cobbzilla.wizard.server.RestServer; | |||
import org.junit.Test; | |||
@@ -18,6 +19,12 @@ public class PaymentTest extends ActivatedBubbleModelTestBase { | |||
super.beforeStart(server); | |||
} | |||
@Override public void onStart(RestServer<BubbleConfiguration> server) { | |||
final BubbleConfiguration configuration = server.getConfiguration(); | |||
configuration.getBean(RefundService.class).start(); // ensure RefundService is always started | |||
super.onStart(server); | |||
} | |||
@Test public void testFreePayment () throws Exception { modelTest("payment/pay_free"); } | |||
@Test public void testCodePayment () throws Exception { modelTest("payment/pay_code"); } | |||
@Test public void testCreditPayment () throws Exception { modelTest("payment/pay_credit"); } | |||
@@ -86,7 +86,7 @@ | |||
"timezone": "EST", | |||
"plan": "{{plans.[0].name}}", | |||
"footprint": "US", | |||
"paymentMethod": { | |||
"paymentMethodObject": { | |||
"paymentMethodType": "credit", | |||
"paymentInfo": "{{stripeToken}}" | |||
} | |||
@@ -158,6 +158,7 @@ | |||
{"condition": "json.length === 1"}, | |||
{"condition": "json[0].getAccountPlan() === '{{accountPlan.uuid}}'"}, | |||
{"condition": "json[0].getAmount() === {{plans.[0].price}}"}, | |||
{"condition": "json[0].getType().name() === 'payment'"}, | |||
{"condition": "json[0].getStatus().name() === 'success'"} | |||
] | |||
} | |||
@@ -165,11 +166,13 @@ | |||
{ | |||
"comment": "verify successful payment has paid for the bill above", | |||
"request": { "uri": "me/payments/{{payments.[0].uuid}}/bills" }, | |||
"request": { "uri": "me/plans/{{accountPlan.uuid}}/bills" }, | |||
"response": { | |||
"check": [ | |||
{"condition": "json.length === 1"}, | |||
{"condition": "json[0].getUuid() === '{{bills.[0].uuid}}'"} | |||
{"condition": "json[0].getUuid() === '{{bills.[0].uuid}}'"}, | |||
{"condition": "json[0].getStatus().name() === 'paid'"}, | |||
{"condition": "json[0].getRefundedAmount() === 0"} | |||
] | |||
} | |||
}, | |||
@@ -202,149 +205,72 @@ | |||
} | |||
}, | |||
{ | |||
"comment": "verify no plans exist", | |||
"request": { "uri": "me/plans" }, | |||
"response": { | |||
"check": [ {"condition": "json.length === 0"} ] | |||
} | |||
}, | |||
{ | |||
"comment": "add a second plan, using saved payment method", | |||
"request": { | |||
"uri": "me/plans", | |||
"method": "put", | |||
"entity": { | |||
"name": "test-net2-{{rand 5}}", | |||
"domain": "{{defaultDomain}}", | |||
"locale": "en_US", | |||
"timezone": "EST", | |||
"plan": "{{plans.[0].name}}", | |||
"footprint": "US", | |||
"paymentMethod": { | |||
"uuid": "{{savedPaymentMethod.uuid}}" | |||
} | |||
} | |||
}, | |||
"response": { | |||
"store": "accountPlan2" | |||
} | |||
}, | |||
{ | |||
"before": "sleep 15s", | |||
"comment": "verify we still have only one payment method", | |||
"request": { "uri": "me/paymentMethods" }, | |||
"response": { | |||
"check": [ {"condition": "json.length === 1"} ] | |||
} | |||
}, | |||
{ | |||
"comment": "verify account plan payment info is same as used before", | |||
"request": { "uri": "me/plans/{{accountPlan2.uuid}}/paymentMethod" }, | |||
"response": { | |||
"check": [ | |||
{"condition": "json.getPaymentMethodType().name() === 'credit'"}, | |||
{"condition": "json.getMaskedPaymentInfo() === 'XXXX-XXXX-XXXX-4242'"}, | |||
{"condition": "json.getUuid() === '{{savedPaymentMethod.uuid}}'"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "verify we now have two bills", | |||
"request": { "uri": "me/bills" }, | |||
"response": { | |||
"check": [ | |||
{"condition": "json.length === 2"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "verify bill exists for new service, but price is zero", | |||
"request": { "uri": "me/plans/{{accountPlan2.uuid}}/bills" }, | |||
"response": { | |||
"store": "plan2bills", | |||
"check": [ | |||
{"condition": "json.length === 1"}, | |||
{"condition": "json[0].getPlan() === '{{plans.[0].uuid}}'"}, | |||
{"condition": "json[0].getAccountPlan() === '{{accountPlan2.uuid}}'"}, | |||
{"condition": "json[0].getQuantity() === 1"}, | |||
{"condition": "json[0].getPrice() === 0"}, | |||
{"condition": "json[0].getTotal() === 0"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "verify we have still only made one successful payment", | |||
"comment": "verify refund payment has been processed", | |||
"request": { "uri": "me/payments" }, | |||
"response": { | |||
"check": [ | |||
{"condition": "json.length === 1"}, | |||
{"condition": "json.length === 2"}, | |||
{"condition": "json[0].getBill() === '{{bills.[0].uuid}}'"}, | |||
{"condition": "json[0].getType().name() === 'refund'"}, | |||
{"condition": "json[0].getStatus().name() === 'success'"}, | |||
{"condition": "json[0].getAmount() === {{plans.[0].price}}"} | |||
{"condition": "json[0].getAmount() > 0"}, | |||
{"condition": "json[0].getAmount() < {{bills.[0].total}}"}, | |||
// not sure if current month has 28, 29, 30 or 31 days, so let's make some reasonable bounds | |||
// we should only have a refund for all but one day | |||
{"condition": "json[0].getAmount() > bills[0].getTotal() - (bills[0].getTotal() / (bills[0].daysInPeriod() - 1))"}, | |||
{"condition": "json[0].getAmount() <= bills[0].getTotal() - (bills[0].getTotal() / bills[0].daysInPeriod())"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "verify payment exists via plan and is successful, and price was zero", | |||
"request": { "uri": "me/plans/{{accountPlan2.uuid}}/payments" }, | |||
"comment": "verify refund is reflected on the original bill", | |||
"request": { "uri": "me/plans/{{accountPlan.uuid}}/bills" }, | |||
"response": { | |||
"store": "plan2payments", | |||
"check": [ | |||
{"condition": "json.length === 1"}, | |||
{"condition": "json[0].getPlan() === '{{plans.[0].uuid}}'"}, | |||
{"condition": "json[0].getAccountPlan() === '{{accountPlan2.uuid}}'"}, | |||
{"condition": "json[0].getPaymentObject().getAmount() === {{plans.[0].price}}"}, | |||
{"condition": "json[0].getPaymentObject().getStatus().name() === 'success'"}, | |||
{"condition": "json[0].getBillObject().getPlan() === '{{plans.[0].uuid}}'"}, | |||
{"condition": "json[0].getBillObject().getAccountPlan() === '{{accountPlan2.uuid}}'"}, | |||
{"condition": "json[0].getBillObject().getQuantity() === 1"}, | |||
{"condition": "json[0].getBillObject().getPrice() === 0"}, | |||
{"condition": "json[0].getBillObject().getTotal() === 0"}, | |||
{"condition": "json[0].getBillObject().getStatus().name() === 'paid'"} | |||
{"condition": "json[0].getUuid() === '{{bills.[0].uuid}}'"}, | |||
{"condition": "json[0].getStatus().name() === 'paid'"}, | |||
{"condition": "json[0].getTotal() === {{plans.[0].price}}"}, | |||
{"condition": "json[0].getRefundedAmount() > 0"}, | |||
{"condition": "json[0].getRefundedAmount() < {{bills.[0].total}}"}, | |||
// not sure if current month has 28, 29, 30 or 31 days, so let's make some reasonable bounds | |||
// we should only have a refund for all but one day | |||
{"condition": "json[0].getRefundedAmount() > bills[0].getTotal() - (bills[0].getTotal() / (bills[0].daysInPeriod() - 1))"}, | |||
{"condition": "json[0].getRefundedAmount() <= bills[0].getTotal() - (bills[0].getTotal() / bills[0].daysInPeriod())"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "verify successful payment has paid for the second bill (with zero total)", | |||
"request": { "uri": "me/plans/{{accountPlan2.uuid}}/payments/{{plan2payments.[0].uuid}}/bill" }, | |||
"comment": "verify no plans exist", | |||
"request": { "uri": "me/plans" }, | |||
"response": { | |||
"check": [ | |||
{"condition": "json.getPlan() === '{{plans.[0].uuid}}'"}, | |||
{"condition": "json.getAccountPlan() === '{{accountPlan2.uuid}}'"}, | |||
{"condition": "json.getQuantity() === 1"}, | |||
{"condition": "json.getPrice() === 0"}, | |||
{"condition": "json.getTotal() === 0"} | |||
] | |||
"check": [ {"condition": "json.length === 0"} ] | |||
} | |||
}, | |||
{ | |||
"comment": "add a third plan, this one will require a second payment since two plans are now active", | |||
"comment": "add a second plan, using saved payment method", | |||
"request": { | |||
"uri": "me/plans", | |||
"method": "put", | |||
"entity": { | |||
"name": "test-net-{{rand 5}}", | |||
"name": "test-net2-{{rand 5}}", | |||
"domain": "{{defaultDomain}}", | |||
"locale": "en_US", | |||
"timezone": "EST", | |||
"plan": "{{plans.[0].name}}", | |||
"footprint": "US", | |||
"paymentMethod": { | |||
"paymentMethodObject": { | |||
"uuid": "{{savedPaymentMethod.uuid}}" | |||
} | |||
} | |||
}, | |||
"response": { | |||
"store": "accountPlan3" | |||
"store": "accountPlan2" | |||
} | |||
}, | |||
@@ -370,24 +296,24 @@ | |||
}, | |||
{ | |||
"comment": "verify we now have three bills", | |||
"comment": "verify we now have two bills", | |||
"request": { "uri": "me/bills" }, | |||
"response": { | |||
"check": [ | |||
{"condition": "json.length === 3"} | |||
{"condition": "json.length === 2"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "verify bill exists for new service, and price is NOT zero", | |||
"request": { "uri": "me/plans/{{accountPlan3.uuid}}/bills" }, | |||
"comment": "verify bill exists for new service", | |||
"request": { "uri": "me/plans/{{accountPlan2.uuid}}/bills" }, | |||
"response": { | |||
"store": "plan2bills", | |||
"check": [ | |||
{"condition": "json.length === 1"}, | |||
{"condition": "json[0].getPlan() === '{{plans.[0].uuid}}'"}, | |||
{"condition": "json[0].getAccountPlan() === '{{accountPlan3.uuid}}'"}, | |||
{"condition": "json[0].getAccountPlan() === '{{accountPlan2.uuid}}'"}, | |||
{"condition": "json[0].getQuantity() === 1"}, | |||
{"condition": "json[0].getPrice() === {{plans.[0].price}}"}, | |||
{"condition": "json[0].getTotal() === {{plans.[0].price}}"} | |||
@@ -396,68 +322,37 @@ | |||
}, | |||
{ | |||
"comment": "verify we have now made two successful payments", | |||
"comment": "verify we have now made two successful payments (plus one refund)", | |||
"request": { "uri": "me/payments" }, | |||
"response": { | |||
"check": [ | |||
{"condition": "json.length === 2"}, | |||
{"condition": "json.length === 3"}, | |||
{"condition": "json[0].getStatus().name() === 'success'"}, | |||
{"condition": "json[0].getAmount() === {{plans.[0].price}}"}, | |||
{"condition": "json[1].getStatus().name() === 'success'"}, | |||
{"condition": "json[1].getAmount() === {{plans.[0].price}}"} | |||
{"condition": "json[0].getAccountPlan() === '{{accountPlan2.uuid}}'"}, | |||
{"condition": "json[0].getAmount() === {{plans.[0].price}}"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "verify payment exists via plan and is successful, and price was NOT zero", | |||
"request": { "uri": "me/plans/{{accountPlan3.uuid}}/payments" }, | |||
"comment": "verify payment exists via plan and is successful", | |||
"request": { "uri": "me/plans/{{accountPlan2.uuid}}/payments" }, | |||
"response": { | |||
"store": "plan3payments", | |||
"store": "plan2payments", | |||
"check": [ | |||
{"condition": "json.length === 1"}, | |||
{"condition": "json[0].getPlan() === '{{plans.[0].uuid}}'"}, | |||
{"condition": "json[0].getAccountPlan() === '{{accountPlan3.uuid}}'"}, | |||
{"condition": "json[0].getPaymentObject().getAmount() === {{plans.[0].price}}"}, | |||
{"condition": "json[0].getPaymentObject().getStatus().name() === 'success'"}, | |||
{"condition": "json[0].getAccountPlan() === '{{accountPlan2.uuid}}'"}, | |||
{"condition": "json[0].getAmount() === {{plans.[0].price}}"}, | |||
{"condition": "json[0].getStatus().name() === 'success'"}, | |||
{"condition": "json[0].getBillObject().getPlan() === '{{plans.[0].uuid}}'"}, | |||
{"condition": "json[0].getBillObject().getAccountPlan() === '{{accountPlan3.uuid}}'"}, | |||
{"condition": "json[0].getBillObject().getAccountPlan() === '{{accountPlan2.uuid}}'"}, | |||
{"condition": "json[0].getBillObject().getQuantity() === 1"}, | |||
{"condition": "json[0].getBillObject().getPrice() === {{plans.[0].price}}"}, | |||
{"condition": "json[0].getBillObject().getTotal() === {{plans.[0].price}}"}, | |||
{"condition": "json[0].getBillObject().getStatus().name() === 'paid'"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "verify successful payment has paid for the third bill (with non-zero total)", | |||
"request": { "uri": "me/plans/{{accountPlan3.uuid}}/payments/{{plan3payments.[0].uuid}}/bill" }, | |||
"response": { | |||
"check": [ | |||
{"condition": "json.getPlan() === '{{plans.[0].uuid}}'"}, | |||
{"condition": "json.getAccountPlan() === '{{accountPlan3.uuid}}'"}, | |||
{"condition": "json.getQuantity() === 1"}, | |||
{"condition": "json.getPrice() === {{plans.[0].price}}"}, | |||
{"condition": "json.getTotal() === {{plans.[0].price}}"} | |||
] | |||
} | |||
}, | |||
{ | |||
"comment": "delete third network", | |||
"request": { | |||
"method": "delete", | |||
"uri": "me/networks/{{accountPlan3.network}}" | |||
} | |||
}, | |||
{ | |||
"comment": "delete third plan", | |||
"request": { | |||
"method": "delete", | |||
"uri": "me/plans/{{accountPlan3.uuid}}" | |||
} | |||
} | |||
// todo: fast-forward 32 days, trigger BillGenerator | |||