From ab006a88b7973e4b2e4b6fd56e117fb71a53161c Mon Sep 17 00:00:00 2001 From: Jonathan Cobb Date: Fri, 14 Feb 2020 09:10:35 -0500 Subject: [PATCH] WIP. adding cancelAuthorization to payment drivers, add single-item get to PromotionsResource, add test stubs --- .../cloud/payment/PaymentDriverBase.java | 8 +++ .../cloud/payment/PaymentServiceDriver.java | 2 + .../delegate/DelegatedPaymentDriver.java | 11 +++ .../payment/stripe/StripePaymentDriver.java | 71 +++++++++++++++++++ .../bubble/model/bill/AccountPayment.java | 5 +- .../java/bubble/model/bill/Promotion.java | 14 ++-- .../model/cloud/notify/NotificationType.java | 1 + ...r_payment_driver_cancel_authorization.java | 23 ++++++ .../resources/bill/PromotionsResource.java | 15 +++- .../bubble/mock/MockStripePaymentDriver.java | 9 +++ ...rstMonthAndReferralMonthPromotionTest.java | 13 ++++ .../test/FirstMonthFreePromotionTest.java | 15 ++++ .../test/ReferralMonthFreePromotionTest.java | 13 ++++ .../resources/models/manifest-1mo-promo.json | 5 ++ .../models/system/cloudService_1mo_free.json | 10 +++ .../models/system/promotion_1mo_free.json | 7 ++ ...nth_and_multiple_referral_months_free.json | 1 + .../models/tests/promo/first_month_free.json | 1 + .../tests/promo/referral_month_free.json | 1 + pom.xml | 3 + 20 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 bubble-server/src/main/java/bubble/notify/payment/NotificationHandler_payment_driver_cancel_authorization.java create mode 100644 bubble-server/src/test/java/bubble/test/FirstMonthAndReferralMonthPromotionTest.java create mode 100644 bubble-server/src/test/java/bubble/test/FirstMonthFreePromotionTest.java create mode 100644 bubble-server/src/test/java/bubble/test/ReferralMonthFreePromotionTest.java create mode 100644 bubble-server/src/test/resources/models/manifest-1mo-promo.json create mode 100644 bubble-server/src/test/resources/models/system/cloudService_1mo_free.json create mode 100644 bubble-server/src/test/resources/models/system/promotion_1mo_free.json create mode 100644 bubble-server/src/test/resources/models/tests/promo/first_month_and_multiple_referral_months_free.json create mode 100644 bubble-server/src/test/resources/models/tests/promo/first_month_free.json create mode 100644 bubble-server/src/test/resources/models/tests/promo/referral_month_free.json diff --git a/bubble-server/src/main/java/bubble/cloud/payment/PaymentDriverBase.java b/bubble-server/src/main/java/bubble/cloud/payment/PaymentDriverBase.java index 31105cc7..78a4a5ee 100644 --- a/bubble-server/src/main/java/bubble/cloud/payment/PaymentDriverBase.java +++ b/bubble-server/src/main/java/bubble/cloud/payment/PaymentDriverBase.java @@ -63,6 +63,10 @@ public abstract class PaymentDriverBase extends CloudServiceDriverBase imp return true; } + @Override public boolean cancelAuthorization(BubblePlan plan, String accountPlanUuid, AccountPaymentMethod paymentMethod) { + return true; + } + @Override public boolean purchase(String accountPlanUuid, String paymentMethodUuid, String billUuid) { final AccountPlan accountPlan = getAccountPlan(accountPlanUuid); @@ -87,6 +91,10 @@ public abstract class PaymentDriverBase extends CloudServiceDriverBase imp return true; } + // If the current PaymentDriver is not for a promotional credit, + // then check for AccountPaymentMethods associated with promotional credits + // If we have one, use that payment driver instead. It may apply a partial payment. + final String chargeInfo; try { chargeInfo = charge(plan, accountPlan, paymentMethod, bill); diff --git a/bubble-server/src/main/java/bubble/cloud/payment/PaymentServiceDriver.java b/bubble-server/src/main/java/bubble/cloud/payment/PaymentServiceDriver.java index 75f05a2c..58ea9eb9 100644 --- a/bubble-server/src/main/java/bubble/cloud/payment/PaymentServiceDriver.java +++ b/bubble-server/src/main/java/bubble/cloud/payment/PaymentServiceDriver.java @@ -20,6 +20,8 @@ public interface PaymentServiceDriver extends CloudServiceDriver { boolean authorize(BubblePlan plan, String accountPlanUuid, AccountPaymentMethod paymentMethod); + boolean cancelAuthorization(BubblePlan plan, String accountPlanUuid, AccountPaymentMethod paymentMethod); + boolean purchase(String accountPlanUuid, String paymentMethodUuid, String billUuid); boolean refund(String accountPlanUuid); diff --git a/bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java b/bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java index c20f60b8..04adae08 100644 --- a/bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java +++ b/bubble-server/src/main/java/bubble/cloud/payment/delegate/DelegatedPaymentDriver.java @@ -67,6 +67,17 @@ public class DelegatedPaymentDriver extends DelegatedCloudServiceDriverBase impl return processResult(result); } + @Override public boolean cancelAuthorization(BubblePlan plan, String accountPlanUuid, AccountPaymentMethod paymentMethod) { + final BubbleNode delegate = getDelegateNode(); + final PaymentResult result = notificationService.notifySync(delegate, payment_driver_cancel_authorization, + new PaymentNotification() + .setCloud(cloud.getName()) + .setPlanUuid(plan.getUuid()) + .setAccountPlanUuid(accountPlanUuid) + .setPaymentMethodUuid(paymentMethod.getUuid())); + return processResult(result); + } + @Override public boolean purchase(String accountPlanUuid, String paymentMethodUuid, String billUuid) { diff --git a/bubble-server/src/main/java/bubble/cloud/payment/stripe/StripePaymentDriver.java b/bubble-server/src/main/java/bubble/cloud/payment/stripe/StripePaymentDriver.java index 6052d047..1cb817d7 100644 --- a/bubble-server/src/main/java/bubble/cloud/payment/stripe/StripePaymentDriver.java +++ b/bubble-server/src/main/java/bubble/cloud/payment/stripe/StripePaymentDriver.java @@ -212,6 +212,77 @@ public class StripePaymentDriver extends PaymentDriverBase refundParams = new LinkedHashMap<>();; + final Refund refund; + final RedisService refundCache = getRefundCache(); + try { + final Long price = plan.getPrice(); + if (price <= 0) throw invalidEx("err.purchase.priceInvalid"); + + final String chargeId = authCache.get(authCacheKey); + if (chargeId == null) throw invalidEx("err.purchase.authNotFound"); + + final String refunded = refundCache.get(chargeId); + if (refunded != null) { + // already refunded, nothing to do + log.info("refund: already refunded: "+refunded); + return true; + } + + refundParams.put("charge", chargeId); + refundParams.put("amount", price); + refund = Refund.create(refundParams); + if (refund.getStatus() == null) { + final String msg = "cancelAuthorization: no status returned for Charge, accountPlan=" + accountPlanUuid + " with paymentMethod=" + paymentMethodUuid; + log.error(msg); + throw invalidEx("err.refund.unknownError", msg); + } else { + final String msg; + switch (refund.getStatus()) { + case "succeeded": + log.info("cancelAuthorization: authorization of "+price+" successful cancelled"); + refundCache.set(chargeId, refund.getId(), EX, REFUND_CACHE_DURATION); + return true; + + case "pending": + msg = "cancelAuthorization: status='pending' (expected 'succeeded'), accountPlan=" + accountPlanUuid + " with paymentMethod=" + paymentMethodUuid; + log.error(msg); + throw invalidEx("err.refund.refundPendingError", msg); + + default: + msg = "cancelAuthorization: status='"+refund.getStatus()+"' (expected 'succeeded'), accountPlan=" + accountPlanUuid + " with paymentMethod=" + paymentMethodUuid; + log.error(msg); + throw invalidEx("err.refund.refundFailedError", msg); + } + } + + } catch (CardException e) { + // The card has been declined + final String msg = "cancelAuthorization: CardException for accountPlan=" + accountPlanUuid + " with paymentMethod=" + paymentMethodUuid + ": requestId=" + e.getRequestId() + ", code="+e.getCode()+", declineCode="+e.getDeclineCode()+", error=" + e.toString(); + log.error(msg); + throw invalidEx("err.purchase.cardError", msg); + + } catch (SimpleViolationException e) { + throw e; + + } catch (StripeException e) { + final String msg = "cancelAuthorization: "+e.getClass().getSimpleName()+" for accountPlan=" + accountPlanUuid + " with paymentMethod=" + paymentMethodUuid + ": requestId=" + e.getRequestId() + ", code="+e.getCode()+", error=" + e.toString(); + log.error(msg); + throw invalidEx("err.purchase.cardProcessingError", msg); + + } catch (Exception e) { + final String msg = "cancelAuthorization: "+e.getClass().getSimpleName()+" for accountPlan=" + accountPlanUuid + " with paymentMethod=" + paymentMethodUuid + ": error=" + e.toString(); + log.error(msg); + throw invalidEx("err.purchase.cardUnknownError", msg); + } + } + @Override protected String charge(BubblePlan plan, AccountPlan accountPlan, AccountPaymentMethod paymentMethod, diff --git a/bubble-server/src/main/java/bubble/model/bill/AccountPayment.java b/bubble-server/src/main/java/bubble/model/bill/AccountPayment.java index c9ef90c7..f56bbf95 100644 --- a/bubble-server/src/main/java/bubble/model/bill/AccountPayment.java +++ b/bubble-server/src/main/java/bubble/model/bill/AccountPayment.java @@ -22,7 +22,10 @@ import static org.cobbzilla.wizard.model.crypto.EncryptedTypes.*; @ECType(root=true) @ECTypeCreate(method="DISABLED") @ECTypeURIs(listFields={"account", "paymentMethod", "amount"}) @ECIndexes({ - @ECIndex(name="account_payment_uniq_bill_type_success", unique=true, of={"bill", "type"}, where="status = 'success'") + @ECIndex(name="account_payment_uniq_bill_type_payment_method_success", + unique=true, + of={"bill", "type", "paymentMethod"}, + where="status = 'success'") }) @Entity @NoArgsConstructor @Accessors(chain=true) public class AccountPayment extends IdentifiableBase implements HasAccountNoName { diff --git a/bubble-server/src/main/java/bubble/model/bill/Promotion.java b/bubble-server/src/main/java/bubble/model/bill/Promotion.java index a472b1ed..51642f5f 100644 --- a/bubble-server/src/main/java/bubble/model/bill/Promotion.java +++ b/bubble-server/src/main/java/bubble/model/bill/Promotion.java @@ -21,12 +21,12 @@ import static org.cobbzilla.util.daemon.ZillaRuntime.now; import static org.cobbzilla.util.reflect.ReflectionUtil.copy; @ECType(root=true) -@ECTypeURIs(baseURI=PROMOTIONS_ENDPOINT, listFields={"name", "enabled", "start", "end"}) +@ECTypeURIs(baseURI=PROMOTIONS_ENDPOINT, listFields={"name", "priority", "enabled", "validFrom", "validTo", "code", "referral"}) @Entity @NoArgsConstructor @Accessors(chain=true) public class Promotion extends IdentifiableBase implements NamedEntity, HasPriority { - public static final String[] UPDATE_FIELDS = {"enabled", "start", "end"}; - public static final String[] CREATE_FIELDS = ArrayUtil.append(UPDATE_FIELDS, "name", "referral"); + public static final String[] UPDATE_FIELDS = {"priority", "enabled", "validFrom", "validTo"}; + public static final String[] CREATE_FIELDS = ArrayUtil.append(UPDATE_FIELDS, "name", "code", "referral"); public Promotion (Promotion other) { copy(this, other, CREATE_FIELDS); } @@ -55,12 +55,12 @@ public class Promotion extends IdentifiableBase implements NamedEntity, HasPrior public boolean enabled () { return enabled == null || enabled; } @ECSearchable @ECField(index=60) - @ECIndex @Getter @Setter private Long start; - public boolean hasStarted () { return start == null || start > now(); } + @ECIndex @Getter @Setter private Long validFrom; + public boolean hasStarted () { return validFrom == null || validFrom > now(); } @ECSearchable @ECField(index=70) - @ECIndex @Getter @Setter private Long end; - public boolean hasEnded () { return end != null && end > now(); } + @ECIndex @Getter @Setter private Long validTo; + public boolean hasEnded () { return validTo != null && validTo > now(); } public boolean active () { return enabled() && hasStarted() && !hasEnded(); } public boolean inactive () { return !active(); } diff --git a/bubble-server/src/main/java/bubble/model/cloud/notify/NotificationType.java b/bubble-server/src/main/java/bubble/model/cloud/notify/NotificationType.java index f81994db..1be54bd6 100644 --- a/bubble-server/src/main/java/bubble/model/cloud/notify/NotificationType.java +++ b/bubble-server/src/main/java/bubble/model/cloud/notify/NotificationType.java @@ -92,6 +92,7 @@ public enum NotificationType { payment_driver_validate (PaymentValidationResult.class), payment_driver_claim (PaymentValidationResult.class), payment_driver_authorize (PaymentResult.class), + payment_driver_cancel_authorization (PaymentResult.class), payment_driver_purchase (PaymentResult.class), payment_driver_refund (PaymentResult.class), payment_driver_response (true); diff --git a/bubble-server/src/main/java/bubble/notify/payment/NotificationHandler_payment_driver_cancel_authorization.java b/bubble-server/src/main/java/bubble/notify/payment/NotificationHandler_payment_driver_cancel_authorization.java new file mode 100644 index 00000000..ced79ce2 --- /dev/null +++ b/bubble-server/src/main/java/bubble/notify/payment/NotificationHandler_payment_driver_cancel_authorization.java @@ -0,0 +1,23 @@ +package bubble.notify.payment; + +import bubble.cloud.payment.PaymentServiceDriver; +import bubble.dao.bill.AccountPaymentMethodDAO; +import bubble.dao.bill.BubblePlanDAO; +import bubble.model.bill.AccountPaymentMethod; +import bubble.model.bill.BubblePlan; +import bubble.model.cloud.CloudService; +import org.springframework.beans.factory.annotation.Autowired; + +public class NotificationHandler_payment_driver_cancel_authorization extends NotificationHandler_payment_driver { + + @Autowired private BubblePlanDAO planDAO; + @Autowired private AccountPaymentMethodDAO paymentMethodDAO; + + @Override public boolean handlePaymentRequest(PaymentNotification paymentNotification, CloudService paymentService) { + final BubblePlan plan = planDAO.findByUuid(paymentNotification.getPlanUuid()); + final AccountPaymentMethod paymentMethod = paymentMethodDAO.findByUuid(paymentNotification.getPaymentMethodUuid()); + final PaymentServiceDriver paymentDriver = paymentService.getPaymentDriver(configuration); + return paymentDriver.cancelAuthorization(plan, paymentNotification.getAccountPlanUuid(), paymentMethod); + } + +} diff --git a/bubble-server/src/main/java/bubble/resources/bill/PromotionsResource.java b/bubble-server/src/main/java/bubble/resources/bill/PromotionsResource.java index 7e1efdbc..cb0774a2 100644 --- a/bubble-server/src/main/java/bubble/resources/bill/PromotionsResource.java +++ b/bubble-server/src/main/java/bubble/resources/bill/PromotionsResource.java @@ -11,17 +11,20 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.glassfish.jersey.server.ContainerRequest; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.Response; +import static bubble.ApiConstants.PROMOTIONS_ENDPOINT; import static org.cobbzilla.util.http.HttpContentTypes.APPLICATION_JSON; import static org.cobbzilla.wizard.resources.ResourceUtil.*; @Consumes(APPLICATION_JSON) @Produces(APPLICATION_JSON) -@Slf4j +@Path(PROMOTIONS_ENDPOINT) +@Service @Slf4j public class PromotionsResource { @Autowired private CloudServiceDAO cloudDAO; @@ -37,6 +40,16 @@ public class PromotionsResource { return ok(promotionDAO.findEnabledAndNoCodeOrWithCode(code)); } + @GET @Path("/{id}") + public Response findPromo(@Context ContainerRequest ctx, + @PathParam("id") String id) { + final Account caller = userPrincipal(ctx); + if (!caller.admin()) return forbidden(); + if (!caller.getUuid().equals(getFirstAdmin().getUuid())) return forbidden(); + + return ok(promotionDAO.findById(id)); + } + @PUT public Response createPromo(@Context ContainerRequest ctx, Promotion request) { diff --git a/bubble-server/src/test/java/bubble/mock/MockStripePaymentDriver.java b/bubble-server/src/test/java/bubble/mock/MockStripePaymentDriver.java index 5d92b6cc..90650708 100644 --- a/bubble-server/src/test/java/bubble/mock/MockStripePaymentDriver.java +++ b/bubble-server/src/test/java/bubble/mock/MockStripePaymentDriver.java @@ -29,6 +29,15 @@ public class MockStripePaymentDriver extends StripePaymentDriver { } } + @Override public boolean cancelAuthorization(BubblePlan plan, String accountPlanUuid, AccountPaymentMethod paymentMethod) { + final String err = error.get(); + if (err != null && (err.equals("cancelAuthorization") || err.equals("all"))) { + throw invalidEx("err.purchase.authNotFound", "mock: error flag="+err); + } else { + return super.cancelAuthorization(plan, accountPlanUuid, paymentMethod); + } + } + @Override protected String charge(BubblePlan plan, AccountPlan accountPlan, AccountPaymentMethod paymentMethod, Bill bill) { final String err = error.get(); if (err != null && (err.equals("charge") || err.equals("all"))) { diff --git a/bubble-server/src/test/java/bubble/test/FirstMonthAndReferralMonthPromotionTest.java b/bubble-server/src/test/java/bubble/test/FirstMonthAndReferralMonthPromotionTest.java new file mode 100644 index 00000000..fedf9da7 --- /dev/null +++ b/bubble-server/src/test/java/bubble/test/FirstMonthAndReferralMonthPromotionTest.java @@ -0,0 +1,13 @@ +package bubble.test; + +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; + +@Slf4j +public class FirstMonthAndReferralMonthPromotionTest extends PaymentTestBase { + + @Test public void testFirstMonthAndMultipleReferralMonthsFree () throws Exception { + modelTest("promo/first_month_and_multiple_referral_months_free"); + } + +} diff --git a/bubble-server/src/test/java/bubble/test/FirstMonthFreePromotionTest.java b/bubble-server/src/test/java/bubble/test/FirstMonthFreePromotionTest.java new file mode 100644 index 00000000..bca2f52f --- /dev/null +++ b/bubble-server/src/test/java/bubble/test/FirstMonthFreePromotionTest.java @@ -0,0 +1,15 @@ +package bubble.test; + +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; + +@Slf4j +public class FirstMonthFreePromotionTest extends PaymentTestBase { + + @Override protected String getManifest() { return "manifest-1mo-promo"; } + + @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 new file mode 100644 index 00000000..1f8f303b --- /dev/null +++ b/bubble-server/src/test/java/bubble/test/ReferralMonthFreePromotionTest.java @@ -0,0 +1,13 @@ +package bubble.test; + +import lombok.extern.slf4j.Slf4j; +import org.junit.Test; + +@Slf4j +public class ReferralMonthFreePromotionTest extends PaymentTestBase { + + @Test public void testReferralMonthFree () throws Exception { + modelTest("promo/referral_month_free"); + } + +} diff --git a/bubble-server/src/test/resources/models/manifest-1mo-promo.json b/bubble-server/src/test/resources/models/manifest-1mo-promo.json new file mode 100644 index 00000000..967ac880 --- /dev/null +++ b/bubble-server/src/test/resources/models/manifest-1mo-promo.json @@ -0,0 +1,5 @@ +[ + "manifest-test", + "system/cloudService_1mo_free", + "system/promotion_1mo_free" +] \ No newline at end of file diff --git a/bubble-server/src/test/resources/models/system/cloudService_1mo_free.json b/bubble-server/src/test/resources/models/system/cloudService_1mo_free.json new file mode 100644 index 00000000..2462934d --- /dev/null +++ b/bubble-server/src/test/resources/models/system/cloudService_1mo_free.json @@ -0,0 +1,10 @@ +[ + { + "name": "FirstMonthFree", + "type": "payment", + "driverClass": "bubble.cloud.payment.firstMonthFree.FirstMonthFreePaymentDriver", + "driverConfig": {}, + "credentials": {}, + "template": true + } +] \ No newline at end of file diff --git a/bubble-server/src/test/resources/models/system/promotion_1mo_free.json b/bubble-server/src/test/resources/models/system/promotion_1mo_free.json new file mode 100644 index 00000000..a506d4b9 --- /dev/null +++ b/bubble-server/src/test/resources/models/system/promotion_1mo_free.json @@ -0,0 +1,7 @@ +[ + { + "name": "FirstMonthFree", + "cloud": "FirstMonthFree", + "priority": 1 + } +] \ No newline at end of file diff --git a/bubble-server/src/test/resources/models/tests/promo/first_month_and_multiple_referral_months_free.json b/bubble-server/src/test/resources/models/tests/promo/first_month_and_multiple_referral_months_free.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/bubble-server/src/test/resources/models/tests/promo/first_month_and_multiple_referral_months_free.json @@ -0,0 +1 @@ +[] \ 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 new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/bubble-server/src/test/resources/models/tests/promo/first_month_free.json @@ -0,0 +1 @@ +[] \ No newline at end of file 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 new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/bubble-server/src/test/resources/models/tests/promo/referral_month_free.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/pom.xml b/pom.xml index 35318c8c..18441186 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,9 @@ This code is available under the GNU Affero General Public License, version 3: h bubble.test.AuthTest bubble.test.PaymentTest bubble.test.RecurringBillingTest + bubble.test.FirstMonthFreePromotionTest + bubble.test.ReferralMonthFreePromotionTest + bubble.test.FirstMonthAndReferralMonthPromotionTest bubble.test.DriverTest bubble.test.ProxyTest bubble.test.TrafficAnalyticsTest