diff --git a/src/routes/likernft/book/store.ts b/src/routes/likernft/book/store.ts index 1fa54d9a6..646fe870c 100644 --- a/src/routes/likernft/book/store.ts +++ b/src/routes/likernft/book/store.ts @@ -41,7 +41,7 @@ import { getStripeClient } from '../../../util/stripe'; import { filterNFTBookListingInfo, filterNFTBookPricesInfo } from '../../../util/ValidationHelper'; import type { NFTBookListingInfo, NFTBookPrice } from '../../../types/book'; import { uploadImageBufferToCache } from '../../../util/fileupload'; -import { convertUSDPriceToCurrency } from '../../../util/pricing'; +import { BOOK_PRICE_OVERRIDE_CURRENCIES, getStripeCurrencyOptionsFromNFTBookPrice } from '../../../util/pricing'; import { normalizeClassIdParam } from '../../../middleware/likernft'; const router = Router(); @@ -315,6 +315,9 @@ router.put(['/:classId/price/:priceIndex', '/class/:classId/price/:priceIndex'], ...oldPriceInfo, ...formatPriceInfo(price), }; + if (!price.priceInDecimalByCurrency) { + delete newPriceInfo.priceInDecimalByCurrency; + } if (oldPriceInfo.stripeProductId) { const stripe = getStripeClient(); @@ -325,19 +328,23 @@ router.put(['/:classId/price/:priceIndex', '/class/:classId/price/:priceIndex'], metadata, }); if (oldPriceInfo.stripePriceId) { - if (oldPriceInfo.priceInDecimal !== newPriceInfo.priceInDecimal) { + const oldCurrencyOverride = oldPriceInfo.priceInDecimalByCurrency || {}; + const newCurrencyOverride = newPriceInfo.priceInDecimalByCurrency || {}; + const isCurrencyOverrideChanged = BOOK_PRICE_OVERRIDE_CURRENCIES.some( + (currency) => oldCurrencyOverride[currency] !== newCurrencyOverride[currency], + ); + if ( + oldPriceInfo.priceInDecimal !== newPriceInfo.priceInDecimal + || isCurrencyOverrideChanged + ) { const newStripePrice = await stripe.prices.create({ product: oldPriceInfo.stripeProductId, currency: 'usd', unit_amount: price.priceInDecimal, - currency_options: { - twd: { - unit_amount: convertUSDPriceToCurrency(price.priceInDecimal / 100, 'twd') * 100, - }, - hkd: { - unit_amount: convertUSDPriceToCurrency(price.priceInDecimal / 100, 'hkd') * 100, - }, - }, + currency_options: getStripeCurrencyOptionsFromNFTBookPrice( + price.priceInDecimal, + newPriceInfo.priceInDecimalByCurrency, + ), }); await stripe.products.update( oldPriceInfo.stripeProductId, diff --git a/src/types/book.d.ts b/src/types/book.d.ts index e2ed1d2d7..90b69ccd5 100644 --- a/src/types/book.d.ts +++ b/src/types/book.d.ts @@ -89,10 +89,19 @@ export interface BookPurchaseCommissionFiltered extends Omit; description?: string | Record; priceInDecimal: number; + priceInDecimalByCurrency?: BookPriceInDecimalByCurrency; isAllowCustomPrice?: boolean; isTippingEnabled?: boolean; isUnlisted?: boolean; @@ -109,6 +118,7 @@ export interface NFTBookPrice { export interface NFTBookPriceFiltered { index: number; price: number; + priceInDecimalByCurrency?: BookPriceInDecimalByCurrency; name?: string | Record; description?: string | Record; stock: number; diff --git a/src/util/ValidationHelper.ts b/src/util/ValidationHelper.ts index 4fb9aa1e8..29c8f05ce 100644 --- a/src/util/ValidationHelper.ts +++ b/src/util/ValidationHelper.ts @@ -538,6 +538,7 @@ export function filterNFTBookPricesInfo( name, description, priceInDecimal, + priceInDecimalByCurrency, isAllowCustomPrice, isTippingEnabled, isUnlisted, @@ -563,6 +564,7 @@ export function filterNFTBookPricesInfo( isTippingEnabled: !priceInDecimal || isTippingEnabled, order: order ?? index, }; + if (priceInDecimalByCurrency) payload.priceInDecimalByCurrency = priceInDecimalByCurrency; if (isOwner) { payload.sold = pSold; payload.stock = pStock; diff --git a/src/util/api/likernft/book/cart.ts b/src/util/api/likernft/book/cart.ts index 191a74f5d..f735b8b90 100644 --- a/src/util/api/likernft/book/cart.ts +++ b/src/util/api/likernft/book/cart.ts @@ -1214,6 +1214,7 @@ export async function formatCartItemsWithInfo(items: CartItem[]) { const priceData = prices[priceIndex]; const { priceInDecimal: originalPriceInDecimal, + priceInDecimalByCurrency, stock, isAllowCustomPrice, name: priceNameObj, @@ -1236,6 +1237,7 @@ export async function formatCartItemsWithInfo(items: CartItem[]) { info = { stock, isAllowCustomPrice, + priceInDecimalByCurrency, name, description, images, @@ -1258,6 +1260,7 @@ export async function formatCartItemsWithInfo(items: CartItem[]) { } = info; const { isAllowCustomPrice, + priceInDecimalByCurrency, originalPriceInDecimal, stock, images, @@ -1296,6 +1299,7 @@ export async function formatCartItemsWithInfo(items: CartItem[]) { ...item, priceName, priceInDecimal, + priceInDecimalByCurrency, customPriceDiffInDecimal, stock, isAllowCustomPrice, diff --git a/src/util/api/likernft/book/index.ts b/src/util/api/likernft/book/index.ts index 5cab80e32..1987a9e8a 100644 --- a/src/util/api/likernft/book/index.ts +++ b/src/util/api/likernft/book/index.ts @@ -27,7 +27,7 @@ import { getBook3NFTClassPageURL } from '../../../liker-land'; import { updateAirtablePublicationRecord } from '../../../airtable'; import { checkIsTrustedPublisher } from './user'; import type { NFTBookListingInfo, NFTBookPrice } from '../../../../types/book'; -import { convertUSDPriceToCurrency } from '../../../pricing'; +import { getStripeCurrencyOptionsFromNFTBookPrice } from '../../../pricing'; export function getAuthorNameFromMetadata(author: unknown): string { if (typeof author === 'string') { @@ -162,6 +162,7 @@ export function formatPriceInfo(price: NFTBookPrice): NFTBookPrice { name: nameInput, description: descriptionInput, priceInDecimal, + priceInDecimalByCurrency, isAllowCustomPrice = false, stock, isAutoDeliver = false, @@ -174,7 +175,7 @@ export function formatPriceInfo(price: NFTBookPrice): NFTBookPrice { if (nameInput) name[locale] = nameInput[locale]; if (descriptionInput) description[locale] = descriptionInput[locale]; }); - return { + const formatted: NFTBookPrice = { name, description, priceInDecimal, @@ -184,6 +185,8 @@ export function formatPriceInfo(price: NFTBookPrice): NFTBookPrice { isUnlisted, autoMemo, }; + if (priceInDecimalByCurrency) formatted.priceInDecimalByCurrency = priceInDecimalByCurrency; + return formatted; } export async function createStripeProductFromNFTBookPrice(classId: string, priceIndex: number, { @@ -210,14 +213,10 @@ export async function createStripeProductFromNFTBookPrice(classId: string, price default_price_data: { currency: 'usd', unit_amount: price.priceInDecimal, - currency_options: { - twd: { - unit_amount: convertUSDPriceToCurrency(price.priceInDecimal / 100, 'twd') * 100, - }, - hkd: { - unit_amount: convertUSDPriceToCurrency(price.priceInDecimal / 100, 'hkd') * 100, - }, - }, + currency_options: getStripeCurrencyOptionsFromNFTBookPrice( + price.priceInDecimal, + price.priceInDecimalByCurrency, + ), }, url: getBook3NFTClassPageURL({ classId, priceIndex }), metadata, diff --git a/src/util/api/likernft/book/purchase.ts b/src/util/api/likernft/book/purchase.ts index 111ef3188..4ad062566 100644 --- a/src/util/api/likernft/book/purchase.ts +++ b/src/util/api/likernft/book/purchase.ts @@ -39,7 +39,7 @@ import { CartItemWithInfo, TransactionFeeInfo } from './type'; import { getClassCurrentTokenId, isEVMClassId, mintNFT, triggerNFTIndexerUpdate, } from '../../../evm/nft'; -import { convertUSDPriceToCurrency } from '../../../pricing'; +import { getCurrencyPriceInDecimal } from '../../../pricing'; import { checkIsFromLikerLand, calculateItemPrices } from './price'; // Re-export pure functions for backward compatibility @@ -694,10 +694,11 @@ export async function formatStripeCheckoutSession({ images: item.images, metadata: productMetadata, }, - unit_amount: convertUSDPriceToCurrency( - item.originalPriceInDecimal / 100, + unit_amount: getCurrencyPriceInDecimal( + item.originalPriceInDecimal, currencyWithDefault, - ) * 100, + item.priceInDecimalByCurrency, + ), }, adjustable_quantity: { enabled: false, @@ -706,10 +707,10 @@ export async function formatStripeCheckoutSession({ }); } if (item.customPriceDiffInDecimal) { - const convertedPriceDiffInDecimal = convertUSDPriceToCurrency( - item.customPriceDiffInDecimal / 100, + const convertedPriceDiffInDecimal = getCurrencyPriceInDecimal( + item.customPriceDiffInDecimal, currencyWithDefault, - ) * 100; + ); lineItems.push({ price_data: { currency: currencyWithDefault, diff --git a/src/util/api/likernft/book/schemas.ts b/src/util/api/likernft/book/schemas.ts index c49609849..07bbd7c4d 100644 --- a/src/util/api/likernft/book/schemas.ts +++ b/src/util/api/likernft/book/schemas.ts @@ -3,6 +3,7 @@ import { MIN_BOOK_PRICE_DECIMAL, NFT_BOOK_TEXT_DEFAULT_LOCALE, } from '../../../../constant'; +import { BOOK_PRICE_OVERRIDE_CURRENCIES } from '../../../pricing'; const LocalizedTextMap = z.record(z.string(), z.string()) .refine( @@ -10,6 +11,15 @@ const LocalizedTextMap = z.record(z.string(), z.string()) { message: `default locale "${NFT_BOOK_TEXT_DEFAULT_LOCALE}" is required` }, ); +const PriceInDecimalByCurrencySchema = z.object( + Object.fromEntries( + BOOK_PRICE_OVERRIDE_CURRENCIES.map((currency) => [ + currency, + z.number().int().min(0), + ]), + ), +).partial(); + export const NFTBookPriceSchema = z.object({ priceInDecimal: z.number() .int() @@ -18,6 +28,7 @@ export const NFTBookPriceSchema = z.object({ (v) => v === 0 || v >= MIN_BOOK_PRICE_DECIMAL, { message: `priceInDecimal must be 0 or >= ${MIN_BOOK_PRICE_DECIMAL}` }, ), + priceInDecimalByCurrency: PriceInDecimalByCurrencySchema.optional(), stock: z.number().int().min(0), name: LocalizedTextMap, description: LocalizedTextMap, diff --git a/src/util/api/likernft/book/type.ts b/src/util/api/likernft/book/type.ts index 67d5d82b5..4e16e4c72 100644 --- a/src/util/api/likernft/book/type.ts +++ b/src/util/api/likernft/book/type.ts @@ -1,3 +1,5 @@ +import type { BookPriceInDecimalByCurrency } from '../../../../types/book'; + export type CartItem = { classId?: string priceIndex?: number @@ -12,6 +14,7 @@ export type CartItemWithInfo = CartItem & { customPriceDiffInDecimal: number; stock: number; isAllowCustomPrice: boolean; + priceInDecimalByCurrency?: BookPriceInDecimalByCurrency; name: string, description: string, images: string[], diff --git a/src/util/pricing.ts b/src/util/pricing.ts index 9bb89744c..3c30604f3 100644 --- a/src/util/pricing.ts +++ b/src/util/pricing.ts @@ -1,5 +1,6 @@ import { USD_PRICE_TIER_LIST, HKD_PRICE_TIER_LIST, TWD_PRICE_TIER_LIST } from '../constant/pricing'; import type { SupportedPlusCurrency } from '../constant'; +import type { BookPriceInDecimalByCurrency } from '../types/book'; const MAX_USD = USD_PRICE_TIER_LIST[USD_PRICE_TIER_LIST.length - 1]!; const MAX_HKD = HKD_PRICE_TIER_LIST[HKD_PRICE_TIER_LIST.length - 1]!; @@ -30,6 +31,36 @@ export function convertUSDPriceToCurrency(price: number, currency: SupportedPlus } } +// USD is excluded by design: it is the stored `priceInDecimal` and the commission base. +export const BOOK_PRICE_OVERRIDE_CURRENCIES = ['hkd', 'twd'] as const; + +export function getCurrencyPriceInDecimal( + usdPriceInDecimal: number, + currency: SupportedPlusCurrency, + priceInDecimalByCurrency?: BookPriceInDecimalByCurrency, +): number { + if (currency === 'usd') return usdPriceInDecimal; + const override = priceInDecimalByCurrency?.[currency]; + if (typeof override === 'number') return override; + return convertUSDPriceToCurrency(usdPriceInDecimal / 100, currency) * 100; +} + +export function getStripeCurrencyOptionsFromNFTBookPrice( + usdPriceInDecimal: number, + priceInDecimalByCurrency?: BookPriceInDecimalByCurrency, +) { + return Object.fromEntries( + BOOK_PRICE_OVERRIDE_CURRENCIES.map((currency) => { + const unitAmount = getCurrencyPriceInDecimal( + usdPriceInDecimal, + currency, + priceInDecimalByCurrency, + ); + return [currency, { unit_amount: unitAmount }]; + }), + ); +} + export function convertCurrencyToUSDPrice(price: number, currency: SupportedPlusCurrency): number { switch (currency) { case 'hkd': {