diff --git a/apps/admin-x-settings/src/utils/currency.ts b/apps/admin-x-settings/src/utils/currency.ts index be1d5da5669..a69e551e35d 100644 --- a/apps/admin-x-settings/src/utils/currency.ts +++ b/apps/admin-x-settings/src/utils/currency.ts @@ -60,6 +60,7 @@ export const currencies: CurrencyOption[] = [ {isoCode: 'INR', name: 'Indian rupee'}, {isoCode: 'ISK', name: 'Icelandic króna'}, {isoCode: 'JMD', name: 'Jamaican dollar'}, + {isoCode: 'JPY', name: 'Japanese yen'}, {isoCode: 'KES', name: 'Kenyan shilling'}, {isoCode: 'KGS', name: 'Kyrgyzstani som'}, {isoCode: 'KHR', name: 'Cambodian riel'}, @@ -152,12 +153,24 @@ export function getSymbol(currency: string): string { return Intl.NumberFormat('en', {currency, style: 'currency'}).format(0).replace(/[\d\s.]/g, ''); } -// We currently only support decimal currencies -export function currencyToDecimal(integerAmount: number): number { +// Zero-decimal currencies don't use minor units +const zeroDecimalCurrencies = ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF']; + +export function isZeroDecimalCurrency(currency: string): boolean { + return zeroDecimalCurrencies.includes(currency?.toUpperCase()); +} + +export function currencyToDecimal(integerAmount: number, currency?: string): number { + if (currency && isZeroDecimalCurrency(currency)) { + return integerAmount; + } return integerAmount / 100; } -export function currencyFromDecimal(decimalAmount: number): number { +export function currencyFromDecimal(decimalAmount: number, currency?: string): number { + if (currency && isZeroDecimalCurrency(currency)) { + return decimalAmount; + } return decimalAmount * 100; } diff --git a/ghost/admin/app/utils/currency.js b/ghost/admin/app/utils/currency.js index 225476d4285..8eeb37ac4e4 100644 --- a/ghost/admin/app/utils/currency.js +++ b/ghost/admin/app/utils/currency.js @@ -53,6 +53,7 @@ export const currencies = [ {isoCode: 'INR', name: 'Indian rupee'}, {isoCode: 'ISK', name: 'Icelandic króna'}, {isoCode: 'JMD', name: 'Jamaican dollar'}, + {isoCode: 'JPY', name: 'Japanese yen'}, {isoCode: 'KES', name: 'Kenyan shilling'}, {isoCode: 'KGS', name: 'Kyrgyzstani som'}, {isoCode: 'KHR', name: 'Cambodian riel'}, @@ -127,8 +128,17 @@ export function getSymbol(currency) { return Intl.NumberFormat('en', {currency, style: 'currency'}).format(0).replace(/[\d\s.]/g, ''); } -// We currently only support decimal currencies -export function getNonDecimal(amount/*, currency*/) { +// Zero-decimal currencies don't use minor units +const zeroDecimalCurrencies = ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF']; + +export function isZeroDecimalCurrency(currency) { + return zeroDecimalCurrencies.includes(currency?.toUpperCase()); +} + +export function getNonDecimal(amount, currency) { + if (currency && isZeroDecimalCurrency(currency)) { + return amount; + } return amount / 100; } diff --git a/ghost/admin/app/utils/subscription-data.js b/ghost/admin/app/utils/subscription-data.js index 9a396963d96..e90ffe929e8 100644 --- a/ghost/admin/app/utils/subscription-data.js +++ b/ghost/admin/app/utils/subscription-data.js @@ -17,7 +17,7 @@ export function getSubscriptionData(sub) { price: { ...sub.price, currencySymbol: getSymbol(sub.price.currency), - nonDecimalAmount: getNonDecimal(sub.price.amount) + nonDecimalAmount: getNonDecimal(sub.price.amount, sub.price.currency) }, isComplimentary: isComplimentary(sub), compExpiry: compExpiry(sub), diff --git a/ghost/core/core/server/services/members/members-api/services/PaymentsService.js b/ghost/core/core/server/services/members/members-api/services/PaymentsService.js index 7f19fbfa5cf..4a80cc90136 100644 --- a/ghost/core/core/server/services/members/members-api/services/PaymentsService.js +++ b/ghost/core/core/server/services/members/members-api/services/PaymentsService.js @@ -4,6 +4,18 @@ const {TierCreatedEvent, TierPriceChangeEvent, TierNameChangeEvent} = require('@ const OfferCreatedEvent = require('@tryghost/members-offers').events.OfferCreatedEvent; const {BadRequestError} = require('@tryghost/errors'); +// Zero-decimal currencies don't use minor units +const zeroDecimalCurrencies = ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF']; + +/** + * Check if a currency is a zero-decimal currency + * @param {string} currency - Currency code + * @returns {boolean} + */ +function isZeroDecimalCurrency(currency) { + return zeroDecimalCurrencies.includes(currency?.toUpperCase()); +} + class PaymentsService { /** * @param {object} deps @@ -403,7 +415,14 @@ class PaymentsService { async getPriceForTierCadence(tier, cadence) { const product = await this.getProductForTier(tier); const currency = tier.currency.toLowerCase(); - const amount = tier.getPrice(cadence); + + // Get the price amount from the tier + const tierAmount = tier.getPrice(cadence); + + // For database query, we need to use the amount as stored in the database + // This should match what we store in createPriceForTierCadence + const amount = tierAmount; + const rows = await this.StripePriceModel.where({ stripe_product_id: product.id, currency, @@ -416,7 +435,18 @@ class PaymentsService { for (const row of rows) { try { const price = await this.stripeAPIService.getPrice(row.stripe_price_id); - if (price.active && price.currency.toLowerCase() === currency && price.unit_amount === amount && price.recurring?.interval === cadence) { + + // For comparison with Stripe's unit_amount, we need to handle zero-decimal currencies + // For zero-decimal currencies, Stripe's unit_amount will be the same as our tierAmount + // For other currencies, Stripe's unit_amount will be 100x our tierAmount + const expectedUnitAmount = isZeroDecimalCurrency(tier.currency) + ? tierAmount / 100 + : tierAmount; + + if (price.active && + price.currency.toLowerCase() === currency && + price.unit_amount === expectedUnitAmount && + price.recurring?.interval === cadence) { return { id: price.id }; @@ -446,11 +476,20 @@ class PaymentsService { */ async createPriceForTierCadence(tier, cadence) { const product = await this.getProductForTier(tier); + // Get the price amount + const priceAmount = tier.getPrice(cadence); + + // For zero-decimal currencies like JPY, we don't need to multiply by 100 + // For other currencies, Stripe expects the amount in cents (smallest currency unit) + const amount = isZeroDecimalCurrency(tier.currency) + ? priceAmount / 100 + : priceAmount; + const price = await this.stripeAPIService.createPrice({ product: product.id, interval: cadence, currency: tier.currency, - amount: tier.getPrice(cadence), + amount: amount, nickname: cadence === 'month' ? 'Monthly' : 'Yearly', type: 'recurring', active: true diff --git a/ghost/core/test/unit/server/services/milestones/MilestonesService.test.js b/ghost/core/test/unit/server/services/milestones/MilestonesService.test.js index 5116e9a1acb..19298e52d52 100644 --- a/ghost/core/test/unit/server/services/milestones/MilestonesService.test.js +++ b/ghost/core/test/unit/server/services/milestones/MilestonesService.test.js @@ -34,6 +34,10 @@ describe('MilestonesService', function () { { currency: 'eur', values: [0, 1000, 10000, 50000, 100000, 250000, 500000, 1000000] + }, + { + currency: 'jpy', + values: [0, 1000, 10000, 50000, 100000, 250000, 500000, 1000000] } ], members: [0, 100, 1000, 10000, 50000, 100000, 250000, 500000, 1000000], @@ -75,6 +79,29 @@ describe('MilestonesService', function () { assert(domainEventSpyResult.data.meta.reason === 'initial'); }); + it('Adds initial JPY ARR milestone without sending email', async function () { + repository = new InMemoryMilestoneRepository({DomainEvents}); + const service = new MilestonesService({ + repository, + milestonesConfig, + queries: { + async getARR() { + return [{currency: 'jpy', arr: 750}]; + }, + async hasImportedMembersInPeriod() { + return false; + }, + async getDefaultCurrency() { + return 'jpy'; + } + } + }); + const result = await service.checkMilestones('arr'); + assert(result.currency === 'jpy'); + assert(result.value === 0); + assert(result.name === 'arr-0-jpy'); + }); + it('Adds first ARR milestones but does not send email if no previous milestones', async function () { repository = new InMemoryMilestoneRepository({DomainEvents});