Skip to content
Merged
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
90 changes: 89 additions & 1 deletion __tests__/contributions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createClient } from '@connectrpc/connect';
import { Credits } from '@dailydotdev/schema';
import { DataSource } from 'typeorm';
import createOrGetConnection from '../src/db';
import appFunc from '../src';
Expand All @@ -6,11 +8,14 @@ import {
disposeGraphQLTesting,
initializeGraphQLTesting,
MockContext,
createMockNjordTransport,
saveFixtures,
type GraphQLTestClient,
type GraphQLTestingState,
} from './helpers';
import { User } from '../src/entity/user/User';
import * as njordCommon from '../src/common/njord';
import { SubscriptionCycles } from '../src/paddle';
import { ContributionAction } from '../src/entity/contribution/ContributionAction';
import { ContributionActionCategory } from '../src/entity/contribution/ContributionActionCategory';
import { ContributionBlockedUser } from '../src/entity/contribution/ContributionBlockedUser';
Expand All @@ -29,6 +34,12 @@ import { ContributionRewardType } from '../src/entity/contribution/ContributionR
import { ContributionSponsor } from '../src/entity/contribution/ContributionSponsor';
import { UserContributionCausePreference } from '../src/entity/contribution/UserContributionCausePreference';
import { UserContributionReward } from '../src/entity/contribution/UserContributionReward';
import {
UserTransaction,
UserTransactionProcessor,
UserTransactionStatus,
UserTransactionType,
} from '../src/entity/user/UserTransaction';
import { remoteConfig } from '../src/remoteConfig';

let con: DataSource;
Expand All @@ -48,6 +59,7 @@ const causeId = '33333333-3333-4333-8333-333333333333';
const secondCauseId = '33333333-3333-4333-8333-333333333334';
const sponsorId = '33333333-3333-4333-8333-333333333335';
const tierId = '44444444-4444-4444-8444-444444444444';
const coresTierId = '44444444-4444-4444-8444-444444444445';
const paymentId = '55555555-5555-4555-8555-555555555555';

