Skip to content

Commit

Permalink
feat(subscription): support year interval
Browse files Browse the repository at this point in the history
  • Loading branch information
gregberge committed Jan 3, 2025
1 parent 890ac6c commit 3999acd
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 104 deletions.
57 changes: 40 additions & 17 deletions apps/backend/src/database/models/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,36 +225,59 @@ export class Account extends Model {

const getCurrentPeriodStartDate = memoize(async () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
if (this.forcedPlanId) {
return startOfMonth;
const [subscription, plan] = await Promise.all([
getActiveSubscription(),
getPlan(),
]);
if (subscription?.startDate) {
invariant(plan, "If there is a subscription, there should be a plan");
return subscription.getLastResetDate(now, plan.interval);
}
const interval = plan?.interval ?? "month";
switch (interval) {
case "month":
return new Date(now.getFullYear(), now.getMonth(), 1);
case "year":
return new Date(now.getFullYear(), 0, 1);
default:
assertNever(interval);
}
const subscription = await getActiveSubscription();
return subscription?.startDate
? subscription.getLastResetDate()
: startOfMonth;
});

const getCurrentPeriodEndDate = memoize(async () => {
const [startDate, activeSubscription] = await Promise.all([
const [startDate, activeSubscription, plan] = await Promise.all([
getCurrentPeriodStartDate(),
getActiveSubscription(),
getPlan(),
]);

if (activeSubscription?.status === "trialing") {
invariant(activeSubscription.trialEndDate);
return new Date(activeSubscription.trialEndDate);
}

const now = new Date();
const endDate = new Date(startDate);
endDate.setMonth(startDate.getMonth() + 1);
return new Date(
Math.min(
endDate.getTime(),
new Date(now.getFullYear(), now.getMonth() + 2, 0).getTime(),
),
);
const interval = plan?.interval ?? "month";

switch (interval) {
case "month": {
const now = new Date();
const endDate = new Date(startDate);
endDate.setMonth(startDate.getMonth() + 1);
return new Date(
Math.min(
endDate.getTime(),
new Date(now.getFullYear(), now.getMonth() + 2, 0).getTime(),
),
);
}
case "year": {
const endDate = new Date(startDate);
endDate.setFullYear(startDate.getFullYear() + 1);
return endDate;
}
default:
assertNever(interval);
}
});

const getCurrentPeriodScreenshots = memoize(async () => {
Expand Down
149 changes: 87 additions & 62 deletions apps/backend/src/database/models/Subscription.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,82 +8,107 @@ describe("Subscription", () => {
});

describe("#getLastResetDate", () => {
it("returns current month after reset date", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10").toISOString(),
describe("month interval", () => {
it("returns current month after reset date", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10").toISOString(),
});
const now = new Date("2023-04-26");
const expectedResetDate = new Date("2023-04-10");
expect(subscription.getLastResetDate(now, "month").toISOString()).toBe(
expectedResetDate.toISOString(),
);
});
const now = new Date("2023-04-26");
const expectedResetDate = new Date("2023-04-10");
expect(subscription.getLastResetDate(now).toISOString()).toBe(
expectedResetDate.toISOString(),
);
});

it("returns previous month before reset date", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10").toISOString(),
it("returns previous month before reset date", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10").toISOString(),
});
const now = new Date("2023-04-05");
const expectedResetDate = new Date("2023-03-10");
expect(subscription.getLastResetDate(now, "month").toISOString()).toBe(
expectedResetDate.toISOString(),
);
});
const now = new Date("2023-04-05");
const expectedResetDate = new Date("2023-03-10");
expect(subscription.getLastResetDate(now).toISOString()).toBe(
expectedResetDate.toISOString(),
);
});

it("returns previous year before reset date", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2015-03-31").toISOString(),
it("returns previous year before reset date", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2015-03-31").toISOString(),
});
const now = new Date("2023-01-15");
const expectedResetDate = new Date("2022-12-31");
expect(subscription.getLastResetDate(now, "month").toISOString()).toBe(
expectedResetDate.toISOString(),
);
});
const now = new Date("2023-01-15");
const expectedResetDate = new Date("2022-12-31");
expect(subscription.getLastResetDate(now).toISOString()).toBe(
expectedResetDate.toISOString(),
);
});

it("returns previous month before reset time", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10T14:00:00.000Z").toISOString(),
it("returns previous month before reset time", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10T14:00:00.000Z").toISOString(),
});
const now = new Date("2023-05-10T13:00:00.000Z");
const expectedResetDate = new Date("2023-04-10T14:00:00.000Z");
expect(subscription.getLastResetDate(now, "month").toISOString()).toBe(
expectedResetDate.toISOString(),
);
});
const now = new Date("2023-05-10T13:00:00.000Z");
const expectedResetDate = new Date("2023-04-10T14:00:00.000Z");
expect(subscription.getLastResetDate(now).toISOString()).toBe(
expectedResetDate.toISOString(),
);
});

it("returns previous month after reset time", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10T14:00:00.000Z").toISOString(),
it("returns current month after reset time", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10T14:00:00.000Z").toISOString(),
});
const now = new Date("2023-05-10T16:00:00.000Z");
const expectedResetDate = new Date("2023-05-10T14:00:00.000Z");
expect(subscription.getLastResetDate(now, "month").toISOString()).toBe(
expectedResetDate.toISOString(),
);
});
const now = new Date("2023-05-10T16:00:00.000Z");
const expectedResetDate = new Date("2023-05-10T14:00:00.000Z");
expect(subscription.getLastResetDate(now).toISOString()).toBe(
expectedResetDate.toISOString(),
);
});

