Skip to content

Commit 449602e

Browse files
authored
refactor: Cancelling subscription (#690)
* fix: Cancelling recurring payment * refactor: Subscription cancelling * chore: Remove redundant logs * chore: Remove redundant stripeService * chore: Remove comment * chore: Fix merge conflict * chore: Remove merge conlifct artifaxt * chore: Remove redundant endpoint
1 parent 0baba3a commit 449602e

File tree

7 files changed

+92
-57
lines changed

7 files changed

+92
-57
lines changed

apps/api/src/donations/donations.module.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
import { StripeModule } from '@golevelup/nestjs-stripe'
21
import { Module } from '@nestjs/common'
32
import { ConfigModule, ConfigService } from '@nestjs/config'
4-
import { StripeConfigFactory } from './helpers/stripe-config-factory'
53
import { CampaignModule } from '../campaign/campaign.module'
6-
import { CampaignService } from '../campaign/campaign.service'
7-
import { RecurringDonationService } from '../recurring-donation/recurring-donation.service'
84
import { ExportService } from '../export/export.service'
95
import { PersonModule } from '../person/person.module'
106
import { PersonService } from '../person/person.service'
@@ -13,7 +9,6 @@ import { VaultModule } from '../vault/vault.module'
139
import { VaultService } from '../vault/vault.service'
1410
import { DonationsController } from './donations.controller'
1511
import { DonationsService } from './donations.service'
16-
import { StripePaymentService } from '../stripe/events/stripe-payment.service'
1712
import { HttpModule } from '@nestjs/axios'
1813
import { ExportModule } from './../export/export.module'
1914
import { NotificationModule } from '../sockets/notifications/notification.module'
@@ -36,7 +31,6 @@ import { PrismaModule } from '../prisma/prisma.module'
3631
controllers: [DonationsController],
3732
providers: [
3833
DonationsService,
39-
RecurringDonationService,
4034
VaultService,
4135
PersonService,
4236
ExportService,

apps/api/src/recurring-donation/recurring-donation.controller.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ import { UpdateRecurringDonationDto } from './dto/update-recurring-donation.dto'
77
import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types'
88
import { ApiTags } from '@nestjs/swagger'
99
import { KeycloakTokenParsed } from '../auth/keycloak'
10-
import { StripeService } from '../stripe/stripe.service'
1110

1211
@ApiTags('recurring-donation')
1312
@Controller('recurring-donation')
1413
export class RecurringDonationController {
15-
constructor(private readonly recurringDonationService: RecurringDonationService, private readonly stripeService:StripeService) {}
14+
constructor(private readonly recurringDonationService: RecurringDonationService) {}
1615

1716
@Get('list')
1817
@Roles({
@@ -46,13 +45,12 @@ export class RecurringDonationController {
4645
return this.recurringDonationService.create(createRecurringDonationDto)
4746
}
4847

49-
//TODO: Deprecate this endpont after FE is configured to call stripe cancel webhook
50-
@Get('cancel/:id')
48+
@Patch(':id/cancel')
5149
async cancelSubscription(
5250
@Param('id') id: string,
5351
@AuthenticatedUser() user: KeycloakTokenParsed,
5452
) {
55-
return await this.stripeService.cancelSubscription(id, user)
53+
return await this.recurringDonationService.cancel(id, user)
5654
}
5755

5856
@Patch(':id')

apps/api/src/recurring-donation/recurring-donation.service.spec.ts

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import { INestApplication } from '@nestjs/common'
1010
import { RecurringDonationStatus } from '@prisma/client'
1111
import { CreateRecurringDonationDto } from './dto/create-recurring-donation.dto'
1212
import { RecurringDonation } from '../domain/generated/recurringDonation/entities/recurringDonation.entity'
13+
import { StripeService } from '../stripe/stripe.service'
14+
import { CampaignService } from '../campaign/campaign.service'
15+
import { DonationsService } from '../donations/donations.service'
16+
import { RealmViewSupporters } from '@podkrepi-bg/podkrepi-types'
17+
1318

1419
const mockCreateRecurring = new CreateRecurringDonationDto()
1520
mockCreateRecurring.amount = 1
@@ -38,6 +43,7 @@ const mockRecurring = {
3843
describe('RecurringDonationService', () => {
3944
let service: RecurringDonationService
4045
let app: INestApplication
46+
let stripeService: StripeService
4147

4248
const stripeMock = {
4349
checkout: { sessions: { create: jest.fn() } },
@@ -50,10 +56,22 @@ describe('RecurringDonationService', () => {
5056
RecurringDonationService,
5157
MockPrismaService,
5258
ConfigService,
59+
{
60+
provide: StripeService,
61+
useValue: mockDeep<StripeService>()
62+
},
5363
{
5464
provide: HttpService,
5565
useValue: mockDeep<HttpService>(),
5666
},
67+
{
68+
provide: CampaignService,
69+
useValue: mockDeep<CampaignService>(),
70+
},
71+
{
72+
provide: DonationsService,
73+
useValue: mockDeep<DonationsService>(),
74+
},
5775
Stripe,
5876
{
5977
provide: STRIPE_CLIENT_TOKEN,
@@ -63,7 +81,7 @@ describe('RecurringDonationService', () => {
6381
}).compile()
6482

6583
service = module.get<RecurringDonationService>(RecurringDonationService)
66-
84+
stripeService = module.get<StripeService>(StripeService)
6785
app = module.createNestApplication()
6886
await app.init()
6987
})
@@ -86,18 +104,51 @@ describe('RecurringDonationService', () => {
86104
expect(result).toStrictEqual(mockRecurring)
87105
})
88106

89-
it('should cancel a subscription in db', async () => {
90-
prismaMock.recurringDonation.update.mockResolvedValueOnce(mockRecurring)
91-
await service.cancel('1')
107+
it('should cancel a subscription in db if admin', async () => {
108+
prismaMock.recurringDonation.update.mockResolvedValueOnce(mockRecurring)
109+
prismaMock.recurringDonation.findUnique.mockResolvedValueOnce(mockRecurring)
110+
const updateDbSpy = jest.spyOn(service, 'updateStatus')
111+
const stripeSpy = jest.spyOn(stripeService, 'cancelSubscription')
112+
const donationBelongsToSpy = jest.spyOn(service, 'donationBelongsTo')
113+
donationBelongsToSpy.mockResolvedValue(false)
114+
stripeSpy.mockResolvedValue({status: 'active'} as any)
115+
await service.cancel('1', { sub: '1', realm_access: {roles: [RealmViewSupporters.role]} } as any)
116+
expect(stripeSpy).toHaveBeenCalledWith(mockRecurring.extSubscriptionId)
117+
expect(updateDbSpy).toHaveBeenCalledWith(mockRecurring.id, RecurringDonationStatus.canceled)
118+
})
119+
120+
it('should cancel a subscription in db if regular user and own donation', async () => {
121+
// prismaMock.recurringDonation.update.mockResolvedValueOnce(mockRecurring)
122+
const findOneSpy = jest.spyOn(service, 'findOne').mockResolvedValue(mockRecurring)
123+
const donationBelongsToSpy = jest.spyOn(service, 'donationBelongsTo')
124+
donationBelongsToSpy.mockResolvedValue(true)
125+
const updateDbSpy = jest.spyOn(service, 'updateStatus')
126+
const stripeSpy = jest.spyOn(stripeService, 'cancelSubscription')
127+
updateDbSpy.mockResolvedValue(mockRecurring)
128+
stripeSpy.mockResolvedValue({status: 'active'} as any)
129+
await service.cancel(mockRecurring.id, { sub: '1', realm_access: {roles: []} } as any)
130+
expect(stripeSpy).toHaveBeenCalledWith(mockRecurring.extSubscriptionId)
131+
expect(updateDbSpy).toHaveBeenCalledWith(mockRecurring.id, RecurringDonationStatus.canceled)
132+
133+
})
134+
135+
it('should not allow to cancel a subscription in db if regular user and not own donation', async () => {
136+
const findOneSpy = jest.spyOn(service, 'findOne').mockResolvedValue(mockRecurring)
137+
const updateStatusSpy = jest.spyOn(service, 'updateStatus')
138+
const donationBelongsToSpy = jest.spyOn(service, 'donationBelongsTo')
139+
donationBelongsToSpy.mockResolvedValue(false)
92140
const updateDbSpy = jest.spyOn(prismaMock.recurringDonation, 'update')
93-
expect(updateDbSpy).toHaveBeenCalledWith({
141+
const stripeSpy = jest.spyOn(stripeService, 'cancelSubscription')
142+
expect(service.cancel(mockRecurring.id, { sub: '1', realm_access: {roles: []} } as any)).rejects.toThrow()
143+
expect(stripeSpy).not.toHaveBeenCalledWith(mockRecurring.extSubscriptionId)
144+
expect(updateStatusSpy).not.toHaveBeenCalledWith({
94145
where: { id: '1' },
95146
data: {
96147
status: RecurringDonationStatus.canceled,
97148
},
98149
})
99150
})
100-
151+
101152
it('should create a subscription in db', async () => {
102153
prismaMock.recurringDonation.create.mockResolvedValueOnce(mockRecurring)
103154

@@ -123,4 +174,8 @@ describe('RecurringDonationService', () => {
123174
},
124175
})
125176
})
177+
178+
it('should update a recurring donation', async () => {
179+
expect(true).toBeTruthy()
180+
})
126181
})

apps/api/src/recurring-donation/recurring-donation.service.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common'
1+
import { Injectable, NotFoundException, Logger, BadRequestException, ForbiddenException } from '@nestjs/common'
22
import { Prisma, RecurringDonation } from '@prisma/client'
33
import { PrismaService } from '../prisma/prisma.service'
44
import { CreateRecurringDonationDto } from './dto/create-recurring-donation.dto'
55
import { UpdateRecurringDonationDto } from './dto/update-recurring-donation.dto'
66
import { RecurringDonationStatus } from '@prisma/client'
7+
import { KeycloakTokenParsed } from '../auth/keycloak'
8+
import { RealmViewSupporters } from '@podkrepi-bg/podkrepi-types'
9+
import { StripeService } from '../stripe/stripe.service'
710

811
@Injectable()
912
export class RecurringDonationService {
10-
constructor(private prisma: PrismaService) {}
13+
constructor(private prisma: PrismaService, private readonly stripeService:StripeService) {}
1114

1215
async create(CreateRecurringDonationDto: CreateRecurringDonationDto): Promise<RecurringDonation> {
1316
return await this.prisma.recurringDonation.create({
@@ -132,7 +135,25 @@ export class RecurringDonationService {
132135
return result
133136
}
134137

135-
async cancel(id: string): Promise<RecurringDonation | null> {
138+
async cancel(id: string, user: KeycloakTokenParsed): Promise<RecurringDonation | null> {
139+
const recurringDonation = await this.findOne(id)
140+
if (!recurringDonation) {
141+
throw new NotFoundException(`Recurring donation with id ${id} not found`)
142+
}
143+
144+
145+
const isAdmin = user.realm_access?.roles.includes(RealmViewSupporters.role)
146+
const belongsTo = await this.donationBelongsTo(recurringDonation.id, user.sub)
147+
if (!isAdmin && !belongsTo) {
148+
throw new ForbiddenException(`User ${user.sub} is not allowed to cancel recurring donation with id ${recurringDonation.id} of person: ${recurringDonation.personId}`,
149+
)
150+
}
151+
152+
const subscription = await this.stripeService.cancelSubscription(recurringDonation.extSubscriptionId)
153+
if (subscription?.status === 'canceled') {
154+
Logger.log(`Subscription cancel attempt failed with status of ${subscription.id}: ${subscription.status}`)
155+
return null
156+
}
136157
return await this.updateStatus(id, RecurringDonationStatus.canceled)
137158
}
138159

apps/api/src/stripe/stripe.controller.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,6 @@ export class StripeController {
144144
return this.stripeService.listPrices('one_time')
145145
}
146146

147-
@Post(':id/cancel-subscription')
148-
cancelSubscription(@Param('id') subscriptionId: string, @AuthenticatedUser() user: KeycloakTokenParsed) {
149-
return this.stripeService.cancelSubscription(subscriptionId, user)
150-
}
151147

152148
@Get('prices/recurring')
153149
@Public()

apps/api/src/stripe/stripe.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { TemplateService } from '../email/template.service'
2323
CampaignModule,
2424
PersonModule,
2525
DonationsModule,
26-
forwardRef(()=>RecurringDonationModule),
26+
forwardRef(() => RecurringDonationModule),
2727
],
2828
providers: [StripeService, StripePaymentService, EmailService, TemplateService],
2929
controllers: [StripeController],

apps/api/src/stripe/stripe.service.ts

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,9 @@ import { RealmViewSupporters } from '@podkrepi-bg/podkrepi-types'
2020
export class StripeService {
2121
constructor(
2222
@InjectStripeClient() private stripeClient: Stripe,
23-
private personService: PersonService,
2423
private campaignService: CampaignService,
2524
private donationService: DonationsService,
2625
private configService: ConfigService,
27-
private reacurringDonationService: RecurringDonationService,
2826
) {}
2927

3028
/**
@@ -421,36 +419,9 @@ export class StripeService {
421419
})
422420
}
423421

424-
async cancelSubscription(subscriptionId: string, user?: KeycloakTokenParsed) {
425-
426-
const rd = await this.reacurringDonationService.findOne(subscriptionId)
427-
if (!rd) {
428-
throw new Error(`Recurring donation with id ${subscriptionId} not found`)
429-
}
430-
431-
if (user) {
432-
433-
const isAdmin = user.realm_access?.roles.includes(RealmViewSupporters.role)
434-
435-
if (!isAdmin && !this.reacurringDonationService.donationBelongsTo(subscriptionId, user.sub)) {
436-
throw new Error(`User ${user.sub} is not allowed to cancel recurring donation with id ${subscriptionId} of person: ${rd.personId}`,
437-
)
438-
}
439-
}
440-
441-
Logger.log(`Canceling subscription with api request to cancel: ${subscriptionId}`)
442-
const result = await this.stripeClient.subscriptions.cancel(rd.extSubscriptionId)
443-
if (result.status !== 'canceled') {
444-
Logger.log(`Subscription cancel attempt failed with status of ${result.id}: ${result.status}`)
445-
return
446-
}
447-
448-
449-
// the webhook will handle this as well.
450-
// but we cancel it here, in case the webhook is slow.
451-
if (rd) {
452-
return this.reacurringDonationService.cancel(rd.id)
453-
}
422+
async cancelSubscription(stripeSubscriptionId: string) {
423+
const result = await this.stripeClient.subscriptions.cancel(stripeSubscriptionId)
424+
return result
454425
}
455426

456427
async findChargeById(chargeId: string): Promise<Stripe.Charge> {

0 commit comments

Comments
 (0)