cancel past due subs and void unpaid invoice on cancel

This commit is contained in:
Owen Greenhalgh
2026-06-15 14:43:19 +01:00
parent 89bff26f4b
commit bfaf14120f
8 changed files with 594 additions and 22 deletions

View File

@@ -26,7 +26,7 @@ env:
# staging repo (autumn-staging) -> us-east-1
# Branches allowed to deploy to staging via workflow_dispatch with tag=deploy-staging.
# Add short-lived PR branches here when you need staging without merging to dev.
STAGING_DEPLOY_BRANCH_ALLOWLIST: fix-health-check-redis-disabled-detection feat/track-rate-limit-redis feat/events-hourly-rollup fix/analytics-tz-bucket-offset uw-1-storage fix/usage-limit-ai-credit-dimension feat/analytics-week-bin
STAGING_DEPLOY_BRANCH_ALLOWLIST: feat/past-due-cancel-immediate-void
jobs:
checks:

View File

@@ -0,0 +1,73 @@
import type { Stripe } from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { invoiceActions } from "@/internal/invoices/actions";
const VOIDABLE_STATUSES: Stripe.Invoice.Status[] = ["open", "uncollectible"];
/**
* Void every open/uncollectible invoice on a Stripe subscription, then mirror the new status
* into Autumn's invoice records. Per-invoice failures are tolerated (the subscription.deleted
* webhook can race this for the same invoices, and Stripe rejects voiding an already-voided one)
* but are counted and returned so the caller can surface a partial failure.
*/
export const voidOpenInvoicesForStripeSubscription = async ({
ctx,
stripeCli,
customerId,
stripeCustomerId,
subscriptionId,
}: {
ctx: AutumnContext;
stripeCli: Stripe;
customerId: string;
stripeCustomerId: string;
subscriptionId: string;
}): Promise<{ voided: number; failed: number }> => {
const { logger } = ctx;
const voidableInvoices: Stripe.Invoice[] = [];
let startingAfter: string | undefined;
while (true) {
const page = await stripeCli.invoices.list({
customer: stripeCustomerId,
subscription: subscriptionId,
limit: 100,
starting_after: startingAfter,
});
for (const invoice of page.data) {
if (VOIDABLE_STATUSES.includes(invoice.status ?? "draft")) {
voidableInvoices.push(invoice);
}
}
if (!page.has_more) break;
startingAfter = page.data[page.data.length - 1]?.id;
if (!startingAfter) break;
}
let failed = 0;
await Promise.all(
voidableInvoices.map(async (invoice) => {
try {
const voidedInvoice = await stripeCli.invoices.voidInvoice(invoice.id);
await invoiceActions.updateFromStripe({
ctx,
customerId,
stripeInvoice: voidedInvoice,
});
logger.info(
`[voidOpenInvoicesForStripeSubscription] Voided invoice ${invoice.id}`,
);
} catch (error) {
failed++;
logger.warn(
`[voidOpenInvoicesForStripeSubscription] Failed to void invoice ${invoice.id}: ${error}`,
);
}
}),
);
return { voided: voidableInvoices.length - failed, failed };
};

View File

