Skip to content

Commit

Permalink
feat(Contributions): ability to pause non-transferable
Browse files Browse the repository at this point in the history
  • Loading branch information
Betree committed Mar 5, 2024
1 parent 2c3efde commit 82740ae
Show file tree
Hide file tree
Showing 24 changed files with 553 additions and 120 deletions.
8 changes: 8 additions & 0 deletions cron/daily/60-clean-orders.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ Promise.all([
AND "Orders"."createdAt" < (NOW() - interval '2 month')
`,
),
// Mark all paused Orders as EXPIRED after 1 year
sequelize.query(
`UPDATE "Orders"
SET "status" = 'EXPIRED', "updatedAt" = NOW()
WHERE "Orders"."status" = 'PAUSED'
AND "Orders"."updatedAt" < (NOW() - interval '1 year')
`,
),
]).then(() => {
console.log('>>> Clean Orders: done');
process.exit(0);
Expand Down
40 changes: 26 additions & 14 deletions cron/hourly/70-cancel-subscriptions-for-cancelled-orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import OrderStatuses from '../../server/constants/order-status';
import logger from '../../server/lib/logger';
import { reportErrorToSentry } from '../../server/lib/sentry';
import { sleep } from '../../server/lib/utils';
import models, { Op } from '../../server/models';
import models, { Collective, Op } from '../../server/models';
import { OrderModelInterface } from '../../server/models/Order';

/**
Expand All @@ -32,17 +32,29 @@ const getHostFromOrder = async order => {
return models.Collective.findByPk(hostIds[0]);
};

const getOrderCancelationReason = (collective, order, orderHost) => {
if (order.TierId && !order.Tier) {
return ['DELETED_TIER', `Order tier deleted`];
const getOrderCancelationReason = (
collective: Collective,
order: OrderModelInterface,
orderHost: Collective,
): {
code: 'TRANSFER' | 'DELETED_TIER' | 'ARCHIVED_ACCOUNT' | 'UNHOSTED_COLLECTIVE' | 'CHANGED_HOST' | 'CANCELLED_ORDER';
message: string;
} => {
if (order.status === 'PAUSED' && order.data?.pausedForTransfer) {
return {
code: 'TRANSFER',
message: `Your contribution to the Collective was paused. We'll inform you when it will be ready for re-activation.`,
};
} else if (order.TierId && !order.Tier) {
return { code: 'DELETED_TIER', message: `Order tier deleted` };
} else if (collective.deactivatedAt) {
return ['ARCHIVED_ACCOUNT', `@${collective.slug} archived their account`];
return { code: 'ARCHIVED_ACCOUNT', message: `@${collective.slug} archived their account` };
} else if (!collective.HostCollectiveId) {
return ['UNHOSTED_COLLECTIVE', `@${collective.slug} was un-hosted`];
return { code: 'UNHOSTED_COLLECTIVE', message: `@${collective.slug} was un-hosted` };
} else if (collective.HostCollectiveId !== orderHost.id) {
return ['CHANGED_HOST', `@${collective.slug} changed host`];
return { code: 'CHANGED_HOST', message: `@${collective.slug} changed host` };
} else {
return ['CANCELLED_ORDER', `Order cancelled`];
return { code: 'CANCELLED_ORDER', message: `Order cancelled` };
}
};

