@@ -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,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; | |||
@@ -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; | |||
} | |||
} |
@@ -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); | |||
} | |||
} |
@@ -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: | |||
@@ -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); } | |||
@@ -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; | |||
@@ -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); | |||
@@ -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; | |||
} | |||
@@ -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; | |||
@@ -0,0 +1 @@ | |||
no-reply@{{network.networkDomain}} |
@@ -0,0 +1 @@ | |||
{{network.networkDomain}} System Account |
@@ -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! |
@@ -0,0 +1 @@ | |||
{{network.networkDomain}}: Bubble stopped due to non-payment. Please pay now to resume service. |
@@ -0,0 +1 @@ | |||
no-reply@{{network.networkDomain}} |
@@ -0,0 +1 @@ | |||
{{network.networkDomain}} System Account |
@@ -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! |
@@ -0,0 +1 @@ | |||
{{network.networkDomain}}: Payment required for your Bubble |
@@ -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 | |||
@@ -0,0 +1 @@ | |||
Bubble {{network.networkDomain}} stopped due to non-payment, to resume service: {{configuration.publicUriBase}}/me/plans/{{message.data}}/bills |
@@ -0,0 +1 @@ | |||
Payment failed for {{network.networkDomain}}. Please pay here: {{configuration.publicUriBase}}/me/plans/{{message.data}}/bills |
@@ -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; | |||
} | |||
} |
@@ -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()); | |||
@@ -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", | |||
@@ -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", | |||
@@ -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", | |||
@@ -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'"} ] | |||
} | |||
} | |||
] |