Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Transferring orders when changing hosts #9883

Merged
merged 2 commits into from
Mar 8, 2024
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@

import '../../server/env';

import { groupBy, size, uniq } from 'lodash';
import { groupBy, size, sortBy, uniq } from 'lodash';
import moment from 'moment';

import { activities } from '../../server/constants';
import FEATURE from '../../server/constants/feature';
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';

/**
* Since the collective has been archived, its HostCollectiveId has been set to null.
* If the collective has been archived, its HostCollectiveId has been set to null.
* We need some more logic to make sure we're loading the right host.
*/
const getHostFromOrder = async order => {
Expand All @@ -32,17 +33,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: 'PAUSED' | 'DELETED_TIER' | 'ARCHIVED_ACCOUNT' | 'UNHOSTED_COLLECTIVE' | 'CHANGED_HOST' | 'CANCELLED_ORDER';
message: string;
} => {
if (order.status === 'PAUSED') {
return {
code: 'PAUSED',
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 +66,13 @@ 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],
data: { needsAsyncDeactivation: true },
updatedAt: {
[Op.gt]: moment().subtract(1, 'month').toDate(), // For performance, only look at orders updated recently
},
},
include: [
{
model: models.Tier,
Expand All @@ -79,20 +98,21 @@ export async function run() {
logger.info(`Found ${orphanOrders.length} recurring contributions to cancel across ${size(groupedOrders)} accounts`);

for (const accountOrders of Object.values(groupedOrders)) {
const collective = accountOrders[0].collective;
const sortedAccountOrders = sortBy(accountOrders, ['Subscription.isManagedExternally']);
const collective = sortedAccountOrders[0].collective;
const collectiveHandle = collective.slug;
logger.info(`Cancelling ${accountOrders.length} subscriptions for @${collectiveHandle}`);
for (const order of accountOrders) {
logger.info(`Cancelling ${sortedAccountOrders.length} subscriptions for @${collectiveHandle}`);
for (const order of sortedAccountOrders) {
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 === 'PAUSED' ? activities.SUBSCRIPTION_PAUSED : activities.SUBSCRIPTION_CANCELED,
CollectiveId: order.CollectiveId,
FromCollectiveId: order.FromCollectiveId,
HostCollectiveId: order.collective.HostCollectiveId,
Expand All @@ -102,13 +122,19 @@ 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,
messageForContributors: order.data?.messageForContributors,
order: order.info,
tier: order.Tier?.info,
awaitForDispatch: true, // To make sure we won't kill the process while emails are still being sent
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this flag to make sure we await the email dispatch in Activities.afterCreate, without it the script could exit before all the emails have been sent.

},
});
await sleep(500); // To prevent rate-limiting issues when calling 3rd party payment processor APIs

await order.update({ data: { ...order.data, needsAsyncDeactivation: false } });
if (order.Subscription.isManagedExternally) {
await sleep(500); // To prevent rate-limiting issues when calling 3rd party payment processor APIs
}
}
} catch (e) {
logger.error(`Error while cancelling subscriptions for @${collectiveHandle}: ${e.message}`);
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 @@
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 @@
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 @@ -4611,12 +4612,33 @@
): TimeSeriesAmount! @deprecated(reason: "2022-12-13: Use totalAmountReceivedTimeSeries + net=true instead")
activeRecurringContributions: JSON
@deprecated(reason: "2022-10-21: Use activeRecurringContributionsV2 while we migrate to better semantics.")
activeRecurringContributionsV2(

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

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Field 'AccountStats.activeRecurringContributionsV2' is deprecated

Field 'AccountStats.activeRecurringContributionsV2' is deprecated

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

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Field 'AccountStats.activeRecurringContributionsV2' has deprecation reason '2024-03-04: Use activeRecurringContributionsBreakdown while we migrate to better semantics.'

Field 'AccountStats.activeRecurringContributionsV2' has deprecation reason '2024-03-04: Use activeRecurringContributionsBreakdown while we migrate to better semantics.'
"""
The frequency of the recurring contribution (MONTHLY or YEARLY)
"""
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 4626 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 @@ -5311,6 +5333,7 @@
CONTRIBUTION_REJECTED
SUBSCRIPTION_ACTIVATED
SUBSCRIPTION_CANCELED
SUBSCRIPTION_PAUSED

Check warning on line 5336 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 @@ -13864,6 +13887,7 @@
CONTRIBUTION_REJECTED
SUBSCRIPTION_ACTIVATED
SUBSCRIPTION_CANCELED
SUBSCRIPTION_PAUSED

Check warning on line 13890 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 @@ -16229,7 +16253,16 @@
The account to unhost
"""
account: AccountReferenceInput!

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

Check notice on line 16260 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 16265 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
Loading