lila/modules/plan/src/main/PlanApi.scala

507 lines
18 KiB
Scala
Raw Normal View History

2016-07-12 11:19:30 -06:00
package lila.plan
2016-06-06 03:36:21 -06:00
2016-06-06 08:41:04 -06:00
import org.joda.time.DateTime
2019-12-02 17:42:57 -07:00
import reactivemongo.api._
2016-07-18 15:24:32 -06:00
import scala.concurrent.duration._
2016-06-06 03:36:21 -06:00
2019-12-01 10:24:02 -07:00
import lila.common.config.Secret
import lila.common.Bus
2019-08-18 07:52:49 -06:00
import lila.db.dsl._
2019-12-23 18:01:45 -07:00
import lila.memo.CacheApi._
2019-08-18 07:52:49 -06:00
import lila.user.{ User, UserRepo }
case class StripeReturnUrls(successUrl: String, cancelUrl: String)
2016-07-12 11:19:30 -06:00
final class PlanApi(
stripeClient: StripeClient,
patronColl: Coll,
2016-07-12 10:33:22 -06:00
chargeColl: Coll,
2016-07-14 04:58:34 -06:00
notifier: PlanNotifier,
2019-12-01 10:24:02 -07:00
userRepo: UserRepo,
lightUserApi: lila.user.LightUserApi,
2019-12-23 18:01:45 -07:00
cacheApi: lila.memo.CacheApi,
mongoCache: lila.memo.MongoCache.Api,
2019-12-01 10:24:02 -07:00
payPalIpnKey: Secret,
monthlyGoalApi: MonthlyGoalApi
)(implicit ec: scala.concurrent.ExecutionContext) {
2016-06-06 08:41:04 -06:00
2016-07-12 10:58:39 -06:00
import BsonHandlers._
import PatronHandlers._
import ChargeHandlers._
2016-06-06 08:41:04 -06:00
def checkout(userOption: Option[User], data: Checkout): Funit =
getOrMakePlan(data.cents, data.freq) flatMap { plan =>
2016-08-29 16:34:18 -06:00
userOption.fold(anonCheckout(plan, data)) { user =>
userCheckout(user, plan, data)
2016-07-13 08:46:25 -06:00
}
} void
2016-07-13 08:14:20 -06:00
def switch(user: User, cents: Cents): Fu[StripeSubscription] =
2016-06-06 08:41:04 -06:00
userCustomer(user) flatMap {
2016-06-06 12:18:40 -06:00
case None => fufail(s"Can't switch non-existent customer ${user.id}")
2016-06-06 08:41:04 -06:00
case Some(customer) =>
customer.firstSubscription match {
2019-12-13 07:30:20 -07:00
case None => fufail(s"Can't switch non-existent subscription of ${user.id}")
2016-07-13 08:14:20 -06:00
case Some(sub) if sub.plan.cents == cents => fuccess(sub)
case Some(sub) =>
getOrMakePlan(cents, Freq.Monthly) flatMap { plan =>
2016-07-13 08:14:20 -06:00
stripeClient.updateSubscription(sub, plan, none)
}
2016-06-06 08:41:04 -06:00
}
}
2016-06-06 12:18:40 -06:00
def cancel(user: User): Funit =
userCustomer(user) flatMap {
case None => fufail(s"Can't cancel non-existent customer ${user.id}")
case Some(customer) =>
customer.firstSubscription match {
case None => fufail(s"Can't cancel non-existent subscription of ${user.id}")
2019-12-13 07:30:20 -07:00
case Some(sub) =>
stripeClient.cancelSubscription(sub) >>
isLifetime(user).flatMap { lifetime =>
!lifetime ?? setDbUserPlan(user, user.plan.disable)
} >>
patronColl.update.one($id(user.id), $unset("stripe", "payPal", "expiresAt")).void >>-
logger.info(s"Canceled subscription $sub of ${user.username}")
2016-06-06 12:18:40 -06:00
}
}
2016-08-21 03:37:48 -06:00
def onStripeCharge(stripeCharge: StripeCharge): Funit =
customerIdPatron(stripeCharge.customer) flatMap { patronOption =>
val charge = Charge.make(
2016-07-14 04:03:04 -06:00
userId = patronOption.map(_.userId),
2016-08-21 03:37:48 -06:00
stripe = Charge.Stripe(stripeCharge.id, stripeCharge.customer).some,
cents = stripeCharge.amount
)
2016-08-21 03:37:48 -06:00
addCharge(charge) >> {
2016-07-12 10:33:22 -06:00
patronOption match {
2016-07-13 08:46:25 -06:00
case None =>
logger.info(s"Charged anon customer $charge")
funit
2016-07-14 14:43:04 -06:00
case Some(patron) =>
logger.info(s"Charged $charge $patron")
2019-12-01 10:24:02 -07:00
userRepo byId patron.userId orFail s"Missing user for $patron" flatMap { user =>
2019-12-13 07:30:20 -07:00
val p2 = patron
.copy(
stripe = Patron.Stripe(stripeCharge.customer).some,
free = none
)
.levelUpIfPossible
2019-12-01 10:24:02 -07:00
patronColl.update.one($id(patron.id), p2) >>
setDbUserPlanOnCharge(user, p2) >> {
2019-12-13 07:30:20 -07:00
stripeCharge.lifetimeWorthy ?? setLifetime(user)
}
2016-07-10 14:12:22 -06:00
}
2016-06-06 08:41:04 -06:00
}
2016-07-12 10:33:22 -06:00
}
2016-06-06 08:41:04 -06:00
}
2016-07-14 13:31:41 -06:00
def onPaypalCharge(
2019-12-13 07:30:20 -07:00
userId: Option[User.ID],
email: Option[Patron.PayPal.Email],
subId: Option[Patron.PayPal.SubId],
cents: Cents,
name: Option[String],
txnId: Option[String],
ip: String,
key: String
): Funit =
2019-12-01 10:24:02 -07:00
if (key != payPalIpnKey.value) {
2016-07-14 15:11:42 -06:00
logger.error(s"Invalid PayPal IPN key $key from $ip $userId $cents")
funit
2017-08-23 17:56:39 -06:00
} else if (cents.value < 100) {
2016-11-02 06:16:57 -06:00
logger.info(s"Ignoring small paypal charge from $ip $userId $cents $txnId")
funit
2017-08-23 17:56:39 -06:00
} else {
2016-08-21 03:37:48 -06:00
val charge = Charge.make(
2016-07-14 15:11:42 -06:00
userId = userId,
2019-12-13 07:30:20 -07:00
payPal = Charge
.PayPal(
name = name,
email = email.map(_.value),
txnId = txnId,
subId = subId.map(_.value),
ip = ip.some
)
.some,
cents = cents
)
2016-08-21 03:37:48 -06:00
addCharge(charge) >>
2019-12-01 10:24:02 -07:00
(userId ?? userRepo.named) flatMap { userOption =>
2019-12-13 07:30:20 -07:00
userOption ?? { user =>
val payPal = Patron.PayPal(email, subId, DateTime.now)
userPatron(user).flatMap {
case None =>
patronColl.insert.one(
Patron(
_id = Patron.UserId(user.id),
payPal = payPal.some,
lastLevelUp = DateTime.now
).expireInOneMonth
) >>
setDbUserPlan(user, lila.user.Plan.start) >>
notifier.onStart(user)
2019-12-13 07:30:20 -07:00
case Some(patron) =>
val p2 = patron
.copy(
payPal = payPal.some,
free = none
2019-12-13 07:30:20 -07:00
)
.levelUpIfPossible
.expireInOneMonth
patronColl.update.one($id(patron.id), p2) >>
setDbUserPlanOnCharge(user, p2)
} >> {
charge.lifetimeWorthy ?? setLifetime(user)
} >>- logger.info(s"Charged ${user.username} with paypal: $cents")
2016-07-12 10:33:22 -06:00
}
2019-12-13 07:30:20 -07:00
}
2016-07-14 15:11:42 -06:00
}
2017-07-19 18:13:20 -06:00
private def setDbUserPlanOnCharge(user: User, patron: Patron): Funit = {
val plan =
if (patron.canLevelUp) user.plan.incMonths
else user.plan.enable
2019-11-29 17:07:51 -07:00
Bus.publish(lila.hub.actorApi.plan.MonthInc(user.id, plan.months), "plan")
2017-07-19 18:13:20 -06:00
setDbUserPlan(user, plan)
}
2016-06-06 08:41:04 -06:00
def onSubscriptionDeleted(sub: StripeSubscription): Funit =
customerIdPatron(sub.customer) flatMap {
2016-07-16 03:31:26 -06:00
case None =>
logger.warn(s"Deleted subscription of unknown patron $sub")
funit
2018-02-01 19:22:48 -07:00
case Some(patron) if patron.isLifetime =>
logger.info(s"Ignore sub end for lifetime patron $patron")
funit
case Some(patron) =>
2019-12-01 10:24:02 -07:00
userRepo byId patron.userId orFail s"Missing user for $patron" flatMap { user =>
setDbUserPlan(user, user.plan.disable) >>
2019-12-01 10:24:02 -07:00
patronColl.update.one($id(user.id), patron.removeStripe).void >>
notifier.onExpire(user) >>-
2016-07-14 04:03:04 -06:00
logger.info(s"Unsubed ${user.username} ${sub}")
2016-06-06 08:41:04 -06:00
}
}
2016-07-12 11:19:30 -06:00
def getEvent = stripeClient.getEvent _
2016-07-14 10:27:07 -06:00
def customerInfo(user: User, customer: StripeCustomer): Fu[Option[CustomerInfo]] =
stripeClient.getNextInvoice(customer.id) zip
stripeClient.getPastInvoices(customer.id) map {
2019-12-13 07:30:20 -07:00
case (Some(nextInvoice), pastInvoices) =>
customer.firstSubscription match {
case Some(sub) => MonthlyCustomerInfo(sub, nextInvoice, pastInvoices).some
case None =>
logger.warn(s"Can't identify ${user.username} monthly subscription $customer")
none
}
case (None, _) =>
customer.firstSubscription match {
case Some(sub) => OneTimeCustomerInfo(customer, sub).some
case None =>
logger.warn(s"Can't identify ${user.username} one-time subscription $customer")
none
}
}
2016-06-06 11:50:13 -06:00
import PlanApi.SyncResult.{ ReloadUser, Synced }
def sync(user: User): Fu[PlanApi.SyncResult] = userPatron(user) flatMap {
2016-07-11 16:55:35 -06:00
2016-07-12 10:58:39 -06:00
case None if user.plan.active =>
2016-10-07 08:51:08 -06:00
logger.warn(s"${user.username} sync: disable plan of non-patron")
setDbUserPlan(user, user.plan.disable) inject ReloadUser
2016-07-11 16:55:35 -06:00
case None => fuccess(Synced(none, none))
2016-07-11 16:55:35 -06:00
2019-12-13 07:30:20 -07:00
case Some(patron) =>
(patron.stripe, patron.payPal) match {
case (Some(stripe), _) =>
stripeClient.getCustomer(stripe.customerId) flatMap {
case None =>
logger.warn(s"${user.username} sync: unset DB patron that's not in stripe")
patronColl.update.one($id(patron.id), patron.removeStripe) >> sync(user)
case Some(customer) if customer.firstSubscription.isEmpty =>
logger.warn(s"${user.username} sync: unset DB patron of customer without a subscription")
patronColl.update.one($id(patron.id), patron.removeStripe) >> sync(user)
case Some(customer) if customer.firstSubscription.isDefined && !user.plan.active =>
logger.warn(s"${user.username} sync: enable plan of customer with a subscription")
setDbUserPlan(user, user.plan.enable) inject ReloadUser
case customer => fuccess(Synced(patron.some, customer))
}
2016-07-11 16:55:35 -06:00
2019-12-13 07:30:20 -07:00
case (_, Some(_)) =>
if (!user.plan.active) {
logger.warn(s"${user.username} sync: enable plan of customer with paypal")
setDbUserPlan(user, user.plan.enable) inject ReloadUser
} else fuccess(Synced(patron.some, none))
2016-07-11 16:55:35 -06:00
2019-12-13 07:30:20 -07:00
case (None, None) if patron.isLifetime => fuccess(Synced(patron.some, none))
2018-05-23 10:51:55 -06:00
2019-12-13 07:30:20 -07:00
case (None, None) if user.plan.active && patron.free.isEmpty =>
logger.warn(s"${user.username} sync: disable plan of patron with no paypal or stripe")
setDbUserPlan(user, user.plan.disable) inject ReloadUser
2016-07-11 16:55:35 -06:00
2019-12-13 07:30:20 -07:00
case _ => fuccess(Synced(patron.some, none))
}
}
2016-06-06 11:50:13 -06:00
2018-04-11 17:34:14 -06:00
def isLifetime(user: User): Fu[Boolean] = userPatron(user) map {
_.exists(_.isLifetime)
}
2019-12-13 07:30:20 -07:00
def setLifetime(user: User): Funit =
userRepo.setPlan(
user,
lila.user.Plan(
months = user.plan.months | 1,
active = true,
since = user.plan.since orElse DateTime.now.some
)
) >> patronColl.update
.one(
$id(user.id),
$set(
"lastLevelUp" -> DateTime.now,
"lifetime" -> true,
"free" -> Patron.Free(DateTime.now)
),
upsert = true
)
.void >>- lightUserApi.invalidate(user.id)
def giveMonth(user: User): Funit =
userRepo.setPlan(
user,
lila.user.Plan(
months = user.plan.months | 1,
active = true,
since = user.plan.since orElse DateTime.now.some
)
) >> patronColl.update
.one(
$id(user.id),
$set(
"lastLevelUp" -> DateTime.now,
"lifetime" -> false,
"free" -> Patron.Free(DateTime.now),
"expiresAt" -> DateTime.now.plusMonths(1).plusDays(1)
),
upsert = true
)
.void >>- lightUserApi.invalidate(user.id)
2018-04-11 17:34:14 -06:00
2019-08-18 07:52:49 -06:00
private val recentChargeUserIdsNb = 100
2019-12-23 18:01:45 -07:00
private val recentChargeUserIdsCache = cacheApi.unit[List[User.ID]] {
_.refreshAfterWrite(30 minutes)
.buildAsyncFuture { _ =>
chargeColl.primitive[User.ID](
$empty,
sort = $doc("date" -> -1),
nb = recentChargeUserIdsNb * 3 / 2,
"userId"
) flatMap filterUserIds dmap (_ take recentChargeUserIdsNb)
}
}
2016-07-18 15:24:32 -06:00
2019-12-23 18:01:45 -07:00
def recentChargeUserIds: Fu[List[User.ID]] = recentChargeUserIdsCache.getUnit
2016-07-14 08:41:51 -06:00
2019-12-04 23:52:53 -07:00
def recentChargesOf(user: User): Fu[List[Charge]] =
2019-12-01 10:24:02 -07:00
chargeColl.ext.find($doc("userId" -> user.id)).sort($doc("date" -> -1)).list[Charge]()
2019-08-18 07:52:49 -06:00
private val topPatronUserIdsNb = 300
private val topPatronUserIdsCache = mongoCache.unit[List[User.ID]](
"patron:top",
59 minutes
) { loader =>
_.refreshAfterWrite(60 minutes)
.buildAsyncFuture {
loader { _ =>
chargeColl
.aggregateList(
maxDocs = topPatronUserIdsNb * 2,
readPreference = ReadPreference.secondaryPreferred
) { framework =>
import framework._
Match($doc("userId" $exists true)) -> List(
GroupField("userId")("total" -> SumField("cents")),
Sort(Descending("total")),
Limit(topPatronUserIdsNb * 3 / 2)
)
}
.dmap {
_.flatMap { _.getAsOpt[User.ID]("_id") }
} flatMap filterUserIds dmap (_ take topPatronUserIdsNb)
2019-12-13 07:30:20 -07:00
}
}
}
def topPatronUserIds: Fu[List[User.ID]] = topPatronUserIdsCache.get({})
2016-07-14 13:50:18 -06:00
private def filterUserIds(ids: List[User.ID]): Fu[List[User.ID]] = {
val dedup = ids.distinct
2019-12-01 10:24:02 -07:00
userRepo.filterByEnabledPatrons(dedup) map { enableds =>
2016-10-24 04:33:35 -06:00
dedup filter enableds.contains
}
}
private def addCharge(charge: Charge): Funit =
2019-12-01 10:24:02 -07:00
chargeColl.insert.one(charge).void >>- {
2019-12-23 18:01:45 -07:00
recentChargeUserIdsCache.invalidateUnit()
2017-01-26 16:23:43 -07:00
monthlyGoalApi.get foreach { m =>
2019-12-13 07:30:20 -07:00
Bus.publish(
lila.hub.actorApi.plan.ChargeEvent(
username = charge.userId.flatMap(lightUserApi.sync).fold("Anonymous")(_.name),
amount = charge.cents.value,
percent = m.percent,
DateTime.now
),
"plan"
)
2019-12-10 14:01:18 -07:00
lila.mon.plan.goal.update(m.goal.value)
lila.mon.plan.current.update(m.current.value)
lila.mon.plan.percent.update(m.percent)
2019-12-14 18:45:22 -07:00
if (charge.isPayPal) lila.mon.plan.paypal.record(charge.cents.value)
else if (charge.isStripe) lila.mon.plan.stripe.record(charge.cents.value)
}
2017-01-26 16:23:43 -07:00
}
2016-07-19 00:04:29 -06:00
private def getOrMakePlan(cents: Cents, freq: Freq): Fu[StripePlan] =
stripeClient.getPlan(cents, freq) getOrElse stripeClient.makePlan(cents, freq)
2016-07-13 08:14:20 -06:00
2016-08-29 16:34:18 -06:00
private def anonCheckout(plan: StripePlan, data: Checkout): Funit =
2016-07-13 08:46:25 -06:00
stripeClient.createAnonCustomer(plan, data) map { customer =>
logger.info(s"Subed anon $customer to ${plan} freq=${data.freq}")
2016-07-13 08:46:25 -06:00
customer.firstSubscription err s"Can't create anon $customer subscription to $plan"
} flatMap { subscription =>
if (data.freq.renew) funit
else stripeClient dontRenewSubscription subscription void
2016-07-13 08:46:25 -06:00
}
private def userCheckout(user: User, plan: StripePlan, data: Checkout): Funit =
2016-06-06 08:41:04 -06:00
userCustomer(user) flatMap {
2019-12-13 07:30:20 -07:00
case None =>
createCustomer(user, data, plan) map { customer =>
customer.firstSubscription err s"Can't create ${user.username} subscription for customer $customer"
} flatMap withNewSubscription(user, data)
2016-08-21 03:37:48 -06:00
case Some(customer) =>
// user has a monthly going on and is making an extra one-time
// let's not change the user plan to one-time, or else
// it would only cancel the monthly
if (customer.renew && !data.freq.renew) stripeClient.addOneTime(customer, data.amount)
// or else, set this new plan to the customer
2019-12-13 07:30:20 -07:00
else
setCustomerPlan(customer, plan, data.source) flatMap { sub =>
saveStripePatron(user, customer.id, data.freq) inject sub
} flatMap withNewSubscription(user, data)
2016-06-06 08:41:04 -06:00
}
private def withNewSubscription(user: User, data: Checkout)(subscription: StripeSubscription): Funit = {
logger.info(s"Subed user ${user.username} $subscription freq=${data.freq}")
if (data.freq.renew) funit
else stripeClient dontRenewSubscription subscription void
}
private def setDbUserPlan(user: User, plan: lila.user.Plan): Funit =
2019-12-01 10:24:02 -07:00
userRepo.setPlan(user, plan) >>- lightUserApi.invalidate(user.id)
private def createCustomer(user: User, data: Checkout, plan: StripePlan): Fu[StripeCustomer] =
stripeClient.createCustomer(user, data, plan) flatMap { customer =>
saveStripePatron(user, customer.id, data.freq) >>
setDbUserPlan(user, lila.user.Plan.start) >>
2016-07-14 04:58:34 -06:00
notifier.onStart(user) >>-
logger.info(s"Create ${user.username} customer $customer") inject customer
2016-06-06 08:41:04 -06:00
}
2019-12-13 07:30:20 -07:00
private def saveStripePatron(user: User, customerId: CustomerId, freq: Freq): Funit =
userPatron(user) flatMap {
case None =>
patronColl.insert.one(
Patron(
_id = Patron.UserId(user.id),
stripe = Patron.Stripe(customerId).some,
lastLevelUp = DateTime.now
).expireInOneMonth(!freq.renew)
)
case Some(patron) =>
patronColl.update.one(
$id(patron.id),
patron
.copy(
stripe = Patron.Stripe(customerId).some
)
.removePayPal
.expireInOneMonth(!freq.renew)
)
} void
private def setCustomerPlan(
customer: StripeCustomer,
plan: StripePlan,
source: Source
): Fu[StripeSubscription] =
2016-06-06 08:41:04 -06:00
customer.subscriptions.data.find(_.plan == plan) match {
case Some(sub) => fuccess(sub)
2019-12-13 07:30:20 -07:00
case None =>
customer.firstSubscription match {
case None => stripeClient.createSubscription(customer, plan, source)
case Some(sub) => stripeClient.updateSubscription(sub, plan, source.some)
}
2016-06-06 08:41:04 -06:00
}
private def userCustomerId(user: User): Fu[Option[CustomerId]] =
2016-07-12 12:25:58 -06:00
userPatron(user) map {
_.flatMap { _.stripe.map(_.customerId) }
}
2016-06-06 08:41:04 -06:00
private def userCustomer(user: User): Fu[Option[StripeCustomer]] =
userCustomerId(user) flatMap {
2016-07-12 11:19:30 -06:00
_ ?? stripeClient.getCustomer
2016-06-06 08:41:04 -06:00
}
def patronCustomer(patron: Patron): Fu[Option[StripeCustomer]] =
patron.stripe.map(_.customerId) ?? stripeClient.getCustomer
private def customerIdPatron(id: CustomerId): Fu[Option[Patron]] =
2019-12-07 21:49:02 -07:00
patronColl.one[Patron]($doc("stripe.customerId" -> id))
2019-12-07 21:49:02 -07:00
def userPatron(user: User): Fu[Option[Patron]] = patronColl.one[Patron]($id(user.id))
def createSession(
urls: StripeReturnUrls,
data: Checkout,
customerId: Option[CustomerId]
): Fu[StripeSession] =
data.freq match {
case Freq.Onetime => createOneTimeSession(urls, data, customerId)
case Freq.Monthly => createMonthlySession(urls, data, customerId)
}
def createOneTimeSession(
urls: StripeReturnUrls,
data: Checkout,
customerId: Option[CustomerId]
): Fu[StripeSession] =
stripeClient.createOneTimeSession(urls, data, customerId)
def createMonthlySession(
urls: StripeReturnUrls,
data: Checkout,
customerId: Option[CustomerId]
): Fu[StripeSession] =
getOrMakePlan(data.cents, data.freq) flatMap { plan =>
stripeClient.createMonthlySession(urls, plan, data, customerId)
}
2016-06-06 03:36:21 -06:00
}
object PlanApi {
sealed trait SyncResult
object SyncResult {
2019-12-13 07:30:20 -07:00
case object ReloadUser extends SyncResult
2016-07-14 10:27:07 -06:00
case class Synced(patron: Option[Patron], customer: Option[StripeCustomer]) extends SyncResult
}
}