From f09c98f0d8358d75e0f1837f1dd896f9d67a0570 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Mon, 12 Feb 2024 13:30:54 +0200 Subject: [PATCH 01/10] Donation table structural changes - Payment table will save the information regarding the payment - Donation table will save information regarding the donation(e.g. campaign, wish etc..) --- .../affiliate/entities/affiliate.entity.ts | 4 +- .../donation/dto/connect-donation.dto.ts | 3 +- .../donation/dto/create-donation.dto.ts | 5 - .../donation/dto/update-donation.dto.ts | 5 - .../donation/entities/donation.entity.ts | 21 +-- .../payments/dto/connect-payments.dto.ts | 4 + .../payments/dto/create-payments.dto.ts | 12 ++ .../domain/generated/payments/dto/index.ts | 3 + .../payments/dto/update-payments.dto.ts | 12 ++ .../generated/payments/entities/index.ts | 1 + .../payments/entities/payments.entity.ts | 23 +++ .../migration.sql | 133 ++++++++++++++++++ podkrepi.dbml | 47 +++++-- schema.prisma | 74 ++++++---- 14 files changed, 277 insertions(+), 70 deletions(-) create mode 100644 apps/api/src/domain/generated/payments/dto/connect-payments.dto.ts create mode 100644 apps/api/src/domain/generated/payments/dto/create-payments.dto.ts create mode 100644 apps/api/src/domain/generated/payments/dto/index.ts create mode 100644 apps/api/src/domain/generated/payments/dto/update-payments.dto.ts create mode 100644 apps/api/src/domain/generated/payments/entities/index.ts create mode 100644 apps/api/src/domain/generated/payments/entities/payments.entity.ts create mode 100644 migrations/20240212112752_new_donation_structure/migration.sql diff --git a/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts b/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts index 3672e5d40..a745a51cc 100644 --- a/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts +++ b/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts @@ -1,6 +1,6 @@ import { AffiliateStatus } from '@prisma/client' import { Company } from '../../company/entities/company.entity' -import { Donation } from '../../donation/entities/donation.entity' +import { Payments } from '../../payments/entities/payments.entity' export class Affiliate { id: string @@ -10,5 +10,5 @@ export class Affiliate { createdAt: Date updatedAt: Date | null company?: Company - donations?: Donation[] + payments?: Payments[] } diff --git a/apps/api/src/domain/generated/donation/dto/connect-donation.dto.ts b/apps/api/src/domain/generated/donation/dto/connect-donation.dto.ts index a5015856d..a318d4f53 100644 --- a/apps/api/src/domain/generated/donation/dto/connect-donation.dto.ts +++ b/apps/api/src/domain/generated/donation/dto/connect-donation.dto.ts @@ -1,4 +1,3 @@ export class ConnectDonationDto { - id?: string - extPaymentIntentId?: string + id: string } diff --git a/apps/api/src/domain/generated/donation/dto/create-donation.dto.ts b/apps/api/src/domain/generated/donation/dto/create-donation.dto.ts index e447208dc..290d9bee1 100644 --- a/apps/api/src/domain/generated/donation/dto/create-donation.dto.ts +++ b/apps/api/src/domain/generated/donation/dto/create-donation.dto.ts @@ -4,9 +4,4 @@ import { ApiProperty } from '@nestjs/swagger' export class CreateDonationDto { @ApiProperty({ enum: DonationType }) type: DonationType - extCustomerId: string - extPaymentIntentId: string - extPaymentMethodId: string - billingEmail?: string - billingName?: string } diff --git a/apps/api/src/domain/generated/donation/dto/update-donation.dto.ts b/apps/api/src/domain/generated/donation/dto/update-donation.dto.ts index c88603166..050aef5ea 100644 --- a/apps/api/src/domain/generated/donation/dto/update-donation.dto.ts +++ b/apps/api/src/domain/generated/donation/dto/update-donation.dto.ts @@ -4,9 +4,4 @@ import { ApiProperty } from '@nestjs/swagger' export class UpdateDonationDto { @ApiProperty({ enum: DonationType }) type?: DonationType - extCustomerId?: string - extPaymentIntentId?: string - extPaymentMethodId?: string - billingEmail?: string - billingName?: string } diff --git a/apps/api/src/domain/generated/donation/entities/donation.entity.ts b/apps/api/src/domain/generated/donation/entities/donation.entity.ts index 57fc0ce8b..61184302b 100644 --- a/apps/api/src/domain/generated/donation/entities/donation.entity.ts +++ b/apps/api/src/domain/generated/donation/entities/donation.entity.ts @@ -1,31 +1,22 @@ -import { DonationType, DonationStatus, PaymentProvider, Currency } from '@prisma/client' +import { DonationType } from '@prisma/client' import { Person } from '../../person/entities/person.entity' import { Vault } from '../../vault/entities/vault.entity' -import { Affiliate } from '../../affiliate/entities/affiliate.entity' import { DonationWish } from '../../donationWish/entities/donationWish.entity' import { DonationMetadata } from '../../donationMetadata/entities/donationMetadata.entity' +import { Payments } from '../../payments/entities/payments.entity' export class Donation { id: string + paymentId: string type: DonationType - status: DonationStatus - provider: PaymentProvider targetVaultId: string - extCustomerId: string - extPaymentIntentId: string - extPaymentMethodId: string - createdAt: Date - updatedAt: Date | null amount: number - currency: Currency - affiliateId: string | null personId: string | null - billingEmail: string | null - billingName: string | null - chargedAmount: number + createdAt: Date + updatedAt: Date | null person?: Person | null targetVault?: Vault - affiliate?: Affiliate | null DonationWish?: DonationWish | null metadata?: DonationMetadata | null + payment?: Payments } diff --git a/apps/api/src/domain/generated/payments/dto/connect-payments.dto.ts b/apps/api/src/domain/generated/payments/dto/connect-payments.dto.ts new file mode 100644 index 000000000..3ab987276 --- /dev/null +++ b/apps/api/src/domain/generated/payments/dto/connect-payments.dto.ts @@ -0,0 +1,4 @@ +export class ConnectPaymentsDto { + id?: string + extPaymentIntentId?: string +} diff --git a/apps/api/src/domain/generated/payments/dto/create-payments.dto.ts b/apps/api/src/domain/generated/payments/dto/create-payments.dto.ts new file mode 100644 index 000000000..370655488 --- /dev/null +++ b/apps/api/src/domain/generated/payments/dto/create-payments.dto.ts @@ -0,0 +1,12 @@ +import { PaymentType } from '@prisma/client' +import { ApiProperty } from '@nestjs/swagger' + +export class CreatePaymentsDto { + extCustomerId: string + extPaymentIntentId: string + extPaymentMethodId: string + @ApiProperty({ enum: PaymentType }) + type: PaymentType + billingEmail?: string + billingName?: string +} diff --git a/apps/api/src/domain/generated/payments/dto/index.ts b/apps/api/src/domain/generated/payments/dto/index.ts new file mode 100644 index 000000000..f5b1ef3e8 --- /dev/null +++ b/apps/api/src/domain/generated/payments/dto/index.ts @@ -0,0 +1,3 @@ +export * from './connect-payments.dto' +export * from './create-payments.dto' +export * from './update-payments.dto' diff --git a/apps/api/src/domain/generated/payments/dto/update-payments.dto.ts b/apps/api/src/domain/generated/payments/dto/update-payments.dto.ts new file mode 100644 index 000000000..fabc19788 --- /dev/null +++ b/apps/api/src/domain/generated/payments/dto/update-payments.dto.ts @@ -0,0 +1,12 @@ +import { PaymentType } from '@prisma/client' +import { ApiProperty } from '@nestjs/swagger' + +export class UpdatePaymentsDto { + extCustomerId?: string + extPaymentIntentId?: string + extPaymentMethodId?: string + @ApiProperty({ enum: PaymentType }) + type?: PaymentType + billingEmail?: string + billingName?: string +} diff --git a/apps/api/src/domain/generated/payments/entities/index.ts b/apps/api/src/domain/generated/payments/entities/index.ts new file mode 100644 index 000000000..e2eff48b0 --- /dev/null +++ b/apps/api/src/domain/generated/payments/entities/index.ts @@ -0,0 +1 @@ +export * from './payments.entity' diff --git a/apps/api/src/domain/generated/payments/entities/payments.entity.ts b/apps/api/src/domain/generated/payments/entities/payments.entity.ts new file mode 100644 index 000000000..6e050ba29 --- /dev/null +++ b/apps/api/src/domain/generated/payments/entities/payments.entity.ts @@ -0,0 +1,23 @@ +import { PaymentType, Currency, PaymentStatus, PaymentProvider } from '@prisma/client' +import { Affiliate } from '../../affiliate/entities/affiliate.entity' +import { Donation } from '../../donation/entities/donation.entity' + +export class Payments { + id: string + extCustomerId: string + extPaymentIntentId: string + extPaymentMethodId: string + type: PaymentType + currency: Currency + status: PaymentStatus + provider: PaymentProvider + affiliateId: string | null + createdAt: Date + updatedAt: Date | null + chargedAmount: number + amount: number + billingEmail: string | null + billingName: string | null + affiliate?: Affiliate | null + donations?: Donation[] +} diff --git a/migrations/20240212112752_new_donation_structure/migration.sql b/migrations/20240212112752_new_donation_structure/migration.sql new file mode 100644 index 000000000..90e592a79 --- /dev/null +++ b/migrations/20240212112752_new_donation_structure/migration.sql @@ -0,0 +1,133 @@ +BEGIN; + +CREATE TABLE "donations_temp" AS TABLE donations; + +--Rename donation_status to payment_status +CREATE TYPE "payment_status" AS ENUM ('initial', 'invalid', 'incomplete', 'declined', 'waiting', 'cancelled', 'guaranteed', 'succeeded', 'deleted', 'refund', 'paymentRequested'); +CREATE TYPE "payment_type" AS ENUM ('single', 'category', 'benevity'); + + +--Drop constraints as donation table will be truncated +--Constraints will be re-added before the transaction is commited +ALTER TABLE "donation_metadata" DROP CONSTRAINT "donation_metadata_donation_id_fkey"; +ALTER TABLE "donation_wishes" DROP CONSTRAINT "donation_wishes_donation_id_fkey"; + +--Delete all existing records of donation +TRUNCATE donations; + +-- Remove redundant fields, indexes and constraint from donations table. Add payment_id field +ALTER TABLE "donations" + DROP COLUMN "affiliate_id", + DROP COLUMN "billing_email", + DROP COLUMN "billing_name", + DROP COLUMN "chargedAmount", + DROP COLUMN "ext_customer_id", + DROP COLUMN "ext_payment_intent_id", + DROP COLUMN "ext_payment_method_id", + DROP COLUMN "currency", + DROP COLUMN "provider", + DROP COLUMN "status", + ADD COLUMN "payment_id" UUID NOT NULL; + + + +--Create Payments table +CREATE TABLE payments ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "ext_customer_id" VARCHAR(50) NOT NULL, + "ext_payment_intent_id" TEXT NOT NULL, + "ext_payment_method_id" TEXT NOT NULL, + "type" "payment_type" NOT NULL, + "currency" "currency" NOT NULL DEFAULT 'BGN', + "status" "payment_status" NOT NULL DEFAULT 'initial', + "provider" "payment_provider" NOT NULL DEFAULT 'none', + "affiliate_id" UUID, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + "charged_amount" INTEGER NOT NULL DEFAULT 0, + "amount" INTEGER NOT NULL DEFAULT 0, + "billing_email" VARCHAR, + "billing_name" VARCHAR, + + CONSTRAINT "payments_pkey" PRIMARY KEY ("id") +); + +--Add donation<->payments relation +ALTER TABLE "donations" ADD CONSTRAINT "donations_payment_id_fkey" FOREIGN KEY ("payment_id") REFERENCES "payments"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "payments_ext_payment_intent_id_key" ON "payments"("ext_payment_intent_id"); + +-- AddForeignKey +ALTER TABLE "payments" ADD CONSTRAINT "payments_affiliate_id_fkey" FOREIGN KEY ("affiliate_id") REFERENCES "affiliates"("id") ON DELETE SET NULL ON UPDATE CASCADE; +DO $$ +DECLARE + dbrow RECORD; + payment_result RECORD; + old_donation_count INTEGER; + payments_count INTEGER; + new_donation_count INTEGER; + l_context TEXT; + + BEGIN +RAISE DEBUG '==FILL TABLES=='; + FOR dbrow IN SELECT * FROM "donations_temp" + LOOP + RAISE DEBUG '%', dbrow; + WITH payment AS ( + INSERT INTO payments ("ext_customer_id", + "ext_payment_intent_id", + "ext_payment_method_id", + "type", + "currency", + "status", + "provider", + "affiliate_id", + "created_at", + "updated_at", + "charged_amount", + "amount", + "billing_email", + "billing_name") + VALUES ( + dbrow.ext_customer_id, + dbrow.ext_payment_intent_id, + dbrow.ext_payment_method_id, + 'single', + dbrow.currency, + dbrow.status::TEXT::payment_status, + dbrow.provider, + dbrow.affiliate_id, + dbrow.created_at, + dbrow.updated_at, + dbrow."chargedAmount", + dbrow.amount, + dbrow.billing_email, + dbrow.billing_name + ) RETURNING id + ) + + SELECT * INTO payment_result FROM payment; + INSERT INTO "donations" (id, payment_id, "type", target_vault_id, amount, person_id, created_at, updated_at) + VALUES(dbrow.id, payment_result.id, dbrow.type, dbrow.target_vault_id, dbrow.amount, dbrow.person_id, dbrow.created_at, dbrow.updated_at); + + END LOOP; +RAISE DEBUG '==END FILL TABLES=='; + +SELECT COUNT(*)::INTEGER INTO old_donation_count FROM donations_temp; +SELECT COUNT(*)::INTEGER INTO payments_count FROM payments; +SELECT COUNT (*)::INTEGER INTO new_donation_count FROM donations; + +ASSERT old_donation_count = payments_count, 'Mismatch of old and new versions'; +ASSERT old_donation_count = new_donation_count, 'Mismatch of old and new versions'; +ASSERT payments_count = new_donation_count, 'Payments and Donations have different length'; +END$$; + +-- Add constraints +ALTER TABLE "donation_metadata" ADD CONSTRAINT "donation_metadata_donation_id_fkey" FOREIGN KEY ("donation_id") REFERENCES "donations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "donation_wishes" ADD CONSTRAINT "donation_wishes_donation_id_fkey" FOREIGN KEY ("donation_id") REFERENCES "donations"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +--DROP temp dable +DROP TABLE "donations_temp"; +DROP TYPE "donation_status"; +COMMIT; \ No newline at end of file diff --git a/podkrepi.dbml b/podkrepi.dbml index 7c6c8b54c..451d653cd 100644 --- a/podkrepi.dbml +++ b/podkrepi.dbml @@ -76,7 +76,7 @@ Table affiliates { createdAt DateTime [default: `now()`, not null] updatedAt DateTime company companies [not null] - donations donations [not null] + payments payments [not null] } Table organizers { @@ -369,29 +369,40 @@ Table vaults { withdraws withdrawals [not null] } -Table donations { +Table payments { id String [pk] - type DonationType [not null] - status DonationStatus [not null, default: 'initial'] - provider PaymentProvider [not null, default: 'none'] - targetVaultId String [not null, note: 'Vault where the funds are going'] - extCustomerId String [not null, note: 'Payment provider attributes'] + extCustomerId String [not null] extPaymentIntentId String [unique, not null] extPaymentMethodId String [not null] + type PaymentType [not null] + currency Currency [not null, default: 'BGN'] + status PaymentStatus [not null, default: 'initial'] + provider PaymentProvider [not null, default: 'none'] + affiliateId String createdAt DateTime [default: `now()`, not null] updatedAt DateTime + chargedAmount Int [not null, default: 0] amount Int [not null, default: 0] - currency Currency [not null, default: 'BGN'] - affiliateId String - personId String billingEmail String billingName String - chargedAmount Int [not null, default: 0] + affiliate affiliates + donations donations [not null] +} + +Table donations { + id String [pk] + paymentId String [not null] + type DonationType [not null] + targetVaultId String [not null, note: 'Vault where the funds are going'] + amount Int [not null, default: 0] + personId String + createdAt DateTime [default: `now()`, not null] + updatedAt DateTime person people targetVault vaults [not null] - affiliate affiliates DonationWish donation_wishes metadata donation_metadata + payment payments [not null] } Table donation_metadata { @@ -660,7 +671,13 @@ Enum DonationType { corporate } -Enum DonationStatus { +Enum PaymentType { + single + category + benevity +} + +Enum PaymentStatus { initial invalid incomplete @@ -867,11 +884,13 @@ Ref: cities.countryId > countries.id Ref: vaults.campaignId > campaigns.id +Ref: payments.affiliateId > affiliates.id + Ref: donations.personId > people.id Ref: donations.targetVaultId > vaults.id -Ref: donations.affiliateId > affiliates.id +Ref: donations.paymentId > payments.id Ref: donation_metadata.donationId - donations.id diff --git a/schema.prisma b/schema.prisma index 1c3d805d6..1a086e635 100644 --- a/schema.prisma +++ b/schema.prisma @@ -107,7 +107,7 @@ model Affiliate { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) company Company @relation(fields: [companyId], references: [id]) - donations Donation[] + payments Payments[] @@map("affiliates") } @@ -448,32 +448,44 @@ model Vault { @@map("vaults") } +model Payments { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + extCustomerId String @map("ext_customer_id") @db.VarChar(50) + extPaymentIntentId String @unique @map("ext_payment_intent_id") + extPaymentMethodId String @map("ext_payment_method_id") + type PaymentType + currency Currency @default(BGN) + status PaymentStatus @default(initial) + provider PaymentProvider @default(none) + affiliateId String? @map("affiliate_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + chargedAmount Int @default(0) @map("charged_amount") + amount Int @default(0) + billingEmail String? @map("billing_email") @db.VarChar + billingName String? @map("billing_name") @db.VarChar + affiliate Affiliate? @relation(fields: [affiliateId], references: [id]) + donations Donation[] + + @@map("payments") +} + model Donation { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid - type DonationType - status DonationStatus @default(initial) - provider PaymentProvider @default(none) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + + paymentId String @map("payment_id") @db.Uuid + type DonationType /// Vault where the funds are going - targetVaultId String @map("target_vault_id") @db.Uuid - /// Payment provider attributes - extCustomerId String @map("ext_customer_id") @db.VarChar(50) - extPaymentIntentId String @unique @map("ext_payment_intent_id") - extPaymentMethodId String @map("ext_payment_method_id") - /// - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) - amount Int @default(0) - currency Currency @default(BGN) - affiliateId String? @map("affiliate_id") @db.Uuid - personId String? @map("person_id") @db.Uuid - billingEmail String? @map("billing_email") @db.VarChar - billingName String? @map("billing_name") @db.VarChar - chargedAmount Int @default(0) - person Person? @relation(fields: [personId], references: [id]) - targetVault Vault @relation(fields: [targetVaultId], references: [id]) - affiliate Affiliate? @relation(fields: [affiliateId], references: [id]) - DonationWish DonationWish? - metadata DonationMetadata? + targetVaultId String @map("target_vault_id") @db.Uuid + amount Int @default(0) + personId String? @map("person_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + person Person? @relation(fields: [personId], references: [id]) + targetVault Vault @relation(fields: [targetVaultId], references: [id]) + DonationWish DonationWish? + metadata DonationMetadata? + payment Payments @relation(fields: [paymentId], references: [id]) @@map("donations") } @@ -801,7 +813,15 @@ enum DonationType { @@map("donation_type") } -enum DonationStatus { +enum PaymentType { + single + category + benevity + + @@map("payment_type") +} + +enum PaymentStatus { initial invalid incomplete @@ -814,7 +834,7 @@ enum DonationStatus { refund paymentRequested - @@map("donation_status") + @@map("payment_status") } enum RecurringDonationStatus { From cd9ab5d5cdff75424c3f11b7a55b9e3a3dd52129 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sun, 25 Feb 2024 13:28:47 +0200 Subject: [PATCH 02/10] Initial API changes for new payment structure --- .../api/src/affiliate/affiliate.controller.ts | 6 +- apps/api/src/affiliate/affiliate.service.ts | 67 ++-- .../dto/create-affiliate-donation.dto.ts | 37 ++- .../bank-transactions-file.controller.ts | 31 +- .../bank-transactions.controller.ts | 3 +- .../bank-transactions.service.ts | 18 +- apps/api/src/campaign/campaign.service.ts | 123 ++++--- apps/api/src/common/dto/donation-query-dto.ts | 9 +- .../donation-wish/donation-wish.service.ts | 1 - .../api/src/donations/donations.controller.ts | 42 ++- apps/api/src/donations/donations.service.ts | 310 ++++++++++++------ .../donations/dto/create-bank-payment.dto.ts | 20 +- .../src/donations/dto/create-payment.dto.ts | 50 +-- .../src/donations/dto/list-donations.dto.ts | 8 +- .../events/stripe-payment.service.ts | 24 +- .../helpers/donation-status-updates.ts | 44 +-- .../donations/queries/donation.validator.ts | 9 + apps/api/src/paypal/paypal.service.ts | 12 +- .../notifications/notification.service.ts | 22 +- apps/api/src/statistics/statistics.service.ts | 26 +- .../bank-import/import-transactions.task.ts | 62 +++- apps/api/src/vault/vault.service.ts | 26 +- 22 files changed, 622 insertions(+), 328 deletions(-) diff --git a/apps/api/src/affiliate/affiliate.controller.ts b/apps/api/src/affiliate/affiliate.controller.ts index 22f906fa9..0db8cc1b9 100644 --- a/apps/api/src/affiliate/affiliate.controller.ts +++ b/apps/api/src/affiliate/affiliate.controller.ts @@ -23,7 +23,7 @@ import { CreateAffiliateDonationDto } from './dto/create-affiliate-donation.dto' import { DonationsService } from '../donations/donations.service' import { shouldAllowStatusChange } from '../donations/helpers/donation-status-updates' import { affiliateCodeGenerator } from './utils/affiliateCodeGenerator' -import { DonationStatus } from '@prisma/client' +import { PaymentStatus } from '@prisma/client' import { CampaignService } from '../campaign/campaign.service' import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types' @@ -134,7 +134,7 @@ export class AffiliateController { @Public() async getAffiliateDonations( @Param('affiliateCode') affiliateCode: string, - @Query('status') status: DonationStatus | undefined, + @Query('status') status: PaymentStatus | undefined, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit') limit: number | undefined, ) { @@ -151,7 +151,7 @@ export class AffiliateController { async findAffiliateDonationByCustomerId( @Param('affiliateCode') affiliateCode: string, @Param('customerId') customerId: string, - @Query('status') status: DonationStatus | undefined, + @Query('status') status: PaymentStatus | undefined, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, @Query('limit') limit: number | undefined, ) { diff --git a/apps/api/src/affiliate/affiliate.service.ts b/apps/api/src/affiliate/affiliate.service.ts index 3ea32aa51..4b72209ba 100644 --- a/apps/api/src/affiliate/affiliate.service.ts +++ b/apps/api/src/affiliate/affiliate.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common' import { PrismaService } from '../prisma/prisma.service' -import { AffiliateStatus, DonationStatus } from '@prisma/client' +import { AffiliateStatus, PaymentStatus } from '@prisma/client' @Injectable() export class AffiliateService { @@ -19,20 +19,24 @@ export class AffiliateService { async findDonationsByCustomerId( affiliateCode: string, extCustomerId: string, - status: DonationStatus | undefined, + status: PaymentStatus | undefined, currentPage: number, limit: number | undefined, ) { return await this.prismaService.affiliate.findFirst({ where: { affiliateCode, - donations: { some: { extCustomerId: { equals: extCustomerId }, status } }, + payments: { some: { extCustomerId, status } }, }, select: { - donations: { - take: limit ? Number(limit) : undefined, - skip: Number((currentPage - 1) * (limit ?? 0)), - include: { metadata: true }, + payments: { + select: { + donations: { + take: limit ? Number(limit) : undefined, + skip: Number((currentPage - 1) * (limit ?? 0)), + include: { metadata: true }, + }, + }, }, }, }) @@ -67,13 +71,22 @@ export class AffiliateService { async getAffiliateDataByKeycloakId(keycloakId: string) { return await this.prismaService.affiliate.findFirst({ where: { company: { person: { keycloakId } } }, - include: { - donations: { - where: { status: DonationStatus.guaranteed }, + select: { + status: true, + affiliateCode: true, + company: { select: { companyName: true } }, + payments: { + where: { status: PaymentStatus.guaranteed }, include: { - targetVault: { select: { campaign: { select: { title: true, slug: true } } } }, - affiliate: { select: { company: { select: { companyName: true } } } }, - metadata: { select: { name: true } }, + donations: { + select: { + id: true, + paymentId: true, + targetVault: { select: { campaign: { select: { title: true, slug: true } } } }, + metadata: { select: { name: true } }, + amount: true, + }, + }, }, }, }, @@ -82,19 +95,23 @@ export class AffiliateService { async findAffiliateDonationsWithPagination( affiliateCode: string, - status: DonationStatus | undefined, + status: PaymentStatus | undefined, currentPage: number, limit: number | undefined, ) { return await this.prismaService.affiliate.findUnique({ where: { affiliateCode }, select: { - donations: { - orderBy: { createdAt: 'desc' }, - where: { status }, - take: limit ? Number(limit) : undefined, - skip: Number((currentPage - 1) * (limit ?? 0)), - include: { metadata: true }, + payments: { + select: { + donations: { + orderBy: { createdAt: 'desc' }, + where: { payment: { status } }, + take: limit ? Number(limit) : undefined, + skip: Number((currentPage - 1) * (limit ?? 0)), + include: { metadata: true }, + }, + }, }, }, }) @@ -104,9 +121,13 @@ export class AffiliateService { return await this.prismaService.affiliate.findUnique({ where: { affiliateCode }, include: { - donations: { - orderBy: { createdAt: 'desc' }, - take: 10, + payments: { + include: { + donations: { + orderBy: { createdAt: 'desc' }, + take: 10, + }, + }, }, company: { select: { companyName: true, companyNumber: true, legalPersonName: true } }, }, diff --git a/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts b/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts index 838182a95..409239a46 100644 --- a/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts +++ b/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts @@ -1,5 +1,12 @@ import { ApiProperty } from '@nestjs/swagger' -import { Currency, DonationStatus, DonationType, PaymentProvider, Prisma } from '@prisma/client' +import { + Currency, + DonationType, + PaymentProvider, + PaymentStatus, + PaymentType, + Prisma, +} from '@prisma/client' import { Expose, Type } from 'class-transformer' import { Equals, @@ -81,10 +88,10 @@ export class CreateAffiliateDonationDto { @ValidateNested({ each: true }) metadata: DonationMetadataDto | undefined - public toEntity(targetVaultId: string): Prisma.DonationCreateInput { + public toEntity(targetVaultId: string): Prisma.PaymentsCreateInput { return { - type: DonationType.corporate, - status: DonationStatus.guaranteed, + type: PaymentType.single, + status: PaymentStatus.guaranteed, provider: PaymentProvider.bank, currency: this.currency, amount: this.amount, @@ -93,16 +100,24 @@ export class CreateAffiliateDonationDto { extPaymentMethodId: this.extPaymentMethodId ?? '', billingEmail: this.billingEmail, billingName: this.billingName, - targetVault: { - connect: { - id: targetVaultId, + donations: { + create: { + type: DonationType.corporate, + amount: this.amount, + + person: + this.isAnonymous === false && this.billingEmail + ? { connect: { email: this.billingEmail } } + : {}, + + targetVault: { + connect: { + id: targetVaultId, + }, + }, }, }, affiliate: this.affiliateId ? { connect: { id: this.affiliateId } } : {}, - person: - this.isAnonymous === false && this.billingEmail - ? { connect: { email: this.billingEmail } } - : {}, } } } diff --git a/apps/api/src/bank-transactions-file/bank-transactions-file.controller.ts b/apps/api/src/bank-transactions-file/bank-transactions-file.controller.ts index bf778da64..0c6fe35ba 100644 --- a/apps/api/src/bank-transactions-file/bank-transactions-file.controller.ts +++ b/apps/api/src/bank-transactions-file/bank-transactions-file.controller.ts @@ -25,7 +25,7 @@ import { VaultService } from '../vault/vault.service' import { CampaignService } from '../campaign/campaign.service' import { DonationsService } from '../donations/donations.service' import { parseBankTransactionsFile } from './helpers/parser' -import { DonationStatus, DonationType, PaymentProvider } from '@prisma/client' +import { DonationType, PaymentProvider, PaymentStatus, PaymentType } from '@prisma/client' import { ApiTags } from '@nestjs/swagger' import { BankImportResult, @@ -108,15 +108,32 @@ export class BankTransactionsFileController { const vault = await this.vaultService.findByCampaignId(campaign.id) movement.payment.extPaymentMethodId = 'imported bank payment' - movement.payment.targetVaultId = vault[0].id - movement.payment.type = DonationType.donation - movement.payment.status = DonationStatus.succeeded + movement.payment.donations[0].targetVaultId = vault[0].id + movement.payment.type = PaymentType.single + movement.payment.status = PaymentStatus.succeeded movement.payment.provider = PaymentProvider.bank + const paymentObj: CreateBankPaymentDto = { + provider: PaymentProvider.bank, + status: PaymentStatus.succeeded, + type: PaymentType.single, + extPaymentIntentId: movement.payment.extPaymentIntentId, + extPaymentMethodId: 'imported bank payment', + amount: movement.payment.amount, + currency: movement.payment.currency, + createdAt: movement.payment.createdAt, + extCustomerId: movement.payment.extCustomerId, + donations: { + create: { + type: DonationType.donation, + amount: movement.payment.amount, + targetVault: { connect: { id: vault[0].id } }, + }, + }, + } + try { - bankImportResult.status = await this.donationsService.createUpdateBankPayment( - movement.payment, - ) + bankImportResult.status = await this.donationsService.createUpdateBankPayment(paymentObj) } catch (e) { const errorMsg = `Error during database import ${movement.paymentRef} : ${e}` bankImportResult.status = ImportStatus.FAILED diff --git a/apps/api/src/bank-transactions/bank-transactions.controller.ts b/apps/api/src/bank-transactions/bank-transactions.controller.ts index 75db2fed5..90525c1a7 100644 --- a/apps/api/src/bank-transactions/bank-transactions.controller.ts +++ b/apps/api/src/bank-transactions/bank-transactions.controller.ts @@ -169,7 +169,8 @@ export class BankTransactionsController { const isDev = appEnv === 'development' || appEnv === 'staging' if (!isDev) throw new ForbiddenException('Endpoint available only for testing enviroments') - if (!isAdmin(user)) throw new ForbiddenException('Must be either an admin or active affiliate') + if (appEnv === 'staging' && !isAdmin(user)) + throw new ForbiddenException('Must be either an admin or active affiliate') return await this.bankTransactionsService.simulateIrisTask( irisDto.irisIbanAccountInfo, diff --git a/apps/api/src/bank-transactions/bank-transactions.service.ts b/apps/api/src/bank-transactions/bank-transactions.service.ts index cc61ec955..b4fd09751 100644 --- a/apps/api/src/bank-transactions/bank-transactions.service.ts +++ b/apps/api/src/bank-transactions/bank-transactions.service.ts @@ -4,10 +4,11 @@ import { BankTransaction, BankTransactionType, Currency, - DonationStatus, + PaymentStatus, DonationType, PaymentProvider, Vault, + PaymentType, } from '@prisma/client' import { ExportService } from '../export/export.service' import { getTemplateByTable } from '../export/helpers/exportableData' @@ -141,11 +142,17 @@ export class BankTransactionsService { createdAt: new Date(bankTransaction?.transactionDate), billingName: bankTransaction?.senderName || '', extPaymentMethodId: 'Manual Re-import', - targetVaultId: vault?.id, - type: DonationType.donation, - status: DonationStatus.succeeded, + type: PaymentType.single, + status: PaymentStatus.succeeded, provider: PaymentProvider.bank, - personId: null, + donations: { + create: { + amount: bankTransaction?.amount || 0, + personId: null, + targetVaultId: vault.id, + type: DonationType.donation, + }, + }, } // Execute as atomic transaction - fail/succeed as a whole @@ -159,6 +166,7 @@ export class BankTransactionsService { }, }) + console.log(`called`) // Import Donation await this.donationService.createUpdateBankPayment(bankPayment) }) diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 2acd46684..20dd2be66 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -4,7 +4,7 @@ import { CampaignState, CampaignType, Donation, - DonationStatus, + PaymentStatus, DonationType, Vault, CampaignFileRole, @@ -12,6 +12,8 @@ import { NotificationList, EmailType, CampaignTypeCategory, + Payments, + PaymentType, } from '@prisma/client' import { BadRequestException, @@ -99,29 +101,27 @@ export class CampaignService { async getCampaignSums(campaignIds?: string[]): Promise { let campaignSums: CampaignSummaryDto[] = [] - const result = await this.prisma.$queryRaw`SELECT + const result = await this.prisma.$queryRaw` + SELECT SUM(d.reached)::INTEGER as "reachedAmount", - SUM(g.guaranteed)::INTEGER as "guaranteedAmount", + SUM(d.guaranteed)::INTEGER as "guaranteedAmount", (SUM(v.amount) - SUM(v."blockedAmount"))::INTEGER as "currentAmount", SUM(v."blockedAmount")::INTEGER as "blockedAmount", SUM(w."withdrawnAmount")::INTEGER as "withdrawnAmount", - SUM(COALESCE(g.donors, 0) + COALESCE(d.donors, 0))::INTEGER as donors, + SUM(COALESCE(d.donors, 0))::INTEGER as donors, v.campaign_id as id FROM api.vaults v LEFT JOIN ( - SELECT target_vault_id, sum(amount) as reached, count(id) as donors + SELECT + target_vault_id, + COUNT(d.id) FILTER (WHERE d.payment_id = p.id AND p.status::text = 'succeeded' OR p.status::text = 'guaranteed') AS donors, + sum(d.amount) FILTER (WHERE d.payment_id = p.id AND p.status::text = 'succeeded') as reached, + sum(d.amount) FILTER (WHERE d.payment_id = p.id AND p.status::text = 'guaranteed') as guaranteed FROM api.donations d - WHERE status = 'succeeded' + INNER JOIN payments as p ON p.id = d.payment_id GROUP BY target_vault_id ) as d - ON d.target_vault_id = v.id - LEFT JOIN ( - SELECT target_vault_id, sum(amount) as guaranteed, count(id) as donors - FROM api.donations d - WHERE status = 'guaranteed' - GROUP BY target_vault_id - ) as g - ON g.target_vault_id = v.id + ON d.target_vault_id = v.id LEFT JOIN ( SELECT source_vault_id, sum(amount) as "withdrawnAmount" FROM api.withdrawals w @@ -494,8 +494,21 @@ export class CampaignService { pageSize?: number, ): Promise< Omit< - Donation, + Prisma.DonationGetPayload<{ + include: { + payment: { + select: { + affiliateId: true + provider: true + amount: true + currency: true + chargedAmount: true + } + } + } + }>, | 'personId' + | 'paymentId' | 'targetVaultId' | 'extCustomerId' | 'extPaymentIntentId' @@ -531,16 +544,20 @@ export class CampaignService { select: { id: true, type: true, - status: true, - affiliateId: true, - provider: true, createdAt: true, updatedAt: true, amount: true, - chargedAmount: true, - currency: true, person: { select: { firstName: true, lastName: true } }, targetVault: { select: { name: true } }, + payment: { + select: { + affiliateId: true, + provider: true, + amount: true, + currency: true, + chargedAmount: true, + }, + }, }, skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, take: pageSize ? pageSize : undefined, @@ -549,8 +566,8 @@ export class CampaignService { return donations } - async getDonationByIntentId(paymentIntentId: string): Promise { - return this.prisma.donation.findFirst({ where: { extPaymentIntentId: paymentIntentId } }) + async getDonationByIntentId(paymentIntentId: string): Promise { + return this.prisma.payments.findFirst({ where: { extPaymentIntentId: paymentIntentId } }) } /** @@ -564,7 +581,7 @@ export class CampaignService { async updateDonationPayment( campaign: Campaign, paymentData: PaymentData, - newDonationStatus: DonationStatus, + newDonationStatus: PaymentStatus, ): Promise { const campaignId = campaign.id Logger.debug('Update donation to status: ' + newDonationStatus, { @@ -607,13 +624,13 @@ export class CampaignService { private async updateDonationIfAllowed( tx: Prisma.TransactionClient, - donation: Donation, - newDonationStatus: DonationStatus, + donation: Prisma.PaymentsGetPayload<{ include: { donations: true } }>, + newDonationStatus: PaymentStatus, paymentData: PaymentData, ) { if (shouldAllowStatusChange(donation.status, newDonationStatus)) { try { - const updatedDonation = await tx.donation.update({ + const updatedDonation = await tx.payments.update({ where: { id: donation.id, }, @@ -631,30 +648,30 @@ export class CampaignService { //if donation is switching to successful, increment the vault amount and send notification if ( - donation.status != DonationStatus.succeeded && - newDonationStatus === DonationStatus.succeeded + donation.status != PaymentStatus.succeeded && + newDonationStatus === PaymentStatus.succeeded ) { await this.vaultService.incrementVaultAmount( - donation.targetVaultId, + donation.donations[0].targetVaultId, paymentData.netAmount, tx, ) this.notificationService.sendNotification('successfulDonation', { ...updatedDonation, - person: updatedDonation.person, + person: updatedDonation.donations[0].person, }) } else if ( - donation.status === DonationStatus.succeeded && - newDonationStatus === DonationStatus.refund + donation.status === PaymentStatus.succeeded && + newDonationStatus === PaymentStatus.refund ) { await this.vaultService.decrementVaultAmount( - donation.targetVaultId, + donation.donations[0].targetVaultId, paymentData.netAmount, tx, ) this.notificationService.sendNotification('successfulRefund', { ...updatedDonation, - person: updatedDonation.person, + person: updatedDonation.donations[0].person, }) } return updatedDonation @@ -677,7 +694,7 @@ export class CampaignService { private async createIncomingDonation( tx: Prisma.TransactionClient, paymentData: PaymentData, - newDonationStatus: DonationStatus, + newDonationStatus: PaymentStatus, campaign: Campaign, ) { Logger.debug( @@ -691,27 +708,37 @@ export class CampaignService { const targetVaultData = { connect: { id: vault.id } } try { - const donation = await tx.donation.create({ + const donation = await tx.payments.create({ data: { amount: paymentData.netAmount, chargedAmount: paymentData.chargedAmount, currency: campaign.currency, - targetVault: targetVaultData, provider: paymentData.paymentProvider, - type: paymentData.type as DonationType, + type: PaymentType.single, status: newDonationStatus, extCustomerId: paymentData.stripeCustomerId ?? '', extPaymentIntentId: paymentData.paymentIntentId, extPaymentMethodId: paymentData.paymentMethodId ?? '', billingName: paymentData.billingName, billingEmail: paymentData.billingEmail, - person: paymentData.personId ? { connect: { id: paymentData.personId } } : {}, + donations: { + create: { + amount: paymentData.netAmount, + type: paymentData.type as DonationType, + person: paymentData.personId ? { connect: { email: paymentData.billingEmail } } : {}, + targetVault: targetVaultData, + }, + }, }, select: donationNotificationSelect, }) - if (newDonationStatus === DonationStatus.succeeded) { - await this.vaultService.incrementVaultAmount(donation.targetVaultId, donation.amount, tx) + if (newDonationStatus === PaymentStatus.succeeded) { + await this.vaultService.incrementVaultAmount( + donation.donations[0].targetVaultId, + donation.amount, + tx, + ) this.notificationService.sendNotification('successfulDonation', donation) } @@ -726,8 +753,9 @@ export class CampaignService { private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) { //first try to find by paymentIntentId - let donation = await tx.donation.findUnique({ + let donation = await tx.payments.findUnique({ where: { extPaymentIntentId: paymentData.paymentIntentId }, + include: { donations: true }, }) // if not found by paymentIntent, check for if this is payment on subscription @@ -736,13 +764,18 @@ export class CampaignService { if (!donation && paymentData.personId && paymentData.personId.length === 36) { // search for a subscription donation // for subscriptions, we don't have a paymentIntentId - donation = await tx.donation.findFirst({ + donation = await tx.payments.findFirst({ where: { - status: DonationStatus.initial, - personId: paymentData.personId, + status: PaymentStatus.initial, chargedAmount: paymentData.chargedAmount, extPaymentMethodId: 'subscription', + donations: { + some: { + personId: paymentData.personId, + }, + }, }, + include: { donations: true }, }) Logger.debug('Donation found by subscription: ', donation) } diff --git a/apps/api/src/common/dto/donation-query-dto.ts b/apps/api/src/common/dto/donation-query-dto.ts index 579d93560..362bd1d59 100644 --- a/apps/api/src/common/dto/donation-query-dto.ts +++ b/apps/api/src/common/dto/donation-query-dto.ts @@ -1,4 +1,4 @@ -import { DonationStatus, PaymentProvider } from '@prisma/client' +import { PaymentStatus, PaymentProvider } from '@prisma/client' import { Expose, Transform } from 'class-transformer' import { IsOptional } from 'class-validator' @@ -16,7 +16,12 @@ export class DonationQueryDto { @Expose() @IsOptional() @Transform(({ value }) => falsyToUndefined(value)) - status?: DonationStatus + status?: PaymentStatus + + @Expose() + @IsOptional() + @Transform(({ value }) => falsyToUndefined(value)) + paymentId?: string @Expose() @IsOptional() diff --git a/apps/api/src/donation-wish/donation-wish.service.ts b/apps/api/src/donation-wish/donation-wish.service.ts index f9eb148ff..bc7824fce 100644 --- a/apps/api/src/donation-wish/donation-wish.service.ts +++ b/apps/api/src/donation-wish/donation-wish.service.ts @@ -76,7 +76,6 @@ export class DonationWishService { donation: { select: { amount: true, - currency: true, type: true, metadata: { select: { name: true } }, }, diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index e7bf46fe2..c17297292 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -14,9 +14,13 @@ import { forwardRef, } from '@nestjs/common' import { ApiQuery, ApiTags } from '@nestjs/swagger' -import { DonationStatus } from '@prisma/client' +import { PaymentStatus } from '@prisma/client' import { AuthenticatedUser, Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect' -import { RealmViewSupporters, ViewSupporters, EditFinancialsRequests } from '@podkrepi-bg/podkrepi-types' +import { + RealmViewSupporters, + ViewSupporters, + EditFinancialsRequests, +} from '@podkrepi-bg/podkrepi-types' import { isAdmin, KeycloakTokenParsed } from '../auth/keycloak' import { DonationsService } from './donations.service' @@ -130,17 +134,12 @@ export class DonationsController { @CacheTTL(2 * 1000) @Public() @ApiQuery({ name: 'campaignId', required: false, type: String }) - @ApiQuery({ name: 'status', required: false, enum: DonationStatus }) @ApiQuery({ name: 'pageindex', required: false, type: Number }) @ApiQuery({ name: 'pagesize', required: false, type: Number }) - findAllPublic( - @Query('campaignId') campaignId?: string, - @Query('status') status?: DonationStatus, - @Query() query?: DonationQueryDto, - ) { + findAllPublic(@Query('campaignId') campaignId?: string, @Query() query?: DonationQueryDto) { return this.donationsService.listDonationsPublic( campaignId, - status, + query?.status, query?.pageindex, query?.pagesize, ) @@ -154,6 +153,7 @@ export class DonationsController { @DonationsApiQuery() findAll(@Query() query: DonationQueryDto) { return this.donationsService.listDonations( + query?.paymentId, query?.campaignId, query?.status, query?.provider, @@ -169,6 +169,30 @@ export class DonationsController { ) } + @Get('payments') + @Roles({ + roles: [RealmViewSupporters.role, ViewSupporters.role], + mode: RoleMatchingMode.ANY, + }) + async paymentsList( + @AuthenticatedUser() user: KeycloakTokenParsed, + @Query() query: DonationQueryDto, + ) { + return await this.donationsService.listPayments( + query?.paymentId, + query?.status, + query?.provider, + query?.minAmount, + query?.maxAmount, + query?.from, + query?.to, + query?.sortBy, + query?.sortOrder, + query?.pageindex, + query?.pagesize, + ) + } + @Get(':id') @Public() findOne(@Param('id') id: string) { diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index cf8e3eaee..899158607 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -4,11 +4,13 @@ import { InjectStripeClient } from '@golevelup/nestjs-stripe' import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common' import { Campaign, - Donation, - DonationStatus, + PaymentStatus, DonationType, PaymentProvider, Prisma, + PaymentType, + Payments, + Donation, } from '@prisma/client' import { Response } from 'express' import { getTemplateByTable } from '../export/helpers/exportableData' @@ -21,14 +23,40 @@ import { DonationMetadata } from './dontation-metadata.interface' import { CreateBankPaymentDto } from './dto/create-bank-payment.dto' import { CreateSessionDto } from './dto/create-session.dto' import { UpdatePaymentDto } from './dto/update-payment.dto' -import { Person } from '../person/entities/person.entity' + import { DonationBaseDto, ListDonationsDto } from './dto/list-donations.dto' -import { donationWithPerson, DonationWithPerson } from './queries/donation.validator' +import { + donationWithPerson, + DonationWithPerson, + PaymentWithDonationCount, +} from './queries/donation.validator' import { CreateStripePaymentDto } from './dto/create-stripe-payment.dto' import { ImportStatus } from '../bank-transactions-file/dto/bank-transactions-import-status.dto' import { DonationQueryDto } from '../common/dto/donation-query-dto' import { CreateAffiliateDonationDto } from '../affiliate/dto/create-affiliate-donation.dto' +type PaymentWithDonation = Prisma.PaymentsGetPayload<{ + select: { + donations: { + select: { + metadata: { select: { name: true } } + person: { + select: { + firstName: true + lastName: true + company: { select: { companyName: true } } + } + } + } + } + } +}> + +type TPaymentWithDonations = Prisma.PaymentsGetPayload<{ include: { donations: true } }> +type VaultUpdate = { + [key: string]: number +} + @Injectable() export class DonationsService { constructor( @@ -91,7 +119,7 @@ export class DonationsService { campaign: Campaign, stripePaymentDto: CreateStripePaymentDto, paymentIntent: Stripe.PaymentIntent, - ): Promise { + ): Promise { Logger.debug('[ CreateInitialDonationFromIntent]', { campaignId: campaign.id, amount: paymentIntent.amount, @@ -104,32 +132,36 @@ export class DonationsService { /** * Create or update initial donation object */ - const donation = await this.prisma.donation.upsert({ + const donation = await this.prisma.payments.upsert({ where: { extPaymentIntentId: paymentIntent.id }, create: { amount: 0, chargedAmount: paymentIntent.amount, currency: campaign.currency, + type: PaymentType.single, provider: PaymentProvider.stripe, - type: DonationType.donation, - status: DonationStatus.initial, + status: PaymentStatus.initial, extCustomerId: stripePaymentDto.personEmail, extPaymentIntentId: paymentIntent.id, extPaymentMethodId: 'card', billingEmail: stripePaymentDto.personEmail, - targetVault: targetVaultData, + donations: { + create: { + type: DonationType.donation, + targetVault: targetVaultData, + }, + }, }, update: { amount: 0, //this will be updated on successful payment event chargedAmount: paymentIntent.amount, currency: campaign.currency, provider: PaymentProvider.stripe, - type: DonationType.donation, - status: DonationStatus.waiting, + type: PaymentType.single, + status: PaymentStatus.waiting, extCustomerId: stripePaymentDto.personEmail, extPaymentMethodId: 'card', billingEmail: stripePaymentDto.personEmail, - targetVault: targetVaultData, }, }) @@ -269,39 +301,35 @@ export class DonationsService { async listDonationsPublic( campaignId?: string, - status?: DonationStatus, + status?: PaymentStatus, pageIndex?: number, pageSize?: number, - ): Promise> { + ) { const [data, count] = await this.prisma.$transaction([ this.prisma.donation.findMany({ where: { - OR: [{ status: status }, { status: DonationStatus.guaranteed }], - targetVault: { campaign: { id: campaignId } }, + OR: [{ payment: { status: status } }, { payment: { status: PaymentStatus.guaranteed } }], + targetVault: { campaignId }, }, orderBy: [{ updatedAt: 'desc' }], select: { id: true, type: true, - status: true, - provider: true, createdAt: true, updatedAt: true, amount: true, - chargedAmount: true, - currency: true, person: { select: { firstName: true, lastName: true, company: { select: { companyName: true } } }, }, - metadata: { select: { name: true } }, }, + skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, take: pageSize ? pageSize : undefined, }), this.prisma.donation.count({ where: { - OR: [{ status: status }, { status: DonationStatus.guaranteed }], - targetVault: { campaign: { id: campaignId } }, + OR: [{ payment: { status: status } }, { payment: { status: PaymentStatus.guaranteed } }], + targetVault: { campaignId }, }, }), ]) @@ -319,13 +347,14 @@ export class DonationsService { if (!vault || vault.length === 0) throw new NotFoundException('Campaign or vault not found') - const donation = await this.prisma.donation.create({ + const payment = await this.prisma.payments.create({ data: donationDto.toEntity(vault[0].id), + include: { donations: true }, }) if (donationDto.metadata) { await this.prisma.donationMetadata.create({ data: { - donationId: donation.id, + donationId: payment.donations[0].id, ...donationDto.metadata, }, }) @@ -335,12 +364,12 @@ export class DonationsService { data: { campaignId: donationDto.campaignId, message: donationDto.message, - donationId: donation.id, + donationId: payment.donations[0].id, personId: donationDto.personId, }, }) } - return donation + return payment } /** @@ -355,9 +384,10 @@ export class DonationsService { * @param pageSize (Optional) */ async listDonations( + paymentId?: string, campaignId?: string, - status?: DonationStatus, - provider?: PaymentProvider, + paymentStatus?: PaymentStatus, + paymentProvider?: PaymentProvider, minAmount?: number, maxAmount?: number, from?: Date, @@ -368,9 +398,7 @@ export class DonationsService { pageIndex?: number, pageSize?: number, ): Promise> { - const whereClause = { - status, - provider, + const whereClause = Prisma.validator()({ amount: { gte: minAmount, lte: maxAmount, @@ -379,39 +407,81 @@ export class DonationsService { gte: from, lte: to, }, - ...(search && { - OR: [ - { billingName: { contains: search } }, - { billingEmail: { contains: search } }, - { - person: { - OR: [ - { - firstName: { contains: search }, - }, - { - lastName: { contains: search }, - }, - ], - }, - }, - ], - }), + paymentId: paymentId, + OR: [ + { payment: { status: paymentStatus } }, + { payment: { provider: paymentProvider } }, + { payment: { billingEmail: { contains: search } } }, + { payment: { billingName: { contains: search } } }, + ], targetVault: { campaign: { id: campaignId } }, + }) + + const [data, count] = await this.prisma.$transaction([ + this.prisma.donation.findMany({ + where: whereClause, + orderBy: [sortBy ? { [sortBy]: sortOrder ? sortOrder : 'desc' } : { createdAt: 'desc' }], + skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, + take: pageSize ? pageSize : undefined, + ...donationWithPerson, + }), + this.prisma.donation.count({ + where: whereClause, + }), + ]) + + const result = { + items: data, + total: count, } - const data = await this.prisma.donation.findMany({ - where: whereClause, - orderBy: [sortBy ? { [sortBy]: sortOrder ? sortOrder : 'desc' } : { createdAt: 'desc' }], - skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, - take: pageSize ? pageSize : undefined, - ...donationWithPerson, - }) + return result + } - const count = await this.prisma.donation.count({ - where: whereClause, + async listPayments( + paymentId?: string, + paymentStatus?: PaymentStatus, + paymentProvider?: PaymentProvider, + minAmount?: number, + maxAmount?: number, + from?: Date, + to?: Date, + sortBy?: string, + sortOrder?: string, + pageIndex?: number, + pageSize?: number, + ): Promise> { + const whereClause = Prisma.validator()({ + id: paymentId, + amount: { + gte: minAmount, + lte: maxAmount, + }, + createdAt: { + gte: from, + lte: to, + }, + status: paymentStatus, + provider: paymentProvider, }) + const [data, count] = await this.prisma.$transaction([ + this.prisma.payments.findMany({ + where: whereClause, + orderBy: [sortBy ? { [sortBy]: sortOrder ? sortOrder : 'desc' } : { createdAt: 'desc' }], + skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, + take: pageSize ? pageSize : undefined, + include: { + _count: { + select: { + donations: true, + }, + }, + }, + }), + this.prisma.payments.count({ where: whereClause }), + ]) + const result = { items: data, total: count, @@ -426,10 +496,11 @@ export class DonationsService { * @returns {Promise} Donation * @throws NotFoundException if no donation is found */ - async getDonationById(id: string): Promise { + async getDonationById(id: string): Promise { try { - const donation = await this.prisma.donation.findFirstOrThrow({ + const donation = await this.prisma.payments.findFirstOrThrow({ where: { id }, + include: { donations: true }, }) return donation } catch (err) { @@ -441,7 +512,7 @@ export class DonationsService { async getAffiliateDonationById(donationId: string, affiliateCode: string) { try { - const donation = await this.prisma.donation.findFirstOrThrow({ + const donation = await this.prisma.payments.findFirstOrThrow({ where: { id: donationId, affiliate: { affiliateCode: affiliateCode } }, }) return donation @@ -465,9 +536,10 @@ export class DonationsService { return await this.prisma.donation.findFirst({ where: { id, - status: DonationStatus.succeeded, - OR: [{ billingEmail: email }, { person: { keycloakId } }], + payment: { status: PaymentStatus.succeeded }, + OR: [{ payment: { billingEmail: email } }, { person: { keycloakId } }], }, + include: { targetVault: { select: { @@ -502,7 +574,7 @@ export class DonationsService { * @param inputDto Payment intent create params * @returns {Promise>} */ - async createStripePayment(inputDto: CreateStripePaymentDto): Promise { + async createStripePayment(inputDto: CreateStripePaymentDto): Promise { const intent = await this.stripeClient.paymentIntents.retrieve(inputDto.paymentIntentId) if (!intent.metadata.campaignId) { throw new BadRequestException('Campaign id is missing from payment intent metadata') @@ -563,25 +635,27 @@ export class DonationsService { async createUpdateBankPayment(donationDto: CreateBankPaymentDto): Promise { return await this.prisma.$transaction(async (tx) => { //to avoid incrementing vault amount twice we first check if there is such donation - const existingDonation = await tx.donation.findUnique({ + const existingDonation = await tx.payments.findUnique({ where: { extPaymentIntentId: donationDto.extPaymentIntentId }, }) if (!existingDonation) { - await tx.donation.create({ + const payment = await tx.payments.create({ data: donationDto, + include: { + donations: true, + }, }) await this.vaultService.incrementVaultAmount( - donationDto.targetVaultId, - donationDto.amount, + payment.donations[0].targetVaultId, + payment.donations[0].amount, tx, ) return ImportStatus.SUCCESS } - //Donation exists, so updating with incoming donation without increasing vault amounts - await this.prisma.donation.update({ + await this.prisma.payments.update({ where: { extPaymentIntentId: donationDto.extPaymentIntentId }, data: { ...donationDto, updatedAt: existingDonation.updatedAt }, }) @@ -589,20 +663,20 @@ export class DonationsService { }) } - async updateAffiliateBankPayment(donationDto: Donation) { + async updateAffiliateBankPayment(paymentsIds: string[], donationDto: VaultUpdate) { return await this.prisma.$transaction(async (tx) => { await Promise.all([ - this.vaultService.incrementVaultAmount(donationDto.targetVaultId, donationDto.amount, tx), - tx.donation.update({ - where: { id: donationDto.id }, - data: { status: DonationStatus.succeeded, updatedAt: donationDto.updatedAt }, + this.vaultService.updateManyVaultsAmount(donationDto, tx, 'increment'), + tx.payments.updateMany({ + where: { id: { in: paymentsIds } }, + data: { status: PaymentStatus.succeeded }, }), ]) }) } - async updateAffiliateDonations(donationId: string, affiliateId: string, status: DonationStatus) { - const donation = await this.prisma.donation.update({ + async updateAffiliateDonations(donationId: string, affiliateId: string, status: PaymentStatus) { + const donation = await this.prisma.payments.update({ where: { id: donationId, affiliateId: affiliateId, @@ -619,31 +693,36 @@ export class DonationsService { * @param updatePaymentDto * @returns */ - async update(id: string, updatePaymentDto: UpdatePaymentDto): Promise { + async update(id: string, updatePaymentDto: UpdatePaymentDto): Promise { try { // execute the below in prisma transaction return await this.prisma.$transaction(async (tx) => { - const currentDonation = await tx.donation.findFirst({ + const currentDonation = await tx.payments.findFirst({ where: { id }, + include: { + donations: { + select: { personId: true, targetVaultId: true }, + }, + }, }) if (!currentDonation) { throw new NotFoundException(`Update failed. No donation found with ID: ${id}`) } if ( - currentDonation.status === DonationStatus.succeeded && + currentDonation.status === PaymentStatus.succeeded && updatePaymentDto.status && - updatePaymentDto.status !== DonationStatus.succeeded + updatePaymentDto.status !== PaymentStatus.succeeded ) { throw new BadRequestException('Succeeded donations cannot be updated.') } const status = updatePaymentDto.status || currentDonation.status - let donorId = currentDonation.personId + let donorId = currentDonation.donations[0].personId let billingEmail: string | null = '' if ( (updatePaymentDto.targetPersonId && - currentDonation.personId !== updatePaymentDto.targetPersonId) || + currentDonation.donations[0].personId !== updatePaymentDto[0].targetPersonId) || updatePaymentDto.billingEmail ) { const targetDonor = await tx.person.findFirst({ @@ -663,11 +742,18 @@ export class DonationsService { billingEmail = targetDonor.email } - const donation = await tx.donation.update({ + const donation = await tx.payments.update({ where: { id }, data: { status: status, - personId: updatePaymentDto.targetPersonId ? donorId : undefined, + donations: { + updateMany: { + where: { paymentId: id }, + data: { + personId: updatePaymentDto.targetPersonId ? donorId : undefined, + }, + }, + }, billingEmail: updatePaymentDto.billingEmail ? billingEmail : undefined, //In case of personId or billingEmail change, take the last updatedAt property to prevent any changes to updatedAt property updatedAt: @@ -678,12 +764,12 @@ export class DonationsService { }) if ( - currentDonation.status !== DonationStatus.succeeded && - updatePaymentDto.status === DonationStatus.succeeded && - donation.status === DonationStatus.succeeded + currentDonation.status !== PaymentStatus.succeeded && + updatePaymentDto.status === PaymentStatus.succeeded && + donation.status === PaymentStatus.succeeded ) { await this.vaultService.incrementVaultAmount( - currentDonation.targetVaultId, + currentDonation.donations[0].targetVaultId, currentDonation.amount, tx, ) @@ -698,10 +784,10 @@ export class DonationsService { async softDelete(ids: string[]): Promise { try { - return await this.prisma.donation.updateMany({ + return await this.prisma.payments.updateMany({ where: { id: { in: ids } }, data: { - status: DonationStatus.deleted, + status: PaymentStatus.deleted, }, }) } catch (err) { @@ -717,14 +803,18 @@ export class DonationsService { await this.prisma.$transaction(async (tx) => { const donation = await this.getDonationById(id) - if (donation.status === DonationStatus.succeeded) { - await this.vaultService.decrementVaultAmount(donation.targetVaultId, donation.amount, tx) + if (donation.status === PaymentStatus.succeeded) { + await this.vaultService.decrementVaultAmount( + donation.donations[0].targetVaultId, + donation.amount, + tx, + ) } - await this.prisma.donation.update({ + await tx.payments.update({ where: { id }, data: { - status: DonationStatus.invalid, + status: PaymentStatus.invalid, }, }) }) @@ -740,18 +830,31 @@ export class DonationsService { async getDonationsByUser(keycloakId: string, email?: string) { const donations = await this.prisma.donation.findMany({ where: { - OR: [{ billingEmail: email }, { person: { keycloakId } }], + OR: [{ person: { keycloakId } }, { payment: { billingEmail: email } }], }, - orderBy: [{ createdAt: 'desc' }], include: { + payment: { + select: { + status: true, + provider: true, + }, + }, targetVault: { - include: { campaign: { select: { title: true, slug: true } } }, + select: { + campaign: { + select: { + title: true, + slug: true, + }, + }, + }, }, }, + orderBy: [{ createdAt: 'desc' }], }) const total = donations.reduce((acc, current) => { - if (current.status === DonationStatus.succeeded) { + if (current.payment.status === PaymentStatus.succeeded) { acc += current.amount } return acc @@ -773,11 +876,11 @@ export class DonationsService { } async getTotalDonatedMoney() { - const totalMoney = await this.prisma.donation.aggregate({ + const totalMoney = await this.prisma.payments.aggregate({ _sum: { amount: true, }, - where: { status: DonationStatus.succeeded }, + where: { status: PaymentStatus.succeeded }, }) return { total: totalMoney._sum.amount } @@ -786,7 +889,7 @@ export class DonationsService { async getDonorsCount() { const donorsCount = await this.prisma.$queryRaw<{ count: number - }>`SELECT COUNT (*)::INTEGER FROM (SELECT DISTINCT billing_name FROM donations WHERE status::text=${DonationStatus.succeeded}) AS unique_donors` + }>`SELECT COUNT (*)::INTEGER FROM (SELECT DISTINCT billing_name FROM payments WHERE status::text=${PaymentStatus.succeeded}) AS unique_donors` return { count: donorsCount[0].count } } @@ -797,6 +900,7 @@ export class DonationsService { async exportToExcel(query: DonationQueryDto, response: Response) { //get donations from db based on the filter parameters const { items } = await this.listDonations( + query?.paymentId, query?.campaignId, query?.status, query?.provider, diff --git a/apps/api/src/donations/dto/create-bank-payment.dto.ts b/apps/api/src/donations/dto/create-bank-payment.dto.ts index ece249f95..d5ddb447d 100644 --- a/apps/api/src/donations/dto/create-bank-payment.dto.ts +++ b/apps/api/src/donations/dto/create-bank-payment.dto.ts @@ -1,17 +1,17 @@ import { ApiProperty } from '@nestjs/swagger' -import { Currency, DonationStatus, DonationType, PaymentProvider } from '@prisma/client' +import { Currency, PaymentStatus, PaymentProvider, PaymentType, Prisma } from '@prisma/client' import { Expose } from 'class-transformer' import { IsDate, IsNumber, IsOptional, IsPositive, IsString, IsUUID } from 'class-validator' @Expose() export class CreateBankPaymentDto { @Expose() - @ApiProperty({ enum: DonationType }) - type: DonationType + @ApiProperty({ enum: PaymentType }) + type: PaymentType @Expose() - @ApiProperty({ enum: DonationStatus }) - status: DonationStatus + @ApiProperty({ enum: PaymentStatus }) + status: PaymentStatus @Expose() @ApiProperty({ enum: PaymentProvider }) @@ -49,15 +49,7 @@ export class CreateBankPaymentDto { @Expose() @ApiProperty() - @IsString() - @IsUUID() - targetVaultId: string - - @Expose() - @ApiProperty() - @IsString() - @IsOptional() - personId: string | null + donations: Prisma.DonationCreateNestedManyWithoutPaymentInput billingName?: string billingEmail?: string diff --git a/apps/api/src/donations/dto/create-payment.dto.ts b/apps/api/src/donations/dto/create-payment.dto.ts index f7af3d4bf..214097eea 100644 --- a/apps/api/src/donations/dto/create-payment.dto.ts +++ b/apps/api/src/donations/dto/create-payment.dto.ts @@ -1,17 +1,24 @@ import { ApiProperty } from '@nestjs/swagger' -import { Currency, DonationStatus, DonationType, PaymentProvider, Prisma } from '@prisma/client' +import { + Currency, + PaymentStatus, + DonationType, + PaymentProvider, + Prisma, + PaymentType, +} from '@prisma/client' import { Expose } from 'class-transformer' import { IsNumber, IsString, IsUUID } from 'class-validator' @Expose() export class CreatePaymentDto { @Expose() - @ApiProperty({ enum: DonationType }) - type: DonationType + @ApiProperty({ enum: PaymentType }) + type: PaymentType @Expose() - @ApiProperty({ enum: DonationStatus }) - status: DonationStatus + @ApiProperty({ enum: PaymentStatus }) + status: PaymentStatus @Expose() @ApiProperty({ enum: PaymentProvider }) @@ -47,7 +54,7 @@ export class CreatePaymentDto { @IsUUID() targetVaultId: string - public toEntity(user): Prisma.DonationCreateInput { + public toEntity(user): Prisma.PaymentsCreateInput { return { type: this.type, status: this.status, @@ -57,20 +64,25 @@ export class CreatePaymentDto { extCustomerId: this.extCustomerId, extPaymentIntentId: this.extPaymentIntentId, extPaymentMethodId: this.extPaymentMethodId, - targetVault: { - connect: { - id: this.targetVaultId, - }, - }, - person: { - connectOrCreate: { - where: { - email: user.email, + donations: { + create: { + type: DonationType.donation, + targetVault: { + connect: { + id: this.targetVaultId, + }, }, - create: { - firstName: user.given_name, - lastName: user.family_name, - email: user.email, + person: { + connectOrCreate: { + where: { + email: user.email, + }, + create: { + firstName: user.given_name, + lastName: user.family_name, + email: user.email, + }, + }, }, }, }, diff --git a/apps/api/src/donations/dto/list-donations.dto.ts b/apps/api/src/donations/dto/list-donations.dto.ts index 4337e9a9c..d567dc692 100644 --- a/apps/api/src/donations/dto/list-donations.dto.ts +++ b/apps/api/src/donations/dto/list-donations.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger' -import { Currency, DonationStatus, DonationType, PaymentProvider } from '@prisma/client' +import { Currency, PaymentStatus, PaymentProvider, PaymentType, Donation } from '@prisma/client' import { Expose } from 'class-transformer' export class DonationBaseDto { @@ -9,11 +9,11 @@ export class DonationBaseDto { @ApiProperty() @Expose() - type: DonationType + type: PaymentType @ApiProperty() @Expose() - status: DonationStatus + status: PaymentStatus @ApiProperty() @Expose() @@ -37,7 +37,7 @@ export class DonationBaseDto { @ApiProperty() @Expose() - person: { firstName: string; lastName: string } | null + donations: Donation[] } export class ListDonationsDto { diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts index 3d7d13160..d15f33795 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/donations/events/stripe-payment.service.ts @@ -15,7 +15,7 @@ import { getPaymentDataFromCharge, PaymentData, } from '../helpers/payment-intent-helpers' -import { DonationStatus, CampaignState } from '@prisma/client' +import { PaymentStatus, CampaignState } from '@prisma/client' import { EmailService } from '../../email/email.service' import { RefundDonationEmailDto } from '../../email/template.interface' import { PrismaService } from '../../prisma/prisma.service' @@ -63,7 +63,7 @@ export class StripePaymentService { /* * Handle the create event */ - await this.campaignService.updateDonationPayment(campaign, paymentData, DonationStatus.waiting) + await this.campaignService.updateDonationPayment(campaign, paymentData, PaymentStatus.waiting) } @StripeWebhookHandler('payment_intent.canceled') @@ -77,7 +77,7 @@ export class StripePaymentService { const billingData = getPaymentData(paymentIntent) - this.updatePaymentDonationStatus(paymentIntent, billingData, DonationStatus.cancelled) + this.updatePaymentPaymentStatus(paymentIntent, billingData, PaymentStatus.cancelled) } @StripeWebhookHandler('payment_intent.payment_failed') @@ -91,13 +91,13 @@ export class StripePaymentService { const billingData = getPaymentData(paymentIntent) - await this.updatePaymentDonationStatus(paymentIntent, billingData, DonationStatus.declined) + await this.updatePaymentPaymentStatus(paymentIntent, billingData, PaymentStatus.declined) } - async updatePaymentDonationStatus( + async updatePaymentPaymentStatus( paymentIntent: Stripe.PaymentIntent, billingData: PaymentData, - donationStatus: DonationStatus, + PaymentStatus: PaymentStatus, ) { const metadata: DonationMetadata = paymentIntent.metadata as DonationMetadata if (!metadata.campaignId) { @@ -109,7 +109,7 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - await this.campaignService.updateDonationPayment(campaign, billingData, donationStatus) + await this.campaignService.updateDonationPayment(campaign, billingData, PaymentStatus) } @StripeWebhookHandler('charge.succeeded') @@ -139,7 +139,7 @@ export class StripePaymentService { const donationId = await this.campaignService.updateDonationPayment( campaign, billingData, - DonationStatus.succeeded, + PaymentStatus.succeeded, ) //updateDonationPayment will mark the campaign as completed if amount is reached @@ -171,7 +171,7 @@ export class StripePaymentService { const campaign = await this.campaignService.getCampaignById(metadata.campaignId) - await this.campaignService.updateDonationPayment(campaign, billingData, DonationStatus.refund) + await this.campaignService.updateDonationPayment(campaign, billingData, PaymentStatus.refund) if (billingData.billingEmail !== undefined) { const recepient = { to: [billingData.billingEmail] } @@ -364,11 +364,7 @@ export class StripePaymentService { const paymentData = getInvoiceData(invoice) - await this.campaignService.updateDonationPayment( - campaign, - paymentData, - DonationStatus.succeeded, - ) + await this.campaignService.updateDonationPayment(campaign, paymentData, PaymentStatus.succeeded) //updateDonationPayment will mark the campaign as completed if amount is reached await this.cancelSubscriptionsIfCompletedCampaign(metadata.campaignId) diff --git a/apps/api/src/donations/helpers/donation-status-updates.ts b/apps/api/src/donations/helpers/donation-status-updates.ts index 532c14974..6a799d325 100644 --- a/apps/api/src/donations/helpers/donation-status-updates.ts +++ b/apps/api/src/donations/helpers/donation-status-updates.ts @@ -1,35 +1,35 @@ -import { DonationStatus } from '@prisma/client' - -const initial: DonationStatus[] = [DonationStatus.initial] -const changeable: DonationStatus[] = [ - DonationStatus.incomplete, - DonationStatus.paymentRequested, - DonationStatus.waiting, - DonationStatus.declined, - DonationStatus.guaranteed, +import { PaymentStatus } from '@prisma/client' + +const initial: PaymentStatus[] = [PaymentStatus.initial] +const changeable: PaymentStatus[] = [ + PaymentStatus.incomplete, + PaymentStatus.paymentRequested, + PaymentStatus.waiting, + PaymentStatus.declined, + PaymentStatus.guaranteed, ] -const final: DonationStatus[] = [ - DonationStatus.succeeded, - DonationStatus.cancelled, - DonationStatus.deleted, - DonationStatus.invalid, - DonationStatus.refund, +const final: PaymentStatus[] = [ + PaymentStatus.succeeded, + PaymentStatus.cancelled, + PaymentStatus.deleted, + PaymentStatus.invalid, + PaymentStatus.refund, ] -function isInitial(status: DonationStatus) { +function isInitial(status: PaymentStatus) { return initial.includes(status) } -function isChangeable(status: DonationStatus) { +function isChangeable(status: PaymentStatus) { return changeable.includes(status) } -function isFinal(status: DonationStatus) { +function isFinal(status: PaymentStatus) { return final.includes(status) } -function isRefundable(oldStatus: DonationStatus, newStatus: DonationStatus) { - return oldStatus === DonationStatus.succeeded && newStatus === DonationStatus.refund +function isRefundable(oldStatus: PaymentStatus, newStatus: PaymentStatus) { + return oldStatus === PaymentStatus.succeeded && newStatus === PaymentStatus.refund } /** @@ -38,8 +38,8 @@ function isRefundable(oldStatus: DonationStatus, newStatus: DonationStatus) { * @returns allowed previous status that can be changed by the event */ export function shouldAllowStatusChange( - oldStatus: DonationStatus, - newStatus: DonationStatus, + oldStatus: PaymentStatus, + newStatus: PaymentStatus, ): boolean { if (oldStatus === newStatus || isRefundable(oldStatus, newStatus)) { return true diff --git a/apps/api/src/donations/queries/donation.validator.ts b/apps/api/src/donations/queries/donation.validator.ts index ac3f6b0be..83b62b0e5 100644 --- a/apps/api/src/donations/queries/donation.validator.ts +++ b/apps/api/src/donations/queries/donation.validator.ts @@ -24,3 +24,12 @@ export const donationWithPerson = Prisma.validator( }) export type DonationWithPerson = Prisma.DonationGetPayload +export type PaymentWithDonationCount = Prisma.PaymentsGetPayload<{ + include: { + _count: { + select: { + donations: true + } + } + } +}> diff --git a/apps/api/src/paypal/paypal.service.ts b/apps/api/src/paypal/paypal.service.ts index 3eb13182a..dc5c303de 100644 --- a/apps/api/src/paypal/paypal.service.ts +++ b/apps/api/src/paypal/paypal.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { CampaignService } from '../campaign/campaign.service' import { HttpService } from '@nestjs/axios' -import { DonationStatus, DonationType, PaymentProvider } from '@prisma/client' +import { PaymentStatus, DonationType, PaymentProvider, PaymentType } from '@prisma/client' @Injectable() export class PaypalService { @@ -29,7 +29,7 @@ export class PaypalService { await this.campaignService.updateDonationPayment( campaign, billingDetails, - DonationStatus.waiting, + PaymentStatus.waiting, ) Logger.debug('Donation created!') @@ -52,7 +52,7 @@ export class PaypalService { await this.campaignService.updateDonationPayment( campaign, billingDetails, - DonationStatus.succeeded, + PaymentStatus.succeeded, ) Logger.debug('Donation completed!') @@ -166,7 +166,7 @@ export class PaypalService { //note we store the money in db as cents so we multiply incoming amounts by 100 return { //TODO: Find a way to attach type to metadata - type: DonationType.donation, + type: PaymentType.single, paymentProvider: PaymentProvider.paypal, campaignId: paypalOrder.resource.purchase_units[0].custom_id, paymentIntentId: paypalOrder.resource.purchase_units[0].payments.captures[0].id, @@ -197,7 +197,7 @@ export class PaypalService { paymentProvider: PaymentProvider.paypal, campaignId: paypalCapture.resource.custom_id, //TODO: Find a way to attach type to metadata - type: DonationType.donation, + type: PaymentType.single, paymentIntentId: paypalCapture.resource.id, netAmount: 100 * Number(paypalCapture.resource.seller_receivable_breakdown.net_amount.value), chargedAmount: @@ -218,5 +218,5 @@ type PaymentData = { billingEmail?: string paymentMethodId?: string stripeCustomerId?: string - type: DonationType + type: PaymentType } diff --git a/apps/api/src/sockets/notifications/notification.service.ts b/apps/api/src/sockets/notifications/notification.service.ts index 1eaea26ef..2c6628a61 100644 --- a/apps/api/src/sockets/notifications/notification.service.ts +++ b/apps/api/src/sockets/notifications/notification.service.ts @@ -1,22 +1,28 @@ import { Injectable } from '@nestjs/common' import { NotificationGateway } from './gateway' +import { Prisma } from '@prisma/client' -export const donationNotificationSelect = { +export const donationNotificationSelect = Prisma.validator()({ id: true, status: true, currency: true, amount: true, - createdAt: true, extPaymentMethodId: true, - targetVaultId: true, - person: { + createdAt: true, + + donations: { select: { - firstName: true, - lastName: true, - picture: true, + targetVaultId: true, + person: { + select: { + firstName: true, + lastName: true, + picture: true, + }, + }, }, }, -} +}) @Injectable() export class NotificationService { diff --git a/apps/api/src/statistics/statistics.service.ts b/apps/api/src/statistics/statistics.service.ts index 3bc6149a6..000f70144 100644 --- a/apps/api/src/statistics/statistics.service.ts +++ b/apps/api/src/statistics/statistics.service.ts @@ -19,21 +19,21 @@ export class StatisticsService { ): Promise { const date = groupBy === GroupBy.MONTH - ? Prisma.sql`DATE_TRUNC('MONTH', created_at) date` + ? Prisma.sql`DATE_TRUNC('MONTH', d.created_at) date` : groupBy === GroupBy.WEEK - ? Prisma.sql`DATE_TRUNC('WEEK', created_at) date` - : Prisma.sql`DATE_TRUNC('DAY', created_at) date` + ? Prisma.sql`DATE_TRUNC('WEEK', d.created_at) date` + : Prisma.sql`DATE_TRUNC('DAY', d.created_at) date` const group = groupBy === GroupBy.MONTH - ? Prisma.sql`GROUP BY DATE_TRUNC('MONTH', created_at)` + ? Prisma.sql`GROUP BY DATE_TRUNC('MONTH', d.created_at)` : groupBy === GroupBy.WEEK - ? Prisma.sql`GROUP BY DATE_TRUNC('WEEK', created_at)` - : Prisma.sql`GROUP BY DATE_TRUNC('DAY', created_at)` + ? Prisma.sql`GROUP BY DATE_TRUNC('WEEK', d.created_at)` + : Prisma.sql`GROUP BY DATE_TRUNC('DAY', d.created_at)` return this.prisma.$queryRaw` - SELECT SUM(amount)::INTEGER, COUNT(id)::INTEGER, ${date} - FROM api.donations WHERE status = 'succeeded' + SELECT SUM(d.amount)::INTEGER, COUNT(d.id)::INTEGER, ${date} + FROM api.donations d, payments p WHERE p.status::text = 'succeeded' ${Prisma.sql`AND target_vault_id IN ( SELECT id from api.vaults WHERE campaign_id = ${campaignId}::uuid)`} ${group} ORDER BY date ASC ` @@ -41,17 +41,17 @@ export class StatisticsService { async listUniqueDonations(campaignId: string): Promise { return this.prisma.$queryRaw` - SELECT amount::INTEGER, COUNT(id)::INTEGER AS count - FROM api.donations WHERE status = 'succeeded' + SELECT d.amount::INTEGER, COUNT(d.id)::INTEGER AS count + FROM api.donations d, payments p WHERE p.status::text = 'succeeded' ${Prisma.sql`AND target_vault_id IN ( SELECT id from api.vaults WHERE campaign_id = ${campaignId}::uuid)`} - GROUP BY amount + GROUP BY d.amount ORDER BY amount ASC` } async listHourlyDonations(campaignId: string): Promise { return this.prisma.$queryRaw` - SELECT EXTRACT(HOUR from created_at)::INTEGER AS hour, COUNT(id)::INTEGER AS count - FROM api.donations where status = 'succeeded' + SELECT EXTRACT(HOUR from d.created_at)::INTEGER AS hour, COUNT(d.id)::INTEGER AS count + FROM api.donations d, payments p WHERE p.status::text = 'succeeded' ${Prisma.sql`AND target_vault_id IN ( SELECT id from api.vaults WHERE campaign_id = ${campaignId}::uuid)`} GROUP BY hour ORDER BY hour ASC` diff --git a/apps/api/src/tasks/bank-import/import-transactions.task.ts b/apps/api/src/tasks/bank-import/import-transactions.task.ts index 27a6bf805..722dcba32 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.ts @@ -5,9 +5,12 @@ import { SchedulerRegistry } from '@nestjs/schedule' import { BankDonationStatus, Currency, - DonationStatus, + Donation, DonationType, PaymentProvider, + PaymentStatus, + PaymentType, + Payments, Prisma, Vault, } from '@prisma/client' @@ -37,8 +40,11 @@ import { IrisTransactionInfoDto } from '../../bank-transactions/dto/iris-bank-tr type filteredTransaction = Prisma.BankTransactionCreateManyInput type AffiliatePayload = Prisma.AffiliateGetPayload<{ - include: { donations: true } + include: { payments: { include: { donations: true } } } }> +type VaultUpdate = { + [key: string]: number +} @Injectable() export class IrisTasks { @@ -393,14 +399,17 @@ export class IrisTasks { }, }, include: { - donations: { + payments: { where: { - status: DonationStatus.guaranteed, + status: PaymentStatus.guaranteed, }, + orderBy: { createdAt: 'asc', }, - include: { targetVault: true }, + include: { + donations: true, + }, }, }, }) @@ -434,6 +443,7 @@ export class IrisTasks { if (!campaign) { //Campaign not found by paymentReference. Check if it is affiliate donation const affiliate = affiliates.find((affiliate) => affiliate.affiliateCode === trx.matchedRef) + console.log(affiliate) if (!affiliate) { trx.bankDonationStatus = BankDonationStatus.unrecognized continue @@ -458,25 +468,38 @@ export class IrisTasks { private async processAffiliateDonations(affiliate: AffiliatePayload, trx: filteredTransaction) { let totalDonated = 0 - let updatedDonations = 0 + let updatedPayments = 0 if (!trx.amount) { trx.bankDonationStatus = BankDonationStatus.importFailed return ImportStatus.UNPROCESSED } + + const paymentIdsToUpdate: string[] = [] + const vaultsToUpdate: VaultUpdate = {} + //If no guaranteed donations are found for the affiliate //mark the transaction as imported - if (affiliate.donations.length === 0) { + if (affiliate.payments.length === 0) { trx.bankDonationStatus = BankDonationStatus.imported return ImportStatus.SUCCESS } - for (const donation of affiliate.donations) { - if (trx.amount - totalDonated < donation.amount) continue - await this.donationsService.updateAffiliateBankPayment(donation) - totalDonated += donation.amount - updatedDonations++ + for (const payment of affiliate.payments) { + if (trx.amount - totalDonated < payment.amount) continue + paymentIdsToUpdate.push(payment.id) + for (const donation of payment.donations) { + if (vaultsToUpdate.hasOwnProperty(donation.targetVaultId)) { + vaultsToUpdate[donation.targetVaultId] += donation.amount + } else { + vaultsToUpdate[donation.targetVaultId] = donation.amount + } + } + totalDonated += payment.amount + updatedPayments++ } - if (trx.amount - totalDonated > 0 || updatedDonations < affiliate.donations.length) { + await this.donationsService.updateAffiliateBankPayment(paymentIdsToUpdate, vaultsToUpdate) + + if (trx.amount - totalDonated > 0 || updatedPayments < affiliate.payments.length) { trx.bankDonationStatus = BankDonationStatus.incomplete return ImportStatus.INCOMPLETE } @@ -497,11 +520,16 @@ export class IrisTasks { createdAt: new Date(bankTransaction.transactionDate), billingName: bankTransaction.senderName || '', extPaymentMethodId: this.paymentMethodId, - targetVaultId: vault.id, - type: DonationType.donation, - status: DonationStatus.succeeded, + type: PaymentType.single, + status: PaymentStatus.succeeded, provider: PaymentProvider.bank, - personId: null, + donations: { + create: { + personId: null, + targetVaultId: vault.id, + type: DonationType.donation, + }, + }, } return bankPayment diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts index a9702baa2..5389e17e4 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -19,6 +19,10 @@ type VaultWithWithdrawalSum = Prisma.VaultGetPayload<{ withdrawnAmount: number } +type VaultUpdate = { + [id: string]: number +} + @Injectable() export class VaultService { constructor( @@ -170,8 +174,9 @@ export class VaultService { vaultId: string, amount: number, tx: Prisma.TransactionClient, - operationType: string, + operationType: 'increment' | 'decrement', ) { + console.log(vaultId, amount, operationType) if (amount <= 0) { throw new Error('Amount cannot be negative or zero.') } @@ -188,4 +193,23 @@ export class VaultService { const vault = await tx.vault.update(updateStatement) return vault } + + public async updateManyVaultsAmount( + vaults: VaultUpdate, + tx: Prisma.TransactionClient, + operationType: 'increment' | 'decrement', + ) { + const sqlValues = Object.entries(vaults) + .map(([vaultId, amount]) => `('${vaultId}'::uuid, ${amount})`) + .join(',') + + return await tx.$queryRawUnsafe(` + UPDATE vaults + SET amount = amount + new_amount + FROM ( + VALUES ${sqlValues} + ) AS updated_vault(id, new_amount) + WHERE vaults.id::text = updated_vault.id::text; + `) + } } From e61b2ecfd7bfc46ca1f74e00708c520cf0d4988b Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Mon, 26 Feb 2024 13:04:06 +0200 Subject: [PATCH 03/10] Adjust existing tests to cover the restructure --- .../bank-transactions.controller.spec.ts | 30 +++- .../bank-transactions.service.ts | 3 +- .../donations/donations.controller.spec.ts | 99 +++++++---- apps/api/src/donations/donations.service.ts | 3 +- .../events/stripe-payment.service.spec.ts | 166 ++++++++---------- .../import-transactions.task.spec.ts | 52 ++++-- .../bank-import/import-transactions.task.ts | 1 - 7 files changed, 201 insertions(+), 153 deletions(-) diff --git a/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts b/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts index 1fe099d13..eceb138a5 100644 --- a/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts +++ b/apps/api/src/bank-transactions/bank-transactions.controller.spec.ts @@ -6,10 +6,12 @@ import { BankTransactionsController } from './bank-transactions.controller' import { BankTransactionsService } from './bank-transactions.service' import { BankDonationStatus, + BankTransaction, BankTransactionType, Campaign, CampaignState, Currency, + Prisma, Vault, } from '@prisma/client' import { DonationsService } from '../donations/donations.service' @@ -27,7 +29,9 @@ import { TemplateService } from '../email/template.service' import { MarketingNotificationsModule } from '../notifications/notifications.module' import { AffiliateService } from '../affiliate/affiliate.service' -const bankTransactionsMock = [ +type CampaignWithVault = Prisma.CampaignGetPayload<{ include: { vaults: true } }> + +const bankTransactionsMock: BankTransaction[] = [ { id: '1679851630581', ibanNumber: 'BG27STSA93001111111111', @@ -43,6 +47,8 @@ const bankTransactionsMock = [ description: 'Campaign_Payment_Ref', type: BankTransactionType.credit, bankDonationStatus: BankDonationStatus.imported, + matchedRef: '123', + notified: true, }, { id: '1679851630581', @@ -59,6 +65,8 @@ const bankTransactionsMock = [ description: 'Campaign_Payment_Ref', type: BankTransactionType.credit, bankDonationStatus: BankDonationStatus.reImported, + matchedRef: '123', + notified: true, }, { id: '1679851630582', @@ -75,6 +83,8 @@ const bankTransactionsMock = [ description: 'WRONG_Campaign_Payment_Ref', type: BankTransactionType.credit, bankDonationStatus: BankDonationStatus.unrecognized, + matchedRef: '123', + notified: true, }, { id: '1679851630583', @@ -91,16 +101,32 @@ const bankTransactionsMock = [ description: 'WRONG_Campaign_Payment_Ref', type: BankTransactionType.credit, bankDonationStatus: BankDonationStatus.importFailed, + matchedRef: '123', + notified: true, }, ] -const mockCampaign: Campaign & { vaults: Vault[] } = { +const mockCampaign: CampaignWithVault = { id: 'testId', + slug: 'test', + beneficiaryId: '123', + campaignTypeId: '123', + title: 'campaign-mock', + essence: 'short-description', state: CampaignState.approved, createdAt: new Date('2022-04-08T06:36:33.661Z'), updatedAt: new Date('2022-04-08T06:36:33.662Z'), deletedAt: null, approvedById: null, + startDate: new Date('2022-04-08T06:36:33.661Z'), + endDate: new Date('2022-04-08T06:36:33.661Z'), + targetAmount: 5000, + allowDonationOnComplete: false, + description: 'h', + coordinatorId: '1234', + companyId: '123', + currency: 'BGN', + organizerId: '123', paymentReference: 'payment-ref', vaults: [], } diff --git a/apps/api/src/bank-transactions/bank-transactions.service.ts b/apps/api/src/bank-transactions/bank-transactions.service.ts index b4fd09751..3b993b45d 100644 --- a/apps/api/src/bank-transactions/bank-transactions.service.ts +++ b/apps/api/src/bank-transactions/bank-transactions.service.ts @@ -149,7 +149,7 @@ export class BankTransactionsService { create: { amount: bankTransaction?.amount || 0, personId: null, - targetVaultId: vault.id, + targetVaultId: vault?.id, type: DonationType.donation, }, }, @@ -165,7 +165,6 @@ export class BankTransactionsService { matchedRef: newPaymentRef, }, }) - console.log(`called`) // Import Donation await this.donationService.createUpdateBankPayment(bankPayment) diff --git a/apps/api/src/donations/donations.controller.spec.ts b/apps/api/src/donations/donations.controller.spec.ts index 909625621..2dcd42e8f 100644 --- a/apps/api/src/donations/donations.controller.spec.ts +++ b/apps/api/src/donations/donations.controller.spec.ts @@ -6,11 +6,13 @@ import { Campaign, CampaignState, Currency, - DonationStatus, + PaymentStatus, DonationType, PaymentProvider, Person, Vault, + Payments, + Prisma, } from '@prisma/client' import { CampaignService } from '../campaign/campaign.service' import { ExportService } from '../export/export.service' @@ -25,6 +27,8 @@ import { UpdatePaymentDto } from './dto/update-payment.dto' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { MarketingNotificationsModule } from '../notifications/notifications.module' +type PaymentWithDonation = Prisma.PaymentsGetPayload<{ include: { donations: true } }> + describe('DonationsController', () => { let controller: DonationsController let vaultService: VaultService @@ -50,11 +54,26 @@ describe('DonationsController', () => { } as CreateSessionDto const mockDonation = { + id: '1234', + paymentId: '123', + type: DonationType.donation, + amount: 10, + targetVaultId: '1000', + createdAt: new Date('2022-01-01'), + updatedAt: new Date('2022-01-02'), + personId: '1', + person: { + id: '1', + keycloakId: '00000000-0000-0000-0000-000000000015', + }, + } + + const mockPayment: PaymentWithDonation = { id: '123', provider: PaymentProvider.bank, currency: Currency.BGN, - type: DonationType.donation, - status: DonationStatus.succeeded, + type: 'single', + status: PaymentStatus.succeeded, amount: 10, affiliateId: null, extCustomerId: 'gosho', @@ -62,15 +81,10 @@ describe('DonationsController', () => { extPaymentMethodId: 'bank', billingEmail: 'gosho1@abv.bg', billingName: 'gosho1', - targetVaultId: '1000', chargedAmount: 10.5, createdAt: new Date('2022-01-01'), updatedAt: new Date('2022-01-02'), - personId: '1', - person: { - id: '1', - keycloakId: '00000000-0000-0000-0000-000000000015', - }, + donations: [mockDonation], } beforeEach(async () => { @@ -177,12 +191,11 @@ describe('DonationsController', () => { it('should update a donations donor, when it is changed', async () => { const updatePaymentDto = { - type: DonationType.donation, amount: 10, targetPersonId: '2', } - const existingDonation = { ...mockDonation } + const existingPayment = { ...mockPayment } const existingTargetPerson: Person = { id: '2', firstName: 'string', @@ -208,19 +221,26 @@ describe('DonationsController', () => { .spyOn(vaultService, 'incrementVaultAmount') .mockImplementation() - prismaMock.donation.findFirst.mockResolvedValueOnce(existingDonation) + prismaMock.payments.findFirst.mockResolvedValueOnce(existingPayment) prismaMock.person.findFirst.mockResolvedValueOnce(existingTargetPerson) // act await controller.update('123', updatePaymentDto) // assert - expect(prismaMock.donation.update).toHaveBeenCalledWith({ + expect(prismaMock.payments.update).toHaveBeenCalledWith({ where: { id: '123' }, data: { - status: existingDonation.status, - personId: '2', - updatedAt: existingDonation.updatedAt, + status: existingPayment.status, + updatedAt: existingPayment.updatedAt, + donations: { + updateMany: { + where: { paymentId: existingPayment.id }, + data: { + personId: '2', + }, + }, + }, }, }) expect(mockedIncrementVaultAmount).toHaveBeenCalledTimes(0) @@ -228,18 +248,18 @@ describe('DonationsController', () => { it('should update a donation status, when it is changed', async () => { const updatePaymentDto: UpdatePaymentDto = { - type: DonationType.donation, + type: 'single', amount: 10, - status: DonationStatus.succeeded, + status: PaymentStatus.succeeded, targetPersonId: mockDonation.personId, - billingEmail: mockDonation.billingEmail, + billingEmail: mockPayment.billingEmail as string, } const existingTargetPerson: Person = { id: mockDonation.personId, firstName: 'string', lastName: 'string', - email: mockDonation.billingEmail, + email: mockPayment.billingEmail, phone: 'string', companyId: 'string', createdAt: new Date('2022-01-01'), @@ -255,34 +275,41 @@ describe('DonationsController', () => { profileEnabled: true, } - const existingDonation = { ...mockDonation, status: DonationStatus.initial } - const expectedUpdatedDonation = { ...existingDonation, status: DonationStatus.succeeded } + const existingPayment = { ...mockPayment, status: PaymentStatus.initial } + const expectedUpdatedPayment = { ...existingPayment, status: PaymentStatus.succeeded } jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) - prismaMock.donation.findFirst.mockResolvedValueOnce(existingDonation) + prismaMock.payments.findFirst.mockResolvedValueOnce(existingPayment) prismaMock.person.findFirst.mockResolvedValueOnce(existingTargetPerson) - prismaMock.donation.update.mockResolvedValueOnce(expectedUpdatedDonation) + prismaMock.payments.update.mockResolvedValueOnce(expectedUpdatedPayment) prismaMock.vault.update.mockResolvedValueOnce({ id: '1000', campaignId: '111' } as Vault) // act await controller.update('123', updatePaymentDto) // assert - expect(prismaMock.donation.update).toHaveBeenCalledWith({ + expect(prismaMock.payments.update).toHaveBeenCalledWith({ where: { id: '123' }, data: { - status: DonationStatus.succeeded, - personId: updatePaymentDto.targetPersonId, + status: PaymentStatus.succeeded, billingEmail: updatePaymentDto.billingEmail, - updatedAt: expectedUpdatedDonation.updatedAt, + updatedAt: expectedUpdatedPayment.updatedAt, + donations: { + updateMany: { + where: { paymentId: existingPayment.id }, + data: { + personId: existingPayment.donations[0].personId, + }, + }, + }, }, }) expect(prismaMock.vault.update).toHaveBeenCalledWith({ - where: { id: existingDonation.targetVaultId }, + where: { id: existingPayment.donations[0].targetVaultId }, data: { amount: { - increment: existingDonation.amount, + increment: existingPayment.donations[0].amount, }, }, }) @@ -299,24 +326,24 @@ describe('DonationsController', () => { }) it('should invalidate a donation and update the vault if needed', async () => { - const existingDonation = { ...mockDonation, status: DonationStatus.succeeded } + const existingPayment = { ...mockPayment, status: PaymentStatus.succeeded } jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) - prismaMock.donation.findFirstOrThrow.mockResolvedValueOnce(existingDonation) + prismaMock.payments.findFirstOrThrow.mockResolvedValueOnce(existingPayment) await controller.invalidate('123') - expect(prismaMock.donation.update).toHaveBeenCalledWith({ + expect(prismaMock.payments.update).toHaveBeenCalledWith({ where: { id: '123' }, data: { - status: DonationStatus.invalid, + status: PaymentStatus.invalid, }, }) expect(prismaMock.vault.update).toHaveBeenCalledWith({ - where: { id: existingDonation.targetVaultId }, + where: { id: existingPayment.donations[0].targetVaultId }, data: { amount: { - decrement: existingDonation.amount, + decrement: existingPayment.donations[0].amount, }, }, }) diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 899158607..2483c810a 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -696,6 +696,7 @@ export class DonationsService { async update(id: string, updatePaymentDto: UpdatePaymentDto): Promise { try { // execute the below in prisma transaction + console.log(updatePaymentDto.targetPersonId) return await this.prisma.$transaction(async (tx) => { const currentDonation = await tx.payments.findFirst({ where: { id }, @@ -722,7 +723,7 @@ export class DonationsService { let billingEmail: string | null = '' if ( (updatePaymentDto.targetPersonId && - currentDonation.donations[0].personId !== updatePaymentDto[0].targetPersonId) || + currentDonation.donations[0].personId !== updatePaymentDto.targetPersonId) || updatePaymentDto.billingEmail ) { const targetDonor = await tx.person.findFirst({ diff --git a/apps/api/src/donations/events/stripe-payment.service.spec.ts b/apps/api/src/donations/events/stripe-payment.service.spec.ts index 21437da3d..515ae9f03 100644 --- a/apps/api/src/donations/events/stripe-payment.service.spec.ts +++ b/apps/api/src/donations/events/stripe-payment.service.spec.ts @@ -16,6 +16,9 @@ import { CampaignState, Donation, DonationType, + PaymentType, + Payments, + Prisma, RecurringDonationStatus, Vault, } from '@prisma/client' @@ -39,7 +42,7 @@ import { mockPaymentEventFailed, mockChargeRefundEventSucceeded, } from './stripe-payment.testdata' -import { DonationStatus } from '@prisma/client' +import { PaymentStatus } from '@prisma/client' import { RecurringDonationService } from '../../recurring-donation/recurring-donation.service' import { HttpService } from '@nestjs/axios' import { mockDeep } from 'jest-mock-extended' @@ -75,6 +78,36 @@ describe('StripePaymentService', () => { }), } + const mockPayment: Prisma.PaymentsGetPayload<{ include: { donations: true } }> = { + id: 'test-donation-id', + type: PaymentType.single, + status: PaymentStatus.waiting, + provider: 'stripe', + affiliateId: null, + extCustomerId: 'test123', + extPaymentIntentId: 'test1234', + extPaymentMethodId: 'card', + amount: 0, //amount is 0 on donation created from payment-intent + chargedAmount: 0, + currency: 'BGN', + createdAt: new Date(), + updatedAt: new Date(), + billingName: 'Test test', + billingEmail: 'test@podkrepi.bg', + donations: [ + { + personId: '123', + targetVaultId: '123', + id: '123', + paymentId: 'test-donation-id', + type: DonationType.donation, + amount: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + } + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ @@ -165,7 +198,7 @@ describe('StripePaymentService', () => { expect(mockedUpdateDonationPayment).toHaveBeenCalledWith( mockedCampaign, paymentData, - DonationStatus.waiting, + PaymentStatus.waiting, ) }) }) @@ -203,7 +236,7 @@ describe('StripePaymentService', () => { expect(mockedUpdateDonationPayment).toHaveBeenCalledWith( mockedCampaign, paymentData, - DonationStatus.cancelled, + PaymentStatus.cancelled, ) }) }) @@ -239,7 +272,7 @@ describe('StripePaymentService', () => { expect(mockedUpdateDonationPayment).toHaveBeenCalledWith( mockedCampaign, paymentData, - DonationStatus.declined, + PaymentStatus.declined, ) }) }) @@ -271,33 +304,13 @@ describe('StripePaymentService', () => { .mockName('createDonationWish') .mockImplementation(() => Promise.resolve()) - prismaMock.donation.findUnique.mockResolvedValue({ - id: 'test-donation-id', - type: DonationType.donation, - status: DonationStatus.waiting, - provider: 'stripe', - affiliateId: null, - extCustomerId: paymentData.stripeCustomerId ?? '', - extPaymentIntentId: paymentData.paymentIntentId, - extPaymentMethodId: 'card', - targetVaultId: 'test-vault-id', - amount: 0, //amount is 0 on donation created from payment-intent - chargedAmount: 0, - currency: 'BGN', - createdAt: new Date(), - updatedAt: new Date(), - billingName: paymentData.billingName ?? '', - billingEmail: paymentData.billingEmail ?? '', - personId: 'donation-person', - }) + prismaMock.payments.findUnique.mockResolvedValue(mockPayment) - prismaMock.donation.update.mockResolvedValue({ - id: 'test-donation-id', - targetVaultId: 'test-vault-id', + prismaMock.payments.update.mockResolvedValue({ + ...mockPayment, amount: paymentData.netAmount, - status: 'succeeded', - person: { firstName: 'Full', lastName: 'Name' }, - } as Donation & { person: unknown }) + status: PaymentStatus.succeeded, + }) prismaMock.vault.update.mockResolvedValue({ campaignId: 'test-campaign' } as Vault) @@ -329,10 +342,10 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedUpdateDonationPayment).toHaveBeenCalled() - expect(prismaMock.donation.findUnique).toHaveBeenCalled() - expect(prismaMock.donation.create).not.toHaveBeenCalled() + expect(prismaMock.payments.findUnique).toHaveBeenCalled() + expect(prismaMock.payments.create).not.toHaveBeenCalled() expect(mockedIncrementVaultAmount).toHaveBeenCalled() - expect(prismaMock.donation.update).toHaveBeenCalledTimes(1) + expect(prismaMock.payments.update).toHaveBeenCalledTimes(1) expect(mockedUpdateCampaignStatusIfTargetReached).toHaveBeenCalled() expect(prismaMock.campaign.update).toHaveBeenCalledWith({ where: { @@ -373,33 +386,13 @@ describe('StripePaymentService', () => { .mockName('createDonationWish') .mockImplementation(() => Promise.resolve()) - prismaMock.donation.findUnique.mockResolvedValue({ - id: 'test-donation-id', - type: DonationType.donation, - status: DonationStatus.waiting, - provider: 'stripe', - affiliateId: '', - extCustomerId: paymentData.stripeCustomerId ?? '', - extPaymentIntentId: paymentData.paymentIntentId, - extPaymentMethodId: 'card', - targetVaultId: 'test-vault-id', - amount: 0, //amount is 0 on donation created from payment-intent - chargedAmount: 0, - currency: 'BGN', - createdAt: new Date(), - updatedAt: new Date(), - billingName: paymentData.billingName ?? '', - billingEmail: paymentData.billingEmail ?? '', - personId: 'donation-person', - }) + prismaMock.payments.findUnique.mockResolvedValue(mockPayment) - prismaMock.donation.update.mockResolvedValue({ - id: 'test-donation-id', - targetVaultId: 'test-vault-id', + prismaMock.payments.update.mockResolvedValue({ + ...mockPayment, amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, status: 'succeeded', - person: { firstName: 'Full', lastName: 'Name' }, - } as Donation & { person: unknown }) + }) prismaMock.vault.update.mockResolvedValue({ campaignId: 'test-campaign' } as Vault) @@ -419,9 +412,9 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedUpdateDonationPayment).toHaveBeenCalled() - expect(prismaMock.donation.findUnique).toHaveBeenCalled() - expect(prismaMock.donation.create).not.toHaveBeenCalled() - expect(prismaMock.donation.update).toHaveBeenCalledOnce() //for the donation to succeeded + expect(prismaMock.payments.findUnique).toHaveBeenCalled() + expect(prismaMock.payments.create).not.toHaveBeenCalled() + expect(prismaMock.payments.update).toHaveBeenCalledOnce() //for the donation to succeeded expect(mockedIncrementVaultAmount).toHaveBeenCalled() expect(mockedcreateDonationWish).toHaveBeenCalled() }) @@ -446,33 +439,13 @@ describe('StripePaymentService', () => { mockChargeEventSucceeded.data.object as Stripe.Charge, ) - prismaMock.donation.findUnique.mockResolvedValue({ - id: 'test-donation-id', - type: DonationType.donation, - status: DonationStatus.succeeded, - provider: 'stripe', - affiliateId: null, - extCustomerId: paymentData.stripeCustomerId ?? '', - extPaymentIntentId: paymentData.paymentIntentId, - extPaymentMethodId: 'card', - targetVaultId: 'test-vault-id', - amount: 1000, //amount is 0 on donation created from payment-intent - chargedAmount: 800, - currency: 'BGN', - createdAt: new Date(), - updatedAt: new Date(), - billingName: paymentData.billingName ?? '', - billingEmail: paymentData.billingEmail ?? '', - personId: 'donation-person', - }) + const succeededPayment: Payments = { ...mockPayment, status: PaymentStatus.succeeded } + prismaMock.payments.findUnique.mockResolvedValue(succeededPayment) - prismaMock.donation.update.mockResolvedValue({ - id: 'test-donation-id', - targetVaultId: 'test-vault-id', - amount: paymentData.netAmount, - status: DonationStatus.refund, - person: { firstName: 'Full', lastName: 'Name' }, - } as Donation & { person: unknown }) + prismaMock.payments.update.mockResolvedValue({ + ...mockPayment, + status: PaymentStatus.refund, + }) prismaMock.vault.update.mockResolvedValue({ campaignId: 'test-campaign' } as Vault) @@ -499,10 +472,10 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedUpdateDonationPayment).toHaveBeenCalled() - expect(prismaMock.donation.findUnique).toHaveBeenCalled() - expect(prismaMock.donation.create).not.toHaveBeenCalled() + expect(prismaMock.payments.findUnique).toHaveBeenCalled() + expect(prismaMock.payments.create).not.toHaveBeenCalled() expect(mockDecremementVaultAmount).toHaveBeenCalled() - expect(prismaMock.donation.update).toHaveBeenCalled() + expect(prismaMock.payments.update).toHaveBeenCalled() }) }) @@ -601,18 +574,17 @@ describe('StripePaymentService', () => { .spyOn(campaignService, 'updateDonationPayment') .mockName('updateDonationPayment') - prismaMock.donation.findFirst.mockResolvedValue({ - targetVaultId: '1', + prismaMock.payments.findFirst.mockResolvedValue({ + ...mockPayment, amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, - status: 'initial', - } as Donation) + status: PaymentStatus.initial, + }) - prismaMock.donation.update.mockResolvedValue({ - targetVaultId: '1', + prismaMock.payments.update.mockResolvedValue({ + ...mockPayment, amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, - status: 'initial', - person: {}, - } as Donation & { person: unknown }) + status: PaymentStatus.initial, + }) const mockedIncrementVaultAmount = jest .spyOn(vaultService, 'incrementVaultAmount') diff --git a/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts b/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts index 92ba78380..39be21441 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.spec.ts @@ -18,7 +18,8 @@ import { BankDonationStatus, BankTransaction, Campaign, - DonationStatus, + DonationType, + PaymentStatus, Prisma, Vault, } from '@prisma/client' @@ -32,7 +33,7 @@ import { MarketingNotificationsService } from '../../notifications/notifications const IBAN = 'BG77UNCR92900016740920' type AffiliateWithPayload = Prisma.AffiliateGetPayload<{ - include: { donations: true } + include: { payments: { include: { donations: true } } } }> class MockIrisTasks extends IrisTasks { @@ -68,25 +69,37 @@ describe('ImportTransactionsTask', () => { companyId: '1234572', affiliateCode: 'af_12345', status: 'active', - donations: [ + createdAt: new Date(), + updatedAt: new Date(), + payments: [ { - id: 'donation-id', - type: 'donation', + id: 'payment-id', + type: 'single', status: 'guaranteed', amount: 5000, affiliateId: 'affiliate-id', - personId: null, extCustomerId: '', extPaymentIntentId: '123456', extPaymentMethodId: '1234', billingEmail: 'test@podkrepi.bg', billingName: 'John doe', - targetVaultId: 'vault-id', chargedAmount: 0, currency: 'BGN', - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date('2023-03-14T00:00:00.000Z'), + updatedAt: new Date('2023-03-14T00:00:00.000Z'), provider: 'bank', + donations: [ + { + type: DonationType.donation, + id: '123', + amount: 50, + targetVaultId: '1', + paymentId: 'payment-id', + createdAt: new Date('2023-03-14T00:00:00.000Z'), + updatedAt: new Date('2023-03-14T00:00:00.000Z'), + personId: null, + }, + ], }, ], } @@ -390,14 +403,14 @@ describe('ImportTransactionsTask', () => { }, }, include: { - donations: { + payments: { where: { - status: DonationStatus.guaranteed, + status: PaymentStatus.guaranteed, }, orderBy: { createdAt: 'asc', }, - include: { targetVault: true }, + include: { donations: true }, }, }, }), @@ -412,10 +425,21 @@ describe('ImportTransactionsTask', () => { id: mockDonatedCampaigns[0].vaults[0].id, }), ) + expect(donationSpy).toHaveBeenCalledWith( expect.objectContaining({ - extPaymentIntentId: mockIrisTransactions[0].transactionId, - targetVaultId: mockDonatedCampaigns[0].vaults[0].id, + amount: 5000, + billingName: 'JOHN DOE', + createdAt: new Date('2023-03-14T00:00:00.000Z'), + currency: 'BGN', + donations: { create: { personId: null, targetVaultId: 'vault-id', type: 'donation' } }, + extCustomerId: 'BG77UNCR92900016740920', + extPaymentIntentId: + 'Booked_5954782144_70123543493054963FTRO23073A58G01C2023345440_20230314', + extPaymentMethodId: 'IRIS bank import', + provider: 'bank', + status: 'succeeded', + type: 'single', }), ) diff --git a/apps/api/src/tasks/bank-import/import-transactions.task.ts b/apps/api/src/tasks/bank-import/import-transactions.task.ts index 722dcba32..ae9e19e2e 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.ts @@ -443,7 +443,6 @@ export class IrisTasks { if (!campaign) { //Campaign not found by paymentReference. Check if it is affiliate donation const affiliate = affiliates.find((affiliate) => affiliate.affiliateCode === trx.matchedRef) - console.log(affiliate) if (!affiliate) { trx.bankDonationStatus = BankDonationStatus.unrecognized continue From 7e37d19780b42c3ba9aeeceecf08524e537e03dd Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Thu, 29 Feb 2024 14:24:25 +0200 Subject: [PATCH 04/10] src/vault: Add possibility to decrement many vaults as well In future situations, we might need to decrement many vaults at once as well,(e.g. payment refund etc..) - Added some unit tests to cover vaults updates -Did some refactoring on commonly used types, as well as test mocks --- .../affiliate/affiliate.controller.spec.ts | 64 +++++++-------- apps/api/src/affiliate/types/affiliate.ts | 9 +++ .../bank-transactions.service.ts | 2 +- .../src/campaign/campaign.controller.spec.ts | 38 +++------ apps/api/src/campaign/campaign.service.ts | 16 ++-- .../src/donations/__mocks__/paymentMock.ts | 57 +++++++++++++ apps/api/src/donations/donations.service.ts | 30 ++----- apps/api/src/donations/types/donation.ts | 4 + .../notifications.interface.providers.ts | 4 +- apps/api/src/person/__mock__/peronMock.ts | 21 +++++ .../{mock => __mock__}/person.service.mock.ts | 0 .../bank-import/import-transactions.task.ts | 13 +-- apps/api/src/vault/__mocks__/vault.ts | 13 +++ apps/api/src/vault/types/vault.ts | 11 +++ apps/api/src/vault/vault.controller.spec.ts | 4 +- apps/api/src/vault/vault.service.spec.ts | 52 +++++++++++- apps/api/src/vault/vault.service.ts | 79 ++++++++++++++----- 17 files changed, 288 insertions(+), 129 deletions(-) create mode 100644 apps/api/src/affiliate/types/affiliate.ts create mode 100644 apps/api/src/donations/__mocks__/paymentMock.ts create mode 100644 apps/api/src/donations/types/donation.ts create mode 100644 apps/api/src/person/__mock__/peronMock.ts rename apps/api/src/person/{mock => __mock__}/person.service.mock.ts (100%) create mode 100644 apps/api/src/vault/__mocks__/vault.ts create mode 100644 apps/api/src/vault/types/vault.ts diff --git a/apps/api/src/affiliate/affiliate.controller.spec.ts b/apps/api/src/affiliate/affiliate.controller.spec.ts index 62a523ef5..55316dd9e 100644 --- a/apps/api/src/affiliate/affiliate.controller.spec.ts +++ b/apps/api/src/affiliate/affiliate.controller.spec.ts @@ -11,16 +11,26 @@ import { VaultService } from '../vault/vault.service' import { NotificationModule } from '../sockets/notifications/notification.module' import { MarketingNotificationsModule } from '../notifications/notifications.module' import { ExportService } from '../export/export.service' -import { Affiliate, Campaign, CampaignState, Donation, Prisma, Vault } from '@prisma/client' +import { + Affiliate, + AffiliateStatus, + Campaign, + CampaignState, + PaymentStatus, + Payments, + Prisma, + Vault, +} from '@prisma/client' import { KeycloakTokenParsed } from '../auth/keycloak' import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common' import { AffiliateStatusUpdateDto } from './dto/affiliate-status-update.dto' import * as afCodeGenerator from './utils/affiliateCodeGenerator' import { CreateAffiliateDonationDto } from './dto/create-affiliate-donation.dto' +import { mockPayment } from '../donations/__mocks__/paymentMock' type PersonWithPayload = Prisma.PersonGetPayload<{ include: { company: true } }> type AffiliateWithPayload = Prisma.AffiliateGetPayload<{ - include: { company: { include: { person: true } }; donations: true } + include: { company: { include: { person: true } }; payments: true } }> describe('AffiliateController', () => { @@ -126,28 +136,10 @@ describe('AffiliateController', () => { countryCode: null, person: { ...mockIndividualProfile }, }, - donations: [], + payments: [], } - const donationResponseMock: Donation = { - id: 'donation-id', - type: 'donation', - status: 'guaranteed', - amount: 5000, - affiliateId: activeAffiliateMock.id, - personId: null, - extCustomerId: '', - extPaymentIntentId: '123456', - extPaymentMethodId: '1234', - billingEmail: 'test@podkrepi.bg', - billingName: 'John Doe', - targetVaultId: vaultMock.id, - chargedAmount: 0, - currency: 'BGN', - createdAt: new Date(), - updatedAt: new Date(), - provider: 'bank', - } + const mockGuaranteedPayment = { ...mockPayment, status: PaymentStatus.guaranteed } const userMock = { sub: 'testKeycloackId', @@ -232,13 +224,13 @@ describe('AffiliateController', () => { const activeAffiliateMock: Affiliate = { ...affiliateMock, - status: 'active', + status: AffiliateStatus.active, id: '12345', affiliateCode: affiliateCodeMock, } const mockCancelledStatus: AffiliateStatusUpdateDto = { - newStatus: 'cancelled', + newStatus: AffiliateStatus.cancelled, } jest.spyOn(service, 'findOneById').mockResolvedValue(activeAffiliateMock) @@ -265,7 +257,7 @@ describe('AffiliateController', () => { jest.spyOn(service, 'findOneById').mockResolvedValue(activeAffiliateMock) const updateStatusDto: AffiliateStatusUpdateDto = { - newStatus: 'active', + newStatus: AffiliateStatus.active, } const codeGenerationSpy = jest .spyOn(afCodeGenerator, 'affiliateCodeGenerator') @@ -305,7 +297,7 @@ describe('AffiliateController', () => { jest.spyOn(service, 'findOneByCode').mockResolvedValue(activeAffiliateMock) const createAffiliateDonationSpy = jest .spyOn(donationService, 'createAffiliateDonation') - .mockResolvedValue(donationResponseMock) + .mockResolvedValue(mockGuaranteedPayment) jest.spyOn(prismaMock.vault, 'findMany').mockResolvedValue([vaultMock]) prismaMock.campaign.findFirst.mockResolvedValue({ id: '123', @@ -318,26 +310,26 @@ describe('AffiliateController', () => { affiliateId: activeAffiliateMock.id, }) expect(await donationService.createAffiliateDonation(affiliateDonationDto)).toEqual( - donationResponseMock, + mockGuaranteedPayment, ) }) it('should cancel', async () => { - const cancelledDonationResponse: Donation = { - ...donationResponseMock, - status: 'cancelled', + const cancelledDonationResponse: Payments = { + ...mockGuaranteedPayment, + status: PaymentStatus.cancelled, } jest .spyOn(donationService, 'getAffiliateDonationById') - .mockResolvedValue(donationResponseMock) + .mockResolvedValue(mockGuaranteedPayment) jest.spyOn(donationService, 'update').mockResolvedValue(cancelledDonationResponse) expect( - await controller.cancelAffiliateDonation(affiliateCodeMock, donationResponseMock.id), + await controller.cancelAffiliateDonation(affiliateCodeMock, mockGuaranteedPayment.id), ).toEqual(cancelledDonationResponse) }) it('should throw error if donation status is succeeded', async () => { - const succeededDonationResponse: Donation = { - ...donationResponseMock, - status: 'succeeded', + const succeededDonationResponse: Payments = { + ...mockGuaranteedPayment, + status: PaymentStatus.succeeded, } jest @@ -345,7 +337,7 @@ describe('AffiliateController', () => { .mockResolvedValue(succeededDonationResponse) const updateDonationStatus = jest.spyOn(donationService, 'update') expect( - controller.cancelAffiliateDonation(affiliateCodeMock, donationResponseMock.id), + controller.cancelAffiliateDonation(affiliateCodeMock, mockGuaranteedPayment.id), ).rejects.toThrow(new BadRequestException("Donation status can't be updated")) expect(updateDonationStatus).not.toHaveBeenCalled() }) diff --git a/apps/api/src/affiliate/types/affiliate.ts b/apps/api/src/affiliate/types/affiliate.ts new file mode 100644 index 000000000..2bfd9cc76 --- /dev/null +++ b/apps/api/src/affiliate/types/affiliate.ts @@ -0,0 +1,9 @@ +import { Prisma } from '@prisma/client' + +export type AffiliateWithDonation = Prisma.AffiliateGetPayload<{ + include: { payments: { include: { donations: true } } } +}> + +export type AffiliateWithCompanyPayload = Prisma.AffiliateGetPayload<{ + include: { company: { include: { person: true } }; donations: true } +}> diff --git a/apps/api/src/bank-transactions/bank-transactions.service.ts b/apps/api/src/bank-transactions/bank-transactions.service.ts index 3b993b45d..3ee0f237d 100644 --- a/apps/api/src/bank-transactions/bank-transactions.service.ts +++ b/apps/api/src/bank-transactions/bank-transactions.service.ts @@ -165,7 +165,7 @@ export class BankTransactionsService { matchedRef: newPaymentRef, }, }) - console.log(`called`) + // Import Donation await this.donationService.createUpdateBankPayment(bankPayment) }) diff --git a/apps/api/src/campaign/campaign.controller.spec.ts b/apps/api/src/campaign/campaign.controller.spec.ts index a8bdb2427..ffe30946e 100644 --- a/apps/api/src/campaign/campaign.controller.spec.ts +++ b/apps/api/src/campaign/campaign.controller.spec.ts @@ -23,18 +23,19 @@ import { MarketingNotificationsService } from '../notifications/notifications.se import { EmailService } from '../email/email.service' import { TemplateService } from '../email/template.service' import { CampaignNewsService } from '../campaign-news/campaign-news.service' +import { personMock } from '../person/__mock__/peronMock' describe('CampaignController', () => { let controller: CampaignController let prismaService: PrismaService let campaignService: CampaignService - let marketingProvider: NotificationsProviderInterface + let marketingProvider: NotificationsProviderInterface let marketingService: MarketingNotificationsService const personServiceMock = { findOneByKeycloakId: jest.fn(() => { return { id: personIdMock } }), - findByEmail: jest.fn(async () => { + findByEmail: jest.fn(async (): Promise => { return person }), update: jest.fn(async () => { @@ -51,24 +52,7 @@ describe('CampaignController', () => { }), } - const person: Person | null = { - id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd', - firstName: 'John', - lastName: 'Doe', - keycloakId: 'some-id', - email: 'user@email.com', - emailConfirmed: false, - companyId: null, - phone: null, - picture: null, - createdAt: new Date('2021-10-07T13:38:11.097Z'), - updatedAt: new Date('2021-10-07T13:38:11.097Z'), - newsletter: true, - address: null, - birthday: null, - personalNumber: null, - stripeCustomerId: null, - } + const person: Person = personMock const mockCreateCampaign = { slug: 'test-slug', @@ -428,7 +412,7 @@ describe('CampaignController', () => { describe('subscribeToCampaignNotifications', () => { it('should throw if no consent is provided', async () => { const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, consent: false, } @@ -439,7 +423,7 @@ describe('CampaignController', () => { it('should throw if the campaign is not active', async () => { const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, consent: true, } @@ -458,7 +442,7 @@ describe('CampaignController', () => { jest.spyOn(marketingService, 'sendConfirmEmail') const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, // Valid consent consent: true, } @@ -497,7 +481,7 @@ describe('CampaignController', () => { jest.spyOn(campaignService, 'createCampaignNotificationList') const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, // Valid consent consent: true, } @@ -538,7 +522,7 @@ describe('CampaignController', () => { jest.spyOn(marketingService, 'sendConfirmEmail') const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, // Valid consent consent: true, } @@ -566,7 +550,7 @@ describe('CampaignController', () => { expect(marketingService.sendConfirmEmail).not.toHaveBeenCalled() }) - it('should create a saparate notification consent record for non-registered emails', async () => { + it('should create a separate notification consent record for non-registered emails', async () => { jest.spyOn(marketingProvider, 'addContactsToList').mockImplementation(async () => '') jest.spyOn(campaignService, 'createCampaignNotificationList') jest.spyOn(marketingService, 'sendConfirmEmail') @@ -577,7 +561,7 @@ describe('CampaignController', () => { }) const data: CampaignSubscribeDto = { - email: person.email, + email: person.email!, // Valid consent consent: true, } diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 20dd2be66..538391c30 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -624,15 +624,15 @@ export class CampaignService { private async updateDonationIfAllowed( tx: Prisma.TransactionClient, - donation: Prisma.PaymentsGetPayload<{ include: { donations: true } }>, + payment: Prisma.PaymentsGetPayload<{ include: { donations: true } }>, newDonationStatus: PaymentStatus, paymentData: PaymentData, ) { - if (shouldAllowStatusChange(donation.status, newDonationStatus)) { + if (shouldAllowStatusChange(payment.status, newDonationStatus)) { try { const updatedDonation = await tx.payments.update({ where: { - id: donation.id, + id: payment.id, }, data: { status: newDonationStatus, @@ -648,11 +648,11 @@ export class CampaignService { //if donation is switching to successful, increment the vault amount and send notification if ( - donation.status != PaymentStatus.succeeded && + payment.status != PaymentStatus.succeeded && newDonationStatus === PaymentStatus.succeeded ) { await this.vaultService.incrementVaultAmount( - donation.donations[0].targetVaultId, + payment.donations[0].targetVaultId, paymentData.netAmount, tx, ) @@ -661,11 +661,11 @@ export class CampaignService { person: updatedDonation.donations[0].person, }) } else if ( - donation.status === PaymentStatus.succeeded && + payment.status === PaymentStatus.succeeded && newDonationStatus === PaymentStatus.refund ) { await this.vaultService.decrementVaultAmount( - donation.donations[0].targetVaultId, + payment.donations[0].targetVaultId, paymentData.netAmount, tx, ) @@ -686,7 +686,7 @@ export class CampaignService { else { Logger.warn( `Skipping update of donation with paymentIntentId: ${paymentData.paymentIntentId} - and status: ${newDonationStatus} because the event comes after existing donation with status: ${donation.status}`, + and status: ${newDonationStatus} because the event comes after existing donation with status: ${payment.status}`, ) } } diff --git a/apps/api/src/donations/__mocks__/paymentMock.ts b/apps/api/src/donations/__mocks__/paymentMock.ts new file mode 100644 index 000000000..f3a5d80fd --- /dev/null +++ b/apps/api/src/donations/__mocks__/paymentMock.ts @@ -0,0 +1,57 @@ +import { Currency, DonationType, PaymentProvider, PaymentStatus } from '@prisma/client' +import { PaymentWithDonation } from '../types/donation' +import { DonationWithPerson } from '../types/donation' +import { personMock } from '../../person/__mock__/peronMock' + +export const mockDonation: DonationWithPerson = { + id: '1234', + paymentId: '123', + type: DonationType.donation, + amount: 10, + targetVaultId: 'vault-1', + createdAt: new Date('2022-01-01'), + updatedAt: new Date('2022-01-02'), + personId: '1', + person: personMock, +} + +//Mock donation to different vault +const mockDonationWithDiffVaultId: DonationWithPerson = { + ...mockDonation, + targetVaultId: 'vault-2', +} + +//Mock donation to same vault as mockDonation, but different amount +const mockDonationWithDiffAmount: DonationWithPerson = { ...mockDonation, amount: 50 } + +export const mockPayment: PaymentWithDonation = { + id: '123', + provider: PaymentProvider.bank, + currency: Currency.BGN, + type: 'single', + status: PaymentStatus.initial, + amount: 10, + affiliateId: null, + extCustomerId: 'hahaha', + extPaymentIntentId: 'pm1', + extPaymentMethodId: 'bank', + billingEmail: 'test@podkrepi.bg', + billingName: 'Test', + chargedAmount: 10.5, + createdAt: new Date('2022-01-01'), + updatedAt: new Date('2022-01-02'), + donations: [mockDonation, mockDonationWithDiffVaultId, mockDonationWithDiffAmount], +} + +export const mockSucceededPayment: PaymentWithDonation = { + ...mockPayment, + status: PaymentStatus.succeeded, +} +export const mockGuaranteedPayment: PaymentWithDonation = { + ...mockPayment, + status: PaymentStatus.guaranteed, +} +export const mockCancelledPayment: PaymentWithDonation = { + ...mockPayment, + status: PaymentStatus.cancelled, +} diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 2483c810a..9f6bf5074 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -34,28 +34,8 @@ import { CreateStripePaymentDto } from './dto/create-stripe-payment.dto' import { ImportStatus } from '../bank-transactions-file/dto/bank-transactions-import-status.dto' import { DonationQueryDto } from '../common/dto/donation-query-dto' import { CreateAffiliateDonationDto } from '../affiliate/dto/create-affiliate-donation.dto' - -type PaymentWithDonation = Prisma.PaymentsGetPayload<{ - select: { - donations: { - select: { - metadata: { select: { name: true } } - person: { - select: { - firstName: true - lastName: true - company: { select: { companyName: true } } - } - } - } - } - } -}> - -type TPaymentWithDonations = Prisma.PaymentsGetPayload<{ include: { donations: true } }> -type VaultUpdate = { - [key: string]: number -} +import { VaultUpdate } from '../vault/types/vault' +import { PaymentWithDonation } from './types/donation' @Injectable() export class DonationsService { @@ -496,7 +476,7 @@ export class DonationsService { * @returns {Promise} Donation * @throws NotFoundException if no donation is found */ - async getDonationById(id: string): Promise { + async getDonationById(id: string): Promise { try { const donation = await this.prisma.payments.findFirstOrThrow({ where: { id }, @@ -663,10 +643,10 @@ export class DonationsService { }) } - async updateAffiliateBankPayment(paymentsIds: string[], donationDto: VaultUpdate) { + async updateAffiliateBankPayment(paymentsIds: string[], listOfVaults: VaultUpdate) { return await this.prisma.$transaction(async (tx) => { await Promise.all([ - this.vaultService.updateManyVaultsAmount(donationDto, tx, 'increment'), + this.vaultService.IncrementManyVaults(listOfVaults, tx), tx.payments.updateMany({ where: { id: { in: paymentsIds } }, data: { status: PaymentStatus.succeeded }, diff --git a/apps/api/src/donations/types/donation.ts b/apps/api/src/donations/types/donation.ts new file mode 100644 index 000000000..9c2b75ca6 --- /dev/null +++ b/apps/api/src/donations/types/donation.ts @@ -0,0 +1,4 @@ +import { Prisma } from '@prisma/client' + +export type PaymentWithDonation = Prisma.PaymentsGetPayload<{ include: { donations: true } }> +export type DonationWithPerson = Prisma.DonationGetPayload<{ include: { person: true } }> diff --git a/apps/api/src/notifications/providers/notifications.interface.providers.ts b/apps/api/src/notifications/providers/notifications.interface.providers.ts index 3f17be686..47d4e5a34 100644 --- a/apps/api/src/notifications/providers/notifications.interface.providers.ts +++ b/apps/api/src/notifications/providers/notifications.interface.providers.ts @@ -21,7 +21,9 @@ type NotificationsInterfaceParams = { SendNotificationRes: unknown } -export abstract class NotificationsProviderInterface { +export abstract class NotificationsProviderInterface< + T extends NotificationsInterfaceParams = NotificationsInterfaceParams, +> { abstract createNewContactList(data: T['CreateListParams']): Promise abstract updateContactList(data: T['UpdateListParams']): Promise abstract deleteContactList(data: T['DeleteListParams']): Promise diff --git a/apps/api/src/person/__mock__/peronMock.ts b/apps/api/src/person/__mock__/peronMock.ts new file mode 100644 index 000000000..21b8f148c --- /dev/null +++ b/apps/api/src/person/__mock__/peronMock.ts @@ -0,0 +1,21 @@ +import { Person } from '@prisma/client' + +export const personMock: Person = { + id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd', + firstName: 'John', + lastName: 'Doe', + keycloakId: 'some-id', + email: 'user@email.com', + emailConfirmed: false, + companyId: null, + phone: null, + picture: null, + createdAt: new Date('2021-10-07T13:38:11.097Z'), + updatedAt: new Date('2021-10-07T13:38:11.097Z'), + newsletter: true, + address: null, + birthday: null, + personalNumber: null, + stripeCustomerId: null, + profileEnabled: false, +} diff --git a/apps/api/src/person/mock/person.service.mock.ts b/apps/api/src/person/__mock__/person.service.mock.ts similarity index 100% rename from apps/api/src/person/mock/person.service.mock.ts rename to apps/api/src/person/__mock__/person.service.mock.ts diff --git a/apps/api/src/tasks/bank-import/import-transactions.task.ts b/apps/api/src/tasks/bank-import/import-transactions.task.ts index ae9e19e2e..0308b9ebe 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.ts @@ -5,12 +5,10 @@ import { SchedulerRegistry } from '@nestjs/schedule' import { BankDonationStatus, Currency, - Donation, DonationType, PaymentProvider, PaymentStatus, PaymentType, - Payments, Prisma, Vault, } from '@prisma/client' @@ -37,14 +35,12 @@ import { import { ImportStatus } from '../../bank-transactions-file/dto/bank-transactions-import-status.dto' import { IrisIbanAccountInfoDto } from '../../bank-transactions/dto/iris-bank-account-info.dto' import { IrisTransactionInfoDto } from '../../bank-transactions/dto/iris-bank-transaction-info.dto' +import { VaultUpdate } from '../../vault/types/vault' type filteredTransaction = Prisma.BankTransactionCreateManyInput type AffiliatePayload = Prisma.AffiliateGetPayload<{ include: { payments: { include: { donations: true } } } }> -type VaultUpdate = { - [key: string]: number -} @Injectable() export class IrisTasks { @@ -487,11 +483,8 @@ export class IrisTasks { if (trx.amount - totalDonated < payment.amount) continue paymentIdsToUpdate.push(payment.id) for (const donation of payment.donations) { - if (vaultsToUpdate.hasOwnProperty(donation.targetVaultId)) { - vaultsToUpdate[donation.targetVaultId] += donation.amount - } else { - vaultsToUpdate[donation.targetVaultId] = donation.amount - } + vaultsToUpdate[donation.targetVaultId] = + (vaultsToUpdate[donation.targetVaultId] || 0) + donation.amount } totalDonated += payment.amount updatedPayments++ diff --git a/apps/api/src/vault/__mocks__/vault.ts b/apps/api/src/vault/__mocks__/vault.ts new file mode 100644 index 000000000..605b51858 --- /dev/null +++ b/apps/api/src/vault/__mocks__/vault.ts @@ -0,0 +1,13 @@ +import { Vault } from '@prisma/client' +import { randomUUID } from 'crypto' + +export const mockVault: Vault = { + id: randomUUID(), + currency: 'BGN', + createdAt: new Date(), + updatedAt: new Date(), + amount: 100, + blockedAmount: 0, + campaignId: 'campaign-id', + name: 'Test vault', +} diff --git a/apps/api/src/vault/types/vault.ts b/apps/api/src/vault/types/vault.ts new file mode 100644 index 000000000..6c4c88368 --- /dev/null +++ b/apps/api/src/vault/types/vault.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@prisma/client' + +export type VaultUpdate = { + [key: string]: number +} + +export type VaultWithWithdrawalSum = Prisma.VaultGetPayload<{ + include: { campaign: { select: { id: true; title: true } } } +}> & { + withdrawnAmount: number +} diff --git a/apps/api/src/vault/vault.controller.spec.ts b/apps/api/src/vault/vault.controller.spec.ts index a9685890f..c83e7bb7f 100644 --- a/apps/api/src/vault/vault.controller.spec.ts +++ b/apps/api/src/vault/vault.controller.spec.ts @@ -2,7 +2,7 @@ import { ConfigService } from '@nestjs/config' import { Test, TestingModule } from '@nestjs/testing' import { UnauthorizedException } from '@nestjs/common' import { CampaignService } from '../campaign/campaign.service' -import { personServiceMock, PersonServiceMock } from '../person/mock/person.service.mock' +import { personServiceMock, PersonServiceMock } from '../person/__mock__/person.service.mock' import { VaultController } from './vault.controller' import { VaultService } from './vault.service' import { KeycloakTokenParsed } from '../auth/keycloak' @@ -14,6 +14,8 @@ import { TemplateService } from '../email/template.service' import { MarketingNotificationsService } from '../notifications/notifications.service' import { NotificationsProviderInterface } from '../notifications/providers/notifications.interface.providers' import { SendGridNotificationsProvider } from '../notifications/providers/notifications.sendgrid.provider' +import { mockedVault } from '../donations/events/stripe-payment.testdata' +import { mockVault } from './__mocks__/vault' describe('VaultController', () => { let controller: VaultController diff --git a/apps/api/src/vault/vault.service.spec.ts b/apps/api/src/vault/vault.service.spec.ts index d7dd49db4..aa0a8ac50 100644 --- a/apps/api/src/vault/vault.service.spec.ts +++ b/apps/api/src/vault/vault.service.spec.ts @@ -3,10 +3,15 @@ import { Test, TestingModule } from '@nestjs/testing' import { CampaignModule } from '../campaign/campaign.module' import { CampaignService } from '../campaign/campaign.service' import { PersonService } from '../person/person.service' -import { MockPrismaService } from '../prisma/prisma-client.mock' +import { MockPrismaService, prismaMock } from '../prisma/prisma-client.mock' import { NotificationModule } from '../sockets/notifications/notification.module' import { VaultService } from './vault.service' import { MarketingNotificationsModule } from '../notifications/notifications.module' +import { mockVault } from './__mocks__/vault' +import { VaultUpdate } from './types/vault' +import { randomUUID } from 'crypto' +import { mockDonation, mockPayment } from '../donations/__mocks__/paymentMock' +import { Donation } from '@prisma/client' describe('VaultService', () => { let service: VaultService @@ -23,4 +28,49 @@ describe('VaultService', () => { it('should be defined', () => { expect(service).toBeDefined() }) + + it('decrementManyVaults should throw an error if one vaults returns negative amount', async () => { + const vaultResponseMock = [ + mockVault, + { ...mockVault, amount: 10, id: randomUUID() }, + { ...mockVault, amount: 20 }, + ] + const listOfVaults: VaultUpdate = { + [vaultResponseMock[0].id]: 90, + [vaultResponseMock[1].id]: 20, + } + + prismaMock.vault.findMany.mockResolvedValue(vaultResponseMock) + const updateSpy = jest.spyOn(service, 'updateManyVaults').mockImplementation() + expect(updateSpy).not.toHaveBeenCalled() + await expect(service.decrementManyVaults(listOfVaults, prismaMock)).rejects + .toThrow(`Updating vaults aborted, due to negative amount in some of the vaults. + Invalid vaultIds: ${vaultResponseMock[1].id}`) + }) + + it('prepareVaultObjectFromDonation should return VaultUpdate object', () => { + const [mockDonation, mockDonationToDiffVault, mockDonationToSameVault] = mockPayment.donations + const vaultId1 = mockDonation.targetVaultId + const vaultId2 = mockDonationToDiffVault.targetVaultId + const result: VaultUpdate = { + [vaultId1]: mockDonation.amount + mockDonationToSameVault.amount, + [vaultId2]: mockDonationToDiffVault.amount, + } + expect(mockDonation.targetVaultId).toEqual(mockDonationToSameVault.targetVaultId) + expect(service.prepareVaultUpdateObjectFromDonation(mockPayment.donations)).toEqual(result) + }) + it('prepareSQLValuesString should return string of VALUES to be updated by SQL statement', () => { + const [mockDonation, mockDonationToDiffVault, mockDonationToSameVault] = mockPayment.donations + const vaultId1 = mockDonation.targetVaultId + const vaultId2 = mockDonationToDiffVault.targetVaultId + + const vaultUpdateObj: VaultUpdate = { + [vaultId1]: mockDonation.amount + mockDonationToSameVault.amount, + [vaultId2]: mockDonationToDiffVault.amount, + } + + const result = `('${vaultId1}'::uuid, ${vaultUpdateObj[vaultId1]}),('${vaultId2}'::uuid, ${vaultUpdateObj[vaultId2]})` + + expect(service.prepareSQLValuesString(vaultUpdateObj)).toEqual(result) + }) }) diff --git a/apps/api/src/vault/vault.service.ts b/apps/api/src/vault/vault.service.ts index 5389e17e4..9ed4b7c31 100644 --- a/apps/api/src/vault/vault.service.ts +++ b/apps/api/src/vault/vault.service.ts @@ -6,22 +6,13 @@ import { UnauthorizedException, BadRequestException, } from '@nestjs/common' -import { Prisma, Vault } from '@prisma/client' +import { Donation, Prisma, Vault } from '@prisma/client' import { CampaignService } from '../campaign/campaign.service' import { PersonService } from '../person/person.service' import { PrismaService } from '../prisma/prisma.service' import { CreateVaultDto } from './dto/create-vault.dto' import { UpdateVaultDto } from './dto/update-vault.dto' - -type VaultWithWithdrawalSum = Prisma.VaultGetPayload<{ - include: { campaign: { select: { id: true; title: true } } } -}> & { - withdrawnAmount: number -} - -type VaultUpdate = { - [id: string]: number -} +import { VaultUpdate, VaultWithWithdrawalSum } from './types/vault' @Injectable() export class VaultService { @@ -176,7 +167,6 @@ export class VaultService { tx: Prisma.TransactionClient, operationType: 'increment' | 'decrement', ) { - console.log(vaultId, amount, operationType) if (amount <= 0) { throw new Error('Amount cannot be negative or zero.') } @@ -194,22 +184,73 @@ export class VaultService { return vault } - public async updateManyVaultsAmount( + prepareVaultUpdateObjectFromDonation(donations: Donation[]): VaultUpdate { + const result = donations.reduce((acc, curr) => { + return { + ...acc, + [curr.targetVaultId]: acc[curr.targetVaultId] + ? acc[curr.targetVaultId] + curr.amount + : curr.amount, + } + }, {}) + return result + } + + async decrementManyVaults(listOfVaults: VaultUpdate, tx: Prisma.TransactionClient) { + const vaults = await tx.vault.findMany({ + where: { id: { in: Object.keys(listOfVaults) } }, + }) + const failedVaults = vaults.reduce((vaultAcc: string[], vault: Vault) => { + if (vault.amount - listOfVaults[vault.id] > 0) return vaultAcc + vaultAcc.push(vault.id) + return vaultAcc + }, []) + + if (failedVaults.length > 0) { + console.log(`errro`) + throw new Error( + `Updating vaults aborted, due to negative amount in some of the vaults. + Invalid vaultIds: ${failedVaults.join(',')}`, + ) + } + return await this.updateManyVaults(listOfVaults, tx, 'decrement') + } + + async IncrementManyVaults(vaults: VaultUpdate, tx: Prisma.TransactionClient) { + return await this.updateManyVaults(vaults, tx, 'increment') + } + + prepareSQLValuesString(vaults: VaultUpdate): string { + return Object.entries(vaults) + .map(([vaultId, amount]) => `('${vaultId}'::uuid, ${amount})`) + .join(',') + } + + /** + * Update many vaults within a single query + * @param {VaultUpdate} vaults Object containing ids of vaults as key, and total amount to be incremented/decremented as a value + * @param tx - Prisma instance within transaction + * @param operationType Whether to increment or decrement vault + * @returns + */ + async updateManyVaults( vaults: VaultUpdate, tx: Prisma.TransactionClient, operationType: 'increment' | 'decrement', ) { - const sqlValues = Object.entries(vaults) - .map(([vaultId, amount]) => `('${vaultId}'::uuid, ${amount})`) - .join(',') + const sqlValues = this.prepareSQLValuesString(vaults) - return await tx.$queryRawUnsafe(` + return await tx.$executeRawUnsafe(` UPDATE vaults - SET amount = amount + new_amount + SET amount = CASE + WHEN ${operationType === 'increment'} THEN amount + new_amount + WHEN ${operationType === 'decrement'} THEN amount - new_amount + ELSE amount + END FROM ( VALUES ${sqlValues} ) AS updated_vault(id, new_amount) - WHERE vaults.id::text = updated_vault.id::text; + WHERE vaults.id::text = updated_vault.id::text AND vaults.amount::INTEGER - updated_vault.new_amount::INTEGER > 0 RETURNING *; `) } } From 3a63238262399ed480354b9016b387b87a84a0f6 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sat, 2 Mar 2024 12:32:49 +0200 Subject: [PATCH 05/10] schema.prisma: Rename Payments to Payment As the name of the model is used as a type, the acceptednaming convention is to name that type in singular way --- .../dto/create-affiliate-donation.dto.ts | 2 +- apps/api/src/campaign/campaign.service.ts | 18 ++--- .../affiliate/entities/affiliate.entity.ts | 4 +- .../donation/entities/donation.entity.ts | 4 +- .../payment/dto/connect-payment.dto.ts | 4 ++ .../payment/dto/create-payment.dto.ts | 12 ++++ .../src/domain/generated/payment/dto/index.ts | 3 + .../payment/dto/update-payment.dto.ts | 12 ++++ .../generated/payment/entities/index.ts | 1 + .../payment/entities/payment.entity.ts | 23 +++++++ .../donations/donations.controller.spec.ts | 20 +++--- .../api/src/donations/donations.controller.ts | 2 + apps/api/src/donations/donations.service.ts | 66 ++++++++++--------- .../src/donations/dto/create-payment.dto.ts | 2 +- .../events/stripe-payment.service.spec.ts | 42 ++++++------ .../donations/queries/donation.validator.ts | 11 ---- apps/api/src/donations/types/donation.ts | 13 +++- .../notifications/notification.service.ts | 2 +- schema.prisma | 6 +- 19 files changed, 154 insertions(+), 93 deletions(-) create mode 100644 apps/api/src/domain/generated/payment/dto/connect-payment.dto.ts create mode 100644 apps/api/src/domain/generated/payment/dto/create-payment.dto.ts create mode 100644 apps/api/src/domain/generated/payment/dto/index.ts create mode 100644 apps/api/src/domain/generated/payment/dto/update-payment.dto.ts create mode 100644 apps/api/src/domain/generated/payment/entities/index.ts create mode 100644 apps/api/src/domain/generated/payment/entities/payment.entity.ts diff --git a/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts b/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts index 409239a46..43ff59061 100644 --- a/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts +++ b/apps/api/src/affiliate/dto/create-affiliate-donation.dto.ts @@ -88,7 +88,7 @@ export class CreateAffiliateDonationDto { @ValidateNested({ each: true }) metadata: DonationMetadataDto | undefined - public toEntity(targetVaultId: string): Prisma.PaymentsCreateInput { + public toEntity(targetVaultId: string): Prisma.PaymentCreateInput { return { type: PaymentType.single, status: PaymentStatus.guaranteed, diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 538391c30..11ce5598b 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -3,7 +3,6 @@ import { Campaign, CampaignState, CampaignType, - Donation, PaymentStatus, DonationType, Vault, @@ -12,8 +11,8 @@ import { NotificationList, EmailType, CampaignTypeCategory, - Payments, PaymentType, + Payment, } from '@prisma/client' import { BadRequestException, @@ -51,6 +50,7 @@ import { ConfigService } from '@nestjs/config' import { DateTime } from 'luxon' import { CampaignSubscribeDto } from './dto/campaign-subscribe.dto' import { MarketingNotificationsService } from '../notifications/notifications.service' +import type { PaymentWithDonation } from '../donations/types/donation' @Injectable() export class CampaignService { @@ -566,8 +566,8 @@ export class CampaignService { return donations } - async getDonationByIntentId(paymentIntentId: string): Promise { - return this.prisma.payments.findFirst({ where: { extPaymentIntentId: paymentIntentId } }) + async getPaymentByIntentId(paymentIntentId: string): Promise { + return this.prisma.payment.findFirst({ where: { extPaymentIntentId: paymentIntentId } }) } /** @@ -624,13 +624,13 @@ export class CampaignService { private async updateDonationIfAllowed( tx: Prisma.TransactionClient, - payment: Prisma.PaymentsGetPayload<{ include: { donations: true } }>, + payment: PaymentWithDonation, newDonationStatus: PaymentStatus, paymentData: PaymentData, ) { if (shouldAllowStatusChange(payment.status, newDonationStatus)) { try { - const updatedDonation = await tx.payments.update({ + const updatedDonation = await tx.payment.update({ where: { id: payment.id, }, @@ -708,7 +708,7 @@ export class CampaignService { const targetVaultData = { connect: { id: vault.id } } try { - const donation = await tx.payments.create({ + const donation = await tx.payment.create({ data: { amount: paymentData.netAmount, chargedAmount: paymentData.chargedAmount, @@ -753,7 +753,7 @@ export class CampaignService { private async findExistingDonation(tx: Prisma.TransactionClient, paymentData: PaymentData) { //first try to find by paymentIntentId - let donation = await tx.payments.findUnique({ + let donation = await tx.payment.findUnique({ where: { extPaymentIntentId: paymentData.paymentIntentId }, include: { donations: true }, }) @@ -764,7 +764,7 @@ export class CampaignService { if (!donation && paymentData.personId && paymentData.personId.length === 36) { // search for a subscription donation // for subscriptions, we don't have a paymentIntentId - donation = await tx.payments.findFirst({ + donation = await tx.payment.findFirst({ where: { status: PaymentStatus.initial, chargedAmount: paymentData.chargedAmount, diff --git a/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts b/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts index a745a51cc..b2fc23620 100644 --- a/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts +++ b/apps/api/src/domain/generated/affiliate/entities/affiliate.entity.ts @@ -1,6 +1,6 @@ import { AffiliateStatus } from '@prisma/client' import { Company } from '../../company/entities/company.entity' -import { Payments } from '../../payments/entities/payments.entity' +import { Payment } from '../../payment/entities/payment.entity' export class Affiliate { id: string @@ -10,5 +10,5 @@ export class Affiliate { createdAt: Date updatedAt: Date | null company?: Company - payments?: Payments[] + payments?: Payment[] } diff --git a/apps/api/src/domain/generated/donation/entities/donation.entity.ts b/apps/api/src/domain/generated/donation/entities/donation.entity.ts index 61184302b..6eb35a1a9 100644 --- a/apps/api/src/domain/generated/donation/entities/donation.entity.ts +++ b/apps/api/src/domain/generated/donation/entities/donation.entity.ts @@ -3,7 +3,7 @@ import { Person } from '../../person/entities/person.entity' import { Vault } from '../../vault/entities/vault.entity' import { DonationWish } from '../../donationWish/entities/donationWish.entity' import { DonationMetadata } from '../../donationMetadata/entities/donationMetadata.entity' -import { Payments } from '../../payments/entities/payments.entity' +import { Payment } from '../../payment/entities/payment.entity' export class Donation { id: string @@ -18,5 +18,5 @@ export class Donation { targetVault?: Vault DonationWish?: DonationWish | null metadata?: DonationMetadata | null - payment?: Payments + payment?: Payment } diff --git a/apps/api/src/domain/generated/payment/dto/connect-payment.dto.ts b/apps/api/src/domain/generated/payment/dto/connect-payment.dto.ts new file mode 100644 index 000000000..a4b7fc988 --- /dev/null +++ b/apps/api/src/domain/generated/payment/dto/connect-payment.dto.ts @@ -0,0 +1,4 @@ +export class ConnectPaymentDto { + id?: string + extPaymentIntentId?: string +} diff --git a/apps/api/src/domain/generated/payment/dto/create-payment.dto.ts b/apps/api/src/domain/generated/payment/dto/create-payment.dto.ts new file mode 100644 index 000000000..e7345fdf3 --- /dev/null +++ b/apps/api/src/domain/generated/payment/dto/create-payment.dto.ts @@ -0,0 +1,12 @@ +import { PaymentType } from '@prisma/client' +import { ApiProperty } from '@nestjs/swagger' + +export class CreatePaymentDto { + extCustomerId: string + extPaymentIntentId: string + extPaymentMethodId: string + @ApiProperty({ enum: PaymentType }) + type: PaymentType + billingEmail?: string + billingName?: string +} diff --git a/apps/api/src/domain/generated/payment/dto/index.ts b/apps/api/src/domain/generated/payment/dto/index.ts new file mode 100644 index 000000000..2b04d6e87 --- /dev/null +++ b/apps/api/src/domain/generated/payment/dto/index.ts @@ -0,0 +1,3 @@ +export * from './connect-payment.dto' +export * from './create-payment.dto' +export * from './update-payment.dto' diff --git a/apps/api/src/domain/generated/payment/dto/update-payment.dto.ts b/apps/api/src/domain/generated/payment/dto/update-payment.dto.ts new file mode 100644 index 000000000..4070875cc --- /dev/null +++ b/apps/api/src/domain/generated/payment/dto/update-payment.dto.ts @@ -0,0 +1,12 @@ +import { PaymentType } from '@prisma/client' +import { ApiProperty } from '@nestjs/swagger' + +export class UpdatePaymentDto { + extCustomerId?: string + extPaymentIntentId?: string + extPaymentMethodId?: string + @ApiProperty({ enum: PaymentType }) + type?: PaymentType + billingEmail?: string + billingName?: string +} diff --git a/apps/api/src/domain/generated/payment/entities/index.ts b/apps/api/src/domain/generated/payment/entities/index.ts new file mode 100644 index 000000000..5319a1832 --- /dev/null +++ b/apps/api/src/domain/generated/payment/entities/index.ts @@ -0,0 +1 @@ +export * from './payment.entity' diff --git a/apps/api/src/domain/generated/payment/entities/payment.entity.ts b/apps/api/src/domain/generated/payment/entities/payment.entity.ts new file mode 100644 index 000000000..82b35a137 --- /dev/null +++ b/apps/api/src/domain/generated/payment/entities/payment.entity.ts @@ -0,0 +1,23 @@ +import { PaymentType, Currency, PaymentStatus, PaymentProvider } from '@prisma/client' +import { Affiliate } from '../../affiliate/entities/affiliate.entity' +import { Donation } from '../../donation/entities/donation.entity' + +export class Payment { + id: string + extCustomerId: string + extPaymentIntentId: string + extPaymentMethodId: string + type: PaymentType + currency: Currency + status: PaymentStatus + provider: PaymentProvider + affiliateId: string | null + createdAt: Date + updatedAt: Date | null + chargedAmount: number + amount: number + billingEmail: string | null + billingName: string | null + affiliate?: Affiliate | null + donations?: Donation[] +} diff --git a/apps/api/src/donations/donations.controller.spec.ts b/apps/api/src/donations/donations.controller.spec.ts index 2dcd42e8f..a1e894e35 100644 --- a/apps/api/src/donations/donations.controller.spec.ts +++ b/apps/api/src/donations/donations.controller.spec.ts @@ -11,8 +11,7 @@ import { PaymentProvider, Person, Vault, - Payments, - Prisma, + Payment, } from '@prisma/client' import { CampaignService } from '../campaign/campaign.service' import { ExportService } from '../export/export.service' @@ -26,8 +25,7 @@ import { CreateSessionDto } from './dto/create-session.dto' import { UpdatePaymentDto } from './dto/update-payment.dto' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { MarketingNotificationsModule } from '../notifications/notifications.module' - -type PaymentWithDonation = Prisma.PaymentsGetPayload<{ include: { donations: true } }> +import type { PaymentWithDonation } from './types/donation' describe('DonationsController', () => { let controller: DonationsController @@ -221,14 +219,14 @@ describe('DonationsController', () => { .spyOn(vaultService, 'incrementVaultAmount') .mockImplementation() - prismaMock.payments.findFirst.mockResolvedValueOnce(existingPayment) + prismaMock.payment.findFirst.mockResolvedValueOnce(existingPayment) prismaMock.person.findFirst.mockResolvedValueOnce(existingTargetPerson) // act await controller.update('123', updatePaymentDto) // assert - expect(prismaMock.payments.update).toHaveBeenCalledWith({ + expect(prismaMock.payment.update).toHaveBeenCalledWith({ where: { id: '123' }, data: { status: existingPayment.status, @@ -280,16 +278,16 @@ describe('DonationsController', () => { jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) - prismaMock.payments.findFirst.mockResolvedValueOnce(existingPayment) + prismaMock.payment.findFirst.mockResolvedValueOnce(existingPayment) prismaMock.person.findFirst.mockResolvedValueOnce(existingTargetPerson) - prismaMock.payments.update.mockResolvedValueOnce(expectedUpdatedPayment) + prismaMock.payment.update.mockResolvedValueOnce(expectedUpdatedPayment) prismaMock.vault.update.mockResolvedValueOnce({ id: '1000', campaignId: '111' } as Vault) // act await controller.update('123', updatePaymentDto) // assert - expect(prismaMock.payments.update).toHaveBeenCalledWith({ + expect(prismaMock.payment.update).toHaveBeenCalledWith({ where: { id: '123' }, data: { status: PaymentStatus.succeeded, @@ -329,11 +327,11 @@ describe('DonationsController', () => { const existingPayment = { ...mockPayment, status: PaymentStatus.succeeded } jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) - prismaMock.payments.findFirstOrThrow.mockResolvedValueOnce(existingPayment) + prismaMock.payment.findFirstOrThrow.mockResolvedValueOnce(existingPayment) await controller.invalidate('123') - expect(prismaMock.payments.update).toHaveBeenCalledWith({ + expect(prismaMock.payment.update).toHaveBeenCalledWith({ where: { id: '123' }, data: { status: PaymentStatus.invalid, diff --git a/apps/api/src/donations/donations.controller.ts b/apps/api/src/donations/donations.controller.ts index c17297292..4c099531b 100644 --- a/apps/api/src/donations/donations.controller.ts +++ b/apps/api/src/donations/donations.controller.ts @@ -180,12 +180,14 @@ export class DonationsController { ) { return await this.donationsService.listPayments( query?.paymentId, + query?.campaignId, query?.status, query?.provider, query?.minAmount, query?.maxAmount, query?.from, query?.to, + query?.search, query?.sortBy, query?.sortOrder, query?.pageindex, diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 9f6bf5074..348090806 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -9,7 +9,7 @@ import { PaymentProvider, Prisma, PaymentType, - Payments, + Payment, Donation, } from '@prisma/client' import { Response } from 'express' @@ -25,17 +25,14 @@ import { CreateSessionDto } from './dto/create-session.dto' import { UpdatePaymentDto } from './dto/update-payment.dto' import { DonationBaseDto, ListDonationsDto } from './dto/list-donations.dto' -import { - donationWithPerson, - DonationWithPerson, - PaymentWithDonationCount, -} from './queries/donation.validator' +import { donationWithPerson } from './queries/donation.validator' import { CreateStripePaymentDto } from './dto/create-stripe-payment.dto' import { ImportStatus } from '../bank-transactions-file/dto/bank-transactions-import-status.dto' import { DonationQueryDto } from '../common/dto/donation-query-dto' import { CreateAffiliateDonationDto } from '../affiliate/dto/create-affiliate-donation.dto' import { VaultUpdate } from '../vault/types/vault' import { PaymentWithDonation } from './types/donation' +import type { DonationWithPersonAndVault, PaymentWithDonationCount } from './types/donation' @Injectable() export class DonationsService { @@ -99,7 +96,7 @@ export class DonationsService { campaign: Campaign, stripePaymentDto: CreateStripePaymentDto, paymentIntent: Stripe.PaymentIntent, - ): Promise { + ): Promise { Logger.debug('[ CreateInitialDonationFromIntent]', { campaignId: campaign.id, amount: paymentIntent.amount, @@ -112,7 +109,7 @@ export class DonationsService { /** * Create or update initial donation object */ - const donation = await this.prisma.payments.upsert({ + const donation = await this.prisma.payment.upsert({ where: { extPaymentIntentId: paymentIntent.id }, create: { amount: 0, @@ -327,7 +324,7 @@ export class DonationsService { if (!vault || vault.length === 0) throw new NotFoundException('Campaign or vault not found') - const payment = await this.prisma.payments.create({ + const payment = await this.prisma.payment.create({ data: donationDto.toEntity(vault[0].id), include: { donations: true }, }) @@ -377,7 +374,7 @@ export class DonationsService { sortOrder?: string, pageIndex?: number, pageSize?: number, - ): Promise> { + ): Promise> { const whereClause = Prisma.validator()({ amount: { gte: minAmount, @@ -420,19 +417,21 @@ export class DonationsService { async listPayments( paymentId?: string, + campaignId?: string, paymentStatus?: PaymentStatus, paymentProvider?: PaymentProvider, minAmount?: number, maxAmount?: number, from?: Date, to?: Date, + search?: string, sortBy?: string, sortOrder?: string, pageIndex?: number, pageSize?: number, ): Promise> { - const whereClause = Prisma.validator()({ - id: paymentId, + const whereClause = Prisma.validator()({ + // id: paymentId, amount: { gte: minAmount, lte: maxAmount, @@ -443,10 +442,18 @@ export class DonationsService { }, status: paymentStatus, provider: paymentProvider, + ...(search && { + OR: [ + { + billingEmail: { contains: search }, + }, + { billingName: { contains: search } }, + ], + }), + donations: { some: { targetVault: { campaignId } } }, }) - const [data, count] = await this.prisma.$transaction([ - this.prisma.payments.findMany({ + this.prisma.payment.findMany({ where: whereClause, orderBy: [sortBy ? { [sortBy]: sortOrder ? sortOrder : 'desc' } : { createdAt: 'desc' }], skip: pageIndex && pageSize ? pageIndex * pageSize : undefined, @@ -459,7 +466,7 @@ export class DonationsService { }, }, }), - this.prisma.payments.count({ where: whereClause }), + this.prisma.payment.count({ where: whereClause }), ]) const result = { @@ -478,7 +485,7 @@ export class DonationsService { */ async getDonationById(id: string): Promise { try { - const donation = await this.prisma.payments.findFirstOrThrow({ + const donation = await this.prisma.payment.findFirstOrThrow({ where: { id }, include: { donations: true }, }) @@ -492,7 +499,7 @@ export class DonationsService { async getAffiliateDonationById(donationId: string, affiliateCode: string) { try { - const donation = await this.prisma.payments.findFirstOrThrow({ + const donation = await this.prisma.payment.findFirstOrThrow({ where: { id: donationId, affiliate: { affiliateCode: affiliateCode } }, }) return donation @@ -554,7 +561,7 @@ export class DonationsService { * @param inputDto Payment intent create params * @returns {Promise>} */ - async createStripePayment(inputDto: CreateStripePaymentDto): Promise { + async createStripePayment(inputDto: CreateStripePaymentDto): Promise { const intent = await this.stripeClient.paymentIntents.retrieve(inputDto.paymentIntentId) if (!intent.metadata.campaignId) { throw new BadRequestException('Campaign id is missing from payment intent metadata') @@ -615,12 +622,12 @@ export class DonationsService { async createUpdateBankPayment(donationDto: CreateBankPaymentDto): Promise { return await this.prisma.$transaction(async (tx) => { //to avoid incrementing vault amount twice we first check if there is such donation - const existingDonation = await tx.payments.findUnique({ + const existingDonation = await tx.payment.findUnique({ where: { extPaymentIntentId: donationDto.extPaymentIntentId }, }) if (!existingDonation) { - const payment = await tx.payments.create({ + const payment = await tx.payment.create({ data: donationDto, include: { donations: true, @@ -635,7 +642,7 @@ export class DonationsService { return ImportStatus.SUCCESS } - await this.prisma.payments.update({ + await this.prisma.payment.update({ where: { extPaymentIntentId: donationDto.extPaymentIntentId }, data: { ...donationDto, updatedAt: existingDonation.updatedAt }, }) @@ -647,7 +654,7 @@ export class DonationsService { return await this.prisma.$transaction(async (tx) => { await Promise.all([ this.vaultService.IncrementManyVaults(listOfVaults, tx), - tx.payments.updateMany({ + tx.payment.updateMany({ where: { id: { in: paymentsIds } }, data: { status: PaymentStatus.succeeded }, }), @@ -656,7 +663,7 @@ export class DonationsService { } async updateAffiliateDonations(donationId: string, affiliateId: string, status: PaymentStatus) { - const donation = await this.prisma.payments.update({ + const donation = await this.prisma.payment.update({ where: { id: donationId, affiliateId: affiliateId, @@ -673,12 +680,11 @@ export class DonationsService { * @param updatePaymentDto * @returns */ - async update(id: string, updatePaymentDto: UpdatePaymentDto): Promise { + async update(id: string, updatePaymentDto: UpdatePaymentDto): Promise { try { // execute the below in prisma transaction - console.log(updatePaymentDto.targetPersonId) return await this.prisma.$transaction(async (tx) => { - const currentDonation = await tx.payments.findFirst({ + const currentDonation = await tx.payment.findFirst({ where: { id }, include: { donations: { @@ -723,7 +729,7 @@ export class DonationsService { billingEmail = targetDonor.email } - const donation = await tx.payments.update({ + const donation = await tx.payment.update({ where: { id }, data: { status: status, @@ -765,7 +771,7 @@ export class DonationsService { async softDelete(ids: string[]): Promise { try { - return await this.prisma.payments.updateMany({ + return await this.prisma.payment.updateMany({ where: { id: { in: ids } }, data: { status: PaymentStatus.deleted, @@ -792,7 +798,7 @@ export class DonationsService { ) } - await tx.payments.update({ + await tx.payment.update({ where: { id }, data: { status: PaymentStatus.invalid, @@ -857,7 +863,7 @@ export class DonationsService { } async getTotalDonatedMoney() { - const totalMoney = await this.prisma.payments.aggregate({ + const totalMoney = await this.prisma.payment.aggregate({ _sum: { amount: true, }, diff --git a/apps/api/src/donations/dto/create-payment.dto.ts b/apps/api/src/donations/dto/create-payment.dto.ts index 214097eea..4279fb28c 100644 --- a/apps/api/src/donations/dto/create-payment.dto.ts +++ b/apps/api/src/donations/dto/create-payment.dto.ts @@ -54,7 +54,7 @@ export class CreatePaymentDto { @IsUUID() targetVaultId: string - public toEntity(user): Prisma.PaymentsCreateInput { + public toEntity(user): Prisma.PaymentCreateInput { return { type: this.type, status: this.status, diff --git a/apps/api/src/donations/events/stripe-payment.service.spec.ts b/apps/api/src/donations/events/stripe-payment.service.spec.ts index 515ae9f03..499f82bc6 100644 --- a/apps/api/src/donations/events/stripe-payment.service.spec.ts +++ b/apps/api/src/donations/events/stripe-payment.service.spec.ts @@ -14,10 +14,9 @@ import { StripeModule, StripeModuleConfig, StripePayloadService } from '@golevel import { Campaign, CampaignState, - Donation, DonationType, PaymentType, - Payments, + Payment, Prisma, RecurringDonationStatus, Vault, @@ -52,6 +51,7 @@ import { SendGridNotificationsProvider } from '../../notifications/providers/not import { MarketingNotificationsService } from '../../notifications/notifications.service' import { EmailService } from '../../email/email.service' import { TemplateService } from '../../email/template.service' +import type { PaymentWithDonation } from '../types/donation' const defaultStripeWebhookEndpoint = '/stripe/webhook' const stripeSecret = 'wh_123' @@ -78,7 +78,7 @@ describe('StripePaymentService', () => { }), } - const mockPayment: Prisma.PaymentsGetPayload<{ include: { donations: true } }> = { + const mockPayment: PaymentWithDonation = { id: 'test-donation-id', type: PaymentType.single, status: PaymentStatus.waiting, @@ -304,9 +304,9 @@ describe('StripePaymentService', () => { .mockName('createDonationWish') .mockImplementation(() => Promise.resolve()) - prismaMock.payments.findUnique.mockResolvedValue(mockPayment) + prismaMock.payment.findUnique.mockResolvedValue(mockPayment) - prismaMock.payments.update.mockResolvedValue({ + prismaMock.payment.update.mockResolvedValue({ ...mockPayment, amount: paymentData.netAmount, status: PaymentStatus.succeeded, @@ -342,10 +342,10 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedUpdateDonationPayment).toHaveBeenCalled() - expect(prismaMock.payments.findUnique).toHaveBeenCalled() - expect(prismaMock.payments.create).not.toHaveBeenCalled() + expect(prismaMock.payment.findUnique).toHaveBeenCalled() + expect(prismaMock.payment.create).not.toHaveBeenCalled() expect(mockedIncrementVaultAmount).toHaveBeenCalled() - expect(prismaMock.payments.update).toHaveBeenCalledTimes(1) + expect(prismaMock.payment.update).toHaveBeenCalledTimes(1) expect(mockedUpdateCampaignStatusIfTargetReached).toHaveBeenCalled() expect(prismaMock.campaign.update).toHaveBeenCalledWith({ where: { @@ -386,9 +386,9 @@ describe('StripePaymentService', () => { .mockName('createDonationWish') .mockImplementation(() => Promise.resolve()) - prismaMock.payments.findUnique.mockResolvedValue(mockPayment) + prismaMock.payment.findUnique.mockResolvedValue(mockPayment) - prismaMock.payments.update.mockResolvedValue({ + prismaMock.payment.update.mockResolvedValue({ ...mockPayment, amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, status: 'succeeded', @@ -412,9 +412,9 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedUpdateDonationPayment).toHaveBeenCalled() - expect(prismaMock.payments.findUnique).toHaveBeenCalled() - expect(prismaMock.payments.create).not.toHaveBeenCalled() - expect(prismaMock.payments.update).toHaveBeenCalledOnce() //for the donation to succeeded + expect(prismaMock.payment.findUnique).toHaveBeenCalled() + expect(prismaMock.payment.create).not.toHaveBeenCalled() + expect(prismaMock.payment.update).toHaveBeenCalledOnce() //for the donation to succeeded expect(mockedIncrementVaultAmount).toHaveBeenCalled() expect(mockedcreateDonationWish).toHaveBeenCalled() }) @@ -439,10 +439,10 @@ describe('StripePaymentService', () => { mockChargeEventSucceeded.data.object as Stripe.Charge, ) - const succeededPayment: Payments = { ...mockPayment, status: PaymentStatus.succeeded } - prismaMock.payments.findUnique.mockResolvedValue(succeededPayment) + const succeededPayment: Payment = { ...mockPayment, status: PaymentStatus.succeeded } + prismaMock.payment.findUnique.mockResolvedValue(succeededPayment) - prismaMock.payments.update.mockResolvedValue({ + prismaMock.payment.update.mockResolvedValue({ ...mockPayment, status: PaymentStatus.refund, }) @@ -472,10 +472,10 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedUpdateDonationPayment).toHaveBeenCalled() - expect(prismaMock.payments.findUnique).toHaveBeenCalled() - expect(prismaMock.payments.create).not.toHaveBeenCalled() + expect(prismaMock.payment.findUnique).toHaveBeenCalled() + expect(prismaMock.payment.create).not.toHaveBeenCalled() expect(mockDecremementVaultAmount).toHaveBeenCalled() - expect(prismaMock.payments.update).toHaveBeenCalled() + expect(prismaMock.payment.update).toHaveBeenCalled() }) }) @@ -574,13 +574,13 @@ describe('StripePaymentService', () => { .spyOn(campaignService, 'updateDonationPayment') .mockName('updateDonationPayment') - prismaMock.payments.findFirst.mockResolvedValue({ + prismaMock.payment.findFirst.mockResolvedValue({ ...mockPayment, amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, status: PaymentStatus.initial, }) - prismaMock.payments.update.mockResolvedValue({ + prismaMock.payment.update.mockResolvedValue({ ...mockPayment, amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, status: PaymentStatus.initial, diff --git a/apps/api/src/donations/queries/donation.validator.ts b/apps/api/src/donations/queries/donation.validator.ts index 83b62b0e5..292984047 100644 --- a/apps/api/src/donations/queries/donation.validator.ts +++ b/apps/api/src/donations/queries/donation.validator.ts @@ -22,14 +22,3 @@ export const donationWithPerson = Prisma.validator( metadata: true, }, }) - -export type DonationWithPerson = Prisma.DonationGetPayload -export type PaymentWithDonationCount = Prisma.PaymentsGetPayload<{ - include: { - _count: { - select: { - donations: true - } - } - } -}> diff --git a/apps/api/src/donations/types/donation.ts b/apps/api/src/donations/types/donation.ts index 9c2b75ca6..84b632dc1 100644 --- a/apps/api/src/donations/types/donation.ts +++ b/apps/api/src/donations/types/donation.ts @@ -1,4 +1,15 @@ import { Prisma } from '@prisma/client' +import { donationWithPerson } from '../queries/donation.validator' -export type PaymentWithDonation = Prisma.PaymentsGetPayload<{ include: { donations: true } }> +export type PaymentWithDonation = Prisma.PaymentGetPayload<{ include: { donations: true } }> export type DonationWithPerson = Prisma.DonationGetPayload<{ include: { person: true } }> +export type DonationWithPersonAndVault = Prisma.DonationGetPayload +export type PaymentWithDonationCount = Prisma.PaymentGetPayload<{ + include: { + _count: { + select: { + donations: true + } + } + } +}> diff --git a/apps/api/src/sockets/notifications/notification.service.ts b/apps/api/src/sockets/notifications/notification.service.ts index 2c6628a61..52feb357c 100644 --- a/apps/api/src/sockets/notifications/notification.service.ts +++ b/apps/api/src/sockets/notifications/notification.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common' import { NotificationGateway } from './gateway' import { Prisma } from '@prisma/client' -export const donationNotificationSelect = Prisma.validator()({ +export const donationNotificationSelect = Prisma.validator()({ id: true, status: true, currency: true, diff --git a/schema.prisma b/schema.prisma index 1a086e635..73826c6d0 100644 --- a/schema.prisma +++ b/schema.prisma @@ -107,7 +107,7 @@ model Affiliate { createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) company Company @relation(fields: [companyId], references: [id]) - payments Payments[] + payments Payment[] @@map("affiliates") } @@ -448,7 +448,7 @@ model Vault { @@map("vaults") } -model Payments { +model Payment { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid extCustomerId String @map("ext_customer_id") @db.VarChar(50) extPaymentIntentId String @unique @map("ext_payment_intent_id") @@ -485,7 +485,7 @@ model Donation { targetVault Vault @relation(fields: [targetVaultId], references: [id]) DonationWish DonationWish? metadata DonationMetadata? - payment Payments @relation(fields: [paymentId], references: [id]) + payment Payment @relation(fields: [paymentId], references: [id]) @@map("donations") } From ce454660fecec2e8efff281c076b4dff4e271a62 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Mon, 4 Mar 2024 11:43:43 +0200 Subject: [PATCH 06/10] Infer person.email as string in campaign.controller test cases Fixes TS related errors --- apps/api/src/campaign/campaign.controller.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/api/src/campaign/campaign.controller.spec.ts b/apps/api/src/campaign/campaign.controller.spec.ts index ffe30946e..2fb786fad 100644 --- a/apps/api/src/campaign/campaign.controller.spec.ts +++ b/apps/api/src/campaign/campaign.controller.spec.ts @@ -412,7 +412,7 @@ describe('CampaignController', () => { describe('subscribeToCampaignNotifications', () => { it('should throw if no consent is provided', async () => { const data: CampaignSubscribeDto = { - email: person.email!, + email: person.email as string, consent: false, } @@ -423,7 +423,7 @@ describe('CampaignController', () => { it('should throw if the campaign is not active', async () => { const data: CampaignSubscribeDto = { - email: person.email!, + email: person.email as string, consent: true, } @@ -442,7 +442,7 @@ describe('CampaignController', () => { jest.spyOn(marketingService, 'sendConfirmEmail') const data: CampaignSubscribeDto = { - email: person.email!, + email: person.email as string, // Valid consent consent: true, } @@ -481,7 +481,7 @@ describe('CampaignController', () => { jest.spyOn(campaignService, 'createCampaignNotificationList') const data: CampaignSubscribeDto = { - email: person.email!, + email: person.email as string, // Valid consent consent: true, } @@ -522,7 +522,7 @@ describe('CampaignController', () => { jest.spyOn(marketingService, 'sendConfirmEmail') const data: CampaignSubscribeDto = { - email: person.email!, + email: person.email as string, // Valid consent consent: true, } @@ -561,7 +561,7 @@ describe('CampaignController', () => { }) const data: CampaignSubscribeDto = { - email: person.email!, + email: person.email as string, // Valid consent consent: true, } From db91e4fc933aca7cf92b6295d87ea1963f270b09 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Mon, 4 Mar 2024 13:06:31 +0200 Subject: [PATCH 07/10] src/bank-transactions: Check for admin privilleges regardless of env when simulating IRIS transactions --- apps/api/src/bank-transactions/bank-transactions.controller.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/api/src/bank-transactions/bank-transactions.controller.ts b/apps/api/src/bank-transactions/bank-transactions.controller.ts index 90525c1a7..3355d8e7e 100644 --- a/apps/api/src/bank-transactions/bank-transactions.controller.ts +++ b/apps/api/src/bank-transactions/bank-transactions.controller.ts @@ -169,8 +169,7 @@ export class BankTransactionsController { const isDev = appEnv === 'development' || appEnv === 'staging' if (!isDev) throw new ForbiddenException('Endpoint available only for testing enviroments') - if (appEnv === 'staging' && !isAdmin(user)) - throw new ForbiddenException('Must be either an admin or active affiliate') + if (!isAdmin(user)) throw new ForbiddenException('Must be an admin') return await this.bankTransactionsService.simulateIrisTask( irisDto.irisIbanAccountInfo, From 0a7189d62f2557a9127897ba9bf84e303e2aceaf Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Mon, 4 Mar 2024 13:10:52 +0200 Subject: [PATCH 08/10] donations.service: Rename currentDonation to currentPayment in update fn --- apps/api/src/donations/donations.service.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/api/src/donations/donations.service.ts b/apps/api/src/donations/donations.service.ts index 348090806..44085b09b 100644 --- a/apps/api/src/donations/donations.service.ts +++ b/apps/api/src/donations/donations.service.ts @@ -684,7 +684,7 @@ export class DonationsService { try { // execute the below in prisma transaction return await this.prisma.$transaction(async (tx) => { - const currentDonation = await tx.payment.findFirst({ + const currentPayment = await tx.payment.findFirst({ where: { id }, include: { donations: { @@ -692,24 +692,24 @@ export class DonationsService { }, }, }) - if (!currentDonation) { + if (!currentPayment) { throw new NotFoundException(`Update failed. No donation found with ID: ${id}`) } if ( - currentDonation.status === PaymentStatus.succeeded && + currentPayment.status === PaymentStatus.succeeded && updatePaymentDto.status && updatePaymentDto.status !== PaymentStatus.succeeded ) { throw new BadRequestException('Succeeded donations cannot be updated.') } - const status = updatePaymentDto.status || currentDonation.status - let donorId = currentDonation.donations[0].personId + const status = updatePaymentDto.status || currentPayment.status + let donorId = currentPayment.donations[0].personId let billingEmail: string | null = '' if ( (updatePaymentDto.targetPersonId && - currentDonation.donations[0].personId !== updatePaymentDto.targetPersonId) || + currentPayment.donations[0].personId !== updatePaymentDto.targetPersonId) || updatePaymentDto.billingEmail ) { const targetDonor = await tx.person.findFirst({ @@ -745,19 +745,19 @@ export class DonationsService { //In case of personId or billingEmail change, take the last updatedAt property to prevent any changes to updatedAt property updatedAt: updatePaymentDto.targetPersonId || updatePaymentDto.billingEmail - ? currentDonation.updatedAt + ? currentPayment.updatedAt : undefined, }, }) if ( - currentDonation.status !== PaymentStatus.succeeded && + currentPayment.status !== PaymentStatus.succeeded && updatePaymentDto.status === PaymentStatus.succeeded && donation.status === PaymentStatus.succeeded ) { await this.vaultService.incrementVaultAmount( - currentDonation.donations[0].targetVaultId, - currentDonation.amount, + currentPayment.donations[0].targetVaultId, + currentPayment.amount, tx, ) } From b868ad175456a20df684257a2ea41fb589a4199c Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sun, 10 Mar 2024 04:07:14 +0200 Subject: [PATCH 09/10] db/seed: Adapt seeding scripts to new structure --- db/seed/donations/factory.ts | 16 ++++ db/seed/index.ts | 4 +- db/seed/{donation => payment}/factory.ts | 11 +-- db/seed/{donation => payment}/seed.ts | 107 +++++++++++++---------- 4 files changed, 85 insertions(+), 53 deletions(-) create mode 100644 db/seed/donations/factory.ts rename db/seed/{donation => payment}/factory.ts (60%) rename db/seed/{donation => payment}/seed.ts (58%) diff --git a/db/seed/donations/factory.ts b/db/seed/donations/factory.ts new file mode 100644 index 000000000..52a094608 --- /dev/null +++ b/db/seed/donations/factory.ts @@ -0,0 +1,16 @@ +import { Factory } from 'fishery' +import { faker } from '@faker-js/faker' + +import { Donation } from '.prisma/client' +import { DonationType } from '@prisma/client' + +export const donationFactory = Factory.define(({ associations }) => ({ + id: faker.datatype.uuid(), + paymentId: associations.paymentId || faker.datatype.uuid(), + type: faker.helpers.arrayElement(Object.values(DonationType)), + targetVaultId: associations.targetVaultId || faker.datatype.uuid(), + personId: associations.personId || null, + amount: parseInt(faker.finance.amount(2000, 20000)), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), +})) diff --git a/db/seed/index.ts b/db/seed/index.ts index 7d8dab554..0f4f56fb3 100644 --- a/db/seed/index.ts +++ b/db/seed/index.ts @@ -13,7 +13,7 @@ import { campaignTypesSeed } from './campaignType/seed' import { bankAccountSeed } from './bankAccount/seed' import { vaultSeed } from './vault/seed' import { expenseSeed } from './expense/seed' -import { donationsSeed } from './donation/seed' +import { paymentsSeed } from './payment/seed' import { companySeed } from './company/seed' import { donationsWishesSeed } from './donationWish/seed' import { campaignNewsSeed } from './campaignNews/seed' @@ -53,7 +53,7 @@ async function seedDevData() { await bankAccountSeed() await vaultSeed() await expenseSeed() - await donationsSeed() + await paymentsSeed() await donationsWishesSeed() await campaignNewsSeed() } diff --git a/db/seed/donation/factory.ts b/db/seed/payment/factory.ts similarity index 60% rename from db/seed/donation/factory.ts rename to db/seed/payment/factory.ts index d0f40799a..c80eb8929 100644 --- a/db/seed/donation/factory.ts +++ b/db/seed/payment/factory.ts @@ -1,21 +1,18 @@ import { Factory } from 'fishery' import { faker } from '@faker-js/faker' -import { Donation } from '.prisma/client' -import { Currency, DonationStatus, DonationType, PaymentProvider } from '@prisma/client' +import { Currency, PaymentStatus, PaymentProvider, PaymentType, Payment } from '@prisma/client' -export const donationFactory = Factory.define(({ associations }) => ({ +export const paymentFactory = Factory.define(({ associations }) => ({ id: faker.datatype.uuid(), affiliateId: null, - type: faker.helpers.arrayElement(Object.values(DonationType)), - status: faker.helpers.arrayElement(Object.values(DonationStatus)), + type: faker.helpers.arrayElement(Object.values(PaymentType)), + status: faker.helpers.arrayElement(Object.values(PaymentStatus)), provider: faker.helpers.arrayElement(Object.values(PaymentProvider)), - targetVaultId: associations.targetVaultId || faker.datatype.uuid(), extCustomerId: 'cus_' + faker.random.alphaNumeric(8), extPaymentIntentId: 'pi_' + faker.random.alphaNumeric(8), extPaymentMethodId: 'pm_' + faker.random.alphaNumeric(8), currency: Currency.BGN, - personId: associations.personId || null, billingEmail: faker.internet.email(), billingName: faker.name.fullName(), amount: parseInt(faker.finance.amount(2000, 20000)), diff --git a/db/seed/donation/seed.ts b/db/seed/payment/seed.ts similarity index 58% rename from db/seed/donation/seed.ts rename to db/seed/payment/seed.ts index 6c0736984..59a5810eb 100644 --- a/db/seed/donation/seed.ts +++ b/db/seed/payment/seed.ts @@ -1,21 +1,24 @@ import { PrismaClient, PaymentProvider, - DonationStatus, - DonationType, + PaymentStatus, Person, CampaignState, + PaymentType, + DonationType, } from '@prisma/client' -import { donationFactory } from './factory' +import { paymentFactory } from './factory' +import { faker } from '@faker-js/faker' +import { donationFactory } from '../donations/factory' const prisma = new PrismaClient() const SEED_COMPLETED_CAMPAIGN_DONATIONS = 5 const SEED_HEAVILY_FUNDED_CAMPAIGN_DONATIONS = 25 -export async function donationsSeed() { - console.log('Donations seed') +export async function paymentsSeed() { + console.log('Payments seed') const person = await prisma.person.findFirst() if (!person) { @@ -36,7 +39,6 @@ async function seedRandomDonations({ person }: SeedData) { if (!vault) { throw new Error('There are no vaults created yet!') } - const donationFactoryOptions = { associations: { personId: person.id, @@ -44,38 +46,40 @@ async function seedRandomDonations({ person }: SeedData) { }, } + const randomPaymentsData = [ + paymentFactory.build({ + type: PaymentType.single, + provider: PaymentProvider.stripe, + status: PaymentStatus.succeeded, + }), + paymentFactory.build({ + type: PaymentType.single, + provider: PaymentProvider.stripe, + status: PaymentStatus.declined, + }), + paymentFactory.build({ + type: PaymentType.single, + provider: PaymentProvider.bank, + status: PaymentStatus.initial, + }), + ] + const randomDonationsData = [ - donationFactory.build( - { - type: DonationType.donation, - provider: PaymentProvider.stripe, - status: DonationStatus.succeeded, - }, - donationFactoryOptions, - ), - donationFactory.build( - { - type: DonationType.donation, - provider: PaymentProvider.stripe, - status: DonationStatus.declined, - }, - donationFactoryOptions, - ), - donationFactory.build( - { - type: DonationType.donation, - provider: PaymentProvider.bank, - status: DonationStatus.initial, - }, - donationFactoryOptions, - ), + donationFactory.build({ paymentId: randomPaymentsData[0].id }, donationFactoryOptions), + donationFactory.build({ paymentId: randomPaymentsData[1].id }, donationFactoryOptions), + donationFactory.build({ paymentId: randomPaymentsData[2].id }, donationFactoryOptions), ] + const insertRandomPayments = await prisma.payment.createMany({ + data: randomPaymentsData, + skipDuplicates: true, + }) const insertRandomDonations = await prisma.donation.createMany({ data: randomDonationsData, skipDuplicates: true, }) + console.log({ insertRandomPayments }) console.log({ insertRandomDonations }) console.log('{ Updating first campaign vault: %s }', vault.id) @@ -102,21 +106,30 @@ async function seedDonationsForCompletedCampaign({ person }: SeedData) { }, } - const completedCampaignDonationsData = donationFactory.buildList( + const completedCampaignPaymentsData = paymentFactory.buildList( SEED_COMPLETED_CAMPAIGN_DONATIONS, { - type: DonationType.donation, + type: PaymentType.single, provider: PaymentProvider.stripe, - status: DonationStatus.succeeded, + status: PaymentStatus.succeeded, }, - donationFactoryOptions, ) + const completedCampaignDonationData = completedCampaignPaymentsData.map((payment) => + donationFactory.build({ paymentId: payment.id }, donationFactoryOptions), + ) + + const insertCompletedCampaignPayments = await prisma.payment.createMany({ + data: completedCampaignPaymentsData, + skipDuplicates: true, + }) + const insertCompletedCampaignDonations = await prisma.donation.createMany({ - data: completedCampaignDonationsData, + data: completedCampaignDonationData, skipDuplicates: true, }) + console.log({ insertCompletedCampaignPayments }) console.log({ insertCompletedCampaignDonations }) console.log('{ Updating completed campaign vault: %s }', completedCampaignVault.id) @@ -145,21 +158,27 @@ async function seedDonationsForHeavilyFundedCampaign({ person }: SeedData) { }, } - const heavilyFundedCampaignDonationsData = donationFactory.buildList( + const heavilyFundedCampaignPaymentData = paymentFactory.buildList( SEED_HEAVILY_FUNDED_CAMPAIGN_DONATIONS, { - type: DonationType.donation, + type: PaymentType.single, provider: PaymentProvider.stripe, - status: DonationStatus.succeeded, + status: PaymentStatus.succeeded, }, - donationFactoryOptions, + ) + const heavilyFundedCampaignDonationData = heavilyFundedCampaignPaymentData.map((payment) => + donationFactory.build({ paymentId: payment.id }, donationFactoryOptions), ) + const insertHeavilyFundedCampaignPayments = await prisma.payment.createMany({ + data: heavilyFundedCampaignPaymentData, + skipDuplicates: true, + }) const insertHeavilyFundedCampaignDonations = await prisma.donation.createMany({ - data: heavilyFundedCampaignDonationsData, + data: heavilyFundedCampaignDonationData, skipDuplicates: true, }) - + console.log({ insertHeavilyFundedCampaignPayments }) console.log({ insertHeavilyFundedCampaignDonations }) console.log('{ Updating heavily-funded campaign vault: %s }', heavilyFundedCampaignVault.id) @@ -172,13 +191,13 @@ async function seedDonationsForHeavilyFundedCampaign({ person }: SeedData) { * @param vaultId */ async function updateVault(vaultId: string) { - const totalDonationsAmount = await prisma.donation.aggregate({ + const totalDonationsAmount = await prisma.payment.aggregate({ _sum: { amount: true, }, where: { - targetVaultId: vaultId, - status: DonationStatus.succeeded, + donations: { some: { targetVaultId: vaultId } }, + status: PaymentStatus.succeeded, }, }) From 8f5558f5c871d0961854c40f3be887d33fac3020 Mon Sep 17 00:00:00 2001 From: Alexander Petkov Date: Sun, 10 Mar 2024 04:23:35 +0200 Subject: [PATCH 10/10] payment/seed: Use payment's createdAt updatedAt values for donation In production, the insertion is done within a single transaction, thus the dates should be the same --- db/seed/payment/seed.ts | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/db/seed/payment/seed.ts b/db/seed/payment/seed.ts index 59a5810eb..978a707c1 100644 --- a/db/seed/payment/seed.ts +++ b/db/seed/payment/seed.ts @@ -65,9 +65,30 @@ async function seedRandomDonations({ person }: SeedData) { ] const randomDonationsData = [ - donationFactory.build({ paymentId: randomPaymentsData[0].id }, donationFactoryOptions), - donationFactory.build({ paymentId: randomPaymentsData[1].id }, donationFactoryOptions), - donationFactory.build({ paymentId: randomPaymentsData[2].id }, donationFactoryOptions), + donationFactory.build( + { + paymentId: randomPaymentsData[0].id, + createdAt: randomPaymentsData[0].createdAt, + updatedAt: randomPaymentsData[0].updatedAt, + }, + donationFactoryOptions, + ), + donationFactory.build( + { + paymentId: randomPaymentsData[1].id, + createdAt: randomPaymentsData[1].createdAt, + updatedAt: randomPaymentsData[1].updatedAt, + }, + donationFactoryOptions, + ), + donationFactory.build( + { + paymentId: randomPaymentsData[2].id, + createdAt: randomPaymentsData[2].createdAt, + updatedAt: randomPaymentsData[2].updatedAt, + }, + donationFactoryOptions, + ), ] const insertRandomPayments = await prisma.payment.createMany({ data: randomPaymentsData, @@ -116,7 +137,10 @@ async function seedDonationsForCompletedCampaign({ person }: SeedData) { ) const completedCampaignDonationData = completedCampaignPaymentsData.map((payment) => - donationFactory.build({ paymentId: payment.id }, donationFactoryOptions), + donationFactory.build( + { paymentId: payment.id, updatedAt: payment.updatedAt, createdAt: payment.createdAt }, + donationFactoryOptions, + ), ) const insertCompletedCampaignPayments = await prisma.payment.createMany({ @@ -167,7 +191,10 @@ async function seedDonationsForHeavilyFundedCampaign({ person }: SeedData) { }, ) const heavilyFundedCampaignDonationData = heavilyFundedCampaignPaymentData.map((payment) => - donationFactory.build({ paymentId: payment.id }, donationFactoryOptions), + donationFactory.build( + { paymentId: payment.id, createdAt: payment.createdAt, updatedAt: payment.updatedAt }, + donationFactoryOptions, + ), ) const insertHeavilyFundedCampaignPayments = await prisma.payment.createMany({