Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/backend/src/rpc/handlers/customers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1091,7 +1091,7 @@ export const CustomerAdminRpcLive = CustomerAdminRpcs.toLayer(
)
}).pipe(remap),

"customers.settings.update": ({ health, reportingCurrency, addOnPlans }) =>
"customers.settings.update": ({ health, reportingCurrency, addOnPlans, overageInMrr }) =>
Effect.gen(function* () {
const actor = yield* requireAdmin
const now = yield* currentTime
Expand All @@ -1104,6 +1104,7 @@ export const CustomerAdminRpcLive = CustomerAdminRpcs.toLayer(
health,
reportingCurrency: reportingCurrency?.toLowerCase(),
addOnPlans,
overageInMrr,
updatedBy: actor.userId,
updatedAt: now,
})
Expand Down
54 changes: 54 additions & 0 deletions apps/backend/src/worker/objects/customers/compute-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,60 @@ describe("Customers compute and sync loop", () => {
}),
))

it("counts a subscription's last paid Stripe invoice, overage included, while overage counts in MRR", () =>
run(
Effect.gen(function* () {
const world = yield* makeCustomersWorld
yield* quietOrg(world)
yield* world.ingest("stripe", [
subscription(60),
...[
{ id: "in_1", amount: 21_000, daysAgo: 35 },
{ id: "in_2", amount: 24_350, daysAgo: 5 },
].map(({ id, amount, daysAgo }): IngestOp => ({
_tag: "invoice",
externalId: id,
account: { provider: "clerk", externalId: "org_1" },
subscriptionExternalId: "sub_1",
number: id,
status: "paid",
amountDueMinor: amount,
amountPaidMinor: amount,
currency: "usd",
issuedAt: new Date(NOW.getTime() - daysAgo * DAY).toISOString(),
dueAt: null,
paidAt: new Date(NOW.getTime() - daysAgo * DAY).toISOString(),
attemptCount: 1,
})),
])
const mrr = () => world.rows(`SELECT mrrMinor FROM customer_accounts`)
yield* world.object.run(runCompute(NOW)).pipe(Effect.orDie)
expect(mrr()).toEqual([{ mrrMinor: 19_900 }])

ok(yield* world.call("customers.settings.update", { overageInMrr: true }, world.owner))
yield* world.object.run(runCompute(NOW)).pipe(Effect.orDie)
expect(mrr()).toEqual([{ mrrMinor: 24_350 }])

// A history rebuild leaves the invoices' MRR to compute; today's movement ends the plans' series there.
yield* world.object.run(requestHistoryRebuild).pipe(Effect.orDie)
yield* world.object.run(runComputeStep(NOW)).pipe(Effect.orDie)
expect(meta(world, "rebuildHistory")).toBeUndefined()
expect(mrr()).toEqual([{ mrrMinor: 24_350 }])
expect(
world.rows(
`SELECT day, type, fromMrrMinor, toMrrMinor FROM customer_mrr_movements ORDER BY day`,
),
).toEqual([
{ day: day(-60), type: "new", fromMrrMinor: 0, toMrrMinor: 19_900 },
{ day: day(0), type: "expansion", fromMrrMinor: 19_900, toMrrMinor: 24_350 },
])

ok(yield* world.call("customers.settings.update", { overageInMrr: false }, world.owner))
yield* world.object.run(runCompute(NOW)).pipe(Effect.orDie)
expect(mrr()).toEqual([{ mrrMinor: 19_900 }])
}),
))