it("returns end of month when reset date exceed month time", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2015-01-31").toISOString(),
describe.only("year interval", () => {
it("returns current year after reset date", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10").toISOString(),
});
const now = new Date("2023-04-26");
const expectedResetDate = new Date("2023-03-10");
expect(subscription.getLastResetDate(now, "year").toISOString()).toBe(
expectedResetDate.toISOString(),
);
});

it("returns previous year before reset date", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10").toISOString(),
});
const now = new Date("2023-02-05");
const expectedResetDate = new Date("2022-03-10");
expect(subscription.getLastResetDate(now, "year").toISOString()).toBe(
expectedResetDate.toISOString(),
);
});

it("returns previous year before reset time", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10T14:00:00.000Z").toISOString(),
});
const now = new Date("2023-03-10T13:00:00.000Z");
const expectedResetDate = new Date("2022-03-10T14:00:00.000Z");
expect(subscription.getLastResetDate(now, "year").toISOString()).toBe(
expectedResetDate.toISOString(),
);
});
const now = new Date("2023-03-05");
const resetDate = subscription.getLastResetDate(now).toISOString();
expect(resetDate).toBe(
new Date("2023-02-28T24:00:00.000Z").toISOString(),
);
expect(resetDate).toBe(new Date("2023-03-01").toISOString());
});

it("returns subscription date end of first month", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10").toISOString(),
it("returns current year after reset time", async () => {
const subscription = await factory.Subscription.create({
startDate: new Date("2021-03-10T14:00:00.000Z").toISOString(),
});
const now = new Date("2023-03-10T16:00:00.000Z");
const expectedResetDate = new Date("2023-03-10T14:00:00.000Z");
expect(subscription.getLastResetDate(now, "year").toISOString()).toBe(
expectedResetDate.toISOString(),
);
});
const now = new Date("2021-03-15");
const expectedResetDate = new Date("2021-03-10");
expect(subscription.getLastResetDate(now).toISOString()).toBe(
expectedResetDate.toISOString(),
);
});
});
});
49 changes: 25 additions & 24 deletions apps/backend/src/database/models/Subscription.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { assertNever } from "@argos/util/assertNever";
import type { RelationMappings } from "objection";

import { Model } from "../util/model.js";
Expand All @@ -7,28 +8,6 @@ import { Plan } from "./Plan.js";

export type SubscriptionInterval = "month" | "year";

const getStartOf = (date: Date, interval: SubscriptionInterval) => {
switch (interval) {
case "month":
return new Date(date.getFullYear(), date.getMonth(), 1);
case "year":
return new Date(date.getFullYear(), 0, 1);
default:
throw new Error(`Invalid interval: ${interval}`);
}
};

const getStartOfPrevious = (date: Date, interval: SubscriptionInterval) => {
switch (interval) {
case "month":
return new Date(date.getFullYear(), date.getMonth() - 1, 1);
case "year":
return new Date(date.getFullYear() - 1, 0, 1);
default:
throw new Error(`Invalid interval: ${interval}`);
}
};

export class Subscription extends Model {
static override tableName = "subscriptions";

Expand Down Expand Up @@ -108,7 +87,7 @@ export class Subscription extends Model {
account?: Account;
plan?: Plan;

getLastResetDate(now = new Date(), interval: "month" | "year" = "month") {
getLastResetDate(now: Date, interval: SubscriptionInterval) {
const startOfPeriod = getStartOf(now, interval);
const startDate = new Date(this.startDate);
const periodDuration = now.getTime() - startOfPeriod.getTime();
Expand All @@ -118,7 +97,7 @@ export class Subscription extends Model {
periodDuration > subscriptionPeriodDuration;

return billingHasResetThisPeriod
? new Date(startOfPeriod.getTime() + periodDuration)
? new Date(startOfPeriod.getTime() + subscriptionPeriodDuration)
: new Date(
Math.min(
getStartOfPrevious(now, interval).getTime() +
Expand All @@ -128,3 +107,25 @@ export class Subscription extends Model {
);
}
}

function getStartOf(date: Date, interval: SubscriptionInterval) {
switch (interval) {
case "month":
return new Date(date.getFullYear(), date.getMonth(), 1);
case "year":
return new Date(date.getFullYear(), 0, 1);
default:
assertNever(interval);
}
}

function getStartOfPrevious(date: Date, interval: SubscriptionInterval) {
switch (interval) {
case "month":
return new Date(date.getFullYear(), date.getMonth() - 1, 1);
case "year":
return new Date(date.getFullYear() - 1, 0, 1);
default:
assertNever(interval);
}
}
2 changes: 1 addition & 1 deletion apps/frontend/src/containers/PlanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ function ManageSubscriptionButton({

function Period({ start, end }: { start: string; end: string }) {
const sameYear = moment(start).isSame(end, "year");
const format = sameYear ? "MMM DD" : "MMM DD YYYY";
const format = sameYear ? "MMM DD" : "MMM D, YYYY";
return (
<div className="font-medium">
Current period ({<Time date={start} format={format} />} -{" "}
Expand Down

0 comments on commit 3999acd

Please sign in to comment.