Ver código fonte

payment notifications now working

tags/v0.1.6
Jonathan Cobb 5 anos atrás
pai
commit
089b4f5100
27 arquivos alterados com 485 adições e 83 exclusões
  1. +2
    -0
      bubble-server/src/main/java/bubble/cloud/CloudRegion.java
  2. +2
    -0
      bubble-server/src/main/java/bubble/cloud/compute/ComputeNodeSize.java
  3. +28
    -6
      bubble-server/src/main/java/bubble/cloud/compute/mock/MockComputeDriver.java
  4. +17
    -0
      bubble-server/src/main/java/bubble/cloud/geoLocation/mock/MockGeoLocationDriver.java
  5. +9
    -0
      bubble-server/src/main/java/bubble/model/account/AccountContact.java
  6. +1
    -1
      bubble-server/src/main/java/bubble/model/account/message/AccountAction.java
  7. +1
    -0
      bubble-server/src/main/java/bubble/model/bill/AccountPlan.java
  8. +8
    -1
      bubble-server/src/main/java/bubble/resources/bill/BillsResource.java
  9. +57
    -3
      bubble-server/src/main/java/bubble/service/bill/BillingService.java
  10. +6
    -6
      bubble-server/src/main/java/bubble/service/cloud/StandardNetworkService.java
  11. +1
    -0
      bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromEmail.hbs
  12. +1
    -0
      bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromName.hbs
  13. +12
    -0
      bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/message.hbs
  14. +1
    -0
      bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/subject.hbs
  15. +1
    -0
      bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromEmail.hbs
  16. +1
    -0
      bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromName.hbs
  17. +14
    -0
      bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/message.hbs
  18. +1
    -0
      bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/subject.hbs
  19. +1
    -1
      bubble-server/src/main/resources/message_templates/server/en_US/post_auth/ResourceMessages.properties
  20. +1
    -0
      bubble-server/src/main/resources/message_templates/sms/en_US/notice/payment/network/message.hbs
  21. +1
    -0
      bubble-server/src/main/resources/message_templates/sms/en_US/request/payment/network/message.hbs
  22. +50
    -0
      bubble-server/src/test/java/bubble/mock/MockNetworkService.java
  23. +38
    -29
      bubble-server/src/test/java/bubble/test/BubbleApiRunnerListener.java
  24. +0
    -11
      bubble-server/src/test/resources/models/system/cloudService.json
  25. +11
    -0
      bubble-server/src/test/resources/models/system/cloudService_live.json
  26. +11
    -0
      bubble-server/src/test/resources/models/system/cloudService_test.json
  27. +209
    -25
      bubble-server/src/test/resources/models/tests/payment/recurring_billing.json

+ 2
- 0
bubble-server/src/main/java/bubble/cloud/CloudRegion.java Ver arquivo

@@ -3,7 +3,9 @@ package bubble.cloud;
import bubble.cloud.geoLocation.GeoLocation;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;

