diff --git a/apps/api/src/stripe/dto/currency-conversion.dto.ts b/apps/api/src/stripe/dto/currency-conversion.dto.ts index 5ad3c004..e24be55f 100644 --- a/apps/api/src/stripe/dto/currency-conversion.dto.ts +++ b/apps/api/src/stripe/dto/currency-conversion.dto.ts @@ -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() diff --git a/apps/api/src/stripe/events/stripe-payment.service.ts b/apps/api/src/stripe/events/stripe-payment.service.ts index 662e94b8..c75c03f3 100644 --- a/apps/api/src/stripe/events/stripe-payment.service.ts +++ b/apps/api/src/stripe/events/stripe-payment.service.ts @@ -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: ' + @@ -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 } diff --git a/apps/api/src/stripe/stripe.service.spec.ts b/apps/api/src/stripe/stripe.service.spec.ts index 95140545..1af77165 100644 --- a/apps/api/src/stripe/stripe.service.spec.ts +++ b/apps/api/src/stripe/stripe.service.spec.ts @@ -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( diff --git a/apps/api/src/stripe/stripe.service.ts b/apps/api/src/stripe/stripe.service.ts index 326ef3fe..2fc99f7e 100644 --- a/apps/api/src/stripe/stripe.service.ts +++ b/apps/api/src/stripe/stripe.service.ts @@ -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, @@ -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}. ` + @@ -1001,6 +1019,33 @@ export class StripeService { newCurrency: Currency, newAmount: number, ): Promise { + // 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 }, }) @@ -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 { + // 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})`, ) } }