Expand All @@ -53,7 +65,7 @@ const getOrderCancelationReason = (collective, order, orderHost) => {
*/
export async function run() {
const orphanOrders = await models.Order.findAll<OrderModelInterface>({
where: { status: OrderStatuses.CANCELLED },
where: { status: [OrderStatuses.CANCELLED, OrderStatuses.PAUSED] },
include: [
{
model: models.Tier,
Expand Down Expand Up @@ -85,14 +97,14 @@ export async function run() {
for (const order of accountOrders) {
try {
const host = await getHostFromOrder(order);
const [reasonCode, reason] = getOrderCancelationReason(collective, order, host);
const reason = getOrderCancelationReason(collective, order, host);
logger.debug(
`Cancelling subscription ${order.Subscription.id} from order ${order.id} of @${collectiveHandle} (host: ${host.slug})`,
);
if (!process.env.DRY) {
await order.Subscription.deactivate(reason, host);
await order.Subscription.deactivate(reason.message, host);
await models.Activity.create({
type: activities.SUBSCRIPTION_CANCELED,
type: reason.code === 'TRANSFER' ? activities.SUBSCRIPTION_PAUSED : activities.SUBSCRIPTION_CANCELED,
CollectiveId: order.CollectiveId,
FromCollectiveId: order.FromCollectiveId,
HostCollectiveId: order.collective.HostCollectiveId,
Expand All @@ -102,8 +114,8 @@ export async function run() {
subscription: order.Subscription,
collective: order.collective.minimal,
fromCollective: order.fromCollective.minimal,
reasonCode: reasonCode,
reason: reason,
reasonCode: reason.code,
reason: reason.message,
order: order.info,
tier: order.Tier?.info,
},
Expand Down
2 changes: 2 additions & 0 deletions server/constants/activities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ enum ActivityTypes {
CONTRIBUTION_REJECTED = 'contribution.rejected',
SUBSCRIPTION_ACTIVATED = 'subscription.activated',
SUBSCRIPTION_CANCELED = 'subscription.canceled',
SUBSCRIPTION_PAUSED = 'subscription.paused',
TICKET_CONFIRMED = 'ticket.confirmed',
ORDER_CANCELED_ARCHIVED_COLLECTIVE = 'order.canceled.archived.collective',
ORDER_PENDING = 'order.pending',
Expand Down Expand Up @@ -239,6 +240,7 @@ export const ActivitiesPerClass: Record<ActivityClasses, ActivityTypes[]> = {
ActivityTypes.PAYMENT_FAILED,
ActivityTypes.SUBSCRIPTION_ACTIVATED,
ActivityTypes.SUBSCRIPTION_CANCELED,
ActivityTypes.SUBSCRIPTION_PAUSED,
ActivityTypes.SUBSCRIPTION_CONFIRMED,
],
[ActivityClasses.ACTIVITIES_UPDATES]: [
Expand Down
1 change: 1 addition & 0 deletions server/constants/order-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ enum OrderStatuses {
// Disputed charges from Stripe
DISPUTED = 'DISPUTED',
REFUNDED = 'REFUNDED',
PAUSED = 'PAUSED',
// In review charges from Stripe,
IN_REVIEW = 'IN_REVIEW',
}
Expand Down
123 changes: 74 additions & 49 deletions server/graphql/loaders/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import DataLoader from 'dataloader';
import { createContext } from 'dataloader-sequelize';
import { get, groupBy } from 'lodash';
import { get, groupBy, isNil } from 'lodash';
import moment from 'moment';

import { CollectiveType } from '../../constants/collectives';
Expand Down Expand Up @@ -372,54 +372,79 @@ export const loaders = req => {
})
.then(results => sortResults(ids, results, 'CollectiveId')),
),
activeRecurringContributions: new DataLoader(ids =>
models.Order.findAll({
attributes: [
'Order.CollectiveId',
'Order.currency',
'Subscription.interval',
[
sequelize.fn(
'SUM',
sequelize.literal(`COALESCE("Order"."totalAmount", 0) - COALESCE("Order"."platformTipAmount", 0)`),
),
'total',
],
],
where: {
CollectiveId: { [Op.in]: ids },
status: 'ACTIVE',
},
group: ['Subscription.interval', 'CollectiveId', 'Order.currency'],
include: [
{
model: models.Subscription,
attributes: [],
where: { isActive: true },
},
],
raw: true,
}).then(rows => {
const results = groupBy(rows, 'CollectiveId');
return Promise.all(
ids.map(async collectiveId => {
const stats = { CollectiveId: Number(collectiveId), monthly: 0, yearly: 0, currency: null };
if (results[collectiveId]) {
for (const result of results[collectiveId]) {
const interval = result.interval === 'month' ? 'monthly' : 'yearly';
// If it's the first total collected, set the currency
if (!stats.currency) {
stats.currency = result.currency;
}
const fxRate = await getFxRate(result.currency, stats.currency);
stats[interval] += result.total * fxRate;
}
}
return stats;
}),
);
}),
),
activeRecurringContributions: {
buildLoader({ currency, hasPortability = undefined, includeChildren = undefined } = {}) {
const key = `${currency}-${hasPortability}-${includeChildren}`;
if (!context.loaders.Collective.stats.activeRecurringContributions[key]) {
const collectiveIdCol = !includeChildren
? 'CollectiveId'
: sequelize.fn('COALESCE', sequelize.col('collective.ParentCollectiveId'), sequelize.col('collective.id'));

context.loaders.Collective.stats.activeRecurringContributions[key] = new DataLoader(ids =>
models.Order.findAll({
attributes: [
[collectiveIdCol, 'CollectiveId'],
'Order.currency',
'Subscription.interval',
[sequelize.fn('COUNT', sequelize.literal(`DISTINCT "Order"."id"`)), 'count'],
[
sequelize.fn(
'SUM',
sequelize.literal(`COALESCE("Order"."totalAmount", 0) - COALESCE("Order"."platformTipAmount", 0)`),
),
'total',
],
],
where: {
status: 'ACTIVE',
},
group: ['Subscription.interval', collectiveIdCol, 'Order.currency'],
include: [
{
association: 'collective',
attributes: [],
required: true,
where: !includeChildren ? { id: ids } : { [Op.or]: [{ id: ids }, { ParentCollectiveId: ids }] },
},
{
model: models.Subscription,
attributes: [],
required: true,
where: {
isActive: true,
...(!isNil(hasPortability) && { isManagedExternally: !hasPortability }),
},
},
],
raw: true,
}).then(rows => {
const results = groupBy(rows, 'CollectiveId');
return Promise.all(
ids.map(async collectiveId => {
const stats = {
CollectiveId: Number(collectiveId),
monthly: 0,
monthlyCount: 0,
yearly: 0,
yearlyCount: 0,
};
if (results[collectiveId]) {
for (const result of results[collectiveId]) {
const interval = result.interval === 'month' ? 'monthly' : 'yearly';
const fxRate = await getFxRate(result.currency, currency);
stats[interval] += result.total * fxRate;
stats[`${interval}Count`] += result.count;
}
}
return stats;
}),
);
}),
);
}
return context.loaders.Collective.stats.activeRecurringContributions[key];
},
},
orders: new DataLoader(async collectiveIds => {
const stats = await sequelize.query(
`SELECT * FROM "CollectiveOrderStats" WHERE "CollectiveId" IN (:collectiveIds)`,
Expand Down
1 change: 1 addition & 0 deletions server/graphql/schemaV1.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,7 @@ enum OrderStatus {
EXPIRED
DISPUTED
REFUNDED
PAUSED

Check warning on line 736 in server/graphql/schemaV1.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v1

Enum value 'PAUSED' was added to enum 'OrderStatus'

Adding an enum value may break existing clients that were not programming defensively against an added case when querying an enum.
IN_REVIEW
}

Expand Down
33 changes: 33 additions & 0 deletions server/graphql/schemaV2.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -1194,6 +1194,7 @@ enum OrderStatus {
EXPIRED
DISPUTED
REFUNDED
PAUSED

Check warning on line 1197 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Enum value 'PAUSED' was added to enum 'OrderStatus'

Adding an enum value may break existing clients that were not programming defensively against an added case when querying an enum.
IN_REVIEW
}

Expand Down Expand Up @@ -4616,6 +4617,27 @@ type AccountStats {
"""
frequency: ContributionFrequency! = MONTHLY
): Amount
@deprecated(reason: "2024-03-04: Use activeRecurringContributionsBreakdown while we migrate to better semantics.")

"""
Returns some statistics about active recurring contributions, broken down by frequency
"""
activeRecurringContributionsBreakdown(

Check notice on line 4625 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Field 'activeRecurringContributionsBreakdown' was added to object type 'AccountStats'

Field 'activeRecurringContributionsBreakdown' was added to object type 'AccountStats'
"""
Return only the stats for this frequency
"""
frequency: ContributionFrequency

"""
Filter contributions on whether they can be ported to another fiscal host directly
"""
hasPortability: Boolean

"""
Include contributions to children accounts (Projects and Events)
"""
includeChildren: Boolean = false
): [AmountStats!]!

"""
Returns expense tags for collective sorted by popularity
Expand Down Expand Up @@ -5310,6 +5332,7 @@ enum ActivityType {
CONTRIBUTION_REJECTED
SUBSCRIPTION_ACTIVATED
SUBSCRIPTION_CANCELED
SUBSCRIPTION_PAUSED

Check warning on line 5335 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Enum value 'SUBSCRIPTION_PAUSED' was added to enum 'ActivityType'

Adding an enum value may break existing clients that were not programming defensively against an added case when querying an enum.
TICKET_CONFIRMED
ORDER_CANCELED_ARCHIVED_COLLECTIVE
ORDER_PENDING
Expand Down Expand Up @@ -13850,6 +13873,7 @@ enum ActivityAndClassesType {
CONTRIBUTION_REJECTED
SUBSCRIPTION_ACTIVATED
SUBSCRIPTION_CANCELED
SUBSCRIPTION_PAUSED

Check warning on line 13876 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Enum value 'SUBSCRIPTION_PAUSED' was added to enum 'ActivityAndClassesType'

Adding an enum value may break existing clients that were not programming defensively against an added case when querying an enum.
TICKET_CONFIRMED
ORDER_CANCELED_ARCHIVED_COLLECTIVE
ORDER_PENDING
Expand Down Expand Up @@ -16215,7 +16239,16 @@ type Mutation {
The account to unhost
"""
account: AccountReferenceInput!

"""
An optional message to explain the reason for unhosting
"""
message: String

Check notice on line 16246 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Description for argument 'message' on field 'Mutation.removeHost' changed from 'undefined' to 'An optional message to explain the reason for unhosting'

Description for argument 'message' on field 'Mutation.removeHost' changed from 'undefined' to 'An optional message to explain the reason for unhosting'

"""
If true, contributions will be paused rather than canceled
"""
pauseContributions: Boolean! = true

Check warning on line 16251 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Argument 'pauseContributions: Boolean!' (with default value) added to field 'Mutation.removeHost'

Adding a new argument to an existing field may involve a change in resolve function logic that potentially may cause some side effects.
): Account!

"""
Expand Down
5 changes: 4 additions & 1 deletion server/graphql/v1/CollectiveInterface.js
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,10 @@ export const CollectiveStatsType = new GraphQLObjectType({
activeRecurringContributions: {
type: GraphQLJSON,
resolve(collective, args, req) {
return req.loaders.Collective.stats.activeRecurringContributions.load(collective.id);
const loader = req.loaders.Collective.stats.activeRecurringContributions.buildLoader({
currency: collective.currency,
});
return loader.load(collective.id);
},
},
};
Expand Down
Loading

0 comments on commit 82740ae

Please sign in to comment.