Skip to content

Commit b456766

Browse files
authored
feat: Add endpoints to convert stripe subscription currencies (#715)
* feat: Add endpoints to convert stripe subscriptions * chore: Fix subscription currency conversion not working * fix: Failing tests
1 parent 8ab283d commit b456766

File tree

10 files changed

+1750
-22
lines changed

10 files changed

+1750
-22
lines changed

apps/api/src/common/money.spec.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { BGN_TO_EUR_RATE, toMoney, eurToBgn, bgnToEur, getFixedExchangeRate } from './money'
2+
3+
describe('money utilities', () => {
4+
describe('BGN_TO_EUR_RATE', () => {
5+
it('should be the fixed BGN to EUR rate', () => {
6+
expect(BGN_TO_EUR_RATE).toBe(1.95583)
7+
})
8+
})
9+
10+
describe('toMoney', () => {
11+
it('should convert number to cents', () => {
12+
expect(toMoney(160.2)).toBe(16020)
13+
})
14+
15+
it('should convert string to cents', () => {
16+
expect(toMoney('160.2')).toBe(16020)
17+
})
18+
19+
it('should handle whole numbers', () => {
20+
expect(toMoney(100)).toBe(10000)
21+
})
22+
23+
it('should handle zero', () => {
24+
expect(toMoney(0)).toBe(0)
25+
})
26+
27+
it('should round floating point precision issues', () => {
28+
// 0.1 + 0.2 = 0.30000000000000004 in JavaScript
29+
expect(toMoney(0.1 + 0.2)).toBe(30)
30+
})
31+
})
32+
33+
describe('eurToBgn', () => {
34+
it('should convert EUR to BGN using the fixed rate', () => {
35+
const result = eurToBgn(100)
36+
expect(result).toBeCloseTo(195.583, 3)
37+
})
38+
39+
it('should handle amount in cents', () => {
40+
// 10000 cents = 100 EUR -> should give BGN amount
41+
const result = eurToBgn(10000, true)
42+
expect(result).toBeCloseTo(195.583, 3)
43+
})
44+
45+
it('should handle zero', () => {
46+
expect(eurToBgn(0)).toBe(0)
47+
})
48+
49+
it('should handle decimal amounts', () => {
50+
const result = eurToBgn(50.5)
51+
expect(result).toBeCloseTo(98.769415, 3)
52+
})
53+
})
54+
55+
describe('bgnToEur', () => {
56+
it('should convert BGN to EUR using the fixed rate', () => {
57+
const result = bgnToEur(195.583)
58+
expect(result).toBeCloseTo(100, 3)
59+
})
60+
61+
it('should handle amount in cents and round result', () => {
62+
// 19558 stotinki (BGN cents) should convert to EUR cents
63+
const result = bgnToEur(19558, true)
64+
// 19558 / 1.95583 ≈ 9999.95 -> rounds to 10000
65+
expect(result).toBe(10000)
66+
})
67+
68+
it('should handle zero', () => {
69+
expect(bgnToEur(0)).toBe(0)
70+
})
71+
72+
it('should handle decimal amounts', () => {
73+
const result = bgnToEur(100)
74+
expect(result).toBeCloseTo(51.129, 3)
75+
})
76+
77+
it('should properly round cents conversion', () => {
78+
// Test that 1000 BGN cents converts correctly
79+
const result = bgnToEur(1000, true)
80+
// 1000 / 1.95583 ≈ 511.29 -> rounds to 511
81+
expect(result).toBe(511)
82+
})
83+
84+
it('should be the inverse of eurToBgn', () => {
85+
const originalEur = 100
86+
const bgn = eurToBgn(originalEur)
87+
const backToEur = bgnToEur(bgn)
88+
expect(backToEur).toBeCloseTo(originalEur, 10)
89+
})
90+
})
91+
92+
describe('getFixedExchangeRate', () => {
93+
it('should return the BGN to EUR rate', () => {
94+
const rate = getFixedExchangeRate('BGN', 'EUR')
95+
expect(rate).toBeDefined()
96+
expect(rate).toBeCloseTo(1 / BGN_TO_EUR_RATE, 10)
97+
expect(rate).toBeCloseTo(0.5113, 4)
98+
})
99+
100+
it('should return the EUR to BGN rate', () => {
101+
const rate = getFixedExchangeRate('EUR', 'BGN')
102+
expect(rate).toBeDefined()
103+
expect(rate).toBe(BGN_TO_EUR_RATE)
104+
})
105+
106+
it('should return undefined for unknown currency pairs', () => {
107+
expect(getFixedExchangeRate('USD', 'EUR')).toBeUndefined()
108+
expect(getFixedExchangeRate('GBP', 'BGN')).toBeUndefined()
109+
expect(getFixedExchangeRate('BGN', 'USD')).toBeUndefined()
110+
})
111+
112+
it('should return undefined for same currency', () => {
113+
expect(getFixedExchangeRate('EUR', 'EUR')).toBeUndefined()
114+
expect(getFixedExchangeRate('BGN', 'BGN')).toBeUndefined()
115+
})
116+
117+
it('should be case sensitive', () => {
118+
expect(getFixedExchangeRate('bgn', 'eur')).toBeUndefined()
119+
expect(getFixedExchangeRate('Bgn', 'Eur')).toBeUndefined()
120+
})
121+
122+
it('BGN_EUR rate should allow correct conversion', () => {
123+
const rate = getFixedExchangeRate('BGN', 'EUR')!
124+
const bgnAmount = 195.583
125+
const eurAmount = bgnAmount * rate
126+
expect(eurAmount).toBeCloseTo(100, 3)
127+
})
128+
129+
it('EUR_BGN rate should allow correct conversion', () => {
130+
const rate = getFixedExchangeRate('EUR', 'BGN')!
131+
const eurAmount = 100
132+
const bgnAmount = eurAmount * rate
133+
expect(bgnAmount).toBeCloseTo(195.583, 3)
134+
})
135+
})
136+
})

apps/api/src/common/money.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,37 @@ export function eurToBgn(eurAmount: number, isInCents = false): number {
2626
const amountInEur = isInCents ? eurAmount / 100 : eurAmount
2727
return amountInEur * BGN_TO_EUR_RATE
2828
}
29+
30+
/**
31+
* Converts BGN amount to EUR amount using the fixed exchange rate
32+
* @param bgnAmount - Amount in BGN (can be in cents or regular units)
33+
* @param isInCents - Whether the input amount is in cents (default: false)
34+
* @returns Amount in EUR (in the same unit as input - cents or regular)
35+
*/
36+
export function bgnToEur(bgnAmount: number, isInCents = false): number {
37+
// When converting BGN to EUR, we divide by the rate since 1 EUR = 1.95583 BGN
38+
if (isInCents) {
39+
// For cents, we need to maintain precision and round
40+
return Math.round(bgnAmount / BGN_TO_EUR_RATE)
41+
}
42+
return bgnAmount / BGN_TO_EUR_RATE
43+
}
44+
45+
/**
46+
* Gets the fixed exchange rate for a currency pair
47+
* Returns undefined if no fixed rate exists for the pair
48+
* @param sourceCurrency - Source currency code (e.g., 'BGN')
49+
* @param targetCurrency - Target currency code (e.g., 'EUR')
50+
* @returns The exchange rate to multiply by, or undefined if no fixed rate exists
51+
*/
52+
export function getFixedExchangeRate(
53+
sourceCurrency: string,
54+
targetCurrency: string,
55+
): number | undefined {
56+
const rateKey = `${sourceCurrency}_${targetCurrency}`
57+
const fixedRates: Record<string, number> = {
58+
BGN_EUR: 1 / BGN_TO_EUR_RATE, // ~0.5113 (1 BGN = 0.5113 EUR)
59+
EUR_BGN: BGN_TO_EUR_RATE, // 1.95583 (1 EUR = 1.95583 BGN)
60+
}
61+
return fixedRates[rateKey]
62+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
2+
import { Currency } from '@prisma/client'
3+
import { Expose } from 'class-transformer'
4+
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'
5+
6+
/**
7+
* Request DTO for bulk currency conversion of all subscriptions
8+
* Designed to be generic for future currency conversions (e.g., BGN to EUR)
9+
*/
10+
export class ConvertSubscriptionsCurrencyDto {
11+
@ApiProperty({ enum: Currency, description: 'Source currency to convert from' })
12+
@Expose()
13+
@IsEnum(Currency)
14+
sourceCurrency: Currency
15+
16+
@ApiProperty({ enum: Currency, description: 'Target currency to convert to' })
17+
@Expose()
18+
@IsEnum(Currency)
19+
targetCurrency: Currency
20+
21+
@ApiPropertyOptional({
22+
description:
23+
'Custom exchange rate. If not provided, uses fixed rate for known pairs (e.g., BGN/EUR)',
24+
})
25+
@Expose()
26+
@IsNumber()
27+
@IsOptional()
28+
exchangeRate?: number
29+
30+
@ApiPropertyOptional({
31+
description: 'Run in dry-run mode without making actual changes',
32+
default: false,
33+
})
34+
@Expose()
35+
@IsBoolean()
36+
@IsOptional()
37+
dryRun?: boolean
38+
39+
@ApiPropertyOptional({
40+
description: 'Batch size for processing subscriptions (for pagination)',
41+
default: 100,
42+
})
43+
@Expose()
44+
@IsNumber()
45+
@IsOptional()
46+
batchSize?: number
47+
48+
@ApiPropertyOptional({
49+
description:
50+
'Delay in milliseconds between each conversion to respect Stripe rate limits. ' +
51+
'Stripe allows ~100 requests/second in live mode, ~25/second in test mode.',
52+
default: 100,
53+
})
54+
@Expose()
55+
@IsNumber()
56+
@IsOptional()
57+
delayMs?: number
58+
}
59+
60+
/**
61+
* Request DTO for single subscription currency conversion
62+
*/
63+
export class ConvertSingleSubscriptionCurrencyDto {
64+
@ApiProperty({ enum: Currency, description: 'Target currency to convert to' })
65+
@Expose()
66+
@IsEnum(Currency)
67+
targetCurrency: Currency
68+
69+
@ApiPropertyOptional({
70+
description: 'Custom exchange rate. If not provided, uses fixed rate for known pairs',
71+
})
72+
@Expose()
73+
@IsNumber()
74+
@IsOptional()
75+
exchangeRate?: number
76+
77+
@ApiPropertyOptional({
78+
description: 'Run in dry-run mode without making actual changes',
79+
default: false,
80+
})
81+
@Expose()
82+
@IsBoolean()
83+
@IsOptional()
84+
dryRun?: boolean
85+
}
86+
87+
/**
88+
* Individual subscription conversion result
89+
*/
90+
export class SubscriptionConversionResultDto {
91+
@ApiProperty({ description: 'Stripe subscription ID' })
92+
@Expose()
93+
subscriptionId: string
94+
95+
@ApiProperty({ description: 'Original amount in cents' })
96+
@Expose()
97+
originalAmount: number
98+
99+
@ApiProperty({ description: 'Converted amount in cents' })
100+
@Expose()
101+
convertedAmount: number
102+
103+
@ApiProperty({ enum: Currency, description: 'Original currency before conversion' })
104+
@Expose()
105+
originalCurrency: string
106+
107+
@ApiProperty({ enum: Currency, description: 'Target currency after conversion' })
108+
@Expose()
109+
targetCurrency: Currency
110+
111+
@ApiProperty({ description: 'Whether the conversion was successful' })
112+
@Expose()
113+
success: boolean
114+
115+
@ApiPropertyOptional({ description: 'Error message if conversion failed' })
116+
@Expose()
117+
@IsString()
118+
@IsOptional()
119+
errorMessage?: string
120+
121+
@ApiPropertyOptional({ description: 'Campaign ID from subscription metadata' })
122+
@Expose()
123+
@IsString()
124+
@IsOptional()
125+
campaignId?: string
126+
}
127+
128+
/**
129+
* Response DTO for bulk currency conversion operation
130+
*/
131+
export class ConvertSubscriptionsCurrencyResponseDto {
132+
@ApiProperty({ description: 'Total subscriptions processed' })
133+
@Expose()
134+
totalFound: number
135+
136+
@ApiProperty({ description: 'Number of successfully converted subscriptions' })
137+
@Expose()
138+
successCount: number
139+
140+
@ApiProperty({ description: 'Number of failed conversions' })
141+
@Expose()
142+
failedCount: number
143+
144+
@ApiProperty({
145+
description:
146+
'Number of subscriptions skipped (already in target currency or not matching source)',
147+
})
148+
@Expose()
149+
skippedCount: number
150+
151+
@ApiProperty({ description: 'Exchange rate used for conversion' })
152+
@Expose()
153+
exchangeRate: number
154+
155+
@ApiProperty({ enum: Currency, description: 'Source currency' })
156+
@Expose()
157+
sourceCurrency: Currency
158+
159+
@ApiProperty({ enum: Currency, description: 'Target currency' })
160+
@Expose()
161+
targetCurrency: Currency
162+
163+
@ApiProperty({ description: 'Whether this was a dry run (no actual changes made)' })
164+
@Expose()
165+
dryRun: boolean
166+
167+
@ApiProperty({
168+
type: [SubscriptionConversionResultDto],
169+
description: 'Detailed results for each subscription',
170+
})
171+
@Expose()
172+
results: SubscriptionConversionResultDto[]
173+
174+
@ApiProperty({ description: 'Timestamp when conversion started' })
175+
@Expose()
176+
startedAt: Date
177+
178+
@ApiProperty({ description: 'Timestamp when conversion completed' })
179+
@Expose()
180+
completedAt: Date
181+
}

0 commit comments

Comments
 (0)