diff --git a/bubble-server/src/main/java/bubble/ApiConstants.java b/bubble-server/src/main/java/bubble/ApiConstants.java index f25986be..5a96b6c1 100644 --- a/bubble-server/src/main/java/bubble/ApiConstants.java +++ b/bubble-server/src/main/java/bubble/ApiConstants.java @@ -157,6 +157,7 @@ public class ApiConstants { public static final String EP_ROLES = ROLES_ENDPOINT; public static final String EP_SENT_NOTIFICATIONS = "/notifications/outbox"; public static final String EP_RECEIVED_NOTIFICATIONS = "/notifications/inbox"; + public static final String EP_REFERRAL_CODES = "/referralCodes"; public static final String EP_STORAGE = "/storage"; public static final String EP_DNS = "/dns"; public static final String EP_BACKUPS = "/backups"; diff --git a/bubble-server/src/main/java/bubble/cloud/payment/firstMonthFree/FirstMonthFreePaymentDriver.java b/bubble-server/src/main/java/bubble/cloud/payment/firstMonthFree/FirstMonthFreePaymentDriver.java index db3b3d2d..a2b42a3f 100644 --- a/bubble-server/src/main/java/bubble/cloud/payment/firstMonthFree/FirstMonthFreePaymentDriver.java +++ b/bubble-server/src/main/java/bubble/cloud/payment/firstMonthFree/FirstMonthFreePaymentDriver.java @@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j; import java.util.List; import static org.cobbzilla.util.daemon.ZillaRuntime.empty; +import static org.cobbzilla.wizard.server.RestServerBase.reportError; @Slf4j public class FirstMonthFreePaymentDriver extends PaymentDriverBase implements PromotionalPaymentServiceDriver { @@ -19,7 +20,7 @@ public class FirstMonthFreePaymentDriver extends PaymentDriverBase implements PromotionalPaymentServiceDriver { + private static final String REFERRAL_MONTH_FREE_INFO = "referralMonthFree"; + @Override public PaymentMethodType getPaymentMethodType() { return PaymentMethodType.promotional_credit; } @Override public boolean applyPromo(Promotion promo, Account caller) { return false; } - @Override public boolean applyReferralPromo(Promotion referralPromo, Account caller, Account referredFrom) { - // todo - // validate referralPromo - // check existing AccountPaymentMethods for caller, they can only have one AccountPaymentMethod of the "joiner" type across all methods - // -- create if not exist - // check existing AccountPaymentMethods for referredFrom, they can only have one AccountPaymentMethod of the "referral" type for the caller - // -- create if not exist - return false; + @Override public boolean applyReferralPromo(Promotion promo, Account caller, Account referredFrom) { + // caller must not have any bills + final int billCount = billDAO.countByAccount(caller.getUuid()); + if (billCount != 0) { + log.warn("applyReferralPromo: promo="+promo.getName()+", account="+caller.getName()+", account must have no Bills, found "+billCount+" bills"); + return false; + } + + // check AccountPaymentMethods for referredFrom + final List referredFromCreditPaymentMethods = paymentMethodDAO.findByAccountAndCloud(referredFrom.getUuid(), promo.getCloud()); + + // It's OK for the referredFrom user to have many of these, as long as there is not one for this user + for (AccountPaymentMethod apm : referredFromCreditPaymentMethods) { + if (apm.getPaymentInfo().equals(caller.getUuid())) { + log.error("applyReferralPromo: promo="+promo.getName()+", account="+caller.getName()+", referredFrom="+referredFrom.getName()+" has already referred this caller"); + return false; + } + } + + // does the caller already have one of these? + final List existingCreditPaymentMethods = paymentMethodDAO.findByAccountAndCloud(caller.getUuid(), promo.getCloud()); + if (!empty(existingCreditPaymentMethods)) { + log.warn("applyReferralPromo: promo="+promo.getName()+", account="+caller.getName()+", account already has one of these promos applied"); + return true; // promo has already been applied, return true + } + + // create new APMs for caller and referredFrom + paymentMethodDAO.create(new AccountPaymentMethod() + .setAccount(caller.getUuid()) + .setCloud(promo.getCloud()) + .setPaymentMethodType(PaymentMethodType.promotional_credit) + .setPaymentInfo(referredFrom.getUuid()) + .setMaskedPaymentInfo(promo.getName()) + .setPromotion(promo.getUuid())); + + paymentMethodDAO.create(new AccountPaymentMethod() + .setAccount(referredFrom.getUuid()) + .setCloud(promo.getCloud()) + .setPaymentMethodType(PaymentMethodType.promotional_credit) + .setPaymentInfo(caller.getUuid()) + .setMaskedPaymentInfo(promo.getName()) + .setPromotion(promo.getUuid())); + + return true; } @Override public PaymentValidationResult validate(AccountPaymentMethod paymentMethod) { - // todo - // validate that this paymentMethod is for this driver - // validate that this paymentMethod has not yet been used on any other AccountPayment - return null; + if (paymentMethod.getPaymentMethodType() != PaymentMethodType.promotional_credit || !paymentMethod.hasPromotion()) { + return new PaymentValidationResult("err.paymentMethodType.mismatch"); + } + if (!paymentMethod.getCloud().equals(cloud.getUuid()) || paymentMethod.deleted()) { + return new PaymentValidationResult("err.paymentMethodType.mismatch"); + } + return new PaymentValidationResult(paymentMethod); } @Override protected String charge(BubblePlan plan, @@ -36,16 +82,15 @@ public class ReferralMonthFreePaymentDriver extends PaymentDriverBase implements SqlViewSearc @Autowired private SelfNodeService selfNodeService; @Autowired private BillDAO billDAO; @Autowired private SearchService searchService; + @Autowired private ReferralCodeDAO referralCodeDAO; public Account newAccount(Request req, Account caller, AccountRegistration request, Account parent) { return create(new Account(request) @@ -281,12 +282,19 @@ public class AccountDAO extends AbstractCRUDDAO implements SqlViewSearc throw invalidEx("err.delete.unpaidBills", "cannot delete account ("+account.getUuid()+") with "+unpaid.size()+" unpaid bills", account.getUuid()); } - final AccountPolicy policy = policyDAO.findSingleByAccount(uuid); + // for referral codes owned by us, set account to null, leave accountUuid in place + final List ownedCodes = referralCodeDAO.findByAccount(uuid); + for (ReferralCode c : ownedCodes) referralCodeDAO.update(c.setAccount(null)); + + // for referral a code we used, set usedBy to null, leave usedByUuid in place + final ReferralCode usedCode = referralCodeDAO.findCodeUsedBy(uuid); + if (usedCode != null) referralCodeDAO.update(usedCode.setUsedBy(null)); log.info("delete ("+Thread.currentThread().getName()+"): starting to delete account-dependent objects"); configuration.deleteDependencies(account); log.info("delete: finished deleting account-dependent objects"); + final AccountPolicy policy = policyDAO.findSingleByAccount(uuid); switch (policy.getDeletionPolicy()) { case full_delete: super.delete(uuid); diff --git a/bubble-server/src/main/java/bubble/dao/account/ReferralCodeDAO.java b/bubble-server/src/main/java/bubble/dao/account/ReferralCodeDAO.java new file mode 100644 index 00000000..8dd27c88 --- /dev/null +++ b/bubble-server/src/main/java/bubble/dao/account/ReferralCodeDAO.java @@ -0,0 +1,13 @@ +package bubble.dao.account; + +import bubble.model.account.ReferralCode; +import org.springframework.stereotype.Repository; + +@Repository +public class ReferralCodeDAO extends AccountOwnedEntityDAO { + + public ReferralCode findCodeUsedBy(String accountUuid) { return findByUniqueField("usedBy", accountUuid); } + + public ReferralCode findByName(String code) { return findByUniqueField("name", code); } + +} diff --git a/bubble-server/src/main/java/bubble/dao/bill/PromotionDAO.java b/bubble-server/src/main/java/bubble/dao/bill/PromotionDAO.java index 9c5f2fb2..368c7040 100644 --- a/bubble-server/src/main/java/bubble/dao/bill/PromotionDAO.java +++ b/bubble-server/src/main/java/bubble/dao/bill/PromotionDAO.java @@ -25,13 +25,12 @@ public class PromotionDAO extends AbstractCRUDDAO { return found != null ? found : findByName(id); } - public Promotion findEnabledWithCode(String code) { - return findByUniqueFields("enabled", true, "code", code); + public Promotion findEnabledAndActiveWithCode(String code) { + return filterActive(findByUniqueFields("enabled", true, "code", code, "referral", false)); } public List findEnabledAndActiveWithNoCode() { - final List promos = findByFields("enabled", true, "code", null); - return filterActive(promos); + return filterActive(findByFields("enabled", true, "code", null, "referral", false)); } public List findEnabledAndActiveWithNoCodeOrWithCode(String code) { @@ -44,12 +43,14 @@ public class PromotionDAO extends AbstractCRUDDAO { } } - public List filterActive(List promos) { - return promos.stream().filter(Promotion::active).collect(Collectors.toList()); - } - public List findEnabledAndActiveWithReferral() { return filterActive(findByFields("enabled", true, "referral", true)); } + public Promotion filterActive(Promotion promo) { return promo != null && promo.active() ? promo : null; } + + public List filterActive(List promos) { + return promos.stream().filter(Promotion::active).collect(Collectors.toList()); + } + } diff --git a/bubble-server/src/main/java/bubble/model/account/Account.java b/bubble-server/src/main/java/bubble/model/account/Account.java index 5ae33274..bcdf1124 100644 --- a/bubble-server/src/main/java/bubble/model/account/Account.java +++ b/bubble-server/src/main/java/bubble/model/account/Account.java @@ -82,7 +82,7 @@ public class Account extends IdentifiableBaseParentEntity implements TokenPrinci public static final String[] UPDATE_FIELDS = {"url", "description", "autoUpdatePolicy"}; public static final String[] ADMIN_UPDATE_FIELDS = ArrayUtil.append(UPDATE_FIELDS, "suspended", "admin"); - public static final String[] CREATE_FIELDS = ArrayUtil.append(ADMIN_UPDATE_FIELDS, "name"); + public static final String[] CREATE_FIELDS = ArrayUtil.append(ADMIN_UPDATE_FIELDS, "name", "referralCode"); public static final String ROOT_USERNAME = "root"; public static final int NAME_MIN_LENGTH = 4; @@ -181,6 +181,8 @@ public class Account extends IdentifiableBaseParentEntity implements TokenPrinci public static final long INIT_WAIT_INTERVAL = MILLISECONDS.toMillis(250); public static final long INIT_WAIT_TIMEOUT = SECONDS.toMillis(60); + @Transient @Getter @Setter private transient String promoError; + @Transient @JsonIgnore @Getter @Setter private transient AccountInitializer accountInitializer; public boolean hasAccountInitializer () { return accountInitializer != null; } diff --git a/bubble-server/src/main/java/bubble/model/account/AccountRegistration.java b/bubble-server/src/main/java/bubble/model/account/AccountRegistration.java index 28009c00..8bfae8de 100644 --- a/bubble-server/src/main/java/bubble/model/account/AccountRegistration.java +++ b/bubble-server/src/main/java/bubble/model/account/AccountRegistration.java @@ -7,6 +7,8 @@ public class AccountRegistration extends Account { @Getter @Setter private String password; + @Getter @Setter private String promoCode; + @Getter @Setter private AccountContact contact; public boolean hasContact () { return contact != null; } diff --git a/bubble-server/src/main/java/bubble/model/account/ReferralCode.java b/bubble-server/src/main/java/bubble/model/account/ReferralCode.java new file mode 100644 index 00000000..38380e2c --- /dev/null +++ b/bubble-server/src/main/java/bubble/model/account/ReferralCode.java @@ -0,0 +1,55 @@ +package bubble.model.account; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.cobbzilla.wizard.model.Identifiable; +import org.cobbzilla.wizard.model.IdentifiableBase; +import org.cobbzilla.wizard.model.entityconfig.annotations.*; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Transient; + +import static org.apache.commons.lang3.RandomStringUtils.randomAlphanumeric; +import static org.cobbzilla.util.daemon.ZillaRuntime.empty; + +@ECType(root=true) @ECTypeUpdate(method="DISABLED") +@Entity @NoArgsConstructor @Accessors(chain=true) +public class ReferralCode extends IdentifiableBase implements HasAccount { + + public ReferralCode (ReferralCode other) { + // only the count is initialized, everything else is set manually + setCount(other.getCount()); + } + + // update is a noop, must update fields manually + @Override public Identifiable update(Identifiable thing) { return this; } + + @ECSearchable @ECField(index=10) + @ECForeignKey(entity=Account.class) + @Column(length=UUID_MAXLEN) + @Getter @Setter private String account; + + @Column(length=UUID_MAXLEN, nullable=false, updatable=false) + @Getter @Setter private String accountUuid; + + @ECSearchable @ECField(index=20) + @ECIndex(unique=true) @Column(nullable=false, updatable=false, length=20) + @Getter @Setter private String name; + public ReferralCode setName () { return setName(randomAlphanumeric(8)); } + + @ECSearchable @ECField(index=30) + @ECForeignKey(index=false, entity=Account.class) @ECIndex(unique=true) + @Column(length=UUID_MAXLEN) + @Getter @Setter private String usedBy; + + @ECSearchable @ECField(index=40) @ECIndex(unique=true) + @Column(length=UUID_MAXLEN) + @Getter @Setter private String usedByUuid; + public boolean used() { return !empty(usedByUuid); } + + @Transient @Getter @Setter private transient int count = 1; + +} 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 403babb9..14bf1124 100644 --- a/bubble-server/src/main/java/bubble/model/bill/AccountPlan.java +++ b/bubble-server/src/main/java/bubble/model/bill/AccountPlan.java @@ -60,79 +60,67 @@ public class AccountPlan extends IdentifiableBase implements HasAccount { @Column(nullable=false, updatable=false, length=UUID_MAXLEN) @Getter @Setter private String account; - // refers to an Account.uuid, but we do not use a foreign key, so if the referring Account is deleted - // then a lookup of the referralFrom will return null, and any unused referral promotion cannot be used @ECSearchable @ECField(index=30) - @Column(length=UUID_MAXLEN, updatable=false) - @Getter @Setter private String referralFrom; - public boolean hasReferralFrom () { return !empty(referralFrom); } - - @ECSearchable @ECField(index=40) - @Column(length=100, updatable=false) - @Getter @Setter private String promoCode; - public boolean hasPromoCode () { return !empty(promoCode); } - - @ECSearchable @ECField(index=50) @ECForeignKey(entity=BubblePlan.class) @Column(nullable=false, updatable=false, length=UUID_MAXLEN) @Getter @Setter private String plan; - @ECSearchable @ECField(index=60) + @ECSearchable @ECField(index=40) @ECForeignKey(entity=AccountPaymentMethod.class) @Column(updatable=false, length=UUID_MAXLEN) @Getter @Setter private String paymentMethod; - @ECSearchable @ECField(index=70) + @ECSearchable @ECField(index=50) @ECForeignKey(entity=BubbleDomain.class) @Column(nullable=false, updatable=false, length=UUID_MAXLEN) @Getter @Setter private String domain; - @ECSearchable @ECField(index=80) + @ECSearchable @ECField(index=60) @ECForeignKey(entity=BubbleNetwork.class, index=false) @ECIndex(unique=true) @Column(length=UUID_MAXLEN) @Getter @Setter private String network; - @ECSearchable @ECField(index=90) + @ECSearchable @ECField(index=70) @ECForeignKey(entity=AccountSshKey.class) @Column(length=UUID_MAXLEN) @Getter @Setter private String sshKey; public boolean hasSshKey () { return !empty(sshKey); } - @ECSearchable @ECField(index=100) + @ECSearchable @ECField(index=80) @Column(nullable=false) @Getter @Setter private Boolean enabled = false; public boolean enabled() { return bool(enabled); } public boolean disabled() { return !enabled(); } - @ECSearchable(type=EntityFieldType.epoch_time) @ECField(index=110) + @ECSearchable(type=EntityFieldType.epoch_time) @ECField(index=90) @Column(nullable=false) @ECIndex @Getter @Setter private Long nextBill; - @ECSearchable @ECField(index=120) + @ECSearchable @ECField(index=100) @Column(nullable=false, length=50) @Getter @Setter private String nextBillDate; public AccountPlan setNextBillDate() { return setNextBillDate(BILL_START_END_FORMAT.print(getNextBill())); } - @ECSearchable @ECField(index=130) + @ECSearchable @ECField(index=110) @ECIndex @Getter @Setter private Long deleted; public boolean deleted() { return deleted != null; } public boolean notDeleted() { return !deleted(); } - @ECSearchable @ECField(index=140) + @ECSearchable @ECField(index=120) @Column(nullable=false) @ECIndex @Getter @Setter private Boolean closed = false; public boolean closed() { return bool(closed); } public boolean notClosed() { return !closed(); } - @ECSearchable @ECField(index=150, type=EntityFieldType.reference) + @ECSearchable @ECField(index=130, type=EntityFieldType.reference) @ECIndex(unique=true) @Column(length=UUID_MAXLEN) @Getter @Setter private String deletedNetwork; public boolean hasDeletedNetwork() { return deletedNetwork != null; } - @ECSearchable @ECField(index=160) @Column(nullable=false) + @ECSearchable @ECField(index=140) @Column(nullable=false) @Getter @Setter private Boolean refundIssued = false; - @ECSearchable @ECField(index=170, type=EntityFieldType.error) + @ECSearchable @ECField(index=150, type=EntityFieldType.error) @Getter @Setter private String refundError; // Fields below are used when creating a new plan, to also create the network associated with it diff --git a/bubble-server/src/main/java/bubble/resources/account/AccountsResource.java b/bubble-server/src/main/java/bubble/resources/account/AccountsResource.java index d935d372..a9de4b6a 100644 --- a/bubble-server/src/main/java/bubble/resources/account/AccountsResource.java +++ b/bubble-server/src/main/java/bubble/resources/account/AccountsResource.java @@ -560,6 +560,13 @@ public class AccountsResource { return configuration.subResource(ReceivedNotificationsResource.class, c.account); } + @Path("/{id}"+EP_REFERRAL_CODES) + public ReferralCodesResource getReferralCodesResource(@Context ContainerRequest ctx, + @PathParam("id") String id) { + final AccountContext c = new AccountContext(ctx, id); + return configuration.subResource(ReferralCodesResource.class, c.account); + } + // Non-admins can only read/edit/delete themselves. Admins can do anything to anyone. private class AccountContext { public Account caller; diff --git a/bubble-server/src/main/java/bubble/resources/account/AuthResource.java b/bubble-server/src/main/java/bubble/resources/account/AuthResource.java index 7febc877..80215879 100644 --- a/bubble-server/src/main/java/bubble/resources/account/AuthResource.java +++ b/bubble-server/src/main/java/bubble/resources/account/AuthResource.java @@ -17,6 +17,7 @@ import bubble.server.BubbleConfiguration; import bubble.service.account.AuthenticatorService; import bubble.service.account.StandardAccountMessageService; import bubble.service.backup.RestoreService; +import bubble.service.bill.PromotionService; import bubble.service.boot.ActivationService; import bubble.service.boot.SageHelloService; import bubble.service.notify.NotificationService; @@ -25,6 +26,7 @@ import org.cobbzilla.util.collection.NameAndValue; import org.cobbzilla.wizard.auth.LoginRequest; import org.cobbzilla.wizard.stream.FileSendableResource; import org.cobbzilla.wizard.validation.ConstraintViolationBean; +import org.cobbzilla.wizard.validation.SimpleViolationException; import org.cobbzilla.wizard.validation.ValidationResult; import org.glassfish.grizzly.http.server.Request; import org.glassfish.jersey.server.ContainerRequest; @@ -71,6 +73,7 @@ public class AuthResource { @Autowired private BubbleNodeDAO nodeDAO; @Autowired private BubbleConfiguration configuration; @Autowired private AuthenticatorService authenticatorService; + @Autowired private PromotionService promoService; public Account updateLastLogin(Account account) { return accountDAO.update(account.setLastLogin()); } @@ -193,6 +196,11 @@ public class AuthResource { } else { request.getContact().validate(errors); } + + if (configuration.paymentsEnabled()) { + errors.addAll(promoService.validatePromotions(request.getPromoCode())); + } + if (errors.isInvalid()) return invalid(errors); final String parentUuid = thisNetwork.getTag(TAG_PARENT_ACCOUNT, thisNetwork.getAccount()); @@ -200,7 +208,18 @@ public class AuthResource { if (parent == null) return invalid("err.parent.notFound", "Parent account does not exist: "+parentUuid); final Account account = accountDAO.newAccount(req, null, request, parent); - return ok(account.waitForAccountInit().setToken(newLoginSession(account))); + SimpleViolationException promoEx = null; + if (configuration.paymentsEnabled()) { + try { + promoService.applyPromotions(account, request.getPromoCode()); + } catch (SimpleViolationException e) { + promoEx = e; + } + } + return ok(account + .waitForAccountInit() + .setPromoError(promoEx == null ? null : promoEx.getMessageTemplate()) + .setToken(newLoginSession(account))); } @POST @Path(EP_LOGIN) diff --git a/bubble-server/src/main/java/bubble/resources/account/MeResource.java b/bubble-server/src/main/java/bubble/resources/account/MeResource.java index 9617d600..a19a5339 100644 --- a/bubble-server/src/main/java/bubble/resources/account/MeResource.java +++ b/bubble-server/src/main/java/bubble/resources/account/MeResource.java @@ -336,6 +336,12 @@ public class MeResource { return configuration.subResource(DevicesResource.class, caller); } + @Path(EP_REFERRAL_CODES) + public ReferralCodesResource getReferralCodes(@Context ContainerRequest ctx) { + final Account caller = userPrincipal(ctx); + return configuration.subResource(ReferralCodesResource.class, caller); + } + @Autowired private StandardNetworkService networkService; @GET @Path(EP_STATUS) diff --git a/bubble-server/src/main/java/bubble/resources/account/ReferralCodesResource.java b/bubble-server/src/main/java/bubble/resources/account/ReferralCodesResource.java new file mode 100644 index 00000000..860b918f --- /dev/null +++ b/bubble-server/src/main/java/bubble/resources/account/ReferralCodesResource.java @@ -0,0 +1,48 @@ +package bubble.resources.account; + +import bubble.dao.account.ReferralCodeDAO; +import bubble.model.account.Account; +import bubble.model.account.ReferralCode; +import lombok.extern.slf4j.Slf4j; +import org.glassfish.grizzly.http.server.Request; +import org.glassfish.jersey.server.ContainerRequest; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +public class ReferralCodesResource extends AccountOwnedResource { + + public ReferralCodesResource(Account account) { super(account); } + + @Override protected boolean canCreate(Request req, ContainerRequest ctx, Account caller, ReferralCode request) { + return caller.admin(); + } + + @Override protected Object daoCreate(ReferralCode toCreate) { + final List createdCodes = new ArrayList<>(); + for (int i=0; i referralPromos = promotionDAO.findEnabledAndActiveWithReferral(); - if (empty(referralPromos)) { - errors.addViolation("err.referralFrom.unavailable"); - } else { - Promotion referralPromo = null; - for (Promotion p : referralPromos) { - if (p.active()) { // todo: add JS condition? - referralPromo = p; - break; - } - } - if (referralPromo == null) { - errors.addViolation("err.referralFrom.unavailable"); - } else { - final CloudService referralCloud = cloudDAO.findByUuid(referralPromo.getCloud()); - if (referralCloud == null || referralCloud.getType() != CloudServiceType.payment) { - errors.addViolation("err.referralFrom.configurationError"); - } else { - final PaymentServiceDriver referralDriver = referralCloud.getPaymentDriver(configuration); - if (referralDriver.getPaymentMethodType() != PaymentMethodType.promotional_credit - || !(referralDriver instanceof PromotionalPaymentServiceDriver)) { - errors.addViolation("err.referralFrom.configurationError"); - } else { - final PromotionalPaymentServiceDriver referralPaymentDriver = (PromotionalPaymentServiceDriver) referralDriver; - if (!referralPaymentDriver.applyReferralPromo(referralPromo, caller, referredFrom)) { - errors.addViolation("err.referralFrom.notApplied"); - } - } - } - } - } - } } if (errors.isInvalid()) throw invalidEx(errors); diff --git a/bubble-server/src/main/java/bubble/service/bill/PromotionService.java b/bubble-server/src/main/java/bubble/service/bill/PromotionService.java new file mode 100644 index 00000000..5280886f --- /dev/null +++ b/bubble-server/src/main/java/bubble/service/bill/PromotionService.java @@ -0,0 +1,135 @@ +package bubble.service.bill; + +import bubble.cloud.CloudServiceType; +import bubble.cloud.payment.PaymentServiceDriver; +import bubble.cloud.payment.PromotionalPaymentServiceDriver; +import bubble.dao.account.AccountDAO; +import bubble.dao.account.ReferralCodeDAO; +import bubble.dao.bill.PromotionDAO; +import bubble.dao.cloud.CloudServiceDAO; +import bubble.model.account.Account; +import bubble.model.account.ReferralCode; +import bubble.model.bill.PaymentMethodType; +import bubble.model.bill.Promotion; +import bubble.model.cloud.CloudService; +import bubble.server.BubbleConfiguration; +import lombok.extern.slf4j.Slf4j; +import org.cobbzilla.wizard.validation.ValidationResult; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Set; +import java.util.TreeSet; + +import static org.cobbzilla.util.daemon.ZillaRuntime.empty; +import static org.cobbzilla.util.daemon.ZillaRuntime.shortError; +import static org.cobbzilla.wizard.resources.ResourceUtil.invalidEx; + +@Service @Slf4j +public class PromotionService { + + @Autowired private PromotionDAO promotionDAO; + @Autowired private ReferralCodeDAO referralCodeDAO; + @Autowired private CloudServiceDAO cloudDAO; + @Autowired private AccountDAO accountDAO; + @Autowired private BubbleConfiguration configuration; + + public void applyPromotions(Account account, String code) { + // apply promo code (or default) promotion + final Set promos = new TreeSet<>(); + ReferralCode referralCode = null; + if (!empty(code)) { + Promotion promo = promotionDAO.findEnabledAndActiveWithCode(code); + if (promo == null) { + // check referral codes + // it might be a referral code + referralCode = referralCodeDAO.findByName(code); + if (referralCode != null && !referralCode.used()) { + // is there a referral promotion we can use? + for (Promotion p : promotionDAO.findEnabledAndActiveWithReferral()) { + promos.add(p); + break; + } + } + } else { + promos.add(promo); + } + if (promos.isEmpty()) throw invalidEx("err.promoCode.notFound"); + } + + // everyone gets the highest-priority default promotion, if there are any enabled and active + for (Promotion p : promotionDAO.findEnabledAndActiveWithNoCode()) { + promos.add(p); + break; + } + + if (promos.isEmpty()) return; // nothing to do + + for (Promotion p : promos) { + final CloudService promoCloud = cloudDAO.findByUuid(p.getCloud()); + if (promoCloud == null || promoCloud.getType() != CloudServiceType.payment) { + throw invalidEx("err.promoCode.configurationError"); + } else { + final PaymentServiceDriver promoDriver = promoCloud.getPaymentDriver(configuration); + if (promoDriver.getPaymentMethodType() != PaymentMethodType.promotional_credit + || !(promoDriver instanceof PromotionalPaymentServiceDriver)) { + throw invalidEx("err.promoCode.configurationError"); + } else { + final PromotionalPaymentServiceDriver promoPaymentDriver = (PromotionalPaymentServiceDriver) promoDriver; + if (p.referral()) { + if (referralCode == null) throw invalidEx("err.promoCode.notFound"); + final Account referer = accountDAO.findById(referralCode.getAccountUuid()); + if (referer == null || referer.deleted()) throw invalidEx("err.promoCode.notFound"); + if (!promoPaymentDriver.applyReferralPromo(p, account, referer)) { + throw invalidEx("err.promoCode.notApplied"); + } + } else { + if (!promoPaymentDriver.applyPromo(p, account)) { + if (!empty(code)) { + throw invalidEx("err.promoCode.notApplied"); + } else { + log.warn("setReferences: promo not applied: " + p.getName()); + } + } + } + } + } + } + } + + public ValidationResult validatePromotions(String code) { + if (!empty(code)) { + Promotion promo = promotionDAO.findEnabledAndActiveWithCode(code); + if (promo == null) { + // it might be a referral code + final ReferralCode referralCode = referralCodeDAO.findByName(code); + if (referralCode != null && !referralCode.used()) { + final Account referer = accountDAO.findById(referralCode.getAccountUuid()); + if (referer == null || referer.deleted()) return new ValidationResult("err.promoCode.notFound"); + + // is there a referral promotion we can use? + for (Promotion p : promotionDAO.findEnabledAndActiveWithReferral()) { + // todo: add JS check? + promo = p; + break; + } + } + if (promo == null) return new ValidationResult("err.promoCode.notFound"); + } + + final CloudService promoCloud = cloudDAO.findByUuid(promo.getCloud()); + if (promoCloud == null || promoCloud.getType() != CloudServiceType.payment) { + return new ValidationResult("err.promoCode.configurationError"); + } + // sanity check the driver + try { + final PaymentServiceDriver driver = promoCloud.getPaymentDriver(configuration); + final PromotionalPaymentServiceDriver promoDriver = (PromotionalPaymentServiceDriver) driver; + } catch (Exception e) { + log.error("validatePromotions: error applying referral promo: "+shortError(e)); + return new ValidationResult("err.promoCode.configurationError"); + } + } + return null; + } +} diff --git a/bubble-server/src/main/java/bubble/service/dbfilter/FilteredEntityIterator.java b/bubble-server/src/main/java/bubble/service/dbfilter/FilteredEntityIterator.java index f69e736c..708ce33e 100644 --- a/bubble-server/src/main/java/bubble/service/dbfilter/FilteredEntityIterator.java +++ b/bubble-server/src/main/java/bubble/service/dbfilter/FilteredEntityIterator.java @@ -7,18 +7,19 @@ import bubble.dao.cloud.BubbleNodeKeyDAO; import bubble.dao.device.DeviceDAO; import bubble.model.account.Account; import bubble.model.account.HasAccount; +import bubble.model.account.ReferralCode; import bubble.model.account.message.AccountMessage; -import bubble.model.bill.BubblePlanApp; +import bubble.model.bill.*; import bubble.model.cloud.BubbleNetwork; import bubble.model.cloud.BubbleNode; import bubble.model.cloud.BubbleNodeKey; import bubble.model.device.Device; import bubble.server.BubbleConfiguration; +import edu.emory.mathcs.backport.java.util.Arrays; import lombok.extern.slf4j.Slf4j; import org.cobbzilla.wizard.dao.DAO; import org.cobbzilla.wizard.model.Identifiable; -import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicReference; @@ -27,14 +28,12 @@ import static org.cobbzilla.util.daemon.ZillaRuntime.die; @Slf4j public class FilteredEntityIterator extends EntityIterator { - public static final List> POST_COPY_ENTITIES = new ArrayList<>(); - static { - POST_COPY_ENTITIES.add(BubbleNode.class); - POST_COPY_ENTITIES.add(BubbleNodeKey.class); - POST_COPY_ENTITIES.add(Device.class); - POST_COPY_ENTITIES.add(AccountMessage.class); - } - public static boolean isPostCopyEntity(Class clazz) { + private static final List> POST_COPY_ENTITIES = Arrays.asList(new Class[] { + BubbleNode.class, BubbleNodeKey.class, Device.class, AccountMessage.class, + ReferralCode.class, AccountPaymentMethod.class, AccountPayment.class, Bill.class, Promotion.class, + }); + + private static boolean isPostCopyEntity(Class clazz) { return POST_COPY_ENTITIES.stream().anyMatch(c -> c.isAssignableFrom(clazz)); } diff --git a/bubble-server/src/main/resources/message_templates/en_US/server/pre_auth/ResourceMessages.properties b/bubble-server/src/main/resources/message_templates/en_US/server/pre_auth/ResourceMessages.properties index 8a3fac2e..21491a67 100644 --- a/bubble-server/src/main/resources/message_templates/en_US/server/pre_auth/ResourceMessages.properties +++ b/bubble-server/src/main/resources/message_templates/en_US/server/pre_auth/ResourceMessages.properties @@ -165,6 +165,7 @@ err.user.noAdmin=No admin account exists, cannot create another account err.user.noSoleNode=Cannot create account, self-node was never initialized and multiple nodes exist err.user.setSelfNodeFailed=Cannot create account, initialization of self-node failed err.uuid.invalid=UUID is invalid +err.referralCode.invalid=Referral code is not valid err.registration.disabled=Account registration is not enabled on this Bubble err.register.alreadyLoggedIn=Cannot register a new account when logged in err.name.registered=Username is already registered diff --git a/bubble-server/src/test/java/bubble/test/BubbleApiRunnerListener.java b/bubble-server/src/test/java/bubble/test/BubbleApiRunnerListener.java index 315f243d..2821cea9 100644 --- a/bubble-server/src/test/java/bubble/test/BubbleApiRunnerListener.java +++ b/bubble-server/src/test/java/bubble/test/BubbleApiRunnerListener.java @@ -13,6 +13,7 @@ import bubble.server.BubbleConfiguration; import bubble.service.bill.BillingService; import com.github.jknack.handlebars.Handlebars; import com.stripe.model.Token; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.cobbzilla.wizard.client.script.SimpleApiRunnerListener; @@ -46,13 +47,15 @@ public class BubbleApiRunnerListener extends SimpleApiRunnerListener { this.configuration = configuration; } + @Getter(lazy=true) private final StripePaymentDriver stripePaymentDriver = configuration.autowire(new StripePaymentDriver()); + @Override public void beforeScript(String before, Map ctx) throws Exception { if (before == null) return; if (before.startsWith(FAST_FORWARD_AND_BILL)) { final List parts = 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)) : DEFAULT_BILLING_SLEEP; - configuration.autowire(new StripePaymentDriver()).flushCaches(); + getStripePaymentDriver().flushCaches(); incrementSystemTimeOffset(delta); configuration.getBean(BillingService.class).processBilling(); sleep(sleepTime, "waiting for BillingService to complete"); diff --git a/bubble-server/src/test/java/bubble/test/FirstMonthFreePromotionTest.java b/bubble-server/src/test/java/bubble/test/FirstMonthFreePromotionTest.java index bca2f52f..8c61ba6d 100644 --- a/bubble-server/src/test/java/bubble/test/FirstMonthFreePromotionTest.java +++ b/bubble-server/src/test/java/bubble/test/FirstMonthFreePromotionTest.java @@ -8,8 +8,6 @@ public class FirstMonthFreePromotionTest extends PaymentTestBase { @Override protected String getManifest() { return "manifest-1mo-promo"; } - @Test public void testFirstMonthFree () throws Exception { - modelTest("promo/first_month_free"); - } + @Test public void testFirstMonthFree () throws Exception { modelTest("promo/first_month_free"); } } diff --git a/bubble-server/src/test/java/bubble/test/ReferralMonthFreePromotionTest.java b/bubble-server/src/test/java/bubble/test/ReferralMonthFreePromotionTest.java index 1f8f303b..a25e165f 100644 --- a/bubble-server/src/test/java/bubble/test/ReferralMonthFreePromotionTest.java +++ b/bubble-server/src/test/java/bubble/test/ReferralMonthFreePromotionTest.java @@ -6,8 +6,8 @@ import org.junit.Test; @Slf4j public class ReferralMonthFreePromotionTest extends PaymentTestBase { - @Test public void testReferralMonthFree () throws Exception { - modelTest("promo/referral_month_free"); - } + @Override protected String getManifest() { return "manifest-referral-promo"; } + + @Test public void testReferralMonthFree () throws Exception { modelTest("promo/referral_month_free"); } } diff --git a/bubble-server/src/test/resources/models/manifest-referral-promo.json b/bubble-server/src/test/resources/models/manifest-referral-promo.json new file mode 100644 index 00000000..d17c6073 --- /dev/null +++ b/bubble-server/src/test/resources/models/manifest-referral-promo.json @@ -0,0 +1,5 @@ +[ + "manifest-test", + "system/cloudService_referral_free", + "system/promotion_referral_free" +] \ No newline at end of file diff --git a/bubble-server/src/test/resources/models/system/cloudService_referral_free.json b/bubble-server/src/test/resources/models/system/cloudService_referral_free.json new file mode 100644 index 00000000..804c41df --- /dev/null +++ b/bubble-server/src/test/resources/models/system/cloudService_referral_free.json @@ -0,0 +1,10 @@ +[ + { + "name": "ReferralMonthFree", + "type": "payment", + "driverClass": "bubble.cloud.payment.referralMonthFree.ReferralMonthFreePaymentDriver", + "driverConfig": {}, + "credentials": {}, + "template": true + } +] \ No newline at end of file diff --git a/bubble-server/src/test/resources/models/system/promotion_referral_free.json b/bubble-server/src/test/resources/models/system/promotion_referral_free.json new file mode 100644 index 00000000..4672f1d6 --- /dev/null +++ b/bubble-server/src/test/resources/models/system/promotion_referral_free.json @@ -0,0 +1,10 @@ +[ + { + "name": "ReferralMonthFree", + "cloud": "ReferralMonthFree", + "priority": 1, + "currency": "USD", + "maxValue": 1200, + "referral": true + } +] \ No newline at end of file diff --git a/bubble-server/src/test/resources/models/tests/promo/first_month_free.json b/bubble-server/src/test/resources/models/tests/promo/first_month_free.json index cbd55334..418a32d5 100644 --- a/bubble-server/src/test/resources/models/tests/promo/first_month_free.json +++ b/bubble-server/src/test/resources/models/tests/promo/first_month_free.json @@ -1,27 +1,14 @@ [ { - "comment": "create a user account", + "comment": "register a user account", "request": { - "uri": "users", - "method": "put", + "session": "new", + "uri": "auth/register", "entity": { "name": "test_user_1mo_free", "password": "password1!", "contact": {"type": "email", "info": "test_user_1mo_free@example.com"} } - } - }, - - { - "before": "sleep 22s", // wait for account objects to be created - "comment": "login as new user", - "request": { - "session": "new", - "uri": "auth/login", - "entity": { - "name": "test_user_1mo_free", - "password": "password1!" - } }, "response": { "store": "testAccount", @@ -31,6 +18,7 @@ }, { + "before": "sleep 10s", "comment": "as root, check email inbox for verification message", "request": { "session": "rootSession", @@ -377,7 +365,7 @@ }, { - "before": "fast_forward_and_bill 31d 20s", + "before": "fast_forward_and_bill 31d 30s", "comment": "fast-forward +31 days, verify a new bill exists for first accountPlan", "request": { "uri": "me/plans/{{accountPlan.uuid}}/bills" }, "response": { diff --git a/bubble-server/src/test/resources/models/tests/promo/referral_month_free.json b/bubble-server/src/test/resources/models/tests/promo/referral_month_free.json index 0637a088..f83445e7 100644 --- a/bubble-server/src/test/resources/models/tests/promo/referral_month_free.json +++ b/bubble-server/src/test/resources/models/tests/promo/referral_month_free.json @@ -1 +1,176 @@ -[] \ No newline at end of file +[ + { + "comment": "create a user account for the referring user", + "request": { + "uri": "users", + "method": "put", + "entity": { + "name": "test_user_referring_free", + "password": "password1!", + "contact": {"type": "email", "info": "test_user_referring_free@example.com"} + } + } + }, + + { + "before": "sleep 22s", // wait for account objects to be created + "comment": "login as referring user", + "request": { + "session": "new", + "uri": "auth/login", + "entity": { + "name": "test_user_referring_free", + "password": "password1!" + } + }, + "response": { + "store": "referringUser", + "sessionName": "referringUserSession", + "session": "token" + } + }, + + { + "comment": "as root, check email inbox for verification message for referring user", + "request": { + "session": "rootSession", + "uri": "debug/inbox/email/test_user_referring_free@example.com?type=request&action=verify&target=account" + }, + "response": { + "store": "emailInbox", + "check": [ + {"condition": "'{{json.[0].ctx.message.messageType}}' == 'request'"}, + {"condition": "'{{json.[0].ctx.message.action}}' == 'verify'"}, + {"condition": "'{{json.[0].ctx.message.target}}' == 'account'"} + ] + } + }, + + { + "comment": "as root, grant some referral codes to the referring user", + "request": { + "uri": "users/test_user_referring_free/referralCodes", + "method": "put", + "entity": { "count": 3 } + }, + "response": { + "store": "referralCodes", + "check": [ {"condition": "json.length === 3"} ] + } + }, + + { + "comment": "approve email verification request for referring user", + "request": { + "session": "referringUserSession", + "uri": "auth/approve/{{emailInbox.[0].ctx.confirmationToken}}", + "method": "post" + } + }, + + { + "comment": "as referring user, list referral codes, verify all codes are unused", + "request": { "uri": "me/referralCodes" }, + "response": { + "check": [ + {"condition": "json.length === 3"}, + {"condition": "json[0].getUsedBy() === null"}, + {"condition": "json[1].getUsedBy() === null"}, + {"condition": "json[2].getUsedBy() === null"} + ] + } + }, + + { + "comment": "register an account for the referred user, using one of the referral codes", + "request": { + "session": "new", + "uri": "auth/register", + "entity": { + "name": "test_user_referred_free", + "password": "password1!", + "contact": {"type": "email", "info": "test_user_referred_free@example.com"}, + "promoCode": "{{referralCodes.[0].name}}" + } + }, + "response": { + "store": "referredUser", + "sessionName": "referredUserSession", + "session": "token" + } + }, + + { + "before": "sleep 22s", // wait for account objects to be created + "comment": "as root, check email inbox for verification message", + "request": { + "session": "rootSession", + "uri": "debug/inbox/email/test_user_referred_free@example.com?type=request&action=verify&target=account" + }, + "response": { + "store": "emailInbox", + "check": [ + {"condition": "'{{json.[0].ctx.message.messageType}}' == 'request'"}, + {"condition": "'{{json.[0].ctx.message.action}}' == 'verify'"}, + {"condition": "'{{json.[0].ctx.message.target}}' == 'account'"} + ] + } + }, + + { + "comment": "as referred user, approve email verification request", + "request": { + "session": "referredUserSession", + "uri": "auth/approve/{{emailInbox.[0].ctx.confirmationToken}}", + "method": "post" + } + }, + + { + "comment": "as referred user, lookup payment methods, ensure ReferralMonthFree is present", + "request": { "uri": "me/paymentMethods" }, + "response": { + "check": [ + {"condition": "json.length === 1"}, + {"condition": "json[0].getPaymentMethodType().name() === 'promotional_credit'"}, + {"condition": "json[0].deleted() === false"} + ] + } + }, + + { + "comment": "as referring user, lookup payment methods, ensure ReferralMonthFree is present", + "request": { + "session": "referringUserSession", + "uri": "me/paymentMethods" + }, + "response": { + "check": [ + {"condition": "json.length === 1"}, + {"condition": "json[0].getPaymentMethodType().name() === 'promotional_credit'"}, + {"condition": "json[0].deleted() === false"} + ] + } + }, + + { + "comment": "get plans", + "request": { "uri": "plans" }, + "response": { + "store": "plans", + "check": [{"condition": "json.length >= 1"}] + } + }, + + { + "comment": "get payment methods, tokenize a credit card", + "request": { "uri": "paymentMethods" }, + "response": { + "store": "paymentMethods" + }, + "after": "stripe_tokenize_card" + } + + // start a network. we don't get the first month free because the user on the other end of + // the ReferralMonthFree has not made any payments +] \ No newline at end of file