Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/api/src/stripe/dto/currency-conversion.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ export class ConvertSubscriptionsCurrencyDto {

@ApiPropertyOptional({
description:
'Custom exchange rate. If not provided, uses fixed rate for known pairs (e.g., BGN/EUR)',
'Exchange rate to MULTIPLY source amount by. For BGN→EUR use ~0.5113 (not 1.95583). ' +
'If not provided, uses the fixed BGN/EUR rate automatically.',
example: 0.5113,
})
@Expose()
@IsNumber()
Expand Down
27 changes: 19 additions & 8 deletions apps/api/src/stripe/events/stripe-payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,21 +317,20 @@ export class StripePaymentService {
const subscription: Stripe.Subscription = event.data.object as Stripe.Subscription
Logger.log('[ handleSubscriptionDeleted ]', subscription)

const metadata = subscription.metadata as StripeMetadata & {
cancelReason?: string
currencyConvertedTo?: string
}

// Skip processing if this subscription was canceled for currency conversion
// Check if this subscription was canceled for currency conversion
// The recurring donation will be updated with the new subscription ID, not marked as canceled
if (metadata.cancelReason === 'currency_conversion') {
const cancellationComment = subscription.cancellation_details?.comment
if (cancellationComment?.startsWith('currency_conversion:')) {
const targetCurrency = cancellationComment.split(':')[1]
Logger.log(
`[ handleSubscriptionDeleted ] Subscription ${subscription.id} was canceled for currency ` +
`conversion to ${metadata.currencyConvertedTo}. Skipping status update.`,
`conversion to ${targetCurrency}. Skipping status update.`,
)
return
}

const metadata = subscription.metadata as StripeMetadata

if (!metadata.campaignId) {
throw new BadRequestException(
'Subscription metadata does not contain target campaignId. Subscription is: ' +
Expand All @@ -343,6 +342,18 @@ export class StripePaymentService {
subscription.id,
)
if (!recurringDonation) {
// Check if this subscription is being converted to a new currency
// In that case, the extSubscriptionId has been prefixed with 'converting:'
const convertingDonation = await this.recurringDonationService.findSubscriptionByExtId(
`converting:${subscription.id}`,
)
if (convertingDonation) {
Logger.log(
`[ handleSubscriptionDeleted ] Subscription ${subscription.id} is being converted. ` +
`Skipping status update.`,
)
return
}
Logger.debug('Received a notification about unknown subscription: ' + subscription.id)
return
}
Expand Down
13 changes: 4 additions & 9 deletions apps/api/src/stripe/stripe.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,17 +236,12 @@ describe('StripeService', () => {
const result = await service.convertSingleSubscriptionCurrency('sub_bgn_123', dto)

expect(result.success).toBe(true)
// Should update old subscription metadata with cancelReason, cancel it, create new subscription
expect(stripeMock.subscriptions.update).toHaveBeenCalledWith(
'sub_bgn_123',
expect.objectContaining({
metadata: expect.objectContaining({
cancelReason: 'currency_conversion',
}),
}),
)
// Should cancel old subscription with cancellation_details comment, then create new subscription
expect(stripeMock.subscriptions.cancel).toHaveBeenCalledWith('sub_bgn_123', {
prorate: false,
cancellation_details: {
comment: 'currency_conversion:EUR',
},
})
// New subscription is created with inline price_data (not a separate price)
expect(stripeMock.subscriptions.create).toHaveBeenCalledWith(
Expand Down
177 changes: 132 additions & 45 deletions apps/api/src/stripe/stripe.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,10 @@ export class StripeService {
const convertedAmount = Math.round(originalAmount * exchangeRate)

if (!dryRun) {
// Mark the local recurring donation as pending conversion BEFORE canceling
// This prevents the webhook from marking it as canceled when the old subscription is deleted
await this.markRecurringDonationForConversion(subscriptionId)

// Update the subscription in Stripe (returns new subscription ID)
const newSubscriptionId = await this.updateSubscriptionCurrency(
subscription,
Expand Down Expand Up @@ -927,59 +931,73 @@ export class StripeService {
originalPeriodEnd: new Date(periodEnd * 1000).toISOString(),
}

// Step 1: Mark the old subscription for currency conversion before canceling
// This allows the webhook handler to skip status updates for this subscription
await this.stripeClient.subscriptions.update(subscription.id, {
metadata: {
...subscription.metadata,
cancelReason: 'currency_conversion',
currencyConvertedTo: newCurrency,
currencyConvertedAt: new Date().toISOString(),
},
})

// Step 2: Cancel the old subscription immediately
// Cancel the old subscription immediately
// We need to cancel immediately because Stripe doesn't allow customers to have
// active subscriptions in multiple currencies at the same time
await this.stripeClient.subscriptions.cancel(subscription.id, {
prorate: false, // Don't create prorated credit, we'll use trial instead
})
try {
await this.stripeClient.subscriptions.cancel(subscription.id, {
prorate: false, // Don't create prorated credit, we'll use trial instead
cancellation_details: {
comment: `currency_conversion:${newCurrency.toUpperCase()}`,
},
})
} catch (error) {
// If the subscription is already canceled, that's fine - continue with creating the new one
if (error.code === 'resource_missing' || error.message?.includes('already been canceled')) {
Logger.warn(
`[Stripe] Subscription ${subscription.id} was already canceled. Continuing with new subscription creation.`,
)
} else {
Logger.error(`[Stripe] Failed to cancel subscription ${subscription.id}: ${error.message}`)
throw new Error(`Failed to cancel subscription: ${error.message}`)
}
}

Logger.debug(
`[Stripe] Canceled subscription ${subscription.id} immediately for currency conversion. ` +
`Original period end was ${new Date(periodEnd * 1000).toISOString()}`,
)

// Step 2: Create new subscription in target currency
// Step 3: Create new subscription in target currency
// Use trial_end set to the original period end to compensate for remaining time
// This way the customer doesn't lose any paid time from the old subscription
const newSubscription = await this.stripeClient.subscriptions.create({
customer: customerId,
items: [
{
price_data: {
currency: newCurrency,
product: productId,
unit_amount: newAmount,
recurring: {
interval: price.recurring?.interval ?? 'month',
interval_count: price.recurring?.interval_count ?? 1,
let newSubscription: Stripe.Subscription
try {
newSubscription = await this.stripeClient.subscriptions.create({
customer: customerId,
items: [
{
price_data: {
currency: newCurrency,
product: productId,
unit_amount: newAmount,
recurring: {
interval: price.recurring?.interval ?? 'month',
interval_count: price.recurring?.interval_count ?? 1,
},
},
},
},
],
metadata: conversionMetadata,
// Trial until the original period end - customer gets remaining time for free
trial_end: periodEnd,
proration_behavior: 'none',
// Copy payment settings from old subscription if available
...(subscription.default_payment_method && {
default_payment_method:
typeof subscription.default_payment_method === 'string'
? subscription.default_payment_method
: subscription.default_payment_method.id,
}),
})
],
metadata: conversionMetadata,
// Trial until the original period end - customer gets remaining time for free
trial_end: periodEnd,
proration_behavior: 'none',
// Copy payment settings from old subscription if available
...(subscription.default_payment_method && {
default_payment_method:
typeof subscription.default_payment_method === 'string'
? subscription.default_payment_method
: subscription.default_payment_method.id,
}),
})
} catch (error) {
Logger.error(
`[Stripe] Failed to create new subscription for customer ${customerId}: ${error.message}`,
)
throw new Error(
`Failed to create new subscription in ${newCurrency.toUpperCase()}: ${error.message}`,
)
}

Logger.log(
`[Stripe] Currency conversion complete: ${subscription.id} -> ${newSubscription.id}. ` +
Expand All @@ -1001,6 +1019,33 @@ export class StripeService {
newCurrency: Currency,
newAmount: number,
): Promise<void> {
// First check for the 'converting:' prefix since that's what markRecurringDonationForConversion sets
const convertingRecord = await this.prisma.recurringDonation.findFirst({
where: { extSubscriptionId: `converting:${oldSubscriptionId}` },
})

if (convertingRecord) {
await this.prisma.recurringDonation.update({
where: { id: convertingRecord.id },
data: {
extSubscriptionId: newSubscriptionId,
currency: newCurrency,
amount: newAmount,
// Set status to active - the Stripe "trial" is just a technical mechanism
// to preserve the billing cycle, not an actual trial period
status: RecurringDonationStatus.active,
},
})
Logger.debug(
`[Stripe] Updated recurring donation ${convertingRecord.id}: ` +
`subscription converting:${oldSubscriptionId} -> ${newSubscriptionId}, ` +
`currency -> ${newCurrency}, amount -> ${newAmount}, status -> active`,
)
return
}

// Fallback: check for old subscription ID directly (in case markRecurringDonationForConversion
// was skipped or failed)
const recurringDonation = await this.prisma.recurringDonation.findFirst({
where: { extSubscriptionId: oldSubscriptionId },
})
Expand All @@ -1012,19 +1057,61 @@ export class StripeService {
extSubscriptionId: newSubscriptionId,
currency: newCurrency,
amount: newAmount,
// Set status to active - the Stripe "trial" is just a technical mechanism
// to preserve the billing cycle, not an actual trial period
status: RecurringDonationStatus.active,
},
})
Logger.debug(
`[Stripe] Updated local recurring donation ${recurringDonation.id}: ` +
`[Stripe] Updated recurring donation ${recurringDonation.id}: ` +
`subscription ${oldSubscriptionId} -> ${newSubscriptionId}, ` +
`currency -> ${newCurrency}, amount -> ${newAmount}, status -> active`,
)
return
}

Logger.warn(
`[Stripe] No local recurring donation found for subscription ${oldSubscriptionId} ` +
`(checked both 'converting:${oldSubscriptionId}' and '${oldSubscriptionId}')`,
)
}

/**
* Mark a recurring donation as pending currency conversion.
* This is done BEFORE canceling the subscription in Stripe, so that when the
* webhook fires for the deleted subscription, we know to skip the status update.
*/
private async markRecurringDonationForConversion(subscriptionId: string): Promise<void> {
// First check if it's already marked for conversion (from a previous attempt)
const alreadyConverting = await this.prisma.recurringDonation.findFirst({
where: { extSubscriptionId: `converting:${subscriptionId}` },
})

if (alreadyConverting) {
Logger.debug(
`[Stripe] Recurring donation ${alreadyConverting.id} already marked for conversion ` +
`(subscription ${subscriptionId})`,
)
return
}

const recurringDonation = await this.prisma.recurringDonation.findFirst({
where: { extSubscriptionId: subscriptionId },
})

if (recurringDonation) {
await this.prisma.recurringDonation.update({
where: { id: recurringDonation.id },
data: {
// Prefix with 'converting:' to mark as pending conversion
extSubscriptionId: `converting:${subscriptionId}`,
},
})
Logger.debug(
`[Stripe] Marked recurring donation ${recurringDonation.id} for currency conversion ` +
`(subscription ${subscriptionId})`,
)
} else {
Logger.warn(
`[Stripe] No local recurring donation found for subscription ${oldSubscriptionId}`,
`[Stripe] No recurring donation found to mark for conversion (subscription ${subscriptionId})`,
)
}
}
Expand Down
Loading