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

[2/2] feat: Allow for creation of payment records manually #650

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/api/src/bank-transactions/bank-transactions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
} from '@nestjs/common'
import { ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'
import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types'
import { Roles, RoleMatchingMode, AuthenticatedUser } from 'nest-keycloak-connect'
import { Roles, RoleMatchingMode, AuthenticatedUser, Public } from 'nest-keycloak-connect'

Check warning on line 19 in apps/api/src/bank-transactions/bank-transactions.controller.ts

View workflow job for this annotation

GitHub Actions / Run API tests

'Public' is defined but never used
import { KeycloakTokenParsed, isAdmin } from '../auth/keycloak'
import { BankTransactionsService } from './bank-transactions.service'
import {
Expand Down Expand Up @@ -68,6 +68,14 @@
)
}

@Get(':id')
@Roles({
roles: [RealmViewSupporters.role, ViewSupporters.role],
mode: RoleMatchingMode.ANY,
})
async findTransactionById(@Param('id') id: string) {
return await this.bankTransactionsService.findTransactionById(id)
}
@Get('export-excel')
@Roles({
roles: [RealmViewSupporters.role, ViewSupporters.role],
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/bank-transactions/bank-transactions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ export class BankTransactionsService {
return result
}

/**
* Find bank transaction by id, whether it is internal, or external defined in the transaction's description.
* @param id Id of transaction
* @returns
*/
async findTransactionById(id: string): Promise<BankTransaction | null> {
return await this.prisma.bankTransaction.findFirst({
where: {
OR: [{ id: id }, { description: { contains: id } }],
},
})
}
/**
* @param res - Response object to be used for the export to excel file
*/
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/campaign/campaign.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ export class CampaignService {
return campaign
}

async getCampaignBySlug(slug: string): Promise<Campaign> {
async getCampaignBySlug(slug: string) {
const includeFilter = {
campaignType: {
select: { name: true, slug: true, category: true },
Expand Down Expand Up @@ -409,6 +409,7 @@ export class CampaignService {
},
},
},
vaults: true,
campaignFiles: true,
}

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/campaign/dto/list-campaigns.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const AdminCampaignListItemSelect = Prisma.validator<Prisma.CampaignArgs>
endDate: true,
createdAt: true,
updatedAt: true,
vaults: true,
deletedAt: true,
campaignType: {
select: {
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/common/stringToUUID.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { v5 as uuidv5 } from 'uuid'
export function stringToUUID(str: string) {
const OIS_NAMESPACE = '6ba7b812-9dad-11d1-80b4-00c04fd430c8'
return uuidv5(str, OIS_NAMESPACE)
}
32 changes: 32 additions & 0 deletions apps/api/src/donations/donations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Res,
Inject,
forwardRef,
Put,
} from '@nestjs/common'
import { ApiQuery, ApiTags } from '@nestjs/swagger'

Expand All @@ -37,6 +38,10 @@ import { PersonService } from '../person/person.service'
import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager'
import { UseInterceptors } from '@nestjs/common'

import { CreateUpdatePaymentFromStripeChargeDto } from './dto/create-update-payment-from-stripe-charge.dto.ts'
import { CreateBenevityPaymentDto } from './dto/create-benevity-payment'
import Stripe from 'stripe'

@ApiTags('donation')
@Controller('donation')
export class DonationsController {
Expand Down Expand Up @@ -295,6 +300,33 @@ export class DonationsController {
return this.donationsService.update(id, updatePaymentDto)
}

@Get('stripe/:id/')
@Roles({
roles: [EditFinancialsRequests.role],
mode: RoleMatchingMode.ANY,
})
async findStripePayment(@Param('id') stripeId: string) {
return await this.donationsService.findDonationByStripeId(stripeId)
}

@Put('create-update-stripe-payment')
@Roles({
roles: [EditFinancialsRequests.role],
mode: RoleMatchingMode.ANY,
})
async syncWithPaymentWithStripe(@Body() stripeChargeDto: Stripe.Charge) {
return await this.donationsService.syncPaymentWithStripe(stripeChargeDto)
}

@Post('import/benevity')
@Roles({
roles: [EditFinancialsRequests.role],
mode: RoleMatchingMode.ANY,
})
async createDonationFromBenevity(@Body() benevityDto: CreateBenevityPaymentDto) {
return await this.donationsService.createFromBenevity(benevityDto)
}

@Patch(':id/sync-with-payment')
@Roles({
roles: [EditFinancialsRequests.role],
Expand Down
119 changes: 118 additions & 1 deletion apps/api/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import Stripe from 'stripe'
import { ConfigService } from '@nestjs/config'
import { InjectStripeClient } from '@golevelup/nestjs-stripe'
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common'
import {
Campaign,
PaymentStatus,
Expand Down Expand Up @@ -33,6 +41,19 @@ import { CreateAffiliateDonationDto } from '../affiliate/dto/create-affiliate-do
import { VaultUpdate } from '../vault/types/vault'
import { PaymentWithDonation } from './types/donation'
import type { DonationWithPersonAndVault, PaymentWithDonationCount } from './types/donation'
import { getCountryRegion, stripeFeeCalculator } from './helpers/stripe-fee-calculator'
import { PaymentData, getPaymentDataFromCharge } from './helpers/payment-intent-helpers'
import {
NotificationService,
donationNotificationSelect,
} from '../sockets/notifications/notification.service'
import {
mapStripeStatusToInternal,
shouldAllowStatusChange,
} from './helpers/donation-status-updates'
import { CreateUpdatePaymentFromStripeChargeDto } from './dto/create-update-payment-from-stripe-charge.dto.ts'
import { CreateBenevityPaymentDto } from './dto/create-benevity-payment'
import { stringToUUID } from '../common/stringToUUID'

@Injectable()
export class DonationsService {
Expand All @@ -43,6 +64,7 @@ export class DonationsService {
private prisma: PrismaService,
private vaultService: VaultService,
private exportService: ExportService,
private notificationService: NotificationService,
) {}
async listPrices(type?: Stripe.PriceListParams.Type, active?: boolean): Promise<Stripe.Price[]> {
const listResponse = await this.stripeClient.prices.list({ active, type, limit: 100 }).then(
Expand Down Expand Up @@ -932,4 +954,99 @@ export class DonationsService {
})
})
}

async findDonationByStripeId(id: string) {
const charge = await this.stripeClient.charges.retrieve(id)
if (!charge) throw new NotFoundException('Charge not found, by payment_intent')
const internalDonation = await this.prisma.payment.findFirst({
where: {
provider: 'stripe',
extPaymentIntentId: charge.payment_intent as string,
},
})

return {
stripe: charge,
internal: internalDonation,
region: getCountryRegion(charge.payment_method_details?.card?.country as string),
}
}

async syncPaymentWithStripe(stripeChargeDto: Stripe.Charge) {
const paymentData = getPaymentDataFromCharge(stripeChargeDto)

const campaignId = stripeChargeDto.metadata?.campaignId
const campaign = await this.campaignService.getCampaignById(campaignId)
const newStatus = mapStripeStatusToInternal(stripeChargeDto)
this.campaignService.updateDonationPayment(campaign, paymentData, newStatus)
}

async createFromBenevity(benevityDto: CreateBenevityPaymentDto) {
const payment = await this.prisma.payment.findUnique({
where: { extPaymentIntentId: benevityDto.extPaymentIntentId },
})
if (payment)
throw new ConflictException(`Payment with id ${payment.extPaymentIntentId} already exists`)

const campaignSlug = benevityDto.benevityData.donations[0].projectRemoteId
const campaign = await this.campaignService.getCampaignBySlug(campaignSlug)

if (!campaign) throw new NotFoundException(`Campaign with ${campaignSlug} not found`)

// Prepare person records for bulk insertion
const personObj: Prisma.PersonCreateInput[] = []
for (const donation of benevityDto.benevityData.donations) {
const donorString = `${donation.donorFirstName}-${donation.donorLastName}-${donation.email}`
const personId = stringToUUID(donorString)
personObj.push({
id: personId,
firstName: donation.donorFirstName,
lastName: donation.donorLastName,
email: donation.email,
})
}

//Prepare payment and donation records for bulk insertion
const paymentData = Prisma.validator<Prisma.PaymentCreateInput>()({
type: 'benevity',
extPaymentIntentId: benevityDto.extPaymentIntentId,
extPaymentMethodId: 'benevity',
provider: 'benevity',
billingName: 'UK ONLINE GIVING FOUNDATION',
extCustomerId: '',
amount: benevityDto.amount * 100,
status: PaymentStatus.succeeded,
donations: {
createMany: {
data: benevityDto.benevityData.donations.map((donation, index) => {
const isAnonymous =
personObj[index].firstName === 'Not shared by donor' ||
personObj[index].lastName === 'Not shared by donor'
return {
type: DonationType.donation,
amount: donation.totalAmount * benevityDto.exchangeRate * 100,
targetVaultId: campaign.vaults[0].id,
personId: !isAnonymous ? personObj[index].id : null,
}
}),
},
},
})

try {
await this.prisma.$transaction(async (tx) => {
await tx.person.createMany({ data: personObj, skipDuplicates: true })
await tx.payment.create({ data: paymentData })
await this.vaultService.incrementVaultAmount(
campaign.vaults[0].id,
benevityDto.amount * 100,
tx,
)
})
} catch (err) {
console.log(err)
}
// console.log(result)
// return result
}
}
70 changes: 70 additions & 0 deletions apps/api/src/donations/dto/create-benevity-payment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ApiProperty } from '@nestjs/swagger'
import { Expose, Type } from 'class-transformer'
import { IsArray, IsNumber, IsObject, IsOptional, IsString, IsUUID } from 'class-validator'

class BenevityDonationDto {
@ApiProperty()
@Expose()
@IsString()
projectRemoteId: string

@ApiProperty()
@Expose()
@IsString()
@IsUUID()
transactionId: string

@ApiProperty()
@Expose()
@IsNumber()
totalAmount: number

@ApiProperty()
@Expose()
@IsOptional()
@IsString()
donorFirstName: string

@ApiProperty()
@Expose()
@IsOptional()
@IsString()
donorLastName: string

@ApiProperty()
@Expose()
@IsOptional()
@IsString()
email: string
}

class BenevityDataDto {
@ApiProperty()
@Expose()
@IsArray()
@Type(() => BenevityDonationDto)
donations: BenevityDonationDto[]
}

export class CreateBenevityPaymentDto {
@ApiProperty()
@Expose()
@IsNumber()
amount: number

@ApiProperty()
@Expose()
@IsNumber()
exchangeRate: number

@ApiProperty()
@Expose()
@IsString()
extPaymentIntentId: string

@ApiProperty()
@Expose()
@IsObject()
@Type(() => BenevityDataDto)
benevityData: BenevityDataDto
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger'
import { Expose } from 'class-transformer'
import type Stripe from 'stripe'

export class CreateUpdatePaymentFromStripeChargeDto {
@ApiProperty()
@Expose()
stripe: Stripe.Charge
}
2 changes: 1 addition & 1 deletion apps/api/src/donations/events/stripe-payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { PaymentStatus, CampaignState } from '@prisma/client'
import { EmailService } from '../../email/email.service'
import { RefundDonationEmailDto } from '../../email/template.interface'
import { PrismaService } from '../../prisma/prisma.service'
import { DonationsService } from '../donations.service'

/** Testing Stripe on localhost is described here:
* https://github.com/podkrepi-bg/api/blob/master/TESTING.md#testing-stripe
Expand All @@ -29,7 +30,6 @@ export class StripePaymentService {
private campaignService: CampaignService,
private recurringDonationService: RecurringDonationService,
private sendEmail: EmailService,
private prismaService: PrismaService,
) {}

@StripeWebhookHandler('payment_intent.created')
Expand Down
14 changes: 14 additions & 0 deletions apps/api/src/donations/helpers/donation-status-updates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PaymentStatus } from '@prisma/client'
import Stripe from 'stripe'

const initial: PaymentStatus[] = [PaymentStatus.initial]
const changeable: PaymentStatus[] = [
Expand Down Expand Up @@ -58,3 +59,16 @@ export function shouldAllowStatusChange(

throw new Error(`Unhandled donation status change from ${oldStatus} to ${newStatus}`)
}

/**
* Convert stripe status to one used internally
* @param charge Stripe.Charge object
* @returns
*/

export function mapStripeStatusToInternal(charge: Stripe.Charge): PaymentStatus {
if (charge.refunded) return PaymentStatus.refund
if (charge.status === 'succeeded') return PaymentStatus.succeeded
if (charge.status === 'pending') return PaymentStatus.waiting
return PaymentStatus.declined
}
1 change: 1 addition & 0 deletions apps/api/src/paypal/paypal.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { HttpModule } from '@nestjs/axios'
import { NotificationModule } from '../sockets/notifications/notification.module'

import { MarketingNotificationsModule } from '../notifications/notifications.module'
import { DonationsService } from '../donations/donations.service'

describe('PaypalController', () => {
let controller: PaypalController
Expand Down
Loading
Loading