it("keeps a Stripe payer on Stripe through an Autumn trial; its conversion is a switch, not revenue", () =>
run(
Effect.gen(function* () {
Expand Down
21 changes: 20 additions & 1 deletion apps/backend/src/worker/objects/customers/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1000,7 +1000,26 @@ export const rebuildHistory = Effect.fn("Customers.rebuildHistory")(function* (b
})
}
const current = series[series.length - 1]
if (current !== account.mrrMinor) mrrNow.push({ id: account.id, mrrMinor: current })
if (current === account.mrrMinor) continue
if (!base.settings.overageInMrr) {
mrrNow.push({ id: account.id, mrrMinor: current })
continue
}
// With `overageInMrr`, today's MRR is the invoices' (compute's): today's movement (one per day)
// ends there instead of at the plans' series end.
const last = movements.at(-1)
const own = last?.accountId === account.id && last.day === today ? movements.pop() : undefined
const from = own === undefined ? current : (own.fromMrrMinor as number)
if (from !== account.mrrMinor)
movements.push({
accountId: account.id,
day: today,
type: movementType(from, account.mrrMinor, everPaid),
deltaMinor: account.mrrMinor - from,
fromMrrMinor: from,
toMrrMinor: account.mrrMinor,
currency,
})
}
const users = yield* activeCounts(first, today)
const health = new Map(
Expand Down
145 changes: 143 additions & 2 deletions apps/backend/src/worker/objects/customers/snapshot.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/**
* The pure account rollup (`rollupAccount`): which subscription names the plan when some plans are
* add-ons, and that add-ons still count for MRR.
* add-ons, that add-ons still count for MRR, and MRR from paid Stripe invoices (`overageInMrr`).
*/
import type { schema } from "@hazel/db"
import type { CustomerProvider } from "@hazel/domain/customers"
import type { CustomerSubscriptionId } from "@hazel/schema"
import { describe, expect, it } from "vitest"
import { rollupAccount } from "./snapshot"
import { invoiceMrrOf, rollupAccount } from "./snapshot"
import { factsOf, NOW, TODAY } from "./test-kit"

const rollupOf = (
Expand All @@ -17,6 +19,7 @@ const rollupOf = (
subscriptions: facts.subscriptions,
mrrSource: "autumn",
addOnPlans,
invoiceMrr: [],
contacts: facts.contacts,
activity: facts.activity,
lastActiveDay: facts.lastActiveDay,
Expand Down Expand Up @@ -100,3 +103,141 @@ describe("account rollup", () => {
).toBe(BYOK)
})
})

const DAY = 86_400_000
const paidDaysAgo = (days: number) => new Date(NOW.getTime() - days * DAY)
const subId = (name: string) => `sub-${name}` as CustomerSubscriptionId

/**
* With `overageInMrr` on: `invoices` are paid Stripe ones (`subscriptionId` a sub's `id`); the
* rollup gets `mrrSource`'s subscriptions, the invoices all of them.
*/
const invoiceRollupOf = (
subscriptions: ReadonlyArray<Partial<schema.CustomerSubscription>>,
invoices: ReadonlyArray<Partial<schema.CustomerInvoice>>,
mrrSource: CustomerProvider = "stripe",
) => {
const facts = factsOf({
subscriptions,
openInvoices: invoices.map((invoice) => ({ status: "paid", ...invoice })),
})
return rollupAccount({
account: facts.account,
subscriptions: facts.subscriptions.filter((sub) => sub.provider === mrrSource),
mrrSource,
addOnPlans: [],
invoiceMrr: invoiceMrrOf(facts.subscriptions, facts.openInvoices, NOW),
contacts: facts.contacts,
activity: facts.activity,
lastActiveDay: facts.lastActiveDay,
currency: "usd",
now: NOW,
today: TODAY,
})
}

describe("MRR from paid Stripe invoices", () => {
const team = { id: subId("team"), mrrMinor: 19_900 }

it("keeps the plans' MRR without counted invoices (the setting off, or none paid yet)", () => {
expect(rollupOf([team]).mrrMinor).toBe(19_900)
expect(invoiceRollupOf([team], []).mrrMinor).toBe(19_900)
})

it("counts a monthly subscription's last paid invoice, overage included", () => {
const rollup = invoiceRollupOf(
[team],
[
{ subscriptionId: team.id, amountPaidMinor: 21_000, paidAt: paidDaysAgo(40) },
{ subscriptionId: team.id, amountPaidMinor: 24_350, paidAt: paidDaysAgo(10) },
],
)
expect(rollup.mrrMinor).toBe(24_350)
expect(rollup.lifecycleStage).toBe("active")
})

it("counts a yearly invoice per month", () => {
const yearly = { id: subId("yearly"), interval: "year" as const, mrrMinor: 15_000 }
expect(
invoiceRollupOf(
[yearly],
[{ subscriptionId: yearly.id, amountPaidMinor: 190_000, paidAt: paidDaysAgo(300) }],
).mrrMinor,
).toBe(15_833)
})

it("falls back to the plans' MRR once the last paid invoice is past its interval and grace", () => {
expect(
invoiceRollupOf(
[team],
[{ subscriptionId: team.id, amountPaidMinor: 24_350, paidAt: paidDaysAgo(39) }],
).mrrMinor,
).toBe(19_900)
})

it("ignores a canceled subscription's invoices", () => {
const old = { id: subId("old"), status: "canceled" as const, mrrMinor: 9_900 }
expect(
invoiceRollupOf(
[team, old],
[
{ subscriptionId: team.id, amountPaidMinor: 20_500, paidAt: paidDaysAgo(5) },
{ subscriptionId: old.id, amountPaidMinor: 9_900, paidAt: paidDaysAgo(5) },
],
).mrrMinor,
).toBe(20_500)
})

it("ignores a paused subscription's invoices, as its plan MRR is", () => {
const paused = { id: subId("paused"), status: "paused" as const, mrrMinor: 10_000 }
expect(
invoiceRollupOf(
[{ id: subId("plan"), mrrMinor: 10_000 }, paused],
[{ subscriptionId: paused.id, amountPaidMinor: 20_000, paidAt: paidDaysAgo(5) }],
).mrrMinor,
).toBe(10_000)
})

it("adds only reporting-currency invoices", () => {
const euro = { id: subId("euro"), currency: "eur", mrrMinor: 5_000 }
const rollup = invoiceRollupOf(
[team, euro],
[
{ subscriptionId: team.id, amountPaidMinor: 20_500, paidAt: paidDaysAgo(5) },
{ subscriptionId: euro.id, amountPaidMinor: 7_000, currency: "eur", paidAt: paidDaysAgo(5) },
],
)
expect(rollup.mrrMinor).toBe(20_500)
expect(rollup.attributes.foreignMrr).toEqual({ eur: 5_000 })
})

it("keeps the plan MRR when only another currency's invoice counts", () => {
const rollup = invoiceRollupOf(
[team],
[{ subscriptionId: team.id, amountPaidMinor: 7_000, currency: "eur", paidAt: paidDaysAgo(5) }],
)
expect(rollup.mrrMinor).toBe(team.mrrMinor)
})

it("keeps an uninvoiced subscription's plan MRR next to an invoiced one", () => {
const addOn = { id: subId("addon"), mrrMinor: 5_000 }
expect(
invoiceRollupOf(
[team, addOn],
[{ subscriptionId: team.id, amountPaidMinor: 24_350, paidAt: paidDaysAgo(5) }],
).mrrMinor,
).toBe(29_350)
})

it("counts an Autumn-sourced account's Stripe subscriptions' invoices", () => {
const rollup = invoiceRollupOf(
[
{ provider: "autumn", planName: "Pro", mrrMinor: 4_900 },
{ id: subId("stripe"), provider: "stripe", planName: "Pro", mrrMinor: 4_900 },
],
[{ subscriptionId: subId("stripe"), amountPaidMinor: 6_200, paidAt: paidDaysAgo(12) }],
"autumn",
)
expect(rollup).toMatchObject({ mrrMinor: 6_200, mrrSource: "autumn", planName: "Pro" })
})
})
Loading
Loading