From 089b4f5100f17da651a30dbc9e6c3f24af729ea2 Mon Sep 17 00:00:00 2001 From: Jonathan Cobb Date: Sun, 15 Dec 2019 17:53:49 -0500 Subject: [PATCH] payment notifications now working --- .../main/java/bubble/cloud/CloudRegion.java | 2 + .../bubble/cloud/compute/ComputeNodeSize.java | 2 + .../cloud/compute/mock/MockComputeDriver.java | 34 ++- .../mock/MockGeoLocationDriver.java | 17 ++ .../bubble/model/account/AccountContact.java | 9 + .../model/account/message/AccountAction.java | 2 +- .../java/bubble/model/bill/AccountPlan.java | 1 + .../bubble/resources/bill/BillsResource.java | 9 +- .../bubble/service/bill/BillingService.java | 60 ++++- .../service/cloud/StandardNetworkService.java | 12 +- .../notice/payment/network/fromEmail.hbs | 1 + .../en_US/notice/payment/network/fromName.hbs | 1 + .../en_US/notice/payment/network/message.hbs | 12 + .../en_US/notice/payment/network/subject.hbs | 1 + .../request/payment/network/fromEmail.hbs | 1 + .../request/payment/network/fromName.hbs | 1 + .../en_US/request/payment/network/message.hbs | 14 ++ .../en_US/request/payment/network/subject.hbs | 1 + .../post_auth/ResourceMessages.properties | 2 +- .../en_US/notice/payment/network/message.hbs | 1 + .../en_US/request/payment/network/message.hbs | 1 + .../java/bubble/mock/MockNetworkService.java | 50 ++++ .../bubble/test/BubbleApiRunnerListener.java | 67 ++--- .../resources/models/system/cloudService.json | 11 - .../models/system/cloudService_live.json | 11 + .../models/system/cloudService_test.json | 11 + .../tests/payment/recurring_billing.json | 234 ++++++++++++++++-- 27 files changed, 485 insertions(+), 83 deletions(-) create mode 100644 bubble-server/src/main/java/bubble/cloud/geoLocation/mock/MockGeoLocationDriver.java create mode 100644 bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromEmail.hbs create mode 100644 bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromName.hbs create mode 100644 bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/message.hbs create mode 100644 bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/subject.hbs create mode 100644 bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromEmail.hbs create mode 100644 bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromName.hbs create mode 100644 bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/message.hbs create mode 100644 bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/subject.hbs create mode 100644 bubble-server/src/main/resources/message_templates/sms/en_US/notice/payment/network/message.hbs create mode 100644 bubble-server/src/main/resources/message_templates/sms/en_US/request/payment/network/message.hbs diff --git a/bubble-server/src/main/java/bubble/cloud/CloudRegion.java b/bubble-server/src/main/java/bubble/cloud/CloudRegion.java index 35f69cde..f12f7fca 100644 --- a/bubble-server/src/main/java/bubble/cloud/CloudRegion.java +++ b/bubble-server/src/main/java/bubble/cloud/CloudRegion.java @@ -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; diff --git a/bubble-server/src/main/java/bubble/cloud/compute/ComputeNodeSize.java b/bubble-server/src/main/java/bubble/cloud/compute/ComputeNodeSize.java index 4cff352a..ee4224a8 100644 --- a/bubble-server/src/main/java/bubble/cloud/compute/ComputeNodeSize.java +++ b/bubble-server/src/main/java/bubble/cloud/compute/ComputeNodeSize.java @@ -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; diff --git a/bubble-server/src/main/java/bubble/cloud/compute/mock/MockComputeDriver.java b/bubble-server/src/main/java/bubble/cloud/compute/mock/MockComputeDriver.java index 1642340a..05aa8866 100644 --- a/bubble-server/src/main/java/bubble/cloud/compute/mock/MockComputeDriver.java +++ b/bubble-server/src/main/java/bubble/cloud/compute/mock/MockComputeDriver.java @@ -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 nodes = new ConcurrentHashMap<>(); + @Getter private final List regions = singletonList(new CloudRegion() + .setDescription("New York City (mock)") + .setName("nyc_mock") + .setLocation(MockGeoLocationDriver.MOCK_LOCAION)); + + @Getter private final List 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 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; } } diff --git a/bubble-server/src/main/java/bubble/cloud/geoLocation/mock/MockGeoLocationDriver.java b/bubble-server/src/main/java/bubble/cloud/geoLocation/mock/MockGeoLocationDriver.java new file mode 100644 index 00000000..3e0dd38d --- /dev/null +++ b/bubble-server/src/main/java/bubble/cloud/geoLocation/mock/MockGeoLocationDriver.java @@ -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 { + + 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); + } + +} diff --git a/bubble-server/src/main/java/bubble/model/account/AccountContact.java b/bubble-server/src/main/java/bubble/model/account/AccountContact.java index 06d28529..96fa8841 100644 --- a/bubble-server/src/main/java/bubble/model/account/AccountContact.java +++ b/bubble-server/src/main/java/bubble/model/account/AccountContact.java @@ -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: diff --git a/bubble-server/src/main/java/bubble/model/account/message/AccountAction.java b/bubble-server/src/main/java/bubble/model/account/message/AccountAction.java index c858161f..2aeaa53d 100644 --- a/bubble-server/src/main/java/bubble/model/account/message/AccountAction.java +++ b/bubble-server/src/main/java/bubble/model/account/message/AccountAction.java @@ -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); } diff --git a/bubble-server/src/main/java/bubble/model/bill/AccountPlan.java b/bubble-server/src/main/java/bubble/model/bill/AccountPlan.java index baa9165a..c14bf918 100644 --- a/bubble-server/src/main/java/bubble/model/bill/AccountPlan.java +++ b/bubble-server/src/main/java/bubble/model/bill/AccountPlan.java @@ -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; diff --git a/bubble-server/src/main/java/bubble/resources/bill/BillsResource.java b/bubble-server/src/main/java/bubble/resources/bill/BillsResource.java index 5c27ba95..3a2bd977 100644 --- a/bubble-server/src/main/java/bubble/resources/bill/BillsResource.java +++ b/bubble-server/src/main/java/bubble/resources/bill/BillsResource.java @@ -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 { + @Autowired private AccountPlanDAO accountPlanDAO; @Autowired private AccountPaymentMethodDAO paymentMethodDAO; @Autowired private CloudServiceDAO cloudDAO; @@ -68,9 +70,14 @@ public class BillsResource extends ReadOnlyAccountOwnedResource { 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); diff --git a/bubble-server/src/main/java/bubble/service/bill/BillingService.java b/bubble-server/src/main/java/bubble/service/bill/BillingService.java index df9c81d3..06841306 100644 --- a/bubble-server/src/main/java/bubble/service/bill/BillingService.java +++ b/bubble-server/src/main/java/bubble/service/bill/BillingService.java @@ -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 bills = billDAO.findUnpaidByAccountPlan(accountPlan.getUuid()); final BillPeriod period = plan.getPeriod(); - final List 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; } diff --git a/bubble-server/src/main/java/bubble/service/cloud/StandardNetworkService.java b/bubble-server/src/main/java/bubble/service/cloud/StandardNetworkService.java index fe91e558..79bdff65 100644 --- a/bubble-server/src/main/java/bubble/service/cloud/StandardNetworkService.java +++ b/bubble-server/src/main/java/bubble/service/cloud/StandardNetworkService.java @@ -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 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; diff --git a/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromEmail.hbs b/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromEmail.hbs new file mode 100644 index 00000000..07124186 --- /dev/null +++ b/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromEmail.hbs @@ -0,0 +1 @@ +no-reply@{{network.networkDomain}} \ No newline at end of file diff --git a/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromName.hbs b/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromName.hbs new file mode 100644 index 00000000..1178b0e8 --- /dev/null +++ b/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/fromName.hbs @@ -0,0 +1 @@ +{{network.networkDomain}} System Account \ No newline at end of file diff --git a/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/message.hbs b/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/message.hbs new file mode 100644 index 00000000..2f280a03 --- /dev/null +++ b/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/message.hbs @@ -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! diff --git a/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/subject.hbs b/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/subject.hbs new file mode 100644 index 00000000..e44651d1 --- /dev/null +++ b/bubble-server/src/main/resources/message_templates/email/en_US/notice/payment/network/subject.hbs @@ -0,0 +1 @@ +{{network.networkDomain}}: Bubble stopped due to non-payment. Please pay now to resume service. \ No newline at end of file diff --git a/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromEmail.hbs b/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromEmail.hbs new file mode 100644 index 00000000..07124186 --- /dev/null +++ b/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromEmail.hbs @@ -0,0 +1 @@ +no-reply@{{network.networkDomain}} \ No newline at end of file diff --git a/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromName.hbs b/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromName.hbs new file mode 100644 index 00000000..1178b0e8 --- /dev/null +++ b/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/fromName.hbs @@ -0,0 +1 @@ +{{network.networkDomain}} System Account \ No newline at end of file diff --git a/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/message.hbs b/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/message.hbs new file mode 100644 index 00000000..3616389d --- /dev/null +++ b/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/message.hbs @@ -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! diff --git a/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/subject.hbs b/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/subject.hbs new file mode 100644 index 00000000..3b5beaab --- /dev/null +++ b/bubble-server/src/main/resources/message_templates/email/en_US/request/payment/network/subject.hbs @@ -0,0 +1 @@ +{{network.networkDomain}}: Payment required for your Bubble \ No newline at end of file diff --git a/bubble-server/src/main/resources/message_templates/server/en_US/post_auth/ResourceMessages.properties b/bubble-server/src/main/resources/message_templates/server/en_US/post_auth/ResourceMessages.properties index e1ee75a0..81d9b243 100644 --- a/bubble-server/src/main/resources/message_templates/server/en_US/post_auth/ResourceMessages.properties +++ b/bubble-server/src/main/resources/message_templates/server/en_US/post_auth/ResourceMessages.properties @@ -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 diff --git a/bubble-server/src/main/resources/message_templates/sms/en_US/notice/payment/network/message.hbs b/bubble-server/src/main/resources/message_templates/sms/en_US/notice/payment/network/message.hbs new file mode 100644 index 00000000..91d2ad82 --- /dev/null +++ b/bubble-server/src/main/resources/message_templates/sms/en_US/notice/payment/network/message.hbs @@ -0,0 +1 @@ +Bubble {{network.networkDomain}} stopped due to non-payment, to resume service: {{configuration.publicUriBase}}/me/plans/{{message.data}}/bills \ No newline at end of file diff --git a/bubble-server/src/main/resources/message_templates/sms/en_US/request/payment/network/message.hbs b/bubble-server/src/main/resources/message_templates/sms/en_US/request/payment/network/message.hbs new file mode 100644 index 00000000..1e0d4af2 --- /dev/null +++ b/bubble-server/src/main/resources/message_templates/sms/en_US/request/payment/network/message.hbs @@ -0,0 +1 @@ +Payment failed for {{network.networkDomain}}. Please pay here: {{configuration.publicUriBase}}/me/plans/{{message.data}}/bills \ No newline at end of file diff --git a/bubble-server/src/test/java/bubble/mock/MockNetworkService.java b/bubble-server/src/test/java/bubble/mock/MockNetworkService.java index dc004454..0323cf2a 100644 --- a/bubble-server/src/test/java/bubble/mock/MockNetworkService.java +++ b/bubble-server/src/test/java/bubble/mock/MockNetworkService.java @@ -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; + } + } diff --git a/bubble-server/src/test/java/bubble/test/BubbleApiRunnerListener.java b/bubble-server/src/test/java/bubble/test/BubbleApiRunnerListener.java index 670a8bbd..ecab86d6 100644 --- a/bubble-server/src/test/java/bubble/test/BubbleApiRunnerListener.java +++ b/bubble-server/src/test/java/bubble/test/BubbleApiRunnerListener.java @@ -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 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 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 tokenParams = new HashMap<>(); - final Map 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 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 tokenParams = new HashMap<>(); + final Map 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 ctx) { ctx.put("serverConfig", configuration); ctx.put("defaultDomain", getBubbleDefaultDomain()); diff --git a/bubble-server/src/test/resources/models/system/cloudService.json b/bubble-server/src/test/resources/models/system/cloudService.json index 79839d05..f097720e 100644 --- a/bubble-server/src/test/resources/models/system/cloudService.json +++ b/bubble-server/src/test/resources/models/system/cloudService.json @@ -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", diff --git a/bubble-server/src/test/resources/models/system/cloudService_live.json b/bubble-server/src/test/resources/models/system/cloudService_live.json index 2d58ebbb..43229db7 100644 --- a/bubble-server/src/test/resources/models/system/cloudService_live.json +++ b/bubble-server/src/test/resources/models/system/cloudService_live.json @@ -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", diff --git a/bubble-server/src/test/resources/models/system/cloudService_test.json b/bubble-server/src/test/resources/models/system/cloudService_test.json index b27c041e..0a3abb1d 100644 --- a/bubble-server/src/test/resources/models/system/cloudService_test.json +++ b/bubble-server/src/test/resources/models/system/cloudService_test.json @@ -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", diff --git a/bubble-server/src/test/resources/models/tests/payment/recurring_billing.json b/bubble-server/src/test/resources/models/tests/payment/recurring_billing.json index b76088f6..ce8e7365 100644 --- a/bubble-server/src/test/resources/models/tests/payment/recurring_billing.json +++ b/bubble-server/src/test/resources/models/tests/payment/recurring_billing.json @@ -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'"} ] + } + } ] \ No newline at end of file