diff --git a/acceptance-tests/script/wait.sh b/acceptance-tests/script/wait.sh index b4254b13d..aed453dc1 100755 --- a/acceptance-tests/script/wait.sh +++ b/acceptance-tests/script/wait.sh @@ -32,6 +32,7 @@ java -cp '/acceptance-tests.jar' org.junit.runner.JUnitCore \ org.ostelco.at.jersey.BundlesAndPurchasesTest \ org.ostelco.at.jersey.SourceTest \ org.ostelco.at.jersey.PurchaseTest \ + org.ostelco.at.jersey.PlanTest \ org.ostelco.at.jersey.AnalyticsTest \ org.ostelco.at.jersey.ConsentTest \ org.ostelco.at.jersey.ProfileTest \ diff --git a/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt b/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt index 0ece77201..779095122 100644 --- a/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt +++ b/acceptance-tests/src/main/kotlin/org/ostelco/at/jersey/Tests.kt @@ -1191,10 +1191,12 @@ class PlanTest { .amount(100) .currency("nok") val plan = Plan() - .name("test") + .name("PLAN_1_NOK_PER_DAY") .price(price) .interval(Plan.IntervalEnum.DAY) .intervalCount(1) + .properties(emptyMap()) + .presentation(emptyMap()) post { path = "/plans" @@ -1239,6 +1241,8 @@ class PlanTest { .price(price) .interval(Plan.IntervalEnum.DAY) .intervalCount(1) + .properties(emptyMap()) + .presentation(emptyMap()) try { // Create subscriber with payment source. diff --git a/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/HoustonResources.kt b/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/HoustonResources.kt index a7299eda8..180782f31 100644 --- a/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/HoustonResources.kt +++ b/admin-api/src/main/kotlin/org/ostelco/prime/admin/api/HoustonResources.kt @@ -196,9 +196,14 @@ class ProfilesResource { @Produces("application/json") fun getPlans(@PathParam("email") email: String): Response { return storage.getPlans(email).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it)) }) - .build() + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to fetch plans", + ApiErrorCode.FAILED_TO_FETCH_PLANS_FOR_SUBSCRIBER, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.OK).entity(asJson(it)) } + ).build() } /** @@ -210,10 +215,15 @@ class ProfilesResource { fun attachPlan(@PathParam("email") email: String, @PathParam("planId") planId: String, @QueryParam("trial_end") trialEnd: Long): Response { - return storage.attachPlan(email, planId, trialEnd).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.CREATED) }) - .build() + return storage.subscribeToPlan(email, planId, trialEnd).fold( + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to store subscription", + ApiErrorCode.FAILED_TO_STORE_SUBSCRIPTION, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.CREATED) } + ).build() } /** @@ -224,10 +234,15 @@ class ProfilesResource { @Produces("application/json") fun detachPlan(@PathParam("email") email: String, @PathParam("planId") planId: String): Response { - return storage.detachPlan(email, planId).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK) }) - .build() + return storage.unsubscribeFromPlan(email, planId).fold( + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to remove subscription", + ApiErrorCode.FAILED_TO_REMOVE_SUBSCRIPTION, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.OK) } + ).build() } } @@ -430,6 +445,7 @@ class NotifyResource { @Path("/plans") class PlanResource() { + private val logger by getLogger() private val storage by lazy { getResource() } /** @@ -441,9 +457,14 @@ class PlanResource() { fun get(@NotNull @PathParam("planId") planId: String): Response { return storage.getPlan(planId).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it)) }) - .build() + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to fetch plan", + ApiErrorCode.FAILED_TO_FETCH_PLAN, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.OK).entity(asJson(it)) } + ).build() } /** @@ -452,11 +473,16 @@ class PlanResource() { @POST @Produces("application/json") @Consumes("application/json") - fun create(plan: Plan) : Response { + fun create(plan: Plan): Response { return storage.createPlan(plan).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError))}, - { Response.status(Response.Status.CREATED).entity(asJson(it))}) - .build() + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to store plan", + ApiErrorCode.FAILED_TO_STORE_PLAN, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.CREATED).entity(asJson(it)) } + ).build() } /** @@ -469,8 +495,13 @@ class PlanResource() { fun delete(@NotNull @PathParam("planId") planId: String) : Response { return storage.deletePlan(planId).fold( - { apiError -> Response.status(apiError.status).entity(asJson(apiError)) }, - { Response.status(Response.Status.OK).entity(asJson(it))}) - .build() + { + val err = ApiErrorMapper.mapStorageErrorToApiError("Failed to remove plan", + ApiErrorCode.FAILED_TO_REMOVE_PLAN, + it) + Response.status(err.status).entity(asJson(err)) + }, + { Response.status(Response.Status.OK).entity(asJson(it))} + ).build() } } diff --git a/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt b/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt index cc75e4a42..bec915071 100644 --- a/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt +++ b/model/src/main/kotlin/org/ostelco/prime/model/Entities.kt @@ -137,8 +137,8 @@ data class Plan( val price: Price, val interval: String, val intervalCount: Long = 1L, - val planId: String = "", - val productId: String = "") : HasId { + val properties: Map = emptyMap(), + val presentation: Map = emptyMap()) : HasId { override val id: String @JsonIgnore diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt index f9b6b1a6a..ecda02612 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Neo4jStore.kt @@ -8,9 +8,6 @@ import arrow.instances.either.monad.monad import arrow.typeclasses.binding import org.neo4j.driver.v1.Transaction import org.ostelco.prime.analytics.AnalyticsService -import org.ostelco.prime.apierror.ApiError -import org.ostelco.prime.apierror.ApiErrorCode -import org.ostelco.prime.apierror.BadRequestError import org.ostelco.prime.getLogger import org.ostelco.prime.model.* import org.ostelco.prime.module.getResource @@ -30,7 +27,8 @@ import java.util.stream.Collectors enum class Relation { HAS_SUBSCRIPTION, // (Subscriber) -[HAS_SUBSCRIPTION]-> (Subscription) HAS_BUNDLE, // (Subscriber) -[HAS_BUNDLE]-> (Bundle) - HAS_PLAN, // (Subscriber> -[HAS_PLAN]-> (Plan) + SUBSCRIBES_TO_PLAN, // (Subscriber) -[SUBSCRIBES_TO_PLAN]-> (Plan) + HAS_PRODUCT, // (Plan) -[HAS_PRODUCT]-> (Product) LINKED_TO_BUNDLE, // (Subscription) -[LINKED_TO_BUNDLE]-> (Bundle) PURCHASED, // (Subscriber) -[PURCHASED]-> (Product) REFERRED, // (Subscriber) -[REFERRED]-> (Subscriber) @@ -114,12 +112,12 @@ object Neo4jStoreSingleton : GraphStore { dataClass = Void::class.java) private val referredRelationStore = RelationStore(referredRelation) - private val hasPlanRelation = RelationType( - relation = Relation.HAS_PLAN, + private val subscribesToPlanRelation = RelationType( + relation = Relation.SUBSCRIBES_TO_PLAN, from = subscriberEntity, to = planEntity, dataClass = Void::class.java) - private val hasPlanRelationStore = UniqueRelationStore(hasPlanRelation) + private val subscribesToPlanRelationStore = UniqueRelationStore(subscribesToPlanRelation) private val subscriberStateRelation = RelationType( relation = Relation.SUBSCRIBER_STATE, @@ -135,6 +133,14 @@ object Neo4jStoreSingleton : GraphStore { dataClass = Void::class.java) private val scanInformationRelationStore = UniqueRelationStore(scanInformationRelation) + private val planProductRelation = RelationType( + relation = Relation.HAS_PRODUCT, + from = planEntity, + to = productEntity, + dataClass = Void::class.java) + private val planProductRelationStore = UniqueRelationStore(planProductRelation) + + // ------------- // Client Store // ------------- @@ -359,22 +365,76 @@ object Neo4jStoreSingleton : GraphStore { subscriberId: String, sku: String, sourceId: String?, - saveCard: Boolean): Either = writeTransaction { + saveCard: Boolean): Either { + + return getProduct(subscriberId, sku).fold( + { + Either.left(org.ostelco.prime.paymentprocessor.core.NotFoundError("Product ${sku} is unavailable", + error = it)) + }, + { + /* TODO: Complete support for 'product-class' and store plans as a + 'product' of product-class: 'plan'. */ + return if (it.properties.containsKey("productType") && it.properties["productType"].equals("plan", true)) + purchasePlan(subscriberId, it, sourceId, saveCard) + else + purchaseProduct(subscriberId, it, sourceId, saveCard) + } + ) + } + + private fun purchasePlan(subscriberId: String, + product: Product, + sourceId: String?, + saveCard: Boolean): Either = writeTransaction { IO { Either.monad().binding { - val product = getProduct(subscriberId, sku, transaction) - // If we can't find the product, return not-found - .mapLeft { org.ostelco.prime.paymentprocessor.core.NotFoundError("Product unavailable") } + val profileInfo = fetchOrCreatePaymentProfile(subscriberId) .bind() + val paymentCustomerId = profileInfo.id + + if (sourceId != null) { + val sourceDetails = paymentProcessor.getSavedSources(paymentCustomerId) + .mapLeft { + BadGatewayError("Failed to fetch sources for subscriber ${subscriberId}", + error = it) + }.bind() + if (!sourceDetails.any { sourceDetailsInfo -> sourceDetailsInfo.id == sourceId }) { + paymentProcessor.addSource(paymentCustomerId, sourceId) + .finallyDo(transaction) { + removePaymentSource(saveCard, paymentCustomerId, it.id) + }.bind().id + } + } + subscribeToPlan(subscriberId, product.id) + .mapLeft { + org.ostelco.prime.paymentprocessor.core.BadGatewayError("Failed to subscribe ${subscriberId} to plan ${product.id}", + error = it) + } + .flatMap { + Either.right(ProductInfo(product.id)) + }.bind() + }.fix() + }.unsafeRunSync() + .ifFailedThenRollback(transaction) + } + + private fun purchaseProduct(subscriberId: String, + product: Product, + sourceId: String?, + saveCard: Boolean): Either = writeTransaction { + IO { + Either.monad().binding { val profileInfo = fetchOrCreatePaymentProfile(subscriberId).bind() val paymentCustomerId = profileInfo.id var addedSourceId: String? = null if (sourceId != null) { // First fetch all existing saved sources val sourceDetails = paymentProcessor.getSavedSources(paymentCustomerId) - // If we can't find the product, return not-found - .mapLeft { org.ostelco.prime.paymentprocessor.core.BadGatewayError("Failed to fetch sources for user", it.description) } - .bind() + .mapLeft { + BadGatewayError("Failed to fetch sources for user", + error = it) + }.bind() addedSourceId = sourceId // If the sourceId is not found in existing list of saved sources, // then save the source @@ -388,28 +448,33 @@ object Neo4jStoreSingleton : GraphStore { } //TODO: If later steps fail, then refund the authorized charge val chargeId = paymentProcessor.authorizeCharge(paymentCustomerId, addedSourceId, product.price.amount, product.price.currency) - .mapLeft { apiError -> - logger.error("failed to authorize purchase for paymentCustomerId $paymentCustomerId, sourceId $addedSourceId, sku $sku") - apiError + .mapLeft { + logger.error("Failed to authorize purchase for paymentCustomerId $paymentCustomerId, sourceId $addedSourceId, sku ${product.sku}") + it }.linkReversalActionToTransaction(transaction) { chargeId -> paymentProcessor.refundCharge(chargeId, product.price.amount, product.price.currency) - logger.error(NOTIFY_OPS_MARKER, "Failed to refund charge for paymentCustomerId $paymentCustomerId, chargeId $chargeId.\nFix this in Stripe dashboard.") + logger.error(NOTIFY_OPS_MARKER, + "Failed to refund charge for paymentCustomerId $paymentCustomerId, chargeId $chargeId.\nFix this in Stripe dashboard.") }.bind() + val purchaseRecord = PurchaseRecord( id = chargeId, product = product, timestamp = Instant.now().toEpochMilli(), msisdn = "") createPurchaseRecordRelation(subscriberId, purchaseRecord, transaction) - .mapLeft { storeError -> - logger.error("failed to save purchase record, for paymentCustomerId $paymentCustomerId, chargeId $chargeId, payment will be unclaimed in Stripe") - BadGatewayError(storeError.message) + .mapLeft { + logger.error("Failed to save purchase record, for paymentCustomerId $paymentCustomerId, chargeId $chargeId, payment will be unclaimed in Stripe") + BadGatewayError("Failed to save purchase record", + error = it) }.bind() + //TODO: While aborting transactions, send a record with "reverted" status analyticsReporter.reportPurchaseInfo(purchaseRecord, subscriberId, "success") - ocs.topup(subscriberId, sku) - .mapLeft { BadGatewayError("Failed to perform topup", it) } - .bind() + ocs.topup(subscriberId, product.sku) + .mapLeft { + BadGatewayError("Failed to perform topup", message = it) + }.bind() // Even if the "capture charge operation" failed, we do not want to rollback. // In that case, we just want to log it at error level. // These transactions can then me manually changed before they are auto rollback'ed in 'X' days. @@ -424,6 +489,7 @@ object Neo4jStoreSingleton : GraphStore { }.unsafeRunSync() .ifFailedThenRollback(transaction) } + // << END private fun removePaymentSource(saveCard: Boolean, paymentCustomerId: String, sourceId: String) { @@ -431,9 +497,9 @@ object Neo4jStoreSingleton : GraphStore { // These saved sources can then me manually removed. if (!saveCard) { paymentProcessor.removeSource(paymentCustomerId, sourceId) - .mapLeft { paymentError -> + .mapLeft { logger.error("Failed to remove card, for customerId $paymentCustomerId, sourceId $sourceId") - paymentError + it } } } @@ -463,6 +529,7 @@ object Neo4jStoreSingleton : GraphStore { } } } + // // Referrals // @@ -682,8 +749,7 @@ object Neo4jStoreSingleton : GraphStore { MATCH (subscriber:${subscriberEntity.name})-[:${purchaseRecordRelation.relation.name}]->(product:${productEntity.name}) WHERE product.`price/amount` > 0 RETURN count(subscriber) AS count - """.trimIndent(), - transaction) { result -> + """.trimIndent(), transaction) { result -> result.single().get("count").asLong() } } @@ -692,102 +758,111 @@ object Neo4jStoreSingleton : GraphStore { // For plans and subscriptions // - override fun getPlan(planId: String): Either = readTransaction { - plansStore.get(planId, transaction).bimap( - { - org.ostelco.prime.apierror.NotFoundError("Plan ${planId} not found", - ApiErrorCode.FAILED_TO_FETCH_PLAN) - }, - { it } - ) + override fun getPlan(planId: String): Either = readTransaction { + plansStore.get(planId, transaction) } - override fun getPlans(subscriberId: String): Either> = readTransaction { - hasPlanRelationStore.get(subscriberId, transaction).bimap( - { - org.ostelco.prime.apierror.NotFoundError("No plans found for ${subscriberId}", - ApiErrorCode.FAILED_TO_FETCH_PLANS_FOR_SUBSCRIBER) - }, - { it } - ) + override fun getPlans(subscriberId: String): Either> = readTransaction { + subscribesToPlanRelationStore.get(subscriberId, transaction) } - override fun createPlan(plan: Plan): Either = writeTransaction { + override fun createPlan(plan: Plan): Either = writeTransaction { IO { - Either.monad().binding { + Either.monad().binding { + + productStore.get(plan.id, transaction) + .fold( + { Either.right(Unit) }, + { + Either.left(AlreadyExistsError(type = productEntity.name, id = "Failed to find product associated with plan ${plan.id}")) + } + ).bind() plansStore.get(plan.id, transaction) .fold( { Either.right(Unit) }, { - Either.left(BadRequestError("Plan ${plan.id} alredy exists", - ApiErrorCode.FAILED_TO_STORE_PLAN)) + Either.left(AlreadyExistsError(type = planEntity.name, id = "Failed to find plan ${plan.id}")) } ).bind() + val productInfo = paymentProcessor.createProduct(plan.id) - .mapLeft { err -> - BadRequestError("Failed to create product ${plan.id}", - ApiErrorCode.FAILED_TO_STORE_PLAN, err) + .mapLeft { + NotCreatedError(type = planEntity.name, id = "Failed to create plan ${plan.id}", + error = it) }.linkReversalActionToTransaction(transaction) { paymentProcessor.removeProduct(it.id) }.bind() val planInfo = paymentProcessor.createPlan(productInfo.id, plan.price.amount, plan.price.currency, PaymentProcessor.Interval.valueOf(plan.interval.toUpperCase()), plan.intervalCount) - .mapLeft { err -> - BadRequestError("Failed to create ${plan.id}", - ApiErrorCode.FAILED_TO_STORE_PLAN, err) + .mapLeft { + NotCreatedError(type = planEntity.name, id = "Failed to create plan ${plan.id}", + error = it) }.linkReversalActionToTransaction(transaction) { paymentProcessor.removePlan(it.id) }.bind() - plansStore.create(plan.copy(planId = planInfo.id, productId = productInfo.id), transaction) - .mapLeft { err -> - BadRequestError("Failed to create ${plan.id}", - ApiErrorCode.FAILED_TO_STORE_PLAN, - err) - }.bind() + + /* The associated product to the plan. Note that: + sku - name of the plan + property value 'productType' is set to "plan" + TODO: Complete support for 'product-class' and merge 'plan' and 'product' objects + into one object differentiated by 'product-class'. */ + val product = Product(sku = plan.id, price = plan.price, + properties = plan.properties + mapOf( + "productType" to "plan", + "interval" to plan.interval, + "intervalCount" to plan.intervalCount.toString()), + presentation = plan.presentation) + + /* Propagates errors from lower layer if any. */ + productStore.create(product, transaction) + .bind() + plansStore.create(plan.copy(properties = plan.properties.plus(mapOf( + "planId" to planInfo.id, + "productId" to productInfo.id))), transaction) + .bind() + planProductRelationStore.create(plan.id, product.id, transaction) + .bind() plansStore.get(plan.id, transaction) - .mapLeft { err -> - BadRequestError("Failed to create ${plan.id}", - ApiErrorCode.FAILED_TO_STORE_PLAN, - err) - }.bind() + .bind() }.fix() }.unsafeRunSync() .ifFailedThenRollback(transaction) } - override fun deletePlan(planId: String): Either = writeTransaction { + override fun deletePlan(planId: String): Either = writeTransaction { IO { - Either.monad().binding { + Either.monad().binding { val plan = plansStore.get(planId, transaction) + .bind() + /* The name of the product is the same as the name of the corresponding plan. */ + productStore.get(planId, transaction) + .bind() + planProductRelationStore.get(plan.id, transaction) + .bind() + + /* Not removing the product due to purchase references. */ + + /* Removing the plan will remove the plan itself and all relations going to it. */ + plansStore.delete(plan.id, transaction) + .bind() + + /* Lookup in payment backend will fail if no value found for 'planId'. */ + paymentProcessor.removePlan(plan.properties.getOrDefault("planId", "missing")) .mapLeft { - org.ostelco.prime.apierror.NotFoundError("Plan ${planId} does not exists", - ApiErrorCode.FAILED_TO_REMOVE_PLAN) - }.bind() - plansStore.delete(planId, transaction) - .mapLeft { err -> - BadRequestError("Failed to remove plan", - ApiErrorCode.FAILED_TO_REMOVE_PLAN, - err) - }.flatMap { - Either.right(Unit) - }.bind() - paymentProcessor.removePlan(plan.planId) - .mapLeft { err -> - BadRequestError("Failed to remove plan ${planId}", - ApiErrorCode.FAILED_TO_REMOVE_PLAN, - err) + NotDeletedError(type = planEntity.name, id = "Failed to delete ${plan.id}", + error = it) }.linkReversalActionToTransaction(transaction) { - // (Nothing to do.) + /* (Nothing to do.) */ }.flatMap { Either.right(Unit) }.bind() - paymentProcessor.removeProduct(plan.productId) - .mapLeft { err -> - BadRequestError("Failed to remove plan ${planId}", - ApiErrorCode.FAILED_TO_REMOVE_PLAN, - err) + /* Lookup in payment backend will fail if no value found for 'productId'. */ + paymentProcessor.removeProduct(plan.properties.getOrDefault("productId", "missing")) + .mapLeft { + NotDeletedError(type = planEntity.name, id = "Failed to delete ${plan.id}", + error = it) }.linkReversalActionToTransaction(transaction) { - // (Nothing to do.) + /* (Nothing to do.) */ }.bind() plan }.fix() @@ -795,81 +870,93 @@ object Neo4jStoreSingleton : GraphStore { .ifFailedThenRollback(transaction) } - override fun attachPlan(subscriberId: String, planId: String, trialEnd: Long): Either = writeTransaction { + override fun subscribeToPlan(subscriberId: String, planId: String, trialEnd: Long): Either = writeTransaction { IO { - Either.monad().binding { - subscriberStore.get(subscriberId, transaction) - .mapLeft { - BadRequestError("Subscriber ${subscriberId} does not exists", - ApiErrorCode.FAILED_TO_FETCH_PROFILE) - }.bind() + Either.monad().binding { + val subscriber = subscriberStore.get(subscriberId, transaction) + .bind() val plan = plansStore.get(planId, transaction) + .bind() + planProductRelationStore.get(plan.id, transaction) + .bind() + val profileInfo = paymentProcessor.getPaymentProfile(subscriber.id) .mapLeft { - org.ostelco.prime.apierror.NotFoundError("Plan ${planId} does not exists", - ApiErrorCode.FAILED_TO_FETCH_PLAN) + NotFoundError(type = planEntity.name, id = "Failed to subscribe ${subscriber.id} to ${plan.id}", + error = it) }.bind() - val profileInfo = paymentProcessor.getPaymentProfile(subscriberId) - .mapLeft { err -> - BadRequestError("Failed to subscribe ${subscriberId} to plan ${planId}", - ApiErrorCode.FAILED_TO_SUBSCRIBE_TO_PLAN, - err) - }.bind() - hasPlanRelationStore.create(subscriberId, planId, transaction) - .mapLeft { err -> - BadRequestError("Failed to subscribe ${subscriberId} to plan ${planId}", - ApiErrorCode.FAILED_TO_SUBSCRIBE_TO_PLAN, - err) - }.bind() - val subscriptionInfo = paymentProcessor.subscribeToPlan(plan.planId, profileInfo.id, trialEnd) - .mapLeft { err -> - BadRequestError("Failed to subscribe ${subscriberId} to plan ${planId}", - ApiErrorCode.FAILED_TO_SUBSCRIBE_TO_PLAN, - err) + subscribesToPlanRelationStore.create(subscriber.id, plan.id, transaction) + .bind() + + /* Lookup in payment backend will fail if no value found for 'planId'. */ + val subscriptionInfo = paymentProcessor.createSubscription(plan.properties.getOrDefault("planId", "missing"), + profileInfo.id, trialEnd) + .mapLeft { + NotCreatedError(type = planEntity.name, id = "Failed to subscribe ${subscriberId} to ${plan.id}", + error = it) }.linkReversalActionToTransaction(transaction) { paymentProcessor.cancelSubscription(it.id) }.bind() - hasPlanRelationStore.setProperties(subscriberId, planId, mapOf("paymentSubscriptionId" to subscriptionInfo.id), transaction) - .mapLeft { err -> - BadRequestError("Failed to subscribe ${subscriberId} to plan ${planId}", - ApiErrorCode.FAILED_TO_SUBSCRIBE_TO_PLAN, - err) - }.flatMap { - Either.right(Unit) + + /* Store information from payment backend for later use. */ + subscribesToPlanRelationStore.setProperties(subscriberId, planId, mapOf("subscriptionId" to subscriptionInfo.id, + "created" to subscriptionInfo.created, + "trialEnd" to subscriptionInfo.trialEnd), transaction) + .flatMap { + Either.right(plan) }.bind() }.fix() }.unsafeRunSync() .ifFailedThenRollback(transaction) } - override fun detachPlan(subscriberId: String, planId: String, atIntervalEnd: Boolean): Either = writeTransaction { + override fun unsubscribeFromPlan(subscriberId: String, planId: String, atIntervalEnd: Boolean): Either = writeTransaction { IO { - Either.monad().binding { - val properties = hasPlanRelationStore.getProperties(subscriberId, planId, transaction) + Either.monad().binding { + val plan = plansStore.get(planId, transaction) + .bind() + val properties = subscribesToPlanRelationStore.getProperties(subscriberId, planId, transaction) + .bind() + paymentProcessor.cancelSubscription(properties["subscriptionId"].toString(), atIntervalEnd) .mapLeft { - BadRequestError("Could not find subscription where ${subscriberId} subscribes to plan ${planId}", - ApiErrorCode.FAILED_TO_FETCH_SUBSCRIPTION) - }.bind() - paymentProcessor.cancelSubscription(properties["paymentSubscriptionId"].toString(), atIntervalEnd) - .mapLeft { err -> - BadRequestError("Failed to remove subscription for ${subscriberId} to plan ${planId}", - ApiErrorCode.FAILED_TO_REMOVE_SUBSCRIPTION, - err) + NotDeletedError(type = planEntity.name, id = "${subscriberId} -> ${plan.id}", + error = it) }.flatMap { Either.right(Unit) }.bind() - hasPlanRelationStore.delete(subscriberId, planId, transaction) - .mapLeft { err -> - BadRequestError("Failed to remove subscription for ${subscriberId} to plan ${planId}", - ApiErrorCode.FAILED_TO_REMOVE_SUBSCRIPTION, - err) - }.flatMap { - Either.right(Unit) + + subscribesToPlanRelationStore.delete(subscriberId, planId, transaction) + .flatMap { + Either.right(plan) }.bind() }.fix() }.unsafeRunSync() .ifFailedThenRollback(transaction) } + override fun subscriptionPurchaseReport(invoiceId: String, subscriberId: String, sku: String, amount: Long, currency: String): Either = writeTransaction { + IO { + Either.monad().binding { + val product = productStore.get(sku, transaction) + .bind() + val plan = planProductRelationStore.getFrom(sku, transaction) + .flatMap { + Either.right(it.get(0)) + }.bind() + val purchaseRecord = PurchaseRecord( + id = invoiceId, + product = product, + timestamp = Instant.now().toEpochMilli(), + msisdn = "") + + createPurchaseRecordRelation(subscriberId, purchaseRecord, transaction) + .flatMap { + Either.right(plan) + }.bind() + }.fix() + }.unsafeRunSync() + .ifFailedThenRollback(transaction) + } + // // For refunds // @@ -877,10 +964,10 @@ object Neo4jStoreSingleton : GraphStore { private fun checkPurchaseRecordForRefund(purchaseRecord: PurchaseRecord): Either { if (purchaseRecord.refund != null) { logger.error("Trying to refund again, ${purchaseRecord.id}, refund ${purchaseRecord.refund?.id}") - return Either.left(org.ostelco.prime.paymentprocessor.core.ForbiddenError("Trying to refund again")) + return Either.left(ForbiddenError("Trying to refund again")) } else if (purchaseRecord.product.price.amount == 0) { logger.error("Trying to refund a free product, ${purchaseRecord.id}") - return Either.left(org.ostelco.prime.paymentprocessor.core.ForbiddenError("Trying to refund a free purchase")) + return Either.left(ForbiddenError("Trying to refund a free purchase")) } return Either.right(Unit) } @@ -900,8 +987,10 @@ object Neo4jStoreSingleton : GraphStore { Either.monad().binding { val purchaseRecord = changablePurchaseRelationStore.get(purchaseRecordId, transaction) // If we can't find the record, return not-found - .mapLeft { org.ostelco.prime.paymentprocessor.core.NotFoundError("Purchase Record unavailable") } - .bind() + .mapLeft { + org.ostelco.prime.paymentprocessor.core.NotFoundError("Purchase Record unavailable", + error = it) + }.bind() checkPurchaseRecordForRefund(purchaseRecord).bind() val refundId = paymentProcessor.refundCharge( purchaseRecord.id, @@ -915,9 +1004,10 @@ object Neo4jStoreSingleton : GraphStore { msisdn = "", refund = refund) updatePurchaseRecord(changedPurchaseRecord, transaction) - .mapLeft { storeError -> + .mapLeft { logger.error("failed to update purchase record, for refund $refund.id, chargeId $purchaseRecordId, payment has been refunded in Stripe") - BadGatewayError(storeError.message) + BadGatewayError("Failed to update purchase record for refund ${refund.id}", + error = it) }.bind() analyticsReporter.reportPurchaseInfo(purchaseRecord, subscriberId, "refunded") ProductInfo(purchaseRecord.product.sku) @@ -1108,4 +1198,4 @@ object Neo4jStoreSingleton : GraphStore { // override fun getSegment(id: String): Segment? = segmentStore.get(id)?.let { Segment().apply { this.id = it.id } } // override fun getProductClass(id: String): ProductClass? = productClassStore.get(id) -} \ No newline at end of file +} diff --git a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Schema.kt b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Schema.kt index 1f9e5f132..8907079c1 100644 --- a/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Schema.kt +++ b/neo4j-store/src/main/kotlin/org/ostelco/prime/storage/graph/Schema.kt @@ -339,6 +339,15 @@ class UniqueRelationStore(private val relationType: Rela } } + fun getFrom(to: String, transaction: Transaction) : Either> { + return read("""MATCH (from:${relationType.from.name})-[r:${relationType.relation.name}]->(to:${relationType.to.name} {id: '${to}'}) + RETURN from""".trimMargin(), transaction) { + Either.cond(it.hasNext(), + ifTrue = { it.list { relationType.from.createEntity(it["from"].asMap()) }.filterNotNull() }, + ifFalse = { NotFoundError(relationType.relation.name, to) }) + } + } + fun create(from: String, to: String, transaction: Transaction) : Either { return write("""MATCH (from:${relationType.from.name} {id: '${from}'}),(to:${relationType.to.name} {id: '${to}'}) MERGE (from)-[:${relationType.relation.name}]->(to)""".trimMargin(), transaction) { diff --git a/payment-processor/src/integration-tests/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt b/payment-processor/src/integration-tests/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt index 4627cba0b..0d57aa855 100644 --- a/payment-processor/src/integration-tests/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt +++ b/payment-processor/src/integration-tests/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessorTest.kt @@ -247,7 +247,7 @@ class StripePaymentProcessorTest { val resultCreatePlan = paymentProcessor.createPlan(resultCreateProduct.fold({ "" }, { it.id }), 1000, "NOK", PaymentProcessor.Interval.MONTH) assertEquals(true, resultCreatePlan.isRight()) - val resultSubscribePlan = paymentProcessor.subscribeToPlan(resultCreatePlan.fold({ "" }, { it.id }), stripeCustomerId) + val resultSubscribePlan = paymentProcessor.createSubscription(resultCreatePlan.fold({ "" }, { it.id }), stripeCustomerId) assertEquals(true, resultSubscribePlan.isRight()) val resultUnsubscribePlan = paymentProcessor.cancelSubscription(resultSubscribePlan.fold({ "" }, { it.id }), false) diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripeEventReporter.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripeEventReporter.kt index bb45c9adf..ecf0b12a0 100644 --- a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripeEventReporter.kt +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripeEventReporter.kt @@ -2,7 +2,8 @@ package org.ostelco.prime.paymentprocessor import com.stripe.model.* import org.ostelco.prime.getLogger -import org.ostelco.prime.notifications.NOTIFY_OPS_MARKER +import org.ostelco.prime.module.getResource +import org.ostelco.prime.storage.AdminDataSource import java.time.Instant import java.time.LocalDateTime import java.time.ZoneOffset @@ -11,6 +12,7 @@ import java.time.format.DateTimeFormatter class StripeEventReporter { private val logger by getLogger() + private val storage by lazy { getResource() } fun handleEvent(event: Event) { @@ -22,6 +24,7 @@ class StripeEventReporter { is Charge -> report(event, data) is Customer -> report(event, data) is Dispute -> report(event, data) + is Invoice -> report(event, data) is Payout -> report(event, data) is Plan -> report(event, data) is Product -> report(event, data) @@ -29,7 +32,7 @@ class StripeEventReporter { is Source -> report(event, data) is Subscription -> report(event, data) else -> { - logger.error(NOTIFY_OPS_MARKER, format("No handler found for Stripe event ${event.type}", + logger.warn(format("No handler found for Stripe event ${event.type}", event)) } } @@ -37,13 +40,13 @@ class StripeEventReporter { private fun report(event: Event, balance: Balance) { when { - event.type == "balance.available" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "balance.available" -> logger.info( format("Your balance has new available transactions" + "${currency(balance.available[0].amount, balance.available[0].currency)} is available, " + "${currency(balance.pending[0].amount, balance.pending[0].currency)} is pending.", event) ) - else -> logger.error(NOTIFY_OPS_MARKER, + else -> logger.warn( format("Unhandled Stripe event ${event.type} (cat: Balance)", event)) } @@ -51,15 +54,15 @@ class StripeEventReporter { private fun report(event: Event, card: Card) { when { - event.type == "customer.source.created" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "customer.source.created" -> logger.info( format("${email(card.customer)} added a new ${card.brand} ending in ${card.last4}", event) ) - event.type == "customer.source.deleted" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "customer.source.deleted" -> logger.info( format("${email(card.customer)} deleted a ${card.brand} ending in ${card.last4}", event) ) - else -> logger.error(NOTIFY_OPS_MARKER, + else -> logger.warn( format("Unhandled Stripe event ${event.type} (cat: Card)", event)) } @@ -67,19 +70,19 @@ class StripeEventReporter { private fun report(event: Event, charge: Charge) { when { - event.type == "charge.captured" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "charge.captured" -> logger.info( format("${email(charge.customer)}'s payment was captured for ${currency(charge.amount, charge.currency)}", event) ) - event.type == "charge.succeeded" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "charge.succeeded" -> logger.info( format("An uncaptured payment for ${currency(charge.amount, charge.currency)} was created for ${email(charge.customer)}", event) ) - event.type == "charge.refunded" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "charge.refunded" -> logger.info( format("A ${currency(charge.amount, charge.currency)} payment was refunded to ${email(charge.customer)}", event) ) - else -> logger.error(NOTIFY_OPS_MARKER, + else -> logger.warn( format("Unhandled Stripe event ${event.type} (cat: Charge)", event)) } @@ -87,87 +90,114 @@ class StripeEventReporter { private fun report(event: Event, customer: Customer) { when { - event.type == "customer.created" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "customer.created" -> logger.info( format("${customer.email} is a new customer", event) ) - event.type == "customer.deleted" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "customer.deleted" -> logger.info( format("${customer.email} had been deleted", event) ) - event.type == "customer.updated" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "customer.updated" -> logger.info( format("${customer.email}'s details where updated", event) ) - else -> logger.error(NOTIFY_OPS_MARKER, + else -> logger.warn( format("Unhandled Stripe event ${event.type} (cat: Customer)", event)) } } private fun report(event: Event, dispute: Dispute) { - logger.error(NOTIFY_OPS_MARKER, + logger.warn( format("Unhandled Stripe event ${event.type} (cat: Dispute)", event)) } private fun report(event: Event, payout: Payout) { when { - event.type == "payout.created" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "payout.created" -> logger.info( format("A new payout for ${currency(payout.amount, payout.currency)} was created and will be deposited " + "on ${millisToDate(payout.arrivalDate)}", event) ) - event.type == "payout.paid" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "payout.paid" -> logger.info( format("A payout of ${currency(payout.amount, payout.currency)} should now appear on your bank account statement", event) ) - else -> logger.error(NOTIFY_OPS_MARKER, + else -> logger.warn( format("Unhandled Stripe event ${event.type} (cat: Payout)", event)) } } + private fun report(event: Event, invoice: Invoice) { + when { + event.type == "invoice.payment_succeeded" -> { + logger.info(format("An invoice ${invoice.id} for the amount of ${invoice.amountPaid} ${invoice.currency} " + + "was successfully charged to ${invoice.subscription}", + event)) + + /* TODO: Redo Stripe event handling and split the action of logging + and the action of updating the backend with information + from Stripe events. (kmm) */ + if (invoice.subscription != null) { + val plan = invoice.lines.data[0].plan + val productId = plan.product + val amount = plan.amount + val currency = plan.currency + val productDetails = Product.retrieve(productId) + val customer = Customer.retrieve(invoice.customer) + // Not checking the return value for now (kmm) + storage.subscriptionPurchaseReport(invoice.id, customer.email, productDetails.name, amount, currency) + } + } + else -> logger.warn( + format("Unhandled Stripe event ${event.type} (cat: Invoice)", + event)) + } + } + private fun report(event: Event, plan: Plan) { - logger.error(NOTIFY_OPS_MARKER, + logger.warn( format("Unhandled Stripe event ${event.type} (cat: Plan)", event)) } private fun report(event: Event, product: Product) { - logger.error(NOTIFY_OPS_MARKER, + logger.warn( format("Unhandled Stripe event ${event.type} (cat: Product)", event)) } private fun report(event: Event, refund: Refund) { - logger.error(NOTIFY_OPS_MARKER, + logger.warn( format("Unhandled Stripe event ${event.type} (cat: Refund)", event)) } private fun report(event: Event, source: Source) { when { - event.type == "customer.source.created" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "customer.source.created" -> logger.info( format("${email(source.customer)} added a new payment source", event) ) - event.type == "customer.source.deleted" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "customer.source.deleted" -> logger.info( format("Customer ${email(source.customer)} deleted a payment source", event) ) - event.type == "source.chargeable" -> logger.info(NOTIFY_OPS_MARKER, + event.type == "source.chargeable" -> logger.info( format("A source with ID ${source.id} is chargeable", event) ) - else -> logger.error(NOTIFY_OPS_MARKER, + else -> logger.warn( "Unhandled Stripe event ${event.type} (cat: Source)" + url(event.id)) } } private fun report(event: Event, subscription: Subscription) { - logger.error(format("Unhandled Stripe event ${event.type} (cat: Subscription)", + logger.warn(format("Unhandled Stripe event ${event.type} (cat: Subscription)", event)) } diff --git a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt index bd3580e05..b71d12325 100644 --- a/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt +++ b/payment-processor/src/main/kotlin/org/ostelco/prime/paymentprocessor/StripePaymentProcessor.kt @@ -140,7 +140,8 @@ class StripePaymentProcessor : PaymentProcessor { "interval" to interval.value, "interval_count" to intervalCount, "currency" to currency) - PlanInfo(Plan.create(planParams).id) + val plan = Plan.create(planParams) + PlanInfo(plan.id) } override fun removePlan(planId: String): Either = @@ -192,7 +193,7 @@ class StripePaymentProcessor : PaymentProcessor { ProfileInfo(customer.delete().id) } - override fun subscribeToPlan(planId: String, customerId: String, trialEnd: Long): Either = + override fun createSubscription(planId: String, customerId: String, trialEnd: Long): Either = either("Failed to subscribe customer $customerId to plan $planId") { val item = mapOf("plan" to planId) val subscriptionParams = mapOf( @@ -202,17 +203,18 @@ class StripePaymentProcessor : PaymentProcessor { arrayOf("trial_end" to trialEnd.toString()) else arrayOf()) ) - SubscriptionInfo(Subscription.create(subscriptionParams).id) + val subscription = Subscription.create(subscriptionParams) + SubscriptionInfo(subscription.id, subscription.created, subscription.trialEnd ?: 0L) } override fun cancelSubscription(subscriptionId: String, atIntervalEnd: Boolean): Either = either("Failed to unsubscribe subscription Id : $subscriptionId atIntervalEnd $atIntervalEnd") { val subscription = Subscription.retrieve(subscriptionId) val subscriptionParams = mapOf("at_period_end" to atIntervalEnd) - SubscriptionInfo(subscription.cancel(subscriptionParams).id) + subscription.cancel(subscriptionParams) + SubscriptionInfo(subscription.id, subscription.created, subscription.trialEnd ?: 0L) } - override fun authorizeCharge(customerId: String, sourceId: String?, amount: Int, currency: String): Either { val errorMessage = "Failed to authorize the charge for customerId $customerId sourceId $sourceId amount $amount currency $currency" return when (amount) { diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt index 6da3eac8d..fb8322b6a 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/apierror/ApiErrorCodes.kt @@ -31,10 +31,12 @@ enum class ApiErrorCode { FAILED_TO_STORE_PLAN, FAILED_TO_REMOVE_PLAN, FAILED_TO_SUBSCRIBE_TO_PLAN, - FAILED_TO_FETCH_SUBSCRIPTION, + FAILED_TO_RECORD_PLAN_INVOICE, + FAILED_TO_STORE_SUBSCRIPTION, FAILED_TO_REMOVE_SUBSCRIPTION, FAILED_TO_CREATE_SCANID, FAILED_TO_FETCH_SCAN_INFORMATION, FAILED_TO_UPDATE_SCAN_RESULTS, FAILED_TO_FETCH_SUBSCRIBER_STATE, + FAILED_TO_FETCH_SUBSCRIPTION } diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt index f6d324a0c..566a0ef08 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/PaymentProcessor.kt @@ -49,23 +49,23 @@ interface PaymentProcessor { * @param currency Three-letter ISO currency code in lowercase * @param interval The frequency with which a subscription should be billed * @param invervalCount The number of intervals between subscription billings - * @return Stripe planId if created + * @return Stripe plan details */ fun createPlan(productId: String, amount: Int, currency: String, interval: Interval, intervalCount: Long = 1): Either /** * @param Stripe Plan Id - * @param Stripe Customer Id - * @param Epoch timestamp for when the trial period ends - * @return Stripe SubscriptionId if subscribed + * @return Stripe PlanId if deleted */ - fun subscribeToPlan(planId: String, customerId: String, trialEnd: Long = 0L): Either + fun removePlan(planId: String): Either /** * @param Stripe Plan Id - * @return Stripe PlanId if deleted + * @param Stripe Customer Id + * @param Epoch timestamp for when the trial period ends + * @return Stripe SubscriptionId if subscribed */ - fun removePlan(planId: String): Either + fun createSubscription(planId: String, customerId: String, trialEnd: Long = 0L): Either /** * @param Stripe Subscription Id diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt index 21c0b9301..ad30c4306 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/Model.kt @@ -10,4 +10,4 @@ data class SourceInfo(val id: String) data class SourceDetailsInfo(val id: String, val type: String, val details: Map) -data class SubscriptionInfo(val id: String) +data class SubscriptionInfo(val id: String, val created: Long, val trialEnd: Long) diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/PaymentError.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/PaymentError.kt index ce3c7188a..cbea8af3c 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/PaymentError.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/paymentprocessor/core/PaymentError.kt @@ -2,10 +2,10 @@ package org.ostelco.prime.paymentprocessor.core import org.ostelco.prime.apierror.InternalError -sealed class PaymentError(val description: String, var externalErrorMessage : String? = null) : InternalError() +sealed class PaymentError(val description: String, var message : String? = null, val error: InternalError?) : InternalError() -class ForbiddenError(description: String, externalErrorMessage: String? = null) : PaymentError(description, externalErrorMessage) +class ForbiddenError(description: String, message: String? = null, error: InternalError? = null) : PaymentError(description, message, error) -class NotFoundError(description: String, externalErrorMessage: String? = null) : PaymentError(description, externalErrorMessage ) +class NotFoundError(description: String, message: String? = null, error: InternalError? = null) : PaymentError(description, message, error) -class BadGatewayError(description: String, externalErrorMessage: String? = null) : PaymentError(description, externalErrorMessage) \ No newline at end of file +class BadGatewayError(description: String, message: String? = null, error: InternalError? = null) : PaymentError(description, message, error) \ No newline at end of file diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt index 2bcd065d1..ee5be881f 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/StoreError.kt @@ -2,22 +2,25 @@ package org.ostelco.prime.storage import org.ostelco.prime.apierror.InternalError -sealed class StoreError(val type: String, val id: String, var message: String) : InternalError() +sealed class StoreError(val type: String, val id: String, var message: String, val error: InternalError?) : InternalError() -class NotFoundError(type: String, id: String) : StoreError(type, id, message = "$type - $id not found.") +class NotFoundError(type: String, id: String, error: InternalError? = null) : StoreError(type, id, message = "$type - $id not found.", error = error) -class AlreadyExistsError(type: String, id: String) : StoreError(type, id, message = "$type - $id already exists.") +class AlreadyExistsError(type: String, id: String, error: InternalError? = null) : StoreError(type, id, message = "$type - $id already exists.", error = error) class NotCreatedError( type: String, id: String = "", val expectedCount: Int = 1, - val actualCount:Int = 0) : StoreError(type, id, message = "Failed to create $type - $id") + val actualCount:Int = 0, + error: InternalError? = null) : StoreError(type, id, message = "Failed to create $type - $id", error = error) -class NotUpdatedError(type: String, id: String) : StoreError(type, id, message = "$type - $id not updated.") +class NotUpdatedError(type: String, id: String, error: InternalError? = null) : StoreError(type, id, message = "$type - $id not updated.", error = error) -class NotDeletedError(type: String, id: String) : StoreError(type, id, message = "$type - $id not deleted.") +class NotDeletedError(type: String, id: String, error: InternalError? = null) : StoreError(type, id, message = "$type - $id not deleted.", error = error) class ValidationError( - type: String, id: String, - message: String) : StoreError(type, id, message) \ No newline at end of file + type: String, + id: String, + message: String, + error: InternalError? = null) : StoreError(type, id, message = message, error = error) diff --git a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt index 71a05d390..e432f4097 100644 --- a/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt +++ b/prime-modules/src/main/kotlin/org/ostelco/prime/storage/Variants.kt @@ -5,6 +5,7 @@ import org.ostelco.prime.apierror.ApiError import org.ostelco.prime.model.* import org.ostelco.prime.paymentprocessor.core.PaymentError import org.ostelco.prime.paymentprocessor.core.ProductInfo +import java.util.* interface ClientDocumentStore { @@ -164,28 +165,28 @@ interface AdminGraphStore { * @param planId - The name/id of the plan * @return Plan details if found */ - fun getPlan(planId: String): Either + fun getPlan(planId: String): Either /** * Get all plans that a subscriber subscribes to. * @param subscriberId - The subscriber * @return List with plan details if found */ - fun getPlans(subscriberId: String): Either> + fun getPlans(subscriberId: String): Either> /** * Create a new plan. * @param plan - Plan details * @return Unit value if created successfully */ - fun createPlan(plan: Plan): Either + fun createPlan(plan: Plan): Either /** * Remove a plan. * @param planId - The name/id of the plan * @return Unit value if removed successfully */ - fun deletePlan(planId: String): Either + fun deletePlan(planId: String): Either /** * Set up a subscriber with a subscription to a specific plan. @@ -194,7 +195,7 @@ interface AdminGraphStore { * @param trialEnd - Epoch timestamp for when the trial period ends * @return Unit value if the subscription was created successfully */ - fun attachPlan(subscriberId: String, planId: String, trialEnd: Long = 0): Either + fun subscribeToPlan(subscriberId: String, planId: String, trialEnd: Long = 0): Either /** * Remove the subscription to a plan for a specific subscrber. @@ -203,7 +204,18 @@ interface AdminGraphStore { * @param atIntervalEnd - Remove at end of curren subscription period * @return Unit value if the subscription was removed successfully */ - fun detachPlan(subscriberId: String, planId: String, atIntervalEnd: Boolean = false): Either + fun unsubscribeFromPlan(subscriberId: String, planId: String, atIntervalEnd: Boolean = false): Either + + /** + * Adds a purchase record to subscriber on start of or renewal + * of a subscription. + * @param invoiceId - The reference to the invoice that has been paid + * @param subscriberId - The subscriber that got charged + * @param sku - The product/plan bought + * @param amount - Cost of the product/plan + * @param currency - Currency used + */ + fun subscriptionPurchaseReport(invoiceId: String, subscriberId: String, sku: String, amount: Long, currency: String): Either // atomic import of Offer + Product + Segment fun atomicCreateOffer( diff --git a/prime/infra/dev/prime-client-api.yaml b/prime/infra/dev/prime-client-api.yaml index 9a7ba5156..6ffebf9b0 100644 --- a/prime/infra/dev/prime-client-api.yaml +++ b/prime/infra/dev/prime-client-api.yaml @@ -726,14 +726,14 @@ definitions: type: integer default: 1 minimum: 1 - planId: - description: "Stripe plan id" - type: string - default: "" - productId: - description: "Stripe product id" - type: string - default: "" + properties: + description: "Free form key/value pairs" + type: object + additionalProperties: true + presentation: + description: "Pretty print version of plan" + type: object + additionalProperties: true required: - name - price diff --git a/prime/infra/dev/prime-houston-api.yaml b/prime/infra/dev/prime-houston-api.yaml index 400cc3c73..9e13fcaf6 100644 --- a/prime/infra/dev/prime-houston-api.yaml +++ b/prime/infra/dev/prime-houston-api.yaml @@ -482,14 +482,14 @@ definitions: type: integer default: 1 minimum: 1 - planId: - description: "Stripe plan id" - type: string - default: "" - productId: - description: "Stripe product id" - type: string - default: "" + properties: + description: "Free form key/value pairs" + type: object + additionalProperties: true + presentation: + description: "Pretty print version of plan" + type: object + additionalProperties: true required: - name - price