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

490 lines
17 KiB
Scala

package lila.plan
import org.joda.time.DateTime
import play.api.i18n.Lang
import reactivemongo.api._
import scala.concurrent.duration._
import lila.common.config.Secret
import lila.common.{ Bus, IpAddress }
import lila.db.dsl._
import lila.memo.CacheApi._
import lila.user.{ User, UserRepo }
final class PlanApi(
stripeClient: StripeClient,
patronColl: Coll,
chargeColl: Coll,
notifier: PlanNotifier,
userRepo: UserRepo,
lightUserApi: lila.user.LightUserApi,
cacheApi: lila.memo.CacheApi,
mongoCache: lila.memo.MongoCache.Api,
payPalIpnKey: Secret,
monthlyGoalApi: MonthlyGoalApi,
currencyApi: CurrencyApi,
pricingApi: PlanPricingApi
)(implicit ec: scala.concurrent.ExecutionContext) {
import BsonHandlers._
import PatronHandlers._
import ChargeHandlers._
def switch(user: User, money: Money): Fu[StripeSubscription] =
userCustomer(user) flatMap {
case None => fufail(s"Can't switch non-existent customer ${user.id}")
case Some(customer) =>
customer.firstSubscription match {
case None => fufail(s"Can't switch non-existent subscription of ${user.id}")
case Some(sub) if sub.item.price.money == money => fuccess(sub)
case Some(sub) => stripeClient.updateSubscription(sub, money)
}
}
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}")
case Some(sub) =>
stripeClient.cancelSubscription(sub) >>
isLifetime(user).flatMap { lifetime =>
!lifetime ?? setDbUserPlan(user.mapPlan(_.disable))
} >>
patronColl.update.one($id(user.id), $unset("stripe", "payPal", "expiresAt")).void >>-
logger.info(s"Canceled subscription $sub of ${user.username}")
}
}
def onStripeCharge(stripeCharge: StripeCharge): Funit = for {
patronOption <- customerIdPatron(stripeCharge.customer)
giftTo <- stripeCharge.giftTo ?? userRepo.byId
money = stripeCharge.amount toMoney stripeCharge.currency
usd <- currencyApi toUsd money
charge = Charge
.make(
userId = patronOption.map(_.userId),
giftTo = giftTo.map(_.id),
stripe = Charge.Stripe(stripeCharge.id, stripeCharge.customer).some,
money = money,
usd = usd | Usd(0)
)
isLifetime <- pricingApi isLifetime money
_ <- addCharge(charge, stripeCharge.country)
_ <- patronOption match {
case None =>
logger.info(s"Charged anon customer $charge")
funit
case Some(prevPatron) =>
logger.info(s"Charged $charge $prevPatron")
userRepo byId prevPatron.userId orFail s"Missing user for $prevPatron" flatMap { user =>
giftTo match {
case Some(to) => gift(user, to, money)
case None =>
stripeClient.getCustomer(stripeCharge.customer) flatMap { customer =>
val freq = if (customer.exists(_.renew)) Freq.Monthly else Freq.Onetime
val patron = prevPatron
.copy(lastLevelUp = prevPatron.lastLevelUp orElse DateTime.now.some)
.levelUpIfPossible
.expireInOneMonth(freq == Freq.Onetime)
patronColl.update.one($id(prevPatron.id), patron) >>
setDbUserPlanOnCharge(user, prevPatron.canLevelUp) >> {
isLifetime ?? setLifetime(user)
}
}
}
}
}
} yield ()
def onPaypalCharge(ipn: PlanForm.Ipn, ip: IpAddress, key: String): Funit = for {
money <- ipn.money.fold[Fu[Money]](fufail(s"Invalid paypal charge ${ipn.txnId}"))(fuccess)
pricing <- pricingApi pricingFor money.currency orFail s"Invalid paypal currency $money"
usd <- currencyApi toUsd money orFail s"Invalid paypal currency $money"
isLifetime <- pricingApi isLifetime money
giftTo <- ipn.giftTo ?? userRepo.byId
_ <-
if (key != payPalIpnKey.value) {
logger.error(s"Invalid PayPal IPN key $key from $ip ${ipn.userId} $money")
funit
} else if (!pricing.valid(money)) {
logger.info(s"Ignoring invalid paypal amount from $ip ${ipn.userId} $money ${ipn.txnId}")
funit
} else {
val charge = Charge.make(
userId = ipn.userId,
giftTo = giftTo.map(_.id),
payPal = Charge
.PayPal(
name = ipn.name,
email = ipn.email,
txnId = ipn.txnId,
subId = ipn.subId,
ip = ip.value.some
)
.some,
money = money,
usd = usd
)
addCharge(charge, ipn.country) >>
(ipn.userId ?? userRepo.named) flatMap {
_ ?? { user =>
giftTo match {
case Some(to) => gift(user, to, money)
case None =>
val payPal =
Patron.PayPal(
ipn.email map Patron.PayPal.Email,
ipn.subId map Patron.PayPal.SubId,
DateTime.now
)
userPatron(user).flatMap {
case None =>
patronColl.insert.one(
Patron(
_id = Patron.UserId(user.id),
payPal = payPal.some,
lastLevelUp = Some(DateTime.now)
).expireInOneMonth
) >>
setDbUserPlanOnCharge(user, levelUp = false)
case Some(patron) =>
val p2 = patron
.copy(
payPal = payPal.some,
free = none
)
.levelUpIfPossible
.expireInOneMonth
patronColl.update.one($id(patron.id), p2) >>
setDbUserPlanOnCharge(user, patron.canLevelUp)
} >> {
isLifetime ?? setLifetime(user)
} >>- logger.info(s"Charged ${user.username} with paypal: $money")
}
}
}
}
} yield ()
private def setDbUserPlanOnCharge(from: User, levelUp: Boolean): Funit = {
val user = from.mapPlan(p => if (levelUp) p.incMonths else p.enable)
notifier.onCharge(user)
setDbUserPlan(user)
}
def onSubscriptionDeleted(sub: StripeSubscription): Funit =
customerIdPatron(sub.customer) flatMap {
_ ?? { patron =>
if (patron.isLifetime) funit
else
userRepo byId patron.userId orFail s"Missing user for $patron" flatMap { user =>
setDbUserPlan(user.mapPlan(_.disable)) >>
patronColl.update.one($id(user.id), patron.removeStripe).void >>-
notifier.onExpire(user) >>-
logger.info(s"Unsubed ${user.username} $sub")
}
}
}
def getEvent = stripeClient.getEvent _
def customerInfo(user: User, customer: StripeCustomer): Fu[Option[CustomerInfo]] =
stripeClient.getNextInvoice(customer.id) zip
customer.firstSubscription.??(stripeClient.getPaymentMethod) map {
case (Some(nextInvoice), paymentMethod) =>
customer.firstSubscription match {
case Some(sub) => MonthlyCustomerInfo(sub, nextInvoice, paymentMethod).some
case None =>
logger.warn(s"Can't identify ${user.username} monthly subscription $customer")
none
}
case (None, _) => OneTimeCustomerInfo(customer).some
}
import PlanApi.SyncResult.{ ReloadUser, Synced }
def sync(user: User): Fu[PlanApi.SyncResult] =
userPatron(user) flatMap {
case None if user.plan.active =>
logger.warn(s"${user.username} sync: disable plan of non-patron")
setDbUserPlan(user.mapPlan(_.disable)) inject ReloadUser
case None => fuccess(Synced(none, none))
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.exists(_.isActive) && !user.plan.active =>
logger.warn(s"${user.username} sync: enable plan of customer with a subscription")
setDbUserPlan(user.mapPlan(_.enable)) inject ReloadUser
case customer => fuccess(Synced(patron.some, customer))
}
case (_, Some(_)) =>
if (!user.plan.active) {
logger.warn(s"${user.username} sync: enable plan of customer with paypal")
setDbUserPlan(user.mapPlan(_.enable)) inject ReloadUser
} else fuccess(Synced(patron.some, none))
case (None, None) if patron.isLifetime => fuccess(Synced(patron.some, none))
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.mapPlan(_.disable)) inject ReloadUser
case _ => fuccess(Synced(patron.some, none))
}
}
def isLifetime(user: User): Fu[Boolean] =
userPatron(user) map {
_.exists(_.isLifetime)
}
def setLifetime(user: User): Funit = {
if (user.plan.isEmpty) Bus.publish(lila.hub.actorApi.plan.MonthInc(user.id, 0), "plan")
userRepo.setPlan(
user,
user.plan.enable
) >> patronColl.update
.one(
$id(user.id),
$set(
"lastLevelUp" -> DateTime.now,
"lifetime" -> true,
"free" -> Patron.Free(DateTime.now, by = none)
),
upsert = true
)
.void >>- lightUserApi.invalidate(user.id)
}
def freeMonth(user: User): Funit =
patronColl.update
.one(
$id(user.id),
$set(
"lastLevelUp" -> DateTime.now,
"lifetime" -> false,
"free" -> Patron.Free(DateTime.now, by = none),
"expiresAt" -> DateTime.now.plusMonths(1)
),
upsert = true
)
.void >> setDbUserPlanOnCharge(user, levelUp = false)
def gift(from: User, to: User, money: Money): Funit =
for {
toPatronOpt <- userPatron(to)
isLifetime <- fuccess(toPatronOpt.exists(_.isLifetime)) >>| (pricingApi isLifetime money)
_ <- patronColl.update
.one(
$id(to.id),
$set(
"lastLevelUp" -> DateTime.now,
"lifetime" -> isLifetime,
"free" -> Patron.Free(DateTime.now, by = from.id.some),
"expiresAt" -> (!isLifetime option DateTime.now.plusMonths(1))
),
upsert = true
)
newTo = to.mapPlan(p => if (toPatronOpt.exists(_.canLevelUp)) p.incMonths else p.enable)
_ <- setDbUserPlan(newTo)
} yield {
notifier.onGift(from, newTo, isLifetime)
}
def recentGiftFrom(from: User): Fu[Option[Patron]] =
patronColl
.find(
$doc(
"free.by" -> from.id,
"free.at" $gt DateTime.now.minusMinutes(2)
)
)
.sort($sort desc "free.at")
.one[Patron]
def remove(user: User): Funit =
userRepo.unsetPlan(user) >>
patronColl.unsetField($id(user.id), "lifetime").void >>-
lightUserApi.invalidate(user.id)
private val recentChargeUserIdsNb = 100
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)
}
}
def recentChargeUserIds: Fu[List[User.ID]] = recentChargeUserIdsCache.getUnit
def recentChargesOf(user: User): Fu[List[Charge]] =
chargeColl
.find(
$or(
$doc("userId" -> user.id),
$doc("giftTo" -> user.id)
)
)
.sort($doc("date" -> -1))
.cursor[Charge]()
.list()
def giftsFrom(user: User): Fu[List[Charge.Gift]] =
chargeColl
.find($doc("userId" -> user.id, "giftTo" $exists true))
.sort($doc("date" -> -1))
.cursor[Charge]()
.list()
.map(_.flatMap(_.toGift))
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("usd")),
Sort(Descending("total")),
Limit(topPatronUserIdsNb * 3 / 2)
)
}
.dmap {
_.flatMap { _.getAsOpt[User.ID]("_id") }
} flatMap filterUserIds dmap (_ take topPatronUserIdsNb)
}
}
}
def topPatronUserIds: Fu[List[User.ID]] = topPatronUserIdsCache.get {}
private def filterUserIds(ids: List[User.ID]): Fu[List[User.ID]] = {
val dedup = ids.distinct
userRepo.filterByEnabledPatrons(dedup) map { enableds =>
dedup filter enableds.contains
}
}
private def addCharge(charge: Charge, country: Option[Country]): Funit =
chargeColl.insert.one(charge).void >>- {
recentChargeUserIdsCache.invalidateUnit()
} >> monitorCharge(charge, country)
private def monitorCharge(charge: Charge, country: Option[Country]): Funit = {
lila.mon.plan.charge
.countryCents(
country = country.fold("unknown")(_.code),
currency = charge.money.currency,
service = charge.serviceName,
gift = charge.giftTo.isDefined
)
.record(charge.usd.cents)
charge.userId.?? { userId =>
chargeColl.countSel($doc("userId" -> userId)) map {
case 1 => lila.mon.plan.charge.first(charge.serviceName).increment().unit
case _ =>
}
} >>
monthlyGoalApi.get.map { m =>
Bus.publish(
lila.hub.actorApi.plan.ChargeEvent(
username = charge.userId.flatMap(lightUserApi.sync).fold("Anonymous")(_.name),
cents = charge.usd.cents,
percent = m.percent,
DateTime.now
),
"plan"
)
lila.mon.plan.goal.update(m.goal.cents)
lila.mon.plan.current.update(m.current.cents)
lila.mon.plan.percent.update(m.percent)
if (charge.isPayPal) lila.mon.plan.paypal.record(charge.usd.cents)
else if (charge.isStripe) lila.mon.plan.stripe.record(charge.usd.cents)
}.void
}
private def setDbUserPlan(user: User): Funit =
userRepo.setPlan(user, user.plan) >>- lightUserApi.invalidate(user.id)
private def saveStripeCustomer(user: User, customerId: CustomerId): Funit =
userPatron(user) flatMap { patronOpt =>
val patron = patronOpt
.getOrElse(Patron(_id = Patron.UserId(user.id)))
.copy(stripe = Patron.Stripe(customerId).some)
patronColl.update.one($id(user.id), patron, upsert = true).void
}
def userCustomerId(user: User): Fu[Option[CustomerId]] =
userPatron(user) map {
_.flatMap { _.stripe.map(_.customerId) }
}
def userCustomer(user: User): Fu[Option[StripeCustomer]] =
userCustomerId(user) flatMap {
_ ?? stripeClient.getCustomer
}
def makeCustomer(user: User, data: Checkout): Fu[StripeCustomer] =
stripeClient.createCustomer(user, data) flatMap { customer =>
saveStripeCustomer(user, customer.id) inject customer
}
def patronCustomer(patron: Patron): Fu[Option[StripeCustomer]] =
patron.stripe.map(_.customerId) ?? stripeClient.getCustomer
private def customerIdPatron(id: CustomerId): Fu[Option[Patron]] =
patronColl.one[Patron]($doc("stripe.customerId" -> id))
def userPatron(user: User): Fu[Option[Patron]] = patronColl.one[Patron]($id(user.id))
def createSession(data: CreateStripeSession)(implicit lang: Lang): Fu[StripeSession] =
data.checkout.freq match {
case Freq.Onetime => stripeClient.createOneTimeSession(data)
case Freq.Monthly => stripeClient.createMonthlySession(data)
}
def createPaymentUpdateSession(sub: StripeSubscription, nextUrls: NextUrls): Fu[StripeSession] =
stripeClient.createPaymentUpdateSession(sub, nextUrls)
def updatePaymentMethod(sub: StripeSubscription, sessionId: String) =
stripeClient.getSession(sessionId) flatMap {
_ ?? { session =>
stripeClient.setCustomerPaymentMethod(sub.customer, session.setup_intent.payment_method) zip
stripeClient.setSubscriptionPaymentMethod(sub, session.setup_intent.payment_method) void
}
}
}
object PlanApi {
sealed trait SyncResult
object SyncResult {
case object ReloadUser extends SyncResult
case class Synced(patron: Option[Patron], customer: Option[StripeCustomer]) extends SyncResult
}
}