Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Generate Stripe idempotency keys server-side #680

Merged
merged 11 commits into from
Dec 11, 2024
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,
sashko9807 marked this conversation as resolved.
Show resolved Hide resolved
},
},
{ 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
Loading