@@ -15,7 +15,10 @@ import { fetchStoredLineItemsForSubscriptionBilling } from "@/internal/billing/v
import { setupAdjustableQuantities } from "@/internal/billing/v2/setup/setupAdjustableQuantities";
import { setupAnchorResetRefund } from "@/internal/billing/v2/setup/setupAnchorResetRefund";
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
import { setupCancelAction } from "@/internal/billing/v2/setup/setupCancelMode";
import {
setupCancelAction,
shouldForcePastDueImmediateCancel,
} from "@/internal/billing/v2/setup/setupCancelMode";
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
import { setupIgnoreProrationBehavior } from "@/internal/billing/v2/setup/setupIgnoreProrationBehavior";
@@ -171,7 +174,18 @@ export const setupUpdateSubscriptionBillingContext = async ({
customerProduct,
});
const cancelAction = setupCancelAction({ params });
const cancelAction = setupCancelAction({
params,
org: ctx.org,
customerProduct,
});
// A past_due cancel forced to immediate must not refund the unpaid cycle.
const forcePastDueImmediate = shouldForcePastDueImmediateCancel({
params,
org: ctx.org,
customerProduct,
});
let checkoutMode = setupAttachCheckoutMode({
paymentMethod,
@@ -221,7 +235,9 @@ export const setupUpdateSubscriptionBillingContext = async ({
billingCycleAnchorMs,
resetCycleAnchorMs,
requestedBillingCycleAnchor: params.billing_cycle_anchor,
requestedProrationBehavior: setupIgnoreProrationBehavior({ intent })
requestedProrationBehavior: forcePastDueImmediate
? "none"
: setupIgnoreProrationBehavior({ intent })
? undefined
: params.proration_behavior,

View File

@@ -10,6 +10,7 @@ import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/actions/up
import { logUpdateSubscriptionContext } from "@/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext";
import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext";
import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan";
import { voidInvoicesOnImmediateCancel } from "@/internal/billing/v2/execute/voidInvoicesOnImmediateCancel";
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult";
@@ -114,6 +115,13 @@ export async function updateSubscription({
billingPlan,
});
await voidInvoicesOnImmediateCancel({
ctx,
billingContext,
billingPlan,
billingResult,
});
logStripeBillingResult({ ctx, result: billingResult.stripe });
return {

View File

@@ -0,0 +1,49 @@
import type {
BillingPlan,
BillingResult,
UpdateSubscriptionBillingContext,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli";
import { voidOpenInvoicesForStripeSubscription } from "@/external/stripe/invoices/operations/voidOpenInvoicesForStripeSubscription";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
// Void-on-cancel must run inline for Autumn-initiated immediate cancels: the sub:<id> lock makes
// the subscription.deleted webhook (which normally does the voiding) skip itself for our cancels.
export const voidInvoicesOnImmediateCancel = async ({
ctx,
billingContext,
billingPlan,
billingResult,
}: {
ctx: AutumnContext;
billingContext: UpdateSubscriptionBillingContext;
billingPlan: BillingPlan;
billingResult: BillingResult;
}): Promise<void> => {
if (!ctx.org.config.void_invoices_on_subscription_deletion) return;
if (billingContext.cancelAction !== "cancel_immediately") return;
if (billingResult.stripe.deferred) return;
// Only void when the whole Stripe subscription was actually cancelled — a partial item
// removal (e.g. cancelling one product while an add-on stays live on the same subscription)
// produces an "update" action, and voiding the surviving product's open invoices would be wrong.
if (billingPlan.stripe.subscriptionAction?.type !== "cancel") return;
const { stripeSubscription, stripeCustomer, fullCustomer } = billingContext;
if (!stripeSubscription || !stripeCustomer) return;
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const { failed } = await voidOpenInvoicesForStripeSubscription({
ctx,
stripeCli,
customerId: fullCustomer.id ?? fullCustomer.internal_id,
stripeCustomerId: stripeCustomer.id,
subscriptionId: stripeSubscription.id,
});
if (failed > 0) {
ctx.logger.error(
`[voidInvoicesOnImmediateCancel] ${failed} invoice(s) failed to void for subscription ${stripeSubscription.id}; they may still be open in Stripe`,
);
}
};

View File

@@ -1,19 +1,37 @@
import type { CancelAction, UpdateSubscriptionV1Params } from "@autumn/shared";
import type {
CancelAction,
FullCusProduct,
Organization,
UpdateSubscriptionV1Params,
} from "@autumn/shared";
import { CusProductStatus } from "@autumn/shared";
/**
* Setup cancel action from params
* @param params - The params
* cancel_action param maps directly to internal cancel action
* - cancel_action: "cancel_immediately" means cancel immediately
* - cancel_action: "cancel_end_of_cycle" means cancel at end of cycle
* - cancel_action: "uncancel" means remove scheduled cancellation
* - cancel_action: undefined means no cancel operation
* @returns The cancel action
*/
export const setupCancelAction = ({
// Void-on-cancel orgs cancel a past_due end-of-cycle request immediately: the customer is in a
// cycle they never paid for, so there is no paid period to honor. The unpaid invoice is voided
// downstream (voidInvoicesOnImmediateCancel) and proration credits are suppressed by the caller
// (no refund is owed for a cycle that was never paid).
export const shouldForcePastDueImmediateCancel = ({
params,
org,
customerProduct,
}: {
params: UpdateSubscriptionV1Params;
}): CancelAction | undefined => {
return params.cancel_action;
};
org: Organization;
customerProduct?: FullCusProduct;
}): boolean =>
params.cancel_action === "cancel_end_of_cycle" &&
org.config.void_invoices_on_subscription_deletion &&
customerProduct?.status === CusProductStatus.PastDue;
export const setupCancelAction = ({
params,
org,
customerProduct,
}: {
params: UpdateSubscriptionV1Params;
org: Organization;
customerProduct?: FullCusProduct;
}): CancelAction | undefined =>
shouldForcePastDueImmediateCancel({ params, org, customerProduct })
? "cancel_immediately"
: params.cancel_action;

View File

@@ -1,11 +1,12 @@
import { Scopes } from "@autumn/shared";
import type { UpdateSubscriptionV1Params } from "@autumn/shared";
import { Scopes } from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { computeUpdateSubscriptionPlan } from "@/internal/billing/v2/actions/updateSubscription/compute/computeUpdateSubscriptionPlan";
import { handleUpdateSubscriptionErrors } from "@/internal/billing/v2/actions/updateSubscription/errors/handleUpdateSubscriptionErrors";
import { logUpdateSubscriptionContext } from "@/internal/billing/v2/actions/updateSubscription/logs/logUpdateSubscriptionContext";
import { setupUpdateSubscriptionBillingContext } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext";
import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan";
import { voidInvoicesOnImmediateCancel } from "@/internal/billing/v2/execute/voidInvoicesOnImmediateCancel";
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult";
@@ -98,6 +99,13 @@ export const handleCancelV2 = createRoute({
},
});
await voidInvoicesOnImmediateCancel({
ctx,
billingContext,
billingPlan,
billingResult,
});
logStripeBillingResult({ ctx, result: billingResult.stripe });
return c.json({

View File

@@ -0,0 +1,400 @@
/**
* TDD (feature) for: void-on-cancel orgs cancel past_due customers immediately.
*
* Contract under test:
* New behavior:
* - flag void_invoices_on_subscription_deletion ON + product past_due + cancel_action "cancel_end_of_cycle"
* -> resolves to an IMMEDIATE cancel: product removed now, default/free active now (not scheduled).
* Side effects:
* - that immediate cancel voids the subscription's open/uncollectible invoices INLINE
* (the Autumn cancel sets the sub:<id> lock, so the subscription.deleted webhook is skipped).
* Gating (unchanged behavior, control cases):
* - flag OFF + past_due + cancel_end_of_cycle -> still scheduled-cancel, open invoice NOT voided.
* - flag ON + NOT past_due + cancel_end_of_cycle -> normal end-of-cycle (canceling + free scheduled).
*
* Pre-impl red: the PRIMARY test fails because cancel_end_of_cycle on a past_due sub today
* schedules the cancel (pro stays, free not active) and never voids the open invoice on the
* Autumn-initiated path.
*
* past_due setup: attach with a good card, swap the SUBSCRIPTION's default_payment_method to a
* failing card and advance the test clock so the renewal charge fails (produces a real open
* invoice in Stripe). The customer_product.status is then flipped to past_due directly in
* Postgres (+ cache bust) rather than waiting on the subscription.updated webhook -- there is no
* public API to set past_due, and the webhook sync is the flakiest part of a local run. This
* mirrors balances/cron/past-due-reset.test.ts. What we're actually testing is the cancel
* behavior, not the past_due sync.
*
* These tests mutate shared org.config, so they run serially (plain `test`) and restore in `finally`.
*/
import { expect, test } from "bun:test";
import type { Stripe } from "stripe";
import type { ApiCustomerV3 } from "@autumn/shared";
import { CusProductStatus, customerProducts } from "@autumn/shared";
import {
expectCustomerProducts,
expectProductActive,
expectProductCanceling,
expectProductPastDue,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
import { getSubscriptionId } from "@tests/integration/billing/utils/stripe/getSubscriptionId";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import { attachFailedPaymentMethod } from "@/external/stripe/stripeCusUtils";
import { CusService } from "@/internal/customers/CusService";
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer";
import { OrgService } from "@/internal/orgs/OrgService";
import { timeout } from "@/utils/genUtils";
/**
* Fail the renewal to create a real open invoice, then force the product into past_due
* directly in Postgres (no webhook dependency). Returns the Stripe customer + subscription ids.
*/
const driveProductPastDue = async ({
ctx,
testClockId,
customerId,
productId,
}: {
ctx: Awaited<ReturnType<typeof initScenario>>["ctx"];
testClockId: string;
customerId: string;
productId: string;
}) => {
const subscriptionId = await getSubscriptionId({
ctx,
customerId,
productId,
});
const customer = await CusService.get({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
await attachFailedPaymentMethod({
stripeCli: ctx.stripeCli,
customer: customer!,
});
const paymentMethods = await ctx.stripeCli.paymentMethods.list({
customer: customer!.processor?.id,
});
await ctx.stripeCli.subscriptions.update(subscriptionId, {
default_payment_method: paymentMethods.data[0].id,
});
await advanceToNextInvoice({ stripeCli: ctx.stripeCli, testClockId });
await timeout(4000);
// Flip the customer_product to past_due directly (no public API; webhook sync is unreliable
// locally), then bust the cache so the HTTP server re-reads it from Postgres.
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const customerProduct = fullCustomer.customer_products.find(
(cp) => cp.product.id === productId,
);
await ctx.db
.update(customerProducts)
.set({ status: CusProductStatus.PastDue })
.where(eq(customerProducts.id, customerProduct!.id));
await deleteCachedFullCustomer({ ctx, customerId });
return { subscriptionId, stripeCustomerId: customer!.processor?.id };
};
// ═══════════════════════════════════════════════════════════════════════════════
// PRIMARY (pre-impl RED): flag on + past_due + cancel_end_of_cycle -> immediate + void
// ═══════════════════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("cancel eoc past_due: flag on -> cancels immediately and voids open invoice")}`, async () => {
const customerId = "cancel-pastdue-void-immediate";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1, ctx, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free, pro], customerIdsToDelete: [customerId] }),
],
actions: [s.attach({ productId: pro.id })],
});
const originalOrgConfig = ctx.org.config;
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: {
config: {
...ctx.org.config,
void_invoices_on_subscription_deletion: true,
},
},
});
try {
const { subscriptionId, stripeCustomerId } = await driveProductPastDue({
ctx,
testClockId: testClockId!,
customerId,
productId: pro.id,
});
// Precondition: product is past_due with an open (unpaid) invoice
const customerBeforeCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductPastDue({
customer: customerBeforeCancel,
productId: pro.id,
});
const invoicesBeforeCancel = await ctx.stripeCli.invoices.list({
customer: stripeCustomerId,
subscription: subscriptionId,
});
expect(
invoicesBeforeCancel.data.filter((inv) => inv.status === "open").length,
).toBeGreaterThan(0);
// ── Action: cancel_end_of_cycle on a past_due sub with the flag on ──
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_end_of_cycle",
});
// Read-consistency wait for the inline void (NOT a webhook wait).
await timeout(3000);
// ── Contract assertion 1: immediate downgrade (not scheduled) ──
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterCancel,
notPresent: [pro.id],
active: [free.id],
});
// ── Contract assertion 2: Stripe subscription cancelled now ──
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
// ── Contract assertion 3: open invoice voided inline ──
const invoicesAfterCancel = await ctx.stripeCli.invoices.list({
customer: stripeCustomerId,
subscription: subscriptionId,
});
expect(
invoicesAfterCancel.data.filter((inv) => inv.status === "open").length,
).toBe(0);
expect(
invoicesAfterCancel.data.filter((inv) => inv.status === "void").length,
).toBeGreaterThan(0);
// ── Contract assertion 4: no proration credit issued for the unpaid cycle ──
// The customer never paid this cycle, so cancelling must not refund/credit them.
const allInvoices = await ctx.stripeCli.invoices.list({
customer: stripeCustomerId,
});
expect(
allInvoices.data.filter((inv) => (inv.total ?? 0) < 0).length,
).toBe(0);
const stripeCustomer = (await ctx.stripeCli.customers.retrieve(
stripeCustomerId!,
)) as Stripe.Customer;
expect(stripeCustomer.balance).toBe(0);
} finally {
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: { config: originalOrgConfig },
});
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// CONTROL A: flag OFF + past_due + cancel_end_of_cycle -> unchanged (no immediate, no void)
// ═══════════════════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("cancel eoc past_due: flag off -> stays scheduled, invoice not voided")}`, async () => {
const customerId = "cancel-pastdue-void-flag-off";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1, ctx, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free, pro], customerIdsToDelete: [customerId] }),
],
actions: [s.attach({ productId: pro.id })],
});
const originalOrgConfig = ctx.org.config;
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: {
config: {
...ctx.org.config,
void_invoices_on_subscription_deletion: false,
},
},
});
try {
const { subscriptionId, stripeCustomerId } = await driveProductPastDue({
ctx,
testClockId: testClockId!,
customerId,
productId: pro.id,
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_end_of_cycle",
});
await timeout(3000);
// Not immediately removed: a Stripe subscription still exists (cancel scheduled at period end)
const subsAfterCancel = await ctx.stripeCli.subscriptions.list({
customer: stripeCustomerId,
});
expect(subsAfterCancel.data.length).toBeGreaterThan(0);
// Open invoice is NOT voided when the flag is off
const invoicesAfterCancel = await ctx.stripeCli.invoices.list({
customer: stripeCustomerId,
subscription: subscriptionId,
});
expect(
invoicesAfterCancel.data.filter((inv) => inv.status === "open").length,
).toBeGreaterThan(0);
expect(
invoicesAfterCancel.data.filter((inv) => inv.status === "void").length,
).toBe(0);
} finally {
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: { config: originalOrgConfig },
});
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// CONTROL B: flag ON + NOT past_due + cancel_end_of_cycle -> normal end-of-cycle
// ═══════════════════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("cancel eoc not past_due: flag on -> normal end-of-cycle (no immediate)")}`, async () => {
const customerId = "cancel-pastdue-void-not-pastdue";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
const pro = products.pro({ id: "pro", items: [messagesItem] });
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro], customerIdsToDelete: [customerId] }),
],
actions: [s.attach({ productId: pro.id })],
});
const originalOrgConfig = ctx.org.config;
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: {
config: {
...ctx.org.config,
void_invoices_on_subscription_deletion: true,
},
},
});
try {
const customerAfterAttach =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterAttach,
productId: pro.id,
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_end_of_cycle",
});
// Active (paid) sub: flag must NOT force immediate; normal end-of-cycle applies.
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductScheduled({
customer: customerAfterCancel,
productId: free.id,
});
// Side-effect contract: a paid, non-past_due cancel must NOT void any invoice.
const customer = await CusService.get({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
});
const subscriptionId = await getSubscriptionId({
ctx,
customerId,
productId: pro.id,
});
const invoices = await ctx.stripeCli.invoices.list({
customer: customer!.processor?.id,
subscription: subscriptionId,
});
expect(invoices.data.filter((inv) => inv.status === "void").length).toBe(0);
} finally {
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: { config: originalOrgConfig },
});
}
});