From 8dbcaf7a8e82d20046aced9146c7aa0bbd7eb408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franc=CC=A7ois=20Hodierne?= Date: Mon, 17 Aug 2026 08:58:32 +0200 Subject: [PATCH 1/2] fix(PlatformSubscription): terminate subscriptions at the start of the day so a new one can be created the same day --- server/models/Collective.ts | 3 +- server/models/PlatformSubscription.ts | 8 ++- .../models/PlatformSubscriptions.test.ts | 67 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/server/models/Collective.ts b/server/models/Collective.ts index 7d193fd1499..216f9b2997e 100644 --- a/server/models/Collective.ts +++ b/server/models/Collective.ts @@ -1145,7 +1145,8 @@ class Collective extends ModelWithPublicId< // Cancel the platform subscription if it exists const currentSubscription = await PlatformSubscription.getCurrentSubscription(this.id, { transaction }); if (currentSubscription) { - // Inclusive: false in case they start a new subscription on the same day. This also means we won't bill for the last day. + // The subscription is terminated at the start of the day (we don't bill for the deactivation day), + // so that a new one can be created if money management is reactivated the same day. await currentSubscription.terminate({ inclusive: false, transaction }); } }; diff --git a/server/models/PlatformSubscription.ts b/server/models/PlatformSubscription.ts index 86efa77b78e..eddbbb87000 100644 --- a/server/models/PlatformSubscription.ts +++ b/server/models/PlatformSubscription.ts @@ -229,7 +229,9 @@ class PlatformSubscription extends Model< if (subStart.isSameOrAfter(dayStart)) { await this.destroy({ transaction }); } else { - await this.update({ period: [this.start, { value: date, inclusive }] }, { transaction }); + // Periods are aligned to day boundaries (see `createSubscription`): terminate at the start of + // the day so that a subscription created later the same day doesn't overlap this one. + await this.update({ period: [this.start, { value: dayStart.toDate(), inclusive }] }, { transaction }); } } @@ -578,7 +580,9 @@ class PlatformSubscription extends Model< notify?: boolean; }, ): Promise { - const currentSubscription = await PlatformSubscription.getCurrentSubscription(collective.id); + const currentSubscription = await PlatformSubscription.getCurrentSubscription(collective.id, { + transaction: opts?.transaction, + }); const newSubscriptionStart = moment.utc(when).startOf('day'); const previousPlan = currentSubscription?.plan; diff --git a/test/server/models/PlatformSubscriptions.test.ts b/test/server/models/PlatformSubscriptions.test.ts index a0280716ba9..59a7dfc0eb9 100644 --- a/test/server/models/PlatformSubscriptions.test.ts +++ b/test/server/models/PlatformSubscriptions.test.ts @@ -240,6 +240,42 @@ describe('server/models/PlatformSubscriptions', () => { ).to.be.rejectedWith(Error); }); + it('can create a subscription the same day the previous one was terminated', async () => { + const admin = await fakeUser(); + const collective = await fakeCollective({ admin }); + const subscription = await PlatformSubscription.createSubscription( + collective, + new Date(Date.UTC(2016, 0, 1)), + { + title: 'A plan', + }, + admin, + ); + + // Terminate mid-day, like `deactivateMoneyManagement` does + await subscription.terminate({ date: new Date(Date.UTC(2016, 0, 5, 13, 59, 46)), inclusive: false }); + + // The termination is aligned to the start of the day + await subscription.reload(); + expect(subscription.end.inclusive).to.be.false; + expect(moment.utc(subscription.end.value).toISOString()).to.equal('2016-01-05T00:00:00.000Z'); + + const newSubscription = await PlatformSubscription.createSubscription( + collective, + new Date(Date.UTC(2016, 0, 5, 15, 0, 0)), + { + title: 'A plan', + }, + admin, + ); + + expect(newSubscription.start.inclusive).to.be.true; + expect(moment.utc(newSubscription.start.value).toISOString()).to.equal('2016-01-05T00:00:00.000Z'); + + expect(newSubscription.end.inclusive).to.be.true; + expect(newSubscription.end.value).to.equal(Infinity); + }); + it('emits PLATFORM_SUBSCRIPTION_UPDATED activity', async () => { const admin = await fakeUser(); const collective = await fakeCollective({ admin }); @@ -353,6 +389,37 @@ describe('server/models/PlatformSubscriptions', () => { expect(newSubscription.end.value).to.equal(Infinity); }); + it('can replace a subscription the same day the previous one was terminated', async () => { + const admin = await fakeUser(); + const collective = await fakeCollective({ admin }); + const subscription = await PlatformSubscription.createSubscription( + collective, + new Date(Date.UTC(2016, 0, 1)), + { + title: 'A plan', + }, + admin, + ); + + // Terminate mid-day, like `deactivateMoneyManagement` does + await subscription.terminate({ date: new Date(Date.UTC(2016, 0, 5, 13, 59, 46)), inclusive: false }); + + const newSubscription = await PlatformSubscription.replaceCurrentSubscription( + collective, + new Date(Date.UTC(2016, 0, 5, 15, 0, 0)), + { + title: 'A new plan', + }, + admin, + ); + + expect(newSubscription.start.inclusive).to.be.true; + expect(moment.utc(newSubscription.start.value).toISOString()).to.equal('2016-01-05T00:00:00.000Z'); + + expect(newSubscription.end.inclusive).to.be.true; + expect(newSubscription.end.value).to.equal(Infinity); + }); + it('emits PLATFORM_SUBSCRIPTION_UPDATED activity with legacy and new plan details', async () => { const admin = await fakeUser(); const collective = await fakeCollective({ admin }); From 25faf8c63e755c361ce461b398c7d252f5c8ef3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franc=CC=A7ois=20Hodierne?= Date: Tue, 18 Aug 2026 08:45:56 +0200 Subject: [PATCH 2/2] test(PlatformSubscription): cover repeated same-day terminate/re-create cycles --- .../models/PlatformSubscriptions.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/server/models/PlatformSubscriptions.test.ts b/test/server/models/PlatformSubscriptions.test.ts index 59a7dfc0eb9..a4654c27b4e 100644 --- a/test/server/models/PlatformSubscriptions.test.ts +++ b/test/server/models/PlatformSubscriptions.test.ts @@ -276,6 +276,43 @@ describe('server/models/PlatformSubscriptions', () => { expect(newSubscription.end.value).to.equal(Infinity); }); + it('supports repeatedly terminating and re-creating subscriptions the same day', async () => { + const admin = await fakeUser(); + const collective = await fakeCollective({ admin }); + const plan = { title: 'A plan' }; + const firstSubscription = await PlatformSubscription.createSubscription( + collective, + new Date(Date.UTC(2016, 0, 1)), + plan, + admin, + ); + + // First deactivation: the subscription started on a previous day, its period is truncated + await firstSubscription.terminate({ date: new Date(Date.UTC(2016, 0, 5, 10, 0, 0)), inclusive: false }); + + // Second and third cycles: the subscription started the same day, it is destroyed on termination + for (const hour of [11, 13]) { + const subscription = await PlatformSubscription.createSubscription( + collective, + new Date(Date.UTC(2016, 0, 5, hour, 0, 0)), + plan, + admin, + ); + await subscription.terminate({ date: new Date(Date.UTC(2016, 0, 5, hour + 1, 0, 0)), inclusive: false }); + await subscription.reload({ paranoid: false }); + expect(subscription.deletedAt).to.not.be.null; + } + + const lastSubscription = await PlatformSubscription.createSubscription( + collective, + new Date(Date.UTC(2016, 0, 5, 15, 0, 0)), + plan, + admin, + ); + expect(moment.utc(lastSubscription.start.value).toISOString()).to.equal('2016-01-05T00:00:00.000Z'); + expect(lastSubscription.end.value).to.equal(Infinity); + }); + it('emits PLATFORM_SUBSCRIPTION_UPDATED activity', async () => { const admin = await fakeUser(); const collective = await fakeCollective({ admin });