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/admin/tests/unit/utils/currency-test.js b/ghost/admin/tests/unit/utils/currency-test.js new file mode 100644 index 00000000000..46a38ff8663 --- /dev/null +++ b/ghost/admin/tests/unit/utils/currency-test.js @@ -0,0 +1,92 @@ +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {getNonDecimal, getSymbol, isZeroDecimalCurrency, minimumAmountForCurrency} from 'ghost-admin/utils/currency'; + +describe('Unit: Util: currency', function () { + describe('isZeroDecimalCurrency', function () { + it('correctly identifies zero decimal currencies', function () { + // JPY is a zero decimal currencies + expect(isZeroDecimalCurrency('JPY')).to.be.true; + expect(isZeroDecimalCurrency('jpy')).to.be.true; + + // Check other zero decimal currencies + expect(isZeroDecimalCurrency('BIF')).to.be.true; + expect(isZeroDecimalCurrency('KRW')).to.be.true; + + // Normal currency returns false + expect(isZeroDecimalCurrency('USD')).to.be.false; + expect(isZeroDecimalCurrency('EUR')).to.be.false; + expect(isZeroDecimalCurrency('GBP')).to.be.false; + }); + + it('handles null or undefined input', function () { + expect(isZeroDecimalCurrency(null)).to.be.false; + expect(isZeroDecimalCurrency(undefined)).to.be.false; + }); + }); + + describe('getNonDecimal', function () { + it('does not divide zero decimal currencies by 100', function () { + // JPY is a zero decimal currency, so the amount is returned as is + expect(getNonDecimal(1000, 'JPY')).to.equal(1000); + expect(getNonDecimal(500, 'jpy')).to.equal(500); + + // Check other zero decimal currencies + expect(getNonDecimal(1000, 'BIF')).to.equal(1000); + expect(getNonDecimal(500, 'KRW')).to.equal(500); + }); + + it('divides normal currencies by 100', function () { + // Normal currency is divided by 100 + // 1000 cents = 10 dollars + expect(getNonDecimal(1000, 'USD')).to.equal(10); + expect(getNonDecimal(500, 'EUR')).to.equal(5); + expect(getNonDecimal(250, 'GBP')).to.equal(2.5); + }); + + it('handles null or undefined currency', function () { + // Divide by 100 if currency not specified + expect(getNonDecimal(1000)).to.equal(10); + expect(getNonDecimal(500, null)).to.equal(5); + expect(getNonDecimal(250, undefined)).to.equal(2.5); + }); + }); + + describe('minimumAmountForCurrency', function () { + it('returns correct minimum amount for JPY', function () { + expect(minimumAmountForCurrency('JPY')).to.equal(100); + expect(minimumAmountForCurrency('jpy')).to.equal(100); + }); + + it('returns correct minimum amount for other currencies', function () { + expect(minimumAmountForCurrency('USD')).to.equal(1); + expect(minimumAmountForCurrency('EUR')).to.equal(1); + expect(minimumAmountForCurrency('GBP')).to.equal(1); + expect(minimumAmountForCurrency('AED')).to.equal(4); + expect(minimumAmountForCurrency('HUF')).to.equal(250); + }); + + it('handles null or undefined input', function () { + expect(minimumAmountForCurrency(null)).to.equal(1); + expect(minimumAmountForCurrency(undefined)).to.equal(1); + }); + }); + + describe('getSymbol', function () { + it('returns correct symbol for JPY', function () { + expect(getSymbol('JPY')).to.equal('¥'); + expect(getSymbol('jpy')).to.equal('¥'); + }); + + it('returns correct symbol for other currencies', function () { + expect(getSymbol('USD')).to.equal('$'); + expect(getSymbol('EUR')).to.equal('€'); + expect(getSymbol('GBP')).to.equal('£'); + }); + + it('handles null or undefined input', function () { + expect(getSymbol(null)).to.equal(''); + expect(getSymbol(undefined)).to.equal(''); + }); + }); +}); \ No newline at end of file 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 83c209725e9..5cc95201417 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 @@ -6,6 +6,18 @@ const TierNameChangeEvent = require('../../../../../../core/server/services/tier const OfferCreatedEvent = require('../../../../../../core/server/services/offers/domain/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 @@ -405,7 +417,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, @@ -418,7 +437,20 @@ 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 + // We divide by 100 for zero-decimal currencies because our amounts are stored in cents internally, + // but Stripe expects them in the base currency units (e.g., 1 yen instead of 100 cents) + 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 }; @@ -448,11 +480,22 @@ 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) + // We divide by 100 for zero-decimal currencies because our amounts are stored in cents internally, + // but for currencies like JPY, Stripe expects amounts in whole yen, not "cents of yen" + 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/members/members-api/services/PaymentsService.test.js b/ghost/core/test/unit/server/services/members/members-api/services/PaymentsService.test.js index fd3eb67ce69..c83e82dce4c 100644 --- a/ghost/core/test/unit/server/services/members/members-api/services/PaymentsService.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/services/PaymentsService.test.js @@ -300,4 +300,101 @@ describe('PaymentsService', function () { assert.equal(stripeAPIService.createCheckoutSession.getCall(0).args[2].trialDays, undefined); }); }); + + describe('createPriceForTierCadence with JPY', function () { + it('correctly handles JPY as a zero decimal currency', async function () { + const BaseModel = Bookshelf.Model.extend({}, { + async add() {}, + async edit() {} + }); + const Offer = BaseModel.extend({ + tableName: 'offers' + }); + const StripeProduct = BaseModel.extend({ + tableName: 'stripe_products' + }); + const StripePrice = BaseModel.extend({ + tableName: 'stripe_prices' + }); + const StripeCustomer = BaseModel.extend({ + tableName: 'stripe_customers' + }); + + const offersAPI = { + repository: {}, + getOffer: sinon.stub(), + createOffer: sinon.stub(), + updateOffer: sinon.stub(), + listOffers: sinon.stub() + }; + + const stripeAPIService = { + _stripe: {}, + _configured: true, + labs: {}, + PAYMENT_METHOD_TYPES: [], + createCheckoutSession: sinon.stub(), + getCustomer: sinon.stub(), + createCustomer: sinon.stub(), + getProduct: sinon.fake.resolves({ + id: 'prod_1', + active: true + }), + createProduct: sinon.fake.resolves({ + id: 'prod_1', + active: true + }), + createPrice: sinon.fake(function (data) { + return Promise.resolve({ + id: 'price_1', + active: data.active, + unit_amount: data.amount, + currency: data.currency, + nickname: data.nickname, + recurring: { + interval: data.interval + } + }); + }), + updatePrice: sinon.stub(), + createCoupon: sinon.stub() + }; + + const service = new PaymentsService({ + Offer, + StripeProduct, + StripePrice, + StripeCustomer, + offersAPI, + stripeAPIService, + settingsCache: { + get: sinon.stub() + } + }); + + // JPYティアを作成 + const tier = await Tier.create({ + name: 'JPY Tier', + slug: 'jpy-tier', + currency: 'JPY', + monthlyPrice: 1000, + yearlyPrice: 10000 + }); + + // Create StripeProduct + const product = StripeProduct.forge({ + id: 'id_1', + stripe_product_id: 'prod_1', + product_id: tier.id.toHexString() + }); + await product.save(null, {method: 'insert'}); + + // Create monthly plan price + await service.createPriceForTierCadence(tier, 'month'); + + // JPY is a zero dicimal currency, so the amount is passed to Stripe without being divided by 100 + assert.equal(stripeAPIService.createPrice.firstCall.args[0].amount, 10); + assert.equal(stripeAPIService.createPrice.firstCall.args[0].currency, 'JPY'); + }); + }); }); 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});