From fa67633a8a0789e39a418ae2106507ce26465d7d Mon Sep 17 00:00:00 2001 From: Benjamin Piouffle Date: Thu, 29 Feb 2024 12:13:00 +0100 Subject: [PATCH] feat(Contributions): ability to pause non-transferable --- cron/daily/60-clean-orders.js | 8 ++ ...ncel-subscriptions-for-cancelled-orders.ts | 40 +++++--- server/constants/activities.ts | 2 + server/constants/order-status.ts | 1 + .../v2/mutation/HostApplicationMutations.ts | 31 +++--- server/lib/emailTemplates.ts | 1 + server/lib/notifications/email.ts | 1 + server/lib/recurring-contributions.js | 11 ++- server/lib/timeline.ts | 1 + server/lib/webhooks.js | 2 +- server/models/Collective.ts | 6 +- server/models/Order.ts | 31 ++++++ templates/emails/subscription.paused.hbs | 22 +++++ .../mutation/HostApplicationMutations.test.ts | 97 ++++++++++++++++++- 14 files changed, 222 insertions(+), 32 deletions(-) create mode 100644 templates/emails/subscription.paused.hbs diff --git a/cron/daily/60-clean-orders.js b/cron/daily/60-clean-orders.js index ed4f2472d932..f341f94d4c5e 100644 --- a/cron/daily/60-clean-orders.js +++ b/cron/daily/60-clean-orders.js @@ -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); diff --git a/cron/hourly/70-cancel-subscriptions-for-cancelled-orders.ts b/cron/hourly/70-cancel-subscriptions-for-cancelled-orders.ts index c6e6ad4e4d24..3b90e3cc3c92 100644 --- a/cron/hourly/70-cancel-subscriptions-for-cancelled-orders.ts +++ b/cron/hourly/70-cancel-subscriptions-for-cancelled-orders.ts @@ -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'; /** @@ -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` }; } }; @@ -53,7 +65,7 @@ const getOrderCancelationReason = (collective, order, orderHost) => { */ export async function run() { const orphanOrders = await models.Order.findAll({ - where: { status: OrderStatuses.CANCELLED }, + where: { status: [OrderStatuses.CANCELLED, OrderStatuses.PAUSED] }, include: [ { model: models.Tier, @@ -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, @@ -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, }, diff --git a/server/constants/activities.ts b/server/constants/activities.ts index e4a25ff314fc..50efddbc51f7 100644 --- a/server/constants/activities.ts +++ b/server/constants/activities.ts @@ -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', @@ -239,6 +240,7 @@ export const ActivitiesPerClass: Record = { ActivityTypes.PAYMENT_FAILED, ActivityTypes.SUBSCRIPTION_ACTIVATED, ActivityTypes.SUBSCRIPTION_CANCELED, + ActivityTypes.SUBSCRIPTION_PAUSED, ActivityTypes.SUBSCRIPTION_CONFIRMED, ], [ActivityClasses.ACTIVITIES_UPDATES]: [ diff --git a/server/constants/order-status.ts b/server/constants/order-status.ts index 59b81e074f99..79b458dd4f7b 100644 --- a/server/constants/order-status.ts +++ b/server/constants/order-status.ts @@ -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', } diff --git a/server/graphql/v2/mutation/HostApplicationMutations.ts b/server/graphql/v2/mutation/HostApplicationMutations.ts index 38dabee2baa9..3b80337908aa 100644 --- a/server/graphql/v2/mutation/HostApplicationMutations.ts +++ b/server/graphql/v2/mutation/HostApplicationMutations.ts @@ -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'; @@ -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 => { @@ -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, diff --git a/server/lib/emailTemplates.ts b/server/lib/emailTemplates.ts index 7f43d2fdd002..527438a163e1 100644 --- a/server/lib/emailTemplates.ts +++ b/server/lib/emailTemplates.ts @@ -92,6 +92,7 @@ export const templateNames = [ 'report.platform', 'report.platform.weekly', 'subscription.canceled', + 'subscription.paused', 'taxform.request', 'ticket.confirmed', 'ticket.confirmed.fearlesscitiesbrussels', diff --git a/server/lib/notifications/email.ts b/server/lib/notifications/email.ts index b6a955bd1fca..150091da75f1 100644 --- a/server/lib/notifications/email.ts +++ b/server/lib/notifications/email.ts @@ -232,6 +232,7 @@ export const notifyByEmail = async (activity: Activity) => { break; case ActivityTypes.SUBSCRIPTION_CANCELED: + case ActivityTypes.SUBSCRIPTION_PAUSED: await notify.user(activity); break; diff --git a/server/lib/recurring-contributions.js b/server/lib/recurring-contributions.js index a22673f0aafd..b93fc8091515 100644 --- a/server/lib/recurring-contributions.js +++ b/server/lib/recurring-contributions.js @@ -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' }, diff --git a/server/lib/timeline.ts b/server/lib/timeline.ts index 63a70f77287f..79af3ad2a34a 100644 --- a/server/lib/timeline.ts +++ b/server/lib/timeline.ts @@ -154,6 +154,7 @@ const makeTimelineQuery = async ( ActivityTypes.PAYMENT_CREDITCARD_EXPIRING, ActivityTypes.PAYMENT_FAILED, ActivityTypes.SUBSCRIPTION_CANCELED, + ActivityTypes.SUBSCRIPTION_PAUSED, ], ); } diff --git a/server/lib/webhooks.js b/server/lib/webhooks.js index 884b9b23a3b2..e5d3f145b36b 100644 --- a/server/lib/webhooks.js +++ b/server/lib/webhooks.js @@ -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); diff --git a/server/models/Collective.ts b/server/models/Collective.ts index d2829b8180c2..2cff78206484 100644 --- a/server/models/Collective.ts +++ b/server/models/Collective.ts @@ -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())); diff --git a/server/models/Order.ts b/server/models/Order.ts index e9a04ce64703..8e5b8bf2abaf 100644 --- a/server/models/Order.ts +++ b/server/models/Order.ts @@ -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]>; } @@ -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; diff --git a/templates/emails/subscription.paused.hbs b/templates/emails/subscription.paused.hbs new file mode 100644 index 000000000000..b24ab08a7856 --- /dev/null +++ b/templates/emails/subscription.paused.hbs @@ -0,0 +1,22 @@ +Subject: Your contribution to {{{collective.name}}} has been paused + +{{> header}} + +

{{> greeting}}

+ +

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

+ +{{#if reason}} +

The reason for this cancellation was:

+
{{reason}}
+{{/if}} + +

We'll inform you when it will be ready for re-activation.

+ +

Warmly,

+ +

+ – {{collective.name}} +

+ +{{> footer}} diff --git a/test/server/graphql/v2/mutation/HostApplicationMutations.test.ts b/test/server/graphql/v2/mutation/HostApplicationMutations.test.ts index d1a13ee7a205..3f0e72eee92a 100644 --- a/test/server/graphql/v2/mutation/HostApplicationMutations.test.ts +++ b/test/server/graphql/v2/mutation/HostApplicationMutations.test.ts @@ -14,6 +14,7 @@ import { VirtualCardStatus } from '../../../../../server/models/VirtualCard'; import * as stripeVirtualCardService from '../../../../../server/paymentProviders/stripe/virtual-cards'; import { randEmail } from '../../../../stores'; import { + fakeActiveHost, fakeCollective, fakeEvent, fakeHost, @@ -92,8 +93,8 @@ const PROCESS_HOST_APPLICATION_MUTATION = gql` `; const REMOVE_HOST_MUTATION = gql` - mutation UnhostAccount($account: AccountReferenceInput!, $message: String) { - removeHost(account: $account, message: $message) { + mutation UnhostAccount($account: AccountReferenceInput!, $message: String, $pauseContributions: Boolean) { + removeHost(account: $account, message: $message, pauseContributions: $pauseContributions) { id slug name @@ -410,7 +411,7 @@ describe('server/graphql/v2/mutation/HostApplicationMutations', () => { ); }); - it('requires token with host scope', async () => { + it("can't remove if unauthenticated", async () => { const result = await graphqlQueryV2(REMOVE_HOST_MUTATION, { account: { id: 'some id', @@ -420,6 +421,14 @@ describe('server/graphql/v2/mutation/HostApplicationMutations', () => { expect(result.errors[0].message).to.equal('You need to be logged in to manage hosted accounts.'); }); + it("can't remove if not an admin", async () => { + const randomUser = await fakeUser(); + const collective = await fakeCollective(); + const result = await graphqlQueryV2(REMOVE_HOST_MUTATION, { account: { legacyId: collective.id } }, randomUser); + expect(result.errors).to.exist; + expect(result.errors[0].message).to.equal('Only the host admin or the account admin can trigger this action'); + }); + it('results in error if account does not exist', async () => { const result = await graphqlQueryV2( REMOVE_HOST_MUTATION, @@ -475,6 +484,62 @@ describe('server/graphql/v2/mutation/HostApplicationMutations', () => { expect(unhostingActivity.data.host.id).to.eq(host.id); }); + it('pauses the contributions if requested', async () => { + const host = await fakeActiveHost(); + const collective = await fakeCollective({ HostCollectiveId: host.id }); + const transferableOrder = await fakeOrder( + { + status: OrderStatuses.ACTIVE, + CollectiveId: collective.id, + interval: 'month', + subscription: { isManagedExternally: false, isActive: true }, + }, + { + withSubscription: true, + }, + ); + + const nonTransferableOrder = await fakeOrder( + { + status: OrderStatuses.ACTIVE, + CollectiveId: collective.id, + interval: 'month', + subscription: { isManagedExternally: true, isActive: true }, + }, + { + withSubscription: true, + }, + ); + + const result = await graphqlQueryV2( + REMOVE_HOST_MUTATION, + { + account: { legacyId: collective.id }, + message: 'We are transitioning to a new host', + pauseContributions: true, + }, + rootUser, + ); + + expect(result.errors).to.not.exist; + expect(result.data.removeHost.host).to.be.null; + + const unhostingActivity = await models.Activity.findOne({ + where: { type: activities.COLLECTIVE_UNHOSTED, CollectiveId: collective.id }, + }); + expect(unhostingActivity).to.exist; + expect(unhostingActivity.data.message).to.eq('We are transitioning to a new host'); + expect(unhostingActivity.data.collective.id).to.eq(collective.id); + expect(unhostingActivity.data.host.id).to.eq(host.id); + + await nonTransferableOrder.reload(); + expect(nonTransferableOrder.status).to.eq(OrderStatuses.PAUSED); + expect(nonTransferableOrder.data.pausedForTransfer).to.be.true; + + await transferableOrder.reload(); + expect(transferableOrder.status).to.eq(OrderStatuses.ACTIVE); + }); + it('validates if collective does not have a parent account', async () => { const host = await fakeHost(); const collective = await fakeCollective({ HostCollectiveId: host.id }); @@ -529,6 +594,30 @@ describe('server/graphql/v2/mutation/HostApplicationMutations', () => { expect(result.errors[0].message).to.equal('Unable to change host: you still have a balance of $24.00'); }); + it('removes the host as a collective admin', async () => { + const host = await fakeActiveHost(); + const collective = await fakeCollective({ HostCollectiveId: host.id }); + const result = await graphqlQueryV2( + REMOVE_HOST_MUTATION, + { + account: { legacyId: collective.id }, + message: 'removing as collective admin', + }, + rootUser, + ); + + expect(result.errors).to.not.exist; + expect(result.data.removeHost.host).to.be.null; + + const unhostingActivity = await models.Activity.findOne({ + where: { type: activities.COLLECTIVE_UNHOSTED, CollectiveId: collective.id }, + }); + expect(unhostingActivity).to.exist; + expect(unhostingActivity.data.message).to.eq('removing as collective admin'); + expect(unhostingActivity.data.collective.id).to.eq(collective.id); + expect(unhostingActivity.data.host.id).to.eq(host.id); + }); + it('validates if user is admin of target host collective', async () => { const user = await fakeUser(); await fakeCollective({ admin: user }); @@ -545,7 +634,7 @@ describe('server/graphql/v2/mutation/HostApplicationMutations', () => { user, ); expect(result.errors).to.exist; - expect(result.errors[0].message).to.equal('You need to be authenticated to perform this action'); + expect(result.errors[0].message).to.equal('Only the host admin or the account admin can trigger this action'); }); it('removes the host from a hosted collective using host admin', async () => {