allow changing stripe payment method

pull/9088/head
Thibault Duplessis 2021-06-02 12:06:06 +02:00
parent 0b448dc8ba
commit 1988f64b7b
14 changed files with 190 additions and 53 deletions

View File

@ -15,6 +15,7 @@ import lila.plan.{
CustomerId,
Freq,
MonthlyCustomerInfo,
NextUrls,
OneTimeCustomerInfo,
StripeCustomer
}
@ -82,7 +83,8 @@ final class Plan(env: Env)(implicit system: akka.actor.ActorSystem) extends Lila
ctx: Context
) =
env.plan.api.customerInfo(me, customer) flatMap {
case Some(info: MonthlyCustomerInfo) => Ok(html.plan.indexStripe(me, patron, info)).fuccess
case Some(info: MonthlyCustomerInfo) =>
Ok(html.plan.indexStripe(me, patron, info, stripePublicKey = env.plan.stripePublicKey)).fuccess
case Some(info: OneTimeCustomerInfo) =>
renderIndex(info.customer.email map EmailAddress.apply, patron.some)
case None =>
@ -144,10 +146,12 @@ final class Plan(env: Env)(implicit system: akka.actor.ActorSystem) extends Lila
env.plan.api
.createSession(
CreateStripeSession(
s"${env.net.baseUrl}${routes.Plan.thanks}",
s"${env.net.baseUrl}${routes.Plan.index}",
customerId,
checkout
checkout,
NextUrls(
cancel = s"${env.net.baseUrl}${routes.Plan.index}",
success = s"${env.net.baseUrl}${routes.Plan.thanks}"
)
)
)
.map(session => JsonOk(Json.obj("session" -> Json.obj("id" -> session.id.value))))
@ -164,8 +168,8 @@ final class Plan(env: Env)(implicit system: akka.actor.ActorSystem) extends Lila
key = "stripe.checkout.ip",
enforce = env.net.rateLimit.value
)(
("fast", 6, 10.minute),
("slow", 30, 1.day)
("fast", 8, 10.minute),
("slow", 40, 1.day)
)
def stripeCheckout =
@ -194,6 +198,39 @@ final class Plan(env: Env)(implicit system: akka.actor.ActorSystem) extends Lila
}(rateLimitedFu)
}
def updatePayment =
AuthBody { implicit ctx => me =>
implicit val req = ctx.body
StripeRateLimit(HTTPRequest ipAddress req) {
env.plan.api.userCustomer(me) flatMap {
_.flatMap(_.firstSubscription) ?? { sub =>
env.plan.api
.createPaymentUpdateSession(
sub,
NextUrls(
cancel = s"${env.net.baseUrl}${routes.Plan.index}",
success =
s"${env.net.baseUrl}${routes.Plan.updatePaymentCallback}?session={CHECKOUT_SESSION_ID}"
)
)
.map(session => JsonOk(Json.obj("session" -> Json.obj("id" -> session.id.value))))
.recover(badStripeApiCall)
}
}
}(rateLimitedFu)
}
def updatePaymentCallback =
AuthBody { implicit ctx => me =>
get("session") ?? { session =>
env.plan.api.userCustomer(me) flatMap {
_.flatMap(_.firstSubscription) ?? { sub =>
env.plan.api.updatePayment(sub, session) inject Redirect(routes.Plan.index)
}
}
}
}
def payPalIpn =
Action.async { implicit req =>
import lila.plan.Patron.PayPal

View File

@ -12,6 +12,8 @@ object index {
import trans.patron._
private[plan] val stripeScript = script(src := "https://js.stripe.com/v3/")
def apply(
email: Option[lila.common.EmailAddress],
stripePublicKey: String,
@ -24,7 +26,7 @@ object index {
title = becomePatron.txt(),
moreCss = cssTag("plan"),
moreJs = frag(
script(src := "https://js.stripe.com/v3/"),
stripeScript,
jsModule("checkout"),
embedJsUnsafeLoadThen(s"""checkoutStart("$stripePublicKey")""")
),

View File

@ -12,13 +12,23 @@ object indexStripe {
private val dataForm = attr("data-form")
def apply(me: lila.user.User, patron: lila.plan.Patron, info: lila.plan.MonthlyCustomerInfo)(implicit
def apply(
me: lila.user.User,
patron: lila.plan.Patron,
info: lila.plan.MonthlyCustomerInfo,
stripePublicKey: String
)(implicit
ctx: Context
) =
views.html.base.layout(
title = thankYou.txt(),
moreCss = cssTag("plan"),
moreJs = jsTag("plan.js")
moreJs = frag(
index.stripeScript,
jsModule("plan"),
embedJsUnsafeLoadThen(s"""planStart("$stripePublicKey")""")
),
csp = defaultCsp.withStripe.some
) {
main(cls := "box box-pad plan")(
h1(
@ -77,6 +87,25 @@ object indexStripe {
)
)
),
tr(
th("Payment details"),
td(
info.paymentMethod.flatMap(_.card) map { m =>
frag(
m.brand.toUpperCase,
" - ",
"*" * 12,
m.last4,
" - ",
m.exp_month,
"/",
m.exp_year,
br
)
},
a(cls := "update-payment-method")("Update payment method")
)
),
tr(
th(paymentHistory()),
td(

View File

@ -195,8 +195,10 @@ GET /patron/list controllers.Plan.list
POST /patron/switch controllers.Plan.switch
POST /patron/cancel controllers.Plan.cancel
POST /patron/webhook controllers.Plan.webhook
POST /patron/stripe-checkout controllers.Plan.stripeCheckout
POST /patron/stripe/checkout controllers.Plan.stripeCheckout
POST /patron/ipn controllers.Plan.payPalIpn
POST /patron/stripe/update-payment controllers.Plan.updatePayment
GET /patron/stripe/update-payment controllers.Plan.updatePaymentCallback
GET /features controllers.Plan.features
GET /donate controllers.Main.movedPermanently(to: String = "/patron")

View File

@ -5,7 +5,8 @@ sealed trait CustomerInfo
case class MonthlyCustomerInfo(
subscription: StripeSubscription,
nextInvoice: StripeInvoice,
pastInvoices: List[StripeInvoice]
pastInvoices: List[StripeInvoice],
paymentMethod: Option[StripePaymentMethod]
) extends CustomerInfo
case class OneTimeCustomerInfo(

View File

@ -84,9 +84,6 @@ final class Env(
def process = {
case "patron" :: "lifetime" :: user :: Nil =>
userRepo named user flatMap { _ ?? api.setLifetime } inject "ok"
// someone donated while logged off.
// we cannot bind the charge to the user so they get their precious wings.
// instead, give them a free month.
case "patron" :: "month" :: user :: Nil =>
userRepo named user flatMap { _ ?? api.giveMonth } inject "ok"
case "patron" :: "remove" :: user :: Nil =>

View File

@ -19,14 +19,19 @@ private[plan] object JsonHandlers {
(__ \ "items" \ "data" \ 0).read[StripeItem] and
(__ \ "customer").read[CustomerId] and
(__ \ "cancel_at_period_end").read[Boolean] and
(__ \ "status").read[String]
(__ \ "status").read[String] and
(__ \ "default_payment_method").readNullable[String]
)(StripeSubscription.apply _)
implicit val StripeSubscriptionsReads = Json.reads[StripeSubscriptions]
implicit val StripeCustomerReads = Json.reads[StripeCustomer]
implicit val StripeAddressReads = Json.reads[StripeCharge.Address]
implicit val StripeBillingReads = Json.reads[StripeCharge.BillingDetails]
implicit val StripeChargeReads = Json.reads[StripeCharge]
implicit val StripeInvoiceReads = Json.reads[StripeInvoice]
implicit val StripeSessionReads = Json.reads[StripeSession]
implicit val StripeSessionCompletedReads = Json.reads[StripeCompletedSession]
implicit val StripeSubscriptionsReads = Json.reads[StripeSubscriptions]
implicit val StripeCustomerReads = Json.reads[StripeCustomer]
implicit val StripeAddressReads = Json.reads[StripeCharge.Address]
implicit val StripeBillingReads = Json.reads[StripeCharge.BillingDetails]
implicit val StripeChargeReads = Json.reads[StripeCharge]
implicit val StripeInvoiceReads = Json.reads[StripeInvoice]
implicit val StripeSessionReads = Json.reads[StripeSession]
implicit val StripeSessionCompletedReads = Json.reads[StripeCompletedSession]
implicit val StripeCardReads = Json.reads[StripeCard]
implicit val StripePaymentMethodReads = Json.reads[StripePaymentMethod]
implicit val StripeSetupIntentReads = Json.reads[StripeSetupIntent]
implicit val StripeSessionWithIntentReads = Json.reads[StripeSessionWithIntent]
}

View File

@ -189,16 +189,16 @@ final class PlanApi(
def customerInfo(user: User, customer: StripeCustomer): Fu[Option[CustomerInfo]] =
stripeClient.getNextInvoice(customer.id) zip
stripeClient.getPastInvoices(customer.id) map {
case (Some(nextInvoice), pastInvoices) =>
stripeClient.getPastInvoices(customer.id) zip
customer.firstSubscription.??(stripeClient.getPaymentMethod) map {
case ((Some(nextInvoice), pastInvoices), paymentMethod) =>
customer.firstSubscription match {
case Some(sub) => MonthlyCustomerInfo(sub, nextInvoice, pastInvoices).some
case Some(sub) => MonthlyCustomerInfo(sub, nextInvoice, pastInvoices, paymentMethod).some
case None =>
logger.warn(s"Can't identify ${user.username} monthly subscription $customer")
none
}
case (None, _) =>
OneTimeCustomerInfo(customer).some
case ((None, _), _) => OneTimeCustomerInfo(customer).some
}
import PlanApi.SyncResult.{ ReloadUser, Synced }
@ -273,7 +273,7 @@ final class PlanApi(
"lastLevelUp" -> DateTime.now,
"lifetime" -> false,
"free" -> Patron.Free(DateTime.now),
"expiresAt" -> DateTime.now.plusMonths(1).plusDays(1)
"expiresAt" -> DateTime.now.plusMonths(1)
),
upsert = true
)
@ -417,6 +417,17 @@ final class PlanApi(
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 updatePayment(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 {

View File

@ -19,17 +19,17 @@ final private class StripeClient(
private val STRIPE_VERSION = "2020-08-27"
// private val STRIPE_VERSION = "2016-07-06"
def sessionArgs(data: CreateStripeSession): List[(String, Any)] =
def sessionArgs(customerId: CustomerId, urls: NextUrls): List[(String, Any)] =
List(
"mode" -> (if (data.checkout.freq.renew) "subscription" else "payment"),
"payment_method_types[]" -> "card",
"success_url" -> data.success_url,
"cancel_url" -> data.cancel_url,
"customer" -> data.customer_id.value
"success_url" -> urls.success,
"cancel_url" -> urls.cancel,
"customer" -> customerId.value
)
def createOneTimeSession(data: CreateStripeSession): Fu[StripeSession] = {
val args = sessionArgs(data) ++ List(
val args = sessionArgs(data.customerId, data.urls) ++ List(
"mode" -> "payment",
"line_items[0][price_data][product]" -> config.products.onetime,
"line_items[0][price_data][currency]" -> "USD",
"line_items[0][price_data][unit_amount]" -> data.checkout.amount.value,
@ -48,7 +48,8 @@ final private class StripeClient(
)
def createMonthlySession(data: CreateStripeSession): Fu[StripeSession] = {
val args = sessionArgs(data) ++
val args = sessionArgs(data.customerId, data.urls) ++
List("mode" -> "subscription") ++
recurringPriceArgs("line_items", data.checkout.amount)
postOne[StripeSession]("checkout/sessions", args: _*)
}
@ -85,13 +86,38 @@ final private class StripeClient(
getOne[JsObject](s"events/$id")
def getNextInvoice(customerId: CustomerId): Fu[Option[StripeInvoice]] =
getOne[StripeInvoice](s"invoices/upcoming", "customer" -> customerId.value)
getOne[StripeInvoice]("invoices/upcoming", "customer" -> customerId.value)
def getPastInvoices(customerId: CustomerId): Fu[List[StripeInvoice]] =
getList[StripeInvoice]("invoices", "customer" -> customerId.value)
def getPaymentMethod(sub: StripeSubscription): Fu[Option[StripePaymentMethod]] =
sub.default_payment_method ?? { id =>
getOne[StripePaymentMethod](s"payment_methods/$id")
}
def createPaymentUpdateSession(sub: StripeSubscription, urls: NextUrls): Fu[StripeSession] = {
val args = sessionArgs(sub.customer, urls) ++ List(
"mode" -> "setup",
"setup_intent_data[metadata][subscription_id]" -> sub.id
)
postOne[StripeSession]("checkout/sessions", args: _*)
}
def getSession(id: String): Fu[Option[StripeSessionWithIntent]] =
getOne[StripeSessionWithIntent](s"checkout/sessions/$id", "expand[]" -> "setup_intent")
def setCustomerPaymentMethod(customerId: CustomerId, paymentMethod: String): Funit =
postOne[JsObject](
s"customers/${customerId.value}",
"invoice_settings[default_payment_method]" -> paymentMethod
).void
def setSubscriptionPaymentMethod(subscription: StripeSubscription, paymentMethod: String): Funit =
postOne[JsObject](s"subscriptions/${subscription.id}", "default_payment_method" -> paymentMethod).void
private def getOne[A: Reads](url: String, queryString: (String, Any)*): Fu[Option[A]] =
get[A](url, queryString) dmap Some.apply recover {
get[A](url, queryString) dmap some recover {
case _: NotFoundException => None
case e: DeletedException =>
play.api.Logger("stripe").warn(e.getMessage)

View File

@ -50,20 +50,18 @@ object StripePrice {
val defaultAmounts = List(5, 10, 20, 50).map(Usd.apply).map(_.cents)
}
case class NextUrls(cancel: String, success: String)
case class StripeSession(id: SessionId)
case class CreateStripeSession(
success_url: String,
cancel_url: String,
customer_id: CustomerId,
checkout: Checkout
)
case class CreateStripeSession(customerId: CustomerId, checkout: Checkout, urls: NextUrls)
case class StripeSubscription(
id: String,
item: StripeItem,
customer: CustomerId,
cancel_at_period_end: Boolean,
status: String
status: String,
default_payment_method: Option[String]
) {
def renew = !cancel_at_period_end
def isActive = status == "active"
@ -105,6 +103,14 @@ case class StripeInvoice(
def dateTime = new DateTime(created * 1000)
}
case class StripePaymentMethod(card: Option[StripeCard])
case class StripeCard(brand: String, last4: String, exp_year: Int, exp_month: Int)
case class StripeCompletedSession(customer: CustomerId, mode: String) {
def freq = if (mode == "subscription") Freq.Monthly else Freq.Onetime
}
case class StripeSetupIntent(payment_method: String)
case class StripeSessionWithIntent(setup_intent: StripeSetupIntent)

View File

@ -19,7 +19,7 @@ mkdir -p public/compiled
apps1="common"
apps2="chess ceval game tree chat nvui puz"
apps3="site swiss msg chat cli challenge notify learn insight editor puzzle round analyse lobby tournament tournamentSchedule tournamentCalendar simul dasher speech palantir serviceWorker dgt storm racer"
site_plugins="tvEmbed puzzleEmbed analyseEmbed user modUser clas coordinate captcha expandText team forum account coachShow coachForm challengePage checkout login passwordComplexity tourForm teamBattleForm gameSearch userComplete infiniteScroll flatpickr teamAdmin appeal modGames publicChats contact userGamesDownload"
site_plugins="tvEmbed puzzleEmbed analyseEmbed user modUser clas coordinate captcha expandText team forum account coachShow coachForm challengePage checkout plan login passwordComplexity tourForm teamBattleForm gameSearch userComplete infiniteScroll flatpickr teamAdmin appeal modGames publicChats contact userGamesDownload"
round_plugins="nvui keyboardMove"
analyse_plugins="nvui studyTopicForm"
puzzle_plugins="dashboard"

View File

@ -158,6 +158,11 @@ export default rollupProject({
output: 'checkout',
name: 'checkoutStart',
},
plan: {
input: 'src/plan.ts',
output: 'plan',
name: 'planStart',
},
login: {
input: 'src/login.ts',
output: 'login',

View File

@ -64,11 +64,7 @@ export default function (publicKey: string) {
});
const stripe = window.Stripe(publicKey);
const showError = (error: string) => {
// TODO: consider a more sophisticated error handling mechanism,
// for now, this should work just fine.
alert(error);
};
const showError = (error: string) => alert(error);
$checkout.find('button.stripe').on('click', function () {
const freq = getFreq(),
amount =
@ -77,7 +73,7 @@ export default function (publicKey: string) {
$checkout.find('.service').html(lichess.spinnerHtml);
xhr
.json('/patron/stripe-checkout', {
.json('/patron/stripe/checkout', {
method: 'post',
body: xhr.form({
email: $checkout.data('email'),
@ -86,7 +82,7 @@ export default function (publicKey: string) {
}),
})
.then(data => {
if (data.session && data.session.id) {
if (data.session?.id) {
stripe
.redirectToCheckout({
sessionId: data.session.id,

View File

@ -0,0 +1,20 @@
import * as xhr from 'common/xhr';
export default function (publicKey: string) {
const stripe = window.Stripe(publicKey);
$('.update-payment-method').on('click', () => {
xhr.json('/patron/stripe/update-payment', { method: 'post' }).then(data => {
if (data.session?.id) {
stripe
.redirectToCheckout({
sessionId: data.session.id,
})
.then((result: any) => showError(result.error.message));
} else {
location.assign('/patron');
}
}, showError);
});
}
const showError = (error: string) => alert(error);