Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { AddEmailSubscribers1778800000002 } from './migrations/1778800000002-add
import { AddGoals1780531200000 } from './migrations/1780531200000-add_goals';
import { UserRefactoringId1780931163251 } from './migrations/1780931163251-user-refactoring-id';
import { RenameGoalColumns1781161660000 } from './migrations/1781161660-rename-goal-columns';
import { AddStripeSubscriptionFields1781200000000 } from './migrations/1781200000000-add-stripe-subscription-fields';

dotenv.config();

Expand All @@ -35,6 +36,7 @@ const AppDataSource = new DataSource({
AddGoals1780531200000,
UserRefactoringId1780931163251,
RenameGoalColumns1781161660000,
AddStripeSubscriptionFields1781200000000,
],
migrationsRun: true,
synchronize: false,
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/src/donations/donation.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ export class Donation {
@Column({ type: 'int', nullable: true })
feeAmount: number | null;

@Column({ type: 'varchar', nullable: true })
stripeSubscriptionId: string | null;

@Column({ type: 'varchar', nullable: true })
stripeCustomerId: string | null;

@CreateDateColumn({ type: 'timestamp', default: () => 'now()' })
createdAt: Date;

Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/donations/donations.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ describe('DonationsRepository', () => {
status: DonationStatus.SUCCEEDED,
transactionId: 'txn_123456',
feeAmount: null,
stripeSubscriptionId: null,
stripeCustomerId: null,
createdAt: new Date('2024-01-15T10:00:00Z'),
updatedAt: new Date('2024-01-15T10:00:00Z'),
};
Expand Down
134 changes: 134 additions & 0 deletions apps/backend/src/donations/donations.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CreateDonationRequest } from './mappers';
import { DonationResponseDto } from './dtos/donation-response-dto';
import { DonationsRepository } from './donations.repository';
import { Goal } from './goal.entity';
import { EmailsService } from '../emails/emails.service';
// mock donations

// invalid donation: non positive donation amount
Expand Down Expand Up @@ -125,6 +126,8 @@ const validDonation1: Donation = {
updatedAt: new Date(2026, 7, 1),
recurringInterval: null,
feeAmount: null,
stripeSubscriptionId: null,
stripeCustomerId: null,
};

const validDonation2: Donation = {
Expand Down Expand Up @@ -155,6 +158,8 @@ const validDonation2: Donation = {

dedicationMessage: 'I love fcc!',
feeAmount: null,
stripeSubscriptionId: null,
stripeCustomerId: null,
};

const validDonation3: Donation = {
Expand Down Expand Up @@ -185,6 +190,8 @@ const validDonation3: Donation = {

dedicationMessage: 'I love fcc!',
feeAmount: null,
stripeSubscriptionId: null,
stripeCustomerId: null,
};

const allDonations: Donation[] = [
Expand Down Expand Up @@ -242,12 +249,17 @@ describe('DonationsService', () => {
findLapsedDonors: jest.fn(),
};

const mockEmailsService = {
sendDonationResponseEmail: jest.fn(),
};

const app = await Test.createTestingModule({
providers: [
DonationsService,
{ provide: getRepositoryToken(Donation), useValue: repoMock },
{ provide: getRepositoryToken(Goal), useValue: {} },
{ provide: DonationsRepository, useValue: mockDonationsRepository },
{ provide: EmailsService, useValue: mockEmailsService },
],
}).compile();

Expand Down Expand Up @@ -323,6 +335,24 @@ describe('DonationsService', () => {
updatedAt: validDonation1.updatedAt,
});
});

it('persists Stripe subscription and customer ids when provided', async () => {
repo.create.mockReturnValue(validDonation1);
repo.save.mockResolvedValue(validDonation1);

await service.create({
...validCreateDonation1,
stripeSubscriptionId: 'sub_persist',
stripeCustomerId: 'cus_persist',
});

expect(repo.create).toHaveBeenCalledWith(
expect.objectContaining({
stripeSubscriptionId: 'sub_persist',
stripeCustomerId: 'cus_persist',
}),
);
});
});

describe('Find all donations method', () => {
Expand Down Expand Up @@ -443,6 +473,110 @@ describe('DonationsService', () => {
});
});

describe('recordRenewalCharge', () => {
const template = {
...validDonation1,
id: 42,
stripeSubscriptionId: 'sub_renew',
stripeCustomerId: 'cus_renew',
};

beforeEach(() => {
// shared repo mocks persist across the beforeAll-scoped suite; reset call
// history (not implementations) so per-test assertions are isolated
repo.create.mockClear();
repo.save.mockClear();
repo.findOne.mockClear();
});

afterEach(() => {
// restore the shared findOne implementation for other suites
repo.findOne.mockImplementation(
async (options?: FindOneOptions<Donation>) => {
const where = options?.where as
| FindOptionsWhere<Donation>
| undefined;
if (!where) return null;
if (where.id !== undefined && where.id !== null) {
return allDonations.find((d) => d.id === where.id) ?? null;
}
if (where.transactionId) {
return (
allDonations.find(
(d) => d.transactionId === where.transactionId,
) ?? null
);
}
return null;
},
);
});

it('is idempotent: skips when a donation with the transactionId already exists', async () => {
repo.findOne.mockResolvedValueOnce(template); // existing by transactionId
const createSpy = jest.spyOn(repo, 'create');

await service.recordRenewalCharge({
stripeSubscriptionId: 'sub_renew',
transactionId: 'pi_dup',
amount: 1000,
status: DonationStatus.SUCCEEDED,
});

expect(createSpy).not.toHaveBeenCalled();
});

it('warns and does nothing when no template donation exists for the subscription', async () => {
repo.findOne
.mockResolvedValueOnce(null) // no existing by transactionId
.mockResolvedValueOnce(null); // no template by subscription id
const createSpy = jest.spyOn(repo, 'create');

await service.recordRenewalCharge({
stripeSubscriptionId: 'sub_missing',
transactionId: 'pi_new',
amount: 1000,
status: DonationStatus.SUCCEEDED,
});

expect(createSpy).not.toHaveBeenCalled();
});

it('clones the template into a new succeeded row counting toward the goal', async () => {
repo.findOne
.mockResolvedValueOnce(null) // no existing by transactionId
.mockResolvedValueOnce(template); // template by subscription id
const createSpy = jest
.spyOn(repo, 'create')
.mockImplementation((d) => d as Donation);
const saveSpy = jest
.spyOn(repo, 'save')
.mockResolvedValue({ ...template, id: 99 } as Donation);

await service.recordRenewalCharge({
stripeSubscriptionId: 'sub_renew',
transactionId: 'pi_renew_new',
amount: 2500,
status: DonationStatus.SUCCEEDED,
feeAmount: 100,
});

expect(createSpy).toHaveBeenCalledWith(
expect.objectContaining({
email: template.email,
amount: 2500,
status: DonationStatus.SUCCEEDED,
transactionId: 'pi_renew_new',
feeAmount: 100,
stripeSubscriptionId: 'sub_renew',
stripeCustomerId: 'cus_renew',
donationType: DonationType.RECURRING,
}),
);
expect(saveSpy).toHaveBeenCalled();
});
});

describe('exportToCsv', () => {
it('should include all donation data in CSV rows', async () => {
const stream = await service.exportToCsv();
Expand Down
100 changes: 96 additions & 4 deletions apps/backend/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ interface PaymentIntentSyncPayload {
feeAmount?: number;
}

interface RenewalChargePayload {
stripeSubscriptionId: string;
transactionId: string;
amount: number;
status: DonationStatus;
feeAmount?: number;
}

interface DonationStats {
total: number;
count: number;
Expand Down Expand Up @@ -93,6 +101,8 @@ export class DonationsService {
dedicationMessage: createDonationRequest.dedicationMessage || null,
showDedicationPublicly: createDonationRequest.showDedicationPublicly,
transactionId: createDonationRequest.paymentIntentId || null,
stripeSubscriptionId: createDonationRequest.stripeSubscriptionId || null,
stripeCustomerId: createDonationRequest.stripeCustomerId || null,
});

// Reload from database so any DB-side defaults are reflected
Expand Down Expand Up @@ -246,10 +256,10 @@ export class DonationsService {
);

return {
total: Number(donations?.total ?? 0),
total: Number(donations?.total ?? 0) / 100,
count: Number(donations?.count ?? 0),
yearToDate: Number(donations?.yearToDate ?? 0),
monthToDate: Number(donations?.monthToDate ?? 0),
yearToDate: Number(donations?.yearToDate ?? 0) / 100,
monthToDate: Number(donations?.monthToDate ?? 0) / 100,
};
}

Expand Down Expand Up @@ -320,6 +330,88 @@ export class DonationsService {
}
}

/**
* Records a recurring-subscription renewal charge (month 2+) as its own donation
* row so it counts toward the goal (goal progress = SUM of succeeded rows).
*
* Donor details are cloned from the original ("template") donation created when the
* subscription started, located via {@link stripeSubscriptionId}. Idempotent on
* {@link transactionId} so redelivered Stripe webhooks don't double-count.
*/
async recordRenewalCharge(payload: RenewalChargePayload): Promise<void> {
const { stripeSubscriptionId, transactionId, amount, status, feeAmount } =
payload;

if (!stripeSubscriptionId || !transactionId) {
this.logger.warn(
'Unable to record renewal charge without subscription and transaction ids',
);
return;
}

// Idempotency: skip if we've already recorded this charge.
const existing = await this.donationRepository.findOne({
where: { transactionId },
});
if (existing) {
this.logger.debug(
`Renewal charge ${transactionId} already recorded (donation ${existing.id}); skipping`,
);
return;
}

// Find the original donation for this subscription to clone donor details.
const template = await this.donationRepository.findOne({
where: { stripeSubscriptionId },
order: { createdAt: 'ASC' },
});
if (!template) {
this.logger.warn(
`No template donation found for subscription ${stripeSubscriptionId}; cannot record renewal ${transactionId}`,
);
return;
}

const renewal = this.donationRepository.create({
firstName: template.firstName,
lastName: template.lastName,
email: template.email,
amount,
isAnonymous: template.isAnonymous,
donationType: DonationType.RECURRING,
recurringInterval: template.recurringInterval,
dedicationMessage: template.dedicationMessage,
showDedicationPublicly: template.showDedicationPublicly,
status,
transactionId,
feeAmount: feeAmount ?? null,
stripeSubscriptionId,
stripeCustomerId: template.stripeCustomerId,
});

const saved = await this.donationRepository.save(renewal);
this.logger.log(
`Recorded renewal charge ${transactionId} for subscription ${stripeSubscriptionId} as donation ${saved.id} (${status})`,
);

if (status === DonationStatus.SUCCEEDED) {
try {
const donorName = `${saved.firstName} ${saved.lastName}`;
await this.emailsService.sendDonationResponseEmail(
saved.email,
donorName,
saved.amount,
);
} catch (error) {
this.logger.error(
`Failed to send Donation Response email for renewal donation ${saved.id}`,
error,
);
// don't let email failure break the renewal sync
}
}
}

async getLapsedDonors(numMonths = 6): Promise<{ emails: string[] }> {
if (!Number.isFinite(numMonths) || numMonths <= 0) {
throw new BadRequestException('numMonths must be a positive number');
Expand Down Expand Up @@ -428,7 +520,7 @@ export class DonationsService {
})
.getRawOne<{ amount: string }>();

const amountRaised = Number(result?.amount ?? 0);
const amountRaised = Number(result?.amount ?? 0) / 100;

const progressPercent =
goal.targetAmount > 0
Expand Down
19 changes: 19 additions & 0 deletions apps/backend/src/donations/dtos/create-donation-dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,23 @@ export class CreateDonationDto {
@IsString()
@IsOptional()
paymentIntentId?: string;

@ApiProperty({
description:
'optional Stripe subscription id (set for recurring donations to link renewals)',
example: 'sub_1J2aBcD3eF4GhIjKlmnoPqr',
required: false,
})
@IsString()
@IsOptional()
stripeSubscriptionId?: string;

@ApiProperty({
description: 'optional Stripe customer id (set for recurring donations)',
example: 'cus_ABC123',
required: false,
})
@IsString()
@IsOptional()
stripeCustomerId?: string;
}
Loading
Loading