allow changing stripe payment method
parent
0b448dc8ba
commit
1988f64b7b
|
@ -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
|
||||
|
|
|
@ -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")""")
|
||||
),
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 =>
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
2
ui/build
2
ui/build
|
@ -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"
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
Loading…
Reference in New Issue