Skip to content

✨ Add Japanese Yen (JPY) currency support #23151

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
19 changes: 16 additions & 3 deletions apps/admin-x-settings/src/utils/currency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand Down Expand Up @@ -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;
}

Expand Down
14 changes: 12 additions & 2 deletions ghost/admin/app/utils/currency.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand Down Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion ghost/admin/app/utils/subscription-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
};
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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});

Expand Down
Loading