diff --git a/src/api/controllers/ContactController.ts b/src/api/controllers/ContactController.ts index fd8f66eab..93d964f84 100644 --- a/src/api/controllers/ContactController.ts +++ b/src/api/controllers/ContactController.ts @@ -62,10 +62,9 @@ import { StartJoinFlowData } from "@api/data/JoinFlowData"; import { - SetContributionData, StartContributionData, - UpdateContributionData, - ForceUpdateContributionData + ForceUpdateContributionData, + UpdateContributionData } from "@api/data/ContributionData"; import { mergeRules, @@ -251,19 +250,11 @@ export class ContactController { @TargetUser() target: Contact, @Body() data: UpdateContributionData ): Promise { - // 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); } @@ -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(); } @@ -398,10 +389,6 @@ export class ContactController { target: Contact, data: CompleteJoinFlowData ): Promise { - if (!(await PaymentService.canChangeContribution(target, false))) { - throw new CantUpdateContribution(); - } - const joinFlow = await PaymentFlowService.getJoinFlowByPaymentId( data.paymentFlowId ); @@ -409,6 +396,16 @@ export class ContactController { 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); diff --git a/src/api/data/ContributionData.ts b/src/api/data/ContributionData.ts index fd9fcffe2..0e51f135a 100644 --- a/src/api/data/ContributionData.ts +++ b/src/api/data/ContributionData.ts @@ -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; @@ -46,7 +40,7 @@ export class SetContributionData implements ContributionData { } export class StartContributionData - extends SetContributionData + extends UpdateContributionData implements StartJoinFlowData { @IsUrl() @@ -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; diff --git a/src/apps/members/apps/member/apps/contribution/app.ts b/src/apps/members/apps/member/apps/contribution/app.ts index c631e4ba6..802432961 100644 --- a/src/apps/members/apps/member/apps/contribution/app.ts +++ b/src/apps/members/apps/member/apps/contribution/app.ts @@ -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 diff --git a/src/core/providers/payment/GCProvider.ts b/src/core/providers/payment/GCProvider.ts index 55ab904ac..575d9681a 100644 --- a/src/core/providers/payment/GCProvider.ts +++ b/src/core/providers/payment/GCProvider.ts @@ -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"; @@ -60,10 +61,10 @@ export default class GCProvider extends PaymentProvider { 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 ) }), @@ -71,7 +72,10 @@ export default class GCProvider extends PaymentProvider { }; } - async canChangeContribution(useExistingMandate: boolean): Promise { + async canChangeContribution( + useExistingMandate: boolean, + paymentForm: PaymentForm + ): Promise { // No payment method available if (useExistingMandate && !this.data.mandateId) { return false; @@ -82,11 +86,13 @@ export default class GCProvider extends PaymentProvider { 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))) ); } @@ -106,26 +112,34 @@ export default class GCProvider extends PaymentProvider { 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 = @@ -137,8 +151,6 @@ export default class GCProvider extends PaymentProvider { this.contact.contributionMonthlyAmount || 0 )); - const expiryDate = await getSubscriptionNextChargeDate(subscription); - log.info("Activate contribution for " + this.contact.id, { userId: this.contact.id, paymentForm, @@ -148,11 +160,16 @@ export default class GCProvider extends PaymentProvider { 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 { @@ -161,7 +178,7 @@ export default class GCProvider extends PaymentProvider { 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; diff --git a/src/core/providers/payment/StripeProvider.ts b/src/core/providers/payment/StripeProvider.ts index c5dd37ba2..8eb134ad9 100644 --- a/src/core/providers/payment/StripeProvider.ts +++ b/src/core/providers/payment/StripeProvider.ts @@ -141,6 +141,7 @@ export default class StripeProvider extends PaymentProvider { this.method, calcRenewalDate(this.contact) ); + // Set this for the updateOrCreateContribution call below this.data.subscriptionId = newSubscription.id; } diff --git a/src/core/providers/payment/index.ts b/src/core/providers/payment/index.ts index d23e410b8..e6543c94c 100644 --- a/src/core/providers/payment/index.ts +++ b/src/core/providers/payment/index.ts @@ -30,7 +30,10 @@ export abstract class PaymentProvider { }); } - abstract canChangeContribution(useExistingMandate: boolean): Promise; + abstract canChangeContribution( + useExistingMandate: boolean, + paymentForm: PaymentForm + ): Promise; abstract cancelContribution(keepMandate: boolean): Promise; diff --git a/src/core/services/ContactsService.ts b/src/core/services/ContactsService.ts index 230e09f6b..176f9b5af 100644 --- a/src/core/services/ContactsService.ts +++ b/src/core/services/ContactsService.ts @@ -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(); } diff --git a/src/core/services/PaymentService.ts b/src/core/services/PaymentService.ts index 7223379f9..4ce2545e7 100644 --- a/src/core/services/PaymentService.ts +++ b/src/core/services/PaymentService.ts @@ -92,10 +92,11 @@ class PaymentService { async canChangeContribution( contact: Contact, - useExistingPaymentSource: boolean + useExistingPaymentSource: boolean, + paymentForm: PaymentForm ): Promise { const ret = await this.provider(contact, (p) => - p.canChangeContribution(useExistingPaymentSource) + p.canChangeContribution(useExistingPaymentSource, paymentForm) ); log.info( `User ${contact.id} ${ret ? "can" : "cannot"} change contribution` diff --git a/src/core/utils/payment/gocardless.ts b/src/core/utils/payment/gocardless.ts index a922df8ac..ec72ede53 100644 --- a/src/core/utils/payment/gocardless.ts +++ b/src/core/utils/payment/gocardless.ts @@ -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 } diff --git a/src/core/utils/payment/stripe.ts b/src/core/utils/payment/stripe.ts index 71470f724..5717e2127 100644 --- a/src/core/utils/payment/stripe.ts +++ b/src/core/utils/payment/stripe.ts @@ -30,6 +30,41 @@ function getPriceData( }; } +async function calculateProrationParams( + subscription: Stripe.Subscription, + subscriptionItem: Stripe.InvoiceRetrieveUpcomingParams.SubscriptionItem +) { + // Prorate by whole months + const monthsLeft = Math.max( + 0, + differenceInMonths(subscription.current_period_end * 1000, new Date()) + ); + // Calculate exact number of seconds to remove (rather than just "one month") + // as this aligns with Stripe's calculations + const prorationTime = + subscription.current_period_end - + (subscription.current_period_end - subscription.current_period_start) * + (monthsLeft / 12); + + const invoice = await stripe.invoices.retrieveUpcoming({ + subscription: subscription.id, + subscription_items: [subscriptionItem], + subscription_proration_date: prorationTime + }); + + const prorationAmount = invoice.lines.data + .filter((item) => item.proration) + .reduce((total, item) => total + item.amount, 0); + + return { + // Only prorate amounts above 100 cents. This aligns with GoCardless's minimum + // amount and is much simpler than trying to calculate the minimum payment per + // payment method + prorationAmount: prorationAmount < 100 ? 0 : prorationAmount, + prorationTime + }; +} + export async function createSubscription( customerId: string, paymentForm: PaymentForm, @@ -53,8 +88,6 @@ export async function createSubscription( }); } -const SECONDS_IN_A_YEAR = 365 * 24 * 60 * 60; - export async function updateSubscription( subscriptionId: string, paymentForm: PaymentForm, @@ -63,48 +96,24 @@ export async function updateSubscription( const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ["schedule"] }); + const newSubscriptionItem = { + id: subscription.items.data[0].id, + price_data: getPriceData(paymentForm, paymentMethod) + }; - const renewalDate = new Date(subscription.current_period_end * 1000); - const monthsLeft = Math.max(0, differenceInMonths(renewalDate, new Date())); - // Calculate exact number of seconds to remove (rather than just "one month") - // as this aligns with Stripe's calculations - const prorationTs = Math.floor( - +renewalDate / 1000 - SECONDS_IN_A_YEAR * (monthsLeft / 12) + const { prorationAmount, prorationTime } = await calculateProrationParams( + subscription, + newSubscriptionItem ); - const priceData = getPriceData(paymentForm, paymentMethod); - const subscriptionItems = [ - { - id: subscription.items.data[0].id, - price_data: priceData - } - ]; - - const invoice = await stripe.invoices.retrieveUpcoming({ - subscription: subscriptionId, - subscription_items: subscriptionItems, - subscription_proration_date: prorationTs - }); - - const prorationAmount = invoice.lines.data - .filter((item) => item.proration) - .reduce((total, item) => total + item.amount, 0); - - // Only prorate amounts above 100 cents. This aligns with GoCardless's minimum - // amount and is much simpler than trying to calculate the minimum payment per - // payment method - const wouldProrate = prorationAmount < 0 || prorationAmount >= 100; - - log.info("Preparing update subscription for " + subscriptionId, { - renewalDate, - prorationDate: new Date(prorationTs * 1000), - wouldProrate, + log.info("Preparing update subscription for " + subscription.id, { + renewalDate: new Date(subscription.current_period_end * 1000), + prorationDate: new Date(prorationTime * 1000), + prorationAmount, paymentForm }); - const startNow = - prorationAmount >= 0 && (!wouldProrate || paymentForm.prorate); - + // Clear any previous schedule const oldSchedule = subscription.schedule as Stripe.SubscriptionSchedule | null; if ( @@ -115,20 +124,28 @@ export async function updateSubscription( await stripe.subscriptionSchedules.release(oldSchedule.id); } + const startNow = prorationAmount === 0 || paymentForm.prorate; + if (startNow) { + // Start new contribution immediately (monthly or prorated annuals) log.info(`Updating subscription for ${subscription.id}`); await stripe.subscriptions.update(subscriptionId, { - items: subscriptionItems, - ...(wouldProrate && paymentForm.prorate + items: [newSubscriptionItem], + ...(prorationAmount > 0 ? { proration_behavior: "always_invoice", - proration_date: prorationTs + proration_date: prorationTime } : { - proration_behavior: "none" + proration_behavior: "none", + // Force it to change at the start of the next period, this is + // important when changing from monthly to annual as otherwise + // Stripe starts the new billing cycle immediately + trial_end: subscription.current_period_end }) }); } else { + // Schedule the change for the next period log.info(`Creating new schedule for ${subscription.id}`); const schedule = await stripe.subscriptionSchedules.create({ from_subscription: subscription.id @@ -143,7 +160,7 @@ export async function updateSubscription( }, { start_date: schedule.phases[0].end_date, - items: [{ price_data: priceData }] + items: [{ price_data: newSubscriptionItem.price_data }] } ] }); diff --git a/src/migrations/1696600862719-UpdateGCNextAmount.ts b/src/migrations/1696600862719-UpdateGCNextAmount.ts new file mode 100644 index 000000000..a7bb35ce2 --- /dev/null +++ b/src/migrations/1696600862719-UpdateGCNextAmount.ts @@ -0,0 +1,79 @@ +import { ContributionPeriod, PaymentMethod } from "@beabee/beabee-common"; +import { MigrationInterface, QueryRunner } from "typeorm"; + +import { getChargeableAmount } from "@core/utils/payment"; + +interface PaymentQueryResults { + id: string; + data: { + nextMonthlyAmount: number; + payFee: boolean; + }; + contributionPeriod: ContributionPeriod; +} + +export class UpdateGCNextAmount1696600862719 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Set nextAmount to null if nextMonthlyAmount is null + queryRunner.query(` + UPDATE "payment_data" SET "data"="data" || '{"nextAmount": null}'::jsonb + WHERE "method"='gc_direct-debit' AND "data"->>'nextMonthlyAmount' IS NULL; + `); + + // Migrate to nextAmount if nextMonthlyAmount is not null + const results: PaymentQueryResults[] = await queryRunner.query(` + SELECT c.id, pd."data", c."contributionPeriod" FROM "payment_data" pd INNER JOIN "contact" c ON pd."contactId"=c.id + WHERE pd."method"='gc_direct-debit' AND pd."data"->>'nextMonthlyAmount' IS NOT NULL; + `); + + for (const result of results) { + const chargeableAmount = getChargeableAmount( + { + monthlyAmount: result.data.nextMonthlyAmount, + period: result.contributionPeriod, + payFee: result.data.payFee, + prorate: false + }, + PaymentMethod.GoCardlessDirectDebit + ); + + const nextAmount = { + monthly: result.data.nextMonthlyAmount, + charegable: chargeableAmount + }; + + queryRunner.query( + ` + UPDATE "payment_data" + SET "data"=jsonb_set("data", '{nextAmount}', $1) + WHERE "contactId"=$2 AND "method"='gc_direct-debit'; + `, + [JSON.stringify(nextAmount), result.id] + ); + } + + // Remove nextMonthlyAmount + queryRunner.query( + `UPDATE "payment_data" SET "data"="data"-'nextMonthlyAmount' WHERE "method"='gc_direct-debit'` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + queryRunner.query(` + UPDATE "payment_data" + SET "data"=jsonb_set( + "data", + '{nextMonthlyAmount}', + "data"->'nextAmount'->'monthly' + ) + WHERE "method"='gc_direct-debit' AND "data"->'nextAmount' IS NOT NULL; + `); + queryRunner.query(` + UPDATE "payment_data" SET "data"="data" || '{"nextMonthlyAmount": null}'::jsonb + WHERE "method"='gc_direct-debit' AND "data"->'nextAmount' IS NULL; + `); + queryRunner.query( + `UPDATE "payment_data" SET "data"="data"-'nextAmount' WHERE "method"='gc_direct-debit'` + ); + } +} diff --git a/src/models/PaymentData.ts b/src/models/PaymentData.ts index e9c8fbea9..3c58de8db 100644 --- a/src/models/PaymentData.ts +++ b/src/models/PaymentData.ts @@ -8,7 +8,10 @@ export interface GCPaymentData { mandateId: string | null; subscriptionId: string | null; payFee: boolean | null; - nextMonthlyAmount: number | null; + nextAmount: { + chargeable: number; + monthly: number; + } | null; } export interface ManualPaymentData { diff --git a/src/webhooks/handlers/gocardless.ts b/src/webhooks/handlers/gocardless.ts index 5c4a9b822..960fc14b5 100644 --- a/src/webhooks/handlers/gocardless.ts +++ b/src/webhooks/handlers/gocardless.ts @@ -85,10 +85,7 @@ async function handlePaymentResourceEvent(event: Event) { if (event.action === PaymentStatus.PaidOut) { await updatePaymentStatus(event.links!.payment!, PaymentStatus.PaidOut); } else { - await updatePayment( - event.links!.payment!, - event.action === PaymentStatus.Confirmed - ); + await updatePayment(event.links!.payment!, event.action); } } diff --git a/src/webhooks/utils/gocardless.ts b/src/webhooks/utils/gocardless.ts index a6c40f5d5..1a1660aa1 100644 --- a/src/webhooks/utils/gocardless.ts +++ b/src/webhooks/utils/gocardless.ts @@ -1,7 +1,7 @@ import { Payment as GCPayment, PaymentStatus as GCPaymentStatus, - Subscription, + Subscription as GCSubscription, SubscriptionIntervalUnit } from "gocardless-nodejs/types/Types"; import moment, { DurationInputObject } from "moment"; @@ -18,12 +18,13 @@ import { GCPaymentData } from "@models/PaymentData"; import Payment from "@models/Payment"; import config from "@config"; +import { PaymentStatus } from "@beabee/beabee-common"; const log = mainLogger.child({ app: "payment-webhook-utils" }); export async function updatePayment( gcPaymentId: string, - isConfirmed: boolean = false + action?: string ): Promise { log.info("Update payment " + gcPaymentId); @@ -39,12 +40,42 @@ export async function updatePayment( await getRepository(Payment).save(payment); - if (isConfirmed) { - await confirmPayment(payment); + switch (action) { + case "cancelled": + case "failed": + await cancelPayment(payment); + break; + + case "confirmed": + await confirmPayment(payment); + break; } } } +async function cancelPayment(payment: Payment): Promise { + if (!payment.contact?.membership?.isActive || !payment.subscriptionId) { + return; + } + + const expiryDate = await calcFailedPaymentPeriodEnd(payment); + + log.info("Cancel payment " + payment.id, { + paymentId: payment.id, + contactId: payment.contact.id, + subscriptionId: payment.subscriptionId, + expiryDate + }); + + if (expiryDate) { + await ContactsService.updateContactRole(payment.contact, "member", { + dateExpires: expiryDate + }); + } else { + await ContactsService.revokeContactRole(payment.contact, "member"); + } +} + async function confirmPayment(payment: Payment): Promise { log.info("Confirm payment " + payment.id, { paymentId: payment.id, @@ -59,45 +90,45 @@ async function confirmPayment(payment: Payment): Promise { return; } - const gcData = (await PaymentService.getData(payment.contact)) - .data as GCPaymentData; - - if (payment.subscriptionId !== gcData.subscriptionId) { - log.error("Mismatched subscription IDs for payment " + payment.id, { - gcSubscriptionId: payment.subscriptionId, - ourSubscriptionId: gcData.subscriptionId - }); - return; - } - - // If there's a pending amount change we assume this confirms it. Because we - // don't allow subscription changes while any payments are pending there can't - // be a pending payment for the previous amount - if (gcData.nextMonthlyAmount) { - await PaymentService.updateDataBy( - payment.contact, - "nextMonthlyAmount", - null - ); - await ContactsService.updateContact(payment.contact, { - contributionMonthlyAmount: gcData.nextMonthlyAmount - }); - } + // TODO: Keep list of valid subscriptions + // if (payment.subscriptionId !== gcData.subscriptionId) { + // log.error("Mismatched subscription IDs for payment " + payment.id, { + // gcSubscriptionId: payment.subscriptionId, + // ourSubscriptionId: gcData.subscriptionId + // }); + // return; + // } await ContactsService.extendContactRole( payment.contact, "member", - await getSubscriptionPeriodEnd(payment) + await calcConfirmedPaymentPeriodEnd(payment) ); + + const data = await PaymentService.getData(payment.contact); + const gcData = data.data as GCPaymentData; + if (payment.amount === gcData.nextAmount?.chargeable) { + await ContactsService.updateContact(payment.contact, { + contributionMonthlyAmount: gcData.nextAmount?.monthly + }); + await PaymentService.updateDataBy(payment.contact, "nextAmount", null); + } // TODO: resubscribe to newsletter } -async function getSubscriptionPeriodEnd(payment: Payment): Promise { +/** + * Calculate when subscription this payment is associated with will renew. This + * assumes the payment was confirmed and either uses the subscription's next + * payment date or the payment's date if the subscription has been cancelled + * + * @param payment The confirmed payment + * @returns The current period end for the subscription + */ +async function calcConfirmedPaymentPeriodEnd(payment: Payment): Promise { const subscription = await gocardless.subscriptions.get( payment.subscriptionId! ); - // If the subscription has been cancelled there won't be any upcoming payments if ( subscription.upcoming_payments && subscription.upcoming_payments.length > 0 @@ -107,6 +138,7 @@ async function getSubscriptionPeriodEnd(payment: Payment): Promise { .add(config.gracePeriod) .toDate(); } else { + // If the subscription has been cancelled there won't be any upcoming payments return moment .utc(payment.chargeDate) .add(getSubscriptionDuration(subscription)) @@ -114,8 +146,37 @@ async function getSubscriptionPeriodEnd(payment: Payment): Promise { } } +/** + * Calculates when the subscription has been paid up until + * + * @param payment The failed or cancelled payment + * @returns The last successful payment's period end or undefined if there isn't one + */ +async function calcFailedPaymentPeriodEnd( + payment: Payment +): Promise { + const subscription = await gocardless.subscriptions.get( + payment.subscriptionId! + ); + + const latestSuccessfulPayment = await getRepository(Payment).findOne({ + where: { + subscriptionId: payment.subscriptionId, + status: PaymentStatus.Successful + }, + order: { chargeDate: "DESC" } + }); + + if (latestSuccessfulPayment) { + return moment + .utc(latestSuccessfulPayment.chargeDate) + .add(getSubscriptionDuration(subscription)) + .toDate(); + } +} + function getSubscriptionDuration( - subscription: Subscription + subscription: GCSubscription ): DurationInputObject { const unit = subscription.interval_unit === SubscriptionIntervalUnit.Yearly