Skip to content

Commit

Permalink
fix: Generate Stripe idempotency keys server-side (#680)
Browse files Browse the repository at this point in the history
* fix: Generate Stripe idempotency keys server-side

* chore: Fix tests

* fix: Tests

* fix: Subscription returning bad request

* chore: Attempt to log the error

* chore: Try to log in try catch clause

* chore: Try to debug by the thrown error message

* chore: Add crypto import

* chore: Bring back original test behavior

* chore: Send forgotten idempotency key

* chore: Remove idempotency from bodyDto
  • Loading branch information
sashko9807 authored Dec 11, 2024
1 parent 7973d42 commit bdc108b
Show file tree
Hide file tree
Showing 3 changed files with 48 additions and 64 deletions.
20 changes: 12 additions & 8 deletions apps/api/src/stripe/stripe.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import { NotificationModule } from '../sockets/notifications/notification.module
import { KeycloakTokenParsed } from '../auth/keycloak'
describe('StripeController', () => {
let controller: StripeController
const idempotencyKey = 'test_123'
const stripeMock = {
checkout: { sessions: { create: jest.fn() } },
paymentIntents: { retrieve: jest.fn() },
Expand Down Expand Up @@ -212,7 +211,7 @@ describe('StripeController', () => {
},
}

await expect(controller.updateSetupIntent('123', idempotencyKey, payload)).rejects.toThrow(
await expect(controller.updateSetupIntent('123', payload)).rejects.toThrow(
new NotAcceptableException('Campaign cannot accept donations in state: complete'),
)
})
Expand All @@ -228,11 +227,16 @@ describe('StripeController', () => {
state: CampaignState.complete,
title: 'active-campaign',
} as Campaign)
await expect(controller.setupIntentToSubscription('123', idempotencyKey)).toResolve()
expect(stripeMock.setupIntents.retrieve).toHaveBeenCalledWith('123', {
expand: ['payment_method'],
})
expect(stripeMock.customers.create).not.toHaveBeenCalled()
expect(stripeMock.products.create).not.toHaveBeenCalled()
try {

await expect(controller.setupIntentToSubscription('123')).toResolve()
expect(stripeMock.setupIntents.retrieve).toHaveBeenCalledWith('123', {
expand: ['payment_method'],
})
expect(stripeMock.customers.create).not.toHaveBeenCalled()
expect(stripeMock.products.create).not.toHaveBeenCalled()
} catch (err) {
throw new Error(JSON.stringify(err))
}
})
})
31 changes: 9 additions & 22 deletions apps/api/src/stripe/stripe.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,15 @@ import {
Param,
Patch,
Post,
Query,
UnauthorizedException,
} from '@nestjs/common'
import { ApiBody, ApiTags } from '@nestjs/swagger'
import { AuthenticatedUser, Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect'
import { Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect'
import { CancelPaymentIntentDto } from './dto/cancel-payment-intent.dto'
import { CreatePaymentIntentDto } from './dto/create-payment-intent.dto'
import { UpdatePaymentIntentDto } from './dto/update-payment-intent.dto'
import { UpdateSetupIntentDto } from './dto/update-setup-intent.dto'
import { StripeService } from './stripe.service'
import { KeycloakTokenParsed } from '../auth/keycloak'
import { CreateSubscriptionPaymentDto } from './dto/create-subscription-payment.dto'
import { EditFinancialsRequests } from '@podkrepi-bg/podkrepi-types'
import { CreateSessionDto } from '../donations/dto/create-session.dto'
import { PersonService } from '../person/person.service'
Expand All @@ -33,8 +30,8 @@ export class StripeController {

@Post('setup-intent')
@Public()
createSetupIntent(@Body() body: { idempotencyKey: string }) {
return this.stripeService.createSetupIntent(body)
createSetupIntent() {
return this.stripeService.createSetupIntent()
}

@Post('create-checkout-session')
Expand Down Expand Up @@ -78,12 +75,8 @@ export class StripeController {

@Post('setup-intent/:id')
@Public()
updateSetupIntent(
@Param('id') id: string,
@Query('idempotency-key') idempotencyKey: string,
@Body() updateSetupIntentDto: UpdateSetupIntentDto,
) {
return this.stripeService.updateSetupIntent(id, idempotencyKey, updateSetupIntentDto)
updateSetupIntent(@Param('id') id: string, @Body() updateSetupIntentDto: UpdateSetupIntentDto) {
return this.stripeService.updateSetupIntent(id, updateSetupIntentDto)
}

@Patch('setup-intent/:id/cancel')
Expand All @@ -97,22 +90,16 @@ export class StripeController {
description: 'Create payment intent from setup intent',
})
@Public()
setupIntentToPaymentIntent(
@Param('id') id: string,
@Query('idempotency-key') idempotencyKey: string,
) {
return this.stripeService.setupIntentToPaymentIntent(id, idempotencyKey)
setupIntentToPaymentIntent(@Param('id') id: string) {
return this.stripeService.setupIntentToPaymentIntent(id)
}

@Post('setup-intent/:id/subscription')
@ApiBody({
description: 'Create payment intent from setup intent',
})
setupIntentToSubscription(
@Param('id') id: string,
@Query('idempotency-key') idempotencyKey: string,
) {
return this.stripeService.setupIntentToSubscription(id, idempotencyKey)
setupIntentToSubscription(@Param('id') id: string) {
return this.stripeService.setupIntentToSubscription(id)
}

@Post('payment-intent')
Expand Down
61 changes: 27 additions & 34 deletions apps/api/src/stripe/stripe.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ConfigService } from '@nestjs/config'
import { StripeMetadata } from './stripe-metadata.interface'
import { CreateStripePaymentDto } from '../donations/dto/create-stripe-payment.dto'
import { RecurringDonationService } from '../recurring-donation/recurring-donation.service'
import * as crypto from 'crypto'

@Injectable()
export class StripeService {
Expand All @@ -32,14 +33,12 @@ export class StripeService {
*/
async updateSetupIntent(
id: string,
idempotencyKey: string,
inputDto: UpdateSetupIntentDto,
): Promise<Stripe.Response<Stripe.SetupIntent>> {
if (!inputDto.metadata.campaignId)
throw new BadRequestException('campaignId is missing from metadata')
await this.campaignService.validateCampaignId(
inputDto.metadata.campaignId as string,
)
await this.campaignService.validateCampaignId(inputDto.metadata.campaignId as string)
const idempotencyKey = crypto.randomUUID()
return await this.stripeClient.setupIntents.update(id, inputDto, { idempotencyKey })
}
/**
Expand Down Expand Up @@ -74,8 +73,9 @@ export class StripeService {
async attachPaymentMethodToCustomer(
paymentMethod: Stripe.PaymentMethod,
customer: Stripe.Customer,
idempotencyKey: string,
) {
const idempotencyKey = crypto.randomUUID()

return await this.stripeClient.paymentMethods.attach(
paymentMethod.id,
{
Expand All @@ -84,10 +84,7 @@ export class StripeService {
{ idempotencyKey: `${idempotencyKey}--pm` },
)
}
async setupIntentToPaymentIntent(
setupIntentId: string,
idempotencyKey: string,
): Promise<Stripe.PaymentIntent> {
async setupIntentToPaymentIntent(setupIntentId: string): Promise<Stripe.PaymentIntent> {
const setupIntent = await this.findSetupIntentById(setupIntentId)

if (setupIntent instanceof Error) throw new BadRequestException(setupIntent.message)
Expand All @@ -96,9 +93,10 @@ export class StripeService {
const name = paymentMethod.billing_details.name as string
const metadata = setupIntent.metadata as Stripe.Metadata

const customer = await this.createCustomer(email, name, paymentMethod, idempotencyKey)
const customer = await this.createCustomer(email, name, paymentMethod)

await this.attachPaymentMethodToCustomer(paymentMethod, customer, idempotencyKey)
await this.attachPaymentMethodToCustomer(paymentMethod, customer)
const idempotencyKey = crypto.randomUUID()

const paymentIntent = await this.stripeClient.paymentIntents.create(
{
Expand All @@ -120,30 +118,24 @@ export class StripeService {
* @param inputDto Payment intent create params
* @returns {Promise<Stripe.Response<Stripe.PaymentIntent>>}
*/
async createSetupIntent({
idempotencyKey,
}: {
idempotencyKey: string
}): Promise<Stripe.Response<Stripe.SetupIntent>> {
async createSetupIntent(): Promise<Stripe.Response<Stripe.SetupIntent>> {
const idempotencyKey = crypto.randomUUID()
return await this.stripeClient.setupIntents.create({}, { idempotencyKey })
}

async setupIntentToSubscription(
setupIntentId: string,
idempotencyKey: string,
): Promise<Stripe.PaymentIntent | Error> {
async setupIntentToSubscription(setupIntentId: string): Promise<Stripe.PaymentIntent | Error> {
const setupIntent = await this.findSetupIntentById(setupIntentId)
if (setupIntent instanceof Error) throw new BadRequestException(setupIntent.message)
const paymentMethod = setupIntent.payment_method as Stripe.PaymentMethod
const email = paymentMethod.billing_details.email as string
const name = paymentMethod.billing_details.name as string
const metadata = setupIntent.metadata as Stripe.Metadata

const customer = await this.createCustomer(email, name, paymentMethod, idempotencyKey)
await this.attachPaymentMethodToCustomer(paymentMethod, customer, idempotencyKey)
const customer = await this.createCustomer(email, name, paymentMethod)
await this.attachPaymentMethodToCustomer(paymentMethod, customer)

const product = await this.createProduct(metadata.campaignId, idempotencyKey)
return await this.createSubscription(metadata, customer, product, paymentMethod, idempotencyKey)
const product = await this.createProduct(metadata.campaignId)
return await this.createSubscription(metadata, customer, product, paymentMethod)
}

/**
Expand Down Expand Up @@ -196,15 +188,11 @@ export class StripeService {
} else return new Array<Stripe.Price>()
}

async createCustomer(
email: string,
name: string,
paymentMethod: Stripe.PaymentMethod,
idempotencyKey: string,
) {
async createCustomer(email: string, name: string, paymentMethod: Stripe.PaymentMethod) {
const customerLookup = await this.stripeClient.customers.list({
email,
})
const idempotencyKey = crypto.randomUUID()
const customer = customerLookup.data[0]
//Customer not found. Create new onw
if (!customer)
Expand All @@ -220,19 +208,23 @@ export class StripeService {
return customer
}

async createProduct(campaignId: string, idempotencyKey: string): Promise<Stripe.Product> {
async createProduct(campaignId: string): Promise<Stripe.Product> {
const campaign = await this.campaignService.getCampaignById(campaignId)
const idempotencyKey = crypto.randomUUID()
if (!campaign) throw new Error(`Campaign with id ${campaignId} not found`)

const productLookup = await this.stripeClient.products.search({
query: `-name:'${campaign.title}'`,
query: `metadata["campaignId"]:"${campaign.id}"`,
})

if (productLookup) return productLookup.data[0]
if (productLookup.data.length) return productLookup.data[0]
return await this.stripeClient.products.create(
{
name: campaign.title,
description: `Donate to ${campaign.title}`,
metadata: {
campaignId: campaign.id,
},
},
{ idempotencyKey: `${idempotencyKey}--product` },
)
Expand All @@ -242,8 +234,9 @@ export class StripeService {
customer: Stripe.Customer,
product: Stripe.Product,
paymentMethod: Stripe.PaymentMethod,
idempotencyKey: string,
) {
const idempotencyKey = crypto.randomUUID()

const subscription = await this.stripeClient.subscriptions.create(
{
customer: customer.id,
Expand Down

0 comments on commit bdc108b

Please sign in to comment.