const CONTRIBUTION_STATUS_QUERY = `
Expand Down Expand Up @@ -209,6 +221,7 @@ mutation ClaimContributionReward($tierId: ID!) {
claimContributionReward(tierId: $tierId) {
status
claimedAt
fulfilledAt
tier {
id
title
Expand Down Expand Up @@ -240,9 +253,13 @@ beforeAll(async () => {
});

beforeEach(async () => {
jest.restoreAllMocks();
loggedUser = userId;
region = 'US';

await con.getRepository(UserTransaction).delete({
referenceType: UserTransactionType.ContributionReward,
});
await con
.createQueryBuilder()
.delete()
Expand Down Expand Up @@ -608,7 +625,7 @@ it('updates cause preferences and claims unlocked reward tiers', async () => {
]);
expect(claimed.errors).toBeUndefined();
expect(claimed.data.claimContributionReward).toMatchObject({
status: 'claimed',
status: 'fulfilled',
tier: {
id: tierId,
title: 'Plus boost',
Expand All @@ -618,6 +635,77 @@ it('updates cause preferences and claims unlocked reward tiers', async () => {
},
});
expect(claimed.data.claimContributionReward.claimedAt).toBeTruthy();
expect(claimed.data.claimContributionReward.fulfilledAt).toBeTruthy();

const user = await con.getRepository(User).findOneByOrFail({ id: userId });
expect(user.subscriptionFlags?.cycle).toEqual(SubscriptionCycles.Monthly);
expect(
new Date(user.subscriptionFlags?.giftExpirationDate ?? 0).getTime(),
).toBeGreaterThan(Date.now());
});

it('fulfills claimed Cores reward tiers through Njord', async () => {
jest
.spyOn(njordCommon, 'getNjordClient')
.mockImplementation(() =>
createClient(Credits, createMockNjordTransport()),
);

await saveFixtures(con, ContributionRewardTier, [
{
id: coresTierId,
title: 'Cores boost',
thresholdPoints: 50,
rewardType: ContributionRewardType.Cores,
metadata: { amount: 25 },
},
]);
await saveFixtures(con, ContributionAction, [
{
id: actionId,
title: 'Referral',
points: 50,
evidence: {},
},
]);
await saveFixtures(con, ContributionSubmission, [
{
userId,
actionId,
status: ContributionSubmissionStatus.Approved,
awardedPoints: 50,
},
]);

const claimed = await client.mutate(CLAIM_CONTRIBUTION_REWARD_MUTATION, {
variables: { tierId: coresTierId },
});

expect(claimed.errors).toBeUndefined();
expect(claimed.data.claimContributionReward).toMatchObject({
status: 'fulfilled',
tier: {
id: coresTierId,
title: 'Cores boost',
thresholdPoints: 50,
rewardType: 'cores',
metadata: { amount: 25 },
},
});

await expect(
con.getRepository(UserTransaction).findOneByOrFail({
receiverId: userId,
referenceId: coresTierId,
referenceType: UserTransactionType.ContributionReward,
}),
).resolves.toMatchObject({
processor: UserTransactionProcessor.Njord,
status: UserTransactionStatus.Success,
value: 25,
valueIncFees: 25,
fee: 0,
});
});

it('returns finalized cause totals, user cause stats, and sponsors', async () => {
Expand Down
232 changes: 232 additions & 0 deletions src/common/contribution/rewards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { ValidationError } from 'apollo-server-errors';
import { addDays } from 'date-fns';
import type { EntityManager } from 'typeorm';
import { v5 } from 'uuid';
import type z from 'zod';
import type { AuthContext } from '../../Context';
import {
contributionCoresRewardMetadataSchema,
contributionPlusDaysRewardMetadataSchema,
} from '../schema/contributions';
import { updateSubscriptionFlags } from '../utils';
import { transferCores } from '../njord';
import { systemUser } from '../utils';
import { isPlusMember, SubscriptionCycles } from '../../paddle';
import { SubscriptionStatus } from '../plus';
import { User } from '../../entity/user/User';
import {
ContributionRewardTier,
ContributionRewardType,
} from '../../entity/contribution/ContributionRewardTier';
import {
UserContributionReward,
UserContributionRewardStatus,
} from '../../entity/contribution/UserContributionReward';
import {
UserTransaction,
UserTransactionProcessor,
UserTransactionStatus,
UserTransactionType,
} from '../../entity/user/UserTransaction';

const contributionRewardTransactionNamespace =
'3507776f-51a3-41e6-bd53-3653ecf10690';

const parseRewardMetadata = <TSchema extends z.ZodType>({
schema,
metadata,
}: {
schema: TSchema;
metadata: ContributionRewardTier['metadata'];
}): z.infer<TSchema> => {
const result = schema.safeParse(metadata);

if (!result.success) {
throw new ValidationError('Invalid reward metadata');
}

return result.data;
};

const markContributionRewardFulfilled = async ({
con,
reward,
fulfilledAt,
}: {
con: EntityManager;
reward: UserContributionReward;
fulfilledAt: Date;
}): Promise<UserContributionReward> => {
await con.getRepository(UserContributionReward).update(
{
userId: reward.userId,
tierId: reward.tierId,
},
{
status: UserContributionRewardStatus.Fulfilled,
fulfilledAt,
},
);

return {
...reward,
status: UserContributionRewardStatus.Fulfilled,
fulfilledAt,
};
};

const fulfillContributionCoresReward = async ({
con,
ctx,
tier,
reward,
now,
}: {
con: EntityManager;
ctx: AuthContext;
tier: ContributionRewardTier;
reward: UserContributionReward;
now: Date;
}): Promise<UserContributionReward> => {
const { amount } = parseRewardMetadata({
schema: contributionCoresRewardMetadataSchema,
metadata: tier.metadata,
});
const transactionId = v5(
`${reward.userId}:${reward.tierId}:cores`,
contributionRewardTransactionNamespace,
);
const existingTransaction = await con.getRepository(UserTransaction).findOne({
select: ['id', 'status'],
where: { id: transactionId },
});

if (existingTransaction?.status === UserTransactionStatus.Success) {
return markContributionRewardFulfilled({ con, reward, fulfilledAt: now });
}

const transaction = await con.getRepository(UserTransaction).save(
con.getRepository(UserTransaction).create({
id: transactionId,
processor: UserTransactionProcessor.Njord,
receiverId: reward.userId,
status: UserTransactionStatus.Success,
productId: null,
senderId: systemUser.id,
value: amount,
valueIncFees: amount,
fee: 0,
request: ctx.requestMeta ?? {},
flags: {
note: `Contribution reward: ${tier.title}`,
},
referenceId: tier.id,
referenceType: UserTransactionType.ContributionReward,
}),
);

await transferCores({
ctx,
transaction,
entityManager: con,
});

return markContributionRewardFulfilled({ con, reward, fulfilledAt: now });
};

const getContributionPlusExpiration = ({
currentExpiration,
days,
now,
}: {
currentExpiration?: Date | string | null;
days: number;
now: Date;
}): Date => {
const parsedExpiration = currentExpiration
? new Date(currentExpiration)
: null;
const startsAt =
parsedExpiration &&
!Number.isNaN(parsedExpiration.getTime()) &&
parsedExpiration > now
? parsedExpiration
: now;

return addDays(startsAt, days);
};

const fulfillContributionPlusDaysReward = async ({
con,
tier,
reward,
now,
}: {
con: EntityManager;
tier: ContributionRewardTier;
reward: UserContributionReward;
now: Date;
}): Promise<UserContributionReward> => {
const { days } = parseRewardMetadata({
schema: contributionPlusDaysRewardMetadataSchema,
metadata: tier.metadata,
});
const user = await con.getRepository(User).findOne({
select: ['id', 'subscriptionFlags'],
where: { id: reward.userId },
});

if (
isPlusMember(user?.subscriptionFlags?.cycle) &&
!user?.subscriptionFlags?.giftExpirationDate
) {
return reward;
}

const giftExpirationDate = getContributionPlusExpiration({
currentExpiration: user?.subscriptionFlags?.giftExpirationDate,
days,
now,
});
const cycle =
days >= 365 ? SubscriptionCycles.Yearly : SubscriptionCycles.Monthly;

await con.getRepository(User).update(reward.userId, {
subscriptionFlags: updateSubscriptionFlags({
cycle,
createdAt: user?.subscriptionFlags?.createdAt ?? now,
updatedAt: now,
giftExpirationDate,
status: SubscriptionStatus.Active,
}),
});

return markContributionRewardFulfilled({ con, reward, fulfilledAt: now });
};

export const fulfillContributionReward = async ({
con,
ctx,
tier,
reward,
}: {
con: EntityManager;
ctx: AuthContext;
tier: ContributionRewardTier;
reward: UserContributionReward;
}): Promise<UserContributionReward> => {
if (reward.status === UserContributionRewardStatus.Fulfilled) {
return reward;
}

const now = new Date();

switch (tier.rewardType) {
case ContributionRewardType.Cores:
return fulfillContributionCoresReward({ con, ctx, tier, reward, now });
case ContributionRewardType.PlusDays:
return fulfillContributionPlusDaysReward({ con, tier, reward, now });
default:
return reward;
}
};
Loading
Loading