diff --git a/apps/api/src/bank-transactions/bank-transactions.controller.ts b/apps/api/src/bank-transactions/bank-transactions.controller.ts index 3355d8e7e..8e6d49988 100644 --- a/apps/api/src/bank-transactions/bank-transactions.controller.ts +++ b/apps/api/src/bank-transactions/bank-transactions.controller.ts @@ -16,7 +16,7 @@ import { } 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' import { KeycloakTokenParsed, isAdmin } from '../auth/keycloak' import { BankTransactionsService } from './bank-transactions.service' import { @@ -68,6 +68,14 @@ export class BankTransactionsController { ) } + @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], diff --git a/apps/api/src/bank-transactions/bank-transactions.service.ts b/apps/api/src/bank-transactions/bank-transactions.service.ts index 3ee0f237d..b41ba0805 100644 --- a/apps/api/src/bank-transactions/bank-transactions.service.ts +++ b/apps/api/src/bank-transactions/bank-transactions.service.ts @@ -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 { + 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 */ diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 6d1857d19..c32c07419 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -375,7 +375,7 @@ export class CampaignService { return campaign } - async getCampaignBySlug(slug: string): Promise { + async getCampaignBySlug(slug: string) { const includeFilter = { campaignType: { select: { name: true, slug: true, category: true }, @@ -409,6 +409,7 @@ export class CampaignService { }, }, }, + vaults: true, campaignFiles: true, } diff --git a/apps/api/src/campaign/dto/list-campaigns.dto.ts b/apps/api/src/campaign/dto/list-campaigns.dto.ts index 1b6172a3f..bfdaec62b 100644 --- a/apps/api/src/campaign/dto/list-campaigns.dto.ts +++ b/apps/api/src/campaign/dto/list-campaigns.dto.ts @@ -15,6 +15,7 @@ export const AdminCampaignListItemSelect = Prisma.validator endDate: true, createdAt: true, updatedAt: true, + vaults: true, deletedAt: true, campaignType: { select: { diff --git a/apps/api/src/common/stringToUUID.ts b/apps/api/src/common/stringToUUID.ts new file mode 100644 index 000000000..3d0965236 --- /dev/null +++ b/apps/api/src/common/stringToUUID.ts @@ -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) +} diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index f73ac80b2..88be40d78 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -12,6 +12,7 @@ import { Res, Inject, forwardRef, + Put, } from '@nestjs/common' import { ApiQuery, ApiTags } from '@nestjs/swagger' @@ -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 { @@ -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], diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 6acd6590f..22d7ee1db 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -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, @@ -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 { @@ -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 { const listResponse = await this.stripeClient.prices.list({ active, type, limit: 100 }).then( @@ -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()({ + 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 + } } diff --git a/apps/api/src/donations/dto/create-benevity-payment.ts b/apps/api/src/donations/dto/create-benevity-payment.ts new file mode 100644 index 000000000..f5af5f870 --- /dev/null +++ b/apps/api/src/donations/dto/create-benevity-payment.ts @@ -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 +} diff --git a/apps/api/src/donations/dto/create-update-payment-from-stripe-charge.dto.ts.ts b/apps/api/src/donations/dto/create-update-payment-from-stripe-charge.dto.ts.ts new file mode 100644 index 000000000..47f22201f --- /dev/null +++ b/apps/api/src/donations/dto/create-update-payment-from-stripe-charge.dto.ts.ts @@ -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 +} diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts index db327edf5..dc0a5d36c 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/donations/events/stripe-payment.service.ts @@ -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 @@ -29,7 +30,6 @@ export class StripePaymentService { private campaignService: CampaignService, private recurringDonationService: RecurringDonationService, private sendEmail: EmailService, - private prismaService: PrismaService, ) {} @StripeWebhookHandler('payment_intent.created') diff --git a/apps/api/src/donations/helpers/donation-status-updates.ts b/apps/api/src/donations/helpers/donation-status-updates.ts index 6a799d325..5242858a9 100644 --- a/apps/api/src/donations/helpers/donation-status-updates.ts +++ b/apps/api/src/donations/helpers/donation-status-updates.ts @@ -1,4 +1,5 @@ import { PaymentStatus } from '@prisma/client' +import Stripe from 'stripe' const initial: PaymentStatus[] = [PaymentStatus.initial] const changeable: PaymentStatus[] = [ @@ -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 +} diff --git a/apps/api/src/paypal/paypal.controller.spec.ts b/apps/api/src/paypal/paypal.controller.spec.ts index cf46e3f4e..56903f541 100644 --- a/apps/api/src/paypal/paypal.controller.spec.ts +++ b/apps/api/src/paypal/paypal.controller.spec.ts @@ -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 diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts index 9ed4b7c31..9ce20a58b 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -170,7 +170,6 @@ export class VaultService { if (amount <= 0) { throw new Error('Amount cannot be negative or zero.') } - const updateStatement = { where: { id: vaultId }, data: { diff --git a/migrations/20240624140843_add_benevity_as_payment_provider/migration.sql b/migrations/20240624140843_add_benevity_as_payment_provider/migration.sql new file mode 100644 index 000000000..99be9ad59 --- /dev/null +++ b/migrations/20240624140843_add_benevity_as_payment_provider/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "payment_provider" ADD VALUE 'benevity'; diff --git a/package.json b/package.json index a0067bbf4..be9e56ce0 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@types/mjml": "^4.7.1", "@types/multer": "1.4.7", "@types/node": "16.9.1", + "@types/uuid": "^9.0.8", "@types/xml2js": "^0", "@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/parser": "^5.48.1", diff --git a/podkrepi.dbml b/podkrepi.dbml index 316d07524..7c9dbfae1 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -699,6 +699,7 @@ Enum PaymentProvider { epay bank cash + benevity } Enum DocumentType { diff --git a/schema.prisma b/schema.prisma index 70d0c5936..127e6b7bf 100644 --- a/schema.prisma +++ b/schema.prisma @@ -795,6 +795,7 @@ enum PaymentProvider { epay bank cash + benevity @@map("payment_provider") } diff --git a/yarn.lock b/yarn.lock index 89bb89cce..20db9e97d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5422,6 +5422,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^9.0.8": + version: 9.0.8 + resolution: "@types/uuid@npm:9.0.8" + checksum: b8c60b7ba8250356b5088302583d1704a4e1a13558d143c549c408bf8920535602ffc12394ede77f8a8083511b023704bc66d1345792714002bfa261b17c5275 + languageName: node + linkType: hard + "@types/validator@npm:^13.7.10": version: 13.7.13 resolution: "@types/validator@npm:13.7.13" @@ -13626,6 +13633,7 @@ __metadata: "@types/mjml": ^4.7.1 "@types/multer": 1.4.7 "@types/node": 16.9.1 + "@types/uuid": ^9.0.8 "@types/xml2js": ^0 "@typescript-eslint/eslint-plugin": ^5.48.1 "@typescript-eslint/parser": ^5.48.1