plan currencies WIP

patron-currency
Thibault Duplessis 2021-06-03 12:20:40 +02:00
parent 3923a2d61b
commit 54bab579be
9 changed files with 92 additions and 84 deletions

View File

@ -69,7 +69,7 @@ final class Plan(env: Env)(implicit system: akka.actor.ActorSystem) extends Lila
recentIds <- env.plan.api.recentChargeUserIds
bestIds <- env.plan.api.topPatronUserIds
_ <- env.user.lightUserApi preloadMany { recentIds ::: bestIds }
prices <- env.plan.priceApi.pricesFor(myGeoLocale)
pricing <- env.plan.priceApi.pricingFor(myGeoLocale)
} yield Ok(
html.plan.index(
stripePublicKey = env.plan.stripePublicKey,
@ -77,7 +77,7 @@ final class Plan(env: Env)(implicit system: akka.actor.ActorSystem) extends Lila
patron = patron,
recentIds = recentIds,
bestIds = bestIds,
prices = prices
pricing = pricing
)
)

View File

@ -122,7 +122,8 @@ trait ScalatagsExtensions {
t.setAttr(a.name, scalatags.text.Builder.GenericAttrValueSource(v.value))
}
implicit val charAttr = genericAttr[Char]
implicit val charAttr = genericAttr[Char]
implicit val bigDecimalAttr = genericAttr[BigDecimal]
implicit val optionStringAttr = new AttrValue[Option[String]] {
def apply(t: scalatags.text.Builder, a: Attr, v: Option[String]): Unit = {

View File

@ -5,6 +5,7 @@ import play.api.i18n.Lang
import lila.api.Context
import lila.app.templating.Environment._
import lila.app.ui.ScalatagsTemplate._
import lila.common.String.html.safeJsonValue
import controllers.routes
@ -20,7 +21,7 @@ object index {
patron: Option[lila.plan.Patron],
recentIds: List[String],
bestIds: List[String],
prices: lila.plan.PlanPrices
pricing: lila.plan.PlanPricing
)(implicit ctx: Context) = {
views.html.base.layout(
@ -29,7 +30,9 @@ object index {
moreJs = frag(
stripeScript,
jsModule("checkout"),
embedJsUnsafeLoadThen(s"""checkoutStart("$stripePublicKey")""")
embedJsUnsafeLoadThen(s"""checkoutStart("$stripePublicKey", ${safeJsonValue(
lila.plan.PlanPriceApi.pricingWrites.writes(pricing)
)})""")
),
openGraph = lila.app.ui
.OpenGraph(
@ -85,8 +88,7 @@ object index {
div(
cls := "plan_checkout",
attr("data-email") := email.??(_.value),
attr("data-lifetime-usd") := lila.plan.Cents.lifetime.usd.toString,
attr("data-lifetime-cents") := lila.plan.Cents.lifetime.value
attr("data-lifetime-amount") := pricing.lifetime.amount
)(
raw(s"""
<form class="paypal_checkout onetime none" action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_top">
@ -101,8 +103,8 @@ object index {
<input type="hidden" name="rm" value="1">
<input type="hidden" name="return" value="https://lichess.org/patron/thanks">
<input type="hidden" name="cancel_return" value="https://lichess.org/patron">
<input type="hidden" name="lc" value="US">
<input type="hidden" name="currency_code" value="USD">
<input type="hidden" name="lc" value="${pricing.locale}">
<input type="hidden" name="currency_code" value="${pricing.currencyCode}">
</form>
<form class="paypal_checkout monthly none" action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_top">
<input type="hidden" name="custom" value="${~ctx.userId}">
@ -118,8 +120,8 @@ object index {
<input type="hidden" name="src" value="1">
<input type="hidden" name="p3" value="1">
<input type="hidden" name="t3" value="M">
<input type="hidden" name="lc" value="US">
<input type="hidden" name="currency_code" value="USD">
<input type="hidden" name="lc" value="${pricing.locale}">
<input type="hidden" name="currency_code" value="${pricing.currencyCode}">
</form>
<form class="paypal_checkout lifetime none" action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_top">
<input type="hidden" name="custom" value="${~ctx.userId}">
@ -187,27 +189,26 @@ object index {
),
div(cls := "amount_choice")(
st.group(cls := "radio buttons amount")(
prices.suggestions.map { money =>
val cents = lila.plan.Cents((money.amount * 100).toInt)
val id = s"plan_${cents.value}"
pricing.suggestions.map { money =>
val id = s"plan_${money.code}"
div(
input(
cls := money == pricing.default option "default",
tpe := "radio",
name := "plan",
st.id := id,
cents.usd.value == 10 option checked,
value := cents.value,
attr("data-usd") := cents.usd.toString,
attr("data-amount") := cents.value
money == pricing.default option checked,
value := money.amount,
attr("data-amount") := money.amount
),
label(`for` := id)(cents.usd.toString)
label(`for` := id)(money.display)
)
},
div(cls := "other")(
input(tpe := "radio", name := "plan", id := "plan_other", value := "other"),
label(
`for` := "plan_other",
title := pleaseEnterAmount.txt(),
title := pleaseEnterAmountInX.txt(pricing.currencyCode),
attr("data-trans-other") := otherAmount.txt()
)(otherAmount())
)
@ -215,10 +216,7 @@ object index {
),
div(cls := "amount_fixed none")(
st.group(cls := "radio buttons amount")(
div {
val cents = lila.plan.Cents.lifetime
label(`for` := s"plan_${cents.value}")(cents.usd.toString)
}
div(label(`for` := s"plan_${pricing.lifetime.code}")(pricing.lifetime.display))
)
),
div(cls := "service")(

View File

@ -1337,7 +1337,7 @@ val `recurringBilling` = new I18nKey("patron:recurringBilling")
val `onetime` = new I18nKey("patron:onetime")
val `singleDonation` = new I18nKey("patron:singleDonation")
val `otherAmount` = new I18nKey("patron:otherAmount")
val `pleaseEnterAmount` = new I18nKey("patron:pleaseEnterAmount")
val `pleaseEnterAmountInX` = new I18nKey("patron:pleaseEnterAmountInX")
val `withCreditCard` = new I18nKey("patron:withCreditCard")
val `withPaypal` = new I18nKey("patron:withPaypal")
val `weAreSmallTeam` = new I18nKey("patron:weAreSmallTeam")
@ -1601,11 +1601,11 @@ val `teamPassword` = new I18nKey("team:teamPassword")
val `teamPasswordDescriptionForLeader` = new I18nKey("team:teamPasswordDescriptionForLeader")
val `incorrectTeamPassword` = new I18nKey("team:incorrectTeamPassword")
val `teamAlreadyExists` = new I18nKey("team:teamAlreadyExists")
val `upcomingTourns` = new I18nKey("team:upcomingTourns")
val `completedTourns` = new I18nKey("team:completedTourns")
val `nbMembers` = new I18nKey("team:nbMembers")
val `teamLeaders` = new I18nKey("team:teamLeaders")
val `xJoinRequests` = new I18nKey("team:xJoinRequests")
val `upcomingTourns` = new I18nKey("team:upcomingTourns")
val `completedTourns` = new I18nKey("team:completedTourns")
}
object perfStat {
@ -1893,15 +1893,15 @@ val `yourStreakX` = new I18nKey("puzzle:yourStreakX")
val `streakSkipExplanation` = new I18nKey("puzzle:streakSkipExplanation")
val `continueTheStreak` = new I18nKey("puzzle:continueTheStreak")
val `newStreak` = new I18nKey("puzzle:newStreak")
val `playedXTimes` = new I18nKey("puzzle:playedXTimes")
val `nbPointsBelowYourPuzzleRating` = new I18nKey("puzzle:nbPointsBelowYourPuzzleRating")
val `nbPointsAboveYourPuzzleRating` = new I18nKey("puzzle:nbPointsAboveYourPuzzleRating")
val `fromMyGames` = new I18nKey("puzzle:fromMyGames")
val `lookupOfPlayer` = new I18nKey("puzzle:lookupOfPlayer")
val `fromXGames` = new I18nKey("puzzle:fromXGames")
val `searchPuzzles` = new I18nKey("puzzle:searchPuzzles")
val `fromMyGamesNone` = new I18nKey("puzzle:fromMyGamesNone")
val `fromXGamesFound` = new I18nKey("puzzle:fromXGamesFound")
val `playedXTimes` = new I18nKey("puzzle:playedXTimes")
val `nbPointsBelowYourPuzzleRating` = new I18nKey("puzzle:nbPointsBelowYourPuzzleRating")
val `nbPointsAboveYourPuzzleRating` = new I18nKey("puzzle:nbPointsAboveYourPuzzleRating")
}
object puzzleTheme {

View File

@ -10,6 +10,7 @@ import scala.concurrent.ExecutionContext
import scala.util.Try
import lila.common.config
import play.api.i18n.Lang
case class CurrencyWithRate(currency: Currency, rate: Double)
@ -43,33 +44,24 @@ final class CurrencyApi(
def convert(money: Money, to: Locale): Fu[Option[Money]] =
ratesCache.get {} map { rates =>
for {
currency <- Try(Currency getInstance to).toOption
currency <- Try(Currency getInstance to).toOption.pp(to.toString)
fromRate <- rates get money.currency.getCurrencyCode
toRate <- rates get currency.getCurrencyCode
} yield Money(money.amount * fromRate / toRate, to)
} yield Money(money.amount / fromRate * toRate, to)
}
val US = Locale.US
val USD = Currency getInstance US
def hasCurrency(locale: Locale) = Try(Currency getInstance locale).isSuccess
def byCountryCode(countryCode: Option[String]): Fu[CurrencyWithRate] =
def localeByCountryCodeOrLang(countryCode: Option[String], lang: Lang): Locale =
countryCode
.flatMap { country =>
Try(new Locale("", country)).toOption
}
.?? { locale =>
Try(Currency getInstance locale).toOption ?? { currency =>
ratesCache.get(()) map {
_ get currency.getCurrencyCode map {
CurrencyWithRate(currency, _)
}
}
}
} dmap {
_ | CurrencyWithRate(USD, 1d)
}
.flatMap { code => scala.util.Try(new java.util.Locale("", code)).toOption }
.filter(hasCurrency)
.orElse(lang.locale.some)
.filter(hasCurrency)
.getOrElse(US)
private def hasCurrency(locale: Locale) = Try(Currency getInstance locale).isSuccess
}
private object CurrencyApi {

View File

@ -4,13 +4,18 @@ import cats.implicits._
import java.util.{ Currency, Locale }
import scala.concurrent.ExecutionContext
case class PlanPrices(locale: Locale, suggestions: List[Money], min: Money, max: Money, lifetime: Money)
case class PlanPricing(locale: Locale, suggestions: List[Money], min: Money, max: Money, lifetime: Money) {
val default = suggestions.lift(1) orElse suggestions.headOption getOrElse min
def currencyCode = min.currency.getCurrencyCode
}
final class PlanPriceApi(currencyApi: CurrencyApi)(implicit ec: ExecutionContext) {
import currencyApi.US
val usdPrices = PlanPrices(
val usdPricing = PlanPricing(
locale = US,
suggestions = List(5, 10, 20, 50).map(usd => Money(usd, US)),
min = Money(1, US),
@ -18,16 +23,17 @@ final class PlanPriceApi(currencyApi: CurrencyApi)(implicit ec: ExecutionContext
lifetime = Money(250, US)
)
def pricesFor(locale: Locale): Fu[PlanPrices] =
if (locale == US) fuccess(usdPrices)
def pricingFor(locale: Locale): Fu[PlanPricing] =
if (locale == US) fuccess(usdPricing)
else {
for {
defaults <- usdPrices.suggestions.map(convertAndRound(_, locale)).sequenceFu.map(_.sequence)
min <- convertAndRound(usdPrices.min, locale)
max <- convertAndRound(usdPrices.max, locale)
lifetime <- convertAndRound(usdPrices.lifetime, locale)
} yield (locale.some, defaults, min, max, lifetime).mapN(PlanPrices.apply)
}.dmap(_ | usdPrices)
allSuggestions <- usdPricing.suggestions.map(convertAndRound(_, locale)).sequenceFu.map(_.sequence)
suggestions = allSuggestions.map(_.distinct)
min <- convertAndRound(usdPricing.min, locale)
max <- convertAndRound(usdPricing.max, locale)
lifetime <- convertAndRound(usdPricing.lifetime, locale)
} yield (locale.some, suggestions, min, max, lifetime).mapN(PlanPricing.apply)
}.dmap(_ | usdPricing)
private def convertAndRound(money: Money, to: Locale): Fu[Option[Money]] =
currencyApi.convert(money, to) map2 { case Money(amount, locale) =>
@ -35,10 +41,10 @@ final class PlanPriceApi(currencyApi: CurrencyApi)(implicit ec: ExecutionContext
}
}
private object PlanPriceApi {
object PlanPriceApi {
// round to closest number in 1-2-5 series
def nicelyRound(amount: BigDecimal): BigDecimal =
private def nicelyRound(amount: BigDecimal): BigDecimal =
if (amount <= 0) amount // ?
else {
val scale = math.floor(math.log10(amount.toDouble))
@ -50,4 +56,16 @@ private object PlanPriceApi {
else 10
math.pow(10, scale) * multiplier
}
import play.api.libs.json._
val pricingWrites = OWrites[PlanPricing] { p =>
Json.obj(
"currency" -> p.currencyCode,
"min" -> p.min.amount,
"max" -> p.max.amount,
"lifetime" -> p.lifetime.amount,
"default" -> p.default.amount,
"suggestions" -> p.suggestions.map(_.amount)
)
}
}

View File

@ -40,6 +40,7 @@ object Cents {
case class Money(amount: BigDecimal, locale: Locale) {
val currency = Currency getInstance locale
def display = NumberFormat.getCurrencyInstance(locale).format(amount)
def code = s"${currency.getCurrencyCode}_$amount"
}
case class Country(code: String) extends AnyVal

View File

@ -27,7 +27,7 @@
<string name="onetime">One-time</string>
<string name="singleDonation">A single donation that grants you the Patron wings for one month.</string>
<string name="otherAmount">Other</string>
<string name="pleaseEnterAmount">Please enter an amount in USD</string>
<string name="pleaseEnterAmountInX">Please enter an amount in %s</string>
<string name="withCreditCard">Credit Card</string>
<string name="withPaypal">PayPal</string>
<string name="weAreSmallTeam">We are a small team, so your support makes a huge difference!</string>

View File

@ -1,13 +1,15 @@
import * as xhr from 'common/xhr';
export default function (publicKey: string) {
export interface Pricing {
currency: string;
default: number;
min: number;
max: number;
lifetime: number;
}
export default function (publicKey: string, pricing: Pricing) {
const $checkout = $('div.plan_checkout');
const lifetime = {
cents: parseInt($checkout.data('lifetime-cents')),
usd: $checkout.data('lifetime-usd'),
};
const min = 100,
max = 100 * 100000;
if (location.hash === '#onetime') $('#freq_onetime').trigger('click');
if (location.hash === '#lifetime') $('#freq_lifetime').trigger('click');
@ -19,7 +21,7 @@ export default function (publicKey: string) {
// Other is selected but no amount specified
// happens with backward button
if (!$checkout.find('.amount_choice group.amount input:checked').data('amount'))
$checkout.find('#plan_monthly_1000').trigger('click');
$checkout.find('input.default').trigger('click');
const selectAmountGroup = function () {
const freq = getFreq();
@ -38,25 +40,21 @@ export default function (publicKey: string) {
} catch (e) {
return false;
}
let cents = Math.round(amount * 100);
if (!cents) {
if (!amount) {
$(this).text($(this).data('trans-other'));
$checkout.find('#plan_monthly_1000').trigger('click');
$checkout.find('input.default').trigger('click');
return false;
}
if (cents < min) cents = min;
else if (cents > max) cents = max;
const usd = '$' + cents / 100;
$(this).text(usd);
$(this).siblings('input').data('amount', cents).data('usd', usd);
amount = Math.max(pricing.min, Math.min(pricing.max, amount));
$(this).text(`${pricing.currency} ${amount}`);
$(this).siblings('input').data('amount', amount);
});
$checkout.find('button.paypal').on('click', function () {
const freq = getFreq(),
cents =
freq == 'lifetime' ? lifetime.cents : parseInt($checkout.find('group.amount input:checked').data('amount'));
if (!cents || cents < min || cents > max) return;
const amount = cents / 100;
amount =
freq == 'lifetime' ? pricing.lifetime : parseInt($checkout.find('group.amount input:checked').data('amount'));
if (!amount || amount < pricing.min || amount > pricing.max) return;
const $form = $checkout.find('form.paypal_checkout.' + freq);
$form.find('input.amount').val('' + amount);
($form[0] as HTMLFormElement).submit();
@ -68,8 +66,8 @@ export default function (publicKey: string) {
$checkout.find('button.stripe').on('click', function () {
const freq = getFreq(),
amount =
freq == 'lifetime' ? lifetime.cents : parseInt($checkout.find('group.amount input:checked').data('amount'));
if (amount < min || amount > max) return;
freq == 'lifetime' ? pricing.lifetime : parseInt($checkout.find('group.amount input:checked').data('amount'));
if (amount < pricing.min || amount > pricing.max) return;
$checkout.find('.service').html(lichess.spinnerHtml);
xhr