@Accessors(chain=true)
public class CloudRegion {

@Getter @Setter private String name;


+ 2
- 0
bubble-server/src/main/java/bubble/cloud/compute/ComputeNodeSize.java Ver arquivo

@@ -2,7 +2,9 @@ package bubble.cloud.compute;

import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;

@Accessors(chain=true)
public class ComputeNodeSize {

@Getter @Setter private ComputeNodeSizeType type;


+ 28
- 6
bubble-server/src/main/java/bubble/cloud/compute/mock/MockComputeDriver.java Ver arquivo

@@ -1,21 +1,36 @@
package bubble.cloud.compute.mock;

import bubble.cloud.CloudRegion;
import bubble.cloud.compute.ComputeNodeSize;
import bubble.cloud.compute.ComputeNodeSizeType;
import bubble.cloud.compute.ComputeServiceDriverBase;
import bubble.cloud.geoLocation.mock.MockGeoLocationDriver;
import bubble.model.cloud.BubbleNode;
import bubble.model.cloud.BubbleNodeState;
import lombok.Getter;
import org.cobbzilla.util.http.HttpRequestBean;
import org.cobbzilla.util.http.HttpResponseBean;

import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

import static java.util.Collections.singletonList;
import static org.cobbzilla.util.daemon.ZillaRuntime.now;

public class MockComputeDriver extends ComputeServiceDriverBase {

private Map<String, BubbleNode> nodes = new ConcurrentHashMap<>();

@Getter private final List<CloudRegion> regions = singletonList(new CloudRegion()
.setDescription("New York City (mock)")
.setName("nyc_mock")
.setLocation(MockGeoLocationDriver.MOCK_LOCAION));

@Getter private final List<ComputeNodeSize> sizes = singletonList(new ComputeNodeSize()
.setName("standard")
.setType(ComputeNodeSizeType.small));

@Override protected String readSshKeyId(HttpResponseBean keyResponse) { return "dummy_ssh_key_id_"+now(); }

@Override public String registerSshKey(BubbleNode node) { return readSshKeyId(null); }
@@ -25,21 +40,28 @@ public class MockComputeDriver extends ComputeServiceDriverBase {
@Override public BubbleNode start(BubbleNode node) throws Exception {
node.setIp4("127.0.0.1");
node.setIp6("::1");
return node;
nodes.put(node.getUuid(), node);
return node.setState(BubbleNodeState.running);
}

@Override public List<BubbleNode> listNodes() throws IOException {
return null;
return new ArrayList<>(nodes.values());
}

@Override public BubbleNode cleanupStart(BubbleNode node) throws Exception { return node; }

@Override public BubbleNode stop(BubbleNode node) throws Exception {
return null;
final BubbleNode found = nodes.get(node.getUuid());
if (found == null) return null;
nodes.put(node.getUuid(), node.setState(BubbleNodeState.stopped));
return node;
}

@Override public BubbleNode status(BubbleNode node) throws Exception {
return null;
final BubbleNode found = nodes.get(node.getUuid());
if (found == null) return node.setState(BubbleNodeState.unknown_error);;
nodes.put(node.getUuid(), node);
return node;
}

}

+ 17
- 0
bubble-server/src/main/java/bubble/cloud/geoLocation/mock/MockGeoLocationDriver.java Ver arquivo

@@ -0,0 +1,17 @@
package bubble.cloud.geoLocation.mock;

import bubble.cloud.config.CloudApiUrlConfig;
import bubble.cloud.geoLocation.GeoLocateServiceDriverBase;
import bubble.cloud.geoLocation.GeoLocation;

public class MockGeoLocationDriver extends GeoLocateServiceDriverBase<CloudApiUrlConfig> {

public static final GeoLocation MOCK_LOCAION = new GeoLocation()
.setLat("40.661").setLon("-73.944")
.setCountry("US").setCity("New York");

@Override protected GeoLocation _geolocate(String ip) {
return MOCK_LOCAION.setCloud(cloud);
}

}

+ 9
- 0
bubble-server/src/main/java/bubble/model/account/AccountContact.java Ver arquivo

@@ -164,6 +164,15 @@ public class AccountContact implements Serializable {
}

switch (action) {
case payment:
switch (type) {
case request: case notice:
return target == ActionTarget.network && getType() != CloudServiceType.authenticator;
default:
log.warn("isAllowed(payment): unknown type: "+type+" for message, returning false");
return false;
}

case login:
switch (type) {
case request:


+ 1
- 1
bubble-server/src/main/java/bubble/model/account/message/AccountAction.java Ver arquivo

@@ -6,7 +6,7 @@ import static bubble.ApiConstants.enumFromString;

public enum AccountAction {

login, password, verify, download, start, stop, delete, welcome, info, promo;
login, password, verify, download, start, stop, delete, welcome, info, promo, payment;

@JsonCreator public static AccountAction fromString (String v) { return enumFromString(AccountAction.class, v); }



+ 1
- 0
bubble-server/src/main/java/bubble/model/bill/AccountPlan.java Ver arquivo

@@ -69,6 +69,7 @@ public class AccountPlan extends IdentifiableBase implements HasAccount {
@Column(nullable=false)
@Getter @Setter private Boolean enabled = false;
public boolean enabled() { return enabled != null && enabled; }
public boolean disabled() { return !enabled(); }

@Column(nullable=false)
@ECIndex @Getter @Setter private Long nextBill;


+ 8
- 1
bubble-server/src/main/java/bubble/resources/bill/BillsResource.java Ver arquivo

@@ -2,6 +2,7 @@ package bubble.resources.bill;

import bubble.cloud.payment.PaymentServiceDriver;
import bubble.dao.bill.AccountPaymentMethodDAO;
import bubble.dao.bill.AccountPlanDAO;
import bubble.dao.bill.BillDAO;
import bubble.dao.cloud.CloudServiceDAO;
import bubble.model.account.Account;
@@ -29,6 +30,7 @@ import static org.cobbzilla.wizard.resources.ResourceUtil.*;
@Slf4j
public class BillsResource extends ReadOnlyAccountOwnedResource<Bill, BillDAO> {

@Autowired private AccountPlanDAO accountPlanDAO;
@Autowired private AccountPaymentMethodDAO paymentMethodDAO;
@Autowired private CloudServiceDAO cloudDAO;

@@ -68,9 +70,14 @@ public class BillsResource extends ReadOnlyAccountOwnedResource<Bill, BillDAO> {
if (bill.paid()) return invalid("err.bill.alreadyPaid");

final AccountPaymentMethod payMethodToUse;
if (paymentMethod.hasUuid()) {
if (paymentMethod == null) {
final AccountPlan accountPlan = accountPlanDAO.findByUuid(bill.getAccountPlan());
payMethodToUse = paymentMethodDAO.findByUuid(accountPlan.getPaymentMethod());

} else if (paymentMethod.hasUuid()) {
payMethodToUse = paymentMethodDAO.findByUuid(paymentMethod.getUuid());
if (payMethodToUse == null) return invalid("err.paymentMethod.notFound");

} else {
final ValidationResult result = new ValidationResult();
paymentMethod.setAccount(getAccountUuid(ctx)).validate(result, configuration);


+ 57
- 3
bubble-server/src/main/java/bubble/service/bill/BillingService.java Ver arquivo

@@ -1,20 +1,29 @@
package bubble.service.bill;

import bubble.cloud.payment.PaymentServiceDriver;
import bubble.dao.account.message.AccountMessageDAO;
import bubble.dao.bill.AccountPaymentMethodDAO;
import bubble.dao.bill.AccountPlanDAO;
import bubble.dao.bill.BillDAO;
import bubble.dao.bill.BubblePlanDAO;
import bubble.dao.cloud.BubbleNetworkDAO;
import bubble.dao.cloud.CloudServiceDAO;
import bubble.model.account.message.AccountAction;
import bubble.model.account.message.AccountMessage;
import bubble.model.account.message.AccountMessageType;
import bubble.model.account.message.ActionTarget;
import bubble.model.bill.*;
import bubble.model.cloud.BubbleNetwork;
import bubble.model.cloud.CloudService;
import bubble.server.BubbleConfiguration;
import bubble.service.cloud.NetworkService;
import lombok.extern.slf4j.Slf4j;
import org.cobbzilla.util.daemon.SimpleDaemon;
import org.joda.time.DateTime;
import org.joda.time.Days;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

import static java.util.concurrent.TimeUnit.HOURS;
@@ -25,12 +34,16 @@ import static org.cobbzilla.util.daemon.ZillaRuntime.now;
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;

@Autowired private AccountPlanDAO accountPlanDAO;
@Autowired private BubblePlanDAO planDAO;
@Autowired private BillDAO billDAO;
@Autowired private CloudServiceDAO cloudDAO;
@Autowired private AccountPaymentMethodDAO paymentMethodDAO;
@Autowired private AccountMessageDAO messageDAO;
@Autowired private BubbleNetworkDAO networkDAO;
@Autowired private NetworkService networkService;
@Autowired private BubbleConfiguration configuration;

public void processBilling () { interrupt(); }
@@ -58,12 +71,52 @@ public class BillingService extends SimpleDaemon {
continue;
}

boolean sendPaymentReminder = false;
try {
if (!payBills(plan, accountPlan, bills)) {
log.error("process: payBills returned false for "+bills.size()+" bill(s) for accountPlan "+accountPlan.getUuid());
sendPaymentReminder = true;
}
} catch (Exception e) {
log.error("process: error paying "+bills.size()+" bill(s) for accountPlan "+accountPlan.getUuid()+": "+e);
sendPaymentReminder = true;
}

if (sendPaymentReminder) {
// plan has unpaid bill, try again tomorrow
accountPlan.setNextBill(new DateTime(now()).plusDays(1).getMillis());
accountPlan.setNextBillDate();
accountPlanDAO.update(accountPlan);

final Bill bill = bills.get(0);
final long unpaidStart = plan.getPeriod().periodMillis(bill.getPeriodStart());
final int unpaidDays = Days.daysBetween(new DateTime(unpaidStart), new DateTime(now())).getDays();
if (unpaidDays > MAX_UNPAID_DAYS_BEFORE_STOP) {
final BubbleNetwork network = networkDAO.findByUuid(accountPlan.getNetwork());
try {
networkService.stopNetwork(network);
} catch (Exception e) {
// todo: notify admin, requires intervention
log.error("process: error stopping network due to non-payment: "+network.getUuid());
continue;
}
messageDAO.create(new AccountMessage()
.setAccount(accountPlan.getAccount())
.setMessageType(AccountMessageType.notice)
.setTarget(ActionTarget.network)
.setAction(AccountAction.payment)
.setName(accountPlan.getUuid())
.setData(accountPlan.getNetwork()));

} else {
messageDAO.create(new AccountMessage()
.setAccount(accountPlan.getAccount())
.setMessageType(AccountMessageType.request)
.setTarget(ActionTarget.network)
.setAction(AccountAction.payment)
.setName(accountPlan.getUuid())
.setData(accountPlan.getNetwork()));
}
}
}
}
@@ -72,8 +125,9 @@ public class BillingService extends SimpleDaemon {
final Bill recentBill = billDAO.findMostRecentBillForAccountPlan(accountPlan.getUuid());
if (recentBill == null) return die("billPlan: no recent bill found for accountPlan: "+accountPlan.getUuid());

// start with any existing unpaid bills
final List<Bill> bills = billDAO.findUnpaidByAccountPlan(accountPlan.getUuid());
final BillPeriod period = plan.getPeriod();
final List<Bill> bills = new ArrayList<>();

// create bills for the past, until a bill has a periodEnd beyond the AccountPlan.nextBill date
Bill bill = recentBill;
@@ -92,7 +146,7 @@ public class BillingService extends SimpleDaemon {
}

if (bills.size() > 1) {
log.warn("billPlan: "+bills.size()+" bills created for accountPlan: "+accountPlan.getUuid());
log.warn("billPlan: "+bills.size()+" bills found for accountPlan: "+accountPlan.getUuid());
}
return bills;
}


+ 6
- 6
bubble-server/src/main/java/bubble/service/cloud/StandardNetworkService.java Ver arquivo

@@ -361,11 +361,11 @@ public class StandardNetworkService implements NetworkService {
return lock;
}

boolean confirmLock(String network, String lock) {
protected boolean confirmLock(String network, String lock) {
return getNetworkLocks().confirmLock(network, lock);
}

void unlockNetwork(String network, String lock) {
protected void unlockNetwork(String network, String lock) {
log.info("lockNetwork: unlocking "+network);
getNetworkLocks().unlock(network, lock);
log.info("lockNetwork: unlocked "+network);
@@ -416,8 +416,8 @@ public class StandardNetworkService implements NetworkService {
if (!nodeDAO.findByNetwork(network.getUuid()).isEmpty()) {
throw invalidEx("err.network.alreadyStarted");
}
if (network.getState() != BubbleNetworkState.created) {
throw invalidEx("err.network.notInCreatedState");
if (network.getState() != BubbleNetworkState.created && network.getState() != BubbleNetworkState.stopped) {
throw invalidEx("err.network.cannotStartInState");
}

network.setState(BubbleNetworkState.setup);
@@ -509,7 +509,7 @@ public class StandardNetworkService implements NetworkService {
public void backgroundNewNode(NewNodeNotification newNodeRequest, final String existingLock) {
final AccountPlan accountPlan = accountPlanDAO.findByAccountAndNetwork(newNodeRequest.getAccount(), newNodeRequest.getNetwork());
if (accountPlan == null) throw invalidEx("err.accountPlan.notFound");
if (!accountPlan.enabled()) throw invalidEx("err.accountPlan.disabled");
if (accountPlan.disabled()) throw invalidEx("err.accountPlan.disabled");
final AtomicReference<String> lock = new AtomicReference<>(existingLock);
daemon(new NodeLauncher(newNodeRequest, lock, this));
}
@@ -556,7 +556,7 @@ public class StandardNetworkService implements NetworkService {
return true;
}

private CloudService findServiceOrDelegate(String cloudUuid) {
protected CloudService findServiceOrDelegate(String cloudUuid) {
CloudService cloud = cloudDAO.findByUuid(cloudUuid);
if (!cloud.delegated()) return cloud;



+ 1
- 0
bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromEmail.hbs Ver arquivo

@@ -0,0 +1 @@
no-reply@{{network.networkDomain}}

+ 1
- 0
bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromName.hbs Ver arquivo

@@ -0,0 +1 @@
{{network.networkDomain}} System Account

+ 12
- 0
bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/message.hbs Ver arquivo

@@ -0,0 +1,12 @@
Hello {{account.name}},

Your Bubble has been stopped due to non-payment.

Please check your payment information and pay your bill:

{{configuration.publicUriBase}}/me/plans/{{message.data}}/bills

NOTE: If you haven't downloaded your Bubble's restore key, you will NOT be able to restore your Bubble.
You will have to set up a new Bubble.

Thank you for using Bubble!

+ 1
- 0
bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/subject.hbs Ver arquivo

@@ -0,0 +1 @@
{{network.networkDomain}}: Bubble stopped due to non-payment. Please pay now to resume service.

+ 1
- 0
bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromEmail.hbs Ver arquivo

@@ -0,0 +1 @@
no-reply@{{network.networkDomain}}

+ 1
- 0
bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromName.hbs Ver arquivo

@@ -0,0 +1 @@
{{network.networkDomain}} System Account

+ 14
- 0
bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/message.hbs Ver arquivo

@@ -0,0 +1,14 @@
Hello {{account.name}},

Your Bubble is running, but a recent bill could not be paid.

Please check your payment information and pay your bill:

{{configuration.publicUriBase}}/me/plans/{{message.data}}/bills

If your bill remains unpaid for 5+ days, your Bubble will be automatically stopped.

NOTE: If you haven't downloaded your Bubble's restore key, you will NOT be able to restore your Bubble
after it has been stopped. You would have to set up a new Bubble.

Thank you for using Bubble!

+ 1
- 0
bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/subject.hbs Ver arquivo

@@ -0,0 +1 @@
{{network.networkDomain}}: Payment required for your Bubble

+ 1
- 1
bubble-server/src/main/resources/message_templates/server/en_US/post_auth/ResourceMessages.properties Ver arquivo

@@ -150,7 +150,7 @@ err.network.alreadyStarted=Network is already started
err.network.exists=A plan already exists for this network
err.networkKeys.noVerifiedContacts=No verified contacts exist
err.networkName.required=Network name is required
err.network.notInCreatedState=Cannot proceed: network must be in 'created' state
err.network.cannotStartInState=Cannot proceed: network cannot be started in its current state
err.network.required=Network is required
err.network.restore.nodesExist=Cannot restore when active nodes exist
err.network.restore.notStopped=Cannot restore when network is running


+ 1
- 0
bubble-server/src/main/resources/message_templates/sms/en_US/notice/payment/network/message.hbs Ver arquivo

@@ -0,0 +1 @@
Bubble {{network.networkDomain}} stopped due to non-payment, to resume service: {{configuration.publicUriBase}}/me/plans/{{message.data}}/bills

+ 1
- 0
bubble-server/src/main/resources/message_templates/sms/en_US/request/payment/network/message.hbs Ver arquivo

@@ -0,0 +1 @@
Payment failed for {{network.networkDomain}}. Please pay here: {{configuration.publicUriBase}}/me/plans/{{message.data}}/bills

+ 50
- 0
bubble-server/src/test/java/bubble/mock/MockNetworkService.java Ver arquivo

@@ -1,16 +1,66 @@
package bubble.mock;

import bubble.cloud.compute.ComputeServiceDriver;
import bubble.cloud.compute.mock.MockComputeDriver;
import bubble.dao.cloud.BubbleNetworkDAO;
import bubble.dao.cloud.BubbleNodeDAO;
import bubble.dao.cloud.CloudServiceDAO;
import bubble.model.cloud.*;
import bubble.notify.NewNodeNotification;
import bubble.server.BubbleConfiguration;
import bubble.service.cloud.StandardNetworkService;
import org.cobbzilla.util.system.CommandResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.IOException;

import static org.cobbzilla.util.daemon.ZillaRuntime.die;

@Service
public class MockNetworkService extends StandardNetworkService {

@Autowired private BubbleNetworkDAO networkDAO;
@Autowired private CloudServiceDAO cloudDAO;
@Autowired private BubbleNodeDAO nodeDAO;
@Autowired private BubbleConfiguration configuration;

@Override public CommandResult ansibleSetup(String script) throws IOException {
return new CommandResult(0, "mock: successful", "");
}

@Override public BubbleNode newNode(NewNodeNotification nn) {

final BubbleNetwork network = networkDAO.findByUuid(nn.getNetwork());
final CloudService cloud = findServiceOrDelegate(nn.getCloud());
final CloudService nodeCloud = cloudDAO.findByAccountAndName(network.getAccount(), cloud.getName());
if (nodeCloud == null) return die("newNode: node cloud not found: "+cloud.getName()+" for account "+network.getAccount());

final ComputeServiceDriver computeDriver = cloud.getComputeDriver(configuration);
if (!(computeDriver instanceof MockComputeDriver)) return die("newNode: expected MockComputeDriver");

final BubbleNode node = nodeDAO.create(new BubbleNode()
.setHost(nn.getHost())
.setState(BubbleNodeState.running)
.setSageNode(nn.fork() ? null : configuration.getThisNode().getUuid())
.setNetwork(network.getUuid())
.setDomain(network.getDomain())
.setAccount(network.getAccount())
.setSizeType(network.getComputeSizeType())
.setSize(computeDriver.getSize(network.getComputeSizeType()).getInternalName())
.setCloud(nodeCloud.getUuid())
.setRegion(nn.getRegion()));

network.setState(BubbleNetworkState.running);
networkDAO.update(network);

return node;
}

@Override public boolean stopNetwork(BubbleNetwork network) {
network.setState(BubbleNetworkState.stopped);
networkDAO.update(network);
return true;
}

}

+ 38
- 29
bubble-server/src/test/java/bubble/test/BubbleApiRunnerListener.java Ver arquivo

@@ -35,6 +35,8 @@ public class BubbleApiRunnerListener extends SimpleApiRunnerListener {
public static final String STRIPE_TOKENIZE_CARD = "stripe_tokenize_card";
public static final String CTX_STRIPE_TOKEN = "stripeToken";

public static final long DEFAULT_BILLING_SLEEP = SECONDS.toMillis(10);

private BubbleConfiguration configuration;

@Override protected Handlebars initHandlebars() { return BubbleHandlebars.instance.getHandlebars(); }
@@ -49,12 +51,15 @@ public class BubbleApiRunnerListener extends SimpleApiRunnerListener {
if (before.startsWith(FAST_FORWARD_AND_BILL)) {
final List<String> parts = StringUtil.splitAndTrim(before.substring(FAST_FORWARD_AND_BILL.length()), " ");
final long delta = parseDuration(parts.get(0));
final long sleepTime = parts.size() > 1 ? parseDuration(parts.get(1)) : SECONDS.toMillis(20);
final long sleepTime = parts.size() > 1 ? parseDuration(parts.get(1)) : DEFAULT_BILLING_SLEEP;
incrementSystemTimeOffset(delta);
configuration.getBean(BillingService.class).processBilling();
sleep(sleepTime, "waiting for BillingService to complete");

} else if (before.equals(SET_STRIPE_ERROR)) {
} else if (before.equals(STRIPE_TOKENIZE_CARD)) {
stripTokenizeCard(ctx);

} else if (before.startsWith(SET_STRIPE_ERROR)) {
MockStripePaymentDriver.setError(before.substring(SET_STRIPE_ERROR.length()).trim());

} else if (before.equals(UNSET_STRIPE_ERROR)) {
@@ -68,33 +73,9 @@ public class BubbleApiRunnerListener extends SimpleApiRunnerListener {
@Override public void afterScript(String after, Map<String, Object> ctx) throws Exception {
if (after == null) return;
if (after.equals(STRIPE_TOKENIZE_CARD)) {
// ensure stripe API token is initialized
final Account admin = configuration.getBean(AccountDAO.class).findFirstAdmin();
final CloudService stripe = configuration.getBean(CloudServiceDAO.class)
.findByAccountAndType(admin.getUuid(), CloudServiceType.payment)
.stream().filter(c -> StripePaymentDriver.class.isAssignableFrom(forName(c.getDriverClass())))
.findFirst().orElse(null);
if (stripe == null) {
die("afterScript: no cloud found with driverClass=" + StripePaymentDriver.class.getName());
return;
}
stripe.getPaymentDriver(configuration);

final Map<String, Object> tokenParams = new HashMap<>();
final Map<String, Object> cardParams = new HashMap<>();
cardParams.put("number", "4242424242424242");
cardParams.put("exp_month", 10);
cardParams.put("exp_year", 2026);
cardParams.put("cvc", "222");
tokenParams.put("card", cardParams);
try {
final Token token = Token.create(tokenParams);
ctx.put(CTX_STRIPE_TOKEN, token.getId());
} catch (Exception e) {
die("afterScript: error creating Stripe token: " + e);
}

} else if (after.equals(SET_STRIPE_ERROR)) {
stripTokenizeCard(ctx);

} else if (after.startsWith(SET_STRIPE_ERROR)) {
MockStripePaymentDriver.setError(after.substring(SET_STRIPE_ERROR.length()).trim());

} else if (after.equals(UNSET_STRIPE_ERROR)) {
@@ -105,6 +86,34 @@ public class BubbleApiRunnerListener extends SimpleApiRunnerListener {
}
}

public void stripTokenizeCard(Map<String, Object> ctx) {
// ensure stripe API token is initialized
final Account admin = configuration.getBean(AccountDAO.class).findFirstAdmin();
final CloudService stripe = configuration.getBean(CloudServiceDAO.class)
.findByAccountAndType(admin.getUuid(), CloudServiceType.payment)
.stream().filter(c -> StripePaymentDriver.class.isAssignableFrom(forName(c.getDriverClass())))
.findFirst().orElse(null);
if (stripe == null) {
die("afterScript: no cloud found with driverClass=" + StripePaymentDriver.class.getName());
return;
}
stripe.getPaymentDriver(configuration);

final Map<String, Object> tokenParams = new HashMap<>();
final Map<String, Object> cardParams = new HashMap<>();
cardParams.put("number", "4242424242424242");
cardParams.put("exp_month", 10);
cardParams.put("exp_year", 2026);
cardParams.put("cvc", "222");
tokenParams.put("card", cardParams);
try {
final Token token = Token.create(tokenParams);
ctx.put(CTX_STRIPE_TOKEN, token.getId());
} catch (Exception e) {
die("afterScript: error creating Stripe token: " + e);
}
}

@Override public void setCtxVars(Map<String, Object> ctx) {
ctx.put("serverConfig", configuration);
ctx.put("defaultDomain", getBubbleDefaultDomain());


+ 0
- 11
bubble-server/src/test/resources/models/system/cloudService.json Ver arquivo

@@ -45,17 +45,6 @@
"template": true
},

{
"name": "MaxMind",
"type": "geoLocation",
"driverClass": "bubble.cloud.geoLocation.maxmind.MaxMindDriver",
"driverConfig": {
"url": "https://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz",
"file": "GeoLite2-City_[\\d]+/GeoLite2-City\\.mmdb"
},
"template": true
},

{
"_subst": true,
"name": "SmtpServer",


+ 11
- 0
bubble-server/src/test/resources/models/system/cloudService_live.json Ver arquivo

@@ -13,6 +13,17 @@
"template": true
},

{
"name": "MaxMind",
"type": "geoLocation",
"driverClass": "bubble.cloud.geoLocation.maxmind.MaxMindDriver",
"driverConfig": {
"url": "https://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz",
"file": "GeoLite2-City_[\\d]+/GeoLite2-City\\.mmdb"
},
"template": true
},

{
"_subst": true,
"name": "VultrCompute",


+ 11
- 0
bubble-server/src/test/resources/models/system/cloudService_test.json Ver arquivo

@@ -55,6 +55,17 @@
}
},

{
"name": "MockGeoLocation",
"type": "geoLocation",
"driverClass": "bubble.cloud.geoLocation.mock.MockGeoLocationDriver",
"driverConfig": {
"url": "https://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz",
"file": "GeoLite2-City_[\\d]+/GeoLite2-City\\.mmdb"
},
"template": true
},

{
"_subst": true,
"name": "StripePayments",


+ 209
- 25
bubble-server/src/test/resources/models/tests/payment/recurring_billing.json Ver arquivo

@@ -178,8 +178,28 @@
},

{
"before": "fast_forward_and_bill 33d",
"comment": "1st fast-forward: +33 days, verify a new bill exists for accountPlan",
"comment": "start the network",
"request": {
"uri": "me/networks/{{accountPlan.network}}/actions/start",
"method": "post"
},
"response": {
"store": "newNetworkNotification"
}
},

{
"before": "sleep 5s",
"comment": "verify the network is running",
"request": { "uri": "me/networks/{{accountPlan.network}}" },
"response": {
"check": [ {"condition": "json.getState().name() == 'running'"} ]
}
},

{
"before": "fast_forward_and_bill 31d",
"comment": "1st fast-forward: +31 days, verify a new bill exists for accountPlan",
"request": { "uri": "me/plans/{{accountPlan.uuid}}/bills" },
"response": {
"check": [
@@ -188,7 +208,8 @@
{"condition": "json[0].getAccountPlan() === '{{accountPlan.uuid}}'"},
{"condition": "json[0].getQuantity() === 1"},
{"condition": "json[0].getPrice() === {{plans.[0].price}}"},
{"condition": "json[0].getTotal() === {{plans.[0].price}}"}
{"condition": "json[0].getTotal() === {{plans.[0].price}}"},
{"condition": "json[0].getStatus().name() === 'paid'"}
]
}
},
@@ -197,7 +218,6 @@
"comment": "1st fast-forward: verify a successful payment has been made for the new bill for accountPlan",
"request": { "uri": "me/plans/{{accountPlan.uuid}}/payments" },
"response": {
"store": "plan2payments",
"check": [
{"condition": "json.length === 2"},
{"condition": "json[0].getPlan() === '{{plans.[0].uuid}}'"},
@@ -215,8 +235,8 @@
},

{
"before": "fast_forward_and_bill 33d",
"comment": "2nd fast-forward: fast-forward another +33 days, verify a new bill exists for accountPlan",
"before": "fast_forward_and_bill 31d",
"comment": "2nd fast-forward: fast-forward another +31 days, verify a new bill exists for accountPlan",
"request": { "uri": "me/plans/{{accountPlan.uuid}}/bills" },
"response": {
"check": [
@@ -225,7 +245,8 @@
{"condition": "json[0].getAccountPlan() === '{{accountPlan.uuid}}'"},
{"condition": "json[0].getQuantity() === 1"},
{"condition": "json[0].getPrice() === {{plans.[0].price}}"},
{"condition": "json[0].getTotal() === {{plans.[0].price}}"}
{"condition": "json[0].getTotal() === {{plans.[0].price}}"},
{"condition": "json[0].getStatus().name() === 'paid'"}
]
}
},
@@ -234,7 +255,6 @@
"comment": "2nd fast-forward: verify a successful payment has been made for the new bill for accountPlan",
"request": { "uri": "me/plans/{{accountPlan.uuid}}/payments" },
"response": {
"store": "plan2payments",
"check": [
{"condition": "json.length === 3"},
{"condition": "json[0].getPlan() === '{{plans.[0].uuid}}'"},
@@ -252,7 +272,7 @@
},

{
"before": "fast_forward_and_bill 66d",
"before": "fast_forward_and_bill 62d 20s",
"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": {
@@ -263,11 +283,13 @@
{"condition": "json[0].getQuantity() === 1"},
{"condition": "json[0].getPrice() === {{plans.[0].price}}"},
{"condition": "json[0].getTotal() === {{plans.[0].price}}"},
{"condition": "json[0].getStatus().name() === 'paid'"},
{"condition": "json[1].getPlan() === '{{plans.[0].uuid}}'"},
{"condition": "json[1].getAccountPlan() === '{{accountPlan.uuid}}'"},
{"condition": "json[1].getQuantity() === 1"},
{"condition": "json[1].getPrice() === {{plans.[0].price}}"},
{"condition": "json[1].getTotal() === {{plans.[0].price}}"}
{"condition": "json[1].getTotal() === {{plans.[0].price}}"},
{"condition": "json[1].getStatus().name() === 'paid'"}
]
}
},
@@ -276,7 +298,6 @@
"comment": "3rd fast-forward: verify a successful payment has been made for all new bills for accountPlan",
"request": { "uri": "me/plans/{{accountPlan.uuid}}/payments" },
"response": {
"store": "plan2payments",
"check": [
{"condition": "json.length === 5"},
{"condition": "json[0].getPlan() === '{{plans.[0].uuid}}'"},
@@ -302,31 +323,194 @@
]
},
"after": "set_stripe_error charge" // set mock so charging the card fails
}
},

// todo: set mock such that charging the card fails
{
"before": "fast_forward_and_bill 31d",
"comment": "4nd fast-forward: fast-forward another +33 days, verify new bill (unpaid) created",
"request": { "uri": "me/plans/{{accountPlan.uuid}}/bills" },
"response": {
"store": "bills",
"check": [
{"condition": "json.length === 6"},
{"condition": "json[0].getPlan() === '{{plans.[0].uuid}}'"},
{"condition": "json[0].getAccountPlan() === '{{accountPlan.uuid}}'"},
{"condition": "json[0].getQuantity() === 1"},
{"condition": "json[0].getPrice() === {{plans.[0].price}}"},
{"condition": "json[0].getTotal() === {{plans.[0].price}}"},
{"condition": "json[0].getStatus().name() === 'unpaid'"}
]
}
},

// todo: fast-forward 32 days, trigger BillingService
{
"comment": "4th fast-forward: verify payment failed",
"request": { "uri": "me/plans/{{accountPlan.uuid}}/payments" },
"response": {
"check": [
{"condition": "json.length === 6"},
{"condition": "json[0].getPlan() === '{{plans.[0].uuid}}'"},
{"condition": "json[0].getAccountPlan() === '{{accountPlan.uuid}}'"},
{"condition": "json[0].getAmount() === {{plans.[0].price}}"},
{"condition": "json[0].getStatus().name() === 'failure'"},
{"condition": "json[0].getBillObject().getPlan() === '{{plans.[0].uuid}}'"},
{"condition": "json[0].getBillObject().getAccountPlan() === '{{accountPlan.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() === 'unpaid'"}
]
}
},

// todo: verify a new Bill exists for accountPlan and Bill remains 'unpaid', and AccountPayment failed
{
"comment": "as root, verify payment reminder message has been sent",
"request": {
"session": "rootSession",
"uri": "debug/inbox/email/test-user@example.com?type=request&action=payment&target=network"
},
"response": {
"store": "emailInbox",
"check": [
{"condition": "'{{json.[0].ctx.message.messageType}}' === 'request'"},
{"condition": "'{{json.[0].ctx.message.action}}' === 'payment'"},
{"condition": "'{{json.[0].ctx.message.target}}' === 'network'"}
]
}
},

// todo: verify payment reminder messages have been sent
{
"before": "fast_forward_and_bill 7d",
"comment": "5th fast-forward: fast-forward another +6 days, bill is still unpaid, bubble is stopped",
"request": {
"session": "userSession",
"uri": "me/plans/{{accountPlan.uuid}}/bills"
},
"response": {
"check": [
{"condition": "json.length === 6"},
{"condition": "json[0].getPlan() === '{{plans.[0].uuid}}'"},
{"condition": "json[0].getAccountPlan() === '{{accountPlan.uuid}}'"},
{"condition": "json[0].getQuantity() === 1"},
{"condition": "json[0].getPrice() === {{plans.[0].price}}"},
{"condition": "json[0].getTotal() === {{plans.[0].price}}"}
]
}
},

// todo: fast-forward 1 day, trigger BillingService
{
"before": "sleep 10s",
"comment": "as root, verify nonpayment message has been sent",
"request": {
"session": "rootSession",
"uri": "debug/inbox/email/test-user@example.com?type=notice&action=payment&target=network"
},
"response": {
"store": "emailInbox",
"check": [
{"condition": "'{{json.[0].ctx.message.messageType}}' === 'notice'"},
{"condition": "'{{json.[0].ctx.message.action}}' === 'payment'"},
{"condition": "'{{json.[0].ctx.message.target}}' === 'network'"}
]
}
},

// todo: verify a new Bill exists for accountPlan and Bill remains 'unpaid', and AccountPayment failed
{
"before": "sleep 10s",
"comment": "verify the network has been stopped",
"request": {
"session": "userSession",
"uri": "me/networks/{{accountPlan.network}}"
},
"response": {
"check": [ {"condition": "json.getState().name() == 'stopped'"} ]
}
},

// todo: verify payment reminder messages have been sent
{
"comment": "verify plan is no longer enabled",
"request": { "uri": "me/plans/{{accountPlan.uuid}}" },
"response": {
"check": [ {"condition": "json.getStatus().disabled()"} ]
}
},

// todo: fast-forward 3 days, trigger BillingService.
{
"comment": "try to start network, fails due to non-payment",
"request": {
"uri": "me/networks/{{accountPlan.network}}/actions/start",
"method": "post"
},
"response": {
"status": 422,
"check": [ {"condition": "json.has('err.accountPlan.disabled')"} ]
}
},

// todo: verify a new Bill exists for accountPlan and Bill remains 'unpaid', and AccountPayment failed
{
"before": "stripe_tokenize_card",
"comment": "add another payment method",
"request": {
"uri": "me/paymentMethods",
"method": "put",
"entity": {
"paymentMethodType": "credit",
"paymentInfo": "{{stripeToken}}"
}
},
"response": {
"store": "newPaymentMethod"
}
},

// todo: verify network associated with plan has been stopped
{
"comment": "update payment method for plan",
"request": {
"uri": "me/plans/{{accountPlan.uuid}}",
"entity": {
"paymentMethodObject": {
"uuid": "{{newPaymentMethod.uuid}}"
}
}
}
},

// todo: try to start network, fails due to non-payment
{
"comment": "submit payment for unpaid bill",
"request": {
"uri": "me/plans/{{accountPlan.uuid}}/bills/{{bills.[0].uuid}}/pay",
"method": "post"
},
"response": {
"check": [ {"condition": "json.getStatus.name() == 'paid'"} ]
}
},

// todo: submit payment
{
"comment": "verify plan has been re-enabled",
"request": { "uri": "me/plans/{{accountPlan.uuid}}" },
"response": {
"check": [ {"condition": "json.getStatus().enabled()"} ]
}
},

// todo: start network, succeeds
{
"comment": "start the network, succeeds because accountPlan is now current",
"request": {
"uri": "me/networks/{{accountPlan.network}}/actions/start",
"method": "post"
},
"response": {
"store": "newNetworkNotification"
}
},

{
"before": "sleep 10s",
"comment": "verify the network is running once again",
"request": { "uri": "me/networks/{{accountPlan.network}}" },
"response": {
"check": [ {"condition": "json.getState().name() == 'running'"} ]
}
}
]

Carregando…
Cancelar
Salvar