Skip to content
This repository has been archived by the owner on Aug 11, 2024. It is now read-only.

Commit

Permalink
Merge pull request #320 from beabee-communityrm/feat/monthly-to-annual
Browse files Browse the repository at this point in the history
feat!: allow monthly to annual contributions
  • Loading branch information
wpf500 authored Nov 1, 2023
2 parents ee834ef + 6e63a90 commit b4c1b51
Show file tree
Hide file tree
Showing 14 changed files with 298 additions and 143 deletions.
33 changes: 15 additions & 18 deletions src/api/controllers/ContactController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,9 @@ import {
StartJoinFlowData
} from "@api/data/JoinFlowData";
import {
SetContributionData,
StartContributionData,
UpdateContributionData,
ForceUpdateContributionData
ForceUpdateContributionData,
UpdateContributionData
} from "@api/data/ContributionData";
import {
mergeRules,
Expand Down Expand Up @@ -251,19 +250,11 @@ export class ContactController {
@TargetUser() target: Contact,
@Body() data: UpdateContributionData
): Promise<ContributionInfo> {
// TODO: can we move this into validators?
const contributionData = new SetContributionData();
contributionData.amount = data.amount;
contributionData.period = target.contributionPeriod!;
contributionData.payFee = data.payFee;
contributionData.prorate = data.prorate;
await validateOrReject(contributionData);

if (!(await PaymentService.canChangeContribution(target, true))) {
if (!(await PaymentService.canChangeContribution(target, true, data))) {
throw new CantUpdateContribution();
}

await ContactsService.updateContactContribution(target, contributionData);
await ContactsService.updateContactContribution(target, data);

return await this.getContribution(target);
}
Expand Down Expand Up @@ -372,7 +363,7 @@ export class ContactController {
target: Contact,
data: StartContributionData
) {
if (!(await PaymentService.canChangeContribution(target, false))) {
if (!(await PaymentService.canChangeContribution(target, false, data))) {
throw new CantUpdateContribution();
}

Expand All @@ -398,17 +389,23 @@ export class ContactController {
target: Contact,
data: CompleteJoinFlowData
): Promise<JoinFlow> {
if (!(await PaymentService.canChangeContribution(target, false))) {
throw new CantUpdateContribution();
}

const joinFlow = await PaymentFlowService.getJoinFlowByPaymentId(
data.paymentFlowId
);
if (!joinFlow) {
throw new NotFoundError();
}

if (
!(await PaymentService.canChangeContribution(
target,
false,
joinFlow.joinForm
))
) {
throw new CantUpdateContribution();
}

const completedFlow = await PaymentFlowService.completeJoinFlow(joinFlow);
await PaymentService.updatePaymentMethod(target, completedFlow);

Expand Down
21 changes: 2 additions & 19 deletions src/api/data/ContributionData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,7 @@ import ValidPayFee from "@api/validators/ValidPayFee";

import { StartJoinFlowData } from "./JoinFlowData";

interface ContributionData {
amount: number;
payFee: boolean;
prorate: boolean;
}

export class SetContributionData implements ContributionData {
export class UpdateContributionData {
@Validate(MinContributionAmount)
amount!: number;

Expand All @@ -46,7 +40,7 @@ export class SetContributionData implements ContributionData {
}

export class StartContributionData
extends SetContributionData
extends UpdateContributionData
implements StartJoinFlowData
{
@IsUrl()
Expand All @@ -56,17 +50,6 @@ export class StartContributionData
paymentMethod!: PaymentMethod;
}

export class UpdateContributionData implements ContributionData {
@IsNumber()
amount!: number;

@IsBoolean()
payFee!: boolean;

@IsBoolean()
prorate!: boolean;
}

export class ForceUpdateContributionData {
@IsIn([ContributionType.Manual, ContributionType.None])
type!: ContributionType.Manual | ContributionType.None;
Expand Down
2 changes: 1 addition & 1 deletion src/apps/members/apps/member/apps/contribution/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ app.get(

res.render("automatic", {
member: contact,
canChange: await PaymentService.canChangeContribution(contact, true),
canChange: true, // TODO: remove
monthsLeft: calcMonthsLeft(contact),
payments,
total
Expand Down
45 changes: 31 additions & 14 deletions src/core/providers/payment/GCProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { PaymentMethod } from "@beabee/beabee-common";
import { Subscription } from "gocardless-nodejs";
import moment from "moment";

import gocardless from "@core/lib/gocardless";
import { log as mainLogger } from "@core/logging";
Expand Down Expand Up @@ -60,18 +61,21 @@ export default class GCProvider extends PaymentProvider<GCPaymentData> {
return {
payFee: this.data.payFee || false,
hasPendingPayment: pendingPayment,
...(this.data.nextMonthlyAmount &&
...(this.data.nextAmount &&
this.contact.contributionPeriod && {
nextAmount: getActualAmount(
this.data.nextMonthlyAmount,
this.data.nextAmount.monthly,
this.contact.contributionPeriod
)
}),
...(paymentSource && { paymentSource })
};
}

async canChangeContribution(useExistingMandate: boolean): Promise<boolean> {
async canChangeContribution(
useExistingMandate: boolean,
paymentForm: PaymentForm
): Promise<boolean> {
// No payment method available
if (useExistingMandate && !this.data.mandateId) {
return false;
Expand All @@ -82,11 +86,13 @@ export default class GCProvider extends PaymentProvider<GCPaymentData> {
return true;
}

// Monthly contributors can update their contribution even if they have
// pending payments, but they can't always change their mandate as this can
// Monthly contributors can update their contribution amount even if they have
// pending payments, but they can't always change their period or mandate as this can
// result in double charging
return (
(useExistingMandate && this.contact.contributionPeriod === "monthly") ||
(useExistingMandate &&
this.contact.contributionPeriod === "monthly" &&
paymentForm.period === "monthly") ||
!(this.data.mandateId && (await hasPendingPayment(this.data.mandateId)))
);
}
Expand All @@ -106,26 +112,34 @@ export default class GCProvider extends PaymentProvider<GCPaymentData> {
let subscription: Subscription | undefined;

if (this.data.subscriptionId) {
if (this.contact.membership?.isActive) {
if (
this.contact.membership?.isActive &&
this.contact.contributionPeriod === paymentForm.period
) {
subscription = await updateSubscription(
this.data.subscriptionId,
paymentForm
);
} else {
// Cancel failed subscriptions, we'll try a new one
// Cancel failed subscriptions or when period is changing
await this.cancelContribution(true);
}
}

const renewalDate = calcRenewalDate(this.contact);
let expiryDate;

if (!subscription) {
if (subscription) {
expiryDate = subscription.upcoming_payments![0].charge_date;
} else {
log.info("Creating new subscription");
subscription = await createSubscription(
this.data.mandateId,
paymentForm,
renewalDate
);
// The second payment is the first renewal payment when you first create a subscription
expiryDate = subscription.upcoming_payments![1].charge_date;
}

const startNow =
Expand All @@ -137,8 +151,6 @@ export default class GCProvider extends PaymentProvider<GCPaymentData> {
this.contact.contributionMonthlyAmount || 0
));

const expiryDate = await getSubscriptionNextChargeDate(subscription);

log.info("Activate contribution for " + this.contact.id, {
userId: this.contact.id,
paymentForm,
Expand All @@ -148,11 +160,16 @@ export default class GCProvider extends PaymentProvider<GCPaymentData> {

this.data.subscriptionId = subscription.id!;
this.data.payFee = paymentForm.payFee;
this.data.nextMonthlyAmount = startNow ? null : paymentForm.monthlyAmount;
this.data.nextAmount = startNow
? null
: {
monthly: paymentForm.monthlyAmount,
chargeable: Number(subscription.amount)
};

await this.updateData();

return { startNow, expiryDate };
return { startNow, expiryDate: moment.utc(expiryDate).toDate() };
}

async cancelContribution(keepMandate: boolean): Promise<void> {
Expand All @@ -161,7 +178,7 @@ export default class GCProvider extends PaymentProvider<GCPaymentData> {
const subscriptionId = this.data.subscriptionId;
const mandateId = this.data.mandateId;

this.data.nextMonthlyAmount = null;
this.data.nextAmount = null;
this.data.subscriptionId = null;
if (!keepMandate) {
this.data.mandateId = null;
Expand Down
1 change: 1 addition & 0 deletions src/core/providers/payment/StripeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export default class StripeProvider extends PaymentProvider<StripePaymentData> {
this.method,
calcRenewalDate(this.contact)
);
// Set this for the updateOrCreateContribution call below
this.data.subscriptionId = newSubscription.id;
}

Expand Down
5 changes: 4 additions & 1 deletion src/core/providers/payment/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ export abstract class PaymentProvider<T extends PaymentProviderData> {
});
}

abstract canChangeContribution(useExistingMandate: boolean): Promise<boolean>;
abstract canChangeContribution(
useExistingMandate: boolean,
paymentForm: PaymentForm
): Promise<boolean>;

abstract cancelContribution(keepMandate: boolean): Promise<void>;

Expand Down
10 changes: 3 additions & 7 deletions src/core/services/ContactsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,13 +314,9 @@ class ContactsService {
// prevent proration problems
if (
contact.membership?.isActive &&
// Manual annual contributors can't change their period
((wasManual &&
contact.contributionPeriod === ContributionPeriod.Annually &&
paymentForm.period !== ContributionPeriod.Annually) ||
// Automated contributors can't either
(contact.contributionType === ContributionType.Automatic &&
contact.contributionPeriod !== paymentForm.period))
// Annual contributors can't change their period
contact.contributionPeriod === ContributionPeriod.Annually &&
paymentForm.period !== ContributionPeriod.Annually
) {
throw new CantUpdateContribution();
}
Expand Down
5 changes: 3 additions & 2 deletions src/core/services/PaymentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,11 @@ class PaymentService {

async canChangeContribution(
contact: Contact,
useExistingPaymentSource: boolean
useExistingPaymentSource: boolean,
paymentForm: PaymentForm
): Promise<boolean> {
const ret = await this.provider(contact, (p) =>
p.canChangeContribution(useExistingPaymentSource)
p.canChangeContribution(useExistingPaymentSource, paymentForm)
);
log.info(
`User ${contact.id} ${ret ? "can" : "cannot"} change contribution`
Expand Down
2 changes: 1 addition & 1 deletion src/core/utils/payment/gocardless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export async function prorateSubscription(
await gocardless.payments.create({
amount: Math.floor(prorateAmount * 100).toFixed(0),
currency: config.currencyCode.toUpperCase() as PaymentCurrency,
// TODO: i18n description: "One-off payment to start new contribution",
description: "One-off payment to start new contribution",
links: {
mandate: mandateId
}
Expand Down
Loading

0 comments on commit b4c1b51

Please sign in to comment.