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 Feb 29, 2024
1 parent d054805 commit fa67633
Show file tree
Hide file tree
Showing 14 changed files with 222 additions and 32 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
31 changes: 20 additions & 11 deletions server/graphql/v2/mutation/HostApplicationMutations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import config from 'config';
import express from 'express';
import { GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';
import { GraphQLBoolean, GraphQLList, GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';
import { GraphQLJSON } from 'graphql-scalars';

import { activities } from '../../../constants';
Expand Down Expand Up @@ -224,6 +224,12 @@ const HostApplicationMutations = {
},
message: {
type: GraphQLString,
description: 'An optional message to explain the reason for unhosting',
},
pauseContributions: {
type: new GraphQLNonNull(GraphQLBoolean),
description: 'If true, contributions will be paused rather than canceled',
defaultValue: false,
},
},
resolve: async (_, args, req: express.Request): Promise<Collective> => {
Expand All @@ -238,19 +244,22 @@ const HostApplicationMutations = {
if (!host) {
return account;
}
if (!req.remoteUser.isAdminOfCollective(host) && !(req.remoteUser.isRoot() && checkScope(req, 'root'))) {
throw new Unauthorized();
}

if (await hasPolicy(host, POLICIES.REQUIRE_2FA_FOR_ADMINS)) {
await twoFactorAuthLib.validateRequest(req, {
alwaysAskForToken: true,
requireTwoFactorAuthEnabled: true,
FromCollectiveId: host.id,
});
if (
!req.remoteUser.isAdminOfCollective(host) &&
!req.remoteUser.isAdminOfCollective(account) &&
!(req.remoteUser.isRoot() && checkScope(req, 'root'))
) {
throw new Forbidden('Only the host admin or the account admin can trigger this action');
}

await account.changeHost(null);
// TODO handle message
await twoFactorAuthLib.enforceForAccountsUserIsAdminOf(req, [account, host], { alwaysAskForToken: true });

await account.changeHost(null, req.remoteUser, {
pauseContributions: args.pauseContributions,
messageForContributors: args.message,
});

await models.Activity.create({
type: activities.COLLECTIVE_UNHOSTED,
Expand Down
1 change: 1 addition & 0 deletions server/lib/emailTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const templateNames = [
'report.platform',
'report.platform.weekly',
'subscription.canceled',
'subscription.paused',
'taxform.request',
'ticket.confirmed',
'ticket.confirmed.fearlesscitiesbrussels',
Expand Down
1 change: 1 addition & 0 deletions server/lib/notifications/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ export const notifyByEmail = async (activity: Activity) => {
break;

case ActivityTypes.SUBSCRIPTION_CANCELED:
case ActivityTypes.SUBSCRIPTION_PAUSED:
await notify.user(activity);
break;

Expand Down
11 changes: 10 additions & 1 deletion server/lib/recurring-contributions.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,22 @@ export const MAX_RETRIES = 6;
export async function ordersWithPendingCharges({ limit, startDate } = {}) {
return models.Order.findAndCountAll({
where: {
status: { [Op.not]: status.PAUSED },
SubscriptionId: { [Op.ne]: null },
deletedAt: null,
},
limit: limit,
include: [
{ model: models.User, as: 'createdByUser' },
{ model: models.Collective, as: 'collective', required: true },
{
model: models.Collective,
as: 'collective',
required: true,
where: {
HostCollectiveId: { [Op.not]: null },
isActive: true,
},
},
{ model: models.Collective, as: 'fromCollective', required: true },
{ model: models.PaymentMethod, as: 'paymentMethod' },
{ model: models.Tier, as: 'Tier' },
Expand Down
1 change: 1 addition & 0 deletions server/lib/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ const makeTimelineQuery = async (
ActivityTypes.PAYMENT_CREDITCARD_EXPIRING,
ActivityTypes.PAYMENT_FAILED,
ActivityTypes.SUBSCRIPTION_CANCELED,
ActivityTypes.SUBSCRIPTION_PAUSED,
],
);
}
Expand Down
2 changes: 1 addition & 1 deletion server/lib/webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export const sanitizeActivity = activity => {
cleanActivity.data = pick(activity.data, ['recipient.name']);
cleanActivity.data.tier = getTierInfo(activity.data.tier);
cleanActivity.data.order = getOrderInfo(activity.data.order);
} else if (type === activities.SUBSCRIPTION_CANCELED) {
} else if (type === activities.SUBSCRIPTION_CANCELED || type === activities.SUBSCRIPTION_PAUSED) {
cleanActivity.data = pick(activity.data, ['subscription.id']);
cleanActivity.data.order = getOrderInfo(activity.data.order);
cleanActivity.data.tier = getTierInfo(activity.data.tier);
Expand Down
6 changes: 5 additions & 1 deletion server/models/Collective.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2412,7 +2412,11 @@ class Collective extends Model<
});
}

await Order.cancelNonTransferableActiveOrdersByCollectiveId(this.id);
if (options?.pauseContributions) {
await Order.pauseNonTransferableActiveOrdersByCollectiveId(this.id);
} else {
await Order.cancelNonTransferableActiveOrdersByCollectiveId(this.id);
}

const virtualCards = await VirtualCard.findAll({ where: { CollectiveId: this.id } });
await Promise.all(virtualCards.map(virtualCard => virtualCard.delete()));
Expand Down
31 changes: 31 additions & 0 deletions server/models/Order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface OrderModelStaticInterface {
generateDescription(collective, amount, interval, tier): string;
cancelActiveOrdersByCollective(collectiveId: number): Promise<[affectedCount: number]>;
cancelActiveOrdersByTierId(tierId: number): Promise<[affectedCount: number]>;
pauseNonTransferableActiveOrdersByCollectiveId(collectiveId: number): Promise<[affectedCount: number]>;
cancelNonTransferableActiveOrdersByCollectiveId(collectiveId: number): Promise<[affectedCount: number]>;
clearExpiredLocks(): Promise<[null, number]>;
}
Expand Down Expand Up @@ -693,6 +694,36 @@ Order.cancelNonTransferableActiveOrdersByCollectiveId = function (collectiveId:
);
};

/**
* Pause all orders with subscriptions that cannot be transferred when changing hosts (i.e. PayPal).
* The CRON job `70-cancel-subscriptions-for-cancelled-orders` will then take care of cancelling them on the payment processor's side.
*/
Order.pauseNonTransferableActiveOrdersByCollectiveId = function (collectiveId: number) {
// TODO: Add stripe intents
return sequelize.query(
`
UPDATE public."Orders"
SET
status = 'PAUSED',
"updatedAt" = NOW(),
data = COALESCE(data, '{}'::JSONB) || '{"pausedForTransfer": true}'
WHERE id IN (
SELECT "Orders".id FROM public."Orders"
INNER JOIN public."Subscriptions" ON "Subscriptions".id = "Orders"."SubscriptionId"
WHERE
"Orders".status NOT IN ('PAID', 'PAUSED', 'CANCELLED', 'REJECTED', 'EXPIRED') AND
"Subscriptions"."isManagedExternally" AND
"Subscriptions"."isActive" AND
"Orders"."CollectiveId" = ?
)
`,
{
type: QueryTypes.UPDATE,
replacements: [collectiveId],
},
);
};

Temporal(Order, sequelize);

export default Order;
22 changes: 22 additions & 0 deletions templates/emails/subscription.paused.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Subject: Your contribution to {{{collective.name}}} has been paused

{{> header}}

<p>{{> greeting}}</p>

<p>Your recurring contribution to {{collective.name}} for {{currency subscription.amount currency=subscription.currency}}/{{subscription.interval}} has been paused.</p>

{{#if reason}}
<p>The reason for this cancellation was:</p>
<blockquote style="color: #6a737d;font-size: 12px; font-style: italic; text-align: left;padding: 0.5em 0.75em;margin: 1em 0;border-left: 3px solid #e4e4e4;white-space: pre-line;">{{reason}}</blockquote>
{{/if}}

<p>We'll inform you when it will be ready for re-activation.</p>

<p>Warmly,</p>

<p>
{{collective.name}}
</p>

{{> footer}}
Loading

0 comments on commit fa67633

Please sign in to comment.