diff --git a/packages/api/src/stripe/services/StripeWebhookService.tsx b/packages/api/src/stripe/services/StripeWebhookService.tsx index 20480c7e..d477d61c 100644 --- a/packages/api/src/stripe/services/StripeWebhookService.tsx +++ b/packages/api/src/stripe/services/StripeWebhookService.tsx @@ -681,21 +681,45 @@ export class StripeWebhookService { } const payment = await this.userRepository.getPaymentByPaymentIntent(paymentIntentId); - if (!payment) { - Logger.error({paymentIntentId, chargeId: charge.id}, 'No payment found for refund'); - throw new StripeError('No payment found for refund'); - } - const user = await this.userRepository.findUnique(payment.userId); - if (!user) { - Logger.error({userId: payment.userId, chargeId: charge.id}, 'User not found for refund'); - throw new StripeError('User not found for refund'); - } + let user: User; + if (payment) { + const foundUser = await this.userRepository.findUnique(payment.userId); + if (!foundUser) { + Logger.error({userId: payment.userId, chargeId: charge.id}, 'User not found for refund'); + throw new StripeError('User not found for refund'); + } + await this.userRepository.updatePayment({ + ...payment.toRow(), + status: 'refunded', + }); + user = foundUser; + } else { + // Subscription-mode checkout sessions do not set payment_intent on the session object, + // so the PaymentsByPaymentIntent index is never populated. Fall back to customer ID lookup. + const customerId = extractId(charge.customer); + if (!customerId) { + Logger.error({paymentIntentId, chargeId: charge.id}, 'No payment found for refund and charge has no customer ID'); + throw new StripeError('No payment found for refund'); + } - await this.userRepository.updatePayment({ - ...payment.toRow(), - status: 'refunded', - }); + const donor = await this.donationRepository.findDonorByStripeCustomerId(customerId); + if (donor) { + Logger.info({customerId, chargeId: charge.id}, 'Refund for donation customer - no premium action required'); + return; + } + + const foundUser = await this.userRepository.findByStripeCustomerId(customerId); + if (!foundUser) { + Logger.error({customerId, paymentIntentId, chargeId: charge.id}, 'No user found for refund by customer ID'); + throw new StripeError('No user found for refund'); + } + Logger.debug( + {userId: foundUser.id, paymentIntentId, chargeId: charge.id}, + 'Processing refund via customer ID (payment intent not indexed)', + ); + user = foundUser; + } const updates: Partial = { premium_type: UserPremiumTypes.NONE, @@ -704,18 +728,18 @@ export class StripeWebhookService { if (!user.firstRefundAt) { updates.first_refund_at = new Date(); - const updatedUser = await this.userRepository.patchUpsert(payment.userId, updates, user.toRow()); + const updatedUser = await this.userRepository.patchUpsert(user.id, updates, user.toRow()); await this.dispatchUser(updatedUser); Logger.debug( - {userId: payment.userId, chargeId: charge.id, paymentIntentId}, + {userId: user.id, chargeId: charge.id, paymentIntentId}, 'First refund recorded - 30 day purchase block applied', ); } else { updates.flags = user.flags | UserFlags.PREMIUM_PURCHASE_DISABLED; - const updatedUser = await this.userRepository.patchUpsert(payment.userId, updates, user.toRow()); + const updatedUser = await this.userRepository.patchUpsert(user.id, updates, user.toRow()); await this.dispatchUser(updatedUser); Logger.debug( - {userId: payment.userId, chargeId: charge.id, paymentIntentId}, + {userId: user.id, chargeId: charge.id, paymentIntentId}, 'Second refund recorded - permanent purchase block applied', ); } diff --git a/packages/api/src/stripe/tests/StripeWebhookRefund.test.tsx b/packages/api/src/stripe/tests/StripeWebhookRefund.test.tsx index 55bd070d..b9c65aa7 100644 --- a/packages/api/src/stripe/tests/StripeWebhookRefund.test.tsx +++ b/packages/api/src/stripe/tests/StripeWebhookRefund.test.tsx @@ -174,5 +174,71 @@ describe('Stripe Webhook Refund', () => { expect(updatedPayment).not.toBeNull(); expect(updatedPayment!.status).toBe('refunded'); }); + + test('falls back to customer ID when payment intent is not indexed (subscription mode)', async () => { + const account = await createTestAccount(harness); + const userId = createUserID(BigInt(account.userId)); + + const {UserRepository} = await import('@fluxer/api/src/user/repositories/UserRepository'); + const userRepository = new UserRepository(); + + const stripeCustomerId = 'cus_test_subscription_fallback'; + await userRepository.patchUpsert( + userId, + {stripe_customer_id: stripeCustomerId}, + (await userRepository.findUnique(userId))!.toRow(), + ); + + await sendWebhook({ + type: 'charge.refunded', + data: { + object: { + id: 'ch_test_refund_sub_789', + payment_intent: 'pi_test_not_indexed_789', + customer: stripeCustomerId, + amount_refunded: 4999, + }, + }, + }); + + const updatedUser = await userRepository.findUnique(userId); + expect(updatedUser).not.toBeNull(); + expect(updatedUser!.firstRefundAt).not.toBeNull(); + }); + + test('skips premium action for donation customer refund', async () => { + const donationCustomerId = 'cus_test_donation_refund'; + + const {DonationRepository} = await import('@fluxer/api/src/donation/DonationRepository'); + const donationRepository = new DonationRepository(); + + await donationRepository.createDonor({ + email: 'donor-refund-test@example.com', + stripeCustomerId: donationCustomerId, + businessName: null, + taxId: null, + taxIdType: null, + stripeSubscriptionId: null, + subscriptionAmountCents: null, + subscriptionCurrency: null, + subscriptionInterval: null, + subscriptionCurrentPeriodEnd: null, + subscriptionCancelAt: null, + }); + + const result = await sendWebhook({ + type: 'charge.refunded', + data: { + object: { + id: 'ch_test_refund_donation_101', + payment_intent: 'pi_test_donation_not_indexed_101', + customer: donationCustomerId, + amount_refunded: 2500, + }, + }, + }); + + expect(result.received).toBe(true); + }); }); });